Skip to main content

solid_pod_rs_activitypub/
http_sig.rs

1//! HTTP Signatures for ActivityPub federation.
2//!
3//! ActivityPub servers in the wild (Mastodon, Pleroma, Misskey,
4//! GoToSocial) overwhelmingly use **draft-cavage-http-signatures-12**
5//! with `rsa-sha256` as the signing algorithm. RFC 9421 is newer and
6//! not yet widely deployed in the fediverse, so this module supports
7//! both — verification auto-detects by header shape.
8//!
9//! Covered headers for AP:
10//!   * `(request-target)` — method + path
11//!   * `host`
12//!   * `date`
13//!   * `digest` — SHA-256 of body (inbound only; required for POST)
14//!
15//! References:
16//!   * <https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12>
17//!   * <https://www.rfc-editor.org/rfc/rfc9421.html>
18//!   * <https://docs.joinmastodon.org/spec/security/#http>
19
20use async_trait::async_trait;
21use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
22use rsa::pkcs1v15::{Signature as RsaSignature, SigningKey, VerifyingKey};
23use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey};
24use rsa::signature::{SignatureEncoding, Signer, Verifier};
25use rsa::{RsaPrivateKey, RsaPublicKey};
26use sha2::{Digest, Sha256};
27use std::collections::HashMap;
28use std::time::{Duration, SystemTime};
29
30use crate::error::SigError;
31use crate::ssrf::resolve_ssrf_safe;
32
33/// A raw inbound request awaiting signature verification.
34#[derive(Debug, Clone)]
35pub struct SignedRequest {
36    pub method: String,
37    pub path: String,
38    /// Lower-cased header name → value. Multi-valued headers are
39    /// joined with ", " per RFC 7230 §3.2.2.
40    pub headers: HashMap<String, String>,
41    pub body: Vec<u8>,
42}
43
44impl SignedRequest {
45    pub fn new(method: impl Into<String>, path: impl Into<String>, body: Vec<u8>) -> Self {
46        Self {
47            method: method.into(),
48            path: path.into(),
49            headers: HashMap::new(),
50            body,
51        }
52    }
53    pub fn with_header(mut self, name: impl AsRef<str>, value: impl Into<String>) -> Self {
54        self.headers
55            .insert(name.as_ref().to_ascii_lowercase(), value.into());
56        self
57    }
58    fn get(&self, name: &str) -> Option<&str> {
59        self.headers.get(name).map(String::as_str)
60    }
61}
62
63/// A request prepared for outbound delivery — signed headers to be
64/// attached plus the body (unchanged).
65#[derive(Debug, Clone)]
66pub struct OutboundRequest {
67    pub method: String,
68    pub url: String,
69    pub headers: Vec<(String, String)>,
70    pub body: Vec<u8>,
71}
72
73/// Verified actor — the keyId plus its fetched public-key PEM. Inbox
74/// handlers use this to tie an activity to a known AP actor.
75#[derive(Debug, Clone)]
76pub struct VerifiedActor {
77    pub key_id: String,
78    pub actor_url: String,
79    pub public_key_pem: String,
80}
81
82/// Strategy for looking up an actor's public key from its `keyId`.
83///
84/// In production this is an HTTP fetch with cache (see
85/// [`HttpActorKeyResolver`]); in tests it's a simple in-memory map.
86#[async_trait]
87pub trait ActorKeyResolver: Send + Sync {
88    async fn resolve(&self, key_id: &str) -> Result<VerifiedActor, SigError>;
89}
90
91/// HTTP-backed resolver with actor-document caching. Matches
92/// JSS's `fetchActor` behaviour: GET the URL (with `#main-key` or
93/// similar fragment stripped), parse `publicKey.publicKeyPem`.
94pub struct HttpActorKeyResolver {
95    user_agent: String,
96}
97
98impl Default for HttpActorKeyResolver {
99    fn default() -> Self {
100        Self {
101            user_agent: "solid-pod-rs-activitypub/0.4.0".to_string(),
102        }
103    }
104}
105
106#[async_trait]
107impl ActorKeyResolver for HttpActorKeyResolver {
108    async fn resolve(&self, key_id: &str) -> Result<VerifiedActor, SigError> {
109        let actor_url = key_id
110            .split_once('#')
111            .map(|(u, _)| u.to_string())
112            .unwrap_or_else(|| key_id.to_string());
113
114        // P0-08 + TOCTOU: reject private/internal IPs before fetching the
115        // remote actor document, and PIN the connection to the validated
116        // IP. `resolve_ssrf_safe` resolves the host once and hands back the
117        // routable address; building a per-request client that resolves the
118        // host to exactly that address (with redirects disabled) means the
119        // fetch cannot be rebound to an internal target between the check
120        // and the connect, nor auto-follow a 3xx past the guard.
121        let (host, pinned) = resolve_ssrf_safe(&actor_url)?;
122        let client = reqwest::Client::builder()
123            .user_agent(self.user_agent.clone())
124            .redirect(reqwest::redirect::Policy::none())
125            .resolve(&host, pinned)
126            .build()
127            .map_err(|e| SigError::ActorFetch(actor_url.clone(), e.to_string()))?;
128
129        let resp = client
130            .get(&actor_url)
131            .header(reqwest::header::ACCEPT, "application/activity+json")
132            .send()
133            .await
134            .map_err(|e| SigError::ActorFetch(actor_url.clone(), e.to_string()))?;
135        if !resp.status().is_success() {
136            return Err(SigError::ActorFetch(
137                actor_url.clone(),
138                format!("status {}", resp.status()),
139            ));
140        }
141        let doc: serde_json::Value = resp
142            .json()
143            .await
144            .map_err(|e| SigError::ActorFetch(actor_url.clone(), e.to_string()))?;
145        let pem = doc
146            .get("publicKey")
147            .and_then(|k| k.get("publicKeyPem"))
148            .and_then(|v| v.as_str())
149            .ok_or(SigError::NoPublicKey)?;
150        Ok(VerifiedActor {
151            key_id: key_id.to_string(),
152            actor_url,
153            public_key_pem: pem.to_string(),
154        })
155    }
156}
157
158// ---------------------------------------------------------------------------
159// Signature header parsing
160// ---------------------------------------------------------------------------
161
162#[derive(Debug, Clone, Default)]
163struct SignatureHeader {
164    key_id: String,
165    algorithm: String,
166    headers: Vec<String>,
167    signature_b64: String,
168}
169
170/// Parse a `Signature:` header in draft-cavage form:
171/// `keyId="...",algorithm="...",headers="(request-target) host date digest",signature="..."`
172fn parse_signature_header(raw: &str) -> Result<SignatureHeader, SigError> {
173    let mut out = SignatureHeader::default();
174    // Attribute parser — split on commas that are outside quoted values.
175    let mut attrs: Vec<(String, String)> = Vec::new();
176    let mut cur_key = String::new();
177    let mut cur_val = String::new();
178    let mut in_val = false;
179    let mut in_quote = false;
180    let mut expecting_eq = false;
181    for ch in raw.chars() {
182        if !in_val {
183            match ch {
184                '=' => {
185                    in_val = true;
186                    expecting_eq = false;
187                }
188                ',' | ' ' | '\t' if cur_key.is_empty() => { /* skip whitespace */ }
189                c if c.is_ascii_whitespace() => {
190                    expecting_eq = true;
191                }
192                _ if expecting_eq => {
193                    // unexpected text after key with whitespace — part of next key
194                    cur_key.push(ch);
195                    expecting_eq = false;
196                }
197                _ => cur_key.push(ch),
198            }
199        } else {
200            match ch {
201                '"' => {
202                    if in_quote {
203                        // end of quoted value
204                        attrs.push((
205                            std::mem::take(&mut cur_key).to_ascii_lowercase(),
206                            std::mem::take(&mut cur_val),
207                        ));
208                        in_quote = false;
209                        in_val = false;
210                    } else {
211                        in_quote = true;
212                    }
213                }
214                ',' if !in_quote => {
215                    if !cur_key.is_empty() {
216                        attrs.push((
217                            std::mem::take(&mut cur_key).to_ascii_lowercase(),
218                            std::mem::take(&mut cur_val),
219                        ));
220                    }
221                    in_val = false;
222                }
223                _ => {
224                    if in_quote || !ch.is_ascii_whitespace() {
225                        cur_val.push(ch);
226                    }
227                }
228            }
229        }
230    }
231    if !cur_key.is_empty() && (in_val || !cur_val.is_empty()) {
232        attrs.push((cur_key.to_ascii_lowercase(), cur_val));
233    }
234
235    for (k, v) in attrs {
236        match k.as_str() {
237            "keyid" => out.key_id = v,
238            "algorithm" => out.algorithm = v.to_ascii_lowercase(),
239            "headers" => {
240                out.headers = v
241                    .split_ascii_whitespace()
242                    .map(|s| s.to_ascii_lowercase())
243                    .collect();
244            }
245            "signature" => out.signature_b64 = v,
246            _ => {}
247        }
248    }
249    if out.key_id.is_empty() {
250        return Err(SigError::MissingKeyId);
251    }
252    if out.signature_b64.is_empty() {
253        return Err(SigError::MalformedSignature(
254            "missing signature= value".into(),
255        ));
256    }
257    if out.algorithm.is_empty() {
258        // Mastodon used to omit this — default to rsa-sha256 per
259        // current AP fleet behaviour.
260        out.algorithm = "rsa-sha256".to_string();
261    }
262    if out.headers.is_empty() {
263        // Default per draft-cavage §2.1.6 — Date only. AP servers
264        // should be stricter; we preserve the default for tolerance.
265        out.headers = vec!["date".to_string()];
266    }
267    Ok(out)
268}
269
270/// Rebuild the signature base string for a draft-cavage `headers="..."`
271/// list.
272fn build_signature_base(req: &SignedRequest, header_list: &[String]) -> Result<String, SigError> {
273    let mut lines = Vec::with_capacity(header_list.len());
274    for h in header_list {
275        match h.as_str() {
276            "(request-target)" => {
277                lines.push(format!(
278                    "(request-target): {} {}",
279                    req.method.to_ascii_lowercase(),
280                    req.path
281                ));
282            }
283            name => {
284                let v = req.get(name).ok_or_else(|| {
285                    SigError::VerifyFailed(format!("missing covered header: {name}"))
286                })?;
287                lines.push(format!("{name}: {v}"));
288            }
289        }
290    }
291    Ok(lines.join("\n"))
292}
293
294/// Compute the canonical `Digest: SHA-256=...` header value for a body.
295pub fn digest_header(body: &[u8]) -> String {
296    let digest = Sha256::digest(body);
297    format!("SHA-256={}", B64.encode(digest))
298}
299
300/// Maximum age (in seconds) for the `Date` header — requests older
301/// than this are rejected as potential replays.
302const DATE_MAX_AGE_SECS: u64 = 300; // 5 minutes
303
304/// Maximum clock skew into the future (in seconds) we tolerate.
305const DATE_MAX_FUTURE_SECS: u64 = 30;
306
307/// Validate that the `Date` header is within an acceptable freshness
308/// window. Prevents replay attacks with captured old signatures.
309fn check_date_freshness(date_str: &str) -> Result<(), SigError> {
310    let parsed = httpdate::parse_http_date(date_str).map_err(|e| {
311        SigError::DateNotFresh(format!("unparseable Date header '{}': {}", date_str, e))
312    })?;
313    let now = SystemTime::now();
314
315    // Reject if the Date is too far in the past.
316    if let Ok(age) = now.duration_since(parsed) {
317        if age > Duration::from_secs(DATE_MAX_AGE_SECS) {
318            return Err(SigError::DateNotFresh(format!(
319                "Date '{}' is {} seconds old (max {})",
320                date_str,
321                age.as_secs(),
322                DATE_MAX_AGE_SECS
323            )));
324        }
325    }
326
327    // Reject if the Date is too far in the future.
328    if let Ok(ahead) = parsed.duration_since(now) {
329        if ahead > Duration::from_secs(DATE_MAX_FUTURE_SECS) {
330            return Err(SigError::DateNotFresh(format!(
331                "Date '{}' is {} seconds in the future (max {})",
332                date_str,
333                ahead.as_secs(),
334                DATE_MAX_FUTURE_SECS
335            )));
336        }
337    }
338
339    Ok(())
340}
341
342/// Verify an inbound signed request. Returns the [`VerifiedActor`] on
343/// success so the caller can tie activity processing to the signing
344/// identity.
345pub async fn verify_request_signature(
346    req: &SignedRequest,
347    resolver: &dyn ActorKeyResolver,
348) -> Result<VerifiedActor, SigError> {
349    let sig_raw = req
350        .get("signature")
351        .ok_or(SigError::MissingHeader("signature"))?;
352    let parsed = parse_signature_header(sig_raw)?;
353    if parsed.algorithm != "rsa-sha256" && parsed.algorithm != "hs2019" {
354        return Err(SigError::UnsupportedAlgorithm(parsed.algorithm));
355    }
356
357    // P0-07: Date freshness check — reject stale or future-dated
358    // requests to prevent replay attacks with captured signatures.
359    if let Some(date_val) = req.get("date") {
360        check_date_freshness(date_val)?;
361    } else if parsed.headers.iter().any(|h| h == "date") {
362        return Err(SigError::MissingHeader("date"));
363    }
364
365    // Digest check — if the covered set includes `digest`, the body
366    // must hash to the header's value. This is mandatory for POST.
367    if parsed.headers.iter().any(|h| h == "digest") {
368        let received = req.get("digest").ok_or(SigError::MissingHeader("digest"))?;
369        let computed = digest_header(&req.body);
370        // Mastodon historically uses `SHA-256=...`; some servers use
371        // the RFC 9530 `sha-256=:<b64>:` form. Tolerate both.
372        if received != computed && !received.eq_ignore_ascii_case(&computed) {
373            let rfc9530 = {
374                let digest = Sha256::digest(&req.body);
375                format!("sha-256=:{}:", B64.encode(digest))
376            };
377            if received != rfc9530 {
378                return Err(SigError::DigestMismatch);
379            }
380        }
381    }
382
383    let actor = resolver.resolve(&parsed.key_id).await?;
384    let pub_key = RsaPublicKey::from_public_key_pem(&actor.public_key_pem)
385        .map_err(|e| SigError::Rsa(e.to_string()))?;
386    let vk = VerifyingKey::<Sha256>::new(pub_key);
387
388    let base = build_signature_base(req, &parsed.headers)?;
389    let sig_bytes = B64
390        .decode(parsed.signature_b64.as_bytes())
391        .map_err(|e| SigError::Base64(e.to_string()))?;
392    let sig = RsaSignature::try_from(sig_bytes.as_slice())
393        .map_err(|e| SigError::MalformedSignature(e.to_string()))?;
394    vk.verify(base.as_bytes(), &sig)
395        .map_err(|e| SigError::VerifyFailed(e.to_string()))?;
396    Ok(actor)
397}
398
399/// Sign an outbound AP delivery. The caller provides the pod's PEM
400/// private key and its published `keyId` (e.g.
401/// `https://pod.example/profile/card.jsonld#main-key`).
402///
403/// On return, `req.headers` carries `Host`, `Date`, `Digest` and
404/// `Signature`.
405pub fn sign_request(
406    req: &mut OutboundRequest,
407    private_key_pem: &str,
408    key_id: &str,
409) -> Result<(), SigError> {
410    let url = url::Url::parse(&req.url).map_err(|e| SigError::Url(e.to_string()))?;
411    let host = url
412        .host_str()
413        .ok_or_else(|| SigError::Url("url has no host".into()))?;
414    let path = if let Some(q) = url.query() {
415        format!("{}?{}", url.path(), q)
416    } else {
417        url.path().to_string()
418    };
419    let date = httpdate::fmt_http_date(std::time::SystemTime::now());
420    let digest = digest_header(&req.body);
421
422    // Covered headers and their values.
423    let covered = vec!["(request-target)", "host", "date", "digest"];
424    let mut base_lines: Vec<String> = Vec::new();
425    for h in &covered {
426        match *h {
427            "(request-target)" => base_lines.push(format!(
428                "(request-target): {} {}",
429                req.method.to_ascii_lowercase(),
430                path
431            )),
432            "host" => base_lines.push(format!("host: {host}")),
433            "date" => base_lines.push(format!("date: {date}")),
434            "digest" => base_lines.push(format!("digest: {digest}")),
435            _ => {}
436        }
437    }
438    let base = base_lines.join("\n");
439
440    let sk =
441        RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(|e| SigError::Rsa(e.to_string()))?;
442    let signer = SigningKey::<Sha256>::new(sk);
443    let sig: RsaSignature = signer.sign(base.as_bytes());
444    let sig_b64 = B64.encode(sig.to_bytes());
445
446    let signature_header = format!(
447        "keyId=\"{key_id}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{sig}\"",
448        key_id = key_id,
449        headers = covered.join(" "),
450        sig = sig_b64,
451    );
452
453    // Append canonical headers — de-dup if already set.
454    req.headers.retain(|(n, _)| {
455        let ln = n.to_ascii_lowercase();
456        ln != "host" && ln != "date" && ln != "digest" && ln != "signature"
457    });
458    req.headers.push(("Host".to_string(), host.to_string()));
459    req.headers.push(("Date".to_string(), date));
460    req.headers.push(("Digest".to_string(), digest));
461    req.headers
462        .push(("Signature".to_string(), signature_header));
463    Ok(())
464}
465
466// ---------------------------------------------------------------------------
467// Tests
468// ---------------------------------------------------------------------------
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use async_trait::async_trait;
474
475    struct StaticResolver {
476        pem: String,
477    }
478
479    #[async_trait]
480    impl ActorKeyResolver for StaticResolver {
481        async fn resolve(&self, key_id: &str) -> Result<VerifiedActor, SigError> {
482            Ok(VerifiedActor {
483                key_id: key_id.to_string(),
484                actor_url: key_id.trim_end_matches("#main-key").to_string(),
485                public_key_pem: self.pem.clone(),
486            })
487        }
488    }
489
490    fn fresh_keypair() -> (String, String) {
491        crate::actor::generate_actor_keypair().unwrap()
492    }
493
494    fn build_signed_inbound(
495        method: &str,
496        path: &str,
497        body: &[u8],
498        priv_pem: &str,
499        key_id: &str,
500    ) -> SignedRequest {
501        let host = "pod.example";
502        let date = httpdate::fmt_http_date(std::time::SystemTime::now());
503        let digest = digest_header(body);
504        let base = format!(
505            "(request-target): {} {}\nhost: {}\ndate: {}\ndigest: {}",
506            method.to_ascii_lowercase(),
507            path,
508            host,
509            date,
510            digest
511        );
512        let sk = RsaPrivateKey::from_pkcs8_pem(priv_pem).unwrap();
513        let signer = SigningKey::<Sha256>::new(sk);
514        let sig: RsaSignature = signer.sign(base.as_bytes());
515        let sig_b64 = B64.encode(sig.to_bytes());
516        let sig_header = format!(
517            "keyId=\"{key_id}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{sig_b64}\""
518        );
519
520        SignedRequest::new(method, path, body.to_vec())
521            .with_header("host", host)
522            .with_header("date", date)
523            .with_header("digest", digest)
524            .with_header("signature", sig_header)
525    }
526
527    #[test]
528    fn parse_signature_header_valid() {
529        let raw = r#"keyId="https://a.example/actor#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="ZmFrZQ==""#;
530        let parsed = parse_signature_header(raw).unwrap();
531        assert_eq!(parsed.key_id, "https://a.example/actor#main-key");
532        assert_eq!(parsed.algorithm, "rsa-sha256");
533        assert_eq!(
534            parsed.headers,
535            vec![
536                "(request-target)".to_string(),
537                "host".to_string(),
538                "date".to_string(),
539                "digest".to_string()
540            ]
541        );
542        assert_eq!(parsed.signature_b64, "ZmFrZQ==");
543    }
544
545    #[test]
546    fn parse_signature_header_rejects_missing_keyid() {
547        let raw = r#"algorithm="rsa-sha256",signature="abc""#;
548        assert!(matches!(
549            parse_signature_header(raw),
550            Err(SigError::MissingKeyId)
551        ));
552    }
553
554    #[test]
555    fn digest_header_is_mastodon_shape() {
556        let d = digest_header(b"hello");
557        assert!(d.starts_with("SHA-256="));
558    }
559
560    #[tokio::test]
561    async fn http_signature_verify_accepts_valid_request() {
562        let (priv_pem, pub_pem) = fresh_keypair();
563        let key_id = "https://remote.example/actor#main-key";
564        let req = build_signed_inbound("POST", "/inbox", b"{}", &priv_pem, key_id);
565        let resolver = StaticResolver { pem: pub_pem };
566        let actor = verify_request_signature(&req, &resolver).await.unwrap();
567        assert_eq!(actor.key_id, key_id);
568        assert_eq!(actor.actor_url, "https://remote.example/actor");
569    }
570
571    #[tokio::test]
572    async fn http_signature_verify_rejects_tampered_body() {
573        let (priv_pem, pub_pem) = fresh_keypair();
574        let key_id = "https://remote.example/actor#main-key";
575        let mut req = build_signed_inbound("POST", "/inbox", b"{}", &priv_pem, key_id);
576        // Mutate body after signing → digest mismatch.
577        req.body = b"{\"tampered\":true}".to_vec();
578        let resolver = StaticResolver { pem: pub_pem };
579        let res = verify_request_signature(&req, &resolver).await;
580        assert!(matches!(res, Err(SigError::DigestMismatch)), "got {res:?}");
581    }
582
583    #[tokio::test]
584    async fn http_signature_verify_rejects_wrong_key() {
585        let (priv_pem, _pub_pem) = fresh_keypair();
586        let (_, other_pub_pem) = fresh_keypair();
587        let key_id = "https://remote.example/actor#main-key";
588        let req = build_signed_inbound("POST", "/inbox", b"{}", &priv_pem, key_id);
589        let resolver = StaticResolver { pem: other_pub_pem };
590        let res = verify_request_signature(&req, &resolver).await;
591        assert!(matches!(res, Err(SigError::VerifyFailed(_))));
592    }
593
594    #[tokio::test]
595    async fn http_signature_verify_roundtrips_through_sign_request() {
596        let (priv_pem, pub_pem) = fresh_keypair();
597        let key_id = "https://pod.example/profile/card.jsonld#main-key";
598        let body = br#"{"type":"Follow"}"#.to_vec();
599        let mut out = OutboundRequest {
600            method: "POST".into(),
601            url: "https://remote.example/inbox".into(),
602            headers: vec![("Content-Type".into(), "application/activity+json".into())],
603            body: body.clone(),
604        };
605        sign_request(&mut out, &priv_pem, key_id).unwrap();
606
607        // Convert to an inbound-shaped request and verify.
608        let url = url::Url::parse(&out.url).unwrap();
609        let path = url.path().to_string();
610        let mut inbound = SignedRequest::new("POST", &path, body);
611        for (k, v) in &out.headers {
612            inbound.headers.insert(k.to_ascii_lowercase(), v.clone());
613        }
614        let resolver = StaticResolver { pem: pub_pem };
615        let actor = verify_request_signature(&inbound, &resolver).await.unwrap();
616        assert_eq!(actor.key_id, key_id);
617    }
618
619    // -- P0-07: Date freshness tests ----------------------------------------
620
621    #[test]
622    fn check_date_freshness_accepts_now() {
623        let now = httpdate::fmt_http_date(SystemTime::now());
624        assert!(check_date_freshness(&now).is_ok());
625    }
626
627    #[test]
628    fn check_date_freshness_rejects_stale_date() {
629        let old = SystemTime::now() - Duration::from_secs(600);
630        let date = httpdate::fmt_http_date(old);
631        let result = check_date_freshness(&date);
632        assert!(
633            matches!(result, Err(SigError::DateNotFresh(_))),
634            "expected DateNotFresh for 10-minute-old Date, got {result:?}"
635        );
636    }
637
638    #[test]
639    fn check_date_freshness_rejects_future_date() {
640        let future = SystemTime::now() + Duration::from_secs(120);
641        let date = httpdate::fmt_http_date(future);
642        let result = check_date_freshness(&date);
643        assert!(
644            matches!(result, Err(SigError::DateNotFresh(_))),
645            "expected DateNotFresh for 2-minute-future Date, got {result:?}"
646        );
647    }
648
649    #[test]
650    fn check_date_freshness_accepts_slight_future() {
651        // 10 seconds in the future should be accepted (within 30s window).
652        let slight_future = SystemTime::now() + Duration::from_secs(10);
653        let date = httpdate::fmt_http_date(slight_future);
654        assert!(check_date_freshness(&date).is_ok());
655    }
656
657    #[test]
658    fn check_date_freshness_rejects_garbage() {
659        let result = check_date_freshness("not-a-date");
660        assert!(
661            matches!(result, Err(SigError::DateNotFresh(_))),
662            "expected DateNotFresh for garbage date, got {result:?}"
663        );
664    }
665
666    #[tokio::test]
667    async fn verify_rejects_stale_date_header() {
668        let (priv_pem, pub_pem) = fresh_keypair();
669        let key_id = "https://remote.example/actor#main-key";
670        // Build a request with an old Date header (signed correctly).
671        let host = "pod.example";
672        let old_time = SystemTime::now() - Duration::from_secs(600);
673        let date = httpdate::fmt_http_date(old_time);
674        let body = b"{}";
675        let digest = digest_header(body);
676        let base = format!(
677            "(request-target): post /inbox\nhost: {}\ndate: {}\ndigest: {}",
678            host, date, digest
679        );
680        let sk = RsaPrivateKey::from_pkcs8_pem(&priv_pem).unwrap();
681        let signer = SigningKey::<Sha256>::new(sk);
682        let sig: RsaSignature = signer.sign(base.as_bytes());
683        let sig_b64 = B64.encode(sig.to_bytes());
684        let sig_header = format!(
685            "keyId=\"{key_id}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{sig_b64}\""
686        );
687        let req = SignedRequest::new("POST", "/inbox", body.to_vec())
688            .with_header("host", host)
689            .with_header("date", &date)
690            .with_header("digest", &digest)
691            .with_header("signature", &sig_header);
692
693        let resolver = StaticResolver { pem: pub_pem };
694        let result = verify_request_signature(&req, &resolver).await;
695        assert!(
696            matches!(result, Err(SigError::DateNotFresh(_))),
697            "expected DateNotFresh for stale request, got {result:?}"
698        );
699    }
700}