[wip] signatures refactor
This commit is contained in:
parent
c784966d20
commit
9845603846
2 changed files with 331 additions and 13 deletions
|
@ -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;
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue