[wip] http signatures refactor
This commit is contained in:
parent
9845603846
commit
9eaad3d7bb
3 changed files with 337 additions and 306 deletions
|
@ -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())
|
||||
|
|
|
@ -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,108 +304,61 @@ 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>();
|
||||
/// Default for when
|
||||
const EXPIRY_WINDOW: TimeDelta = TimeDelta::minutes(5);
|
||||
|
||||
#[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")
|
||||
/// 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,
|
||||
}
|
||||
|
||||
// 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";
|
||||
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,
|
||||
};
|
||||
|
||||
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";
|
||||
/// Use rsa-sha256 with the `date` header.
|
||||
pub const LEGACY: Options = Options {
|
||||
use_created: false,
|
||||
algorithm: RSA_SHA256,
|
||||
..Options::MODERN
|
||||
};
|
||||
}
|
||||
|
||||
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()));
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Options {
|
||||
use_created: true,
|
||||
strip_query: false,
|
||||
expires_after: EXPIRY_WINDOW,
|
||||
algorithm: HS2019,
|
||||
}
|
||||
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 {
|
||||
|
@ -451,76 +367,28 @@ mod new {
|
|||
use http::{HeaderName, HeaderValue, Method, Request};
|
||||
use rsa::sha2::{Digest, Sha256};
|
||||
|
||||
use super::{sign_rsa_sha256, Algorithm, SigningKey, HS2019, RSA_SHA256};
|
||||
use super::{
|
||||
sign_rsa_sha256, Algorithm, Options, Signature, SigningKey, EXPIRY_WINDOW, 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,
|
||||
/// Calculate the SHA256 hash of something.
|
||||
pub fn sha256(buf: impl AsRef<[u8]>) -> Vec<u8> {
|
||||
<Sha256 as Digest>::digest(buf.as_ref()).to_vec()
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
/// Base64-encode something.
|
||||
pub fn encode(buf: impl AsRef<[u8]>) -> String {
|
||||
BASE64_STANDARD.encode(buf.as_ref())
|
||||
}
|
||||
|
||||
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)
|
||||
/// 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
|
||||
|
|
|
@ -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() }))
|
||||
|
|
Loading…
Reference in a new issue