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";
#[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, 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,
}
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);
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 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 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,
};
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, 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",
}
}
#[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 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(ChainRow {
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.clone(),
chain_hash: hash.clone(),
});
prev = hash;
}
rows
}
#[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),
};
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!(
first_broken_seq == 2 || first_broken_seq == 3,
"verifier must flag the inserted row or the row that follows it (got {first_broken_seq})"
),
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() {
assert_ne!(ROW_DOMAIN, CHECKPOINT_DOMAIN);
assert_ne!(ROW_DOMAIN, APPROVAL_DOMAIN);
assert_ne!(CHECKPOINT_DOMAIN, APPROVAL_DOMAIN);
assert!(!ROW_DOMAIN.starts_with(CHECKPOINT_DOMAIN));
assert!(!CHECKPOINT_DOMAIN.starts_with(ROW_DOMAIN));
assert!(!ROW_DOMAIN.starts_with(APPROVAL_DOMAIN));
assert!(!APPROVAL_DOMAIN.starts_with(ROW_DOMAIN));
assert!(!CHECKPOINT_DOMAIN.starts_with(APPROVAL_DOMAIN));
assert!(!APPROVAL_DOMAIN.starts_with(CHECKPOINT_DOMAIN));
}
#[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")),
);
}
}