Skip to main content

actpub_httpsig/cavage/
verify.rs

1//! Cavage draft-12 request verifier.
2
3use base64ct::{Base64, Encoding};
4use chrono::{DateTime, Utc};
5use http::Request;
6
7use crate::cavage::canonical::{Timestamps, build_signature_base};
8use crate::cavage::header::{CavageHeaderParams, SIGNATURE_HEADER};
9use crate::error::Error;
10use crate::key::{Algorithm, VerifyingKey};
11use crate::policy::VerifyPolicy;
12
13/// Successful verification report.
14#[derive(Debug, Clone)]
15#[non_exhaustive]
16pub struct CavageVerified {
17    /// The `keyId=` parameter from the signature header.
18    pub key_id: String,
19    /// Algorithm hint as it appeared on the wire, if any.
20    pub algorithm: Option<String>,
21    /// The canonical signature base string that was verified.
22    pub signature_base: String,
23}
24
25/// Verifies a Cavage-signed request against a key returned by
26/// `resolve_key(key_id)`.
27///
28/// The resolver closure is where the caller performs `WebFinger` lookup, a
29/// database fetch or any other means of turning a `keyId` URI into a
30/// [`VerifyingKey`]. The closure fails whenever the key cannot be found
31/// or the caller wants to reject the actor for policy reasons.
32///
33/// # Errors
34///
35/// Returns [`Error::MissingHeader`] if the request lacks a `Signature:`
36/// header; [`Error::MalformedSignatureHeader`] /
37/// [`Error::MissingSignatureParameter`] if the header is unparseable;
38/// [`Error::KeyResolution`] if the resolver fails; and
39/// [`Error::VerificationFailed`] if the signature does not match.
40pub fn cavage_verify<B, F>(req: &Request<B>, resolve_key: F) -> Result<CavageVerified, Error>
41where
42    F: FnOnce(&str) -> Result<VerifyingKey, Error>,
43{
44    cavage_verify_with_policy(
45        req,
46        &VerifyPolicy::no_freshness_check(),
47        Utc::now(),
48        resolve_key,
49    )
50}
51
52/// Verifies a Cavage-signed request **with replay-protection**.
53///
54/// Equivalent to [`cavage_verify`] except that `policy` is consulted to
55/// reject stale, future-dated or expired timestamps against `now`. Most
56/// production callers should use this form with
57/// [`VerifyPolicy::mastodon`] (or their own tuning) and `Utc::now()`.
58///
59/// # Errors
60///
61/// Same as [`cavage_verify`] plus [`Error::TimestampTooOld`],
62/// [`Error::TimestampInFuture`], [`Error::TimestampExpired`] and
63/// [`Error::TimestampMissing`] when the policy is violated.
64pub fn cavage_verify_with_policy<B, F>(
65    req: &Request<B>,
66    policy: &VerifyPolicy,
67    now: DateTime<Utc>,
68    resolve_key: F,
69) -> Result<CavageVerified, Error>
70where
71    F: FnOnce(&str) -> Result<VerifyingKey, Error>,
72{
73    let header = req
74        .headers()
75        .get(SIGNATURE_HEADER)
76        .ok_or(Error::MissingHeader(SIGNATURE_HEADER))?;
77    let raw = header.to_str().map_err(|e| Error::InvalidHeader {
78        name: SIGNATURE_HEADER,
79        reason: e.to_string(),
80    })?;
81
82    let params = CavageHeaderParams::parse(raw)?;
83
84    // Freshness check runs *before* the cryptographic verification so
85    // that replayed or expired signatures are rejected without taking a
86    // cryptographic-work timing hit.
87    let date_header = req
88        .headers()
89        .get(http::header::DATE)
90        .and_then(|v| v.to_str().ok());
91    policy.check(params.created, params.expires, date_header, now)?;
92
93    let key = resolve_key(&params.key_id).map_err(|e| Error::KeyResolution(e.to_string()))?;
94
95    // Cross-check algorithm hint when supplied.
96    if let Some(hint) = params.algorithm.as_deref()
97        && let Some(hinted) = Algorithm::parse(hint)?
98        && hinted != key.algorithm()
99    {
100        return Err(Error::VerificationFailed);
101    }
102
103    let base = build_signature_base(
104        req,
105        &params.headers,
106        Timestamps {
107            created: params.created,
108            expires: params.expires,
109        },
110    )?;
111
112    let mut sig_bytes = vec![0u8; params.signature.len()];
113    let sig = Base64::decode(&params.signature, &mut sig_bytes)?;
114    key.verify(base.as_bytes(), sig)?;
115
116    Ok(CavageVerified {
117        key_id: params.key_id,
118        algorithm: params.algorithm,
119        signature_base: base,
120    })
121}
122
123#[cfg(test)]
124mod tests {
125    use http::{Method, Request};
126    use pretty_assertions::assert_eq;
127
128    use super::*;
129    use crate::cavage::sign::CavageSigner;
130    use crate::digest::sha256_digest_header;
131    use crate::key::{RsaBits, SigningKey};
132
133    fn sample_signed_request(key: &SigningKey, body: &[u8]) -> Request<Vec<u8>> {
134        let mut req = Request::builder()
135            .method(Method::POST)
136            .uri("https://example.com/inbox?a=1")
137            .header("host", "example.com")
138            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
139            .header("digest", sha256_digest_header(body))
140            .header("content-type", "application/activity+json")
141            .body(body.to_vec())
142            .expect("valid");
143        CavageSigner::new(key, "https://example.com/actors/alice#main-key")
144            .sign(&mut req)
145            .expect("sign");
146        req
147    }
148
149    #[test]
150    fn ed25519_signature_roundtrips_sign_then_verify() {
151        let key = SigningKey::generate_ed25519();
152        let public = key.verifying_key();
153        let req = sample_signed_request(&key, b"{}");
154
155        let report = cavage_verify(&req, |kid| {
156            assert_eq!(kid, "https://example.com/actors/alice#main-key");
157            Ok(public.clone())
158        })
159        .expect("verify must succeed");
160
161        assert_eq!(report.key_id, "https://example.com/actors/alice#main-key");
162        assert!(
163            report
164                .signature_base
165                .contains("(request-target): post /inbox?a=1")
166        );
167    }
168
169    #[test]
170    fn rsa_sha256_signature_roundtrips_sign_then_verify() {
171        let key = SigningKey::generate_rsa(RsaBits::Rsa2048).expect("rng");
172        let public = key.verifying_key();
173        let req = sample_signed_request(&key, b"{}");
174        cavage_verify(&req, |_| Ok(public.clone())).expect("verify must succeed");
175    }
176
177    #[test]
178    fn tampered_body_fails_verification_via_digest_loop() {
179        // When the body changes the `Digest:` header embedded in the
180        // signature base still reflects the original body, so the
181        // signature verifies. The purpose of digest is to let a caller
182        // who *also* re-hashes the body detect tampering; verifying only
183        // the signature is insufficient. This test documents that
184        // behaviour: we expect the signature to still verify here.
185        let key = SigningKey::generate_ed25519();
186        let public = key.verifying_key();
187        let mut req = sample_signed_request(&key, b"original");
188        *req.body_mut() = b"tampered".to_vec();
189        cavage_verify(&req, |_| Ok(public.clone()))
190            .expect("signature alone does not depend on body bytes");
191    }
192
193    #[test]
194    fn tampered_date_header_fails_verification() {
195        let key = SigningKey::generate_ed25519();
196        let public = key.verifying_key();
197        let mut req = sample_signed_request(&key, b"{}");
198        req.headers_mut().insert(
199            "date",
200            "Mon, 06 Jan 2014 00:00:00 GMT".parse().expect("valid"),
201        );
202        let err = cavage_verify(&req, |_| Ok(public.clone())).expect_err("tampered date must fail");
203        assert!(matches!(err, Error::VerificationFailed));
204    }
205
206    #[test]
207    fn missing_signature_header_is_reported() {
208        let req: Request<Vec<u8>> = Request::builder()
209            .method(Method::POST)
210            .uri("https://example.com/inbox")
211            .body(Vec::new())
212            .unwrap();
213        let err = cavage_verify(&req, |_| panic!("resolver must not be called"))
214            .expect_err("missing Signature header");
215        assert!(matches!(err, Error::MissingHeader("signature")));
216    }
217
218    #[test]
219    fn key_resolver_error_is_surfaced() {
220        let key = SigningKey::generate_ed25519();
221        let req = sample_signed_request(&key, b"{}");
222        let err =
223            cavage_verify(&req, |_| Err(Error::VerificationFailed)).expect_err("resolver failed");
224        assert!(matches!(err, Error::KeyResolution(_)));
225    }
226
227    #[test]
228    fn algorithm_mismatch_between_hint_and_key_rejects() {
229        // Sign with Ed25519 but claim rsa-sha256 in the header.
230        let key = SigningKey::generate_ed25519();
231        let public_rsa = SigningKey::generate_rsa(RsaBits::Rsa2048)
232            .expect("rng")
233            .verifying_key();
234        let mut req = sample_signed_request(&key, b"{}");
235        let original_header = req
236            .headers()
237            .get(SIGNATURE_HEADER)
238            .unwrap()
239            .to_str()
240            .unwrap()
241            .replace(r#"algorithm="ed25519""#, r#"algorithm="rsa-sha256""#);
242        req.headers_mut()
243            .insert(SIGNATURE_HEADER, original_header.parse().unwrap());
244
245        let err = cavage_verify(&req, |_| Ok(public_rsa.clone()))
246            .expect_err("algorithm mismatch must fail");
247        assert!(matches!(err, Error::VerificationFailed));
248    }
249}