Skip to main content

actpub_httpsig/rfc9421/
verify.rs

1//! RFC 9421 request verifier.
2
3use chrono::{DateTime, Utc};
4use http::Request;
5
6use crate::error::Error;
7use crate::key::{Algorithm, VerifyingKey};
8use crate::policy::VerifyPolicy;
9use crate::rfc9421::components::{Component, build_signature_base};
10use crate::rfc9421::signature::{SIGNATURE_HEADER, parse_signature_dict};
11use crate::rfc9421::signature_input::{
12    SIGNATURE_INPUT_HEADER, SignatureInput, parse_signature_input_dict,
13};
14
15/// Successful RFC 9421 verification report.
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct Rfc9421Verified {
19    /// Label of the signature that matched.
20    pub label: String,
21    /// Parsed `Signature-Input:` entry for that label.
22    pub input: SignatureInput,
23    /// Rebuilt signature base string, for audit / logging.
24    pub signature_base: String,
25}
26
27/// Verifies an RFC 9421-signed request against a key returned by
28/// `resolve_key(key_id)`.
29///
30/// When multiple labels are present, this function picks the **first
31/// label whose key the resolver accepts** and returns its report. If the
32/// resolver fails for every label the last error is returned.
33///
34/// # Errors
35///
36/// Returns [`Error::MissingHeader`] if either header is absent, and
37/// [`Error::VerificationFailed`] if no label produces a valid signature.
38/// See also [`Error::MalformedSignatureHeader`] and
39/// [`Error::KeyResolution`].
40pub fn rfc9421_verify<B, F>(req: &Request<B>, resolve_key: F) -> Result<Rfc9421Verified, Error>
41where
42    F: FnMut(&str) -> Result<VerifyingKey, Error>,
43{
44    rfc9421_verify_with_policy(
45        req,
46        &VerifyPolicy::no_freshness_check(),
47        Utc::now(),
48        resolve_key,
49    )
50}
51
52/// Verifies an RFC 9421-signed request **with replay-protection**.
53///
54/// Equivalent to [`rfc9421_verify`] except that `policy` is consulted
55/// for every candidate label to reject stale, future-dated or expired
56/// timestamps against `now`.
57///
58/// # Errors
59///
60/// Same as [`rfc9421_verify`] plus [`Error::TimestampTooOld`],
61/// [`Error::TimestampInFuture`], [`Error::TimestampExpired`] and
62/// [`Error::TimestampMissing`] when the policy is violated.
63pub fn rfc9421_verify_with_policy<B, F>(
64    req: &Request<B>,
65    policy: &VerifyPolicy,
66    now: DateTime<Utc>,
67    mut resolve_key: F,
68) -> Result<Rfc9421Verified, Error>
69where
70    F: FnMut(&str) -> Result<VerifyingKey, Error>,
71{
72    let date_header = req
73        .headers()
74        .get(http::header::DATE)
75        .and_then(|v| v.to_str().ok())
76        .map(str::to_owned);
77    let input_raw = req
78        .headers()
79        .get(SIGNATURE_INPUT_HEADER)
80        .ok_or(Error::MissingHeader(SIGNATURE_INPUT_HEADER))?
81        .to_str()
82        .map_err(|e| Error::InvalidHeader {
83            name: SIGNATURE_INPUT_HEADER,
84            reason: e.to_string(),
85        })?;
86    let sig_raw = req
87        .headers()
88        .get(SIGNATURE_HEADER)
89        .ok_or(Error::MissingHeader(SIGNATURE_HEADER))?
90        .to_str()
91        .map_err(|e| Error::InvalidHeader {
92            name: SIGNATURE_HEADER,
93            reason: e.to_string(),
94        })?;
95
96    let inputs = parse_signature_input_dict(input_raw)?;
97    let sigs = parse_signature_dict(sig_raw)?;
98
99    if inputs.is_empty() {
100        return Err(Error::MalformedSignatureHeader(
101            "empty Signature-Input dictionary".into(),
102        ));
103    }
104
105    if !policy.allow_multiple_signatures && inputs.len() > 1 {
106        return Err(Error::MalformedSignatureHeader(format!(
107            "Signature-Input carries {} labels but policy allows only one",
108            inputs.len()
109        )));
110    }
111
112    let mut last_err: Option<Error> = None;
113    for (label, input) in inputs {
114        let Some((_, sig_bytes)) = sigs.iter().find(|(l, _)| l == &label) else {
115            last_err = Some(Error::MalformedSignatureHeader(format!(
116                "no Signature entry for label `{label}`"
117            )));
118            continue;
119        };
120
121        // Cheapest-possible replay guard: reject signatures whose
122        // covered-component set omits any identifier in
123        // `policy.rfc9421_required_components` before any crypto
124        // work runs. A signature that does not cover `@method` /
125        // `@target-uri` / `content-digest` can be replayed against
126        // a different path, method, or body.
127        if let Err(e) =
128            enforce_required_components(&input.components, policy.rfc9421_required_components)
129        {
130            last_err = Some(e);
131            continue;
132        }
133
134        // Freshness check on a per-label basis so that one rotated
135        // label does not invalidate a sibling signature.
136        if let Err(e) = policy.check(input.created, input.expires, date_header.as_deref(), now) {
137            last_err = Some(e);
138            continue;
139        }
140
141        let Some(key_id) = input.keyid.as_deref() else {
142            last_err = Some(Error::MissingSignatureParameter("keyid"));
143            continue;
144        };
145
146        let key = match resolve_key(key_id) {
147            Ok(k) => k,
148            Err(e) => {
149                last_err = Some(Error::KeyResolution(e.to_string()));
150                continue;
151            }
152        };
153
154        if let Some(hint) = input.algorithm.as_deref() {
155            match parse_alg_hint(hint) {
156                Ok(Some(hinted)) if hinted != key.algorithm() => {
157                    last_err = Some(Error::VerificationFailed);
158                    continue;
159                }
160                Ok(_) => {}
161                Err(e) => {
162                    last_err = Some(e);
163                    continue;
164                }
165            }
166        }
167
168        let inner_list = input.serialise_inner_list();
169        let base = build_signature_base(req, &input.components, &inner_list)?;
170
171        if key.verify(base.as_bytes(), sig_bytes).is_err() {
172            last_err = Some(Error::VerificationFailed);
173            continue;
174        }
175
176        return Ok(Rfc9421Verified {
177            label,
178            input,
179            signature_base: base,
180        });
181    }
182
183    Err(last_err.unwrap_or(Error::VerificationFailed))
184}
185
186/// Parses the RFC 9421 `alg` signature parameter using the same
187/// canonical table as the rest of the crate.
188///
189/// Previously this had its own ad-hoc match that rejected `hs2019`
190/// as unsupported; that bypassed [`Algorithm::parse`], which
191/// correctly maps `hs2019` to `Ok(None)` (i.e. "derive algorithm
192/// from the key, no hint"). The old behaviour broke interop with
193/// any Fediverse peer still emitting Mastodon's legacy `hs2019`
194/// label and was a maintenance hazard: adding e.g. RSA-PSS to the
195/// canonical parser required touching two places.
196fn parse_alg_hint(hint: &str) -> Result<Option<Algorithm>, Error> {
197    Algorithm::parse(hint)
198}
199
200/// Rejects the signature when `signed` is missing any identifier in
201/// `required`. Identifiers are matched case-insensitively against
202/// [`Component::identifier`], so a policy entry `"content-digest"`
203/// matches any casing the signer emitted.
204fn enforce_required_components(signed: &[Component], required: &[&str]) -> Result<(), Error> {
205    for needed in required {
206        let present = signed
207            .iter()
208            .any(|c| c.identifier().eq_ignore_ascii_case(needed));
209        if !present {
210            return Err(Error::RequiredHeaderAbsent((*needed).to_owned()));
211        }
212    }
213    Ok(())
214}
215
216#[cfg(test)]
217mod tests {
218    use http::{Method, Request};
219    use pretty_assertions::assert_eq;
220
221    use super::*;
222    use crate::content_digest::content_digest_header;
223    use crate::key::{RsaBits, SigningKey};
224    use crate::rfc9421::sign::Rfc9421Signer;
225
226    fn signed_request(key: &SigningKey) -> Request<Vec<u8>> {
227        let body = b"{}";
228        let mut req = Request::builder()
229            .method(Method::POST)
230            .uri("https://example.com/inbox?a=1")
231            .header("host", "example.com")
232            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
233            .header("content-digest", content_digest_header(body))
234            .body(body.to_vec())
235            .expect("valid");
236        Rfc9421Signer::new(key, "https://example.com/actor#sig")
237            .with_created(1_700_000_000)
238            .sign(&mut req)
239            .expect("sign");
240        req
241    }
242
243    #[test]
244    fn ed25519_roundtrips_sign_then_verify() {
245        let key = SigningKey::generate_ed25519();
246        let public = key.verifying_key();
247        let req = signed_request(&key);
248
249        let report = rfc9421_verify(&req, |kid| {
250            assert_eq!(kid, "https://example.com/actor#sig");
251            Ok(public.clone())
252        })
253        .expect("verify");
254
255        assert_eq!(report.label, "sig1");
256        assert!(report.signature_base.contains(r#""@method": POST"#));
257    }
258
259    #[test]
260    fn rsa_sha256_roundtrips_sign_then_verify() {
261        let key = SigningKey::generate_rsa(RsaBits::Rsa2048).expect("rng");
262        let public = key.verifying_key();
263        let req = signed_request(&key);
264        rfc9421_verify(&req, |_| Ok(public.clone())).expect("verify");
265    }
266
267    #[test]
268    fn tampered_date_header_fails_verification() {
269        let key = SigningKey::generate_ed25519();
270        let public = key.verifying_key();
271        let mut req = signed_request(&key);
272        req.headers_mut().insert(
273            "date",
274            "Mon, 06 Jan 2014 00:00:00 GMT".parse().expect("valid"),
275        );
276        let err =
277            rfc9421_verify(&req, |_| Ok(public.clone())).expect_err("tampered date must fail");
278        assert!(matches!(err, Error::VerificationFailed));
279    }
280
281    #[test]
282    fn parse_alg_hint_accepts_legacy_hs2019_as_key_derived() {
283        // P0-N3 (sixth-round audit) regression: the old ad-hoc
284        // `parse_alg_hint` implementation hard-errored on every
285        // algorithm name outside {rsa-sha256, rsa-v1_5-sha256,
286        // ed25519}, including Mastodon's legacy `hs2019` label —
287        // which per RFC 9421 §3.1 means "derive algorithm from
288        // the key, no hint". The new implementation delegates to
289        // the canonical `Algorithm::parse` so `hs2019` returns
290        // `Ok(None)` and the verifier falls through to the
291        // key-derived algorithm as the RFC requires.
292        assert_eq!(
293            parse_alg_hint("hs2019").expect("hs2019 must be accepted"),
294            None
295        );
296        // And the canonical names still parse as specific algos.
297        assert_eq!(
298            parse_alg_hint("rsa-v1_5-sha256").expect("parse"),
299            Some(Algorithm::RsaSha256),
300        );
301        assert_eq!(
302            parse_alg_hint("ed25519").expect("parse"),
303            Some(Algorithm::Ed25519),
304        );
305    }
306
307    #[test]
308    fn algorithm_mismatch_between_hint_and_key_is_rejected() {
309        let key = SigningKey::generate_ed25519();
310        // Resolver returns an RSA public key — alg hint `ed25519` won't match.
311        let rsa_public = SigningKey::generate_rsa(RsaBits::Rsa2048)
312            .expect("rng")
313            .verifying_key();
314        let req = signed_request(&key);
315        let err =
316            rfc9421_verify(&req, |_| Ok(rsa_public.clone())).expect_err("mismatched alg must fail");
317        assert!(matches!(err, Error::VerificationFailed));
318    }
319
320    #[test]
321    fn missing_input_header_is_reported() {
322        let key = SigningKey::generate_ed25519();
323        let mut req = signed_request(&key);
324        req.headers_mut().remove(SIGNATURE_INPUT_HEADER);
325        let err = rfc9421_verify(&req, |_| panic!("resolver must not be called"))
326            .expect_err("missing input");
327        assert!(matches!(err, Error::MissingHeader(SIGNATURE_INPUT_HEADER)));
328    }
329
330    #[test]
331    fn missing_signature_header_is_reported() {
332        let key = SigningKey::generate_ed25519();
333        let mut req = signed_request(&key);
334        req.headers_mut().remove(SIGNATURE_HEADER);
335        let err = rfc9421_verify(&req, |_| panic!("resolver must not be called"))
336            .expect_err("missing signature");
337        assert!(matches!(err, Error::MissingHeader(SIGNATURE_HEADER)));
338    }
339
340    #[test]
341    fn multi_label_signature_input_is_rejected_by_default() {
342        // Mastodon and the RFC 9421 interop profile both expect a
343        // single label; attaching a second one opens a fallback an
344        // attacker can exploit to bypass policy.
345        let key = SigningKey::generate_ed25519();
346        let public = key.verifying_key();
347        let mut req = signed_request(&key);
348        // Append a second, empty inner list to produce `sig1=(...), attacker=()`.
349        let input_raw = req
350            .headers()
351            .get(SIGNATURE_INPUT_HEADER)
352            .unwrap()
353            .to_str()
354            .unwrap()
355            .to_owned()
356            + r", attacker=()";
357        req.headers_mut()
358            .insert(SIGNATURE_INPUT_HEADER, input_raw.parse().unwrap());
359
360        let err = rfc9421_verify(&req, |_| Ok(public.clone()))
361            .expect_err("multiple labels must be rejected");
362        assert!(matches!(err, Error::MalformedSignatureHeader(_)));
363    }
364
365    #[test]
366    fn multi_label_signature_input_is_accepted_when_policy_allows_it() {
367        // Interop escape hatch: some research / middle-box setups do
368        // attach multiple signatures. Flipping the policy knob must
369        // restore the historical tolerant behaviour.
370        use chrono::DateTime;
371
372        let key = SigningKey::generate_ed25519();
373        let public = key.verifying_key();
374        let mut req = signed_request(&key);
375        let input_raw = req
376            .headers()
377            .get(SIGNATURE_INPUT_HEADER)
378            .unwrap()
379            .to_str()
380            .unwrap()
381            .to_owned()
382            + r", attacker=()";
383        req.headers_mut()
384            .insert(SIGNATURE_INPUT_HEADER, input_raw.parse().unwrap());
385
386        let policy = VerifyPolicy {
387            allow_multiple_signatures: true,
388            ..VerifyPolicy::no_freshness_check()
389        };
390        rfc9421_verify_with_policy(
391            &req,
392            &policy,
393            DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
394            |_| Ok(public.clone()),
395        )
396        .expect("the valid sig1 label must still verify");
397    }
398
399    #[test]
400    fn mastodon_policy_rejects_signature_without_target_uri_component() {
401        // A signature covering `@method` + `content-digest` but not
402        // `@target-uri` can be replayed verbatim against a different
403        // path on the same server; the policy MUST cut it off before
404        // any crypto work runs.
405        use chrono::DateTime;
406
407        use crate::rfc9421::Component;
408
409        let key = SigningKey::generate_ed25519();
410        let public = key.verifying_key();
411        let body = b"{}";
412        let mut req = Request::builder()
413            .method(Method::POST)
414            .uri("https://example.com/inbox?a=1")
415            .header("host", "example.com")
416            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
417            .header("content-digest", content_digest_header(body))
418            .body(body.to_vec())
419            .expect("valid");
420        Rfc9421Signer::new(&key, "kid")
421            .with_components(vec![
422                Component::Method,
423                Component::Header("content-digest".into()),
424            ])
425            .with_created(1_700_000_000)
426            .sign(&mut req)
427            .expect("sign");
428
429        let err = rfc9421_verify_with_policy(
430            &req,
431            &VerifyPolicy::mastodon(),
432            DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
433            |_| Ok(public.clone()),
434        )
435        .expect_err("missing `@target-uri` must be rejected by the Mastodon policy");
436        assert!(
437            matches!(&err, Error::RequiredHeaderAbsent(name) if name == "@target-uri"),
438            "unexpected error variant: {err:?}",
439        );
440    }
441
442    #[test]
443    fn mastodon_policy_rejects_signature_without_content_digest_component() {
444        // Same shape as the previous test but the covered set now
445        // omits `content-digest`: an intermediary could replay the
446        // signed `@method` + `@target-uri` against a different body.
447        use chrono::DateTime;
448
449        use crate::rfc9421::Component;
450
451        let key = SigningKey::generate_ed25519();
452        let public = key.verifying_key();
453        let body = b"{}";
454        let mut req = Request::builder()
455            .method(Method::POST)
456            .uri("https://example.com/inbox?a=1")
457            .header("host", "example.com")
458            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
459            .header("content-digest", content_digest_header(body))
460            .body(body.to_vec())
461            .expect("valid");
462        Rfc9421Signer::new(&key, "kid")
463            .with_components(vec![Component::Method, Component::TargetUri])
464            .with_created(1_700_000_000)
465            .sign(&mut req)
466            .expect("sign");
467
468        let err = rfc9421_verify_with_policy(
469            &req,
470            &VerifyPolicy::mastodon(),
471            DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
472            |_| Ok(public.clone()),
473        )
474        .expect_err("missing `content-digest` must be rejected");
475        assert!(
476            matches!(&err, Error::RequiredHeaderAbsent(name) if name == "content-digest"),
477            "unexpected: {err:?}",
478        );
479    }
480
481    #[test]
482    fn no_freshness_check_policy_tolerates_minimal_covered_components() {
483        // Byte-level conformance tests against static RFC 9421
484        // fixtures may exercise sparse inner lists; the
485        // freshness-disabled preset MUST also disable the
486        // required-components gate so those fixtures still verify.
487        use crate::rfc9421::Component;
488
489        let key = SigningKey::generate_ed25519();
490        let public = key.verifying_key();
491        let body = b"{}";
492        let mut req = Request::builder()
493            .method(Method::POST)
494            .uri("https://example.com/inbox")
495            .header("host", "example.com")
496            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
497            .body(body.to_vec())
498            .expect("valid");
499        Rfc9421Signer::new(&key, "kid")
500            .with_components(vec![Component::Method])
501            .sign(&mut req)
502            .expect("sign");
503
504        rfc9421_verify(&req, |_| Ok(public.clone()))
505            .expect("no_freshness_check preset must not enforce required components");
506    }
507
508    #[test]
509    fn unknown_alg_hint_does_not_short_circuit_multi_label_verification() {
510        // Regression for the `?` short-circuit bug: when an earlier
511        // label carries an unrecognised `alg=` parameter the verifier
512        // must skip it and keep trying later labels, not abort the
513        // entire function.
514        let key = SigningKey::generate_ed25519();
515        let public = key.verifying_key();
516        let mut req = signed_request(&key);
517
518        // Tamper the produced Signature-Input header to claim an
519        // unknown algorithm for the single present label. The
520        // resolver still returns a valid key; prior to the fix the
521        // `?` on `parse_alg_hint` bubbled `UnsupportedAlgorithm` out
522        // of the function.
523        let input_raw = req
524            .headers()
525            .get(SIGNATURE_INPUT_HEADER)
526            .unwrap()
527            .to_str()
528            .unwrap()
529            .replace(r#"alg="ed25519""#, r#"alg="bogus-alg""#);
530        req.headers_mut()
531            .insert(SIGNATURE_INPUT_HEADER, input_raw.parse().unwrap());
532
533        let err = rfc9421_verify(&req, |_| Ok(public.clone()))
534            .expect_err("unknown alg hint must surface as the last recorded error");
535        assert!(matches!(err, Error::UnsupportedAlgorithm(_)));
536    }
537}