http_signatures.rs
1162 lignes · 45186 octets
//! Mastodon-compatible HTTP signatures (draft-cavage-12) (RFC 0015). //! //! This module ships the production-ready cryptographic primitives //! that plug into the `SignatureVerifier` / `OutboundDelivery` //! traits. The current `SignatureVerifier` trait signature only carries //! `signature_header` + `body` + `public_key_pem`; real HTTP-Sig //! verification also needs the request method, path, and `host` / //! `date` headers. Until a follow-up RFC widens the trait, callers //! invoke `parse_and_verify_cavage` directly with the full request //! context. //! //! The signing side is integrated end-to-end via `HttpSignedDelivery`. //! use std::collections::BTreeMap; use std::sync::Arc; use base64::Engine; use rsa::pkcs1v15::{Signature, SigningKey, VerifyingKey}; use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey}; use rsa::signature::{SignatureEncoding, Signer, Verifier}; use rsa::{RsaPrivateKey, RsaPublicKey}; use sha2::Sha256; use async_trait::async_trait; use sea_orm::DatabaseConnection; use crate::models::fediverse_outbound_queue; use crate::observer::{FediverseEventObserver, NoopFediverseObserver}; use crate::services::actor_key_service::ActorKeyService; use crate::services::fediverse_outbound_service::OutboundDelivery; use crate::services::fediverse_signal_service::{SignatureVerifier, VerifyCtx}; use rustwarden_core::error::AppError; /// Canonical fields extracted from a draft-cavage `Signature` header. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParsedSignature { /// Stable identifier of the signing key (e.g. /// `https://a.tld/users/alice#main-key`). pub key_id: String, /// Algorithm. The verifier only accepts `rsa-sha256` for now. pub algorithm: String, /// Ordered list of signed headers (lowercased). pub headers: Vec<String>, /// Decoded signature bytes (raw, **not** base64-encoded). pub signature: Vec<u8>, } /// Parse a `Signature: keyId="…",algorithm="…",headers="…",signature="…"` /// header into a [`ParsedSignature`]. /// /// Returns `Err(AppError::Validation(_))` when a required field is /// missing or the signature is not valid base64. pub fn parse_signature_header(raw: &str) -> Result<ParsedSignature, AppError> { let mut map: BTreeMap<&str, String> = BTreeMap::new(); for part in raw.split(',') { let part = part.trim(); let Some(eq) = part.find('=') else { continue }; let (k, v) = part.split_at(eq); let v = &v[1..]; // skip '=' let v = v.trim(); let unquoted = v.trim_start_matches('"').trim_end_matches('"'); map.insert(k.trim(), unquoted.to_string()); } let key_id = map .remove("keyId") .ok_or_else(|| AppError::Validation("Signature missing keyId".into()))?; let algorithm = map .remove("algorithm") .ok_or_else(|| AppError::Validation("Signature missing algorithm".into()))?; let headers_raw = map .remove("headers") .ok_or_else(|| AppError::Validation("Signature missing headers".into()))?; let signature_b64 = map .remove("signature") .ok_or_else(|| AppError::Validation("Signature missing signature".into()))?; let headers = headers_raw .split_whitespace() .map(|h| h.to_ascii_lowercase()) .collect(); let signature = base64::engine::general_purpose::STANDARD .decode(&signature_b64) .map_err(|e| AppError::Validation(format!("Signature base64 decode failed: {e}")))?; Ok(ParsedSignature { key_id, algorithm, headers, signature, }) } /// Build the **signature input string** that draft-cavage signs / verifies. /// /// `signed_headers` is the ordered list from the `Signature: headers="…"` /// field. `request_target` is the lowercased HTTP method + space + /// path-and-query (e.g. `"post /inbox"`). `lookup` returns the canonical /// header value when called with a lowercased header name. pub fn build_signature_input( signed_headers: &[String], request_target: &str, lookup: impl Fn(&str) -> Option<String>, ) -> Result<String, AppError> { let mut lines = Vec::with_capacity(signed_headers.len()); for header in signed_headers { let line = if header == "(request-target)" { format!("(request-target): {request_target}") } else { let value = lookup(header).ok_or_else(|| { AppError::Validation(format!( "Signature header references missing field `{header}`", )) })?; format!("{header}: {}", value.trim()) }; lines.push(line); } Ok(lines.join("\n")) } /// Sign an arbitrary byte slice with an RSA-SHA256 PKCS#1 v1.5 /// signature, returning the raw signature bytes. pub fn rsa_sha256_sign(private_pem: &str, input: &[u8]) -> Result<Vec<u8>, AppError> { let private_key = RsaPrivateKey::from_pkcs8_pem(private_pem) .map_err(|e| AppError::Internal(format!("invalid PKCS#8 PEM: {e}")))?; let signing_key = SigningKey::<Sha256>::new(private_key); let signature = signing_key.sign(input); Ok(signature.to_bytes().to_vec()) } /// Verify an RSA-SHA256 PKCS#1 v1.5 signature against `input`. pub fn rsa_sha256_verify(public_pem: &str, input: &[u8], signature: &[u8]) -> Result<(), AppError> { let public_key = RsaPublicKey::from_public_key_pem(public_pem) .map_err(|e| AppError::Internal(format!("invalid SPKI PEM: {e}")))?; let verifying_key = VerifyingKey::<Sha256>::new(public_key); let sig = Signature::try_from(signature) .map_err(|e| AppError::Internal(format!("signature shape: {e}")))?; verifying_key .verify(input, &sig) .map_err(|_| AppError::Internal("HTTP signature mismatch".into()))?; Ok(()) } /// Higher-level helper: parse + verify a draft-cavage Signature header /// against the given request context. Use this in inbox handlers that /// have access to the method, path, headers and body. pub fn parse_and_verify_cavage( signature_header: &str, request_target: &str, body: &[u8], public_pem: &str, headers: impl Fn(&str) -> Option<String>, ) -> Result<(), AppError> { let parsed = parse_signature_header(signature_header)?; if parsed.algorithm != "rsa-sha256" { return Err(AppError::Validation(format!( "unsupported algorithm: {}", parsed.algorithm, ))); } // RFC 0015 / ANSSI F-CRYPTO-2 — when the `date` header is part of // the signature coverage, reject signatures whose declared time // drifts by more than `MAX_DATE_SKEW_SECONDS` from the local UTC // clock. This blocks indefinite replay of captured signed // activities (Delete, Update, Revoke, …). if parsed.headers.iter().any(|h| h == "date") { let date_value = headers("date").ok_or_else(|| { AppError::Validation("Signature covers 'date' but the Date header is absent".into()) })?; validate_date_header(&date_value, MAX_DATE_SKEW_SECONDS)?; } // RFC 0015 / ANSSI F-CRYPTO-1 — when the `digest` header is part // of the signature coverage, verify that it matches the SHA-256 of // the actual body. Without this, an attacker can substitute the // body while keeping the signed headers intact (MITM / log replay). if parsed.headers.iter().any(|h| h == "digest") { let digest_value = headers("digest").ok_or_else(|| { AppError::Validation("Signature covers 'digest' but the Digest header is absent".into()) })?; validate_digest(&digest_value, body)?; } let input = build_signature_input(&parsed.headers, request_target, headers)?; rsa_sha256_verify(public_pem, input.as_bytes(), &parsed.signature) } /// Default tolerance for the signed `Date` header (5 minutes, /// Mastodon/Pleroma/Misskey-compatible). const MAX_DATE_SKEW_SECONDS: i64 = 300; /// Verify that a signed `Date` header is within ±`skew_seconds` of the /// current UTC time (RFC 0015 — replay protection). /// /// Returns `Err(AppError::Validation(_))` if the value cannot be parsed /// as RFC 2822 or if the absolute drift exceeds `skew_seconds`. fn validate_date_header(date_str: &str, skew_seconds: i64) -> Result<(), AppError> { let parsed = chrono::DateTime::parse_from_rfc2822(date_str.trim()) .map_err(|e| AppError::Validation(format!("invalid Date header: {e}")))?; let diff = (chrono::Utc::now() - parsed.with_timezone(&chrono::Utc)) .num_seconds() .abs(); if diff > skew_seconds { return Err(AppError::Validation(format!( "Date header out of acceptable skew window ({diff}s > {skew_seconds}s)" ))); } Ok(()) } /// Verify that a signed `Digest` header matches `SHA-256(body)` in /// constant time (RFC 0015 — body integrity). /// /// Accepted format: `SHA-256=<base64>` (Mastodon canonical). Rejects /// any other algorithm, malformed base64, or hash mismatch. Uses /// `subtle::ConstantTimeEq` to avoid timing side-channels. fn validate_digest(digest_header: &str, body: &[u8]) -> Result<(), AppError> { use sha2::Digest; use subtle::ConstantTimeEq; let (algo, expected_b64) = digest_header .split_once('=') .ok_or_else(|| AppError::Validation("malformed Digest header".into()))?; if !algo.eq_ignore_ascii_case("SHA-256") { return Err(AppError::Validation(format!( "unsupported Digest algorithm: {algo}" ))); } let expected = base64::engine::general_purpose::STANDARD .decode(expected_b64.trim()) .map_err(|e| AppError::Validation(format!("Digest base64 decode: {e}")))?; let actual = Sha256::digest(body); if actual.as_slice().ct_eq(expected.as_slice()).unwrap_u8() == 0 { return Err(AppError::Validation("body digest mismatch".into())); } Ok(()) } /// Production-grade implementation of [`SignatureVerifier`] (RFC 0016 /// + RFC 0017). /// /// Reads the `Signature` header, builds the canonical input string, /// and validates the RSA-SHA256 signature. When `ctx.public_key_pem` /// is empty: /// /// - if the verifier was built with [`Self::with_resolver`], it looks /// up the actor in `remote_identities` (RFC 0003) and uses the /// cached PEM ; /// - otherwise it rejects with `Validation`. pub struct HttpSigVerifier { resolver_db: Option<DatabaseConnection>, observer: Arc<dyn FediverseEventObserver>, } impl HttpSigVerifier { /// Build a verifier that does **not** auto-resolve the public /// key. Callers must populate `ctx.public_key_pem` themselves. pub fn new() -> Self { Self { resolver_db: None, observer: Arc::new(NoopFediverseObserver), } } /// Build a verifier that auto-resolves the public key from the /// `remote_identities` cache (RFC 0003) when `ctx.public_key_pem` /// is empty (RFC 0017). pub fn with_resolver(db: DatabaseConnection) -> Self { Self { resolver_db: Some(db), observer: Arc::new(NoopFediverseObserver), } } /// Attach a [`FediverseEventObserver`] so that granular verification /// failures (date skew, digest mismatch, signature mismatch) are /// emitted to the guard pipeline (RFC 0028). When no observer is set, /// the default [`NoopFediverseObserver`] swallows every event. #[must_use] pub fn with_observer(mut self, observer: Arc<dyn FediverseEventObserver>) -> Self { self.observer = observer; self } } impl Default for HttpSigVerifier { fn default() -> Self { Self::new() } } #[async_trait] impl SignatureVerifier for HttpSigVerifier { async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError> { let signature_header = ctx .headers .get("signature") .and_then(|v| v.to_str().ok()) .ok_or_else(|| AppError::Validation("missing Signature header".into()))?; let resolved_pem; let public_key_pem: &str = if !ctx.public_key_pem.is_empty() { ctx.public_key_pem } else if let Some(db) = self.resolver_db.as_ref() { use crate::models::remote_identity; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; let row = remote_identity::Entity::find() .filter(remote_identity::Column::ApId.eq(ctx.actor_ap_id)) .one(db) .await? .ok_or_else(|| { AppError::Validation(format!("actor not in cache: {}", ctx.actor_ap_id,)) })?; resolved_pem = row.public_key_pem; &resolved_pem } else { return Err(AppError::Validation( "public key not resolved for actor".into(), )); }; let request_target = format!("{} {}", ctx.method, ctx.path); let domain = extract_actor_domain(ctx.actor_ap_id); let actor_uri = if ctx.actor_ap_id.is_empty() { None } else { Some(ctx.actor_ap_id) }; // Inline the verification steps (instead of delegating to // `parse_and_verify_cavage`) so that each failure mode can be // routed to the right [`FediverseEventObserver`] hook. let parsed = parse_signature_header(signature_header).map_err(|e| { // A malformed Signature header is not a replay or signature // mismatch — fold it into the generic signature-failure // bucket so detectors can still count it. let reason = format!("{e}"); let observer = self.observer.clone(); let domain = domain.clone(); let actor = actor_uri.map(str::to_string); tokio::spawn(async move { observer .on_signature_failure(&domain, actor.as_deref(), &reason) .await; }); e })?; if parsed.algorithm != "rsa-sha256" { self.observer .on_signature_failure( &domain, actor_uri, &format!("unsupported algorithm: {}", parsed.algorithm), ) .await; return Err(AppError::Validation(format!( "unsupported algorithm: {}", parsed.algorithm, ))); } let header_lookup = |h: &str| -> Option<String> { ctx.headers .get(h) .and_then(|v| v.to_str().ok().map(str::to_string)) }; // RFC 0015 / ANSSI F-CRYPTO-2 — Date skew detection. if parsed.headers.iter().any(|h| h == "date") { let date_value = header_lookup("date").ok_or_else(|| { AppError::Validation("Signature covers 'date' but the Date header is absent".into()) })?; if let Err((skew, err)) = validate_date_header_signed(&date_value, MAX_DATE_SKEW_SECONDS) { self.observer .on_replay_attempt(&domain, actor_uri, serde_json::json!({"skew_secs": skew})) .await; return Err(err); } } // RFC 0015 / ANSSI F-CRYPTO-1 — Body digest detection. if parsed.headers.iter().any(|h| h == "digest") { let digest_value = header_lookup("digest").ok_or_else(|| { AppError::Validation( "Signature covers 'digest' but the Digest header is absent".into(), ) })?; if let Err(err) = validate_digest(&digest_value, ctx.body) { self.observer .on_replay_attempt( &domain, actor_uri, serde_json::json!({"digest": "mismatch"}), ) .await; return Err(err); } } let input = build_signature_input(&parsed.headers, &request_target, header_lookup)?; if let Err(err) = rsa_sha256_verify(public_key_pem, input.as_bytes(), &parsed.signature) { self.observer .on_signature_failure(&domain, actor_uri, "bad_signature") .await; return Err(err); } Ok(()) } } /// Extract the lowercased host part of an actor AP id, returning an /// empty string when the input is not a valid URL (callers downstream /// of `normalise_domain` swallow empty values). fn extract_actor_domain(actor_ap_id: &str) -> String { url::Url::parse(actor_ap_id) .ok() .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) .unwrap_or_default() } /// Variant of [`validate_date_header`] that returns the measured skew /// alongside the error so that the observer can emit it as structured /// JSON. fn validate_date_header_signed(date_str: &str, skew_seconds: i64) -> Result<(), (i64, AppError)> { let parsed = chrono::DateTime::parse_from_rfc2822(date_str.trim()) .map_err(|e| (0, AppError::Validation(format!("invalid Date header: {e}"))))?; let diff = (chrono::Utc::now() - parsed.with_timezone(&chrono::Utc)) .num_seconds() .abs(); if diff > skew_seconds { return Err(( diff, AppError::Validation(format!( "Date header out of acceptable skew window ({diff}s > {skew_seconds}s)" )), )); } Ok(()) } /// Production-grade implementation of [`OutboundDelivery`] (RFC 0016). /// /// Loads the local actor's RSA key (RFC 0005), signs the /// `(request-target) host date` input string, and POSTs the activity /// body to the remote inbox. pub struct HttpSignedDelivery { db: DatabaseConnection, http: reqwest::Client, } impl HttpSignedDelivery { /// Build a delivery handler around an existing SeaORM connection. /// Uses a default `reqwest::Client` (TLS via `native-tls`). pub fn new(db: DatabaseConnection) -> Self { Self { db, http: reqwest::Client::new(), } } } #[async_trait] impl OutboundDelivery for HttpSignedDelivery { async fn deliver(&self, item: &fediverse_outbound_queue::Model) -> Result<(), AppError> { let actor_user_id = item .actor_user_id .ok_or_else(|| AppError::Config("HttpSignedDelivery requires actor_user_id".into()))?; let material = ActorKeyService::ensure_keys(&self.db, actor_user_id).await?; let url = url::Url::parse(&item.target_inbox) .map_err(|e| AppError::Internal(format!("invalid target_inbox URL: {e}")))?; let host = url .host_str() .ok_or_else(|| AppError::Internal("target_inbox URL has no host".to_string()))? .to_string(); let path = url.path().to_string(); let date = chrono::Utc::now() .format("%a, %d %b %Y %H:%M:%S GMT") .to_string(); let signed_headers = vec![ "(request-target)".to_string(), "host".to_string(), "date".to_string(), ]; let request_target = format!("post {path}"); let input = build_signature_input(&signed_headers, &request_target, |h| match h { "host" => Some(host.clone()), "date" => Some(date.clone()), _ => None, })?; let signature_bytes = rsa_sha256_sign(&material.private_pem, input.as_bytes())?; let signature_b64 = base64::engine::general_purpose::STANDARD.encode(&signature_bytes); let signature_header = format!( r#"keyId="{}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{signature_b64}""#, item.target_inbox.trim_end_matches('/'), ); let response = self .http .post(&item.target_inbox) .header("Date", date) .header("Host", host) .header("Signature", signature_header) .header("Content-Type", "application/activity+json") .body(item.payload_json.clone()) .send() .await .map_err(|e| AppError::Internal(format!("HTTP delivery failed: {e}")))?; let status = response.status(); if status.is_success() { Ok(()) } else { Err(AppError::Internal(format!( "remote inbox responded {status}", ))) } } } #[cfg(test)] mod tests { use super::*; use crate::services::actor_key_service::ActorKeyService; fn fresh_keypair() -> (String, String) { // Generate once per test for honest end-to-end coverage. let material = ActorKeyService::generate().expect("RSA gen"); (material.public_pem, material.private_pem) } fn b64(bytes: &[u8]) -> String { base64::engine::general_purpose::STANDARD.encode(bytes) } #[test] fn parse_signature_header_extracts_keyid_algorithm_headers_signature() { let raw = r#"keyId="https://a.tld/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="dGVzdA==""#; let parsed = parse_signature_header(raw).expect("parse ok"); assert_eq!(parsed.key_id, "https://a.tld/users/alice#main-key"); assert_eq!(parsed.algorithm, "rsa-sha256"); assert_eq!(parsed.headers, vec!["(request-target)", "host", "date"]); assert_eq!(parsed.signature, b"test"); } #[test] fn parse_signature_header_rejects_missing_required_fields() { let raw = r#"algorithm="rsa-sha256",headers="host","#; let err = parse_signature_header(raw).expect_err("must fail"); assert!(matches!(err, AppError::Validation(_))); } #[test] fn build_signature_input_uses_lowercase_header_names() { let signed = vec!["host".to_string(), "date".to_string()]; let input = build_signature_input(&signed, "post /inbox", |h| match h { "host" => Some("b.tld".into()), "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()), _ => None, }) .unwrap(); assert!(input.contains("host: b.tld")); assert!(input.contains("date: Mon, 10 May 2026 12:00:00 GMT")); } #[test] fn build_signature_input_handles_request_target() { let signed = vec!["(request-target)".to_string()]; let input = build_signature_input(&signed, "post /inbox", |_| None).unwrap(); assert_eq!(input, "(request-target): post /inbox"); } #[test] fn build_signature_input_errors_on_missing_field() { let signed = vec!["digest".to_string()]; let err = build_signature_input(&signed, "post /inbox", |_| None).expect_err(""); assert!(matches!(err, AppError::Validation(_))); } #[test] fn sign_then_verify_round_trip() { let (pub_pem, priv_pem) = fresh_keypair(); let input = b"(request-target): post /inbox\nhost: b.tld\ndate: x"; let sig = rsa_sha256_sign(&priv_pem, input).unwrap(); rsa_sha256_verify(&pub_pem, input, &sig).expect("verify ok"); } #[test] fn verify_rejects_tampered_signature() { let (pub_pem, priv_pem) = fresh_keypair(); let input = b"hello world"; let mut sig = rsa_sha256_sign(&priv_pem, input).unwrap(); sig[0] ^= 0xff; let err = rsa_sha256_verify(&pub_pem, input, &sig).expect_err("must reject"); assert!(matches!(err, AppError::Internal(_))); } #[test] fn verify_rejects_wrong_public_key() { let (_other_pub, priv_pem) = fresh_keypair(); let (pub_pem, _) = fresh_keypair(); let input = b"hello"; let sig = rsa_sha256_sign(&priv_pem, input).unwrap(); let err = rsa_sha256_verify(&pub_pem, input, &sig).expect_err(""); assert!(matches!(err, AppError::Internal(_))); } #[test] fn parse_and_verify_cavage_accepts_well_formed_signature() { let (pub_pem, priv_pem) = fresh_keypair(); let signed_headers = vec![ "(request-target)".to_string(), "host".to_string(), "date".to_string(), ]; // F-CRYPTO-2: use a fresh `Date` so the skew validator accepts it. let date = chrono::Utc::now() .format("%a, %d %b %Y %H:%M:%S GMT") .to_string(); let input = build_signature_input(&signed_headers, "post /inbox", |h| match h { "host" => Some("b.tld".into()), "date" => Some(date.clone()), _ => None, }) .unwrap(); let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); let header = format!( r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, b64(&sig_bytes), ); parse_and_verify_cavage(&header, "post /inbox", b"", &pub_pem, |h| match h { "host" => Some("b.tld".into()), "date" => Some(date.clone()), _ => None, }) .expect("verify ok"); } #[test] fn parse_and_verify_cavage_rejects_unsupported_algorithm() { let header = r#"keyId="x",algorithm="hs2019",headers="host",signature="dGVzdA==""#; let err = parse_and_verify_cavage(header, "post /x", b"", "", |_| None).expect_err(""); assert!(matches!(err, AppError::Validation(_))); } #[test] fn parse_and_verify_cavage_rejects_invalid_base64() { let header = r#"keyId="x",algorithm="rsa-sha256",headers="host",signature="???""#; let err = parse_and_verify_cavage(header, "post /x", b"", "", |_| Some("h".into())) .expect_err(""); assert!(matches!(err, AppError::Validation(_))); } // ─── RFC 0016 — HttpSigVerifier + HttpSignedDelivery ─────────── #[tokio::test] async fn http_sig_verifier_rejects_when_signature_header_missing() { use axum::http::HeaderMap; let headers = HeaderMap::new(); let ctx = VerifyCtx { actor_ap_id: "x", method: "post", path: "/inbox", headers: &headers, body: b"", public_key_pem: "anything", }; let err = HttpSigVerifier::new().verify(&ctx).await.expect_err(""); assert!(matches!(err, AppError::Validation(_))); } #[tokio::test] async fn http_sig_verifier_rejects_when_public_key_pem_empty() { use axum::http::HeaderMap; let mut headers = HeaderMap::new(); headers.insert("signature", "keyId=\"x\"".parse().unwrap()); let ctx = VerifyCtx { actor_ap_id: "x", method: "post", path: "/inbox", headers: &headers, body: b"", public_key_pem: "", }; let err = HttpSigVerifier::new().verify(&ctx).await.expect_err(""); assert!(matches!(err, AppError::Validation(_))); } #[tokio::test] async fn http_sig_verifier_accepts_valid_signature() { use axum::http::{HeaderMap, HeaderValue}; let (pub_pem, priv_pem) = fresh_keypair(); let signed_headers = vec![ "(request-target)".to_string(), "host".to_string(), "date".to_string(), ]; let date = chrono::Utc::now() .format("%a, %d %b %Y %H:%M:%S GMT") .to_string(); let input = build_signature_input(&signed_headers, "post /inbox", |h| match h { "host" => Some("b.tld".into()), "date" => Some(date.clone()), _ => None, }) .unwrap(); let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); let header_value = format!( r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, b64(&sig_bytes), ); let mut headers = HeaderMap::new(); headers.insert("signature", HeaderValue::from_str(&header_value).unwrap()); headers.insert("host", HeaderValue::from_static("b.tld")); headers.insert("date", HeaderValue::from_str(&date).unwrap()); let body = b""; let ctx = VerifyCtx { actor_ap_id: "x", method: "post", path: "/inbox", headers: &headers, body, public_key_pem: &pub_pem, }; HttpSigVerifier::new() .verify(&ctx) .await .expect("valid signature must pass"); } #[tokio::test] async fn http_sig_verifier_resolves_pem_from_remote_identities_when_empty() { use axum::http::{HeaderMap, HeaderValue}; use chrono::Utc; use sea_orm::{DatabaseBackend, MockDatabase}; use uuid::Uuid; let (pub_pem, priv_pem) = fresh_keypair(); let signed_headers = vec![ "(request-target)".to_string(), "host".to_string(), "date".to_string(), ]; let date = chrono::Utc::now() .format("%a, %d %b %Y %H:%M:%S GMT") .to_string(); let input = build_signature_input(&signed_headers, "post /inbox", |h| match h { "host" => Some("b.tld".into()), "date" => Some(date.clone()), _ => None, }) .unwrap(); let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); let header_value = format!( r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, b64(&sig_bytes), ); let mut headers = HeaderMap::new(); headers.insert("signature", HeaderValue::from_str(&header_value).unwrap()); headers.insert("host", HeaderValue::from_static("b.tld")); headers.insert("date", HeaderValue::from_str(&date).unwrap()); let now = Utc::now(); let cached = crate::models::remote_identity::Model { id: Uuid::new_v4(), ap_id: "https://a.tld/users/alice".into(), username: "alice".into(), domain: "a.tld".into(), display_name: None, summary_html: None, inbox_url: "https://a.tld/users/alice/inbox".into(), shared_inbox_url: None, public_key_pem: pub_pem, icon_url: None, actor_type: "Person".into(), raw_json: None, last_fetched_at: now, disabled_at: None, created_at: now, updated_at: now, }; let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![cached]]) .into_connection(); let ctx = VerifyCtx { actor_ap_id: "https://a.tld/users/alice", method: "post", path: "/inbox", headers: &headers, body: b"", public_key_pem: "", // empty → triggers resolver path }; HttpSigVerifier::with_resolver(db) .verify(&ctx) .await .expect("resolver path must accept valid signature"); } #[tokio::test] async fn http_sig_verifier_rejects_when_actor_not_in_cache() { use axum::http::{HeaderMap, HeaderValue}; use sea_orm::{DatabaseBackend, MockDatabase}; let mut headers = HeaderMap::new(); headers.insert( "signature", HeaderValue::from_static( r#"keyId="x",algorithm="rsa-sha256",headers="host",signature="dGVzdA==""#, ), ); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) .into_connection(); let ctx = VerifyCtx { actor_ap_id: "https://ghost.tld/users/nobody", method: "post", path: "/inbox", headers: &headers, body: b"", public_key_pem: "", }; let err = HttpSigVerifier::with_resolver(db) .verify(&ctx) .await .expect_err("must reject unknown actor"); assert!(matches!(err, AppError::Validation(_))); } #[tokio::test] async fn http_signed_delivery_rejects_when_actor_user_id_none() { use crate::models::fediverse_outbound_queue::Model; use chrono::Utc; use sea_orm::{DatabaseBackend, MockDatabase}; use uuid::Uuid; let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); let now = Utc::now(); let item = Model { id: Uuid::new_v4(), activity_id: "act".into(), payload_json: "{}".into(), target_inbox: "https://b.tld/inbox".into(), target_domain: "b.tld".into(), status: "pending".into(), attempts: 0, max_attempts: 8, last_error: None, next_retry_at: None, created_at: now, delivered_at: None, actor_user_id: None, }; let delivery = HttpSignedDelivery::new(db); let err = delivery.deliver(&item).await.expect_err("must reject"); assert!(matches!(err, AppError::Config(_))); } #[test] fn parse_and_verify_cavage_rejects_tampered_input() { let (pub_pem, priv_pem) = fresh_keypair(); let signed_headers = vec!["host".to_string()]; let input = build_signature_input(&signed_headers, "post /x", |h| { if h == "host" { Some("b.tld".into()) } else { None } }) .unwrap(); let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); let header = format!( r#"keyId="x",algorithm="rsa-sha256",headers="host",signature="{}""#, b64(&sig_bytes), ); // Verify against a different host value. let err = parse_and_verify_cavage(&header, "post /x", b"", &pub_pem, |h| { if h == "host" { Some("evil.tld".into()) } else { None } }) .expect_err("must reject tampered host"); assert!(matches!(err, AppError::Internal(_))); } // ─── ANSSI F-CRYPTO-1 — body digest validation ───────────────── #[test] fn validate_digest_accepts_matching_sha256() { use sha2::Digest; let body = b"hello world"; let expected = base64::engine::general_purpose::STANDARD.encode(sha2::Sha256::digest(body)); let header = format!("SHA-256={expected}"); validate_digest(&header, body).expect("matching digest must succeed"); } #[test] fn validate_digest_rejects_mismatched_body() { use sha2::Digest; let header = format!( "SHA-256={}", base64::engine::general_purpose::STANDARD.encode(sha2::Sha256::digest(b"signed body")) ); let err = validate_digest(&header, b"tampered body") .expect_err("mismatched digest must be rejected"); assert!(format!("{err}").contains("mismatch")); } #[test] fn validate_digest_rejects_unsupported_algorithm() { let err = validate_digest("MD5=abc", b"").expect_err("unsupported algorithm must be rejected"); assert!(format!("{err}").contains("unsupported")); } #[test] fn validate_digest_rejects_malformed_header() { let err = validate_digest("no-equals-sign", b"").expect_err("malformed digest must be rejected"); assert!(format!("{err}").contains("malformed")); } // ─── ANSSI F-CRYPTO-2 — Date header skew validation ──────────── #[test] fn validate_date_header_accepts_recent_timestamp() { let now = chrono::Utc::now() .format("%a, %d %b %Y %H:%M:%S GMT") .to_string(); validate_date_header(&now, MAX_DATE_SKEW_SECONDS).expect("recent date must succeed"); } #[test] fn validate_date_header_rejects_stale_timestamp() { let stale = (chrono::Utc::now() - chrono::Duration::hours(2)) .format("%a, %d %b %Y %H:%M:%S GMT") .to_string(); let err = validate_date_header(&stale, MAX_DATE_SKEW_SECONDS) .expect_err("stale date must be rejected"); assert!(format!("{err}").contains("skew")); } #[test] fn validate_date_header_rejects_future_timestamp() { let future = (chrono::Utc::now() + chrono::Duration::hours(2)) .format("%a, %d %b %Y %H:%M:%S GMT") .to_string(); let err = validate_date_header(&future, MAX_DATE_SKEW_SECONDS) .expect_err("future date must be rejected"); assert!(format!("{err}").contains("skew")); } #[test] fn validate_date_header_rejects_malformed_string() { let err = validate_date_header("not a date", MAX_DATE_SKEW_SECONDS) .expect_err("malformed date must be rejected"); assert!(format!("{err}").contains("invalid")); } // ─── RFC 0028 / Lot 7b — observer emission points ────────────── use std::sync::atomic::{AtomicUsize, Ordering}; #[derive(Default)] struct CountingObserver { replay: AtomicUsize, sig_failure: AtomicUsize, last_details: std::sync::Mutex<Option<serde_json::Value>>, } #[async_trait] impl FediverseEventObserver for CountingObserver { async fn on_signature_failure(&self, _domain: &str, _actor: Option<&str>, _reason: &str) { self.sig_failure.fetch_add(1, Ordering::SeqCst); } async fn on_replay_attempt( &self, _domain: &str, _actor: Option<&str>, details: serde_json::Value, ) { self.replay.fetch_add(1, Ordering::SeqCst); *self.last_details.lock().unwrap() = Some(details); } } /// Helper: build a `VerifyCtx` with a signed signature for the given /// `date` value. The signature itself is mechanically valid (host + /// date covered) so the only thing under test is the date-skew gate. fn signed_ctx_for_date<'a>( date: &'a str, pub_pem: &'a str, priv_pem: &'a str, headers: &'a mut axum::http::HeaderMap, ) -> VerifyCtx<'a> { use axum::http::HeaderValue; let signed_headers = vec![ "(request-target)".to_string(), "host".to_string(), "date".to_string(), ]; let input = build_signature_input(&signed_headers, "post /inbox", |h| match h { "host" => Some("b.tld".into()), "date" => Some(date.to_string()), _ => None, }) .unwrap(); let sig = rsa_sha256_sign(priv_pem, input.as_bytes()).unwrap(); let header = format!( r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, b64(&sig), ); headers.insert("signature", HeaderValue::from_str(&header).unwrap()); headers.insert("host", HeaderValue::from_static("b.tld")); headers.insert("date", HeaderValue::from_str(date).unwrap()); let _ = pub_pem; VerifyCtx { actor_ap_id: "https://a.tld/users/alice", method: "post", path: "/inbox", headers, body: b"", public_key_pem: "", } } #[tokio::test] async fn http_sig_verifier_emits_replay_on_stale_date() { let (pub_pem, priv_pem) = fresh_keypair(); let stale = (chrono::Utc::now() - chrono::Duration::hours(2)) .format("%a, %d %b %Y %H:%M:%S GMT") .to_string(); let mut headers = axum::http::HeaderMap::new(); let ctx = signed_ctx_for_date(&stale, &pub_pem, &priv_pem, &mut headers); let observer = Arc::new(CountingObserver::default()); let verifier = HttpSigVerifier::new().with_observer(observer.clone()); // The verifier needs the PEM — fold it into the ctx by hand. let ctx = VerifyCtx { public_key_pem: &pub_pem, ..ctx }; let err = verifier .verify(&ctx) .await .expect_err("stale date must reject"); assert!(matches!(err, AppError::Validation(_))); assert_eq!(observer.replay.load(Ordering::SeqCst), 1); let details = observer.last_details.lock().unwrap().clone().unwrap(); assert!(details.get("skew_secs").is_some(), "got: {details}"); } #[tokio::test] async fn http_sig_verifier_emits_replay_on_digest_mismatch() { use axum::http::HeaderValue; use sha2::Digest as _; let (pub_pem, priv_pem) = fresh_keypair(); let date = chrono::Utc::now() .format("%a, %d %b %Y %H:%M:%S GMT") .to_string(); let signed = vec![ "(request-target)".to_string(), "host".to_string(), "date".to_string(), "digest".to_string(), ]; // Sign over a digest of "signed body" but send "tampered body" // so the Digest header value mismatches the actual body bytes. let fake_digest = format!("SHA-256={}", b64(&sha2::Sha256::digest(b"signed body"))); let input = build_signature_input(&signed, "post /inbox", |h| match h { "host" => Some("b.tld".into()), "date" => Some(date.clone()), "digest" => Some(fake_digest.clone()), _ => None, }) .unwrap(); let sig = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); let header_val = format!( r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="{}""#, b64(&sig), ); let mut headers = axum::http::HeaderMap::new(); headers.insert("signature", HeaderValue::from_str(&header_val).unwrap()); headers.insert("host", HeaderValue::from_static("b.tld")); headers.insert("date", HeaderValue::from_str(&date).unwrap()); headers.insert("digest", HeaderValue::from_str(&fake_digest).unwrap()); let ctx = VerifyCtx { actor_ap_id: "https://a.tld/users/alice", method: "post", path: "/inbox", headers: &headers, body: b"tampered body", public_key_pem: &pub_pem, }; let observer = Arc::new(CountingObserver::default()); let verifier = HttpSigVerifier::new().with_observer(observer.clone()); let err = verifier .verify(&ctx) .await .expect_err("digest mismatch must reject"); assert!(matches!(err, AppError::Validation(_))); assert_eq!(observer.replay.load(Ordering::SeqCst), 1); let details = observer.last_details.lock().unwrap().clone().unwrap(); assert_eq!(details.get("digest"), Some(&serde_json::json!("mismatch"))); } #[tokio::test] async fn http_sig_verifier_emits_sig_failure_on_bad_signature() { use axum::http::HeaderValue; let (pub_pem, _) = fresh_keypair(); let (_, priv_pem) = fresh_keypair(); // wrong key → bad signature let date = chrono::Utc::now() .format("%a, %d %b %Y %H:%M:%S GMT") .to_string(); let signed = vec![ "(request-target)".to_string(), "host".to_string(), "date".to_string(), ]; let input = build_signature_input(&signed, "post /inbox", |h| match h { "host" => Some("b.tld".into()), "date" => Some(date.clone()), _ => None, }) .unwrap(); let sig = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); let header_val = format!( r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, b64(&sig), ); let mut headers = axum::http::HeaderMap::new(); headers.insert("signature", HeaderValue::from_str(&header_val).unwrap()); headers.insert("host", HeaderValue::from_static("b.tld")); headers.insert("date", HeaderValue::from_str(&date).unwrap()); let ctx = VerifyCtx { actor_ap_id: "https://a.tld/users/alice", method: "post", path: "/inbox", headers: &headers, body: b"", public_key_pem: &pub_pem, }; let observer = Arc::new(CountingObserver::default()); let verifier = HttpSigVerifier::new().with_observer(observer.clone()); let err = verifier .verify(&ctx) .await .expect_err("bad signature must reject"); assert!(matches!(err, AppError::Internal(_))); assert_eq!(observer.sig_failure.load(Ordering::SeqCst), 1); } }
GitRust