Skip to main content

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}