[wip] signatures refactor

This commit is contained in:
Riley Apeldoorn 2024-04-29 13:14:25 +02:00
parent c784966d20
commit 9845603846
2 changed files with 331 additions and 13 deletions

View file

@ -1,4 +1,4 @@
#![feature(iter_intersperse, yeet_expr)] #![feature(iter_intersperse, yeet_expr, iterator_try_collect)]
use chrono::Utc; use chrono::Utc;
use http_body_util::BodyExt as _; use http_body_util::BodyExt as _;
use reqwest::Body; use reqwest::Body;

View file

@ -92,18 +92,6 @@ impl Key {
} }
} }
impl Key<Public> {
/// 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. /// A key that can be used to generate signatures.
#[derive(Clone)] #[derive(Clone)]
pub struct Private(rsa::RsaPrivateKey); pub struct Private(rsa::RsaPrivateKey);
@ -203,6 +191,15 @@ impl VerificationKey {
.verify(sig.signing_string.as_bytes(), &signature) .verify(sig.signing_string.as_bytes(), &signature)
.map_err(|e| e.to_string()) .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. /// The algorithm to sign with.
@ -447,3 +444,324 @@ fn parse<'s, T>(req: &'s Request<T>) -> Result<Signature<'s>, String> {
signing_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<Vec<(HeaderName, HeaderValue)>, String> {
gen_headers(req, key, opt, Vec::new(), false)
}
/// Generate signature headers for a request with a body.
pub fn headers_with_digest<T>(
req: &Request<T>,
key: &SigningKey,
opt: Options,
) -> Result<Vec<(HeaderName, HeaderValue)>, String>
where
T: AsRef<[u8]>,
{
let digest = {
let hash = <Sha256 as Digest>::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<T>(
req: &Request<T>,
key: &SigningKey,
opt: Options,
extra: Vec<(&str, String)>,
use_digest: bool,
) -> Result<Vec<(HeaderName, HeaderValue)>, 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<TimeDelta>,
use_digest: bool,
headers: &[(&str, &str)],
method: &Method,
path: &str,
) -> Result<String, String> {
// 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<Item = &'a str> + 'a,
headers: &'a [(&'a str, &'a str)],
pseudo: &'a [(&'a str, &'a str)],
) -> Result<Vec<(&'a str, &'a 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))
}
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::<String>()
}
/// 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()
}
}