Skip to main content

arkhe_forge_platform/hf2_kms/
journal.rs

1//! `runtime_doctor_journal` chain-signed persistence — audit-log
2//! tamper-resistance.
3//!
4//! Each [`JournalEntry`] links to its predecessor through a BLAKE3 chain
5//! hash and carries an Ed25519 signature over that hash; readers verify the
6//! whole log with [`PersistentJournal::verify_chain`] — a single tamper
7//! surfaces as [`JournalError::ChainIntegrity`] or
8//! [`JournalError::SignatureInvalid`].
9//!
10//! # Layering
11//!
12//! - [`ConsumedToken`] — the audit payload (Shamir token identifier,
13//!   consuming operator fingerprint, tick).
14//! - [`JournalEntry`] — `ConsumedToken` + `prev_hash` + `entry_hash` +
15//!   `signature`. Entry hash is a BLAKE3 keyed hash over `prev_hash || token
16//!   canonical bytes` under the `arkhe-runtime-doctor-journal-chain` domain.
17//! - [`JournalSigner`] — signing trait; the real HW-key-backed signer
18//!   (YubiKey / NitroKey per `docs/release-keys.md` §3) lives outside this
19//!   module. [`InMemoryJournalSigner`] ships only for dev / unit tests.
20//! - [`PersistentJournal`] — pluggable backend trait. [`InMemoryJournal`]
21//!   is the dev impl; [`WalBackedJournal`] wires against
22//!   `arkhe-kernel` WAL.
23//!
24//! # `KmsBackend` integration
25//!
26//! The journal append path lives in the **upper coordinator**, not
27//! inside `KmsBackend` (e.g. auto_promote evaluator, crypto-erasure
28//! coordinator), which calls it. This preserves the sync trait
29//! surface and avoids `AwsKmsBackend`'s `tokio::block_on` bridge
30//! re-entrance. Detailed wiring lives in `kms_backend.rs`.
31//!
32//! # Signer injection
33//!
34//! The runtime process **does not directly hold** private Ed25519
35//! key material — a `JournalSigner` trait object is injected from
36//! the 2-person co-custody HW key described in
37//! `docs/release-keys.md` §3. The trait keeps backend selection
38//! orthogonal: `InMemoryJournalSigner` covers the dev path,
39//! HW-backed signers (e.g. `YubiKeyJournalSigner`) plug in via the
40//! same trait.
41
42use blake3::derive_key;
43use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
44
45/// BLAKE3 domain separator for journal chain hashing. Registered in spec
46/// `Runtime BLAKE3 domain string list` (canonical mirror);
47/// `runtime_doctor_journal` chain hash cross-ref.
48pub const JOURNAL_CHAIN_DOMAIN: &str = "arkhe-runtime-doctor-journal-chain";
49
50/// Genesis `prev_hash` — the first entry uses a zero prev_hash.
51pub const GENESIS_PREV_HASH: [u8; 32] = [0u8; 32];
52
53/// Consumed Shamir authorization token — the audit payload.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ConsumedToken {
56    /// Token identifier (BLAKE3 hash of share set, 32 byte).
57    pub token_hash: [u8; 32],
58    /// Consuming operator fingerprint (Ed25519 pubkey first 8 byte).
59    pub operator_fingerprint: [u8; 8],
60    /// Consumed at tick.
61    pub consumed_at_tick: u64,
62}
63
64impl ConsumedToken {
65    /// Canonical byte encoding — field order + lengths are pinned so chain
66    /// hashes stay stable across releases.
67    pub fn canonical_bytes(&self) -> Vec<u8> {
68        let mut buf = Vec::with_capacity(32 + 8 + 8);
69        buf.extend_from_slice(&self.token_hash);
70        buf.extend_from_slice(&self.operator_fingerprint);
71        buf.extend_from_slice(&self.consumed_at_tick.to_be_bytes());
72        buf
73    }
74}
75
76/// Chain-signed journal entry.
77#[derive(Debug, Clone)]
78pub struct JournalEntry {
79    /// Audit payload.
80    pub token: ConsumedToken,
81    /// Previous entry's `entry_hash` (or [`GENESIS_PREV_HASH`] for the first
82    /// entry).
83    pub prev_hash: [u8; 32],
84    /// `BLAKE3-derive_key(JOURNAL_CHAIN_DOMAIN, prev_hash || token_canonical_bytes)`.
85    pub entry_hash: [u8; 32],
86    /// `Ed25519 sign(entry_hash)`.
87    pub signature: [u8; 64],
88    /// Signer's Ed25519 public key.
89    pub signer_pubkey: [u8; 32],
90}
91
92impl JournalEntry {
93    /// Re-compute `entry_hash` from `prev_hash` + `token` canonical bytes.
94    ///
95    /// The hashed payload is the fixed 80-byte layout `prev_hash (32) ||
96    /// token_hash (32) || operator_fingerprint (8) || consumed_at_tick_be (8)`
97    /// — byte-identical to `prev_hash || token.canonical_bytes()`, assembled on
98    /// the stack to avoid two heap allocations per hash.
99    pub fn compute_entry_hash(prev_hash: &[u8; 32], token: &ConsumedToken) -> [u8; 32] {
100        let mut payload = [0u8; 80];
101        payload[0..32].copy_from_slice(prev_hash);
102        payload[32..64].copy_from_slice(&token.token_hash);
103        payload[64..72].copy_from_slice(&token.operator_fingerprint);
104        payload[72..80].copy_from_slice(&token.consumed_at_tick.to_be_bytes());
105        derive_key(JOURNAL_CHAIN_DOMAIN, &payload)
106    }
107}
108
109/// Signing abstraction — the real HW-key signer (YubiKey / NitroKey) lives
110/// behind this trait so the journal never touches raw `SigningKey` material.
111///
112/// `Send + Sync` are required so `&dyn JournalSigner` survives future L2
113/// multi-consumer transport (audit replicator / transparency-log publisher)
114/// even though the current single-active L2 path only crosses threads via
115/// the observer pool. Impls are expected to be cheap to share — an
116/// `Arc<SigningKey>` wrapper or HW-backed handle.
117pub trait JournalSigner: Send + Sync {
118    /// Sign `message` and return the 64-byte Ed25519 signature.
119    fn sign(&self, message: &[u8]) -> [u8; 64];
120    /// Signer's Ed25519 public key (32 byte) — embedded in each entry for
121    /// independent verification.
122    fn public_key(&self) -> [u8; 32];
123}
124
125/// Dev-only signer backed by an in-process `SigningKey`. **Production**:
126/// replace with a HW-backed signer (e.g. `YubiKeyJournalSigner`) so private
127/// key material never enters the process address space
128/// (`docs/release-keys.md` §3).
129pub struct InMemoryJournalSigner {
130    key: SigningKey,
131}
132
133impl InMemoryJournalSigner {
134    /// Wrap an in-process `SigningKey`. Callers must ensure the key material
135    /// stays inside the [`process_protection`](super::super::process_protection)
136    /// boundary (Tier-0 software-kek) or is supplied exclusively via test
137    /// fixtures.
138    pub fn new(key: SigningKey) -> Self {
139        Self { key }
140    }
141
142    /// Verify handle — exposed mostly so tests can assert signature
143    /// validity without reaching into the crate internals.
144    pub fn verifying_key(&self) -> VerifyingKey {
145        self.key.verifying_key()
146    }
147}
148
149impl JournalSigner for InMemoryJournalSigner {
150    fn sign(&self, message: &[u8]) -> [u8; 64] {
151        let sig: Signature = self.key.sign(message);
152        sig.to_bytes()
153    }
154
155    fn public_key(&self) -> [u8; 32] {
156        self.key.verifying_key().to_bytes()
157    }
158}
159
160/// Journal operation error.
161#[non_exhaustive]
162#[derive(Debug, thiserror::Error, PartialEq, Eq)]
163pub enum JournalError {
164    /// Same-token reuse detected — replay attack.
165    #[error("duplicate token consume attempt")]
166    DuplicateToken,
167    /// Chain hash recomputation mismatch — tamper detected.
168    #[error("journal chain integrity violation at entry {index}")]
169    ChainIntegrity {
170        /// 0-based index of the first failing entry.
171        index: usize,
172    },
173    /// Ed25519 signature verification failed.
174    #[error("journal signature invalid at entry {index}")]
175    SignatureInvalid {
176        /// 0-based index of the first failing entry.
177        index: usize,
178    },
179    /// An entry's `signer_pubkey` did not match the pinned trust anchor.
180    /// `verify_chain` (self-consistency) cannot catch this — a forged log
181    /// re-signed under an attacker key is internally consistent. Only the
182    /// trust-anchored check ([`InMemoryJournal::verify_chain_anchored`])
183    /// rejects it.
184    #[error("journal signer key mismatch at entry {index}")]
185    SignerKeyMismatch {
186        /// 0-based index of the first entry signed under the wrong key.
187        index: usize,
188    },
189    /// The chain tip hash did not match the caller's pinned expectation —
190    /// the log was rewritten or replaced wholesale.
191    #[error("journal tip hash mismatch")]
192    TipMismatch,
193    /// The chain length did not match the caller's pinned expectation —
194    /// e.g. a truncated (rolled-back) log.
195    #[error("journal length mismatch: expected {expected}, got {actual}")]
196    LengthMismatch {
197        /// Length the caller pinned.
198        expected: usize,
199        /// Length actually present.
200        actual: usize,
201    },
202    /// Backend I/O error — used by the WAL-backed path.
203    #[error("journal backend error: {0}")]
204    BackendIo(String),
205}
206
207/// Append-only chain-signed journal — pluggable backend.
208pub trait PersistentJournal {
209    /// Append a consumed token. Duplicate `token_hash` is rejected with
210    /// [`JournalError::DuplicateToken`]. Success returns the newly-created
211    /// entry so the caller can verify / publish it.
212    fn append(
213        &mut self,
214        token: ConsumedToken,
215        signer: &dyn JournalSigner,
216    ) -> Result<JournalEntry, JournalError>;
217
218    /// **Self-consistency check only.** Returns `Ok(())` if every entry's
219    /// `entry_hash` matches its re-computation **and** every signature
220    /// validates under the entry's OWN embedded `signer_pubkey`; otherwise
221    /// surfaces the first failing index.
222    ///
223    /// This does NOT establish producer authenticity: each entry is verified
224    /// against its own embedded key, so an attacker holding any keypair can
225    /// re-sign every entry and forge a fully self-consistent journal that
226    /// passes this check. Use
227    /// [`InMemoryJournal::verify_chain_anchored`] to pin a trusted key plus
228    /// the expected tip + length when the producer is untrusted (mirror of
229    /// the kernel's `verify_chain` vs `verify_chain_anchored` split).
230    fn verify_chain(&self) -> Result<(), JournalError>;
231
232    /// Last entry's `entry_hash`, or [`GENESIS_PREV_HASH`] for an empty
233    /// journal. Useful for external publishing (transparency log).
234    fn tip_hash(&self) -> [u8; 32];
235
236    /// Count entries.
237    fn len(&self) -> usize;
238
239    /// Empty journal check.
240    fn is_empty(&self) -> bool {
241        self.len() == 0
242    }
243
244    /// Duplicate check — O(n) linear scan on in-memory, backend-specific on
245    /// WAL-backed.
246    fn is_duplicate(&self, token_hash: &[u8; 32]) -> bool;
247}
248
249/// Marker trait for WAL-backed journal impls — the real `arkhe-kernel`
250/// WAL integration routes through `WalBackedJournal`. Tier-1 operators
251/// use [`InMemoryJournal`] for dev / single-node deployments.
252pub trait WalBackedJournal: PersistentJournal {
253    // Stub — `WalBackedJournal` adds `persist_to_wal(...)` +
254    // `reconstruct_from_wal(...)` surface when L0 WAL exposes the hook.
255}
256
257/// Dev-only in-memory chain-signed journal.
258///
259/// `entries` is the ordered chain (walked by `verify_chain` / `tip_hash`);
260/// `seen` is a parallel `token_hash` set so `is_duplicate` (and therefore
261/// `append`'s replay guard) runs in O(1) instead of an O(n) scan per append.
262#[derive(Debug, Default)]
263pub struct InMemoryJournal {
264    entries: Vec<JournalEntry>,
265    seen: std::collections::HashSet<[u8; 32]>,
266}
267
268impl InMemoryJournal {
269    /// Empty journal.
270    pub fn new() -> Self {
271        Self::default()
272    }
273
274    /// Borrow the full entry list — read-only view for transparency-log
275    /// publishers.
276    pub fn entries(&self) -> &[JournalEntry] {
277        &self.entries
278    }
279
280    /// Trust-anchored verification — the consumer-side check under an
281    /// untrusted-producer threat model. Mirrors the kernel's
282    /// `verify_chain_anchored`: in addition to the self-consistency check
283    /// run by [`verify_chain`](PersistentJournal::verify_chain), it
284    ///
285    /// * asserts every entry was signed under `trusted_pubkey` (rejects a
286    ///   forged log re-signed under an attacker key —
287    ///   [`JournalError::SignerKeyMismatch`]),
288    /// * asserts the chain tip equals `expected_tip`
289    ///   ([`JournalError::TipMismatch`]),
290    /// * asserts the entry count equals `expected_len`
291    ///   ([`JournalError::LengthMismatch`] — catches truncation / rollback).
292    ///
293    /// `verify_chain()` alone is insufficient when the producer is
294    /// untrusted: each entry validates against its OWN embedded
295    /// `signer_pubkey`, so any keypair forges a self-consistent log.
296    pub fn verify_chain_anchored(
297        &self,
298        trusted_pubkey: &[u8; 32],
299        expected_tip: &[u8; 32],
300        expected_len: usize,
301    ) -> Result<(), JournalError> {
302        if self.entries.len() != expected_len {
303            return Err(JournalError::LengthMismatch {
304                expected: expected_len,
305                actual: self.entries.len(),
306            });
307        }
308        // Self-consistency (chain hashes + per-entry signatures) first.
309        self.verify_chain()?;
310        // Then pin every signer to the trust anchor.
311        for (idx, entry) in self.entries.iter().enumerate() {
312            if &entry.signer_pubkey != trusted_pubkey {
313                return Err(JournalError::SignerKeyMismatch { index: idx });
314            }
315        }
316        if &self.tip_hash() != expected_tip {
317            return Err(JournalError::TipMismatch);
318        }
319        Ok(())
320    }
321}
322
323impl PersistentJournal for InMemoryJournal {
324    fn append(
325        &mut self,
326        token: ConsumedToken,
327        signer: &dyn JournalSigner,
328    ) -> Result<JournalEntry, JournalError> {
329        if self.is_duplicate(&token.token_hash) {
330            return Err(JournalError::DuplicateToken);
331        }
332        let prev_hash = self.tip_hash();
333        let entry_hash = JournalEntry::compute_entry_hash(&prev_hash, &token);
334        let signature = signer.sign(&entry_hash);
335        let entry = JournalEntry {
336            token,
337            prev_hash,
338            entry_hash,
339            signature,
340            signer_pubkey: signer.public_key(),
341        };
342        self.seen.insert(entry.token.token_hash);
343        self.entries.push(entry.clone());
344        Ok(entry)
345    }
346
347    fn verify_chain(&self) -> Result<(), JournalError> {
348        let mut expected_prev = GENESIS_PREV_HASH;
349        for (idx, entry) in self.entries.iter().enumerate() {
350            if entry.prev_hash != expected_prev {
351                return Err(JournalError::ChainIntegrity { index: idx });
352            }
353            let recomputed = JournalEntry::compute_entry_hash(&entry.prev_hash, &entry.token);
354            if recomputed != entry.entry_hash {
355                return Err(JournalError::ChainIntegrity { index: idx });
356            }
357            let verifying_key = VerifyingKey::from_bytes(&entry.signer_pubkey)
358                .map_err(|_| JournalError::SignatureInvalid { index: idx })?;
359            let sig = Signature::from_bytes(&entry.signature);
360            verifying_key
361                .verify(&entry.entry_hash, &sig)
362                .map_err(|_| JournalError::SignatureInvalid { index: idx })?;
363            expected_prev = entry.entry_hash;
364        }
365        Ok(())
366    }
367
368    fn tip_hash(&self) -> [u8; 32] {
369        self.entries
370            .last()
371            .map(|e| e.entry_hash)
372            .unwrap_or(GENESIS_PREV_HASH)
373    }
374
375    fn len(&self) -> usize {
376        self.entries.len()
377    }
378
379    fn is_duplicate(&self, token_hash: &[u8; 32]) -> bool {
380        self.seen.contains(token_hash)
381    }
382}
383
384/// Backward-compatible alias — other modules (e.g. `threshold.rs`
385/// module-doc) still references this name.
386pub type ConsumedTokenJournal = InMemoryJournal;
387
388#[cfg(test)]
389#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
390mod tests {
391    use super::*;
392
393    fn test_signer(seed: u8) -> InMemoryJournalSigner {
394        let secret = [seed; 32];
395        InMemoryJournalSigner::new(SigningKey::from_bytes(&secret))
396    }
397
398    fn make_token(tag: u8, tick: u64) -> ConsumedToken {
399        ConsumedToken {
400            token_hash: [tag; 32],
401            operator_fingerprint: [tag; 8],
402            consumed_at_tick: tick,
403        }
404    }
405
406    #[test]
407    fn journal_initial_empty_and_genesis_tip() {
408        let j = InMemoryJournal::new();
409        assert!(j.is_empty());
410        assert_eq!(j.len(), 0);
411        assert_eq!(j.tip_hash(), GENESIS_PREV_HASH);
412    }
413
414    #[test]
415    fn append_produces_chained_entry() {
416        let mut j = InMemoryJournal::new();
417        let signer = test_signer(0x01);
418        let entry = j.append(make_token(0x11, 100), &signer).unwrap();
419        assert_eq!(entry.prev_hash, GENESIS_PREV_HASH);
420        assert_eq!(j.tip_hash(), entry.entry_hash);
421        assert_eq!(j.len(), 1);
422    }
423
424    #[test]
425    fn second_entry_chains_to_first() {
426        let mut j = InMemoryJournal::new();
427        let signer = test_signer(0x02);
428        let first = j.append(make_token(0x11, 1), &signer).unwrap();
429        let second = j.append(make_token(0x22, 2), &signer).unwrap();
430        assert_eq!(second.prev_hash, first.entry_hash);
431    }
432
433    #[test]
434    fn duplicate_token_rejected() {
435        let mut j = InMemoryJournal::new();
436        let signer = test_signer(0x03);
437        let token = make_token(0x42, 200);
438        assert!(j.append(token.clone(), &signer).is_ok());
439        assert_eq!(
440            j.append(token, &signer).unwrap_err(),
441            JournalError::DuplicateToken
442        );
443        assert_eq!(j.len(), 1);
444    }
445
446    #[test]
447    fn verify_chain_accepts_clean_log() {
448        let mut j = InMemoryJournal::new();
449        let signer = test_signer(0x04);
450        j.append(make_token(0x01, 10), &signer).unwrap();
451        j.append(make_token(0x02, 20), &signer).unwrap();
452        j.append(make_token(0x03, 30), &signer).unwrap();
453        assert!(j.verify_chain().is_ok());
454    }
455
456    #[test]
457    fn verify_chain_detects_tampered_hash() {
458        let mut j = InMemoryJournal::new();
459        let signer = test_signer(0x05);
460        j.append(make_token(0x01, 10), &signer).unwrap();
461        j.append(make_token(0x02, 20), &signer).unwrap();
462        // Tamper: flip one byte of the second entry's token tick.
463        j.entries[1].token.consumed_at_tick = 99;
464        match j.verify_chain() {
465            Err(JournalError::ChainIntegrity { index: 1 }) => {}
466            other => panic!("expected ChainIntegrity {{ index: 1 }}, got {other:?}"),
467        }
468    }
469
470    #[test]
471    fn verify_chain_detects_tampered_signature() {
472        let mut j = InMemoryJournal::new();
473        let signer = test_signer(0x06);
474        j.append(make_token(0x01, 10), &signer).unwrap();
475        // Flip a signature byte.
476        j.entries[0].signature[0] ^= 0xFF;
477        match j.verify_chain() {
478            Err(JournalError::SignatureInvalid { index: 0 }) => {}
479            other => panic!("expected SignatureInvalid {{ index: 0 }}, got {other:?}"),
480        }
481    }
482
483    #[test]
484    fn is_duplicate_query_matches_append_rejection() {
485        let mut j = InMemoryJournal::new();
486        let signer = test_signer(0x07);
487        let hash = [0x55u8; 32];
488        assert!(!j.is_duplicate(&hash));
489        j.append(
490            ConsumedToken {
491                token_hash: hash,
492                operator_fingerprint: [0u8; 8],
493                consumed_at_tick: 1,
494            },
495            &signer,
496        )
497        .unwrap();
498        assert!(j.is_duplicate(&hash));
499    }
500
501    /// #10 — the stack-buffer `compute_entry_hash` must be byte-identical to
502    /// the original `prev_hash || token.canonical_bytes()` payload hash. We
503    /// rebuild that payload independently here (the pre-optimization code path)
504    /// and assert equality, guarding the chain hash against a layout change.
505    #[test]
506    fn compute_entry_hash_matches_canonical_payload() {
507        let prev_hash = [0xABu8; 32];
508        let token = ConsumedToken {
509            token_hash: [0x11u8; 32],
510            operator_fingerprint: [0x22u8; 8],
511            consumed_at_tick: 0x3344_5566_7788_99AA,
512        };
513
514        // Independent reference: concat prev_hash with canonical_bytes() — the
515        // exact two-Vec payload the optimized stack buffer replaces.
516        let mut reference = Vec::new();
517        reference.extend_from_slice(&prev_hash);
518        reference.extend_from_slice(&token.canonical_bytes());
519        let expected = blake3::derive_key(JOURNAL_CHAIN_DOMAIN, &reference);
520
521        let got = JournalEntry::compute_entry_hash(&prev_hash, &token);
522        assert_eq!(
523            got, expected,
524            "stack-buffer hash must match canonical payload"
525        );
526    }
527
528    /// #25 — the O(1) dedup set must stay in lock-step with the chain: every
529    /// appended token_hash is reported duplicate, an absent one is not, and the
530    /// set size tracks the entry count.
531    #[test]
532    fn dedup_set_tracks_chain() {
533        let mut j = InMemoryJournal::new();
534        let signer = test_signer(0x21);
535        j.append(make_token(0xA1, 1), &signer).unwrap();
536        j.append(make_token(0xB2, 2), &signer).unwrap();
537        j.append(make_token(0xC3, 3), &signer).unwrap();
538        assert!(j.is_duplicate(&[0xA1; 32]));
539        assert!(j.is_duplicate(&[0xB2; 32]));
540        assert!(j.is_duplicate(&[0xC3; 32]));
541        assert!(!j.is_duplicate(&[0xD4; 32]));
542        assert_eq!(j.len(), 3);
543        // Re-appending any seen token is rejected and does not grow the chain.
544        assert_eq!(
545            j.append(make_token(0xB2, 99), &signer).unwrap_err(),
546            JournalError::DuplicateToken
547        );
548        assert_eq!(j.len(), 3);
549    }
550
551    #[test]
552    fn backward_alias_still_usable() {
553        // `ConsumedTokenJournal` alias keeps threshold.rs module-doc live.
554        let j: ConsumedTokenJournal = InMemoryJournal::new();
555        assert_eq!(j.len(), 0);
556    }
557
558    /// Rebuild a journal's entries from scratch, re-signing every entry under
559    /// `forger` — produces a fully self-consistent forgery (each entry's
560    /// embedded `signer_pubkey` matches its own signature).
561    fn forge_under(original: &InMemoryJournal, forger: &InMemoryJournalSigner) -> InMemoryJournal {
562        let mut j = InMemoryJournal::new();
563        for e in original.entries() {
564            j.append(e.token.clone(), forger)
565                .expect("forged append must succeed");
566        }
567        j
568    }
569
570    /// #7 — a forged journal re-signed under an attacker key PASSES the
571    /// self-consistency `verify_chain()` but FAILS `verify_chain_anchored()`
572    /// because no entry was signed under the pinned trusted key.
573    #[test]
574    fn anchored_rejects_forged_resigned_journal() {
575        let genuine_signer = test_signer(0x10);
576        let trusted_pubkey = genuine_signer.public_key();
577
578        let mut genuine = InMemoryJournal::new();
579        genuine
580            .append(make_token(0x01, 10), &genuine_signer)
581            .unwrap();
582        genuine
583            .append(make_token(0x02, 20), &genuine_signer)
584            .unwrap();
585        let expected_tip = genuine.tip_hash();
586        let expected_len = genuine.len();
587
588        // Genuine journal passes the anchored check.
589        assert!(genuine
590            .verify_chain_anchored(&trusted_pubkey, &expected_tip, expected_len)
591            .is_ok());
592
593        // Forge: re-sign the same tokens under a DIFFERENT key.
594        let forger = test_signer(0x99);
595        let forged = forge_under(&genuine, &forger);
596
597        // The forgery is internally consistent — self-check passes.
598        assert!(
599            forged.verify_chain().is_ok(),
600            "forged log is self-consistent under its own key",
601        );
602        // But the trust anchor rejects it: entry 0 is signed under the wrong key.
603        match forged.verify_chain_anchored(&trusted_pubkey, &expected_tip, expected_len) {
604            Err(JournalError::SignerKeyMismatch { index: 0 }) => {}
605            other => panic!("expected SignerKeyMismatch {{ index: 0 }}, got {other:?}"),
606        }
607    }
608
609    /// #7 — a truncated journal fails the length / tip check.
610    #[test]
611    fn anchored_rejects_truncated_journal() {
612        let signer = test_signer(0x11);
613        let trusted_pubkey = signer.public_key();
614
615        let mut j = InMemoryJournal::new();
616        j.append(make_token(0x01, 10), &signer).unwrap();
617        j.append(make_token(0x02, 20), &signer).unwrap();
618        let full_tip = j.tip_hash();
619        let full_len = j.len();
620
621        // Truncate the last entry (rollback attack).
622        j.entries.pop();
623
624        // Length check fires first.
625        match j.verify_chain_anchored(&trusted_pubkey, &full_tip, full_len) {
626            Err(JournalError::LengthMismatch {
627                expected: 2,
628                actual: 1,
629            }) => {}
630            other => panic!("expected LengthMismatch, got {other:?}"),
631        }
632
633        // Even when the caller pins the truncated length, the tip mismatch fires.
634        match j.verify_chain_anchored(&trusted_pubkey, &full_tip, j.len()) {
635            Err(JournalError::TipMismatch) => {}
636            other => panic!("expected TipMismatch, got {other:?}"),
637        }
638    }
639}