From fc4e4595c25911f2ca40126cd0c43691e5ba6bde Mon Sep 17 00:00:00 2001 From: Riley Apeldoorn Date: Mon, 29 Apr 2024 23:36:57 +0200 Subject: [PATCH] [wip] YET MORE signatures cleanup and fixing --- Cargo.lock | 12 ++ lib/fetch/Cargo.toml | 2 + lib/fetch/src/lib.rs | 29 ++-- lib/fetch/src/signatures.rs | 333 ++++++++++++++---------------------- lib/puppy/src/lib.rs | 34 ++-- 5 files changed, 180 insertions(+), 230 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2bcf002..db91675 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -485,11 +485,13 @@ dependencies = [ "derive_more", "http", "http-body-util", + "pem", "rand", "reqwest", "rsa", "serde_json", "sigh", + "spki", ] [[package]] @@ -1128,6 +1130,16 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "pem" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +dependencies = [ + "base64 0.22.0", + "serde", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" diff --git a/lib/fetch/Cargo.toml b/lib/fetch/Cargo.toml index ffbddf3..945308c 100644 --- a/lib/fetch/Cargo.toml +++ b/lib/fetch/Cargo.toml @@ -14,5 +14,7 @@ http = "*" chrono = "*" base64 = "*" rsa = { version = "*", features = ["sha2"] } +spki = "*" http-body-util = "*" rand = "*" +pem = "*" diff --git a/lib/fetch/src/lib.rs b/lib/fetch/src/lib.rs index bbe5a74..9b5c375 100644 --- a/lib/fetch/src/lib.rs +++ b/lib/fetch/src/lib.rs @@ -1,4 +1,4 @@ -#![feature(iter_intersperse, yeet_expr, iterator_try_collect)] +#![feature(iter_intersperse, yeet_expr, iterator_try_collect, try_blocks)] use chrono::Utc; use http_body_util::BodyExt as _; use reqwest::Body; @@ -74,44 +74,51 @@ 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 { // 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); - // Sun, 06 Nov 1994 08:49:37 GMT - const RFC_822: &str = "%a, %d %b %Y %H:%M:%S GMT"; - + let uri = target.parse::().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(); - // hs2019 works with masto, but (currently) our implementation is limited so that we cannot use those. - key.sign(Options::LEGACY, &req) - .expect("signing error") - .commit(&mut req); + println!("[resolver]: constructed request {req:#?}"); + + // hs2019 works with masto + 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> { - // 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::().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(); diff --git a/lib/fetch/src/signatures.rs b/lib/fetch/src/signatures.rs index c67af58..2dfcf57 100644 --- a/lib/fetch/src/signatures.rs +++ b/lib/fetch/src/signatures.rs @@ -23,12 +23,11 @@ //! - //! - -use std::{borrow::Cow, collections::HashMap, path::Path}; +use std::path::Path; use chrono::{DateTime, TimeDelta, Utc}; -use http::{HeaderValue, Method, Request}; +use http::{HeaderValue, Request}; use rsa::{ - pkcs1v15::VerifyingKey, pkcs8::{ DecodePrivateKey, DecodePublicKey, EncodePrivateKey as _, EncodePublicKey as _, LineEnding, }, @@ -37,12 +36,9 @@ use rsa::{ RsaPrivateKey, }; -use base64::prelude::*; use serde_json::{Map, Value}; -use crate::signatures::new::decode; - -use self::new::{format_target, IR}; +use self::new::{decode, encode, sha256, IR}; /// A key that can be used to verify a request signature. pub type VerificationKey = Key; @@ -74,15 +70,15 @@ impl Key { 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) + json.get("publicKey")?.as_object().and_then(Key::from_map) }) } /// Construct fn from_map(map: &Map) -> Option { Some(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)?, + id: map.get("id")?.as_str().map(str::to_owned)?, + owner: map.get("owner")?.as_str().map(str::to_owned)?, + inner: map.get("publicKeyPem")?.as_str().map(str::to_owned)?, }) } /// "Upgrade" a pem-encoded public key to a key that can actually be used for requests. @@ -103,12 +99,22 @@ pub struct Private(rsa::RsaPrivateKey); impl Private { /// Generate a new keypair. pub fn gen() -> (Private, Public) { + println!("[!] gen"); let mut rng = rand::thread_rng(); - let bits = 4096; + let bits = 512; let private = RsaPrivateKey::new(&mut rng, bits).unwrap(); let public = private.to_public_key(); (Private(private), Public(public)) } + pub fn tee(path: impl AsRef) -> (Private, Public) { + println!("[!] tee"); + let (a, b) = Private::gen(); + println!("[!] keygen complete"); + a.0.write_pkcs8_pem_file(path, LineEnding::default()) + .unwrap(); + println!("[!] write finished"); + (a, b) + } /// Get the public counterpart to this key. pub fn get_public(&self) -> Public { Public(self.0.to_public_key()) @@ -149,19 +155,17 @@ impl Public { } /// 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) + let doc = pem::parse(pkcs8_pem).unwrap(); + ::from_public_key_der(doc.contents()) .map(Public) .unwrap() } } impl SigningKey { - /// Create a signature for `req` using the given algorithm, for `GET` requests. - pub fn sign(&self, opt: Options, req: &Request<()>) -> Result { - let target = format_target(&req, opt); - IR::partial(&req, &target, opt)? - .signed(self)? - .to_signature() + /// Create a signature for `req` using the given options. + pub fn sign(&self, opt: Options, req: &Request) -> Result { + IR::partial(&req, opt, |ir| ir.signed(self)?.to_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). @@ -175,7 +179,12 @@ impl SigningKey { where T: AsRef<[u8]>, { - todo!() + // Calculate and insert digest if it isn't there yet, otherwise do nothing. + let digest = encode(sha256(req.body())); + req.headers_mut() + .entry("digest") + .or_insert_with(|| digest.try_into().unwrap()); + self.sign(opt, req) } } @@ -227,6 +236,7 @@ pub const HS2019: Algorithm = Algorithm("hs2019"); pub const RSA_SHA256: Algorithm = Algorithm("rsa-sha256"); /// A signature derived from an [`http::Request`]. +#[derive(Debug)] pub struct Signature { key_id: String, components: Vec<(String, String)>, @@ -240,9 +250,7 @@ pub struct Signature { impl Signature { /// Attempt to extract a signature from a request. pub fn derive(req: &Request) -> Result { - let target = format_target(&req, Options::MODERN); - let ir = IR::<&str>::from_request(req, target.as_ref())?; - ir.to_signature() + new::with_ir(req, Options::MODERN, |ir| ir.to_signature()) } /// Obtain the key id for the signature. pub fn key_id(&self) -> &str { @@ -363,14 +371,11 @@ impl Default for Options { mod new { use base64::prelude::*; - use chrono::{TimeDelta, Utc}; - use http::{HeaderName, HeaderValue, Method, Request}; + use chrono::{DateTime, Utc}; + use http::{Method, Request}; use rsa::sha2::{Digest, Sha256}; - use super::{ - sign_rsa_sha256, Algorithm, Options, Signature, SigningKey, EXPIRY_WINDOW, HS2019, - RSA_SHA256, - }; + use super::{sign_rsa_sha256, Options, Signature, SigningKey, EXPIRY_WINDOW, HS2019, RSA_SHA256}; /// Calculate the SHA256 hash of something. pub fn sha256(buf: impl AsRef<[u8]>) -> Vec { @@ -387,143 +392,6 @@ mod new { BASE64_STANDARD.decode(buf).map_err(|e| e.to_string()) } - /// Generate signature headers for a request with a body. - fn headers_with_digest( - req: &Request, - key: &SigningKey, - opt: Options, - ) -> Result, String> - where - T: AsRef<[u8]>, - { - let digest = { - let encoded = encode(sha256(req.body())); - format!("sha256={encoded}") - }; - gen_headers(req, key, opt, vec![("digest", digest)], true) - } - - fn gen_headers( - req: &Request, - key: &SigningKey, - opt: Options, - extra: Vec<(&str, String)>, - use_digest: bool, - ) -> Result, String> { - // We need to add a date header is one doesn't already exist. - const RFC_822: &str = "%a, %d %b %Y %H:%M:%S GMT"; - let date = Utc::now().format(RFC_822).to_string(); - let date = (!(opt.use_created || req.headers().contains_key("date"))) - .then_some(("date", date.as_str())); - - // QUIRK: some older mastodon versions don't sign the query params. - let path = if opt.strip_query { - req.uri().path() - } else { - req.uri() - .path_and_query() - .map(|s| s.as_str()) - .unwrap_or(req.uri().path()) - }; - - // Preprocess the headers into the format expected by the signing functions. - // NOTE: only the first value of each key is ever used. - let header_map: Vec<(&str, &str)> = make_header_map(&req) - .into_iter() - // The "uncommitted" headers need to be added as well - .chain(extra.iter().map(|(k, v)| (*k, v.as_str()))) - // And at last, our date header too, if we're adding one. - .chain(date) - .collect(); - - let signature_header = make_signature_header( - key, - opt.algorithm, - // We only use the expiry deadline if we're using the `(created)` and `(expires)` inputs - // for signature generation. - opt.use_created.then_some(opt.expires_after), - use_digest, - &header_map, - req.method(), - path, - )?; - - extra - .into_iter() - // Don't forget the signature :) - .chain([("signature", signature_header)]) - // Turn all of the strings into header names/values. - .map(|(name, data)| -> Result<_, http::Error> { - let name = name.try_into()?; - let data = data.try_into()?; - Ok((name, data)) - }) - .try_collect() - .map_err(|e| format!("{e:?}")) - } - - fn make_signature_header( - key: &SigningKey, - alg: Algorithm, - expires_after: Option, - use_digest: bool, - headers: &[(&str, &str)], - method: &Method, - path: &str, - ) -> Result { - // List of input names that we want. Pseudo-headers are ordered before normal headers. - let needed = ["(request-target)"] - .into_iter() - .chain(if expires_after.is_some() { - vec!["(created)", "(expired)"] - } else { - vec!["date"] - }) - .chain(use_digest.then_some("digest")) - .chain(["host"]); - - let created = Utc::now(); - let expires = created + expires_after.unwrap_or(EXPIRY_WINDOW); - - let target_str = format!("{} {path}", method.as_str().to_ascii_lowercase()); - let created_str = created.timestamp().to_string(); - let expires_str = expires.timestamp().to_string(); - - // Lookup table for pseudo-headers. - let pseudo = [ - ("(request-target)", target_str.as_str()), - ("(created)", created_str.as_str()), - ("(expired)", expires_str.as_str()), - ]; - - let inputs = collect_inputs(needed, &headers, &pseudo)?; - let signing_str = make_signing_string(&inputs); - - let signature = sign_rsa_sha256(&signing_str, &key.inner)?; - let encoded = BASE64_STANDARD.encode(signature); - - Ok(format_header(&inputs, &key.id, alg.0, &encoded)) - } - - fn collect_inputs<'a>( - needed: impl IntoIterator + 'a, - headers: &[(&'a str, &'a str)], - pseudo: &[(&'a str, &'a str)], - ) -> Result, String> { - // Quick utility function to get stuff from an association list. - fn get<'x>(map: &[(&str, &'x str)], key: &str) -> Option<&'x str> { - map.iter().find_map(|(k, v)| (*k == key).then_some(*v)) - } - - let assoc = |k| { - get(&headers, k) - .or_else(|| get(&pseudo, k)) - .ok_or_else(|| format!("Missing (pseudo)header `{k}`")) - .map(|v| (k, v)) - }; - needed.into_iter().map(assoc).try_collect() - } - pub struct IR<'s, S = &'s str> { target: &'s str, inputs: Vec<(&'s str, &'s str)>, @@ -532,6 +400,21 @@ mod new { sig: S, } + /// Allocates a new [`IR`] for doing signature operations with. + pub fn with_ir( + req: &Request, + opt: Options, + f: impl FnOnce(IR<'_>) -> Result, + ) -> Result { + let target = &format_target(&req, opt); + let map = make_header_map(&req); + let Some(header) = get(&map, "signature") else { + do yeet "Missing required `signature` header"; + }; + let (inputs, key, alg, sig) = parse_header(header, &target, &map)?; + f(IR { target, inputs, key, alg, sig }) + } + impl IR<'_, S> { /// Create an HTTP header from the IR. pub fn to_header(&self) -> String @@ -540,22 +423,31 @@ mod new { { format_header(&self.inputs, self.key.as_ref(), self.alg, self.sig.as_ref()) } - /// Given a *correct* target string, extract a signature header from a request. - pub fn from_request<'r, T>(req: &'r Request, target: &'r str) -> Result, String> { - let map = make_header_map(&req); - let Some(header) = get(&map, "signature") else { - do yeet "Missing required `signature` header"; - }; - let (inputs, key, alg, sig) = parse_header(header, &target, &map)?; - Ok(IR { target, inputs, key, alg, sig }) - } /// Validate and upgrade the IR to a structured signature. pub fn to_signature(&self) -> Result where S: ToString, { - let created = Utc::now(); // TODO - let expires = Utc::now(); // TODO + let times: Result<_, String> = try { + let date = get(&self.inputs, "date").map(from_rfc822).transpose()?; + let created = get(&self.inputs, "(created)").map(from_secs).transpose()?; + let expires = get(&self.inputs, "(expires)").map(from_secs).transpose()?; + (date, created, expires) + }; + let (date, created, expires) = + times.map_err(|e: String| format!("Failed to parse time: {e}"))?; + + let (created, expires) = match (created, expires) { + (Some(created), None) => (created, created + EXPIRY_WINDOW), + (Some(created), Some(expires)) => (created, expires), + (None, _) => { + let Some(date) = date else { + do yeet "Cannot determine validity window"; + }; + (date, date + EXPIRY_WINDOW) + } + }; + let algorithm = match self.alg { "rsa-sha256" => RSA_SHA256, "hs2019" => HS2019, @@ -599,15 +491,36 @@ mod new { impl<'s> IR<'s, ()> { /// Create a partial, unsigned IR. - pub fn partial<'r, T>( + pub fn partial<'r, T, U>( req: &'r Request, - target: &'r str, opt: Options, - ) -> Result, String> { + f: impl FnOnce(IR<'_, ()>) -> Result, + ) -> Result { let map = make_header_map(req); let digest = req.method() == Method::POST; - let inputs = compute_inputs(&map, target, opt, digest)?; - Ok(IR { + + let expires_after = opt.use_created.then_some(opt.expires_after); + let created = Utc::now(); + let expires = created + expires_after.unwrap_or(EXPIRY_WINDOW); + + let target = &format_target(&req, opt); + let created = created.timestamp().to_string(); + let expires = expires.timestamp().to_string(); + + // Association list mapping pseudo headers names to concrete values. + #[rustfmt::skip] + let pseudo = &[ + ("(request-target)", target.as_str()), + ("(created)", created.as_str()), + ("(expired)", expires.as_str()), + ]; + + let inputs = match compute_inputs(&map, pseudo, opt, digest) { + Err(error) => do yeet format!("computing inputs: {error}"), + Ok(inputs) => inputs, + }; + + f(IR { target, inputs, alg: opt.algorithm.0, @@ -618,7 +531,10 @@ mod new { /// Sign a partially constructed IR to make it actually useful. pub fn signed(self, key: &'s SigningKey) -> Result, String> { let sig_str = self.to_signing_str(); - let signature = sign_rsa_sha256(&sig_str, &key.inner).map(encode)?; + let signature = match sign_rsa_sha256(&sig_str, &key.inner).map(encode) { + Err(error) => do yeet format!("RSA error: {error}"), + Ok(signature) => signature, + }; Ok(IR { target: self.target, inputs: self.inputs, @@ -633,15 +549,14 @@ mod new { /// turned into the signing string. fn compute_inputs<'a>( headers: &[(&'a str, &'a str)], - target: &'a str, + pseudo: &[(&'a str, &'a str)], opt: Options, use_digest: bool, ) -> Result, String> { - let expires_after = opt.use_created.then_some(opt.expires_after); // List of input names that we want. Pseudo-headers are ordered before normal headers. let needed = ["(request-target)"] .into_iter() - .chain(if expires_after.is_some() { + .chain(if opt.use_created { vec!["(created)", "(expired)"] } else { vec!["date"] @@ -649,25 +564,18 @@ mod new { .chain(use_digest.then_some("digest")) .chain(["host"]); - let created = Utc::now(); - let expires = created + expires_after.unwrap_or(EXPIRY_WINDOW); + let assoc = |k| { + get(headers, k) + .or_else(|| get(&pseudo, k)) + .ok_or_else(|| format!("Missing (pseudo)header `{k}`")) + .map(|v| (k, v)) + }; - let created_str = created.timestamp().to_string(); - let expires_str = expires.timestamp().to_string(); - - // Lookup table for pseudo-headers. - // TODO: currently, `(created)` and `(expires)` can't be used because they require string alloc. - let pseudo = [ - ("(request-target)", target), - // ("(created)", created_str.as_str()), FIXME - // ("(expired)", expires_str.as_str()), FIXME - ]; - - collect_inputs(needed, &headers, &pseudo) + needed.map(assoc).try_collect() } /// Allocate a `(request-target)` buffer. - pub fn format_target(req: &Request, opt: Options) -> String { + fn format_target(req: &Request, opt: Options) -> String { let path = if opt.strip_query { req.uri().path() } else { @@ -726,13 +634,18 @@ mod new { // Parse the top-level table. let table: Vec<(&str, &str)> = header // Split into entries - .split(", ") + .split(",") // Split entries into key-value pairs - .filter_map(|pair| pair.split_once('=')) + .filter_map(|pair| { + pair.trim_end_matches(' ') // QUIRK: akkoma does not put a space between entries + .split_once('=') + }) // Undo quoting of the values .filter_map(|(k, v)| v.strip_prefix('"')?.strip_suffix('"').map(|v| (k, v))) .collect(); + let table = dbg!(table); + let Some(headers) = get(&table, "headers") else { do yeet "Missing `headers` field"; }; @@ -795,4 +708,18 @@ mod new { .intersperse("\n".to_string()) .collect() } + + fn from_secs(s: &str) -> Result, String> { + s.parse::().map_err(|e| e.to_string()).and_then(|t| { + let Some(time) = DateTime::from_timestamp(t, 0) else { + do yeet "Timestamp out of range"; + }; + Ok(time) + }) + } + fn from_rfc822(s: &str) -> Result, String> { + DateTime::parse_from_rfc2822(s) + .map_err(|e| e.to_string()) + .map(|time| time.to_utc()) + } } diff --git a/lib/puppy/src/lib.rs b/lib/puppy/src/lib.rs index d941b2a..5d7f15b 100644 --- a/lib/puppy/src/lib.rs +++ b/lib/puppy/src/lib.rs @@ -215,20 +215,7 @@ pub mod auth { 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 { - 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(), - }) + Ok(fetch::Key::from_json(dbg!(json)).unwrap()) } pub fn signing_key(&self) -> fetch::SigningKey { @@ -257,8 +244,10 @@ pub mod auth { } pub fn load(cfg: &Config) -> Verifier { + println!("[*] loading private key"); let domain = &cfg.ap_domain; let private = Private::load(".state/fetcher.pem"); + println!("* done loading private key"); Verifier { actor_id: format!("https://{domain}/s/request-verifier"), key_id: format!("https://{domain}/s/request-verifier#sig-key"), @@ -282,23 +271,32 @@ pub mod auth { /// Check the signature for a request. pub async fn verify_signature( - req: &fetch::http::Request>, + req: &fetch::http::Request + std::fmt::Debug>, cfg: &Config, ) -> Result, SigError> { + println!(">>> starting signature verification for {req:#?}"); + if req.uri().path() == "/s/request-verifier" { // 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) => return Err(SigError::ParseSignature { error }), + 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 = Verifier::load(cfg); @@ -307,12 +305,16 @@ pub mod auth { keyid: sig.key_id().to_string(), }); }; + println!(">>> public key fetched"); // Verify the signature header on the request. let public_key = public_key.upgrade(); + println!(">>> upgraded"); 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.into() })) } }