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}