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}