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);
    }
}