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_V2: u32 = 2;
pub const CHAIN_VERSION_V3: u32 = 3;
pub const CHAIN_VERSION_CURRENT: u32 = CHAIN_VERSION_V3;
#[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,
},
V3 {
caller_role: &'a str,
event_tip: &'a str,
caller_principal: &'a str,
},
}
impl ChainIdentity<'_> {
pub fn version(&self) -> u32 {
match self {
Self::LegacyV1 => CHAIN_VERSION_LEGACY,
Self::V2 { .. } => CHAIN_VERSION_V2,
Self::V3 { .. } => CHAIN_VERSION_V3,
}
}
}
#[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);
match self.identity {
ChainIdentity::LegacyV1 => {}
ChainIdentity::V2 {
caller_role,
event_tip,
} => {
push_field(&mut buf, "chain_version", &CHAIN_VERSION_V2.to_string());
push_field(&mut buf, "caller_role", caller_role);
push_field(&mut buf, "event_tip", event_tip);
}
ChainIdentity::V3 {
caller_role,
event_tip,
caller_principal,
} => {
push_field(&mut buf, "chain_version", &CHAIN_VERSION_V3.to_string());
push_field(&mut buf, "caller_role", caller_role);
push_field(&mut buf, "event_tip", event_tip);
push_field(&mut buf, "caller_principal", caller_principal);
}
}
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>,
pub caller_principal: 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_V2 => 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"))?,
}),
CHAIN_VERSION_V3 => Ok(ChainIdentity::V3 {
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"))?,
caller_principal: self
.caller_principal
.as_deref()
.filter(|p| !p.is_empty())
.ok_or(RowIdentityError::MissingField("caller_principal"))?,
}),
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)) => {
let stored = match field {
"caller_role" => row.caller_role.as_deref(),
"event_tip" => row.event_tip.as_deref(),
"caller_principal" => row.caller_principal.as_deref(),
_ => None,
};
return VerifyOutcome::Broken {
rows_checked,
first_broken_seq: row.seq,
first_broken_transaction_id: row.transaction_id.clone(),
expected: format!(
"chain_version={} row carrying a non-empty {field}",
row.chain_version
),
actual: match stored {
None => format!("{field}=NULL"),
Some("") => format!("{field}='' (empty, not NULL)"),
Some(other) => format!("{field}={other:?}"),
},
};
}
};
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 },
NotChecked,
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,
pub attribution: Option<AttributionCensus>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RowStanding {
NamesAccount,
AttributionFailed,
NotRecorded,
Unattested,
}
fn standing(row: &ChainRow) -> RowStanding {
let stored = row.caller_principal.as_deref().filter(|p| !p.is_empty());
match row.chain_version {
CHAIN_VERSION_LEGACY | CHAIN_VERSION_V2 => match stored {
None => RowStanding::NotRecorded,
Some(_) => RowStanding::Unattested,
},
CHAIN_VERSION_V3 => match stored {
None => RowStanding::NotRecorded,
Some(p) => match crate::auth::CallerPrincipal::classify(p) {
crate::auth::PrincipalClaim::Account => RowStanding::NamesAccount,
crate::auth::PrincipalClaim::AttributionFailed => RowStanding::AttributionFailed,
crate::auth::PrincipalClaim::Unrecognized => RowStanding::Unattested,
},
},
_ => RowStanding::Unattested,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AttributionCensus {
named: u64,
attribution_failed: u64,
not_recorded: u64,
unattested: u64,
rows: u64,
}
impl AttributionCensus {
pub fn of(tx_rows: &[ChainRow]) -> Self {
let mut census = Self {
named: 0,
attribution_failed: 0,
not_recorded: 0,
unattested: 0,
rows: tx_rows.len() as u64,
};
for row in tx_rows {
match standing(row) {
RowStanding::NamesAccount => census.named += 1,
RowStanding::AttributionFailed => census.attribution_failed += 1,
RowStanding::NotRecorded => census.not_recorded += 1,
RowStanding::Unattested => census.unattested += 1,
}
}
census
}
pub fn named(&self) -> u64 {
self.named
}
pub fn attribution_failed(&self) -> u64 {
self.attribution_failed
}
pub fn not_recorded(&self) -> u64 {
self.not_recorded
}
pub fn unattested(&self) -> u64 {
self.unattested
}
pub fn rows(&self) -> u64 {
self.rows
}
pub fn unnamed(&self) -> u64 {
self.rows - self.named
}
#[cfg(any(test, feature = "test-support"))]
pub fn from_counts_for_tests(
named: u64,
attribution_failed: u64,
not_recorded: u64,
unattested: u64,
) -> Self {
let rows = named
.checked_add(attribution_failed)
.and_then(|n| n.checked_add(not_recorded))
.and_then(|n| n.checked_add(unattested))
.expect("census counts must not overflow u64");
Self {
named,
attribution_failed,
not_recorded,
unattested,
rows,
}
}
}
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),
attribution: Some(AttributionCensus::of(tx_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),
attribution: Some(AttributionCensus::of(tx_rows)),
}
}
pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
match outcome {
BindingOutcome::Consistent { .. } => 0,
BindingOutcome::NotChecked => 2,
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, caller_principal) = match content.identity {
ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None, None),
ChainIdentity::V2 {
caller_role,
event_tip,
} => (
CHAIN_VERSION_V2,
Some(caller_role.to_string()),
Some(event_tip.to_string()),
None,
),
ChainIdentity::V3 {
caller_role,
event_tip,
caller_principal,
} => (
CHAIN_VERSION_V3,
Some(caller_role.to_string()),
Some(event_tip.to_string()),
Some(caller_principal.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,
caller_principal,
}
}
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 two_admins_are_distinguishable_in_the_signed_record() {
let key = fixed_key();
let mut alice = sample_content(1, "tx-alice");
alice.identity = ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "uid:1000",
};
let mut bob = sample_content(1, "tx-alice");
bob.identity = ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "uid:1001",
};
assert_ne!(
key.chain_hash(&alice, ""),
key.chain_hash(&bob, ""),
"identical rows differing only in principal must sign differently, \
otherwise the chain still cannot answer which account acted"
);
}
#[test]
fn editing_the_stored_principal_breaks_the_row() {
let key = fixed_key();
let mut content = sample_content(1, "tx1");
content.identity = ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "uid:1000",
};
let hash = key.chain_hash(&content, "");
let mut row = row_for(&content, "", hash);
row.caller_principal = Some("uid:0".to_string());
assert!(
matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
"a principal swapped to root must not verify"
);
}
#[test]
fn downgrading_a_v3_row_to_v2_to_hide_the_account_breaks_it() {
let key = fixed_key();
let mut content = sample_content(1, "tx1");
content.identity = ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "uid:1000",
};
let hash = key.chain_hash(&content, "");
let mut row = row_for(&content, "", hash);
row.chain_version = CHAIN_VERSION_V2;
row.caller_principal = None;
assert!(
matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
"a v3 row relabelled as v2 must not verify"
);
}
#[test]
fn a_v3_row_without_a_usable_principal_is_broken() {
let key = fixed_key();
let mut content = sample_content(1, "tx1");
content.identity = ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "uid:1000",
};
let hash = key.chain_hash(&content, "");
for absent in [None, Some(String::new())] {
let mut row = row_for(&content, "", hash.clone());
row.caller_principal = absent.clone();
assert!(
matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
"a v3 row with principal {absent:?} must be reported as broken"
);
}
}
#[test]
fn a_row_signed_with_a_blank_principal_is_rejected_even_though_it_signs() {
let key = fixed_key();
let mut content = sample_content(1, "tx-blank");
content.identity = ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "",
};
let hash = key.chain_hash(&content, "");
let row = row_for(&content, "", hash);
assert_eq!(
row.caller_principal.as_deref(),
Some(""),
"fixture must store the blank principal it signed"
);
assert_eq!(row.chain_version, CHAIN_VERSION_V3);
assert!(
matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
"a v3 row naming nobody must be broken, not accepted"
);
}
#[test]
fn a_row_recording_an_attribution_failure_verifies() {
let key = fixed_key();
let principal = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
let mut content = sample_content(1, "tx-unattributed");
content.identity = ChainIdentity::V3 {
caller_role: "observer",
event_tip: "",
caller_principal: &principal,
};
let hash = key.chain_hash(&content, "");
let row = row_for(&content, "", hash);
assert_eq!(
verify_chain(&key, &[row]),
VerifyOutcome::Intact { rows_checked: 1 }
);
}
#[test]
fn a_chain_spanning_all_three_encodings_verifies() {
let key = fixed_key();
let mut rows = Vec::new();
let mut prev = String::new();
for (i, identity) in [
ChainIdentity::LegacyV1,
ChainIdentity::V2 {
caller_role: "dev",
event_tip: "",
},
ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "uid:1000",
},
ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "token:vsock",
},
]
.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 }
);
}
fn chain_of(key: &AuditKey, identities: Vec<ChainIdentity<'_>>) -> Vec<ChainRow> {
let mut rows = Vec::new();
let mut prev = String::new();
for (i, identity) in identities.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;
}
rows
}
#[test]
fn the_census_separates_attribution_failures_from_encodings_without_the_field() {
let key = fixed_key();
let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
let rows = chain_of(
&key,
vec![
ChainIdentity::LegacyV1,
ChainIdentity::V2 {
caller_role: "dev",
event_tip: "",
},
ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "uid:1000",
},
ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "uid:1001",
},
ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "token:vsock",
},
ChainIdentity::V3 {
caller_role: "observer",
event_tip: "",
caller_principal: &unattributed,
},
],
);
let verification = verify_all(&key, &rows, &[]);
assert_eq!(
verification.chain,
VerifyOutcome::Intact { rows_checked: 6 },
"every encoding must keep verifying; the census is not a verdict"
);
let census = verification
.attribution
.expect("rows were read, so a census was taken");
assert_eq!(census.named(), 3);
assert_eq!(census.attribution_failed(), 1);
assert_eq!(census.not_recorded(), 2);
assert_eq!(census.unattested(), 0);
assert_eq!(census.rows(), 6);
assert_eq!(
census.unnamed(),
3,
"three of the six rows cannot name an account, by either reason"
);
}
#[test]
fn a_principal_written_into_an_encoding_that_does_not_sign_it_is_never_an_account() {
let key = fixed_key();
let mut rows = chain_of(
&key,
vec![
ChainIdentity::LegacyV1,
ChainIdentity::V2 {
caller_role: "dev",
event_tip: "",
},
],
);
rows[0].caller_principal = Some("uid:0".to_string());
rows[1].caller_principal = Some("uid:0".to_string());
let verification = verify_all(&key, &rows, &[]);
assert_eq!(
verification.chain,
VerifyOutcome::Intact { rows_checked: 2 },
"no signature covers that column on v1/v2, so tampering with it is \
invisible to verification -- which is exactly why the census must \
not believe it"
);
let census = verification.attribution.expect("rows were read");
assert_eq!(
census.named(),
0,
"an unsigned column must never be credited as an account"
);
assert_eq!(
census.unattested(),
2,
"and it must be reported as unattested, because nothing in SysKnife \
writes that column on those encodings"
);
assert_eq!(census.unnamed(), 2);
}
#[test]
fn a_blank_principal_column_is_counted_as_naming_nobody() {
let key = fixed_key();
let mut content = sample_content(1, "tx-blank-census");
content.identity = ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "",
};
let hash = key.chain_hash(&content, "");
let row = row_for(&content, "", hash);
assert_eq!(row.caller_principal.as_deref(), Some(""));
let verification = verify_all(&key, &[row], &[]);
let census = verification.attribution.expect("a row was read");
assert_eq!(census.named(), 0);
assert_eq!(census.not_recorded(), 1);
assert_eq!(census.rows(), 1);
assert!(
matches!(verification.chain, VerifyOutcome::Broken { .. }),
"and the row is still reported as broken once the walk reaches it, \
so census and verdict agree"
);
}
#[test]
fn a_principal_this_binary_cannot_read_is_counted_as_unattested_not_as_an_account() {
let key = fixed_key();
for forged in [
"garbage",
"uid:",
"none:overflow",
" ",
"UID:1000",
"uid:notanumber",
"uid:1000:extra",
"token:not-vsock",
] {
let mut content = sample_content(1, "tx-forged");
content.identity = ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: forged,
};
let hash = key.chain_hash(&content, "");
let row = row_for(&content, "", hash);
let census = verify_all(&key, &[row], &[])
.attribution
.expect("a row was read");
assert_eq!(
census.named(),
0,
"{forged:?} names no account and must not be counted as one"
);
assert_eq!(
census.unnamed(),
1,
"{forged:?} must be counted among the rows naming nobody"
);
assert_eq!(
census.unattested(),
1,
"{forged:?} is signed content the daemon never writes, so it is a \
finding to investigate, not history to explain away"
);
assert_eq!(
census.not_recorded(),
0,
"{forged:?} is present, so it must not be reported as a row that \
predates the column"
);
}
}
#[test]
fn a_chain_predating_the_principal_field_reports_no_row_as_attributed() {
let key = fixed_key();
let rows = chain_of(
&key,
vec![
ChainIdentity::LegacyV1,
ChainIdentity::V2 {
caller_role: "dev",
event_tip: "",
},
],
);
let verification = verify_all(&key, &rows, &[]);
assert_eq!(
verification.chain,
VerifyOutcome::Intact { rows_checked: 2 },
"legacy rows must keep verifying; the census is not a verdict"
);
let census = verification.attribution.expect("rows were read");
assert_eq!(census.attribution_failed(), 0);
assert_eq!(census.named(), 0);
assert_eq!(census.not_recorded(), 2);
assert_eq!(census.unnamed(), 2);
}
#[test]
fn a_broken_chain_censuses_more_rows_than_it_verified() {
let key = fixed_key();
let mut rows = chain_of(
&key,
vec![
ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "uid:1000",
},
ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "uid:1000",
},
ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "uid:1000",
},
],
);
rows[1].summary = "tampered".to_string();
let verification = verify_all(&key, &rows, &[]);
let rows_checked = match verification.chain {
VerifyOutcome::Broken { rows_checked, .. } => rows_checked,
ref other => panic!("expected a detected tamper, got {other:?}"),
};
let census = verification.attribution.expect("rows were read");
assert_eq!(rows_checked, 1, "the walk stops at the first broken row");
assert_eq!(
census.rows(),
3,
"while the census still describes every row read"
);
assert!(
census.rows() > rows_checked,
"the report must be able to show that more was counted than checked"
);
}
#[test]
fn the_pubkey_only_path_takes_the_same_census() {
let key = fixed_key();
let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
let rows = chain_of(
&key,
vec![
ChainIdentity::V3 {
caller_role: "admin",
event_tip: "",
caller_principal: "uid:1000",
},
ChainIdentity::V3 {
caller_role: "observer",
event_tip: "",
caller_principal: &unattributed,
},
],
);
let with_key = verify_all(&key, &rows, &[]).attribution;
let with_pubkey = verify_all_with_pubkey(&key.verifying_key_hex(), &rows, &[]).attribution;
assert_eq!(
with_key, with_pubkey,
"the auditor must see the same attribution numbers as the keyholder"
);
let census = with_pubkey.expect("rows were read");
assert_eq!(census.named(), 1);
assert_eq!(census.attribution_failed(), 1);
}
#[test]
fn a_chain_that_names_nobody_still_exits_zero() {
let key = fixed_key();
let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
let rows = chain_of(
&key,
vec![
ChainIdentity::LegacyV1,
ChainIdentity::V3 {
caller_role: "observer",
event_tip: "",
caller_principal: &unattributed,
},
],
);
let verification = verify_all(&key, &rows, &[]);
let census = verification.attribution.expect("rows were read");
assert_eq!(census.unnamed(), 2, "not one row names an account");
assert_eq!(
verification.exit_code(),
0,
"yet nothing was tampered with, so the command must succeed"
);
}
#[test]
fn a_future_encoding_reports_cannot_verify_with_the_supported_range() {
let key = fixed_key();
let content = sample_content(1, "tx1");
let hash = key.chain_hash(&content, "");
let mut row = row_for(&content, "", hash);
row.chain_version = CHAIN_VERSION_CURRENT + 1;
match verify_chain(&key, &[row]) {
VerifyOutcome::CannotVerify { reason } => {
assert!(
reason.contains(&format!("chain_version={}", CHAIN_VERSION_CURRENT + 1)),
"name the version found, got: {reason}"
);
assert!(
reason.contains(&CHAIN_VERSION_CURRENT.to_string()),
"and the newest version understood, got: {reason}"
);
}
other => panic!("a future encoding must not be reported as tampering: {other:?}"),
}
}
#[test]
fn a_row_written_by_the_previous_release_still_verifies() {
const GOLDEN_V2_CHAIN_HASH: &str = "ba12cbe49a149387898bc30f1e8d409effa025ab8e440e6e72bedf060afc831e45d9be264d6b18b7e0ef2f2b3584dbc6953b535546cecbbdc3d7ac666cae4d0a";
let key = fixed_key();
let row = ChainRow {
seq: 7,
key_id: CURRENT_KEY_ID.to_string(),
transaction_id: "tx-golden".to_string(),
request_id: "req-golden".to_string(),
request_hash: "hash-golden".to_string(),
action_name: "AptInstall".to_string(),
risk_level: RiskLevel::Medium,
summary: "install ripgrep".to_string(),
approval_id: Some("appr-golden".to_string()),
warnings_json: "[]".to_string(),
created_at: "2026-07-29T00:00:00Z".to_string(),
prev_chain_hash: String::new(),
chain_hash: GOLDEN_V2_CHAIN_HASH.to_string(),
chain_version: 2,
caller_role: Some("admin".to_string()),
event_tip: Some("eventtip-golden".to_string()),
caller_principal: None,
};
assert_eq!(
verify_chain(&key, std::slice::from_ref(&row)),
VerifyOutcome::Intact { rows_checked: 1 },
"a v2 row on disk must verify under this binary; if this fails, the v2 \
encoding or its version dispatch changed and existing audit logs are unreadable"
);
let mut tampered = row;
let flipped = match GOLDEN_V2_CHAIN_HASH.strip_prefix('b') {
Some(rest) => format!("a{rest}"),
None => format!("b{}", &GOLDEN_V2_CHAIN_HASH[1..]),
};
tampered.chain_hash = flipped;
assert!(
matches!(
verify_chain(&key, &[tampered]),
VerifyOutcome::Broken { .. }
),
"a golden row with one nibble changed must not verify"
);
}
#[test]
fn a_legacy_v1_row_on_disk_still_verifies() {
const GOLDEN_V1_CHAIN_HASH: &str = "ce8b47c15414e989e099303b826fcd9985360b794220cefbbf9689d25f31fe173283687feb6d1dd265ae5aca94c8bcfb87ce33ba8c954fafdad18a59cd97c702";
let row = ChainRow {
chain_version: 1,
caller_role: None,
event_tip: None,
caller_principal: None,
chain_hash: GOLDEN_V1_CHAIN_HASH.to_string(),
..golden_row_shape()
};
assert_eq!(
verify_chain(&fixed_key(), &[row]),
VerifyOutcome::Intact { rows_checked: 1 }
);
}
#[test]
fn a_v3_row_on_disk_still_verifies() {
const GOLDEN_V3_CHAIN_HASH: &str = "3c0d05f6ccaf9e65ccb796d4ffe96eb31a2ea67e446217c5234af916dc959d0f2c0ee3d3e5b525aece791ee7d6507efb98dcdd79f0d74dde4273859aa9627d08";
let row = ChainRow {
chain_version: 3,
caller_role: Some("admin".to_string()),
event_tip: Some("eventtip-golden".to_string()),
caller_principal: Some("uid:1000".to_string()),
chain_hash: GOLDEN_V3_CHAIN_HASH.to_string(),
..golden_row_shape()
};
assert_eq!(
verify_chain(&fixed_key(), &[row]),
VerifyOutcome::Intact { rows_checked: 1 }
);
}
#[test]
fn the_escape_table_and_absent_approval_encoding_are_frozen() {
const GOLDEN_V2_AWKWARD_CHAIN_HASH: &str = "e6face6fe2346fc0f6c5c1e5845255579c8f08738406b88b0a1f6dbdf8ff85090a4b80d95bd0927ef54b0a2c4810d52ebb4bbf76cb68c865a7748df26442aa05";
let key = fixed_key();
let content = ChainContent {
seq: 8,
approval_id: None,
summary: "back\\slash and \x1funit and \x1erecord and nul\0byte",
identity: ChainIdentity::V2 {
caller_role: "dev",
event_tip: "",
},
..golden_content_shape()
};
assert_eq!(
key.chain_hash(&content, "ba12cbe4"),
GOLDEN_V2_AWKWARD_CHAIN_HASH,
"the canonical encoding of escapes, an absent approval id, or a \
non-empty prev_chain_hash changed"
);
}
fn golden_content_shape() -> ChainContent<'static> {
ChainContent {
seq: 7,
key_id: CURRENT_KEY_ID,
transaction_id: "tx-golden",
request_id: "req-golden",
request_hash: "hash-golden",
action_name: "AptInstall",
risk_level: RiskLevel::Medium,
summary: "install ripgrep",
approval_id: Some("appr-golden"),
warnings_json: "[]",
created_at: "2026-07-29T00:00:00Z",
identity: ChainIdentity::LegacyV1,
}
}
fn golden_row_shape() -> ChainRow {
ChainRow {
seq: 7,
key_id: CURRENT_KEY_ID.to_string(),
transaction_id: "tx-golden".to_string(),
request_id: "req-golden".to_string(),
request_hash: "hash-golden".to_string(),
action_name: "AptInstall".to_string(),
risk_level: RiskLevel::Medium,
summary: "install ripgrep".to_string(),
approval_id: Some("appr-golden".to_string()),
warnings_json: "[]".to_string(),
created_at: "2026-07-29T00:00:00Z".to_string(),
prev_chain_hash: String::new(),
chain_hash: String::new(),
chain_version: 1,
caller_role: None,
event_tip: None,
caller_principal: None,
}
}
#[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,
},
attribution: None,
};
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(),
},
attribution: None,
};
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,
},
attribution: None,
};
assert_eq!(verification.exit_code(), 0);
}
#[test]
fn binding_exit_codes_split_clean_tampered_and_unchecked() {
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
);
assert_eq!(binding_outcome_to_exit_code(&BindingOutcome::NotChecked), 2);
}
#[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 {
caller_principal: Some("uid:1000".to_string()),
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")),
);
}
}