use blake3::derive_key;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
pub const JOURNAL_CHAIN_DOMAIN: &str = "arkhe-runtime-doctor-journal-chain";
pub const GENESIS_PREV_HASH: [u8; 32] = [0u8; 32];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsumedToken {
pub token_hash: [u8; 32],
pub operator_fingerprint: [u8; 8],
pub consumed_at_tick: u64,
}
impl ConsumedToken {
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(32 + 8 + 8);
buf.extend_from_slice(&self.token_hash);
buf.extend_from_slice(&self.operator_fingerprint);
buf.extend_from_slice(&self.consumed_at_tick.to_be_bytes());
buf
}
}
#[derive(Debug, Clone)]
pub struct JournalEntry {
pub token: ConsumedToken,
pub prev_hash: [u8; 32],
pub entry_hash: [u8; 32],
pub signature: [u8; 64],
pub signer_pubkey: [u8; 32],
}
impl JournalEntry {
pub fn compute_entry_hash(prev_hash: &[u8; 32], token: &ConsumedToken) -> [u8; 32] {
let mut payload = [0u8; 80];
payload[0..32].copy_from_slice(prev_hash);
payload[32..64].copy_from_slice(&token.token_hash);
payload[64..72].copy_from_slice(&token.operator_fingerprint);
payload[72..80].copy_from_slice(&token.consumed_at_tick.to_be_bytes());
derive_key(JOURNAL_CHAIN_DOMAIN, &payload)
}
}
pub trait JournalSigner: Send + Sync {
fn sign(&self, message: &[u8]) -> [u8; 64];
fn public_key(&self) -> [u8; 32];
}
pub struct InMemoryJournalSigner {
key: SigningKey,
}
impl InMemoryJournalSigner {
pub fn new(key: SigningKey) -> Self {
Self { key }
}
pub fn verifying_key(&self) -> VerifyingKey {
self.key.verifying_key()
}
}
impl JournalSigner for InMemoryJournalSigner {
fn sign(&self, message: &[u8]) -> [u8; 64] {
let sig: Signature = self.key.sign(message);
sig.to_bytes()
}
fn public_key(&self) -> [u8; 32] {
self.key.verifying_key().to_bytes()
}
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum JournalError {
#[error("duplicate token consume attempt")]
DuplicateToken,
#[error("journal chain integrity violation at entry {index}")]
ChainIntegrity {
index: usize,
},
#[error("journal signature invalid at entry {index}")]
SignatureInvalid {
index: usize,
},
#[error("journal signer key mismatch at entry {index}")]
SignerKeyMismatch {
index: usize,
},
#[error("journal tip hash mismatch")]
TipMismatch,
#[error("journal length mismatch: expected {expected}, got {actual}")]
LengthMismatch {
expected: usize,
actual: usize,
},
#[error("journal backend error: {0}")]
BackendIo(String),
}
pub trait PersistentJournal {
fn append(
&mut self,
token: ConsumedToken,
signer: &dyn JournalSigner,
) -> Result<JournalEntry, JournalError>;
fn verify_chain(&self) -> Result<(), JournalError>;
fn tip_hash(&self) -> [u8; 32];
fn len(&self) -> usize;
fn is_empty(&self) -> bool {
self.len() == 0
}
fn is_duplicate(&self, token_hash: &[u8; 32]) -> bool;
}
pub trait WalBackedJournal: PersistentJournal {
}
#[derive(Debug, Default)]
pub struct InMemoryJournal {
entries: Vec<JournalEntry>,
seen: std::collections::HashSet<[u8; 32]>,
}
impl InMemoryJournal {
pub fn new() -> Self {
Self::default()
}
pub fn entries(&self) -> &[JournalEntry] {
&self.entries
}
pub fn verify_chain_anchored(
&self,
trusted_pubkey: &[u8; 32],
expected_tip: &[u8; 32],
expected_len: usize,
) -> Result<(), JournalError> {
if self.entries.len() != expected_len {
return Err(JournalError::LengthMismatch {
expected: expected_len,
actual: self.entries.len(),
});
}
self.verify_chain()?;
for (idx, entry) in self.entries.iter().enumerate() {
if &entry.signer_pubkey != trusted_pubkey {
return Err(JournalError::SignerKeyMismatch { index: idx });
}
}
if &self.tip_hash() != expected_tip {
return Err(JournalError::TipMismatch);
}
Ok(())
}
}
impl PersistentJournal for InMemoryJournal {
fn append(
&mut self,
token: ConsumedToken,
signer: &dyn JournalSigner,
) -> Result<JournalEntry, JournalError> {
if self.is_duplicate(&token.token_hash) {
return Err(JournalError::DuplicateToken);
}
let prev_hash = self.tip_hash();
let entry_hash = JournalEntry::compute_entry_hash(&prev_hash, &token);
let signature = signer.sign(&entry_hash);
let entry = JournalEntry {
token,
prev_hash,
entry_hash,
signature,
signer_pubkey: signer.public_key(),
};
self.seen.insert(entry.token.token_hash);
self.entries.push(entry.clone());
Ok(entry)
}
fn verify_chain(&self) -> Result<(), JournalError> {
let mut expected_prev = GENESIS_PREV_HASH;
for (idx, entry) in self.entries.iter().enumerate() {
if entry.prev_hash != expected_prev {
return Err(JournalError::ChainIntegrity { index: idx });
}
let recomputed = JournalEntry::compute_entry_hash(&entry.prev_hash, &entry.token);
if recomputed != entry.entry_hash {
return Err(JournalError::ChainIntegrity { index: idx });
}
let verifying_key = VerifyingKey::from_bytes(&entry.signer_pubkey)
.map_err(|_| JournalError::SignatureInvalid { index: idx })?;
let sig = Signature::from_bytes(&entry.signature);
verifying_key
.verify(&entry.entry_hash, &sig)
.map_err(|_| JournalError::SignatureInvalid { index: idx })?;
expected_prev = entry.entry_hash;
}
Ok(())
}
fn tip_hash(&self) -> [u8; 32] {
self.entries
.last()
.map(|e| e.entry_hash)
.unwrap_or(GENESIS_PREV_HASH)
}
fn len(&self) -> usize {
self.entries.len()
}
fn is_duplicate(&self, token_hash: &[u8; 32]) -> bool {
self.seen.contains(token_hash)
}
}
pub type ConsumedTokenJournal = InMemoryJournal;
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn test_signer(seed: u8) -> InMemoryJournalSigner {
let secret = [seed; 32];
InMemoryJournalSigner::new(SigningKey::from_bytes(&secret))
}
fn make_token(tag: u8, tick: u64) -> ConsumedToken {
ConsumedToken {
token_hash: [tag; 32],
operator_fingerprint: [tag; 8],
consumed_at_tick: tick,
}
}
#[test]
fn journal_initial_empty_and_genesis_tip() {
let j = InMemoryJournal::new();
assert!(j.is_empty());
assert_eq!(j.len(), 0);
assert_eq!(j.tip_hash(), GENESIS_PREV_HASH);
}
#[test]
fn append_produces_chained_entry() {
let mut j = InMemoryJournal::new();
let signer = test_signer(0x01);
let entry = j.append(make_token(0x11, 100), &signer).unwrap();
assert_eq!(entry.prev_hash, GENESIS_PREV_HASH);
assert_eq!(j.tip_hash(), entry.entry_hash);
assert_eq!(j.len(), 1);
}
#[test]
fn second_entry_chains_to_first() {
let mut j = InMemoryJournal::new();
let signer = test_signer(0x02);
let first = j.append(make_token(0x11, 1), &signer).unwrap();
let second = j.append(make_token(0x22, 2), &signer).unwrap();
assert_eq!(second.prev_hash, first.entry_hash);
}
#[test]
fn duplicate_token_rejected() {
let mut j = InMemoryJournal::new();
let signer = test_signer(0x03);
let token = make_token(0x42, 200);
assert!(j.append(token.clone(), &signer).is_ok());
assert_eq!(
j.append(token, &signer).unwrap_err(),
JournalError::DuplicateToken
);
assert_eq!(j.len(), 1);
}
#[test]
fn verify_chain_accepts_clean_log() {
let mut j = InMemoryJournal::new();
let signer = test_signer(0x04);
j.append(make_token(0x01, 10), &signer).unwrap();
j.append(make_token(0x02, 20), &signer).unwrap();
j.append(make_token(0x03, 30), &signer).unwrap();
assert!(j.verify_chain().is_ok());
}
#[test]
fn verify_chain_detects_tampered_hash() {
let mut j = InMemoryJournal::new();
let signer = test_signer(0x05);
j.append(make_token(0x01, 10), &signer).unwrap();
j.append(make_token(0x02, 20), &signer).unwrap();
j.entries[1].token.consumed_at_tick = 99;
match j.verify_chain() {
Err(JournalError::ChainIntegrity { index: 1 }) => {}
other => panic!("expected ChainIntegrity {{ index: 1 }}, got {other:?}"),
}
}
#[test]
fn verify_chain_detects_tampered_signature() {
let mut j = InMemoryJournal::new();
let signer = test_signer(0x06);
j.append(make_token(0x01, 10), &signer).unwrap();
j.entries[0].signature[0] ^= 0xFF;
match j.verify_chain() {
Err(JournalError::SignatureInvalid { index: 0 }) => {}
other => panic!("expected SignatureInvalid {{ index: 0 }}, got {other:?}"),
}
}
#[test]
fn is_duplicate_query_matches_append_rejection() {
let mut j = InMemoryJournal::new();
let signer = test_signer(0x07);
let hash = [0x55u8; 32];
assert!(!j.is_duplicate(&hash));
j.append(
ConsumedToken {
token_hash: hash,
operator_fingerprint: [0u8; 8],
consumed_at_tick: 1,
},
&signer,
)
.unwrap();
assert!(j.is_duplicate(&hash));
}
#[test]
fn compute_entry_hash_matches_canonical_payload() {
let prev_hash = [0xABu8; 32];
let token = ConsumedToken {
token_hash: [0x11u8; 32],
operator_fingerprint: [0x22u8; 8],
consumed_at_tick: 0x3344_5566_7788_99AA,
};
let mut reference = Vec::new();
reference.extend_from_slice(&prev_hash);
reference.extend_from_slice(&token.canonical_bytes());
let expected = blake3::derive_key(JOURNAL_CHAIN_DOMAIN, &reference);
let got = JournalEntry::compute_entry_hash(&prev_hash, &token);
assert_eq!(
got, expected,
"stack-buffer hash must match canonical payload"
);
}
#[test]
fn dedup_set_tracks_chain() {
let mut j = InMemoryJournal::new();
let signer = test_signer(0x21);
j.append(make_token(0xA1, 1), &signer).unwrap();
j.append(make_token(0xB2, 2), &signer).unwrap();
j.append(make_token(0xC3, 3), &signer).unwrap();
assert!(j.is_duplicate(&[0xA1; 32]));
assert!(j.is_duplicate(&[0xB2; 32]));
assert!(j.is_duplicate(&[0xC3; 32]));
assert!(!j.is_duplicate(&[0xD4; 32]));
assert_eq!(j.len(), 3);
assert_eq!(
j.append(make_token(0xB2, 99), &signer).unwrap_err(),
JournalError::DuplicateToken
);
assert_eq!(j.len(), 3);
}
#[test]
fn backward_alias_still_usable() {
let j: ConsumedTokenJournal = InMemoryJournal::new();
assert_eq!(j.len(), 0);
}
fn forge_under(original: &InMemoryJournal, forger: &InMemoryJournalSigner) -> InMemoryJournal {
let mut j = InMemoryJournal::new();
for e in original.entries() {
j.append(e.token.clone(), forger)
.expect("forged append must succeed");
}
j
}
#[test]
fn anchored_rejects_forged_resigned_journal() {
let genuine_signer = test_signer(0x10);
let trusted_pubkey = genuine_signer.public_key();
let mut genuine = InMemoryJournal::new();
genuine
.append(make_token(0x01, 10), &genuine_signer)
.unwrap();
genuine
.append(make_token(0x02, 20), &genuine_signer)
.unwrap();
let expected_tip = genuine.tip_hash();
let expected_len = genuine.len();
assert!(genuine
.verify_chain_anchored(&trusted_pubkey, &expected_tip, expected_len)
.is_ok());
let forger = test_signer(0x99);
let forged = forge_under(&genuine, &forger);
assert!(
forged.verify_chain().is_ok(),
"forged log is self-consistent under its own key",
);
match forged.verify_chain_anchored(&trusted_pubkey, &expected_tip, expected_len) {
Err(JournalError::SignerKeyMismatch { index: 0 }) => {}
other => panic!("expected SignerKeyMismatch {{ index: 0 }}, got {other:?}"),
}
}
#[test]
fn anchored_rejects_truncated_journal() {
let signer = test_signer(0x11);
let trusted_pubkey = signer.public_key();
let mut j = InMemoryJournal::new();
j.append(make_token(0x01, 10), &signer).unwrap();
j.append(make_token(0x02, 20), &signer).unwrap();
let full_tip = j.tip_hash();
let full_len = j.len();
j.entries.pop();
match j.verify_chain_anchored(&trusted_pubkey, &full_tip, full_len) {
Err(JournalError::LengthMismatch {
expected: 2,
actual: 1,
}) => {}
other => panic!("expected LengthMismatch, got {other:?}"),
}
match j.verify_chain_anchored(&trusted_pubkey, &full_tip, j.len()) {
Err(JournalError::TipMismatch) => {}
other => panic!("expected TipMismatch, got {other:?}"),
}
}
}