1use chrono::{DateTime, Duration, Utc};
18use httpdate::parse_http_date;
19
20use crate::error::Error;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct VerifyPolicy {
31 pub max_age: Option<Duration>,
35
36 pub max_clock_skew_future: Option<Duration>,
41
42 pub require_timestamp: bool,
46}
47
48impl VerifyPolicy {
49 #[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 #[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 #[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 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 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 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 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 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 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 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 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 let stale = now().timestamp() - 100 * 365 * 24 * 3600;
264 policy
265 .check(Some(stale), None, None, now())
266 .expect("stale OK");
267 }
268}