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    pub fn compute_entry_hash(prev_hash: &[u8; 32], token: &ConsumedToken) -> [u8; 32] {
95        let mut payload = Vec::with_capacity(32 + 48);
96        payload.extend_from_slice(prev_hash);
97        payload.extend_from_slice(&token.canonical_bytes());
98        derive_key(JOURNAL_CHAIN_DOMAIN, &payload)
99    }
100}
101
102/// Signing abstraction — the real HW-key signer (YubiKey / NitroKey) lives
103/// behind this trait so the journal never touches raw `SigningKey` material.
104///
105/// `Send + Sync` are required so `&dyn JournalSigner` survives future L2
106/// multi-consumer transport (audit replicator / transparency-log publisher)
107/// even though the current single-active L2 path only crosses threads via
108/// the observer pool. Impls are expected to be cheap to share — an
109/// `Arc<SigningKey>` wrapper or HW-backed handle.
110pub trait JournalSigner: Send + Sync {
111    /// Sign `message` and return the 64-byte Ed25519 signature.
112    fn sign(&self, message: &[u8]) -> [u8; 64];
113    /// Signer's Ed25519 public key (32 byte) — embedded in each entry for
114    /// independent verification.
115    fn public_key(&self) -> [u8; 32];
116}
117
118/// Dev-only signer backed by an in-process `SigningKey`. **Production**:
119/// replace with a HW-backed signer (e.g. `YubiKeyJournalSigner`) so private
120/// key material never enters the process address space
121/// (`docs/release-keys.md` §3).
122pub struct InMemoryJournalSigner {
123    key: SigningKey,
124}
125
126impl InMemoryJournalSigner {
127    /// Wrap an in-process `SigningKey`. Callers must ensure the key material
128    /// stays inside the [`process_protection`](super::super::process_protection)
129    /// boundary (Tier-0 software-kek) or is supplied exclusively via test
130    /// fixtures.
131    pub fn new(key: SigningKey) -> Self {
132        Self { key }
133    }
134
135    /// Verify handle — exposed mostly so tests can assert signature
136    /// validity without reaching into the crate internals.
137    pub fn verifying_key(&self) -> VerifyingKey {
138        self.key.verifying_key()
139    }
140}
141
142impl JournalSigner for InMemoryJournalSigner {
143    fn sign(&self, message: &[u8]) -> [u8; 64] {
144        let sig: Signature = self.key.sign(message);
145        sig.to_bytes()
146    }
147
148    fn public_key(&self) -> [u8; 32] {
149        self.key.verifying_key().to_bytes()
150    }
151}
152
153/// Journal operation error.
154#[non_exhaustive]
155#[derive(Debug, thiserror::Error, PartialEq, Eq)]
156pub enum JournalError {
157    /// Same-token reuse detected — replay attack.
158    #[error("duplicate token consume attempt")]
159    DuplicateToken,
160    /// Chain hash recomputation mismatch — tamper detected.
161    #[error("journal chain integrity violation at entry {index}")]
162    ChainIntegrity {
163        /// 0-based index of the first failing entry.
164        index: usize,
165    },
166    /// Ed25519 signature verification failed.
167    #[error("journal signature invalid at entry {index}")]
168    SignatureInvalid {
169        /// 0-based index of the first failing entry.
170        index: usize,
171    },
172    /// Backend I/O error — used by the WAL-backed path.
173    #[error("journal backend error: {0}")]
174    BackendIo(String),
175}
176
177/// Append-only chain-signed journal — pluggable backend.
178pub trait PersistentJournal {
179    /// Append a consumed token. Duplicate `token_hash` is rejected with
180    /// [`JournalError::DuplicateToken`]. Success returns the newly-created
181    /// entry so the caller can verify / publish it.
182    fn append(
183        &mut self,
184        token: ConsumedToken,
185        signer: &dyn JournalSigner,
186    ) -> Result<JournalEntry, JournalError>;
187
188    /// Full chain integrity + signature verification. Returns `Ok(())` if
189    /// every entry's `entry_hash` matches its re-computation **and** every
190    /// signature validates under `signer_pubkey`; otherwise surfaces the
191    /// first failing index.
192    fn verify_chain(&self) -> Result<(), JournalError>;
193
194    /// Last entry's `entry_hash`, or [`GENESIS_PREV_HASH`] for an empty
195    /// journal. Useful for external publishing (transparency log).
196    fn tip_hash(&self) -> [u8; 32];
197
198    /// Count entries.
199    fn len(&self) -> usize;
200
201    /// Empty journal check.
202    fn is_empty(&self) -> bool {
203        self.len() == 0
204    }
205
206    /// Duplicate check — O(n) linear scan on in-memory, backend-specific on
207    /// WAL-backed.
208    fn is_duplicate(&self, token_hash: &[u8; 32]) -> bool;
209}
210
211/// Marker trait for WAL-backed journal impls — the real `arkhe-kernel`
212/// WAL integration routes through `WalBackedJournal`. Tier-1 operators
213/// use [`InMemoryJournal`] for dev / single-node deployments.
214pub trait WalBackedJournal: PersistentJournal {
215    // Stub — `WalBackedJournal` adds `persist_to_wal(...)` +
216    // `reconstruct_from_wal(...)` surface when L0 WAL exposes the hook.
217}
218
219/// Dev-only in-memory chain-signed journal.
220#[derive(Debug, Default)]
221pub struct InMemoryJournal {
222    entries: Vec<JournalEntry>,
223}
224
225impl InMemoryJournal {
226    /// Empty journal.
227    pub fn new() -> Self {
228        Self::default()
229    }
230
231    /// Borrow the full entry list — read-only view for transparency-log
232    /// publishers.
233    pub fn entries(&self) -> &[JournalEntry] {
234        &self.entries
235    }
236}
237
238impl PersistentJournal for InMemoryJournal {
239    fn append(
240        &mut self,
241        token: ConsumedToken,
242        signer: &dyn JournalSigner,
243    ) -> Result<JournalEntry, JournalError> {
244        if self.is_duplicate(&token.token_hash) {
245            return Err(JournalError::DuplicateToken);
246        }
247        let prev_hash = self.tip_hash();
248        let entry_hash = JournalEntry::compute_entry_hash(&prev_hash, &token);
249        let signature = signer.sign(&entry_hash);
250        let entry = JournalEntry {
251            token,
252            prev_hash,
253            entry_hash,
254            signature,
255            signer_pubkey: signer.public_key(),
256        };
257        self.entries.push(entry.clone());
258        Ok(entry)
259    }
260
261    fn verify_chain(&self) -> Result<(), JournalError> {
262        let mut expected_prev = GENESIS_PREV_HASH;
263        for (idx, entry) in self.entries.iter().enumerate() {
264            if entry.prev_hash != expected_prev {
265                return Err(JournalError::ChainIntegrity { index: idx });
266            }
267            let recomputed = JournalEntry::compute_entry_hash(&entry.prev_hash, &entry.token);
268            if recomputed != entry.entry_hash {
269                return Err(JournalError::ChainIntegrity { index: idx });
270            }
271            let verifying_key = VerifyingKey::from_bytes(&entry.signer_pubkey)
272                .map_err(|_| JournalError::SignatureInvalid { index: idx })?;
273            let sig = Signature::from_bytes(&entry.signature);
274            verifying_key
275                .verify(&entry.entry_hash, &sig)
276                .map_err(|_| JournalError::SignatureInvalid { index: idx })?;
277            expected_prev = entry.entry_hash;
278        }
279        Ok(())
280    }
281
282    fn tip_hash(&self) -> [u8; 32] {
283        self.entries
284            .last()
285            .map(|e| e.entry_hash)
286            .unwrap_or(GENESIS_PREV_HASH)
287    }
288
289    fn len(&self) -> usize {
290        self.entries.len()
291    }
292
293    fn is_duplicate(&self, token_hash: &[u8; 32]) -> bool {
294        self.entries
295            .iter()
296            .any(|e| &e.token.token_hash == token_hash)
297    }
298}
299
300/// Backward-compatible alias — other modules (e.g. `threshold.rs`
301/// module-doc) still references this name.
302pub type ConsumedTokenJournal = InMemoryJournal;
303
304#[cfg(test)]
305#[allow(clippy::panic, clippy::unwrap_used)]
306mod tests {
307    use super::*;
308
309    fn test_signer(seed: u8) -> InMemoryJournalSigner {
310        let secret = [seed; 32];
311        InMemoryJournalSigner::new(SigningKey::from_bytes(&secret))
312    }
313
314    fn make_token(tag: u8, tick: u64) -> ConsumedToken {
315        ConsumedToken {
316            token_hash: [tag; 32],
317            operator_fingerprint: [tag; 8],
318            consumed_at_tick: tick,
319        }
320    }
321
322    #[test]
323    fn journal_initial_empty_and_genesis_tip() {
324        let j = InMemoryJournal::new();
325        assert!(j.is_empty());
326        assert_eq!(j.len(), 0);
327        assert_eq!(j.tip_hash(), GENESIS_PREV_HASH);
328    }
329
330    #[test]
331    fn append_produces_chained_entry() {
332        let mut j = InMemoryJournal::new();
333        let signer = test_signer(0x01);
334        let entry = j.append(make_token(0x11, 100), &signer).unwrap();
335        assert_eq!(entry.prev_hash, GENESIS_PREV_HASH);
336        assert_eq!(j.tip_hash(), entry.entry_hash);
337        assert_eq!(j.len(), 1);
338    }
339
340    #[test]
341    fn second_entry_chains_to_first() {
342        let mut j = InMemoryJournal::new();
343        let signer = test_signer(0x02);
344        let first = j.append(make_token(0x11, 1), &signer).unwrap();
345        let second = j.append(make_token(0x22, 2), &signer).unwrap();
346        assert_eq!(second.prev_hash, first.entry_hash);
347    }
348
349    #[test]
350    fn duplicate_token_rejected() {
351        let mut j = InMemoryJournal::new();
352        let signer = test_signer(0x03);
353        let token = make_token(0x42, 200);
354        assert!(j.append(token.clone(), &signer).is_ok());
355        assert_eq!(
356            j.append(token, &signer).unwrap_err(),
357            JournalError::DuplicateToken
358        );
359        assert_eq!(j.len(), 1);
360    }
361
362    #[test]
363    fn verify_chain_accepts_clean_log() {
364        let mut j = InMemoryJournal::new();
365        let signer = test_signer(0x04);
366        j.append(make_token(0x01, 10), &signer).unwrap();
367        j.append(make_token(0x02, 20), &signer).unwrap();
368        j.append(make_token(0x03, 30), &signer).unwrap();
369        assert!(j.verify_chain().is_ok());
370    }
371
372    #[test]
373    fn verify_chain_detects_tampered_hash() {
374        let mut j = InMemoryJournal::new();
375        let signer = test_signer(0x05);
376        j.append(make_token(0x01, 10), &signer).unwrap();
377        j.append(make_token(0x02, 20), &signer).unwrap();
378        // Tamper: flip one byte of the second entry's token tick.
379        j.entries[1].token.consumed_at_tick = 99;
380        match j.verify_chain() {
381            Err(JournalError::ChainIntegrity { index: 1 }) => {}
382            other => panic!("expected ChainIntegrity {{ index: 1 }}, got {other:?}"),
383        }
384    }
385
386    #[test]
387    fn verify_chain_detects_tampered_signature() {
388        let mut j = InMemoryJournal::new();
389        let signer = test_signer(0x06);
390        j.append(make_token(0x01, 10), &signer).unwrap();
391        // Flip a signature byte.
392        j.entries[0].signature[0] ^= 0xFF;
393        match j.verify_chain() {
394            Err(JournalError::SignatureInvalid { index: 0 }) => {}
395            other => panic!("expected SignatureInvalid {{ index: 0 }}, got {other:?}"),
396        }
397    }
398
399    #[test]
400    fn is_duplicate_query_matches_append_rejection() {
401        let mut j = InMemoryJournal::new();
402        let signer = test_signer(0x07);
403        let hash = [0x55u8; 32];
404        assert!(!j.is_duplicate(&hash));
405        j.append(
406            ConsumedToken {
407                token_hash: hash,
408                operator_fingerprint: [0u8; 8],
409                consumed_at_tick: 1,
410            },
411            &signer,
412        )
413        .unwrap();
414        assert!(j.is_duplicate(&hash));
415    }
416
417    #[test]
418    fn backward_alias_still_usable() {
419        // `ConsumedTokenJournal` alias keeps threshold.rs module-doc live.
420        let j: ConsumedTokenJournal = InMemoryJournal::new();
421        assert_eq!(j.len(), 0);
422    }
423}