1use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
71use sha2::{Digest, Sha256};
72use std::path::{Path, PathBuf};
73use sysknife_types::RiskLevel;
74use zeroize::Zeroize;
75
76pub const CURRENT_KEY_ID: &str = "v1";
79
80pub const HASH_HEX_LEN: usize = 128;
82
83const ROW_DOMAIN: &[u8] = b"sysknife-audit-row-v1\x1f";
87const CHECKPOINT_DOMAIN: &[u8] = b"sysknife-checkpoint-v1\x1f";
88const APPROVAL_DOMAIN: &[u8] = b"sysknife-approval-receipt-v1\x1f";
89const EVENT_DOMAIN: &[u8] = b"sysknife-audit-event-v1\x1f";
90
91pub const CHAIN_VERSION_LEGACY: u32 = 1;
94pub const CHAIN_VERSION_V2: u32 = 2;
104pub const CHAIN_VERSION_V3: u32 = 3;
112pub const CHAIN_VERSION_CURRENT: u32 = CHAIN_VERSION_V3;
119
120#[derive(Clone)]
132pub struct AuditKey {
133 key_id: String,
134 signing: SigningKey,
135}
136
137impl std::fmt::Debug for AuditKey {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 f.debug_struct("AuditKey")
144 .field("key_id", &self.key_id)
145 .field("signing", &format_args!("<redacted signing key>"))
146 .finish()
147 }
148}
149
150#[derive(Debug, thiserror::Error)]
155pub enum AuditKeyError {
156 #[error("io error reading audit key {path}: {source}")]
157 Io {
158 path: PathBuf,
159 #[source]
160 source: std::io::Error,
161 },
162 #[error(
163 "audit key file {path} has unsafe permissions {mode:#o}; \
164 tighten with `chmod 600 {path:?}` and restart"
165 )]
166 UnsafePermissions { path: PathBuf, mode: u32 },
167 #[error(
168 "audit key file {path} is too short ({len} bytes); \
169 expected at least 32 bytes of random material"
170 )]
171 KeyTooShort { path: PathBuf, len: usize },
172}
173
174impl AuditKey {
175 pub fn load_or_generate(path: &Path) -> Result<Self, AuditKeyError> {
182 if !path.exists() {
183 generate_key_at(path)?;
184 }
185
186 let metadata = std::fs::metadata(path).map_err(|e| AuditKeyError::Io {
187 path: path.to_path_buf(),
188 source: e,
189 })?;
190
191 use std::os::unix::fs::PermissionsExt;
192 let mode = metadata.permissions().mode() & 0o777;
193 if mode & 0o077 != 0 {
194 return Err(AuditKeyError::UnsafePermissions {
196 path: path.to_path_buf(),
197 mode,
198 });
199 }
200
201 let mut key_bytes = std::fs::read(path).map_err(|e| AuditKeyError::Io {
202 path: path.to_path_buf(),
203 source: e,
204 })?;
205 if key_bytes.len() < 32 {
206 return Err(AuditKeyError::KeyTooShort {
207 path: path.to_path_buf(),
208 len: key_bytes.len(),
209 });
210 }
211
212 let mut seed = [0u8; 32];
213 seed.copy_from_slice(&key_bytes[..32]);
214 let signing = SigningKey::from_bytes(&seed);
215 seed.zeroize();
216 key_bytes.zeroize();
217
218 Ok(Self {
219 key_id: CURRENT_KEY_ID.to_string(),
220 signing,
221 })
222 }
223
224 #[cfg(test)]
227 pub fn from_bytes(bytes: Vec<u8>) -> Self {
228 let mut seed = [0u8; 32];
229 seed.copy_from_slice(&bytes[..32]);
230 let signing = SigningKey::from_bytes(&seed);
231 seed.zeroize();
232 Self {
233 key_id: CURRENT_KEY_ID.to_string(),
234 signing,
235 }
236 }
237
238 pub fn key_id(&self) -> &str {
240 &self.key_id
241 }
242
243 pub fn chain_hash(&self, content: &ChainContent, prev_chain_hash: &str) -> String {
248 let sig = self.signing.sign(&chain_message(content, prev_chain_hash));
249 hex::encode(sig.to_bytes())
250 }
251
252 pub fn verifying_key(&self) -> VerifyingKey {
254 self.signing.verifying_key()
255 }
256
257 pub fn verifying_key_hex(&self) -> String {
260 hex::encode(self.signing.verifying_key().to_bytes())
261 }
262
263 pub fn approval_receipt(&self, transaction_id: &str, request_hash: &str) -> String {
267 let mut message = APPROVAL_DOMAIN.to_vec();
273 push_field(&mut message, "txid", transaction_id);
274 push_field(&mut message, "reqhash", request_hash);
275 hex::encode(self.signing.sign(&message).to_bytes())
276 }
277
278 pub fn approval_commitment(&self, transaction_id: &str, request_hash: &str) -> String {
280 approval_receipt_digest(&self.approval_receipt(transaction_id, request_hash))
281 }
282}
283
284pub fn approval_receipt_digest(receipt: &str) -> String {
285 hex::encode(Sha256::digest(receipt.as_bytes()))
286}
287
288fn chain_message(content: &ChainContent, prev_chain_hash: &str) -> Vec<u8> {
294 let mut msg = ROW_DOMAIN.to_vec();
295 msg.extend_from_slice(&content.canonical_bytes());
296 msg.extend_from_slice(prev_chain_hash.as_bytes());
297 msg
298}
299
300pub fn resolve_audit_key_path(db_path: &Path) -> PathBuf {
306 std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
307 .map(PathBuf::from)
308 .unwrap_or_else(|_| {
309 db_path
310 .parent()
311 .unwrap_or_else(|| Path::new("."))
312 .join("audit-key")
313 })
314}
315
316fn generate_key_at(path: &Path) -> Result<(), AuditKeyError> {
319 use std::io::Write as _;
320 use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
321
322 if let Some(parent) = path.parent() {
323 if !parent.exists() {
324 std::fs::DirBuilder::new()
325 .recursive(true)
326 .mode(0o700)
327 .create(parent)
328 .map_err(|e| AuditKeyError::Io {
329 path: parent.to_path_buf(),
330 source: e,
331 })?;
332 }
333 }
334
335 let mut bytes = [0u8; 32];
336 fill_random(&mut bytes).map_err(|e| AuditKeyError::Io {
337 path: path.to_path_buf(),
338 source: e,
339 })?;
340
341 let mut f = std::fs::OpenOptions::new()
342 .write(true)
343 .create_new(true)
344 .mode(0o600)
345 .open(path)
346 .map_err(|e| AuditKeyError::Io {
347 path: path.to_path_buf(),
348 source: e,
349 })?;
350 f.write_all(&bytes).map_err(|e| AuditKeyError::Io {
351 path: path.to_path_buf(),
352 source: e,
353 })?;
354 f.sync_all().map_err(|e| AuditKeyError::Io {
355 path: path.to_path_buf(),
356 source: e,
357 })?;
358
359 let signing = SigningKey::from_bytes(&bytes);
362 let pub_hex = hex::encode(signing.verifying_key().to_bytes());
363 bytes.zeroize();
364 let mut pub_path = path.as_os_str().to_os_string();
365 pub_path.push(".pub");
366 std::fs::write(&pub_path, format!("{pub_hex}\n")).map_err(|e| AuditKeyError::Io {
367 path: PathBuf::from(pub_path),
368 source: e,
369 })?;
370 Ok(())
371}
372
373fn fill_random(buf: &mut [u8]) -> std::io::Result<()> {
375 use std::io::Read as _;
376 let mut f = std::fs::File::open("/dev/urandom")?;
377 f.read_exact(buf)
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub enum ChainIdentity<'a> {
444 LegacyV1,
446 V2 {
449 caller_role: &'a str,
454 event_tip: &'a str,
460 },
461 V3 {
468 caller_role: &'a str,
470 event_tip: &'a str,
472 caller_principal: &'a str,
483 },
484}
485
486impl ChainIdentity<'_> {
487 pub fn version(&self) -> u32 {
489 match self {
490 Self::LegacyV1 => CHAIN_VERSION_LEGACY,
491 Self::V2 { .. } => CHAIN_VERSION_V2,
492 Self::V3 { .. } => CHAIN_VERSION_V3,
493 }
494 }
495}
496
497#[derive(Debug, Clone, PartialEq, Eq)]
498pub struct ChainContent<'a> {
499 pub seq: u64,
500 pub key_id: &'a str,
501 pub transaction_id: &'a str,
502 pub request_id: &'a str,
503 pub request_hash: &'a str,
504 pub action_name: &'a str,
505 pub risk_level: RiskLevel,
506 pub summary: &'a str,
515 pub approval_id: Option<&'a str>,
518 pub warnings_json: &'a str,
520 pub created_at: &'a str,
521 pub identity: ChainIdentity<'a>,
523}
524
525impl<'a> ChainContent<'a> {
526 pub fn canonical_bytes(&self) -> Vec<u8> {
530 let risk_level_str = match self.risk_level {
531 RiskLevel::Low => "low",
532 RiskLevel::Medium => "medium",
533 RiskLevel::High => "high",
534 };
535 let approval = self.approval_id.unwrap_or("");
536
537 let mut buf = Vec::with_capacity(512);
538 push_field(&mut buf, "seq", &self.seq.to_string());
541 push_field(&mut buf, "key_id", self.key_id);
542 push_field(&mut buf, "transaction_id", self.transaction_id);
543 push_field(&mut buf, "request_id", self.request_id);
544 push_field(&mut buf, "request_hash", self.request_hash);
545 push_field(&mut buf, "action_name", self.action_name);
546 push_field(&mut buf, "risk_level", risk_level_str);
547 push_field(&mut buf, "summary", self.summary);
548 push_field(&mut buf, "approval_id", approval);
549 push_field(&mut buf, "warnings_json", self.warnings_json);
550 push_field(&mut buf, "created_at", self.created_at);
551 match self.identity {
567 ChainIdentity::LegacyV1 => {}
568 ChainIdentity::V2 {
569 caller_role,
570 event_tip,
571 } => {
572 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V2.to_string());
573 push_field(&mut buf, "caller_role", caller_role);
574 push_field(&mut buf, "event_tip", event_tip);
575 }
576 ChainIdentity::V3 {
577 caller_role,
578 event_tip,
579 caller_principal,
580 } => {
581 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V3.to_string());
582 push_field(&mut buf, "caller_role", caller_role);
583 push_field(&mut buf, "event_tip", event_tip);
584 push_field(&mut buf, "caller_principal", caller_principal);
585 }
586 }
587 buf
588 }
589}
590
591fn push_field(buf: &mut Vec<u8>, tag: &str, value: &str) {
609 buf.extend_from_slice(tag.as_bytes());
610 buf.push(0x1E); for b in value.bytes() {
612 match b {
613 b'\\' => buf.extend_from_slice(b"\\\\"),
614 0x00 => buf.extend_from_slice(b"\\0"),
615 0x1E => buf.extend_from_slice(b"\\1E"),
616 0x1F => buf.extend_from_slice(b"\\1F"),
617 other => buf.push(other),
618 }
619 }
620 buf.push(0x1F); }
622
623#[derive(Debug, Clone, PartialEq, Eq)]
625pub enum VerifyOutcome {
626 Intact { rows_checked: u64 },
628 Broken {
630 rows_checked: u64,
631 first_broken_seq: u64,
632 first_broken_transaction_id: String,
633 expected: String,
634 actual: String,
635 },
636 CannotVerify { reason: String },
640}
641
642pub fn outcome_to_exit_code(outcome: &VerifyOutcome) -> i32 {
648 match outcome {
649 VerifyOutcome::Intact { .. } => 0,
650 VerifyOutcome::Broken { .. } => 1,
651 VerifyOutcome::CannotVerify { .. } => 2,
652 }
653}
654
655#[derive(Debug, Clone)]
657pub struct ChainRow {
658 pub seq: u64,
659 pub key_id: String,
660 pub transaction_id: String,
661 pub request_id: String,
662 pub request_hash: String,
663 pub action_name: String,
664 pub risk_level: RiskLevel,
665 pub summary: String,
666 pub approval_id: Option<String>,
667 pub warnings_json: String,
668 pub created_at: String,
669 pub prev_chain_hash: String,
670 pub chain_hash: String,
671 pub chain_version: u32,
673 pub caller_role: Option<String>,
675 pub event_tip: Option<String>,
678 pub caller_principal: Option<String>,
682}
683
684#[derive(Debug)]
686pub(crate) enum RowIdentityError {
687 UnknownVersion(u32),
691 MissingField(&'static str),
697}
698
699impl ChainRow {
700 pub(crate) fn identity(&self) -> Result<ChainIdentity<'_>, RowIdentityError> {
703 match self.chain_version {
704 CHAIN_VERSION_LEGACY => Ok(ChainIdentity::LegacyV1),
705 CHAIN_VERSION_V2 => Ok(ChainIdentity::V2 {
706 caller_role: self
707 .caller_role
708 .as_deref()
709 .ok_or(RowIdentityError::MissingField("caller_role"))?,
710 event_tip: self
711 .event_tip
712 .as_deref()
713 .ok_or(RowIdentityError::MissingField("event_tip"))?,
714 }),
715 CHAIN_VERSION_V3 => Ok(ChainIdentity::V3 {
716 caller_role: self
717 .caller_role
718 .as_deref()
719 .ok_or(RowIdentityError::MissingField("caller_role"))?,
720 event_tip: self
721 .event_tip
722 .as_deref()
723 .ok_or(RowIdentityError::MissingField("event_tip"))?,
724 caller_principal: self
728 .caller_principal
729 .as_deref()
730 .filter(|p| !p.is_empty())
731 .ok_or(RowIdentityError::MissingField("caller_principal"))?,
732 }),
733 other => Err(RowIdentityError::UnknownVersion(other)),
734 }
735 }
736}
737
738pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
742 verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
743}
744
745pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
750 match parse_verifying_key(verifying_key_hex) {
751 Some(vk) => verify_rows(&vk, None, rows),
752 None => VerifyOutcome::CannotVerify {
753 reason: format!(
754 "invalid public key hex ({} chars); expected 64 hex chars of a \
755 32-byte Ed25519 public key",
756 verifying_key_hex.len()
757 ),
758 },
759 }
760}
761
762fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
763 let bytes = hex::decode(hex_str.trim()).ok()?;
764 let arr: [u8; 32] = bytes.try_into().ok()?;
765 VerifyingKey::from_bytes(&arr).ok()
766}
767
768fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
771 let Ok(bytes) = hex::decode(sig_hex) else {
772 return false;
773 };
774 let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
775 return false;
776 };
777 vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
778}
779
780fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
784 let mut last_hash = String::new();
785 let mut rows_checked = 0u64;
786 for row in rows {
787 if let Some(kid) = expect_key_id {
788 if row.key_id != kid {
789 return VerifyOutcome::CannotVerify {
790 reason: format!(
791 "row seq={} uses key_id={:?} but only {:?} is loaded; \
792 epoch keys not yet supported",
793 row.seq, row.key_id, kid
794 ),
795 };
796 }
797 }
798 if row.prev_chain_hash != last_hash {
799 return VerifyOutcome::Broken {
800 rows_checked,
801 first_broken_seq: row.seq,
802 first_broken_transaction_id: row.transaction_id.clone(),
803 expected: format!("prev_chain_hash={last_hash}"),
804 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
805 };
806 }
807 let identity = match row.identity() {
808 Ok(identity) => identity,
809 Err(RowIdentityError::UnknownVersion(v)) => {
810 return VerifyOutcome::CannotVerify {
811 reason: format!(
812 "row seq={} declares chain_version={v}, which this binary cannot \
813 reproduce (it understands {CHAIN_VERSION_LEGACY}..={CHAIN_VERSION_CURRENT}); \
814 verify with a build at least as new as the one that wrote the chain",
815 row.seq
816 ),
817 };
818 }
819 Err(RowIdentityError::MissingField(field)) => {
820 let stored = match field {
825 "caller_role" => row.caller_role.as_deref(),
826 "event_tip" => row.event_tip.as_deref(),
827 "caller_principal" => row.caller_principal.as_deref(),
828 _ => None,
829 };
830 return VerifyOutcome::Broken {
831 rows_checked,
832 first_broken_seq: row.seq,
833 first_broken_transaction_id: row.transaction_id.clone(),
834 expected: format!(
835 "chain_version={} row carrying a non-empty {field}",
836 row.chain_version
837 ),
838 actual: match stored {
841 None => format!("{field}=NULL"),
842 Some("") => format!("{field}='' (empty, not NULL)"),
843 Some(other) => format!("{field}={other:?}"),
844 },
845 };
846 }
847 };
848 let content = ChainContent {
849 seq: row.seq,
850 key_id: &row.key_id,
851 transaction_id: &row.transaction_id,
852 request_id: &row.request_id,
853 request_hash: &row.request_hash,
854 action_name: &row.action_name,
855 risk_level: row.risk_level,
856 summary: &row.summary,
857 approval_id: row.approval_id.as_deref(),
858 warnings_json: &row.warnings_json,
859 created_at: &row.created_at,
860 identity,
861 };
862 let msg = chain_message(&content, &row.prev_chain_hash);
863 if !signature_ok(vk, &msg, &row.chain_hash) {
864 return VerifyOutcome::Broken {
865 rows_checked,
866 first_broken_seq: row.seq,
867 first_broken_transaction_id: row.transaction_id.clone(),
868 expected: "valid ed25519 signature".to_string(),
869 actual: row.chain_hash.clone(),
870 };
871 }
872 last_hash = row.chain_hash.clone();
873 rows_checked += 1;
874 }
875 VerifyOutcome::Intact { rows_checked }
876}
877
878#[derive(Debug, Clone, Copy, PartialEq, Eq)]
891pub enum AuditEventKind {
892 ApprovalGranted,
894 ApprovalConsumed,
896 ApprovalRevoked,
898}
899
900impl AuditEventKind {
901 pub fn as_str(&self) -> &'static str {
904 match self {
905 Self::ApprovalGranted => "approval_granted",
906 Self::ApprovalConsumed => "approval_consumed",
907 Self::ApprovalRevoked => "approval_revoked",
908 }
909 }
910}
911
912#[derive(Debug, Clone, PartialEq, Eq)]
914pub struct EventContent<'a> {
915 pub seq: u64,
916 pub key_id: &'a str,
917 pub kind: AuditEventKind,
918 pub transaction_id: &'a str,
919 pub receipt_digest: &'a str,
923 pub created_at: &'a str,
924}
925
926impl EventContent<'_> {
927 pub fn canonical_bytes(&self) -> Vec<u8> {
930 let mut buf = Vec::with_capacity(256);
931 push_field(&mut buf, "seq", &self.seq.to_string());
932 push_field(&mut buf, "key_id", self.key_id);
933 push_field(&mut buf, "kind", self.kind.as_str());
934 push_field(&mut buf, "transaction_id", self.transaction_id);
935 push_field(&mut buf, "receipt_digest", self.receipt_digest);
936 push_field(&mut buf, "created_at", self.created_at);
937 buf
938 }
939}
940
941fn event_message(content: &EventContent, prev_chain_hash: &str) -> Vec<u8> {
942 let mut msg = EVENT_DOMAIN.to_vec();
943 msg.extend_from_slice(&content.canonical_bytes());
944 msg.extend_from_slice(prev_chain_hash.as_bytes());
945 msg
946}
947
948impl AuditKey {
949 pub fn event_hash(&self, content: &EventContent, prev_chain_hash: &str) -> String {
953 hex::encode(
954 self.signing
955 .sign(&event_message(content, prev_chain_hash))
956 .to_bytes(),
957 )
958 }
959}
960
961#[derive(Debug, Clone, PartialEq, Eq)]
963pub struct EventRow {
964 pub seq: u64,
965 pub key_id: String,
966 pub kind: String,
970 pub transaction_id: String,
971 pub receipt_digest: String,
972 pub created_at: String,
973 pub prev_chain_hash: String,
974 pub chain_hash: String,
975}
976
977pub fn verify_event_chain(key: &AuditKey, rows: &[EventRow]) -> VerifyOutcome {
979 verify_event_rows(&key.verifying_key(), Some(key.key_id()), rows)
980}
981
982pub fn verify_event_chain_with_pubkey(verifying_key_hex: &str, rows: &[EventRow]) -> VerifyOutcome {
984 match parse_verifying_key(verifying_key_hex) {
985 Some(vk) => verify_event_rows(&vk, None, rows),
986 None => VerifyOutcome::CannotVerify {
987 reason: format!(
988 "invalid public key hex ({} chars); expected 64 hex chars of a \
989 32-byte Ed25519 public key",
990 verifying_key_hex.len()
991 ),
992 },
993 }
994}
995
996fn verify_event_rows(
997 vk: &VerifyingKey,
998 expect_key_id: Option<&str>,
999 rows: &[EventRow],
1000) -> VerifyOutcome {
1001 let mut last_hash = String::new();
1002 let mut rows_checked = 0u64;
1003 for row in rows {
1004 if let Some(kid) = expect_key_id {
1005 if row.key_id != kid {
1006 return VerifyOutcome::CannotVerify {
1007 reason: format!(
1008 "event seq={} uses key_id={:?} but only {:?} is loaded; \
1009 epoch keys not yet supported",
1010 row.seq, row.key_id, kid
1011 ),
1012 };
1013 }
1014 }
1015 if row.prev_chain_hash != last_hash {
1016 return VerifyOutcome::Broken {
1017 rows_checked,
1018 first_broken_seq: row.seq,
1019 first_broken_transaction_id: row.transaction_id.clone(),
1020 expected: format!("prev_chain_hash={last_hash}"),
1021 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
1022 };
1023 }
1024 let Some(kind) = parse_event_kind(&row.kind) else {
1027 return VerifyOutcome::Broken {
1028 rows_checked,
1029 first_broken_seq: row.seq,
1030 first_broken_transaction_id: row.transaction_id.clone(),
1031 expected: "a known event kind".to_string(),
1032 actual: format!("kind={:?}", row.kind),
1033 };
1034 };
1035 let content = EventContent {
1036 seq: row.seq,
1037 key_id: &row.key_id,
1038 kind,
1039 transaction_id: &row.transaction_id,
1040 receipt_digest: &row.receipt_digest,
1041 created_at: &row.created_at,
1042 };
1043 if !signature_ok(
1044 vk,
1045 &event_message(&content, &row.prev_chain_hash),
1046 &row.chain_hash,
1047 ) {
1048 return VerifyOutcome::Broken {
1049 rows_checked,
1050 first_broken_seq: row.seq,
1051 first_broken_transaction_id: row.transaction_id.clone(),
1052 expected: "valid ed25519 signature".to_string(),
1053 actual: row.chain_hash.clone(),
1054 };
1055 }
1056 last_hash = row.chain_hash.clone();
1057 rows_checked += 1;
1058 }
1059 VerifyOutcome::Intact { rows_checked }
1060}
1061
1062fn parse_event_kind(raw: &str) -> Option<AuditEventKind> {
1063 [
1066 AuditEventKind::ApprovalGranted,
1067 AuditEventKind::ApprovalConsumed,
1068 AuditEventKind::ApprovalRevoked,
1069 ]
1070 .into_iter()
1071 .find(|kind| kind.as_str() == raw)
1072}
1073
1074#[derive(Debug, Clone, PartialEq, Eq)]
1077pub enum BindingOutcome {
1078 Consistent { bindings_checked: u64 },
1080 MissingEvent {
1084 transaction_seq: u64,
1085 event_tip: String,
1086 },
1087}
1088
1089pub fn verify_event_binding(tx_rows: &[ChainRow], event_rows: &[EventRow]) -> BindingOutcome {
1099 let known: std::collections::HashSet<&str> = event_rows
1100 .iter()
1101 .map(|row| row.chain_hash.as_str())
1102 .collect();
1103 let mut bindings_checked = 0u64;
1104 for row in tx_rows {
1105 let Some(tip) = row.event_tip.as_deref() else {
1106 continue;
1107 };
1108 if tip.is_empty() {
1109 continue;
1110 }
1111 if !known.contains(tip) {
1112 return BindingOutcome::MissingEvent {
1113 transaction_seq: row.seq,
1114 event_tip: tip.to_string(),
1115 };
1116 }
1117 bindings_checked += 1;
1118 }
1119 BindingOutcome::Consistent { bindings_checked }
1120}
1121
1122#[derive(Debug, Clone, PartialEq, Eq)]
1129pub struct AuditVerification {
1130 pub chain: VerifyOutcome,
1131 pub events: VerifyOutcome,
1132 pub binding: BindingOutcome,
1133 pub attribution: Option<AttributionCensus>,
1149}
1150
1151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1156enum RowStanding {
1157 NamesAccount,
1159 AttributionFailed,
1162 NotRecorded,
1166 Unattested,
1176}
1177
1178fn standing(row: &ChainRow) -> RowStanding {
1190 let stored = row.caller_principal.as_deref().filter(|p| !p.is_empty());
1198 match row.chain_version {
1199 CHAIN_VERSION_LEGACY | CHAIN_VERSION_V2 => match stored {
1200 None => RowStanding::NotRecorded,
1201 Some(_) => RowStanding::Unattested,
1202 },
1203 CHAIN_VERSION_V3 => match stored {
1204 None => RowStanding::NotRecorded,
1205 Some(p) => match crate::auth::CallerPrincipal::classify(p) {
1206 crate::auth::PrincipalClaim::Account => RowStanding::NamesAccount,
1207 crate::auth::PrincipalClaim::AttributionFailed => RowStanding::AttributionFailed,
1208 crate::auth::PrincipalClaim::Unrecognized => RowStanding::Unattested,
1209 },
1210 },
1211 _ => RowStanding::Unattested,
1217 }
1218}
1219
1220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1252pub struct AttributionCensus {
1253 named: u64,
1254 attribution_failed: u64,
1255 not_recorded: u64,
1256 unattested: u64,
1257 rows: u64,
1258}
1259
1260impl AttributionCensus {
1261 pub fn of(tx_rows: &[ChainRow]) -> Self {
1263 let mut census = Self {
1264 named: 0,
1265 attribution_failed: 0,
1266 not_recorded: 0,
1267 unattested: 0,
1268 rows: tx_rows.len() as u64,
1269 };
1270 for row in tx_rows {
1271 match standing(row) {
1272 RowStanding::NamesAccount => census.named += 1,
1273 RowStanding::AttributionFailed => census.attribution_failed += 1,
1274 RowStanding::NotRecorded => census.not_recorded += 1,
1275 RowStanding::Unattested => census.unattested += 1,
1276 }
1277 }
1278 census
1279 }
1280
1281 pub fn named(&self) -> u64 {
1287 self.named
1288 }
1289
1290 pub fn attribution_failed(&self) -> u64 {
1293 self.attribution_failed
1294 }
1295
1296 pub fn not_recorded(&self) -> u64 {
1299 self.not_recorded
1300 }
1301
1302 pub fn unattested(&self) -> u64 {
1305 self.unattested
1306 }
1307
1308 pub fn rows(&self) -> u64 {
1315 self.rows
1316 }
1317
1318 pub fn unnamed(&self) -> u64 {
1327 self.rows - self.named
1328 }
1329
1330 #[cfg(any(test, feature = "test-support"))]
1343 pub fn from_counts_for_tests(
1344 named: u64,
1345 attribution_failed: u64,
1346 not_recorded: u64,
1347 unattested: u64,
1348 ) -> Self {
1349 let rows = named
1350 .checked_add(attribution_failed)
1351 .and_then(|n| n.checked_add(not_recorded))
1352 .and_then(|n| n.checked_add(unattested))
1353 .expect("census counts must not overflow u64");
1354 Self {
1355 named,
1356 attribution_failed,
1357 not_recorded,
1358 unattested,
1359 rows,
1360 }
1361 }
1362}
1363
1364impl AuditVerification {
1365 pub fn exit_code(&self) -> i32 {
1371 let codes = [
1372 outcome_to_exit_code(&self.chain),
1373 outcome_to_exit_code(&self.events),
1374 binding_outcome_to_exit_code(&self.binding),
1375 ];
1376 if codes.contains(&1) {
1377 1
1378 } else if codes.contains(&2) {
1379 2
1380 } else {
1381 0
1382 }
1383 }
1384}
1385
1386pub fn verify_all(
1388 key: &AuditKey,
1389 tx_rows: &[ChainRow],
1390 event_rows: &[EventRow],
1391) -> AuditVerification {
1392 AuditVerification {
1393 chain: verify_chain(key, tx_rows),
1394 events: verify_event_chain(key, event_rows),
1395 binding: verify_event_binding(tx_rows, event_rows),
1396 attribution: Some(AttributionCensus::of(tx_rows)),
1397 }
1398}
1399
1400pub fn verify_all_with_pubkey(
1402 verifying_key_hex: &str,
1403 tx_rows: &[ChainRow],
1404 event_rows: &[EventRow],
1405) -> AuditVerification {
1406 AuditVerification {
1407 chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
1408 events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
1409 binding: verify_event_binding(tx_rows, event_rows),
1410 attribution: Some(AttributionCensus::of(tx_rows)),
1411 }
1412}
1413
1414pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
1417 match outcome {
1418 BindingOutcome::Consistent { .. } => 0,
1419 BindingOutcome::MissingEvent { .. } => 1,
1420 }
1421}
1422
1423#[derive(Debug, Clone, PartialEq, Eq)]
1439pub struct Checkpoint {
1440 pub seq: u64,
1443 pub chain_tip: String,
1445 pub created_at: String,
1447 pub signature: String,
1449}
1450
1451fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
1454 let mut buf = CHECKPOINT_DOMAIN.to_vec();
1457 push_field(&mut buf, "seq", &seq.to_string());
1458 push_field(&mut buf, "chain_tip", chain_tip);
1459 push_field(&mut buf, "created_at", created_at);
1460 buf
1461}
1462
1463impl AuditKey {
1464 pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
1466 let sig = self
1467 .signing
1468 .sign(&checkpoint_message(seq, chain_tip, created_at));
1469 Checkpoint {
1470 seq,
1471 chain_tip: chain_tip.to_string(),
1472 created_at: created_at.to_string(),
1473 signature: hex::encode(sig.to_bytes()),
1474 }
1475 }
1476}
1477
1478#[derive(Debug, Clone, PartialEq, Eq)]
1480pub enum CheckpointOutcome {
1481 Consistent { checkpoints_checked: u64 },
1484 BadSignature { seq: u64 },
1486 Truncated {
1489 checkpoint_seq: u64,
1490 current_max_seq: u64,
1491 },
1492 TipMismatch {
1495 seq: u64,
1496 anchored: String,
1497 actual: String,
1498 },
1499 CannotVerify { reason: String },
1501}
1502
1503pub fn verify_checkpoints(
1509 verifying_key_hex: &str,
1510 rows: &[ChainRow],
1511 checkpoints: &[Checkpoint],
1512) -> CheckpointOutcome {
1513 let vk = match parse_verifying_key(verifying_key_hex) {
1514 Some(vk) => vk,
1515 None => {
1516 return CheckpointOutcome::CannotVerify {
1517 reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
1518 };
1519 }
1520 };
1521 let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
1524 let mut checked = 0u64;
1525 for cp in checkpoints {
1526 let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
1527 if !signature_ok(&vk, &msg, &cp.signature) {
1528 return CheckpointOutcome::BadSignature { seq: cp.seq };
1529 }
1530 match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
1531 Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
1532 Ok(idx) => {
1533 return CheckpointOutcome::TipMismatch {
1534 seq: cp.seq,
1535 anchored: cp.chain_tip.clone(),
1536 actual: rows[idx].chain_hash.clone(),
1537 };
1538 }
1539 Err(_) => {
1540 return CheckpointOutcome::Truncated {
1541 checkpoint_seq: cp.seq,
1542 current_max_seq,
1543 };
1544 }
1545 }
1546 checked += 1;
1547 }
1548 CheckpointOutcome::Consistent {
1549 checkpoints_checked: checked,
1550 }
1551}
1552
1553pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
1558 match outcome {
1559 CheckpointOutcome::Consistent { .. } => 0,
1560 CheckpointOutcome::BadSignature { .. }
1561 | CheckpointOutcome::Truncated { .. }
1562 | CheckpointOutcome::TipMismatch { .. } => 1,
1563 CheckpointOutcome::CannotVerify { .. } => 2,
1564 }
1565}
1566
1567#[cfg(test)]
1568mod tests {
1569 use super::*;
1570
1571 fn fixed_key() -> AuditKey {
1572 AuditKey::from_bytes(vec![0x42; 32])
1573 }
1574
1575 fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
1576 ChainContent {
1577 seq,
1578 key_id: CURRENT_KEY_ID,
1579 transaction_id: txid,
1580 request_id: "req-1",
1581 request_hash: "hash-abc",
1582 action_name: "UpdateSystem",
1583 risk_level: RiskLevel::High,
1584 summary: "Upgrade",
1585 approval_id: None,
1586 warnings_json: "[]",
1587 created_at: "2026-04-24T12:00:00Z",
1588 identity: ChainIdentity::V2 {
1589 caller_role: "Dev",
1590 event_tip: "",
1591 },
1592 }
1593 }
1594
1595 #[test]
1598 fn same_inputs_yield_same_hash() {
1599 let key = fixed_key();
1600 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1601 let h2 = key.chain_hash(&sample_content(1, "txa"), "");
1602 assert_eq!(h1, h2);
1603 assert_eq!(h1.len(), HASH_HEX_LEN);
1604 }
1605
1606 #[test]
1607 fn different_seq_yields_different_hash() {
1608 let key = fixed_key();
1609 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1610 let h2 = key.chain_hash(&sample_content(2, "txa"), "");
1611 assert_ne!(h1, h2);
1612 }
1613
1614 #[test]
1615 fn different_prev_hash_yields_different_hash() {
1616 let key = fixed_key();
1617 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1618 let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
1619 assert_ne!(h1, h2);
1620 }
1621
1622 #[test]
1623 fn different_keys_yield_different_hashes() {
1624 let key1 = AuditKey::from_bytes(vec![0x01; 32]);
1625 let key2 = AuditKey::from_bytes(vec![0x02; 32]);
1626 let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
1627 let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
1628 assert_ne!(h1, h2);
1629 }
1630
1631 #[test]
1634 fn canonical_bytes_have_stable_field_order() {
1635 let c = sample_content(1, "txa");
1636 let bytes = c.canonical_bytes();
1637 let s = String::from_utf8_lossy(&bytes);
1638 let order = [
1640 "seq",
1641 "key_id",
1642 "transaction_id",
1643 "request_id",
1644 "request_hash",
1645 "action_name",
1646 "risk_level",
1647 "summary",
1648 "approval_id",
1649 "warnings_json",
1650 "created_at",
1651 ];
1652 let mut last_idx = 0;
1653 for tag in order {
1654 let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
1655 assert!(idx >= last_idx, "{tag} out of order");
1656 last_idx = idx;
1657 }
1658 }
1659
1660 #[test]
1661 fn nul_bytes_in_field_value_are_escaped() {
1662 let mut c = sample_content(1, "txa");
1663 let summary = "before\0after";
1664 c.summary = summary;
1665 let bytes = c.canonical_bytes();
1666 assert!(!bytes.contains(&0x00));
1668 assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
1669 }
1670
1671 #[test]
1677 fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
1678 let mut a = sample_content(1, "txa");
1679 a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
1681 b.summary = "before\0after"; assert_ne!(
1683 a.canonical_bytes(),
1684 b.canonical_bytes(),
1685 "backslash escape must run before NUL escape to prevent collision"
1686 );
1687 }
1688
1689 fn key_for_collision_test() -> AuditKey {
1690 AuditKey::from_bytes(vec![0xab; 32])
1691 }
1692
1693 #[test]
1694 fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
1695 let key = key_for_collision_test();
1696 let mut a = sample_content(1, "txa");
1697 a.summary = "before\\0after";
1698 let mut b = sample_content(1, "txa");
1699 b.summary = "before\0after";
1700 assert_ne!(
1701 key.chain_hash(&a, ""),
1702 key.chain_hash(&b, ""),
1703 "chain signature must distinguish escape from raw byte"
1704 );
1705 }
1706
1707 fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
1713 let (chain_version, caller_role, event_tip, caller_principal) = match content.identity {
1714 ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None, None),
1715 ChainIdentity::V2 {
1716 caller_role,
1717 event_tip,
1718 } => (
1719 CHAIN_VERSION_V2,
1720 Some(caller_role.to_string()),
1721 Some(event_tip.to_string()),
1722 None,
1723 ),
1724 ChainIdentity::V3 {
1725 caller_role,
1726 event_tip,
1727 caller_principal,
1728 } => (
1729 CHAIN_VERSION_V3,
1730 Some(caller_role.to_string()),
1731 Some(event_tip.to_string()),
1732 Some(caller_principal.to_string()),
1733 ),
1734 };
1735 ChainRow {
1736 seq: content.seq,
1737 key_id: content.key_id.to_string(),
1738 transaction_id: content.transaction_id.to_string(),
1739 request_id: content.request_id.to_string(),
1740 request_hash: content.request_hash.to_string(),
1741 action_name: content.action_name.to_string(),
1742 risk_level: content.risk_level,
1743 summary: content.summary.to_string(),
1744 approval_id: content.approval_id.map(str::to_string),
1745 warnings_json: content.warnings_json.to_string(),
1746 created_at: content.created_at.to_string(),
1747 prev_chain_hash: prev.to_string(),
1748 chain_hash,
1749 chain_version,
1750 caller_role,
1751 event_tip,
1752 caller_principal,
1753 }
1754 }
1755
1756 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
1757 let mut rows = Vec::with_capacity(count);
1758 let mut prev = String::new();
1759 for i in 0..count {
1760 let seq = (i + 1) as u64;
1761 let txid = format!("tx{i}");
1762 let content = sample_content(seq, &txid);
1763 let hash = key.chain_hash(&content, &prev);
1764 rows.push(row_for(&content, &prev, hash.clone()));
1765 prev = hash;
1766 }
1767 rows
1768 }
1769
1770 #[test]
1773 fn caller_role_is_covered_by_the_row_signature() {
1774 let key = fixed_key();
1778 let mut dev = sample_content(1, "txa");
1779 dev.identity = ChainIdentity::V2 {
1780 caller_role: "Dev",
1781 event_tip: "",
1782 };
1783 let mut boot = sample_content(1, "txa");
1784 boot.identity = ChainIdentity::V2 {
1785 caller_role: "Boot",
1786 event_tip: "",
1787 };
1788 assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
1789 }
1790
1791 #[test]
1792 fn event_tip_is_covered_by_the_row_signature() {
1793 let key = fixed_key();
1794 let mut empty = sample_content(1, "txa");
1795 empty.identity = ChainIdentity::V2 {
1796 caller_role: "Dev",
1797 event_tip: "",
1798 };
1799 let mut bound = sample_content(1, "txa");
1800 bound.identity = ChainIdentity::V2 {
1801 caller_role: "Dev",
1802 event_tip: "abc123",
1803 };
1804 assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
1805 }
1806
1807 #[test]
1808 fn a_legacy_row_written_before_the_migration_still_verifies() {
1809 let key = fixed_key();
1814 let mut content = sample_content(1, "tx-legacy");
1815 content.identity = ChainIdentity::LegacyV1;
1816 let hash = key.chain_hash(&content, "");
1817 let row = row_for(&content, "", hash);
1818 assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
1819 assert_eq!(row.caller_role, None);
1820 assert_eq!(
1821 verify_chain(&key, &[row]),
1822 VerifyOutcome::Intact { rows_checked: 1 }
1823 );
1824 }
1825
1826 #[test]
1832 fn two_admins_are_distinguishable_in_the_signed_record() {
1833 let key = fixed_key();
1834 let mut alice = sample_content(1, "tx-alice");
1835 alice.identity = ChainIdentity::V3 {
1836 caller_role: "admin",
1837 event_tip: "",
1838 caller_principal: "uid:1000",
1839 };
1840 let mut bob = sample_content(1, "tx-alice");
1841 bob.identity = ChainIdentity::V3 {
1842 caller_role: "admin",
1843 event_tip: "",
1844 caller_principal: "uid:1001",
1845 };
1846
1847 assert_ne!(
1848 key.chain_hash(&alice, ""),
1849 key.chain_hash(&bob, ""),
1850 "identical rows differing only in principal must sign differently, \
1851 otherwise the chain still cannot answer which account acted"
1852 );
1853 }
1854
1855 #[test]
1858 fn editing_the_stored_principal_breaks_the_row() {
1859 let key = fixed_key();
1860 let mut content = sample_content(1, "tx1");
1861 content.identity = ChainIdentity::V3 {
1862 caller_role: "admin",
1863 event_tip: "",
1864 caller_principal: "uid:1000",
1865 };
1866 let hash = key.chain_hash(&content, "");
1867 let mut row = row_for(&content, "", hash);
1868 row.caller_principal = Some("uid:0".to_string());
1869
1870 assert!(
1871 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1872 "a principal swapped to root must not verify"
1873 );
1874 }
1875
1876 #[test]
1880 fn downgrading_a_v3_row_to_v2_to_hide_the_account_breaks_it() {
1881 let key = fixed_key();
1882 let mut content = sample_content(1, "tx1");
1883 content.identity = ChainIdentity::V3 {
1884 caller_role: "admin",
1885 event_tip: "",
1886 caller_principal: "uid:1000",
1887 };
1888 let hash = key.chain_hash(&content, "");
1889 let mut row = row_for(&content, "", hash);
1890 row.chain_version = CHAIN_VERSION_V2;
1891 row.caller_principal = None;
1892
1893 assert!(
1894 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1895 "a v3 row relabelled as v2 must not verify"
1896 );
1897 }
1898
1899 #[test]
1904 fn a_v3_row_without_a_usable_principal_is_broken() {
1905 let key = fixed_key();
1906 let mut content = sample_content(1, "tx1");
1907 content.identity = ChainIdentity::V3 {
1908 caller_role: "admin",
1909 event_tip: "",
1910 caller_principal: "uid:1000",
1911 };
1912 let hash = key.chain_hash(&content, "");
1913
1914 for absent in [None, Some(String::new())] {
1915 let mut row = row_for(&content, "", hash.clone());
1916 row.caller_principal = absent.clone();
1917 assert!(
1918 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1919 "a v3 row with principal {absent:?} must be reported as broken"
1920 );
1921 }
1922 }
1923
1924 #[test]
1934 fn a_row_signed_with_a_blank_principal_is_rejected_even_though_it_signs() {
1935 let key = fixed_key();
1936 let mut content = sample_content(1, "tx-blank");
1937 content.identity = ChainIdentity::V3 {
1938 caller_role: "admin",
1939 event_tip: "",
1940 caller_principal: "",
1941 };
1942 let hash = key.chain_hash(&content, "");
1943 let row = row_for(&content, "", hash);
1948 assert_eq!(
1949 row.caller_principal.as_deref(),
1950 Some(""),
1951 "fixture must store the blank principal it signed"
1952 );
1953 assert_eq!(row.chain_version, CHAIN_VERSION_V3);
1954 assert!(
1955 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1956 "a v3 row naming nobody must be broken, not accepted"
1957 );
1958 }
1959
1960 #[test]
1965 fn a_row_recording_an_attribution_failure_verifies() {
1966 let key = fixed_key();
1967 let principal = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
1968 let mut content = sample_content(1, "tx-unattributed");
1969 content.identity = ChainIdentity::V3 {
1970 caller_role: "observer",
1971 event_tip: "",
1972 caller_principal: &principal,
1973 };
1974 let hash = key.chain_hash(&content, "");
1975 let row = row_for(&content, "", hash);
1976
1977 assert_eq!(
1978 verify_chain(&key, &[row]),
1979 VerifyOutcome::Intact { rows_checked: 1 }
1980 );
1981 }
1982
1983 #[test]
1987 fn a_chain_spanning_all_three_encodings_verifies() {
1988 let key = fixed_key();
1989 let mut rows = Vec::new();
1990 let mut prev = String::new();
1991 for (i, identity) in [
1992 ChainIdentity::LegacyV1,
1993 ChainIdentity::V2 {
1994 caller_role: "dev",
1995 event_tip: "",
1996 },
1997 ChainIdentity::V3 {
1998 caller_role: "admin",
1999 event_tip: "",
2000 caller_principal: "uid:1000",
2001 },
2002 ChainIdentity::V3 {
2003 caller_role: "admin",
2004 event_tip: "",
2005 caller_principal: "token:vsock",
2006 },
2007 ]
2008 .into_iter()
2009 .enumerate()
2010 {
2011 let txid = format!("tx{i}");
2012 let mut content = sample_content((i + 1) as u64, &txid);
2013 content.identity = identity;
2014 let hash = key.chain_hash(&content, &prev);
2015 rows.push(row_for(&content, &prev, hash.clone()));
2016 prev = hash;
2017 }
2018
2019 assert_eq!(
2020 verify_chain(&key, &rows),
2021 VerifyOutcome::Intact { rows_checked: 4 }
2022 );
2023 }
2024
2025 fn chain_of(key: &AuditKey, identities: Vec<ChainIdentity<'_>>) -> Vec<ChainRow> {
2028 let mut rows = Vec::new();
2029 let mut prev = String::new();
2030 for (i, identity) in identities.into_iter().enumerate() {
2031 let txid = format!("tx{i}");
2032 let mut content = sample_content((i + 1) as u64, &txid);
2033 content.identity = identity;
2034 let hash = key.chain_hash(&content, &prev);
2035 rows.push(row_for(&content, &prev, hash.clone()));
2036 prev = hash;
2037 }
2038 rows
2039 }
2040
2041 #[test]
2051 fn the_census_separates_attribution_failures_from_encodings_without_the_field() {
2052 let key = fixed_key();
2053 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2054 let rows = chain_of(
2055 &key,
2056 vec![
2057 ChainIdentity::LegacyV1,
2058 ChainIdentity::V2 {
2059 caller_role: "dev",
2060 event_tip: "",
2061 },
2062 ChainIdentity::V3 {
2063 caller_role: "admin",
2064 event_tip: "",
2065 caller_principal: "uid:1000",
2066 },
2067 ChainIdentity::V3 {
2068 caller_role: "admin",
2069 event_tip: "",
2070 caller_principal: "uid:1001",
2071 },
2072 ChainIdentity::V3 {
2073 caller_role: "admin",
2074 event_tip: "",
2075 caller_principal: "token:vsock",
2076 },
2077 ChainIdentity::V3 {
2078 caller_role: "observer",
2079 event_tip: "",
2080 caller_principal: &unattributed,
2081 },
2082 ],
2083 );
2084
2085 let verification = verify_all(&key, &rows, &[]);
2086 assert_eq!(
2087 verification.chain,
2088 VerifyOutcome::Intact { rows_checked: 6 },
2089 "every encoding must keep verifying; the census is not a verdict"
2090 );
2091 let census = verification
2092 .attribution
2093 .expect("rows were read, so a census was taken");
2094 assert_eq!(census.named(), 3);
2095 assert_eq!(census.attribution_failed(), 1);
2096 assert_eq!(census.not_recorded(), 2);
2097 assert_eq!(census.unattested(), 0);
2098 assert_eq!(census.rows(), 6);
2099 assert_eq!(
2100 census.unnamed(),
2101 3,
2102 "three of the six rows cannot name an account, by either reason"
2103 );
2104 }
2105
2106 #[test]
2116 fn a_principal_written_into_an_encoding_that_does_not_sign_it_is_never_an_account() {
2117 let key = fixed_key();
2118 let mut rows = chain_of(
2119 &key,
2120 vec![
2121 ChainIdentity::LegacyV1,
2122 ChainIdentity::V2 {
2123 caller_role: "dev",
2124 event_tip: "",
2125 },
2126 ],
2127 );
2128 rows[0].caller_principal = Some("uid:0".to_string());
2131 rows[1].caller_principal = Some("uid:0".to_string());
2132
2133 let verification = verify_all(&key, &rows, &[]);
2134 assert_eq!(
2135 verification.chain,
2136 VerifyOutcome::Intact { rows_checked: 2 },
2137 "no signature covers that column on v1/v2, so tampering with it is \
2138 invisible to verification -- which is exactly why the census must \
2139 not believe it"
2140 );
2141 let census = verification.attribution.expect("rows were read");
2142 assert_eq!(
2143 census.named(),
2144 0,
2145 "an unsigned column must never be credited as an account"
2146 );
2147 assert_eq!(
2148 census.unattested(),
2149 2,
2150 "and it must be reported as unattested, because nothing in SysKnife \
2151 writes that column on those encodings"
2152 );
2153 assert_eq!(census.unnamed(), 2);
2154 }
2155
2156 #[test]
2160 fn a_blank_principal_column_is_counted_as_naming_nobody() {
2161 let key = fixed_key();
2162 let mut content = sample_content(1, "tx-blank-census");
2163 content.identity = ChainIdentity::V3 {
2164 caller_role: "admin",
2165 event_tip: "",
2166 caller_principal: "",
2167 };
2168 let hash = key.chain_hash(&content, "");
2169 let row = row_for(&content, "", hash);
2170 assert_eq!(row.caller_principal.as_deref(), Some(""));
2171
2172 let verification = verify_all(&key, &[row], &[]);
2173 let census = verification.attribution.expect("a row was read");
2174 assert_eq!(census.named(), 0);
2175 assert_eq!(census.not_recorded(), 1);
2176 assert_eq!(census.rows(), 1);
2177 assert!(
2178 matches!(verification.chain, VerifyOutcome::Broken { .. }),
2179 "and the row is still reported as broken once the walk reaches it, \
2180 so census and verdict agree"
2181 );
2182 }
2183
2184 #[test]
2192 fn a_principal_this_binary_cannot_read_is_counted_as_unattested_not_as_an_account() {
2193 let key = fixed_key();
2194 for forged in [
2195 "garbage",
2196 "uid:",
2197 "none:overflow",
2198 " ",
2203 "UID:1000",
2204 "uid:notanumber",
2206 "uid:1000:extra",
2207 "token:not-vsock",
2208 ] {
2209 let mut content = sample_content(1, "tx-forged");
2210 content.identity = ChainIdentity::V3 {
2211 caller_role: "admin",
2212 event_tip: "",
2213 caller_principal: forged,
2214 };
2215 let hash = key.chain_hash(&content, "");
2216 let row = row_for(&content, "", hash);
2217 let census = verify_all(&key, &[row], &[])
2218 .attribution
2219 .expect("a row was read");
2220 assert_eq!(
2221 census.named(),
2222 0,
2223 "{forged:?} names no account and must not be counted as one"
2224 );
2225 assert_eq!(
2226 census.unnamed(),
2227 1,
2228 "{forged:?} must be counted among the rows naming nobody"
2229 );
2230 assert_eq!(
2231 census.unattested(),
2232 1,
2233 "{forged:?} is signed content the daemon never writes, so it is a \
2234 finding to investigate, not history to explain away"
2235 );
2236 assert_eq!(
2237 census.not_recorded(),
2238 0,
2239 "{forged:?} is present, so it must not be reported as a row that \
2240 predates the column"
2241 );
2242 }
2243 }
2244
2245 #[test]
2250 fn a_chain_predating_the_principal_field_reports_no_row_as_attributed() {
2251 let key = fixed_key();
2252 let rows = chain_of(
2253 &key,
2254 vec![
2255 ChainIdentity::LegacyV1,
2256 ChainIdentity::V2 {
2257 caller_role: "dev",
2258 event_tip: "",
2259 },
2260 ],
2261 );
2262
2263 let verification = verify_all(&key, &rows, &[]);
2264 assert_eq!(
2265 verification.chain,
2266 VerifyOutcome::Intact { rows_checked: 2 },
2267 "legacy rows must keep verifying; the census is not a verdict"
2268 );
2269 let census = verification.attribution.expect("rows were read");
2270 assert_eq!(census.attribution_failed(), 0);
2271 assert_eq!(census.named(), 0);
2272 assert_eq!(census.not_recorded(), 2);
2273 assert_eq!(census.unnamed(), 2);
2274 }
2275
2276 #[test]
2282 fn a_broken_chain_censuses_more_rows_than_it_verified() {
2283 let key = fixed_key();
2284 let mut rows = chain_of(
2285 &key,
2286 vec![
2287 ChainIdentity::V3 {
2288 caller_role: "admin",
2289 event_tip: "",
2290 caller_principal: "uid:1000",
2291 },
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 ],
2303 );
2304 rows[1].summary = "tampered".to_string();
2305
2306 let verification = verify_all(&key, &rows, &[]);
2307 let rows_checked = match verification.chain {
2308 VerifyOutcome::Broken { rows_checked, .. } => rows_checked,
2309 ref other => panic!("expected a detected tamper, got {other:?}"),
2310 };
2311 let census = verification.attribution.expect("rows were read");
2312 assert_eq!(rows_checked, 1, "the walk stops at the first broken row");
2313 assert_eq!(
2314 census.rows(),
2315 3,
2316 "while the census still describes every row read"
2317 );
2318 assert!(
2319 census.rows() > rows_checked,
2320 "the report must be able to show that more was counted than checked"
2321 );
2322 }
2323
2324 #[test]
2328 fn the_pubkey_only_path_takes_the_same_census() {
2329 let key = fixed_key();
2330 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2331 let rows = chain_of(
2332 &key,
2333 vec![
2334 ChainIdentity::V3 {
2335 caller_role: "admin",
2336 event_tip: "",
2337 caller_principal: "uid:1000",
2338 },
2339 ChainIdentity::V3 {
2340 caller_role: "observer",
2341 event_tip: "",
2342 caller_principal: &unattributed,
2343 },
2344 ],
2345 );
2346
2347 let with_key = verify_all(&key, &rows, &[]).attribution;
2348 let with_pubkey = verify_all_with_pubkey(&key.verifying_key_hex(), &rows, &[]).attribution;
2349 assert_eq!(
2350 with_key, with_pubkey,
2351 "the auditor must see the same attribution numbers as the keyholder"
2352 );
2353 let census = with_pubkey.expect("rows were read");
2354 assert_eq!(census.named(), 1);
2355 assert_eq!(census.attribution_failed(), 1);
2356 }
2357
2358 #[test]
2363 fn a_chain_that_names_nobody_still_exits_zero() {
2364 let key = fixed_key();
2365 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2366 let rows = chain_of(
2367 &key,
2368 vec![
2369 ChainIdentity::LegacyV1,
2370 ChainIdentity::V3 {
2371 caller_role: "observer",
2372 event_tip: "",
2373 caller_principal: &unattributed,
2374 },
2375 ],
2376 );
2377
2378 let verification = verify_all(&key, &rows, &[]);
2379 let census = verification.attribution.expect("rows were read");
2380 assert_eq!(census.unnamed(), 2, "not one row names an account");
2381 assert_eq!(
2382 verification.exit_code(),
2383 0,
2384 "yet nothing was tampered with, so the command must succeed"
2385 );
2386 }
2387
2388 #[test]
2393 fn a_future_encoding_reports_cannot_verify_with_the_supported_range() {
2394 let key = fixed_key();
2395 let content = sample_content(1, "tx1");
2396 let hash = key.chain_hash(&content, "");
2397 let mut row = row_for(&content, "", hash);
2398 row.chain_version = CHAIN_VERSION_CURRENT + 1;
2399
2400 match verify_chain(&key, &[row]) {
2401 VerifyOutcome::CannotVerify { reason } => {
2402 assert!(
2403 reason.contains(&format!("chain_version={}", CHAIN_VERSION_CURRENT + 1)),
2404 "name the version found, got: {reason}"
2405 );
2406 assert!(
2407 reason.contains(&CHAIN_VERSION_CURRENT.to_string()),
2408 "and the newest version understood, got: {reason}"
2409 );
2410 }
2411 other => panic!("a future encoding must not be reported as tampering: {other:?}"),
2412 }
2413 }
2414
2415 #[test]
2428 fn a_row_written_by_the_previous_release_still_verifies() {
2429 const GOLDEN_V2_CHAIN_HASH: &str = "ba12cbe49a149387898bc30f1e8d409effa025ab8e440e6e72bedf060afc831e45d9be264d6b18b7e0ef2f2b3584dbc6953b535546cecbbdc3d7ac666cae4d0a";
2430
2431 let key = fixed_key();
2432 let row = ChainRow {
2433 seq: 7,
2434 key_id: CURRENT_KEY_ID.to_string(),
2435 transaction_id: "tx-golden".to_string(),
2436 request_id: "req-golden".to_string(),
2437 request_hash: "hash-golden".to_string(),
2438 action_name: "AptInstall".to_string(),
2439 risk_level: RiskLevel::Medium,
2440 summary: "install ripgrep".to_string(),
2441 approval_id: Some("appr-golden".to_string()),
2442 warnings_json: "[]".to_string(),
2443 created_at: "2026-07-29T00:00:00Z".to_string(),
2444 prev_chain_hash: String::new(),
2445 chain_hash: GOLDEN_V2_CHAIN_HASH.to_string(),
2446 chain_version: 2,
2447 caller_role: Some("admin".to_string()),
2448 event_tip: Some("eventtip-golden".to_string()),
2449 caller_principal: None,
2450 };
2451
2452 assert_eq!(
2453 verify_chain(&key, std::slice::from_ref(&row)),
2454 VerifyOutcome::Intact { rows_checked: 1 },
2455 "a v2 row on disk must verify under this binary; if this fails, the v2 \
2456 encoding or its version dispatch changed and existing audit logs are unreadable"
2457 );
2458
2459 let mut tampered = row;
2463 let flipped = match GOLDEN_V2_CHAIN_HASH.strip_prefix('b') {
2464 Some(rest) => format!("a{rest}"),
2465 None => format!("b{}", &GOLDEN_V2_CHAIN_HASH[1..]),
2466 };
2467 tampered.chain_hash = flipped;
2468 assert!(
2469 matches!(
2470 verify_chain(&key, &[tampered]),
2471 VerifyOutcome::Broken { .. }
2472 ),
2473 "a golden row with one nibble changed must not verify"
2474 );
2475 }
2476
2477 #[test]
2481 fn a_legacy_v1_row_on_disk_still_verifies() {
2482 const GOLDEN_V1_CHAIN_HASH: &str = "ce8b47c15414e989e099303b826fcd9985360b794220cefbbf9689d25f31fe173283687feb6d1dd265ae5aca94c8bcfb87ce33ba8c954fafdad18a59cd97c702";
2483
2484 let row = ChainRow {
2485 chain_version: 1,
2486 caller_role: None,
2487 event_tip: None,
2488 caller_principal: None,
2489 chain_hash: GOLDEN_V1_CHAIN_HASH.to_string(),
2490 ..golden_row_shape()
2491 };
2492 assert_eq!(
2493 verify_chain(&fixed_key(), &[row]),
2494 VerifyOutcome::Intact { rows_checked: 1 }
2495 );
2496 }
2497
2498 #[test]
2502 fn a_v3_row_on_disk_still_verifies() {
2503 const GOLDEN_V3_CHAIN_HASH: &str = "3c0d05f6ccaf9e65ccb796d4ffe96eb31a2ea67e446217c5234af916dc959d0f2c0ee3d3e5b525aece791ee7d6507efb98dcdd79f0d74dde4273859aa9627d08";
2504
2505 let row = ChainRow {
2506 chain_version: 3,
2507 caller_role: Some("admin".to_string()),
2508 event_tip: Some("eventtip-golden".to_string()),
2509 caller_principal: Some("uid:1000".to_string()),
2510 chain_hash: GOLDEN_V3_CHAIN_HASH.to_string(),
2511 ..golden_row_shape()
2512 };
2513 assert_eq!(
2514 verify_chain(&fixed_key(), &[row]),
2515 VerifyOutcome::Intact { rows_checked: 1 }
2516 );
2517 }
2518
2519 #[test]
2526 fn the_escape_table_and_absent_approval_encoding_are_frozen() {
2527 const GOLDEN_V2_AWKWARD_CHAIN_HASH: &str = "e6face6fe2346fc0f6c5c1e5845255579c8f08738406b88b0a1f6dbdf8ff85090a4b80d95bd0927ef54b0a2c4810d52ebb4bbf76cb68c865a7748df26442aa05";
2528
2529 let key = fixed_key();
2530 let content = ChainContent {
2531 seq: 8,
2532 approval_id: None,
2533 summary: "back\\slash and \x1funit and \x1erecord and nul\0byte",
2534 identity: ChainIdentity::V2 {
2535 caller_role: "dev",
2536 event_tip: "",
2537 },
2538 ..golden_content_shape()
2539 };
2540 assert_eq!(
2541 key.chain_hash(&content, "ba12cbe4"),
2542 GOLDEN_V2_AWKWARD_CHAIN_HASH,
2543 "the canonical encoding of escapes, an absent approval id, or a \
2544 non-empty prev_chain_hash changed"
2545 );
2546 }
2547
2548 fn golden_content_shape() -> ChainContent<'static> {
2552 ChainContent {
2553 seq: 7,
2554 key_id: CURRENT_KEY_ID,
2555 transaction_id: "tx-golden",
2556 request_id: "req-golden",
2557 request_hash: "hash-golden",
2558 action_name: "AptInstall",
2559 risk_level: RiskLevel::Medium,
2560 summary: "install ripgrep",
2561 approval_id: Some("appr-golden"),
2562 warnings_json: "[]",
2563 created_at: "2026-07-29T00:00:00Z",
2564 identity: ChainIdentity::LegacyV1,
2565 }
2566 }
2567
2568 fn golden_row_shape() -> ChainRow {
2570 ChainRow {
2571 seq: 7,
2572 key_id: CURRENT_KEY_ID.to_string(),
2573 transaction_id: "tx-golden".to_string(),
2574 request_id: "req-golden".to_string(),
2575 request_hash: "hash-golden".to_string(),
2576 action_name: "AptInstall".to_string(),
2577 risk_level: RiskLevel::Medium,
2578 summary: "install ripgrep".to_string(),
2579 approval_id: Some("appr-golden".to_string()),
2580 warnings_json: "[]".to_string(),
2581 created_at: "2026-07-29T00:00:00Z".to_string(),
2582 prev_chain_hash: String::new(),
2583 chain_hash: String::new(),
2584 chain_version: 1,
2585 caller_role: None,
2586 event_tip: None,
2587 caller_principal: None,
2588 }
2589 }
2590
2591 #[test]
2592 fn a_chain_that_spans_the_migration_verifies_end_to_end() {
2593 let key = fixed_key();
2596 let mut rows = Vec::new();
2597 let mut prev = String::new();
2598 for (i, identity) in [
2599 ChainIdentity::LegacyV1,
2600 ChainIdentity::LegacyV1,
2601 ChainIdentity::V2 {
2602 caller_role: "Dev",
2603 event_tip: "",
2604 },
2605 ChainIdentity::V2 {
2606 caller_role: "Boot",
2607 event_tip: "",
2608 },
2609 ]
2610 .into_iter()
2611 .enumerate()
2612 {
2613 let txid = format!("tx{i}");
2614 let mut content = sample_content((i + 1) as u64, &txid);
2615 content.identity = identity;
2616 let hash = key.chain_hash(&content, &prev);
2617 rows.push(row_for(&content, &prev, hash.clone()));
2618 prev = hash;
2619 }
2620 assert_eq!(
2621 verify_chain(&key, &rows),
2622 VerifyOutcome::Intact { rows_checked: 4 }
2623 );
2624 }
2625
2626 #[test]
2627 fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
2628 let key = fixed_key();
2633 let mut rows = build_chain(&key, 2);
2634 rows[1].chain_version = CHAIN_VERSION_LEGACY;
2635 rows[1].caller_role = None;
2636 rows[1].event_tip = None;
2637 match verify_chain(&key, &rows) {
2638 VerifyOutcome::Broken {
2639 first_broken_seq, ..
2640 } => assert_eq!(first_broken_seq, 2),
2641 other => panic!("expected Broken, got {other:?}"),
2642 }
2643 }
2644
2645 #[test]
2646 fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
2647 let key = fixed_key();
2652 let mut rows = build_chain(&key, 1);
2653 rows[0].caller_role = None;
2654 let outcome = verify_chain(&key, &rows);
2655 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2656 assert_eq!(outcome_to_exit_code(&outcome), 1);
2657 }
2658
2659 #[test]
2660 fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
2661 let key = fixed_key();
2664 let mut rows = build_chain(&key, 1);
2665 rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
2666 let outcome = verify_chain(&key, &rows);
2667 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
2668 assert_eq!(outcome_to_exit_code(&outcome), 2);
2669 }
2670
2671 fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
2674 EventContent {
2675 seq,
2676 key_id: CURRENT_KEY_ID,
2677 kind,
2678 transaction_id: txid,
2679 receipt_digest: "digest-abc",
2680 created_at: "2026-04-24T12:00:00Z",
2681 }
2682 }
2683
2684 fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
2685 let mut rows = Vec::with_capacity(kinds.len());
2686 let mut prev = String::new();
2687 for (i, kind) in kinds.iter().enumerate() {
2688 let seq = (i + 1) as u64;
2689 let txid = format!("tx{i}");
2690 let content = event_content(seq, *kind, &txid);
2691 let hash = key.event_hash(&content, &prev);
2692 rows.push(EventRow {
2693 seq,
2694 key_id: content.key_id.to_string(),
2695 kind: content.kind.as_str().to_string(),
2696 transaction_id: content.transaction_id.to_string(),
2697 receipt_digest: content.receipt_digest.to_string(),
2698 created_at: content.created_at.to_string(),
2699 prev_chain_hash: prev.clone(),
2700 chain_hash: hash.clone(),
2701 });
2702 prev = hash;
2703 }
2704 rows
2705 }
2706
2707 #[test]
2708 fn event_kind_spellings_are_stable() {
2709 assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
2712 assert_eq!(
2713 AuditEventKind::ApprovalConsumed.as_str(),
2714 "approval_consumed"
2715 );
2716 assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
2717 }
2718
2719 #[test]
2720 fn intact_event_chain_verifies() {
2721 let key = fixed_key();
2722 let rows = build_event_chain(
2723 &key,
2724 &[
2725 AuditEventKind::ApprovalGranted,
2726 AuditEventKind::ApprovalConsumed,
2727 ],
2728 );
2729 assert_eq!(
2730 verify_event_chain(&key, &rows),
2731 VerifyOutcome::Intact { rows_checked: 2 }
2732 );
2733 }
2734
2735 #[test]
2736 fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
2737 let key = fixed_key();
2740 let mut rows = build_event_chain(
2741 &key,
2742 &[
2743 AuditEventKind::ApprovalGranted,
2744 AuditEventKind::ApprovalConsumed,
2745 AuditEventKind::ApprovalRevoked,
2746 ],
2747 );
2748 rows.remove(1);
2749 match verify_event_chain(&key, &rows) {
2750 VerifyOutcome::Broken {
2751 first_broken_seq, ..
2752 } => assert_eq!(first_broken_seq, 3),
2753 other => panic!("expected Broken, got {other:?}"),
2754 }
2755 }
2756
2757 #[test]
2758 fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
2759 let key = fixed_key();
2760 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
2761 rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
2762 assert!(matches!(
2763 verify_event_chain(&key, &rows),
2764 VerifyOutcome::Broken { .. }
2765 ));
2766 }
2767
2768 #[test]
2769 fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
2770 let key = fixed_key();
2771 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
2772 rows[0].kind = "approval_unicorned".to_string();
2773 assert!(matches!(
2774 verify_event_chain(&key, &rows),
2775 VerifyOutcome::Broken { .. }
2776 ));
2777 }
2778
2779 #[test]
2780 fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
2781 let key = fixed_key();
2785 let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
2786 let event_sig = key.event_hash(&content, "");
2787 let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
2788 assert_ne!(event_sig, row_sig);
2789
2790 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
2791 rows[0].chain_hash = row_sig;
2792 assert!(matches!(
2793 verify_event_chain(&key, &rows),
2794 VerifyOutcome::Broken { .. }
2795 ));
2796 }
2797
2798 #[test]
2801 fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
2802 let key = fixed_key();
2807 let events = build_event_chain(
2808 &key,
2809 &[
2810 AuditEventKind::ApprovalGranted,
2811 AuditEventKind::ApprovalConsumed,
2812 ],
2813 );
2814 let mut content = sample_content(1, "tx-after");
2815 content.identity = ChainIdentity::V2 {
2816 caller_role: "Dev",
2817 event_tip: &events[1].chain_hash,
2818 };
2819 let hash = key.chain_hash(&content, "");
2820 let tx_rows = vec![row_for(&content, "", hash)];
2821
2822 assert_eq!(
2823 verify_event_binding(&tx_rows, &events),
2824 BindingOutcome::Consistent {
2825 bindings_checked: 1
2826 }
2827 );
2828
2829 let truncated = &events[..1];
2830 assert_eq!(
2831 verify_event_chain(&key, truncated),
2832 VerifyOutcome::Intact { rows_checked: 1 },
2833 "a truncated event chain still walks clean; the binding is the detector"
2834 );
2835 match verify_event_binding(&tx_rows, truncated) {
2836 BindingOutcome::MissingEvent {
2837 transaction_seq, ..
2838 } => assert_eq!(transaction_seq, 1),
2839 other => panic!("expected MissingEvent, got {other:?}"),
2840 }
2841 }
2842
2843 #[test]
2844 fn legacy_rows_carry_no_binding_to_check() {
2845 let key = fixed_key();
2848 let mut content = sample_content(1, "tx-legacy");
2849 content.identity = ChainIdentity::LegacyV1;
2850 let hash = key.chain_hash(&content, "");
2851 let rows = vec![row_for(&content, "", hash)];
2852 assert_eq!(
2853 verify_event_binding(&rows, &[]),
2854 BindingOutcome::Consistent {
2855 bindings_checked: 0
2856 }
2857 );
2858 }
2859
2860 #[test]
2861 fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
2862 let verification = AuditVerification {
2867 chain: VerifyOutcome::Broken {
2868 rows_checked: 1,
2869 first_broken_seq: 2,
2870 first_broken_transaction_id: "tx".to_string(),
2871 expected: "e".to_string(),
2872 actual: "a".to_string(),
2873 },
2874 events: VerifyOutcome::CannotVerify {
2875 reason: "no key".to_string(),
2876 },
2877 binding: BindingOutcome::Consistent {
2878 bindings_checked: 0,
2879 },
2880 attribution: None,
2881 };
2882 assert_eq!(verification.exit_code(), 1);
2883 }
2884
2885 #[test]
2886 fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
2887 let verification = AuditVerification {
2888 chain: VerifyOutcome::Intact { rows_checked: 3 },
2889 events: VerifyOutcome::Intact { rows_checked: 1 },
2890 binding: BindingOutcome::MissingEvent {
2891 transaction_seq: 3,
2892 event_tip: "abc".to_string(),
2893 },
2894 attribution: None,
2895 };
2896 assert_eq!(verification.exit_code(), 1);
2897 }
2898
2899 #[test]
2900 fn all_three_clean_is_exit_zero() {
2901 let verification = AuditVerification {
2902 chain: VerifyOutcome::Intact { rows_checked: 3 },
2903 events: VerifyOutcome::Intact { rows_checked: 2 },
2904 binding: BindingOutcome::Consistent {
2905 bindings_checked: 1,
2906 },
2907 attribution: None,
2908 };
2909 assert_eq!(verification.exit_code(), 0);
2910 }
2911
2912 #[test]
2913 fn binding_exit_codes_split_clean_from_tampered() {
2914 assert_eq!(
2915 binding_outcome_to_exit_code(&BindingOutcome::Consistent {
2916 bindings_checked: 0
2917 }),
2918 0
2919 );
2920 assert_eq!(
2921 binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
2922 transaction_seq: 7,
2923 event_tip: "abc".to_string()
2924 }),
2925 1
2926 );
2927 }
2928
2929 #[test]
2930 fn intact_chain_verifies() {
2931 let key = fixed_key();
2932 let rows = build_chain(&key, 5);
2933 let outcome = verify_chain(&key, &rows);
2934 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
2935 assert_eq!(outcome_to_exit_code(&outcome), 0);
2936 }
2937
2938 #[test]
2939 fn empty_chain_verifies() {
2940 let key = fixed_key();
2941 let outcome = verify_chain(&key, &[]);
2942 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
2943 }
2944
2945 #[test]
2946 fn tampered_summary_breaks_chain_at_first_offending_row() {
2947 let key = fixed_key();
2948 let mut rows = build_chain(&key, 3);
2949 rows[1].summary = "TAMPERED".to_string();
2951 let outcome = verify_chain(&key, &rows);
2952 match outcome {
2953 VerifyOutcome::Broken {
2954 first_broken_seq, ..
2955 } => assert_eq!(first_broken_seq, 2),
2956 other => panic!("expected Broken, got {other:?}"),
2957 }
2958 }
2959
2960 #[test]
2961 fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
2962 let key = fixed_key();
2963 let mut rows = build_chain(&key, 4);
2964 rows.remove(1);
2966 let outcome = verify_chain(&key, &rows);
2967 match outcome {
2968 VerifyOutcome::Broken {
2969 first_broken_seq, ..
2970 } => assert_eq!(
2971 first_broken_seq, 3,
2972 "first broken row is the one whose prev_hash refers to deleted row"
2973 ),
2974 other => panic!("expected Broken, got {other:?}"),
2975 }
2976 }
2977
2978 #[test]
2979 fn inserted_forged_row_breaks_chain() {
2980 let key = fixed_key();
2987 let mut rows = build_chain(&key, 3);
2988
2989 let forged = ChainRow {
2991 caller_principal: Some("uid:1000".to_string()),
2995 seq: 2,
2996 key_id: CURRENT_KEY_ID.to_string(),
2997 transaction_id: "tx-forged".to_string(),
2998 request_id: "req-forged".to_string(),
2999 request_hash: "hash-forged".to_string(),
3000 action_name: "InstallFlatpak".to_string(),
3001 risk_level: RiskLevel::Medium,
3002 summary: "Forged row".to_string(),
3003 approval_id: None,
3004 warnings_json: "[]".to_string(),
3005 created_at: "2026-04-25T13:00:00Z".to_string(),
3006 prev_chain_hash: rows[0].chain_hash.clone(),
3008 chain_hash: "0".repeat(HASH_HEX_LEN),
3010 chain_version: CHAIN_VERSION_CURRENT,
3011 caller_role: Some("Dev".to_string()),
3012 event_tip: Some(String::new()),
3013 };
3014
3015 let mut rest: Vec<ChainRow> = rows.split_off(1);
3017 for r in rest.iter_mut() {
3018 r.seq += 1;
3019 }
3020 rows.push(forged);
3021 rows.extend(rest);
3022
3023 let outcome = verify_chain(&key, &rows);
3027 match outcome {
3028 VerifyOutcome::Broken {
3029 first_broken_seq, ..
3030 } => assert_eq!(
3031 first_broken_seq, 2,
3032 "the verifier must flag the forged row itself, not a later one"
3033 ),
3034 other => panic!("expected Broken, got {other:?}"),
3035 }
3036 }
3037
3038 #[test]
3041 fn signature_verifies_under_exported_public_key() {
3042 let key = fixed_key();
3044 let rows = build_chain(&key, 4);
3045 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3046 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
3047 }
3048
3049 #[test]
3050 fn foreign_public_key_cannot_validate_chain() {
3051 let signer = AuditKey::from_bytes(vec![0x11; 32]);
3055 let rows = build_chain(&signer, 3);
3056 let other = AuditKey::from_bytes(vec![0x22; 32]);
3057 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
3058 assert!(matches!(
3059 outcome,
3060 VerifyOutcome::Broken {
3061 first_broken_seq: 1,
3062 ..
3063 }
3064 ));
3065 }
3066
3067 #[test]
3068 fn verifying_key_hex_is_a_32_byte_public_key() {
3069 let key = fixed_key();
3070 let vk_hex = key.verifying_key_hex();
3071 assert_eq!(
3072 vk_hex.len(),
3073 64,
3074 "32-byte ed25519 public key = 64 hex chars"
3075 );
3076 assert!(hex::decode(&vk_hex).is_ok());
3077 }
3078
3079 #[test]
3080 fn malformed_signature_hex_is_broken_not_panic() {
3081 let key = fixed_key();
3082 let mut rows = build_chain(&key, 2);
3083 rows[0].chain_hash = "not-valid-hex!!".to_string();
3084 let outcome = verify_chain(&key, &rows);
3085 assert!(matches!(
3086 outcome,
3087 VerifyOutcome::Broken {
3088 first_broken_seq: 1,
3089 ..
3090 }
3091 ));
3092 }
3093
3094 #[test]
3095 fn bad_public_key_hex_yields_cannot_verify() {
3096 let key = fixed_key();
3097 let rows = build_chain(&key, 1);
3098 let outcome = verify_chain_with_pubkey("zz", &rows);
3099 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3100 }
3101
3102 #[test]
3103 fn load_or_generate_writes_public_key_sidecar() {
3104 let dir = tempfile::tempdir().unwrap();
3105 let path = dir.path().join("audit-key");
3106 let key = AuditKey::load_or_generate(&path).unwrap();
3107 let pub_path = dir.path().join("audit-key.pub");
3108 assert!(pub_path.exists(), "public key sidecar must be written");
3109 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
3110 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
3111 }
3112
3113 #[test]
3114 fn wrong_key_id_yields_cannot_verify() {
3115 let key = fixed_key();
3116 let mut rows = build_chain(&key, 2);
3117 rows[0].key_id = "v99".to_string();
3118 let outcome = verify_chain(&key, &rows);
3119 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3120 assert_eq!(outcome_to_exit_code(&outcome), 2);
3121 }
3122
3123 #[test]
3124 fn exit_code_for_each_outcome() {
3125 assert_eq!(
3126 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
3127 0
3128 );
3129 assert_eq!(
3130 outcome_to_exit_code(&VerifyOutcome::Broken {
3131 rows_checked: 0,
3132 first_broken_seq: 1,
3133 first_broken_transaction_id: String::new(),
3134 expected: String::new(),
3135 actual: String::new(),
3136 }),
3137 1
3138 );
3139 assert_eq!(
3140 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
3141 reason: String::new()
3142 }),
3143 2
3144 );
3145 }
3146
3147 #[test]
3150 fn load_or_generate_creates_with_0600_mode() {
3151 use std::os::unix::fs::PermissionsExt;
3152 let dir = tempfile::tempdir().unwrap();
3153 let path = dir.path().join("audit-key");
3154 let _key = AuditKey::load_or_generate(&path).unwrap();
3155 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
3156 assert_eq!(mode, 0o600);
3157 }
3158
3159 #[test]
3160 fn load_or_generate_is_idempotent() {
3161 let dir = tempfile::tempdir().unwrap();
3162 let path = dir.path().join("audit-key");
3163 let key1_bytes = {
3164 let _ = AuditKey::load_or_generate(&path).unwrap();
3165 std::fs::read(&path).unwrap()
3166 };
3167 let _ = AuditKey::load_or_generate(&path).unwrap();
3168 let key2_bytes = std::fs::read(&path).unwrap();
3169 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
3170 }
3171
3172 #[test]
3173 fn rejects_world_readable_key_file() {
3174 use std::os::unix::fs::PermissionsExt;
3175 let dir = tempfile::tempdir().unwrap();
3176 let path = dir.path().join("audit-key");
3177 std::fs::write(&path, vec![0u8; 32]).unwrap();
3178 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3179 let result = AuditKey::load_or_generate(&path);
3180 assert!(matches!(
3181 result,
3182 Err(AuditKeyError::UnsafePermissions { .. })
3183 ));
3184 }
3185
3186 #[test]
3187 fn rejects_short_key_file() {
3188 use std::os::unix::fs::PermissionsExt;
3189 let dir = tempfile::tempdir().unwrap();
3190 let path = dir.path().join("audit-key");
3191 std::fs::write(&path, vec![0u8; 8]).unwrap();
3192 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
3193 let result = AuditKey::load_or_generate(&path);
3194 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
3195 }
3196
3197 #[test]
3203 fn debug_redacts_key_bytes_and_their_hex_encoding() {
3204 let raw = (0u8..32).collect::<Vec<u8>>();
3206 let key = AuditKey::from_bytes(raw.clone());
3207 let dbg = format!("{key:?}");
3208
3209 assert!(
3210 dbg.contains("redacted"),
3211 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
3212 );
3213
3214 let hex_lower = hex::encode(&raw);
3216 let hex_upper = hex::encode_upper(&raw);
3217 assert!(
3218 !dbg.contains(&hex_lower),
3219 "Debug output leaks lowercase hex of key bytes: {dbg}"
3220 );
3221 assert!(
3222 !dbg.contains(&hex_upper),
3223 "Debug output leaks uppercase hex of key bytes: {dbg}"
3224 );
3225
3226 for b in &raw {
3230 let pair = format!("{b:02x}");
3231 if *b >= 0x10 && pair != "1e" && pair != "1f" {
3236 assert!(
3237 !dbg.contains(&pair),
3238 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
3239 );
3240 }
3241 }
3242 }
3243
3244 #[test]
3247 fn debug_preserves_key_id() {
3248 let key = AuditKey::from_bytes(vec![0xff; 32]);
3249 let dbg = format!("{key:?}");
3250 assert!(
3251 dbg.contains("key_id"),
3252 "Debug output must label the key_id field: {dbg}"
3253 );
3254 assert!(
3255 dbg.contains(CURRENT_KEY_ID),
3256 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
3257 );
3258 }
3259
3260 #[test]
3263 fn checkpoint_consistent_with_intact_chain() {
3264 let key = fixed_key();
3265 let rows = build_chain(&key, 5);
3266 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3267 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3268 assert_eq!(
3269 outcome,
3270 CheckpointOutcome::Consistent {
3271 checkpoints_checked: 1
3272 }
3273 );
3274 }
3275
3276 #[test]
3277 fn checkpoint_detects_truncation() {
3278 let key = fixed_key();
3279 let full = build_chain(&key, 5);
3280 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
3282 let truncated = &full[..3];
3283 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
3284 assert!(matches!(
3285 outcome,
3286 CheckpointOutcome::Truncated {
3287 checkpoint_seq: 5,
3288 current_max_seq: 3
3289 }
3290 ));
3291 }
3292
3293 #[test]
3294 fn checkpoint_detects_rewrite() {
3295 let key = fixed_key();
3296 let mut rows = build_chain(&key, 4);
3297 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3298 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
3300 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3301 assert!(matches!(
3302 outcome,
3303 CheckpointOutcome::TipMismatch { seq: 3, .. }
3304 ));
3305 }
3306
3307 #[test]
3308 fn checkpoint_bad_signature_detected() {
3309 let key = fixed_key();
3310 let rows = build_chain(&key, 3);
3311 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
3312 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3314 assert!(matches!(
3315 outcome,
3316 CheckpointOutcome::BadSignature { seq: 2 }
3317 ));
3318 }
3319
3320 #[test]
3321 fn checkpoint_foreign_key_rejected() {
3322 let signer = AuditKey::from_bytes(vec![0x11; 32]);
3324 let rows = build_chain(&signer, 3);
3325 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
3326 let other = AuditKey::from_bytes(vec![0x22; 32]);
3327 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
3328 assert!(matches!(
3329 outcome,
3330 CheckpointOutcome::BadSignature { seq: 2 }
3331 ));
3332 }
3333
3334 #[test]
3335 fn pubkey_verify_detects_tampered_middle_row() {
3336 let key = fixed_key();
3339 let mut rows = build_chain(&key, 4);
3340 rows[2].summary = "TAMPERED".to_string();
3341 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3342 assert!(matches!(
3343 outcome,
3344 VerifyOutcome::Broken {
3345 first_broken_seq: 3,
3346 ..
3347 }
3348 ));
3349 }
3350
3351 #[test]
3352 fn multiple_checkpoints_all_consistent() {
3353 let key = fixed_key();
3354 let rows = build_chain(&key, 5);
3355 let cps = vec![
3356 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
3357 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
3358 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
3359 ];
3360 assert_eq!(
3361 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
3362 CheckpointOutcome::Consistent {
3363 checkpoints_checked: 3
3364 }
3365 );
3366 }
3367
3368 #[test]
3369 fn middle_checkpoint_failure_is_reported() {
3370 let key = fixed_key();
3372 let mut rows = build_chain(&key, 5);
3373 let cps = vec![
3374 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
3375 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
3376 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
3377 ];
3378 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
3380 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
3381 CheckpointOutcome::TipMismatch { seq: 3, .. }
3382 ));
3383 }
3384
3385 #[test]
3386 fn checkpoint_created_at_is_signed() {
3387 let key = fixed_key();
3389 let rows = build_chain(&key, 3);
3390 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3391 cp.created_at = "2020-01-01T00:00:00Z".to_string();
3392 assert!(matches!(
3393 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
3394 CheckpointOutcome::BadSignature { .. }
3395 ));
3396 }
3397
3398 #[test]
3399 fn checkpoint_seq_is_signed() {
3400 let key = fixed_key();
3402 let rows = build_chain(&key, 3);
3403 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3404 cp.seq = 2;
3405 assert!(matches!(
3406 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
3407 CheckpointOutcome::BadSignature { seq: 2 }
3408 ));
3409 }
3410
3411 #[test]
3412 fn checkpoint_detects_full_wipe() {
3413 let key = fixed_key();
3415 let full = build_chain(&key, 3);
3416 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
3417 assert!(matches!(
3418 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
3419 CheckpointOutcome::Truncated {
3420 checkpoint_seq: 3,
3421 current_max_seq: 0
3422 }
3423 ));
3424 }
3425
3426 #[test]
3427 fn domain_tags_are_distinct_and_prefix_free() {
3428 let tags: [(&str, &[u8]); 4] = [
3435 ("row", ROW_DOMAIN),
3436 ("checkpoint", CHECKPOINT_DOMAIN),
3437 ("approval", APPROVAL_DOMAIN),
3438 ("event", EVENT_DOMAIN),
3439 ];
3440 for (i, (name_a, a)) in tags.iter().enumerate() {
3441 for (name_b, b) in tags.iter().skip(i + 1) {
3442 assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
3443 assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
3444 assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
3445 }
3446 }
3447 }
3448
3449 #[test]
3450 fn approval_receipt_framing_is_injective() {
3451 let key = fixed_key();
3456 let sep = "\u{1f}";
3457 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
3458 let b = key.approval_receipt("tx", &format!("b{sep}c"));
3459 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
3460
3461 assert_eq!(
3463 key.approval_receipt("tx-1", "hash-1"),
3464 key.approval_receipt("tx-1", "hash-1"),
3465 );
3466 assert_eq!(
3468 key.approval_commitment("tx-1", "hash-1"),
3469 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
3470 );
3471 }
3472}