[wip] http signatures refactor

This commit is contained in:
Riley Apeldoorn 2024-04-29 20:17:56 +02:00
parent 9845603846
commit 9eaad3d7bb
3 changed files with 337 additions and 306 deletions

View file

@ -4,7 +4,7 @@ use http_body_util::BodyExt as _;
use reqwest::Body;
use serde_json::{json, Value};
use crate::signatures::HS2019;
use crate::signatures::{Options, HS2019};
pub use http;
@ -91,10 +91,10 @@ pub async fn resolve(key: &SigningKey, target: &str) -> reqwest::Result<Value> {
.body(())
.unwrap();
// hs2019 works with masto
key.sign(HS2019, &req)
// hs2019 works with masto, but (currently) our implementation is limited so that we cannot use those.
key.sign(Options::LEGACY, &req)
.expect("signing error")
.attach_to(&mut req);
.commit(&mut req);
reqwest::Client::new()
.execute(req.map(|_| Body::default()).try_into().unwrap())
@ -116,9 +116,9 @@ pub async fn forward(key: &SigningKey, target: &str) -> reqwest::Result<http::Re
.body(())
.unwrap();
// hs2019 works with masto
key.sign(HS2019, &req)
key.sign(Options::LEGACY, &req)
.expect("signing error")
.attach_to(&mut req);
.commit(&mut req);
let resp = reqwest::Client::new()
.execute(req.map(|_| Body::default()).try_into().unwrap())

View file

@ -40,6 +40,10 @@ use rsa::{
use base64::prelude::*;
use serde_json::{Map, Value};
use crate::signatures::new::decode;
use self::new::{format_target, IR};
/// A key that can be used to verify a request signature.
pub type VerificationKey = Key<Public>;
@ -153,11 +157,11 @@ impl Public {
impl SigningKey {
/// Create a signature for `req` using the given algorithm, for `GET` requests.
pub fn sign(&self, alg: Algorithm, req: &Request<()>) -> Result<Signature<'_>, String> {
let pieces = gather_pieces(&req)?;
let signing_string = make_signing_string(&pieces);
let signature = create(signing_string, alg, &self.inner, &self.id, pieces)?;
Ok(signature)
pub fn sign(&self, opt: Options, req: &Request<()>) -> Result<Signature, String> {
let target = format_target(&req, opt);
IR::partial(&req, &target, opt)?
.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).
@ -165,9 +169,9 @@ impl SigningKey {
/// This is required by most implementations when POSTing to an inbox.
pub fn sign_with_digest<T>(
&self,
alg: Algorithm,
opt: Options,
req: &mut Request<T>,
) -> Result<Signature<'_>, String>
) -> Result<Signature, String>
where
T: AsRef<[u8]>,
{
@ -176,20 +180,26 @@ impl SigningKey {
}
impl VerificationKey {
/// Verify the request's signature.
pub fn verify(&self, sig: Signature<'_>) -> Result<(), String> {
use rsa::pkcs1v15::Signature;
// TODO: support elliptic curve keys
// TODO: nicer error reporting
let Public(key) = self.inner.clone();
let verifying_key = VerifyingKey::<Sha256>::new(key);
let decoded = BASE64_STANDARD
.decode(&sig.signature_encoded)
.map_err(|e| e.to_string())?;
let signature = Signature::try_from(decoded.as_slice()).map_err(|e| e.to_string())?;
verifying_key
.verify(sig.signing_string.as_bytes(), &signature)
.map_err(|e| e.to_string())
/// Test the signature against three requirements:
///
/// 1. The signature must not be expired.
/// 2. The signature's `keyId` must be the same as `self`'s.
/// 3. The `signed_str` must have been signed by the private counterpart of `self`.
pub fn verify(&self, sig: &Signature) -> Result<(), String> {
if sig.is_expired_at(Utc::now()) {
do yeet format!("Signature is expired: (deadline was {})", sig.expires());
}
if sig.key_id() != &self.id {
do yeet format! {
"Mismatched key id; signature's key id is '{}', while presented key has '{}'",
sig.key_id(),
self.id
};
}
if !sig.was_signed_by(&self.inner)? {
do yeet "Signature was not generated by the presented key's private counterpart";
}
Ok(())
}
/// Encode a verification key so that it can be presented in a json.
pub fn serialize(self) -> Key {
@ -217,20 +227,22 @@ pub const HS2019: Algorithm = Algorithm("hs2019");
pub const RSA_SHA256: Algorithm = Algorithm("rsa-sha256");
/// A signature derived from an [`http::Request`].
pub struct Signature<'k> {
key_id: &'k str,
alg: Algorithm,
components: Vec<Component>,
created: String,
expires: String,
signing_string: Cow<'k, str>,
signature_encoded: String,
pub struct Signature {
key_id: String,
components: Vec<(String, String)>,
time_range: (DateTime<Utc>, DateTime<Utc>),
target_str: String,
signed_str: String,
signature: String,
algorithm: Algorithm,
}
impl Signature<'_> {
impl Signature {
/// Attempt to extract a signature from a request.
pub fn derive<T>(req: &Request<T>) -> Result<Signature<'_>, String> {
parse(req)
pub fn derive<T>(req: &Request<T>) -> Result<Signature, String> {
let target = format_target(&req, Options::MODERN);
let ir = IR::<&str>::from_request(req, target.as_ref())?;
ir.to_signature()
}
/// Obtain the key id for the signature.
pub fn key_id(&self) -> &str {
@ -239,99 +251,50 @@ impl Signature<'_> {
/// Get the time the signature was created. This information is extracted from the `(created)`
/// pseudo-header if it is defined, and the `date` http header otherwise.
pub fn created(&self) -> DateTime<Utc> {
todo!()
self.time_range.0
}
/// If specified, get the expiry time.
pub fn expires(&self) -> Option<DateTime<Utc>> {
todo!()
/// If specified, get the `(expires)` header, otherwise get the creation time + the configured grace window.
pub fn expires(&self) -> DateTime<Utc> {
self.time_range.1
}
/// Retrieve the algorithm used for the signature.
pub fn algorithm(&self) -> Algorithm {
self.alg
self.algorithm
}
/// Attach `self` to `req` as the `signature` header.
pub fn attach_to<T>(self, req: &mut Request<T>) {
pub fn commit<T>(self, req: &mut Request<T>) {
req.headers_mut().insert("signature", self.make_header());
}
/// Determine whether `self` was signed by the private counterpart of `key`.
pub fn was_signed_by(&self, key: &Public) -> Result<bool, String> {
use rsa::pkcs1v15::{VerifyingKey, Signature};
let Public(inner) = key.clone();
let key = VerifyingKey::<Sha256>::new(inner);
let raw_buf = decode(&self.signature)?;
let Ok(sig) = Signature::try_from(raw_buf.as_slice()) else {
do yeet "Failed to construct signature from decoded signature";
};
key.verify(self.signed_str.as_bytes(), &sig)
.map_err(|s| format!("{s:?}"))?;
Ok(true)
}
/// Check whether the given `time` falls within the window for valid signatures.
pub fn is_expired_at(&self, time: DateTime<Utc>) -> bool {
!(self.created()..self.expires()).contains(&time)
}
/// Turn the signature into an HTTP header value.
fn make_header(self) -> HeaderValue {
render(self)
IR::<&str>::from_signature(&self)
.to_header()
.try_into()
.unwrap()
}
}
/// Gather all the bits from a `Request`.
fn gather_pieces<T>(req: &Request<T>) -> Result<Vec<Component>, &'static str> {
let target = {
let method = req.method().as_str().to_lowercase();
let path = req.uri().path();
format!("{method} {path}")
};
let created = Utc::now();
let expires = created + TimeDelta::minutes(5);
let mut components = vec![
("(request-target)", target),
("(created)", created.timestamp().to_string()),
("(expires)", expires.timestamp().to_string()),
("host", req.uri().host().unwrap().to_owned()),
];
if let Method::POST | Method::PUT | Method::PATCH = *req.method() {
let digest = req
.headers()
.get("digest")
.map(|v| v.to_str().unwrap().to_string())
.ok_or("digest header is required for POST, PUT and PATCH requests")?;
components.push(("digest", digest));
}
Ok(components)
}
fn make_signing_string(pieces: &[Component]) -> String {
pieces
.iter()
.map(|(k, v)| format!("{k}: {v}"))
.intersperse("\n".to_string())
.collect()
}
type Component = (&'static str, String);
/// Sign the `signing_string`.
fn create<'s>(
signing_string: String,
alg: Algorithm,
key: &Private,
key_url: &'s str,
components: Vec<Component>,
) -> Result<Signature<'s>, String> {
let created = components
.iter()
.find_map(|(k, v)| (*k == "(created)").then_some(v))
.cloned()
.unwrap();
let expires = components
.iter()
.find_map(|(k, v)| (*k == "(expires)").then_some(v))
.cloned()
.unwrap();
// Regardless of the algorithm, we produce RSA-SHA256 signatures, because this is broadly compatible
// with everything.
let signature = sign_rsa_sha256(&signing_string, key)?;
let encoded = BASE64_STANDARD.encode(signature);
Ok(Signature {
signature_encoded: encoded,
signing_string: signing_string.into(),
key_id: key_url,
components,
created,
expires,
alg,
})
}
/// `rsa-sha256` is created using an rsa key and a sha256 hash.
fn sign_rsa_sha256(signing_string: &str, key: &Private) -> Result<Vec<u8>, String> {
use rsa::pkcs1v15::SigningKey;
@ -341,118 +304,6 @@ fn sign_rsa_sha256(signing_string: &str, key: &Private) -> Result<Vec<u8>, Strin
Ok(buf)
}
/// Format the signature.
fn render(sig: Signature<'_>) -> HeaderValue {
let headers = sig
.components
.iter()
// We only need the header names.
.map(|(name, _)| name.as_ref())
// Names need to be space-separated.
.intersperse(" ")
// Equivalent to `fold ["a", "b"]` in haskell
.collect::<String>();
#[rustfmt::skip]
let data = [
("keyId", sig.key_id),
("created", &sig.created),
("expires", &sig.expires),
("algorithm", sig.alg.0),
("headers", &headers),
("signature", &sig.signature_encoded),
];
// Ok, now let's put it all together.
data.into_iter()
// 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>()
// Then, it needs to become a header value
.try_into()
.expect("signature formatting should give a correct header value")
}
// TODO: clean this mess up
fn parse<'s, T>(req: &'s Request<T>) -> Result<Signature<'s>, String> {
let Some(sig_header) = req.headers().get("signature").and_then(|v| v.to_str().ok()) else {
do yeet "No signature header";
};
let stuff: HashMap<&str, &str> = sig_header
.split(", ")
.filter_map(|piece| piece.split_once('='))
// TODO: technically more liberal than the spec
.map(|(k, v)| (k, v.trim_matches('"')))
.collect();
let inputs: Vec<&str> = stuff["headers"].split(' ').collect();
let (created, expires) = if inputs.contains(&"(created)") && inputs.contains(&"(expires)") {
(stuff["created"].to_string(), stuff["expires"].to_string())
} else {
// TODO: support "date" header instead of created/expires
do yeet "Only (created) + (expires) is currently supported";
};
let alg = match stuff.get("algorithm") {
Some(&"hs2019") => HS2019,
Some(&"rsa-sha256") => RSA_SHA256,
Some(alg) => do yeet format!("unsupported alg: {alg}"),
None => do yeet "Missing `algorithm`",
};
let components = {
let target = {
let method = req.method().as_str().to_lowercase();
let path = req.uri().path();
format!("{method} {path}")
};
let Some(host) = req.headers().get("host").and_then(|v| v.to_str().ok()) else {
do yeet "host header is required"
};
let mut components = vec![
("(request-target)", target),
("(created)", created.clone()),
("(expires)", expires.clone()),
("host", req.uri().host().unwrap_or(host).to_owned()),
];
if let Method::POST | Method::PUT | Method::PATCH = *req.method() {
let Some(digest) = req.headers().get("digest").and_then(|v| v.to_str().ok()) else {
do yeet "Digest header is required for POST/PATCH/PUT"
};
components.push(("digest", digest.to_string()));
}
components
};
let signing_string = make_signing_string(&components).into();
Ok(Signature {
key_id: stuff["keyId"],
signature_encoded: stuff["signature"].to_string(),
alg,
created,
expires,
components,
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);
@ -510,17 +361,34 @@ mod new {
}
}
/// 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)
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, Options, Signature, SigningKey, EXPIRY_WINDOW, HS2019,
RSA_SHA256,
};
/// Calculate the SHA256 hash of something.
pub fn sha256(buf: impl AsRef<[u8]>) -> Vec<u8> {
<Sha256 as Digest>::digest(buf.as_ref()).to_vec()
}
/// Base64-encode something.
pub fn encode(buf: impl AsRef<[u8]>) -> String {
BASE64_STANDARD.encode(buf.as_ref())
}
/// Base64-decode something.
pub fn decode(buf: &str) -> Result<Vec<u8>, String> {
BASE64_STANDARD.decode(buf).map_err(|e| e.to_string())
}
/// Generate signature headers for a request with a body.
pub fn headers_with_digest<T>(
fn headers_with_digest<T>(
req: &Request<T>,
key: &SigningKey,
opt: Options,
@ -529,8 +397,7 @@ mod new {
T: AsRef<[u8]>,
{
let digest = {
let hash = <Sha256 as Digest>::digest(req.body().as_ref());
let encoded = BASE64_STANDARD.encode(hash);
let encoded = encode(sha256(req.body()));
format!("sha256={encoded}")
};
gen_headers(req, key, opt, vec![("digest", digest)], true)
@ -561,11 +428,8 @@ mod new {
// 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)))
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.
@ -643,8 +507,8 @@ mod new {
fn collect_inputs<'a>(
needed: impl IntoIterator<Item = &'a str> + 'a,
headers: &'a [(&'a str, &'a str)],
pseudo: &'a [(&'a str, &'a str)],
headers: &[(&'a str, &'a str)],
pseudo: &[(&'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> {
@ -654,18 +518,169 @@ mod new {
let assoc = |k| {
get(&headers, k)
.or_else(|| get(&pseudo, k))
.ok_or_else(|| format!("missing (pseudo)header `{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))
pub struct IR<'s, S = &'s str> {
target: &'s str,
inputs: Vec<(&'s str, &'s str)>,
alg: &'s str,
key: S,
sig: S,
}
impl<S> IR<'_, S> {
/// Create an HTTP header from the IR.
pub fn to_header(&self) -> String
where
S: AsRef<str>,
{
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<T>, target: &'r str) -> Result<IR<'r>, 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<Signature, String>
where
S: ToString,
{
let created = Utc::now(); // TODO
let expires = Utc::now(); // TODO
let algorithm = match self.alg {
"rsa-sha256" => RSA_SHA256,
"hs2019" => HS2019,
a => do yeet format!("Unsupported algorithm {a}"),
};
let signed_str = make_signing_string(&self.inputs);
let components = self
.inputs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
Ok(Signature {
key_id: self.key.to_string(),
signature: self.sig.to_string(),
time_range: (created, expires),
target_str: self.target.to_string(),
components,
signed_str,
algorithm,
})
}
/// Create an IR from a signature.
pub fn from_signature<'s>(sig: &'s Signature) -> IR<'s> {
IR {
target: &sig.target_str,
inputs: sig
.components
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect(),
key: &sig.key_id,
alg: sig.algorithm.0,
sig: &sig.signature,
}
}
/// Create a signing string.
pub fn to_signing_str(&self) -> String {
make_signing_string(&self.inputs)
}
}
impl<'s> IR<'s, ()> {
/// Create a partial, unsigned IR.
pub fn partial<'r, T>(
req: &'r Request<T>,
target: &'r str,
opt: Options,
) -> Result<IR<'r, ()>, String> {
let map = make_header_map(req);
let digest = req.method() == Method::POST;
let inputs = compute_inputs(&map, target, opt, digest)?;
Ok(IR {
target,
inputs,
alg: opt.algorithm.0,
key: (),
sig: (),
})
}
/// Sign a partially constructed IR to make it actually useful.
pub fn signed(self, key: &'s SigningKey) -> Result<IR<'s, String>, String> {
let sig_str = self.to_signing_str();
let signature = sign_rsa_sha256(&sig_str, &key.inner).map(encode)?;
Ok(IR {
target: self.target,
inputs: self.inputs,
alg: self.alg,
key: key.id.to_string(),
sig: signature,
})
}
}
/// With the given options and headers, compute a set of headers and pseudo-headers that (in order) are to be
/// turned into the signing string.
fn compute_inputs<'a>(
headers: &[(&'a str, &'a str)],
target: &'a str,
opt: Options,
use_digest: bool,
) -> Result<Vec<(&'a str, &'a str)>, 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() {
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 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)
}
/// Allocate a `(request-target)` buffer.
pub fn format_target<T>(req: &Request<T>, opt: Options) -> String {
let path = if opt.strip_query {
req.uri().path()
} else {
req.uri()
.path_and_query()
.map(|r| r.as_str())
.unwrap_or_else(|| req.uri().path())
};
let method = req.method().as_str().to_ascii_lowercase();
format!("{method} {path}")
}
fn format_header(inputs: &[(&str, &str)], key: &str, alg: &str, sig: &str) -> String {
// Format all the headers in the order that we used them in the signing string.
let headers: String = inputs
.iter()
@ -703,14 +718,11 @@ mod new {
.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))
}
fn parse_header<'s>(
header: &'s str,
target: &'s str,
extra: &[(&'s str, &'s str)],
) -> Result<(Vec<(&'s str, &'s str)>, &'s str, &'s str, &'s str), String> {
// Parse the top-level table.
let table: Vec<(&str, &str)> = header
// Split into entries
@ -734,28 +746,47 @@ mod new {
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
let inputs: Vec<(&str, &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 {
"(request-target)" => Ok(("(request-target)", target)),
// 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))),
.ok_or("`(created)` pseudo-header is listed, but does not exist".to_string())
.map(|v| ("(created)", v)),
"(expires)" => get(&table, "expires")
.ok_or("`(expires)` pseudo-header is listed, but does not exist")
.map(|v| ("expires", Some(v))),
.ok_or("`(expires)` pseudo-header is listed, but does not exist".to_string())
.map(|v| ("(expires)", 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)),
k => get(&extra, k)
.ok_or(format!("header '{k}' is missing"))
.map(|v| (k, v)),
})
.try_collect()?;
Ok((inputs, key, algorithm, signature))
}
/// Make an association list associating header names to header values.
///
/// Allocates a new vector, but not any strings.
fn make_header_map<'r, T>(req: &'r Request<T>) -> Vec<(&'r str, &'r str)> {
req.headers()
.iter()
// Acquire string slices of every name-value pair.
.filter_map(|(k, v)| v.to_str().ok().map(|v| (k.as_str(), v)))
.collect()
}
/// 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.eq_ignore_ascii_case(key).then_some(*v))
}
fn make_signing_string(data: &[(&str, &str)]) -> String {
data.iter()
// Each pair is separated by a colon and a space

View file

@ -310,7 +310,7 @@ pub mod auth {
// Verify the signature header on the request.
let public_key = public_key.upgrade();
if let Err(error) = public_key.verify(sig) {
if let Err(error) = public_key.verify(&sig) {
Err(SigError::VerificationFailed { error })
} else {
Ok(Some(Signer { ap_id: public_key.owner.into() }))