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 NotChecked,
1088 MissingEvent {
1092 transaction_seq: u64,
1093 event_tip: String,
1094 },
1095}
1096
1097pub fn verify_event_binding(tx_rows: &[ChainRow], event_rows: &[EventRow]) -> BindingOutcome {
1107 let known: std::collections::HashSet<&str> = event_rows
1108 .iter()
1109 .map(|row| row.chain_hash.as_str())
1110 .collect();
1111 let mut bindings_checked = 0u64;
1112 for row in tx_rows {
1113 let Some(tip) = row.event_tip.as_deref() else {
1114 continue;
1115 };
1116 if tip.is_empty() {
1117 continue;
1118 }
1119 if !known.contains(tip) {
1120 return BindingOutcome::MissingEvent {
1121 transaction_seq: row.seq,
1122 event_tip: tip.to_string(),
1123 };
1124 }
1125 bindings_checked += 1;
1126 }
1127 BindingOutcome::Consistent { bindings_checked }
1128}
1129
1130#[derive(Debug, Clone, PartialEq, Eq)]
1137pub struct AuditVerification {
1138 pub chain: VerifyOutcome,
1139 pub events: VerifyOutcome,
1140 pub binding: BindingOutcome,
1141 pub attribution: Option<AttributionCensus>,
1157}
1158
1159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1164enum RowStanding {
1165 NamesAccount,
1167 AttributionFailed,
1170 NotRecorded,
1174 Unattested,
1184}
1185
1186fn standing(row: &ChainRow) -> RowStanding {
1198 let stored = row.caller_principal.as_deref().filter(|p| !p.is_empty());
1206 match row.chain_version {
1207 CHAIN_VERSION_LEGACY | CHAIN_VERSION_V2 => match stored {
1208 None => RowStanding::NotRecorded,
1209 Some(_) => RowStanding::Unattested,
1210 },
1211 CHAIN_VERSION_V3 => match stored {
1212 None => RowStanding::NotRecorded,
1213 Some(p) => match crate::auth::CallerPrincipal::classify(p) {
1214 crate::auth::PrincipalClaim::Account => RowStanding::NamesAccount,
1215 crate::auth::PrincipalClaim::AttributionFailed => RowStanding::AttributionFailed,
1216 crate::auth::PrincipalClaim::Unrecognized => RowStanding::Unattested,
1217 },
1218 },
1219 _ => RowStanding::Unattested,
1225 }
1226}
1227
1228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1260pub struct AttributionCensus {
1261 named: u64,
1262 attribution_failed: u64,
1263 not_recorded: u64,
1264 unattested: u64,
1265 rows: u64,
1266}
1267
1268impl AttributionCensus {
1269 pub fn of(tx_rows: &[ChainRow]) -> Self {
1271 let mut census = Self {
1272 named: 0,
1273 attribution_failed: 0,
1274 not_recorded: 0,
1275 unattested: 0,
1276 rows: tx_rows.len() as u64,
1277 };
1278 for row in tx_rows {
1279 match standing(row) {
1280 RowStanding::NamesAccount => census.named += 1,
1281 RowStanding::AttributionFailed => census.attribution_failed += 1,
1282 RowStanding::NotRecorded => census.not_recorded += 1,
1283 RowStanding::Unattested => census.unattested += 1,
1284 }
1285 }
1286 census
1287 }
1288
1289 pub fn named(&self) -> u64 {
1295 self.named
1296 }
1297
1298 pub fn attribution_failed(&self) -> u64 {
1301 self.attribution_failed
1302 }
1303
1304 pub fn not_recorded(&self) -> u64 {
1307 self.not_recorded
1308 }
1309
1310 pub fn unattested(&self) -> u64 {
1313 self.unattested
1314 }
1315
1316 pub fn rows(&self) -> u64 {
1323 self.rows
1324 }
1325
1326 pub fn unnamed(&self) -> u64 {
1335 self.rows - self.named
1336 }
1337
1338 #[cfg(any(test, feature = "test-support"))]
1351 pub fn from_counts_for_tests(
1352 named: u64,
1353 attribution_failed: u64,
1354 not_recorded: u64,
1355 unattested: u64,
1356 ) -> Self {
1357 let rows = named
1358 .checked_add(attribution_failed)
1359 .and_then(|n| n.checked_add(not_recorded))
1360 .and_then(|n| n.checked_add(unattested))
1361 .expect("census counts must not overflow u64");
1362 Self {
1363 named,
1364 attribution_failed,
1365 not_recorded,
1366 unattested,
1367 rows,
1368 }
1369 }
1370}
1371
1372impl AuditVerification {
1373 pub fn exit_code(&self) -> i32 {
1379 let codes = [
1380 outcome_to_exit_code(&self.chain),
1381 outcome_to_exit_code(&self.events),
1382 binding_outcome_to_exit_code(&self.binding),
1383 ];
1384 if codes.contains(&1) {
1385 1
1386 } else if codes.contains(&2) {
1387 2
1388 } else {
1389 0
1390 }
1391 }
1392}
1393
1394pub fn verify_all(
1396 key: &AuditKey,
1397 tx_rows: &[ChainRow],
1398 event_rows: &[EventRow],
1399) -> AuditVerification {
1400 AuditVerification {
1401 chain: verify_chain(key, tx_rows),
1402 events: verify_event_chain(key, event_rows),
1403 binding: verify_event_binding(tx_rows, event_rows),
1404 attribution: Some(AttributionCensus::of(tx_rows)),
1405 }
1406}
1407
1408pub fn verify_all_with_pubkey(
1410 verifying_key_hex: &str,
1411 tx_rows: &[ChainRow],
1412 event_rows: &[EventRow],
1413) -> AuditVerification {
1414 AuditVerification {
1415 chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
1416 events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
1417 binding: verify_event_binding(tx_rows, event_rows),
1418 attribution: Some(AttributionCensus::of(tx_rows)),
1419 }
1420}
1421
1422pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
1426 match outcome {
1427 BindingOutcome::Consistent { .. } => 0,
1428 BindingOutcome::NotChecked => 2,
1429 BindingOutcome::MissingEvent { .. } => 1,
1430 }
1431}
1432
1433#[derive(Debug, Clone, PartialEq, Eq)]
1449pub struct Checkpoint {
1450 pub seq: u64,
1453 pub chain_tip: String,
1455 pub created_at: String,
1457 pub signature: String,
1459}
1460
1461fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
1464 let mut buf = CHECKPOINT_DOMAIN.to_vec();
1467 push_field(&mut buf, "seq", &seq.to_string());
1468 push_field(&mut buf, "chain_tip", chain_tip);
1469 push_field(&mut buf, "created_at", created_at);
1470 buf
1471}
1472
1473impl AuditKey {
1474 pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
1476 let sig = self
1477 .signing
1478 .sign(&checkpoint_message(seq, chain_tip, created_at));
1479 Checkpoint {
1480 seq,
1481 chain_tip: chain_tip.to_string(),
1482 created_at: created_at.to_string(),
1483 signature: hex::encode(sig.to_bytes()),
1484 }
1485 }
1486}
1487
1488#[derive(Debug, Clone, PartialEq, Eq)]
1490pub enum CheckpointOutcome {
1491 Consistent { checkpoints_checked: u64 },
1494 BadSignature { seq: u64 },
1496 Truncated {
1499 checkpoint_seq: u64,
1500 current_max_seq: u64,
1501 },
1502 TipMismatch {
1505 seq: u64,
1506 anchored: String,
1507 actual: String,
1508 },
1509 CannotVerify { reason: String },
1511}
1512
1513pub fn verify_checkpoints(
1519 verifying_key_hex: &str,
1520 rows: &[ChainRow],
1521 checkpoints: &[Checkpoint],
1522) -> CheckpointOutcome {
1523 let vk = match parse_verifying_key(verifying_key_hex) {
1524 Some(vk) => vk,
1525 None => {
1526 return CheckpointOutcome::CannotVerify {
1527 reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
1528 };
1529 }
1530 };
1531 let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
1534 let mut checked = 0u64;
1535 for cp in checkpoints {
1536 let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
1537 if !signature_ok(&vk, &msg, &cp.signature) {
1538 return CheckpointOutcome::BadSignature { seq: cp.seq };
1539 }
1540 match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
1541 Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
1542 Ok(idx) => {
1543 return CheckpointOutcome::TipMismatch {
1544 seq: cp.seq,
1545 anchored: cp.chain_tip.clone(),
1546 actual: rows[idx].chain_hash.clone(),
1547 };
1548 }
1549 Err(_) => {
1550 return CheckpointOutcome::Truncated {
1551 checkpoint_seq: cp.seq,
1552 current_max_seq,
1553 };
1554 }
1555 }
1556 checked += 1;
1557 }
1558 CheckpointOutcome::Consistent {
1559 checkpoints_checked: checked,
1560 }
1561}
1562
1563pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
1568 match outcome {
1569 CheckpointOutcome::Consistent { .. } => 0,
1570 CheckpointOutcome::BadSignature { .. }
1571 | CheckpointOutcome::Truncated { .. }
1572 | CheckpointOutcome::TipMismatch { .. } => 1,
1573 CheckpointOutcome::CannotVerify { .. } => 2,
1574 }
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579 use super::*;
1580
1581 fn fixed_key() -> AuditKey {
1582 AuditKey::from_bytes(vec![0x42; 32])
1583 }
1584
1585 fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
1586 ChainContent {
1587 seq,
1588 key_id: CURRENT_KEY_ID,
1589 transaction_id: txid,
1590 request_id: "req-1",
1591 request_hash: "hash-abc",
1592 action_name: "UpdateSystem",
1593 risk_level: RiskLevel::High,
1594 summary: "Upgrade",
1595 approval_id: None,
1596 warnings_json: "[]",
1597 created_at: "2026-04-24T12:00:00Z",
1598 identity: ChainIdentity::V2 {
1599 caller_role: "Dev",
1600 event_tip: "",
1601 },
1602 }
1603 }
1604
1605 #[test]
1608 fn same_inputs_yield_same_hash() {
1609 let key = fixed_key();
1610 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1611 let h2 = key.chain_hash(&sample_content(1, "txa"), "");
1612 assert_eq!(h1, h2);
1613 assert_eq!(h1.len(), HASH_HEX_LEN);
1614 }
1615
1616 #[test]
1617 fn different_seq_yields_different_hash() {
1618 let key = fixed_key();
1619 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1620 let h2 = key.chain_hash(&sample_content(2, "txa"), "");
1621 assert_ne!(h1, h2);
1622 }
1623
1624 #[test]
1625 fn different_prev_hash_yields_different_hash() {
1626 let key = fixed_key();
1627 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1628 let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
1629 assert_ne!(h1, h2);
1630 }
1631
1632 #[test]
1633 fn different_keys_yield_different_hashes() {
1634 let key1 = AuditKey::from_bytes(vec![0x01; 32]);
1635 let key2 = AuditKey::from_bytes(vec![0x02; 32]);
1636 let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
1637 let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
1638 assert_ne!(h1, h2);
1639 }
1640
1641 #[test]
1644 fn canonical_bytes_have_stable_field_order() {
1645 let c = sample_content(1, "txa");
1646 let bytes = c.canonical_bytes();
1647 let s = String::from_utf8_lossy(&bytes);
1648 let order = [
1650 "seq",
1651 "key_id",
1652 "transaction_id",
1653 "request_id",
1654 "request_hash",
1655 "action_name",
1656 "risk_level",
1657 "summary",
1658 "approval_id",
1659 "warnings_json",
1660 "created_at",
1661 ];
1662 let mut last_idx = 0;
1663 for tag in order {
1664 let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
1665 assert!(idx >= last_idx, "{tag} out of order");
1666 last_idx = idx;
1667 }
1668 }
1669
1670 #[test]
1671 fn nul_bytes_in_field_value_are_escaped() {
1672 let mut c = sample_content(1, "txa");
1673 let summary = "before\0after";
1674 c.summary = summary;
1675 let bytes = c.canonical_bytes();
1676 assert!(!bytes.contains(&0x00));
1678 assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
1679 }
1680
1681 #[test]
1687 fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
1688 let mut a = sample_content(1, "txa");
1689 a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
1691 b.summary = "before\0after"; assert_ne!(
1693 a.canonical_bytes(),
1694 b.canonical_bytes(),
1695 "backslash escape must run before NUL escape to prevent collision"
1696 );
1697 }
1698
1699 fn key_for_collision_test() -> AuditKey {
1700 AuditKey::from_bytes(vec![0xab; 32])
1701 }
1702
1703 #[test]
1704 fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
1705 let key = key_for_collision_test();
1706 let mut a = sample_content(1, "txa");
1707 a.summary = "before\\0after";
1708 let mut b = sample_content(1, "txa");
1709 b.summary = "before\0after";
1710 assert_ne!(
1711 key.chain_hash(&a, ""),
1712 key.chain_hash(&b, ""),
1713 "chain signature must distinguish escape from raw byte"
1714 );
1715 }
1716
1717 fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
1723 let (chain_version, caller_role, event_tip, caller_principal) = match content.identity {
1724 ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None, None),
1725 ChainIdentity::V2 {
1726 caller_role,
1727 event_tip,
1728 } => (
1729 CHAIN_VERSION_V2,
1730 Some(caller_role.to_string()),
1731 Some(event_tip.to_string()),
1732 None,
1733 ),
1734 ChainIdentity::V3 {
1735 caller_role,
1736 event_tip,
1737 caller_principal,
1738 } => (
1739 CHAIN_VERSION_V3,
1740 Some(caller_role.to_string()),
1741 Some(event_tip.to_string()),
1742 Some(caller_principal.to_string()),
1743 ),
1744 };
1745 ChainRow {
1746 seq: content.seq,
1747 key_id: content.key_id.to_string(),
1748 transaction_id: content.transaction_id.to_string(),
1749 request_id: content.request_id.to_string(),
1750 request_hash: content.request_hash.to_string(),
1751 action_name: content.action_name.to_string(),
1752 risk_level: content.risk_level,
1753 summary: content.summary.to_string(),
1754 approval_id: content.approval_id.map(str::to_string),
1755 warnings_json: content.warnings_json.to_string(),
1756 created_at: content.created_at.to_string(),
1757 prev_chain_hash: prev.to_string(),
1758 chain_hash,
1759 chain_version,
1760 caller_role,
1761 event_tip,
1762 caller_principal,
1763 }
1764 }
1765
1766 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
1767 let mut rows = Vec::with_capacity(count);
1768 let mut prev = String::new();
1769 for i in 0..count {
1770 let seq = (i + 1) as u64;
1771 let txid = format!("tx{i}");
1772 let content = sample_content(seq, &txid);
1773 let hash = key.chain_hash(&content, &prev);
1774 rows.push(row_for(&content, &prev, hash.clone()));
1775 prev = hash;
1776 }
1777 rows
1778 }
1779
1780 #[test]
1783 fn caller_role_is_covered_by_the_row_signature() {
1784 let key = fixed_key();
1788 let mut dev = sample_content(1, "txa");
1789 dev.identity = ChainIdentity::V2 {
1790 caller_role: "Dev",
1791 event_tip: "",
1792 };
1793 let mut boot = sample_content(1, "txa");
1794 boot.identity = ChainIdentity::V2 {
1795 caller_role: "Boot",
1796 event_tip: "",
1797 };
1798 assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
1799 }
1800
1801 #[test]
1802 fn event_tip_is_covered_by_the_row_signature() {
1803 let key = fixed_key();
1804 let mut empty = sample_content(1, "txa");
1805 empty.identity = ChainIdentity::V2 {
1806 caller_role: "Dev",
1807 event_tip: "",
1808 };
1809 let mut bound = sample_content(1, "txa");
1810 bound.identity = ChainIdentity::V2 {
1811 caller_role: "Dev",
1812 event_tip: "abc123",
1813 };
1814 assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
1815 }
1816
1817 #[test]
1818 fn a_legacy_row_written_before_the_migration_still_verifies() {
1819 let key = fixed_key();
1824 let mut content = sample_content(1, "tx-legacy");
1825 content.identity = ChainIdentity::LegacyV1;
1826 let hash = key.chain_hash(&content, "");
1827 let row = row_for(&content, "", hash);
1828 assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
1829 assert_eq!(row.caller_role, None);
1830 assert_eq!(
1831 verify_chain(&key, &[row]),
1832 VerifyOutcome::Intact { rows_checked: 1 }
1833 );
1834 }
1835
1836 #[test]
1842 fn two_admins_are_distinguishable_in_the_signed_record() {
1843 let key = fixed_key();
1844 let mut alice = sample_content(1, "tx-alice");
1845 alice.identity = ChainIdentity::V3 {
1846 caller_role: "admin",
1847 event_tip: "",
1848 caller_principal: "uid:1000",
1849 };
1850 let mut bob = sample_content(1, "tx-alice");
1851 bob.identity = ChainIdentity::V3 {
1852 caller_role: "admin",
1853 event_tip: "",
1854 caller_principal: "uid:1001",
1855 };
1856
1857 assert_ne!(
1858 key.chain_hash(&alice, ""),
1859 key.chain_hash(&bob, ""),
1860 "identical rows differing only in principal must sign differently, \
1861 otherwise the chain still cannot answer which account acted"
1862 );
1863 }
1864
1865 #[test]
1868 fn editing_the_stored_principal_breaks_the_row() {
1869 let key = fixed_key();
1870 let mut content = sample_content(1, "tx1");
1871 content.identity = ChainIdentity::V3 {
1872 caller_role: "admin",
1873 event_tip: "",
1874 caller_principal: "uid:1000",
1875 };
1876 let hash = key.chain_hash(&content, "");
1877 let mut row = row_for(&content, "", hash);
1878 row.caller_principal = Some("uid:0".to_string());
1879
1880 assert!(
1881 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1882 "a principal swapped to root must not verify"
1883 );
1884 }
1885
1886 #[test]
1890 fn downgrading_a_v3_row_to_v2_to_hide_the_account_breaks_it() {
1891 let key = fixed_key();
1892 let mut content = sample_content(1, "tx1");
1893 content.identity = ChainIdentity::V3 {
1894 caller_role: "admin",
1895 event_tip: "",
1896 caller_principal: "uid:1000",
1897 };
1898 let hash = key.chain_hash(&content, "");
1899 let mut row = row_for(&content, "", hash);
1900 row.chain_version = CHAIN_VERSION_V2;
1901 row.caller_principal = None;
1902
1903 assert!(
1904 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1905 "a v3 row relabelled as v2 must not verify"
1906 );
1907 }
1908
1909 #[test]
1914 fn a_v3_row_without_a_usable_principal_is_broken() {
1915 let key = fixed_key();
1916 let mut content = sample_content(1, "tx1");
1917 content.identity = ChainIdentity::V3 {
1918 caller_role: "admin",
1919 event_tip: "",
1920 caller_principal: "uid:1000",
1921 };
1922 let hash = key.chain_hash(&content, "");
1923
1924 for absent in [None, Some(String::new())] {
1925 let mut row = row_for(&content, "", hash.clone());
1926 row.caller_principal = absent.clone();
1927 assert!(
1928 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1929 "a v3 row with principal {absent:?} must be reported as broken"
1930 );
1931 }
1932 }
1933
1934 #[test]
1944 fn a_row_signed_with_a_blank_principal_is_rejected_even_though_it_signs() {
1945 let key = fixed_key();
1946 let mut content = sample_content(1, "tx-blank");
1947 content.identity = ChainIdentity::V3 {
1948 caller_role: "admin",
1949 event_tip: "",
1950 caller_principal: "",
1951 };
1952 let hash = key.chain_hash(&content, "");
1953 let row = row_for(&content, "", hash);
1958 assert_eq!(
1959 row.caller_principal.as_deref(),
1960 Some(""),
1961 "fixture must store the blank principal it signed"
1962 );
1963 assert_eq!(row.chain_version, CHAIN_VERSION_V3);
1964 assert!(
1965 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1966 "a v3 row naming nobody must be broken, not accepted"
1967 );
1968 }
1969
1970 #[test]
1975 fn a_row_recording_an_attribution_failure_verifies() {
1976 let key = fixed_key();
1977 let principal = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
1978 let mut content = sample_content(1, "tx-unattributed");
1979 content.identity = ChainIdentity::V3 {
1980 caller_role: "observer",
1981 event_tip: "",
1982 caller_principal: &principal,
1983 };
1984 let hash = key.chain_hash(&content, "");
1985 let row = row_for(&content, "", hash);
1986
1987 assert_eq!(
1988 verify_chain(&key, &[row]),
1989 VerifyOutcome::Intact { rows_checked: 1 }
1990 );
1991 }
1992
1993 #[test]
1997 fn a_chain_spanning_all_three_encodings_verifies() {
1998 let key = fixed_key();
1999 let mut rows = Vec::new();
2000 let mut prev = String::new();
2001 for (i, identity) in [
2002 ChainIdentity::LegacyV1,
2003 ChainIdentity::V2 {
2004 caller_role: "dev",
2005 event_tip: "",
2006 },
2007 ChainIdentity::V3 {
2008 caller_role: "admin",
2009 event_tip: "",
2010 caller_principal: "uid:1000",
2011 },
2012 ChainIdentity::V3 {
2013 caller_role: "admin",
2014 event_tip: "",
2015 caller_principal: "token:vsock",
2016 },
2017 ]
2018 .into_iter()
2019 .enumerate()
2020 {
2021 let txid = format!("tx{i}");
2022 let mut content = sample_content((i + 1) as u64, &txid);
2023 content.identity = identity;
2024 let hash = key.chain_hash(&content, &prev);
2025 rows.push(row_for(&content, &prev, hash.clone()));
2026 prev = hash;
2027 }
2028
2029 assert_eq!(
2030 verify_chain(&key, &rows),
2031 VerifyOutcome::Intact { rows_checked: 4 }
2032 );
2033 }
2034
2035 fn chain_of(key: &AuditKey, identities: Vec<ChainIdentity<'_>>) -> Vec<ChainRow> {
2038 let mut rows = Vec::new();
2039 let mut prev = String::new();
2040 for (i, identity) in identities.into_iter().enumerate() {
2041 let txid = format!("tx{i}");
2042 let mut content = sample_content((i + 1) as u64, &txid);
2043 content.identity = identity;
2044 let hash = key.chain_hash(&content, &prev);
2045 rows.push(row_for(&content, &prev, hash.clone()));
2046 prev = hash;
2047 }
2048 rows
2049 }
2050
2051 #[test]
2061 fn the_census_separates_attribution_failures_from_encodings_without_the_field() {
2062 let key = fixed_key();
2063 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2064 let rows = chain_of(
2065 &key,
2066 vec![
2067 ChainIdentity::LegacyV1,
2068 ChainIdentity::V2 {
2069 caller_role: "dev",
2070 event_tip: "",
2071 },
2072 ChainIdentity::V3 {
2073 caller_role: "admin",
2074 event_tip: "",
2075 caller_principal: "uid:1000",
2076 },
2077 ChainIdentity::V3 {
2078 caller_role: "admin",
2079 event_tip: "",
2080 caller_principal: "uid:1001",
2081 },
2082 ChainIdentity::V3 {
2083 caller_role: "admin",
2084 event_tip: "",
2085 caller_principal: "token:vsock",
2086 },
2087 ChainIdentity::V3 {
2088 caller_role: "observer",
2089 event_tip: "",
2090 caller_principal: &unattributed,
2091 },
2092 ],
2093 );
2094
2095 let verification = verify_all(&key, &rows, &[]);
2096 assert_eq!(
2097 verification.chain,
2098 VerifyOutcome::Intact { rows_checked: 6 },
2099 "every encoding must keep verifying; the census is not a verdict"
2100 );
2101 let census = verification
2102 .attribution
2103 .expect("rows were read, so a census was taken");
2104 assert_eq!(census.named(), 3);
2105 assert_eq!(census.attribution_failed(), 1);
2106 assert_eq!(census.not_recorded(), 2);
2107 assert_eq!(census.unattested(), 0);
2108 assert_eq!(census.rows(), 6);
2109 assert_eq!(
2110 census.unnamed(),
2111 3,
2112 "three of the six rows cannot name an account, by either reason"
2113 );
2114 }
2115
2116 #[test]
2126 fn a_principal_written_into_an_encoding_that_does_not_sign_it_is_never_an_account() {
2127 let key = fixed_key();
2128 let mut rows = chain_of(
2129 &key,
2130 vec![
2131 ChainIdentity::LegacyV1,
2132 ChainIdentity::V2 {
2133 caller_role: "dev",
2134 event_tip: "",
2135 },
2136 ],
2137 );
2138 rows[0].caller_principal = Some("uid:0".to_string());
2141 rows[1].caller_principal = Some("uid:0".to_string());
2142
2143 let verification = verify_all(&key, &rows, &[]);
2144 assert_eq!(
2145 verification.chain,
2146 VerifyOutcome::Intact { rows_checked: 2 },
2147 "no signature covers that column on v1/v2, so tampering with it is \
2148 invisible to verification -- which is exactly why the census must \
2149 not believe it"
2150 );
2151 let census = verification.attribution.expect("rows were read");
2152 assert_eq!(
2153 census.named(),
2154 0,
2155 "an unsigned column must never be credited as an account"
2156 );
2157 assert_eq!(
2158 census.unattested(),
2159 2,
2160 "and it must be reported as unattested, because nothing in SysKnife \
2161 writes that column on those encodings"
2162 );
2163 assert_eq!(census.unnamed(), 2);
2164 }
2165
2166 #[test]
2170 fn a_blank_principal_column_is_counted_as_naming_nobody() {
2171 let key = fixed_key();
2172 let mut content = sample_content(1, "tx-blank-census");
2173 content.identity = ChainIdentity::V3 {
2174 caller_role: "admin",
2175 event_tip: "",
2176 caller_principal: "",
2177 };
2178 let hash = key.chain_hash(&content, "");
2179 let row = row_for(&content, "", hash);
2180 assert_eq!(row.caller_principal.as_deref(), Some(""));
2181
2182 let verification = verify_all(&key, &[row], &[]);
2183 let census = verification.attribution.expect("a row was read");
2184 assert_eq!(census.named(), 0);
2185 assert_eq!(census.not_recorded(), 1);
2186 assert_eq!(census.rows(), 1);
2187 assert!(
2188 matches!(verification.chain, VerifyOutcome::Broken { .. }),
2189 "and the row is still reported as broken once the walk reaches it, \
2190 so census and verdict agree"
2191 );
2192 }
2193
2194 #[test]
2202 fn a_principal_this_binary_cannot_read_is_counted_as_unattested_not_as_an_account() {
2203 let key = fixed_key();
2204 for forged in [
2205 "garbage",
2206 "uid:",
2207 "none:overflow",
2208 " ",
2213 "UID:1000",
2214 "uid:notanumber",
2216 "uid:1000:extra",
2217 "token:not-vsock",
2218 ] {
2219 let mut content = sample_content(1, "tx-forged");
2220 content.identity = ChainIdentity::V3 {
2221 caller_role: "admin",
2222 event_tip: "",
2223 caller_principal: forged,
2224 };
2225 let hash = key.chain_hash(&content, "");
2226 let row = row_for(&content, "", hash);
2227 let census = verify_all(&key, &[row], &[])
2228 .attribution
2229 .expect("a row was read");
2230 assert_eq!(
2231 census.named(),
2232 0,
2233 "{forged:?} names no account and must not be counted as one"
2234 );
2235 assert_eq!(
2236 census.unnamed(),
2237 1,
2238 "{forged:?} must be counted among the rows naming nobody"
2239 );
2240 assert_eq!(
2241 census.unattested(),
2242 1,
2243 "{forged:?} is signed content the daemon never writes, so it is a \
2244 finding to investigate, not history to explain away"
2245 );
2246 assert_eq!(
2247 census.not_recorded(),
2248 0,
2249 "{forged:?} is present, so it must not be reported as a row that \
2250 predates the column"
2251 );
2252 }
2253 }
2254
2255 #[test]
2260 fn a_chain_predating_the_principal_field_reports_no_row_as_attributed() {
2261 let key = fixed_key();
2262 let rows = chain_of(
2263 &key,
2264 vec![
2265 ChainIdentity::LegacyV1,
2266 ChainIdentity::V2 {
2267 caller_role: "dev",
2268 event_tip: "",
2269 },
2270 ],
2271 );
2272
2273 let verification = verify_all(&key, &rows, &[]);
2274 assert_eq!(
2275 verification.chain,
2276 VerifyOutcome::Intact { rows_checked: 2 },
2277 "legacy rows must keep verifying; the census is not a verdict"
2278 );
2279 let census = verification.attribution.expect("rows were read");
2280 assert_eq!(census.attribution_failed(), 0);
2281 assert_eq!(census.named(), 0);
2282 assert_eq!(census.not_recorded(), 2);
2283 assert_eq!(census.unnamed(), 2);
2284 }
2285
2286 #[test]
2292 fn a_broken_chain_censuses_more_rows_than_it_verified() {
2293 let key = fixed_key();
2294 let mut rows = chain_of(
2295 &key,
2296 vec![
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 ChainIdentity::V3 {
2308 caller_role: "admin",
2309 event_tip: "",
2310 caller_principal: "uid:1000",
2311 },
2312 ],
2313 );
2314 rows[1].summary = "tampered".to_string();
2315
2316 let verification = verify_all(&key, &rows, &[]);
2317 let rows_checked = match verification.chain {
2318 VerifyOutcome::Broken { rows_checked, .. } => rows_checked,
2319 ref other => panic!("expected a detected tamper, got {other:?}"),
2320 };
2321 let census = verification.attribution.expect("rows were read");
2322 assert_eq!(rows_checked, 1, "the walk stops at the first broken row");
2323 assert_eq!(
2324 census.rows(),
2325 3,
2326 "while the census still describes every row read"
2327 );
2328 assert!(
2329 census.rows() > rows_checked,
2330 "the report must be able to show that more was counted than checked"
2331 );
2332 }
2333
2334 #[test]
2338 fn the_pubkey_only_path_takes_the_same_census() {
2339 let key = fixed_key();
2340 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2341 let rows = chain_of(
2342 &key,
2343 vec![
2344 ChainIdentity::V3 {
2345 caller_role: "admin",
2346 event_tip: "",
2347 caller_principal: "uid:1000",
2348 },
2349 ChainIdentity::V3 {
2350 caller_role: "observer",
2351 event_tip: "",
2352 caller_principal: &unattributed,
2353 },
2354 ],
2355 );
2356
2357 let with_key = verify_all(&key, &rows, &[]).attribution;
2358 let with_pubkey = verify_all_with_pubkey(&key.verifying_key_hex(), &rows, &[]).attribution;
2359 assert_eq!(
2360 with_key, with_pubkey,
2361 "the auditor must see the same attribution numbers as the keyholder"
2362 );
2363 let census = with_pubkey.expect("rows were read");
2364 assert_eq!(census.named(), 1);
2365 assert_eq!(census.attribution_failed(), 1);
2366 }
2367
2368 #[test]
2373 fn a_chain_that_names_nobody_still_exits_zero() {
2374 let key = fixed_key();
2375 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2376 let rows = chain_of(
2377 &key,
2378 vec![
2379 ChainIdentity::LegacyV1,
2380 ChainIdentity::V3 {
2381 caller_role: "observer",
2382 event_tip: "",
2383 caller_principal: &unattributed,
2384 },
2385 ],
2386 );
2387
2388 let verification = verify_all(&key, &rows, &[]);
2389 let census = verification.attribution.expect("rows were read");
2390 assert_eq!(census.unnamed(), 2, "not one row names an account");
2391 assert_eq!(
2392 verification.exit_code(),
2393 0,
2394 "yet nothing was tampered with, so the command must succeed"
2395 );
2396 }
2397
2398 #[test]
2403 fn a_future_encoding_reports_cannot_verify_with_the_supported_range() {
2404 let key = fixed_key();
2405 let content = sample_content(1, "tx1");
2406 let hash = key.chain_hash(&content, "");
2407 let mut row = row_for(&content, "", hash);
2408 row.chain_version = CHAIN_VERSION_CURRENT + 1;
2409
2410 match verify_chain(&key, &[row]) {
2411 VerifyOutcome::CannotVerify { reason } => {
2412 assert!(
2413 reason.contains(&format!("chain_version={}", CHAIN_VERSION_CURRENT + 1)),
2414 "name the version found, got: {reason}"
2415 );
2416 assert!(
2417 reason.contains(&CHAIN_VERSION_CURRENT.to_string()),
2418 "and the newest version understood, got: {reason}"
2419 );
2420 }
2421 other => panic!("a future encoding must not be reported as tampering: {other:?}"),
2422 }
2423 }
2424
2425 #[test]
2438 fn a_row_written_by_the_previous_release_still_verifies() {
2439 const GOLDEN_V2_CHAIN_HASH: &str = "ba12cbe49a149387898bc30f1e8d409effa025ab8e440e6e72bedf060afc831e45d9be264d6b18b7e0ef2f2b3584dbc6953b535546cecbbdc3d7ac666cae4d0a";
2440
2441 let key = fixed_key();
2442 let row = ChainRow {
2443 seq: 7,
2444 key_id: CURRENT_KEY_ID.to_string(),
2445 transaction_id: "tx-golden".to_string(),
2446 request_id: "req-golden".to_string(),
2447 request_hash: "hash-golden".to_string(),
2448 action_name: "AptInstall".to_string(),
2449 risk_level: RiskLevel::Medium,
2450 summary: "install ripgrep".to_string(),
2451 approval_id: Some("appr-golden".to_string()),
2452 warnings_json: "[]".to_string(),
2453 created_at: "2026-07-29T00:00:00Z".to_string(),
2454 prev_chain_hash: String::new(),
2455 chain_hash: GOLDEN_V2_CHAIN_HASH.to_string(),
2456 chain_version: 2,
2457 caller_role: Some("admin".to_string()),
2458 event_tip: Some("eventtip-golden".to_string()),
2459 caller_principal: None,
2460 };
2461
2462 assert_eq!(
2463 verify_chain(&key, std::slice::from_ref(&row)),
2464 VerifyOutcome::Intact { rows_checked: 1 },
2465 "a v2 row on disk must verify under this binary; if this fails, the v2 \
2466 encoding or its version dispatch changed and existing audit logs are unreadable"
2467 );
2468
2469 let mut tampered = row;
2473 let flipped = match GOLDEN_V2_CHAIN_HASH.strip_prefix('b') {
2474 Some(rest) => format!("a{rest}"),
2475 None => format!("b{}", &GOLDEN_V2_CHAIN_HASH[1..]),
2476 };
2477 tampered.chain_hash = flipped;
2478 assert!(
2479 matches!(
2480 verify_chain(&key, &[tampered]),
2481 VerifyOutcome::Broken { .. }
2482 ),
2483 "a golden row with one nibble changed must not verify"
2484 );
2485 }
2486
2487 #[test]
2491 fn a_legacy_v1_row_on_disk_still_verifies() {
2492 const GOLDEN_V1_CHAIN_HASH: &str = "ce8b47c15414e989e099303b826fcd9985360b794220cefbbf9689d25f31fe173283687feb6d1dd265ae5aca94c8bcfb87ce33ba8c954fafdad18a59cd97c702";
2493
2494 let row = ChainRow {
2495 chain_version: 1,
2496 caller_role: None,
2497 event_tip: None,
2498 caller_principal: None,
2499 chain_hash: GOLDEN_V1_CHAIN_HASH.to_string(),
2500 ..golden_row_shape()
2501 };
2502 assert_eq!(
2503 verify_chain(&fixed_key(), &[row]),
2504 VerifyOutcome::Intact { rows_checked: 1 }
2505 );
2506 }
2507
2508 #[test]
2512 fn a_v3_row_on_disk_still_verifies() {
2513 const GOLDEN_V3_CHAIN_HASH: &str = "3c0d05f6ccaf9e65ccb796d4ffe96eb31a2ea67e446217c5234af916dc959d0f2c0ee3d3e5b525aece791ee7d6507efb98dcdd79f0d74dde4273859aa9627d08";
2514
2515 let row = ChainRow {
2516 chain_version: 3,
2517 caller_role: Some("admin".to_string()),
2518 event_tip: Some("eventtip-golden".to_string()),
2519 caller_principal: Some("uid:1000".to_string()),
2520 chain_hash: GOLDEN_V3_CHAIN_HASH.to_string(),
2521 ..golden_row_shape()
2522 };
2523 assert_eq!(
2524 verify_chain(&fixed_key(), &[row]),
2525 VerifyOutcome::Intact { rows_checked: 1 }
2526 );
2527 }
2528
2529 #[test]
2536 fn the_escape_table_and_absent_approval_encoding_are_frozen() {
2537 const GOLDEN_V2_AWKWARD_CHAIN_HASH: &str = "e6face6fe2346fc0f6c5c1e5845255579c8f08738406b88b0a1f6dbdf8ff85090a4b80d95bd0927ef54b0a2c4810d52ebb4bbf76cb68c865a7748df26442aa05";
2538
2539 let key = fixed_key();
2540 let content = ChainContent {
2541 seq: 8,
2542 approval_id: None,
2543 summary: "back\\slash and \x1funit and \x1erecord and nul\0byte",
2544 identity: ChainIdentity::V2 {
2545 caller_role: "dev",
2546 event_tip: "",
2547 },
2548 ..golden_content_shape()
2549 };
2550 assert_eq!(
2551 key.chain_hash(&content, "ba12cbe4"),
2552 GOLDEN_V2_AWKWARD_CHAIN_HASH,
2553 "the canonical encoding of escapes, an absent approval id, or a \
2554 non-empty prev_chain_hash changed"
2555 );
2556 }
2557
2558 fn golden_content_shape() -> ChainContent<'static> {
2562 ChainContent {
2563 seq: 7,
2564 key_id: CURRENT_KEY_ID,
2565 transaction_id: "tx-golden",
2566 request_id: "req-golden",
2567 request_hash: "hash-golden",
2568 action_name: "AptInstall",
2569 risk_level: RiskLevel::Medium,
2570 summary: "install ripgrep",
2571 approval_id: Some("appr-golden"),
2572 warnings_json: "[]",
2573 created_at: "2026-07-29T00:00:00Z",
2574 identity: ChainIdentity::LegacyV1,
2575 }
2576 }
2577
2578 fn golden_row_shape() -> ChainRow {
2580 ChainRow {
2581 seq: 7,
2582 key_id: CURRENT_KEY_ID.to_string(),
2583 transaction_id: "tx-golden".to_string(),
2584 request_id: "req-golden".to_string(),
2585 request_hash: "hash-golden".to_string(),
2586 action_name: "AptInstall".to_string(),
2587 risk_level: RiskLevel::Medium,
2588 summary: "install ripgrep".to_string(),
2589 approval_id: Some("appr-golden".to_string()),
2590 warnings_json: "[]".to_string(),
2591 created_at: "2026-07-29T00:00:00Z".to_string(),
2592 prev_chain_hash: String::new(),
2593 chain_hash: String::new(),
2594 chain_version: 1,
2595 caller_role: None,
2596 event_tip: None,
2597 caller_principal: None,
2598 }
2599 }
2600
2601 #[test]
2602 fn a_chain_that_spans_the_migration_verifies_end_to_end() {
2603 let key = fixed_key();
2606 let mut rows = Vec::new();
2607 let mut prev = String::new();
2608 for (i, identity) in [
2609 ChainIdentity::LegacyV1,
2610 ChainIdentity::LegacyV1,
2611 ChainIdentity::V2 {
2612 caller_role: "Dev",
2613 event_tip: "",
2614 },
2615 ChainIdentity::V2 {
2616 caller_role: "Boot",
2617 event_tip: "",
2618 },
2619 ]
2620 .into_iter()
2621 .enumerate()
2622 {
2623 let txid = format!("tx{i}");
2624 let mut content = sample_content((i + 1) as u64, &txid);
2625 content.identity = identity;
2626 let hash = key.chain_hash(&content, &prev);
2627 rows.push(row_for(&content, &prev, hash.clone()));
2628 prev = hash;
2629 }
2630 assert_eq!(
2631 verify_chain(&key, &rows),
2632 VerifyOutcome::Intact { rows_checked: 4 }
2633 );
2634 }
2635
2636 #[test]
2637 fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
2638 let key = fixed_key();
2643 let mut rows = build_chain(&key, 2);
2644 rows[1].chain_version = CHAIN_VERSION_LEGACY;
2645 rows[1].caller_role = None;
2646 rows[1].event_tip = None;
2647 match verify_chain(&key, &rows) {
2648 VerifyOutcome::Broken {
2649 first_broken_seq, ..
2650 } => assert_eq!(first_broken_seq, 2),
2651 other => panic!("expected Broken, got {other:?}"),
2652 }
2653 }
2654
2655 #[test]
2656 fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
2657 let key = fixed_key();
2662 let mut rows = build_chain(&key, 1);
2663 rows[0].caller_role = None;
2664 let outcome = verify_chain(&key, &rows);
2665 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2666 assert_eq!(outcome_to_exit_code(&outcome), 1);
2667 }
2668
2669 #[test]
2670 fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
2671 let key = fixed_key();
2674 let mut rows = build_chain(&key, 1);
2675 rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
2676 let outcome = verify_chain(&key, &rows);
2677 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
2678 assert_eq!(outcome_to_exit_code(&outcome), 2);
2679 }
2680
2681 fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
2684 EventContent {
2685 seq,
2686 key_id: CURRENT_KEY_ID,
2687 kind,
2688 transaction_id: txid,
2689 receipt_digest: "digest-abc",
2690 created_at: "2026-04-24T12:00:00Z",
2691 }
2692 }
2693
2694 fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
2695 let mut rows = Vec::with_capacity(kinds.len());
2696 let mut prev = String::new();
2697 for (i, kind) in kinds.iter().enumerate() {
2698 let seq = (i + 1) as u64;
2699 let txid = format!("tx{i}");
2700 let content = event_content(seq, *kind, &txid);
2701 let hash = key.event_hash(&content, &prev);
2702 rows.push(EventRow {
2703 seq,
2704 key_id: content.key_id.to_string(),
2705 kind: content.kind.as_str().to_string(),
2706 transaction_id: content.transaction_id.to_string(),
2707 receipt_digest: content.receipt_digest.to_string(),
2708 created_at: content.created_at.to_string(),
2709 prev_chain_hash: prev.clone(),
2710 chain_hash: hash.clone(),
2711 });
2712 prev = hash;
2713 }
2714 rows
2715 }
2716
2717 #[test]
2718 fn event_kind_spellings_are_stable() {
2719 assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
2722 assert_eq!(
2723 AuditEventKind::ApprovalConsumed.as_str(),
2724 "approval_consumed"
2725 );
2726 assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
2727 }
2728
2729 #[test]
2730 fn intact_event_chain_verifies() {
2731 let key = fixed_key();
2732 let rows = build_event_chain(
2733 &key,
2734 &[
2735 AuditEventKind::ApprovalGranted,
2736 AuditEventKind::ApprovalConsumed,
2737 ],
2738 );
2739 assert_eq!(
2740 verify_event_chain(&key, &rows),
2741 VerifyOutcome::Intact { rows_checked: 2 }
2742 );
2743 }
2744
2745 #[test]
2746 fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
2747 let key = fixed_key();
2750 let mut rows = build_event_chain(
2751 &key,
2752 &[
2753 AuditEventKind::ApprovalGranted,
2754 AuditEventKind::ApprovalConsumed,
2755 AuditEventKind::ApprovalRevoked,
2756 ],
2757 );
2758 rows.remove(1);
2759 match verify_event_chain(&key, &rows) {
2760 VerifyOutcome::Broken {
2761 first_broken_seq, ..
2762 } => assert_eq!(first_broken_seq, 3),
2763 other => panic!("expected Broken, got {other:?}"),
2764 }
2765 }
2766
2767 #[test]
2768 fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
2769 let key = fixed_key();
2770 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
2771 rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
2772 assert!(matches!(
2773 verify_event_chain(&key, &rows),
2774 VerifyOutcome::Broken { .. }
2775 ));
2776 }
2777
2778 #[test]
2779 fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
2780 let key = fixed_key();
2781 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
2782 rows[0].kind = "approval_unicorned".to_string();
2783 assert!(matches!(
2784 verify_event_chain(&key, &rows),
2785 VerifyOutcome::Broken { .. }
2786 ));
2787 }
2788
2789 #[test]
2790 fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
2791 let key = fixed_key();
2795 let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
2796 let event_sig = key.event_hash(&content, "");
2797 let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
2798 assert_ne!(event_sig, row_sig);
2799
2800 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
2801 rows[0].chain_hash = row_sig;
2802 assert!(matches!(
2803 verify_event_chain(&key, &rows),
2804 VerifyOutcome::Broken { .. }
2805 ));
2806 }
2807
2808 #[test]
2811 fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
2812 let key = fixed_key();
2817 let events = build_event_chain(
2818 &key,
2819 &[
2820 AuditEventKind::ApprovalGranted,
2821 AuditEventKind::ApprovalConsumed,
2822 ],
2823 );
2824 let mut content = sample_content(1, "tx-after");
2825 content.identity = ChainIdentity::V2 {
2826 caller_role: "Dev",
2827 event_tip: &events[1].chain_hash,
2828 };
2829 let hash = key.chain_hash(&content, "");
2830 let tx_rows = vec![row_for(&content, "", hash)];
2831
2832 assert_eq!(
2833 verify_event_binding(&tx_rows, &events),
2834 BindingOutcome::Consistent {
2835 bindings_checked: 1
2836 }
2837 );
2838
2839 let truncated = &events[..1];
2840 assert_eq!(
2841 verify_event_chain(&key, truncated),
2842 VerifyOutcome::Intact { rows_checked: 1 },
2843 "a truncated event chain still walks clean; the binding is the detector"
2844 );
2845 match verify_event_binding(&tx_rows, truncated) {
2846 BindingOutcome::MissingEvent {
2847 transaction_seq, ..
2848 } => assert_eq!(transaction_seq, 1),
2849 other => panic!("expected MissingEvent, got {other:?}"),
2850 }
2851 }
2852
2853 #[test]
2854 fn legacy_rows_carry_no_binding_to_check() {
2855 let key = fixed_key();
2858 let mut content = sample_content(1, "tx-legacy");
2859 content.identity = ChainIdentity::LegacyV1;
2860 let hash = key.chain_hash(&content, "");
2861 let rows = vec![row_for(&content, "", hash)];
2862 assert_eq!(
2863 verify_event_binding(&rows, &[]),
2864 BindingOutcome::Consistent {
2865 bindings_checked: 0
2866 }
2867 );
2868 }
2869
2870 #[test]
2871 fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
2872 let verification = AuditVerification {
2877 chain: VerifyOutcome::Broken {
2878 rows_checked: 1,
2879 first_broken_seq: 2,
2880 first_broken_transaction_id: "tx".to_string(),
2881 expected: "e".to_string(),
2882 actual: "a".to_string(),
2883 },
2884 events: VerifyOutcome::CannotVerify {
2885 reason: "no key".to_string(),
2886 },
2887 binding: BindingOutcome::Consistent {
2888 bindings_checked: 0,
2889 },
2890 attribution: None,
2891 };
2892 assert_eq!(verification.exit_code(), 1);
2893 }
2894
2895 #[test]
2896 fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
2897 let verification = AuditVerification {
2898 chain: VerifyOutcome::Intact { rows_checked: 3 },
2899 events: VerifyOutcome::Intact { rows_checked: 1 },
2900 binding: BindingOutcome::MissingEvent {
2901 transaction_seq: 3,
2902 event_tip: "abc".to_string(),
2903 },
2904 attribution: None,
2905 };
2906 assert_eq!(verification.exit_code(), 1);
2907 }
2908
2909 #[test]
2910 fn all_three_clean_is_exit_zero() {
2911 let verification = AuditVerification {
2912 chain: VerifyOutcome::Intact { rows_checked: 3 },
2913 events: VerifyOutcome::Intact { rows_checked: 2 },
2914 binding: BindingOutcome::Consistent {
2915 bindings_checked: 1,
2916 },
2917 attribution: None,
2918 };
2919 assert_eq!(verification.exit_code(), 0);
2920 }
2921
2922 #[test]
2923 fn binding_exit_codes_split_clean_tampered_and_unchecked() {
2924 assert_eq!(
2925 binding_outcome_to_exit_code(&BindingOutcome::Consistent {
2926 bindings_checked: 0
2927 }),
2928 0
2929 );
2930 assert_eq!(
2931 binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
2932 transaction_seq: 7,
2933 event_tip: "abc".to_string()
2934 }),
2935 1
2936 );
2937 assert_eq!(binding_outcome_to_exit_code(&BindingOutcome::NotChecked), 2);
2938 }
2939
2940 #[test]
2941 fn intact_chain_verifies() {
2942 let key = fixed_key();
2943 let rows = build_chain(&key, 5);
2944 let outcome = verify_chain(&key, &rows);
2945 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
2946 assert_eq!(outcome_to_exit_code(&outcome), 0);
2947 }
2948
2949 #[test]
2950 fn empty_chain_verifies() {
2951 let key = fixed_key();
2952 let outcome = verify_chain(&key, &[]);
2953 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
2954 }
2955
2956 #[test]
2957 fn tampered_summary_breaks_chain_at_first_offending_row() {
2958 let key = fixed_key();
2959 let mut rows = build_chain(&key, 3);
2960 rows[1].summary = "TAMPERED".to_string();
2962 let outcome = verify_chain(&key, &rows);
2963 match outcome {
2964 VerifyOutcome::Broken {
2965 first_broken_seq, ..
2966 } => assert_eq!(first_broken_seq, 2),
2967 other => panic!("expected Broken, got {other:?}"),
2968 }
2969 }
2970
2971 #[test]
2972 fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
2973 let key = fixed_key();
2974 let mut rows = build_chain(&key, 4);
2975 rows.remove(1);
2977 let outcome = verify_chain(&key, &rows);
2978 match outcome {
2979 VerifyOutcome::Broken {
2980 first_broken_seq, ..
2981 } => assert_eq!(
2982 first_broken_seq, 3,
2983 "first broken row is the one whose prev_hash refers to deleted row"
2984 ),
2985 other => panic!("expected Broken, got {other:?}"),
2986 }
2987 }
2988
2989 #[test]
2990 fn inserted_forged_row_breaks_chain() {
2991 let key = fixed_key();
2998 let mut rows = build_chain(&key, 3);
2999
3000 let forged = ChainRow {
3002 caller_principal: Some("uid:1000".to_string()),
3006 seq: 2,
3007 key_id: CURRENT_KEY_ID.to_string(),
3008 transaction_id: "tx-forged".to_string(),
3009 request_id: "req-forged".to_string(),
3010 request_hash: "hash-forged".to_string(),
3011 action_name: "InstallFlatpak".to_string(),
3012 risk_level: RiskLevel::Medium,
3013 summary: "Forged row".to_string(),
3014 approval_id: None,
3015 warnings_json: "[]".to_string(),
3016 created_at: "2026-04-25T13:00:00Z".to_string(),
3017 prev_chain_hash: rows[0].chain_hash.clone(),
3019 chain_hash: "0".repeat(HASH_HEX_LEN),
3021 chain_version: CHAIN_VERSION_CURRENT,
3022 caller_role: Some("Dev".to_string()),
3023 event_tip: Some(String::new()),
3024 };
3025
3026 let mut rest: Vec<ChainRow> = rows.split_off(1);
3028 for r in rest.iter_mut() {
3029 r.seq += 1;
3030 }
3031 rows.push(forged);
3032 rows.extend(rest);
3033
3034 let outcome = verify_chain(&key, &rows);
3038 match outcome {
3039 VerifyOutcome::Broken {
3040 first_broken_seq, ..
3041 } => assert_eq!(
3042 first_broken_seq, 2,
3043 "the verifier must flag the forged row itself, not a later one"
3044 ),
3045 other => panic!("expected Broken, got {other:?}"),
3046 }
3047 }
3048
3049 #[test]
3052 fn signature_verifies_under_exported_public_key() {
3053 let key = fixed_key();
3055 let rows = build_chain(&key, 4);
3056 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3057 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
3058 }
3059
3060 #[test]
3061 fn foreign_public_key_cannot_validate_chain() {
3062 let signer = AuditKey::from_bytes(vec![0x11; 32]);
3066 let rows = build_chain(&signer, 3);
3067 let other = AuditKey::from_bytes(vec![0x22; 32]);
3068 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
3069 assert!(matches!(
3070 outcome,
3071 VerifyOutcome::Broken {
3072 first_broken_seq: 1,
3073 ..
3074 }
3075 ));
3076 }
3077
3078 #[test]
3079 fn verifying_key_hex_is_a_32_byte_public_key() {
3080 let key = fixed_key();
3081 let vk_hex = key.verifying_key_hex();
3082 assert_eq!(
3083 vk_hex.len(),
3084 64,
3085 "32-byte ed25519 public key = 64 hex chars"
3086 );
3087 assert!(hex::decode(&vk_hex).is_ok());
3088 }
3089
3090 #[test]
3091 fn malformed_signature_hex_is_broken_not_panic() {
3092 let key = fixed_key();
3093 let mut rows = build_chain(&key, 2);
3094 rows[0].chain_hash = "not-valid-hex!!".to_string();
3095 let outcome = verify_chain(&key, &rows);
3096 assert!(matches!(
3097 outcome,
3098 VerifyOutcome::Broken {
3099 first_broken_seq: 1,
3100 ..
3101 }
3102 ));
3103 }
3104
3105 #[test]
3106 fn bad_public_key_hex_yields_cannot_verify() {
3107 let key = fixed_key();
3108 let rows = build_chain(&key, 1);
3109 let outcome = verify_chain_with_pubkey("zz", &rows);
3110 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3111 }
3112
3113 #[test]
3114 fn load_or_generate_writes_public_key_sidecar() {
3115 let dir = tempfile::tempdir().unwrap();
3116 let path = dir.path().join("audit-key");
3117 let key = AuditKey::load_or_generate(&path).unwrap();
3118 let pub_path = dir.path().join("audit-key.pub");
3119 assert!(pub_path.exists(), "public key sidecar must be written");
3120 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
3121 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
3122 }
3123
3124 #[test]
3125 fn wrong_key_id_yields_cannot_verify() {
3126 let key = fixed_key();
3127 let mut rows = build_chain(&key, 2);
3128 rows[0].key_id = "v99".to_string();
3129 let outcome = verify_chain(&key, &rows);
3130 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3131 assert_eq!(outcome_to_exit_code(&outcome), 2);
3132 }
3133
3134 #[test]
3135 fn exit_code_for_each_outcome() {
3136 assert_eq!(
3137 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
3138 0
3139 );
3140 assert_eq!(
3141 outcome_to_exit_code(&VerifyOutcome::Broken {
3142 rows_checked: 0,
3143 first_broken_seq: 1,
3144 first_broken_transaction_id: String::new(),
3145 expected: String::new(),
3146 actual: String::new(),
3147 }),
3148 1
3149 );
3150 assert_eq!(
3151 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
3152 reason: String::new()
3153 }),
3154 2
3155 );
3156 }
3157
3158 #[test]
3161 fn load_or_generate_creates_with_0600_mode() {
3162 use std::os::unix::fs::PermissionsExt;
3163 let dir = tempfile::tempdir().unwrap();
3164 let path = dir.path().join("audit-key");
3165 let _key = AuditKey::load_or_generate(&path).unwrap();
3166 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
3167 assert_eq!(mode, 0o600);
3168 }
3169
3170 #[test]
3171 fn load_or_generate_is_idempotent() {
3172 let dir = tempfile::tempdir().unwrap();
3173 let path = dir.path().join("audit-key");
3174 let key1_bytes = {
3175 let _ = AuditKey::load_or_generate(&path).unwrap();
3176 std::fs::read(&path).unwrap()
3177 };
3178 let _ = AuditKey::load_or_generate(&path).unwrap();
3179 let key2_bytes = std::fs::read(&path).unwrap();
3180 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
3181 }
3182
3183 #[test]
3184 fn rejects_world_readable_key_file() {
3185 use std::os::unix::fs::PermissionsExt;
3186 let dir = tempfile::tempdir().unwrap();
3187 let path = dir.path().join("audit-key");
3188 std::fs::write(&path, vec![0u8; 32]).unwrap();
3189 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3190 let result = AuditKey::load_or_generate(&path);
3191 assert!(matches!(
3192 result,
3193 Err(AuditKeyError::UnsafePermissions { .. })
3194 ));
3195 }
3196
3197 #[test]
3198 fn rejects_short_key_file() {
3199 use std::os::unix::fs::PermissionsExt;
3200 let dir = tempfile::tempdir().unwrap();
3201 let path = dir.path().join("audit-key");
3202 std::fs::write(&path, vec![0u8; 8]).unwrap();
3203 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
3204 let result = AuditKey::load_or_generate(&path);
3205 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
3206 }
3207
3208 #[test]
3214 fn debug_redacts_key_bytes_and_their_hex_encoding() {
3215 let raw = (0u8..32).collect::<Vec<u8>>();
3217 let key = AuditKey::from_bytes(raw.clone());
3218 let dbg = format!("{key:?}");
3219
3220 assert!(
3221 dbg.contains("redacted"),
3222 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
3223 );
3224
3225 let hex_lower = hex::encode(&raw);
3227 let hex_upper = hex::encode_upper(&raw);
3228 assert!(
3229 !dbg.contains(&hex_lower),
3230 "Debug output leaks lowercase hex of key bytes: {dbg}"
3231 );
3232 assert!(
3233 !dbg.contains(&hex_upper),
3234 "Debug output leaks uppercase hex of key bytes: {dbg}"
3235 );
3236
3237 for b in &raw {
3241 let pair = format!("{b:02x}");
3242 if *b >= 0x10 && pair != "1e" && pair != "1f" {
3247 assert!(
3248 !dbg.contains(&pair),
3249 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
3250 );
3251 }
3252 }
3253 }
3254
3255 #[test]
3258 fn debug_preserves_key_id() {
3259 let key = AuditKey::from_bytes(vec![0xff; 32]);
3260 let dbg = format!("{key:?}");
3261 assert!(
3262 dbg.contains("key_id"),
3263 "Debug output must label the key_id field: {dbg}"
3264 );
3265 assert!(
3266 dbg.contains(CURRENT_KEY_ID),
3267 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
3268 );
3269 }
3270
3271 #[test]
3274 fn checkpoint_consistent_with_intact_chain() {
3275 let key = fixed_key();
3276 let rows = build_chain(&key, 5);
3277 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3278 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3279 assert_eq!(
3280 outcome,
3281 CheckpointOutcome::Consistent {
3282 checkpoints_checked: 1
3283 }
3284 );
3285 }
3286
3287 #[test]
3288 fn checkpoint_detects_truncation() {
3289 let key = fixed_key();
3290 let full = build_chain(&key, 5);
3291 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
3293 let truncated = &full[..3];
3294 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
3295 assert!(matches!(
3296 outcome,
3297 CheckpointOutcome::Truncated {
3298 checkpoint_seq: 5,
3299 current_max_seq: 3
3300 }
3301 ));
3302 }
3303
3304 #[test]
3305 fn checkpoint_detects_rewrite() {
3306 let key = fixed_key();
3307 let mut rows = build_chain(&key, 4);
3308 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3309 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
3311 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3312 assert!(matches!(
3313 outcome,
3314 CheckpointOutcome::TipMismatch { seq: 3, .. }
3315 ));
3316 }
3317
3318 #[test]
3319 fn checkpoint_bad_signature_detected() {
3320 let key = fixed_key();
3321 let rows = build_chain(&key, 3);
3322 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
3323 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3325 assert!(matches!(
3326 outcome,
3327 CheckpointOutcome::BadSignature { seq: 2 }
3328 ));
3329 }
3330
3331 #[test]
3332 fn checkpoint_foreign_key_rejected() {
3333 let signer = AuditKey::from_bytes(vec![0x11; 32]);
3335 let rows = build_chain(&signer, 3);
3336 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
3337 let other = AuditKey::from_bytes(vec![0x22; 32]);
3338 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
3339 assert!(matches!(
3340 outcome,
3341 CheckpointOutcome::BadSignature { seq: 2 }
3342 ));
3343 }
3344
3345 #[test]
3346 fn pubkey_verify_detects_tampered_middle_row() {
3347 let key = fixed_key();
3350 let mut rows = build_chain(&key, 4);
3351 rows[2].summary = "TAMPERED".to_string();
3352 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3353 assert!(matches!(
3354 outcome,
3355 VerifyOutcome::Broken {
3356 first_broken_seq: 3,
3357 ..
3358 }
3359 ));
3360 }
3361
3362 #[test]
3363 fn multiple_checkpoints_all_consistent() {
3364 let key = fixed_key();
3365 let rows = build_chain(&key, 5);
3366 let cps = vec![
3367 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
3368 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
3369 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
3370 ];
3371 assert_eq!(
3372 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
3373 CheckpointOutcome::Consistent {
3374 checkpoints_checked: 3
3375 }
3376 );
3377 }
3378
3379 #[test]
3380 fn middle_checkpoint_failure_is_reported() {
3381 let key = fixed_key();
3383 let mut rows = build_chain(&key, 5);
3384 let cps = vec![
3385 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
3386 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
3387 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
3388 ];
3389 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
3391 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
3392 CheckpointOutcome::TipMismatch { seq: 3, .. }
3393 ));
3394 }
3395
3396 #[test]
3397 fn checkpoint_created_at_is_signed() {
3398 let key = fixed_key();
3400 let rows = build_chain(&key, 3);
3401 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3402 cp.created_at = "2020-01-01T00:00:00Z".to_string();
3403 assert!(matches!(
3404 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
3405 CheckpointOutcome::BadSignature { .. }
3406 ));
3407 }
3408
3409 #[test]
3410 fn checkpoint_seq_is_signed() {
3411 let key = fixed_key();
3413 let rows = build_chain(&key, 3);
3414 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3415 cp.seq = 2;
3416 assert!(matches!(
3417 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
3418 CheckpointOutcome::BadSignature { seq: 2 }
3419 ));
3420 }
3421
3422 #[test]
3423 fn checkpoint_detects_full_wipe() {
3424 let key = fixed_key();
3426 let full = build_chain(&key, 3);
3427 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
3428 assert!(matches!(
3429 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
3430 CheckpointOutcome::Truncated {
3431 checkpoint_seq: 3,
3432 current_max_seq: 0
3433 }
3434 ));
3435 }
3436
3437 #[test]
3438 fn domain_tags_are_distinct_and_prefix_free() {
3439 let tags: [(&str, &[u8]); 4] = [
3446 ("row", ROW_DOMAIN),
3447 ("checkpoint", CHECKPOINT_DOMAIN),
3448 ("approval", APPROVAL_DOMAIN),
3449 ("event", EVENT_DOMAIN),
3450 ];
3451 for (i, (name_a, a)) in tags.iter().enumerate() {
3452 for (name_b, b) in tags.iter().skip(i + 1) {
3453 assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
3454 assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
3455 assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
3456 }
3457 }
3458 }
3459
3460 #[test]
3461 fn approval_receipt_framing_is_injective() {
3462 let key = fixed_key();
3467 let sep = "\u{1f}";
3468 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
3469 let b = key.approval_receipt("tx", &format!("b{sep}c"));
3470 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
3471
3472 assert_eq!(
3474 key.approval_receipt("tx-1", "hash-1"),
3475 key.approval_receipt("tx-1", "hash-1"),
3476 );
3477 assert_eq!(
3479 key.approval_commitment("tx-1", "hash-1"),
3480 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
3481 );
3482 }
3483}