1use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
118use hmac::{Hmac, KeyInit, Mac};
119use sha2::{Digest, Sha256};
120use std::path::{Path, PathBuf};
121use sysknife_types::RiskLevel;
122use zeroize::{Zeroize, Zeroizing};
123
124pub const CURRENT_KEY_ID: &str = "v1";
127
128pub const HASH_HEX_LEN: usize = 128;
130
131const ROW_DOMAIN: &[u8] = b"sysknife-audit-row-v1\x1f";
135const CHECKPOINT_DOMAIN: &[u8] = b"sysknife-checkpoint-v1\x1f";
136const APPROVAL_DOMAIN: &[u8] = b"sysknife-approval-receipt-v1\x1f";
137const EVENT_DOMAIN: &[u8] = b"sysknife-audit-event-v1\x1f";
138const REQUEST_HASH_DOMAIN: &[u8] = b"sysknife-request-hash-v1\x1f";
143
144pub const CHAIN_VERSION_LEGACY: u32 = 1;
147pub const CHAIN_VERSION_V2: u32 = 2;
157pub const CHAIN_VERSION_V3: u32 = 3;
165pub const CHAIN_VERSION_V4: u32 = 4;
189pub const CHAIN_VERSION_CURRENT: u32 = CHAIN_VERSION_V4;
196
197pub const EVENT_VERSION_LEGACY: u32 = 1;
201pub const EVENT_VERSION_V2: u32 = 2;
210pub const EVENT_VERSION_CURRENT: u32 = EVENT_VERSION_V2;
214
215#[derive(Clone)]
227pub struct AuditKey {
228 key_id: String,
229 signing: SigningKey,
230}
231
232impl std::fmt::Debug for AuditKey {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 f.debug_struct("AuditKey")
239 .field("key_id", &self.key_id)
240 .field("signing", &format_args!("<redacted signing key>"))
241 .finish()
242 }
243}
244
245#[derive(Debug, thiserror::Error)]
250pub enum AuditKeyError {
251 #[error("io error reading audit key {path}: {source}")]
252 Io {
253 path: PathBuf,
254 #[source]
255 source: std::io::Error,
256 },
257 #[error(
258 "audit key file {path} has unsafe permissions {mode:#o}; \
259 tighten with `chmod 600 {path:?}` and restart"
260 )]
261 UnsafePermissions { path: PathBuf, mode: u32 },
262 #[error(
263 "audit key file {path} is too short ({len} bytes); \
264 expected at least 32 bytes of random material"
265 )]
266 KeyTooShort { path: PathBuf, len: usize },
267}
268
269impl AuditKey {
270 pub fn load_or_generate(path: &Path) -> Result<Self, AuditKeyError> {
277 if !path.exists() {
278 generate_key_at(path)?;
279 }
280
281 let metadata = std::fs::metadata(path).map_err(|e| AuditKeyError::Io {
282 path: path.to_path_buf(),
283 source: e,
284 })?;
285
286 use std::os::unix::fs::PermissionsExt;
287 let mode = metadata.permissions().mode() & 0o777;
288 if mode & 0o077 != 0 {
289 return Err(AuditKeyError::UnsafePermissions {
291 path: path.to_path_buf(),
292 mode,
293 });
294 }
295
296 let mut key_bytes = std::fs::read(path).map_err(|e| AuditKeyError::Io {
297 path: path.to_path_buf(),
298 source: e,
299 })?;
300 if key_bytes.len() < 32 {
301 return Err(AuditKeyError::KeyTooShort {
302 path: path.to_path_buf(),
303 len: key_bytes.len(),
304 });
305 }
306
307 let mut seed = [0u8; 32];
308 seed.copy_from_slice(&key_bytes[..32]);
309 let signing = SigningKey::from_bytes(&seed);
310 seed.zeroize();
311 key_bytes.zeroize();
312
313 Ok(Self {
314 key_id: CURRENT_KEY_ID.to_string(),
315 signing,
316 })
317 }
318
319 #[cfg(test)]
322 pub fn from_bytes(bytes: Vec<u8>) -> Self {
323 let mut seed = [0u8; 32];
324 seed.copy_from_slice(&bytes[..32]);
325 let signing = SigningKey::from_bytes(&seed);
326 seed.zeroize();
327 Self {
328 key_id: CURRENT_KEY_ID.to_string(),
329 signing,
330 }
331 }
332
333 pub fn key_id(&self) -> &str {
335 &self.key_id
336 }
337
338 pub fn request_hash_key(&self) -> RequestHashKey {
346 RequestHashKey::derive_from(self.signing.as_bytes())
347 }
348
349 pub fn chain_hash(&self, content: &ChainContent, prev_chain_hash: &str) -> String {
354 let sig = self.signing.sign(&chain_message(content, prev_chain_hash));
355 hex::encode(sig.to_bytes())
356 }
357
358 pub fn verifying_key(&self) -> VerifyingKey {
360 self.signing.verifying_key()
361 }
362
363 pub fn verifying_key_hex(&self) -> String {
366 hex::encode(self.signing.verifying_key().to_bytes())
367 }
368
369 pub fn approval_receipt(&self, transaction_id: &str, request_hash: &str) -> String {
373 let mut message = APPROVAL_DOMAIN.to_vec();
379 push_field(&mut message, "txid", transaction_id);
380 push_field(&mut message, "reqhash", request_hash);
381 hex::encode(self.signing.sign(&message).to_bytes())
382 }
383
384 pub fn approval_commitment(&self, transaction_id: &str, request_hash: &str) -> String {
386 approval_receipt_digest(&self.approval_receipt(transaction_id, request_hash))
387 }
388}
389
390pub fn approval_receipt_digest(receipt: &str) -> String {
391 hex::encode(Sha256::digest(receipt.as_bytes()))
392}
393
394fn chain_message(content: &ChainContent, prev_chain_hash: &str) -> Vec<u8> {
400 let mut msg = ROW_DOMAIN.to_vec();
401 msg.extend_from_slice(&content.canonical_bytes());
402 msg.extend_from_slice(prev_chain_hash.as_bytes());
403 msg
404}
405
406pub fn resolve_audit_key_path(db_path: &Path) -> PathBuf {
412 std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
413 .map(PathBuf::from)
414 .unwrap_or_else(|_| {
415 db_path
416 .parent()
417 .unwrap_or_else(|| Path::new("."))
418 .join("audit-key")
419 })
420}
421
422#[derive(Clone)]
437pub struct RequestHashKey {
438 key: Zeroizing<[u8; 32]>,
439}
440
441impl std::fmt::Debug for RequestHashKey {
442 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
443 f.debug_struct("RequestHashKey")
444 .field("key", &format_args!("<redacted hmac key>"))
445 .finish()
446 }
447}
448
449impl RequestHashKey {
450 fn derive_from(ikm: &[u8]) -> Self {
453 use hkdf::Hkdf;
454 let hk = Hkdf::<Sha256>::new(None, ikm);
455 let mut okm = Zeroizing::new([0u8; 32]);
456 hk.expand(REQUEST_HASH_DOMAIN, &mut *okm)
459 .expect("32-byte HKDF-SHA256 expansion is infallible");
460 Self { key: okm }
461 }
462
463 pub fn request_hash(&self, action_name: &str, canonical_params: &str) -> String {
473 let mut mac = Hmac::<Sha256>::new_from_slice(&self.key[..])
474 .expect("HMAC-SHA256 accepts any key length");
475 mac.update(action_name.as_bytes());
476 mac.update(b"\x00");
477 mac.update(canonical_params.as_bytes());
478 hex::encode(mac.finalize().into_bytes())
479 }
480}
481
482fn generate_key_at(path: &Path) -> Result<(), AuditKeyError> {
485 use std::io::Write as _;
486 use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
487
488 if let Some(parent) = path.parent() {
489 if !parent.exists() {
490 std::fs::DirBuilder::new()
491 .recursive(true)
492 .mode(0o700)
493 .create(parent)
494 .map_err(|e| AuditKeyError::Io {
495 path: parent.to_path_buf(),
496 source: e,
497 })?;
498 }
499 }
500
501 let mut bytes = [0u8; 32];
502 fill_random(&mut bytes).map_err(|e| AuditKeyError::Io {
503 path: path.to_path_buf(),
504 source: e,
505 })?;
506
507 let mut f = std::fs::OpenOptions::new()
508 .write(true)
509 .create_new(true)
510 .mode(0o600)
511 .open(path)
512 .map_err(|e| AuditKeyError::Io {
513 path: path.to_path_buf(),
514 source: e,
515 })?;
516 f.write_all(&bytes).map_err(|e| AuditKeyError::Io {
517 path: path.to_path_buf(),
518 source: e,
519 })?;
520 f.sync_all().map_err(|e| AuditKeyError::Io {
521 path: path.to_path_buf(),
522 source: e,
523 })?;
524
525 let signing = SigningKey::from_bytes(&bytes);
528 let pub_hex = hex::encode(signing.verifying_key().to_bytes());
529 bytes.zeroize();
530 let mut pub_path = path.as_os_str().to_os_string();
531 pub_path.push(".pub");
532 std::fs::write(&pub_path, format!("{pub_hex}\n")).map_err(|e| AuditKeyError::Io {
533 path: PathBuf::from(pub_path),
534 source: e,
535 })?;
536 Ok(())
537}
538
539fn fill_random(buf: &mut [u8]) -> std::io::Result<()> {
541 use std::io::Read as _;
542 let mut f = std::fs::File::open("/dev/urandom")?;
543 f.read_exact(buf)
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
609pub enum ChainIdentity<'a> {
610 LegacyV1,
612 V2 {
615 caller_role: &'a str,
620 event_tip: &'a str,
626 },
627 V3 {
634 caller_role: &'a str,
636 event_tip: &'a str,
638 caller_principal: &'a str,
649 },
650 V4 {
657 caller_role: &'a str,
659 event_tip: &'a str,
661 caller_principal: &'a str,
663 },
664}
665
666impl ChainIdentity<'_> {
667 pub fn version(&self) -> u32 {
669 match self {
670 Self::LegacyV1 => CHAIN_VERSION_LEGACY,
671 Self::V2 { .. } => CHAIN_VERSION_V2,
672 Self::V3 { .. } => CHAIN_VERSION_V3,
673 Self::V4 { .. } => CHAIN_VERSION_V4,
674 }
675 }
676}
677
678#[derive(Debug, Clone, PartialEq, Eq)]
679pub struct ChainContent<'a> {
680 pub seq: u64,
681 pub key_id: &'a str,
682 pub transaction_id: &'a str,
683 pub request_id: &'a str,
684 pub request_hash: &'a str,
685 pub action_name: &'a str,
686 pub risk_level: RiskLevel,
687 pub summary: &'a str,
696 pub approval_id: Option<&'a str>,
699 pub warnings_json: &'a str,
701 pub created_at: &'a str,
702 pub identity: ChainIdentity<'a>,
704}
705
706impl<'a> ChainContent<'a> {
707 pub fn canonical_bytes(&self) -> Vec<u8> {
711 let risk_level_str = match self.risk_level {
712 RiskLevel::Low => "low",
713 RiskLevel::Medium => "medium",
714 RiskLevel::High => "high",
715 };
716 let approval = self.approval_id.unwrap_or("");
717
718 let mut buf = Vec::with_capacity(512);
719 push_field(&mut buf, "seq", &self.seq.to_string());
722 push_field(&mut buf, "key_id", self.key_id);
723 push_field(&mut buf, "transaction_id", self.transaction_id);
724 push_field(&mut buf, "request_id", self.request_id);
725 push_field(&mut buf, "request_hash", self.request_hash);
726 push_field(&mut buf, "action_name", self.action_name);
727 push_field(&mut buf, "risk_level", risk_level_str);
728 push_field(&mut buf, "summary", self.summary);
729 push_field(&mut buf, "approval_id", approval);
730 push_field(&mut buf, "warnings_json", self.warnings_json);
731 push_field(&mut buf, "created_at", self.created_at);
732 match self.identity {
748 ChainIdentity::LegacyV1 => {}
749 ChainIdentity::V2 {
750 caller_role,
751 event_tip,
752 } => {
753 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V2.to_string());
754 push_field(&mut buf, "caller_role", caller_role);
755 push_field(&mut buf, "event_tip", event_tip);
756 }
757 ChainIdentity::V3 {
758 caller_role,
759 event_tip,
760 caller_principal,
761 } => {
762 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V3.to_string());
763 push_field(&mut buf, "caller_role", caller_role);
764 push_field(&mut buf, "event_tip", event_tip);
765 push_field(&mut buf, "caller_principal", caller_principal);
766 }
767 ChainIdentity::V4 {
768 caller_role,
769 event_tip,
770 caller_principal,
771 } => {
772 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V4.to_string());
773 push_field(&mut buf, "caller_role", caller_role);
774 push_field(&mut buf, "event_tip", event_tip);
775 push_field(&mut buf, "caller_principal", caller_principal);
776 }
777 }
778 buf
779 }
780}
781
782fn push_field(buf: &mut Vec<u8>, tag: &str, value: &str) {
800 buf.extend_from_slice(tag.as_bytes());
801 buf.push(0x1E); for b in value.bytes() {
803 match b {
804 b'\\' => buf.extend_from_slice(b"\\\\"),
805 0x00 => buf.extend_from_slice(b"\\0"),
806 0x1E => buf.extend_from_slice(b"\\1E"),
807 0x1F => buf.extend_from_slice(b"\\1F"),
808 other => buf.push(other),
809 }
810 }
811 buf.push(0x1F); }
813
814#[derive(Debug, Clone, PartialEq, Eq)]
816pub enum VerifyOutcome {
817 Intact { rows_checked: u64 },
819 Broken {
821 rows_checked: u64,
822 first_broken_seq: u64,
823 first_broken_transaction_id: String,
824 expected: String,
825 actual: String,
826 },
827 CannotVerify { reason: String },
831}
832
833pub fn outcome_to_exit_code(outcome: &VerifyOutcome) -> i32 {
839 match outcome {
840 VerifyOutcome::Intact { .. } => 0,
841 VerifyOutcome::Broken { .. } => 1,
842 VerifyOutcome::CannotVerify { .. } => 2,
843 }
844}
845
846#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
848pub struct ChainRow {
849 pub seq: u64,
850 pub key_id: String,
851 pub transaction_id: String,
852 pub request_id: String,
853 pub request_hash: String,
854 pub action_name: String,
855 pub risk_level: RiskLevel,
856 pub summary: String,
857 pub approval_id: Option<String>,
858 pub warnings_json: String,
859 pub created_at: String,
860 pub prev_chain_hash: String,
861 pub chain_hash: String,
862 pub chain_version: u32,
864 pub caller_role: Option<String>,
866 pub event_tip: Option<String>,
869 pub caller_principal: Option<String>,
873}
874
875#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
877pub enum ChainExportError {
878 #[error("--since: {0:?} is not a valid RFC 3339 timestamp")]
879 InvalidSince(String),
880 #[error("audit row {seq} has an invalid RFC 3339 created_at value {created_at:?}")]
881 InvalidCreatedAt { seq: u64, created_at: String },
882}
883
884pub fn validate_export_since(since: &str) -> Result<(), ChainExportError> {
886 chrono::DateTime::parse_from_rfc3339(since)
887 .map(|_| ())
888 .map_err(|_| ChainExportError::InvalidSince(since.to_string()))
889}
890
891pub fn select_chain_rows_for_export(
898 rows: Vec<ChainRow>,
899 since: Option<&str>,
900 limit: Option<u32>,
901) -> Result<Vec<ChainRow>, ChainExportError> {
902 let since = since
903 .map(|value| {
904 chrono::DateTime::parse_from_rfc3339(value)
905 .map_err(|_| ChainExportError::InvalidSince(value.to_string()))
906 })
907 .transpose()?;
908 let limit = limit.map_or(usize::MAX, |value| value as usize);
909
910 rows.into_iter()
911 .filter_map(|row| {
912 let keep = match since.as_ref() {
913 None => true,
914 Some(since) => match chrono::DateTime::parse_from_rfc3339(&row.created_at) {
915 Ok(created_at) => created_at >= *since,
916 Err(_) => {
917 return Some(Err(ChainExportError::InvalidCreatedAt {
918 seq: row.seq,
919 created_at: row.created_at,
920 }));
921 }
922 },
923 };
924 keep.then_some(Ok(row))
925 })
926 .take(limit)
927 .collect()
928}
929
930#[derive(Debug)]
932pub(crate) enum RowIdentityError {
933 UnknownVersion(u32),
937 MissingField(&'static str),
943}
944
945impl ChainRow {
946 pub(crate) fn identity(&self) -> Result<ChainIdentity<'_>, RowIdentityError> {
949 match self.chain_version {
950 CHAIN_VERSION_LEGACY => Ok(ChainIdentity::LegacyV1),
951 CHAIN_VERSION_V2 => Ok(ChainIdentity::V2 {
952 caller_role: self
953 .caller_role
954 .as_deref()
955 .ok_or(RowIdentityError::MissingField("caller_role"))?,
956 event_tip: self
957 .event_tip
958 .as_deref()
959 .ok_or(RowIdentityError::MissingField("event_tip"))?,
960 }),
961 CHAIN_VERSION_V3 => Ok(ChainIdentity::V3 {
962 caller_role: self
963 .caller_role
964 .as_deref()
965 .ok_or(RowIdentityError::MissingField("caller_role"))?,
966 event_tip: self
967 .event_tip
968 .as_deref()
969 .ok_or(RowIdentityError::MissingField("event_tip"))?,
970 caller_principal: self
974 .caller_principal
975 .as_deref()
976 .filter(|p| !p.is_empty())
977 .ok_or(RowIdentityError::MissingField("caller_principal"))?,
978 }),
979 CHAIN_VERSION_V4 => Ok(ChainIdentity::V4 {
980 caller_role: self
981 .caller_role
982 .as_deref()
983 .ok_or(RowIdentityError::MissingField("caller_role"))?,
984 event_tip: self
985 .event_tip
986 .as_deref()
987 .ok_or(RowIdentityError::MissingField("event_tip"))?,
988 caller_principal: self
990 .caller_principal
991 .as_deref()
992 .filter(|p| !p.is_empty())
993 .ok_or(RowIdentityError::MissingField("caller_principal"))?,
994 }),
995 other => Err(RowIdentityError::UnknownVersion(other)),
996 }
997 }
998}
999
1000pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
1004 verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
1005}
1006
1007pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
1012 match parse_verifying_key(verifying_key_hex) {
1013 Some(vk) => verify_rows(&vk, None, rows),
1014 None => VerifyOutcome::CannotVerify {
1015 reason: format!(
1016 "invalid public key hex ({} chars); expected 64 hex chars of a \
1017 32-byte Ed25519 public key",
1018 verifying_key_hex.len()
1019 ),
1020 },
1021 }
1022}
1023
1024fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
1025 let bytes = hex::decode(hex_str.trim()).ok()?;
1026 let arr: [u8; 32] = bytes.try_into().ok()?;
1027 VerifyingKey::from_bytes(&arr).ok()
1028}
1029
1030fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
1033 let Ok(bytes) = hex::decode(sig_hex) else {
1034 return false;
1035 };
1036 let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
1037 return false;
1038 };
1039 vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
1040}
1041
1042enum RowCheck {
1045 Verified,
1047 Unverifiable(String),
1051 Broken { expected: String, actual: String },
1053}
1054
1055trait ChainLink {
1057 fn seq(&self) -> u64;
1058 fn transaction_id(&self) -> &str;
1059 fn prev_chain_hash(&self) -> &str;
1060 fn chain_hash(&self) -> &str;
1061}
1062
1063impl ChainLink for ChainRow {
1064 fn seq(&self) -> u64 {
1065 self.seq
1066 }
1067 fn transaction_id(&self) -> &str {
1068 &self.transaction_id
1069 }
1070 fn prev_chain_hash(&self) -> &str {
1071 &self.prev_chain_hash
1072 }
1073 fn chain_hash(&self) -> &str {
1074 &self.chain_hash
1075 }
1076}
1077
1078impl ChainLink for EventRow {
1079 fn seq(&self) -> u64 {
1080 self.seq
1081 }
1082 fn transaction_id(&self) -> &str {
1083 &self.transaction_id
1084 }
1085 fn prev_chain_hash(&self) -> &str {
1086 &self.prev_chain_hash
1087 }
1088 fn chain_hash(&self) -> &str {
1089 &self.chain_hash
1090 }
1091}
1092
1093fn walk_chain<R: ChainLink>(rows: &[R], mut check: impl FnMut(&R) -> RowCheck) -> VerifyOutcome {
1108 let mut last_hash = "";
1109 let mut rows_checked = 0u64;
1110 let mut first_unverifiable: Option<String> = None;
1111 let mut unverifiable = 0u64;
1112 for row in rows {
1113 if row.prev_chain_hash() != last_hash {
1114 return VerifyOutcome::Broken {
1115 rows_checked,
1116 first_broken_seq: row.seq(),
1117 first_broken_transaction_id: row.transaction_id().to_string(),
1118 expected: format!("prev_chain_hash={last_hash}"),
1119 actual: format!("prev_chain_hash={}", row.prev_chain_hash()),
1120 };
1121 }
1122 match check(row) {
1123 RowCheck::Verified => rows_checked += 1,
1124 RowCheck::Unverifiable(reason) => {
1125 unverifiable += 1;
1126 first_unverifiable.get_or_insert(reason);
1127 }
1128 RowCheck::Broken { expected, actual } => {
1129 return VerifyOutcome::Broken {
1130 rows_checked,
1131 first_broken_seq: row.seq(),
1132 first_broken_transaction_id: row.transaction_id().to_string(),
1133 expected,
1134 actual,
1135 };
1136 }
1137 }
1138 last_hash = row.chain_hash();
1139 }
1140 match first_unverifiable {
1141 None => VerifyOutcome::Intact { rows_checked },
1142 Some(first) => VerifyOutcome::CannotVerify {
1143 reason: format!(
1144 "{first}; {unverifiable} row(s) could not be checked, \
1145 {rows_checked} other row(s) verified and every link is intact"
1146 ),
1147 },
1148 }
1149}
1150
1151fn failed_signature(
1157 what: &str,
1158 seq: u64,
1159 key_id: &str,
1160 expect_key_id: Option<&str>,
1161 chain_hash: &str,
1162) -> RowCheck {
1163 match expect_key_id {
1164 Some(kid) if key_id != kid => RowCheck::Unverifiable(format!(
1165 "{what} seq={seq} uses key_id={key_id:?} but only {kid:?} is loaded, \
1166 and the loaded key does not reproduce its signature; epoch keys not yet supported"
1167 )),
1168 _ => RowCheck::Broken {
1169 expected: "valid ed25519 signature".to_string(),
1170 actual: chain_hash.to_string(),
1171 },
1172 }
1173}
1174
1175fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
1180 walk_chain(rows, |row| {
1181 let identity = match row.identity() {
1182 Ok(identity) => identity,
1183 Err(RowIdentityError::UnknownVersion(v)) => {
1184 return RowCheck::Unverifiable(format!(
1185 "row seq={} declares chain_version={v}, which this binary cannot \
1186 reproduce (it understands {CHAIN_VERSION_LEGACY}..={CHAIN_VERSION_CURRENT}); \
1187 verify with a build at least as new as the one that wrote the chain",
1188 row.seq
1189 ));
1190 }
1191 Err(RowIdentityError::MissingField(field)) => {
1192 let stored = match field {
1197 "caller_role" => row.caller_role.as_deref(),
1198 "event_tip" => row.event_tip.as_deref(),
1199 "caller_principal" => row.caller_principal.as_deref(),
1200 _ => None,
1201 };
1202 return RowCheck::Broken {
1203 expected: format!(
1204 "chain_version={} row carrying a non-empty {field}",
1205 row.chain_version
1206 ),
1207 actual: match stored {
1210 None => format!("{field}=NULL"),
1211 Some("") => format!("{field}='' (empty, not NULL)"),
1212 Some(other) => format!("{field}={other:?}"),
1213 },
1214 };
1215 }
1216 };
1217 let content = ChainContent {
1218 seq: row.seq,
1219 key_id: &row.key_id,
1220 transaction_id: &row.transaction_id,
1221 request_id: &row.request_id,
1222 request_hash: &row.request_hash,
1223 action_name: &row.action_name,
1224 risk_level: row.risk_level,
1225 summary: &row.summary,
1226 approval_id: row.approval_id.as_deref(),
1227 warnings_json: &row.warnings_json,
1228 created_at: &row.created_at,
1229 identity,
1230 };
1231 let msg = chain_message(&content, &row.prev_chain_hash);
1232 if signature_ok(vk, &msg, &row.chain_hash) {
1233 RowCheck::Verified
1234 } else {
1235 failed_signature("row", row.seq, &row.key_id, expect_key_id, &row.chain_hash)
1236 }
1237 })
1238}
1239
1240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1253pub enum AuditEventKind {
1254 ApprovalGranted,
1256 ApprovalConsumed,
1258 ApprovalRevoked,
1260 StatusQueued,
1269 StatusRunning,
1270 StatusSucceeded,
1271 StatusFailed,
1272 StatusCanceled,
1273 StatusRolledBack,
1274 StatusNeedsReboot,
1275}
1276
1277impl From<sysknife_types::JobState> for AuditEventKind {
1278 fn from(state: sysknife_types::JobState) -> Self {
1279 use sysknife_types::JobState as S;
1280 match state {
1281 S::Queued => Self::StatusQueued,
1282 S::Running => Self::StatusRunning,
1283 S::Succeeded => Self::StatusSucceeded,
1284 S::Failed => Self::StatusFailed,
1285 S::Canceled => Self::StatusCanceled,
1286 S::RolledBack => Self::StatusRolledBack,
1287 S::NeedsReboot => Self::StatusNeedsReboot,
1288 }
1289 }
1290}
1291
1292impl AuditEventKind {
1293 pub const ALL: &'static [AuditEventKind] = &[
1304 Self::ApprovalGranted,
1305 Self::ApprovalConsumed,
1306 Self::ApprovalRevoked,
1307 Self::StatusQueued,
1308 Self::StatusRunning,
1309 Self::StatusSucceeded,
1310 Self::StatusFailed,
1311 Self::StatusCanceled,
1312 Self::StatusRolledBack,
1313 Self::StatusNeedsReboot,
1314 ];
1315
1316 pub fn as_str(&self) -> &'static str {
1319 match self {
1320 Self::ApprovalGranted => "approval_granted",
1321 Self::ApprovalConsumed => "approval_consumed",
1322 Self::ApprovalRevoked => "approval_revoked",
1323 Self::StatusQueued => "status_queued",
1324 Self::StatusRunning => "status_running",
1325 Self::StatusSucceeded => "status_succeeded",
1326 Self::StatusFailed => "status_failed",
1327 Self::StatusCanceled => "status_canceled",
1328 Self::StatusRolledBack => "status_rolled_back",
1329 Self::StatusNeedsReboot => "status_needs_reboot",
1330 }
1331 }
1332}
1333
1334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1344pub enum EventIdentity<'a> {
1345 LegacyV1,
1349 V2 {
1356 caller_principal: &'a str,
1362 },
1363}
1364
1365impl EventIdentity<'_> {
1366 pub fn version(&self) -> u32 {
1368 match self {
1369 Self::LegacyV1 => EVENT_VERSION_LEGACY,
1370 Self::V2 { .. } => EVENT_VERSION_V2,
1371 }
1372 }
1373}
1374
1375#[derive(Debug, Clone, PartialEq, Eq)]
1377pub struct EventContent<'a> {
1378 pub seq: u64,
1379 pub key_id: &'a str,
1380 pub kind: AuditEventKind,
1381 pub transaction_id: &'a str,
1382 pub receipt_digest: &'a str,
1386 pub created_at: &'a str,
1387 pub identity: EventIdentity<'a>,
1389}
1390
1391impl EventContent<'_> {
1392 pub fn canonical_bytes(&self) -> Vec<u8> {
1395 let mut buf = Vec::with_capacity(256);
1396 push_field(&mut buf, "seq", &self.seq.to_string());
1397 push_field(&mut buf, "key_id", self.key_id);
1398 push_field(&mut buf, "kind", self.kind.as_str());
1399 push_field(&mut buf, "transaction_id", self.transaction_id);
1400 push_field(&mut buf, "receipt_digest", self.receipt_digest);
1401 push_field(&mut buf, "created_at", self.created_at);
1402 match self.identity {
1408 EventIdentity::LegacyV1 => {}
1409 EventIdentity::V2 { caller_principal } => {
1410 push_field(&mut buf, "event_version", &EVENT_VERSION_V2.to_string());
1411 push_field(&mut buf, "caller_principal", caller_principal);
1412 }
1413 }
1414 buf
1415 }
1416}
1417
1418fn event_message(content: &EventContent, prev_chain_hash: &str) -> Vec<u8> {
1419 let mut msg = EVENT_DOMAIN.to_vec();
1420 msg.extend_from_slice(&content.canonical_bytes());
1421 msg.extend_from_slice(prev_chain_hash.as_bytes());
1422 msg
1423}
1424
1425impl AuditKey {
1426 pub fn event_hash(&self, content: &EventContent, prev_chain_hash: &str) -> String {
1430 hex::encode(
1431 self.signing
1432 .sign(&event_message(content, prev_chain_hash))
1433 .to_bytes(),
1434 )
1435 }
1436}
1437
1438#[derive(Debug, Clone, PartialEq, Eq)]
1440pub struct EventRow {
1441 pub seq: u64,
1442 pub key_id: String,
1443 pub kind: String,
1447 pub transaction_id: String,
1448 pub receipt_digest: String,
1449 pub created_at: String,
1450 pub prev_chain_hash: String,
1451 pub chain_hash: String,
1452 pub chain_version: u32,
1455 pub caller_principal: Option<String>,
1461}
1462
1463impl EventRow {
1464 pub(crate) fn identity(&self) -> Result<EventIdentity<'_>, RowIdentityError> {
1468 match self.chain_version {
1469 EVENT_VERSION_LEGACY => Ok(EventIdentity::LegacyV1),
1470 EVENT_VERSION_V2 => Ok(EventIdentity::V2 {
1471 caller_principal: self
1472 .caller_principal
1473 .as_deref()
1474 .filter(|p| !p.is_empty())
1475 .ok_or(RowIdentityError::MissingField("caller_principal"))?,
1476 }),
1477 other => Err(RowIdentityError::UnknownVersion(other)),
1478 }
1479 }
1480}
1481
1482pub fn verify_event_chain(key: &AuditKey, rows: &[EventRow]) -> VerifyOutcome {
1484 verify_event_rows(&key.verifying_key(), Some(key.key_id()), rows)
1485}
1486
1487pub fn verify_event_chain_with_pubkey(verifying_key_hex: &str, rows: &[EventRow]) -> VerifyOutcome {
1489 match parse_verifying_key(verifying_key_hex) {
1490 Some(vk) => verify_event_rows(&vk, None, rows),
1491 None => VerifyOutcome::CannotVerify {
1492 reason: format!(
1493 "invalid public key hex ({} chars); expected 64 hex chars of a \
1494 32-byte Ed25519 public key",
1495 verifying_key_hex.len()
1496 ),
1497 },
1498 }
1499}
1500
1501fn verify_event_rows(
1502 vk: &VerifyingKey,
1503 expect_key_id: Option<&str>,
1504 rows: &[EventRow],
1505) -> VerifyOutcome {
1506 walk_chain(rows, |row| {
1507 let Some(kind) = parse_event_kind(&row.kind) else {
1510 return RowCheck::Broken {
1511 expected: "a known event kind".to_string(),
1512 actual: format!("kind={:?}", row.kind),
1513 };
1514 };
1515 let identity = match row.identity() {
1521 Ok(identity) => identity,
1522 Err(RowIdentityError::UnknownVersion(v)) => {
1523 return RowCheck::Unverifiable(format!(
1524 "event seq={} declares chain_version={v}, which this binary cannot \
1525 reproduce (it understands {EVENT_VERSION_LEGACY}..={EVENT_VERSION_CURRENT}); \
1526 verify with a build at least as new as the one that wrote the chain",
1527 row.seq
1528 ));
1529 }
1530 Err(RowIdentityError::MissingField(field)) => {
1531 return RowCheck::Broken {
1532 expected: format!(
1533 "a non-empty {field} on a chain_version={} row",
1534 row.chain_version
1535 ),
1536 actual: format!("{field}={:?}", row.caller_principal),
1537 };
1538 }
1539 };
1540 let content = EventContent {
1541 seq: row.seq,
1542 key_id: &row.key_id,
1543 kind,
1544 transaction_id: &row.transaction_id,
1545 receipt_digest: &row.receipt_digest,
1546 created_at: &row.created_at,
1547 identity,
1548 };
1549 if signature_ok(
1550 vk,
1551 &event_message(&content, &row.prev_chain_hash),
1552 &row.chain_hash,
1553 ) {
1554 RowCheck::Verified
1555 } else {
1556 failed_signature(
1557 "event",
1558 row.seq,
1559 &row.key_id,
1560 expect_key_id,
1561 &row.chain_hash,
1562 )
1563 }
1564 })
1565}
1566
1567fn parse_event_kind(raw: &str) -> Option<AuditEventKind> {
1568 AuditEventKind::ALL
1569 .iter()
1570 .copied()
1571 .find(|kind| kind.as_str() == raw)
1572}
1573
1574#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1583#[serde(tag = "state", rename_all = "snake_case")]
1584pub enum StatusOutcome {
1585 Agrees { rows_checked: u64 },
1587 Disagrees { detail: String },
1589 CannotVerify { detail: String },
1594}
1595
1596#[derive(Debug, Clone)]
1598pub(crate) struct StatusEvidence {
1599 pub transaction_id: String,
1600 pub status: sysknife_types::JobState,
1602 pub newest_status_event: Option<String>,
1604 pub approval_consumed: bool,
1606 pub predates_status_events: bool,
1612}
1613
1614pub(crate) fn status_outcome(rows: impl IntoIterator<Item = StatusEvidence>) -> StatusOutcome {
1622 use sysknife_types::JobState;
1623 let mut disagreements = Vec::new();
1624 let mut unproven = Vec::new();
1625 let mut checked: u64 = 0;
1626 for row in rows {
1627 checked += 1;
1628 let StatusEvidence {
1629 transaction_id: id,
1630 status: state,
1631 newest_status_event,
1632 approval_consumed: consumed,
1633 predates_status_events,
1634 } = row;
1635 let Some(newest) = newest_status_event else {
1640 if state == JobState::Queued && consumed {
1641 disagreements.push(format!(
1642 "{id}: status is Queued but a chained approval_consumed event proves it left Queued"
1643 ));
1644 } else if matches!(state, JobState::Running | JobState::Canceled) {
1645 unproven.push(format!(
1646 "{id}: status is {state:?} and no status event was ever chained; \
1647 historical writes cannot establish the execution outcome"
1648 ));
1649 } else if state != JobState::Queued && predates_status_events {
1650 unproven.push(format!(
1651 "{id}: status is {state:?} on a row written before this store \
1652 chained status events; nothing signed records its outcome"
1653 ));
1654 } else if state != JobState::Queued {
1655 disagreements.push(format!(
1656 "{id}: status is {state:?} but no status event was ever chained; \
1657 terminal outcomes require a signed status event"
1658 ));
1659 }
1660 continue;
1661 };
1662 let expected = AuditEventKind::from(state).as_str();
1663 if newest != expected {
1664 disagreements.push(format!(
1665 "{id}: status column says {state:?} ({expected}) and the newest \
1666 chained status event is {newest}"
1667 ));
1668 }
1669 }
1670 if !disagreements.is_empty() {
1671 StatusOutcome::Disagrees {
1672 detail: disagreements.join("; "),
1673 }
1674 } else if !unproven.is_empty() {
1675 StatusOutcome::CannotVerify {
1676 detail: unproven.join("; "),
1677 }
1678 } else {
1679 StatusOutcome::Agrees {
1680 rows_checked: checked,
1681 }
1682 }
1683}
1684
1685pub fn status_outcome_to_exit_code(outcome: &StatusOutcome) -> i32 {
1689 match outcome {
1690 StatusOutcome::Agrees { .. } => 0,
1691 StatusOutcome::Disagrees { .. } => 1,
1692 StatusOutcome::CannotVerify { .. } => 2,
1693 }
1694}
1695
1696#[derive(Debug, Clone, PartialEq, Eq)]
1699pub enum BindingOutcome {
1700 Consistent { bindings_checked: u64 },
1702 NotChecked,
1705 MissingEvent {
1709 transaction_seq: u64,
1710 event_tip: String,
1711 },
1712}
1713
1714pub fn verify_event_binding(tx_rows: &[ChainRow], event_rows: &[EventRow]) -> BindingOutcome {
1724 let known: std::collections::HashSet<&str> = event_rows
1725 .iter()
1726 .map(|row| row.chain_hash.as_str())
1727 .collect();
1728 let mut bindings_checked = 0u64;
1729 for row in tx_rows {
1730 let Some(tip) = row.event_tip.as_deref() else {
1731 continue;
1732 };
1733 if tip.is_empty() {
1734 continue;
1735 }
1736 if !known.contains(tip) {
1737 return BindingOutcome::MissingEvent {
1738 transaction_seq: row.seq,
1739 event_tip: tip.to_string(),
1740 };
1741 }
1742 bindings_checked += 1;
1743 }
1744 BindingOutcome::Consistent { bindings_checked }
1745}
1746
1747#[derive(Debug, Clone, PartialEq, Eq)]
1754pub struct AuditVerification {
1755 pub chain: VerifyOutcome,
1756 pub events: VerifyOutcome,
1757 pub binding: BindingOutcome,
1758 pub attribution: Option<AttributionCensus>,
1774 pub status: Option<StatusOutcome>,
1783}
1784
1785#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1790enum RowStanding {
1791 NamesAccount,
1793 AttributionFailed,
1796 NotRecorded,
1800 Unattested,
1810}
1811
1812fn standing(row: &ChainRow) -> RowStanding {
1824 let stored = row.caller_principal.as_deref().filter(|p| !p.is_empty());
1832 match row.chain_version {
1833 CHAIN_VERSION_LEGACY | CHAIN_VERSION_V2 => match stored {
1834 None => RowStanding::NotRecorded,
1835 Some(_) => RowStanding::Unattested,
1836 },
1837 CHAIN_VERSION_V3 | CHAIN_VERSION_V4 => match stored {
1838 None => RowStanding::NotRecorded,
1839 Some(p) => match crate::auth::CallerPrincipal::classify(p) {
1840 crate::auth::PrincipalClaim::Account => RowStanding::NamesAccount,
1841 crate::auth::PrincipalClaim::AttributionFailed => RowStanding::AttributionFailed,
1842 crate::auth::PrincipalClaim::Unrecognized => RowStanding::Unattested,
1843 },
1844 },
1845 _ => RowStanding::Unattested,
1851 }
1852}
1853
1854#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1886pub struct AttributionCensus {
1887 named: u64,
1888 attribution_failed: u64,
1889 not_recorded: u64,
1890 unattested: u64,
1891 rows: u64,
1892}
1893
1894impl AttributionCensus {
1895 pub fn of(tx_rows: &[ChainRow]) -> Self {
1897 let mut census = Self {
1898 named: 0,
1899 attribution_failed: 0,
1900 not_recorded: 0,
1901 unattested: 0,
1902 rows: tx_rows.len() as u64,
1903 };
1904 for row in tx_rows {
1905 match standing(row) {
1906 RowStanding::NamesAccount => census.named += 1,
1907 RowStanding::AttributionFailed => census.attribution_failed += 1,
1908 RowStanding::NotRecorded => census.not_recorded += 1,
1909 RowStanding::Unattested => census.unattested += 1,
1910 }
1911 }
1912 census
1913 }
1914
1915 pub fn named(&self) -> u64 {
1921 self.named
1922 }
1923
1924 pub fn attribution_failed(&self) -> u64 {
1927 self.attribution_failed
1928 }
1929
1930 pub fn not_recorded(&self) -> u64 {
1933 self.not_recorded
1934 }
1935
1936 pub fn unattested(&self) -> u64 {
1939 self.unattested
1940 }
1941
1942 pub fn rows(&self) -> u64 {
1949 self.rows
1950 }
1951
1952 pub fn unnamed(&self) -> u64 {
1961 self.rows - self.named
1962 }
1963
1964 #[cfg(any(test, feature = "test-support"))]
1977 pub fn from_counts_for_tests(
1978 named: u64,
1979 attribution_failed: u64,
1980 not_recorded: u64,
1981 unattested: u64,
1982 ) -> Self {
1983 let rows = named
1984 .checked_add(attribution_failed)
1985 .and_then(|n| n.checked_add(not_recorded))
1986 .and_then(|n| n.checked_add(unattested))
1987 .expect("census counts must not overflow u64");
1988 Self {
1989 named,
1990 attribution_failed,
1991 not_recorded,
1992 unattested,
1993 rows,
1994 }
1995 }
1996}
1997
1998impl AuditVerification {
1999 pub fn exit_code(&self) -> i32 {
2005 let mut codes = vec![
2006 outcome_to_exit_code(&self.chain),
2007 outcome_to_exit_code(&self.events),
2008 binding_outcome_to_exit_code(&self.binding),
2009 ];
2010 if let Some(status) = &self.status {
2013 codes.push(status_outcome_to_exit_code(status));
2014 }
2015 if codes.contains(&1) {
2016 1
2017 } else if codes.contains(&2) {
2018 2
2019 } else {
2020 0
2021 }
2022 }
2023}
2024
2025pub fn verify_all(
2027 key: &AuditKey,
2028 tx_rows: &[ChainRow],
2029 event_rows: &[EventRow],
2030) -> AuditVerification {
2031 AuditVerification {
2032 chain: verify_chain(key, tx_rows),
2033 events: verify_event_chain(key, event_rows),
2034 binding: verify_event_binding(tx_rows, event_rows),
2035 attribution: Some(AttributionCensus::of(tx_rows)),
2036 status: None,
2037 }
2038}
2039
2040pub fn verify_all_with_pubkey(
2042 verifying_key_hex: &str,
2043 tx_rows: &[ChainRow],
2044 event_rows: &[EventRow],
2045) -> AuditVerification {
2046 AuditVerification {
2047 chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
2048 events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
2049 binding: verify_event_binding(tx_rows, event_rows),
2050 attribution: Some(AttributionCensus::of(tx_rows)),
2051 status: None,
2052 }
2053}
2054
2055pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
2059 match outcome {
2060 BindingOutcome::Consistent { .. } => 0,
2061 BindingOutcome::NotChecked => 2,
2062 BindingOutcome::MissingEvent { .. } => 1,
2063 }
2064}
2065
2066#[derive(Debug, Clone, PartialEq, Eq)]
2082pub struct Checkpoint {
2083 pub seq: u64,
2086 pub chain_tip: String,
2088 pub created_at: String,
2090 pub signature: String,
2092}
2093
2094fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
2097 let mut buf = CHECKPOINT_DOMAIN.to_vec();
2100 push_field(&mut buf, "seq", &seq.to_string());
2101 push_field(&mut buf, "chain_tip", chain_tip);
2102 push_field(&mut buf, "created_at", created_at);
2103 buf
2104}
2105
2106impl AuditKey {
2107 pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
2109 let sig = self
2110 .signing
2111 .sign(&checkpoint_message(seq, chain_tip, created_at));
2112 Checkpoint {
2113 seq,
2114 chain_tip: chain_tip.to_string(),
2115 created_at: created_at.to_string(),
2116 signature: hex::encode(sig.to_bytes()),
2117 }
2118 }
2119}
2120
2121#[derive(Debug, Clone, PartialEq, Eq)]
2123pub enum CheckpointOutcome {
2124 Consistent { checkpoints_checked: u64 },
2127 BadSignature { seq: u64 },
2129 Truncated {
2132 checkpoint_seq: u64,
2133 current_max_seq: u64,
2134 },
2135 TipMismatch {
2138 seq: u64,
2139 anchored: String,
2140 actual: String,
2141 },
2142 CannotVerify { reason: String },
2144}
2145
2146pub fn verify_checkpoints(
2152 verifying_key_hex: &str,
2153 rows: &[ChainRow],
2154 checkpoints: &[Checkpoint],
2155) -> CheckpointOutcome {
2156 let vk = match parse_verifying_key(verifying_key_hex) {
2157 Some(vk) => vk,
2158 None => {
2159 return CheckpointOutcome::CannotVerify {
2160 reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
2161 };
2162 }
2163 };
2164 let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
2167 let mut checked = 0u64;
2168 for cp in checkpoints {
2169 let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
2170 if !signature_ok(&vk, &msg, &cp.signature) {
2171 return CheckpointOutcome::BadSignature { seq: cp.seq };
2172 }
2173 match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
2174 Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
2175 Ok(idx) => {
2176 return CheckpointOutcome::TipMismatch {
2177 seq: cp.seq,
2178 anchored: cp.chain_tip.clone(),
2179 actual: rows[idx].chain_hash.clone(),
2180 };
2181 }
2182 Err(_) => {
2183 return CheckpointOutcome::Truncated {
2184 checkpoint_seq: cp.seq,
2185 current_max_seq,
2186 };
2187 }
2188 }
2189 checked += 1;
2190 }
2191 CheckpointOutcome::Consistent {
2192 checkpoints_checked: checked,
2193 }
2194}
2195
2196pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
2201 match outcome {
2202 CheckpointOutcome::Consistent { .. } => 0,
2203 CheckpointOutcome::BadSignature { .. }
2204 | CheckpointOutcome::Truncated { .. }
2205 | CheckpointOutcome::TipMismatch { .. } => 1,
2206 CheckpointOutcome::CannotVerify { .. } => 2,
2207 }
2208}
2209
2210#[cfg(test)]
2211mod tests {
2212 #[test]
2213 fn audit_key_env_resolution_is_centralised() {
2214 const INLINE_RESOLUTION: &str = "std::env::var(\"SYSKNIFE_AUDIT_KEY_PATH\")";
2215 let daemon_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
2216 let cli_src =
2217 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps/sysknife-cli/src");
2218
2219 let mut roots = vec![daemon_src.clone(), cli_src.clone()];
2220 let mut files_checked = 0;
2221 while let Some(dir) = roots.pop() {
2222 for entry in std::fs::read_dir(&dir).expect("source directory must be readable") {
2223 let path = entry.expect("source entry must be readable").path();
2224 if path.is_dir() {
2225 roots.push(path);
2226 continue;
2227 }
2228 if path.extension().and_then(|ext| ext.to_str()) != Some("rs")
2229 || path == daemon_src.join("audit_chain.rs")
2230 {
2231 continue;
2232 }
2233 files_checked += 1;
2234 let source = std::fs::read_to_string(&path).expect("Rust source must be readable");
2235 assert!(
2236 !source.contains(INLINE_RESOLUTION),
2237 "{} must resolve the audit key through resolve_audit_key_path",
2238 path.display()
2239 );
2240 }
2241 }
2242 assert!(
2243 files_checked >= 60,
2244 "source walk unexpectedly checked only {files_checked} Rust files"
2245 );
2246 }
2247
2248 #[test]
2256 fn every_variant_is_in_all() {
2257 fn spelling(kind: AuditEventKind) -> &'static str {
2258 match kind {
2259 AuditEventKind::ApprovalGranted => "approval_granted",
2260 AuditEventKind::ApprovalConsumed => "approval_consumed",
2261 AuditEventKind::ApprovalRevoked => "approval_revoked",
2262 AuditEventKind::StatusQueued => "status_queued",
2263 AuditEventKind::StatusRunning => "status_running",
2264 AuditEventKind::StatusSucceeded => "status_succeeded",
2265 AuditEventKind::StatusFailed => "status_failed",
2266 AuditEventKind::StatusCanceled => "status_canceled",
2267 AuditEventKind::StatusRolledBack => "status_rolled_back",
2268 AuditEventKind::StatusNeedsReboot => "status_needs_reboot",
2269 }
2270 }
2271 for kind in AuditEventKind::ALL {
2272 assert_eq!(
2273 spelling(*kind),
2274 kind.as_str(),
2275 "as_str and the exhaustive table disagree about {kind:?}"
2276 );
2277 assert_eq!(
2278 parse_event_kind(kind.as_str()),
2279 Some(*kind),
2280 "{kind:?} does not round-trip, so a row carrying it verifies as broken"
2281 );
2282 }
2283 for kind in AuditEventKind::ALL {
2286 assert!(
2287 AuditEventKind::ALL
2288 .iter()
2289 .any(|k| k.as_str() == spelling(*kind)),
2290 "{kind:?} has a spelling and is not reachable through ALL"
2291 );
2292 }
2293 assert!(
2294 parse_event_kind("not_a_kind").is_none(),
2295 "an unknown kind must not parse, or the check inspects nothing"
2296 );
2297 }
2298
2299 use super::*;
2300
2301 fn fixed_key() -> AuditKey {
2302 AuditKey::from_bytes(vec![0x42; 32])
2303 }
2304
2305 fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
2306 ChainContent {
2307 seq,
2308 key_id: CURRENT_KEY_ID,
2309 transaction_id: txid,
2310 request_id: "req-1",
2311 request_hash: "hash-abc",
2312 action_name: "UpdateSystem",
2313 risk_level: RiskLevel::High,
2314 summary: "Upgrade",
2315 approval_id: None,
2316 warnings_json: "[]",
2317 created_at: "2026-04-24T12:00:00Z",
2318 identity: ChainIdentity::V2 {
2319 caller_role: "Dev",
2320 event_tip: "",
2321 },
2322 }
2323 }
2324
2325 #[test]
2328 fn same_inputs_yield_same_hash() {
2329 let key = fixed_key();
2330 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
2331 let h2 = key.chain_hash(&sample_content(1, "txa"), "");
2332 assert_eq!(h1, h2);
2333 assert_eq!(h1.len(), HASH_HEX_LEN);
2334 }
2335
2336 #[test]
2337 fn different_seq_yields_different_hash() {
2338 let key = fixed_key();
2339 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
2340 let h2 = key.chain_hash(&sample_content(2, "txa"), "");
2341 assert_ne!(h1, h2);
2342 }
2343
2344 #[test]
2345 fn different_prev_hash_yields_different_hash() {
2346 let key = fixed_key();
2347 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
2348 let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
2349 assert_ne!(h1, h2);
2350 }
2351
2352 #[test]
2353 fn different_keys_yield_different_hashes() {
2354 let key1 = AuditKey::from_bytes(vec![0x01; 32]);
2355 let key2 = AuditKey::from_bytes(vec![0x02; 32]);
2356 let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
2357 let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
2358 assert_ne!(h1, h2);
2359 }
2360
2361 #[test]
2364 fn canonical_bytes_have_stable_field_order() {
2365 let c = sample_content(1, "txa");
2366 let bytes = c.canonical_bytes();
2367 let s = String::from_utf8_lossy(&bytes);
2368 let order = [
2370 "seq",
2371 "key_id",
2372 "transaction_id",
2373 "request_id",
2374 "request_hash",
2375 "action_name",
2376 "risk_level",
2377 "summary",
2378 "approval_id",
2379 "warnings_json",
2380 "created_at",
2381 ];
2382 let mut last_idx = 0;
2383 for tag in order {
2384 let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
2385 assert!(idx >= last_idx, "{tag} out of order");
2386 last_idx = idx;
2387 }
2388 }
2389
2390 #[test]
2391 fn nul_bytes_in_field_value_are_escaped() {
2392 let mut c = sample_content(1, "txa");
2393 let summary = "before\0after";
2394 c.summary = summary;
2395 let bytes = c.canonical_bytes();
2396 assert!(!bytes.contains(&0x00));
2398 assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
2399 }
2400
2401 #[test]
2407 fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
2408 let mut a = sample_content(1, "txa");
2409 a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
2411 b.summary = "before\0after"; assert_ne!(
2413 a.canonical_bytes(),
2414 b.canonical_bytes(),
2415 "backslash escape must run before NUL escape to prevent collision"
2416 );
2417 }
2418
2419 fn key_for_collision_test() -> AuditKey {
2420 AuditKey::from_bytes(vec![0xab; 32])
2421 }
2422
2423 #[test]
2424 fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
2425 let key = key_for_collision_test();
2426 let mut a = sample_content(1, "txa");
2427 a.summary = "before\\0after";
2428 let mut b = sample_content(1, "txa");
2429 b.summary = "before\0after";
2430 assert_ne!(
2431 key.chain_hash(&a, ""),
2432 key.chain_hash(&b, ""),
2433 "chain signature must distinguish escape from raw byte"
2434 );
2435 }
2436
2437 fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
2443 let (chain_version, caller_role, event_tip, caller_principal) = match content.identity {
2444 ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None, None),
2445 ChainIdentity::V2 {
2446 caller_role,
2447 event_tip,
2448 } => (
2449 CHAIN_VERSION_V2,
2450 Some(caller_role.to_string()),
2451 Some(event_tip.to_string()),
2452 None,
2453 ),
2454 ChainIdentity::V3 {
2455 caller_role,
2456 event_tip,
2457 caller_principal,
2458 } => (
2459 CHAIN_VERSION_V3,
2460 Some(caller_role.to_string()),
2461 Some(event_tip.to_string()),
2462 Some(caller_principal.to_string()),
2463 ),
2464 ChainIdentity::V4 {
2465 caller_role,
2466 event_tip,
2467 caller_principal,
2468 } => (
2469 CHAIN_VERSION_V4,
2470 Some(caller_role.to_string()),
2471 Some(event_tip.to_string()),
2472 Some(caller_principal.to_string()),
2473 ),
2474 };
2475 ChainRow {
2476 seq: content.seq,
2477 key_id: content.key_id.to_string(),
2478 transaction_id: content.transaction_id.to_string(),
2479 request_id: content.request_id.to_string(),
2480 request_hash: content.request_hash.to_string(),
2481 action_name: content.action_name.to_string(),
2482 risk_level: content.risk_level,
2483 summary: content.summary.to_string(),
2484 approval_id: content.approval_id.map(str::to_string),
2485 warnings_json: content.warnings_json.to_string(),
2486 created_at: content.created_at.to_string(),
2487 prev_chain_hash: prev.to_string(),
2488 chain_hash,
2489 chain_version,
2490 caller_role,
2491 event_tip,
2492 caller_principal,
2493 }
2494 }
2495
2496 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
2497 let mut rows = Vec::with_capacity(count);
2498 let mut prev = String::new();
2499 for i in 0..count {
2500 let seq = (i + 1) as u64;
2501 let txid = format!("tx{i}");
2502 let content = sample_content(seq, &txid);
2503 let hash = key.chain_hash(&content, &prev);
2504 rows.push(row_for(&content, &prev, hash.clone()));
2505 prev = hash;
2506 }
2507 rows
2508 }
2509
2510 #[test]
2511 fn exported_chain_row_json_round_trips_without_changing_the_signature() {
2512 let key = fixed_key();
2513 let row = build_chain(&key, 1).remove(0);
2514
2515 let json = serde_json::to_string(&row).expect("chain row serializes");
2516 let decoded: ChainRow = serde_json::from_str(&json).expect("chain row deserializes");
2517
2518 assert_eq!(decoded, row);
2519 assert_eq!(decoded.chain_hash.as_bytes(), row.chain_hash.as_bytes());
2520 }
2521
2522 #[test]
2523 fn exported_chain_row_uses_the_stored_column_contract_and_json_nulls() {
2524 let key = fixed_key();
2525 let mut row = build_chain(&key, 1).remove(0);
2526 row.approval_id = None;
2527 row.caller_role = None;
2528 row.event_tip = None;
2529 row.caller_principal = None;
2530
2531 let value = serde_json::to_value(&row).expect("chain row serializes");
2532 let object = value.as_object().expect("chain row is a JSON object");
2533 let keys: Vec<&str> = object.keys().map(String::as_str).collect();
2534
2535 assert_eq!(
2536 keys,
2537 vec![
2538 "action_name",
2539 "approval_id",
2540 "caller_principal",
2541 "caller_role",
2542 "chain_hash",
2543 "chain_version",
2544 "created_at",
2545 "event_tip",
2546 "key_id",
2547 "prev_chain_hash",
2548 "request_hash",
2549 "request_id",
2550 "risk_level",
2551 "seq",
2552 "summary",
2553 "transaction_id",
2554 "warnings_json",
2555 ]
2556 );
2557 for optional in [
2558 "approval_id",
2559 "caller_role",
2560 "event_tip",
2561 "caller_principal",
2562 ] {
2563 assert!(object[optional].is_null(), "{optional} must be JSON null");
2564 }
2565 assert!(!object.contains_key("argv"));
2566 assert!(!object.contains_key("outcome"));
2567 assert!(!object.contains_key("signature"));
2568 }
2569
2570 #[test]
2571 fn export_since_is_inclusive_and_limit_preserves_chain_order() {
2572 let key = fixed_key();
2573 let mut rows = build_chain(&key, 4);
2574 for (row, created_at) in rows.iter_mut().zip([
2575 "2026-08-19T09:00:00Z",
2576 "2026-08-19T10:00:00Z",
2577 "2026-08-19T11:00:00Z",
2578 "2026-08-19T12:00:00Z",
2579 ]) {
2580 row.created_at = created_at.to_string();
2581 }
2582
2583 let selected =
2584 select_chain_rows_for_export(rows, Some("2026-08-19T10:00:00+00:00"), Some(2)).unwrap();
2585
2586 assert_eq!(
2587 selected.iter().map(|row| row.seq).collect::<Vec<_>>(),
2588 [2, 3]
2589 );
2590 }
2591
2592 #[test]
2593 fn export_rejects_an_invalid_since_timestamp() {
2594 let error = select_chain_rows_for_export(Vec::new(), Some("last Tuesday"), None)
2595 .expect_err("invalid --since must fail before reading rows");
2596 assert!(matches!(error, ChainExportError::InvalidSince(_)));
2597 }
2598
2599 #[test]
2602 fn caller_role_is_covered_by_the_row_signature() {
2603 let key = fixed_key();
2607 let mut dev = sample_content(1, "txa");
2608 dev.identity = ChainIdentity::V2 {
2609 caller_role: "Dev",
2610 event_tip: "",
2611 };
2612 let mut boot = sample_content(1, "txa");
2613 boot.identity = ChainIdentity::V2 {
2614 caller_role: "Boot",
2615 event_tip: "",
2616 };
2617 assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
2618 }
2619
2620 #[test]
2621 fn event_tip_is_covered_by_the_row_signature() {
2622 let key = fixed_key();
2623 let mut empty = sample_content(1, "txa");
2624 empty.identity = ChainIdentity::V2 {
2625 caller_role: "Dev",
2626 event_tip: "",
2627 };
2628 let mut bound = sample_content(1, "txa");
2629 bound.identity = ChainIdentity::V2 {
2630 caller_role: "Dev",
2631 event_tip: "abc123",
2632 };
2633 assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
2634 }
2635
2636 #[test]
2637 fn a_legacy_row_written_before_the_migration_still_verifies() {
2638 let key = fixed_key();
2643 let mut content = sample_content(1, "tx-legacy");
2644 content.identity = ChainIdentity::LegacyV1;
2645 let hash = key.chain_hash(&content, "");
2646 let row = row_for(&content, "", hash);
2647 assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
2648 assert_eq!(row.caller_role, None);
2649 assert_eq!(
2650 verify_chain(&key, &[row]),
2651 VerifyOutcome::Intact { rows_checked: 1 }
2652 );
2653 }
2654
2655 #[test]
2661 fn two_admins_are_distinguishable_in_the_signed_record() {
2662 let key = fixed_key();
2663 let mut alice = sample_content(1, "tx-alice");
2664 alice.identity = ChainIdentity::V3 {
2665 caller_role: "admin",
2666 event_tip: "",
2667 caller_principal: "uid:1000",
2668 };
2669 let mut bob = sample_content(1, "tx-alice");
2670 bob.identity = ChainIdentity::V3 {
2671 caller_role: "admin",
2672 event_tip: "",
2673 caller_principal: "uid:1001",
2674 };
2675
2676 assert_ne!(
2677 key.chain_hash(&alice, ""),
2678 key.chain_hash(&bob, ""),
2679 "identical rows differing only in principal must sign differently, \
2680 otherwise the chain still cannot answer which account acted"
2681 );
2682 }
2683
2684 #[test]
2687 fn editing_the_stored_principal_breaks_the_row() {
2688 let key = fixed_key();
2689 let mut content = sample_content(1, "tx1");
2690 content.identity = ChainIdentity::V3 {
2691 caller_role: "admin",
2692 event_tip: "",
2693 caller_principal: "uid:1000",
2694 };
2695 let hash = key.chain_hash(&content, "");
2696 let mut row = row_for(&content, "", hash);
2697 row.caller_principal = Some("uid:0".to_string());
2698
2699 assert!(
2700 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2701 "a principal swapped to root must not verify"
2702 );
2703 }
2704
2705 #[test]
2709 fn downgrading_a_v3_row_to_v2_to_hide_the_account_breaks_it() {
2710 let key = fixed_key();
2711 let mut content = sample_content(1, "tx1");
2712 content.identity = ChainIdentity::V3 {
2713 caller_role: "admin",
2714 event_tip: "",
2715 caller_principal: "uid:1000",
2716 };
2717 let hash = key.chain_hash(&content, "");
2718 let mut row = row_for(&content, "", hash);
2719 row.chain_version = CHAIN_VERSION_V2;
2720 row.caller_principal = None;
2721
2722 assert!(
2723 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2724 "a v3 row relabelled as v2 must not verify"
2725 );
2726 }
2727
2728 #[test]
2733 fn a_v3_row_without_a_usable_principal_is_broken() {
2734 let key = fixed_key();
2735 let mut content = sample_content(1, "tx1");
2736 content.identity = ChainIdentity::V3 {
2737 caller_role: "admin",
2738 event_tip: "",
2739 caller_principal: "uid:1000",
2740 };
2741 let hash = key.chain_hash(&content, "");
2742
2743 for absent in [None, Some(String::new())] {
2744 let mut row = row_for(&content, "", hash.clone());
2745 row.caller_principal = absent.clone();
2746 assert!(
2747 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2748 "a v3 row with principal {absent:?} must be reported as broken"
2749 );
2750 }
2751 }
2752
2753 #[test]
2763 fn a_row_signed_with_a_blank_principal_is_rejected_even_though_it_signs() {
2764 let key = fixed_key();
2765 let mut content = sample_content(1, "tx-blank");
2766 content.identity = ChainIdentity::V3 {
2767 caller_role: "admin",
2768 event_tip: "",
2769 caller_principal: "",
2770 };
2771 let hash = key.chain_hash(&content, "");
2772 let row = row_for(&content, "", hash);
2777 assert_eq!(
2778 row.caller_principal.as_deref(),
2779 Some(""),
2780 "fixture must store the blank principal it signed"
2781 );
2782 assert_eq!(row.chain_version, CHAIN_VERSION_V3);
2783 assert!(
2784 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2785 "a v3 row naming nobody must be broken, not accepted"
2786 );
2787 }
2788
2789 #[test]
2794 fn a_row_recording_an_attribution_failure_verifies() {
2795 let key = fixed_key();
2796 let principal = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2797 let mut content = sample_content(1, "tx-unattributed");
2798 content.identity = ChainIdentity::V3 {
2799 caller_role: "observer",
2800 event_tip: "",
2801 caller_principal: &principal,
2802 };
2803 let hash = key.chain_hash(&content, "");
2804 let row = row_for(&content, "", hash);
2805
2806 assert_eq!(
2807 verify_chain(&key, &[row]),
2808 VerifyOutcome::Intact { rows_checked: 1 }
2809 );
2810 }
2811
2812 #[test]
2816 fn a_chain_spanning_all_four_encodings_verifies() {
2817 let key = fixed_key();
2818 let mut rows = Vec::new();
2819 let mut prev = String::new();
2820 for (i, identity) in [
2821 ChainIdentity::LegacyV1,
2822 ChainIdentity::V2 {
2823 caller_role: "dev",
2824 event_tip: "",
2825 },
2826 ChainIdentity::V3 {
2827 caller_role: "admin",
2828 event_tip: "",
2829 caller_principal: "uid:1000",
2830 },
2831 ChainIdentity::V3 {
2832 caller_role: "admin",
2833 event_tip: "",
2834 caller_principal: "token:vsock",
2835 },
2836 ChainIdentity::V4 {
2837 caller_role: "admin",
2838 event_tip: "",
2839 caller_principal: "uid:1000",
2840 },
2841 ]
2842 .into_iter()
2843 .enumerate()
2844 {
2845 let txid = format!("tx{i}");
2846 let mut content = sample_content((i + 1) as u64, &txid);
2847 content.identity = identity;
2848 let hash = key.chain_hash(&content, &prev);
2849 rows.push(row_for(&content, &prev, hash.clone()));
2850 prev = hash;
2851 }
2852
2853 assert_eq!(
2854 verify_chain(&key, &rows),
2855 VerifyOutcome::Intact { rows_checked: 5 }
2856 );
2857 }
2858
2859 fn chain_of(key: &AuditKey, identities: Vec<ChainIdentity<'_>>) -> Vec<ChainRow> {
2862 let mut rows = Vec::new();
2863 let mut prev = String::new();
2864 for (i, identity) in identities.into_iter().enumerate() {
2865 let txid = format!("tx{i}");
2866 let mut content = sample_content((i + 1) as u64, &txid);
2867 content.identity = identity;
2868 let hash = key.chain_hash(&content, &prev);
2869 rows.push(row_for(&content, &prev, hash.clone()));
2870 prev = hash;
2871 }
2872 rows
2873 }
2874
2875 #[test]
2885 fn the_census_separates_attribution_failures_from_encodings_without_the_field() {
2886 let key = fixed_key();
2887 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2888 let rows = chain_of(
2889 &key,
2890 vec![
2891 ChainIdentity::LegacyV1,
2892 ChainIdentity::V2 {
2893 caller_role: "dev",
2894 event_tip: "",
2895 },
2896 ChainIdentity::V3 {
2897 caller_role: "admin",
2898 event_tip: "",
2899 caller_principal: "uid:1000",
2900 },
2901 ChainIdentity::V3 {
2902 caller_role: "admin",
2903 event_tip: "",
2904 caller_principal: "uid:1001",
2905 },
2906 ChainIdentity::V3 {
2907 caller_role: "admin",
2908 event_tip: "",
2909 caller_principal: "token:vsock",
2910 },
2911 ChainIdentity::V3 {
2912 caller_role: "observer",
2913 event_tip: "",
2914 caller_principal: &unattributed,
2915 },
2916 ],
2917 );
2918
2919 let verification = verify_all(&key, &rows, &[]);
2920 assert_eq!(
2921 verification.chain,
2922 VerifyOutcome::Intact { rows_checked: 6 },
2923 "every encoding must keep verifying; the census is not a verdict"
2924 );
2925 let census = verification
2926 .attribution
2927 .expect("rows were read, so a census was taken");
2928 assert_eq!(census.named(), 3);
2929 assert_eq!(census.attribution_failed(), 1);
2930 assert_eq!(census.not_recorded(), 2);
2931 assert_eq!(census.unattested(), 0);
2932 assert_eq!(census.rows(), 6);
2933 assert_eq!(
2934 census.unnamed(),
2935 3,
2936 "three of the six rows cannot name an account, by either reason"
2937 );
2938 }
2939
2940 #[test]
2950 fn a_principal_written_into_an_encoding_that_does_not_sign_it_is_never_an_account() {
2951 let key = fixed_key();
2952 let mut rows = chain_of(
2953 &key,
2954 vec![
2955 ChainIdentity::LegacyV1,
2956 ChainIdentity::V2 {
2957 caller_role: "dev",
2958 event_tip: "",
2959 },
2960 ],
2961 );
2962 rows[0].caller_principal = Some("uid:0".to_string());
2965 rows[1].caller_principal = Some("uid:0".to_string());
2966
2967 let verification = verify_all(&key, &rows, &[]);
2968 assert_eq!(
2969 verification.chain,
2970 VerifyOutcome::Intact { rows_checked: 2 },
2971 "no signature covers that column on v1/v2, so tampering with it is \
2972 invisible to verification -- which is exactly why the census must \
2973 not believe it"
2974 );
2975 let census = verification.attribution.expect("rows were read");
2976 assert_eq!(
2977 census.named(),
2978 0,
2979 "an unsigned column must never be credited as an account"
2980 );
2981 assert_eq!(
2982 census.unattested(),
2983 2,
2984 "and it must be reported as unattested, because nothing in SysKnife \
2985 writes that column on those encodings"
2986 );
2987 assert_eq!(census.unnamed(), 2);
2988 }
2989
2990 #[test]
2994 fn a_blank_principal_column_is_counted_as_naming_nobody() {
2995 let key = fixed_key();
2996 let mut content = sample_content(1, "tx-blank-census");
2997 content.identity = ChainIdentity::V3 {
2998 caller_role: "admin",
2999 event_tip: "",
3000 caller_principal: "",
3001 };
3002 let hash = key.chain_hash(&content, "");
3003 let row = row_for(&content, "", hash);
3004 assert_eq!(row.caller_principal.as_deref(), Some(""));
3005
3006 let verification = verify_all(&key, &[row], &[]);
3007 let census = verification.attribution.expect("a row was read");
3008 assert_eq!(census.named(), 0);
3009 assert_eq!(census.not_recorded(), 1);
3010 assert_eq!(census.rows(), 1);
3011 assert!(
3012 matches!(verification.chain, VerifyOutcome::Broken { .. }),
3013 "and the row is still reported as broken once the walk reaches it, \
3014 so census and verdict agree"
3015 );
3016 }
3017
3018 #[test]
3026 fn a_principal_this_binary_cannot_read_is_counted_as_unattested_not_as_an_account() {
3027 let key = fixed_key();
3028 for forged in [
3029 "garbage",
3030 "uid:",
3031 "none:overflow",
3032 " ",
3037 "UID:1000",
3038 "uid:notanumber",
3040 "uid:1000:extra",
3041 "token:not-vsock",
3042 ] {
3043 let mut content = sample_content(1, "tx-forged");
3044 content.identity = ChainIdentity::V3 {
3045 caller_role: "admin",
3046 event_tip: "",
3047 caller_principal: forged,
3048 };
3049 let hash = key.chain_hash(&content, "");
3050 let row = row_for(&content, "", hash);
3051 let census = verify_all(&key, &[row], &[])
3052 .attribution
3053 .expect("a row was read");
3054 assert_eq!(
3055 census.named(),
3056 0,
3057 "{forged:?} names no account and must not be counted as one"
3058 );
3059 assert_eq!(
3060 census.unnamed(),
3061 1,
3062 "{forged:?} must be counted among the rows naming nobody"
3063 );
3064 assert_eq!(
3065 census.unattested(),
3066 1,
3067 "{forged:?} is signed content the daemon never writes, so it is a \
3068 finding to investigate, not history to explain away"
3069 );
3070 assert_eq!(
3071 census.not_recorded(),
3072 0,
3073 "{forged:?} is present, so it must not be reported as a row that \
3074 predates the column"
3075 );
3076 }
3077 }
3078
3079 #[test]
3084 fn a_chain_predating_the_principal_field_reports_no_row_as_attributed() {
3085 let key = fixed_key();
3086 let rows = chain_of(
3087 &key,
3088 vec![
3089 ChainIdentity::LegacyV1,
3090 ChainIdentity::V2 {
3091 caller_role: "dev",
3092 event_tip: "",
3093 },
3094 ],
3095 );
3096
3097 let verification = verify_all(&key, &rows, &[]);
3098 assert_eq!(
3099 verification.chain,
3100 VerifyOutcome::Intact { rows_checked: 2 },
3101 "legacy rows must keep verifying; the census is not a verdict"
3102 );
3103 let census = verification.attribution.expect("rows were read");
3104 assert_eq!(census.attribution_failed(), 0);
3105 assert_eq!(census.named(), 0);
3106 assert_eq!(census.not_recorded(), 2);
3107 assert_eq!(census.unnamed(), 2);
3108 }
3109
3110 #[test]
3116 fn a_broken_chain_censuses_more_rows_than_it_verified() {
3117 let key = fixed_key();
3118 let mut rows = chain_of(
3119 &key,
3120 vec![
3121 ChainIdentity::V3 {
3122 caller_role: "admin",
3123 event_tip: "",
3124 caller_principal: "uid:1000",
3125 },
3126 ChainIdentity::V3 {
3127 caller_role: "admin",
3128 event_tip: "",
3129 caller_principal: "uid:1000",
3130 },
3131 ChainIdentity::V3 {
3132 caller_role: "admin",
3133 event_tip: "",
3134 caller_principal: "uid:1000",
3135 },
3136 ],
3137 );
3138 rows[1].summary = "tampered".to_string();
3139
3140 let verification = verify_all(&key, &rows, &[]);
3141 let rows_checked = match verification.chain {
3142 VerifyOutcome::Broken { rows_checked, .. } => rows_checked,
3143 ref other => panic!("expected a detected tamper, got {other:?}"),
3144 };
3145 let census = verification.attribution.expect("rows were read");
3146 assert_eq!(rows_checked, 1, "the walk stops at the first broken row");
3147 assert_eq!(
3148 census.rows(),
3149 3,
3150 "while the census still describes every row read"
3151 );
3152 assert!(
3153 census.rows() > rows_checked,
3154 "the report must be able to show that more was counted than checked"
3155 );
3156 }
3157
3158 #[test]
3162 fn the_pubkey_only_path_takes_the_same_census() {
3163 let key = fixed_key();
3164 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
3165 let rows = chain_of(
3166 &key,
3167 vec![
3168 ChainIdentity::V3 {
3169 caller_role: "admin",
3170 event_tip: "",
3171 caller_principal: "uid:1000",
3172 },
3173 ChainIdentity::V3 {
3174 caller_role: "observer",
3175 event_tip: "",
3176 caller_principal: &unattributed,
3177 },
3178 ],
3179 );
3180
3181 let with_key = verify_all(&key, &rows, &[]).attribution;
3182 let with_pubkey = verify_all_with_pubkey(&key.verifying_key_hex(), &rows, &[]).attribution;
3183 assert_eq!(
3184 with_key, with_pubkey,
3185 "the auditor must see the same attribution numbers as the keyholder"
3186 );
3187 let census = with_pubkey.expect("rows were read");
3188 assert_eq!(census.named(), 1);
3189 assert_eq!(census.attribution_failed(), 1);
3190 }
3191
3192 #[test]
3197 fn a_chain_that_names_nobody_still_exits_zero() {
3198 let key = fixed_key();
3199 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
3200 let rows = chain_of(
3201 &key,
3202 vec![
3203 ChainIdentity::LegacyV1,
3204 ChainIdentity::V3 {
3205 caller_role: "observer",
3206 event_tip: "",
3207 caller_principal: &unattributed,
3208 },
3209 ],
3210 );
3211
3212 let verification = verify_all(&key, &rows, &[]);
3213 let census = verification.attribution.expect("rows were read");
3214 assert_eq!(census.unnamed(), 2, "not one row names an account");
3215 assert_eq!(
3216 verification.exit_code(),
3217 0,
3218 "yet nothing was tampered with, so the command must succeed"
3219 );
3220 }
3221
3222 #[test]
3227 fn a_future_encoding_reports_cannot_verify_with_the_supported_range() {
3228 let key = fixed_key();
3229 let content = sample_content(1, "tx1");
3230 let hash = key.chain_hash(&content, "");
3231 let mut row = row_for(&content, "", hash);
3232 row.chain_version = CHAIN_VERSION_CURRENT + 1;
3233
3234 match verify_chain(&key, &[row]) {
3235 VerifyOutcome::CannotVerify { reason } => {
3236 assert!(
3237 reason.contains(&format!("chain_version={}", CHAIN_VERSION_CURRENT + 1)),
3238 "name the version found, got: {reason}"
3239 );
3240 assert!(
3241 reason.contains(&CHAIN_VERSION_CURRENT.to_string()),
3242 "and the newest version understood, got: {reason}"
3243 );
3244 }
3245 other => panic!("a future encoding must not be reported as tampering: {other:?}"),
3246 }
3247 }
3248
3249 #[test]
3262 fn a_row_written_by_the_previous_release_still_verifies() {
3263 const GOLDEN_V2_CHAIN_HASH: &str = "ba12cbe49a149387898bc30f1e8d409effa025ab8e440e6e72bedf060afc831e45d9be264d6b18b7e0ef2f2b3584dbc6953b535546cecbbdc3d7ac666cae4d0a";
3264
3265 let key = fixed_key();
3266 let row = ChainRow {
3267 seq: 7,
3268 key_id: CURRENT_KEY_ID.to_string(),
3269 transaction_id: "tx-golden".to_string(),
3270 request_id: "req-golden".to_string(),
3271 request_hash: "hash-golden".to_string(),
3272 action_name: "AptInstall".to_string(),
3273 risk_level: RiskLevel::Medium,
3274 summary: "install ripgrep".to_string(),
3275 approval_id: Some("appr-golden".to_string()),
3276 warnings_json: "[]".to_string(),
3277 created_at: "2026-07-29T00:00:00Z".to_string(),
3278 prev_chain_hash: String::new(),
3279 chain_hash: GOLDEN_V2_CHAIN_HASH.to_string(),
3280 chain_version: 2,
3281 caller_role: Some("admin".to_string()),
3282 event_tip: Some("eventtip-golden".to_string()),
3283 caller_principal: None,
3284 };
3285
3286 assert_eq!(
3287 verify_chain(&key, std::slice::from_ref(&row)),
3288 VerifyOutcome::Intact { rows_checked: 1 },
3289 "a v2 row on disk must verify under this binary; if this fails, the v2 \
3290 encoding or its version dispatch changed and existing audit logs are unreadable"
3291 );
3292
3293 let mut tampered = row;
3297 let flipped = match GOLDEN_V2_CHAIN_HASH.strip_prefix('b') {
3298 Some(rest) => format!("a{rest}"),
3299 None => format!("b{}", &GOLDEN_V2_CHAIN_HASH[1..]),
3300 };
3301 tampered.chain_hash = flipped;
3302 assert!(
3303 matches!(
3304 verify_chain(&key, &[tampered]),
3305 VerifyOutcome::Broken { .. }
3306 ),
3307 "a golden row with one nibble changed must not verify"
3308 );
3309 }
3310
3311 #[test]
3315 fn a_legacy_v1_row_on_disk_still_verifies() {
3316 const GOLDEN_V1_CHAIN_HASH: &str = "ce8b47c15414e989e099303b826fcd9985360b794220cefbbf9689d25f31fe173283687feb6d1dd265ae5aca94c8bcfb87ce33ba8c954fafdad18a59cd97c702";
3317
3318 let row = ChainRow {
3319 chain_version: 1,
3320 caller_role: None,
3321 event_tip: None,
3322 caller_principal: None,
3323 chain_hash: GOLDEN_V1_CHAIN_HASH.to_string(),
3324 ..golden_row_shape()
3325 };
3326 assert_eq!(
3327 verify_chain(&fixed_key(), &[row]),
3328 VerifyOutcome::Intact { rows_checked: 1 }
3329 );
3330 }
3331
3332 #[test]
3336 fn a_v3_row_on_disk_still_verifies() {
3337 const GOLDEN_V3_CHAIN_HASH: &str = "3c0d05f6ccaf9e65ccb796d4ffe96eb31a2ea67e446217c5234af916dc959d0f2c0ee3d3e5b525aece791ee7d6507efb98dcdd79f0d74dde4273859aa9627d08";
3338
3339 let row = ChainRow {
3340 chain_version: 3,
3341 caller_role: Some("admin".to_string()),
3342 event_tip: Some("eventtip-golden".to_string()),
3343 caller_principal: Some("uid:1000".to_string()),
3344 chain_hash: GOLDEN_V3_CHAIN_HASH.to_string(),
3345 ..golden_row_shape()
3346 };
3347 assert_eq!(
3348 verify_chain(&fixed_key(), &[row]),
3349 VerifyOutcome::Intact { rows_checked: 1 }
3350 );
3351 }
3352
3353 #[test]
3364 fn a_v4_row_on_disk_still_verifies() {
3365 const GOLDEN_V4_CHAIN_HASH: &str = "8d82aac6c21c8224613e1a69f6a2a4c3298dd5d601bda9b1d997daf0bf552db499cbf5243397464c1add52e2f0a41c5e4ec5c788c9e84b4917ee33b3d496e10f";
3366
3367 let row = ChainRow {
3368 chain_version: 4,
3369 caller_role: Some("admin".to_string()),
3370 event_tip: Some("eventtip-golden".to_string()),
3371 caller_principal: Some("uid:1000".to_string()),
3372 chain_hash: GOLDEN_V4_CHAIN_HASH.to_string(),
3373 ..golden_row_shape()
3374 };
3375 assert_eq!(
3376 verify_chain(&fixed_key(), std::slice::from_ref(&row)),
3377 VerifyOutcome::Intact { rows_checked: 1 },
3378 "a v4 row on disk must verify under this binary; if this fails, the v4 \
3379 encoding or its version dispatch changed and audit logs written by \
3380 this release become unreadable"
3381 );
3382
3383 let mut tampered = row.clone();
3386 tampered.chain_hash = format!(
3387 "{}{}",
3388 &GOLDEN_V4_CHAIN_HASH[1..],
3389 &GOLDEN_V4_CHAIN_HASH[..1]
3390 );
3391 assert!(
3392 matches!(
3393 verify_chain(&fixed_key(), &[tampered]),
3394 VerifyOutcome::Broken { .. }
3395 ),
3396 "a rotated golden hash must not verify"
3397 );
3398
3399 let mut relabelled = row;
3404 relabelled.chain_version = 3;
3405 assert!(
3406 matches!(
3407 verify_chain(&fixed_key(), &[relabelled]),
3408 VerifyOutcome::Broken { .. }
3409 ),
3410 "a v4 row relabelled as v3 must not verify; the version field \
3411 must be inside the signed bytes"
3412 );
3413 }
3414
3415 #[test]
3419 fn v3_and_v4_encodings_do_not_alias() {
3420 let key = fixed_key();
3421 let mut v3 = golden_content_shape();
3422 v3.identity = ChainIdentity::V3 {
3423 caller_role: "admin",
3424 event_tip: "eventtip-golden",
3425 caller_principal: "uid:1000",
3426 };
3427 let mut v4 = golden_content_shape();
3428 v4.identity = ChainIdentity::V4 {
3429 caller_role: "admin",
3430 event_tip: "eventtip-golden",
3431 caller_principal: "uid:1000",
3432 };
3433 assert_ne!(
3434 key.chain_hash(&v3, ""),
3435 key.chain_hash(&v4, ""),
3436 "v3 and v4 sign the same fields, so only the chain_version tag \
3437 separates them; if these collide, relabelling a row across the \
3438 boundary is undetectable"
3439 );
3440 }
3441
3442 #[test]
3448 fn request_hash_is_not_the_unsigned_sha256() {
3449 let rhk = fixed_key().request_hash_key();
3450 let action = "ConfigureWifi";
3453 let canonical = r#"{"password":"hunter2","ssid":"CoffeeShop"}"#;
3454
3455 let mac = rhk.request_hash(action, canonical);
3456
3457 let mut hasher = Sha256::new();
3458 hasher.update(action.as_bytes());
3459 hasher.update(b"\x00");
3460 hasher.update(canonical.as_bytes());
3461 let legacy = hex::encode(hasher.finalize());
3462
3463 assert_eq!(mac.len(), 64, "the column width must not move");
3464 assert_ne!(
3465 mac, legacy,
3466 "request_hash is still the unsalted SHA-256; an exported row is \
3467 still offline-crackable"
3468 );
3469 }
3470
3471 #[test]
3476 fn request_hash_is_deterministic_and_key_bound() {
3477 let k1 = AuditKey::from_bytes(vec![0x01; 32]).request_hash_key();
3478 let k2 = AuditKey::from_bytes(vec![0x02; 32]).request_hash_key();
3479 assert_eq!(
3480 k1.request_hash("Op", "{}"),
3481 k1.request_hash("Op", "{}"),
3482 "preview and execute must agree on the hash"
3483 );
3484 assert_ne!(
3485 k1.request_hash("Op", "{}"),
3486 k2.request_hash("Op", "{}"),
3487 "the hash must not be computable without this key"
3488 );
3489 assert_ne!(
3490 k1.request_hash("OpA", "{}"),
3491 k1.request_hash("OpB", "{}"),
3492 "the action name is part of the message"
3493 );
3494 }
3495
3496 #[test]
3503 fn request_hash_golden_vector() {
3504 const GOLDEN_REQUEST_HASH: &str =
3505 "4f6bde0da85486323247e531482825059ddca0bd621997005f5d45dabeadcbf4";
3506 let rhk = fixed_key().request_hash_key();
3507 assert_eq!(
3508 rhk.request_hash("UpdateSystem", "{}"),
3509 GOLDEN_REQUEST_HASH,
3510 "the request_hash derivation changed; approvals pending across \
3511 the upgrade would fail the execute-time binding check"
3512 );
3513 }
3514
3515 #[test]
3520 fn request_hash_key_is_derived_not_the_signing_seed() {
3521 let seed = vec![0x42u8; 32];
3522 let rhk = AuditKey::from_bytes(seed.clone()).request_hash_key();
3523 let mut raw = Hmac::<Sha256>::new_from_slice(&seed).expect("HMAC accepts any key length");
3527 raw.update(b"UpdateSystem");
3528 raw.update(b"\x00");
3529 raw.update(b"{}");
3530 let raw_mac = hex::encode(raw.finalize().into_bytes());
3531 assert_ne!(
3532 rhk.request_hash("UpdateSystem", "{}"),
3533 raw_mac,
3534 "the HMAC key is the raw seed; the derivation is not happening"
3535 );
3536 }
3537
3538 #[test]
3545 fn the_escape_table_and_absent_approval_encoding_are_frozen() {
3546 const GOLDEN_V2_AWKWARD_CHAIN_HASH: &str = "e6face6fe2346fc0f6c5c1e5845255579c8f08738406b88b0a1f6dbdf8ff85090a4b80d95bd0927ef54b0a2c4810d52ebb4bbf76cb68c865a7748df26442aa05";
3547
3548 let key = fixed_key();
3549 let content = ChainContent {
3550 seq: 8,
3551 approval_id: None,
3552 summary: "back\\slash and \x1funit and \x1erecord and nul\0byte",
3553 identity: ChainIdentity::V2 {
3554 caller_role: "dev",
3555 event_tip: "",
3556 },
3557 ..golden_content_shape()
3558 };
3559 assert_eq!(
3560 key.chain_hash(&content, "ba12cbe4"),
3561 GOLDEN_V2_AWKWARD_CHAIN_HASH,
3562 "the canonical encoding of escapes, an absent approval id, or a \
3563 non-empty prev_chain_hash changed"
3564 );
3565 }
3566
3567 fn golden_content_shape() -> ChainContent<'static> {
3571 ChainContent {
3572 seq: 7,
3573 key_id: CURRENT_KEY_ID,
3574 transaction_id: "tx-golden",
3575 request_id: "req-golden",
3576 request_hash: "hash-golden",
3577 action_name: "AptInstall",
3578 risk_level: RiskLevel::Medium,
3579 summary: "install ripgrep",
3580 approval_id: Some("appr-golden"),
3581 warnings_json: "[]",
3582 created_at: "2026-07-29T00:00:00Z",
3583 identity: ChainIdentity::LegacyV1,
3584 }
3585 }
3586
3587 fn golden_row_shape() -> ChainRow {
3589 ChainRow {
3590 seq: 7,
3591 key_id: CURRENT_KEY_ID.to_string(),
3592 transaction_id: "tx-golden".to_string(),
3593 request_id: "req-golden".to_string(),
3594 request_hash: "hash-golden".to_string(),
3595 action_name: "AptInstall".to_string(),
3596 risk_level: RiskLevel::Medium,
3597 summary: "install ripgrep".to_string(),
3598 approval_id: Some("appr-golden".to_string()),
3599 warnings_json: "[]".to_string(),
3600 created_at: "2026-07-29T00:00:00Z".to_string(),
3601 prev_chain_hash: String::new(),
3602 chain_hash: String::new(),
3603 chain_version: 1,
3604 caller_role: None,
3605 event_tip: None,
3606 caller_principal: None,
3607 }
3608 }
3609
3610 #[test]
3611 fn a_chain_that_spans_the_migration_verifies_end_to_end() {
3612 let key = fixed_key();
3615 let mut rows = Vec::new();
3616 let mut prev = String::new();
3617 for (i, identity) in [
3618 ChainIdentity::LegacyV1,
3619 ChainIdentity::LegacyV1,
3620 ChainIdentity::V2 {
3621 caller_role: "Dev",
3622 event_tip: "",
3623 },
3624 ChainIdentity::V2 {
3625 caller_role: "Boot",
3626 event_tip: "",
3627 },
3628 ]
3629 .into_iter()
3630 .enumerate()
3631 {
3632 let txid = format!("tx{i}");
3633 let mut content = sample_content((i + 1) as u64, &txid);
3634 content.identity = identity;
3635 let hash = key.chain_hash(&content, &prev);
3636 rows.push(row_for(&content, &prev, hash.clone()));
3637 prev = hash;
3638 }
3639 assert_eq!(
3640 verify_chain(&key, &rows),
3641 VerifyOutcome::Intact { rows_checked: 4 }
3642 );
3643 }
3644
3645 #[test]
3646 fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
3647 let key = fixed_key();
3652 let mut rows = build_chain(&key, 2);
3653 rows[1].chain_version = CHAIN_VERSION_LEGACY;
3654 rows[1].caller_role = None;
3655 rows[1].event_tip = None;
3656 match verify_chain(&key, &rows) {
3657 VerifyOutcome::Broken {
3658 first_broken_seq, ..
3659 } => assert_eq!(first_broken_seq, 2),
3660 other => panic!("expected Broken, got {other:?}"),
3661 }
3662 }
3663
3664 #[test]
3665 fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
3666 let key = fixed_key();
3671 let mut rows = build_chain(&key, 1);
3672 rows[0].caller_role = None;
3673 let outcome = verify_chain(&key, &rows);
3674 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
3675 assert_eq!(outcome_to_exit_code(&outcome), 1);
3676 }
3677
3678 #[test]
3679 fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
3680 let key = fixed_key();
3683 let mut rows = build_chain(&key, 1);
3684 rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
3685 let outcome = verify_chain(&key, &rows);
3686 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3687 assert_eq!(outcome_to_exit_code(&outcome), 2);
3688 }
3689
3690 fn expect_broken_at(outcome: VerifyOutcome, seq: u64) {
3697 match outcome {
3698 VerifyOutcome::Broken {
3699 first_broken_seq, ..
3700 } => assert_eq!(first_broken_seq, seq),
3701 other => panic!("expected Broken at seq={seq}, got {other:?}"),
3702 }
3703 }
3704
3705 #[test]
3706 fn an_unknown_version_row_does_not_hide_a_forgery_after_it() {
3707 let key = fixed_key();
3708 let mut rows = build_chain(&key, 4);
3709 rows[1].chain_version = CHAIN_VERSION_CURRENT + 1;
3710 rows[3].summary = "FORGED".to_string();
3711 expect_broken_at(verify_chain(&key, &rows), 4);
3712 expect_broken_at(verify_chain_with_pubkey(&key.verifying_key_hex(), &rows), 4);
3713 }
3714
3715 #[test]
3716 fn a_row_deleted_after_an_unknown_version_row_breaks_the_link() {
3717 let key = fixed_key();
3718 let mut rows = build_chain(&key, 4);
3719 rows[1].chain_version = CHAIN_VERSION_CURRENT + 1;
3720 rows.remove(2);
3723 expect_broken_at(verify_chain(&key, &rows), 4);
3724 }
3725
3726 #[test]
3727 fn an_unknown_version_row_alone_is_still_cannot_verify() {
3728 let key = fixed_key();
3729 let mut rows = build_chain(&key, 4);
3730 rows[1].chain_version = CHAIN_VERSION_CURRENT + 1;
3731 match verify_chain(&key, &rows) {
3732 VerifyOutcome::CannotVerify { reason } => {
3733 assert!(
3734 reason.contains("seq=2"),
3735 "name the row that could not be checked: {reason}"
3736 );
3737 assert!(
3738 reason.contains("3 other row(s) verified"),
3739 "say the rest of the chain was walked: {reason}"
3740 );
3741 }
3742 other => panic!("nothing here is provably broken: {other:?}"),
3743 }
3744 }
3745
3746 #[test]
3747 fn a_key_id_mismatch_does_not_hide_a_forgery_after_it() {
3748 let key = fixed_key();
3749 let mut rows = build_chain(&key, 3);
3750 rows[0].key_id = "v99".to_string();
3751 rows[2].summary = "FORGED".to_string();
3752 expect_broken_at(verify_chain(&key, &rows), 3);
3753 }
3754
3755 #[test]
3759 fn a_foreign_key_id_the_loaded_key_signed_verifies() {
3760 let key = fixed_key();
3761 let mut content = sample_content(1, "tx0");
3762 content.key_id = "v0";
3763 let hash = key.chain_hash(&content, "");
3764 let row = row_for(&content, "", hash);
3765 assert_eq!(
3766 verify_chain(&key, &[row]),
3767 VerifyOutcome::Intact { rows_checked: 1 }
3768 );
3769 }
3770
3771 #[test]
3772 fn an_unknown_event_version_does_not_hide_a_forged_event_after_it() {
3773 let key = fixed_key();
3774 let mut rows = build_event_chain(
3775 &key,
3776 &[
3777 AuditEventKind::ApprovalGranted,
3778 AuditEventKind::ApprovalConsumed,
3779 AuditEventKind::StatusSucceeded,
3780 ],
3781 );
3782 rows[0].chain_version = EVENT_VERSION_CURRENT + 1;
3783 rows[2].kind = AuditEventKind::StatusCanceled.as_str().to_string();
3784 expect_broken_at(verify_event_chain(&key, &rows), 3);
3785 expect_broken_at(
3786 verify_event_chain_with_pubkey(&key.verifying_key_hex(), &rows),
3787 3,
3788 );
3789 }
3790
3791 #[test]
3792 fn an_event_key_id_mismatch_does_not_hide_a_forged_event_after_it() {
3793 let key = fixed_key();
3794 let mut rows = build_event_chain(
3795 &key,
3796 &[
3797 AuditEventKind::ApprovalGranted,
3798 AuditEventKind::ApprovalConsumed,
3799 AuditEventKind::StatusSucceeded,
3800 ],
3801 );
3802 rows[0].key_id = "v99".to_string();
3803 rows[2].kind = AuditEventKind::StatusCanceled.as_str().to_string();
3804 expect_broken_at(verify_event_chain(&key, &rows), 3);
3805 }
3806
3807 #[test]
3808 fn an_unknown_event_version_alone_is_still_cannot_verify() {
3809 let key = fixed_key();
3810 let mut rows = build_event_chain(
3811 &key,
3812 &[
3813 AuditEventKind::ApprovalGranted,
3814 AuditEventKind::ApprovalConsumed,
3815 ],
3816 );
3817 rows[0].chain_version = EVENT_VERSION_CURRENT + 1;
3818 let outcome = verify_event_chain(&key, &rows);
3819 assert!(
3820 matches!(outcome, VerifyOutcome::CannotVerify { .. }),
3821 "{outcome:?}"
3822 );
3823 }
3824
3825 fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
3828 EventContent {
3829 seq,
3830 key_id: CURRENT_KEY_ID,
3831 kind,
3832 transaction_id: txid,
3833 receipt_digest: "digest-abc",
3834 created_at: "2026-04-24T12:00:00Z",
3835 identity: EventIdentity::LegacyV1,
3839 }
3840 }
3841
3842 fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
3843 let mut rows = Vec::with_capacity(kinds.len());
3844 let mut prev = String::new();
3845 for (i, kind) in kinds.iter().enumerate() {
3846 let seq = (i + 1) as u64;
3847 let txid = format!("tx{i}");
3848 let content = event_content(seq, *kind, &txid);
3849 let hash = key.event_hash(&content, &prev);
3850 rows.push(EventRow {
3851 seq,
3852 key_id: content.key_id.to_string(),
3853 kind: content.kind.as_str().to_string(),
3854 transaction_id: content.transaction_id.to_string(),
3855 receipt_digest: content.receipt_digest.to_string(),
3856 created_at: content.created_at.to_string(),
3857 prev_chain_hash: prev.clone(),
3858 chain_hash: hash.clone(),
3859 chain_version: content.identity.version(),
3860 caller_principal: match content.identity {
3861 EventIdentity::V2 { caller_principal } => Some(caller_principal.to_string()),
3862 EventIdentity::LegacyV1 => None,
3863 },
3864 });
3865 prev = hash;
3866 }
3867 rows
3868 }
3869
3870 #[allow(clippy::too_many_arguments)]
3875 fn event_row(
3876 key: &AuditKey,
3877 seq: u64,
3878 kind: AuditEventKind,
3879 txid: &str,
3880 receipt_digest: &str,
3881 created_at: &str,
3882 identity: EventIdentity<'_>,
3883 prev: &str,
3884 ) -> EventRow {
3885 let content = EventContent {
3886 seq,
3887 key_id: CURRENT_KEY_ID,
3888 kind,
3889 transaction_id: txid,
3890 receipt_digest,
3891 created_at,
3892 identity,
3893 };
3894 let hash = key.event_hash(&content, prev);
3895 EventRow {
3896 seq,
3897 key_id: CURRENT_KEY_ID.to_string(),
3898 kind: kind.as_str().to_string(),
3899 transaction_id: txid.to_string(),
3900 receipt_digest: receipt_digest.to_string(),
3901 created_at: created_at.to_string(),
3902 prev_chain_hash: prev.to_string(),
3903 chain_hash: hash,
3904 chain_version: identity.version(),
3905 caller_principal: match identity {
3906 EventIdentity::V2 { caller_principal } => Some(caller_principal.to_string()),
3907 EventIdentity::LegacyV1 => None,
3908 },
3909 }
3910 }
3911
3912 #[test]
3913 fn a_legacy_event_row_signed_by_the_previous_release_still_verifies() {
3914 let key = fixed_key();
3925 let row = EventRow {
3926 seq: 1,
3927 key_id: CURRENT_KEY_ID.to_string(),
3928 kind: AuditEventKind::ApprovalGranted.as_str().to_string(),
3929 transaction_id: "tx-fixture-legacy".to_string(),
3930 receipt_digest: "digest-fixture-legacy".to_string(),
3931 created_at: "2026-08-18T09:00:00.000Z".to_string(),
3932 prev_chain_hash: String::new(),
3933 chain_hash: "1b7c2b864c13c8c2da5436b4a3d0d10ec37c129e97a4525228110c574fccf32b\
3934 d1a22001acb555faa016f6c50f26d5c783b447fbf866f8ff689ebec2fc7c340a"
3935 .to_string(),
3936 chain_version: EVENT_VERSION_LEGACY,
3937 caller_principal: None,
3938 };
3939 assert_eq!(
3940 verify_event_chain(&key, &[row]),
3941 VerifyOutcome::Intact { rows_checked: 1 },
3942 "the committed legacy event signature must still verify"
3943 );
3944 }
3945
3946 #[test]
3947 fn a_v2_event_signs_and_verifies_with_the_acting_account() {
3948 let key = fixed_key();
3949 let row = event_row(
3950 &key,
3951 1,
3952 AuditEventKind::ApprovalGranted,
3953 "tx1",
3954 "digest1",
3955 "2026-08-18T09:00:00.000Z",
3956 EventIdentity::V2 {
3957 caller_principal: "uid:1000",
3958 },
3959 "",
3960 );
3961 assert_eq!(row.chain_version, EVENT_VERSION_V2);
3962 assert_eq!(row.caller_principal.as_deref(), Some("uid:1000"));
3963 assert_eq!(
3964 verify_event_chain(&key, &[row]),
3965 VerifyOutcome::Intact { rows_checked: 1 }
3966 );
3967 }
3968
3969 #[test]
3970 fn a_grant_and_a_consume_by_different_accounts_are_distinguishable() {
3971 let key = fixed_key();
3976 let grant = event_row(
3977 &key,
3978 1,
3979 AuditEventKind::ApprovalGranted,
3980 "tx1",
3981 "digest1",
3982 "2026-08-18T09:00:00.000Z",
3983 EventIdentity::V2 {
3984 caller_principal: "uid:1000",
3985 },
3986 "",
3987 );
3988 let consume = event_row(
3989 &key,
3990 2,
3991 AuditEventKind::ApprovalConsumed,
3992 "tx1",
3993 "digest1",
3994 "2026-08-18T09:05:00.000Z",
3995 EventIdentity::V2 {
3996 caller_principal: "uid:1001",
3997 },
3998 &grant.chain_hash,
3999 );
4000 assert_eq!(
4001 verify_event_chain(&key, &[grant.clone(), consume.clone()]),
4002 VerifyOutcome::Intact { rows_checked: 2 }
4003 );
4004 assert_eq!(grant.caller_principal.as_deref(), Some("uid:1000"));
4005 assert_eq!(consume.caller_principal.as_deref(), Some("uid:1001"));
4006 assert_ne!(
4007 grant.caller_principal, consume.caller_principal,
4008 "grant and consume by different uids must not collapse to one record"
4009 );
4010 }
4011
4012 #[test]
4013 fn editing_a_stored_event_principal_reports_broken() {
4014 let key = fixed_key();
4018 let mut row = event_row(
4019 &key,
4020 1,
4021 AuditEventKind::ApprovalGranted,
4022 "tx1",
4023 "digest1",
4024 "2026-08-18T09:00:00.000Z",
4025 EventIdentity::V2 {
4026 caller_principal: "uid:1000",
4027 },
4028 "",
4029 );
4030 assert_eq!(
4031 verify_event_chain(&key, &[row.clone()]),
4032 VerifyOutcome::Intact { rows_checked: 1 }
4033 );
4034 row.caller_principal = Some("uid:9999".to_string());
4035 assert!(
4036 matches!(
4037 verify_event_chain(&key, &[row]),
4038 VerifyOutcome::Broken { .. }
4039 ),
4040 "an edited principal must not still verify"
4041 );
4042 }
4043
4044 #[test]
4045 fn downgrading_a_v2_event_to_legacy_reports_broken() {
4046 let key = fixed_key();
4050 let mut row = event_row(
4051 &key,
4052 1,
4053 AuditEventKind::ApprovalGranted,
4054 "tx1",
4055 "digest1",
4056 "2026-08-18T09:00:00.000Z",
4057 EventIdentity::V2 {
4058 caller_principal: "uid:1000",
4059 },
4060 "",
4061 );
4062 row.chain_version = EVENT_VERSION_LEGACY;
4063 row.caller_principal = None;
4064 assert!(matches!(
4065 verify_event_chain(&key, &[row]),
4066 VerifyOutcome::Broken { .. }
4067 ));
4068 }
4069
4070 #[test]
4071 fn a_v2_event_naming_nobody_is_broken_not_accepted() {
4072 let key = fixed_key();
4076 let mut blank = event_row(
4077 &key,
4078 1,
4079 AuditEventKind::ApprovalGranted,
4080 "tx1",
4081 "digest1",
4082 "2026-08-18T09:00:00.000Z",
4083 EventIdentity::V2 {
4084 caller_principal: "uid:1000",
4085 },
4086 "",
4087 );
4088 blank.caller_principal = Some(String::new());
4089 assert!(
4090 matches!(
4091 verify_event_chain(&key, &[blank]),
4092 VerifyOutcome::Broken { .. }
4093 ),
4094 "a blank principal on a v2 row must be broken"
4095 );
4096
4097 let mut absent = event_row(
4098 &key,
4099 1,
4100 AuditEventKind::ApprovalGranted,
4101 "tx1",
4102 "digest1",
4103 "2026-08-18T09:00:00.000Z",
4104 EventIdentity::V2 {
4105 caller_principal: "uid:1000",
4106 },
4107 "",
4108 );
4109 absent.caller_principal = None;
4110 assert!(matches!(
4111 verify_event_chain(&key, &[absent]),
4112 VerifyOutcome::Broken { .. }
4113 ));
4114 }
4115
4116 #[test]
4117 fn an_unknown_event_version_cannot_verify_rather_than_breaking() {
4118 let key = fixed_key();
4122 let mut row = event_row(
4123 &key,
4124 1,
4125 AuditEventKind::ApprovalGranted,
4126 "tx1",
4127 "digest1",
4128 "2026-08-18T09:00:00.000Z",
4129 EventIdentity::V2 {
4130 caller_principal: "uid:1000",
4131 },
4132 "",
4133 );
4134 row.chain_version = EVENT_VERSION_CURRENT + 1;
4135 let outcome = verify_event_chain(&key, &[row]);
4136 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
4137 assert_eq!(outcome_to_exit_code(&outcome), 2);
4138 }
4139
4140 #[test]
4141 fn a_mixed_legacy_and_v2_event_chain_verifies() {
4142 let key = fixed_key();
4147 let legacy = event_row(
4148 &key,
4149 1,
4150 AuditEventKind::ApprovalGranted,
4151 "tx-old",
4152 "digest-old",
4153 "2026-08-18T09:00:00.000Z",
4154 EventIdentity::LegacyV1,
4155 "",
4156 );
4157 let v2 = event_row(
4158 &key,
4159 2,
4160 AuditEventKind::ApprovalGranted,
4161 "tx-new",
4162 "digest-new",
4163 "2026-09-01T09:00:00.000Z",
4164 EventIdentity::V2 {
4165 caller_principal: "uid:1000",
4166 },
4167 &legacy.chain_hash,
4168 );
4169 assert_eq!(legacy.chain_version, EVENT_VERSION_LEGACY);
4170 assert_eq!(v2.chain_version, EVENT_VERSION_V2);
4171 assert_eq!(
4172 verify_event_chain(&key, &[legacy, v2]),
4173 VerifyOutcome::Intact { rows_checked: 2 }
4174 );
4175 }
4176
4177 #[test]
4178 fn event_kind_spellings_are_stable() {
4179 assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
4182 assert_eq!(
4183 AuditEventKind::ApprovalConsumed.as_str(),
4184 "approval_consumed"
4185 );
4186 assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
4187 }
4188
4189 #[test]
4190 fn intact_event_chain_verifies() {
4191 let key = fixed_key();
4192 let rows = build_event_chain(
4193 &key,
4194 &[
4195 AuditEventKind::ApprovalGranted,
4196 AuditEventKind::ApprovalConsumed,
4197 ],
4198 );
4199 assert_eq!(
4200 verify_event_chain(&key, &rows),
4201 VerifyOutcome::Intact { rows_checked: 2 }
4202 );
4203 }
4204
4205 #[test]
4206 fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
4207 let key = fixed_key();
4210 let mut rows = build_event_chain(
4211 &key,
4212 &[
4213 AuditEventKind::ApprovalGranted,
4214 AuditEventKind::ApprovalConsumed,
4215 AuditEventKind::ApprovalRevoked,
4216 ],
4217 );
4218 rows.remove(1);
4219 match verify_event_chain(&key, &rows) {
4220 VerifyOutcome::Broken {
4221 first_broken_seq, ..
4222 } => assert_eq!(first_broken_seq, 3),
4223 other => panic!("expected Broken, got {other:?}"),
4224 }
4225 }
4226
4227 #[test]
4228 fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
4229 let key = fixed_key();
4230 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
4231 rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
4232 assert!(matches!(
4233 verify_event_chain(&key, &rows),
4234 VerifyOutcome::Broken { .. }
4235 ));
4236 }
4237
4238 #[test]
4239 fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
4240 let key = fixed_key();
4241 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
4242 rows[0].kind = "approval_unicorned".to_string();
4243 assert!(matches!(
4244 verify_event_chain(&key, &rows),
4245 VerifyOutcome::Broken { .. }
4246 ));
4247 }
4248
4249 #[test]
4250 fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
4251 let key = fixed_key();
4255 let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
4256 let event_sig = key.event_hash(&content, "");
4257 let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
4258 assert_ne!(event_sig, row_sig);
4259
4260 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
4261 rows[0].chain_hash = row_sig;
4262 assert!(matches!(
4263 verify_event_chain(&key, &rows),
4264 VerifyOutcome::Broken { .. }
4265 ));
4266 }
4267
4268 #[test]
4271 fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
4272 let key = fixed_key();
4277 let events = build_event_chain(
4278 &key,
4279 &[
4280 AuditEventKind::ApprovalGranted,
4281 AuditEventKind::ApprovalConsumed,
4282 ],
4283 );
4284 let mut content = sample_content(1, "tx-after");
4285 content.identity = ChainIdentity::V2 {
4286 caller_role: "Dev",
4287 event_tip: &events[1].chain_hash,
4288 };
4289 let hash = key.chain_hash(&content, "");
4290 let tx_rows = vec![row_for(&content, "", hash)];
4291
4292 assert_eq!(
4293 verify_event_binding(&tx_rows, &events),
4294 BindingOutcome::Consistent {
4295 bindings_checked: 1
4296 }
4297 );
4298
4299 let truncated = &events[..1];
4300 assert_eq!(
4301 verify_event_chain(&key, truncated),
4302 VerifyOutcome::Intact { rows_checked: 1 },
4303 "a truncated event chain still walks clean; the binding is the detector"
4304 );
4305 match verify_event_binding(&tx_rows, truncated) {
4306 BindingOutcome::MissingEvent {
4307 transaction_seq, ..
4308 } => assert_eq!(transaction_seq, 1),
4309 other => panic!("expected MissingEvent, got {other:?}"),
4310 }
4311 }
4312
4313 #[test]
4314 fn legacy_rows_carry_no_binding_to_check() {
4315 let key = fixed_key();
4318 let mut content = sample_content(1, "tx-legacy");
4319 content.identity = ChainIdentity::LegacyV1;
4320 let hash = key.chain_hash(&content, "");
4321 let rows = vec![row_for(&content, "", hash)];
4322 assert_eq!(
4323 verify_event_binding(&rows, &[]),
4324 BindingOutcome::Consistent {
4325 bindings_checked: 0
4326 }
4327 );
4328 }
4329
4330 #[test]
4331 fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
4332 let verification = AuditVerification {
4337 chain: VerifyOutcome::Broken {
4338 rows_checked: 1,
4339 first_broken_seq: 2,
4340 first_broken_transaction_id: "tx".to_string(),
4341 expected: "e".to_string(),
4342 actual: "a".to_string(),
4343 },
4344 events: VerifyOutcome::CannotVerify {
4345 reason: "no key".to_string(),
4346 },
4347 binding: BindingOutcome::Consistent {
4348 bindings_checked: 0,
4349 },
4350 attribution: None,
4351 status: None,
4352 };
4353 assert_eq!(verification.exit_code(), 1);
4354 }
4355
4356 #[test]
4357 fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
4358 let verification = AuditVerification {
4359 chain: VerifyOutcome::Intact { rows_checked: 3 },
4360 events: VerifyOutcome::Intact { rows_checked: 1 },
4361 binding: BindingOutcome::MissingEvent {
4362 transaction_seq: 3,
4363 event_tip: "abc".to_string(),
4364 },
4365 attribution: None,
4366 status: None,
4367 };
4368 assert_eq!(verification.exit_code(), 1);
4369 }
4370
4371 #[test]
4372 fn all_three_clean_is_exit_zero() {
4373 let verification = AuditVerification {
4374 chain: VerifyOutcome::Intact { rows_checked: 3 },
4375 events: VerifyOutcome::Intact { rows_checked: 2 },
4376 binding: BindingOutcome::Consistent {
4377 bindings_checked: 1,
4378 },
4379 attribution: None,
4380 status: None,
4381 };
4382 assert_eq!(verification.exit_code(), 0);
4383 }
4384
4385 #[test]
4386 fn binding_exit_codes_split_clean_tampered_and_unchecked() {
4387 assert_eq!(
4388 binding_outcome_to_exit_code(&BindingOutcome::Consistent {
4389 bindings_checked: 0
4390 }),
4391 0
4392 );
4393 assert_eq!(
4394 binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
4395 transaction_seq: 7,
4396 event_tip: "abc".to_string()
4397 }),
4398 1
4399 );
4400 assert_eq!(binding_outcome_to_exit_code(&BindingOutcome::NotChecked), 2);
4401 }
4402
4403 #[test]
4404 fn intact_chain_verifies() {
4405 let key = fixed_key();
4406 let rows = build_chain(&key, 5);
4407 let outcome = verify_chain(&key, &rows);
4408 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
4409 assert_eq!(outcome_to_exit_code(&outcome), 0);
4410 }
4411
4412 #[test]
4413 fn empty_chain_verifies() {
4414 let key = fixed_key();
4415 let outcome = verify_chain(&key, &[]);
4416 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
4417 }
4418
4419 #[test]
4420 fn tampered_summary_breaks_chain_at_first_offending_row() {
4421 let key = fixed_key();
4422 let mut rows = build_chain(&key, 3);
4423 rows[1].summary = "TAMPERED".to_string();
4425 let outcome = verify_chain(&key, &rows);
4426 match outcome {
4427 VerifyOutcome::Broken {
4428 first_broken_seq, ..
4429 } => assert_eq!(first_broken_seq, 2),
4430 other => panic!("expected Broken, got {other:?}"),
4431 }
4432 }
4433
4434 #[test]
4435 fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
4436 let key = fixed_key();
4437 let mut rows = build_chain(&key, 4);
4438 rows.remove(1);
4440 let outcome = verify_chain(&key, &rows);
4441 match outcome {
4442 VerifyOutcome::Broken {
4443 first_broken_seq, ..
4444 } => assert_eq!(
4445 first_broken_seq, 3,
4446 "first broken row is the one whose prev_hash refers to deleted row"
4447 ),
4448 other => panic!("expected Broken, got {other:?}"),
4449 }
4450 }
4451
4452 #[test]
4453 fn inserted_forged_row_breaks_chain() {
4454 let key = fixed_key();
4461 let mut rows = build_chain(&key, 3);
4462
4463 let forged = ChainRow {
4465 caller_principal: Some("uid:1000".to_string()),
4469 seq: 2,
4470 key_id: CURRENT_KEY_ID.to_string(),
4471 transaction_id: "tx-forged".to_string(),
4472 request_id: "req-forged".to_string(),
4473 request_hash: "hash-forged".to_string(),
4474 action_name: "InstallFlatpak".to_string(),
4475 risk_level: RiskLevel::Medium,
4476 summary: "Forged row".to_string(),
4477 approval_id: None,
4478 warnings_json: "[]".to_string(),
4479 created_at: "2026-04-25T13:00:00Z".to_string(),
4480 prev_chain_hash: rows[0].chain_hash.clone(),
4482 chain_hash: "0".repeat(HASH_HEX_LEN),
4484 chain_version: CHAIN_VERSION_CURRENT,
4485 caller_role: Some("Dev".to_string()),
4486 event_tip: Some(String::new()),
4487 };
4488
4489 let mut rest: Vec<ChainRow> = rows.split_off(1);
4491 for r in rest.iter_mut() {
4492 r.seq += 1;
4493 }
4494 rows.push(forged);
4495 rows.extend(rest);
4496
4497 let outcome = verify_chain(&key, &rows);
4501 match outcome {
4502 VerifyOutcome::Broken {
4503 first_broken_seq, ..
4504 } => assert_eq!(
4505 first_broken_seq, 2,
4506 "the verifier must flag the forged row itself, not a later one"
4507 ),
4508 other => panic!("expected Broken, got {other:?}"),
4509 }
4510 }
4511
4512 #[test]
4515 fn signature_verifies_under_exported_public_key() {
4516 let key = fixed_key();
4518 let rows = build_chain(&key, 4);
4519 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
4520 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
4521 }
4522
4523 #[test]
4524 fn foreign_public_key_cannot_validate_chain() {
4525 let signer = AuditKey::from_bytes(vec![0x11; 32]);
4529 let rows = build_chain(&signer, 3);
4530 let other = AuditKey::from_bytes(vec![0x22; 32]);
4531 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
4532 assert!(matches!(
4533 outcome,
4534 VerifyOutcome::Broken {
4535 first_broken_seq: 1,
4536 ..
4537 }
4538 ));
4539 }
4540
4541 #[test]
4542 fn verifying_key_hex_is_a_32_byte_public_key() {
4543 let key = fixed_key();
4544 let vk_hex = key.verifying_key_hex();
4545 assert_eq!(
4546 vk_hex.len(),
4547 64,
4548 "32-byte ed25519 public key = 64 hex chars"
4549 );
4550 assert!(hex::decode(&vk_hex).is_ok());
4551 }
4552
4553 #[test]
4554 fn malformed_signature_hex_is_broken_not_panic() {
4555 let key = fixed_key();
4556 let mut rows = build_chain(&key, 2);
4557 rows[0].chain_hash = "not-valid-hex!!".to_string();
4558 let outcome = verify_chain(&key, &rows);
4559 assert!(matches!(
4560 outcome,
4561 VerifyOutcome::Broken {
4562 first_broken_seq: 1,
4563 ..
4564 }
4565 ));
4566 }
4567
4568 #[test]
4569 fn bad_public_key_hex_yields_cannot_verify() {
4570 let key = fixed_key();
4571 let rows = build_chain(&key, 1);
4572 let outcome = verify_chain_with_pubkey("zz", &rows);
4573 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
4574 }
4575
4576 #[test]
4577 fn load_or_generate_writes_public_key_sidecar() {
4578 let dir = tempfile::tempdir().unwrap();
4579 let path = dir.path().join("audit-key");
4580 let key = AuditKey::load_or_generate(&path).unwrap();
4581 let pub_path = dir.path().join("audit-key.pub");
4582 assert!(pub_path.exists(), "public key sidecar must be written");
4583 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
4584 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
4585 }
4586
4587 #[test]
4588 fn wrong_key_id_yields_cannot_verify() {
4589 let key = fixed_key();
4590 let mut rows = build_chain(&key, 2);
4591 rows[0].key_id = "v99".to_string();
4592 let outcome = verify_chain(&key, &rows);
4593 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
4594 assert_eq!(outcome_to_exit_code(&outcome), 2);
4595 }
4596
4597 #[test]
4598 fn exit_code_for_each_outcome() {
4599 assert_eq!(
4600 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
4601 0
4602 );
4603 assert_eq!(
4604 outcome_to_exit_code(&VerifyOutcome::Broken {
4605 rows_checked: 0,
4606 first_broken_seq: 1,
4607 first_broken_transaction_id: String::new(),
4608 expected: String::new(),
4609 actual: String::new(),
4610 }),
4611 1
4612 );
4613 assert_eq!(
4614 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
4615 reason: String::new()
4616 }),
4617 2
4618 );
4619 }
4620
4621 #[test]
4624 fn load_or_generate_creates_with_0600_mode() {
4625 use std::os::unix::fs::PermissionsExt;
4626 let dir = tempfile::tempdir().unwrap();
4627 let path = dir.path().join("audit-key");
4628 let _key = AuditKey::load_or_generate(&path).unwrap();
4629 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
4630 assert_eq!(mode, 0o600);
4631 }
4632
4633 #[test]
4634 fn load_or_generate_is_idempotent() {
4635 let dir = tempfile::tempdir().unwrap();
4636 let path = dir.path().join("audit-key");
4637 let key1_bytes = {
4638 let _ = AuditKey::load_or_generate(&path).unwrap();
4639 std::fs::read(&path).unwrap()
4640 };
4641 let _ = AuditKey::load_or_generate(&path).unwrap();
4642 let key2_bytes = std::fs::read(&path).unwrap();
4643 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
4644 }
4645
4646 #[test]
4647 fn rejects_world_readable_key_file() {
4648 use std::os::unix::fs::PermissionsExt;
4649 let dir = tempfile::tempdir().unwrap();
4650 let path = dir.path().join("audit-key");
4651 std::fs::write(&path, vec![0u8; 32]).unwrap();
4652 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
4653 let result = AuditKey::load_or_generate(&path);
4654 assert!(matches!(
4655 result,
4656 Err(AuditKeyError::UnsafePermissions { .. })
4657 ));
4658 }
4659
4660 #[test]
4661 fn rejects_short_key_file() {
4662 use std::os::unix::fs::PermissionsExt;
4663 let dir = tempfile::tempdir().unwrap();
4664 let path = dir.path().join("audit-key");
4665 std::fs::write(&path, vec![0u8; 8]).unwrap();
4666 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
4667 let result = AuditKey::load_or_generate(&path);
4668 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
4669 }
4670
4671 #[test]
4677 fn debug_redacts_key_bytes_and_their_hex_encoding() {
4678 let raw = (0u8..32).collect::<Vec<u8>>();
4680 let key = AuditKey::from_bytes(raw.clone());
4681 let dbg = format!("{key:?}");
4682
4683 assert!(
4684 dbg.contains("redacted"),
4685 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
4686 );
4687
4688 let hex_lower = hex::encode(&raw);
4690 let hex_upper = hex::encode_upper(&raw);
4691 assert!(
4692 !dbg.contains(&hex_lower),
4693 "Debug output leaks lowercase hex of key bytes: {dbg}"
4694 );
4695 assert!(
4696 !dbg.contains(&hex_upper),
4697 "Debug output leaks uppercase hex of key bytes: {dbg}"
4698 );
4699
4700 for b in &raw {
4704 let pair = format!("{b:02x}");
4705 if *b >= 0x10 && pair != "1e" && pair != "1f" {
4710 assert!(
4711 !dbg.contains(&pair),
4712 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
4713 );
4714 }
4715 }
4716 }
4717
4718 #[test]
4721 fn debug_preserves_key_id() {
4722 let key = AuditKey::from_bytes(vec![0xff; 32]);
4723 let dbg = format!("{key:?}");
4724 assert!(
4725 dbg.contains("key_id"),
4726 "Debug output must label the key_id field: {dbg}"
4727 );
4728 assert!(
4729 dbg.contains(CURRENT_KEY_ID),
4730 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
4731 );
4732 }
4733
4734 #[test]
4737 fn checkpoint_consistent_with_intact_chain() {
4738 let key = fixed_key();
4739 let rows = build_chain(&key, 5);
4740 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4741 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
4742 assert_eq!(
4743 outcome,
4744 CheckpointOutcome::Consistent {
4745 checkpoints_checked: 1
4746 }
4747 );
4748 }
4749
4750 #[test]
4751 fn checkpoint_detects_truncation() {
4752 let key = fixed_key();
4753 let full = build_chain(&key, 5);
4754 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
4756 let truncated = &full[..3];
4757 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
4758 assert!(matches!(
4759 outcome,
4760 CheckpointOutcome::Truncated {
4761 checkpoint_seq: 5,
4762 current_max_seq: 3
4763 }
4764 ));
4765 }
4766
4767 #[test]
4768 fn checkpoint_detects_rewrite() {
4769 let key = fixed_key();
4770 let mut rows = build_chain(&key, 4);
4771 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4772 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
4774 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
4775 assert!(matches!(
4776 outcome,
4777 CheckpointOutcome::TipMismatch { seq: 3, .. }
4778 ));
4779 }
4780
4781 #[test]
4782 fn checkpoint_bad_signature_detected() {
4783 let key = fixed_key();
4784 let rows = build_chain(&key, 3);
4785 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
4786 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
4788 assert!(matches!(
4789 outcome,
4790 CheckpointOutcome::BadSignature { seq: 2 }
4791 ));
4792 }
4793
4794 #[test]
4795 fn checkpoint_foreign_key_rejected() {
4796 let signer = AuditKey::from_bytes(vec![0x11; 32]);
4798 let rows = build_chain(&signer, 3);
4799 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
4800 let other = AuditKey::from_bytes(vec![0x22; 32]);
4801 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
4802 assert!(matches!(
4803 outcome,
4804 CheckpointOutcome::BadSignature { seq: 2 }
4805 ));
4806 }
4807
4808 #[test]
4809 fn pubkey_verify_detects_tampered_middle_row() {
4810 let key = fixed_key();
4813 let mut rows = build_chain(&key, 4);
4814 rows[2].summary = "TAMPERED".to_string();
4815 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
4816 assert!(matches!(
4817 outcome,
4818 VerifyOutcome::Broken {
4819 first_broken_seq: 3,
4820 ..
4821 }
4822 ));
4823 }
4824
4825 #[test]
4826 fn multiple_checkpoints_all_consistent() {
4827 let key = fixed_key();
4828 let rows = build_chain(&key, 5);
4829 let cps = vec![
4830 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
4831 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
4832 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
4833 ];
4834 assert_eq!(
4835 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
4836 CheckpointOutcome::Consistent {
4837 checkpoints_checked: 3
4838 }
4839 );
4840 }
4841
4842 #[test]
4843 fn middle_checkpoint_failure_is_reported() {
4844 let key = fixed_key();
4846 let mut rows = build_chain(&key, 5);
4847 let cps = vec![
4848 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
4849 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
4850 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
4851 ];
4852 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
4854 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
4855 CheckpointOutcome::TipMismatch { seq: 3, .. }
4856 ));
4857 }
4858
4859 #[test]
4860 fn checkpoint_created_at_is_signed() {
4861 let key = fixed_key();
4863 let rows = build_chain(&key, 3);
4864 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4865 cp.created_at = "2020-01-01T00:00:00Z".to_string();
4866 assert!(matches!(
4867 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
4868 CheckpointOutcome::BadSignature { .. }
4869 ));
4870 }
4871
4872 #[test]
4873 fn checkpoint_seq_is_signed() {
4874 let key = fixed_key();
4876 let rows = build_chain(&key, 3);
4877 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4878 cp.seq = 2;
4879 assert!(matches!(
4880 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
4881 CheckpointOutcome::BadSignature { seq: 2 }
4882 ));
4883 }
4884
4885 #[test]
4886 fn checkpoint_detects_full_wipe() {
4887 let key = fixed_key();
4889 let full = build_chain(&key, 3);
4890 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
4891 assert!(matches!(
4892 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
4893 CheckpointOutcome::Truncated {
4894 checkpoint_seq: 3,
4895 current_max_seq: 0
4896 }
4897 ));
4898 }
4899
4900 #[test]
4901 fn domain_tags_are_distinct_and_prefix_free() {
4902 let tags: [(&str, &[u8]); 5] = [
4909 ("row", ROW_DOMAIN),
4910 ("checkpoint", CHECKPOINT_DOMAIN),
4911 ("approval", APPROVAL_DOMAIN),
4912 ("event", EVENT_DOMAIN),
4913 ("request_hash", REQUEST_HASH_DOMAIN),
4914 ];
4915 for (i, (name_a, a)) in tags.iter().enumerate() {
4916 for (name_b, b) in tags.iter().skip(i + 1) {
4917 assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
4918 assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
4919 assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
4920 }
4921 }
4922 }
4923
4924 #[test]
4925 fn approval_receipt_framing_is_injective() {
4926 let key = fixed_key();
4931 let sep = "\u{1f}";
4932 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
4933 let b = key.approval_receipt("tx", &format!("b{sep}c"));
4934 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
4935
4936 assert_eq!(
4938 key.approval_receipt("tx-1", "hash-1"),
4939 key.approval_receipt("tx-1", "hash-1"),
4940 );
4941 assert_eq!(
4943 key.approval_commitment("tx-1", "hash-1"),
4944 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
4945 );
4946 }
4947}