vti_common/audit/checkpoint.rs
1//! Signed audit checkpoints — tamper-evidence against a *store-level*
2//! adversary.
3//!
4//! # What the hash chain does not solve
5//!
6//! [`super::envelope::verify_chain`] detects reordering, dropping,
7//! duplication, and content edits. It does not detect a competent
8//! adversary, because [`super::AuditEnvelope::chain_digest`] is an
9//! **unkeyed** SHA-256: anyone who can write to the `audit` keyspace holds
10//! everything needed to recompute it. Two attacks follow directly.
11//!
12//! 1. **Restamping.** Edit or insert an envelope, recompute its `entry_hash`,
13//! then walk forward restamping every successor. The result verifies
14//! cleanly. O(entries after the edit), no secret required.
15//! 2. **Truncation.** Delete everything after some point. The remaining
16//! prefix is a *valid chain*, and nothing records how long the log should
17//! be — so a truncated log is indistinguishable from a community that went
18//! quiet.
19//!
20//! Truncation is the cheaper attack and the more serious one: it erases an
21//! incident with no forgery at all.
22//!
23//! The actor/target HMAC does not help. It protects attribution (and enables
24//! RTBF via key rotation), not sequence integrity — and the writer holds that
25//! key anyway.
26//!
27//! # What a checkpoint adds
28//!
29//! A periodically-persisted, **signed** commitment to the chain head *and the
30//! number of entries behind it*. [`AuditCheckpoint::entry_count`] is the
31//! load-bearing field: a log shorter than a signed checkpoint claims is
32//! truncation, and that check cannot be spoofed without the signing key.
33//!
34//! # Why the community Ed25519 key, not the audit HMAC key
35//!
36//! | | Audit HMAC key | Community Ed25519 key |
37//! |---|---|---|
38//! | Verifiable by | the daemon only | anyone with the community DID |
39//! | Forgeable by a store-adversary | **yes** — it is in the same store | no |
40//!
41//! The HMAC key is rejected on both counts: it lives in the very store the
42//! adversary is assumed to have reached, and symmetric verification means
43//! whoever can *check* a checkpoint can also *forge* one — which reduces to
44//! the status quo. Signing with the community key instead makes checkpoints
45//! **externally** verifiable: an auditor holding only the community DID can
46//! confirm the log has not been rewritten, with no shared secret and no daemon
47//! access.
48//!
49//! Consequence accepted: verification depends on the community DID resolving,
50//! and on key rotation being handled — a checkpoint signed under a retired key
51//! must stay verifiable, which the `did:webvh` document history already
52//! provides. [`AuditCheckpoint::verification_method`] records which key signed.
53//!
54//! # What this still does not protect against
55//!
56//! An adversary who *also* holds the community signing key. Closing that needs
57//! the head published somewhere append-only (the community's own `did.jsonl`,
58//! a transparency log, a peer VTC). Out of scope — the signature is the
59//! prerequisite. See `docs/05-design-notes/vtc-audit-checkpoints.md`.
60
61use chrono::{DateTime, Utc};
62use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
63use serde::{Deserialize, Serialize};
64use sha2::{Digest, Sha256};
65use uuid::Uuid;
66
67use super::envelope::{GENESIS_HASH, hash32_b64, hash32_opt_b64};
68
69/// Domain separator for the bytes a checkpoint signature covers.
70///
71/// Distinct from the envelope chain's `vtc-audit-chain/v1\0` so a signature
72/// over one can never be replayed as a signature over the other.
73const CHECKPOINT_DOMAIN: &[u8] = b"vtc-audit-checkpoint/v1\0";
74
75/// Domain separator for a checkpoint's *own* hash — the value the next
76/// checkpoint's [`AuditCheckpoint::prev_checkpoint`] points at.
77const CHECKPOINT_LINK_DOMAIN: &[u8] = b"vtc-audit-checkpoint-link/v1\0";
78
79/// A signed commitment to the audit chain's state at a point in time.
80///
81/// Stored in the `audit_checkpoint` keyspace keyed by `<rfc3339>:<uuid>`,
82/// matching the audit keyspace's convention so an ascending walk is
83/// chronological.
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "camelCase")]
86pub struct AuditCheckpoint {
87 /// Stable identifier for this checkpoint.
88 pub checkpoint_id: Uuid,
89
90 /// `entry_hash` of the newest chainable envelope at checkpoint time.
91 #[serde(with = "hash32_b64")]
92 pub head: [u8; 32],
93
94 /// Total **chainable** (v2+) envelopes written up to and including
95 /// [`Self::head`].
96 ///
97 /// This is the field that makes truncation detectable: a log holding
98 /// fewer chainable entries than a signed checkpoint claims has lost
99 /// entries, and no amount of restamping fixes that without the signing
100 /// key. Counting only chainable envelopes matters — pre-v2 rows are
101 /// skipped by the verifier, so including them would make the count
102 /// disagree with what verification can actually recount.
103 pub entry_count: u64,
104
105 /// `event_id` of the envelope at [`Self::head`], so a verifier can locate
106 /// the anchor point directly instead of recomputing the whole chain.
107 pub head_event_id: Uuid,
108
109 /// Wall-clock at checkpoint time.
110 pub checkpoint_at: DateTime<Utc>,
111
112 /// The previous checkpoint's own [`Self::link_hash`], or `None` for the
113 /// first. Checkpoints chain too, so **deleting a checkpoint is itself
114 /// detectable** — otherwise an adversary would simply drop the
115 /// checkpoints that contradict a truncated log.
116 #[serde(with = "hash32_opt_b64")]
117 pub prev_checkpoint: Option<[u8; 32]>,
118
119 /// `verificationMethod` URI of the key that signed this checkpoint (e.g.
120 /// `did:webvh:…#key-0`). Recorded so a checkpoint stays verifiable across
121 /// a key rotation: a verifier resolves *this* key from the community's DID
122 /// document history rather than assuming the current one.
123 pub verification_method: String,
124
125 /// Ed25519 signature over [`Self::signing_payload`].
126 #[serde(with = "sig_b64")]
127 pub signature: Vec<u8>,
128}
129
130/// Everything a checkpoint commits to, except the signature itself.
131///
132/// Split out so [`AuditCheckpoint::sign`] and
133/// [`AuditCheckpoint::verify_signature`] cannot disagree about what is signed
134/// — the classic way a signature scheme ends up covering less than it appears
135/// to.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct CheckpointClaim {
138 pub checkpoint_id: Uuid,
139 pub head: [u8; 32],
140 pub entry_count: u64,
141 pub head_event_id: Uuid,
142 pub checkpoint_at: DateTime<Utc>,
143 pub prev_checkpoint: Option<[u8; 32]>,
144 pub verification_method: String,
145}
146
147impl CheckpointClaim {
148 /// The exact bytes an Ed25519 signature covers.
149 ///
150 /// Length-prefixed and domain-tagged rather than a JSON encoding: the
151 /// signature must not depend on serializer field ordering or on a
152 /// canonicalisation step that could differ between signer and verifier.
153 /// Same reasoning as the envelope's `chain_digest`.
154 #[must_use]
155 pub fn signing_payload(&self) -> Vec<u8> {
156 let mut out = Vec::with_capacity(256);
157 out.extend_from_slice(CHECKPOINT_DOMAIN);
158 out.extend_from_slice(self.checkpoint_id.as_bytes());
159 out.extend_from_slice(&self.head);
160 out.extend_from_slice(&self.entry_count.to_be_bytes());
161 out.extend_from_slice(self.head_event_id.as_bytes());
162 let ts = self.checkpoint_at.to_rfc3339();
163 out.extend_from_slice(&(ts.len() as u64).to_be_bytes());
164 out.extend_from_slice(ts.as_bytes());
165 match self.prev_checkpoint {
166 Some(p) => {
167 out.push(1);
168 out.extend_from_slice(&p);
169 }
170 None => out.push(0),
171 }
172 out.extend_from_slice(&(self.verification_method.len() as u64).to_be_bytes());
173 out.extend_from_slice(self.verification_method.as_bytes());
174 out
175 }
176}
177
178impl AuditCheckpoint {
179 /// Build and sign a checkpoint with `signing_key`.
180 ///
181 /// `verification_method` must name the public key corresponding to
182 /// `signing_key` in the community's DID document — a verifier resolves it
183 /// to check the signature.
184 #[must_use]
185 pub fn sign(claim: CheckpointClaim, signing_key: &SigningKey) -> Self {
186 let signature = signing_key
187 .sign(&claim.signing_payload())
188 .to_bytes()
189 .to_vec();
190 Self {
191 checkpoint_id: claim.checkpoint_id,
192 head: claim.head,
193 entry_count: claim.entry_count,
194 head_event_id: claim.head_event_id,
195 checkpoint_at: claim.checkpoint_at,
196 prev_checkpoint: claim.prev_checkpoint,
197 verification_method: claim.verification_method,
198 signature,
199 }
200 }
201
202 /// The claim this checkpoint carries — the signed half of it.
203 #[must_use]
204 pub fn claim(&self) -> CheckpointClaim {
205 CheckpointClaim {
206 checkpoint_id: self.checkpoint_id,
207 head: self.head,
208 entry_count: self.entry_count,
209 head_event_id: self.head_event_id,
210 checkpoint_at: self.checkpoint_at,
211 prev_checkpoint: self.prev_checkpoint,
212 verification_method: self.verification_method.clone(),
213 }
214 }
215
216 /// Verify the signature against `public_key` (32 raw Ed25519 bytes).
217 ///
218 /// Returns `false` for a malformed key or signature as well as a genuine
219 /// mismatch — from the verifier's point of view those are the same
220 /// finding: this checkpoint does not prove anything.
221 #[must_use]
222 pub fn verify_signature(&self, public_key: &[u8]) -> bool {
223 let Ok(key_bytes) = <[u8; 32]>::try_from(public_key) else {
224 return false;
225 };
226 let Ok(verifying) = VerifyingKey::from_bytes(&key_bytes) else {
227 return false;
228 };
229 let Ok(sig_bytes) = <[u8; 64]>::try_from(self.signature.as_slice()) else {
230 return false;
231 };
232 verifying
233 .verify(
234 &self.claim().signing_payload(),
235 &Signature::from_bytes(&sig_bytes),
236 )
237 .is_ok()
238 }
239
240 /// This checkpoint's own hash — what the next one's
241 /// [`Self::prev_checkpoint`] points at.
242 ///
243 /// Covers the signature as well as the claim, so swapping a valid
244 /// signature for a different valid signature over the same claim still
245 /// breaks the link.
246 #[must_use]
247 pub fn link_hash(&self) -> [u8; 32] {
248 let mut h = Sha256::new();
249 h.update(CHECKPOINT_LINK_DOMAIN);
250 h.update(self.claim().signing_payload());
251 h.update((self.signature.len() as u64).to_be_bytes());
252 h.update(&self.signature);
253 h.finalize().into()
254 }
255
256 /// Storage key: `<rfc3339>:<checkpoint_id>`, so an ascending prefix walk
257 /// is chronological (matching the audit keyspace's convention).
258 #[must_use]
259 pub fn storage_key(&self) -> Vec<u8> {
260 format!("{}:{}", self.checkpoint_at.to_rfc3339(), self.checkpoint_id).into_bytes()
261 }
262}
263
264/// Why a checkpoint chain failed to verify.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub enum CheckpointBreak {
267 /// The signature does not verify under the resolved public key. The
268 /// checkpoint was forged, altered, or signed by a different key than
269 /// `verification_method` names.
270 BadSignature { index: usize, checkpoint_id: Uuid },
271 /// `prev_checkpoint` does not point at the previous checkpoint's
272 /// `link_hash` — a checkpoint was reordered, dropped, or inserted.
273 BrokenLink { index: usize, checkpoint_id: Uuid },
274 /// `entry_count` went backwards. The audit log only grows, so a later
275 /// checkpoint claiming fewer entries than an earlier one is a forgery or
276 /// a replay of an older checkpoint under a later timestamp.
277 CountWentBackwards {
278 index: usize,
279 checkpoint_id: Uuid,
280 previous: u64,
281 claimed: u64,
282 },
283}
284
285/// Verify a checkpoint chain in ascending (chronological) order.
286///
287/// Checks each signature, the `prev_checkpoint` links, and that
288/// `entry_count` is monotonically non-decreasing. Returns the newest
289/// checkpoint on success, or `None` when `checkpoints` is empty (a community
290/// that has not checkpointed yet — not an error).
291///
292/// `public_key_for` resolves a `verification_method` URI to raw Ed25519 public
293/// bytes. It is a callback rather than a single key so a checkpoint signed
294/// before a key rotation still verifies against the key that actually signed
295/// it. Returning `None` fails that checkpoint as [`CheckpointBreak::BadSignature`]
296/// — an unresolvable signing key proves nothing.
297pub fn verify_checkpoints<F>(
298 checkpoints: &[AuditCheckpoint],
299 mut public_key_for: F,
300) -> Result<Option<&AuditCheckpoint>, CheckpointBreak>
301where
302 F: FnMut(&str) -> Option<Vec<u8>>,
303{
304 let mut prev_link: Option<[u8; 32]> = None;
305 let mut prev_count: u64 = 0;
306
307 for (index, cp) in checkpoints.iter().enumerate() {
308 let ok = public_key_for(&cp.verification_method).is_some_and(|pk| cp.verify_signature(&pk));
309 if !ok {
310 return Err(CheckpointBreak::BadSignature {
311 index,
312 checkpoint_id: cp.checkpoint_id,
313 });
314 }
315 if cp.prev_checkpoint != prev_link {
316 return Err(CheckpointBreak::BrokenLink {
317 index,
318 checkpoint_id: cp.checkpoint_id,
319 });
320 }
321 if cp.entry_count < prev_count {
322 return Err(CheckpointBreak::CountWentBackwards {
323 index,
324 checkpoint_id: cp.checkpoint_id,
325 previous: prev_count,
326 claimed: cp.entry_count,
327 });
328 }
329 prev_link = Some(cp.link_hash());
330 prev_count = cp.entry_count;
331 }
332
333 Ok(checkpoints.last())
334}
335
336/// How the audit log measured up against its newest signed checkpoint.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub enum CheckpointAudit {
339 /// No checkpoints exist. Nothing is contradicted, but nothing is
340 /// attested either — the log carries only unkeyed-chain assurance.
341 NoCheckpoints,
342 /// The log is consistent with the newest checkpoint.
343 Consistent {
344 checkpoint_at: DateTime<Utc>,
345 attested_entries: u64,
346 /// Chainable entries written *since* the checkpoint. These are
347 /// covered by the hash chain but not by any signature — an
348 /// adversary can still truncate this tail freely, so it is the
349 /// residual exposure and worth surfacing.
350 unattested_entries: u64,
351 },
352 /// **Truncation.** The log holds fewer chainable entries than the newest
353 /// signed checkpoint attests to. This is the finding the whole mechanism
354 /// exists for and cannot be produced without the signing key.
355 Truncated { attested: u64, found: u64 },
356 /// The envelope named by `head_event_id` is missing, or its `entry_hash`
357 /// no longer matches the signed `head`. The attested anchor point has
358 /// been removed or rewritten.
359 HeadMismatch {
360 head_event_id: Uuid,
361 /// `false` when the envelope is absent entirely rather than altered.
362 found: bool,
363 },
364}
365
366// Base64 codec for the signature, matching the envelope's hash encodings so
367// the whole audit surface serialises consistently.
368mod sig_b64 {
369 use base64::Engine as _;
370 use base64::engine::general_purpose::STANDARD as B64;
371 use serde::{Deserialize, Deserializer, Serializer};
372
373 pub fn serialize<S: Serializer>(v: &[u8], s: S) -> Result<S::Ok, S::Error> {
374 s.serialize_str(&B64.encode(v))
375 }
376
377 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
378 let s = String::deserialize(d)?;
379 B64.decode(s.as_bytes()).map_err(serde::de::Error::custom)
380 }
381}
382
383/// A checkpoint over an empty log anchors at [`GENESIS_HASH`] with a zero
384/// count — the same convention the envelope chain uses for its first link.
385#[must_use]
386pub fn genesis_claim(
387 checkpoint_id: Uuid,
388 checkpoint_at: DateTime<Utc>,
389 verification_method: String,
390) -> CheckpointClaim {
391 CheckpointClaim {
392 checkpoint_id,
393 head: GENESIS_HASH,
394 entry_count: 0,
395 head_event_id: Uuid::nil(),
396 checkpoint_at,
397 prev_checkpoint: None,
398 verification_method,
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 fn key(seed: u8) -> SigningKey {
407 SigningKey::from_bytes(&[seed; 32])
408 }
409
410 fn claim(n: u64, prev: Option<[u8; 32]>) -> CheckpointClaim {
411 CheckpointClaim {
412 checkpoint_id: Uuid::from_u128(u128::from(n) + 1),
413 head: [n as u8; 32],
414 entry_count: n,
415 head_event_id: Uuid::from_u128(u128::from(n) + 1000),
416 checkpoint_at: DateTime::parse_from_rfc3339("2026-07-25T10:00:00Z")
417 .unwrap()
418 .with_timezone(&Utc),
419 prev_checkpoint: prev,
420 verification_method: "did:webvh:scid:vtc.example#key-0".into(),
421 }
422 }
423
424 fn chain(sk: &SigningKey, counts: &[u64]) -> Vec<AuditCheckpoint> {
425 let mut out: Vec<AuditCheckpoint> = Vec::new();
426 for &n in counts {
427 let prev = out.last().map(AuditCheckpoint::link_hash);
428 out.push(AuditCheckpoint::sign(claim(n, prev), sk));
429 }
430 out
431 }
432
433 fn resolver(sk: &SigningKey) -> impl FnMut(&str) -> Option<Vec<u8>> + use<'_> {
434 move |_vm: &str| Some(sk.verifying_key().to_bytes().to_vec())
435 }
436
437 #[test]
438 fn a_signed_checkpoint_verifies_under_its_own_key() {
439 let sk = key(1);
440 let cp = AuditCheckpoint::sign(claim(10, None), &sk);
441 assert!(cp.verify_signature(&sk.verifying_key().to_bytes()));
442 }
443
444 /// The point of signing: a store-level adversary holds the store but not
445 /// the community key, so a checkpoint they mint does not verify.
446 #[test]
447 fn a_checkpoint_signed_by_a_different_key_is_rejected() {
448 let real = key(1);
449 let attacker = key(2);
450 let forged = AuditCheckpoint::sign(claim(10, None), &attacker);
451 assert!(!forged.verify_signature(&real.verifying_key().to_bytes()));
452 }
453
454 /// Editing any signed field invalidates the signature. `entry_count` is
455 /// the one that matters — lowering it is how an adversary would try to
456 /// make a truncated log look complete.
457 #[test]
458 fn lowering_entry_count_breaks_the_signature() {
459 let sk = key(1);
460 let mut cp = AuditCheckpoint::sign(claim(500, None), &sk);
461 cp.entry_count = 3;
462 assert!(!cp.verify_signature(&sk.verifying_key().to_bytes()));
463 }
464
465 #[test]
466 fn head_cannot_be_swapped() {
467 let sk = key(1);
468 let mut cp = AuditCheckpoint::sign(claim(10, None), &sk);
469 cp.head = [0xAB; 32];
470 assert!(!cp.verify_signature(&sk.verifying_key().to_bytes()));
471 }
472
473 #[test]
474 fn a_well_formed_chain_verifies() {
475 let sk = key(1);
476 let cps = chain(&sk, &[10, 25, 40]);
477 let newest = verify_checkpoints(&cps, resolver(&sk)).expect("chain verifies");
478 assert_eq!(newest.map(|c| c.entry_count), Some(40));
479 }
480
481 /// Checkpoints chain so that *deleting one* is detectable — otherwise an
482 /// adversary would simply drop the checkpoints contradicting a truncated
483 /// log, and the mechanism would protect nothing.
484 #[test]
485 fn deleting_a_checkpoint_breaks_the_chain() {
486 let sk = key(1);
487 let cps = chain(&sk, &[10, 25, 40]);
488 let gapped = vec![cps[0].clone(), cps[2].clone()];
489 assert!(matches!(
490 verify_checkpoints(&gapped, resolver(&sk)),
491 Err(CheckpointBreak::BrokenLink { index: 1, .. })
492 ));
493 }
494
495 #[test]
496 fn reordering_checkpoints_breaks_the_chain() {
497 let sk = key(1);
498 let cps = chain(&sk, &[10, 25]);
499 let swapped = vec![cps[1].clone(), cps[0].clone()];
500 assert!(matches!(
501 verify_checkpoints(&swapped, resolver(&sk)),
502 Err(CheckpointBreak::BrokenLink { .. })
503 ));
504 }
505
506 /// A replayed older checkpoint re-linked under a later position would let
507 /// an adversary lower the attested count without forging a signature.
508 #[test]
509 fn entry_count_may_not_go_backwards() {
510 let sk = key(1);
511 // Hand-build a chain whose second link is genuinely signed but claims
512 // fewer entries than the first.
513 let first = AuditCheckpoint::sign(claim(40, None), &sk);
514 let second = AuditCheckpoint::sign(claim(10, Some(first.link_hash())), &sk);
515 assert!(matches!(
516 verify_checkpoints(&[first, second], resolver(&sk)),
517 Err(CheckpointBreak::CountWentBackwards {
518 previous: 40,
519 claimed: 10,
520 ..
521 })
522 ));
523 }
524
525 /// An unresolvable `verification_method` proves nothing, so it must fail
526 /// rather than be skipped — skipping would let an adversary sign with a
527 /// key they invented and name it something that does not resolve.
528 #[test]
529 fn an_unresolvable_signing_key_fails_verification() {
530 let sk = key(1);
531 let cps = chain(&sk, &[10]);
532 assert!(matches!(
533 verify_checkpoints(&cps, |_vm: &str| None),
534 Err(CheckpointBreak::BadSignature { index: 0, .. })
535 ));
536 }
537
538 #[test]
539 fn an_empty_checkpoint_set_is_not_an_error() {
540 let sk = key(1);
541 assert_eq!(verify_checkpoints(&[], resolver(&sk)), Ok(None));
542 }
543
544 #[test]
545 fn checkpoints_round_trip_through_json() {
546 let sk = key(1);
547 let cp = AuditCheckpoint::sign(claim(7, Some([9u8; 32])), &sk);
548 let json = serde_json::to_vec(&cp).expect("serialize");
549 let back: AuditCheckpoint = serde_json::from_slice(&json).expect("deserialize");
550 assert_eq!(cp, back);
551 assert!(back.verify_signature(&sk.verifying_key().to_bytes()));
552 }
553
554 /// The signature must not be replayable as an envelope-chain digest, nor
555 /// a link hash confusable with a signing payload.
556 #[test]
557 fn link_hash_and_signing_payload_are_domain_separated() {
558 let sk = key(1);
559 let cp = AuditCheckpoint::sign(claim(10, None), &sk);
560 assert_ne!(cp.link_hash().to_vec(), cp.claim().signing_payload());
561 }
562
563 /// Two checkpoints differing only in signature must not share a link
564 /// hash — otherwise a signature swap would be invisible to the chain.
565 #[test]
566 fn link_hash_covers_the_signature() {
567 let a = AuditCheckpoint::sign(claim(10, None), &key(1));
568 let b = AuditCheckpoint::sign(claim(10, None), &key(2));
569 assert_eq!(a.claim(), b.claim(), "same claim");
570 assert_ne!(a.link_hash(), b.link_hash(), "different signature");
571 }
572
573 #[test]
574 fn storage_key_sorts_chronologically() {
575 let sk = key(1);
576 let mut early = claim(1, None);
577 early.checkpoint_at = DateTime::parse_from_rfc3339("2026-07-25T09:00:00Z")
578 .unwrap()
579 .with_timezone(&Utc);
580 let a = AuditCheckpoint::sign(early, &sk);
581 let b = AuditCheckpoint::sign(claim(2, Some(a.link_hash())), &sk);
582 assert!(a.storage_key() < b.storage_key());
583 }
584}