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::{CavageHeaderSet, 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    // Enforce the required Cavage header set *before* any crypto
85    // work: a signature that omits `(request-target)` or `host` can be
86    // replayed verbatim against different paths or virtual hosts, so
87    // we reject it at the cheapest possible layer.
88    enforce_required_headers(&params.headers, policy.cavage_required_headers)?;
89
90    // Freshness check runs *before* the cryptographic verification so
91    // that replayed or expired signatures are rejected without taking a
92    // cryptographic-work timing hit.
93    let date_header = req
94        .headers()
95        .get(http::header::DATE)
96        .and_then(|v| v.to_str().ok());
97    policy.check(params.created, params.expires, date_header, now)?;
98
99    let key = resolve_key(&params.key_id).map_err(|e| Error::KeyResolution(e.to_string()))?;
100
101    // Cross-check algorithm hint when supplied.
102    if let Some(hint) = params.algorithm.as_deref()
103        && let Some(hinted) = Algorithm::parse(hint)?
104        && hinted != key.algorithm()
105    {
106        return Err(Error::VerificationFailed);
107    }
108
109    let base = build_signature_base(
110        req,
111        &params.headers,
112        Timestamps {
113            created: params.created,
114            expires: params.expires,
115        },
116    )?;
117
118    let mut sig_bytes = vec![0u8; params.signature.len()];
119    let sig = Base64::decode(&params.signature, &mut sig_bytes)?;
120    key.verify(base.as_bytes(), sig)?;
121
122    Ok(CavageVerified {
123        key_id: params.key_id,
124        algorithm: params.algorithm,
125        signature_base: base,
126    })
127}
128
129/// Rejects the signature when `signed` is missing any name in
130/// `required`. Names are matched case-insensitively, mirroring how HTTP
131/// headers themselves are handled throughout this crate.
132fn enforce_required_headers(signed: &CavageHeaderSet, required: &[&str]) -> Result<(), Error> {
133    for needed in required {
134        let present = signed.iter().any(|h| h.eq_ignore_ascii_case(needed));
135        if !present {
136            return Err(Error::RequiredHeaderAbsent((*needed).to_owned()));
137        }
138    }
139    Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144    use http::{Method, Request};
145    use pretty_assertions::assert_eq;
146
147    use super::*;
148    use crate::cavage::sign::CavageSigner;
149    use crate::digest::sha256_digest_header;
150    use crate::key::{RsaBits, SigningKey};
151
152    fn sample_signed_request(key: &SigningKey, body: &[u8]) -> Request<Vec<u8>> {
153        let mut req = Request::builder()
154            .method(Method::POST)
155            .uri("https://example.com/inbox?a=1")
156            .header("host", "example.com")
157            .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
158            .header("digest", sha256_digest_header(body))
159            .header("content-type", "application/activity+json")
160            .body(body.to_vec())
161            .expect("valid");
162        CavageSigner::new(key, "https://example.com/actors/alice#main-key")
163            .sign(&mut req)
164            .expect("sign");
165        req
166    }
167
168    #[test]
169    fn ed25519_signature_roundtrips_sign_then_verify() {
170        let key = SigningKey::generate_ed25519();
171        let public = key.verifying_key();
172        let req = sample_signed_request(&key, b"{}");
173
174        let report = cavage_verify(&req, |kid| {
175            assert_eq!(kid, "https://example.com/actors/alice#main-key");
176            Ok(public.clone())
177        })
178        .expect("verify must succeed");
179
180        assert_eq!(report.key_id, "https://example.com/actors/alice#main-key");
181        assert!(
182            report
183                .signature_base
184                .contains("(request-target): post /inbox?a=1")
185        );
186    }
187
188    #[test]
189    fn rsa_sha256_signature_roundtrips_sign_then_verify() {
190        let key = SigningKey::generate_rsa(RsaBits::Rsa2048).expect("rng");
191        let public = key.verifying_key();
192        let req = sample_signed_request(&key, b"{}");
193        cavage_verify(&req, |_| Ok(public.clone())).expect("verify must succeed");
194    }
195
196    #[test]
197    fn tampered_body_fails_verification_via_digest_loop() {
198        // When the body changes the `Digest:` header embedded in the
199        // signature base still reflects the original body, so the
200        // signature verifies. The purpose of digest is to let a caller
201        // who *also* re-hashes the body detect tampering; verifying only
202        // the signature is insufficient. This test documents that
203        // behaviour: we expect the signature to still verify here.
204        let key = SigningKey::generate_ed25519();
205        let public = key.verifying_key();
206        let mut req = sample_signed_request(&key, b"original");
207        *req.body_mut() = b"tampered".to_vec();
208        cavage_verify(&req, |_| Ok(public.clone()))
209            .expect("signature alone does not depend on body bytes");
210    }
211
212    #[test]
213    fn tampered_date_header_fails_verification() {
214        let key = SigningKey::generate_ed25519();
215        let public = key.verifying_key();
216        let mut req = sample_signed_request(&key, b"{}");
217        req.headers_mut().insert(
218            "date",
219            "Mon, 06 Jan 2014 00:00:00 GMT".parse().expect("valid"),
220        );
221        let err = cavage_verify(&req, |_| Ok(public.clone())).expect_err("tampered date must fail");
222        assert!(matches!(err, Error::VerificationFailed));
223    }
224
225    #[test]
226    fn missing_signature_header_is_reported() {
227        let req: Request<Vec<u8>> = Request::builder()
228            .method(Method::POST)
229            .uri("https://example.com/inbox")
230            .body(Vec::new())
231            .unwrap();
232        let err = cavage_verify(&req, |_| panic!("resolver must not be called"))
233            .expect_err("missing Signature header");
234        assert!(matches!(err, Error::MissingHeader("signature")));
235    }
236
237    #[test]
238    fn key_resolver_error_is_surfaced() {
239        let key = SigningKey::generate_ed25519();
240        let req = sample_signed_request(&key, b"{}");
241        let err =
242            cavage_verify(&req, |_| Err(Error::VerificationFailed)).expect_err("resolver failed");
243        assert!(matches!(err, Error::KeyResolution(_)));
244    }
245
246    #[test]
247    fn signature_missing_required_host_header_is_rejected() {
248        // The attacker supplies a valid signature that omits `host`
249        // from the covered header set. A replay against a different
250        // virtual host would succeed without this guard.
251        let key = SigningKey::generate_ed25519();
252        let public = key.verifying_key();
253        let mut req = sample_signed_request(&key, b"{}");
254        // Re-sign covering only (request-target) and date — no host.
255        CavageSigner::new(&key, "https://example.com/actors/alice#main-key")
256            .with_headers(["(request-target)", "date"])
257            .sign(&mut req)
258            .expect("sign");
259
260        let err = cavage_verify(&req, |_| Ok(public.clone()))
261            .expect_err("signature without `host` must be rejected");
262        assert!(matches!(err, Error::RequiredHeaderAbsent(name) if name == "host"));
263    }
264
265    #[test]
266    fn signature_missing_required_request_target_is_rejected() {
267        let key = SigningKey::generate_ed25519();
268        let public = key.verifying_key();
269        let mut req = sample_signed_request(&key, b"{}");
270        CavageSigner::new(&key, "kid")
271            .with_headers(["host", "date"])
272            .sign(&mut req)
273            .expect("sign");
274
275        let err = cavage_verify(&req, |_| Ok(public.clone()))
276            .expect_err("signature without `(request-target)` must be rejected");
277        assert!(matches!(err, Error::RequiredHeaderAbsent(name) if name == "(request-target)"));
278    }
279
280    #[test]
281    fn algorithm_mismatch_between_hint_and_key_rejects() {
282        // Sign with Ed25519 but claim rsa-sha256 in the header.
283        let key = SigningKey::generate_ed25519();
284        let public_rsa = SigningKey::generate_rsa(RsaBits::Rsa2048)
285            .expect("rng")
286            .verifying_key();
287        let mut req = sample_signed_request(&key, b"{}");
288        let original_header = req
289            .headers()
290            .get(SIGNATURE_HEADER)
291            .unwrap()
292            .to_str()
293            .unwrap()
294            .replace(r#"algorithm="ed25519""#, r#"algorithm="rsa-sha256""#);
295        req.headers_mut()
296            .insert(SIGNATURE_HEADER, original_header.parse().unwrap());
297
298        let err = cavage_verify(&req, |_| Ok(public_rsa.clone()))
299            .expect_err("algorithm mismatch must fail");
300        assert!(matches!(err, Error::VerificationFailed));
301    }
302}