Skip to main content

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}