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