use chrono::{DateTime, Utc};
use http::Request;
use crate::cavage::{CavageVerified, cavage_verify, cavage_verify_with_policy};
use crate::error::Error;
use crate::key::VerifyingKey;
use crate::policy::VerifyPolicy;
use crate::rfc9421::{
Rfc9421Verified, SIGNATURE_INPUT_HEADER, rfc9421_verify, rfc9421_verify_with_policy,
};
pub const REDACTED_HEADERS_DEFAULT: &[&str] = &["authorization", "cookie", "proxy-authorization"];
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Verified {
Cavage(CavageVerified),
Rfc9421(Rfc9421Verified),
}
impl Verified {
#[must_use]
pub fn key_id(&self) -> &str {
match self {
Self::Cavage(c) => &c.key_id,
Self::Rfc9421(r) => r.input.keyid.as_deref().unwrap_or_default(),
}
}
#[must_use]
pub fn signature_base(&self) -> &str {
match self {
Self::Cavage(c) => &c.signature_base,
Self::Rfc9421(r) => &r.signature_base,
}
}
#[must_use]
pub fn signature_base_redacted(&self, sensitive_headers: &[&str]) -> String {
let base = self.signature_base();
let mut out = String::with_capacity(base.len());
for line in base.split_inclusive('\n') {
out.push_str(&redact_line(line, sensitive_headers));
}
out
}
}
fn redact_line(line: &str, sensitive: &[&str]) -> String {
let trimmed = line.trim_end_matches('\n');
let has_newline = line.ends_with('\n');
let sensitive_hit = sensitive.iter().any(|h| line_header_matches(trimmed, h));
let Some((prefix, _)) = trimmed.split_once(':').filter(|_| sensitive_hit) else {
return line.to_owned();
};
let mut out = String::with_capacity(prefix.len() + 16);
out.push_str(prefix);
out.push_str(": <redacted>");
if has_newline {
out.push('\n');
}
out
}
fn line_header_matches(line: &str, name: &str) -> bool {
let stripped = line
.strip_prefix('"')
.and_then(|s| s.split_once("\":"))
.map(|(n, _)| n);
let cavage = line.split_once(':').map(|(n, _)| n);
stripped
.or(cavage)
.is_some_and(|found| found.eq_ignore_ascii_case(name))
}
pub fn verify<B, F>(req: &Request<B>, mut resolve_key: F) -> Result<Verified, Error>
where
F: FnMut(&str) -> Result<VerifyingKey, Error>,
{
if req.headers().contains_key(SIGNATURE_INPUT_HEADER) {
return rfc9421_verify(req, &mut resolve_key).map(Verified::Rfc9421);
}
cavage_verify(req, |kid| resolve_key(kid)).map(Verified::Cavage)
}
pub fn verify_with_policy<B, F>(
req: &Request<B>,
policy: &VerifyPolicy,
now: DateTime<Utc>,
mut resolve_key: F,
) -> Result<Verified, Error>
where
F: FnMut(&str) -> Result<VerifyingKey, Error>,
{
if req.headers().contains_key(SIGNATURE_INPUT_HEADER) {
return rfc9421_verify_with_policy(req, policy, now, &mut resolve_key)
.map(Verified::Rfc9421);
}
cavage_verify_with_policy(req, policy, now, |kid| resolve_key(kid)).map(Verified::Cavage)
}
#[cfg(test)]
mod tests {
use http::{Method, Request};
use pretty_assertions::assert_eq;
use super::*;
use crate::cavage::CavageSigner;
use crate::content_digest::content_digest_header;
use crate::digest::sha256_digest_header;
use crate::key::SigningKey;
use crate::rfc9421::Rfc9421Signer;
fn base_request(body: &[u8]) -> Request<Vec<u8>> {
Request::builder()
.method(Method::POST)
.uri("https://example.com/inbox")
.header("host", "example.com")
.header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
.header("digest", sha256_digest_header(body))
.header("content-digest", content_digest_header(body))
.header("content-type", "application/activity+json")
.body(body.to_vec())
.expect("valid")
}
#[test]
fn cavage_signed_request_is_dispatched_to_cavage_verifier() {
let key = SigningKey::generate_ed25519();
let public = key.verifying_key();
let mut req = base_request(b"{}");
CavageSigner::new(&key, "https://example.com/actor#kid")
.sign(&mut req)
.expect("sign");
let report = verify(&req, |_| Ok(public.clone())).expect("verify");
assert!(matches!(report, Verified::Cavage(_)));
assert_eq!(report.key_id(), "https://example.com/actor#kid");
}
#[test]
fn rfc9421_signed_request_is_dispatched_to_rfc9421_verifier() {
let key = SigningKey::generate_ed25519();
let public = key.verifying_key();
let mut req = base_request(b"{}");
Rfc9421Signer::new(&key, "https://example.com/actor#kid")
.sign(&mut req)
.expect("sign");
let report = verify(&req, |_| Ok(public.clone())).expect("verify");
assert!(matches!(report, Verified::Rfc9421(_)));
assert_eq!(report.key_id(), "https://example.com/actor#kid");
}
#[test]
fn rfc9421_takes_precedence_over_cavage_when_both_are_present() {
let key = SigningKey::generate_ed25519();
let public = key.verifying_key();
let mut req = base_request(b"{}");
CavageSigner::new(&key, "cavage-kid")
.sign(&mut req)
.expect("sign cavage");
Rfc9421Signer::new(&key, "rfc9421-kid")
.sign(&mut req)
.expect("sign 9421");
let report = verify(&req, |_| Ok(public.clone())).expect("verify");
assert!(matches!(report, Verified::Rfc9421(_)));
assert_eq!(report.key_id(), "rfc9421-kid");
}
#[test]
fn unsigned_request_returns_missing_header_error() {
let req = base_request(b"{}");
let err =
verify(&req, |_| panic!("resolver must not be called")).expect_err("unsigned request");
assert!(matches!(err, Error::MissingHeader(_)));
}
#[test]
fn policy_rejects_cavage_signature_older_than_max_age() {
let key = SigningKey::generate_ed25519();
let public = key.verifying_key();
let mut req = base_request(b"{}");
CavageSigner::new(&key, "kid")
.with_created(1_700_000_000)
.sign(&mut req)
.expect("sign");
let now = DateTime::<Utc>::from_timestamp(1_700_000_000 + 20 * 3600, 0).expect("valid");
let err = verify_with_policy(&req, &VerifyPolicy::mastodon(), now, |_| Ok(public.clone()))
.expect_err("stale signature must be rejected");
assert!(matches!(err, Error::TimestampTooOld { .. }));
}
#[test]
fn policy_rejects_rfc9421_signature_in_the_future() {
let key = SigningKey::generate_ed25519();
let public = key.verifying_key();
let mut req = base_request(b"{}");
Rfc9421Signer::new(&key, "kid")
.with_created(1_700_000_000 + 15 * 60)
.sign(&mut req)
.expect("sign");
let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid");
let err = verify_with_policy(&req, &VerifyPolicy::mastodon(), now, |_| Ok(public.clone()))
.expect_err("future-dated signature must be rejected");
assert!(matches!(err, Error::TimestampInFuture { .. }));
}
#[test]
fn signature_base_redacted_masks_sensitive_header_values_for_cavage() {
let key = SigningKey::generate_ed25519();
let public = key.verifying_key();
let secret = "Bearer s3cr3t-token";
let mut req = base_request(b"{}");
req.headers_mut()
.insert("authorization", secret.parse().unwrap());
CavageSigner::new(&key, "kid")
.with_headers(["(request-target)", "host", "date", "authorization"])
.sign(&mut req)
.expect("sign");
let report = verify(&req, |_| Ok(public.clone())).expect("verify");
let redacted = report.signature_base_redacted(REDACTED_HEADERS_DEFAULT);
assert!(!redacted.contains(secret), "token must be scrubbed");
assert!(
redacted.contains("authorization: <redacted>"),
"redaction marker must be emitted: {redacted}",
);
assert!(
report.signature_base().contains(secret),
"non-redacted accessor must still expose the original value",
);
}
#[test]
fn signature_base_redacted_masks_sensitive_header_values_for_rfc9421() {
use crate::rfc9421::Component;
let key = SigningKey::generate_ed25519();
let public = key.verifying_key();
let secret = "SessionID=opaque";
let mut req = base_request(b"{}");
req.headers_mut().insert("cookie", secret.parse().unwrap());
Rfc9421Signer::new(&key, "kid")
.with_components(vec![
Component::Method,
Component::TargetUri,
Component::Header("cookie".into()),
])
.sign(&mut req)
.expect("sign");
let report = verify(&req, |_| Ok(public.clone())).expect("verify");
let redacted = report.signature_base_redacted(REDACTED_HEADERS_DEFAULT);
assert!(!redacted.contains(secret), "cookie must be scrubbed");
assert!(
redacted.contains("\"cookie\": <redacted>"),
"RFC 9421 quoted-name lines must be recognised: {redacted}",
);
}
#[test]
fn policy_accepts_signature_within_skew_tolerance() {
let key = SigningKey::generate_ed25519();
let public = key.verifying_key();
let mut req = base_request(b"{}");
Rfc9421Signer::new(&key, "kid")
.with_created(1_700_000_000 + 60)
.sign(&mut req)
.expect("sign");
let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid");
verify_with_policy(&req, &VerifyPolicy::mastodon(), now, |_| Ok(public.clone()))
.expect("signature within skew tolerance must verify");
}
}