use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use sysknife_types::RiskLevel;
use zeroize::Zeroize;
pub const CURRENT_KEY_ID: &str = "v1";
pub const HASH_HEX_LEN: usize = 128;
const ROW_DOMAIN: &[u8] = b"sysknife-audit-row-v1\x1f";
const CHECKPOINT_DOMAIN: &[u8] = b"sysknife-checkpoint-v1\x1f";
const APPROVAL_DOMAIN: &[u8] = b"sysknife-approval-receipt-v1\x1f";
const EVENT_DOMAIN: &[u8] = b"sysknife-audit-event-v1\x1f";
pub const CHAIN_VERSION_LEGACY: u32 = 1;
pub const CHAIN_VERSION_CURRENT: u32 = 2;
#[derive(Clone)]
pub struct AuditKey {
key_id: String,
signing: SigningKey,
}
impl std::fmt::Debug for AuditKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditKey")
.field("key_id", &self.key_id)
.field("signing", &format_args!("<redacted signing key>"))
.finish()
}
}
#[derive(Debug, thiserror::Error)]
pub enum AuditKeyError {
#[error("io error reading audit key {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"audit key file {path} has unsafe permissions {mode:#o}; \
tighten with `chmod 600 {path:?}` and restart"
)]
UnsafePermissions { path: PathBuf, mode: u32 },
#[error(
"audit key file {path} is too short ({len} bytes); \
expected at least 32 bytes of random material"
)]
KeyTooShort { path: PathBuf, len: usize },
}
impl AuditKey {
pub fn load_or_generate(path: &Path) -> Result<Self, AuditKeyError> {
if !path.exists() {
generate_key_at(path)?;
}
let metadata = std::fs::metadata(path).map_err(|e| AuditKeyError::Io {
path: path.to_path_buf(),
source: e,
})?;
use std::os::unix::fs::PermissionsExt;
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(AuditKeyError::UnsafePermissions {
path: path.to_path_buf(),
mode,
});
}
let mut key_bytes = std::fs::read(path).map_err(|e| AuditKeyError::Io {
path: path.to_path_buf(),
source: e,
})?;
if key_bytes.len() < 32 {
return Err(AuditKeyError::KeyTooShort {
path: path.to_path_buf(),
len: key_bytes.len(),
});
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&key_bytes[..32]);
let signing = SigningKey::from_bytes(&seed);
seed.zeroize();
key_bytes.zeroize();
Ok(Self {
key_id: CURRENT_KEY_ID.to_string(),
signing,
})
}
#[cfg(test)]
pub fn from_bytes(bytes: Vec<u8>) -> Self {
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes[..32]);
let signing = SigningKey::from_bytes(&seed);
seed.zeroize();
Self {
key_id: CURRENT_KEY_ID.to_string(),
signing,
}
}
pub fn key_id(&self) -> &str {
&self.key_id
}
pub fn chain_hash(&self, content: &ChainContent, prev_chain_hash: &str) -> String {
let sig = self.signing.sign(&chain_message(content, prev_chain_hash));
hex::encode(sig.to_bytes())
}
pub fn verifying_key(&self) -> VerifyingKey {
self.signing.verifying_key()
}
pub fn verifying_key_hex(&self) -> String {
hex::encode(self.signing.verifying_key().to_bytes())
}
pub fn approval_receipt(&self, transaction_id: &str, request_hash: &str) -> String {
let mut message = APPROVAL_DOMAIN.to_vec();
push_field(&mut message, "txid", transaction_id);
push_field(&mut message, "reqhash", request_hash);
hex::encode(self.signing.sign(&message).to_bytes())
}
pub fn approval_commitment(&self, transaction_id: &str, request_hash: &str) -> String {
approval_receipt_digest(&self.approval_receipt(transaction_id, request_hash))
}
}
pub fn approval_receipt_digest(receipt: &str) -> String {
hex::encode(Sha256::digest(receipt.as_bytes()))
}
fn chain_message(content: &ChainContent, prev_chain_hash: &str) -> Vec<u8> {
let mut msg = ROW_DOMAIN.to_vec();
msg.extend_from_slice(&content.canonical_bytes());
msg.extend_from_slice(prev_chain_hash.as_bytes());
msg
}
pub fn resolve_audit_key_path(db_path: &Path) -> PathBuf {
std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| {
db_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("audit-key")
})
}
fn generate_key_at(path: &Path) -> Result<(), AuditKeyError> {
use std::io::Write as _;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
if let Some(parent) = path.parent() {
if !parent.exists() {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(parent)
.map_err(|e| AuditKeyError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
}
let mut bytes = [0u8; 32];
fill_random(&mut bytes).map_err(|e| AuditKeyError::Io {
path: path.to_path_buf(),
source: e,
})?;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.map_err(|e| AuditKeyError::Io {
path: path.to_path_buf(),
source: e,
})?;
f.write_all(&bytes).map_err(|e| AuditKeyError::Io {
path: path.to_path_buf(),
source: e,
})?;
f.sync_all().map_err(|e| AuditKeyError::Io {
path: path.to_path_buf(),
source: e,
})?;
let signing = SigningKey::from_bytes(&bytes);
let pub_hex = hex::encode(signing.verifying_key().to_bytes());
bytes.zeroize();
let mut pub_path = path.as_os_str().to_os_string();
pub_path.push(".pub");
std::fs::write(&pub_path, format!("{pub_hex}\n")).map_err(|e| AuditKeyError::Io {
path: PathBuf::from(pub_path),
source: e,
})?;
Ok(())
}
fn fill_random(buf: &mut [u8]) -> std::io::Result<()> {
use std::io::Read as _;
let mut f = std::fs::File::open("/dev/urandom")?;
f.read_exact(buf)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChainIdentity<'a> {
LegacyV1,
V2 {
caller_role: &'a str,
event_tip: &'a str,
},
}
impl ChainIdentity<'_> {
pub fn version(&self) -> u32 {
match self {
Self::LegacyV1 => CHAIN_VERSION_LEGACY,
Self::V2 { .. } => CHAIN_VERSION_CURRENT,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChainContent<'a> {
pub seq: u64,
pub key_id: &'a str,
pub transaction_id: &'a str,
pub request_id: &'a str,
pub request_hash: &'a str,
pub action_name: &'a str,
pub risk_level: RiskLevel,
pub summary: &'a str,
pub approval_id: Option<&'a str>,
pub warnings_json: &'a str,
pub created_at: &'a str,
pub identity: ChainIdentity<'a>,
}
impl<'a> ChainContent<'a> {
pub fn canonical_bytes(&self) -> Vec<u8> {
let risk_level_str = match self.risk_level {
RiskLevel::Low => "low",
RiskLevel::Medium => "medium",
RiskLevel::High => "high",
};
let approval = self.approval_id.unwrap_or("");
let mut buf = Vec::with_capacity(512);
push_field(&mut buf, "seq", &self.seq.to_string());
push_field(&mut buf, "key_id", self.key_id);
push_field(&mut buf, "transaction_id", self.transaction_id);
push_field(&mut buf, "request_id", self.request_id);
push_field(&mut buf, "request_hash", self.request_hash);
push_field(&mut buf, "action_name", self.action_name);
push_field(&mut buf, "risk_level", risk_level_str);
push_field(&mut buf, "summary", self.summary);
push_field(&mut buf, "approval_id", approval);
push_field(&mut buf, "warnings_json", self.warnings_json);
push_field(&mut buf, "created_at", self.created_at);
if let ChainIdentity::V2 {
caller_role,
event_tip,
} = self.identity
{
push_field(
&mut buf,
"chain_version",
&CHAIN_VERSION_CURRENT.to_string(),
);
push_field(&mut buf, "caller_role", caller_role);
push_field(&mut buf, "event_tip", event_tip);
}
buf
}
}
fn push_field(buf: &mut Vec<u8>, tag: &str, value: &str) {
buf.extend_from_slice(tag.as_bytes());
buf.push(0x1E); for b in value.bytes() {
match b {
b'\\' => buf.extend_from_slice(b"\\\\"),
0x00 => buf.extend_from_slice(b"\\0"),
0x1E => buf.extend_from_slice(b"\\1E"),
0x1F => buf.extend_from_slice(b"\\1F"),
other => buf.push(other),
}
}
buf.push(0x1F); }
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyOutcome {
Intact { rows_checked: u64 },
Broken {
rows_checked: u64,
first_broken_seq: u64,
first_broken_transaction_id: String,
expected: String,
actual: String,
},
CannotVerify { reason: String },
}
pub fn outcome_to_exit_code(outcome: &VerifyOutcome) -> i32 {
match outcome {
VerifyOutcome::Intact { .. } => 0,
VerifyOutcome::Broken { .. } => 1,
VerifyOutcome::CannotVerify { .. } => 2,
}
}
#[derive(Debug, Clone)]
pub struct ChainRow {
pub seq: u64,
pub key_id: String,
pub transaction_id: String,
pub request_id: String,
pub request_hash: String,
pub action_name: String,
pub risk_level: RiskLevel,
pub summary: String,
pub approval_id: Option<String>,
pub warnings_json: String,
pub created_at: String,
pub prev_chain_hash: String,
pub chain_hash: String,
pub chain_version: u32,
pub caller_role: Option<String>,
pub event_tip: Option<String>,
}
#[derive(Debug)]
pub(crate) enum RowIdentityError {
UnknownVersion(u32),
MissingField(&'static str),
}
impl ChainRow {
pub(crate) fn identity(&self) -> Result<ChainIdentity<'_>, RowIdentityError> {
match self.chain_version {
CHAIN_VERSION_LEGACY => Ok(ChainIdentity::LegacyV1),
CHAIN_VERSION_CURRENT => Ok(ChainIdentity::V2 {
caller_role: self
.caller_role
.as_deref()
.ok_or(RowIdentityError::MissingField("caller_role"))?,
event_tip: self
.event_tip
.as_deref()
.ok_or(RowIdentityError::MissingField("event_tip"))?,
}),
other => Err(RowIdentityError::UnknownVersion(other)),
}
}
}
pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
}
pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
match parse_verifying_key(verifying_key_hex) {
Some(vk) => verify_rows(&vk, None, rows),
None => VerifyOutcome::CannotVerify {
reason: format!(
"invalid public key hex ({} chars); expected 64 hex chars of a \
32-byte Ed25519 public key",
verifying_key_hex.len()
),
},
}
}
fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
let bytes = hex::decode(hex_str.trim()).ok()?;
let arr: [u8; 32] = bytes.try_into().ok()?;
VerifyingKey::from_bytes(&arr).ok()
}
fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
let Ok(bytes) = hex::decode(sig_hex) else {
return false;
};
let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
return false;
};
vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
}
fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
let mut last_hash = String::new();
let mut rows_checked = 0u64;
for row in rows {
if let Some(kid) = expect_key_id {
if row.key_id != kid {
return VerifyOutcome::CannotVerify {
reason: format!(
"row seq={} uses key_id={:?} but only {:?} is loaded; \
epoch keys not yet supported",
row.seq, row.key_id, kid
),
};
}
}
if row.prev_chain_hash != last_hash {
return VerifyOutcome::Broken {
rows_checked,
first_broken_seq: row.seq,
first_broken_transaction_id: row.transaction_id.clone(),
expected: format!("prev_chain_hash={last_hash}"),
actual: format!("prev_chain_hash={}", row.prev_chain_hash),
};
}
let identity = match row.identity() {
Ok(identity) => identity,
Err(RowIdentityError::UnknownVersion(v)) => {
return VerifyOutcome::CannotVerify {
reason: format!(
"row seq={} declares chain_version={v}, which this binary cannot \
reproduce (it understands {CHAIN_VERSION_LEGACY}..={CHAIN_VERSION_CURRENT}); \
verify with a build at least as new as the one that wrote the chain",
row.seq
),
};
}
Err(RowIdentityError::MissingField(field)) => {
return VerifyOutcome::Broken {
rows_checked,
first_broken_seq: row.seq,
first_broken_transaction_id: row.transaction_id.clone(),
expected: format!("chain_version={CHAIN_VERSION_CURRENT} row carrying {field}"),
actual: format!("{field}=NULL"),
};
}
};
let content = ChainContent {
seq: row.seq,
key_id: &row.key_id,
transaction_id: &row.transaction_id,
request_id: &row.request_id,
request_hash: &row.request_hash,
action_name: &row.action_name,
risk_level: row.risk_level,
summary: &row.summary,
approval_id: row.approval_id.as_deref(),
warnings_json: &row.warnings_json,
created_at: &row.created_at,
identity,
};
let msg = chain_message(&content, &row.prev_chain_hash);
if !signature_ok(vk, &msg, &row.chain_hash) {
return VerifyOutcome::Broken {
rows_checked,
first_broken_seq: row.seq,
first_broken_transaction_id: row.transaction_id.clone(),
expected: "valid ed25519 signature".to_string(),
actual: row.chain_hash.clone(),
};
}
last_hash = row.chain_hash.clone();
rows_checked += 1;
}
VerifyOutcome::Intact { rows_checked }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditEventKind {
ApprovalGranted,
ApprovalConsumed,
ApprovalRevoked,
}
impl AuditEventKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::ApprovalGranted => "approval_granted",
Self::ApprovalConsumed => "approval_consumed",
Self::ApprovalRevoked => "approval_revoked",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventContent<'a> {
pub seq: u64,
pub key_id: &'a str,
pub kind: AuditEventKind,
pub transaction_id: &'a str,
pub receipt_digest: &'a str,
pub created_at: &'a str,
}
impl EventContent<'_> {
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(256);
push_field(&mut buf, "seq", &self.seq.to_string());
push_field(&mut buf, "key_id", self.key_id);
push_field(&mut buf, "kind", self.kind.as_str());
push_field(&mut buf, "transaction_id", self.transaction_id);
push_field(&mut buf, "receipt_digest", self.receipt_digest);
push_field(&mut buf, "created_at", self.created_at);
buf
}
}
fn event_message(content: &EventContent, prev_chain_hash: &str) -> Vec<u8> {
let mut msg = EVENT_DOMAIN.to_vec();
msg.extend_from_slice(&content.canonical_bytes());
msg.extend_from_slice(prev_chain_hash.as_bytes());
msg
}
impl AuditKey {
pub fn event_hash(&self, content: &EventContent, prev_chain_hash: &str) -> String {
hex::encode(
self.signing
.sign(&event_message(content, prev_chain_hash))
.to_bytes(),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventRow {
pub seq: u64,
pub key_id: String,
pub kind: String,
pub transaction_id: String,
pub receipt_digest: String,
pub created_at: String,
pub prev_chain_hash: String,
pub chain_hash: String,
}
pub fn verify_event_chain(key: &AuditKey, rows: &[EventRow]) -> VerifyOutcome {
verify_event_rows(&key.verifying_key(), Some(key.key_id()), rows)
}
pub fn verify_event_chain_with_pubkey(verifying_key_hex: &str, rows: &[EventRow]) -> VerifyOutcome {
match parse_verifying_key(verifying_key_hex) {
Some(vk) => verify_event_rows(&vk, None, rows),
None => VerifyOutcome::CannotVerify {
reason: format!(
"invalid public key hex ({} chars); expected 64 hex chars of a \
32-byte Ed25519 public key",
verifying_key_hex.len()
),
},
}
}
fn verify_event_rows(
vk: &VerifyingKey,
expect_key_id: Option<&str>,
rows: &[EventRow],
) -> VerifyOutcome {
let mut last_hash = String::new();
let mut rows_checked = 0u64;
for row in rows {
if let Some(kid) = expect_key_id {
if row.key_id != kid {
return VerifyOutcome::CannotVerify {
reason: format!(
"event seq={} uses key_id={:?} but only {:?} is loaded; \
epoch keys not yet supported",
row.seq, row.key_id, kid
),
};
}
}
if row.prev_chain_hash != last_hash {
return VerifyOutcome::Broken {
rows_checked,
first_broken_seq: row.seq,
first_broken_transaction_id: row.transaction_id.clone(),
expected: format!("prev_chain_hash={last_hash}"),
actual: format!("prev_chain_hash={}", row.prev_chain_hash),
};
}
let Some(kind) = parse_event_kind(&row.kind) else {
return VerifyOutcome::Broken {
rows_checked,
first_broken_seq: row.seq,
first_broken_transaction_id: row.transaction_id.clone(),
expected: "a known event kind".to_string(),
actual: format!("kind={:?}", row.kind),
};
};
let content = EventContent {
seq: row.seq,
key_id: &row.key_id,
kind,
transaction_id: &row.transaction_id,
receipt_digest: &row.receipt_digest,
created_at: &row.created_at,
};
if !signature_ok(
vk,
&event_message(&content, &row.prev_chain_hash),
&row.chain_hash,
) {
return VerifyOutcome::Broken {
rows_checked,
first_broken_seq: row.seq,
first_broken_transaction_id: row.transaction_id.clone(),
expected: "valid ed25519 signature".to_string(),
actual: row.chain_hash.clone(),
};
}
last_hash = row.chain_hash.clone();
rows_checked += 1;
}
VerifyOutcome::Intact { rows_checked }
}
fn parse_event_kind(raw: &str) -> Option<AuditEventKind> {
[
AuditEventKind::ApprovalGranted,
AuditEventKind::ApprovalConsumed,
AuditEventKind::ApprovalRevoked,
]
.into_iter()
.find(|kind| kind.as_str() == raw)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BindingOutcome {
Consistent { bindings_checked: u64 },
MissingEvent {
transaction_seq: u64,
event_tip: String,
},
}
pub fn verify_event_binding(tx_rows: &[ChainRow], event_rows: &[EventRow]) -> BindingOutcome {
let known: std::collections::HashSet<&str> = event_rows
.iter()
.map(|row| row.chain_hash.as_str())
.collect();
let mut bindings_checked = 0u64;
for row in tx_rows {
let Some(tip) = row.event_tip.as_deref() else {
continue;
};
if tip.is_empty() {
continue;
}
if !known.contains(tip) {
return BindingOutcome::MissingEvent {
transaction_seq: row.seq,
event_tip: tip.to_string(),
};
}
bindings_checked += 1;
}
BindingOutcome::Consistent { bindings_checked }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuditVerification {
pub chain: VerifyOutcome,
pub events: VerifyOutcome,
pub binding: BindingOutcome,
}
impl AuditVerification {
pub fn exit_code(&self) -> i32 {
let codes = [
outcome_to_exit_code(&self.chain),
outcome_to_exit_code(&self.events),
binding_outcome_to_exit_code(&self.binding),
];
if codes.contains(&1) {
1
} else if codes.contains(&2) {
2
} else {
0
}
}
}
pub fn verify_all(
key: &AuditKey,
tx_rows: &[ChainRow],
event_rows: &[EventRow],
) -> AuditVerification {
AuditVerification {
chain: verify_chain(key, tx_rows),
events: verify_event_chain(key, event_rows),
binding: verify_event_binding(tx_rows, event_rows),
}
}
pub fn verify_all_with_pubkey(
verifying_key_hex: &str,
tx_rows: &[ChainRow],
event_rows: &[EventRow],
) -> AuditVerification {
AuditVerification {
chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
binding: verify_event_binding(tx_rows, event_rows),
}
}
pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
match outcome {
BindingOutcome::Consistent { .. } => 0,
BindingOutcome::MissingEvent { .. } => 1,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Checkpoint {
pub seq: u64,
pub chain_tip: String,
pub created_at: String,
pub signature: String,
}
fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
let mut buf = CHECKPOINT_DOMAIN.to_vec();
push_field(&mut buf, "seq", &seq.to_string());
push_field(&mut buf, "chain_tip", chain_tip);
push_field(&mut buf, "created_at", created_at);
buf
}
impl AuditKey {
pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
let sig = self
.signing
.sign(&checkpoint_message(seq, chain_tip, created_at));
Checkpoint {
seq,
chain_tip: chain_tip.to_string(),
created_at: created_at.to_string(),
signature: hex::encode(sig.to_bytes()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckpointOutcome {
Consistent { checkpoints_checked: u64 },
BadSignature { seq: u64 },
Truncated {
checkpoint_seq: u64,
current_max_seq: u64,
},
TipMismatch {
seq: u64,
anchored: String,
actual: String,
},
CannotVerify { reason: String },
}
pub fn verify_checkpoints(
verifying_key_hex: &str,
rows: &[ChainRow],
checkpoints: &[Checkpoint],
) -> CheckpointOutcome {
let vk = match parse_verifying_key(verifying_key_hex) {
Some(vk) => vk,
None => {
return CheckpointOutcome::CannotVerify {
reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
};
}
};
let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
let mut checked = 0u64;
for cp in checkpoints {
let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
if !signature_ok(&vk, &msg, &cp.signature) {
return CheckpointOutcome::BadSignature { seq: cp.seq };
}
match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
Ok(idx) => {
return CheckpointOutcome::TipMismatch {
seq: cp.seq,
anchored: cp.chain_tip.clone(),
actual: rows[idx].chain_hash.clone(),
};
}
Err(_) => {
return CheckpointOutcome::Truncated {
checkpoint_seq: cp.seq,
current_max_seq,
};
}
}
checked += 1;
}
CheckpointOutcome::Consistent {
checkpoints_checked: checked,
}
}
pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
match outcome {
CheckpointOutcome::Consistent { .. } => 0,
CheckpointOutcome::BadSignature { .. }
| CheckpointOutcome::Truncated { .. }
| CheckpointOutcome::TipMismatch { .. } => 1,
CheckpointOutcome::CannotVerify { .. } => 2,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixed_key() -> AuditKey {
AuditKey::from_bytes(vec![0x42; 32])
}
fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
ChainContent {
seq,
key_id: CURRENT_KEY_ID,
transaction_id: txid,
request_id: "req-1",
request_hash: "hash-abc",
action_name: "UpdateSystem",
risk_level: RiskLevel::High,
summary: "Upgrade",
approval_id: None,
warnings_json: "[]",
created_at: "2026-04-24T12:00:00Z",
identity: ChainIdentity::V2 {
caller_role: "Dev",
event_tip: "",
},
}
}
#[test]
fn same_inputs_yield_same_hash() {
let key = fixed_key();
let h1 = key.chain_hash(&sample_content(1, "txa"), "");
let h2 = key.chain_hash(&sample_content(1, "txa"), "");
assert_eq!(h1, h2);
assert_eq!(h1.len(), HASH_HEX_LEN);
}
#[test]
fn different_seq_yields_different_hash() {
let key = fixed_key();
let h1 = key.chain_hash(&sample_content(1, "txa"), "");
let h2 = key.chain_hash(&sample_content(2, "txa"), "");
assert_ne!(h1, h2);
}
#[test]
fn different_prev_hash_yields_different_hash() {
let key = fixed_key();
let h1 = key.chain_hash(&sample_content(1, "txa"), "");
let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
assert_ne!(h1, h2);
}
#[test]
fn different_keys_yield_different_hashes() {
let key1 = AuditKey::from_bytes(vec![0x01; 32]);
let key2 = AuditKey::from_bytes(vec![0x02; 32]);
let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
assert_ne!(h1, h2);
}
#[test]
fn canonical_bytes_have_stable_field_order() {
let c = sample_content(1, "txa");
let bytes = c.canonical_bytes();
let s = String::from_utf8_lossy(&bytes);
let order = [
"seq",
"key_id",
"transaction_id",
"request_id",
"request_hash",
"action_name",
"risk_level",
"summary",
"approval_id",
"warnings_json",
"created_at",
];
let mut last_idx = 0;
for tag in order {
let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
assert!(idx >= last_idx, "{tag} out of order");
last_idx = idx;
}
}
#[test]
fn nul_bytes_in_field_value_are_escaped() {
let mut c = sample_content(1, "txa");
let summary = "before\0after";
c.summary = summary;
let bytes = c.canonical_bytes();
assert!(!bytes.contains(&0x00));
assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
}
#[test]
fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
let mut a = sample_content(1, "txa");
a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
b.summary = "before\0after"; assert_ne!(
a.canonical_bytes(),
b.canonical_bytes(),
"backslash escape must run before NUL escape to prevent collision"
);
}
fn key_for_collision_test() -> AuditKey {
AuditKey::from_bytes(vec![0xab; 32])
}
#[test]
fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
let key = key_for_collision_test();
let mut a = sample_content(1, "txa");
a.summary = "before\\0after";
let mut b = sample_content(1, "txa");
b.summary = "before\0after";
assert_ne!(
key.chain_hash(&a, ""),
key.chain_hash(&b, ""),
"chain signature must distinguish escape from raw byte"
);
}
fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
let (chain_version, caller_role, event_tip) = match content.identity {
ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None),
ChainIdentity::V2 {
caller_role,
event_tip,
} => (
CHAIN_VERSION_CURRENT,
Some(caller_role.to_string()),
Some(event_tip.to_string()),
),
};
ChainRow {
seq: content.seq,
key_id: content.key_id.to_string(),
transaction_id: content.transaction_id.to_string(),
request_id: content.request_id.to_string(),
request_hash: content.request_hash.to_string(),
action_name: content.action_name.to_string(),
risk_level: content.risk_level,
summary: content.summary.to_string(),
approval_id: content.approval_id.map(str::to_string),
warnings_json: content.warnings_json.to_string(),
created_at: content.created_at.to_string(),
prev_chain_hash: prev.to_string(),
chain_hash,
chain_version,
caller_role,
event_tip,
}
}
fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
let mut rows = Vec::with_capacity(count);
let mut prev = String::new();
for i in 0..count {
let seq = (i + 1) as u64;
let txid = format!("tx{i}");
let content = sample_content(seq, &txid);
let hash = key.chain_hash(&content, &prev);
rows.push(row_for(&content, &prev, hash.clone()));
prev = hash;
}
rows
}
#[test]
fn caller_role_is_covered_by_the_row_signature() {
let key = fixed_key();
let mut dev = sample_content(1, "txa");
dev.identity = ChainIdentity::V2 {
caller_role: "Dev",
event_tip: "",
};
let mut boot = sample_content(1, "txa");
boot.identity = ChainIdentity::V2 {
caller_role: "Boot",
event_tip: "",
};
assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
}
#[test]
fn event_tip_is_covered_by_the_row_signature() {
let key = fixed_key();
let mut empty = sample_content(1, "txa");
empty.identity = ChainIdentity::V2 {
caller_role: "Dev",
event_tip: "",
};
let mut bound = sample_content(1, "txa");
bound.identity = ChainIdentity::V2 {
caller_role: "Dev",
event_tip: "abc123",
};
assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
}
#[test]
fn a_legacy_row_written_before_the_migration_still_verifies() {
let key = fixed_key();
let mut content = sample_content(1, "tx-legacy");
content.identity = ChainIdentity::LegacyV1;
let hash = key.chain_hash(&content, "");
let row = row_for(&content, "", hash);
assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
assert_eq!(row.caller_role, None);
assert_eq!(
verify_chain(&key, &[row]),
VerifyOutcome::Intact { rows_checked: 1 }
);
}
#[test]
fn a_chain_that_spans_the_migration_verifies_end_to_end() {
let key = fixed_key();
let mut rows = Vec::new();
let mut prev = String::new();
for (i, identity) in [
ChainIdentity::LegacyV1,
ChainIdentity::LegacyV1,
ChainIdentity::V2 {
caller_role: "Dev",
event_tip: "",
},
ChainIdentity::V2 {
caller_role: "Boot",
event_tip: "",
},
]
.into_iter()
.enumerate()
{
let txid = format!("tx{i}");
let mut content = sample_content((i + 1) as u64, &txid);
content.identity = identity;
let hash = key.chain_hash(&content, &prev);
rows.push(row_for(&content, &prev, hash.clone()));
prev = hash;
}
assert_eq!(
verify_chain(&key, &rows),
VerifyOutcome::Intact { rows_checked: 4 }
);
}
#[test]
fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
let key = fixed_key();
let mut rows = build_chain(&key, 2);
rows[1].chain_version = CHAIN_VERSION_LEGACY;
rows[1].caller_role = None;
rows[1].event_tip = None;
match verify_chain(&key, &rows) {
VerifyOutcome::Broken {
first_broken_seq, ..
} => assert_eq!(first_broken_seq, 2),
other => panic!("expected Broken, got {other:?}"),
}
}
#[test]
fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
let key = fixed_key();
let mut rows = build_chain(&key, 1);
rows[0].caller_role = None;
let outcome = verify_chain(&key, &rows);
assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
assert_eq!(outcome_to_exit_code(&outcome), 1);
}
#[test]
fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
let key = fixed_key();
let mut rows = build_chain(&key, 1);
rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
let outcome = verify_chain(&key, &rows);
assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
assert_eq!(outcome_to_exit_code(&outcome), 2);
}
fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
EventContent {
seq,
key_id: CURRENT_KEY_ID,
kind,
transaction_id: txid,
receipt_digest: "digest-abc",
created_at: "2026-04-24T12:00:00Z",
}
}
fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
let mut rows = Vec::with_capacity(kinds.len());
let mut prev = String::new();
for (i, kind) in kinds.iter().enumerate() {
let seq = (i + 1) as u64;
let txid = format!("tx{i}");
let content = event_content(seq, *kind, &txid);
let hash = key.event_hash(&content, &prev);
rows.push(EventRow {
seq,
key_id: content.key_id.to_string(),
kind: content.kind.as_str().to_string(),
transaction_id: content.transaction_id.to_string(),
receipt_digest: content.receipt_digest.to_string(),
created_at: content.created_at.to_string(),
prev_chain_hash: prev.clone(),
chain_hash: hash.clone(),
});
prev = hash;
}
rows
}
#[test]
fn event_kind_spellings_are_stable() {
assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
assert_eq!(
AuditEventKind::ApprovalConsumed.as_str(),
"approval_consumed"
);
assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
}
#[test]
fn intact_event_chain_verifies() {
let key = fixed_key();
let rows = build_event_chain(
&key,
&[
AuditEventKind::ApprovalGranted,
AuditEventKind::ApprovalConsumed,
],
);
assert_eq!(
verify_event_chain(&key, &rows),
VerifyOutcome::Intact { rows_checked: 2 }
);
}
#[test]
fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
let key = fixed_key();
let mut rows = build_event_chain(
&key,
&[
AuditEventKind::ApprovalGranted,
AuditEventKind::ApprovalConsumed,
AuditEventKind::ApprovalRevoked,
],
);
rows.remove(1);
match verify_event_chain(&key, &rows) {
VerifyOutcome::Broken {
first_broken_seq, ..
} => assert_eq!(first_broken_seq, 3),
other => panic!("expected Broken, got {other:?}"),
}
}
#[test]
fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
let key = fixed_key();
let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
assert!(matches!(
verify_event_chain(&key, &rows),
VerifyOutcome::Broken { .. }
));
}
#[test]
fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
let key = fixed_key();
let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
rows[0].kind = "approval_unicorned".to_string();
assert!(matches!(
verify_event_chain(&key, &rows),
VerifyOutcome::Broken { .. }
));
}
#[test]
fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
let key = fixed_key();
let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
let event_sig = key.event_hash(&content, "");
let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
assert_ne!(event_sig, row_sig);
let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
rows[0].chain_hash = row_sig;
assert!(matches!(
verify_event_chain(&key, &rows),
VerifyOutcome::Broken { .. }
));
}
#[test]
fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
let key = fixed_key();
let events = build_event_chain(
&key,
&[
AuditEventKind::ApprovalGranted,
AuditEventKind::ApprovalConsumed,
],
);
let mut content = sample_content(1, "tx-after");
content.identity = ChainIdentity::V2 {
caller_role: "Dev",
event_tip: &events[1].chain_hash,
};
let hash = key.chain_hash(&content, "");
let tx_rows = vec![row_for(&content, "", hash)];
assert_eq!(
verify_event_binding(&tx_rows, &events),
BindingOutcome::Consistent {
bindings_checked: 1
}
);
let truncated = &events[..1];
assert_eq!(
verify_event_chain(&key, truncated),
VerifyOutcome::Intact { rows_checked: 1 },
"a truncated event chain still walks clean; the binding is the detector"
);
match verify_event_binding(&tx_rows, truncated) {
BindingOutcome::MissingEvent {
transaction_seq, ..
} => assert_eq!(transaction_seq, 1),
other => panic!("expected MissingEvent, got {other:?}"),
}
}
#[test]
fn legacy_rows_carry_no_binding_to_check() {
let key = fixed_key();
let mut content = sample_content(1, "tx-legacy");
content.identity = ChainIdentity::LegacyV1;
let hash = key.chain_hash(&content, "");
let rows = vec![row_for(&content, "", hash)];
assert_eq!(
verify_event_binding(&rows, &[]),
BindingOutcome::Consistent {
bindings_checked: 0
}
);
}
#[test]
fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
let verification = AuditVerification {
chain: VerifyOutcome::Broken {
rows_checked: 1,
first_broken_seq: 2,
first_broken_transaction_id: "tx".to_string(),
expected: "e".to_string(),
actual: "a".to_string(),
},
events: VerifyOutcome::CannotVerify {
reason: "no key".to_string(),
},
binding: BindingOutcome::Consistent {
bindings_checked: 0,
},
};
assert_eq!(verification.exit_code(), 1);
}
#[test]
fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
let verification = AuditVerification {
chain: VerifyOutcome::Intact { rows_checked: 3 },
events: VerifyOutcome::Intact { rows_checked: 1 },
binding: BindingOutcome::MissingEvent {
transaction_seq: 3,
event_tip: "abc".to_string(),
},
};
assert_eq!(verification.exit_code(), 1);
}
#[test]
fn all_three_clean_is_exit_zero() {
let verification = AuditVerification {
chain: VerifyOutcome::Intact { rows_checked: 3 },
events: VerifyOutcome::Intact { rows_checked: 2 },
binding: BindingOutcome::Consistent {
bindings_checked: 1,
},
};
assert_eq!(verification.exit_code(), 0);
}
#[test]
fn binding_exit_codes_split_clean_from_tampered() {
assert_eq!(
binding_outcome_to_exit_code(&BindingOutcome::Consistent {
bindings_checked: 0
}),
0
);
assert_eq!(
binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
transaction_seq: 7,
event_tip: "abc".to_string()
}),
1
);
}
#[test]
fn intact_chain_verifies() {
let key = fixed_key();
let rows = build_chain(&key, 5);
let outcome = verify_chain(&key, &rows);
assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
assert_eq!(outcome_to_exit_code(&outcome), 0);
}
#[test]
fn empty_chain_verifies() {
let key = fixed_key();
let outcome = verify_chain(&key, &[]);
assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
}
#[test]
fn tampered_summary_breaks_chain_at_first_offending_row() {
let key = fixed_key();
let mut rows = build_chain(&key, 3);
rows[1].summary = "TAMPERED".to_string();
let outcome = verify_chain(&key, &rows);
match outcome {
VerifyOutcome::Broken {
first_broken_seq, ..
} => assert_eq!(first_broken_seq, 2),
other => panic!("expected Broken, got {other:?}"),
}
}
#[test]
fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
let key = fixed_key();
let mut rows = build_chain(&key, 4);
rows.remove(1);
let outcome = verify_chain(&key, &rows);
match outcome {
VerifyOutcome::Broken {
first_broken_seq, ..
} => assert_eq!(
first_broken_seq, 3,
"first broken row is the one whose prev_hash refers to deleted row"
),
other => panic!("expected Broken, got {other:?}"),
}
}
#[test]
fn inserted_forged_row_breaks_chain() {
let key = fixed_key();
let mut rows = build_chain(&key, 3);
let forged = ChainRow {
seq: 2,
key_id: CURRENT_KEY_ID.to_string(),
transaction_id: "tx-forged".to_string(),
request_id: "req-forged".to_string(),
request_hash: "hash-forged".to_string(),
action_name: "InstallFlatpak".to_string(),
risk_level: RiskLevel::Medium,
summary: "Forged row".to_string(),
approval_id: None,
warnings_json: "[]".to_string(),
created_at: "2026-04-25T13:00:00Z".to_string(),
prev_chain_hash: rows[0].chain_hash.clone(),
chain_hash: "0".repeat(HASH_HEX_LEN),
chain_version: CHAIN_VERSION_CURRENT,
caller_role: Some("Dev".to_string()),
event_tip: Some(String::new()),
};
let mut rest: Vec<ChainRow> = rows.split_off(1);
for r in rest.iter_mut() {
r.seq += 1;
}
rows.push(forged);
rows.extend(rest);
let outcome = verify_chain(&key, &rows);
match outcome {
VerifyOutcome::Broken {
first_broken_seq, ..
} => assert_eq!(
first_broken_seq, 2,
"the verifier must flag the forged row itself, not a later one"
),
other => panic!("expected Broken, got {other:?}"),
}
}
#[test]
fn signature_verifies_under_exported_public_key() {
let key = fixed_key();
let rows = build_chain(&key, 4);
let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
}
#[test]
fn foreign_public_key_cannot_validate_chain() {
let signer = AuditKey::from_bytes(vec![0x11; 32]);
let rows = build_chain(&signer, 3);
let other = AuditKey::from_bytes(vec![0x22; 32]);
let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
assert!(matches!(
outcome,
VerifyOutcome::Broken {
first_broken_seq: 1,
..
}
));
}
#[test]
fn verifying_key_hex_is_a_32_byte_public_key() {
let key = fixed_key();
let vk_hex = key.verifying_key_hex();
assert_eq!(
vk_hex.len(),
64,
"32-byte ed25519 public key = 64 hex chars"
);
assert!(hex::decode(&vk_hex).is_ok());
}
#[test]
fn malformed_signature_hex_is_broken_not_panic() {
let key = fixed_key();
let mut rows = build_chain(&key, 2);
rows[0].chain_hash = "not-valid-hex!!".to_string();
let outcome = verify_chain(&key, &rows);
assert!(matches!(
outcome,
VerifyOutcome::Broken {
first_broken_seq: 1,
..
}
));
}
#[test]
fn bad_public_key_hex_yields_cannot_verify() {
let key = fixed_key();
let rows = build_chain(&key, 1);
let outcome = verify_chain_with_pubkey("zz", &rows);
assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
}
#[test]
fn load_or_generate_writes_public_key_sidecar() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit-key");
let key = AuditKey::load_or_generate(&path).unwrap();
let pub_path = dir.path().join("audit-key.pub");
assert!(pub_path.exists(), "public key sidecar must be written");
let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
assert_eq!(pub_hex.trim(), key.verifying_key_hex());
}
#[test]
fn wrong_key_id_yields_cannot_verify() {
let key = fixed_key();
let mut rows = build_chain(&key, 2);
rows[0].key_id = "v99".to_string();
let outcome = verify_chain(&key, &rows);
assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
assert_eq!(outcome_to_exit_code(&outcome), 2);
}
#[test]
fn exit_code_for_each_outcome() {
assert_eq!(
outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
0
);
assert_eq!(
outcome_to_exit_code(&VerifyOutcome::Broken {
rows_checked: 0,
first_broken_seq: 1,
first_broken_transaction_id: String::new(),
expected: String::new(),
actual: String::new(),
}),
1
);
assert_eq!(
outcome_to_exit_code(&VerifyOutcome::CannotVerify {
reason: String::new()
}),
2
);
}
#[test]
fn load_or_generate_creates_with_0600_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit-key");
let _key = AuditKey::load_or_generate(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn load_or_generate_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit-key");
let key1_bytes = {
let _ = AuditKey::load_or_generate(&path).unwrap();
std::fs::read(&path).unwrap()
};
let _ = AuditKey::load_or_generate(&path).unwrap();
let key2_bytes = std::fs::read(&path).unwrap();
assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
}
#[test]
fn rejects_world_readable_key_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit-key");
std::fs::write(&path, vec![0u8; 32]).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let result = AuditKey::load_or_generate(&path);
assert!(matches!(
result,
Err(AuditKeyError::UnsafePermissions { .. })
));
}
#[test]
fn rejects_short_key_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit-key");
std::fs::write(&path, vec![0u8; 8]).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
let result = AuditKey::load_or_generate(&path);
assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
}
#[test]
fn debug_redacts_key_bytes_and_their_hex_encoding() {
let raw = (0u8..32).collect::<Vec<u8>>();
let key = AuditKey::from_bytes(raw.clone());
let dbg = format!("{key:?}");
assert!(
dbg.contains("redacted"),
"Debug output must contain the literal 'redacted' marker, got: {dbg}"
);
let hex_lower = hex::encode(&raw);
let hex_upper = hex::encode_upper(&raw);
assert!(
!dbg.contains(&hex_lower),
"Debug output leaks lowercase hex of key bytes: {dbg}"
);
assert!(
!dbg.contains(&hex_upper),
"Debug output leaks uppercase hex of key bytes: {dbg}"
);
for b in &raw {
let pair = format!("{b:02x}");
if *b >= 0x10 && pair != "1e" && pair != "1f" {
assert!(
!dbg.contains(&pair),
"Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
);
}
}
}
#[test]
fn debug_preserves_key_id() {
let key = AuditKey::from_bytes(vec![0xff; 32]);
let dbg = format!("{key:?}");
assert!(
dbg.contains("key_id"),
"Debug output must label the key_id field: {dbg}"
);
assert!(
dbg.contains(CURRENT_KEY_ID),
"Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
);
}
#[test]
fn checkpoint_consistent_with_intact_chain() {
let key = fixed_key();
let rows = build_chain(&key, 5);
let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
assert_eq!(
outcome,
CheckpointOutcome::Consistent {
checkpoints_checked: 1
}
);
}
#[test]
fn checkpoint_detects_truncation() {
let key = fixed_key();
let full = build_chain(&key, 5);
let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
let truncated = &full[..3];
let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
assert!(matches!(
outcome,
CheckpointOutcome::Truncated {
checkpoint_seq: 5,
current_max_seq: 3
}
));
}
#[test]
fn checkpoint_detects_rewrite() {
let key = fixed_key();
let mut rows = build_chain(&key, 4);
let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
assert!(matches!(
outcome,
CheckpointOutcome::TipMismatch { seq: 3, .. }
));
}
#[test]
fn checkpoint_bad_signature_detected() {
let key = fixed_key();
let rows = build_chain(&key, 3);
let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
assert!(matches!(
outcome,
CheckpointOutcome::BadSignature { seq: 2 }
));
}
#[test]
fn checkpoint_foreign_key_rejected() {
let signer = AuditKey::from_bytes(vec![0x11; 32]);
let rows = build_chain(&signer, 3);
let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
let other = AuditKey::from_bytes(vec![0x22; 32]);
let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
assert!(matches!(
outcome,
CheckpointOutcome::BadSignature { seq: 2 }
));
}
#[test]
fn pubkey_verify_detects_tampered_middle_row() {
let key = fixed_key();
let mut rows = build_chain(&key, 4);
rows[2].summary = "TAMPERED".to_string();
let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
assert!(matches!(
outcome,
VerifyOutcome::Broken {
first_broken_seq: 3,
..
}
));
}
#[test]
fn multiple_checkpoints_all_consistent() {
let key = fixed_key();
let rows = build_chain(&key, 5);
let cps = vec![
key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
];
assert_eq!(
verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
CheckpointOutcome::Consistent {
checkpoints_checked: 3
}
);
}
#[test]
fn middle_checkpoint_failure_is_reported() {
let key = fixed_key();
let mut rows = build_chain(&key, 5);
let cps = vec![
key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
];
rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
CheckpointOutcome::TipMismatch { seq: 3, .. }
));
}
#[test]
fn checkpoint_created_at_is_signed() {
let key = fixed_key();
let rows = build_chain(&key, 3);
let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
cp.created_at = "2020-01-01T00:00:00Z".to_string();
assert!(matches!(
verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
CheckpointOutcome::BadSignature { .. }
));
}
#[test]
fn checkpoint_seq_is_signed() {
let key = fixed_key();
let rows = build_chain(&key, 3);
let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
cp.seq = 2;
assert!(matches!(
verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
CheckpointOutcome::BadSignature { seq: 2 }
));
}
#[test]
fn checkpoint_detects_full_wipe() {
let key = fixed_key();
let full = build_chain(&key, 3);
let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
assert!(matches!(
verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
CheckpointOutcome::Truncated {
checkpoint_seq: 3,
current_max_seq: 0
}
));
}
#[test]
fn domain_tags_are_distinct_and_prefix_free() {
let tags: [(&str, &[u8]); 4] = [
("row", ROW_DOMAIN),
("checkpoint", CHECKPOINT_DOMAIN),
("approval", APPROVAL_DOMAIN),
("event", EVENT_DOMAIN),
];
for (i, (name_a, a)) in tags.iter().enumerate() {
for (name_b, b) in tags.iter().skip(i + 1) {
assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
}
}
}
#[test]
fn approval_receipt_framing_is_injective() {
let key = fixed_key();
let sep = "\u{1f}";
let a = key.approval_receipt(&format!("tx{sep}b"), "c");
let b = key.approval_receipt("tx", &format!("b{sep}c"));
assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
assert_eq!(
key.approval_receipt("tx-1", "hash-1"),
key.approval_receipt("tx-1", "hash-1"),
);
assert_eq!(
key.approval_commitment("tx-1", "hash-1"),
approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
);
}
}