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/// Tunables governing which signed timestamps are accepted at
23/// verification time.
24///
25/// A `max_age` of `None` disables the past-side check and a
26/// `max_clock_skew_future` of `None` disables the future-side check;
27/// both default to `Some(...)` in the presets.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct VerifyPolicy {
31    /// Maximum permissible age of a signature. A `created` (or `Date`)
32    /// timestamp older than `now - max_age` is rejected. `None`
33    /// disables the past-side check.
34    pub max_age: Option<Duration>,
35
36    /// Maximum permissible future skew. A timestamp claimed to be more
37    /// than `max_clock_skew_future` ahead of the verifier's clock is
38    /// rejected, to catch badly-set signer clocks and straight-out
39    /// forgeries. `None` disables the future-side check.
40    pub max_clock_skew_future: Option<Duration>,
41
42    /// If `true`, a request carrying neither a `created` parameter nor
43    /// a `Date` header is rejected. Defaults to `false` to stay
44    /// compatible with servers that only emit one of the two.
45    pub require_timestamp: bool,
46}
47
48impl VerifyPolicy {
49    /// Returns the policy Mastodon applies to inbound federated
50    /// requests: 12 hours past, 5 minutes future, timestamps optional.
51    ///
52    /// See <https://docs.joinmastodon.org/spec/security/>.
53    #[must_use]
54    pub const fn mastodon() -> Self {
55        Self {
56            max_age: Some(Duration::hours(12)),
57            max_clock_skew_future: Some(Duration::minutes(5)),
58            require_timestamp: false,
59        }
60    }
61
62    /// Returns a tight policy appropriate for internal services where
63    /// every hop has NTP-synchronised clocks: 5 minutes past, 1 minute
64    /// future, and timestamps are mandatory.
65    #[must_use]
66    pub const fn strict() -> Self {
67        Self {
68            max_age: Some(Duration::minutes(5)),
69            max_clock_skew_future: Some(Duration::minutes(1)),
70            require_timestamp: true,
71        }
72    }
73
74    /// Returns a policy that **disables** freshness checking entirely.
75    ///
76    /// Only intended for byte-level conformance tests against static
77    /// RFC 9421 / Cavage fixtures that bake fixed timestamps into their
78    /// inputs. Do not use in production.
79    #[must_use]
80    pub const fn no_freshness_check() -> Self {
81        Self {
82            max_age: None,
83            max_clock_skew_future: None,
84            require_timestamp: false,
85        }
86    }
87
88    /// Evaluates the policy against a signature whose `created`
89    /// parameter is `created_unix` (seconds since epoch), `expires`
90    /// parameter is `expires_unix`, and whose companion `Date` header
91    /// (if any) contained `date_header`. Returns `Ok` when the
92    /// signature is fresh, or a specific error otherwise.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`Error::TimestampMissing`] when `require_timestamp`
97    /// is on and no source is available, [`Error::TimestampTooOld`]
98    /// when `now - source > max_age`, [`Error::TimestampInFuture`]
99    /// when the source is too far ahead of `now`, and
100    /// [`Error::TimestampExpired`] when `expires` is already in the
101    /// past.
102    pub fn check(
103        &self,
104        created_unix: Option<i64>,
105        expires_unix: Option<i64>,
106        date_header: Option<&str>,
107        now: DateTime<Utc>,
108    ) -> Result<(), Error> {
109        let reference = created_unix
110            .and_then(unix_to_datetime)
111            .or_else(|| date_header.and_then(parse_date_header));
112
113        let Some(reference) = reference else {
114            if self.require_timestamp {
115                return Err(Error::TimestampMissing);
116            }
117            return Ok(());
118        };
119
120        if let Some(future_skew) = self.max_clock_skew_future
121            && reference > now + future_skew
122        {
123            return Err(Error::TimestampInFuture {
124                timestamp: reference,
125                now,
126            });
127        }
128
129        if let Some(max_age) = self.max_age
130            && now.signed_duration_since(reference) > max_age
131        {
132            return Err(Error::TimestampTooOld {
133                timestamp: reference,
134                now,
135            });
136        }
137
138        // `expires` is evaluated without clock-skew tolerance on the
139        // past side: a signature with an `expires` parameter tells the
140        // verifier *exactly* when it becomes invalid.
141        if let Some(expires_unix) = expires_unix
142            && let Some(expires) = unix_to_datetime(expires_unix)
143            && now > expires
144        {
145            return Err(Error::TimestampExpired { expires, now });
146        }
147
148        Ok(())
149    }
150}
151
152impl Default for VerifyPolicy {
153    /// Returns [`Self::mastodon`] — the Fediverse-compatible default.
154    fn default() -> Self {
155        Self::mastodon()
156    }
157}
158
159const fn unix_to_datetime(seconds: i64) -> Option<DateTime<Utc>> {
160    DateTime::<Utc>::from_timestamp(seconds, 0)
161}
162
163fn parse_date_header(value: &str) -> Option<DateTime<Utc>> {
164    let system_time = parse_http_date(value).ok()?;
165    Some(DateTime::<Utc>::from(system_time))
166}
167
168#[cfg(test)]
169mod tests {
170    use pretty_assertions::assert_eq;
171
172    use super::*;
173
174    fn now() -> DateTime<Utc> {
175        DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid UNIX time")
176    }
177
178    #[test]
179    fn default_is_mastodon_policy() {
180        assert_eq!(VerifyPolicy::default(), VerifyPolicy::mastodon());
181    }
182
183    #[test]
184    fn fresh_signature_with_created_passes() {
185        let policy = VerifyPolicy::mastodon();
186        // created 1 hour before now — within the 12h window.
187        let created = now().timestamp() - 3600;
188        policy
189            .check(Some(created), None, None, now())
190            .expect("fresh");
191    }
192
193    #[test]
194    fn too_old_signature_is_rejected() {
195        let policy = VerifyPolicy::mastodon();
196        // created 13 hours ago — beyond the 12h window.
197        let created = now().timestamp() - 13 * 3600;
198        let err = policy
199            .check(Some(created), None, None, now())
200            .expect_err("too old");
201        assert!(matches!(err, Error::TimestampTooOld { .. }));
202    }
203
204    #[test]
205    fn signature_in_the_future_is_rejected() {
206        let policy = VerifyPolicy::mastodon();
207        // created 10 minutes in the future — beyond the 5m skew window.
208        let created = now().timestamp() + 10 * 60;
209        let err = policy
210            .check(Some(created), None, None, now())
211            .expect_err("future");
212        assert!(matches!(err, Error::TimestampInFuture { .. }));
213    }
214
215    #[test]
216    fn expires_in_the_past_is_rejected() {
217        let policy = VerifyPolicy::mastodon();
218        let created = now().timestamp() - 60;
219        let expires = now().timestamp() - 30;
220        let err = policy
221            .check(Some(created), Some(expires), None, now())
222            .expect_err("expired");
223        assert!(matches!(err, Error::TimestampExpired { .. }));
224    }
225
226    #[test]
227    fn date_header_is_used_when_created_is_absent() {
228        let policy = VerifyPolicy::mastodon();
229        // 1 hour before `now`: 2023-11-14 21:13:20 UTC (epoch 1699996400).
230        let ts = DateTime::<Utc>::from_timestamp(now().timestamp() - 3600, 0).expect("valid");
231        let header = httpdate::fmt_http_date(std::time::SystemTime::from(ts));
232        policy
233            .check(None, None, Some(&header), now())
234            .expect("date-header fallback");
235    }
236
237    #[test]
238    fn missing_timestamp_passes_by_default() {
239        let policy = VerifyPolicy::mastodon();
240        policy.check(None, None, None, now()).expect("tolerated");
241    }
242
243    #[test]
244    fn missing_timestamp_fails_under_strict_policy() {
245        let policy = VerifyPolicy::strict();
246        let err = policy.check(None, None, None, now()).expect_err("required");
247        assert!(matches!(err, Error::TimestampMissing));
248    }
249
250    #[test]
251    fn malformed_date_header_is_ignored_and_treated_as_absent() {
252        let policy = VerifyPolicy::mastodon();
253        // With `require_timestamp=false`, a bad Date just falls through.
254        policy
255            .check(None, None, Some("not a date"), now())
256            .expect("ignored");
257    }
258
259    #[test]
260    fn no_freshness_check_preset_accepts_stale_timestamps() {
261        let policy = VerifyPolicy::no_freshness_check();
262        // 100 years in the past — should still pass.
263        let stale = now().timestamp() - 100 * 365 * 24 * 3600;
264        policy
265            .check(Some(stale), None, None, now())
266            .expect("stale OK");
267    }
268}