zeph_common/hash_chain.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Keyed-BLAKE3 hash-chain primitive for tamper-evident, append-only JSONL history.
5//!
6//! This module is the shared core behind the transcript-integrity feature (issue #6360):
7//! [`zeph-subagent`](../../zeph_subagent/index.html)'s `<task_id>.jsonl` transcripts and
8//! [`zeph-session`](../../zeph_session/index.html)'s `events.jsonl` event log both chain their
9//! entries with the same primitive, reusing the project's existing keyed-BLAKE3 pattern
10//! (`crates/zeph-durable/src/backend/local.rs`'s `compute_control_hmac`,
11//! `crates/zeph-core/src/durable.rs`'s `derive_control_hmac_key_b64`) rather than introducing a
12//! new cryptographic dependency (constitution VII, spec-069 §8 Always).
13//!
14//! # The scheme
15//!
16//! ```text
17//! genesis = blake3::keyed_hash(key, DOMAIN_TAG || file_identity || key_epoch)
18//! chain[0] = blake3::keyed_hash(key, genesis || content_bytes[0])
19//! chain[i] = blake3::keyed_hash(key, chain[i-1] || content_bytes[i])
20//! ```
21//!
22//! `content_bytes[i]` is the entry serialized with its own chain field excluded (see the adapter
23//! crates for the exact strip-then-reserialize procedure). `DOMAIN_TAG` and `file_identity`
24//! (e.g. a `task_id` or `session_id`) bind the chain to one subsystem and one file, so neither a
25//! cross-subsystem replay nor a wholesale substitution of one file for another produces a valid
26//! chain.
27//!
28//! # Threat model and honest scope (spec-069 §9, critic rev1-3)
29//!
30//! This defends against an attacker with filesystem write access but **not** vault access. Such
31//! an attacker cannot forge a valid chain link, so **in-place content edits, entry reordering,
32//! and a partial strip of chain metadata are always detected** (any chained file with a `chain`
33//! field on some but not all of its post-chain-start lines is a hard tamper failure, never a
34//! legacy downgrade). A **fully consistent whole-file strip** (delete every `chain` field so the
35//! file looks pre-feature-legacy) is a distinct, harder threat: nothing in this module alone
36//! defends against it — that requires an anchor stored outside filesystem-write reach (the
37//! opt-in/default `integrity.anchor = "vault"` mechanism the adapter crates layer on top, or the
38//! deferred P3 external anchor). Do not read this module in isolation as providing
39//! downgrade-resistance; see each adapter's module docs for its anchor posture.
40//!
41//! # Key rotation (FR-008)
42//!
43//! [`ChainKeyRing`] carries a current epoch and an optional previous epoch so a legitimate
44//! single-step key rotation does not turn all pre-rotation history into apparent tamper.
45//! [`verify_chained_prefix`] tries the current epoch's genesis first, then the previous epoch's;
46//! whichever produces a valid link for the *first* chained entry is used for the rest of the
47//! file, and the result is tagged with [`KeyResolution`] so callers can distinguish "re-keyed"
48//! from "tampered" in their error reporting (never conflate the two — see spec-069 FR-008).
49//!
50//! A `key_epoch` that resolves to neither the current nor the previous epoch is
51//! [`ChainError::Unverifiable`] — NOT legacy. Per NFR-004, an integrity check that cannot be
52//! evaluated must fail, never silently degrade to trusted-legacy; degrading here would let an
53//! attacker force legacy trust by writing a bogus epoch (the downgrade lever the critic's
54//! rev2/rev3 review closed).
55
56/// 32-byte keyed-BLAKE3 subkey for one subsystem's hash chain, already domain-separated via
57/// `blake3::derive_key` from the root `ZEPH_HISTORY_KEY` vault secret by the caller (mirroring
58/// `derive_control_hmac_key_b64`).
59///
60/// Deliberately opaque: [`Debug`] never prints the key material (mirrors the
61/// secret-bearing-Debug-derive lesson learned elsewhere in this codebase — a derived `Debug`
62/// would leak key bytes into logs/panics).
63#[derive(Clone, Copy)]
64pub struct ChainKey([u8; 32]);
65
66impl ChainKey {
67 /// Wrap raw key bytes as a [`ChainKey`].
68 #[must_use]
69 pub fn new(bytes: [u8; 32]) -> Self {
70 Self(bytes)
71 }
72}
73
74impl std::fmt::Debug for ChainKey {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.write_str("ChainKey(..)")
77 }
78}
79
80/// A 32-byte chain link value: either a genesis seed or the output of [`chain_next`].
81///
82/// Not secret (it is stored on disk alongside the content it authenticates), so [`Debug`] and
83/// hex encode/decode are unrestricted. Equality compares via [`blake3::Hash`], which is
84/// constant-time — the same idiom already used for the promise resolver-token check and
85/// `verify_control_hmac`, so a forged stored hash reveals no timing signal.
86#[derive(Clone, Copy)]
87pub struct ChainHash([u8; 32]);
88
89impl ChainHash {
90 /// Render as a lowercase hex string for JSONL storage.
91 #[must_use]
92 pub fn to_hex(self) -> String {
93 hex_encode(&self.0)
94 }
95
96 /// Parse a lowercase (or uppercase) hex string produced by [`Self::to_hex`].
97 ///
98 /// # Errors
99 ///
100 /// Returns [`ChainError::MalformedHash`] if `s` is not exactly 64 hex characters.
101 pub fn from_hex(s: &str) -> Result<Self, ChainError> {
102 hex_decode(s).map(Self).ok_or(ChainError::MalformedHash)
103 }
104}
105
106impl std::fmt::Debug for ChainHash {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 write!(f, "ChainHash({})", self.to_hex())
109 }
110}
111
112impl PartialEq for ChainHash {
113 fn eq(&self, other: &Self) -> bool {
114 blake3::Hash::from(self.0) == blake3::Hash::from(other.0)
115 }
116}
117
118impl Eq for ChainHash {}
119
120fn hex_encode(bytes: &[u8; 32]) -> String {
121 use std::fmt::Write as _;
122 let mut out = String::with_capacity(64);
123 for b in bytes {
124 let _ = write!(out, "{b:02x}");
125 }
126 out
127}
128
129fn hex_decode(s: &str) -> Option<[u8; 32]> {
130 let s = s.trim();
131 if s.len() != 64 {
132 return None;
133 }
134 let mut out = [0u8; 32];
135 for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
136 let hi = (chunk[0] as char).to_digit(16)?;
137 let lo = (chunk[1] as char).to_digit(16)?;
138 out[i] = u8::try_from(hi * 16 + lo).ok()?;
139 }
140 Some(out)
141}
142
143/// Compute the genesis (seed) hash for one chained file.
144///
145/// `domain` distinguishes subsystems (e.g. `"zeph-subagent transcript v1"`,
146/// `"zeph-session log v1"`) so a chain valid in one subsystem can never verify in another.
147/// `file_identity` (e.g. a `task_id` or `session_id`, as raw bytes) binds the chain to this one
148/// file, so wholesale substitution of one chained file for another (same subsystem) breaks at
149/// the first entry. `key_epoch` is folded in so a rotation changes genesis deterministically
150/// (FR-008) — see the module docs' Key rotation section.
151#[must_use]
152pub fn genesis(key: &ChainKey, domain: &str, file_identity: &[u8], key_epoch: u32) -> ChainHash {
153 let mut input = Vec::with_capacity(domain.len() + file_identity.len() + 4);
154 input.extend_from_slice(domain.as_bytes());
155 input.extend_from_slice(file_identity);
156 input.extend_from_slice(&key_epoch.to_le_bytes());
157 ChainHash(*blake3::keyed_hash(&key.0, &input).as_bytes())
158}
159
160/// Compute the next chain link from the previous link and this entry's canonicalized content
161/// bytes (the entry serialized with its own chain field excluded — see each adapter for the
162/// exact strip-then-reserialize procedure).
163#[must_use]
164pub fn chain_next(key: &ChainKey, prev: &ChainHash, content: &[u8]) -> ChainHash {
165 let mut input = Vec::with_capacity(32 + content.len());
166 input.extend_from_slice(&prev.0);
167 input.extend_from_slice(content);
168 ChainHash(*blake3::keyed_hash(&key.0, &input).as_bytes())
169}
170
171/// Errors from computing or verifying a hash chain.
172#[derive(Debug, thiserror::Error, PartialEq, Eq)]
173pub enum ChainError {
174 /// A stored hex-encoded chain hash was not exactly 64 hex characters.
175 #[error("malformed chain hash (expected 64 hex characters)")]
176 MalformedHash,
177
178 /// A chain link recomputed under a **known** key (current or a previous rotation-window
179 /// epoch) did not match the stored value at `index` within the chained region — this is a
180 /// definite tamper verdict: the key resolved correctly at the first chained entry, so a
181 /// later mismatch means the content itself was altered, reordered, or deleted-and-replaced.
182 #[error(
183 "chain hash mismatch at chained-entry index {index}: content was modified after being written"
184 )]
185 Mismatch {
186 /// Zero-based index within the chained region (not the file's physical line number).
187 index: u64,
188 },
189
190 /// Neither the current epoch's key nor any previous-epoch key in the rotation window
191 /// produced a valid link for the first chained entry. This is genuinely ambiguous — it
192 /// could be tamper under an unknown key, or a legitimate session that predates the
193 /// rotation window — so it fails closed without asserting either. Per NFR-004 this is
194 /// never downgraded to trusted-legacy: only a file with **no** chain metadata anywhere is
195 /// legacy.
196 #[error(
197 "chain is unverifiable: no known key epoch (current or previous rotation window) \
198 produces a valid link for this file — possibly re-keyed past the rotation window, \
199 or tampered"
200 )]
201 Unverifiable,
202
203 /// No chain key is available at all (e.g. the vault key was never provisioned, or the
204 /// vault is unreachable) for a file that carries chain metadata. Per NFR-004 this is a
205 /// failure, never a silent skip.
206 #[error("no chain key is available to verify a chained file")]
207 KeyUnavailable,
208}
209
210/// How a chained file's key epoch was resolved during verification (FR-008): distinguishes a
211/// legitimate rotation from tamper so callers can report the two differently rather than
212/// misleading an operator into believing a re-keyed (but otherwise intact) file was tampered
213/// with.
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum KeyResolution {
216 /// The file verifies under the current epoch's key — the common case.
217 Current,
218 /// The file verifies under a previous epoch's key still held in the rotation window — a
219 /// legitimate rotation, not tamper.
220 Rekeyed(u32),
221}
222
223/// Current epoch plus an optional previous epoch, both carrying their own domain-separated
224/// [`ChainKey`] — the rotation window a chain verification is checked against (FR-008).
225///
226/// Building the full multi-epoch vault rotation *tooling* (issuing a new epoch, retiring old
227/// ones) is spec-056's concern; this type only carries whatever window the caller resolved from
228/// the vault at verification time.
229#[derive(Clone, Copy)]
230pub struct ChainKeyRing {
231 current_epoch: u32,
232 current_key: ChainKey,
233 previous: Option<(u32, ChainKey)>,
234}
235
236impl std::fmt::Debug for ChainKeyRing {
237 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 f.debug_struct("ChainKeyRing")
239 .field("current_epoch", &self.current_epoch)
240 .field("previous_epoch", &self.previous.map(|(epoch, _)| epoch))
241 .finish_non_exhaustive()
242 }
243}
244
245impl ChainKeyRing {
246 /// Construct a ring with only a current epoch (no rotation window yet).
247 #[must_use]
248 pub fn new(current_epoch: u32, current_key: ChainKey) -> Self {
249 Self {
250 current_epoch,
251 current_key,
252 previous: None,
253 }
254 }
255
256 /// Add a previous epoch to the rotation window.
257 #[must_use]
258 pub fn with_previous(mut self, epoch: u32, key: ChainKey) -> Self {
259 self.previous = Some((epoch, key));
260 self
261 }
262
263 /// The current epoch number. New appends are always written under this epoch.
264 #[must_use]
265 pub fn current_epoch(&self) -> u32 {
266 self.current_epoch
267 }
268
269 /// The current epoch's key, for writing new entries.
270 #[must_use]
271 pub fn current_key(&self) -> ChainKey {
272 self.current_key
273 }
274
275 /// Every candidate `(epoch, key, resolution)` to try during verification, current epoch
276 /// first.
277 fn candidates(&self) -> Vec<(u32, ChainKey, KeyResolution)> {
278 let mut out = vec![(self.current_epoch, self.current_key, KeyResolution::Current)];
279 if let Some((epoch, key)) = self.previous {
280 out.push((epoch, key, KeyResolution::Rekeyed(epoch)));
281 }
282 out
283 }
284}
285
286/// Incremental, O(1)-memory chain verifier: folds one entry at a time, carrying forward only the
287/// last verified hash (NFR-002's "carry forward only the last verified hash as state" shape,
288/// reused here for the JSONL adapters even though NFR-002 itself was written for durable's
289/// segment reads).
290pub struct ChainVerifier {
291 key: ChainKey,
292 prev: ChainHash,
293 index: u64,
294}
295
296impl ChainVerifier {
297 /// Start a verifier at `genesis` (or at the last verified head, when resuming mid-file).
298 #[must_use]
299 pub fn new(key: ChainKey, genesis: ChainHash) -> Self {
300 Self {
301 key,
302 prev: genesis,
303 index: 0,
304 }
305 }
306
307 /// Verify one more entry against the running chain state, advancing it on success.
308 ///
309 /// # Errors
310 ///
311 /// Returns [`ChainError::Mismatch`] if the recomputed hash does not equal `stored`.
312 pub fn verify_next(&mut self, content: &[u8], stored: &ChainHash) -> Result<(), ChainError> {
313 let expected = chain_next(&self.key, &self.prev, content);
314 if expected != *stored {
315 return Err(ChainError::Mismatch { index: self.index });
316 }
317 self.prev = expected;
318 self.index += 1;
319 Ok(())
320 }
321
322 /// Compute the next link without advancing state — used by writers to compute the hash to
323 /// store for a new append, immediately followed by a manual [`Self::advance`] once the write
324 /// is known to have succeeded.
325 #[must_use]
326 pub fn peek_next(&self, content: &[u8]) -> ChainHash {
327 chain_next(&self.key, &self.prev, content)
328 }
329
330 /// Advance the running state to `head` after a write using [`Self::peek_next`]'s result has
331 /// been durably committed.
332 pub fn advance(&mut self, head: ChainHash) {
333 self.prev = head;
334 self.index += 1;
335 }
336
337 /// The current head hash (the last verified, or last advanced-to, link).
338 #[must_use]
339 pub fn head(&self) -> ChainHash {
340 self.prev
341 }
342
343 /// How many entries have been verified/advanced so far.
344 #[must_use]
345 pub fn index(&self) -> u64 {
346 self.index
347 }
348}
349
350/// Streaming chain verifier that resolves the key epoch incrementally from the first chained
351/// entry it sees, without requiring the caller to buffer the whole chained region in memory.
352///
353/// Before the epoch is resolved, every candidate epoch in the [`ChainKeyRing`] is tried against
354/// each incoming entry in parallel (at most 2: current + previous — a small, fixed, O(1) memory
355/// cost independent of file size); once exactly one candidate's genesis produces a valid link,
356/// verification collapses to a single incremental [`ChainVerifier`] for the rest of the stream.
357/// This lets [`zeph-session`](../../zeph_session/index.html)'s `read_chunked` verify a
358/// replay-trusted log's chain without materializing the whole file — the same bounded-memory
359/// shape its chunked read already provides for the torn-tail check.
360pub struct ChainStreamVerifier {
361 domain: String,
362 file_identity: Vec<u8>,
363 /// Unresolved candidates, tried against every entry until exactly one survives. Empty once
364 /// [`Self::resolved`] is `Some`.
365 candidates: Vec<(u32, ChainKey, KeyResolution)>,
366 resolved: Option<ChainVerifier>,
367 resolution: Option<KeyResolution>,
368}
369
370impl ChainStreamVerifier {
371 /// Start a streaming verifier for one chained region.
372 #[must_use]
373 pub fn new(
374 ring: &ChainKeyRing,
375 domain: impl Into<String>,
376 file_identity: impl Into<Vec<u8>>,
377 ) -> Self {
378 Self {
379 domain: domain.into(),
380 file_identity: file_identity.into(),
381 candidates: ring.candidates(),
382 resolved: None,
383 resolution: None,
384 }
385 }
386
387 /// Verify the next entry in on-disk order, resolving the key epoch on the first call.
388 ///
389 /// # Errors
390 ///
391 /// Returns [`ChainError::Unverifiable`] on the first call if no candidate epoch's genesis
392 /// produces a valid link, or [`ChainError::Mismatch`] on any call once the epoch is resolved
393 /// and a later entry breaks the chain.
394 pub fn verify_next(&mut self, content: &[u8], stored: &ChainHash) -> Result<(), ChainError> {
395 if let Some(verifier) = self.resolved.as_mut() {
396 return verifier.verify_next(content, stored);
397 }
398
399 let mut survivor = None;
400 for (epoch, key, resolution) in &self.candidates {
401 let base = genesis(key, &self.domain, &self.file_identity, *epoch);
402 if chain_next(key, &base, content) == *stored {
403 survivor = Some((*epoch, *key, *resolution));
404 break;
405 }
406 }
407 let Some((epoch, key, resolution)) = survivor else {
408 return Err(ChainError::Unverifiable);
409 };
410
411 let base = genesis(&key, &self.domain, &self.file_identity, epoch);
412 let mut verifier = ChainVerifier::new(key, base);
413 verifier.verify_next(content, stored)?; // re-derives the same hash just matched; infallible in practice
414 self.resolved = Some(verifier);
415 self.resolution = Some(resolution);
416 self.candidates.clear();
417 Ok(())
418 }
419
420 /// The verified head hash, once at least one entry has been verified.
421 #[must_use]
422 pub fn head(&self) -> Option<ChainHash> {
423 self.resolved.as_ref().map(ChainVerifier::head)
424 }
425
426 /// How the key epoch was resolved, once at least one entry has been verified.
427 #[must_use]
428 pub fn resolution(&self) -> Option<KeyResolution> {
429 self.resolution
430 }
431}
432
433/// Verify a contiguous run of chained entries already held in memory (the file's chained region
434/// — see each adapter for how it locates where legacy content ends and chaining begins). A thin
435/// convenience wrapper over [`ChainStreamVerifier`] for callers that already have the whole
436/// region as a slice (both JSONL adapters' non-chunked read paths); streaming callers (e.g.
437/// bounded-memory chunked reads) should drive [`ChainStreamVerifier`] directly instead.
438///
439/// `entries` is `(content_bytes, stored_hash)` pairs in on-disk order, content bytes excluding
440/// the entry's own chain field.
441///
442/// # Errors
443///
444/// Returns [`ChainError::Unverifiable`] if no candidate epoch's genesis produces a valid link
445/// for the first entry, or [`ChainError::Mismatch`] if a later entry breaks the chain under the
446/// key epoch that verified the first entry.
447///
448/// # Examples
449///
450/// ```
451/// use zeph_common::hash_chain::{ChainKey, ChainKeyRing, chain_next, genesis, verify_chained_prefix};
452///
453/// let key = ChainKey::new([7u8; 32]);
454/// let ring = ChainKeyRing::new(0, key);
455/// let base = genesis(&key, "test v1", b"file-1", 0);
456/// let h0 = chain_next(&key, &base, b"line0");
457/// let h1 = chain_next(&key, &h0, b"line1");
458///
459/// let entries = vec![(b"line0".to_vec(), h0), (b"line1".to_vec(), h1)];
460/// let (head, _resolution) =
461/// verify_chained_prefix(&ring, "test v1", b"file-1", &entries).unwrap();
462/// assert_eq!(head, h1);
463/// ```
464pub fn verify_chained_prefix(
465 ring: &ChainKeyRing,
466 domain: &str,
467 file_identity: &[u8],
468 entries: &[(Vec<u8>, ChainHash)],
469) -> Result<(ChainHash, KeyResolution), ChainError> {
470 let (head, _checkpoint, resolution) =
471 verify_chained_prefix_with_checkpoint(ring, domain, file_identity, entries, u64::MAX)?;
472 Ok((head, resolution))
473}
474
475/// Like [`verify_chained_prefix`], but additionally captures the chain head immediately after
476/// entry `checkpoint_index` (0-based within `entries`) is verified — used by the vault-anchor
477/// downgrade-resistance mechanism (issue #6449) to compare a stored anchor's head against the
478/// file's head at the anchor's recorded count, without re-deriving the chain a second time.
479///
480/// Returns `(final_head, checkpoint_head, resolution)`. `checkpoint_head` is `None` if
481/// `checkpoint_index >= entries.len()` (out of range — including the common case of passing
482/// `u64::MAX` from [`verify_chained_prefix`] to opt out of capturing a checkpoint).
483///
484/// # Errors
485///
486/// Same as [`verify_chained_prefix`].
487pub fn verify_chained_prefix_with_checkpoint(
488 ring: &ChainKeyRing,
489 domain: &str,
490 file_identity: &[u8],
491 entries: &[(Vec<u8>, ChainHash)],
492 checkpoint_index: u64,
493) -> Result<(ChainHash, Option<ChainHash>, KeyResolution), ChainError> {
494 if entries.is_empty() {
495 // Nothing to verify: an empty chained region has no key to resolve. Callers should not
496 // invoke this with an empty slice; treat it as trivially verified at the current epoch.
497 return Ok((
498 genesis(&ring.current_key, domain, file_identity, ring.current_epoch),
499 None,
500 KeyResolution::Current,
501 ));
502 }
503
504 let mut streaming = ChainStreamVerifier::new(ring, domain, file_identity.to_vec());
505 let mut checkpoint_head = None;
506 for (i, (content, stored)) in entries.iter().enumerate() {
507 streaming.verify_next(content, stored)?;
508 if i as u64 == checkpoint_index {
509 checkpoint_head = streaming.head();
510 }
511 }
512 // Infallible: the loop above verified at least one entry (entries is non-empty), which
513 // always sets `resolved`/`resolution` on success.
514 let head = streaming
515 .head()
516 .unwrap_or_else(|| genesis(&ring.current_key, domain, file_identity, ring.current_epoch));
517 let resolution = streaming.resolution().unwrap_or(KeyResolution::Current);
518 Ok((head, checkpoint_head, resolution))
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 fn key(byte: u8) -> ChainKey {
526 ChainKey::new([byte; 32])
527 }
528
529 #[test]
530 fn hex_round_trip() {
531 let h = chain_next(&key(1), &genesis(&key(1), "d", b"f", 0), b"content");
532 let hex = h.to_hex();
533 assert_eq!(hex.len(), 64);
534 let back = ChainHash::from_hex(&hex).unwrap();
535 assert_eq!(h, back);
536 }
537
538 #[test]
539 fn from_hex_rejects_wrong_length() {
540 assert_eq!(ChainHash::from_hex("abc"), Err(ChainError::MalformedHash));
541 }
542
543 #[test]
544 fn from_hex_rejects_non_hex() {
545 let bad = "z".repeat(64);
546 assert_eq!(ChainHash::from_hex(&bad), Err(ChainError::MalformedHash));
547 }
548
549 #[test]
550 fn genesis_differs_per_domain() {
551 let a = genesis(&key(1), "domain-a", b"file", 0);
552 let b = genesis(&key(1), "domain-b", b"file", 0);
553 assert_ne!(a, b, "cross-subsystem genesis must differ");
554 }
555
556 #[test]
557 fn genesis_differs_per_file_identity() {
558 let a = genesis(&key(1), "d", b"file-a", 0);
559 let b = genesis(&key(1), "d", b"file-b", 0);
560 assert_ne!(a, b, "whole-file substitution must break at genesis");
561 }
562
563 #[test]
564 fn genesis_differs_per_epoch() {
565 let a = genesis(&key(1), "d", b"file", 0);
566 let b = genesis(&key(1), "d", b"file", 1);
567 assert_ne!(a, b, "key rotation must change genesis deterministically");
568 }
569
570 #[test]
571 fn verifier_detects_in_place_edit() {
572 let k = key(9);
573 let base = genesis(&k, "d", b"f", 0);
574 let mut writer = ChainVerifier::new(k, base);
575 let h0 = writer.peek_next(b"original");
576 writer.advance(h0);
577
578 // Reader recomputes over tampered content but the stored hash is unchanged.
579 let mut reader = ChainVerifier::new(k, base);
580 let err = reader.verify_next(b"tampered", &h0).unwrap_err();
581 assert_eq!(err, ChainError::Mismatch { index: 0 });
582 }
583
584 #[test]
585 fn verifier_detects_reorder() {
586 let k = key(3);
587 let base = genesis(&k, "d", b"f", 0);
588 let h0 = chain_next(&k, &base, b"a");
589 let h1 = chain_next(&k, &h0, b"b");
590 let _h2 = chain_next(&k, &h1, b"c");
591
592 // Entry 0 ("a") is untouched, so key-epoch resolution succeeds there. Entries 1 and 2
593 // ("b", "c") are then swapped physically while keeping their originally-computed stored
594 // hashes — the swapped-in entry's prev-hash no longer matches its actual predecessor,
595 // which must surface as a definite tamper (Mismatch), not an ambiguous Unverifiable.
596 let entries = vec![(b"a".to_vec(), h0), (b"c".to_vec(), h1)];
597 let ring = ChainKeyRing::new(0, k);
598 let err = verify_chained_prefix(&ring, "d", b"f", &entries).unwrap_err();
599 assert_eq!(err, ChainError::Mismatch { index: 1 });
600 }
601
602 #[test]
603 fn verify_chained_prefix_happy_path() {
604 let k = key(4);
605 let ring = ChainKeyRing::new(0, k);
606 let base = genesis(&k, "d", b"f", 0);
607 let h0 = chain_next(&k, &base, b"a");
608 let h1 = chain_next(&k, &h0, b"b");
609 let entries = vec![(b"a".to_vec(), h0), (b"b".to_vec(), h1)];
610 let (head, resolution) = verify_chained_prefix(&ring, "d", b"f", &entries).unwrap();
611 assert_eq!(head, h1);
612 assert_eq!(resolution, KeyResolution::Current);
613 }
614
615 #[test]
616 fn verify_chained_prefix_resolves_previous_epoch_as_rekeyed() {
617 let old_key = key(5);
618 let new_key = key(6);
619 let ring = ChainKeyRing::new(1, new_key).with_previous(0, old_key);
620
621 // File was fully written under the old (epoch 0) key before rotation.
622 let base = genesis(&old_key, "d", b"f", 0);
623 let h0 = chain_next(&old_key, &base, b"a");
624 let entries = vec![(b"a".to_vec(), h0)];
625
626 let (_head, resolution) = verify_chained_prefix(&ring, "d", b"f", &entries).unwrap();
627 assert_eq!(resolution, KeyResolution::Rekeyed(0));
628 }
629
630 #[test]
631 fn verify_chained_prefix_unverifiable_when_no_epoch_matches() {
632 let ring = ChainKeyRing::new(0, key(1));
633 let wrong = genesis(&key(99), "d", b"f", 0);
634 let h0 = chain_next(&key(99), &wrong, b"a");
635 let entries = vec![(b"a".to_vec(), h0)];
636 let err = verify_chained_prefix(&ring, "d", b"f", &entries).unwrap_err();
637 assert_eq!(err, ChainError::Unverifiable);
638 }
639
640 #[test]
641 fn verify_chained_prefix_mismatch_after_correct_genesis_is_definite_tamper() {
642 let k = key(7);
643 let ring = ChainKeyRing::new(0, k);
644 let base = genesis(&k, "d", b"f", 0);
645 let h0 = chain_next(&k, &base, b"a");
646 // Second entry's stored hash does not follow h0 at all (forged/dangling).
647 let bogus = ChainHash(*blake3::hash(b"not a real chain link").as_bytes());
648 let entries = vec![(b"a".to_vec(), h0), (b"b".to_vec(), bogus)];
649 let err = verify_chained_prefix(&ring, "d", b"f", &entries).unwrap_err();
650 assert_eq!(err, ChainError::Mismatch { index: 1 });
651 }
652
653 /// M1 (canonicalization config guard), **corrected during implementation**: the original
654 /// design assumed this workspace's `serde_json` has `preserve_order` disabled everywhere
655 /// (root `Cargo.toml` alone has no `preserve_order` feature declared, which is what the
656 /// critic's rev1-3 reviews checked). Building with the actual CI feature set
657 /// (`desktop,ide,server,chat,pdf,scheduler,testing`) and running this exact test empirically
658 /// falsified that assumption: `agent-client-protocol`/`agent-client-protocol-schema`
659 /// (pulled in by the `acp`/`ide` feature, via `schemars`) and `tree-sitter`'s build script
660 /// (via `zeph-common`'s `treesitter` feature) both transitively enable
661 /// `serde_json/preserve_order`, and Cargo feature unification makes that apply
662 /// workspace-wide to every crate depending on `serde_json` — including this one — for any
663 /// build that enables ACP.
664 ///
665 /// The canonicalization scheme remains sound despite this: it never required *sorted* key
666 /// order, only that **serialize → deserialize → serialize reproduces byte-identical
667 /// output** (see [`round_trip_serialization_is_byte_identical`], the test that actually
668 /// matters and which passes under both `preserve_order` on and off — insertion order is
669 /// preserved through a deserialize/reserialize round-trip exactly as faithfully as sorted
670 /// order is, since neither backend's iteration order is affected by *which* representation
671 /// is compiled in, only *what write path produced the bytes on disk*). This test now
672 /// documents that reality directly, instead of asserting the disproven "must be sorted"
673 /// claim.
674 #[test]
675 fn serde_json_value_maps_serialize_deterministically_whichever_backend_is_compiled_in() {
676 let mut map = serde_json::Map::new();
677 // Insertion order deliberately not sorted.
678 map.insert("zebra".to_owned(), serde_json::json!(1));
679 map.insert("alpha".to_owned(), serde_json::json!(2));
680 map.insert("mango".to_owned(), serde_json::json!(3));
681 let value = serde_json::Value::Object(map);
682
683 // Whichever backend is compiled in (sorted `BTreeMap` with `preserve_order` off,
684 // insertion-order `IndexMap` with it on — currently on, transitively, for this exact
685 // feature set), two consecutive serializations of the same unmutated value must agree.
686 let first = serde_json::to_string(&value).unwrap();
687 let second = serde_json::to_string(&value).unwrap();
688 assert_eq!(
689 first, second,
690 "serde_json::Value must serialize deterministically for a fixed in-memory value, \
691 regardless of which backend (sorted BTreeMap or insertion-order IndexMap) is \
692 compiled in — this is the actual invariant canonicalization depends on, not sorted \
693 key order (see the corrected M1 note on this test)"
694 );
695 }
696
697 /// A second determinism fixture: round-tripping a struct through serialize → deserialize →
698 /// serialize must reproduce byte-identical output, which is what each adapter's write path
699 /// (serialize once to hash, again to store) and read path (deserialize, strip chain,
700 /// re-serialize to verify) both depend on.
701 #[test]
702 fn round_trip_serialization_is_byte_identical() {
703 #[derive(serde::Serialize, serde::Deserialize)]
704 struct Fixture {
705 seq: u64,
706 #[serde(default, skip_serializing_if = "Option::is_none")]
707 chain: Option<String>,
708 payload: serde_json::Value,
709 }
710
711 let mut map = serde_json::Map::new();
712 map.insert("z".to_owned(), serde_json::json!("last"));
713 map.insert("a".to_owned(), serde_json::json!("first"));
714 map.insert(
715 "big".to_owned(),
716 serde_json::json!(9_007_199_254_740_993u64),
717 ); // > 2^53
718
719 let original = Fixture {
720 seq: 5,
721 chain: None,
722 payload: serde_json::Value::Object(map),
723 };
724 let bytes1 = serde_json::to_vec(&original).unwrap();
725 let round_tripped: Fixture = serde_json::from_slice(&bytes1).unwrap();
726 let bytes2 = serde_json::to_vec(&round_tripped).unwrap();
727 assert_eq!(
728 bytes1, bytes2,
729 "serialize -> deserialize -> serialize must be byte-identical for canonicalization \
730 to be sound"
731 );
732 // u64 > 2^53 must round-trip losslessly — this is exactly what JCS (serde_json_canonicalizer)
733 // would lose (it normalizes to ES6 f64), which is why this module deliberately does NOT
734 // adopt it (M1).
735 assert_eq!(
736 round_tripped.payload.get("big").unwrap(),
737 &serde_json::json!(9_007_199_254_740_993u64)
738 );
739 }
740
741 #[test]
742 fn chain_stream_verifier_matches_whole_slice_verification() {
743 let k = key(11);
744 let ring = ChainKeyRing::new(0, k);
745 let base = genesis(&k, "d", b"f", 0);
746 let h0 = chain_next(&k, &base, b"a");
747 let h1 = chain_next(&k, &h0, b"b");
748 let h2 = chain_next(&k, &h1, b"c");
749 let entries = vec![
750 (b"a".to_vec(), h0),
751 (b"b".to_vec(), h1),
752 (b"c".to_vec(), h2),
753 ];
754
755 let (whole_head, whole_res) = verify_chained_prefix(&ring, "d", b"f", &entries).unwrap();
756
757 // Feed the same entries one at a time, simulating a bounded-memory chunked reader.
758 let mut streaming = ChainStreamVerifier::new(&ring, "d", b"f".to_vec());
759 for (content, stored) in &entries {
760 streaming.verify_next(content, stored).unwrap();
761 }
762 assert_eq!(streaming.head(), Some(whole_head));
763 assert_eq!(streaming.resolution(), Some(whole_res));
764 }
765
766 #[test]
767 fn verify_chained_prefix_with_checkpoint_captures_intermediate_head() {
768 let k = key(13);
769 let ring = ChainKeyRing::new(0, k);
770 let base = genesis(&k, "d", b"f", 0);
771 let h0 = chain_next(&k, &base, b"a");
772 let h1 = chain_next(&k, &h0, b"b");
773 let h2 = chain_next(&k, &h1, b"c");
774 let entries = vec![
775 (b"a".to_vec(), h0),
776 (b"b".to_vec(), h1),
777 (b"c".to_vec(), h2),
778 ];
779
780 let (final_head, checkpoint, _res) =
781 verify_chained_prefix_with_checkpoint(&ring, "d", b"f", &entries, 1).unwrap();
782 assert_eq!(final_head, h2);
783 assert_eq!(checkpoint, Some(h1), "checkpoint at index 1 must be h1");
784
785 let (_final, out_of_range, _res) =
786 verify_chained_prefix_with_checkpoint(&ring, "d", b"f", &entries, 99).unwrap();
787 assert_eq!(out_of_range, None, "an out-of-range checkpoint is None");
788 }
789
790 #[test]
791 fn chain_stream_verifier_detects_tamper_mid_stream() {
792 let k = key(12);
793 let ring = ChainKeyRing::new(0, k);
794 let base = genesis(&k, "d", b"f", 0);
795 let h0 = chain_next(&k, &base, b"a");
796
797 let mut streaming = ChainStreamVerifier::new(&ring, "d", b"f".to_vec());
798 streaming.verify_next(b"a", &h0).unwrap();
799 // Second entry's stored hash does not follow h0 at all.
800 let bogus = ChainHash(*blake3::hash(b"forged").as_bytes());
801 let err = streaming.verify_next(b"b", &bogus).unwrap_err();
802 assert_eq!(err, ChainError::Mismatch { index: 1 });
803 }
804
805 #[test]
806 fn chain_key_debug_does_not_leak_key_material() {
807 let k = key(0xAB);
808 let debug = format!("{k:?}");
809 assert!(
810 !debug.contains("171"),
811 "ChainKey Debug must not print key bytes"
812 );
813 assert_eq!(debug, "ChainKey(..)");
814 }
815}