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}