Skip to main content

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}