Major refactor
* Reorganize the fetch component * Organize the server code a little more * Move verification to the server and clean it up * Improve the error handling around the fetch code
This commit is contained in:
parent
8d350e8cd9
commit
564771931f
10 changed files with 878 additions and 593 deletions
|
@ -1,11 +1,12 @@
|
||||||
|
//! Control program for the ActivityPub federated social media server.
|
||||||
#![feature(iterator_try_collect)]
|
#![feature(iterator_try_collect)]
|
||||||
|
|
||||||
use puppy::{
|
use puppy::{
|
||||||
actor::Actor,
|
actor::Actor,
|
||||||
auth::Verifier,
|
|
||||||
config::Config,
|
config::Config,
|
||||||
data::{schema, Bite, Profile},
|
data::{Bite, Profile},
|
||||||
post::Author,
|
post::Author,
|
||||||
store::{util::IterExt as _, Store},
|
store::util::IterExt as _,
|
||||||
Context,
|
Context,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -17,9 +18,8 @@ fn main() -> puppy::Result<()> {
|
||||||
state_dir: ".state".to_string(),
|
state_dir: ".state".to_string(),
|
||||||
port: 1312,
|
port: 1312,
|
||||||
};
|
};
|
||||||
let verifier = Verifier::load(&config);
|
let cx = Context::load(config)?;
|
||||||
let db = Store::open(&config.state_dir, schema())?;
|
let db = cx.store();
|
||||||
let cx = Context::new(config, db.clone(), verifier);
|
|
||||||
println!("creating actors");
|
println!("creating actors");
|
||||||
let riley = get_or_create_actor(&cx, "riley")?;
|
let riley = get_or_create_actor(&cx, "riley")?;
|
||||||
let linen = get_or_create_actor(&cx, "linen")?;
|
let linen = get_or_create_actor(&cx, "linen")?;
|
||||||
|
@ -31,7 +31,6 @@ fn main() -> puppy::Result<()> {
|
||||||
|
|
||||||
if true {
|
if true {
|
||||||
println!("making riley follow linen");
|
println!("making riley follow linen");
|
||||||
|
|
||||||
cx.run(|tx| {
|
cx.run(|tx| {
|
||||||
if !riley.follows(&tx, &linen)? {
|
if !riley.follows(&tx, &linen)? {
|
||||||
println!("follow relation does not exist yet");
|
println!("follow relation does not exist yet");
|
||||||
|
|
|
@ -1,5 +1,23 @@
|
||||||
//! API endpoints and request handlers.
|
//! API endpoints and request handlers.
|
||||||
|
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use http_body_util::{BodyExt as _, Full};
|
||||||
|
use hyper::body::Bytes;
|
||||||
|
use hyper::server::conn::http1;
|
||||||
|
use hyper::service::service_fn;
|
||||||
|
use hyper_util::rt::TokioIo;
|
||||||
|
use hyper::Method;
|
||||||
|
use puppy::Context;
|
||||||
|
use serde_json::{from_slice, json, Value};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
use crate::sig::{Signer, Verdict, Verifier, VERIFIER_MOUNT};
|
||||||
|
|
||||||
|
use self::error::Message;
|
||||||
|
|
||||||
pub mod ap {
|
pub mod ap {
|
||||||
//! ActivityPub handlers.
|
//! ActivityPub handlers.
|
||||||
|
|
||||||
|
@ -7,14 +25,17 @@ pub mod ap {
|
||||||
use hyper::body::Bytes;
|
use hyper::body::Bytes;
|
||||||
use puppy::{
|
use puppy::{
|
||||||
actor::Actor,
|
actor::Actor,
|
||||||
auth::{Signer, Verifier},
|
|
||||||
config::Config,
|
|
||||||
data::{Id, PrivateKey, PublicKey},
|
data::{Id, PrivateKey, PublicKey},
|
||||||
fetch::{signatures::Private, SigningKey},
|
fetch::signatures::{Private, SigningKey},
|
||||||
get_local_ap_object, Context, Key,
|
get_local_ap_object, Context, Key,
|
||||||
};
|
};
|
||||||
use serde_json::{to_string, Value};
|
use serde_json::{to_string, Value};
|
||||||
use crate::{respond, Response};
|
|
||||||
|
use crate::sig::{Signer, Verifier};
|
||||||
|
use super::{
|
||||||
|
error::{self, Message},
|
||||||
|
respond, Response,
|
||||||
|
};
|
||||||
|
|
||||||
/// Proxy a request through the instance.
|
/// Proxy a request through the instance.
|
||||||
pub async fn proxy(cx: &Context, params: &[(&str, &str)]) -> Response {
|
pub async fn proxy(cx: &Context, params: &[(&str, &str)]) -> Response {
|
||||||
|
@ -53,23 +74,26 @@ pub mod ap {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serve an ActivityPub object as json-ld.
|
/// Serve an ActivityPub object as json-ld.
|
||||||
pub fn serve_object(cx: &Context, object_ulid: &str) -> Response {
|
pub fn serve_object(cx: &Context, object_ulid: &str) -> Result<Response, Message> {
|
||||||
let Ok(parsed) = object_ulid.parse::<Key>() else {
|
let Ok(parsed) = object_ulid.parse::<Key>() else {
|
||||||
return respond(400, Some("improperly formatted id"), []);
|
return Err(Message {
|
||||||
|
error: "improperly formatted ulid",
|
||||||
|
..error::BAD_REQUEST
|
||||||
|
});
|
||||||
};
|
};
|
||||||
let result = cx.run(|tx| get_local_ap_object(&tx, parsed));
|
let result = cx.run(|tx| get_local_ap_object(&tx, parsed));
|
||||||
let Ok(object) = result else {
|
let Ok(object) = result else {
|
||||||
return respond(404, <Option<String>>::None, []);
|
return Err(error::NOT_FOUND);
|
||||||
};
|
};
|
||||||
let json = to_string(&object.to_json_ld()).unwrap();
|
let json = to_string(&object.to_json_ld()).unwrap();
|
||||||
respond(200, Some(json), [AP_CONTENT_TYPE])
|
Ok(respond(200, Some(json), [AP_CONTENT_TYPE]))
|
||||||
}
|
}
|
||||||
|
|
||||||
const AP_CONTENT_TYPE: (&str, &str) = ("content-type", "application/activity+json");
|
const AP_CONTENT_TYPE: (&str, &str) = ("content-type", "application/activity+json");
|
||||||
|
|
||||||
/// Serve the special actor used for signing requests.
|
/// Serve the special actor used for signing requests.
|
||||||
pub fn serve_verifier_actor(cx: &Context) -> Response {
|
pub fn serve_verifier_actor(verifier: &Verifier) -> Response {
|
||||||
let body = cx.verifier().to_json_ld();
|
let body = verifier.to_json_ld();
|
||||||
let encoded = serde_json::to_vec(&body).unwrap();
|
let encoded = serde_json::to_vec(&body).unwrap();
|
||||||
respond(200, Some(encoded), [AP_CONTENT_TYPE])
|
respond(200, Some(encoded), [AP_CONTENT_TYPE])
|
||||||
}
|
}
|
||||||
|
@ -78,32 +102,68 @@ pub mod ap {
|
||||||
pub mod wf {
|
pub mod wf {
|
||||||
//! WebFinger endpoints and related stuff.
|
//! WebFinger endpoints and related stuff.
|
||||||
|
|
||||||
use puppy::{data::Username, Context, Error};
|
use puppy::{
|
||||||
use serde_json::json;
|
data::{Id, Username},
|
||||||
|
Context,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::{respond, Response};
|
use super::{
|
||||||
|
error::{Message, BAD_REQUEST, INTERNAL, NOT_FOUND},
|
||||||
|
respond, Response,
|
||||||
|
};
|
||||||
|
|
||||||
const WF_CONTENT_TYPE: (&str, &str) = ("content-type", "application/jrd+json");
|
const WF_CONTENT_TYPE: (&str, &str) = ("content-type", "application/jrd+json");
|
||||||
|
|
||||||
pub fn resolve(cx: &Context, query: &[(&str, &str)]) -> Response {
|
/// Respond to a webfinger request.
|
||||||
match query.iter().find_map(get_handle) {
|
pub fn resolve(cx: &Context, params: &[(&str, &str)]) -> Result<Response, Message> {
|
||||||
// Serve JRDs for local actors.
|
match params.iter().find_map(get_handle) {
|
||||||
Some(handle) if cx.config().wf_domain == handle.instance => {
|
Some(handle) if cx.config().wf_domain == handle.instance => {
|
||||||
let id = {
|
let username = Username(handle.username.to_string());
|
||||||
let user = cx
|
let Ok(Some(user)) = cx.store().lookup(username) else {
|
||||||
.store()
|
do yeet NOT_FOUND;
|
||||||
.lookup(Username(handle.username.to_string()))
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
let id = cx
|
|
||||||
.store()
|
|
||||||
.get_alias::<puppy::data::Id>(user)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap()
|
|
||||||
.0;
|
|
||||||
id
|
|
||||||
};
|
};
|
||||||
let jrd = json!({
|
let Ok(Some(Id(id))) = cx.store().get_alias(user) else {
|
||||||
|
do yeet INTERNAL;
|
||||||
|
};
|
||||||
|
let jrd = make_jrd(handle, &id);
|
||||||
|
let encoded = serde_json::to_vec(&jrd).unwrap();
|
||||||
|
Ok(respond(200, Some(encoded), [WF_CONTENT_TYPE]))
|
||||||
|
}
|
||||||
|
Some(_) | None => Err(Message {
|
||||||
|
error: "missing/invalid resource parameter",
|
||||||
|
..BAD_REQUEST
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct Handle<'x> {
|
||||||
|
username: &'x str,
|
||||||
|
instance: &'x str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the `resource` parameter into a [`Handle`].
|
||||||
|
fn get_handle<'x>((k, v): &'x (&str, &str)) -> Option<Handle<'x>> {
|
||||||
|
// We're looking for the `resource` query parameter.
|
||||||
|
if *k == "resource" {
|
||||||
|
// This prefix needs to exist according to spec.
|
||||||
|
let (username, instance) = v
|
||||||
|
.strip_prefix("acct:")?
|
||||||
|
// Some implementations may prefix with `@`. its ok if it's there and its also ok
|
||||||
|
// if its not there, so we use `trim_start_matches` instead of `strip_prefix`.
|
||||||
|
.trim_start_matches('@')
|
||||||
|
// Split on the middle `@` symbol, which separates the username and instance bits
|
||||||
|
.split_once('@')?;
|
||||||
|
Some(Handle { username, instance })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a "JSON resource descriptor".
|
||||||
|
fn make_jrd(handle: Handle<'_>, id: &str) -> Value {
|
||||||
|
json!({
|
||||||
"subject": format!("acct:{}@{}", handle.username, handle.instance),
|
"subject": format!("acct:{}@{}", handle.username, handle.instance),
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{
|
||||||
|
@ -112,35 +172,267 @@ pub mod wf {
|
||||||
"href": id
|
"href": id
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Request = hyper::Request<hyper::body::Incoming>;
|
||||||
|
type Response<T = Full<Bytes>> = hyper::Response<T>;
|
||||||
|
|
||||||
|
/// Initialize the http server loop.
|
||||||
|
pub async fn start(context: Context) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let addr = SocketAddr::from(([127, 0, 0, 1], context.config().port));
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(addr).await?;
|
||||||
|
let verifier = Arc::new(Verifier::load(context.config()));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (stream, _) = listener.accept().await?;
|
||||||
|
let io = TokioIo::new(stream);
|
||||||
|
|
||||||
|
let cx = context.clone();
|
||||||
|
let verifier = verifier.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = http1::Builder::new()
|
||||||
|
.serve_connection(io, service_fn(|req| handle(req, &verifier, cx.clone())))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
eprintln!("Error serving connection: {:?}", err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let encoded = serde_json::to_vec(&jrd).unwrap();
|
|
||||||
respond(200, Some(encoded), [WF_CONTENT_TYPE])
|
|
||||||
}
|
|
||||||
Some(_) => todo!(),
|
|
||||||
None => todo!("bad request: could not find valid resource parameter"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Handle<'x> {
|
// A parsed HTTP request for easy handling.
|
||||||
username: &'x str,
|
struct Req<'a> {
|
||||||
instance: &'x str,
|
method: &'a Method,
|
||||||
}
|
body: Bytes,
|
||||||
|
// The content-types in the accept header
|
||||||
|
accept: Vec<&'a str>,
|
||||||
|
// URI bits
|
||||||
|
params: Vec<(&'a str, &'a str)>,
|
||||||
|
path: Vec<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse the `resource` parameter into a [`Handle`].
|
impl Req<'_> {
|
||||||
pub fn get_handle<'x>((k, v): &'x (&str, &str)) -> Option<Handle<'x>> {
|
/// Get the path segments (non-empty parts of the path string separated by the '/' character).
|
||||||
// We're looking for the `resource` query parameter.
|
fn path(&self) -> &[&str] {
|
||||||
if *k == "resource" {
|
&self.path
|
||||||
// This prefix needs to exist according to spec.
|
}
|
||||||
v.strip_prefix("acct:")?
|
/// Turn an HTTP request into a more simple form so we can process it more easily.
|
||||||
// Some implementations may prefix with `@`. its ok if it's there and its also ok
|
fn simplify<'x>(r: &'x http::Request<Bytes>) -> Req<'x> {
|
||||||
// if its not there, so we use `trim_start_matches` instead of `strip_prefix`.
|
let path: Vec<&str> = r
|
||||||
.trim_start_matches('@')
|
.uri()
|
||||||
// Split on the middle `@` symbol, which separates the username and instance bits
|
.path()
|
||||||
.split_once('@')
|
.split('/')
|
||||||
// Convert to a structured format.
|
.filter(|s| !s.is_empty())
|
||||||
.map(|(username, instance)| Handle { username, instance })
|
.collect();
|
||||||
} else {
|
let params: Vec<(&str, &str)> = r
|
||||||
None
|
.uri()
|
||||||
|
.query()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|s| s.split('&'))
|
||||||
|
.filter_map(|s| s.split_once('='))
|
||||||
|
.collect();
|
||||||
|
let accept = r
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.find_map(|(k, v)| (k == "accept").then_some(v))
|
||||||
|
.and_then(|val| val.to_str().ok())
|
||||||
|
.iter()
|
||||||
|
.flat_map(|s| s.split(' '))
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
Req {
|
||||||
|
method: r.method(),
|
||||||
|
body: r.body().clone(),
|
||||||
|
accept,
|
||||||
|
params,
|
||||||
|
path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The request handler.
|
||||||
|
async fn handle(req: Request, verifier: &Verifier, cx: Context) -> Result<Response, Infallible> {
|
||||||
|
// We need to fetch the entire body of the request for signature validation, because that involves making
|
||||||
|
// a digest of the request body in some cases.
|
||||||
|
// TODO: defer loading the body until it is needed.
|
||||||
|
let request = {
|
||||||
|
let (req, body) = req.into_parts();
|
||||||
|
let Ok(body) = body.collect().await.map(|b| b.to_bytes()) else {
|
||||||
|
todo!();
|
||||||
|
};
|
||||||
|
http::Request::from_parts(req, body)
|
||||||
|
};
|
||||||
|
// Simplified representation of a request, so we can pattern match on it more easily in the dispatchers.
|
||||||
|
let req = Req::simplify(&request);
|
||||||
|
// We'll use the path to pick where specifically to send the request.
|
||||||
|
// Check request signature at the door. Even if it isn't needed for a particular endpoint, failing fast
|
||||||
|
// with a clear error message will save anyone trying to get *their* signatures implementation a major
|
||||||
|
// headache.
|
||||||
|
let res = match verifier.verify(&request).await {
|
||||||
|
// If the request was signed and the signature was accepted, they can access the protected endpoints.
|
||||||
|
Verdict::Verified(sig) => dispatch_signed(cx, &verifier, &req, sig).await,
|
||||||
|
// Unsigned requests can see a smaller subset of endpoints, most notably the verification actor.
|
||||||
|
Verdict::Unsigned => dispatch_public(cx, &verifier, &req).await,
|
||||||
|
// If a signature was provided *but it turned out to be unverifiable*, show them the error message.
|
||||||
|
Verdict::Rejected { reason, signature_str } => Err(Message {
|
||||||
|
error: "signature verification failed",
|
||||||
|
status: 403,
|
||||||
|
detail: Some(json!({
|
||||||
|
"signature": signature_str,
|
||||||
|
"reason": reason,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
// If one of the endpoints gave us an error message, we convert that into a response and then
|
||||||
|
// serve it to the client. In either case, we just serve a response.
|
||||||
|
Ok(res.unwrap_or_else(|msg| req.error(msg)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const POST: &Method = &Method::POST;
|
||||||
|
const GET: &Method = &Method::GET;
|
||||||
|
|
||||||
|
/// Handle a signed and verified request.
|
||||||
|
///
|
||||||
|
/// This function is where all requests to a protected endpoint have to go through. If the request
|
||||||
|
/// was signed but does not target a protected endpoint, this function will fall back to the
|
||||||
|
/// [`dispatch_public`] handler.
|
||||||
|
async fn dispatch_signed(
|
||||||
|
cx: Context,
|
||||||
|
verifier: &Verifier,
|
||||||
|
req: &Req<'_>,
|
||||||
|
sig: Signer,
|
||||||
|
) -> Result<Response, Message> {
|
||||||
|
match (req.method, req.path()) {
|
||||||
|
// Viewing ActivityPub objects requires a signed request, i.e. "authorized fetch".
|
||||||
|
// The one exception for this is `/s/request-verifier`, which is where the request
|
||||||
|
// verification actor lives.
|
||||||
|
(GET, ["o", ulid]) => ap::serve_object(&cx, ulid),
|
||||||
|
// POSTs to an actor's inbox need to be signed to prevent impersonation.
|
||||||
|
(POST, ["o", ulid, "inbox"]) => with_json(&req.body, |json| try {
|
||||||
|
// We only handle the intermediate parsing of the json, full resolution of the
|
||||||
|
// activity object will happen inside the inbox handler itself.
|
||||||
|
ap::inbox(&cx, ulid, sig, json)
|
||||||
|
}),
|
||||||
|
// Try the resources for which no signature is required as well.
|
||||||
|
_ => dispatch_public(cx, verifier, req).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch `req` to an unprotected endpoint. If the requested path does not exist, the
|
||||||
|
/// function will return a 404 response. If the path *does* exist, but the signature is not
|
||||||
|
/// valid, they will also get a 404.
|
||||||
|
async fn dispatch_public(
|
||||||
|
cx: Context,
|
||||||
|
verifier: &Verifier,
|
||||||
|
req: &Req<'_>,
|
||||||
|
) -> Result<Response, Message> {
|
||||||
|
match (req.method, req.path()) {
|
||||||
|
(GET, ["proxy"]) => Ok(ap::proxy(&cx, &req.params).await),
|
||||||
|
(GET, [".well-known", "webfinger"]) => wf::resolve(&cx, &req.params),
|
||||||
|
// TODO: nicer solution for this
|
||||||
|
(GET, VERIFIER_MOUNT) => Ok(ap::serve_verifier_actor(&verifier)),
|
||||||
|
_ => Err(error::NOT_FOUND),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_json(
|
||||||
|
body: &[u8],
|
||||||
|
f: impl FnOnce(Value) -> Result<Response, Message>,
|
||||||
|
) -> Result<Response, Message> {
|
||||||
|
let Ok(json) = from_slice(body) else {
|
||||||
|
return Err(Message {
|
||||||
|
error: "could not decode json",
|
||||||
|
..error::BAD_REQUEST
|
||||||
|
});
|
||||||
|
};
|
||||||
|
f(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A quick, simple way to construct a response.
|
||||||
|
fn respond<const N: usize>(
|
||||||
|
status: u16,
|
||||||
|
body: Option<impl Into<Bytes>>,
|
||||||
|
headers: [(&str, &str); N],
|
||||||
|
) -> Response {
|
||||||
|
let mut resp = Response::<()>::builder().status(status);
|
||||||
|
for (name, data) in headers {
|
||||||
|
resp = resp.header(name, data);
|
||||||
|
}
|
||||||
|
resp.body(match body {
|
||||||
|
Some(bytes) => Full::new(bytes.into()),
|
||||||
|
None => Full::new(Bytes::default()),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
mod error {
|
||||||
|
//! Pre-baked error responses.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use super::Response;
|
||||||
|
|
||||||
|
/// An error message shown to an end user of the API.
|
||||||
|
pub struct Message {
|
||||||
|
/// The main error message.
|
||||||
|
pub error: &'static str,
|
||||||
|
/// Only shown if the `accept` header included json.
|
||||||
|
pub detail: Option<Value>,
|
||||||
|
/// The status code for the response.
|
||||||
|
pub status: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::Req<'_> {
|
||||||
|
/// Generate an error response for the request.
|
||||||
|
pub fn error(&self, err: Message) -> Response {
|
||||||
|
let resp = Response::<()>::builder().status(err.status);
|
||||||
|
// If the accept header wants json, we will give them a nice structured error
|
||||||
|
// message. Otherwise, we throw a short bit of text at them.
|
||||||
|
if self.accepts_json() {
|
||||||
|
let json = json!({
|
||||||
|
"error": err.error,
|
||||||
|
"details": err.detail,
|
||||||
|
});
|
||||||
|
let body = serde_json::to_vec_pretty(&json).unwrap();
|
||||||
|
resp.header("content-type", "application/json")
|
||||||
|
.body(body.try_into().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
} else {
|
||||||
|
resp.header("content-type", "text/plain")
|
||||||
|
.body(err.error.try_into().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Check whether the requester wants json from us.
|
||||||
|
pub fn accepts_json(&self) -> bool {
|
||||||
|
self.accept
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| s.split_once('/'))
|
||||||
|
.any(|(k, v)| k == "application" && v.split('+').any(|p| p == "json"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 404 NOT FOUND response.
|
||||||
|
pub const NOT_FOUND: Message = Message {
|
||||||
|
error: "not found",
|
||||||
|
detail: None,
|
||||||
|
status: 404,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A basic 400 BAD REQUEST response.
|
||||||
|
pub const BAD_REQUEST: Message = Message {
|
||||||
|
error: "bad request",
|
||||||
|
detail: None,
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A basic 500 INTERNAL SERVER ERROR message.
|
||||||
|
pub const INTERNAL: Message = Message {
|
||||||
|
error: "internal server error",
|
||||||
|
detail: None,
|
||||||
|
status: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -1,233 +1,29 @@
|
||||||
|
//! The ActivityPuppy social media server.
|
||||||
|
//!
|
||||||
|
//! This crate contains the implementation of the ActivityPuppy's server binary. Also see the library,
|
||||||
|
//! [`puppy`], and the other two major components: [`store`] for persistence and [`fetch`] for the
|
||||||
|
//! federation implementation.
|
||||||
|
//!
|
||||||
|
//! [`store`]: puppy::store
|
||||||
|
//! [`fetch`]: puppy::fetch
|
||||||
#![feature(try_blocks, yeet_expr)]
|
#![feature(try_blocks, yeet_expr)]
|
||||||
|
|
||||||
use std::convert::Infallible;
|
use puppy::{config::Config, Context};
|
||||||
use std::net::SocketAddr;
|
|
||||||
|
|
||||||
use http_body_util::{BodyExt as _, Full};
|
mod sig;
|
||||||
use hyper::body::Bytes;
|
mod api;
|
||||||
use hyper::server::conn::http1;
|
|
||||||
use hyper::service::service_fn;
|
|
||||||
use hyper_util::rt::TokioIo;
|
|
||||||
use puppy::auth::{self, SigError, Signer, Verifier};
|
|
||||||
use puppy::store::Store;
|
|
||||||
use puppy::{auth::verify_signature, config::Config, Context};
|
|
||||||
use serde_json::{from_slice, Value};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
|
/// Starts up the whole shebang.
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
// TODO: load the config from a file or something.
|
||||||
let config = Config {
|
let config = Config {
|
||||||
ap_domain: "test.piss-on.me".to_string(),
|
ap_domain: "test.piss-on.me".to_string(),
|
||||||
wf_domain: "test.piss-on.me".to_string(),
|
wf_domain: "test.piss-on.me".to_string(),
|
||||||
state_dir: "state".to_string(),
|
state_dir: ".state".to_string(),
|
||||||
port: 1312,
|
port: 1312,
|
||||||
};
|
};
|
||||||
let store = Store::open(&config.state_dir, puppy::data::schema()).unwrap();
|
let context = Context::load(config).unwrap();
|
||||||
let verifier = Verifier::load(&config);
|
// Start the web server
|
||||||
let context = Context::new(config, store, verifier);
|
api::start(context).await.unwrap();
|
||||||
start(context).await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start(context: Context) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], context.config().port));
|
|
||||||
let listener = TcpListener::bind(addr).await?;
|
|
||||||
|
|
||||||
// We start a loop to continuously accept incoming connections
|
|
||||||
loop {
|
|
||||||
let (stream, _) = listener.accept().await?;
|
|
||||||
|
|
||||||
// Use an adapter to access something implementing `tokio::io` traits as if they implement
|
|
||||||
// `hyper::rt` IO traits.
|
|
||||||
let io = TokioIo::new(stream);
|
|
||||||
|
|
||||||
let context = context.clone();
|
|
||||||
// Spawn a tokio task to serve multiple connections concurrently
|
|
||||||
tokio::task::spawn(async move {
|
|
||||||
// Finally, we bind the incoming connection to our `hello` service
|
|
||||||
if let Err(err) = http1::Builder::new()
|
|
||||||
// `service_fn` converts our function in a `Service`
|
|
||||||
.serve_connection(io, service_fn(|req| handle(req, context.clone())))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
eprintln!("Error serving connection: {:?}", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Request = hyper::Request<hyper::body::Incoming>;
|
|
||||||
type Response<T = Full<Bytes>> = hyper::Response<T>;
|
|
||||||
|
|
||||||
// A parsed HTTP request for easy handling.
|
|
||||||
struct Req<'a> {
|
|
||||||
method: &'a Method,
|
|
||||||
body: Bytes,
|
|
||||||
// URI bits
|
|
||||||
params: Vec<(&'a str, &'a str)>,
|
|
||||||
path: Vec<&'a str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Req<'_> {
|
|
||||||
fn path(&self) -> &[&str] {
|
|
||||||
&self.path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn simplify<'x>(r: &'x http::Request<Bytes>) -> Req<'x> {
|
|
||||||
let path: Vec<&str> = r
|
|
||||||
.uri()
|
|
||||||
.path()
|
|
||||||
.split('/')
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
let params: Vec<(&str, &str)> = r
|
|
||||||
.uri()
|
|
||||||
.query()
|
|
||||||
.into_iter()
|
|
||||||
.flat_map(|s| s.split('&'))
|
|
||||||
.filter_map(|s| s.split_once('='))
|
|
||||||
.collect();
|
|
||||||
Req {
|
|
||||||
method: r.method(),
|
|
||||||
body: r.body().clone(),
|
|
||||||
params,
|
|
||||||
path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use hyper::Method;
|
|
||||||
|
|
||||||
const POST: &Method = &Method::POST;
|
|
||||||
const GET: &Method = &Method::GET;
|
|
||||||
|
|
||||||
/// The request handler.
|
|
||||||
async fn handle(req: Request, cx: Context) -> Result<Response, Infallible> {
|
|
||||||
// We need to fetch the entire body of the request for signature validation, because that involves making
|
|
||||||
// a digest of the request body in some cases.
|
|
||||||
let request = {
|
|
||||||
let (req, body) = req.into_parts();
|
|
||||||
let Ok(body) = body.collect().await.map(|b| b.to_bytes()) else {
|
|
||||||
return Ok(error::invalid_body("Could not get request body"));
|
|
||||||
};
|
|
||||||
http::Request::from_parts(req, body)
|
|
||||||
};
|
|
||||||
// Simplified representation of a request, so we can pattern match on it more easily in the dispatchers.
|
|
||||||
let req = simplify(&request);
|
|
||||||
// We'll use the path to pick where specifically to send the request.
|
|
||||||
// Check request signature at the door. Even if it isn't needed for a particular endpoint, failing fast
|
|
||||||
// with a clear error message will save anyone trying to get *their* signatures implementation a major
|
|
||||||
// headache.
|
|
||||||
let res = match verify_signature(&cx, &request).await {
|
|
||||||
// If the request was signed and the signature was accepted, they can access the protected endpoints.
|
|
||||||
Ok(Some(sig)) => dispatch_signed(cx, req, sig).await,
|
|
||||||
// Unsigned requests can see a smaller subset of endpoints, most notably the verification actor.
|
|
||||||
Ok(None) => dispatch_public(cx, req).await,
|
|
||||||
// If a signature was provided *but it turned out to be unverifiable*, show them the error message.
|
|
||||||
Err(err) => error::bad_signature(match err {
|
|
||||||
SigError::VerificationFailed { error } => format!("Verification failed: {error}"),
|
|
||||||
SigError::ParseSignature { error } => format!("Failed to parse signature: {error}"),
|
|
||||||
SigError::FailedToFetchKey { keyid } => format!("Failed to fetch {keyid}"),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
Ok(res)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a signed and verified request.
|
|
||||||
///
|
|
||||||
/// This function is where all requests to a protected endpoint have to go through. If the request
|
|
||||||
/// was signed but does not target a protected endpoint, this function will fall back to the
|
|
||||||
/// [`dispatch_public`] handler.
|
|
||||||
async fn dispatch_signed(cx: Context, req: Req<'_>, sig: Signer) -> Response {
|
|
||||||
eprintln!("Dispatching signed request");
|
|
||||||
match (req.method, req.path()) {
|
|
||||||
// Viewing ActivityPub objects requires a signed request, i.e. "authorized fetch".
|
|
||||||
// The one exception for this is `/s/request-verifier`, which is where the request
|
|
||||||
// verification actor lives.
|
|
||||||
(GET, ["o", ulid]) => api::ap::serve_object(&cx, ulid),
|
|
||||||
// POSTs to an actor's inbox need to be signed to prevent impersonation.
|
|
||||||
(POST, ["o", ulid, "inbox"]) => with_json(&req.body, |json| {
|
|
||||||
// We only handle the intermediate parsing of the json, full resolution of the
|
|
||||||
// activity object will happen inside the inbox handler itself.
|
|
||||||
api::ap::inbox(&cx, ulid, sig, json)
|
|
||||||
}),
|
|
||||||
// Try the resources for which no signature is required as well.
|
|
||||||
_ => dispatch_public(cx, req).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch `req` to an unprotected endpoint. If the requested path does not exist, the
|
|
||||||
/// function will return a 404 response.
|
|
||||||
async fn dispatch_public(cx: Context, req: Req<'_>) -> Response {
|
|
||||||
eprintln!("Dispatching public request");
|
|
||||||
match (req.method, req.path()) {
|
|
||||||
(GET, ["proxy"]) => api::ap::proxy(&cx, &req.params).await,
|
|
||||||
(GET, [".well-known", "webfinger"]) => api::wf::resolve(&cx, &req.params),
|
|
||||||
(GET, auth::VERIFIER_MOUNT) => api::ap::serve_verifier_actor(&cx),
|
|
||||||
(m, p) => {
|
|
||||||
eprintln!("404: {m} {p:?}");
|
|
||||||
error::not_found()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_json(body: &[u8], f: impl FnOnce(Value) -> Response) -> Response {
|
|
||||||
let Ok(json) = from_slice(body) else {
|
|
||||||
return error::invalid_body("Could not decode as JSON");
|
|
||||||
};
|
|
||||||
f(json)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A quick, simple way to construct a response.
|
|
||||||
fn respond<const N: usize>(
|
|
||||||
status: u16,
|
|
||||||
body: Option<impl Into<Bytes>>,
|
|
||||||
headers: [(&str, &str); N],
|
|
||||||
) -> Response {
|
|
||||||
let mut resp = Response::<()>::builder().status(status);
|
|
||||||
for (name, data) in headers {
|
|
||||||
resp = resp.header(name, data);
|
|
||||||
}
|
|
||||||
resp.body(match body {
|
|
||||||
Some(bytes) => Full::new(bytes.into()),
|
|
||||||
None => Full::new(Bytes::default()),
|
|
||||||
})
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
mod api;
|
|
||||||
|
|
||||||
mod error {
|
|
||||||
//! Pre-baked error responses.
|
|
||||||
|
|
||||||
use http_body_util::Full;
|
|
||||||
use super::Response;
|
|
||||||
|
|
||||||
/// 404 response.
|
|
||||||
pub fn not_found() -> Response {
|
|
||||||
let body = Full::new("Not found".into());
|
|
||||||
Response::<()>::builder()
|
|
||||||
.status(404)
|
|
||||||
.header("content-type", "text/plain")
|
|
||||||
.body(body)
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 403 response indicating a bad request signature.
|
|
||||||
pub fn bad_signature(err: String) -> Response {
|
|
||||||
let body = Full::new(err.into());
|
|
||||||
Response::<()>::builder()
|
|
||||||
.status(403)
|
|
||||||
.header("content-type", "text/plain")
|
|
||||||
.body(body)
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn invalid_body(err: impl ToString) -> Response {
|
|
||||||
let body = Full::new(err.to_string().into());
|
|
||||||
Response::<()>::builder()
|
|
||||||
.status(400)
|
|
||||||
.header("content-type", "text/plain")
|
|
||||||
.body(body)
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
149
bin/server/src/sig.rs
Normal file
149
bin/server/src/sig.rs
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
//! Verification of HTTP signatures.
|
||||||
|
|
||||||
|
use http::Request;
|
||||||
|
use puppy::fetch::{
|
||||||
|
signatures::{Private, Public, Signature, SigningKey, VerificationKey, Key},
|
||||||
|
FetchError,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use puppy::config::Config;
|
||||||
|
|
||||||
|
/// Checks request signatures.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Verifier {
|
||||||
|
actor_id: String,
|
||||||
|
key_id: String,
|
||||||
|
private: Private,
|
||||||
|
public: Public,
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERIFIER_PATH: &str = "/s/request-verifier";
|
||||||
|
/// The path at which the request verification actor will present itself.
|
||||||
|
pub const VERIFIER_MOUNT: &[&str] = &["s", "request-verifier"];
|
||||||
|
|
||||||
|
/// A "verdict" about a signed request, passed by a [`Verifier`].
|
||||||
|
pub enum Verdict {
|
||||||
|
/// The signature checks out.
|
||||||
|
Verified(Signer),
|
||||||
|
/// The signature does not contain a signature header. This may be intentional, or a client error.
|
||||||
|
Unsigned,
|
||||||
|
/// The signature failed to verify due to an error related to the signature itself.
|
||||||
|
Rejected {
|
||||||
|
signature_str: String,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Verifier {
|
||||||
|
/// Get the JSON-LD representation of the verifier actor.
|
||||||
|
pub fn to_json_ld(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"@context": [
|
||||||
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
"https://w3id.org/security/v1",
|
||||||
|
],
|
||||||
|
"id": self.actor_id,
|
||||||
|
"name": "Public key fetcher",
|
||||||
|
"publicKey": {
|
||||||
|
"id": self.key_id,
|
||||||
|
"owner": self.actor_id,
|
||||||
|
"publicKeyPem": self.public.encode_pem()
|
||||||
|
},
|
||||||
|
"type": "Service",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/// Load the server's verifier actor.
|
||||||
|
///
|
||||||
|
/// Each server has one special actor for fetching public keys. Unlike all other objects,
|
||||||
|
/// acquiring that actor's JSON-LD representation does not require a request signature.
|
||||||
|
///
|
||||||
|
/// It doesn't have any data in the data store. Due to its exceptional nature, we just put
|
||||||
|
/// the private key in the [`state_dir`][Config::state_dir]. The very first time you load
|
||||||
|
/// the verifier, it generates the required private keys.
|
||||||
|
pub fn load(cfg: &Config) -> Verifier {
|
||||||
|
let Config { ap_domain, state_dir, .. } = cfg;
|
||||||
|
let key_path = format!("{state_dir}/fetcher.pem");
|
||||||
|
// Read the private key from the state directory, or generate a new one if it couldn't
|
||||||
|
// be read.
|
||||||
|
let private = Private::load(&key_path).unwrap_or_else(|| {
|
||||||
|
let (private, _) = Private::gen();
|
||||||
|
private.save(key_path);
|
||||||
|
private
|
||||||
|
});
|
||||||
|
Verifier {
|
||||||
|
actor_id: format!("https://{ap_domain}{VERIFIER_PATH}"),
|
||||||
|
key_id: format!("https://{ap_domain}{VERIFIER_PATH}#sig-key"),
|
||||||
|
public: private.get_public(),
|
||||||
|
private,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Does the HTTP signature verification process, and returns a "proof" of the signature in the form
|
||||||
|
/// of the [`Signer`], which contains information about who signed a particular request.
|
||||||
|
pub async fn verify<B>(&self, req: &Request<B>) -> Verdict {
|
||||||
|
// TODO: implement the whole verification thing as a middleware so we can intercept requests
|
||||||
|
// like these, instead of coupling this tightly with the router.
|
||||||
|
if req.uri().path() == VERIFIER_PATH {
|
||||||
|
// HACK: Allow access to the request verifier actor without checking the signature.
|
||||||
|
return Verdict::Unsigned;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(header) = req.headers().get("signature") else {
|
||||||
|
return Verdict::Unsigned;
|
||||||
|
};
|
||||||
|
|
||||||
|
let signature_str = header
|
||||||
|
.to_str()
|
||||||
|
.expect("signature header value should be valid ascii")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let sig = match Signature::derive(&req) {
|
||||||
|
Err(error) => return Verdict::Rejected { signature_str, reason: error },
|
||||||
|
Ok(signature) => signature,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch the signer's public key using our private key.
|
||||||
|
let fetch_result = self.fetch_public_key(sig.key_id()).await;
|
||||||
|
let public_key = match fetch_result {
|
||||||
|
Ok(public_key) => public_key,
|
||||||
|
Err(err) => {
|
||||||
|
return Verdict::Rejected {
|
||||||
|
reason: format!("could not fetch public key: {err}"),
|
||||||
|
signature_str,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: verify digest also
|
||||||
|
if let Err(error) = public_key.verify(&sig) {
|
||||||
|
Verdict::Rejected { signature_str, reason: error }
|
||||||
|
} else {
|
||||||
|
Verdict::Verified(Signer { ap_id: public_key.owner })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Send a request to get the public key from an ID. This request will be signed with the
|
||||||
|
/// verifier actor's public key.
|
||||||
|
async fn fetch_public_key(&self, uri: &str) -> Result<VerificationKey, FetchError> {
|
||||||
|
let json = puppy::fetch::resolve(&self.signing_key(), uri).await?;
|
||||||
|
let Some(key) = Key::from_json(json) else {
|
||||||
|
return Err(FetchError::BadJson(
|
||||||
|
"invalid public key structure".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Ok(key.upgrade())
|
||||||
|
}
|
||||||
|
/// Get the key that the verification actor signs requests with.
|
||||||
|
fn signing_key(&self) -> SigningKey {
|
||||||
|
Key {
|
||||||
|
id: self.key_id.clone(),
|
||||||
|
owner: self.actor_id.clone(),
|
||||||
|
inner: self.private.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ActivityPub actor that signed a request.
|
||||||
|
pub struct Signer {
|
||||||
|
/// The ActivityPub ID (a URL) of the signer of the request.
|
||||||
|
pub ap_id: String,
|
||||||
|
}
|
166
lib/fetch/src/client.rs
Normal file
166
lib/fetch/src/client.rs
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
use chrono::Utc;
|
||||||
|
use http_body_util::BodyExt as _;
|
||||||
|
use reqwest::Body;
|
||||||
|
use serde_json::Value;
|
||||||
|
use derive_more::Display;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
object::Activity,
|
||||||
|
signatures::{SigningKey, Options},
|
||||||
|
FetchError,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The name of the server software, used for generating the user agent string.
|
||||||
|
///
|
||||||
|
/// See also [`VERSION`].
|
||||||
|
pub const SOFTWARE: &str = "ActivityPuppy";
|
||||||
|
/// The current version of the server software, which is incorporated into the user agent string
|
||||||
|
/// for all outbound requests made by ActivityPuppy.
|
||||||
|
pub const VERSION: &str = "0.0.1-dev";
|
||||||
|
|
||||||
|
/// Content-type/accept header for ActivityPub requests.
|
||||||
|
pub const ACTIVITYPUB_TYPE: &str = "application/activity+json";
|
||||||
|
|
||||||
|
/// A client for sending ActivityPub and WebFinger requests with.
|
||||||
|
pub struct Client {
|
||||||
|
inner: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
/// Constructs a new federation client.
|
||||||
|
pub fn new() -> Client {
|
||||||
|
Client { inner: reqwest::Client::new() }
|
||||||
|
}
|
||||||
|
/// Deliver an [`Activity`] to a particular `inbox`.
|
||||||
|
///
|
||||||
|
/// Note that in order for the request to be considered valid by most implementations, `key.owner`
|
||||||
|
/// must equal `payload.actor`.
|
||||||
|
pub async fn deliver(&self, key: &SigningKey, payload: &Activity, inbox: &str) {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
/// A high-level function to resolve a single ActivityPub ID using a signed request.
|
||||||
|
pub async fn resolve(&self, key: &SigningKey, url: &str) -> Result<Value, FetchError> {
|
||||||
|
let system = Subsystem::Resolver;
|
||||||
|
|
||||||
|
let mut req = system
|
||||||
|
.new_request(url)?
|
||||||
|
.header("accept", ACTIVITYPUB_TYPE)
|
||||||
|
.body(())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
key.sign(Options::MODERN, &req)
|
||||||
|
.map_err(FetchError::Sig)?
|
||||||
|
.commit(&mut req);
|
||||||
|
|
||||||
|
let request = req.map(|()| Body::default()).try_into()?;
|
||||||
|
let response = self.inner.execute(request).await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
response.json().await.map_err(From::from)
|
||||||
|
} else {
|
||||||
|
Err(FetchError::NotSuccess {
|
||||||
|
status: response.status().as_u16(),
|
||||||
|
body: response.text().await?,
|
||||||
|
url: url.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Forwards a request and returns the raw response, so that it can be analyzed for debugging.
|
||||||
|
///
|
||||||
|
/// It exists solely as a debugging tool!
|
||||||
|
pub async fn proxy(
|
||||||
|
&self,
|
||||||
|
key: &SigningKey,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<http::Response<String>, FetchError> {
|
||||||
|
let system = Subsystem::DevProxy;
|
||||||
|
|
||||||
|
let mut req = system
|
||||||
|
.new_request(url)?
|
||||||
|
.header("accept", ACTIVITYPUB_TYPE)
|
||||||
|
.body(())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("[{system}]: using modern config");
|
||||||
|
key.sign(Options::MODERN, &req)
|
||||||
|
.expect("signing error")
|
||||||
|
.commit(&mut req);
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.execute(req.map(|_| Body::default()).try_into().unwrap())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let http_resp: http::Response<reqwest::Body> = resp.into();
|
||||||
|
let (res, body) = http_resp.into_parts();
|
||||||
|
let body = body.collect().await.unwrap().to_bytes();
|
||||||
|
let http_resp =
|
||||||
|
http::Response::from_parts(res, String::from_utf8_lossy(body.as_ref()).into_owned());
|
||||||
|
Ok(http_resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identifies a specific subsystem that makes an outgoing request.
|
||||||
|
///
|
||||||
|
/// This allows us to precisely track each outgoing request, as well as generate a meaningful
|
||||||
|
/// user-agent header. It is also used to generate a "base request".
|
||||||
|
#[derive(Clone, Copy, Display)]
|
||||||
|
enum Subsystem {
|
||||||
|
/// The subsystem that dereferences ActivityPub URLs to JSON values.
|
||||||
|
///
|
||||||
|
/// In addition, the resolver is used for resolving webfinger handles to ActivityPub actors.
|
||||||
|
#[display = "resolver"]
|
||||||
|
Resolver,
|
||||||
|
/// The subsystem responsible for delivering activities to inboxes.
|
||||||
|
#[display = "delivery"]
|
||||||
|
Delivery,
|
||||||
|
/// For testing the resolver and signatures.
|
||||||
|
#[display = "devproxy"]
|
||||||
|
DevProxy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Subsystem {
|
||||||
|
/// Get the user agent string for the subsystem.
|
||||||
|
fn user_agent(&self) -> String {
|
||||||
|
format!("{SOFTWARE}/{VERSION} [{}]", match self {
|
||||||
|
Subsystem::Resolver => "resolver",
|
||||||
|
Subsystem::Delivery => "delivery",
|
||||||
|
Subsystem::DevProxy => "devproxy",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/// Construct a new request for this subsystem.
|
||||||
|
///
|
||||||
|
/// This will set the following headers, which are common to all requests made by the fetch
|
||||||
|
/// system:
|
||||||
|
///
|
||||||
|
/// - `user-agent`, which depends on the particular subsystem in use
|
||||||
|
/// - `date`, which is generated from the current time
|
||||||
|
/// - `host`, which is derived from `target`
|
||||||
|
///
|
||||||
|
/// This function returns an error if the `target` is not a valid URI. It panics if the URI
|
||||||
|
/// does not have a host specified.
|
||||||
|
fn new_request(self, target: &str) -> Result<http::request::Builder, FetchError> {
|
||||||
|
// Format our time like "Sun, 06 Nov 1994 08:49:37 GMT"
|
||||||
|
const RFC_822: &str = "%a, %d %b %Y %H:%M:%S GMT";
|
||||||
|
let date = Utc::now().format(RFC_822).to_string();
|
||||||
|
|
||||||
|
let uri = target
|
||||||
|
.parse::<http::Uri>()
|
||||||
|
.map_err(|e| FetchError::InvalidURI {
|
||||||
|
url: target.to_string(),
|
||||||
|
error: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let Some(host) = uri.host() else {
|
||||||
|
// SECURITY: Refuse to resolve URLs to local resources using local keys.
|
||||||
|
panic!("refusing to resolve a relative URL: {target}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let req = http::Request::builder()
|
||||||
|
.uri(target)
|
||||||
|
.header("user-agent", self.user_agent())
|
||||||
|
.header("date", date)
|
||||||
|
.header("host", host);
|
||||||
|
|
||||||
|
Ok(req)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,179 +1,85 @@
|
||||||
#![feature(iter_intersperse, yeet_expr, iterator_try_collect, try_blocks)]
|
#![feature(iter_intersperse, yeet_expr, iterator_try_collect, try_blocks)]
|
||||||
use chrono::Utc;
|
use std::error::Error;
|
||||||
use http_body_util::BodyExt as _;
|
|
||||||
use reqwest::Body;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use crate::signatures::{Options, HS2019};
|
use derive_more::Display;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use object::Activity;
|
||||||
|
use signatures::SigningKey;
|
||||||
|
|
||||||
pub use http;
|
pub use http;
|
||||||
|
|
||||||
pub use signatures::{Key, SigningKey, VerificationKey};
|
|
||||||
pub mod signatures;
|
pub mod signatures;
|
||||||
|
pub mod object;
|
||||||
|
|
||||||
pub enum Object {
|
pub use client::Client;
|
||||||
Activity(Activity),
|
mod client;
|
||||||
Actor(Actor),
|
|
||||||
Object {
|
/// Deliver an activity to an inbox.
|
||||||
id: String,
|
pub async fn deliver(key: &SigningKey, activity: Activity, inbox: &str) {
|
||||||
kind: String,
|
Client::new().deliver(key, &activity, inbox).await
|
||||||
content: Option<String>,
|
}
|
||||||
summary: Option<String>,
|
|
||||||
|
/// Resolve an ActivityPub ID to a JSON value.
|
||||||
|
///
|
||||||
|
/// Note: This creates a new [`Client`] every time you call it, so if you're gonna call it more than just
|
||||||
|
/// a couple of times, create a `Client` and call its inherent methods instead.
|
||||||
|
pub async fn resolve(key: &SigningKey, target: &str) -> Result<Value, FetchError> {
|
||||||
|
Client::new().resolve(key, target).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proxy a GET request through this server.
|
||||||
|
///
|
||||||
|
/// Should only be used for manually testing stuff.
|
||||||
|
pub async fn forward(key: &SigningKey, target: &str) -> Result<http::Response<String>, FetchError> {
|
||||||
|
Client::new().proxy(key, target).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that may occur during the execution of HTTP request routines.
|
||||||
|
#[derive(Debug, Display)]
|
||||||
|
pub enum FetchError {
|
||||||
|
/// Some error internal to the request sending process occurred.
|
||||||
|
#[display(fmt = "internal error: {error} (url={url:?})")]
|
||||||
|
Internal { url: Option<String>, error: String },
|
||||||
|
/// The URI was not valid and therefore the request could not be made.
|
||||||
|
#[display(fmt = "invalid uri: {error} (url={url})")]
|
||||||
|
InvalidURI { url: String, error: String },
|
||||||
|
/// A non-success status code was encountered.
|
||||||
|
#[display(fmt = "non-2xx status code: {status} (url={url})")]
|
||||||
|
NotSuccess {
|
||||||
|
status: u16,
|
||||||
|
url: String,
|
||||||
|
body: String,
|
||||||
},
|
},
|
||||||
|
/// The JSON body of a response could not be loaded. The string inside is the error
|
||||||
|
/// message produced by the JSON deserializer.
|
||||||
|
#[display(fmt = "deserialization error: {}", self.0)]
|
||||||
|
BadJson(String),
|
||||||
|
/// An error that occurred while generating a signature for a a request.
|
||||||
|
#[display(fmt = "signing error: {}", self.0)]
|
||||||
|
Sig(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Object {
|
impl FetchError {
|
||||||
pub fn id(&self) -> &str {
|
/// Check whether the error is due to a 403 UNAUTHORIZED response status code.
|
||||||
match self {
|
pub fn is_unauthorized(&self) -> bool {
|
||||||
Object::Activity(a) => &a.id,
|
matches!(self, FetchError::NotSuccess { status: 403, .. })
|
||||||
Object::Actor(a) => &a.id,
|
|
||||||
Object::Object { id, .. } => id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn to_json_ld(&self) -> Value {
|
|
||||||
match self {
|
|
||||||
Object::Activity(a) => a.to_json_ld(),
|
|
||||||
Object::Actor(a) => a.to_json_ld(),
|
|
||||||
Object::Object { id, kind, content, summary } => json!({
|
|
||||||
"id": id.to_string(),
|
|
||||||
"type": kind,
|
|
||||||
"content": content,
|
|
||||||
"summary": summary,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
/// Check whether the error is due to a 404 NOT FOUND response status code.
|
||||||
|
pub fn is_not_found(&self) -> bool {
|
||||||
|
matches!(self, FetchError::NotSuccess { status: 404, .. })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Activity<T = String> {
|
#[doc(hidden)]
|
||||||
pub id: String,
|
impl From<reqwest::Error> for FetchError {
|
||||||
pub actor: String,
|
fn from(error: reqwest::Error) -> FetchError {
|
||||||
pub object: Box<Object>,
|
match error.source().and_then(|e| e.downcast_ref()) {
|
||||||
pub kind: T,
|
Some(e @ serde_json::Error { .. }) => FetchError::BadJson(e.to_string()),
|
||||||
}
|
None => {
|
||||||
|
let url = error.url().map(|u| u.to_string());
|
||||||
impl<K> Activity<K>
|
FetchError::Internal { url, error: error.to_string() }
|
||||||
where
|
}
|
||||||
K: ToString,
|
}
|
||||||
{
|
|
||||||
pub fn to_json_ld(&self) -> Value {
|
|
||||||
json!({
|
|
||||||
"@context": [
|
|
||||||
"https://www.w3.org/ns/activitystreams",
|
|
||||||
{ "Bite": "https://ns.mia.jetzt/as#Bite" },
|
|
||||||
],
|
|
||||||
"id": self.id.to_string(),
|
|
||||||
"actor": self.actor.to_string(),
|
|
||||||
"type": self.kind.to_string(),
|
|
||||||
"object": self.object.to_json_ld()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deliver an [`Activity`] to a particular `inbox`.
|
|
||||||
pub async fn deliver(key: &SigningKey, activity: Activity, inbox: &str) -> () {
|
|
||||||
todo!()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sun, 06 Nov 1994 08:49:37 GMT
|
|
||||||
const RFC_822: &str = "%a, %d %b %Y %H:%M:%S GMT";
|
|
||||||
pub async fn resolve(key: &SigningKey, target: &str) -> reqwest::Result<Value> {
|
|
||||||
// TODO: make this retry with different signature options and remember what works for the
|
|
||||||
// particular host.
|
|
||||||
println!("[resolver]: resolving url {target} using key {}", key.id);
|
|
||||||
|
|
||||||
let uri = target.parse::<http::Uri>().unwrap();
|
|
||||||
let host = uri.host().unwrap();
|
|
||||||
let date = Utc::now().format(RFC_822).to_string();
|
|
||||||
let mut req = http::Request::builder()
|
|
||||||
.uri(target)
|
|
||||||
.header("accept", "application/activity+json")
|
|
||||||
.header("user-agent", "ActivityPuppy/0.0.0 (resolver)")
|
|
||||||
.header("date", date)
|
|
||||||
.header("host", host)
|
|
||||||
// Empty body
|
|
||||||
.body(())
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
println!("[resolver]: constructed request {req:#?}");
|
|
||||||
|
|
||||||
// hs2019 works with masto
|
|
||||||
println!("[resolver]: using modern config");
|
|
||||||
let sig = key.sign(Options::MODERN, &req).expect("signing error");
|
|
||||||
|
|
||||||
println!("[resolver]: constructed signature {sig:#?}");
|
|
||||||
sig.commit(&mut req);
|
|
||||||
|
|
||||||
reqwest::Client::new()
|
|
||||||
.execute(req.map(|_| Body::default()).try_into().unwrap())
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn forward(key: &SigningKey, target: &str) -> reqwest::Result<http::Response<String>> {
|
|
||||||
let date = Utc::now().format(RFC_822).to_string();
|
|
||||||
let uri = target.parse::<http::Uri>().unwrap();
|
|
||||||
let host = uri.host().unwrap();
|
|
||||||
let mut req = http::Request::get(target)
|
|
||||||
.header("accept", "application/activity+json")
|
|
||||||
.header("user-agent", "ActivityPuppy/0.0.0 (resolver)")
|
|
||||||
.header("date", date)
|
|
||||||
.header("host", host)
|
|
||||||
// Empty body
|
|
||||||
.body(())
|
|
||||||
.unwrap();
|
|
||||||
// hs2019 works with masto
|
|
||||||
println!("[proxy]: using modern config");
|
|
||||||
key.sign(Options::MODERN, &req)
|
|
||||||
.expect("signing error")
|
|
||||||
.commit(&mut req);
|
|
||||||
|
|
||||||
let resp = reqwest::Client::new()
|
|
||||||
.execute(req.map(|_| Body::default()).try_into().unwrap())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let http_resp: http::Response<reqwest::Body> = resp.into();
|
|
||||||
let (res, body) = http_resp.into_parts();
|
|
||||||
let body = body.collect().await.unwrap().to_bytes();
|
|
||||||
let http_resp =
|
|
||||||
http::Response::from_parts(res, String::from_utf8_lossy(body.as_ref()).into_owned());
|
|
||||||
Ok(http_resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An actor is an entity capable of producing Takes.
|
|
||||||
pub struct Actor {
|
|
||||||
/// The URL pointing to this object.
|
|
||||||
pub id: String,
|
|
||||||
/// Where others should send activities.
|
|
||||||
pub inbox: String,
|
|
||||||
/// Note: this maps to the `preferredUsername` property.
|
|
||||||
pub account_name: String,
|
|
||||||
/// Note: this maps to the `name` property.
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
/// Public counterpart to the signing key used to sign activities
|
|
||||||
/// generated by the actor.
|
|
||||||
pub public_key: Key,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Actor {
|
|
||||||
pub fn to_json_ld(&self) -> Value {
|
|
||||||
json!({
|
|
||||||
"@context": [
|
|
||||||
"https://www.w3.org/ns/activitystreams",
|
|
||||||
"https://w3id.org/security/v1",
|
|
||||||
],
|
|
||||||
"id": self.id.to_string(),
|
|
||||||
"inbox": self.inbox.to_string(),
|
|
||||||
"outbox": self.inbox.to_string().replace("inbox", "outbox"),
|
|
||||||
"preferredUsername": self.account_name,
|
|
||||||
"name": self.display_name,
|
|
||||||
"type": "Person",
|
|
||||||
"publicKey": {
|
|
||||||
"id": self.public_key.id,
|
|
||||||
"publicKeyPem": self.public_key.inner,
|
|
||||||
"owner": self.id.to_string(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
100
lib/fetch/src/object.rs
Normal file
100
lib/fetch/src/object.rs
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
//! ActivityPub vocabulary as interpreted by ActivityPuppy.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub use crate::signatures::Key as PublicKey;
|
||||||
|
|
||||||
|
pub struct Activity<T = String> {
|
||||||
|
pub id: String,
|
||||||
|
pub actor: String,
|
||||||
|
pub object: Box<Object>,
|
||||||
|
pub kind: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K> Activity<K>
|
||||||
|
where
|
||||||
|
K: ToString,
|
||||||
|
{
|
||||||
|
pub fn to_json_ld(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"@context": [
|
||||||
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
{ "Bite": "https://ns.mia.jetzt/as#Bite" },
|
||||||
|
],
|
||||||
|
"id": self.id.to_string(),
|
||||||
|
"actor": self.actor.to_string(),
|
||||||
|
"type": self.kind.to_string(),
|
||||||
|
"object": self.object.to_json_ld()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An actor is an entity capable of producing Takes.
|
||||||
|
pub struct Actor {
|
||||||
|
/// The URL pointing to this object.
|
||||||
|
pub id: String,
|
||||||
|
/// Where others should send activities.
|
||||||
|
pub inbox: String,
|
||||||
|
/// Note: this maps to the `preferredUsername` property.
|
||||||
|
pub account_name: String,
|
||||||
|
/// Note: this maps to the `name` property.
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
/// Public counterpart to the signing key used to sign activities
|
||||||
|
/// generated by the actor.
|
||||||
|
pub public_key: PublicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Actor {
|
||||||
|
pub fn to_json_ld(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"@context": [
|
||||||
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
"https://w3id.org/security/v1",
|
||||||
|
],
|
||||||
|
"id": self.id.to_string(),
|
||||||
|
"inbox": self.inbox.to_string(),
|
||||||
|
"outbox": self.inbox.to_string().replace("inbox", "outbox"),
|
||||||
|
"preferredUsername": self.account_name,
|
||||||
|
"name": self.display_name,
|
||||||
|
"type": "Person",
|
||||||
|
"publicKey": {
|
||||||
|
"id": self.public_key.id,
|
||||||
|
"publicKeyPem": self.public_key.inner,
|
||||||
|
"owner": self.id.to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Object {
|
||||||
|
Activity(Activity),
|
||||||
|
Actor(Actor),
|
||||||
|
Object {
|
||||||
|
id: String,
|
||||||
|
kind: String,
|
||||||
|
content: Option<String>,
|
||||||
|
summary: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Object {
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Object::Activity(a) => &a.id,
|
||||||
|
Object::Actor(a) => &a.id,
|
||||||
|
Object::Object { id, .. } => id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn to_json_ld(&self) -> Value {
|
||||||
|
match self {
|
||||||
|
Object::Activity(a) => a.to_json_ld(),
|
||||||
|
Object::Actor(a) => a.to_json_ld(),
|
||||||
|
Object::Object { id, kind, content, summary } => json!({
|
||||||
|
"id": id.to_string(),
|
||||||
|
"type": kind,
|
||||||
|
"content": content,
|
||||||
|
"summary": summary,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -40,6 +40,9 @@ use serde_json::{Map, Value};
|
||||||
|
|
||||||
use self::new::{decode, encode, sha256, IR};
|
use self::new::{decode, encode, sha256, IR};
|
||||||
|
|
||||||
|
/// Size of the RSA private keys puppy generates.
|
||||||
|
const KEY_SIZE: usize = 2048;
|
||||||
|
|
||||||
/// A key that can be used to verify a request signature.
|
/// A key that can be used to verify a request signature.
|
||||||
pub type VerificationKey = Key<Public>;
|
pub type VerificationKey = Key<Public>;
|
||||||
|
|
||||||
|
@ -73,7 +76,7 @@ impl Key {
|
||||||
json.get("publicKey")?.as_object().and_then(Key::from_map)
|
json.get("publicKey")?.as_object().and_then(Key::from_map)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/// Construct
|
/// Try to interpret the given map as a public key.
|
||||||
fn from_map(map: &Map<String, Value>) -> Option<Key> {
|
fn from_map(map: &Map<String, Value>) -> Option<Key> {
|
||||||
Some(Key {
|
Some(Key {
|
||||||
id: map.get("id")?.as_str().map(str::to_owned)?,
|
id: map.get("id")?.as_str().map(str::to_owned)?,
|
||||||
|
@ -82,6 +85,10 @@ impl Key {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/// "Upgrade" a pem-encoded public key to a key that can actually be used for requests.
|
/// "Upgrade" a pem-encoded public key to a key that can actually be used for requests.
|
||||||
|
///
|
||||||
|
/// The inverse of this is [`Key::serialize`], which turns `inner` back into a string.
|
||||||
|
///
|
||||||
|
/// [`Key::serialize`]: Key::<Public>::serialize
|
||||||
pub fn upgrade(self) -> Key<Public> {
|
pub fn upgrade(self) -> Key<Public> {
|
||||||
let inner = Public::decode_pem(&self.inner);
|
let inner = Public::decode_pem(&self.inner);
|
||||||
Key {
|
Key {
|
||||||
|
@ -100,8 +107,7 @@ impl Private {
|
||||||
/// Generate a new keypair.
|
/// Generate a new keypair.
|
||||||
pub fn gen() -> (Private, Public) {
|
pub fn gen() -> (Private, Public) {
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
let bits = 512;
|
let private = RsaPrivateKey::new(&mut rng, KEY_SIZE).unwrap();
|
||||||
let private = RsaPrivateKey::new(&mut rng, bits).unwrap();
|
|
||||||
let public = private.to_public_key();
|
let public = private.to_public_key();
|
||||||
(Private(private), Public(public))
|
(Private(private), Public(public))
|
||||||
}
|
}
|
||||||
|
@ -110,11 +116,19 @@ impl Private {
|
||||||
Public(self.0.to_public_key())
|
Public(self.0.to_public_key())
|
||||||
}
|
}
|
||||||
/// Load a private key from a file on disk.
|
/// Load a private key from a file on disk.
|
||||||
pub fn load(path: impl AsRef<Path>) -> Private {
|
pub fn load(path: impl AsRef<Path>) -> Option<Private> {
|
||||||
use rsa::pkcs8::DecodePrivateKey;
|
use rsa::pkcs8::DecodePrivateKey;
|
||||||
|
let path = path.as_ref();
|
||||||
DecodePrivateKey::read_pkcs8_pem_file(path)
|
DecodePrivateKey::read_pkcs8_pem_file(path)
|
||||||
.map(Private)
|
.map(Private)
|
||||||
.unwrap()
|
.ok()
|
||||||
|
}
|
||||||
|
/// Store the private key at `path`.
|
||||||
|
pub fn save(&self, path: impl AsRef<Path>) {
|
||||||
|
use rsa::pkcs8::EncodePrivateKey;
|
||||||
|
self.0
|
||||||
|
.write_pkcs8_pem_file(path, LineEnding::default())
|
||||||
|
.expect("writing a private key to a file should not fail")
|
||||||
}
|
}
|
||||||
/// PEM-encode the key PKCS#8 style.
|
/// PEM-encode the key PKCS#8 style.
|
||||||
pub fn encode_pem(&self) -> String {
|
pub fn encode_pem(&self) -> String {
|
||||||
|
|
|
@ -1,24 +1,24 @@
|
||||||
use store::{Key, Store, Transaction};
|
use store::{Key, Store, Transaction};
|
||||||
|
|
||||||
use crate::{auth::Verifier, config::Config, Result};
|
use crate::{config::Config, Result};
|
||||||
|
|
||||||
/// The context of a running ActivityPuppy.
|
/// The context of a running ActivityPuppy.
|
||||||
///
|
///
|
||||||
/// This type provides access to the data store and configuration.
|
/// This type provides access to the data store and configuration.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Context {
|
pub struct Context {
|
||||||
verifier: Option<Verifier>,
|
|
||||||
config: Config,
|
config: Config,
|
||||||
store: Store,
|
store: Store,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Context {
|
impl Context {
|
||||||
pub fn new(config: Config, store: Store, verifier: Verifier) -> Context {
|
fn new(config: Config, store: Store) -> Context {
|
||||||
Context {
|
Context { config, store }
|
||||||
verifier: Some(verifier),
|
|
||||||
config,
|
|
||||||
store,
|
|
||||||
}
|
}
|
||||||
|
/// Load the server context from the configuration.
|
||||||
|
pub fn load(config: Config) -> Result<Context> {
|
||||||
|
let store = Store::open(&config.state_dir, crate::data::schema())?;
|
||||||
|
Ok(Context { config, store })
|
||||||
}
|
}
|
||||||
/// Do a data store [transaction][store::Transaction].
|
/// Do a data store [transaction][store::Transaction].
|
||||||
pub fn run<T>(&self, f: impl FnOnce(&Transaction<'_>) -> Result<T>) -> Result<T> {
|
pub fn run<T>(&self, f: impl FnOnce(&Transaction<'_>) -> Result<T>) -> Result<T> {
|
||||||
|
@ -36,10 +36,6 @@ impl Context {
|
||||||
pub fn mk_url(&self, key: Key) -> String {
|
pub fn mk_url(&self, key: Key) -> String {
|
||||||
format!("https://{}/o/{key}", self.config.ap_domain)
|
format!("https://{}/o/{key}", self.config.ap_domain)
|
||||||
}
|
}
|
||||||
/// Get the verification actor.
|
|
||||||
pub fn verifier(&self) -> &Verifier {
|
|
||||||
self.verifier.as_ref().unwrap()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a context for running tests in.
|
/// Load a context for running tests in.
|
||||||
|
@ -49,7 +45,5 @@ pub fn test_context<T>(
|
||||||
schema: store::types::Schema,
|
schema: store::types::Schema,
|
||||||
test: impl FnOnce(Context) -> Result<T>,
|
test: impl FnOnce(Context) -> Result<T>,
|
||||||
) -> Result<T> {
|
) -> Result<T> {
|
||||||
Store::test(schema, |store| {
|
Store::test(schema, |store| test(Context { config, store }))
|
||||||
test(Context { config, store, verifier: None })
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ mod interact;
|
||||||
/// Retrieve an ActivityPub object from the database.
|
/// Retrieve an ActivityPub object from the database.
|
||||||
///
|
///
|
||||||
/// Fails with `Error::Missing` if the required properties are not present.
|
/// Fails with `Error::Missing` if the required properties are not present.
|
||||||
pub fn get_local_ap_object(tx: &Transaction<'_>, key: Key) -> Result<fetch::Object> {
|
pub fn get_local_ap_object(tx: &Transaction<'_>, key: Key) -> Result<fetch::object::Object> {
|
||||||
let Some(obj) = tx.get_mixin::<Object>(key)? else {
|
let Some(obj) = tx.get_mixin::<Object>(key)? else {
|
||||||
// We need this data in order to determine the object type. If the passed key does not
|
// We need this data in order to determine the object type. If the passed key does not
|
||||||
// have this data, it must not be an ActivityPub object.
|
// have this data, it must not be an ActivityPub object.
|
||||||
|
@ -48,12 +48,12 @@ pub fn get_local_ap_object(tx: &Transaction<'_>, key: Key) -> Result<fetch::Obje
|
||||||
let Some(PublicKey { key_id, key_pem }) = tx.get_mixin(key)? else {
|
let Some(PublicKey { key_id, key_pem }) = tx.get_mixin(key)? else {
|
||||||
return Err(Error::MissingData { node: key, prop: "PublicKey" });
|
return Err(Error::MissingData { node: key, prop: "PublicKey" });
|
||||||
};
|
};
|
||||||
Ok(fetch::Object::Actor(fetch::Actor {
|
Ok(fetch::object::Object::Actor(fetch::object::Actor {
|
||||||
id: obj.id.0.clone().into(),
|
id: obj.id.0.clone().into(),
|
||||||
inbox: inbox.into(),
|
inbox: inbox.into(),
|
||||||
account_name: account_name.0,
|
account_name: account_name.0,
|
||||||
display_name,
|
display_name,
|
||||||
public_key: fetch::Key {
|
public_key: fetch::object::PublicKey {
|
||||||
owner: obj.id.0.into(),
|
owner: obj.id.0.into(),
|
||||||
id: key_id.into(),
|
id: key_id.into(),
|
||||||
inner: key_pem,
|
inner: key_pem,
|
||||||
|
@ -65,7 +65,7 @@ pub fn get_local_ap_object(tx: &Transaction<'_>, key: Key) -> Result<fetch::Obje
|
||||||
panic!("expected a `Create`");
|
panic!("expected a `Create`");
|
||||||
};
|
};
|
||||||
let Id(actor) = tx.get_alias(actor)?.unwrap();
|
let Id(actor) = tx.get_alias(actor)?.unwrap();
|
||||||
Ok(fetch::Object::Activity(fetch::Activity {
|
Ok(fetch::object::Object::Activity(fetch::object::Activity {
|
||||||
id: obj.id.0.into(),
|
id: obj.id.0.into(),
|
||||||
actor: actor.into(),
|
actor: actor.into(),
|
||||||
object: Box::new(get_local_ap_object(tx, object)?),
|
object: Box::new(get_local_ap_object(tx, object)?),
|
||||||
|
@ -76,7 +76,7 @@ pub fn get_local_ap_object(tx: &Transaction<'_>, key: Key) -> Result<fetch::Obje
|
||||||
let Some(Content { content, warning, .. }) = tx.get_mixin(key)? else {
|
let Some(Content { content, warning, .. }) = tx.get_mixin(key)? else {
|
||||||
panic!()
|
panic!()
|
||||||
};
|
};
|
||||||
Ok(fetch::Object::Object {
|
Ok(fetch::object::Object::Object {
|
||||||
id: obj.id.0.clone().into(),
|
id: obj.id.0.clone().into(),
|
||||||
summary: warning,
|
summary: warning,
|
||||||
content,
|
content,
|
||||||
|
@ -199,134 +199,3 @@ pub mod config {
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod auth {
|
|
||||||
use fetch::signatures::{Private, Public, Signature};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use crate::{config::Config, Context};
|
|
||||||
|
|
||||||
/// Checks request signatures.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Verifier {
|
|
||||||
actor_id: String,
|
|
||||||
key_id: String,
|
|
||||||
private: Private,
|
|
||||||
public: Public,
|
|
||||||
}
|
|
||||||
|
|
||||||
const VERIFIER_PATH: &str = "/s/request-verifier";
|
|
||||||
/// The path at which the request verification actor will present itself.
|
|
||||||
pub const VERIFIER_MOUNT: &[&str] = &["s", "request-verifier"];
|
|
||||||
|
|
||||||
impl Verifier {
|
|
||||||
/// Send a request to get the public key from an ID. This request will be signed with the
|
|
||||||
/// verifier actor's public key.
|
|
||||||
async fn fetch_public_key(&self, uri: &str) -> Result<fetch::Key<Public>, String> {
|
|
||||||
let json = fetch::resolve(&self.signing_key(), uri).await.unwrap();
|
|
||||||
Ok(fetch::Key::from_json(dbg!(json)).unwrap().upgrade())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the key that the verification actor signs requests with.
|
|
||||||
fn signing_key(&self) -> fetch::SigningKey {
|
|
||||||
fetch::Key {
|
|
||||||
id: self.key_id.clone(),
|
|
||||||
owner: self.actor_id.clone(),
|
|
||||||
inner: self.private.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the JSON-LD representation of the verifier actor.
|
|
||||||
pub fn to_json_ld(&self) -> Value {
|
|
||||||
json!({
|
|
||||||
"@context": [
|
|
||||||
"https://www.w3.org/ns/activitystreams",
|
|
||||||
"https://w3id.org/security/v1",
|
|
||||||
],
|
|
||||||
"id": self.actor_id,
|
|
||||||
"name": "Public key fetcher",
|
|
||||||
"publicKey": {
|
|
||||||
"id": self.key_id,
|
|
||||||
"owner": self.actor_id,
|
|
||||||
"publicKeyPem": self.public.encode_pem()
|
|
||||||
},
|
|
||||||
"type": "Service",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the actor's verifier actor.
|
|
||||||
pub fn load(cfg: &Config) -> Verifier {
|
|
||||||
println!("[*] loading private key");
|
|
||||||
let Config { ap_domain, state_dir, .. } = cfg;
|
|
||||||
let private = Private::load(format!("{state_dir}/fetcher.pem"));
|
|
||||||
println!("* done loading private key");
|
|
||||||
Verifier {
|
|
||||||
actor_id: format!("https://{ap_domain}{VERIFIER_PATH}"),
|
|
||||||
key_id: format!("https://{ap_domain}{VERIFIER_PATH}#sig-key"),
|
|
||||||
public: private.get_public(),
|
|
||||||
private,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An ActivityPub actor that signed a request.
|
|
||||||
pub struct Signer {
|
|
||||||
/// The ActivityPub ID (a URL) of the signer of the request.
|
|
||||||
pub ap_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum SigError {
|
|
||||||
FailedToFetchKey { keyid: String },
|
|
||||||
ParseSignature { error: String },
|
|
||||||
VerificationFailed { error: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: make it so we don't have to know what an "http request" is in this crate.
|
|
||||||
/// Check the signature for a request.
|
|
||||||
pub async fn verify_signature(
|
|
||||||
cx: &Context,
|
|
||||||
req: &fetch::http::Request<impl AsRef<[u8]> + std::fmt::Debug>,
|
|
||||||
) -> Result<Option<Signer>, SigError> {
|
|
||||||
println!(">>> starting signature verification for {req:#?}");
|
|
||||||
|
|
||||||
if req.uri().path() == VERIFIER_PATH {
|
|
||||||
// HACK: Allow access to the request verifier actor without checking the signature.
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
println!(">>> not going for the verifier!");
|
|
||||||
|
|
||||||
if req.headers().get("signature").is_none() {
|
|
||||||
// Request is not signed.
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
println!(">>> has signature");
|
|
||||||
|
|
||||||
// Parse the signature.
|
|
||||||
let sig = match Signature::derive(&req) {
|
|
||||||
Ok(signature) => signature,
|
|
||||||
Err(error) => {
|
|
||||||
println!(">>> signature could not be parsed: {error}");
|
|
||||||
return Err(SigError::ParseSignature { error });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
println!(">>> signature is syntatically valid");
|
|
||||||
|
|
||||||
// Fetch the public key using the verifier private key.
|
|
||||||
let verifier = cx.verifier();
|
|
||||||
let Ok(public_key) = verifier.fetch_public_key(sig.key_id()).await else {
|
|
||||||
return Err(SigError::FailedToFetchKey {
|
|
||||||
keyid: sig.key_id().to_string(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
println!(">>> public key fetched");
|
|
||||||
|
|
||||||
// Verify the signature header on the request.
|
|
||||||
if let Err(error) = public_key.verify(&sig) {
|
|
||||||
println!(">>> rejected");
|
|
||||||
Err(SigError::VerificationFailed { error })
|
|
||||||
} else {
|
|
||||||
println!(">>> request verified");
|
|
||||||
Ok(Some(Signer { ap_id: public_key.owner }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in a new issue