[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 http_body_util::BodyExt as _;
|
||||
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.
|
||||
#[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<T>) -> Result<Signature<'s>, 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