diff --git a/lib/fetch/src/lib.rs b/lib/fetch/src/lib.rs index ebccfc8..a23d19e 100644 --- a/lib/fetch/src/lib.rs +++ b/lib/fetch/src/lib.rs @@ -1,4 +1,4 @@ -#![feature(iter_intersperse, yeet_expr)] +#![feature(iter_intersperse, yeet_expr, iterator_try_collect)] use chrono::Utc; use http_body_util::BodyExt as _; use reqwest::Body; diff --git a/lib/fetch/src/signatures.rs b/lib/fetch/src/signatures.rs index d36ee86..835b265 100644 --- a/lib/fetch/src/signatures.rs +++ b/lib/fetch/src/signatures.rs @@ -92,18 +92,6 @@ impl Key { } } -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 { - id: self.id, - owner: self.owner, - inner: public_key_pem, - } - } -} - /// A key that can be used to generate signatures. #[derive(Clone)] pub struct Private(rsa::RsaPrivateKey); @@ -203,6 +191,15 @@ impl VerificationKey { .verify(sig.signing_string.as_bytes(), &signature) .map_err(|e| e.to_string()) } + /// 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 { + id: self.id, + owner: self.owner, + inner: public_key_pem, + } + } } /// The algorithm to sign with. @@ -447,3 +444,324 @@ fn parse<'s, T>(req: &'s Request) -> Result, String> { signing_string, }) } + +mod new { + use base64::prelude::*; + use chrono::{TimeDelta, Utc}; + use http::{HeaderName, HeaderValue, Method, Request}; + use rsa::sha2::{Digest, Sha256}; + + use super::{sign_rsa_sha256, Algorithm, SigningKey, HS2019, RSA_SHA256}; + + /// Default for when + const EXPIRY_WINDOW: TimeDelta = TimeDelta::minutes(5); + + /// Configuration for the behavior of the signing and verification routines. + /// + /// This struct is non-exhaustive. + #[derive(Clone, Copy)] + pub struct Options { + /// Whether to use the `(created)` and `(expires)`. If `false`, the `date` header is used instead. + /// + /// Defaults to `true`. + pub use_created: bool, + /// Quirk for older mastodon versions, which don't incorporate the query string into the signing + /// string during verification. + /// + /// Defaults to `false`. + pub strip_query: bool, + /// For how long the signature is valid. + /// + /// For signing, this only has an effect if `use_created` is also set. For verification, this is only + /// used if the `(expires)` pseudo-header is not present. + /// + /// Defaults to 5 minutes. + pub expires_after: TimeDelta, + /// Which signature algorithm to use. + /// + /// Defaults to [`"hs2019"`][super::HS2019]. + pub algorithm: Algorithm, + } + + impl Options { + /// Use hs2019 with the `(created)` pseudo-header. + pub const MODERN: Options = Options { + use_created: true, + strip_query: false, + expires_after: EXPIRY_WINDOW, + algorithm: HS2019, + }; + /// Use rsa-sha256 with the `date` header. + pub const LEGACY: Options = Options { + use_created: false, + algorithm: RSA_SHA256, + ..Options::MODERN + }; + } + + impl Default for Options { + fn default() -> Self { + Options { + use_created: true, + strip_query: false, + expires_after: EXPIRY_WINDOW, + algorithm: HS2019, + } + } + } + + /// Get the headers that need to be added to a request in order for the signature to be complete. + pub fn headers( + req: &Request<()>, + key: &SigningKey, + opt: Options, + ) -> Result, String> { + gen_headers(req, key, opt, Vec::new(), false) + } + + /// Generate signature headers for a request with a body. + pub fn headers_with_digest( + req: &Request, + key: &SigningKey, + opt: Options, + ) -> Result, String> + where + T: AsRef<[u8]>, + { + let digest = { + let hash = ::digest(req.body().as_ref()); + let encoded = BASE64_STANDARD.encode(hash); + 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)> = req + .headers() + .iter() + // Our headers need to be turned into an association list. + .filter_map(|(k, v)| v.to_str().ok().map(|v| (k.as_str(), v))) + // 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 [(&'a str, &'a str)], + pseudo: &'a [(&'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() + } + + fn format_header(inputs: &[(&str, &str)], key: &str, alg: &str, sig: &str) -> 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)) + } + + // Format all the headers in the order that we used them in the signing string. + let headers: String = inputs + .iter() + .map(|(k, _)| k.as_ref()) + .intersperse(" ") + .collect(); + + // Get the time-based parameters, if they exist. + let created = get(inputs, "(created)").map(|v| ("created", v)); + let expires = get(inputs, "(expires)").map(|v| ("expires", v)); + + // These parameters are always produced. + #[rustfmt::skip] + let table = [ + ("keyId", key), + ("algorithm", alg), + ("signature", sig), + ("headers", &headers), + ]; + + // Now we need to format the whole shebang. + table + .into_iter() + // `(created)` is part of a newer draft that not everyone implements. + .chain(created) + // `(expires)` is optional per the spec + .chain(expires) + // Step 1: all the values need to be surrounded by quotes + .map(|(k, v)| (k, format!(r#""{v}""#))) + // Step 2. join each pair together + .map(|(k, v)| format!("{k}={v}")) + // Step 3. comma separate everything + .intersperse(", ".to_string()) + // Step 4. fold the entire thing into one + .collect::() + } + + /// Partial inverse of `format_header_` (sort of, we don't have enough information to construct the inputs map from + /// within the function). + fn parse_header(header: &str) -> Result<(Vec<(&str, Option<&str>)>, &str, &str, &str), 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)) + } + + // Parse the top-level table. + let table: Vec<(&str, &str)> = header + // Split into entries + .split(", ") + // Split entries into key-value pairs + .filter_map(|pair| pair.split_once('=')) + // Undo quoting of the values + .filter_map(|(k, v)| v.strip_prefix('"')?.strip_suffix('"').map(|v| (k, v))) + .collect(); + + let Some(headers) = get(&table, "headers") else { + do yeet "Missing `headers` field"; + }; + let Some(key) = get(&table, "keyId") else { + do yeet "Missing `keyId` field"; + }; + let Some(algorithm) = get(&table, "algorithm") else { + do yeet "Missing `algorithm` field"; + }; + let Some(signature) = get(&table, "signature") else { + do yeet "Missing `signature` field" + }; + + // Construct a partial multi-map of all the inputs that are used to generate the signing string. + let inputs: Vec<(&str, Option<&str>)> = headers + // Headers and pseudo-headers are separated by spaces in the order in which they appear. + .split(' ') + // Map created and expires pseudo-headers to the ones specified in the inputs table. + .map(|k| match k { + // If these exist, the table must have them, but other than that they're optional. + "(created)" => get(&table, "created") + .ok_or("`(created)` pseudo-header is listed, but does not exist") + .map(|v| ("created", Some(v))), + "(expires)" => get(&table, "expires") + .ok_or("`(expires)` pseudo-header is listed, but does not exist") + .map(|v| ("expires", Some(v))), + // For anything else, we don't have the required information, and we'll need access + // to the entire request in order to fill in the blanks. + k => Ok((k, None)), + }) + .try_collect()?; + + Ok((inputs, key, algorithm, signature)) + } + + fn make_signing_string(data: &[(&str, &str)]) -> String { + data.iter() + // Each pair is separated by a colon and a space + .map(|(k, v)| format!("{k}: {v}")) + // Pairs must be separated by a newline + .intersperse("\n".to_string()) + .collect() + } +}