arkhe_forge_platform/hf2_kms/
journal.rs1use blake3::derive_key;
43use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
44
45pub const JOURNAL_CHAIN_DOMAIN: &str = "arkhe-runtime-doctor-journal-chain";
49
50pub const GENESIS_PREV_HASH: [u8; 32] = [0u8; 32];
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ConsumedToken {
56 pub token_hash: [u8; 32],
58 pub operator_fingerprint: [u8; 8],
60 pub consumed_at_tick: u64,
62}
63
64impl ConsumedToken {
65 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#[derive(Debug, Clone)]
78pub struct JournalEntry {
79 pub token: ConsumedToken,
81 pub prev_hash: [u8; 32],
84 pub entry_hash: [u8; 32],
86 pub signature: [u8; 64],
88 pub signer_pubkey: [u8; 32],
90}
91
92impl JournalEntry {
93 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
102pub trait JournalSigner: Send + Sync {
111 fn sign(&self, message: &[u8]) -> [u8; 64];
113 fn public_key(&self) -> [u8; 32];
116}
117
118pub struct InMemoryJournalSigner {
123 key: SigningKey,
124}
125
126impl InMemoryJournalSigner {
127 pub fn new(key: SigningKey) -> Self {
132 Self { key }
133 }
134
135 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#[non_exhaustive]
155#[derive(Debug, thiserror::Error, PartialEq, Eq)]
156pub enum JournalError {
157 #[error("duplicate token consume attempt")]
159 DuplicateToken,
160 #[error("journal chain integrity violation at entry {index}")]
162 ChainIntegrity {
163 index: usize,
165 },
166 #[error("journal signature invalid at entry {index}")]
168 SignatureInvalid {
169 index: usize,
171 },
172 #[error("journal backend error: {0}")]
174 BackendIo(String),
175}
176
177pub trait PersistentJournal {
179 fn append(
183 &mut self,
184 token: ConsumedToken,
185 signer: &dyn JournalSigner,
186 ) -> Result<JournalEntry, JournalError>;
187
188 fn verify_chain(&self) -> Result<(), JournalError>;
193
194 fn tip_hash(&self) -> [u8; 32];
197
198 fn len(&self) -> usize;
200
201 fn is_empty(&self) -> bool {
203 self.len() == 0
204 }
205
206 fn is_duplicate(&self, token_hash: &[u8; 32]) -> bool;
209}
210
211pub trait WalBackedJournal: PersistentJournal {
215 }
218
219#[derive(Debug, Default)]
221pub struct InMemoryJournal {
222 entries: Vec<JournalEntry>,
223}
224
225impl InMemoryJournal {
226 pub fn new() -> Self {
228 Self::default()
229 }
230
231 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
300pub 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 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 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 let j: ConsumedTokenJournal = InMemoryJournal::new();
421 assert_eq!(j.len(), 0);
422 }
423}