From 37acb67aa56fd79600499b62468c2956fdf870b3 Mon Sep 17 00:00:00 2001 From: Riley Apeldoorn Date: Sun, 28 Apr 2024 23:40:37 +0200 Subject: [PATCH] Major cleanup * Rename `fetch::keys` to `fetch::signatures` * Clean up the public api of `fetch::signatures` * Switch from axum to hyper * Add request signature validation (buggy, wip) --- Cargo.lock | 104 +-------- bin/server/Cargo.toml | 5 +- bin/server/src/api.rs | 140 ++++++++++++ bin/server/src/main.rs | 306 +++++++++++++++++++------ lib/fetch/Cargo.toml | 18 +- lib/fetch/src/lib.rs | 469 ++++++++++++++++++++++++++++++--------- lib/puppy/Cargo.toml | 1 + lib/puppy/src/context.rs | 7 +- lib/puppy/src/lib.rs | 137 +++++++++++- lib/puppy/src/post.rs | 16 +- 10 files changed, 905 insertions(+), 298 deletions(-) create mode 100644 bin/server/src/api.rs diff --git a/Cargo.lock b/Cargo.lock index 560c675..2bcf002 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,78 +89,12 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "async-trait" -version = "0.1.80" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.60", -] - [[package]] name = "autocfg" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" -[[package]] -name = "axum" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" -dependencies = [ - "async-trait", - "axum-core", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper 1.0.1", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper 0.1.2", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "backtrace" version = "0.3.71" @@ -550,6 +484,8 @@ dependencies = [ "chrono", "derive_more", "http", + "http-body-util", + "rand", "reqwest", "rsa", "serde_json", @@ -987,12 +923,6 @@ dependencies = [ "syn 2.0.60", ] -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "memchr" version = "2.7.2" @@ -1305,6 +1235,7 @@ dependencies = [ "derive_more", "either", "fetch", + "serde_json", "store", ] @@ -1415,7 +1346,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 0.1.2", + "sync_wrapper", "system-configuration", "tokio", "tokio-native-tls", @@ -1507,12 +1438,6 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecd36cc4259e3e4514335c4a138c6b43171a8d61d8f5c9348f9fc7529416f247" -[[package]] -name = "rustversion" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47" - [[package]] name = "ryu" version = "1.0.17" @@ -1594,16 +1519,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" -dependencies = [ - "itoa", - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1620,7 +1535,10 @@ dependencies = [ name = "server" version = "0.0.0" dependencies = [ - "axum", + "http", + "http-body-util", + "hyper", + "hyper-util", "puppy", "serde_json", "tokio", @@ -1770,12 +1688,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" -[[package]] -name = "sync_wrapper" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" - [[package]] name = "system-configuration" version = "0.5.1" diff --git a/bin/server/Cargo.toml b/bin/server/Cargo.toml index c15089f..04d167f 100644 --- a/bin/server/Cargo.toml +++ b/bin/server/Cargo.toml @@ -4,6 +4,9 @@ edition = "2021" [dependencies] puppy = { path = "../../lib/puppy" } +hyper = { version = "*", features = ["full"] } tokio = { version = "*", features = ["full"] } -axum = "*" +http-body-util = "*" +hyper-util = { version = "*", features = ["full"] } serde_json = "*" +http = "*" diff --git a/bin/server/src/api.rs b/bin/server/src/api.rs new file mode 100644 index 0000000..b01366e --- /dev/null +++ b/bin/server/src/api.rs @@ -0,0 +1,140 @@ +//! API endpoints and request handlers. + +pub mod ap { + //! ActivityPub handlers. + + use http_body_util::Full; + use hyper::body::Bytes; + use puppy::{ + actor::Actor, + auth::{Signer, Verifier}, + config::Config, + data::{Id, PrivateKey, PublicKey}, + fetch::{signatures::Private, SigningKey}, + get_local_ap_object, Key, + }; + use serde_json::{to_string, Value}; + use crate::{respond, Response}; + + /// Proxy a request through the instance. + pub async fn proxy(params: &[(&str, &str)]) -> Response { + // Extract our query parameters. + let Some(user) = params.iter().find_map(|(k, v)| (*k == "user").then_some(v)) else { + return respond(400, Some("Expected `user` query param"), []); + }; + let Some(url) = params.iter().find_map(|(k, v)| (*k == "url").then_some(v)) else { + return respond(400, Some("Expected `url` query param"), []); + }; + + // Look up the actor's key in the store (which is accessible through the puppy context). + let (signing_key) = puppy::context::<_, puppy::Error>(|cx| try { + let actor = Actor::by_username(&cx, user)?.unwrap(); + let (PrivateKey { key_pem, .. }, PublicKey { key_id, .. }) = + cx.store().get_mixin_many(actor.key)?; + let Id(owner) = cx.store().get_alias(actor.key)?.unwrap(); + let inner = Private::decode_pem(&key_pem); + SigningKey { key_id, owner, inner } + }) + .unwrap(); + + eprintln!("proxy: params: {params:?}"); + // Proxy the request through our fetcher. + let resp = puppy::fetch::forward(&signing_key, url).await.unwrap(); + eprintln!("proxy: status = {}", resp.status()); + + // Convert the http-types request to a hyper request. + resp.map(Bytes::from).map(Full::new).into() + } + + /// Handle POSTs to actor inboxes. Requires request signature. + pub fn inbox(actor_id: &str, sig: Signer, body: Value, cfg: Config) -> Response { + todo!() + } + + /// Serve an ActivityPub object as json-ld. + pub fn serve_object(object_ulid: &str) -> Response { + let Ok(parsed) = object_ulid.parse::() else { + return respond(400, Some("improperly formatted id"), []); + }; + let result = puppy::context(|cx| get_local_ap_object(&cx, parsed)); + let Ok(object) = result else { + return respond(404, >::None, []); + }; + let json = to_string(&object.to_json_ld()).unwrap(); + 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(cfg: Config) -> Response { + let body = Verifier::load(&cfg).to_json_ld(); + let encoded = serde_json::to_vec(&body).unwrap(); + respond(200, Some(encoded), [AP_CONTENT_TYPE]) + } +} + +pub mod wf { + //! WebFinger endpoints and related stuff. + + use puppy::{config::Config, data::Username, Error}; + use serde_json::json; + + use crate::{respond, Response}; + + const WF_CONTENT_TYPE: (&str, &str) = ("content-type", "application/jrd+json"); + + pub fn resolve(query: &[(&str, &str)], cfg: Config) -> Response { + match query.iter().find_map(get_handle) { + // Serve JRDs for local actors. + Some(handle) if cfg.wf_domain == handle.instance => { + let id = puppy::context::<_, Error>(|cx| try { + let user = cx + .store() + .lookup(Username(handle.username.to_string()))? + .unwrap(); + let id = cx.store().get_alias::(user)?.unwrap().0; + id + }) + .unwrap(); + let jrd = json!({ + "subject": format!("acct:{}@{}", handle.username, handle.instance), + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": id + }, + ] + }); + 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, + } + + /// Parse the `resource` parameter into a [`Handle`]. + pub fn get_handle<'x>((k, v): &'x (&str, &str)) -> Option> { + // 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 + } + } +} diff --git a/bin/server/src/main.rs b/bin/server/src/main.rs index e98dbf0..02cf2e5 100644 --- a/bin/server/src/main.rs +++ b/bin/server/src/main.rs @@ -1,79 +1,237 @@ -#![feature(try_blocks)] -use std::collections::HashMap; +#![feature(try_blocks, yeet_expr)] -use axum::{ - extract::{Path, Query}, - response::{AppendHeaders, IntoResponse as _}, - routing::get, - Json, Router, -}; -use puppy::{ - actor::Actor, - context, - data::{PrivateKey, PublicKey}, - get_local_ap_object, Key, -}; -use serde_json::json; +use std::convert::Infallible; +use std::net::SocketAddr; + +use http::request::Parts; +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::{SigError, Signer}; +use puppy::{auth::verify_signature, config::Config}; +use puppy::fetch::signatures::Signature; +use serde_json::{from_slice, json, Value}; +use tokio::net::TcpListener; #[tokio::main] async fn main() { - let app = Router::new() - .route( - "/o/:ulid", - get( - |Path(raw_object_id): Path, req: axum::extract::Request| async move { - eprintln!("req: {req:?}"); - context::<_, puppy::Error>(|cx| try { - let object_id = raw_object_id.parse::().unwrap(); - let obj = get_local_ap_object(&cx, object_id).unwrap().to_json_ld(); - ( - AppendHeaders([("content-type", "application/activity+json")]), - Json(obj).into_response(), - ) - }) - .unwrap() - }, - ), - ) - .route( - "/proxy", - get(|Query(q): Query>| async move { - let (key_pem, key_id) = context::<_, puppy::Error>(|cx| try { - let actor = Actor::by_username(&cx, "riley")?.unwrap(); - let (private, public) = cx - .store() - .get_mixin_many::<(PrivateKey, PublicKey)>(actor.key)?; - (private.key_pem, public.key_id) - }) - .unwrap(); - puppy::fetch::resolve(&key_pem, &key_id, &q["target"]) - .await - .unwrap() - }), - ) - .route( - "/.well-known/webfinger", - get( - |Query(q): Query>, req: axum::extract::Request| async move { - eprintln!("req: {req:?}"); - let Some(rest) = q["resource"].strip_prefix("acct:") else { - panic!("{q:?}"); - }; - if rest == "riley@test.piss-on.me" { - Json(json!({ - "subject": "acct:riley@test.piss-on.me", - "links": [{ - "rel": "self", - "type": "application/activity+json", - "href": "https://test.piss-on.me/o/01HWG4BQJR23TWF12KVPBBP1HG", - }], - })) - } else { - panic!("{rest:?}") - } - }, - ), - ); - let sock = tokio::net::TcpListener::bind("0.0.0.0:1312").await.unwrap(); - axum::serve(sock, app).await.unwrap(); + let config = Config { + ap_domain: "test.piss-on.me".to_string(), + wf_domain: "test.piss-on.me".to_string(), + port: 1312, + }; + start(&config).await.unwrap(); +} + +pub async fn start(cfg: &Config) -> Result<(), Box> { + let addr = SocketAddr::from(([127, 0, 0, 1], cfg.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 cfg = cfg.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, cfg.clone()))) + .await + { + eprintln!("Error serving connection: {:?}", err); + } + }); + } +} + +type Request = hyper::Request; +type Response> = hyper::Response; + +/// The request handler. +async fn handle(req: Request, cfg: Config) -> Result { + // 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 = make_req(&request); + eprintln!("{request:?}: open"); + // 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(&request, &cfg).await { + // If the request was signed and the signature was accepted, they can access the protected endpoints. + Ok(Some(sig)) => dispatch_signed(req, sig, cfg).await, + // Unsigned requests can see a smaller subset of endpoints, most notably the verification actor. + Ok(None) => dispatch_public(req, cfg).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}"), + }), + }; + eprintln!( + "{} {}: done (status: {})", + request.method(), + request.uri(), + res.status() + ); + Ok(res) +} + +// 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 make_req<'x>(r: &'x http::Request) -> 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; + +/// 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(req: Req<'_>, sig: Signer, cfg: Config) -> 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(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(ulid, sig, json, cfg) + }), + // Try the resources for which no signature is required as well. + _ => dispatch_public(req, cfg).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(req: Req<'_>, cfg: Config) -> Response { + eprintln!("Dispatching public request"); + match (req.method, req.path()) { + (GET, ["proxy"]) => api::ap::proxy(&req.params).await, + (GET, [".well-known", "webfinger"]) => api::wf::resolve(&req.params, cfg), + (GET, ["s", "request-verifier"]) => api::ap::serve_verifier_actor(cfg), + (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( + status: u16, + body: Option>, + 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() + } } diff --git a/lib/fetch/Cargo.toml b/lib/fetch/Cargo.toml index 3732688..ffbddf3 100644 --- a/lib/fetch/Cargo.toml +++ b/lib/fetch/Cargo.toml @@ -6,11 +6,13 @@ edition = "2021" path = "src/lib.rs" [dependencies] -reqwest = "*" -sigh = "*" -serde_json = "*" -derive_more = "*" -http = "*" -chrono = "*" -base64 = "*" -rsa = { version = "*", features = ["sha2"] } +reqwest = { version = "*", features = ["json"] } +sigh = "*" +serde_json = "*" +derive_more = "*" +http = "*" +chrono = "*" +base64 = "*" +rsa = { version = "*", features = ["sha2"] } +http-body-util = "*" +rand = "*" diff --git a/lib/fetch/src/lib.rs b/lib/fetch/src/lib.rs index 8851ab9..b58662c 100644 --- a/lib/fetch/src/lib.rs +++ b/lib/fetch/src/lib.rs @@ -1,14 +1,14 @@ -#![feature(iter_intersperse)] +#![feature(iter_intersperse, yeet_expr)] use chrono::Utc; use derive_more::{Display, From, Into}; +use http_body_util::BodyExt as _; +use signatures::{Private, Public}; use reqwest::Body; use serde_json::{json, Value}; -use sigh::{ - alg::{Hs2019, RsaSha256}, - Key as _, SigningConfig, -}; -use crate::keys::{HS2019, RSA_SHA256}; +use crate::signatures::HS2019; + +pub use http; pub enum Object { Activity(Activity), @@ -69,44 +69,66 @@ where } /// Deliver an [`Activity`] to a particular `inbox`. -pub async fn deliver(private_key_pem: &str, key_id: &str, activity: Activity, inbox: &str) -> () { - let body = serde_json::to_string_pretty(&activity.to_json_ld()).unwrap(); - let mut req = http::Request::post(inbox) - .header("content-type", "application/activity+json") - .header("user-agent", "ActivityPuppy/0.0.0 (delivery)") - .header("date", Utc::now().to_rfc3339()) - .body(body) - .unwrap(); - let key = sigh::PrivateKey::from_pem(private_key_pem.as_bytes()).unwrap(); - SigningConfig::new(RsaSha256, &key, key_id) - .sign(&mut req) - .unwrap(); - reqwest::Client::new() - .execute(req.try_into().unwrap()) - .await - .unwrap(); +pub async fn deliver(key: &SigningKey, activity: Activity, inbox: &str) -> () { + todo!() } -pub async fn resolve(private_key_pem: &str, key_id: &str, target: &str) -> reqwest::Result { - const EMPTY_DIGEST: &str = "sha-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; +pub async fn resolve(key: &SigningKey, target: &str) -> reqwest::Result { + // TODO: make this retry with different signature options and remember what works for the + // particular host. + + // 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 mut req = http::Request::builder() + .uri(target) + .header("accept", "application/activity+json") + .header("user-agent", "ActivityPuppy/0.0.0 (resolver)") + .header("date", date) + // Empty body + .body(()) + .unwrap(); + + // hs2019 works with masto + key.sign(HS2019, &req) + .expect("signing error") + .attach_to(&mut req); + + reqwest::Client::new() + .execute(req.map(|_| Body::default()).try_into().unwrap()) + .await? + .json() + .await +} + +pub async fn forward(key: &SigningKey, target: &str) -> reqwest::Result> { // 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 mut req = http::Request::get(target) - .header("accept", "application/json") + .header("accept", "application/activity+json") .header("user-agent", "ActivityPuppy/0.0.0 (resolver)") .header("date", date) // Empty body - .body(Body::default()) + .body(()) .unwrap(); // hs2019 works with masto - keys::sign(&mut req, HS2019, private_key_pem, key_id).unwrap(); - reqwest::Client::new() - .execute(req.try_into().unwrap()) - .await? - .text() - .await + key.sign(HS2019, &req) + .expect("signing error") + .attach_to(&mut req); + + let resp = reqwest::Client::new() + .execute(req.map(|_| Body::default()).try_into().unwrap()) + .await?; + + let http_resp: http::Response = 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. @@ -121,7 +143,7 @@ pub struct Actor { pub display_name: Option, /// Public counterpart to the signing key used to sign activities /// generated by the actor. - pub public_key: PublicKey, + pub public_key: Key, } impl Actor { @@ -138,97 +160,280 @@ impl Actor { "name": self.display_name, "type": "Person", "publicKey": { - "id": self.public_key.key_id.to_string(), - "publicKeyPem": self.public_key.public_key_pem, + "id": self.public_key.key_id, + "publicKeyPem": self.public_key.inner, "owner": self.id.to_string(), } }) } } -pub struct PublicKey { - pub key_id: Id, - pub owner: Id, - pub public_key_pem: String, +/// A key that can be used to verify a request signature. +pub type VerificationKey = Key; + +/// A key that can be used to sign a request. +pub type SigningKey = Key; + +/// A key used for authorized fetch. +/// +/// It comes in several flavors: +/// +/// - `Key` (`K` = [`String`]): PEM-encoded, can be turned into a JSON object. +/// - [`VerificationKey`] (`K` = [`Public`]): used as an input in the request signature validation process. +/// - [`SigningKey`] (`K` = [`Private`]): used as an input in the generation of a signed request. +pub struct Key { + /// The `"id"` property of the public key. + pub key_id: String, + /// The `"owner"` property. + pub owner: String, + /// Maps to the `"publicKeyPem"` property of an actor's `"publicKey"` when (de)serializing, and when the + /// key is used for doing [signatures]. + pub inner: K, +} + +impl Key { + /// Tries to find the PEM-encoded public key from the result of fetching a key id. + pub fn from_json(json: Value) -> Option { + // First, we try the object itself. + json.as_object().and_then(Key::from_map).or_else(|| { + // Because of how mastodon deals with pubkey resolution, most implementations will serve the whole actor + // object instead of just the key, so we try that first, because it is the de facto standard. + json["publicKey"].as_object().and_then(Key::from_map) + }) + } + /// Construct + fn from_map(map: &serde_json::Map) -> Option { + Some(Key { + key_id: map["id"].as_str().map(str::to_owned)?, + owner: map["owner"].as_str().map(str::to_owned)?, + inner: map["publicKeyPem"].as_str().map(str::to_owned)?, + }) + } + /// "Upgrade" a pem-encoded public key to a key that can actually be used for requests. + pub fn upgrade(self) -> Key { + let inner = Public::decode_pem(&self.inner); + Key { + key_id: self.key_id, + owner: self.owner, + inner, + } + } +} + +impl Key { + /// Encode a verification key so that it can be presented in a json. + pub fn serialize(self) -> Key { + let public_key_pem = self.inner.encode_pem(); + Key { + key_id: self.key_id, + owner: self.owner, + inner: public_key_pem, + } + } } #[derive(Display, From, Into, Debug, Clone)] pub struct Id(String); -pub mod keys { - //! Cryptography and such. +pub mod signatures { + //! Containment zone for the funny math that doesn't make much sense to puppy. + //! + //! This module provides ActivityPuppy's HTTP signatures implementation. The state of HTTP signatures implementations + //! is, to put it mildly, *een fucking kutzooi*. For historical reasons, no one implements it *exactly* right (much + //! like URI parsers). This implementation aims to be as broadly compatible as possible. + //! + //! The only non-deprecated [`Algorithm`] is [`"hs2019"`][HS2019], but not everyone implements it, because the initial + //! round of implementations of the spec were based on a draft, and [`"rsa-sha256"`][RSA_SHA256] is kinda the de facto + //! standard. + //! + //! # Behavior + //! + //! By default, puppy will sign with `algorithm="hs2019"` (using `(created)` and `(expires)` pseudo-headers), and retry + //! in legacy mode (using `algorithm="rsa-sha256"` with `date` header) if the signature gets rejected. + //! + //! Currently, `"hs2019"` is treated as equivalent to `"rsa-sha256"` for verification purposes. Support for elliptic + //! curve keys is planned, but not a priority. + //! + //! # Links + //! + //! More information about http signatures: + //! + //! - + //! - - use chrono::{TimeDelta, Utc}; + use std::{borrow::Cow, collections::HashMap, path::Path}; + + use chrono::{DateTime, TimeDelta, Utc}; use http::{HeaderValue, Method, Request}; - use reqwest::Body; - use rsa::{pkcs1v15::SigningKey, pkcs8::DecodePrivateKey as _, sha2}; - use sigh::{ - Key as _, - alg::{Algorithm as _, RsaSha256}, + use rsa::{ + signature::{Verifier as _, Signer as _, SignatureEncoding as _}, + pkcs1v15::{SigningKey, VerifyingKey}, + pkcs8::{ + DecodePrivateKey, DecodePublicKey, EncodePublicKey as _, EncodePrivateKey as _, + LineEnding, + }, + sha2::{self, Sha256}, + RsaPrivateKey, }; - pub struct RawPrivateKey { - inner: sigh::PrivateKey, - } + use base64::prelude::*; - impl RawPrivateKey { - pub fn to_pem(&self) -> String { - self.inner.to_pem().unwrap() + /// A key that can be used to generate signatures. + #[derive(Clone)] + pub struct Private(rsa::RsaPrivateKey); + + impl Private { + /// Generate a new keypair. + pub fn gen() -> (Private, Public) { + let mut rng = rand::thread_rng(); + let bits = 4096; + let private = RsaPrivateKey::new(&mut rng, bits).unwrap(); + let public = private.to_public_key(); + (Private(private), Public(public)) + } + /// Get the public counterpart to this key. + pub fn get_public(&self) -> Public { + Public(self.0.to_public_key()) + } + /// Load a private key from a file on disk. + pub fn load(path: impl AsRef) -> Private { + use rsa::pkcs8::DecodePrivateKey; + DecodePrivateKey::read_pkcs8_pem_file(path) + .map(Private) + .unwrap() + } + /// PEM-encode the key PKCS#8 style. + pub fn encode_pem(&self) -> String { + self.0 + .to_pkcs8_pem(LineEnding::default()) + .unwrap() + .to_string() + } + /// Decode the key from a PKCS#8 PEM-encoded string. + pub fn decode_pem(pkcs8_pem: &str) -> Private { + DecodePrivateKey::from_pkcs8_pem(&pkcs8_pem) + .map(Private) + .unwrap() } } - pub struct RawPublicKey { - inner: sigh::PublicKey, - } + /// A key that can be used to verify signatures. + #[derive(Clone)] + pub struct Public(rsa::RsaPublicKey); - impl RawPublicKey { - pub fn to_pem(&self) -> String { - self.inner.to_pem().unwrap() + impl Public { + /// PEM-encode the public key in accordance with PKCS#8. + pub fn encode_pem(&self) -> String { + self.0 + .to_public_key_pem(LineEnding::default()) + .unwrap() + .to_string() + } + /// Decode a PKCS#8 PEM-encoded public key from a string. + pub fn decode_pem(pkcs8_pem: &str) -> Public { + DecodePublicKey::from_public_key_pem(&pkcs8_pem) + .map(Public) + .unwrap() } } - /// Generate a private and public keypair. - pub fn gen_keypair() -> (RawPrivateKey, RawPublicKey) { - let (private, public) = RsaSha256.generate_keys().unwrap(); - (RawPrivateKey { inner: private }, RawPublicKey { - inner: public, - }) + impl crate::SigningKey { + /// Create a signature for `req` using the given algorithm, for `GET` requests. + pub fn sign(&self, alg: Algorithm, req: &Request<()>) -> Result, String> { + let pieces = gather_pieces(&req)?; + let signing_string = make_signing_string(&pieces); + let signature = create(signing_string, alg, &self.inner, &self.key_id, pieces)?; + Ok(signature) + } + /// Create a signature for `req` using the given algorithm, and calculate and attach the `digest` header to + /// the request (if it doesn't already have one). + /// + /// This is required by most implementations when POSTing to an inbox. + pub fn sign_with_digest( + &self, + alg: Algorithm, + req: &mut Request, + ) -> Result, String> + where + T: AsRef<[u8]>, + { + todo!() + } + } + + impl crate::VerificationKey { + /// Verify the request's signature. + pub fn verify(&self, sig: Signature<'_>) -> Result<(), String> { + use rsa::pkcs1v15::Signature; + // TODO: support elliptic curve keys + // TODO: nicer error reporting + let Public(key) = self.inner.clone(); + let verifying_key = VerifyingKey::::new(key); + let decoded = BASE64_STANDARD + .decode(&sig.signature_encoded) + .map_err(|e| e.to_string())?; + let signature = Signature::try_from(decoded.as_slice()).map_err(|e| e.to_string())?; + verifying_key + .verify(sig.signing_string.as_bytes(), &signature) + .map_err(|e| e.to_string()) + } } /// The algorithm to sign with. - #[derive(PartialEq, Debug)] + /// + /// Your two options are: + /// + /// - [`hs2019`][HS2019], the *correct* option + /// - [`"rsa-sha256"`][RSA_SHA256], the most compatible option + #[derive(PartialEq, Debug, Clone, Copy)] pub struct Algorithm(&'static str); - pub const RSA_SHA256: Algorithm = Algorithm("rsa-sha256"); + /// `hs2019`, the only non-deprecated HTTP signatures algorithm. pub const HS2019: Algorithm = Algorithm("hs2019"); + /// The HTTP signatures algorithm everyone uses, `rsa-sha256`. + pub const RSA_SHA256: Algorithm = Algorithm("rsa-sha256"); - struct Signature<'k> { + /// A signature derived from an [`http::Request`]. + pub struct Signature<'k> { key_id: &'k str, alg: Algorithm, components: Vec, created: String, expires: String, - /// Base64-encoded signature. + signing_string: Cow<'k, str>, signature_encoded: String, } - /// Sign `req`. - pub fn sign( - req: &mut Request, - alg: Algorithm, - private_key: &str, - key_id: &str, - ) -> Result<(), String> { - // NOTE: Rough translation of https://nest.pijul.org/ez/ActivityPuppy:main/XXPS2UOWSWD2Y.ZJAAA - let pieces = gather_pieces(&req)?; - let signing_string = make_signing_string(&pieces); - let signature = create(&signing_string, alg, private_key, key_id, pieces)?; - req.headers_mut().insert("signature", render(signature)); - Ok(()) + impl Signature<'_> { + /// Obtain the key id for the signature. + pub fn key_id(&self) -> &str { + &self.key_id + } + /// Get the time the signature was created. This information is extracted from the `(created)` + /// pseudo-header if it is defined, and the `date` http header otherwise. + pub fn created(&self) -> DateTime { + todo!() + } + /// If specified, get the expiry time. + pub fn expires(&self) -> Option> { + todo!() + } + /// Retrieve the algorithm used for the signature. + pub fn algorithm(&self) -> Algorithm { + self.alg + } + /// Attempt to extract a signature from a request. + pub fn derive(req: &Request) -> Result, String> { + parse(req) + } + /// Attach `self` to `req` as the `signature` header. + pub fn attach_to(self, req: &mut Request) { + req.headers_mut().insert("signature", render(self)); + } } /// Gather all the bits from a `Request`. - fn gather_pieces(req: &Request) -> Result, &'static str> { + fn gather_pieces(req: &Request) -> Result, &'static str> { let target = { let method = req.method().as_str().to_lowercase(); let path = req.uri().path(); @@ -245,7 +450,7 @@ pub mod keys { ("host", req.uri().host().unwrap().to_owned()), ]; - if [Method::POST, Method::PUT, Method::PATCH].contains(req.method()) { + if let Method::POST | Method::PUT | Method::PATCH = *req.method() { let digest = req .headers() .get("digest") @@ -269,28 +474,29 @@ pub mod keys { /// Sign the `signing_string`. fn create<'s>( - signing_string: &'s str, + signing_string: String, alg: Algorithm, - key_pem: &str, + key: &Private, key_url: &'s str, components: Vec, ) -> Result, String> { let created = components .iter() - .find_map(|(k, v)| if *k == "(created)" { Some(v) } else { None }) + .find_map(|(k, v)| (*k == "(created)").then_some(v)) .cloned() .unwrap(); let expires = components .iter() - .find_map(|(k, v)| if *k == "(expires)" { Some(v) } else { None }) + .find_map(|(k, v)| (*k == "(expires)").then_some(v)) .cloned() .unwrap(); - // We regardless of the algorithm, we produce RSA-SHA256 signatures, because this is broadly compatible + // Regardless of the algorithm, we produce RSA-SHA256 signatures, because this is broadly compatible // with everything. - let signature = sign_rsa_sha256(signing_string, key_pem)?; - let encoded = base64(signature); + let signature = sign_rsa_sha256(&signing_string, key)?; + let encoded = BASE64_STANDARD.encode(signature); Ok(Signature { signature_encoded: encoded, + signing_string: signing_string.into(), key_id: key_url, components, created, @@ -300,23 +506,13 @@ pub mod keys { } /// `rsa-sha256` is created using an rsa key and a sha256 hash. - fn sign_rsa_sha256(signing_string: &str, key_pem: &str) -> Result, String> { - use rsa::{ - signature::{Signer as _, SignatureEncoding as _}, - RsaPrivateKey, - }; - let rsa = RsaPrivateKey::from_pkcs8_pem(key_pem).map_err(|e| e.to_string())?; + fn sign_rsa_sha256(signing_string: &str, key: &Private) -> Result, String> { + let Private(rsa) = key.clone(); let key = SigningKey::::new(rsa); let buf = key.sign(signing_string.as_bytes()).to_vec(); Ok(buf) } - fn base64(buf: Vec) -> String { - use base64::Engine as _; - // STANDARD works on masto, url safe does not. - base64::prelude::BASE64_STANDARD.encode(buf) - } - /// Format the signature. fn render(sig: Signature<'_>) -> HeaderValue { let headers = sig @@ -353,4 +549,71 @@ pub mod keys { .try_into() .expect("signature formatting should give a correct header value") } + + // TODO: clean this mess up + fn parse<'s, T>(req: &'s Request) -> Result, String> { + let Some(sig_header) = req.headers().get("signature").and_then(|v| v.to_str().ok()) else { + do yeet "No signature header"; + }; + + let stuff: HashMap<&str, &str> = sig_header + .split(", ") + .filter_map(|piece| piece.split_once('=')) + // TODO: technically more liberal than the spec + .map(|(k, v)| (k, v.trim_matches('"'))) + .collect(); + + let inputs: Vec<&str> = stuff["headers"].split(' ').collect(); + + let (created, expires) = if inputs.contains(&"(created)") && inputs.contains(&"(expires)") { + (stuff["created"].to_string(), stuff["expires"].to_string()) + } else { + // TODO: support "date" header instead of created/expires + do yeet "Only (created) + (expires) is currently supported"; + }; + + let alg = match stuff.get("algorithm") { + Some(&"hs2019") => HS2019, + Some(&"rsa-sha256") => RSA_SHA256, + Some(alg) => do yeet format!("unsupported alg: {alg}"), + None => do yeet "Missing `algorithm`", + }; + + let components = { + let target = { + let method = req.method().as_str().to_lowercase(); + let path = req.uri().path(); + format!("{method} {path}") + }; + + let Some(host) = req.headers().get("host").and_then(|v| v.to_str().ok()) else { + do yeet "host header is required" + }; + let mut components = vec![ + ("(request-target)", target), + ("(created)", created.clone()), + ("(expires)", expires.clone()), + ("host", req.uri().host().unwrap_or(host).to_owned()), + ]; + + if let Method::POST | Method::PUT | Method::PATCH = *req.method() { + let Some(digest) = req.headers().get("digest").and_then(|v| v.to_str().ok()) else { + do yeet "Digest header is required for POST/PATCH/PUT" + }; + components.push(("digest", digest.to_string())); + } + components + }; + let signing_string = make_signing_string(&components).into(); + + Ok(Signature { + key_id: stuff["keyId"], + signature_encoded: stuff["signature"].to_string(), + alg, + created, + expires, + components, + signing_string, + }) + } } diff --git a/lib/puppy/Cargo.toml b/lib/puppy/Cargo.toml index e3cbdd3..2fc1bfb 100644 --- a/lib/puppy/Cargo.toml +++ b/lib/puppy/Cargo.toml @@ -12,3 +12,4 @@ bincode = "2.0.0-rc.3" chrono = "*" either = "*" derive_more = "*" +serde_json = "*" diff --git a/lib/puppy/src/context.rs b/lib/puppy/src/context.rs index 0d67ed9..3fcff8d 100644 --- a/lib/puppy/src/context.rs +++ b/lib/puppy/src/context.rs @@ -2,7 +2,7 @@ use std::sync::OnceLock; use store::{Key, Store, Transaction}; -use crate::{config::Config, data::schema, Error, Result}; +use crate::{auth, config::Config, data::schema, Error, Result}; /// The context of a running ActivityPuppy. /// @@ -29,6 +29,10 @@ 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) -> &auth::Verifier { + todo!() + } } /// The store, which we initialize only once this way. @@ -48,6 +52,7 @@ where let cfg = Config { ap_domain: String::from("test.piss-on.me"), wf_domain: String::from("test.piss-on.me"), + port: 1312, }; let db = STORE .get_or_try_init(|| Store::open(".state", schema())) diff --git a/lib/puppy/src/lib.rs b/lib/puppy/src/lib.rs index 13a2f01..6657795 100644 --- a/lib/puppy/src/lib.rs +++ b/lib/puppy/src/lib.rs @@ -1,3 +1,6 @@ +//! If you're an ActivityPub developer looking for information about ActivityPuppy's federation behavior, +//! you should take a look at [`fetch`]. + // Working with result types is such a bitch without these. #![feature(iterator_try_collect, try_blocks, once_cell_try)] // Cause an error if someone tries to call [`context`] from within this crate. If we need one, @@ -49,10 +52,10 @@ pub fn get_local_ap_object(cx: &Context<'_>, key: Key) -> Result inbox: inbox.into(), account_name: account_name.0, display_name, - public_key: fetch::PublicKey { + public_key: fetch::Key { owner: obj.id.0.into(), key_id: key_id.into(), - public_key_pem: key_pem, + inner: key_pem, }, })) } @@ -84,6 +87,7 @@ pub fn get_local_ap_object(cx: &Context<'_>, key: Key) -> Result } pub mod actor { + use fetch::signatures::Private; use store::{Key, StoreError, Transaction}; use crate::{ @@ -161,14 +165,12 @@ pub mod actor { domain: &str, ) -> Result<(), StoreError> { let key_id = format!("https://{domain}/o/{vertex}#sig-key"); - let (private, public) = fetch::keys::gen_keypair(); + let (private, public) = Private::gen(); tx.add_mixin(vertex, PublicKey { - key_pem: public.to_pem(), + key_pem: public.encode_pem(), key_id, })?; - tx.add_mixin(vertex, PrivateKey { - key_pem: dbg!(private.to_pem()), - })?; + tx.add_mixin(vertex, PrivateKey { key_pem: private.encode_pem() })?; store::OK } } @@ -189,8 +191,129 @@ pub enum Error { } pub mod config { + #[derive(Clone)] pub struct Config { pub ap_domain: String, pub wf_domain: String, + pub port: u16, + } +} + +pub mod auth { + use fetch::signatures::{Private, Public, Signature}; + use serde_json::{json, Value}; + + use crate::config::Config; + + pub struct Verifier { + actor_id: String, + key_id: String, + private: Private, + public: Public, + } + + impl Verifier { + pub async fn get_public_key(&self, uri: &str) -> Result { + let json = fetch::resolve(&self.signing_key(), uri).await.unwrap(); + // TODO: make this parsing work better. + Ok(fetch::Key { + key_id: json["publicKey"]["id"].as_str().unwrap().to_string().into(), + owner: json["publicKey"]["owner"] + .as_str() + .unwrap() + .to_string() + .into(), + inner: json["publicKey"]["publicKeyPem"] + .as_str() + .unwrap() + .to_string() + .into(), + }) + } + + pub fn signing_key(&self) -> fetch::SigningKey { + fetch::Key { + key_id: self.key_id.clone(), + owner: self.actor_id.clone(), + inner: self.private.clone(), + } + } + + 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", + }) + } + + pub fn load(cfg: &Config) -> Verifier { + let domain = &cfg.ap_domain; + let private = Private::load(".state/fetcher.pem"); + Verifier { + actor_id: format!("https://{domain}/s/request-verifier"), + key_id: format!("https://{domain}/s/request-verifier#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 }, + } + + /// Check the signature for a request. + pub async fn verify_signature( + req: &fetch::http::Request>, + cfg: &Config, + ) -> Result, SigError> { + if req.uri().path() == "/s/request-verifier" { + // Allow access to the request verifier actor without checking the signature. + return Ok(None); + } + if req.headers().get("signature").is_none() { + // Request is not signed! + return Ok(None); + }; + + // Parse the signature. + let sig = match Signature::derive(&req) { + Ok(signature) => signature, + Err(error) => return Err(SigError::ParseSignature { error }), + }; + + // Fetch the public key using the verifier private key. + let verifier = Verifier::load(cfg); + let Ok(public_key) = verifier.get_public_key(sig.key_id()).await else { + return Err(SigError::FailedToFetchKey { + keyid: sig.key_id().to_string(), + }); + }; + + // Verify the signature header on the request. + let public_key = public_key.upgrade(); + if let Err(error) = public_key.verify(sig) { + Err(SigError::VerificationFailed { error }) + } else { + Ok(Some(Signer { ap_id: public_key.owner.into() })) + } } } diff --git a/lib/puppy/src/post.rs b/lib/puppy/src/post.rs index 95b2216..e44a820 100644 --- a/lib/puppy/src/post.rs +++ b/lib/puppy/src/post.rs @@ -4,6 +4,7 @@ use std::ops::RangeBounds; use chrono::{DateTime, Utc}; use either::Either::{Left, Right}; +use fetch::signatures::Private; use store::{util::IterExt as _, Key, Store, StoreError, Transaction}; use crate::{ @@ -33,9 +34,14 @@ impl Post { id: post.id().clone().into(), object: Box::new(post), kind: String::from("Create"), - actor: author_id.0.into(), + actor: author_id.0.clone().into(), }; let (private, public) = cx.db.get_mixin_many::<(PrivateKey, PublicKey)>(author)?; + let key = fetch::SigningKey { + key_id: public.key_id, + owner: author_id.0, + inner: Private::decode_pem(&private.key_pem), + }; // Insert the activity in the database. cx.run(|tx| try { let activity_key = Key::gen(); @@ -53,13 +59,7 @@ impl Post { tx.add_alias(activity_key, id)?; })?; // Send the requests. - fetch::deliver( - &private.key_pem, - &public.key_id, - activity, - "https://crimew.gay/users/ezri/inbox", - ) - .await; + fetch::deliver(&key, activity, "https://crimew.gay/users/ezri/inbox").await; Ok(()) } }