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/// Headers whose values [`Verified::signature_base_redacted`] replaces
21/// with a placeholder.
22///
23/// All three are low-entropy authentication credentials that a signer
24/// should never cover with a signature in the first place, but a
25/// defensive logger still wants to strip them from an audit trail
26/// before writing the string to disk.
27pub const REDACTED_HEADERS_DEFAULT: &[&str] = &["authorization", "cookie", "proxy-authorization"];
28
29/// Report summarising a successful verification.
30#[derive(Debug, Clone)]
31#[non_exhaustive]
32pub enum Verified {
33    /// The request was signed using the Cavage draft-12 flavour.
34    Cavage(CavageVerified),
35    /// The request was signed using RFC 9421.
36    Rfc9421(Rfc9421Verified),
37}
38
39impl Verified {
40    /// Returns the `keyId` / `keyid` that identified the signer.
41    #[must_use]
42    pub fn key_id(&self) -> &str {
43        match self {
44            Self::Cavage(c) => &c.key_id,
45            Self::Rfc9421(r) => r.input.keyid.as_deref().unwrap_or_default(),
46        }
47    }
48
49    /// Returns the signature base string that was verified, for audit
50    /// logging and troubleshooting.
51    ///
52    /// **Security note.** The signature base contains the literal
53    /// value of every header that participated in the signature,
54    /// including anything sensitive the signer accidentally covered
55    /// (typically nothing on `ActivityPub`, but defence-in-depth still
56    /// matters). Prefer [`Self::signature_base_redacted`] for any
57    /// log line that might be captured by a third party.
58    #[must_use]
59    pub fn signature_base(&self) -> &str {
60        match self {
61            Self::Cavage(c) => &c.signature_base,
62            Self::Rfc9421(r) => &r.signature_base,
63        }
64    }
65
66    /// Returns the signature base string with the values of any header
67    /// named in `sensitive_headers` replaced by `<redacted>`.
68    ///
69    /// The headers are matched case-insensitively against the line
70    /// prefix that [`build_signature_base`](crate::rfc9421) /
71    /// [`build_signature_base`](crate::cavage) emit; entries not
72    /// present in the signature base pass through unchanged.
73    ///
74    /// Pass [`REDACTED_HEADERS_DEFAULT`] to match the header set this
75    /// crate considers sensitive by default.
76    #[must_use]
77    pub fn signature_base_redacted(&self, sensitive_headers: &[&str]) -> String {
78        let base = self.signature_base();
79        let mut out = String::with_capacity(base.len());
80        for line in base.split_inclusive('\n') {
81            out.push_str(&redact_line(line, sensitive_headers));
82        }
83        out
84    }
85}
86
87fn redact_line(line: &str, sensitive: &[&str]) -> String {
88    let trimmed = line.trim_end_matches('\n');
89    let has_newline = line.ends_with('\n');
90    let sensitive_hit = sensitive.iter().any(|h| line_header_matches(trimmed, h));
91    let Some((prefix, _)) = trimmed.split_once(':').filter(|_| sensitive_hit) else {
92        return line.to_owned();
93    };
94    let mut out = String::with_capacity(prefix.len() + 16);
95    out.push_str(prefix);
96    out.push_str(": <redacted>");
97    if has_newline {
98        out.push('\n');
99    }
100    out
101}
102
103/// Whether `line` is of the form `"<name>": …` for one of the
104/// two signature-base grammars (RFC 9421's quoted form or Cavage's
105/// pseudo-header form), case-insensitively matching `name`.
106fn line_header_matches(line: &str, name: &str) -> bool {
107    let stripped = line
108        .strip_prefix('"')
109        .and_then(|s| s.split_once("\":"))
110        .map(|(n, _)| n);
111    let cavage = line.split_once(':').map(|(n, _)| n);
112    stripped
113        .or(cavage)
114        .is_some_and(|found| found.eq_ignore_ascii_case(name))
115}
116
117/// Verifies a signed HTTP request, autodetecting the signature flavour.
118///
119/// If the request carries a `Signature-Input:` header the RFC 9421
120/// verifier is used; otherwise the Cavage draft-12 verifier is tried.
121/// The resolver is called with the signer's `keyId` to fetch a
122/// [`VerifyingKey`].
123///
124/// # Errors
125///
126/// Propagates every error surface of the two underlying verifiers.
127/// [`Error::MissingHeader`] is returned when neither `Signature-Input:`
128/// nor `Signature:` is present.
129pub fn verify<B, F>(req: &Request<B>, mut resolve_key: F) -> Result<Verified, Error>
130where
131    F: FnMut(&str) -> Result<VerifyingKey, Error>,
132{
133    if req.headers().contains_key(SIGNATURE_INPUT_HEADER) {
134        return rfc9421_verify(req, &mut resolve_key).map(Verified::Rfc9421);
135    }
136    cavage_verify(req, |kid| resolve_key(kid)).map(Verified::Cavage)
137}
138
139/// Verifies a signed HTTP request **with replay-protection**, picking
140/// the correct flavour automatically.
141///
142/// This is [`verify`]'s policy-aware companion: both `VerifyPolicy` and
143/// a `now` timestamp are threaded through to the underlying verifier.
144///
145/// # Errors
146///
147/// Propagates every error surface of [`cavage_verify_with_policy`] and
148/// [`rfc9421_verify_with_policy`].
149pub fn verify_with_policy<B, F>(
150    req: &Request<B>,
151    policy: &VerifyPolicy,
152    now: DateTime<Utc>,
153    mut resolve_key: F,
154) -> Result<Verified, Error>
155where
156    F: FnMut(&str) -> Result<VerifyingKey, Error>,
157{
158    if req.headers().contains_key(SIGNATURE_INPUT_HEADER) {
159        return rfc9421_verify_with_policy(req, policy, now, &mut resolve_key)
160            .map(Verified::Rfc9421);
161    }
162    cavage_verify_with_policy(req, policy, now, |kid| resolve_key(kid)).map(Verified::Cavage)
163}
164
165#[cfg(test)]
166mod tests {
167    use http::{Method, Request};
168    use pretty_assertions::assert_eq;
169
170    use super::*;
171    use crate::cavage::CavageSigner;
172    use crate::content_digest::content_digest_header;
173    use crate::digest::sha256_digest_header;
174    use crate::key::SigningKey;
175    use crate::rfc9421::Rfc9421Signer;
176
177    fn base_request(body: &[u8]) -> Request<Vec<u8>> {
178        // The autodetecting verifier handles both Cavage (legacy
179        // `Digest:`) and RFC 9421 (modern `Content-Digest:`), so the
180        // fixture carries both headers simultaneously — real dual-stack
181        // deployments do the same.
182        Request::builder()
183            .method(Method::POST)
184            .uri("https://example.com/inbox")
185            .header("host", "example.com")
186            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
187            .header("digest", sha256_digest_header(body))
188            .header("content-digest", content_digest_header(body))
189            .header("content-type", "application/activity+json")
190            .body(body.to_vec())
191            .expect("valid")
192    }
193
194    #[test]
195    fn cavage_signed_request_is_dispatched_to_cavage_verifier() {
196        let key = SigningKey::generate_ed25519();
197        let public = key.verifying_key();
198        let mut req = base_request(b"{}");
199        CavageSigner::new(&key, "https://example.com/actor#kid")
200            .sign(&mut req)
201            .expect("sign");
202
203        let report = verify(&req, |_| Ok(public.clone())).expect("verify");
204        assert!(matches!(report, Verified::Cavage(_)));
205        assert_eq!(report.key_id(), "https://example.com/actor#kid");
206    }
207
208    #[test]
209    fn rfc9421_signed_request_is_dispatched_to_rfc9421_verifier() {
210        let key = SigningKey::generate_ed25519();
211        let public = key.verifying_key();
212        let mut req = base_request(b"{}");
213        Rfc9421Signer::new(&key, "https://example.com/actor#kid")
214            .sign(&mut req)
215            .expect("sign");
216
217        let report = verify(&req, |_| Ok(public.clone())).expect("verify");
218        assert!(matches!(report, Verified::Rfc9421(_)));
219        assert_eq!(report.key_id(), "https://example.com/actor#kid");
220    }
221
222    #[test]
223    fn rfc9421_takes_precedence_over_cavage_when_both_are_present() {
224        // Dual-signed outbound messages (some deployments attach both for
225        // broad compatibility). Verifier should prefer the modern flavour.
226        let key = SigningKey::generate_ed25519();
227        let public = key.verifying_key();
228        let mut req = base_request(b"{}");
229        CavageSigner::new(&key, "cavage-kid")
230            .sign(&mut req)
231            .expect("sign cavage");
232        Rfc9421Signer::new(&key, "rfc9421-kid")
233            .sign(&mut req)
234            .expect("sign 9421");
235
236        let report = verify(&req, |_| Ok(public.clone())).expect("verify");
237        assert!(matches!(report, Verified::Rfc9421(_)));
238        assert_eq!(report.key_id(), "rfc9421-kid");
239    }
240
241    #[test]
242    fn unsigned_request_returns_missing_header_error() {
243        let req = base_request(b"{}");
244        let err =
245            verify(&req, |_| panic!("resolver must not be called")).expect_err("unsigned request");
246        assert!(matches!(err, Error::MissingHeader(_)));
247    }
248
249    #[test]
250    fn policy_rejects_cavage_signature_older_than_max_age() {
251        let key = SigningKey::generate_ed25519();
252        let public = key.verifying_key();
253        let mut req = base_request(b"{}");
254        CavageSigner::new(&key, "kid")
255            .with_created(1_700_000_000)
256            .sign(&mut req)
257            .expect("sign");
258
259        // `now` is 20 hours ahead of `created` — well beyond Mastodon's 12h window.
260        let now = DateTime::<Utc>::from_timestamp(1_700_000_000 + 20 * 3600, 0).expect("valid");
261        let err = verify_with_policy(&req, &VerifyPolicy::mastodon(), now, |_| Ok(public.clone()))
262            .expect_err("stale signature must be rejected");
263        assert!(matches!(err, Error::TimestampTooOld { .. }));
264    }
265
266    #[test]
267    fn policy_rejects_rfc9421_signature_in_the_future() {
268        let key = SigningKey::generate_ed25519();
269        let public = key.verifying_key();
270        let mut req = base_request(b"{}");
271        // Future `created` — 15 minutes ahead of our `now`.
272        Rfc9421Signer::new(&key, "kid")
273            .with_created(1_700_000_000 + 15 * 60)
274            .sign(&mut req)
275            .expect("sign");
276
277        let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid");
278        let err = verify_with_policy(&req, &VerifyPolicy::mastodon(), now, |_| Ok(public.clone()))
279            .expect_err("future-dated signature must be rejected");
280        assert!(matches!(err, Error::TimestampInFuture { .. }));
281    }
282
283    #[test]
284    fn signature_base_redacted_masks_sensitive_header_values_for_cavage() {
285        let key = SigningKey::generate_ed25519();
286        let public = key.verifying_key();
287        let secret = "Bearer s3cr3t-token";
288        let mut req = base_request(b"{}");
289        req.headers_mut()
290            .insert("authorization", secret.parse().unwrap());
291
292        CavageSigner::new(&key, "kid")
293            .with_headers(["(request-target)", "host", "date", "authorization"])
294            .sign(&mut req)
295            .expect("sign");
296
297        let report = verify(&req, |_| Ok(public.clone())).expect("verify");
298        let redacted = report.signature_base_redacted(REDACTED_HEADERS_DEFAULT);
299        assert!(!redacted.contains(secret), "token must be scrubbed");
300        assert!(
301            redacted.contains("authorization: <redacted>"),
302            "redaction marker must be emitted: {redacted}",
303        );
304        assert!(
305            report.signature_base().contains(secret),
306            "non-redacted accessor must still expose the original value",
307        );
308    }
309
310    #[test]
311    fn signature_base_redacted_masks_sensitive_header_values_for_rfc9421() {
312        use crate::rfc9421::Component;
313
314        let key = SigningKey::generate_ed25519();
315        let public = key.verifying_key();
316        let secret = "SessionID=opaque";
317        let mut req = base_request(b"{}");
318        req.headers_mut().insert("cookie", secret.parse().unwrap());
319
320        Rfc9421Signer::new(&key, "kid")
321            .with_components(vec![
322                Component::Method,
323                Component::TargetUri,
324                Component::Header("cookie".into()),
325            ])
326            .sign(&mut req)
327            .expect("sign");
328
329        let report = verify(&req, |_| Ok(public.clone())).expect("verify");
330        let redacted = report.signature_base_redacted(REDACTED_HEADERS_DEFAULT);
331        assert!(!redacted.contains(secret), "cookie must be scrubbed");
332        assert!(
333            redacted.contains("\"cookie\": <redacted>"),
334            "RFC 9421 quoted-name lines must be recognised: {redacted}",
335        );
336    }
337
338    #[test]
339    fn policy_accepts_signature_within_skew_tolerance() {
340        let key = SigningKey::generate_ed25519();
341        let public = key.verifying_key();
342        let mut req = base_request(b"{}");
343        // 1 minute into the future — within the Mastodon 5-minute skew window.
344        Rfc9421Signer::new(&key, "kid")
345            .with_created(1_700_000_000 + 60)
346            .sign(&mut req)
347            .expect("sign");
348
349        let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid");
350        verify_with_policy(&req, &VerifyPolicy::mastodon(), now, |_| Ok(public.clone()))
351            .expect("signature within skew tolerance must verify");
352    }
353}