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)]
|
||||
|
||||
use puppy::{
|
||||
actor::Actor,
|
||||
auth::Verifier,
|
||||
config::Config,
|
||||
data::{schema, Bite, Profile},
|
||||
data::{Bite, Profile},
|
||||
post::Author,
|
||||
store::{util::IterExt as _, Store},
|
||||
store::util::IterExt as _,
|
||||
Context,
|
||||
};
|
||||
|
||||
|
@ -17,9 +18,8 @@ fn main() -> puppy::Result<()> {
|
|||
state_dir: ".state".to_string(),
|
||||
port: 1312,
|
||||
};
|
||||
let verifier = Verifier::load(&config);
|
||||
let db = Store::open(&config.state_dir, schema())?;
|
||||
let cx = Context::new(config, db.clone(), verifier);
|
||||
let cx = Context::load(config)?;
|
||||
let db = cx.store();
|
||||
println!("creating actors");
|
||||
let riley = get_or_create_actor(&cx, "riley")?;
|
||||
let linen = get_or_create_actor(&cx, "linen")?;
|
||||
|
@ -31,7 +31,6 @@ fn main() -> puppy::Result<()> {
|
|||
|
||||
if true {
|
||||
println!("making riley follow linen");
|
||||
|
||||
cx.run(|tx| {
|
||||
if !riley.follows(&tx, &linen)? {
|
||||
println!("follow relation does not exist yet");
|
||||
|
|
|
@ -1,5 +1,23 @@
|
|||
//! 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 {
|
||||
//! ActivityPub handlers.
|
||||
|
||||
|
@ -7,14 +25,17 @@ pub mod ap {
|
|||
use hyper::body::Bytes;
|
||||
use puppy::{
|
||||
actor::Actor,
|
||||
auth::{Signer, Verifier},
|
||||
config::Config,
|
||||
data::{Id, PrivateKey, PublicKey},
|
||||
fetch::{signatures::Private, SigningKey},
|
||||
fetch::signatures::{Private, SigningKey},
|
||||
get_local_ap_object, Context, Key,
|
||||
};
|
||||
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.
|
||||
pub async fn proxy(cx: &Context, params: &[(&str, &str)]) -> Response {
|
||||
|
@ -53,23 +74,26 @@ pub mod ap {
|
|||
}
|
||||
|
||||
/// 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 {
|
||||
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 Ok(object) = result else {
|
||||
return respond(404, <Option<String>>::None, []);
|
||||
return Err(error::NOT_FOUND);
|
||||
};
|
||||
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");
|
||||
|
||||
/// Serve the special actor used for signing requests.
|
||||
pub fn serve_verifier_actor(cx: &Context) -> Response {
|
||||
let body = cx.verifier().to_json_ld();
|
||||
pub fn serve_verifier_actor(verifier: &Verifier) -> Response {
|
||||
let body = verifier.to_json_ld();
|
||||
let encoded = serde_json::to_vec(&body).unwrap();
|
||||
respond(200, Some(encoded), [AP_CONTENT_TYPE])
|
||||
}
|
||||
|
@ -78,32 +102,68 @@ pub mod ap {
|
|||
pub mod wf {
|
||||
//! WebFinger endpoints and related stuff.
|
||||
|
||||
use puppy::{data::Username, Context, Error};
|
||||
use serde_json::json;
|
||||
use puppy::{
|
||||
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");
|
||||
|
||||
pub fn resolve(cx: &Context, query: &[(&str, &str)]) -> Response {
|
||||
match query.iter().find_map(get_handle) {
|
||||
// Serve JRDs for local actors.
|
||||
/// Respond to a webfinger request.
|
||||
pub fn resolve(cx: &Context, params: &[(&str, &str)]) -> Result<Response, Message> {
|
||||
match params.iter().find_map(get_handle) {
|
||||
Some(handle) if cx.config().wf_domain == handle.instance => {
|
||||
let id = {
|
||||
let user = cx
|
||||
.store()
|
||||
.lookup(Username(handle.username.to_string()))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let id = cx
|
||||
.store()
|
||||
.get_alias::<puppy::data::Id>(user)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.0;
|
||||
id
|
||||
let username = Username(handle.username.to_string());
|
||||
let Ok(Some(user)) = cx.store().lookup(username) else {
|
||||
do yeet NOT_FOUND;
|
||||
};
|
||||
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),
|
||||
"links": [
|
||||
{
|
||||
|
@ -112,35 +172,267 @@ pub mod wf {
|
|||
"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> {
|
||||
username: &'x str,
|
||||
instance: &'x str,
|
||||
}
|
||||
// A parsed HTTP request for easy handling.
|
||||
struct Req<'a> {
|
||||
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`].
|
||||
pub 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.
|
||||
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('@')
|
||||
// Convert to a structured format.
|
||||
.map(|(username, instance)| Handle { username, instance })
|
||||
} else {
|
||||
None
|
||||
impl Req<'_> {
|
||||
/// Get the path segments (non-empty parts of the path string separated by the '/' character).
|
||||
fn path(&self) -> &[&str] {
|
||||
&self.path
|
||||
}
|
||||
/// Turn an HTTP request into a more simple form so we can process it more easily.
|
||||
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();
|
||||
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)]
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use puppy::{config::Config, Context};
|
||||
|
||||
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 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;
|
||||
mod sig;
|
||||
mod api;
|
||||
|
||||
/// Starts up the whole shebang.
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// TODO: load the config from a file or something.
|
||||
let config = Config {
|
||||
ap_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,
|
||||
};
|
||||
let store = Store::open(&config.state_dir, puppy::data::schema()).unwrap();
|
||||
let verifier = Verifier::load(&config);
|
||||
let context = Context::new(config, store, verifier);
|
||||
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()
|
||||
}
|
||||
let context = Context::load(config).unwrap();
|
||||
// Start the web server
|
||||
api::start(context).await.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)]
|
||||
use chrono::Utc;
|
||||
use http_body_util::BodyExt as _;
|
||||
use reqwest::Body;
|
||||
use serde_json::{json, Value};
|
||||
use std::error::Error;
|
||||
|
||||
use crate::signatures::{Options, HS2019};
|
||||
use derive_more::Display;
|
||||
use serde_json::Value;
|
||||
|
||||
use object::Activity;
|
||||
use signatures::SigningKey;
|
||||
|
||||
pub use http;
|
||||
|
||||
pub use signatures::{Key, SigningKey, VerificationKey};
|
||||
pub mod signatures;
|
||||
pub mod object;
|
||||
|
||||
pub enum Object {
|
||||
Activity(Activity),
|
||||
Actor(Actor),
|
||||
Object {
|
||||
id: String,
|
||||
kind: String,
|
||||
content: Option<String>,
|
||||
summary: Option<String>,
|
||||
pub use client::Client;
|
||||
mod client;
|
||||
|
||||
/// Deliver an activity to an inbox.
|
||||
pub async fn deliver(key: &SigningKey, activity: Activity, inbox: &str) {
|
||||
Client::new().deliver(key, &activity, inbox).await
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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,
|
||||
}),
|
||||
impl FetchError {
|
||||
/// Check whether the error is due to a 403 UNAUTHORIZED response status code.
|
||||
pub fn is_unauthorized(&self) -> bool {
|
||||
matches!(self, FetchError::NotSuccess { status: 403, .. })
|
||||
}
|
||||
/// 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> {
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
}
|
||||
})
|
||||
#[doc(hidden)]
|
||||
impl From<reqwest::Error> for FetchError {
|
||||
fn from(error: reqwest::Error) -> FetchError {
|
||||
match error.source().and_then(|e| e.downcast_ref()) {
|
||||
Some(e @ serde_json::Error { .. }) => FetchError::BadJson(e.to_string()),
|
||||
None => {
|
||||
let url = error.url().map(|u| u.to_string());
|
||||
FetchError::Internal { url, error: error.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};
|
||||
|
||||
/// Size of the RSA private keys puppy generates.
|
||||
const KEY_SIZE: usize = 2048;
|
||||
|
||||
/// A key that can be used to verify a request signature.
|
||||
pub type VerificationKey = Key<Public>;
|
||||
|
||||
|
@ -73,7 +76,7 @@ impl Key {
|
|||
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> {
|
||||
Some(Key {
|
||||
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.
|
||||
///
|
||||
/// 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> {
|
||||
let inner = Public::decode_pem(&self.inner);
|
||||
Key {
|
||||
|
@ -100,8 +107,7 @@ impl Private {
|
|||
/// Generate a new keypair.
|
||||
pub fn gen() -> (Private, Public) {
|
||||
let mut rng = rand::thread_rng();
|
||||
let bits = 512;
|
||||
let private = RsaPrivateKey::new(&mut rng, bits).unwrap();
|
||||
let private = RsaPrivateKey::new(&mut rng, KEY_SIZE).unwrap();
|
||||
let public = private.to_public_key();
|
||||
(Private(private), Public(public))
|
||||
}
|
||||
|
@ -110,11 +116,19 @@ impl Private {
|
|||
Public(self.0.to_public_key())
|
||||
}
|
||||
/// 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;
|
||||
let path = path.as_ref();
|
||||
DecodePrivateKey::read_pkcs8_pem_file(path)
|
||||
.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.
|
||||
pub fn encode_pem(&self) -> String {
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
use store::{Key, Store, Transaction};
|
||||
|
||||
use crate::{auth::Verifier, config::Config, Result};
|
||||
use crate::{config::Config, Result};
|
||||
|
||||
/// The context of a running ActivityPuppy.
|
||||
///
|
||||
/// This type provides access to the data store and configuration.
|
||||
#[derive(Clone)]
|
||||
pub struct Context {
|
||||
verifier: Option<Verifier>,
|
||||
config: Config,
|
||||
store: Store,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn new(config: Config, store: Store, verifier: Verifier) -> Context {
|
||||
Context {
|
||||
verifier: Some(verifier),
|
||||
config,
|
||||
store,
|
||||
fn new(config: Config, store: Store) -> Context {
|
||||
Context { 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].
|
||||
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 {
|
||||
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.
|
||||
|
@ -49,7 +45,5 @@ pub fn test_context<T>(
|
|||
schema: store::types::Schema,
|
||||
test: impl FnOnce(Context) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
Store::test(schema, |store| {
|
||||
test(Context { config, store, verifier: None })
|
||||
})
|
||||
Store::test(schema, |store| test(Context { config, store }))
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ mod interact;
|
|||
/// Retrieve an ActivityPub object from the database.
|
||||
///
|
||||
/// 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 {
|
||||
// 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.
|
||||
|
@ -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 {
|
||||
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(),
|
||||
inbox: inbox.into(),
|
||||
account_name: account_name.0,
|
||||
display_name,
|
||||
public_key: fetch::Key {
|
||||
public_key: fetch::object::PublicKey {
|
||||
owner: obj.id.0.into(),
|
||||
id: key_id.into(),
|
||||
inner: key_pem,
|
||||
|
@ -65,7 +65,7 @@ pub fn get_local_ap_object(tx: &Transaction<'_>, key: Key) -> Result<fetch::Obje
|
|||
panic!("expected a `Create`");
|
||||
};
|
||||
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(),
|
||||
actor: actor.into(),
|
||||
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 {
|
||||
panic!()
|
||||
};
|
||||
Ok(fetch::Object::Object {
|
||||
Ok(fetch::object::Object::Object {
|
||||
id: obj.id.0.clone().into(),
|
||||
summary: warning,
|
||||
content,
|
||||
|
@ -199,134 +199,3 @@ pub mod config {
|
|||
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