auths_verifier/freshness.rs
1//! Freshness model for verification verdicts (ADR 009 — bounded freshness, verifier-set
2//! policy).
3//!
4//! Offline verification cannot guarantee real-time freshness: a verifier only knows what
5//! is in the slice it was handed. So a positive verdict carries a freshness bound, and the
6//! *tolerance* is the relying party's policy — never the signer's. See
7//! `docs/architecture/ADRs/009-freshness-verdict-model.md`.
8
9use serde::{Deserialize, Serialize};
10use std::time::Duration;
11
12/// How fresh a positive verdict is, relative to the verifier's freshness policy. A positive
13/// verdict is never bare: it is always qualified by one of these.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum Freshness {
17 /// The supplied freshness evidence is within the policy window.
18 Fresh,
19 /// No source fresher than the supplied slice was available (offline). The verdict is
20 /// valid as-of its bound, but freshness cannot be confirmed — this is named, never a
21 /// silent pass and never a hard reject.
22 Unknown,
23 /// The supplied freshness evidence is provably older than the policy window.
24 Stale,
25}
26
27impl Default for Freshness {
28 /// Offline-unknown is the safe default: absent any fresher-source evidence, a verdict's
29 /// freshness cannot be confirmed, so it is named [`Freshness::Unknown`] — never silently
30 /// treated as fresh. This is what `#[serde(default)]` resolves a missing field to.
31 fn default() -> Self {
32 Freshness::Unknown
33 }
34}
35
36/// What the verifier knows about a source fresher than the supplied slice (ADR 009 D3–D5).
37/// The verifier reads no clock and no network — this is an INPUT supplied at the boundary
38/// (a bundle timestamp, a witness head, a transparency-log tip), the one model both the
39/// time-based (bundle) and positional (KEL/credential) call sites share.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum FreshnessEvidence {
42 /// No source fresher than the supplied slice was available (offline) → `Unknown`.
43 Offline,
44 /// The age of the freshest available source (a witness-head / checkpoint timestamp);
45 /// time-based, used by bundle verification. Within the policy window → `Fresh`, beyond
46 /// it → `Stale`.
47 SourceAge(Duration),
48 /// A known-fresher tip handed to the verifier out of band (a witness head / log tip),
49 /// compared against the slice's `as_of`; positional, used by KEL/credential verification.
50 FresherTip {
51 /// The freshest tip sequence the verifier knows of.
52 latest_seq: u128,
53 /// The sequence the verified slice is as-of.
54 slice_as_of: u128,
55 },
56}
57
58/// The relying party's freshness tolerance. Verifier-set, never signer-set: a bundle
59/// producer states an age, but the verifier caps the trust window (ADR 009 D2 — this is what
60/// kills the "1-year bundle" anti-pattern).
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct FreshnessPolicy {
63 /// Maximum age of the freshness evidence before the verdict is [`Freshness::Stale`].
64 pub max_age: Duration,
65 /// Whether [`Freshness::Unknown`] (offline / unconfirmable) is trusted. A strict relying
66 /// party sets this to `false`; the offline-friendly default tolerates it.
67 pub trust_unknown: bool,
68}
69
70impl Default for FreshnessPolicy {
71 /// ADR 009 D2 default: a 24-hour window, tolerating `Unknown` (offline-friendly).
72 fn default() -> Self {
73 Self {
74 max_age: Duration::from_secs(24 * 60 * 60),
75 trust_unknown: true,
76 }
77 }
78}
79
80impl FreshnessPolicy {
81 /// A strict policy: a short window, and `Unknown` is denied (requires a fresh source —
82 /// a witness/checkpoint head).
83 ///
84 /// Args:
85 /// * `max_age`: the maximum age of freshness evidence before it is `Stale`.
86 pub fn strict(max_age: Duration) -> Self {
87 Self {
88 max_age,
89 trust_unknown: false,
90 }
91 }
92
93 /// Classify a verdict's freshness from the evidence of a fresher source.
94 ///
95 /// [`FreshnessEvidence::Offline`] NAMES the oracle as [`Freshness::Unknown`] — never a
96 /// silent pass and never a hard reject. A source within the window (or a slice at/beyond a
97 /// known-fresher tip) is [`Freshness::Fresh`]; one provably past it is [`Freshness::Stale`].
98 ///
99 /// Args:
100 /// * `evidence`: what the verifier was told about a source fresher than the slice.
101 ///
102 /// Usage:
103 /// ```ignore
104 /// let f = policy.classify(FreshnessEvidence::SourceAge(bundle_age));
105 /// ```
106 pub fn classify(&self, evidence: FreshnessEvidence) -> Freshness {
107 match evidence {
108 FreshnessEvidence::Offline => Freshness::Unknown,
109 FreshnessEvidence::SourceAge(age) if age <= self.max_age => Freshness::Fresh,
110 FreshnessEvidence::SourceAge(_) => Freshness::Stale,
111 FreshnessEvidence::FresherTip {
112 latest_seq,
113 slice_as_of,
114 } if latest_seq > slice_as_of => Freshness::Stale,
115 FreshnessEvidence::FresherTip { .. } => Freshness::Fresh,
116 }
117 }
118
119 /// Whether a freshness level clears this policy for a trust decision.
120 ///
121 /// Args:
122 /// * `freshness`: the classified freshness of an otherwise-valid verdict.
123 pub fn trusts(&self, freshness: Freshness) -> bool {
124 match freshness {
125 Freshness::Fresh => true,
126 Freshness::Unknown => self.trust_unknown,
127 Freshness::Stale => false,
128 }
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn classify_names_the_oracle_offline_is_unknown() {
138 let p = FreshnessPolicy::default(); // 24h, tolerates unknown
139 // Offline — no fresher source than the slice → Unknown (named), never a silent pass
140 // and never a hard reject.
141 assert_eq!(p.classify(FreshnessEvidence::Offline), Freshness::Unknown);
142 // A source within the window → Fresh; older than the window → Stale.
143 assert_eq!(
144 p.classify(FreshnessEvidence::SourceAge(Duration::from_secs(3600))),
145 Freshness::Fresh
146 );
147 assert_eq!(
148 p.classify(FreshnessEvidence::SourceAge(Duration::from_secs(25 * 3600))),
149 Freshness::Stale
150 );
151 }
152
153 #[test]
154 fn classify_positional_slice_behind_a_fresher_tip_is_stale() {
155 let p = FreshnessPolicy::default();
156 // A slice behind a known-fresher tip → Stale (it cannot see the later events,
157 // including a revocation).
158 assert_eq!(
159 p.classify(FreshnessEvidence::FresherTip {
160 latest_seq: 5,
161 slice_as_of: 3,
162 }),
163 Freshness::Stale
164 );
165 // A slice at (or beyond) the known-fresher tip → Fresh.
166 assert_eq!(
167 p.classify(FreshnessEvidence::FresherTip {
168 latest_seq: 3,
169 slice_as_of: 3,
170 }),
171 Freshness::Fresh
172 );
173 }
174
175 #[test]
176 fn strict_denies_unknown_and_stale_default_tolerates_unknown() {
177 let strict = FreshnessPolicy::strict(Duration::from_secs(3600));
178 assert!(strict.trusts(Freshness::Fresh));
179 assert!(
180 !strict.trusts(Freshness::Unknown),
181 "strict denies offline-unknown"
182 );
183 assert!(!strict.trusts(Freshness::Stale));
184 // The offline-friendly default tolerates Unknown but never Stale.
185 assert!(FreshnessPolicy::default().trusts(Freshness::Unknown));
186 assert!(!FreshnessPolicy::default().trusts(Freshness::Stale));
187 }
188
189 #[test]
190 fn the_default_window_is_24h() {
191 assert_eq!(
192 FreshnessPolicy::default().max_age,
193 Duration::from_secs(86_400)
194 );
195 }
196}