1use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
93use hmac::{Hmac, KeyInit, Mac};
94use sha2::{Digest, Sha256};
95use std::path::{Path, PathBuf};
96use sysknife_types::RiskLevel;
97use zeroize::{Zeroize, Zeroizing};
98
99pub const CURRENT_KEY_ID: &str = "v1";
102
103pub const HASH_HEX_LEN: usize = 128;
105
106const ROW_DOMAIN: &[u8] = b"sysknife-audit-row-v1\x1f";
110const CHECKPOINT_DOMAIN: &[u8] = b"sysknife-checkpoint-v1\x1f";
111const APPROVAL_DOMAIN: &[u8] = b"sysknife-approval-receipt-v1\x1f";
112const EVENT_DOMAIN: &[u8] = b"sysknife-audit-event-v1\x1f";
113const REQUEST_HASH_DOMAIN: &[u8] = b"sysknife-request-hash-v1\x1f";
118
119pub const CHAIN_VERSION_LEGACY: u32 = 1;
122pub const CHAIN_VERSION_V2: u32 = 2;
132pub const CHAIN_VERSION_V3: u32 = 3;
140pub const CHAIN_VERSION_V4: u32 = 4;
164pub const CHAIN_VERSION_CURRENT: u32 = CHAIN_VERSION_V4;
171
172pub const EVENT_VERSION_LEGACY: u32 = 1;
176pub const EVENT_VERSION_V2: u32 = 2;
185pub const EVENT_VERSION_CURRENT: u32 = EVENT_VERSION_V2;
189
190#[derive(Clone)]
202pub struct AuditKey {
203 key_id: String,
204 signing: SigningKey,
205}
206
207impl std::fmt::Debug for AuditKey {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 f.debug_struct("AuditKey")
214 .field("key_id", &self.key_id)
215 .field("signing", &format_args!("<redacted signing key>"))
216 .finish()
217 }
218}
219
220#[derive(Debug, thiserror::Error)]
225pub enum AuditKeyError {
226 #[error("io error reading audit key {path}: {source}")]
227 Io {
228 path: PathBuf,
229 #[source]
230 source: std::io::Error,
231 },
232 #[error(
233 "audit key file {path} has unsafe permissions {mode:#o}; \
234 tighten with `chmod 600 {path:?}` and restart"
235 )]
236 UnsafePermissions { path: PathBuf, mode: u32 },
237 #[error(
238 "audit key file {path} is too short ({len} bytes); \
239 expected at least 32 bytes of random material"
240 )]
241 KeyTooShort { path: PathBuf, len: usize },
242}
243
244impl AuditKey {
245 pub fn load_or_generate(path: &Path) -> Result<Self, AuditKeyError> {
252 if !path.exists() {
253 generate_key_at(path)?;
254 }
255
256 let metadata = std::fs::metadata(path).map_err(|e| AuditKeyError::Io {
257 path: path.to_path_buf(),
258 source: e,
259 })?;
260
261 use std::os::unix::fs::PermissionsExt;
262 let mode = metadata.permissions().mode() & 0o777;
263 if mode & 0o077 != 0 {
264 return Err(AuditKeyError::UnsafePermissions {
266 path: path.to_path_buf(),
267 mode,
268 });
269 }
270
271 let mut key_bytes = std::fs::read(path).map_err(|e| AuditKeyError::Io {
272 path: path.to_path_buf(),
273 source: e,
274 })?;
275 if key_bytes.len() < 32 {
276 return Err(AuditKeyError::KeyTooShort {
277 path: path.to_path_buf(),
278 len: key_bytes.len(),
279 });
280 }
281
282 let mut seed = [0u8; 32];
283 seed.copy_from_slice(&key_bytes[..32]);
284 let signing = SigningKey::from_bytes(&seed);
285 seed.zeroize();
286 key_bytes.zeroize();
287
288 Ok(Self {
289 key_id: CURRENT_KEY_ID.to_string(),
290 signing,
291 })
292 }
293
294 #[cfg(test)]
297 pub fn from_bytes(bytes: Vec<u8>) -> Self {
298 let mut seed = [0u8; 32];
299 seed.copy_from_slice(&bytes[..32]);
300 let signing = SigningKey::from_bytes(&seed);
301 seed.zeroize();
302 Self {
303 key_id: CURRENT_KEY_ID.to_string(),
304 signing,
305 }
306 }
307
308 pub fn key_id(&self) -> &str {
310 &self.key_id
311 }
312
313 pub fn request_hash_key(&self) -> RequestHashKey {
321 RequestHashKey::derive_from(self.signing.as_bytes())
322 }
323
324 pub fn chain_hash(&self, content: &ChainContent, prev_chain_hash: &str) -> String {
329 let sig = self.signing.sign(&chain_message(content, prev_chain_hash));
330 hex::encode(sig.to_bytes())
331 }
332
333 pub fn verifying_key(&self) -> VerifyingKey {
335 self.signing.verifying_key()
336 }
337
338 pub fn verifying_key_hex(&self) -> String {
341 hex::encode(self.signing.verifying_key().to_bytes())
342 }
343
344 pub fn approval_receipt(&self, transaction_id: &str, request_hash: &str) -> String {
348 let mut message = APPROVAL_DOMAIN.to_vec();
354 push_field(&mut message, "txid", transaction_id);
355 push_field(&mut message, "reqhash", request_hash);
356 hex::encode(self.signing.sign(&message).to_bytes())
357 }
358
359 pub fn approval_commitment(&self, transaction_id: &str, request_hash: &str) -> String {
361 approval_receipt_digest(&self.approval_receipt(transaction_id, request_hash))
362 }
363}
364
365pub fn approval_receipt_digest(receipt: &str) -> String {
366 hex::encode(Sha256::digest(receipt.as_bytes()))
367}
368
369fn chain_message(content: &ChainContent, prev_chain_hash: &str) -> Vec<u8> {
375 let mut msg = ROW_DOMAIN.to_vec();
376 msg.extend_from_slice(&content.canonical_bytes());
377 msg.extend_from_slice(prev_chain_hash.as_bytes());
378 msg
379}
380
381pub fn resolve_audit_key_path(db_path: &Path) -> PathBuf {
387 std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
388 .map(PathBuf::from)
389 .unwrap_or_else(|_| {
390 db_path
391 .parent()
392 .unwrap_or_else(|| Path::new("."))
393 .join("audit-key")
394 })
395}
396
397#[derive(Clone)]
412pub struct RequestHashKey {
413 key: Zeroizing<[u8; 32]>,
414}
415
416impl std::fmt::Debug for RequestHashKey {
417 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
418 f.debug_struct("RequestHashKey")
419 .field("key", &format_args!("<redacted hmac key>"))
420 .finish()
421 }
422}
423
424impl RequestHashKey {
425 fn derive_from(ikm: &[u8]) -> Self {
428 use hkdf::Hkdf;
429 let hk = Hkdf::<Sha256>::new(None, ikm);
430 let mut okm = Zeroizing::new([0u8; 32]);
431 hk.expand(REQUEST_HASH_DOMAIN, &mut *okm)
434 .expect("32-byte HKDF-SHA256 expansion is infallible");
435 Self { key: okm }
436 }
437
438 pub fn request_hash(&self, action_name: &str, canonical_params: &str) -> String {
448 let mut mac = Hmac::<Sha256>::new_from_slice(&self.key[..])
449 .expect("HMAC-SHA256 accepts any key length");
450 mac.update(action_name.as_bytes());
451 mac.update(b"\x00");
452 mac.update(canonical_params.as_bytes());
453 hex::encode(mac.finalize().into_bytes())
454 }
455}
456
457fn generate_key_at(path: &Path) -> Result<(), AuditKeyError> {
460 use std::io::Write as _;
461 use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
462
463 if let Some(parent) = path.parent() {
464 if !parent.exists() {
465 std::fs::DirBuilder::new()
466 .recursive(true)
467 .mode(0o700)
468 .create(parent)
469 .map_err(|e| AuditKeyError::Io {
470 path: parent.to_path_buf(),
471 source: e,
472 })?;
473 }
474 }
475
476 let mut bytes = [0u8; 32];
477 fill_random(&mut bytes).map_err(|e| AuditKeyError::Io {
478 path: path.to_path_buf(),
479 source: e,
480 })?;
481
482 let mut f = std::fs::OpenOptions::new()
483 .write(true)
484 .create_new(true)
485 .mode(0o600)
486 .open(path)
487 .map_err(|e| AuditKeyError::Io {
488 path: path.to_path_buf(),
489 source: e,
490 })?;
491 f.write_all(&bytes).map_err(|e| AuditKeyError::Io {
492 path: path.to_path_buf(),
493 source: e,
494 })?;
495 f.sync_all().map_err(|e| AuditKeyError::Io {
496 path: path.to_path_buf(),
497 source: e,
498 })?;
499
500 let signing = SigningKey::from_bytes(&bytes);
503 let pub_hex = hex::encode(signing.verifying_key().to_bytes());
504 bytes.zeroize();
505 let mut pub_path = path.as_os_str().to_os_string();
506 pub_path.push(".pub");
507 std::fs::write(&pub_path, format!("{pub_hex}\n")).map_err(|e| AuditKeyError::Io {
508 path: PathBuf::from(pub_path),
509 source: e,
510 })?;
511 Ok(())
512}
513
514fn fill_random(buf: &mut [u8]) -> std::io::Result<()> {
516 use std::io::Read as _;
517 let mut f = std::fs::File::open("/dev/urandom")?;
518 f.read_exact(buf)
519}
520
521#[derive(Debug, Clone, Copy, PartialEq, Eq)]
584pub enum ChainIdentity<'a> {
585 LegacyV1,
587 V2 {
590 caller_role: &'a str,
595 event_tip: &'a str,
601 },
602 V3 {
609 caller_role: &'a str,
611 event_tip: &'a str,
613 caller_principal: &'a str,
624 },
625 V4 {
632 caller_role: &'a str,
634 event_tip: &'a str,
636 caller_principal: &'a str,
638 },
639}
640
641impl ChainIdentity<'_> {
642 pub fn version(&self) -> u32 {
644 match self {
645 Self::LegacyV1 => CHAIN_VERSION_LEGACY,
646 Self::V2 { .. } => CHAIN_VERSION_V2,
647 Self::V3 { .. } => CHAIN_VERSION_V3,
648 Self::V4 { .. } => CHAIN_VERSION_V4,
649 }
650 }
651}
652
653#[derive(Debug, Clone, PartialEq, Eq)]
654pub struct ChainContent<'a> {
655 pub seq: u64,
656 pub key_id: &'a str,
657 pub transaction_id: &'a str,
658 pub request_id: &'a str,
659 pub request_hash: &'a str,
660 pub action_name: &'a str,
661 pub risk_level: RiskLevel,
662 pub summary: &'a str,
671 pub approval_id: Option<&'a str>,
674 pub warnings_json: &'a str,
676 pub created_at: &'a str,
677 pub identity: ChainIdentity<'a>,
679}
680
681impl<'a> ChainContent<'a> {
682 pub fn canonical_bytes(&self) -> Vec<u8> {
686 let risk_level_str = match self.risk_level {
687 RiskLevel::Low => "low",
688 RiskLevel::Medium => "medium",
689 RiskLevel::High => "high",
690 };
691 let approval = self.approval_id.unwrap_or("");
692
693 let mut buf = Vec::with_capacity(512);
694 push_field(&mut buf, "seq", &self.seq.to_string());
697 push_field(&mut buf, "key_id", self.key_id);
698 push_field(&mut buf, "transaction_id", self.transaction_id);
699 push_field(&mut buf, "request_id", self.request_id);
700 push_field(&mut buf, "request_hash", self.request_hash);
701 push_field(&mut buf, "action_name", self.action_name);
702 push_field(&mut buf, "risk_level", risk_level_str);
703 push_field(&mut buf, "summary", self.summary);
704 push_field(&mut buf, "approval_id", approval);
705 push_field(&mut buf, "warnings_json", self.warnings_json);
706 push_field(&mut buf, "created_at", self.created_at);
707 match self.identity {
723 ChainIdentity::LegacyV1 => {}
724 ChainIdentity::V2 {
725 caller_role,
726 event_tip,
727 } => {
728 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V2.to_string());
729 push_field(&mut buf, "caller_role", caller_role);
730 push_field(&mut buf, "event_tip", event_tip);
731 }
732 ChainIdentity::V3 {
733 caller_role,
734 event_tip,
735 caller_principal,
736 } => {
737 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V3.to_string());
738 push_field(&mut buf, "caller_role", caller_role);
739 push_field(&mut buf, "event_tip", event_tip);
740 push_field(&mut buf, "caller_principal", caller_principal);
741 }
742 ChainIdentity::V4 {
743 caller_role,
744 event_tip,
745 caller_principal,
746 } => {
747 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V4.to_string());
748 push_field(&mut buf, "caller_role", caller_role);
749 push_field(&mut buf, "event_tip", event_tip);
750 push_field(&mut buf, "caller_principal", caller_principal);
751 }
752 }
753 buf
754 }
755}
756
757fn push_field(buf: &mut Vec<u8>, tag: &str, value: &str) {
775 buf.extend_from_slice(tag.as_bytes());
776 buf.push(0x1E); for b in value.bytes() {
778 match b {
779 b'\\' => buf.extend_from_slice(b"\\\\"),
780 0x00 => buf.extend_from_slice(b"\\0"),
781 0x1E => buf.extend_from_slice(b"\\1E"),
782 0x1F => buf.extend_from_slice(b"\\1F"),
783 other => buf.push(other),
784 }
785 }
786 buf.push(0x1F); }
788
789#[derive(Debug, Clone, PartialEq, Eq)]
791pub enum VerifyOutcome {
792 Intact { rows_checked: u64 },
794 Broken {
796 rows_checked: u64,
797 first_broken_seq: u64,
798 first_broken_transaction_id: String,
799 expected: String,
800 actual: String,
801 },
802 CannotVerify { reason: String },
806}
807
808pub fn outcome_to_exit_code(outcome: &VerifyOutcome) -> i32 {
814 match outcome {
815 VerifyOutcome::Intact { .. } => 0,
816 VerifyOutcome::Broken { .. } => 1,
817 VerifyOutcome::CannotVerify { .. } => 2,
818 }
819}
820
821#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
823pub struct ChainRow {
824 pub seq: u64,
825 pub key_id: String,
826 pub transaction_id: String,
827 pub request_id: String,
828 pub request_hash: String,
829 pub action_name: String,
830 pub risk_level: RiskLevel,
831 pub summary: String,
832 pub approval_id: Option<String>,
833 pub warnings_json: String,
834 pub created_at: String,
835 pub prev_chain_hash: String,
836 pub chain_hash: String,
837 pub chain_version: u32,
839 pub caller_role: Option<String>,
841 pub event_tip: Option<String>,
844 pub caller_principal: Option<String>,
848}
849
850#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
852pub enum ChainExportError {
853 #[error("--since: {0:?} is not a valid RFC 3339 timestamp")]
854 InvalidSince(String),
855 #[error("audit row {seq} has an invalid RFC 3339 created_at value {created_at:?}")]
856 InvalidCreatedAt { seq: u64, created_at: String },
857}
858
859pub fn validate_export_since(since: &str) -> Result<(), ChainExportError> {
861 chrono::DateTime::parse_from_rfc3339(since)
862 .map(|_| ())
863 .map_err(|_| ChainExportError::InvalidSince(since.to_string()))
864}
865
866pub fn select_chain_rows_for_export(
873 rows: Vec<ChainRow>,
874 since: Option<&str>,
875 limit: Option<u32>,
876) -> Result<Vec<ChainRow>, ChainExportError> {
877 let since = since
878 .map(|value| {
879 chrono::DateTime::parse_from_rfc3339(value)
880 .map_err(|_| ChainExportError::InvalidSince(value.to_string()))
881 })
882 .transpose()?;
883 let limit = limit.map_or(usize::MAX, |value| value as usize);
884
885 rows.into_iter()
886 .filter_map(|row| {
887 let keep = match since.as_ref() {
888 None => true,
889 Some(since) => match chrono::DateTime::parse_from_rfc3339(&row.created_at) {
890 Ok(created_at) => created_at >= *since,
891 Err(_) => {
892 return Some(Err(ChainExportError::InvalidCreatedAt {
893 seq: row.seq,
894 created_at: row.created_at,
895 }));
896 }
897 },
898 };
899 keep.then_some(Ok(row))
900 })
901 .take(limit)
902 .collect()
903}
904
905#[derive(Debug)]
907pub(crate) enum RowIdentityError {
908 UnknownVersion(u32),
912 MissingField(&'static str),
918}
919
920impl ChainRow {
921 pub(crate) fn identity(&self) -> Result<ChainIdentity<'_>, RowIdentityError> {
924 match self.chain_version {
925 CHAIN_VERSION_LEGACY => Ok(ChainIdentity::LegacyV1),
926 CHAIN_VERSION_V2 => Ok(ChainIdentity::V2 {
927 caller_role: self
928 .caller_role
929 .as_deref()
930 .ok_or(RowIdentityError::MissingField("caller_role"))?,
931 event_tip: self
932 .event_tip
933 .as_deref()
934 .ok_or(RowIdentityError::MissingField("event_tip"))?,
935 }),
936 CHAIN_VERSION_V3 => Ok(ChainIdentity::V3 {
937 caller_role: self
938 .caller_role
939 .as_deref()
940 .ok_or(RowIdentityError::MissingField("caller_role"))?,
941 event_tip: self
942 .event_tip
943 .as_deref()
944 .ok_or(RowIdentityError::MissingField("event_tip"))?,
945 caller_principal: self
949 .caller_principal
950 .as_deref()
951 .filter(|p| !p.is_empty())
952 .ok_or(RowIdentityError::MissingField("caller_principal"))?,
953 }),
954 CHAIN_VERSION_V4 => Ok(ChainIdentity::V4 {
955 caller_role: self
956 .caller_role
957 .as_deref()
958 .ok_or(RowIdentityError::MissingField("caller_role"))?,
959 event_tip: self
960 .event_tip
961 .as_deref()
962 .ok_or(RowIdentityError::MissingField("event_tip"))?,
963 caller_principal: self
965 .caller_principal
966 .as_deref()
967 .filter(|p| !p.is_empty())
968 .ok_or(RowIdentityError::MissingField("caller_principal"))?,
969 }),
970 other => Err(RowIdentityError::UnknownVersion(other)),
971 }
972 }
973}
974
975pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
979 verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
980}
981
982pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
987 match parse_verifying_key(verifying_key_hex) {
988 Some(vk) => verify_rows(&vk, None, rows),
989 None => VerifyOutcome::CannotVerify {
990 reason: format!(
991 "invalid public key hex ({} chars); expected 64 hex chars of a \
992 32-byte Ed25519 public key",
993 verifying_key_hex.len()
994 ),
995 },
996 }
997}
998
999fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
1000 let bytes = hex::decode(hex_str.trim()).ok()?;
1001 let arr: [u8; 32] = bytes.try_into().ok()?;
1002 VerifyingKey::from_bytes(&arr).ok()
1003}
1004
1005fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
1008 let Ok(bytes) = hex::decode(sig_hex) else {
1009 return false;
1010 };
1011 let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
1012 return false;
1013 };
1014 vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
1015}
1016
1017fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
1021 let mut last_hash = String::new();
1022 let mut rows_checked = 0u64;
1023 for row in rows {
1024 if let Some(kid) = expect_key_id {
1025 if row.key_id != kid {
1026 return VerifyOutcome::CannotVerify {
1027 reason: format!(
1028 "row seq={} uses key_id={:?} but only {:?} is loaded; \
1029 epoch keys not yet supported",
1030 row.seq, row.key_id, kid
1031 ),
1032 };
1033 }
1034 }
1035 if row.prev_chain_hash != last_hash {
1036 return VerifyOutcome::Broken {
1037 rows_checked,
1038 first_broken_seq: row.seq,
1039 first_broken_transaction_id: row.transaction_id.clone(),
1040 expected: format!("prev_chain_hash={last_hash}"),
1041 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
1042 };
1043 }
1044 let identity = match row.identity() {
1045 Ok(identity) => identity,
1046 Err(RowIdentityError::UnknownVersion(v)) => {
1047 return VerifyOutcome::CannotVerify {
1048 reason: format!(
1049 "row seq={} declares chain_version={v}, which this binary cannot \
1050 reproduce (it understands {CHAIN_VERSION_LEGACY}..={CHAIN_VERSION_CURRENT}); \
1051 verify with a build at least as new as the one that wrote the chain",
1052 row.seq
1053 ),
1054 };
1055 }
1056 Err(RowIdentityError::MissingField(field)) => {
1057 let stored = match field {
1062 "caller_role" => row.caller_role.as_deref(),
1063 "event_tip" => row.event_tip.as_deref(),
1064 "caller_principal" => row.caller_principal.as_deref(),
1065 _ => None,
1066 };
1067 return VerifyOutcome::Broken {
1068 rows_checked,
1069 first_broken_seq: row.seq,
1070 first_broken_transaction_id: row.transaction_id.clone(),
1071 expected: format!(
1072 "chain_version={} row carrying a non-empty {field}",
1073 row.chain_version
1074 ),
1075 actual: match stored {
1078 None => format!("{field}=NULL"),
1079 Some("") => format!("{field}='' (empty, not NULL)"),
1080 Some(other) => format!("{field}={other:?}"),
1081 },
1082 };
1083 }
1084 };
1085 let content = ChainContent {
1086 seq: row.seq,
1087 key_id: &row.key_id,
1088 transaction_id: &row.transaction_id,
1089 request_id: &row.request_id,
1090 request_hash: &row.request_hash,
1091 action_name: &row.action_name,
1092 risk_level: row.risk_level,
1093 summary: &row.summary,
1094 approval_id: row.approval_id.as_deref(),
1095 warnings_json: &row.warnings_json,
1096 created_at: &row.created_at,
1097 identity,
1098 };
1099 let msg = chain_message(&content, &row.prev_chain_hash);
1100 if !signature_ok(vk, &msg, &row.chain_hash) {
1101 return VerifyOutcome::Broken {
1102 rows_checked,
1103 first_broken_seq: row.seq,
1104 first_broken_transaction_id: row.transaction_id.clone(),
1105 expected: "valid ed25519 signature".to_string(),
1106 actual: row.chain_hash.clone(),
1107 };
1108 }
1109 last_hash = row.chain_hash.clone();
1110 rows_checked += 1;
1111 }
1112 VerifyOutcome::Intact { rows_checked }
1113}
1114
1115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1128pub enum AuditEventKind {
1129 ApprovalGranted,
1131 ApprovalConsumed,
1133 ApprovalRevoked,
1135 StatusQueued,
1144 StatusRunning,
1145 StatusSucceeded,
1146 StatusFailed,
1147 StatusCanceled,
1148 StatusRolledBack,
1149 StatusNeedsReboot,
1150}
1151
1152impl From<sysknife_types::JobState> for AuditEventKind {
1153 fn from(state: sysknife_types::JobState) -> Self {
1154 use sysknife_types::JobState as S;
1155 match state {
1156 S::Queued => Self::StatusQueued,
1157 S::Running => Self::StatusRunning,
1158 S::Succeeded => Self::StatusSucceeded,
1159 S::Failed => Self::StatusFailed,
1160 S::Canceled => Self::StatusCanceled,
1161 S::RolledBack => Self::StatusRolledBack,
1162 S::NeedsReboot => Self::StatusNeedsReboot,
1163 }
1164 }
1165}
1166
1167impl AuditEventKind {
1168 pub const ALL: &'static [AuditEventKind] = &[
1179 Self::ApprovalGranted,
1180 Self::ApprovalConsumed,
1181 Self::ApprovalRevoked,
1182 Self::StatusQueued,
1183 Self::StatusRunning,
1184 Self::StatusSucceeded,
1185 Self::StatusFailed,
1186 Self::StatusCanceled,
1187 Self::StatusRolledBack,
1188 Self::StatusNeedsReboot,
1189 ];
1190
1191 pub fn as_str(&self) -> &'static str {
1194 match self {
1195 Self::ApprovalGranted => "approval_granted",
1196 Self::ApprovalConsumed => "approval_consumed",
1197 Self::ApprovalRevoked => "approval_revoked",
1198 Self::StatusQueued => "status_queued",
1199 Self::StatusRunning => "status_running",
1200 Self::StatusSucceeded => "status_succeeded",
1201 Self::StatusFailed => "status_failed",
1202 Self::StatusCanceled => "status_canceled",
1203 Self::StatusRolledBack => "status_rolled_back",
1204 Self::StatusNeedsReboot => "status_needs_reboot",
1205 }
1206 }
1207}
1208
1209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1219pub enum EventIdentity<'a> {
1220 LegacyV1,
1224 V2 {
1231 caller_principal: &'a str,
1237 },
1238}
1239
1240impl EventIdentity<'_> {
1241 pub fn version(&self) -> u32 {
1243 match self {
1244 Self::LegacyV1 => EVENT_VERSION_LEGACY,
1245 Self::V2 { .. } => EVENT_VERSION_V2,
1246 }
1247 }
1248}
1249
1250#[derive(Debug, Clone, PartialEq, Eq)]
1252pub struct EventContent<'a> {
1253 pub seq: u64,
1254 pub key_id: &'a str,
1255 pub kind: AuditEventKind,
1256 pub transaction_id: &'a str,
1257 pub receipt_digest: &'a str,
1261 pub created_at: &'a str,
1262 pub identity: EventIdentity<'a>,
1264}
1265
1266impl EventContent<'_> {
1267 pub fn canonical_bytes(&self) -> Vec<u8> {
1270 let mut buf = Vec::with_capacity(256);
1271 push_field(&mut buf, "seq", &self.seq.to_string());
1272 push_field(&mut buf, "key_id", self.key_id);
1273 push_field(&mut buf, "kind", self.kind.as_str());
1274 push_field(&mut buf, "transaction_id", self.transaction_id);
1275 push_field(&mut buf, "receipt_digest", self.receipt_digest);
1276 push_field(&mut buf, "created_at", self.created_at);
1277 match self.identity {
1283 EventIdentity::LegacyV1 => {}
1284 EventIdentity::V2 { caller_principal } => {
1285 push_field(&mut buf, "event_version", &EVENT_VERSION_V2.to_string());
1286 push_field(&mut buf, "caller_principal", caller_principal);
1287 }
1288 }
1289 buf
1290 }
1291}
1292
1293fn event_message(content: &EventContent, prev_chain_hash: &str) -> Vec<u8> {
1294 let mut msg = EVENT_DOMAIN.to_vec();
1295 msg.extend_from_slice(&content.canonical_bytes());
1296 msg.extend_from_slice(prev_chain_hash.as_bytes());
1297 msg
1298}
1299
1300impl AuditKey {
1301 pub fn event_hash(&self, content: &EventContent, prev_chain_hash: &str) -> String {
1305 hex::encode(
1306 self.signing
1307 .sign(&event_message(content, prev_chain_hash))
1308 .to_bytes(),
1309 )
1310 }
1311}
1312
1313#[derive(Debug, Clone, PartialEq, Eq)]
1315pub struct EventRow {
1316 pub seq: u64,
1317 pub key_id: String,
1318 pub kind: String,
1322 pub transaction_id: String,
1323 pub receipt_digest: String,
1324 pub created_at: String,
1325 pub prev_chain_hash: String,
1326 pub chain_hash: String,
1327 pub chain_version: u32,
1330 pub caller_principal: Option<String>,
1336}
1337
1338impl EventRow {
1339 pub(crate) fn identity(&self) -> Result<EventIdentity<'_>, RowIdentityError> {
1343 match self.chain_version {
1344 EVENT_VERSION_LEGACY => Ok(EventIdentity::LegacyV1),
1345 EVENT_VERSION_V2 => Ok(EventIdentity::V2 {
1346 caller_principal: self
1347 .caller_principal
1348 .as_deref()
1349 .filter(|p| !p.is_empty())
1350 .ok_or(RowIdentityError::MissingField("caller_principal"))?,
1351 }),
1352 other => Err(RowIdentityError::UnknownVersion(other)),
1353 }
1354 }
1355}
1356
1357pub fn verify_event_chain(key: &AuditKey, rows: &[EventRow]) -> VerifyOutcome {
1359 verify_event_rows(&key.verifying_key(), Some(key.key_id()), rows)
1360}
1361
1362pub fn verify_event_chain_with_pubkey(verifying_key_hex: &str, rows: &[EventRow]) -> VerifyOutcome {
1364 match parse_verifying_key(verifying_key_hex) {
1365 Some(vk) => verify_event_rows(&vk, None, rows),
1366 None => VerifyOutcome::CannotVerify {
1367 reason: format!(
1368 "invalid public key hex ({} chars); expected 64 hex chars of a \
1369 32-byte Ed25519 public key",
1370 verifying_key_hex.len()
1371 ),
1372 },
1373 }
1374}
1375
1376fn verify_event_rows(
1377 vk: &VerifyingKey,
1378 expect_key_id: Option<&str>,
1379 rows: &[EventRow],
1380) -> VerifyOutcome {
1381 let mut last_hash = String::new();
1382 let mut rows_checked = 0u64;
1383 for row in rows {
1384 if let Some(kid) = expect_key_id {
1385 if row.key_id != kid {
1386 return VerifyOutcome::CannotVerify {
1387 reason: format!(
1388 "event seq={} uses key_id={:?} but only {:?} is loaded; \
1389 epoch keys not yet supported",
1390 row.seq, row.key_id, kid
1391 ),
1392 };
1393 }
1394 }
1395 if row.prev_chain_hash != last_hash {
1396 return VerifyOutcome::Broken {
1397 rows_checked,
1398 first_broken_seq: row.seq,
1399 first_broken_transaction_id: row.transaction_id.clone(),
1400 expected: format!("prev_chain_hash={last_hash}"),
1401 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
1402 };
1403 }
1404 let Some(kind) = parse_event_kind(&row.kind) else {
1407 return VerifyOutcome::Broken {
1408 rows_checked,
1409 first_broken_seq: row.seq,
1410 first_broken_transaction_id: row.transaction_id.clone(),
1411 expected: "a known event kind".to_string(),
1412 actual: format!("kind={:?}", row.kind),
1413 };
1414 };
1415 let identity = match row.identity() {
1421 Ok(identity) => identity,
1422 Err(RowIdentityError::UnknownVersion(v)) => {
1423 return VerifyOutcome::CannotVerify {
1424 reason: format!(
1425 "event seq={} declares chain_version={v}, which this binary cannot \
1426 reproduce (it understands {EVENT_VERSION_LEGACY}..={EVENT_VERSION_CURRENT}); \
1427 verify with a build at least as new as the one that wrote the chain",
1428 row.seq
1429 ),
1430 };
1431 }
1432 Err(RowIdentityError::MissingField(field)) => {
1433 return VerifyOutcome::Broken {
1434 rows_checked,
1435 first_broken_seq: row.seq,
1436 first_broken_transaction_id: row.transaction_id.clone(),
1437 expected: format!(
1438 "a non-empty {field} on a chain_version={} row",
1439 row.chain_version
1440 ),
1441 actual: format!("{field}={:?}", row.caller_principal),
1442 };
1443 }
1444 };
1445 let content = EventContent {
1446 seq: row.seq,
1447 key_id: &row.key_id,
1448 kind,
1449 transaction_id: &row.transaction_id,
1450 receipt_digest: &row.receipt_digest,
1451 created_at: &row.created_at,
1452 identity,
1453 };
1454 if !signature_ok(
1455 vk,
1456 &event_message(&content, &row.prev_chain_hash),
1457 &row.chain_hash,
1458 ) {
1459 return VerifyOutcome::Broken {
1460 rows_checked,
1461 first_broken_seq: row.seq,
1462 first_broken_transaction_id: row.transaction_id.clone(),
1463 expected: "valid ed25519 signature".to_string(),
1464 actual: row.chain_hash.clone(),
1465 };
1466 }
1467 last_hash = row.chain_hash.clone();
1468 rows_checked += 1;
1469 }
1470 VerifyOutcome::Intact { rows_checked }
1471}
1472
1473fn parse_event_kind(raw: &str) -> Option<AuditEventKind> {
1474 AuditEventKind::ALL
1475 .iter()
1476 .copied()
1477 .find(|kind| kind.as_str() == raw)
1478}
1479
1480#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1489#[serde(tag = "state", rename_all = "snake_case")]
1490pub enum StatusOutcome {
1491 Agrees { rows_checked: u64 },
1493 Disagrees { detail: String },
1495 CannotVerify { detail: String },
1500}
1501
1502pub fn status_outcome_to_exit_code(outcome: &StatusOutcome) -> i32 {
1506 match outcome {
1507 StatusOutcome::Agrees { .. } => 0,
1508 StatusOutcome::Disagrees { .. } => 1,
1509 StatusOutcome::CannotVerify { .. } => 2,
1510 }
1511}
1512
1513#[derive(Debug, Clone, PartialEq, Eq)]
1516pub enum BindingOutcome {
1517 Consistent { bindings_checked: u64 },
1519 NotChecked,
1522 MissingEvent {
1526 transaction_seq: u64,
1527 event_tip: String,
1528 },
1529}
1530
1531pub fn verify_event_binding(tx_rows: &[ChainRow], event_rows: &[EventRow]) -> BindingOutcome {
1541 let known: std::collections::HashSet<&str> = event_rows
1542 .iter()
1543 .map(|row| row.chain_hash.as_str())
1544 .collect();
1545 let mut bindings_checked = 0u64;
1546 for row in tx_rows {
1547 let Some(tip) = row.event_tip.as_deref() else {
1548 continue;
1549 };
1550 if tip.is_empty() {
1551 continue;
1552 }
1553 if !known.contains(tip) {
1554 return BindingOutcome::MissingEvent {
1555 transaction_seq: row.seq,
1556 event_tip: tip.to_string(),
1557 };
1558 }
1559 bindings_checked += 1;
1560 }
1561 BindingOutcome::Consistent { bindings_checked }
1562}
1563
1564#[derive(Debug, Clone, PartialEq, Eq)]
1571pub struct AuditVerification {
1572 pub chain: VerifyOutcome,
1573 pub events: VerifyOutcome,
1574 pub binding: BindingOutcome,
1575 pub attribution: Option<AttributionCensus>,
1591 pub status: Option<StatusOutcome>,
1599}
1600
1601#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1606enum RowStanding {
1607 NamesAccount,
1609 AttributionFailed,
1612 NotRecorded,
1616 Unattested,
1626}
1627
1628fn standing(row: &ChainRow) -> RowStanding {
1640 let stored = row.caller_principal.as_deref().filter(|p| !p.is_empty());
1648 match row.chain_version {
1649 CHAIN_VERSION_LEGACY | CHAIN_VERSION_V2 => match stored {
1650 None => RowStanding::NotRecorded,
1651 Some(_) => RowStanding::Unattested,
1652 },
1653 CHAIN_VERSION_V3 | CHAIN_VERSION_V4 => match stored {
1654 None => RowStanding::NotRecorded,
1655 Some(p) => match crate::auth::CallerPrincipal::classify(p) {
1656 crate::auth::PrincipalClaim::Account => RowStanding::NamesAccount,
1657 crate::auth::PrincipalClaim::AttributionFailed => RowStanding::AttributionFailed,
1658 crate::auth::PrincipalClaim::Unrecognized => RowStanding::Unattested,
1659 },
1660 },
1661 _ => RowStanding::Unattested,
1667 }
1668}
1669
1670#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1702pub struct AttributionCensus {
1703 named: u64,
1704 attribution_failed: u64,
1705 not_recorded: u64,
1706 unattested: u64,
1707 rows: u64,
1708}
1709
1710impl AttributionCensus {
1711 pub fn of(tx_rows: &[ChainRow]) -> Self {
1713 let mut census = Self {
1714 named: 0,
1715 attribution_failed: 0,
1716 not_recorded: 0,
1717 unattested: 0,
1718 rows: tx_rows.len() as u64,
1719 };
1720 for row in tx_rows {
1721 match standing(row) {
1722 RowStanding::NamesAccount => census.named += 1,
1723 RowStanding::AttributionFailed => census.attribution_failed += 1,
1724 RowStanding::NotRecorded => census.not_recorded += 1,
1725 RowStanding::Unattested => census.unattested += 1,
1726 }
1727 }
1728 census
1729 }
1730
1731 pub fn named(&self) -> u64 {
1737 self.named
1738 }
1739
1740 pub fn attribution_failed(&self) -> u64 {
1743 self.attribution_failed
1744 }
1745
1746 pub fn not_recorded(&self) -> u64 {
1749 self.not_recorded
1750 }
1751
1752 pub fn unattested(&self) -> u64 {
1755 self.unattested
1756 }
1757
1758 pub fn rows(&self) -> u64 {
1765 self.rows
1766 }
1767
1768 pub fn unnamed(&self) -> u64 {
1777 self.rows - self.named
1778 }
1779
1780 #[cfg(any(test, feature = "test-support"))]
1793 pub fn from_counts_for_tests(
1794 named: u64,
1795 attribution_failed: u64,
1796 not_recorded: u64,
1797 unattested: u64,
1798 ) -> Self {
1799 let rows = named
1800 .checked_add(attribution_failed)
1801 .and_then(|n| n.checked_add(not_recorded))
1802 .and_then(|n| n.checked_add(unattested))
1803 .expect("census counts must not overflow u64");
1804 Self {
1805 named,
1806 attribution_failed,
1807 not_recorded,
1808 unattested,
1809 rows,
1810 }
1811 }
1812}
1813
1814impl AuditVerification {
1815 pub fn exit_code(&self) -> i32 {
1821 let mut codes = vec![
1822 outcome_to_exit_code(&self.chain),
1823 outcome_to_exit_code(&self.events),
1824 binding_outcome_to_exit_code(&self.binding),
1825 ];
1826 if let Some(status) = &self.status {
1829 codes.push(status_outcome_to_exit_code(status));
1830 }
1831 if codes.contains(&1) {
1832 1
1833 } else if codes.contains(&2) {
1834 2
1835 } else {
1836 0
1837 }
1838 }
1839}
1840
1841pub fn verify_all(
1843 key: &AuditKey,
1844 tx_rows: &[ChainRow],
1845 event_rows: &[EventRow],
1846) -> AuditVerification {
1847 AuditVerification {
1848 chain: verify_chain(key, tx_rows),
1849 events: verify_event_chain(key, event_rows),
1850 binding: verify_event_binding(tx_rows, event_rows),
1851 attribution: Some(AttributionCensus::of(tx_rows)),
1852 status: None,
1853 }
1854}
1855
1856pub fn verify_all_with_pubkey(
1858 verifying_key_hex: &str,
1859 tx_rows: &[ChainRow],
1860 event_rows: &[EventRow],
1861) -> AuditVerification {
1862 AuditVerification {
1863 chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
1864 events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
1865 binding: verify_event_binding(tx_rows, event_rows),
1866 attribution: Some(AttributionCensus::of(tx_rows)),
1867 status: None,
1868 }
1869}
1870
1871pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
1875 match outcome {
1876 BindingOutcome::Consistent { .. } => 0,
1877 BindingOutcome::NotChecked => 2,
1878 BindingOutcome::MissingEvent { .. } => 1,
1879 }
1880}
1881
1882#[derive(Debug, Clone, PartialEq, Eq)]
1898pub struct Checkpoint {
1899 pub seq: u64,
1902 pub chain_tip: String,
1904 pub created_at: String,
1906 pub signature: String,
1908}
1909
1910fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
1913 let mut buf = CHECKPOINT_DOMAIN.to_vec();
1916 push_field(&mut buf, "seq", &seq.to_string());
1917 push_field(&mut buf, "chain_tip", chain_tip);
1918 push_field(&mut buf, "created_at", created_at);
1919 buf
1920}
1921
1922impl AuditKey {
1923 pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
1925 let sig = self
1926 .signing
1927 .sign(&checkpoint_message(seq, chain_tip, created_at));
1928 Checkpoint {
1929 seq,
1930 chain_tip: chain_tip.to_string(),
1931 created_at: created_at.to_string(),
1932 signature: hex::encode(sig.to_bytes()),
1933 }
1934 }
1935}
1936
1937#[derive(Debug, Clone, PartialEq, Eq)]
1939pub enum CheckpointOutcome {
1940 Consistent { checkpoints_checked: u64 },
1943 BadSignature { seq: u64 },
1945 Truncated {
1948 checkpoint_seq: u64,
1949 current_max_seq: u64,
1950 },
1951 TipMismatch {
1954 seq: u64,
1955 anchored: String,
1956 actual: String,
1957 },
1958 CannotVerify { reason: String },
1960}
1961
1962pub fn verify_checkpoints(
1968 verifying_key_hex: &str,
1969 rows: &[ChainRow],
1970 checkpoints: &[Checkpoint],
1971) -> CheckpointOutcome {
1972 let vk = match parse_verifying_key(verifying_key_hex) {
1973 Some(vk) => vk,
1974 None => {
1975 return CheckpointOutcome::CannotVerify {
1976 reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
1977 };
1978 }
1979 };
1980 let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
1983 let mut checked = 0u64;
1984 for cp in checkpoints {
1985 let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
1986 if !signature_ok(&vk, &msg, &cp.signature) {
1987 return CheckpointOutcome::BadSignature { seq: cp.seq };
1988 }
1989 match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
1990 Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
1991 Ok(idx) => {
1992 return CheckpointOutcome::TipMismatch {
1993 seq: cp.seq,
1994 anchored: cp.chain_tip.clone(),
1995 actual: rows[idx].chain_hash.clone(),
1996 };
1997 }
1998 Err(_) => {
1999 return CheckpointOutcome::Truncated {
2000 checkpoint_seq: cp.seq,
2001 current_max_seq,
2002 };
2003 }
2004 }
2005 checked += 1;
2006 }
2007 CheckpointOutcome::Consistent {
2008 checkpoints_checked: checked,
2009 }
2010}
2011
2012pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
2017 match outcome {
2018 CheckpointOutcome::Consistent { .. } => 0,
2019 CheckpointOutcome::BadSignature { .. }
2020 | CheckpointOutcome::Truncated { .. }
2021 | CheckpointOutcome::TipMismatch { .. } => 1,
2022 CheckpointOutcome::CannotVerify { .. } => 2,
2023 }
2024}
2025
2026#[cfg(test)]
2027mod tests {
2028 #[test]
2029 fn audit_key_env_resolution_is_centralised() {
2030 const INLINE_RESOLUTION: &str = "std::env::var(\"SYSKNIFE_AUDIT_KEY_PATH\")";
2031 let daemon_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
2032 let cli_src =
2033 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps/sysknife-cli/src");
2034
2035 let mut roots = vec![daemon_src.clone(), cli_src.clone()];
2036 let mut files_checked = 0;
2037 while let Some(dir) = roots.pop() {
2038 for entry in std::fs::read_dir(&dir).expect("source directory must be readable") {
2039 let path = entry.expect("source entry must be readable").path();
2040 if path.is_dir() {
2041 roots.push(path);
2042 continue;
2043 }
2044 if path.extension().and_then(|ext| ext.to_str()) != Some("rs")
2045 || path == daemon_src.join("audit_chain.rs")
2046 {
2047 continue;
2048 }
2049 files_checked += 1;
2050 let source = std::fs::read_to_string(&path).expect("Rust source must be readable");
2051 assert!(
2052 !source.contains(INLINE_RESOLUTION),
2053 "{} must resolve the audit key through resolve_audit_key_path",
2054 path.display()
2055 );
2056 }
2057 }
2058 assert!(
2059 files_checked >= 60,
2060 "source walk unexpectedly checked only {files_checked} Rust files"
2061 );
2062 }
2063
2064 #[test]
2072 fn every_variant_is_in_all() {
2073 fn spelling(kind: AuditEventKind) -> &'static str {
2074 match kind {
2075 AuditEventKind::ApprovalGranted => "approval_granted",
2076 AuditEventKind::ApprovalConsumed => "approval_consumed",
2077 AuditEventKind::ApprovalRevoked => "approval_revoked",
2078 AuditEventKind::StatusQueued => "status_queued",
2079 AuditEventKind::StatusRunning => "status_running",
2080 AuditEventKind::StatusSucceeded => "status_succeeded",
2081 AuditEventKind::StatusFailed => "status_failed",
2082 AuditEventKind::StatusCanceled => "status_canceled",
2083 AuditEventKind::StatusRolledBack => "status_rolled_back",
2084 AuditEventKind::StatusNeedsReboot => "status_needs_reboot",
2085 }
2086 }
2087 for kind in AuditEventKind::ALL {
2088 assert_eq!(
2089 spelling(*kind),
2090 kind.as_str(),
2091 "as_str and the exhaustive table disagree about {kind:?}"
2092 );
2093 assert_eq!(
2094 parse_event_kind(kind.as_str()),
2095 Some(*kind),
2096 "{kind:?} does not round-trip, so a row carrying it verifies as broken"
2097 );
2098 }
2099 for kind in AuditEventKind::ALL {
2102 assert!(
2103 AuditEventKind::ALL
2104 .iter()
2105 .any(|k| k.as_str() == spelling(*kind)),
2106 "{kind:?} has a spelling and is not reachable through ALL"
2107 );
2108 }
2109 assert!(
2110 parse_event_kind("not_a_kind").is_none(),
2111 "an unknown kind must not parse, or the check inspects nothing"
2112 );
2113 }
2114
2115 use super::*;
2116
2117 fn fixed_key() -> AuditKey {
2118 AuditKey::from_bytes(vec![0x42; 32])
2119 }
2120
2121 fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
2122 ChainContent {
2123 seq,
2124 key_id: CURRENT_KEY_ID,
2125 transaction_id: txid,
2126 request_id: "req-1",
2127 request_hash: "hash-abc",
2128 action_name: "UpdateSystem",
2129 risk_level: RiskLevel::High,
2130 summary: "Upgrade",
2131 approval_id: None,
2132 warnings_json: "[]",
2133 created_at: "2026-04-24T12:00:00Z",
2134 identity: ChainIdentity::V2 {
2135 caller_role: "Dev",
2136 event_tip: "",
2137 },
2138 }
2139 }
2140
2141 #[test]
2144 fn same_inputs_yield_same_hash() {
2145 let key = fixed_key();
2146 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
2147 let h2 = key.chain_hash(&sample_content(1, "txa"), "");
2148 assert_eq!(h1, h2);
2149 assert_eq!(h1.len(), HASH_HEX_LEN);
2150 }
2151
2152 #[test]
2153 fn different_seq_yields_different_hash() {
2154 let key = fixed_key();
2155 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
2156 let h2 = key.chain_hash(&sample_content(2, "txa"), "");
2157 assert_ne!(h1, h2);
2158 }
2159
2160 #[test]
2161 fn different_prev_hash_yields_different_hash() {
2162 let key = fixed_key();
2163 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
2164 let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
2165 assert_ne!(h1, h2);
2166 }
2167
2168 #[test]
2169 fn different_keys_yield_different_hashes() {
2170 let key1 = AuditKey::from_bytes(vec![0x01; 32]);
2171 let key2 = AuditKey::from_bytes(vec![0x02; 32]);
2172 let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
2173 let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
2174 assert_ne!(h1, h2);
2175 }
2176
2177 #[test]
2180 fn canonical_bytes_have_stable_field_order() {
2181 let c = sample_content(1, "txa");
2182 let bytes = c.canonical_bytes();
2183 let s = String::from_utf8_lossy(&bytes);
2184 let order = [
2186 "seq",
2187 "key_id",
2188 "transaction_id",
2189 "request_id",
2190 "request_hash",
2191 "action_name",
2192 "risk_level",
2193 "summary",
2194 "approval_id",
2195 "warnings_json",
2196 "created_at",
2197 ];
2198 let mut last_idx = 0;
2199 for tag in order {
2200 let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
2201 assert!(idx >= last_idx, "{tag} out of order");
2202 last_idx = idx;
2203 }
2204 }
2205
2206 #[test]
2207 fn nul_bytes_in_field_value_are_escaped() {
2208 let mut c = sample_content(1, "txa");
2209 let summary = "before\0after";
2210 c.summary = summary;
2211 let bytes = c.canonical_bytes();
2212 assert!(!bytes.contains(&0x00));
2214 assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
2215 }
2216
2217 #[test]
2223 fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
2224 let mut a = sample_content(1, "txa");
2225 a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
2227 b.summary = "before\0after"; assert_ne!(
2229 a.canonical_bytes(),
2230 b.canonical_bytes(),
2231 "backslash escape must run before NUL escape to prevent collision"
2232 );
2233 }
2234
2235 fn key_for_collision_test() -> AuditKey {
2236 AuditKey::from_bytes(vec![0xab; 32])
2237 }
2238
2239 #[test]
2240 fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
2241 let key = key_for_collision_test();
2242 let mut a = sample_content(1, "txa");
2243 a.summary = "before\\0after";
2244 let mut b = sample_content(1, "txa");
2245 b.summary = "before\0after";
2246 assert_ne!(
2247 key.chain_hash(&a, ""),
2248 key.chain_hash(&b, ""),
2249 "chain signature must distinguish escape from raw byte"
2250 );
2251 }
2252
2253 fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
2259 let (chain_version, caller_role, event_tip, caller_principal) = match content.identity {
2260 ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None, None),
2261 ChainIdentity::V2 {
2262 caller_role,
2263 event_tip,
2264 } => (
2265 CHAIN_VERSION_V2,
2266 Some(caller_role.to_string()),
2267 Some(event_tip.to_string()),
2268 None,
2269 ),
2270 ChainIdentity::V3 {
2271 caller_role,
2272 event_tip,
2273 caller_principal,
2274 } => (
2275 CHAIN_VERSION_V3,
2276 Some(caller_role.to_string()),
2277 Some(event_tip.to_string()),
2278 Some(caller_principal.to_string()),
2279 ),
2280 ChainIdentity::V4 {
2281 caller_role,
2282 event_tip,
2283 caller_principal,
2284 } => (
2285 CHAIN_VERSION_V4,
2286 Some(caller_role.to_string()),
2287 Some(event_tip.to_string()),
2288 Some(caller_principal.to_string()),
2289 ),
2290 };
2291 ChainRow {
2292 seq: content.seq,
2293 key_id: content.key_id.to_string(),
2294 transaction_id: content.transaction_id.to_string(),
2295 request_id: content.request_id.to_string(),
2296 request_hash: content.request_hash.to_string(),
2297 action_name: content.action_name.to_string(),
2298 risk_level: content.risk_level,
2299 summary: content.summary.to_string(),
2300 approval_id: content.approval_id.map(str::to_string),
2301 warnings_json: content.warnings_json.to_string(),
2302 created_at: content.created_at.to_string(),
2303 prev_chain_hash: prev.to_string(),
2304 chain_hash,
2305 chain_version,
2306 caller_role,
2307 event_tip,
2308 caller_principal,
2309 }
2310 }
2311
2312 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
2313 let mut rows = Vec::with_capacity(count);
2314 let mut prev = String::new();
2315 for i in 0..count {
2316 let seq = (i + 1) as u64;
2317 let txid = format!("tx{i}");
2318 let content = sample_content(seq, &txid);
2319 let hash = key.chain_hash(&content, &prev);
2320 rows.push(row_for(&content, &prev, hash.clone()));
2321 prev = hash;
2322 }
2323 rows
2324 }
2325
2326 #[test]
2327 fn exported_chain_row_json_round_trips_without_changing_the_signature() {
2328 let key = fixed_key();
2329 let row = build_chain(&key, 1).remove(0);
2330
2331 let json = serde_json::to_string(&row).expect("chain row serializes");
2332 let decoded: ChainRow = serde_json::from_str(&json).expect("chain row deserializes");
2333
2334 assert_eq!(decoded, row);
2335 assert_eq!(decoded.chain_hash.as_bytes(), row.chain_hash.as_bytes());
2336 }
2337
2338 #[test]
2339 fn exported_chain_row_uses_the_stored_column_contract_and_json_nulls() {
2340 let key = fixed_key();
2341 let mut row = build_chain(&key, 1).remove(0);
2342 row.approval_id = None;
2343 row.caller_role = None;
2344 row.event_tip = None;
2345 row.caller_principal = None;
2346
2347 let value = serde_json::to_value(&row).expect("chain row serializes");
2348 let object = value.as_object().expect("chain row is a JSON object");
2349 let keys: Vec<&str> = object.keys().map(String::as_str).collect();
2350
2351 assert_eq!(
2352 keys,
2353 vec![
2354 "action_name",
2355 "approval_id",
2356 "caller_principal",
2357 "caller_role",
2358 "chain_hash",
2359 "chain_version",
2360 "created_at",
2361 "event_tip",
2362 "key_id",
2363 "prev_chain_hash",
2364 "request_hash",
2365 "request_id",
2366 "risk_level",
2367 "seq",
2368 "summary",
2369 "transaction_id",
2370 "warnings_json",
2371 ]
2372 );
2373 for optional in [
2374 "approval_id",
2375 "caller_role",
2376 "event_tip",
2377 "caller_principal",
2378 ] {
2379 assert!(object[optional].is_null(), "{optional} must be JSON null");
2380 }
2381 assert!(!object.contains_key("argv"));
2382 assert!(!object.contains_key("outcome"));
2383 assert!(!object.contains_key("signature"));
2384 }
2385
2386 #[test]
2387 fn export_since_is_inclusive_and_limit_preserves_chain_order() {
2388 let key = fixed_key();
2389 let mut rows = build_chain(&key, 4);
2390 for (row, created_at) in rows.iter_mut().zip([
2391 "2026-08-19T09:00:00Z",
2392 "2026-08-19T10:00:00Z",
2393 "2026-08-19T11:00:00Z",
2394 "2026-08-19T12:00:00Z",
2395 ]) {
2396 row.created_at = created_at.to_string();
2397 }
2398
2399 let selected =
2400 select_chain_rows_for_export(rows, Some("2026-08-19T10:00:00+00:00"), Some(2)).unwrap();
2401
2402 assert_eq!(
2403 selected.iter().map(|row| row.seq).collect::<Vec<_>>(),
2404 [2, 3]
2405 );
2406 }
2407
2408 #[test]
2409 fn export_rejects_an_invalid_since_timestamp() {
2410 let error = select_chain_rows_for_export(Vec::new(), Some("last Tuesday"), None)
2411 .expect_err("invalid --since must fail before reading rows");
2412 assert!(matches!(error, ChainExportError::InvalidSince(_)));
2413 }
2414
2415 #[test]
2418 fn caller_role_is_covered_by_the_row_signature() {
2419 let key = fixed_key();
2423 let mut dev = sample_content(1, "txa");
2424 dev.identity = ChainIdentity::V2 {
2425 caller_role: "Dev",
2426 event_tip: "",
2427 };
2428 let mut boot = sample_content(1, "txa");
2429 boot.identity = ChainIdentity::V2 {
2430 caller_role: "Boot",
2431 event_tip: "",
2432 };
2433 assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
2434 }
2435
2436 #[test]
2437 fn event_tip_is_covered_by_the_row_signature() {
2438 let key = fixed_key();
2439 let mut empty = sample_content(1, "txa");
2440 empty.identity = ChainIdentity::V2 {
2441 caller_role: "Dev",
2442 event_tip: "",
2443 };
2444 let mut bound = sample_content(1, "txa");
2445 bound.identity = ChainIdentity::V2 {
2446 caller_role: "Dev",
2447 event_tip: "abc123",
2448 };
2449 assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
2450 }
2451
2452 #[test]
2453 fn a_legacy_row_written_before_the_migration_still_verifies() {
2454 let key = fixed_key();
2459 let mut content = sample_content(1, "tx-legacy");
2460 content.identity = ChainIdentity::LegacyV1;
2461 let hash = key.chain_hash(&content, "");
2462 let row = row_for(&content, "", hash);
2463 assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
2464 assert_eq!(row.caller_role, None);
2465 assert_eq!(
2466 verify_chain(&key, &[row]),
2467 VerifyOutcome::Intact { rows_checked: 1 }
2468 );
2469 }
2470
2471 #[test]
2477 fn two_admins_are_distinguishable_in_the_signed_record() {
2478 let key = fixed_key();
2479 let mut alice = sample_content(1, "tx-alice");
2480 alice.identity = ChainIdentity::V3 {
2481 caller_role: "admin",
2482 event_tip: "",
2483 caller_principal: "uid:1000",
2484 };
2485 let mut bob = sample_content(1, "tx-alice");
2486 bob.identity = ChainIdentity::V3 {
2487 caller_role: "admin",
2488 event_tip: "",
2489 caller_principal: "uid:1001",
2490 };
2491
2492 assert_ne!(
2493 key.chain_hash(&alice, ""),
2494 key.chain_hash(&bob, ""),
2495 "identical rows differing only in principal must sign differently, \
2496 otherwise the chain still cannot answer which account acted"
2497 );
2498 }
2499
2500 #[test]
2503 fn editing_the_stored_principal_breaks_the_row() {
2504 let key = fixed_key();
2505 let mut content = sample_content(1, "tx1");
2506 content.identity = ChainIdentity::V3 {
2507 caller_role: "admin",
2508 event_tip: "",
2509 caller_principal: "uid:1000",
2510 };
2511 let hash = key.chain_hash(&content, "");
2512 let mut row = row_for(&content, "", hash);
2513 row.caller_principal = Some("uid:0".to_string());
2514
2515 assert!(
2516 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2517 "a principal swapped to root must not verify"
2518 );
2519 }
2520
2521 #[test]
2525 fn downgrading_a_v3_row_to_v2_to_hide_the_account_breaks_it() {
2526 let key = fixed_key();
2527 let mut content = sample_content(1, "tx1");
2528 content.identity = ChainIdentity::V3 {
2529 caller_role: "admin",
2530 event_tip: "",
2531 caller_principal: "uid:1000",
2532 };
2533 let hash = key.chain_hash(&content, "");
2534 let mut row = row_for(&content, "", hash);
2535 row.chain_version = CHAIN_VERSION_V2;
2536 row.caller_principal = None;
2537
2538 assert!(
2539 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2540 "a v3 row relabelled as v2 must not verify"
2541 );
2542 }
2543
2544 #[test]
2549 fn a_v3_row_without_a_usable_principal_is_broken() {
2550 let key = fixed_key();
2551 let mut content = sample_content(1, "tx1");
2552 content.identity = ChainIdentity::V3 {
2553 caller_role: "admin",
2554 event_tip: "",
2555 caller_principal: "uid:1000",
2556 };
2557 let hash = key.chain_hash(&content, "");
2558
2559 for absent in [None, Some(String::new())] {
2560 let mut row = row_for(&content, "", hash.clone());
2561 row.caller_principal = absent.clone();
2562 assert!(
2563 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2564 "a v3 row with principal {absent:?} must be reported as broken"
2565 );
2566 }
2567 }
2568
2569 #[test]
2579 fn a_row_signed_with_a_blank_principal_is_rejected_even_though_it_signs() {
2580 let key = fixed_key();
2581 let mut content = sample_content(1, "tx-blank");
2582 content.identity = ChainIdentity::V3 {
2583 caller_role: "admin",
2584 event_tip: "",
2585 caller_principal: "",
2586 };
2587 let hash = key.chain_hash(&content, "");
2588 let row = row_for(&content, "", hash);
2593 assert_eq!(
2594 row.caller_principal.as_deref(),
2595 Some(""),
2596 "fixture must store the blank principal it signed"
2597 );
2598 assert_eq!(row.chain_version, CHAIN_VERSION_V3);
2599 assert!(
2600 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2601 "a v3 row naming nobody must be broken, not accepted"
2602 );
2603 }
2604
2605 #[test]
2610 fn a_row_recording_an_attribution_failure_verifies() {
2611 let key = fixed_key();
2612 let principal = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2613 let mut content = sample_content(1, "tx-unattributed");
2614 content.identity = ChainIdentity::V3 {
2615 caller_role: "observer",
2616 event_tip: "",
2617 caller_principal: &principal,
2618 };
2619 let hash = key.chain_hash(&content, "");
2620 let row = row_for(&content, "", hash);
2621
2622 assert_eq!(
2623 verify_chain(&key, &[row]),
2624 VerifyOutcome::Intact { rows_checked: 1 }
2625 );
2626 }
2627
2628 #[test]
2632 fn a_chain_spanning_all_four_encodings_verifies() {
2633 let key = fixed_key();
2634 let mut rows = Vec::new();
2635 let mut prev = String::new();
2636 for (i, identity) in [
2637 ChainIdentity::LegacyV1,
2638 ChainIdentity::V2 {
2639 caller_role: "dev",
2640 event_tip: "",
2641 },
2642 ChainIdentity::V3 {
2643 caller_role: "admin",
2644 event_tip: "",
2645 caller_principal: "uid:1000",
2646 },
2647 ChainIdentity::V3 {
2648 caller_role: "admin",
2649 event_tip: "",
2650 caller_principal: "token:vsock",
2651 },
2652 ChainIdentity::V4 {
2653 caller_role: "admin",
2654 event_tip: "",
2655 caller_principal: "uid:1000",
2656 },
2657 ]
2658 .into_iter()
2659 .enumerate()
2660 {
2661 let txid = format!("tx{i}");
2662 let mut content = sample_content((i + 1) as u64, &txid);
2663 content.identity = identity;
2664 let hash = key.chain_hash(&content, &prev);
2665 rows.push(row_for(&content, &prev, hash.clone()));
2666 prev = hash;
2667 }
2668
2669 assert_eq!(
2670 verify_chain(&key, &rows),
2671 VerifyOutcome::Intact { rows_checked: 5 }
2672 );
2673 }
2674
2675 fn chain_of(key: &AuditKey, identities: Vec<ChainIdentity<'_>>) -> Vec<ChainRow> {
2678 let mut rows = Vec::new();
2679 let mut prev = String::new();
2680 for (i, identity) in identities.into_iter().enumerate() {
2681 let txid = format!("tx{i}");
2682 let mut content = sample_content((i + 1) as u64, &txid);
2683 content.identity = identity;
2684 let hash = key.chain_hash(&content, &prev);
2685 rows.push(row_for(&content, &prev, hash.clone()));
2686 prev = hash;
2687 }
2688 rows
2689 }
2690
2691 #[test]
2701 fn the_census_separates_attribution_failures_from_encodings_without_the_field() {
2702 let key = fixed_key();
2703 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2704 let rows = chain_of(
2705 &key,
2706 vec![
2707 ChainIdentity::LegacyV1,
2708 ChainIdentity::V2 {
2709 caller_role: "dev",
2710 event_tip: "",
2711 },
2712 ChainIdentity::V3 {
2713 caller_role: "admin",
2714 event_tip: "",
2715 caller_principal: "uid:1000",
2716 },
2717 ChainIdentity::V3 {
2718 caller_role: "admin",
2719 event_tip: "",
2720 caller_principal: "uid:1001",
2721 },
2722 ChainIdentity::V3 {
2723 caller_role: "admin",
2724 event_tip: "",
2725 caller_principal: "token:vsock",
2726 },
2727 ChainIdentity::V3 {
2728 caller_role: "observer",
2729 event_tip: "",
2730 caller_principal: &unattributed,
2731 },
2732 ],
2733 );
2734
2735 let verification = verify_all(&key, &rows, &[]);
2736 assert_eq!(
2737 verification.chain,
2738 VerifyOutcome::Intact { rows_checked: 6 },
2739 "every encoding must keep verifying; the census is not a verdict"
2740 );
2741 let census = verification
2742 .attribution
2743 .expect("rows were read, so a census was taken");
2744 assert_eq!(census.named(), 3);
2745 assert_eq!(census.attribution_failed(), 1);
2746 assert_eq!(census.not_recorded(), 2);
2747 assert_eq!(census.unattested(), 0);
2748 assert_eq!(census.rows(), 6);
2749 assert_eq!(
2750 census.unnamed(),
2751 3,
2752 "three of the six rows cannot name an account, by either reason"
2753 );
2754 }
2755
2756 #[test]
2766 fn a_principal_written_into_an_encoding_that_does_not_sign_it_is_never_an_account() {
2767 let key = fixed_key();
2768 let mut rows = chain_of(
2769 &key,
2770 vec![
2771 ChainIdentity::LegacyV1,
2772 ChainIdentity::V2 {
2773 caller_role: "dev",
2774 event_tip: "",
2775 },
2776 ],
2777 );
2778 rows[0].caller_principal = Some("uid:0".to_string());
2781 rows[1].caller_principal = Some("uid:0".to_string());
2782
2783 let verification = verify_all(&key, &rows, &[]);
2784 assert_eq!(
2785 verification.chain,
2786 VerifyOutcome::Intact { rows_checked: 2 },
2787 "no signature covers that column on v1/v2, so tampering with it is \
2788 invisible to verification -- which is exactly why the census must \
2789 not believe it"
2790 );
2791 let census = verification.attribution.expect("rows were read");
2792 assert_eq!(
2793 census.named(),
2794 0,
2795 "an unsigned column must never be credited as an account"
2796 );
2797 assert_eq!(
2798 census.unattested(),
2799 2,
2800 "and it must be reported as unattested, because nothing in SysKnife \
2801 writes that column on those encodings"
2802 );
2803 assert_eq!(census.unnamed(), 2);
2804 }
2805
2806 #[test]
2810 fn a_blank_principal_column_is_counted_as_naming_nobody() {
2811 let key = fixed_key();
2812 let mut content = sample_content(1, "tx-blank-census");
2813 content.identity = ChainIdentity::V3 {
2814 caller_role: "admin",
2815 event_tip: "",
2816 caller_principal: "",
2817 };
2818 let hash = key.chain_hash(&content, "");
2819 let row = row_for(&content, "", hash);
2820 assert_eq!(row.caller_principal.as_deref(), Some(""));
2821
2822 let verification = verify_all(&key, &[row], &[]);
2823 let census = verification.attribution.expect("a row was read");
2824 assert_eq!(census.named(), 0);
2825 assert_eq!(census.not_recorded(), 1);
2826 assert_eq!(census.rows(), 1);
2827 assert!(
2828 matches!(verification.chain, VerifyOutcome::Broken { .. }),
2829 "and the row is still reported as broken once the walk reaches it, \
2830 so census and verdict agree"
2831 );
2832 }
2833
2834 #[test]
2842 fn a_principal_this_binary_cannot_read_is_counted_as_unattested_not_as_an_account() {
2843 let key = fixed_key();
2844 for forged in [
2845 "garbage",
2846 "uid:",
2847 "none:overflow",
2848 " ",
2853 "UID:1000",
2854 "uid:notanumber",
2856 "uid:1000:extra",
2857 "token:not-vsock",
2858 ] {
2859 let mut content = sample_content(1, "tx-forged");
2860 content.identity = ChainIdentity::V3 {
2861 caller_role: "admin",
2862 event_tip: "",
2863 caller_principal: forged,
2864 };
2865 let hash = key.chain_hash(&content, "");
2866 let row = row_for(&content, "", hash);
2867 let census = verify_all(&key, &[row], &[])
2868 .attribution
2869 .expect("a row was read");
2870 assert_eq!(
2871 census.named(),
2872 0,
2873 "{forged:?} names no account and must not be counted as one"
2874 );
2875 assert_eq!(
2876 census.unnamed(),
2877 1,
2878 "{forged:?} must be counted among the rows naming nobody"
2879 );
2880 assert_eq!(
2881 census.unattested(),
2882 1,
2883 "{forged:?} is signed content the daemon never writes, so it is a \
2884 finding to investigate, not history to explain away"
2885 );
2886 assert_eq!(
2887 census.not_recorded(),
2888 0,
2889 "{forged:?} is present, so it must not be reported as a row that \
2890 predates the column"
2891 );
2892 }
2893 }
2894
2895 #[test]
2900 fn a_chain_predating_the_principal_field_reports_no_row_as_attributed() {
2901 let key = fixed_key();
2902 let rows = chain_of(
2903 &key,
2904 vec![
2905 ChainIdentity::LegacyV1,
2906 ChainIdentity::V2 {
2907 caller_role: "dev",
2908 event_tip: "",
2909 },
2910 ],
2911 );
2912
2913 let verification = verify_all(&key, &rows, &[]);
2914 assert_eq!(
2915 verification.chain,
2916 VerifyOutcome::Intact { rows_checked: 2 },
2917 "legacy rows must keep verifying; the census is not a verdict"
2918 );
2919 let census = verification.attribution.expect("rows were read");
2920 assert_eq!(census.attribution_failed(), 0);
2921 assert_eq!(census.named(), 0);
2922 assert_eq!(census.not_recorded(), 2);
2923 assert_eq!(census.unnamed(), 2);
2924 }
2925
2926 #[test]
2932 fn a_broken_chain_censuses_more_rows_than_it_verified() {
2933 let key = fixed_key();
2934 let mut rows = chain_of(
2935 &key,
2936 vec![
2937 ChainIdentity::V3 {
2938 caller_role: "admin",
2939 event_tip: "",
2940 caller_principal: "uid:1000",
2941 },
2942 ChainIdentity::V3 {
2943 caller_role: "admin",
2944 event_tip: "",
2945 caller_principal: "uid:1000",
2946 },
2947 ChainIdentity::V3 {
2948 caller_role: "admin",
2949 event_tip: "",
2950 caller_principal: "uid:1000",
2951 },
2952 ],
2953 );
2954 rows[1].summary = "tampered".to_string();
2955
2956 let verification = verify_all(&key, &rows, &[]);
2957 let rows_checked = match verification.chain {
2958 VerifyOutcome::Broken { rows_checked, .. } => rows_checked,
2959 ref other => panic!("expected a detected tamper, got {other:?}"),
2960 };
2961 let census = verification.attribution.expect("rows were read");
2962 assert_eq!(rows_checked, 1, "the walk stops at the first broken row");
2963 assert_eq!(
2964 census.rows(),
2965 3,
2966 "while the census still describes every row read"
2967 );
2968 assert!(
2969 census.rows() > rows_checked,
2970 "the report must be able to show that more was counted than checked"
2971 );
2972 }
2973
2974 #[test]
2978 fn the_pubkey_only_path_takes_the_same_census() {
2979 let key = fixed_key();
2980 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2981 let rows = chain_of(
2982 &key,
2983 vec![
2984 ChainIdentity::V3 {
2985 caller_role: "admin",
2986 event_tip: "",
2987 caller_principal: "uid:1000",
2988 },
2989 ChainIdentity::V3 {
2990 caller_role: "observer",
2991 event_tip: "",
2992 caller_principal: &unattributed,
2993 },
2994 ],
2995 );
2996
2997 let with_key = verify_all(&key, &rows, &[]).attribution;
2998 let with_pubkey = verify_all_with_pubkey(&key.verifying_key_hex(), &rows, &[]).attribution;
2999 assert_eq!(
3000 with_key, with_pubkey,
3001 "the auditor must see the same attribution numbers as the keyholder"
3002 );
3003 let census = with_pubkey.expect("rows were read");
3004 assert_eq!(census.named(), 1);
3005 assert_eq!(census.attribution_failed(), 1);
3006 }
3007
3008 #[test]
3013 fn a_chain_that_names_nobody_still_exits_zero() {
3014 let key = fixed_key();
3015 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
3016 let rows = chain_of(
3017 &key,
3018 vec![
3019 ChainIdentity::LegacyV1,
3020 ChainIdentity::V3 {
3021 caller_role: "observer",
3022 event_tip: "",
3023 caller_principal: &unattributed,
3024 },
3025 ],
3026 );
3027
3028 let verification = verify_all(&key, &rows, &[]);
3029 let census = verification.attribution.expect("rows were read");
3030 assert_eq!(census.unnamed(), 2, "not one row names an account");
3031 assert_eq!(
3032 verification.exit_code(),
3033 0,
3034 "yet nothing was tampered with, so the command must succeed"
3035 );
3036 }
3037
3038 #[test]
3043 fn a_future_encoding_reports_cannot_verify_with_the_supported_range() {
3044 let key = fixed_key();
3045 let content = sample_content(1, "tx1");
3046 let hash = key.chain_hash(&content, "");
3047 let mut row = row_for(&content, "", hash);
3048 row.chain_version = CHAIN_VERSION_CURRENT + 1;
3049
3050 match verify_chain(&key, &[row]) {
3051 VerifyOutcome::CannotVerify { reason } => {
3052 assert!(
3053 reason.contains(&format!("chain_version={}", CHAIN_VERSION_CURRENT + 1)),
3054 "name the version found, got: {reason}"
3055 );
3056 assert!(
3057 reason.contains(&CHAIN_VERSION_CURRENT.to_string()),
3058 "and the newest version understood, got: {reason}"
3059 );
3060 }
3061 other => panic!("a future encoding must not be reported as tampering: {other:?}"),
3062 }
3063 }
3064
3065 #[test]
3078 fn a_row_written_by_the_previous_release_still_verifies() {
3079 const GOLDEN_V2_CHAIN_HASH: &str = "ba12cbe49a149387898bc30f1e8d409effa025ab8e440e6e72bedf060afc831e45d9be264d6b18b7e0ef2f2b3584dbc6953b535546cecbbdc3d7ac666cae4d0a";
3080
3081 let key = fixed_key();
3082 let row = ChainRow {
3083 seq: 7,
3084 key_id: CURRENT_KEY_ID.to_string(),
3085 transaction_id: "tx-golden".to_string(),
3086 request_id: "req-golden".to_string(),
3087 request_hash: "hash-golden".to_string(),
3088 action_name: "AptInstall".to_string(),
3089 risk_level: RiskLevel::Medium,
3090 summary: "install ripgrep".to_string(),
3091 approval_id: Some("appr-golden".to_string()),
3092 warnings_json: "[]".to_string(),
3093 created_at: "2026-07-29T00:00:00Z".to_string(),
3094 prev_chain_hash: String::new(),
3095 chain_hash: GOLDEN_V2_CHAIN_HASH.to_string(),
3096 chain_version: 2,
3097 caller_role: Some("admin".to_string()),
3098 event_tip: Some("eventtip-golden".to_string()),
3099 caller_principal: None,
3100 };
3101
3102 assert_eq!(
3103 verify_chain(&key, std::slice::from_ref(&row)),
3104 VerifyOutcome::Intact { rows_checked: 1 },
3105 "a v2 row on disk must verify under this binary; if this fails, the v2 \
3106 encoding or its version dispatch changed and existing audit logs are unreadable"
3107 );
3108
3109 let mut tampered = row;
3113 let flipped = match GOLDEN_V2_CHAIN_HASH.strip_prefix('b') {
3114 Some(rest) => format!("a{rest}"),
3115 None => format!("b{}", &GOLDEN_V2_CHAIN_HASH[1..]),
3116 };
3117 tampered.chain_hash = flipped;
3118 assert!(
3119 matches!(
3120 verify_chain(&key, &[tampered]),
3121 VerifyOutcome::Broken { .. }
3122 ),
3123 "a golden row with one nibble changed must not verify"
3124 );
3125 }
3126
3127 #[test]
3131 fn a_legacy_v1_row_on_disk_still_verifies() {
3132 const GOLDEN_V1_CHAIN_HASH: &str = "ce8b47c15414e989e099303b826fcd9985360b794220cefbbf9689d25f31fe173283687feb6d1dd265ae5aca94c8bcfb87ce33ba8c954fafdad18a59cd97c702";
3133
3134 let row = ChainRow {
3135 chain_version: 1,
3136 caller_role: None,
3137 event_tip: None,
3138 caller_principal: None,
3139 chain_hash: GOLDEN_V1_CHAIN_HASH.to_string(),
3140 ..golden_row_shape()
3141 };
3142 assert_eq!(
3143 verify_chain(&fixed_key(), &[row]),
3144 VerifyOutcome::Intact { rows_checked: 1 }
3145 );
3146 }
3147
3148 #[test]
3152 fn a_v3_row_on_disk_still_verifies() {
3153 const GOLDEN_V3_CHAIN_HASH: &str = "3c0d05f6ccaf9e65ccb796d4ffe96eb31a2ea67e446217c5234af916dc959d0f2c0ee3d3e5b525aece791ee7d6507efb98dcdd79f0d74dde4273859aa9627d08";
3154
3155 let row = ChainRow {
3156 chain_version: 3,
3157 caller_role: Some("admin".to_string()),
3158 event_tip: Some("eventtip-golden".to_string()),
3159 caller_principal: Some("uid:1000".to_string()),
3160 chain_hash: GOLDEN_V3_CHAIN_HASH.to_string(),
3161 ..golden_row_shape()
3162 };
3163 assert_eq!(
3164 verify_chain(&fixed_key(), &[row]),
3165 VerifyOutcome::Intact { rows_checked: 1 }
3166 );
3167 }
3168
3169 #[test]
3180 fn a_v4_row_on_disk_still_verifies() {
3181 const GOLDEN_V4_CHAIN_HASH: &str = "8d82aac6c21c8224613e1a69f6a2a4c3298dd5d601bda9b1d997daf0bf552db499cbf5243397464c1add52e2f0a41c5e4ec5c788c9e84b4917ee33b3d496e10f";
3182
3183 let row = ChainRow {
3184 chain_version: 4,
3185 caller_role: Some("admin".to_string()),
3186 event_tip: Some("eventtip-golden".to_string()),
3187 caller_principal: Some("uid:1000".to_string()),
3188 chain_hash: GOLDEN_V4_CHAIN_HASH.to_string(),
3189 ..golden_row_shape()
3190 };
3191 assert_eq!(
3192 verify_chain(&fixed_key(), std::slice::from_ref(&row)),
3193 VerifyOutcome::Intact { rows_checked: 1 },
3194 "a v4 row on disk must verify under this binary; if this fails, the v4 \
3195 encoding or its version dispatch changed and audit logs written by \
3196 this release become unreadable"
3197 );
3198
3199 let mut tampered = row.clone();
3202 tampered.chain_hash = format!(
3203 "{}{}",
3204 &GOLDEN_V4_CHAIN_HASH[1..],
3205 &GOLDEN_V4_CHAIN_HASH[..1]
3206 );
3207 assert!(
3208 matches!(
3209 verify_chain(&fixed_key(), &[tampered]),
3210 VerifyOutcome::Broken { .. }
3211 ),
3212 "a rotated golden hash must not verify"
3213 );
3214
3215 let mut relabelled = row;
3220 relabelled.chain_version = 3;
3221 assert!(
3222 matches!(
3223 verify_chain(&fixed_key(), &[relabelled]),
3224 VerifyOutcome::Broken { .. }
3225 ),
3226 "a v4 row relabelled as v3 must not verify; the version field \
3227 must be inside the signed bytes"
3228 );
3229 }
3230
3231 #[test]
3235 fn v3_and_v4_encodings_do_not_alias() {
3236 let key = fixed_key();
3237 let mut v3 = golden_content_shape();
3238 v3.identity = ChainIdentity::V3 {
3239 caller_role: "admin",
3240 event_tip: "eventtip-golden",
3241 caller_principal: "uid:1000",
3242 };
3243 let mut v4 = golden_content_shape();
3244 v4.identity = ChainIdentity::V4 {
3245 caller_role: "admin",
3246 event_tip: "eventtip-golden",
3247 caller_principal: "uid:1000",
3248 };
3249 assert_ne!(
3250 key.chain_hash(&v3, ""),
3251 key.chain_hash(&v4, ""),
3252 "v3 and v4 sign the same fields, so only the chain_version tag \
3253 separates them; if these collide, relabelling a row across the \
3254 boundary is undetectable"
3255 );
3256 }
3257
3258 #[test]
3264 fn request_hash_is_not_the_unsigned_sha256() {
3265 let rhk = fixed_key().request_hash_key();
3266 let action = "ConfigureWifi";
3269 let canonical = r#"{"password":"hunter2","ssid":"CoffeeShop"}"#;
3270
3271 let mac = rhk.request_hash(action, canonical);
3272
3273 let mut hasher = Sha256::new();
3274 hasher.update(action.as_bytes());
3275 hasher.update(b"\x00");
3276 hasher.update(canonical.as_bytes());
3277 let legacy = hex::encode(hasher.finalize());
3278
3279 assert_eq!(mac.len(), 64, "the column width must not move");
3280 assert_ne!(
3281 mac, legacy,
3282 "request_hash is still the unsalted SHA-256; an exported row is \
3283 still offline-crackable"
3284 );
3285 }
3286
3287 #[test]
3292 fn request_hash_is_deterministic_and_key_bound() {
3293 let k1 = AuditKey::from_bytes(vec![0x01; 32]).request_hash_key();
3294 let k2 = AuditKey::from_bytes(vec![0x02; 32]).request_hash_key();
3295 assert_eq!(
3296 k1.request_hash("Op", "{}"),
3297 k1.request_hash("Op", "{}"),
3298 "preview and execute must agree on the hash"
3299 );
3300 assert_ne!(
3301 k1.request_hash("Op", "{}"),
3302 k2.request_hash("Op", "{}"),
3303 "the hash must not be computable without this key"
3304 );
3305 assert_ne!(
3306 k1.request_hash("OpA", "{}"),
3307 k1.request_hash("OpB", "{}"),
3308 "the action name is part of the message"
3309 );
3310 }
3311
3312 #[test]
3319 fn request_hash_golden_vector() {
3320 const GOLDEN_REQUEST_HASH: &str =
3321 "4f6bde0da85486323247e531482825059ddca0bd621997005f5d45dabeadcbf4";
3322 let rhk = fixed_key().request_hash_key();
3323 assert_eq!(
3324 rhk.request_hash("UpdateSystem", "{}"),
3325 GOLDEN_REQUEST_HASH,
3326 "the request_hash derivation changed; approvals pending across \
3327 the upgrade would fail the execute-time binding check"
3328 );
3329 }
3330
3331 #[test]
3336 fn request_hash_key_is_derived_not_the_signing_seed() {
3337 let seed = vec![0x42u8; 32];
3338 let rhk = AuditKey::from_bytes(seed.clone()).request_hash_key();
3339 let mut raw = Hmac::<Sha256>::new_from_slice(&seed).expect("HMAC accepts any key length");
3343 raw.update(b"UpdateSystem");
3344 raw.update(b"\x00");
3345 raw.update(b"{}");
3346 let raw_mac = hex::encode(raw.finalize().into_bytes());
3347 assert_ne!(
3348 rhk.request_hash("UpdateSystem", "{}"),
3349 raw_mac,
3350 "the HMAC key is the raw seed; the derivation is not happening"
3351 );
3352 }
3353
3354 #[test]
3361 fn the_escape_table_and_absent_approval_encoding_are_frozen() {
3362 const GOLDEN_V2_AWKWARD_CHAIN_HASH: &str = "e6face6fe2346fc0f6c5c1e5845255579c8f08738406b88b0a1f6dbdf8ff85090a4b80d95bd0927ef54b0a2c4810d52ebb4bbf76cb68c865a7748df26442aa05";
3363
3364 let key = fixed_key();
3365 let content = ChainContent {
3366 seq: 8,
3367 approval_id: None,
3368 summary: "back\\slash and \x1funit and \x1erecord and nul\0byte",
3369 identity: ChainIdentity::V2 {
3370 caller_role: "dev",
3371 event_tip: "",
3372 },
3373 ..golden_content_shape()
3374 };
3375 assert_eq!(
3376 key.chain_hash(&content, "ba12cbe4"),
3377 GOLDEN_V2_AWKWARD_CHAIN_HASH,
3378 "the canonical encoding of escapes, an absent approval id, or a \
3379 non-empty prev_chain_hash changed"
3380 );
3381 }
3382
3383 fn golden_content_shape() -> ChainContent<'static> {
3387 ChainContent {
3388 seq: 7,
3389 key_id: CURRENT_KEY_ID,
3390 transaction_id: "tx-golden",
3391 request_id: "req-golden",
3392 request_hash: "hash-golden",
3393 action_name: "AptInstall",
3394 risk_level: RiskLevel::Medium,
3395 summary: "install ripgrep",
3396 approval_id: Some("appr-golden"),
3397 warnings_json: "[]",
3398 created_at: "2026-07-29T00:00:00Z",
3399 identity: ChainIdentity::LegacyV1,
3400 }
3401 }
3402
3403 fn golden_row_shape() -> ChainRow {
3405 ChainRow {
3406 seq: 7,
3407 key_id: CURRENT_KEY_ID.to_string(),
3408 transaction_id: "tx-golden".to_string(),
3409 request_id: "req-golden".to_string(),
3410 request_hash: "hash-golden".to_string(),
3411 action_name: "AptInstall".to_string(),
3412 risk_level: RiskLevel::Medium,
3413 summary: "install ripgrep".to_string(),
3414 approval_id: Some("appr-golden".to_string()),
3415 warnings_json: "[]".to_string(),
3416 created_at: "2026-07-29T00:00:00Z".to_string(),
3417 prev_chain_hash: String::new(),
3418 chain_hash: String::new(),
3419 chain_version: 1,
3420 caller_role: None,
3421 event_tip: None,
3422 caller_principal: None,
3423 }
3424 }
3425
3426 #[test]
3427 fn a_chain_that_spans_the_migration_verifies_end_to_end() {
3428 let key = fixed_key();
3431 let mut rows = Vec::new();
3432 let mut prev = String::new();
3433 for (i, identity) in [
3434 ChainIdentity::LegacyV1,
3435 ChainIdentity::LegacyV1,
3436 ChainIdentity::V2 {
3437 caller_role: "Dev",
3438 event_tip: "",
3439 },
3440 ChainIdentity::V2 {
3441 caller_role: "Boot",
3442 event_tip: "",
3443 },
3444 ]
3445 .into_iter()
3446 .enumerate()
3447 {
3448 let txid = format!("tx{i}");
3449 let mut content = sample_content((i + 1) as u64, &txid);
3450 content.identity = identity;
3451 let hash = key.chain_hash(&content, &prev);
3452 rows.push(row_for(&content, &prev, hash.clone()));
3453 prev = hash;
3454 }
3455 assert_eq!(
3456 verify_chain(&key, &rows),
3457 VerifyOutcome::Intact { rows_checked: 4 }
3458 );
3459 }
3460
3461 #[test]
3462 fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
3463 let key = fixed_key();
3468 let mut rows = build_chain(&key, 2);
3469 rows[1].chain_version = CHAIN_VERSION_LEGACY;
3470 rows[1].caller_role = None;
3471 rows[1].event_tip = None;
3472 match verify_chain(&key, &rows) {
3473 VerifyOutcome::Broken {
3474 first_broken_seq, ..
3475 } => assert_eq!(first_broken_seq, 2),
3476 other => panic!("expected Broken, got {other:?}"),
3477 }
3478 }
3479
3480 #[test]
3481 fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
3482 let key = fixed_key();
3487 let mut rows = build_chain(&key, 1);
3488 rows[0].caller_role = None;
3489 let outcome = verify_chain(&key, &rows);
3490 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
3491 assert_eq!(outcome_to_exit_code(&outcome), 1);
3492 }
3493
3494 #[test]
3495 fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
3496 let key = fixed_key();
3499 let mut rows = build_chain(&key, 1);
3500 rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
3501 let outcome = verify_chain(&key, &rows);
3502 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3503 assert_eq!(outcome_to_exit_code(&outcome), 2);
3504 }
3505
3506 fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
3509 EventContent {
3510 seq,
3511 key_id: CURRENT_KEY_ID,
3512 kind,
3513 transaction_id: txid,
3514 receipt_digest: "digest-abc",
3515 created_at: "2026-04-24T12:00:00Z",
3516 identity: EventIdentity::LegacyV1,
3520 }
3521 }
3522
3523 fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
3524 let mut rows = Vec::with_capacity(kinds.len());
3525 let mut prev = String::new();
3526 for (i, kind) in kinds.iter().enumerate() {
3527 let seq = (i + 1) as u64;
3528 let txid = format!("tx{i}");
3529 let content = event_content(seq, *kind, &txid);
3530 let hash = key.event_hash(&content, &prev);
3531 rows.push(EventRow {
3532 seq,
3533 key_id: content.key_id.to_string(),
3534 kind: content.kind.as_str().to_string(),
3535 transaction_id: content.transaction_id.to_string(),
3536 receipt_digest: content.receipt_digest.to_string(),
3537 created_at: content.created_at.to_string(),
3538 prev_chain_hash: prev.clone(),
3539 chain_hash: hash.clone(),
3540 chain_version: content.identity.version(),
3541 caller_principal: match content.identity {
3542 EventIdentity::V2 { caller_principal } => Some(caller_principal.to_string()),
3543 EventIdentity::LegacyV1 => None,
3544 },
3545 });
3546 prev = hash;
3547 }
3548 rows
3549 }
3550
3551 #[allow(clippy::too_many_arguments)]
3556 fn event_row(
3557 key: &AuditKey,
3558 seq: u64,
3559 kind: AuditEventKind,
3560 txid: &str,
3561 receipt_digest: &str,
3562 created_at: &str,
3563 identity: EventIdentity<'_>,
3564 prev: &str,
3565 ) -> EventRow {
3566 let content = EventContent {
3567 seq,
3568 key_id: CURRENT_KEY_ID,
3569 kind,
3570 transaction_id: txid,
3571 receipt_digest,
3572 created_at,
3573 identity,
3574 };
3575 let hash = key.event_hash(&content, prev);
3576 EventRow {
3577 seq,
3578 key_id: CURRENT_KEY_ID.to_string(),
3579 kind: kind.as_str().to_string(),
3580 transaction_id: txid.to_string(),
3581 receipt_digest: receipt_digest.to_string(),
3582 created_at: created_at.to_string(),
3583 prev_chain_hash: prev.to_string(),
3584 chain_hash: hash,
3585 chain_version: identity.version(),
3586 caller_principal: match identity {
3587 EventIdentity::V2 { caller_principal } => Some(caller_principal.to_string()),
3588 EventIdentity::LegacyV1 => None,
3589 },
3590 }
3591 }
3592
3593 #[test]
3594 fn a_legacy_event_row_signed_by_the_previous_release_still_verifies() {
3595 let key = fixed_key();
3606 let row = EventRow {
3607 seq: 1,
3608 key_id: CURRENT_KEY_ID.to_string(),
3609 kind: AuditEventKind::ApprovalGranted.as_str().to_string(),
3610 transaction_id: "tx-fixture-legacy".to_string(),
3611 receipt_digest: "digest-fixture-legacy".to_string(),
3612 created_at: "2026-08-18T09:00:00.000Z".to_string(),
3613 prev_chain_hash: String::new(),
3614 chain_hash: "1b7c2b864c13c8c2da5436b4a3d0d10ec37c129e97a4525228110c574fccf32b\
3615 d1a22001acb555faa016f6c50f26d5c783b447fbf866f8ff689ebec2fc7c340a"
3616 .to_string(),
3617 chain_version: EVENT_VERSION_LEGACY,
3618 caller_principal: None,
3619 };
3620 assert_eq!(
3621 verify_event_chain(&key, &[row]),
3622 VerifyOutcome::Intact { rows_checked: 1 },
3623 "the committed legacy event signature must still verify"
3624 );
3625 }
3626
3627 #[test]
3628 fn a_v2_event_signs_and_verifies_with_the_acting_account() {
3629 let key = fixed_key();
3630 let row = event_row(
3631 &key,
3632 1,
3633 AuditEventKind::ApprovalGranted,
3634 "tx1",
3635 "digest1",
3636 "2026-08-18T09:00:00.000Z",
3637 EventIdentity::V2 {
3638 caller_principal: "uid:1000",
3639 },
3640 "",
3641 );
3642 assert_eq!(row.chain_version, EVENT_VERSION_V2);
3643 assert_eq!(row.caller_principal.as_deref(), Some("uid:1000"));
3644 assert_eq!(
3645 verify_event_chain(&key, &[row]),
3646 VerifyOutcome::Intact { rows_checked: 1 }
3647 );
3648 }
3649
3650 #[test]
3651 fn a_grant_and_a_consume_by_different_accounts_are_distinguishable() {
3652 let key = fixed_key();
3657 let grant = event_row(
3658 &key,
3659 1,
3660 AuditEventKind::ApprovalGranted,
3661 "tx1",
3662 "digest1",
3663 "2026-08-18T09:00:00.000Z",
3664 EventIdentity::V2 {
3665 caller_principal: "uid:1000",
3666 },
3667 "",
3668 );
3669 let consume = event_row(
3670 &key,
3671 2,
3672 AuditEventKind::ApprovalConsumed,
3673 "tx1",
3674 "digest1",
3675 "2026-08-18T09:05:00.000Z",
3676 EventIdentity::V2 {
3677 caller_principal: "uid:1001",
3678 },
3679 &grant.chain_hash,
3680 );
3681 assert_eq!(
3682 verify_event_chain(&key, &[grant.clone(), consume.clone()]),
3683 VerifyOutcome::Intact { rows_checked: 2 }
3684 );
3685 assert_eq!(grant.caller_principal.as_deref(), Some("uid:1000"));
3686 assert_eq!(consume.caller_principal.as_deref(), Some("uid:1001"));
3687 assert_ne!(
3688 grant.caller_principal, consume.caller_principal,
3689 "grant and consume by different uids must not collapse to one record"
3690 );
3691 }
3692
3693 #[test]
3694 fn editing_a_stored_event_principal_reports_broken() {
3695 let key = fixed_key();
3699 let mut row = event_row(
3700 &key,
3701 1,
3702 AuditEventKind::ApprovalGranted,
3703 "tx1",
3704 "digest1",
3705 "2026-08-18T09:00:00.000Z",
3706 EventIdentity::V2 {
3707 caller_principal: "uid:1000",
3708 },
3709 "",
3710 );
3711 assert_eq!(
3712 verify_event_chain(&key, &[row.clone()]),
3713 VerifyOutcome::Intact { rows_checked: 1 }
3714 );
3715 row.caller_principal = Some("uid:9999".to_string());
3716 assert!(
3717 matches!(
3718 verify_event_chain(&key, &[row]),
3719 VerifyOutcome::Broken { .. }
3720 ),
3721 "an edited principal must not still verify"
3722 );
3723 }
3724
3725 #[test]
3726 fn downgrading_a_v2_event_to_legacy_reports_broken() {
3727 let key = fixed_key();
3731 let mut row = event_row(
3732 &key,
3733 1,
3734 AuditEventKind::ApprovalGranted,
3735 "tx1",
3736 "digest1",
3737 "2026-08-18T09:00:00.000Z",
3738 EventIdentity::V2 {
3739 caller_principal: "uid:1000",
3740 },
3741 "",
3742 );
3743 row.chain_version = EVENT_VERSION_LEGACY;
3744 row.caller_principal = None;
3745 assert!(matches!(
3746 verify_event_chain(&key, &[row]),
3747 VerifyOutcome::Broken { .. }
3748 ));
3749 }
3750
3751 #[test]
3752 fn a_v2_event_naming_nobody_is_broken_not_accepted() {
3753 let key = fixed_key();
3757 let mut blank = event_row(
3758 &key,
3759 1,
3760 AuditEventKind::ApprovalGranted,
3761 "tx1",
3762 "digest1",
3763 "2026-08-18T09:00:00.000Z",
3764 EventIdentity::V2 {
3765 caller_principal: "uid:1000",
3766 },
3767 "",
3768 );
3769 blank.caller_principal = Some(String::new());
3770 assert!(
3771 matches!(
3772 verify_event_chain(&key, &[blank]),
3773 VerifyOutcome::Broken { .. }
3774 ),
3775 "a blank principal on a v2 row must be broken"
3776 );
3777
3778 let mut absent = event_row(
3779 &key,
3780 1,
3781 AuditEventKind::ApprovalGranted,
3782 "tx1",
3783 "digest1",
3784 "2026-08-18T09:00:00.000Z",
3785 EventIdentity::V2 {
3786 caller_principal: "uid:1000",
3787 },
3788 "",
3789 );
3790 absent.caller_principal = None;
3791 assert!(matches!(
3792 verify_event_chain(&key, &[absent]),
3793 VerifyOutcome::Broken { .. }
3794 ));
3795 }
3796
3797 #[test]
3798 fn an_unknown_event_version_cannot_verify_rather_than_breaking() {
3799 let key = fixed_key();
3803 let mut row = event_row(
3804 &key,
3805 1,
3806 AuditEventKind::ApprovalGranted,
3807 "tx1",
3808 "digest1",
3809 "2026-08-18T09:00:00.000Z",
3810 EventIdentity::V2 {
3811 caller_principal: "uid:1000",
3812 },
3813 "",
3814 );
3815 row.chain_version = EVENT_VERSION_CURRENT + 1;
3816 let outcome = verify_event_chain(&key, &[row]);
3817 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3818 assert_eq!(outcome_to_exit_code(&outcome), 2);
3819 }
3820
3821 #[test]
3822 fn a_mixed_legacy_and_v2_event_chain_verifies() {
3823 let key = fixed_key();
3828 let legacy = event_row(
3829 &key,
3830 1,
3831 AuditEventKind::ApprovalGranted,
3832 "tx-old",
3833 "digest-old",
3834 "2026-08-18T09:00:00.000Z",
3835 EventIdentity::LegacyV1,
3836 "",
3837 );
3838 let v2 = event_row(
3839 &key,
3840 2,
3841 AuditEventKind::ApprovalGranted,
3842 "tx-new",
3843 "digest-new",
3844 "2026-09-01T09:00:00.000Z",
3845 EventIdentity::V2 {
3846 caller_principal: "uid:1000",
3847 },
3848 &legacy.chain_hash,
3849 );
3850 assert_eq!(legacy.chain_version, EVENT_VERSION_LEGACY);
3851 assert_eq!(v2.chain_version, EVENT_VERSION_V2);
3852 assert_eq!(
3853 verify_event_chain(&key, &[legacy, v2]),
3854 VerifyOutcome::Intact { rows_checked: 2 }
3855 );
3856 }
3857
3858 #[test]
3859 fn event_kind_spellings_are_stable() {
3860 assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
3863 assert_eq!(
3864 AuditEventKind::ApprovalConsumed.as_str(),
3865 "approval_consumed"
3866 );
3867 assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
3868 }
3869
3870 #[test]
3871 fn intact_event_chain_verifies() {
3872 let key = fixed_key();
3873 let rows = build_event_chain(
3874 &key,
3875 &[
3876 AuditEventKind::ApprovalGranted,
3877 AuditEventKind::ApprovalConsumed,
3878 ],
3879 );
3880 assert_eq!(
3881 verify_event_chain(&key, &rows),
3882 VerifyOutcome::Intact { rows_checked: 2 }
3883 );
3884 }
3885
3886 #[test]
3887 fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
3888 let key = fixed_key();
3891 let mut rows = build_event_chain(
3892 &key,
3893 &[
3894 AuditEventKind::ApprovalGranted,
3895 AuditEventKind::ApprovalConsumed,
3896 AuditEventKind::ApprovalRevoked,
3897 ],
3898 );
3899 rows.remove(1);
3900 match verify_event_chain(&key, &rows) {
3901 VerifyOutcome::Broken {
3902 first_broken_seq, ..
3903 } => assert_eq!(first_broken_seq, 3),
3904 other => panic!("expected Broken, got {other:?}"),
3905 }
3906 }
3907
3908 #[test]
3909 fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
3910 let key = fixed_key();
3911 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
3912 rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
3913 assert!(matches!(
3914 verify_event_chain(&key, &rows),
3915 VerifyOutcome::Broken { .. }
3916 ));
3917 }
3918
3919 #[test]
3920 fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
3921 let key = fixed_key();
3922 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
3923 rows[0].kind = "approval_unicorned".to_string();
3924 assert!(matches!(
3925 verify_event_chain(&key, &rows),
3926 VerifyOutcome::Broken { .. }
3927 ));
3928 }
3929
3930 #[test]
3931 fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
3932 let key = fixed_key();
3936 let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
3937 let event_sig = key.event_hash(&content, "");
3938 let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
3939 assert_ne!(event_sig, row_sig);
3940
3941 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
3942 rows[0].chain_hash = row_sig;
3943 assert!(matches!(
3944 verify_event_chain(&key, &rows),
3945 VerifyOutcome::Broken { .. }
3946 ));
3947 }
3948
3949 #[test]
3952 fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
3953 let key = fixed_key();
3958 let events = build_event_chain(
3959 &key,
3960 &[
3961 AuditEventKind::ApprovalGranted,
3962 AuditEventKind::ApprovalConsumed,
3963 ],
3964 );
3965 let mut content = sample_content(1, "tx-after");
3966 content.identity = ChainIdentity::V2 {
3967 caller_role: "Dev",
3968 event_tip: &events[1].chain_hash,
3969 };
3970 let hash = key.chain_hash(&content, "");
3971 let tx_rows = vec![row_for(&content, "", hash)];
3972
3973 assert_eq!(
3974 verify_event_binding(&tx_rows, &events),
3975 BindingOutcome::Consistent {
3976 bindings_checked: 1
3977 }
3978 );
3979
3980 let truncated = &events[..1];
3981 assert_eq!(
3982 verify_event_chain(&key, truncated),
3983 VerifyOutcome::Intact { rows_checked: 1 },
3984 "a truncated event chain still walks clean; the binding is the detector"
3985 );
3986 match verify_event_binding(&tx_rows, truncated) {
3987 BindingOutcome::MissingEvent {
3988 transaction_seq, ..
3989 } => assert_eq!(transaction_seq, 1),
3990 other => panic!("expected MissingEvent, got {other:?}"),
3991 }
3992 }
3993
3994 #[test]
3995 fn legacy_rows_carry_no_binding_to_check() {
3996 let key = fixed_key();
3999 let mut content = sample_content(1, "tx-legacy");
4000 content.identity = ChainIdentity::LegacyV1;
4001 let hash = key.chain_hash(&content, "");
4002 let rows = vec![row_for(&content, "", hash)];
4003 assert_eq!(
4004 verify_event_binding(&rows, &[]),
4005 BindingOutcome::Consistent {
4006 bindings_checked: 0
4007 }
4008 );
4009 }
4010
4011 #[test]
4012 fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
4013 let verification = AuditVerification {
4018 chain: VerifyOutcome::Broken {
4019 rows_checked: 1,
4020 first_broken_seq: 2,
4021 first_broken_transaction_id: "tx".to_string(),
4022 expected: "e".to_string(),
4023 actual: "a".to_string(),
4024 },
4025 events: VerifyOutcome::CannotVerify {
4026 reason: "no key".to_string(),
4027 },
4028 binding: BindingOutcome::Consistent {
4029 bindings_checked: 0,
4030 },
4031 attribution: None,
4032 status: None,
4033 };
4034 assert_eq!(verification.exit_code(), 1);
4035 }
4036
4037 #[test]
4038 fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
4039 let verification = AuditVerification {
4040 chain: VerifyOutcome::Intact { rows_checked: 3 },
4041 events: VerifyOutcome::Intact { rows_checked: 1 },
4042 binding: BindingOutcome::MissingEvent {
4043 transaction_seq: 3,
4044 event_tip: "abc".to_string(),
4045 },
4046 attribution: None,
4047 status: None,
4048 };
4049 assert_eq!(verification.exit_code(), 1);
4050 }
4051
4052 #[test]
4053 fn all_three_clean_is_exit_zero() {
4054 let verification = AuditVerification {
4055 chain: VerifyOutcome::Intact { rows_checked: 3 },
4056 events: VerifyOutcome::Intact { rows_checked: 2 },
4057 binding: BindingOutcome::Consistent {
4058 bindings_checked: 1,
4059 },
4060 attribution: None,
4061 status: None,
4062 };
4063 assert_eq!(verification.exit_code(), 0);
4064 }
4065
4066 #[test]
4067 fn binding_exit_codes_split_clean_tampered_and_unchecked() {
4068 assert_eq!(
4069 binding_outcome_to_exit_code(&BindingOutcome::Consistent {
4070 bindings_checked: 0
4071 }),
4072 0
4073 );
4074 assert_eq!(
4075 binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
4076 transaction_seq: 7,
4077 event_tip: "abc".to_string()
4078 }),
4079 1
4080 );
4081 assert_eq!(binding_outcome_to_exit_code(&BindingOutcome::NotChecked), 2);
4082 }
4083
4084 #[test]
4085 fn intact_chain_verifies() {
4086 let key = fixed_key();
4087 let rows = build_chain(&key, 5);
4088 let outcome = verify_chain(&key, &rows);
4089 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
4090 assert_eq!(outcome_to_exit_code(&outcome), 0);
4091 }
4092
4093 #[test]
4094 fn empty_chain_verifies() {
4095 let key = fixed_key();
4096 let outcome = verify_chain(&key, &[]);
4097 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
4098 }
4099
4100 #[test]
4101 fn tampered_summary_breaks_chain_at_first_offending_row() {
4102 let key = fixed_key();
4103 let mut rows = build_chain(&key, 3);
4104 rows[1].summary = "TAMPERED".to_string();
4106 let outcome = verify_chain(&key, &rows);
4107 match outcome {
4108 VerifyOutcome::Broken {
4109 first_broken_seq, ..
4110 } => assert_eq!(first_broken_seq, 2),
4111 other => panic!("expected Broken, got {other:?}"),
4112 }
4113 }
4114
4115 #[test]
4116 fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
4117 let key = fixed_key();
4118 let mut rows = build_chain(&key, 4);
4119 rows.remove(1);
4121 let outcome = verify_chain(&key, &rows);
4122 match outcome {
4123 VerifyOutcome::Broken {
4124 first_broken_seq, ..
4125 } => assert_eq!(
4126 first_broken_seq, 3,
4127 "first broken row is the one whose prev_hash refers to deleted row"
4128 ),
4129 other => panic!("expected Broken, got {other:?}"),
4130 }
4131 }
4132
4133 #[test]
4134 fn inserted_forged_row_breaks_chain() {
4135 let key = fixed_key();
4142 let mut rows = build_chain(&key, 3);
4143
4144 let forged = ChainRow {
4146 caller_principal: Some("uid:1000".to_string()),
4150 seq: 2,
4151 key_id: CURRENT_KEY_ID.to_string(),
4152 transaction_id: "tx-forged".to_string(),
4153 request_id: "req-forged".to_string(),
4154 request_hash: "hash-forged".to_string(),
4155 action_name: "InstallFlatpak".to_string(),
4156 risk_level: RiskLevel::Medium,
4157 summary: "Forged row".to_string(),
4158 approval_id: None,
4159 warnings_json: "[]".to_string(),
4160 created_at: "2026-04-25T13:00:00Z".to_string(),
4161 prev_chain_hash: rows[0].chain_hash.clone(),
4163 chain_hash: "0".repeat(HASH_HEX_LEN),
4165 chain_version: CHAIN_VERSION_CURRENT,
4166 caller_role: Some("Dev".to_string()),
4167 event_tip: Some(String::new()),
4168 };
4169
4170 let mut rest: Vec<ChainRow> = rows.split_off(1);
4172 for r in rest.iter_mut() {
4173 r.seq += 1;
4174 }
4175 rows.push(forged);
4176 rows.extend(rest);
4177
4178 let outcome = verify_chain(&key, &rows);
4182 match outcome {
4183 VerifyOutcome::Broken {
4184 first_broken_seq, ..
4185 } => assert_eq!(
4186 first_broken_seq, 2,
4187 "the verifier must flag the forged row itself, not a later one"
4188 ),
4189 other => panic!("expected Broken, got {other:?}"),
4190 }
4191 }
4192
4193 #[test]
4196 fn signature_verifies_under_exported_public_key() {
4197 let key = fixed_key();
4199 let rows = build_chain(&key, 4);
4200 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
4201 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
4202 }
4203
4204 #[test]
4205 fn foreign_public_key_cannot_validate_chain() {
4206 let signer = AuditKey::from_bytes(vec![0x11; 32]);
4210 let rows = build_chain(&signer, 3);
4211 let other = AuditKey::from_bytes(vec![0x22; 32]);
4212 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
4213 assert!(matches!(
4214 outcome,
4215 VerifyOutcome::Broken {
4216 first_broken_seq: 1,
4217 ..
4218 }
4219 ));
4220 }
4221
4222 #[test]
4223 fn verifying_key_hex_is_a_32_byte_public_key() {
4224 let key = fixed_key();
4225 let vk_hex = key.verifying_key_hex();
4226 assert_eq!(
4227 vk_hex.len(),
4228 64,
4229 "32-byte ed25519 public key = 64 hex chars"
4230 );
4231 assert!(hex::decode(&vk_hex).is_ok());
4232 }
4233
4234 #[test]
4235 fn malformed_signature_hex_is_broken_not_panic() {
4236 let key = fixed_key();
4237 let mut rows = build_chain(&key, 2);
4238 rows[0].chain_hash = "not-valid-hex!!".to_string();
4239 let outcome = verify_chain(&key, &rows);
4240 assert!(matches!(
4241 outcome,
4242 VerifyOutcome::Broken {
4243 first_broken_seq: 1,
4244 ..
4245 }
4246 ));
4247 }
4248
4249 #[test]
4250 fn bad_public_key_hex_yields_cannot_verify() {
4251 let key = fixed_key();
4252 let rows = build_chain(&key, 1);
4253 let outcome = verify_chain_with_pubkey("zz", &rows);
4254 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
4255 }
4256
4257 #[test]
4258 fn load_or_generate_writes_public_key_sidecar() {
4259 let dir = tempfile::tempdir().unwrap();
4260 let path = dir.path().join("audit-key");
4261 let key = AuditKey::load_or_generate(&path).unwrap();
4262 let pub_path = dir.path().join("audit-key.pub");
4263 assert!(pub_path.exists(), "public key sidecar must be written");
4264 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
4265 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
4266 }
4267
4268 #[test]
4269 fn wrong_key_id_yields_cannot_verify() {
4270 let key = fixed_key();
4271 let mut rows = build_chain(&key, 2);
4272 rows[0].key_id = "v99".to_string();
4273 let outcome = verify_chain(&key, &rows);
4274 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
4275 assert_eq!(outcome_to_exit_code(&outcome), 2);
4276 }
4277
4278 #[test]
4279 fn exit_code_for_each_outcome() {
4280 assert_eq!(
4281 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
4282 0
4283 );
4284 assert_eq!(
4285 outcome_to_exit_code(&VerifyOutcome::Broken {
4286 rows_checked: 0,
4287 first_broken_seq: 1,
4288 first_broken_transaction_id: String::new(),
4289 expected: String::new(),
4290 actual: String::new(),
4291 }),
4292 1
4293 );
4294 assert_eq!(
4295 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
4296 reason: String::new()
4297 }),
4298 2
4299 );
4300 }
4301
4302 #[test]
4305 fn load_or_generate_creates_with_0600_mode() {
4306 use std::os::unix::fs::PermissionsExt;
4307 let dir = tempfile::tempdir().unwrap();
4308 let path = dir.path().join("audit-key");
4309 let _key = AuditKey::load_or_generate(&path).unwrap();
4310 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
4311 assert_eq!(mode, 0o600);
4312 }
4313
4314 #[test]
4315 fn load_or_generate_is_idempotent() {
4316 let dir = tempfile::tempdir().unwrap();
4317 let path = dir.path().join("audit-key");
4318 let key1_bytes = {
4319 let _ = AuditKey::load_or_generate(&path).unwrap();
4320 std::fs::read(&path).unwrap()
4321 };
4322 let _ = AuditKey::load_or_generate(&path).unwrap();
4323 let key2_bytes = std::fs::read(&path).unwrap();
4324 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
4325 }
4326
4327 #[test]
4328 fn rejects_world_readable_key_file() {
4329 use std::os::unix::fs::PermissionsExt;
4330 let dir = tempfile::tempdir().unwrap();
4331 let path = dir.path().join("audit-key");
4332 std::fs::write(&path, vec![0u8; 32]).unwrap();
4333 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
4334 let result = AuditKey::load_or_generate(&path);
4335 assert!(matches!(
4336 result,
4337 Err(AuditKeyError::UnsafePermissions { .. })
4338 ));
4339 }
4340
4341 #[test]
4342 fn rejects_short_key_file() {
4343 use std::os::unix::fs::PermissionsExt;
4344 let dir = tempfile::tempdir().unwrap();
4345 let path = dir.path().join("audit-key");
4346 std::fs::write(&path, vec![0u8; 8]).unwrap();
4347 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
4348 let result = AuditKey::load_or_generate(&path);
4349 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
4350 }
4351
4352 #[test]
4358 fn debug_redacts_key_bytes_and_their_hex_encoding() {
4359 let raw = (0u8..32).collect::<Vec<u8>>();
4361 let key = AuditKey::from_bytes(raw.clone());
4362 let dbg = format!("{key:?}");
4363
4364 assert!(
4365 dbg.contains("redacted"),
4366 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
4367 );
4368
4369 let hex_lower = hex::encode(&raw);
4371 let hex_upper = hex::encode_upper(&raw);
4372 assert!(
4373 !dbg.contains(&hex_lower),
4374 "Debug output leaks lowercase hex of key bytes: {dbg}"
4375 );
4376 assert!(
4377 !dbg.contains(&hex_upper),
4378 "Debug output leaks uppercase hex of key bytes: {dbg}"
4379 );
4380
4381 for b in &raw {
4385 let pair = format!("{b:02x}");
4386 if *b >= 0x10 && pair != "1e" && pair != "1f" {
4391 assert!(
4392 !dbg.contains(&pair),
4393 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
4394 );
4395 }
4396 }
4397 }
4398
4399 #[test]
4402 fn debug_preserves_key_id() {
4403 let key = AuditKey::from_bytes(vec![0xff; 32]);
4404 let dbg = format!("{key:?}");
4405 assert!(
4406 dbg.contains("key_id"),
4407 "Debug output must label the key_id field: {dbg}"
4408 );
4409 assert!(
4410 dbg.contains(CURRENT_KEY_ID),
4411 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
4412 );
4413 }
4414
4415 #[test]
4418 fn checkpoint_consistent_with_intact_chain() {
4419 let key = fixed_key();
4420 let rows = build_chain(&key, 5);
4421 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4422 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
4423 assert_eq!(
4424 outcome,
4425 CheckpointOutcome::Consistent {
4426 checkpoints_checked: 1
4427 }
4428 );
4429 }
4430
4431 #[test]
4432 fn checkpoint_detects_truncation() {
4433 let key = fixed_key();
4434 let full = build_chain(&key, 5);
4435 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
4437 let truncated = &full[..3];
4438 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
4439 assert!(matches!(
4440 outcome,
4441 CheckpointOutcome::Truncated {
4442 checkpoint_seq: 5,
4443 current_max_seq: 3
4444 }
4445 ));
4446 }
4447
4448 #[test]
4449 fn checkpoint_detects_rewrite() {
4450 let key = fixed_key();
4451 let mut rows = build_chain(&key, 4);
4452 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4453 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
4455 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
4456 assert!(matches!(
4457 outcome,
4458 CheckpointOutcome::TipMismatch { seq: 3, .. }
4459 ));
4460 }
4461
4462 #[test]
4463 fn checkpoint_bad_signature_detected() {
4464 let key = fixed_key();
4465 let rows = build_chain(&key, 3);
4466 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
4467 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
4469 assert!(matches!(
4470 outcome,
4471 CheckpointOutcome::BadSignature { seq: 2 }
4472 ));
4473 }
4474
4475 #[test]
4476 fn checkpoint_foreign_key_rejected() {
4477 let signer = AuditKey::from_bytes(vec![0x11; 32]);
4479 let rows = build_chain(&signer, 3);
4480 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
4481 let other = AuditKey::from_bytes(vec![0x22; 32]);
4482 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
4483 assert!(matches!(
4484 outcome,
4485 CheckpointOutcome::BadSignature { seq: 2 }
4486 ));
4487 }
4488
4489 #[test]
4490 fn pubkey_verify_detects_tampered_middle_row() {
4491 let key = fixed_key();
4494 let mut rows = build_chain(&key, 4);
4495 rows[2].summary = "TAMPERED".to_string();
4496 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
4497 assert!(matches!(
4498 outcome,
4499 VerifyOutcome::Broken {
4500 first_broken_seq: 3,
4501 ..
4502 }
4503 ));
4504 }
4505
4506 #[test]
4507 fn multiple_checkpoints_all_consistent() {
4508 let key = fixed_key();
4509 let rows = build_chain(&key, 5);
4510 let cps = vec![
4511 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
4512 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
4513 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
4514 ];
4515 assert_eq!(
4516 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
4517 CheckpointOutcome::Consistent {
4518 checkpoints_checked: 3
4519 }
4520 );
4521 }
4522
4523 #[test]
4524 fn middle_checkpoint_failure_is_reported() {
4525 let key = fixed_key();
4527 let mut rows = build_chain(&key, 5);
4528 let cps = vec![
4529 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
4530 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
4531 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
4532 ];
4533 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
4535 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
4536 CheckpointOutcome::TipMismatch { seq: 3, .. }
4537 ));
4538 }
4539
4540 #[test]
4541 fn checkpoint_created_at_is_signed() {
4542 let key = fixed_key();
4544 let rows = build_chain(&key, 3);
4545 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4546 cp.created_at = "2020-01-01T00:00:00Z".to_string();
4547 assert!(matches!(
4548 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
4549 CheckpointOutcome::BadSignature { .. }
4550 ));
4551 }
4552
4553 #[test]
4554 fn checkpoint_seq_is_signed() {
4555 let key = fixed_key();
4557 let rows = build_chain(&key, 3);
4558 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4559 cp.seq = 2;
4560 assert!(matches!(
4561 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
4562 CheckpointOutcome::BadSignature { seq: 2 }
4563 ));
4564 }
4565
4566 #[test]
4567 fn checkpoint_detects_full_wipe() {
4568 let key = fixed_key();
4570 let full = build_chain(&key, 3);
4571 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
4572 assert!(matches!(
4573 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
4574 CheckpointOutcome::Truncated {
4575 checkpoint_seq: 3,
4576 current_max_seq: 0
4577 }
4578 ));
4579 }
4580
4581 #[test]
4582 fn domain_tags_are_distinct_and_prefix_free() {
4583 let tags: [(&str, &[u8]); 5] = [
4590 ("row", ROW_DOMAIN),
4591 ("checkpoint", CHECKPOINT_DOMAIN),
4592 ("approval", APPROVAL_DOMAIN),
4593 ("event", EVENT_DOMAIN),
4594 ("request_hash", REQUEST_HASH_DOMAIN),
4595 ];
4596 for (i, (name_a, a)) in tags.iter().enumerate() {
4597 for (name_b, b) in tags.iter().skip(i + 1) {
4598 assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
4599 assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
4600 assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
4601 }
4602 }
4603 }
4604
4605 #[test]
4606 fn approval_receipt_framing_is_injective() {
4607 let key = fixed_key();
4612 let sep = "\u{1f}";
4613 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
4614 let b = key.approval_receipt("tx", &format!("b{sep}c"));
4615 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
4616
4617 assert_eq!(
4619 key.approval_receipt("tx-1", "hash-1"),
4620 key.approval_receipt("tx-1", "hash-1"),
4621 );
4622 assert_eq!(
4624 key.approval_commitment("tx-1", "hash-1"),
4625 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
4626 );
4627 }
4628}