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 = Vec::with_capacity(32 + 48);
payload.extend_from_slice(prev_hash);
payload.extend_from_slice(&token.canonical_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 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>,
}
impl InMemoryJournal {
pub fn new() -> Self {
Self::default()
}
pub fn entries(&self) -> &[JournalEntry] {
&self.entries
}
}
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.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.entries
.iter()
.any(|e| &e.token.token_hash == token_hash)
}
}
pub type ConsumedTokenJournal = InMemoryJournal;
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_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 backward_alias_still_usable() {
let j: ConsumedTokenJournal = InMemoryJournal::new();
assert_eq!(j.len(), 0);
}
}