1use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
76use sha2::{Digest, Sha256};
77use std::path::{Path, PathBuf};
78use sysknife_types::RiskLevel;
79use zeroize::Zeroize;
80
81pub const CURRENT_KEY_ID: &str = "v1";
84
85pub const HASH_HEX_LEN: usize = 128;
87
88const ROW_DOMAIN: &[u8] = b"sysknife-audit-row-v1\x1f";
92const CHECKPOINT_DOMAIN: &[u8] = b"sysknife-checkpoint-v1\x1f";
93const APPROVAL_DOMAIN: &[u8] = b"sysknife-approval-receipt-v1\x1f";
94const EVENT_DOMAIN: &[u8] = b"sysknife-audit-event-v1\x1f";
95
96pub const CHAIN_VERSION_LEGACY: u32 = 1;
99pub const CHAIN_VERSION_V2: u32 = 2;
109pub const CHAIN_VERSION_V3: u32 = 3;
117pub const CHAIN_VERSION_CURRENT: u32 = CHAIN_VERSION_V3;
124
125#[derive(Clone)]
137pub struct AuditKey {
138 key_id: String,
139 signing: SigningKey,
140}
141
142impl std::fmt::Debug for AuditKey {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 f.debug_struct("AuditKey")
149 .field("key_id", &self.key_id)
150 .field("signing", &format_args!("<redacted signing key>"))
151 .finish()
152 }
153}
154
155#[derive(Debug, thiserror::Error)]
160pub enum AuditKeyError {
161 #[error("io error reading audit key {path}: {source}")]
162 Io {
163 path: PathBuf,
164 #[source]
165 source: std::io::Error,
166 },
167 #[error(
168 "audit key file {path} has unsafe permissions {mode:#o}; \
169 tighten with `chmod 600 {path:?}` and restart"
170 )]
171 UnsafePermissions { path: PathBuf, mode: u32 },
172 #[error(
173 "audit key file {path} is too short ({len} bytes); \
174 expected at least 32 bytes of random material"
175 )]
176 KeyTooShort { path: PathBuf, len: usize },
177}
178
179impl AuditKey {
180 pub fn load_or_generate(path: &Path) -> Result<Self, AuditKeyError> {
187 if !path.exists() {
188 generate_key_at(path)?;
189 }
190
191 let metadata = std::fs::metadata(path).map_err(|e| AuditKeyError::Io {
192 path: path.to_path_buf(),
193 source: e,
194 })?;
195
196 use std::os::unix::fs::PermissionsExt;
197 let mode = metadata.permissions().mode() & 0o777;
198 if mode & 0o077 != 0 {
199 return Err(AuditKeyError::UnsafePermissions {
201 path: path.to_path_buf(),
202 mode,
203 });
204 }
205
206 let mut key_bytes = std::fs::read(path).map_err(|e| AuditKeyError::Io {
207 path: path.to_path_buf(),
208 source: e,
209 })?;
210 if key_bytes.len() < 32 {
211 return Err(AuditKeyError::KeyTooShort {
212 path: path.to_path_buf(),
213 len: key_bytes.len(),
214 });
215 }
216
217 let mut seed = [0u8; 32];
218 seed.copy_from_slice(&key_bytes[..32]);
219 let signing = SigningKey::from_bytes(&seed);
220 seed.zeroize();
221 key_bytes.zeroize();
222
223 Ok(Self {
224 key_id: CURRENT_KEY_ID.to_string(),
225 signing,
226 })
227 }
228
229 #[cfg(test)]
232 pub fn from_bytes(bytes: Vec<u8>) -> Self {
233 let mut seed = [0u8; 32];
234 seed.copy_from_slice(&bytes[..32]);
235 let signing = SigningKey::from_bytes(&seed);
236 seed.zeroize();
237 Self {
238 key_id: CURRENT_KEY_ID.to_string(),
239 signing,
240 }
241 }
242
243 pub fn key_id(&self) -> &str {
245 &self.key_id
246 }
247
248 pub fn chain_hash(&self, content: &ChainContent, prev_chain_hash: &str) -> String {
253 let sig = self.signing.sign(&chain_message(content, prev_chain_hash));
254 hex::encode(sig.to_bytes())
255 }
256
257 pub fn verifying_key(&self) -> VerifyingKey {
259 self.signing.verifying_key()
260 }
261
262 pub fn verifying_key_hex(&self) -> String {
265 hex::encode(self.signing.verifying_key().to_bytes())
266 }
267
268 pub fn approval_receipt(&self, transaction_id: &str, request_hash: &str) -> String {
272 let mut message = APPROVAL_DOMAIN.to_vec();
278 push_field(&mut message, "txid", transaction_id);
279 push_field(&mut message, "reqhash", request_hash);
280 hex::encode(self.signing.sign(&message).to_bytes())
281 }
282
283 pub fn approval_commitment(&self, transaction_id: &str, request_hash: &str) -> String {
285 approval_receipt_digest(&self.approval_receipt(transaction_id, request_hash))
286 }
287}
288
289pub fn approval_receipt_digest(receipt: &str) -> String {
290 hex::encode(Sha256::digest(receipt.as_bytes()))
291}
292
293fn chain_message(content: &ChainContent, prev_chain_hash: &str) -> Vec<u8> {
299 let mut msg = ROW_DOMAIN.to_vec();
300 msg.extend_from_slice(&content.canonical_bytes());
301 msg.extend_from_slice(prev_chain_hash.as_bytes());
302 msg
303}
304
305pub fn resolve_audit_key_path(db_path: &Path) -> PathBuf {
311 std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
312 .map(PathBuf::from)
313 .unwrap_or_else(|_| {
314 db_path
315 .parent()
316 .unwrap_or_else(|| Path::new("."))
317 .join("audit-key")
318 })
319}
320
321fn generate_key_at(path: &Path) -> Result<(), AuditKeyError> {
324 use std::io::Write as _;
325 use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
326
327 if let Some(parent) = path.parent() {
328 if !parent.exists() {
329 std::fs::DirBuilder::new()
330 .recursive(true)
331 .mode(0o700)
332 .create(parent)
333 .map_err(|e| AuditKeyError::Io {
334 path: parent.to_path_buf(),
335 source: e,
336 })?;
337 }
338 }
339
340 let mut bytes = [0u8; 32];
341 fill_random(&mut bytes).map_err(|e| AuditKeyError::Io {
342 path: path.to_path_buf(),
343 source: e,
344 })?;
345
346 let mut f = std::fs::OpenOptions::new()
347 .write(true)
348 .create_new(true)
349 .mode(0o600)
350 .open(path)
351 .map_err(|e| AuditKeyError::Io {
352 path: path.to_path_buf(),
353 source: e,
354 })?;
355 f.write_all(&bytes).map_err(|e| AuditKeyError::Io {
356 path: path.to_path_buf(),
357 source: e,
358 })?;
359 f.sync_all().map_err(|e| AuditKeyError::Io {
360 path: path.to_path_buf(),
361 source: e,
362 })?;
363
364 let signing = SigningKey::from_bytes(&bytes);
367 let pub_hex = hex::encode(signing.verifying_key().to_bytes());
368 bytes.zeroize();
369 let mut pub_path = path.as_os_str().to_os_string();
370 pub_path.push(".pub");
371 std::fs::write(&pub_path, format!("{pub_hex}\n")).map_err(|e| AuditKeyError::Io {
372 path: PathBuf::from(pub_path),
373 source: e,
374 })?;
375 Ok(())
376}
377
378fn fill_random(buf: &mut [u8]) -> std::io::Result<()> {
380 use std::io::Read as _;
381 let mut f = std::fs::File::open("/dev/urandom")?;
382 f.read_exact(buf)
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub enum ChainIdentity<'a> {
449 LegacyV1,
451 V2 {
454 caller_role: &'a str,
459 event_tip: &'a str,
465 },
466 V3 {
473 caller_role: &'a str,
475 event_tip: &'a str,
477 caller_principal: &'a str,
488 },
489}
490
491impl ChainIdentity<'_> {
492 pub fn version(&self) -> u32 {
494 match self {
495 Self::LegacyV1 => CHAIN_VERSION_LEGACY,
496 Self::V2 { .. } => CHAIN_VERSION_V2,
497 Self::V3 { .. } => CHAIN_VERSION_V3,
498 }
499 }
500}
501
502#[derive(Debug, Clone, PartialEq, Eq)]
503pub struct ChainContent<'a> {
504 pub seq: u64,
505 pub key_id: &'a str,
506 pub transaction_id: &'a str,
507 pub request_id: &'a str,
508 pub request_hash: &'a str,
509 pub action_name: &'a str,
510 pub risk_level: RiskLevel,
511 pub summary: &'a str,
520 pub approval_id: Option<&'a str>,
523 pub warnings_json: &'a str,
525 pub created_at: &'a str,
526 pub identity: ChainIdentity<'a>,
528}
529
530impl<'a> ChainContent<'a> {
531 pub fn canonical_bytes(&self) -> Vec<u8> {
535 let risk_level_str = match self.risk_level {
536 RiskLevel::Low => "low",
537 RiskLevel::Medium => "medium",
538 RiskLevel::High => "high",
539 };
540 let approval = self.approval_id.unwrap_or("");
541
542 let mut buf = Vec::with_capacity(512);
543 push_field(&mut buf, "seq", &self.seq.to_string());
546 push_field(&mut buf, "key_id", self.key_id);
547 push_field(&mut buf, "transaction_id", self.transaction_id);
548 push_field(&mut buf, "request_id", self.request_id);
549 push_field(&mut buf, "request_hash", self.request_hash);
550 push_field(&mut buf, "action_name", self.action_name);
551 push_field(&mut buf, "risk_level", risk_level_str);
552 push_field(&mut buf, "summary", self.summary);
553 push_field(&mut buf, "approval_id", approval);
554 push_field(&mut buf, "warnings_json", self.warnings_json);
555 push_field(&mut buf, "created_at", self.created_at);
556 match self.identity {
572 ChainIdentity::LegacyV1 => {}
573 ChainIdentity::V2 {
574 caller_role,
575 event_tip,
576 } => {
577 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V2.to_string());
578 push_field(&mut buf, "caller_role", caller_role);
579 push_field(&mut buf, "event_tip", event_tip);
580 }
581 ChainIdentity::V3 {
582 caller_role,
583 event_tip,
584 caller_principal,
585 } => {
586 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V3.to_string());
587 push_field(&mut buf, "caller_role", caller_role);
588 push_field(&mut buf, "event_tip", event_tip);
589 push_field(&mut buf, "caller_principal", caller_principal);
590 }
591 }
592 buf
593 }
594}
595
596fn push_field(buf: &mut Vec<u8>, tag: &str, value: &str) {
614 buf.extend_from_slice(tag.as_bytes());
615 buf.push(0x1E); for b in value.bytes() {
617 match b {
618 b'\\' => buf.extend_from_slice(b"\\\\"),
619 0x00 => buf.extend_from_slice(b"\\0"),
620 0x1E => buf.extend_from_slice(b"\\1E"),
621 0x1F => buf.extend_from_slice(b"\\1F"),
622 other => buf.push(other),
623 }
624 }
625 buf.push(0x1F); }
627
628#[derive(Debug, Clone, PartialEq, Eq)]
630pub enum VerifyOutcome {
631 Intact { rows_checked: u64 },
633 Broken {
635 rows_checked: u64,
636 first_broken_seq: u64,
637 first_broken_transaction_id: String,
638 expected: String,
639 actual: String,
640 },
641 CannotVerify { reason: String },
645}
646
647pub fn outcome_to_exit_code(outcome: &VerifyOutcome) -> i32 {
653 match outcome {
654 VerifyOutcome::Intact { .. } => 0,
655 VerifyOutcome::Broken { .. } => 1,
656 VerifyOutcome::CannotVerify { .. } => 2,
657 }
658}
659
660#[derive(Debug, Clone)]
662pub struct ChainRow {
663 pub seq: u64,
664 pub key_id: String,
665 pub transaction_id: String,
666 pub request_id: String,
667 pub request_hash: String,
668 pub action_name: String,
669 pub risk_level: RiskLevel,
670 pub summary: String,
671 pub approval_id: Option<String>,
672 pub warnings_json: String,
673 pub created_at: String,
674 pub prev_chain_hash: String,
675 pub chain_hash: String,
676 pub chain_version: u32,
678 pub caller_role: Option<String>,
680 pub event_tip: Option<String>,
683 pub caller_principal: Option<String>,
687}
688
689#[derive(Debug)]
691pub(crate) enum RowIdentityError {
692 UnknownVersion(u32),
696 MissingField(&'static str),
702}
703
704impl ChainRow {
705 pub(crate) fn identity(&self) -> Result<ChainIdentity<'_>, RowIdentityError> {
708 match self.chain_version {
709 CHAIN_VERSION_LEGACY => Ok(ChainIdentity::LegacyV1),
710 CHAIN_VERSION_V2 => Ok(ChainIdentity::V2 {
711 caller_role: self
712 .caller_role
713 .as_deref()
714 .ok_or(RowIdentityError::MissingField("caller_role"))?,
715 event_tip: self
716 .event_tip
717 .as_deref()
718 .ok_or(RowIdentityError::MissingField("event_tip"))?,
719 }),
720 CHAIN_VERSION_V3 => Ok(ChainIdentity::V3 {
721 caller_role: self
722 .caller_role
723 .as_deref()
724 .ok_or(RowIdentityError::MissingField("caller_role"))?,
725 event_tip: self
726 .event_tip
727 .as_deref()
728 .ok_or(RowIdentityError::MissingField("event_tip"))?,
729 caller_principal: self
733 .caller_principal
734 .as_deref()
735 .filter(|p| !p.is_empty())
736 .ok_or(RowIdentityError::MissingField("caller_principal"))?,
737 }),
738 other => Err(RowIdentityError::UnknownVersion(other)),
739 }
740 }
741}
742
743pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
747 verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
748}
749
750pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
755 match parse_verifying_key(verifying_key_hex) {
756 Some(vk) => verify_rows(&vk, None, rows),
757 None => VerifyOutcome::CannotVerify {
758 reason: format!(
759 "invalid public key hex ({} chars); expected 64 hex chars of a \
760 32-byte Ed25519 public key",
761 verifying_key_hex.len()
762 ),
763 },
764 }
765}
766
767fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
768 let bytes = hex::decode(hex_str.trim()).ok()?;
769 let arr: [u8; 32] = bytes.try_into().ok()?;
770 VerifyingKey::from_bytes(&arr).ok()
771}
772
773fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
776 let Ok(bytes) = hex::decode(sig_hex) else {
777 return false;
778 };
779 let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
780 return false;
781 };
782 vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
783}
784
785fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
789 let mut last_hash = String::new();
790 let mut rows_checked = 0u64;
791 for row in rows {
792 if let Some(kid) = expect_key_id {
793 if row.key_id != kid {
794 return VerifyOutcome::CannotVerify {
795 reason: format!(
796 "row seq={} uses key_id={:?} but only {:?} is loaded; \
797 epoch keys not yet supported",
798 row.seq, row.key_id, kid
799 ),
800 };
801 }
802 }
803 if row.prev_chain_hash != last_hash {
804 return VerifyOutcome::Broken {
805 rows_checked,
806 first_broken_seq: row.seq,
807 first_broken_transaction_id: row.transaction_id.clone(),
808 expected: format!("prev_chain_hash={last_hash}"),
809 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
810 };
811 }
812 let identity = match row.identity() {
813 Ok(identity) => identity,
814 Err(RowIdentityError::UnknownVersion(v)) => {
815 return VerifyOutcome::CannotVerify {
816 reason: format!(
817 "row seq={} declares chain_version={v}, which this binary cannot \
818 reproduce (it understands {CHAIN_VERSION_LEGACY}..={CHAIN_VERSION_CURRENT}); \
819 verify with a build at least as new as the one that wrote the chain",
820 row.seq
821 ),
822 };
823 }
824 Err(RowIdentityError::MissingField(field)) => {
825 let stored = match field {
830 "caller_role" => row.caller_role.as_deref(),
831 "event_tip" => row.event_tip.as_deref(),
832 "caller_principal" => row.caller_principal.as_deref(),
833 _ => None,
834 };
835 return VerifyOutcome::Broken {
836 rows_checked,
837 first_broken_seq: row.seq,
838 first_broken_transaction_id: row.transaction_id.clone(),
839 expected: format!(
840 "chain_version={} row carrying a non-empty {field}",
841 row.chain_version
842 ),
843 actual: match stored {
846 None => format!("{field}=NULL"),
847 Some("") => format!("{field}='' (empty, not NULL)"),
848 Some(other) => format!("{field}={other:?}"),
849 },
850 };
851 }
852 };
853 let content = ChainContent {
854 seq: row.seq,
855 key_id: &row.key_id,
856 transaction_id: &row.transaction_id,
857 request_id: &row.request_id,
858 request_hash: &row.request_hash,
859 action_name: &row.action_name,
860 risk_level: row.risk_level,
861 summary: &row.summary,
862 approval_id: row.approval_id.as_deref(),
863 warnings_json: &row.warnings_json,
864 created_at: &row.created_at,
865 identity,
866 };
867 let msg = chain_message(&content, &row.prev_chain_hash);
868 if !signature_ok(vk, &msg, &row.chain_hash) {
869 return VerifyOutcome::Broken {
870 rows_checked,
871 first_broken_seq: row.seq,
872 first_broken_transaction_id: row.transaction_id.clone(),
873 expected: "valid ed25519 signature".to_string(),
874 actual: row.chain_hash.clone(),
875 };
876 }
877 last_hash = row.chain_hash.clone();
878 rows_checked += 1;
879 }
880 VerifyOutcome::Intact { rows_checked }
881}
882
883#[derive(Debug, Clone, Copy, PartialEq, Eq)]
896pub enum AuditEventKind {
897 ApprovalGranted,
899 ApprovalConsumed,
901 ApprovalRevoked,
903}
904
905impl AuditEventKind {
906 pub fn as_str(&self) -> &'static str {
909 match self {
910 Self::ApprovalGranted => "approval_granted",
911 Self::ApprovalConsumed => "approval_consumed",
912 Self::ApprovalRevoked => "approval_revoked",
913 }
914 }
915}
916
917#[derive(Debug, Clone, PartialEq, Eq)]
919pub struct EventContent<'a> {
920 pub seq: u64,
921 pub key_id: &'a str,
922 pub kind: AuditEventKind,
923 pub transaction_id: &'a str,
924 pub receipt_digest: &'a str,
928 pub created_at: &'a str,
929}
930
931impl EventContent<'_> {
932 pub fn canonical_bytes(&self) -> Vec<u8> {
935 let mut buf = Vec::with_capacity(256);
936 push_field(&mut buf, "seq", &self.seq.to_string());
937 push_field(&mut buf, "key_id", self.key_id);
938 push_field(&mut buf, "kind", self.kind.as_str());
939 push_field(&mut buf, "transaction_id", self.transaction_id);
940 push_field(&mut buf, "receipt_digest", self.receipt_digest);
941 push_field(&mut buf, "created_at", self.created_at);
942 buf
943 }
944}
945
946fn event_message(content: &EventContent, prev_chain_hash: &str) -> Vec<u8> {
947 let mut msg = EVENT_DOMAIN.to_vec();
948 msg.extend_from_slice(&content.canonical_bytes());
949 msg.extend_from_slice(prev_chain_hash.as_bytes());
950 msg
951}
952
953impl AuditKey {
954 pub fn event_hash(&self, content: &EventContent, prev_chain_hash: &str) -> String {
958 hex::encode(
959 self.signing
960 .sign(&event_message(content, prev_chain_hash))
961 .to_bytes(),
962 )
963 }
964}
965
966#[derive(Debug, Clone, PartialEq, Eq)]
968pub struct EventRow {
969 pub seq: u64,
970 pub key_id: String,
971 pub kind: String,
975 pub transaction_id: String,
976 pub receipt_digest: String,
977 pub created_at: String,
978 pub prev_chain_hash: String,
979 pub chain_hash: String,
980}
981
982pub fn verify_event_chain(key: &AuditKey, rows: &[EventRow]) -> VerifyOutcome {
984 verify_event_rows(&key.verifying_key(), Some(key.key_id()), rows)
985}
986
987pub fn verify_event_chain_with_pubkey(verifying_key_hex: &str, rows: &[EventRow]) -> VerifyOutcome {
989 match parse_verifying_key(verifying_key_hex) {
990 Some(vk) => verify_event_rows(&vk, None, rows),
991 None => VerifyOutcome::CannotVerify {
992 reason: format!(
993 "invalid public key hex ({} chars); expected 64 hex chars of a \
994 32-byte Ed25519 public key",
995 verifying_key_hex.len()
996 ),
997 },
998 }
999}
1000
1001fn verify_event_rows(
1002 vk: &VerifyingKey,
1003 expect_key_id: Option<&str>,
1004 rows: &[EventRow],
1005) -> VerifyOutcome {
1006 let mut last_hash = String::new();
1007 let mut rows_checked = 0u64;
1008 for row in rows {
1009 if let Some(kid) = expect_key_id {
1010 if row.key_id != kid {
1011 return VerifyOutcome::CannotVerify {
1012 reason: format!(
1013 "event seq={} uses key_id={:?} but only {:?} is loaded; \
1014 epoch keys not yet supported",
1015 row.seq, row.key_id, kid
1016 ),
1017 };
1018 }
1019 }
1020 if row.prev_chain_hash != last_hash {
1021 return VerifyOutcome::Broken {
1022 rows_checked,
1023 first_broken_seq: row.seq,
1024 first_broken_transaction_id: row.transaction_id.clone(),
1025 expected: format!("prev_chain_hash={last_hash}"),
1026 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
1027 };
1028 }
1029 let Some(kind) = parse_event_kind(&row.kind) else {
1032 return VerifyOutcome::Broken {
1033 rows_checked,
1034 first_broken_seq: row.seq,
1035 first_broken_transaction_id: row.transaction_id.clone(),
1036 expected: "a known event kind".to_string(),
1037 actual: format!("kind={:?}", row.kind),
1038 };
1039 };
1040 let content = EventContent {
1041 seq: row.seq,
1042 key_id: &row.key_id,
1043 kind,
1044 transaction_id: &row.transaction_id,
1045 receipt_digest: &row.receipt_digest,
1046 created_at: &row.created_at,
1047 };
1048 if !signature_ok(
1049 vk,
1050 &event_message(&content, &row.prev_chain_hash),
1051 &row.chain_hash,
1052 ) {
1053 return VerifyOutcome::Broken {
1054 rows_checked,
1055 first_broken_seq: row.seq,
1056 first_broken_transaction_id: row.transaction_id.clone(),
1057 expected: "valid ed25519 signature".to_string(),
1058 actual: row.chain_hash.clone(),
1059 };
1060 }
1061 last_hash = row.chain_hash.clone();
1062 rows_checked += 1;
1063 }
1064 VerifyOutcome::Intact { rows_checked }
1065}
1066
1067fn parse_event_kind(raw: &str) -> Option<AuditEventKind> {
1068 [
1071 AuditEventKind::ApprovalGranted,
1072 AuditEventKind::ApprovalConsumed,
1073 AuditEventKind::ApprovalRevoked,
1074 ]
1075 .into_iter()
1076 .find(|kind| kind.as_str() == raw)
1077}
1078
1079#[derive(Debug, Clone, PartialEq, Eq)]
1082pub enum BindingOutcome {
1083 Consistent { bindings_checked: u64 },
1085 MissingEvent {
1089 transaction_seq: u64,
1090 event_tip: String,
1091 },
1092}
1093
1094pub fn verify_event_binding(tx_rows: &[ChainRow], event_rows: &[EventRow]) -> BindingOutcome {
1104 let known: std::collections::HashSet<&str> = event_rows
1105 .iter()
1106 .map(|row| row.chain_hash.as_str())
1107 .collect();
1108 let mut bindings_checked = 0u64;
1109 for row in tx_rows {
1110 let Some(tip) = row.event_tip.as_deref() else {
1111 continue;
1112 };
1113 if tip.is_empty() {
1114 continue;
1115 }
1116 if !known.contains(tip) {
1117 return BindingOutcome::MissingEvent {
1118 transaction_seq: row.seq,
1119 event_tip: tip.to_string(),
1120 };
1121 }
1122 bindings_checked += 1;
1123 }
1124 BindingOutcome::Consistent { bindings_checked }
1125}
1126
1127#[derive(Debug, Clone, PartialEq, Eq)]
1134pub struct AuditVerification {
1135 pub chain: VerifyOutcome,
1136 pub events: VerifyOutcome,
1137 pub binding: BindingOutcome,
1138 pub attribution: Option<AttributionCensus>,
1154}
1155
1156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1161enum RowStanding {
1162 NamesAccount,
1164 AttributionFailed,
1167 NotRecorded,
1171 Unattested,
1181}
1182
1183fn standing(row: &ChainRow) -> RowStanding {
1195 let stored = row.caller_principal.as_deref().filter(|p| !p.is_empty());
1203 match row.chain_version {
1204 CHAIN_VERSION_LEGACY | CHAIN_VERSION_V2 => match stored {
1205 None => RowStanding::NotRecorded,
1206 Some(_) => RowStanding::Unattested,
1207 },
1208 CHAIN_VERSION_V3 => match stored {
1209 None => RowStanding::NotRecorded,
1210 Some(p) => match crate::auth::CallerPrincipal::classify(p) {
1211 crate::auth::PrincipalClaim::Account => RowStanding::NamesAccount,
1212 crate::auth::PrincipalClaim::AttributionFailed => RowStanding::AttributionFailed,
1213 crate::auth::PrincipalClaim::Unrecognized => RowStanding::Unattested,
1214 },
1215 },
1216 _ => RowStanding::Unattested,
1222 }
1223}
1224
1225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1257pub struct AttributionCensus {
1258 named: u64,
1259 attribution_failed: u64,
1260 not_recorded: u64,
1261 unattested: u64,
1262 rows: u64,
1263}
1264
1265impl AttributionCensus {
1266 pub fn of(tx_rows: &[ChainRow]) -> Self {
1268 let mut census = Self {
1269 named: 0,
1270 attribution_failed: 0,
1271 not_recorded: 0,
1272 unattested: 0,
1273 rows: tx_rows.len() as u64,
1274 };
1275 for row in tx_rows {
1276 match standing(row) {
1277 RowStanding::NamesAccount => census.named += 1,
1278 RowStanding::AttributionFailed => census.attribution_failed += 1,
1279 RowStanding::NotRecorded => census.not_recorded += 1,
1280 RowStanding::Unattested => census.unattested += 1,
1281 }
1282 }
1283 census
1284 }
1285
1286 pub fn named(&self) -> u64 {
1292 self.named
1293 }
1294
1295 pub fn attribution_failed(&self) -> u64 {
1298 self.attribution_failed
1299 }
1300
1301 pub fn not_recorded(&self) -> u64 {
1304 self.not_recorded
1305 }
1306
1307 pub fn unattested(&self) -> u64 {
1310 self.unattested
1311 }
1312
1313 pub fn rows(&self) -> u64 {
1320 self.rows
1321 }
1322
1323 pub fn unnamed(&self) -> u64 {
1332 self.rows - self.named
1333 }
1334
1335 #[cfg(any(test, feature = "test-support"))]
1348 pub fn from_counts_for_tests(
1349 named: u64,
1350 attribution_failed: u64,
1351 not_recorded: u64,
1352 unattested: u64,
1353 ) -> Self {
1354 let rows = named
1355 .checked_add(attribution_failed)
1356 .and_then(|n| n.checked_add(not_recorded))
1357 .and_then(|n| n.checked_add(unattested))
1358 .expect("census counts must not overflow u64");
1359 Self {
1360 named,
1361 attribution_failed,
1362 not_recorded,
1363 unattested,
1364 rows,
1365 }
1366 }
1367}
1368
1369impl AuditVerification {
1370 pub fn exit_code(&self) -> i32 {
1376 let codes = [
1377 outcome_to_exit_code(&self.chain),
1378 outcome_to_exit_code(&self.events),
1379 binding_outcome_to_exit_code(&self.binding),
1380 ];
1381 if codes.contains(&1) {
1382 1
1383 } else if codes.contains(&2) {
1384 2
1385 } else {
1386 0
1387 }
1388 }
1389}
1390
1391pub fn verify_all(
1393 key: &AuditKey,
1394 tx_rows: &[ChainRow],
1395 event_rows: &[EventRow],
1396) -> AuditVerification {
1397 AuditVerification {
1398 chain: verify_chain(key, tx_rows),
1399 events: verify_event_chain(key, event_rows),
1400 binding: verify_event_binding(tx_rows, event_rows),
1401 attribution: Some(AttributionCensus::of(tx_rows)),
1402 }
1403}
1404
1405pub fn verify_all_with_pubkey(
1407 verifying_key_hex: &str,
1408 tx_rows: &[ChainRow],
1409 event_rows: &[EventRow],
1410) -> AuditVerification {
1411 AuditVerification {
1412 chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
1413 events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
1414 binding: verify_event_binding(tx_rows, event_rows),
1415 attribution: Some(AttributionCensus::of(tx_rows)),
1416 }
1417}
1418
1419pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
1422 match outcome {
1423 BindingOutcome::Consistent { .. } => 0,
1424 BindingOutcome::MissingEvent { .. } => 1,
1425 }
1426}
1427
1428#[derive(Debug, Clone, PartialEq, Eq)]
1444pub struct Checkpoint {
1445 pub seq: u64,
1448 pub chain_tip: String,
1450 pub created_at: String,
1452 pub signature: String,
1454}
1455
1456fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
1459 let mut buf = CHECKPOINT_DOMAIN.to_vec();
1462 push_field(&mut buf, "seq", &seq.to_string());
1463 push_field(&mut buf, "chain_tip", chain_tip);
1464 push_field(&mut buf, "created_at", created_at);
1465 buf
1466}
1467
1468impl AuditKey {
1469 pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
1471 let sig = self
1472 .signing
1473 .sign(&checkpoint_message(seq, chain_tip, created_at));
1474 Checkpoint {
1475 seq,
1476 chain_tip: chain_tip.to_string(),
1477 created_at: created_at.to_string(),
1478 signature: hex::encode(sig.to_bytes()),
1479 }
1480 }
1481}
1482
1483#[derive(Debug, Clone, PartialEq, Eq)]
1485pub enum CheckpointOutcome {
1486 Consistent { checkpoints_checked: u64 },
1489 BadSignature { seq: u64 },
1491 Truncated {
1494 checkpoint_seq: u64,
1495 current_max_seq: u64,
1496 },
1497 TipMismatch {
1500 seq: u64,
1501 anchored: String,
1502 actual: String,
1503 },
1504 CannotVerify { reason: String },
1506}
1507
1508pub fn verify_checkpoints(
1514 verifying_key_hex: &str,
1515 rows: &[ChainRow],
1516 checkpoints: &[Checkpoint],
1517) -> CheckpointOutcome {
1518 let vk = match parse_verifying_key(verifying_key_hex) {
1519 Some(vk) => vk,
1520 None => {
1521 return CheckpointOutcome::CannotVerify {
1522 reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
1523 };
1524 }
1525 };
1526 let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
1529 let mut checked = 0u64;
1530 for cp in checkpoints {
1531 let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
1532 if !signature_ok(&vk, &msg, &cp.signature) {
1533 return CheckpointOutcome::BadSignature { seq: cp.seq };
1534 }
1535 match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
1536 Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
1537 Ok(idx) => {
1538 return CheckpointOutcome::TipMismatch {
1539 seq: cp.seq,
1540 anchored: cp.chain_tip.clone(),
1541 actual: rows[idx].chain_hash.clone(),
1542 };
1543 }
1544 Err(_) => {
1545 return CheckpointOutcome::Truncated {
1546 checkpoint_seq: cp.seq,
1547 current_max_seq,
1548 };
1549 }
1550 }
1551 checked += 1;
1552 }
1553 CheckpointOutcome::Consistent {
1554 checkpoints_checked: checked,
1555 }
1556}
1557
1558pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
1563 match outcome {
1564 CheckpointOutcome::Consistent { .. } => 0,
1565 CheckpointOutcome::BadSignature { .. }
1566 | CheckpointOutcome::Truncated { .. }
1567 | CheckpointOutcome::TipMismatch { .. } => 1,
1568 CheckpointOutcome::CannotVerify { .. } => 2,
1569 }
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574 use super::*;
1575
1576 fn fixed_key() -> AuditKey {
1577 AuditKey::from_bytes(vec![0x42; 32])
1578 }
1579
1580 fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
1581 ChainContent {
1582 seq,
1583 key_id: CURRENT_KEY_ID,
1584 transaction_id: txid,
1585 request_id: "req-1",
1586 request_hash: "hash-abc",
1587 action_name: "UpdateSystem",
1588 risk_level: RiskLevel::High,
1589 summary: "Upgrade",
1590 approval_id: None,
1591 warnings_json: "[]",
1592 created_at: "2026-04-24T12:00:00Z",
1593 identity: ChainIdentity::V2 {
1594 caller_role: "Dev",
1595 event_tip: "",
1596 },
1597 }
1598 }
1599
1600 #[test]
1603 fn same_inputs_yield_same_hash() {
1604 let key = fixed_key();
1605 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1606 let h2 = key.chain_hash(&sample_content(1, "txa"), "");
1607 assert_eq!(h1, h2);
1608 assert_eq!(h1.len(), HASH_HEX_LEN);
1609 }
1610
1611 #[test]
1612 fn different_seq_yields_different_hash() {
1613 let key = fixed_key();
1614 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1615 let h2 = key.chain_hash(&sample_content(2, "txa"), "");
1616 assert_ne!(h1, h2);
1617 }
1618
1619 #[test]
1620 fn different_prev_hash_yields_different_hash() {
1621 let key = fixed_key();
1622 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1623 let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
1624 assert_ne!(h1, h2);
1625 }
1626
1627 #[test]
1628 fn different_keys_yield_different_hashes() {
1629 let key1 = AuditKey::from_bytes(vec![0x01; 32]);
1630 let key2 = AuditKey::from_bytes(vec![0x02; 32]);
1631 let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
1632 let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
1633 assert_ne!(h1, h2);
1634 }
1635
1636 #[test]
1639 fn canonical_bytes_have_stable_field_order() {
1640 let c = sample_content(1, "txa");
1641 let bytes = c.canonical_bytes();
1642 let s = String::from_utf8_lossy(&bytes);
1643 let order = [
1645 "seq",
1646 "key_id",
1647 "transaction_id",
1648 "request_id",
1649 "request_hash",
1650 "action_name",
1651 "risk_level",
1652 "summary",
1653 "approval_id",
1654 "warnings_json",
1655 "created_at",
1656 ];
1657 let mut last_idx = 0;
1658 for tag in order {
1659 let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
1660 assert!(idx >= last_idx, "{tag} out of order");
1661 last_idx = idx;
1662 }
1663 }
1664
1665 #[test]
1666 fn nul_bytes_in_field_value_are_escaped() {
1667 let mut c = sample_content(1, "txa");
1668 let summary = "before\0after";
1669 c.summary = summary;
1670 let bytes = c.canonical_bytes();
1671 assert!(!bytes.contains(&0x00));
1673 assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
1674 }
1675
1676 #[test]
1682 fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
1683 let mut a = sample_content(1, "txa");
1684 a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
1686 b.summary = "before\0after"; assert_ne!(
1688 a.canonical_bytes(),
1689 b.canonical_bytes(),
1690 "backslash escape must run before NUL escape to prevent collision"
1691 );
1692 }
1693
1694 fn key_for_collision_test() -> AuditKey {
1695 AuditKey::from_bytes(vec![0xab; 32])
1696 }
1697
1698 #[test]
1699 fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
1700 let key = key_for_collision_test();
1701 let mut a = sample_content(1, "txa");
1702 a.summary = "before\\0after";
1703 let mut b = sample_content(1, "txa");
1704 b.summary = "before\0after";
1705 assert_ne!(
1706 key.chain_hash(&a, ""),
1707 key.chain_hash(&b, ""),
1708 "chain signature must distinguish escape from raw byte"
1709 );
1710 }
1711
1712 fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
1718 let (chain_version, caller_role, event_tip, caller_principal) = match content.identity {
1719 ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None, None),
1720 ChainIdentity::V2 {
1721 caller_role,
1722 event_tip,
1723 } => (
1724 CHAIN_VERSION_V2,
1725 Some(caller_role.to_string()),
1726 Some(event_tip.to_string()),
1727 None,
1728 ),
1729 ChainIdentity::V3 {
1730 caller_role,
1731 event_tip,
1732 caller_principal,
1733 } => (
1734 CHAIN_VERSION_V3,
1735 Some(caller_role.to_string()),
1736 Some(event_tip.to_string()),
1737 Some(caller_principal.to_string()),
1738 ),
1739 };
1740 ChainRow {
1741 seq: content.seq,
1742 key_id: content.key_id.to_string(),
1743 transaction_id: content.transaction_id.to_string(),
1744 request_id: content.request_id.to_string(),
1745 request_hash: content.request_hash.to_string(),
1746 action_name: content.action_name.to_string(),
1747 risk_level: content.risk_level,
1748 summary: content.summary.to_string(),
1749 approval_id: content.approval_id.map(str::to_string),
1750 warnings_json: content.warnings_json.to_string(),
1751 created_at: content.created_at.to_string(),
1752 prev_chain_hash: prev.to_string(),
1753 chain_hash,
1754 chain_version,
1755 caller_role,
1756 event_tip,
1757 caller_principal,
1758 }
1759 }
1760
1761 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
1762 let mut rows = Vec::with_capacity(count);
1763 let mut prev = String::new();
1764 for i in 0..count {
1765 let seq = (i + 1) as u64;
1766 let txid = format!("tx{i}");
1767 let content = sample_content(seq, &txid);
1768 let hash = key.chain_hash(&content, &prev);
1769 rows.push(row_for(&content, &prev, hash.clone()));
1770 prev = hash;
1771 }
1772 rows
1773 }
1774
1775 #[test]
1778 fn caller_role_is_covered_by_the_row_signature() {
1779 let key = fixed_key();
1783 let mut dev = sample_content(1, "txa");
1784 dev.identity = ChainIdentity::V2 {
1785 caller_role: "Dev",
1786 event_tip: "",
1787 };
1788 let mut boot = sample_content(1, "txa");
1789 boot.identity = ChainIdentity::V2 {
1790 caller_role: "Boot",
1791 event_tip: "",
1792 };
1793 assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
1794 }
1795
1796 #[test]
1797 fn event_tip_is_covered_by_the_row_signature() {
1798 let key = fixed_key();
1799 let mut empty = sample_content(1, "txa");
1800 empty.identity = ChainIdentity::V2 {
1801 caller_role: "Dev",
1802 event_tip: "",
1803 };
1804 let mut bound = sample_content(1, "txa");
1805 bound.identity = ChainIdentity::V2 {
1806 caller_role: "Dev",
1807 event_tip: "abc123",
1808 };
1809 assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
1810 }
1811
1812 #[test]
1813 fn a_legacy_row_written_before_the_migration_still_verifies() {
1814 let key = fixed_key();
1819 let mut content = sample_content(1, "tx-legacy");
1820 content.identity = ChainIdentity::LegacyV1;
1821 let hash = key.chain_hash(&content, "");
1822 let row = row_for(&content, "", hash);
1823 assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
1824 assert_eq!(row.caller_role, None);
1825 assert_eq!(
1826 verify_chain(&key, &[row]),
1827 VerifyOutcome::Intact { rows_checked: 1 }
1828 );
1829 }
1830
1831 #[test]
1837 fn two_admins_are_distinguishable_in_the_signed_record() {
1838 let key = fixed_key();
1839 let mut alice = sample_content(1, "tx-alice");
1840 alice.identity = ChainIdentity::V3 {
1841 caller_role: "admin",
1842 event_tip: "",
1843 caller_principal: "uid:1000",
1844 };
1845 let mut bob = sample_content(1, "tx-alice");
1846 bob.identity = ChainIdentity::V3 {
1847 caller_role: "admin",
1848 event_tip: "",
1849 caller_principal: "uid:1001",
1850 };
1851
1852 assert_ne!(
1853 key.chain_hash(&alice, ""),
1854 key.chain_hash(&bob, ""),
1855 "identical rows differing only in principal must sign differently, \
1856 otherwise the chain still cannot answer which account acted"
1857 );
1858 }
1859
1860 #[test]
1863 fn editing_the_stored_principal_breaks_the_row() {
1864 let key = fixed_key();
1865 let mut content = sample_content(1, "tx1");
1866 content.identity = ChainIdentity::V3 {
1867 caller_role: "admin",
1868 event_tip: "",
1869 caller_principal: "uid:1000",
1870 };
1871 let hash = key.chain_hash(&content, "");
1872 let mut row = row_for(&content, "", hash);
1873 row.caller_principal = Some("uid:0".to_string());
1874
1875 assert!(
1876 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1877 "a principal swapped to root must not verify"
1878 );
1879 }
1880
1881 #[test]
1885 fn downgrading_a_v3_row_to_v2_to_hide_the_account_breaks_it() {
1886 let key = fixed_key();
1887 let mut content = sample_content(1, "tx1");
1888 content.identity = ChainIdentity::V3 {
1889 caller_role: "admin",
1890 event_tip: "",
1891 caller_principal: "uid:1000",
1892 };
1893 let hash = key.chain_hash(&content, "");
1894 let mut row = row_for(&content, "", hash);
1895 row.chain_version = CHAIN_VERSION_V2;
1896 row.caller_principal = None;
1897
1898 assert!(
1899 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1900 "a v3 row relabelled as v2 must not verify"
1901 );
1902 }
1903
1904 #[test]
1909 fn a_v3_row_without_a_usable_principal_is_broken() {
1910 let key = fixed_key();
1911 let mut content = sample_content(1, "tx1");
1912 content.identity = ChainIdentity::V3 {
1913 caller_role: "admin",
1914 event_tip: "",
1915 caller_principal: "uid:1000",
1916 };
1917 let hash = key.chain_hash(&content, "");
1918
1919 for absent in [None, Some(String::new())] {
1920 let mut row = row_for(&content, "", hash.clone());
1921 row.caller_principal = absent.clone();
1922 assert!(
1923 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1924 "a v3 row with principal {absent:?} must be reported as broken"
1925 );
1926 }
1927 }
1928
1929 #[test]
1939 fn a_row_signed_with_a_blank_principal_is_rejected_even_though_it_signs() {
1940 let key = fixed_key();
1941 let mut content = sample_content(1, "tx-blank");
1942 content.identity = ChainIdentity::V3 {
1943 caller_role: "admin",
1944 event_tip: "",
1945 caller_principal: "",
1946 };
1947 let hash = key.chain_hash(&content, "");
1948 let row = row_for(&content, "", hash);
1953 assert_eq!(
1954 row.caller_principal.as_deref(),
1955 Some(""),
1956 "fixture must store the blank principal it signed"
1957 );
1958 assert_eq!(row.chain_version, CHAIN_VERSION_V3);
1959 assert!(
1960 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1961 "a v3 row naming nobody must be broken, not accepted"
1962 );
1963 }
1964
1965 #[test]
1970 fn a_row_recording_an_attribution_failure_verifies() {
1971 let key = fixed_key();
1972 let principal = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
1973 let mut content = sample_content(1, "tx-unattributed");
1974 content.identity = ChainIdentity::V3 {
1975 caller_role: "observer",
1976 event_tip: "",
1977 caller_principal: &principal,
1978 };
1979 let hash = key.chain_hash(&content, "");
1980 let row = row_for(&content, "", hash);
1981
1982 assert_eq!(
1983 verify_chain(&key, &[row]),
1984 VerifyOutcome::Intact { rows_checked: 1 }
1985 );
1986 }
1987
1988 #[test]
1992 fn a_chain_spanning_all_three_encodings_verifies() {
1993 let key = fixed_key();
1994 let mut rows = Vec::new();
1995 let mut prev = String::new();
1996 for (i, identity) in [
1997 ChainIdentity::LegacyV1,
1998 ChainIdentity::V2 {
1999 caller_role: "dev",
2000 event_tip: "",
2001 },
2002 ChainIdentity::V3 {
2003 caller_role: "admin",
2004 event_tip: "",
2005 caller_principal: "uid:1000",
2006 },
2007 ChainIdentity::V3 {
2008 caller_role: "admin",
2009 event_tip: "",
2010 caller_principal: "token:vsock",
2011 },
2012 ]
2013 .into_iter()
2014 .enumerate()
2015 {
2016 let txid = format!("tx{i}");
2017 let mut content = sample_content((i + 1) as u64, &txid);
2018 content.identity = identity;
2019 let hash = key.chain_hash(&content, &prev);
2020 rows.push(row_for(&content, &prev, hash.clone()));
2021 prev = hash;
2022 }
2023
2024 assert_eq!(
2025 verify_chain(&key, &rows),
2026 VerifyOutcome::Intact { rows_checked: 4 }
2027 );
2028 }
2029
2030 fn chain_of(key: &AuditKey, identities: Vec<ChainIdentity<'_>>) -> Vec<ChainRow> {
2033 let mut rows = Vec::new();
2034 let mut prev = String::new();
2035 for (i, identity) in identities.into_iter().enumerate() {
2036 let txid = format!("tx{i}");
2037 let mut content = sample_content((i + 1) as u64, &txid);
2038 content.identity = identity;
2039 let hash = key.chain_hash(&content, &prev);
2040 rows.push(row_for(&content, &prev, hash.clone()));
2041 prev = hash;
2042 }
2043 rows
2044 }
2045
2046 #[test]
2056 fn the_census_separates_attribution_failures_from_encodings_without_the_field() {
2057 let key = fixed_key();
2058 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2059 let rows = chain_of(
2060 &key,
2061 vec![
2062 ChainIdentity::LegacyV1,
2063 ChainIdentity::V2 {
2064 caller_role: "dev",
2065 event_tip: "",
2066 },
2067 ChainIdentity::V3 {
2068 caller_role: "admin",
2069 event_tip: "",
2070 caller_principal: "uid:1000",
2071 },
2072 ChainIdentity::V3 {
2073 caller_role: "admin",
2074 event_tip: "",
2075 caller_principal: "uid:1001",
2076 },
2077 ChainIdentity::V3 {
2078 caller_role: "admin",
2079 event_tip: "",
2080 caller_principal: "token:vsock",
2081 },
2082 ChainIdentity::V3 {
2083 caller_role: "observer",
2084 event_tip: "",
2085 caller_principal: &unattributed,
2086 },
2087 ],
2088 );
2089
2090 let verification = verify_all(&key, &rows, &[]);
2091 assert_eq!(
2092 verification.chain,
2093 VerifyOutcome::Intact { rows_checked: 6 },
2094 "every encoding must keep verifying; the census is not a verdict"
2095 );
2096 let census = verification
2097 .attribution
2098 .expect("rows were read, so a census was taken");
2099 assert_eq!(census.named(), 3);
2100 assert_eq!(census.attribution_failed(), 1);
2101 assert_eq!(census.not_recorded(), 2);
2102 assert_eq!(census.unattested(), 0);
2103 assert_eq!(census.rows(), 6);
2104 assert_eq!(
2105 census.unnamed(),
2106 3,
2107 "three of the six rows cannot name an account, by either reason"
2108 );
2109 }
2110
2111 #[test]
2121 fn a_principal_written_into_an_encoding_that_does_not_sign_it_is_never_an_account() {
2122 let key = fixed_key();
2123 let mut rows = chain_of(
2124 &key,
2125 vec![
2126 ChainIdentity::LegacyV1,
2127 ChainIdentity::V2 {
2128 caller_role: "dev",
2129 event_tip: "",
2130 },
2131 ],
2132 );
2133 rows[0].caller_principal = Some("uid:0".to_string());
2136 rows[1].caller_principal = Some("uid:0".to_string());
2137
2138 let verification = verify_all(&key, &rows, &[]);
2139 assert_eq!(
2140 verification.chain,
2141 VerifyOutcome::Intact { rows_checked: 2 },
2142 "no signature covers that column on v1/v2, so tampering with it is \
2143 invisible to verification -- which is exactly why the census must \
2144 not believe it"
2145 );
2146 let census = verification.attribution.expect("rows were read");
2147 assert_eq!(
2148 census.named(),
2149 0,
2150 "an unsigned column must never be credited as an account"
2151 );
2152 assert_eq!(
2153 census.unattested(),
2154 2,
2155 "and it must be reported as unattested, because nothing in SysKnife \
2156 writes that column on those encodings"
2157 );
2158 assert_eq!(census.unnamed(), 2);
2159 }
2160
2161 #[test]
2165 fn a_blank_principal_column_is_counted_as_naming_nobody() {
2166 let key = fixed_key();
2167 let mut content = sample_content(1, "tx-blank-census");
2168 content.identity = ChainIdentity::V3 {
2169 caller_role: "admin",
2170 event_tip: "",
2171 caller_principal: "",
2172 };
2173 let hash = key.chain_hash(&content, "");
2174 let row = row_for(&content, "", hash);
2175 assert_eq!(row.caller_principal.as_deref(), Some(""));
2176
2177 let verification = verify_all(&key, &[row], &[]);
2178 let census = verification.attribution.expect("a row was read");
2179 assert_eq!(census.named(), 0);
2180 assert_eq!(census.not_recorded(), 1);
2181 assert_eq!(census.rows(), 1);
2182 assert!(
2183 matches!(verification.chain, VerifyOutcome::Broken { .. }),
2184 "and the row is still reported as broken once the walk reaches it, \
2185 so census and verdict agree"
2186 );
2187 }
2188
2189 #[test]
2197 fn a_principal_this_binary_cannot_read_is_counted_as_unattested_not_as_an_account() {
2198 let key = fixed_key();
2199 for forged in [
2200 "garbage",
2201 "uid:",
2202 "none:overflow",
2203 " ",
2208 "UID:1000",
2209 "uid:notanumber",
2211 "uid:1000:extra",
2212 "token:not-vsock",
2213 ] {
2214 let mut content = sample_content(1, "tx-forged");
2215 content.identity = ChainIdentity::V3 {
2216 caller_role: "admin",
2217 event_tip: "",
2218 caller_principal: forged,
2219 };
2220 let hash = key.chain_hash(&content, "");
2221 let row = row_for(&content, "", hash);
2222 let census = verify_all(&key, &[row], &[])
2223 .attribution
2224 .expect("a row was read");
2225 assert_eq!(
2226 census.named(),
2227 0,
2228 "{forged:?} names no account and must not be counted as one"
2229 );
2230 assert_eq!(
2231 census.unnamed(),
2232 1,
2233 "{forged:?} must be counted among the rows naming nobody"
2234 );
2235 assert_eq!(
2236 census.unattested(),
2237 1,
2238 "{forged:?} is signed content the daemon never writes, so it is a \
2239 finding to investigate, not history to explain away"
2240 );
2241 assert_eq!(
2242 census.not_recorded(),
2243 0,
2244 "{forged:?} is present, so it must not be reported as a row that \
2245 predates the column"
2246 );
2247 }
2248 }
2249
2250 #[test]
2255 fn a_chain_predating_the_principal_field_reports_no_row_as_attributed() {
2256 let key = fixed_key();
2257 let rows = chain_of(
2258 &key,
2259 vec![
2260 ChainIdentity::LegacyV1,
2261 ChainIdentity::V2 {
2262 caller_role: "dev",
2263 event_tip: "",
2264 },
2265 ],
2266 );
2267
2268 let verification = verify_all(&key, &rows, &[]);
2269 assert_eq!(
2270 verification.chain,
2271 VerifyOutcome::Intact { rows_checked: 2 },
2272 "legacy rows must keep verifying; the census is not a verdict"
2273 );
2274 let census = verification.attribution.expect("rows were read");
2275 assert_eq!(census.attribution_failed(), 0);
2276 assert_eq!(census.named(), 0);
2277 assert_eq!(census.not_recorded(), 2);
2278 assert_eq!(census.unnamed(), 2);
2279 }
2280
2281 #[test]
2287 fn a_broken_chain_censuses_more_rows_than_it_verified() {
2288 let key = fixed_key();
2289 let mut rows = chain_of(
2290 &key,
2291 vec![
2292 ChainIdentity::V3 {
2293 caller_role: "admin",
2294 event_tip: "",
2295 caller_principal: "uid:1000",
2296 },
2297 ChainIdentity::V3 {
2298 caller_role: "admin",
2299 event_tip: "",
2300 caller_principal: "uid:1000",
2301 },
2302 ChainIdentity::V3 {
2303 caller_role: "admin",
2304 event_tip: "",
2305 caller_principal: "uid:1000",
2306 },
2307 ],
2308 );
2309 rows[1].summary = "tampered".to_string();
2310
2311 let verification = verify_all(&key, &rows, &[]);
2312 let rows_checked = match verification.chain {
2313 VerifyOutcome::Broken { rows_checked, .. } => rows_checked,
2314 ref other => panic!("expected a detected tamper, got {other:?}"),
2315 };
2316 let census = verification.attribution.expect("rows were read");
2317 assert_eq!(rows_checked, 1, "the walk stops at the first broken row");
2318 assert_eq!(
2319 census.rows(),
2320 3,
2321 "while the census still describes every row read"
2322 );
2323 assert!(
2324 census.rows() > rows_checked,
2325 "the report must be able to show that more was counted than checked"
2326 );
2327 }
2328
2329 #[test]
2333 fn the_pubkey_only_path_takes_the_same_census() {
2334 let key = fixed_key();
2335 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2336 let rows = chain_of(
2337 &key,
2338 vec![
2339 ChainIdentity::V3 {
2340 caller_role: "admin",
2341 event_tip: "",
2342 caller_principal: "uid:1000",
2343 },
2344 ChainIdentity::V3 {
2345 caller_role: "observer",
2346 event_tip: "",
2347 caller_principal: &unattributed,
2348 },
2349 ],
2350 );
2351
2352 let with_key = verify_all(&key, &rows, &[]).attribution;
2353 let with_pubkey = verify_all_with_pubkey(&key.verifying_key_hex(), &rows, &[]).attribution;
2354 assert_eq!(
2355 with_key, with_pubkey,
2356 "the auditor must see the same attribution numbers as the keyholder"
2357 );
2358 let census = with_pubkey.expect("rows were read");
2359 assert_eq!(census.named(), 1);
2360 assert_eq!(census.attribution_failed(), 1);
2361 }
2362
2363 #[test]
2368 fn a_chain_that_names_nobody_still_exits_zero() {
2369 let key = fixed_key();
2370 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2371 let rows = chain_of(
2372 &key,
2373 vec![
2374 ChainIdentity::LegacyV1,
2375 ChainIdentity::V3 {
2376 caller_role: "observer",
2377 event_tip: "",
2378 caller_principal: &unattributed,
2379 },
2380 ],
2381 );
2382
2383 let verification = verify_all(&key, &rows, &[]);
2384 let census = verification.attribution.expect("rows were read");
2385 assert_eq!(census.unnamed(), 2, "not one row names an account");
2386 assert_eq!(
2387 verification.exit_code(),
2388 0,
2389 "yet nothing was tampered with, so the command must succeed"
2390 );
2391 }
2392
2393 #[test]
2398 fn a_future_encoding_reports_cannot_verify_with_the_supported_range() {
2399 let key = fixed_key();
2400 let content = sample_content(1, "tx1");
2401 let hash = key.chain_hash(&content, "");
2402 let mut row = row_for(&content, "", hash);
2403 row.chain_version = CHAIN_VERSION_CURRENT + 1;
2404
2405 match verify_chain(&key, &[row]) {
2406 VerifyOutcome::CannotVerify { reason } => {
2407 assert!(
2408 reason.contains(&format!("chain_version={}", CHAIN_VERSION_CURRENT + 1)),
2409 "name the version found, got: {reason}"
2410 );
2411 assert!(
2412 reason.contains(&CHAIN_VERSION_CURRENT.to_string()),
2413 "and the newest version understood, got: {reason}"
2414 );
2415 }
2416 other => panic!("a future encoding must not be reported as tampering: {other:?}"),
2417 }
2418 }
2419
2420 #[test]
2433 fn a_row_written_by_the_previous_release_still_verifies() {
2434 const GOLDEN_V2_CHAIN_HASH: &str = "ba12cbe49a149387898bc30f1e8d409effa025ab8e440e6e72bedf060afc831e45d9be264d6b18b7e0ef2f2b3584dbc6953b535546cecbbdc3d7ac666cae4d0a";
2435
2436 let key = fixed_key();
2437 let row = ChainRow {
2438 seq: 7,
2439 key_id: CURRENT_KEY_ID.to_string(),
2440 transaction_id: "tx-golden".to_string(),
2441 request_id: "req-golden".to_string(),
2442 request_hash: "hash-golden".to_string(),
2443 action_name: "AptInstall".to_string(),
2444 risk_level: RiskLevel::Medium,
2445 summary: "install ripgrep".to_string(),
2446 approval_id: Some("appr-golden".to_string()),
2447 warnings_json: "[]".to_string(),
2448 created_at: "2026-07-29T00:00:00Z".to_string(),
2449 prev_chain_hash: String::new(),
2450 chain_hash: GOLDEN_V2_CHAIN_HASH.to_string(),
2451 chain_version: 2,
2452 caller_role: Some("admin".to_string()),
2453 event_tip: Some("eventtip-golden".to_string()),
2454 caller_principal: None,
2455 };
2456
2457 assert_eq!(
2458 verify_chain(&key, std::slice::from_ref(&row)),
2459 VerifyOutcome::Intact { rows_checked: 1 },
2460 "a v2 row on disk must verify under this binary; if this fails, the v2 \
2461 encoding or its version dispatch changed and existing audit logs are unreadable"
2462 );
2463
2464 let mut tampered = row;
2468 let flipped = match GOLDEN_V2_CHAIN_HASH.strip_prefix('b') {
2469 Some(rest) => format!("a{rest}"),
2470 None => format!("b{}", &GOLDEN_V2_CHAIN_HASH[1..]),
2471 };
2472 tampered.chain_hash = flipped;
2473 assert!(
2474 matches!(
2475 verify_chain(&key, &[tampered]),
2476 VerifyOutcome::Broken { .. }
2477 ),
2478 "a golden row with one nibble changed must not verify"
2479 );
2480 }
2481
2482 #[test]
2486 fn a_legacy_v1_row_on_disk_still_verifies() {
2487 const GOLDEN_V1_CHAIN_HASH: &str = "ce8b47c15414e989e099303b826fcd9985360b794220cefbbf9689d25f31fe173283687feb6d1dd265ae5aca94c8bcfb87ce33ba8c954fafdad18a59cd97c702";
2488
2489 let row = ChainRow {
2490 chain_version: 1,
2491 caller_role: None,
2492 event_tip: None,
2493 caller_principal: None,
2494 chain_hash: GOLDEN_V1_CHAIN_HASH.to_string(),
2495 ..golden_row_shape()
2496 };
2497 assert_eq!(
2498 verify_chain(&fixed_key(), &[row]),
2499 VerifyOutcome::Intact { rows_checked: 1 }
2500 );
2501 }
2502
2503 #[test]
2507 fn a_v3_row_on_disk_still_verifies() {
2508 const GOLDEN_V3_CHAIN_HASH: &str = "3c0d05f6ccaf9e65ccb796d4ffe96eb31a2ea67e446217c5234af916dc959d0f2c0ee3d3e5b525aece791ee7d6507efb98dcdd79f0d74dde4273859aa9627d08";
2509
2510 let row = ChainRow {
2511 chain_version: 3,
2512 caller_role: Some("admin".to_string()),
2513 event_tip: Some("eventtip-golden".to_string()),
2514 caller_principal: Some("uid:1000".to_string()),
2515 chain_hash: GOLDEN_V3_CHAIN_HASH.to_string(),
2516 ..golden_row_shape()
2517 };
2518 assert_eq!(
2519 verify_chain(&fixed_key(), &[row]),
2520 VerifyOutcome::Intact { rows_checked: 1 }
2521 );
2522 }
2523
2524 #[test]
2531 fn the_escape_table_and_absent_approval_encoding_are_frozen() {
2532 const GOLDEN_V2_AWKWARD_CHAIN_HASH: &str = "e6face6fe2346fc0f6c5c1e5845255579c8f08738406b88b0a1f6dbdf8ff85090a4b80d95bd0927ef54b0a2c4810d52ebb4bbf76cb68c865a7748df26442aa05";
2533
2534 let key = fixed_key();
2535 let content = ChainContent {
2536 seq: 8,
2537 approval_id: None,
2538 summary: "back\\slash and \x1funit and \x1erecord and nul\0byte",
2539 identity: ChainIdentity::V2 {
2540 caller_role: "dev",
2541 event_tip: "",
2542 },
2543 ..golden_content_shape()
2544 };
2545 assert_eq!(
2546 key.chain_hash(&content, "ba12cbe4"),
2547 GOLDEN_V2_AWKWARD_CHAIN_HASH,
2548 "the canonical encoding of escapes, an absent approval id, or a \
2549 non-empty prev_chain_hash changed"
2550 );
2551 }
2552
2553 fn golden_content_shape() -> ChainContent<'static> {
2557 ChainContent {
2558 seq: 7,
2559 key_id: CURRENT_KEY_ID,
2560 transaction_id: "tx-golden",
2561 request_id: "req-golden",
2562 request_hash: "hash-golden",
2563 action_name: "AptInstall",
2564 risk_level: RiskLevel::Medium,
2565 summary: "install ripgrep",
2566 approval_id: Some("appr-golden"),
2567 warnings_json: "[]",
2568 created_at: "2026-07-29T00:00:00Z",
2569 identity: ChainIdentity::LegacyV1,
2570 }
2571 }
2572
2573 fn golden_row_shape() -> ChainRow {
2575 ChainRow {
2576 seq: 7,
2577 key_id: CURRENT_KEY_ID.to_string(),
2578 transaction_id: "tx-golden".to_string(),
2579 request_id: "req-golden".to_string(),
2580 request_hash: "hash-golden".to_string(),
2581 action_name: "AptInstall".to_string(),
2582 risk_level: RiskLevel::Medium,
2583 summary: "install ripgrep".to_string(),
2584 approval_id: Some("appr-golden".to_string()),
2585 warnings_json: "[]".to_string(),
2586 created_at: "2026-07-29T00:00:00Z".to_string(),
2587 prev_chain_hash: String::new(),
2588 chain_hash: String::new(),
2589 chain_version: 1,
2590 caller_role: None,
2591 event_tip: None,
2592 caller_principal: None,
2593 }
2594 }
2595
2596 #[test]
2597 fn a_chain_that_spans_the_migration_verifies_end_to_end() {
2598 let key = fixed_key();
2601 let mut rows = Vec::new();
2602 let mut prev = String::new();
2603 for (i, identity) in [
2604 ChainIdentity::LegacyV1,
2605 ChainIdentity::LegacyV1,
2606 ChainIdentity::V2 {
2607 caller_role: "Dev",
2608 event_tip: "",
2609 },
2610 ChainIdentity::V2 {
2611 caller_role: "Boot",
2612 event_tip: "",
2613 },
2614 ]
2615 .into_iter()
2616 .enumerate()
2617 {
2618 let txid = format!("tx{i}");
2619 let mut content = sample_content((i + 1) as u64, &txid);
2620 content.identity = identity;
2621 let hash = key.chain_hash(&content, &prev);
2622 rows.push(row_for(&content, &prev, hash.clone()));
2623 prev = hash;
2624 }
2625 assert_eq!(
2626 verify_chain(&key, &rows),
2627 VerifyOutcome::Intact { rows_checked: 4 }
2628 );
2629 }
2630
2631 #[test]
2632 fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
2633 let key = fixed_key();
2638 let mut rows = build_chain(&key, 2);
2639 rows[1].chain_version = CHAIN_VERSION_LEGACY;
2640 rows[1].caller_role = None;
2641 rows[1].event_tip = None;
2642 match verify_chain(&key, &rows) {
2643 VerifyOutcome::Broken {
2644 first_broken_seq, ..
2645 } => assert_eq!(first_broken_seq, 2),
2646 other => panic!("expected Broken, got {other:?}"),
2647 }
2648 }
2649
2650 #[test]
2651 fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
2652 let key = fixed_key();
2657 let mut rows = build_chain(&key, 1);
2658 rows[0].caller_role = None;
2659 let outcome = verify_chain(&key, &rows);
2660 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2661 assert_eq!(outcome_to_exit_code(&outcome), 1);
2662 }
2663
2664 #[test]
2665 fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
2666 let key = fixed_key();
2669 let mut rows = build_chain(&key, 1);
2670 rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
2671 let outcome = verify_chain(&key, &rows);
2672 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
2673 assert_eq!(outcome_to_exit_code(&outcome), 2);
2674 }
2675
2676 fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
2679 EventContent {
2680 seq,
2681 key_id: CURRENT_KEY_ID,
2682 kind,
2683 transaction_id: txid,
2684 receipt_digest: "digest-abc",
2685 created_at: "2026-04-24T12:00:00Z",
2686 }
2687 }
2688
2689 fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
2690 let mut rows = Vec::with_capacity(kinds.len());
2691 let mut prev = String::new();
2692 for (i, kind) in kinds.iter().enumerate() {
2693 let seq = (i + 1) as u64;
2694 let txid = format!("tx{i}");
2695 let content = event_content(seq, *kind, &txid);
2696 let hash = key.event_hash(&content, &prev);
2697 rows.push(EventRow {
2698 seq,
2699 key_id: content.key_id.to_string(),
2700 kind: content.kind.as_str().to_string(),
2701 transaction_id: content.transaction_id.to_string(),
2702 receipt_digest: content.receipt_digest.to_string(),
2703 created_at: content.created_at.to_string(),
2704 prev_chain_hash: prev.clone(),
2705 chain_hash: hash.clone(),
2706 });
2707 prev = hash;
2708 }
2709 rows
2710 }
2711
2712 #[test]
2713 fn event_kind_spellings_are_stable() {
2714 assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
2717 assert_eq!(
2718 AuditEventKind::ApprovalConsumed.as_str(),
2719 "approval_consumed"
2720 );
2721 assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
2722 }
2723
2724 #[test]
2725 fn intact_event_chain_verifies() {
2726 let key = fixed_key();
2727 let rows = build_event_chain(
2728 &key,
2729 &[
2730 AuditEventKind::ApprovalGranted,
2731 AuditEventKind::ApprovalConsumed,
2732 ],
2733 );
2734 assert_eq!(
2735 verify_event_chain(&key, &rows),
2736 VerifyOutcome::Intact { rows_checked: 2 }
2737 );
2738 }
2739
2740 #[test]
2741 fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
2742 let key = fixed_key();
2745 let mut rows = build_event_chain(
2746 &key,
2747 &[
2748 AuditEventKind::ApprovalGranted,
2749 AuditEventKind::ApprovalConsumed,
2750 AuditEventKind::ApprovalRevoked,
2751 ],
2752 );
2753 rows.remove(1);
2754 match verify_event_chain(&key, &rows) {
2755 VerifyOutcome::Broken {
2756 first_broken_seq, ..
2757 } => assert_eq!(first_broken_seq, 3),
2758 other => panic!("expected Broken, got {other:?}"),
2759 }
2760 }
2761
2762 #[test]
2763 fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
2764 let key = fixed_key();
2765 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
2766 rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
2767 assert!(matches!(
2768 verify_event_chain(&key, &rows),
2769 VerifyOutcome::Broken { .. }
2770 ));
2771 }
2772
2773 #[test]
2774 fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
2775 let key = fixed_key();
2776 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
2777 rows[0].kind = "approval_unicorned".to_string();
2778 assert!(matches!(
2779 verify_event_chain(&key, &rows),
2780 VerifyOutcome::Broken { .. }
2781 ));
2782 }
2783
2784 #[test]
2785 fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
2786 let key = fixed_key();
2790 let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
2791 let event_sig = key.event_hash(&content, "");
2792 let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
2793 assert_ne!(event_sig, row_sig);
2794
2795 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
2796 rows[0].chain_hash = row_sig;
2797 assert!(matches!(
2798 verify_event_chain(&key, &rows),
2799 VerifyOutcome::Broken { .. }
2800 ));
2801 }
2802
2803 #[test]
2806 fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
2807 let key = fixed_key();
2812 let events = build_event_chain(
2813 &key,
2814 &[
2815 AuditEventKind::ApprovalGranted,
2816 AuditEventKind::ApprovalConsumed,
2817 ],
2818 );
2819 let mut content = sample_content(1, "tx-after");
2820 content.identity = ChainIdentity::V2 {
2821 caller_role: "Dev",
2822 event_tip: &events[1].chain_hash,
2823 };
2824 let hash = key.chain_hash(&content, "");
2825 let tx_rows = vec![row_for(&content, "", hash)];
2826
2827 assert_eq!(
2828 verify_event_binding(&tx_rows, &events),
2829 BindingOutcome::Consistent {
2830 bindings_checked: 1
2831 }
2832 );
2833
2834 let truncated = &events[..1];
2835 assert_eq!(
2836 verify_event_chain(&key, truncated),
2837 VerifyOutcome::Intact { rows_checked: 1 },
2838 "a truncated event chain still walks clean; the binding is the detector"
2839 );
2840 match verify_event_binding(&tx_rows, truncated) {
2841 BindingOutcome::MissingEvent {
2842 transaction_seq, ..
2843 } => assert_eq!(transaction_seq, 1),
2844 other => panic!("expected MissingEvent, got {other:?}"),
2845 }
2846 }
2847
2848 #[test]
2849 fn legacy_rows_carry_no_binding_to_check() {
2850 let key = fixed_key();
2853 let mut content = sample_content(1, "tx-legacy");
2854 content.identity = ChainIdentity::LegacyV1;
2855 let hash = key.chain_hash(&content, "");
2856 let rows = vec![row_for(&content, "", hash)];
2857 assert_eq!(
2858 verify_event_binding(&rows, &[]),
2859 BindingOutcome::Consistent {
2860 bindings_checked: 0
2861 }
2862 );
2863 }
2864
2865 #[test]
2866 fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
2867 let verification = AuditVerification {
2872 chain: VerifyOutcome::Broken {
2873 rows_checked: 1,
2874 first_broken_seq: 2,
2875 first_broken_transaction_id: "tx".to_string(),
2876 expected: "e".to_string(),
2877 actual: "a".to_string(),
2878 },
2879 events: VerifyOutcome::CannotVerify {
2880 reason: "no key".to_string(),
2881 },
2882 binding: BindingOutcome::Consistent {
2883 bindings_checked: 0,
2884 },
2885 attribution: None,
2886 };
2887 assert_eq!(verification.exit_code(), 1);
2888 }
2889
2890 #[test]
2891 fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
2892 let verification = AuditVerification {
2893 chain: VerifyOutcome::Intact { rows_checked: 3 },
2894 events: VerifyOutcome::Intact { rows_checked: 1 },
2895 binding: BindingOutcome::MissingEvent {
2896 transaction_seq: 3,
2897 event_tip: "abc".to_string(),
2898 },
2899 attribution: None,
2900 };
2901 assert_eq!(verification.exit_code(), 1);
2902 }
2903
2904 #[test]
2905 fn all_three_clean_is_exit_zero() {
2906 let verification = AuditVerification {
2907 chain: VerifyOutcome::Intact { rows_checked: 3 },
2908 events: VerifyOutcome::Intact { rows_checked: 2 },
2909 binding: BindingOutcome::Consistent {
2910 bindings_checked: 1,
2911 },
2912 attribution: None,
2913 };
2914 assert_eq!(verification.exit_code(), 0);
2915 }
2916
2917 #[test]
2918 fn binding_exit_codes_split_clean_from_tampered() {
2919 assert_eq!(
2920 binding_outcome_to_exit_code(&BindingOutcome::Consistent {
2921 bindings_checked: 0
2922 }),
2923 0
2924 );
2925 assert_eq!(
2926 binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
2927 transaction_seq: 7,
2928 event_tip: "abc".to_string()
2929 }),
2930 1
2931 );
2932 }
2933
2934 #[test]
2935 fn intact_chain_verifies() {
2936 let key = fixed_key();
2937 let rows = build_chain(&key, 5);
2938 let outcome = verify_chain(&key, &rows);
2939 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
2940 assert_eq!(outcome_to_exit_code(&outcome), 0);
2941 }
2942
2943 #[test]
2944 fn empty_chain_verifies() {
2945 let key = fixed_key();
2946 let outcome = verify_chain(&key, &[]);
2947 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
2948 }
2949
2950 #[test]
2951 fn tampered_summary_breaks_chain_at_first_offending_row() {
2952 let key = fixed_key();
2953 let mut rows = build_chain(&key, 3);
2954 rows[1].summary = "TAMPERED".to_string();
2956 let outcome = verify_chain(&key, &rows);
2957 match outcome {
2958 VerifyOutcome::Broken {
2959 first_broken_seq, ..
2960 } => assert_eq!(first_broken_seq, 2),
2961 other => panic!("expected Broken, got {other:?}"),
2962 }
2963 }
2964
2965 #[test]
2966 fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
2967 let key = fixed_key();
2968 let mut rows = build_chain(&key, 4);
2969 rows.remove(1);
2971 let outcome = verify_chain(&key, &rows);
2972 match outcome {
2973 VerifyOutcome::Broken {
2974 first_broken_seq, ..
2975 } => assert_eq!(
2976 first_broken_seq, 3,
2977 "first broken row is the one whose prev_hash refers to deleted row"
2978 ),
2979 other => panic!("expected Broken, got {other:?}"),
2980 }
2981 }
2982
2983 #[test]
2984 fn inserted_forged_row_breaks_chain() {
2985 let key = fixed_key();
2992 let mut rows = build_chain(&key, 3);
2993
2994 let forged = ChainRow {
2996 caller_principal: Some("uid:1000".to_string()),
3000 seq: 2,
3001 key_id: CURRENT_KEY_ID.to_string(),
3002 transaction_id: "tx-forged".to_string(),
3003 request_id: "req-forged".to_string(),
3004 request_hash: "hash-forged".to_string(),
3005 action_name: "InstallFlatpak".to_string(),
3006 risk_level: RiskLevel::Medium,
3007 summary: "Forged row".to_string(),
3008 approval_id: None,
3009 warnings_json: "[]".to_string(),
3010 created_at: "2026-04-25T13:00:00Z".to_string(),
3011 prev_chain_hash: rows[0].chain_hash.clone(),
3013 chain_hash: "0".repeat(HASH_HEX_LEN),
3015 chain_version: CHAIN_VERSION_CURRENT,
3016 caller_role: Some("Dev".to_string()),
3017 event_tip: Some(String::new()),
3018 };
3019
3020 let mut rest: Vec<ChainRow> = rows.split_off(1);
3022 for r in rest.iter_mut() {
3023 r.seq += 1;
3024 }
3025 rows.push(forged);
3026 rows.extend(rest);
3027
3028 let outcome = verify_chain(&key, &rows);
3032 match outcome {
3033 VerifyOutcome::Broken {
3034 first_broken_seq, ..
3035 } => assert_eq!(
3036 first_broken_seq, 2,
3037 "the verifier must flag the forged row itself, not a later one"
3038 ),
3039 other => panic!("expected Broken, got {other:?}"),
3040 }
3041 }
3042
3043 #[test]
3046 fn signature_verifies_under_exported_public_key() {
3047 let key = fixed_key();
3049 let rows = build_chain(&key, 4);
3050 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3051 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
3052 }
3053
3054 #[test]
3055 fn foreign_public_key_cannot_validate_chain() {
3056 let signer = AuditKey::from_bytes(vec![0x11; 32]);
3060 let rows = build_chain(&signer, 3);
3061 let other = AuditKey::from_bytes(vec![0x22; 32]);
3062 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
3063 assert!(matches!(
3064 outcome,
3065 VerifyOutcome::Broken {
3066 first_broken_seq: 1,
3067 ..
3068 }
3069 ));
3070 }
3071
3072 #[test]
3073 fn verifying_key_hex_is_a_32_byte_public_key() {
3074 let key = fixed_key();
3075 let vk_hex = key.verifying_key_hex();
3076 assert_eq!(
3077 vk_hex.len(),
3078 64,
3079 "32-byte ed25519 public key = 64 hex chars"
3080 );
3081 assert!(hex::decode(&vk_hex).is_ok());
3082 }
3083
3084 #[test]
3085 fn malformed_signature_hex_is_broken_not_panic() {
3086 let key = fixed_key();
3087 let mut rows = build_chain(&key, 2);
3088 rows[0].chain_hash = "not-valid-hex!!".to_string();
3089 let outcome = verify_chain(&key, &rows);
3090 assert!(matches!(
3091 outcome,
3092 VerifyOutcome::Broken {
3093 first_broken_seq: 1,
3094 ..
3095 }
3096 ));
3097 }
3098
3099 #[test]
3100 fn bad_public_key_hex_yields_cannot_verify() {
3101 let key = fixed_key();
3102 let rows = build_chain(&key, 1);
3103 let outcome = verify_chain_with_pubkey("zz", &rows);
3104 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3105 }
3106
3107 #[test]
3108 fn load_or_generate_writes_public_key_sidecar() {
3109 let dir = tempfile::tempdir().unwrap();
3110 let path = dir.path().join("audit-key");
3111 let key = AuditKey::load_or_generate(&path).unwrap();
3112 let pub_path = dir.path().join("audit-key.pub");
3113 assert!(pub_path.exists(), "public key sidecar must be written");
3114 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
3115 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
3116 }
3117
3118 #[test]
3119 fn wrong_key_id_yields_cannot_verify() {
3120 let key = fixed_key();
3121 let mut rows = build_chain(&key, 2);
3122 rows[0].key_id = "v99".to_string();
3123 let outcome = verify_chain(&key, &rows);
3124 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3125 assert_eq!(outcome_to_exit_code(&outcome), 2);
3126 }
3127
3128 #[test]
3129 fn exit_code_for_each_outcome() {
3130 assert_eq!(
3131 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
3132 0
3133 );
3134 assert_eq!(
3135 outcome_to_exit_code(&VerifyOutcome::Broken {
3136 rows_checked: 0,
3137 first_broken_seq: 1,
3138 first_broken_transaction_id: String::new(),
3139 expected: String::new(),
3140 actual: String::new(),
3141 }),
3142 1
3143 );
3144 assert_eq!(
3145 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
3146 reason: String::new()
3147 }),
3148 2
3149 );
3150 }
3151
3152 #[test]
3155 fn load_or_generate_creates_with_0600_mode() {
3156 use std::os::unix::fs::PermissionsExt;
3157 let dir = tempfile::tempdir().unwrap();
3158 let path = dir.path().join("audit-key");
3159 let _key = AuditKey::load_or_generate(&path).unwrap();
3160 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
3161 assert_eq!(mode, 0o600);
3162 }
3163
3164 #[test]
3165 fn load_or_generate_is_idempotent() {
3166 let dir = tempfile::tempdir().unwrap();
3167 let path = dir.path().join("audit-key");
3168 let key1_bytes = {
3169 let _ = AuditKey::load_or_generate(&path).unwrap();
3170 std::fs::read(&path).unwrap()
3171 };
3172 let _ = AuditKey::load_or_generate(&path).unwrap();
3173 let key2_bytes = std::fs::read(&path).unwrap();
3174 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
3175 }
3176
3177 #[test]
3178 fn rejects_world_readable_key_file() {
3179 use std::os::unix::fs::PermissionsExt;
3180 let dir = tempfile::tempdir().unwrap();
3181 let path = dir.path().join("audit-key");
3182 std::fs::write(&path, vec![0u8; 32]).unwrap();
3183 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3184 let result = AuditKey::load_or_generate(&path);
3185 assert!(matches!(
3186 result,
3187 Err(AuditKeyError::UnsafePermissions { .. })
3188 ));
3189 }
3190
3191 #[test]
3192 fn rejects_short_key_file() {
3193 use std::os::unix::fs::PermissionsExt;
3194 let dir = tempfile::tempdir().unwrap();
3195 let path = dir.path().join("audit-key");
3196 std::fs::write(&path, vec![0u8; 8]).unwrap();
3197 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
3198 let result = AuditKey::load_or_generate(&path);
3199 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
3200 }
3201
3202 #[test]
3208 fn debug_redacts_key_bytes_and_their_hex_encoding() {
3209 let raw = (0u8..32).collect::<Vec<u8>>();
3211 let key = AuditKey::from_bytes(raw.clone());
3212 let dbg = format!("{key:?}");
3213
3214 assert!(
3215 dbg.contains("redacted"),
3216 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
3217 );
3218
3219 let hex_lower = hex::encode(&raw);
3221 let hex_upper = hex::encode_upper(&raw);
3222 assert!(
3223 !dbg.contains(&hex_lower),
3224 "Debug output leaks lowercase hex of key bytes: {dbg}"
3225 );
3226 assert!(
3227 !dbg.contains(&hex_upper),
3228 "Debug output leaks uppercase hex of key bytes: {dbg}"
3229 );
3230
3231 for b in &raw {
3235 let pair = format!("{b:02x}");
3236 if *b >= 0x10 && pair != "1e" && pair != "1f" {
3241 assert!(
3242 !dbg.contains(&pair),
3243 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
3244 );
3245 }
3246 }
3247 }
3248
3249 #[test]
3252 fn debug_preserves_key_id() {
3253 let key = AuditKey::from_bytes(vec![0xff; 32]);
3254 let dbg = format!("{key:?}");
3255 assert!(
3256 dbg.contains("key_id"),
3257 "Debug output must label the key_id field: {dbg}"
3258 );
3259 assert!(
3260 dbg.contains(CURRENT_KEY_ID),
3261 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
3262 );
3263 }
3264
3265 #[test]
3268 fn checkpoint_consistent_with_intact_chain() {
3269 let key = fixed_key();
3270 let rows = build_chain(&key, 5);
3271 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3272 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3273 assert_eq!(
3274 outcome,
3275 CheckpointOutcome::Consistent {
3276 checkpoints_checked: 1
3277 }
3278 );
3279 }
3280
3281 #[test]
3282 fn checkpoint_detects_truncation() {
3283 let key = fixed_key();
3284 let full = build_chain(&key, 5);
3285 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
3287 let truncated = &full[..3];
3288 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
3289 assert!(matches!(
3290 outcome,
3291 CheckpointOutcome::Truncated {
3292 checkpoint_seq: 5,
3293 current_max_seq: 3
3294 }
3295 ));
3296 }
3297
3298 #[test]
3299 fn checkpoint_detects_rewrite() {
3300 let key = fixed_key();
3301 let mut rows = build_chain(&key, 4);
3302 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3303 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
3305 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3306 assert!(matches!(
3307 outcome,
3308 CheckpointOutcome::TipMismatch { seq: 3, .. }
3309 ));
3310 }
3311
3312 #[test]
3313 fn checkpoint_bad_signature_detected() {
3314 let key = fixed_key();
3315 let rows = build_chain(&key, 3);
3316 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
3317 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3319 assert!(matches!(
3320 outcome,
3321 CheckpointOutcome::BadSignature { seq: 2 }
3322 ));
3323 }
3324
3325 #[test]
3326 fn checkpoint_foreign_key_rejected() {
3327 let signer = AuditKey::from_bytes(vec![0x11; 32]);
3329 let rows = build_chain(&signer, 3);
3330 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
3331 let other = AuditKey::from_bytes(vec![0x22; 32]);
3332 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
3333 assert!(matches!(
3334 outcome,
3335 CheckpointOutcome::BadSignature { seq: 2 }
3336 ));
3337 }
3338
3339 #[test]
3340 fn pubkey_verify_detects_tampered_middle_row() {
3341 let key = fixed_key();
3344 let mut rows = build_chain(&key, 4);
3345 rows[2].summary = "TAMPERED".to_string();
3346 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3347 assert!(matches!(
3348 outcome,
3349 VerifyOutcome::Broken {
3350 first_broken_seq: 3,
3351 ..
3352 }
3353 ));
3354 }
3355
3356 #[test]
3357 fn multiple_checkpoints_all_consistent() {
3358 let key = fixed_key();
3359 let rows = build_chain(&key, 5);
3360 let cps = vec![
3361 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
3362 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
3363 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
3364 ];
3365 assert_eq!(
3366 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
3367 CheckpointOutcome::Consistent {
3368 checkpoints_checked: 3
3369 }
3370 );
3371 }
3372
3373 #[test]
3374 fn middle_checkpoint_failure_is_reported() {
3375 let key = fixed_key();
3377 let mut rows = build_chain(&key, 5);
3378 let cps = vec![
3379 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
3380 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
3381 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
3382 ];
3383 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
3385 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
3386 CheckpointOutcome::TipMismatch { seq: 3, .. }
3387 ));
3388 }
3389
3390 #[test]
3391 fn checkpoint_created_at_is_signed() {
3392 let key = fixed_key();
3394 let rows = build_chain(&key, 3);
3395 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3396 cp.created_at = "2020-01-01T00:00:00Z".to_string();
3397 assert!(matches!(
3398 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
3399 CheckpointOutcome::BadSignature { .. }
3400 ));
3401 }
3402
3403 #[test]
3404 fn checkpoint_seq_is_signed() {
3405 let key = fixed_key();
3407 let rows = build_chain(&key, 3);
3408 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3409 cp.seq = 2;
3410 assert!(matches!(
3411 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
3412 CheckpointOutcome::BadSignature { seq: 2 }
3413 ));
3414 }
3415
3416 #[test]
3417 fn checkpoint_detects_full_wipe() {
3418 let key = fixed_key();
3420 let full = build_chain(&key, 3);
3421 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
3422 assert!(matches!(
3423 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
3424 CheckpointOutcome::Truncated {
3425 checkpoint_seq: 3,
3426 current_max_seq: 0
3427 }
3428 ));
3429 }
3430
3431 #[test]
3432 fn domain_tags_are_distinct_and_prefix_free() {
3433 let tags: [(&str, &[u8]); 4] = [
3440 ("row", ROW_DOMAIN),
3441 ("checkpoint", CHECKPOINT_DOMAIN),
3442 ("approval", APPROVAL_DOMAIN),
3443 ("event", EVENT_DOMAIN),
3444 ];
3445 for (i, (name_a, a)) in tags.iter().enumerate() {
3446 for (name_b, b) in tags.iter().skip(i + 1) {
3447 assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
3448 assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
3449 assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
3450 }
3451 }
3452 }
3453
3454 #[test]
3455 fn approval_receipt_framing_is_injective() {
3456 let key = fixed_key();
3461 let sep = "\u{1f}";
3462 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
3463 let b = key.approval_receipt("tx", &format!("b{sep}c"));
3464 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
3465
3466 assert_eq!(
3468 key.approval_receipt("tx-1", "hash-1"),
3469 key.approval_receipt("tx-1", "hash-1"),
3470 );
3471 assert_eq!(
3473 key.approval_commitment("tx-1", "hash-1"),
3474 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
3475 );
3476 }
3477}