Skip to main content

actpub_httpsig/
verify.rs

1//! High-level, flavour-autodetecting verification entry point.
2//!
3//! A request carrying a `Signature-Input:` header is treated as RFC 9421;
4//! otherwise a `Signature:` header alone is treated as Cavage draft-12.
5//! This matches how Mastodon 4.5+ negotiates between the two stacks on
6//! the receiving side, and lets callers verify either kind with one
7//! function call.
8
9use chrono::{DateTime, Utc};
10use http::Request;
11
12use crate::cavage::{CavageVerified, cavage_verify, cavage_verify_with_policy};
13use crate::error::Error;
14use crate::key::VerifyingKey;
15use crate::policy::VerifyPolicy;
16use crate::rfc9421::{
17    Rfc9421Verified, SIGNATURE_INPUT_HEADER, rfc9421_verify, rfc9421_verify_with_policy,
18};
19
20/// Report summarising a successful verification.
21#[derive(Debug, Clone)]
22#[non_exhaustive]
23pub enum Verified {
24    /// The request was signed using the Cavage draft-12 flavour.
25    Cavage(CavageVerified),
26    /// The request was signed using RFC 9421.
27    Rfc9421(Rfc9421Verified),
28}
29
30impl Verified {
31    /// Returns the `keyId` / `keyid` that identified the signer.
32    #[must_use]
33    pub fn key_id(&self) -> &str {
34        match self {
35            Self::Cavage(c) => &c.key_id,
36            Self::Rfc9421(r) => r.input.keyid.as_deref().unwrap_or_default(),
37        }
38    }
39
40    /// Returns the signature base string that was verified, for audit
41    /// logging and troubleshooting.
42    #[must_use]
43    pub fn signature_base(&self) -> &str {
44        match self {
45            Self::Cavage(c) => &c.signature_base,
46            Self::Rfc9421(r) => &r.signature_base,
47        }
48    }
49}
50
51/// Verifies a signed HTTP request, autodetecting the signature flavour.
52///
53/// If the request carries a `Signature-Input:` header the RFC 9421
54/// verifier is used; otherwise the Cavage draft-12 verifier is tried.
55/// The resolver is called with the signer's `keyId` to fetch a
56/// [`VerifyingKey`].
57///
58/// # Errors
59///
60/// Propagates every error surface of the two underlying verifiers.
61/// [`Error::MissingHeader`] is returned when neither `Signature-Input:`
62/// nor `Signature:` is present.
63pub fn verify<B, F>(req: &Request<B>, mut resolve_key: F) -> Result<Verified, Error>
64where
65    F: FnMut(&str) -> Result<VerifyingKey, Error>,
66{
67    if req.headers().contains_key(SIGNATURE_INPUT_HEADER) {
68        return rfc9421_verify(req, &mut resolve_key).map(Verified::Rfc9421);
69    }
70    cavage_verify(req, |kid| resolve_key(kid)).map(Verified::Cavage)
71}
72
73/// Verifies a signed HTTP request **with replay-protection**, picking
74/// the correct flavour automatically.
75///
76/// This is [`verify`]'s policy-aware companion: both `VerifyPolicy` and
77/// a `now` timestamp are threaded through to the underlying verifier.
78///
79/// # Errors
80///
81/// Propagates every error surface of [`cavage_verify_with_policy`] and
82/// [`rfc9421_verify_with_policy`].
83pub fn verify_with_policy<B, F>(
84    req: &Request<B>,
85    policy: &VerifyPolicy,
86    now: DateTime<Utc>,
87    mut resolve_key: F,
88) -> Result<Verified, Error>
89where
90    F: FnMut(&str) -> Result<VerifyingKey, Error>,
91{
92    if req.headers().contains_key(SIGNATURE_INPUT_HEADER) {
93        return rfc9421_verify_with_policy(req, policy, now, &mut resolve_key)
94            .map(Verified::Rfc9421);
95    }
96    cavage_verify_with_policy(req, policy, now, |kid| resolve_key(kid)).map(Verified::Cavage)
97}
98
99#[cfg(test)]
100mod tests {
101    use http::{Method, Request};
102    use pretty_assertions::assert_eq;
103
104    use super::*;
105    use crate::cavage::CavageSigner;
106    use crate::digest::sha256_digest_header;
107    use crate::key::SigningKey;
108    use crate::rfc9421::Rfc9421Signer;
109
110    fn base_request(body: &[u8]) -> Request<Vec<u8>> {
111        Request::builder()
112            .method(Method::POST)
113            .uri("https://example.com/inbox")
114            .header("host", "example.com")
115            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
116            .header("digest", sha256_digest_header(body))
117            .header("content-type", "application/activity+json")
118            .body(body.to_vec())
119            .expect("valid")
120    }
121
122    #[test]
123    fn cavage_signed_request_is_dispatched_to_cavage_verifier() {
124        let key = SigningKey::generate_ed25519();
125        let public = key.verifying_key();
126        let mut req = base_request(b"{}");
127        CavageSigner::new(&key, "https://example.com/actor#kid")
128            .sign(&mut req)
129            .expect("sign");
130
131        let report = verify(&req, |_| Ok(public.clone())).expect("verify");
132        assert!(matches!(report, Verified::Cavage(_)));
133        assert_eq!(report.key_id(), "https://example.com/actor#kid");
134    }
135
136    #[test]
137    fn rfc9421_signed_request_is_dispatched_to_rfc9421_verifier() {
138        let key = SigningKey::generate_ed25519();
139        let public = key.verifying_key();
140        let mut req = base_request(b"{}");
141        Rfc9421Signer::new(&key, "https://example.com/actor#kid")
142            .sign(&mut req)
143            .expect("sign");
144
145        let report = verify(&req, |_| Ok(public.clone())).expect("verify");
146        assert!(matches!(report, Verified::Rfc9421(_)));
147        assert_eq!(report.key_id(), "https://example.com/actor#kid");
148    }
149
150    #[test]
151    fn rfc9421_takes_precedence_over_cavage_when_both_are_present() {
152        // Dual-signed outbound messages (some deployments attach both for
153        // broad compatibility). Verifier should prefer the modern flavour.
154        let key = SigningKey::generate_ed25519();
155        let public = key.verifying_key();
156        let mut req = base_request(b"{}");
157        CavageSigner::new(&key, "cavage-kid")
158            .sign(&mut req)
159            .expect("sign cavage");
160        Rfc9421Signer::new(&key, "rfc9421-kid")
161            .sign(&mut req)
162            .expect("sign 9421");
163
164        let report = verify(&req, |_| Ok(public.clone())).expect("verify");
165        assert!(matches!(report, Verified::Rfc9421(_)));
166        assert_eq!(report.key_id(), "rfc9421-kid");
167    }
168
169    #[test]
170    fn unsigned_request_returns_missing_header_error() {
171        let req = base_request(b"{}");
172        let err =
173            verify(&req, |_| panic!("resolver must not be called")).expect_err("unsigned request");
174        assert!(matches!(err, Error::MissingHeader(_)));
175    }
176
177    #[test]
178    fn policy_rejects_cavage_signature_older_than_max_age() {
179        let key = SigningKey::generate_ed25519();
180        let public = key.verifying_key();
181        let mut req = base_request(b"{}");
182        CavageSigner::new(&key, "kid")
183            .with_created(1_700_000_000)
184            .sign(&mut req)
185            .expect("sign");
186
187        // `now` is 20 hours ahead of `created` — well beyond Mastodon's 12h window.
188        let now = DateTime::<Utc>::from_timestamp(1_700_000_000 + 20 * 3600, 0).expect("valid");
189        let err = verify_with_policy(&req, &VerifyPolicy::mastodon(), now, |_| Ok(public.clone()))
190            .expect_err("stale signature must be rejected");
191        assert!(matches!(err, Error::TimestampTooOld { .. }));
192    }
193
194    #[test]
195    fn policy_rejects_rfc9421_signature_in_the_future() {
196        let key = SigningKey::generate_ed25519();
197        let public = key.verifying_key();
198        let mut req = base_request(b"{}");
199        // Future `created` — 15 minutes ahead of our `now`.
200        Rfc9421Signer::new(&key, "kid")
201            .with_created(1_700_000_000 + 15 * 60)
202            .sign(&mut req)
203            .expect("sign");
204
205        let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid");
206        let err = verify_with_policy(&req, &VerifyPolicy::mastodon(), now, |_| Ok(public.clone()))
207            .expect_err("future-dated signature must be rejected");
208        assert!(matches!(err, Error::TimestampInFuture { .. }));
209    }
210
211    #[test]
212    fn policy_accepts_signature_within_skew_tolerance() {
213        let key = SigningKey::generate_ed25519();
214        let public = key.verifying_key();
215        let mut req = base_request(b"{}");
216        // 1 minute into the future — within the Mastodon 5-minute skew window.
217        Rfc9421Signer::new(&key, "kid")
218            .with_created(1_700_000_000 + 60)
219            .sign(&mut req)
220            .expect("sign");
221
222        let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid");
223        verify_with_policy(&req, &VerifyPolicy::mastodon(), now, |_| Ok(public.clone()))
224            .expect("signature within skew tolerance must verify");
225    }
226}