Skip to main content

treeship_core/verify/
anchoring.rs

1//! Anchoring coverage: how much of a claimed timeline had an external witness.
2//!
3//! # Why this exists
4//!
5//! A receipt's timestamp comes from `SystemTime::now()` on the signing machine
6//! and is signed with that machine's own key. It proves *"this key asserted
7//! this"*, never *"this happened then"*. An actor holding its own key and
8//! controlling its own clock can emit a chain claiming any timeline it likes.
9//!
10//! What constrains a timeline is an **anchor**: a record, held by someone
11//! other than the actor, that a given digest existed at a given time. A Hub
12//! checkpoint, a Rekor entry, an OpenTimestamps attestation. Anchors cannot be
13//! obtained retroactively, so work that happened between two anchors is
14//! bracketed by them.
15//!
16//! # What this reports, and why it is not a boolean
17//!
18//! `anchored: true` would flatten three materially different situations into
19//! one word: a session witnessed every few minutes, a session witnessed once
20//! at close, and a session witnessed never. Only the first constrains the
21//! timeline. That flattening is the same defect as revocation resolving
22//! `Unknown` and still reaching a passing exit code -- the honest detail
23//! exists and the summary discards it.
24//!
25//! So the output carries [`AnchorCoverage::unwitnessed_span_seconds`]: the
26//! longest stretch of claimed work with no external witness. For an
27//! 11-hour session honestly anchored every 15 minutes that number is small.
28//! For an 11-hour session fabricated at the end and anchored once, it is the
29//! whole session -- whatever the receipts themselves claim.
30//!
31//! # What this does NOT prove
32//!
33//! Anchoring bounds *when a claim was made*, never whether the claim is true.
34//! A receipt describing work that never happened, anchored punctually, is a
35//! punctual lie. And a low coverage score is not evidence of wrongdoing:
36//! offline work, air-gapped machines and network failures all produce
37//! unanchored sessions. This reports the fact; the policy belongs to the
38//! caller.
39//!
40//! Nor does it detect **omission** -- an actor presenting a shortened chain
41//! and discarding the rest. That chain is honestly signed and honestly
42//! anchored; catching it requires anchors discoverable by identity, which is
43//! the transparency log's property. See `docs/specs/time-anchoring.md`.
44
45use serde::{Deserialize, Serialize};
46
47/// How a claimed timeline relates to its external witnesses.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum Coverage {
51    /// No anchors at all. The timeline rests entirely on the actor's own
52    /// clock and key. Not an accusation -- offline work looks like this.
53    None,
54    /// Anchors exist, but none of them falls inside the work. Everything
55    /// before the first anchor is unconstrained, which for a
56    /// single-anchor-at-close session is the entire timeline.
57    EndpointOnly,
58    /// Anchors interleave with the work, so stretches between them are
59    /// bracketed. `unwitnessed_span_seconds` says how coarsely.
60    Periodic,
61}
62
63/// One external witness: someone other than the actor recorded that a digest
64/// existed at a time.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct Anchor {
67    /// Unix seconds at which the witness recorded the digest. This is the
68    /// witness's clock, not the actor's -- that is the entire point.
69    pub at: i64,
70    /// Which mechanism produced it: "hub", "rekor", "ots", "tsa".
71    pub mechanism: String,
72}
73
74/// The anchoring picture for one session.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct AnchorCoverage {
77    pub coverage: Coverage,
78    /// The longest stretch of claimed work with no external witness. The
79    /// number a caller gating on time should read.
80    pub unwitnessed_span_seconds: i64,
81    /// Total claimed duration, for context: an unwitnessed span of 600s means
82    /// something different across a 10-minute session than a 10-hour one.
83    pub claimed_span_seconds: i64,
84    pub anchor_count: usize,
85    /// Distinct mechanisms, sorted. Mechanisms fail differently -- a Hub
86    /// anchor needs the Hub trusted, an OTS anchor does not -- so which ones
87    /// were used is part of the answer.
88    pub mechanisms: Vec<String>,
89}
90
91impl AnchorCoverage {
92    /// Compute coverage from claimed event times and the anchors witnessing
93    /// them. Both are unix seconds; `event_times` need not be sorted.
94    ///
95    /// The unwitnessed span is the widest gap in the sequence
96    /// `[work_start, ..anchors inside the work.., work_end]`. So:
97    ///
98    /// * no anchors -> the whole session is one unwitnessed span;
99    /// * one anchor at close -> everything before it is unwitnessed, which is
100    ///   the correct reading: an anchor at the end says nothing about when the
101    ///   middle was written;
102    /// * anchors every N seconds -> roughly N.
103    ///
104    /// Anchors outside the work window are counted (they are real witnesses)
105    /// but cannot shrink an interior gap, which is why a close-time anchor
106    /// does not rescue an unwitnessed session.
107    pub fn compute(event_times: &[i64], anchors: &[Anchor]) -> Self {
108        let mut mechanisms: Vec<String> = anchors.iter().map(|a| a.mechanism.clone()).collect();
109        mechanisms.sort();
110        mechanisms.dedup();
111
112        // No events means no claimed timeline to witness. Reporting a
113        // confident zero here would be a vacuous pass -- there is nothing to
114        // be confident about.
115        let (Some(&start), Some(&end)) = (event_times.iter().min(), event_times.iter().max())
116        else {
117            return Self {
118                coverage: Coverage::None,
119                unwitnessed_span_seconds: 0,
120                claimed_span_seconds: 0,
121                anchor_count: anchors.len(),
122                mechanisms,
123            };
124        };
125
126        let claimed = (end - start).max(0);
127
128        if anchors.is_empty() {
129            return Self {
130                coverage: Coverage::None,
131                unwitnessed_span_seconds: claimed,
132                claimed_span_seconds: claimed,
133                anchor_count: 0,
134                mechanisms,
135            };
136        }
137
138        // Only anchors strictly inside the work window subdivide it. One at or
139        // before the start, or at or after the end, brackets the session
140        // without constraining anything within it.
141        let mut interior: Vec<i64> = anchors
142            .iter()
143            .map(|a| a.at)
144            .filter(|&t| t > start && t < end)
145            .collect();
146        interior.sort_unstable();
147
148        let coverage = if interior.is_empty() {
149            Coverage::EndpointOnly
150        } else {
151            Coverage::Periodic
152        };
153
154        let mut widest = 0i64;
155        let mut prev = start;
156        for t in &interior {
157            widest = widest.max(t - prev);
158            prev = *t;
159        }
160        widest = widest.max(end - prev);
161
162        Self {
163            coverage,
164            unwitnessed_span_seconds: widest,
165            claimed_span_seconds: claimed,
166            anchor_count: anchors.len(),
167            mechanisms,
168        }
169    }
170
171    /// Whether this satisfies a caller's maximum tolerated unwitnessed span.
172    ///
173    /// A session with no claimed work cannot violate a bound -- there is no
174    /// timeline to have fabricated.
175    pub fn within(&self, max_unwitnessed_seconds: i64) -> bool {
176        self.claimed_span_seconds == 0 || self.unwitnessed_span_seconds <= max_unwitnessed_seconds
177    }
178
179    /// One line for humans, phrased so it cannot be misread as a guarantee.
180    pub fn summary(&self) -> String {
181        match self.coverage {
182            Coverage::None if self.claimed_span_seconds == 0 => "no timeline to anchor".to_string(),
183            Coverage::None => format!(
184                "UNWITNESSED — {} of claimed work with no external anchor; \
185                 the timeline rests on the signer's own clock",
186                human(self.claimed_span_seconds)
187            ),
188            Coverage::EndpointOnly => format!(
189                "endpoint-only — {} anchor(s), none during the work; \
190                 {} unwitnessed",
191                self.anchor_count,
192                human(self.unwitnessed_span_seconds)
193            ),
194            Coverage::Periodic => format!(
195                "anchored — {} anchor(s) via {}; longest unwitnessed span {}",
196                self.anchor_count,
197                self.mechanisms.join("+"),
198                human(self.unwitnessed_span_seconds)
199            ),
200        }
201    }
202}
203
204fn human(secs: i64) -> String {
205    match secs {
206        s if s < 60 => format!("{s}s"),
207        s if s < 3600 => format!("{}m", s / 60),
208        s => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn anchor(at: i64) -> Anchor {
217        Anchor {
218            at,
219            mechanism: "hub".into(),
220        }
221    }
222
223    const H: i64 = 3600;
224
225    /// The scenario the spec is written around: 11 hours of work presented as
226    /// 4. Whichever way it is told, coverage is what distinguishes the honest
227    /// session from the fabricated one -- not the receipts, which are equally
228    /// well signed in both.
229    #[test]
230    fn fabricated_session_reports_the_whole_span_unwitnessed() {
231        // Eleven hours of claimed work, one anchor when it was all over.
232        let events: Vec<i64> = (0..=11).map(|h| h * H).collect();
233        let c = AnchorCoverage::compute(&events, &[anchor(11 * H)]);
234
235        assert_eq!(c.coverage, Coverage::EndpointOnly);
236        assert_eq!(c.unwitnessed_span_seconds, 11 * H);
237        assert!(!c.within(H), "an 11h unwitnessed span must fail a 1h bound");
238    }
239
240    #[test]
241    fn honest_session_anchored_throughout_reports_the_interval() {
242        let events: Vec<i64> = (0..=11).map(|h| h * H).collect();
243        // Anchored every hour, including one after close.
244        let anchors: Vec<Anchor> = (1..=11).map(|h| anchor(h * H)).collect();
245        let c = AnchorCoverage::compute(&events, &anchors);
246
247        assert_eq!(c.coverage, Coverage::Periodic);
248        assert_eq!(c.unwitnessed_span_seconds, H);
249        assert!(c.within(2 * H));
250        assert!(!c.within(H / 2));
251    }
252
253    /// An anchor at close must not rescue the session. This is the case a
254    /// boolean `anchored: true` would get wrong, and the reason this module
255    /// reports a span instead.
256    #[test]
257    fn closing_anchor_does_not_shrink_the_interior_gap() {
258        let events = vec![0, 4 * H];
259        let with = AnchorCoverage::compute(&events, &[anchor(4 * H)]);
260        let without = AnchorCoverage::compute(&events, &[]);
261
262        assert_eq!(
263            with.unwitnessed_span_seconds, without.unwitnessed_span_seconds,
264            "an anchor at close constrains nothing inside the session"
265        );
266        // It is still a real witness, so it is still counted and still
267        // changes the classification.
268        assert_eq!(with.anchor_count, 1);
269        assert_eq!(with.coverage, Coverage::EndpointOnly);
270        assert_eq!(without.coverage, Coverage::None);
271    }
272
273    #[test]
274    fn no_anchors_reports_the_entire_claimed_span() {
275        let c = AnchorCoverage::compute(&[0, 4 * H], &[]);
276        assert_eq!(c.coverage, Coverage::None);
277        assert_eq!(c.unwitnessed_span_seconds, 4 * H);
278        assert!(c.summary().contains("UNWITNESSED"));
279    }
280
281    /// An empty timeline has nothing to fabricate, so it must not be reported
282    /// as a violation -- but it must not be reported as *proven* either.
283    #[test]
284    fn empty_timeline_is_neither_a_pass_nor_a_violation() {
285        let c = AnchorCoverage::compute(&[], &[]);
286        assert_eq!(c.claimed_span_seconds, 0);
287        assert!(c.within(0));
288        assert_eq!(c.coverage, Coverage::None);
289        assert!(!c.summary().contains("UNWITNESSED"));
290    }
291
292    #[test]
293    fn unsorted_events_are_handled() {
294        let jumbled = vec![3 * H, 0, 2 * H, H];
295        let ordered = vec![0, H, 2 * H, 3 * H];
296        assert_eq!(
297            AnchorCoverage::compute(&jumbled, &[anchor(90 * 60)]),
298            AnchorCoverage::compute(&ordered, &[anchor(90 * 60)]),
299        );
300    }
301
302    #[test]
303    fn mechanisms_are_deduped_and_sorted() {
304        let events = vec![0, 4 * H];
305        let anchors = vec![
306            Anchor {
307                at: H,
308                mechanism: "rekor".into(),
309            },
310            Anchor {
311                at: 2 * H,
312                mechanism: "hub".into(),
313            },
314            Anchor {
315                at: 3 * H,
316                mechanism: "rekor".into(),
317            },
318        ];
319        let c = AnchorCoverage::compute(&events, &anchors);
320        assert_eq!(c.mechanisms, vec!["hub", "rekor"]);
321        assert_eq!(c.anchor_count, 3);
322    }
323
324    /// A single instant of work is a degenerate timeline, not a fabricated
325    /// one. Reporting a negative or nonsense span here would be worse than
326    /// reporting zero.
327    #[test]
328    fn single_event_has_no_span_to_fabricate() {
329        let c = AnchorCoverage::compute(&[1000], &[]);
330        assert_eq!(c.claimed_span_seconds, 0);
331        assert_eq!(c.unwitnessed_span_seconds, 0);
332        assert!(c.within(0));
333    }
334}