Skip to main content

actpub_httpsig/
policy.rs

1//! Timestamp freshness / replay-protection policy for signature verification.
2//!
3//! The HTTP-Signatures specs (Cavage draft-12 §2.1.2 and RFC 9421
4//! §7.2.2) both warn that signatures alone do **not** protect against
5//! replay attacks: an intermediary who captures a valid signed request
6//! can resend it verbatim until the signer's key rotates. The standard
7//! mitigation is to require each signature to carry a `created`
8//! parameter (or a `Date` header) and reject anything older than a
9//! configured age, bounded on the future side by a small clock-skew
10//! tolerance.
11//!
12//! [`VerifyPolicy`] captures the tunables for this check. Callers
13//! choose a policy via one of the presets ([`VerifyPolicy::mastodon`],
14//! [`VerifyPolicy::strict`]) or build one directly and pass it to the
15//! `*_verify_with_policy` variants.
16
17use chrono::{DateTime, Duration, Utc};
18use httpdate::parse_http_date;
19
20use crate::error::Error;
21
22/// Minimum Cavage header set every compliant verifier should enforce.
23///
24/// The three names together bind the signature to the exact request
25/// URI — omitting any of them lets an intermediary replay a captured
26/// signature against a different path or a different virtual host.
27/// Mastodon's own verifier hard-codes this requirement, so matching it
28/// keeps us bug-for-bug compatible with the reference Fediverse
29/// implementation.
30pub const CAVAGE_REQUIRED_HEADERS: &[&str] = &["(request-target)", "host", "date"];
31
32/// Tunables governing which signed requests are accepted at
33/// verification time.
34///
35/// A `max_age` of `None` disables the past-side check and a
36/// `max_clock_skew_future` of `None` disables the future-side check;
37/// both default to `Some(...)` in the presets. `cavage_required_headers`
38/// defaults to [`CAVAGE_REQUIRED_HEADERS`], and `allow_multiple_signatures`
39/// defaults to `false` — callers that need the historical permissive
40/// behaviour can flip either knob.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[non_exhaustive]
43pub struct VerifyPolicy {
44    /// Maximum permissible age of a signature. A `created` (or `Date`)
45    /// timestamp older than `now - max_age` is rejected. `None`
46    /// disables the past-side check.
47    pub max_age: Option<Duration>,
48
49    /// Maximum permissible future skew. A timestamp claimed to be more
50    /// than `max_clock_skew_future` ahead of the verifier's clock is
51    /// rejected, to catch badly-set signer clocks and straight-out
52    /// forgeries. `None` disables the future-side check.
53    pub max_clock_skew_future: Option<Duration>,
54
55    /// If `true`, a request carrying neither a `created` parameter nor
56    /// a `Date` header is rejected. Defaults to `false` to stay
57    /// compatible with servers that only emit one of the two.
58    pub require_timestamp: bool,
59
60    /// Cavage-specific: the list of header names whose presence in the
61    /// `headers=` parameter is mandatory. A signature whose coverage
62    /// does not include every name listed here is rejected with
63    /// [`Error::RequiredHeaderAbsent`]. The names are compared
64    /// case-insensitively.
65    pub cavage_required_headers: &'static [&'static str],
66
67    /// If `false` (the default), a `Signature-Input:` header containing
68    /// more than one label is rejected outright. Mastodon and the RFC
69    /// 9421 interop profile both expect exactly one signature per
70    /// request; permitting additional labels opens a fallback channel
71    /// an attacker can use to bypass policy by attaching a second
72    /// signature of their own.
73    pub allow_multiple_signatures: bool,
74}
75
76impl VerifyPolicy {
77    /// Returns the policy Mastodon applies to inbound federated
78    /// requests: 12 hours past, 5 minutes future, timestamps optional,
79    /// and the Cavage minimum header set enforced.
80    ///
81    /// See <https://docs.joinmastodon.org/spec/security/>.
82    #[must_use]
83    pub const fn mastodon() -> Self {
84        Self {
85            max_age: Some(Duration::hours(12)),
86            max_clock_skew_future: Some(Duration::minutes(5)),
87            require_timestamp: false,
88            cavage_required_headers: CAVAGE_REQUIRED_HEADERS,
89            allow_multiple_signatures: false,
90        }
91    }
92
93    /// Returns a tight policy appropriate for internal services where
94    /// every hop has NTP-synchronised clocks: 5 minutes past, 1 minute
95    /// future, timestamps mandatory, Cavage minimum header set
96    /// enforced, and multi-signature requests rejected.
97    #[must_use]
98    pub const fn strict() -> Self {
99        Self {
100            max_age: Some(Duration::minutes(5)),
101            max_clock_skew_future: Some(Duration::minutes(1)),
102            require_timestamp: true,
103            cavage_required_headers: CAVAGE_REQUIRED_HEADERS,
104            allow_multiple_signatures: false,
105        }
106    }
107
108    /// Returns a policy that **disables** freshness checking entirely.
109    ///
110    /// Only intended for byte-level conformance tests against static
111    /// RFC 9421 / Cavage fixtures that bake fixed timestamps into their
112    /// inputs. Do not use in production.
113    #[must_use]
114    pub const fn no_freshness_check() -> Self {
115        Self {
116            max_age: None,
117            max_clock_skew_future: None,
118            require_timestamp: false,
119            cavage_required_headers: CAVAGE_REQUIRED_HEADERS,
120            allow_multiple_signatures: false,
121        }
122    }
123
124    /// Evaluates the policy against a signature whose `created`
125    /// parameter is `created_unix` (seconds since epoch), `expires`
126    /// parameter is `expires_unix`, and whose companion `Date` header
127    /// (if any) contained `date_header`. Returns `Ok` when the
128    /// signature is fresh, or a specific error otherwise.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`Error::TimestampMissing`] when `require_timestamp`
133    /// is on and no source is available, [`Error::TimestampTooOld`]
134    /// when `now - source > max_age`, [`Error::TimestampInFuture`]
135    /// when the source is too far ahead of `now`, and
136    /// [`Error::TimestampExpired`] when `expires` is already in the
137    /// past.
138    pub fn check(
139        &self,
140        created_unix: Option<i64>,
141        expires_unix: Option<i64>,
142        date_header: Option<&str>,
143        now: DateTime<Utc>,
144    ) -> Result<(), Error> {
145        let reference = created_unix
146            .and_then(unix_to_datetime)
147            .or_else(|| date_header.and_then(parse_date_header));
148
149        let Some(reference) = reference else {
150            if self.require_timestamp {
151                return Err(Error::TimestampMissing);
152            }
153            return Ok(());
154        };
155
156        if let Some(future_skew) = self.max_clock_skew_future
157            && reference > now + future_skew
158        {
159            return Err(Error::TimestampInFuture {
160                timestamp: reference,
161                now,
162            });
163        }
164
165        if let Some(max_age) = self.max_age
166            && now.signed_duration_since(reference) > max_age
167        {
168            return Err(Error::TimestampTooOld {
169                timestamp: reference,
170                now,
171            });
172        }
173
174        // `expires` is evaluated without clock-skew tolerance on the
175        // past side: a signature with an `expires` parameter tells the
176        // verifier *exactly* when it becomes invalid.
177        if let Some(expires_unix) = expires_unix
178            && let Some(expires) = unix_to_datetime(expires_unix)
179            && now > expires
180        {
181            return Err(Error::TimestampExpired { expires, now });
182        }
183
184        Ok(())
185    }
186}
187
188impl Default for VerifyPolicy {
189    /// Returns [`Self::mastodon`] — the Fediverse-compatible default.
190    fn default() -> Self {
191        Self::mastodon()
192    }
193}
194
195const fn unix_to_datetime(seconds: i64) -> Option<DateTime<Utc>> {
196    DateTime::<Utc>::from_timestamp(seconds, 0)
197}
198
199fn parse_date_header(value: &str) -> Option<DateTime<Utc>> {
200    let system_time = parse_http_date(value).ok()?;
201    Some(DateTime::<Utc>::from(system_time))
202}
203
204#[cfg(test)]
205mod tests {
206    use pretty_assertions::assert_eq;
207
208    use super::*;
209
210    fn now() -> DateTime<Utc> {
211        DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid UNIX time")
212    }
213
214    #[test]
215    fn default_is_mastodon_policy() {
216        assert_eq!(VerifyPolicy::default(), VerifyPolicy::mastodon());
217    }
218
219    #[test]
220    fn fresh_signature_with_created_passes() {
221        let policy = VerifyPolicy::mastodon();
222        // created 1 hour before now — within the 12h window.
223        let created = now().timestamp() - 3600;
224        policy
225            .check(Some(created), None, None, now())
226            .expect("fresh");
227    }
228
229    #[test]
230    fn too_old_signature_is_rejected() {
231        let policy = VerifyPolicy::mastodon();
232        // created 13 hours ago — beyond the 12h window.
233        let created = now().timestamp() - 13 * 3600;
234        let err = policy
235            .check(Some(created), None, None, now())
236            .expect_err("too old");
237        assert!(matches!(err, Error::TimestampTooOld { .. }));
238    }
239
240    #[test]
241    fn signature_in_the_future_is_rejected() {
242        let policy = VerifyPolicy::mastodon();
243        // created 10 minutes in the future — beyond the 5m skew window.
244        let created = now().timestamp() + 10 * 60;
245        let err = policy
246            .check(Some(created), None, None, now())
247            .expect_err("future");
248        assert!(matches!(err, Error::TimestampInFuture { .. }));
249    }
250
251    #[test]
252    fn expires_in_the_past_is_rejected() {
253        let policy = VerifyPolicy::mastodon();
254        let created = now().timestamp() - 60;
255        let expires = now().timestamp() - 30;
256        let err = policy
257            .check(Some(created), Some(expires), None, now())
258            .expect_err("expired");
259        assert!(matches!(err, Error::TimestampExpired { .. }));
260    }
261
262    #[test]
263    fn date_header_is_used_when_created_is_absent() {
264        let policy = VerifyPolicy::mastodon();
265        // 1 hour before `now`: 2023-11-14 21:13:20 UTC (epoch 1699996400).
266        let ts = DateTime::<Utc>::from_timestamp(now().timestamp() - 3600, 0).expect("valid");
267        let header = httpdate::fmt_http_date(std::time::SystemTime::from(ts));
268        policy
269            .check(None, None, Some(&header), now())
270            .expect("date-header fallback");
271    }
272
273    #[test]
274    fn missing_timestamp_passes_by_default() {
275        let policy = VerifyPolicy::mastodon();
276        policy.check(None, None, None, now()).expect("tolerated");
277    }
278
279    #[test]
280    fn missing_timestamp_fails_under_strict_policy() {
281        let policy = VerifyPolicy::strict();
282        let err = policy.check(None, None, None, now()).expect_err("required");
283        assert!(matches!(err, Error::TimestampMissing));
284    }
285
286    #[test]
287    fn malformed_date_header_is_ignored_and_treated_as_absent() {
288        let policy = VerifyPolicy::mastodon();
289        // With `require_timestamp=false`, a bad Date just falls through.
290        policy
291            .check(None, None, Some("not a date"), now())
292            .expect("ignored");
293    }
294
295    #[test]
296    fn no_freshness_check_preset_accepts_stale_timestamps() {
297        let policy = VerifyPolicy::no_freshness_check();
298        // 100 years in the past — should still pass.
299        let stale = now().timestamp() - 100 * 365 * 24 * 3600;
300        policy
301            .check(Some(stale), None, None, now())
302            .expect("stale OK");
303    }
304}