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, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
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, Clone, PartialEq, Eq, thiserror::Error)]
691pub enum ChainExportError {
692 #[error("--since: {0:?} is not a valid RFC 3339 timestamp")]
693 InvalidSince(String),
694 #[error("audit row {seq} has an invalid RFC 3339 created_at value {created_at:?}")]
695 InvalidCreatedAt { seq: u64, created_at: String },
696}
697
698pub fn validate_export_since(since: &str) -> Result<(), ChainExportError> {
700 chrono::DateTime::parse_from_rfc3339(since)
701 .map(|_| ())
702 .map_err(|_| ChainExportError::InvalidSince(since.to_string()))
703}
704
705pub fn select_chain_rows_for_export(
712 rows: Vec<ChainRow>,
713 since: Option<&str>,
714 limit: Option<u32>,
715) -> Result<Vec<ChainRow>, ChainExportError> {
716 let since = since
717 .map(|value| {
718 chrono::DateTime::parse_from_rfc3339(value)
719 .map_err(|_| ChainExportError::InvalidSince(value.to_string()))
720 })
721 .transpose()?;
722 let limit = limit.map_or(usize::MAX, |value| value as usize);
723
724 rows.into_iter()
725 .filter_map(|row| {
726 let keep = match since.as_ref() {
727 None => true,
728 Some(since) => match chrono::DateTime::parse_from_rfc3339(&row.created_at) {
729 Ok(created_at) => created_at >= *since,
730 Err(_) => {
731 return Some(Err(ChainExportError::InvalidCreatedAt {
732 seq: row.seq,
733 created_at: row.created_at,
734 }));
735 }
736 },
737 };
738 keep.then_some(Ok(row))
739 })
740 .take(limit)
741 .collect()
742}
743
744#[derive(Debug)]
746pub(crate) enum RowIdentityError {
747 UnknownVersion(u32),
751 MissingField(&'static str),
757}
758
759impl ChainRow {
760 pub(crate) fn identity(&self) -> Result<ChainIdentity<'_>, RowIdentityError> {
763 match self.chain_version {
764 CHAIN_VERSION_LEGACY => Ok(ChainIdentity::LegacyV1),
765 CHAIN_VERSION_V2 => Ok(ChainIdentity::V2 {
766 caller_role: self
767 .caller_role
768 .as_deref()
769 .ok_or(RowIdentityError::MissingField("caller_role"))?,
770 event_tip: self
771 .event_tip
772 .as_deref()
773 .ok_or(RowIdentityError::MissingField("event_tip"))?,
774 }),
775 CHAIN_VERSION_V3 => Ok(ChainIdentity::V3 {
776 caller_role: self
777 .caller_role
778 .as_deref()
779 .ok_or(RowIdentityError::MissingField("caller_role"))?,
780 event_tip: self
781 .event_tip
782 .as_deref()
783 .ok_or(RowIdentityError::MissingField("event_tip"))?,
784 caller_principal: self
788 .caller_principal
789 .as_deref()
790 .filter(|p| !p.is_empty())
791 .ok_or(RowIdentityError::MissingField("caller_principal"))?,
792 }),
793 other => Err(RowIdentityError::UnknownVersion(other)),
794 }
795 }
796}
797
798pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
802 verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
803}
804
805pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
810 match parse_verifying_key(verifying_key_hex) {
811 Some(vk) => verify_rows(&vk, None, rows),
812 None => VerifyOutcome::CannotVerify {
813 reason: format!(
814 "invalid public key hex ({} chars); expected 64 hex chars of a \
815 32-byte Ed25519 public key",
816 verifying_key_hex.len()
817 ),
818 },
819 }
820}
821
822fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
823 let bytes = hex::decode(hex_str.trim()).ok()?;
824 let arr: [u8; 32] = bytes.try_into().ok()?;
825 VerifyingKey::from_bytes(&arr).ok()
826}
827
828fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
831 let Ok(bytes) = hex::decode(sig_hex) else {
832 return false;
833 };
834 let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
835 return false;
836 };
837 vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
838}
839
840fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
844 let mut last_hash = String::new();
845 let mut rows_checked = 0u64;
846 for row in rows {
847 if let Some(kid) = expect_key_id {
848 if row.key_id != kid {
849 return VerifyOutcome::CannotVerify {
850 reason: format!(
851 "row seq={} uses key_id={:?} but only {:?} is loaded; \
852 epoch keys not yet supported",
853 row.seq, row.key_id, kid
854 ),
855 };
856 }
857 }
858 if row.prev_chain_hash != last_hash {
859 return VerifyOutcome::Broken {
860 rows_checked,
861 first_broken_seq: row.seq,
862 first_broken_transaction_id: row.transaction_id.clone(),
863 expected: format!("prev_chain_hash={last_hash}"),
864 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
865 };
866 }
867 let identity = match row.identity() {
868 Ok(identity) => identity,
869 Err(RowIdentityError::UnknownVersion(v)) => {
870 return VerifyOutcome::CannotVerify {
871 reason: format!(
872 "row seq={} declares chain_version={v}, which this binary cannot \
873 reproduce (it understands {CHAIN_VERSION_LEGACY}..={CHAIN_VERSION_CURRENT}); \
874 verify with a build at least as new as the one that wrote the chain",
875 row.seq
876 ),
877 };
878 }
879 Err(RowIdentityError::MissingField(field)) => {
880 let stored = match field {
885 "caller_role" => row.caller_role.as_deref(),
886 "event_tip" => row.event_tip.as_deref(),
887 "caller_principal" => row.caller_principal.as_deref(),
888 _ => None,
889 };
890 return VerifyOutcome::Broken {
891 rows_checked,
892 first_broken_seq: row.seq,
893 first_broken_transaction_id: row.transaction_id.clone(),
894 expected: format!(
895 "chain_version={} row carrying a non-empty {field}",
896 row.chain_version
897 ),
898 actual: match stored {
901 None => format!("{field}=NULL"),
902 Some("") => format!("{field}='' (empty, not NULL)"),
903 Some(other) => format!("{field}={other:?}"),
904 },
905 };
906 }
907 };
908 let content = ChainContent {
909 seq: row.seq,
910 key_id: &row.key_id,
911 transaction_id: &row.transaction_id,
912 request_id: &row.request_id,
913 request_hash: &row.request_hash,
914 action_name: &row.action_name,
915 risk_level: row.risk_level,
916 summary: &row.summary,
917 approval_id: row.approval_id.as_deref(),
918 warnings_json: &row.warnings_json,
919 created_at: &row.created_at,
920 identity,
921 };
922 let msg = chain_message(&content, &row.prev_chain_hash);
923 if !signature_ok(vk, &msg, &row.chain_hash) {
924 return VerifyOutcome::Broken {
925 rows_checked,
926 first_broken_seq: row.seq,
927 first_broken_transaction_id: row.transaction_id.clone(),
928 expected: "valid ed25519 signature".to_string(),
929 actual: row.chain_hash.clone(),
930 };
931 }
932 last_hash = row.chain_hash.clone();
933 rows_checked += 1;
934 }
935 VerifyOutcome::Intact { rows_checked }
936}
937
938#[derive(Debug, Clone, Copy, PartialEq, Eq)]
951pub enum AuditEventKind {
952 ApprovalGranted,
954 ApprovalConsumed,
956 ApprovalRevoked,
958}
959
960impl AuditEventKind {
961 pub fn as_str(&self) -> &'static str {
964 match self {
965 Self::ApprovalGranted => "approval_granted",
966 Self::ApprovalConsumed => "approval_consumed",
967 Self::ApprovalRevoked => "approval_revoked",
968 }
969 }
970}
971
972#[derive(Debug, Clone, PartialEq, Eq)]
974pub struct EventContent<'a> {
975 pub seq: u64,
976 pub key_id: &'a str,
977 pub kind: AuditEventKind,
978 pub transaction_id: &'a str,
979 pub receipt_digest: &'a str,
983 pub created_at: &'a str,
984}
985
986impl EventContent<'_> {
987 pub fn canonical_bytes(&self) -> Vec<u8> {
990 let mut buf = Vec::with_capacity(256);
991 push_field(&mut buf, "seq", &self.seq.to_string());
992 push_field(&mut buf, "key_id", self.key_id);
993 push_field(&mut buf, "kind", self.kind.as_str());
994 push_field(&mut buf, "transaction_id", self.transaction_id);
995 push_field(&mut buf, "receipt_digest", self.receipt_digest);
996 push_field(&mut buf, "created_at", self.created_at);
997 buf
998 }
999}
1000
1001fn event_message(content: &EventContent, prev_chain_hash: &str) -> Vec<u8> {
1002 let mut msg = EVENT_DOMAIN.to_vec();
1003 msg.extend_from_slice(&content.canonical_bytes());
1004 msg.extend_from_slice(prev_chain_hash.as_bytes());
1005 msg
1006}
1007
1008impl AuditKey {
1009 pub fn event_hash(&self, content: &EventContent, prev_chain_hash: &str) -> String {
1013 hex::encode(
1014 self.signing
1015 .sign(&event_message(content, prev_chain_hash))
1016 .to_bytes(),
1017 )
1018 }
1019}
1020
1021#[derive(Debug, Clone, PartialEq, Eq)]
1023pub struct EventRow {
1024 pub seq: u64,
1025 pub key_id: String,
1026 pub kind: String,
1030 pub transaction_id: String,
1031 pub receipt_digest: String,
1032 pub created_at: String,
1033 pub prev_chain_hash: String,
1034 pub chain_hash: String,
1035}
1036
1037pub fn verify_event_chain(key: &AuditKey, rows: &[EventRow]) -> VerifyOutcome {
1039 verify_event_rows(&key.verifying_key(), Some(key.key_id()), rows)
1040}
1041
1042pub fn verify_event_chain_with_pubkey(verifying_key_hex: &str, rows: &[EventRow]) -> VerifyOutcome {
1044 match parse_verifying_key(verifying_key_hex) {
1045 Some(vk) => verify_event_rows(&vk, None, rows),
1046 None => VerifyOutcome::CannotVerify {
1047 reason: format!(
1048 "invalid public key hex ({} chars); expected 64 hex chars of a \
1049 32-byte Ed25519 public key",
1050 verifying_key_hex.len()
1051 ),
1052 },
1053 }
1054}
1055
1056fn verify_event_rows(
1057 vk: &VerifyingKey,
1058 expect_key_id: Option<&str>,
1059 rows: &[EventRow],
1060) -> VerifyOutcome {
1061 let mut last_hash = String::new();
1062 let mut rows_checked = 0u64;
1063 for row in rows {
1064 if let Some(kid) = expect_key_id {
1065 if row.key_id != kid {
1066 return VerifyOutcome::CannotVerify {
1067 reason: format!(
1068 "event seq={} uses key_id={:?} but only {:?} is loaded; \
1069 epoch keys not yet supported",
1070 row.seq, row.key_id, kid
1071 ),
1072 };
1073 }
1074 }
1075 if row.prev_chain_hash != last_hash {
1076 return VerifyOutcome::Broken {
1077 rows_checked,
1078 first_broken_seq: row.seq,
1079 first_broken_transaction_id: row.transaction_id.clone(),
1080 expected: format!("prev_chain_hash={last_hash}"),
1081 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
1082 };
1083 }
1084 let Some(kind) = parse_event_kind(&row.kind) else {
1087 return VerifyOutcome::Broken {
1088 rows_checked,
1089 first_broken_seq: row.seq,
1090 first_broken_transaction_id: row.transaction_id.clone(),
1091 expected: "a known event kind".to_string(),
1092 actual: format!("kind={:?}", row.kind),
1093 };
1094 };
1095 let content = EventContent {
1096 seq: row.seq,
1097 key_id: &row.key_id,
1098 kind,
1099 transaction_id: &row.transaction_id,
1100 receipt_digest: &row.receipt_digest,
1101 created_at: &row.created_at,
1102 };
1103 if !signature_ok(
1104 vk,
1105 &event_message(&content, &row.prev_chain_hash),
1106 &row.chain_hash,
1107 ) {
1108 return VerifyOutcome::Broken {
1109 rows_checked,
1110 first_broken_seq: row.seq,
1111 first_broken_transaction_id: row.transaction_id.clone(),
1112 expected: "valid ed25519 signature".to_string(),
1113 actual: row.chain_hash.clone(),
1114 };
1115 }
1116 last_hash = row.chain_hash.clone();
1117 rows_checked += 1;
1118 }
1119 VerifyOutcome::Intact { rows_checked }
1120}
1121
1122fn parse_event_kind(raw: &str) -> Option<AuditEventKind> {
1123 [
1126 AuditEventKind::ApprovalGranted,
1127 AuditEventKind::ApprovalConsumed,
1128 AuditEventKind::ApprovalRevoked,
1129 ]
1130 .into_iter()
1131 .find(|kind| kind.as_str() == raw)
1132}
1133
1134#[derive(Debug, Clone, PartialEq, Eq)]
1137pub enum BindingOutcome {
1138 Consistent { bindings_checked: u64 },
1140 NotChecked,
1143 MissingEvent {
1147 transaction_seq: u64,
1148 event_tip: String,
1149 },
1150}
1151
1152pub fn verify_event_binding(tx_rows: &[ChainRow], event_rows: &[EventRow]) -> BindingOutcome {
1162 let known: std::collections::HashSet<&str> = event_rows
1163 .iter()
1164 .map(|row| row.chain_hash.as_str())
1165 .collect();
1166 let mut bindings_checked = 0u64;
1167 for row in tx_rows {
1168 let Some(tip) = row.event_tip.as_deref() else {
1169 continue;
1170 };
1171 if tip.is_empty() {
1172 continue;
1173 }
1174 if !known.contains(tip) {
1175 return BindingOutcome::MissingEvent {
1176 transaction_seq: row.seq,
1177 event_tip: tip.to_string(),
1178 };
1179 }
1180 bindings_checked += 1;
1181 }
1182 BindingOutcome::Consistent { bindings_checked }
1183}
1184
1185#[derive(Debug, Clone, PartialEq, Eq)]
1192pub struct AuditVerification {
1193 pub chain: VerifyOutcome,
1194 pub events: VerifyOutcome,
1195 pub binding: BindingOutcome,
1196 pub attribution: Option<AttributionCensus>,
1212}
1213
1214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1219enum RowStanding {
1220 NamesAccount,
1222 AttributionFailed,
1225 NotRecorded,
1229 Unattested,
1239}
1240
1241fn standing(row: &ChainRow) -> RowStanding {
1253 let stored = row.caller_principal.as_deref().filter(|p| !p.is_empty());
1261 match row.chain_version {
1262 CHAIN_VERSION_LEGACY | CHAIN_VERSION_V2 => match stored {
1263 None => RowStanding::NotRecorded,
1264 Some(_) => RowStanding::Unattested,
1265 },
1266 CHAIN_VERSION_V3 => match stored {
1267 None => RowStanding::NotRecorded,
1268 Some(p) => match crate::auth::CallerPrincipal::classify(p) {
1269 crate::auth::PrincipalClaim::Account => RowStanding::NamesAccount,
1270 crate::auth::PrincipalClaim::AttributionFailed => RowStanding::AttributionFailed,
1271 crate::auth::PrincipalClaim::Unrecognized => RowStanding::Unattested,
1272 },
1273 },
1274 _ => RowStanding::Unattested,
1280 }
1281}
1282
1283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1315pub struct AttributionCensus {
1316 named: u64,
1317 attribution_failed: u64,
1318 not_recorded: u64,
1319 unattested: u64,
1320 rows: u64,
1321}
1322
1323impl AttributionCensus {
1324 pub fn of(tx_rows: &[ChainRow]) -> Self {
1326 let mut census = Self {
1327 named: 0,
1328 attribution_failed: 0,
1329 not_recorded: 0,
1330 unattested: 0,
1331 rows: tx_rows.len() as u64,
1332 };
1333 for row in tx_rows {
1334 match standing(row) {
1335 RowStanding::NamesAccount => census.named += 1,
1336 RowStanding::AttributionFailed => census.attribution_failed += 1,
1337 RowStanding::NotRecorded => census.not_recorded += 1,
1338 RowStanding::Unattested => census.unattested += 1,
1339 }
1340 }
1341 census
1342 }
1343
1344 pub fn named(&self) -> u64 {
1350 self.named
1351 }
1352
1353 pub fn attribution_failed(&self) -> u64 {
1356 self.attribution_failed
1357 }
1358
1359 pub fn not_recorded(&self) -> u64 {
1362 self.not_recorded
1363 }
1364
1365 pub fn unattested(&self) -> u64 {
1368 self.unattested
1369 }
1370
1371 pub fn rows(&self) -> u64 {
1378 self.rows
1379 }
1380
1381 pub fn unnamed(&self) -> u64 {
1390 self.rows - self.named
1391 }
1392
1393 #[cfg(any(test, feature = "test-support"))]
1406 pub fn from_counts_for_tests(
1407 named: u64,
1408 attribution_failed: u64,
1409 not_recorded: u64,
1410 unattested: u64,
1411 ) -> Self {
1412 let rows = named
1413 .checked_add(attribution_failed)
1414 .and_then(|n| n.checked_add(not_recorded))
1415 .and_then(|n| n.checked_add(unattested))
1416 .expect("census counts must not overflow u64");
1417 Self {
1418 named,
1419 attribution_failed,
1420 not_recorded,
1421 unattested,
1422 rows,
1423 }
1424 }
1425}
1426
1427impl AuditVerification {
1428 pub fn exit_code(&self) -> i32 {
1434 let codes = [
1435 outcome_to_exit_code(&self.chain),
1436 outcome_to_exit_code(&self.events),
1437 binding_outcome_to_exit_code(&self.binding),
1438 ];
1439 if codes.contains(&1) {
1440 1
1441 } else if codes.contains(&2) {
1442 2
1443 } else {
1444 0
1445 }
1446 }
1447}
1448
1449pub fn verify_all(
1451 key: &AuditKey,
1452 tx_rows: &[ChainRow],
1453 event_rows: &[EventRow],
1454) -> AuditVerification {
1455 AuditVerification {
1456 chain: verify_chain(key, tx_rows),
1457 events: verify_event_chain(key, event_rows),
1458 binding: verify_event_binding(tx_rows, event_rows),
1459 attribution: Some(AttributionCensus::of(tx_rows)),
1460 }
1461}
1462
1463pub fn verify_all_with_pubkey(
1465 verifying_key_hex: &str,
1466 tx_rows: &[ChainRow],
1467 event_rows: &[EventRow],
1468) -> AuditVerification {
1469 AuditVerification {
1470 chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
1471 events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
1472 binding: verify_event_binding(tx_rows, event_rows),
1473 attribution: Some(AttributionCensus::of(tx_rows)),
1474 }
1475}
1476
1477pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
1481 match outcome {
1482 BindingOutcome::Consistent { .. } => 0,
1483 BindingOutcome::NotChecked => 2,
1484 BindingOutcome::MissingEvent { .. } => 1,
1485 }
1486}
1487
1488#[derive(Debug, Clone, PartialEq, Eq)]
1504pub struct Checkpoint {
1505 pub seq: u64,
1508 pub chain_tip: String,
1510 pub created_at: String,
1512 pub signature: String,
1514}
1515
1516fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
1519 let mut buf = CHECKPOINT_DOMAIN.to_vec();
1522 push_field(&mut buf, "seq", &seq.to_string());
1523 push_field(&mut buf, "chain_tip", chain_tip);
1524 push_field(&mut buf, "created_at", created_at);
1525 buf
1526}
1527
1528impl AuditKey {
1529 pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
1531 let sig = self
1532 .signing
1533 .sign(&checkpoint_message(seq, chain_tip, created_at));
1534 Checkpoint {
1535 seq,
1536 chain_tip: chain_tip.to_string(),
1537 created_at: created_at.to_string(),
1538 signature: hex::encode(sig.to_bytes()),
1539 }
1540 }
1541}
1542
1543#[derive(Debug, Clone, PartialEq, Eq)]
1545pub enum CheckpointOutcome {
1546 Consistent { checkpoints_checked: u64 },
1549 BadSignature { seq: u64 },
1551 Truncated {
1554 checkpoint_seq: u64,
1555 current_max_seq: u64,
1556 },
1557 TipMismatch {
1560 seq: u64,
1561 anchored: String,
1562 actual: String,
1563 },
1564 CannotVerify { reason: String },
1566}
1567
1568pub fn verify_checkpoints(
1574 verifying_key_hex: &str,
1575 rows: &[ChainRow],
1576 checkpoints: &[Checkpoint],
1577) -> CheckpointOutcome {
1578 let vk = match parse_verifying_key(verifying_key_hex) {
1579 Some(vk) => vk,
1580 None => {
1581 return CheckpointOutcome::CannotVerify {
1582 reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
1583 };
1584 }
1585 };
1586 let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
1589 let mut checked = 0u64;
1590 for cp in checkpoints {
1591 let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
1592 if !signature_ok(&vk, &msg, &cp.signature) {
1593 return CheckpointOutcome::BadSignature { seq: cp.seq };
1594 }
1595 match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
1596 Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
1597 Ok(idx) => {
1598 return CheckpointOutcome::TipMismatch {
1599 seq: cp.seq,
1600 anchored: cp.chain_tip.clone(),
1601 actual: rows[idx].chain_hash.clone(),
1602 };
1603 }
1604 Err(_) => {
1605 return CheckpointOutcome::Truncated {
1606 checkpoint_seq: cp.seq,
1607 current_max_seq,
1608 };
1609 }
1610 }
1611 checked += 1;
1612 }
1613 CheckpointOutcome::Consistent {
1614 checkpoints_checked: checked,
1615 }
1616}
1617
1618pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
1623 match outcome {
1624 CheckpointOutcome::Consistent { .. } => 0,
1625 CheckpointOutcome::BadSignature { .. }
1626 | CheckpointOutcome::Truncated { .. }
1627 | CheckpointOutcome::TipMismatch { .. } => 1,
1628 CheckpointOutcome::CannotVerify { .. } => 2,
1629 }
1630}
1631
1632#[cfg(test)]
1633mod tests {
1634 use super::*;
1635
1636 fn fixed_key() -> AuditKey {
1637 AuditKey::from_bytes(vec![0x42; 32])
1638 }
1639
1640 fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
1641 ChainContent {
1642 seq,
1643 key_id: CURRENT_KEY_ID,
1644 transaction_id: txid,
1645 request_id: "req-1",
1646 request_hash: "hash-abc",
1647 action_name: "UpdateSystem",
1648 risk_level: RiskLevel::High,
1649 summary: "Upgrade",
1650 approval_id: None,
1651 warnings_json: "[]",
1652 created_at: "2026-04-24T12:00:00Z",
1653 identity: ChainIdentity::V2 {
1654 caller_role: "Dev",
1655 event_tip: "",
1656 },
1657 }
1658 }
1659
1660 #[test]
1663 fn same_inputs_yield_same_hash() {
1664 let key = fixed_key();
1665 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1666 let h2 = key.chain_hash(&sample_content(1, "txa"), "");
1667 assert_eq!(h1, h2);
1668 assert_eq!(h1.len(), HASH_HEX_LEN);
1669 }
1670
1671 #[test]
1672 fn different_seq_yields_different_hash() {
1673 let key = fixed_key();
1674 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1675 let h2 = key.chain_hash(&sample_content(2, "txa"), "");
1676 assert_ne!(h1, h2);
1677 }
1678
1679 #[test]
1680 fn different_prev_hash_yields_different_hash() {
1681 let key = fixed_key();
1682 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1683 let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
1684 assert_ne!(h1, h2);
1685 }
1686
1687 #[test]
1688 fn different_keys_yield_different_hashes() {
1689 let key1 = AuditKey::from_bytes(vec![0x01; 32]);
1690 let key2 = AuditKey::from_bytes(vec![0x02; 32]);
1691 let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
1692 let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
1693 assert_ne!(h1, h2);
1694 }
1695
1696 #[test]
1699 fn canonical_bytes_have_stable_field_order() {
1700 let c = sample_content(1, "txa");
1701 let bytes = c.canonical_bytes();
1702 let s = String::from_utf8_lossy(&bytes);
1703 let order = [
1705 "seq",
1706 "key_id",
1707 "transaction_id",
1708 "request_id",
1709 "request_hash",
1710 "action_name",
1711 "risk_level",
1712 "summary",
1713 "approval_id",
1714 "warnings_json",
1715 "created_at",
1716 ];
1717 let mut last_idx = 0;
1718 for tag in order {
1719 let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
1720 assert!(idx >= last_idx, "{tag} out of order");
1721 last_idx = idx;
1722 }
1723 }
1724
1725 #[test]
1726 fn nul_bytes_in_field_value_are_escaped() {
1727 let mut c = sample_content(1, "txa");
1728 let summary = "before\0after";
1729 c.summary = summary;
1730 let bytes = c.canonical_bytes();
1731 assert!(!bytes.contains(&0x00));
1733 assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
1734 }
1735
1736 #[test]
1742 fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
1743 let mut a = sample_content(1, "txa");
1744 a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
1746 b.summary = "before\0after"; assert_ne!(
1748 a.canonical_bytes(),
1749 b.canonical_bytes(),
1750 "backslash escape must run before NUL escape to prevent collision"
1751 );
1752 }
1753
1754 fn key_for_collision_test() -> AuditKey {
1755 AuditKey::from_bytes(vec![0xab; 32])
1756 }
1757
1758 #[test]
1759 fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
1760 let key = key_for_collision_test();
1761 let mut a = sample_content(1, "txa");
1762 a.summary = "before\\0after";
1763 let mut b = sample_content(1, "txa");
1764 b.summary = "before\0after";
1765 assert_ne!(
1766 key.chain_hash(&a, ""),
1767 key.chain_hash(&b, ""),
1768 "chain signature must distinguish escape from raw byte"
1769 );
1770 }
1771
1772 fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
1778 let (chain_version, caller_role, event_tip, caller_principal) = match content.identity {
1779 ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None, None),
1780 ChainIdentity::V2 {
1781 caller_role,
1782 event_tip,
1783 } => (
1784 CHAIN_VERSION_V2,
1785 Some(caller_role.to_string()),
1786 Some(event_tip.to_string()),
1787 None,
1788 ),
1789 ChainIdentity::V3 {
1790 caller_role,
1791 event_tip,
1792 caller_principal,
1793 } => (
1794 CHAIN_VERSION_V3,
1795 Some(caller_role.to_string()),
1796 Some(event_tip.to_string()),
1797 Some(caller_principal.to_string()),
1798 ),
1799 };
1800 ChainRow {
1801 seq: content.seq,
1802 key_id: content.key_id.to_string(),
1803 transaction_id: content.transaction_id.to_string(),
1804 request_id: content.request_id.to_string(),
1805 request_hash: content.request_hash.to_string(),
1806 action_name: content.action_name.to_string(),
1807 risk_level: content.risk_level,
1808 summary: content.summary.to_string(),
1809 approval_id: content.approval_id.map(str::to_string),
1810 warnings_json: content.warnings_json.to_string(),
1811 created_at: content.created_at.to_string(),
1812 prev_chain_hash: prev.to_string(),
1813 chain_hash,
1814 chain_version,
1815 caller_role,
1816 event_tip,
1817 caller_principal,
1818 }
1819 }
1820
1821 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
1822 let mut rows = Vec::with_capacity(count);
1823 let mut prev = String::new();
1824 for i in 0..count {
1825 let seq = (i + 1) as u64;
1826 let txid = format!("tx{i}");
1827 let content = sample_content(seq, &txid);
1828 let hash = key.chain_hash(&content, &prev);
1829 rows.push(row_for(&content, &prev, hash.clone()));
1830 prev = hash;
1831 }
1832 rows
1833 }
1834
1835 #[test]
1836 fn exported_chain_row_json_round_trips_without_changing_the_signature() {
1837 let key = fixed_key();
1838 let row = build_chain(&key, 1).remove(0);
1839
1840 let json = serde_json::to_string(&row).expect("chain row serializes");
1841 let decoded: ChainRow = serde_json::from_str(&json).expect("chain row deserializes");
1842
1843 assert_eq!(decoded, row);
1844 assert_eq!(decoded.chain_hash.as_bytes(), row.chain_hash.as_bytes());
1845 }
1846
1847 #[test]
1848 fn exported_chain_row_uses_the_stored_column_contract_and_json_nulls() {
1849 let key = fixed_key();
1850 let mut row = build_chain(&key, 1).remove(0);
1851 row.approval_id = None;
1852 row.caller_role = None;
1853 row.event_tip = None;
1854 row.caller_principal = None;
1855
1856 let value = serde_json::to_value(&row).expect("chain row serializes");
1857 let object = value.as_object().expect("chain row is a JSON object");
1858 let keys: Vec<&str> = object.keys().map(String::as_str).collect();
1859
1860 assert_eq!(
1861 keys,
1862 vec![
1863 "action_name",
1864 "approval_id",
1865 "caller_principal",
1866 "caller_role",
1867 "chain_hash",
1868 "chain_version",
1869 "created_at",
1870 "event_tip",
1871 "key_id",
1872 "prev_chain_hash",
1873 "request_hash",
1874 "request_id",
1875 "risk_level",
1876 "seq",
1877 "summary",
1878 "transaction_id",
1879 "warnings_json",
1880 ]
1881 );
1882 for optional in [
1883 "approval_id",
1884 "caller_role",
1885 "event_tip",
1886 "caller_principal",
1887 ] {
1888 assert!(object[optional].is_null(), "{optional} must be JSON null");
1889 }
1890 assert!(!object.contains_key("argv"));
1891 assert!(!object.contains_key("outcome"));
1892 assert!(!object.contains_key("signature"));
1893 }
1894
1895 #[test]
1896 fn export_since_is_inclusive_and_limit_preserves_chain_order() {
1897 let key = fixed_key();
1898 let mut rows = build_chain(&key, 4);
1899 for (row, created_at) in rows.iter_mut().zip([
1900 "2026-08-19T09:00:00Z",
1901 "2026-08-19T10:00:00Z",
1902 "2026-08-19T11:00:00Z",
1903 "2026-08-19T12:00:00Z",
1904 ]) {
1905 row.created_at = created_at.to_string();
1906 }
1907
1908 let selected =
1909 select_chain_rows_for_export(rows, Some("2026-08-19T10:00:00+00:00"), Some(2)).unwrap();
1910
1911 assert_eq!(
1912 selected.iter().map(|row| row.seq).collect::<Vec<_>>(),
1913 [2, 3]
1914 );
1915 }
1916
1917 #[test]
1918 fn export_rejects_an_invalid_since_timestamp() {
1919 let error = select_chain_rows_for_export(Vec::new(), Some("last Tuesday"), None)
1920 .expect_err("invalid --since must fail before reading rows");
1921 assert!(matches!(error, ChainExportError::InvalidSince(_)));
1922 }
1923
1924 #[test]
1927 fn caller_role_is_covered_by_the_row_signature() {
1928 let key = fixed_key();
1932 let mut dev = sample_content(1, "txa");
1933 dev.identity = ChainIdentity::V2 {
1934 caller_role: "Dev",
1935 event_tip: "",
1936 };
1937 let mut boot = sample_content(1, "txa");
1938 boot.identity = ChainIdentity::V2 {
1939 caller_role: "Boot",
1940 event_tip: "",
1941 };
1942 assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
1943 }
1944
1945 #[test]
1946 fn event_tip_is_covered_by_the_row_signature() {
1947 let key = fixed_key();
1948 let mut empty = sample_content(1, "txa");
1949 empty.identity = ChainIdentity::V2 {
1950 caller_role: "Dev",
1951 event_tip: "",
1952 };
1953 let mut bound = sample_content(1, "txa");
1954 bound.identity = ChainIdentity::V2 {
1955 caller_role: "Dev",
1956 event_tip: "abc123",
1957 };
1958 assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
1959 }
1960
1961 #[test]
1962 fn a_legacy_row_written_before_the_migration_still_verifies() {
1963 let key = fixed_key();
1968 let mut content = sample_content(1, "tx-legacy");
1969 content.identity = ChainIdentity::LegacyV1;
1970 let hash = key.chain_hash(&content, "");
1971 let row = row_for(&content, "", hash);
1972 assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
1973 assert_eq!(row.caller_role, None);
1974 assert_eq!(
1975 verify_chain(&key, &[row]),
1976 VerifyOutcome::Intact { rows_checked: 1 }
1977 );
1978 }
1979
1980 #[test]
1986 fn two_admins_are_distinguishable_in_the_signed_record() {
1987 let key = fixed_key();
1988 let mut alice = sample_content(1, "tx-alice");
1989 alice.identity = ChainIdentity::V3 {
1990 caller_role: "admin",
1991 event_tip: "",
1992 caller_principal: "uid:1000",
1993 };
1994 let mut bob = sample_content(1, "tx-alice");
1995 bob.identity = ChainIdentity::V3 {
1996 caller_role: "admin",
1997 event_tip: "",
1998 caller_principal: "uid:1001",
1999 };
2000
2001 assert_ne!(
2002 key.chain_hash(&alice, ""),
2003 key.chain_hash(&bob, ""),
2004 "identical rows differing only in principal must sign differently, \
2005 otherwise the chain still cannot answer which account acted"
2006 );
2007 }
2008
2009 #[test]
2012 fn editing_the_stored_principal_breaks_the_row() {
2013 let key = fixed_key();
2014 let mut content = sample_content(1, "tx1");
2015 content.identity = ChainIdentity::V3 {
2016 caller_role: "admin",
2017 event_tip: "",
2018 caller_principal: "uid:1000",
2019 };
2020 let hash = key.chain_hash(&content, "");
2021 let mut row = row_for(&content, "", hash);
2022 row.caller_principal = Some("uid:0".to_string());
2023
2024 assert!(
2025 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2026 "a principal swapped to root must not verify"
2027 );
2028 }
2029
2030 #[test]
2034 fn downgrading_a_v3_row_to_v2_to_hide_the_account_breaks_it() {
2035 let key = fixed_key();
2036 let mut content = sample_content(1, "tx1");
2037 content.identity = ChainIdentity::V3 {
2038 caller_role: "admin",
2039 event_tip: "",
2040 caller_principal: "uid:1000",
2041 };
2042 let hash = key.chain_hash(&content, "");
2043 let mut row = row_for(&content, "", hash);
2044 row.chain_version = CHAIN_VERSION_V2;
2045 row.caller_principal = None;
2046
2047 assert!(
2048 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2049 "a v3 row relabelled as v2 must not verify"
2050 );
2051 }
2052
2053 #[test]
2058 fn a_v3_row_without_a_usable_principal_is_broken() {
2059 let key = fixed_key();
2060 let mut content = sample_content(1, "tx1");
2061 content.identity = ChainIdentity::V3 {
2062 caller_role: "admin",
2063 event_tip: "",
2064 caller_principal: "uid:1000",
2065 };
2066 let hash = key.chain_hash(&content, "");
2067
2068 for absent in [None, Some(String::new())] {
2069 let mut row = row_for(&content, "", hash.clone());
2070 row.caller_principal = absent.clone();
2071 assert!(
2072 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2073 "a v3 row with principal {absent:?} must be reported as broken"
2074 );
2075 }
2076 }
2077
2078 #[test]
2088 fn a_row_signed_with_a_blank_principal_is_rejected_even_though_it_signs() {
2089 let key = fixed_key();
2090 let mut content = sample_content(1, "tx-blank");
2091 content.identity = ChainIdentity::V3 {
2092 caller_role: "admin",
2093 event_tip: "",
2094 caller_principal: "",
2095 };
2096 let hash = key.chain_hash(&content, "");
2097 let row = row_for(&content, "", hash);
2102 assert_eq!(
2103 row.caller_principal.as_deref(),
2104 Some(""),
2105 "fixture must store the blank principal it signed"
2106 );
2107 assert_eq!(row.chain_version, CHAIN_VERSION_V3);
2108 assert!(
2109 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2110 "a v3 row naming nobody must be broken, not accepted"
2111 );
2112 }
2113
2114 #[test]
2119 fn a_row_recording_an_attribution_failure_verifies() {
2120 let key = fixed_key();
2121 let principal = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2122 let mut content = sample_content(1, "tx-unattributed");
2123 content.identity = ChainIdentity::V3 {
2124 caller_role: "observer",
2125 event_tip: "",
2126 caller_principal: &principal,
2127 };
2128 let hash = key.chain_hash(&content, "");
2129 let row = row_for(&content, "", hash);
2130
2131 assert_eq!(
2132 verify_chain(&key, &[row]),
2133 VerifyOutcome::Intact { rows_checked: 1 }
2134 );
2135 }
2136
2137 #[test]
2141 fn a_chain_spanning_all_three_encodings_verifies() {
2142 let key = fixed_key();
2143 let mut rows = Vec::new();
2144 let mut prev = String::new();
2145 for (i, identity) in [
2146 ChainIdentity::LegacyV1,
2147 ChainIdentity::V2 {
2148 caller_role: "dev",
2149 event_tip: "",
2150 },
2151 ChainIdentity::V3 {
2152 caller_role: "admin",
2153 event_tip: "",
2154 caller_principal: "uid:1000",
2155 },
2156 ChainIdentity::V3 {
2157 caller_role: "admin",
2158 event_tip: "",
2159 caller_principal: "token:vsock",
2160 },
2161 ]
2162 .into_iter()
2163 .enumerate()
2164 {
2165 let txid = format!("tx{i}");
2166 let mut content = sample_content((i + 1) as u64, &txid);
2167 content.identity = identity;
2168 let hash = key.chain_hash(&content, &prev);
2169 rows.push(row_for(&content, &prev, hash.clone()));
2170 prev = hash;
2171 }
2172
2173 assert_eq!(
2174 verify_chain(&key, &rows),
2175 VerifyOutcome::Intact { rows_checked: 4 }
2176 );
2177 }
2178
2179 fn chain_of(key: &AuditKey, identities: Vec<ChainIdentity<'_>>) -> Vec<ChainRow> {
2182 let mut rows = Vec::new();
2183 let mut prev = String::new();
2184 for (i, identity) in identities.into_iter().enumerate() {
2185 let txid = format!("tx{i}");
2186 let mut content = sample_content((i + 1) as u64, &txid);
2187 content.identity = identity;
2188 let hash = key.chain_hash(&content, &prev);
2189 rows.push(row_for(&content, &prev, hash.clone()));
2190 prev = hash;
2191 }
2192 rows
2193 }
2194
2195 #[test]
2205 fn the_census_separates_attribution_failures_from_encodings_without_the_field() {
2206 let key = fixed_key();
2207 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2208 let rows = chain_of(
2209 &key,
2210 vec![
2211 ChainIdentity::LegacyV1,
2212 ChainIdentity::V2 {
2213 caller_role: "dev",
2214 event_tip: "",
2215 },
2216 ChainIdentity::V3 {
2217 caller_role: "admin",
2218 event_tip: "",
2219 caller_principal: "uid:1000",
2220 },
2221 ChainIdentity::V3 {
2222 caller_role: "admin",
2223 event_tip: "",
2224 caller_principal: "uid:1001",
2225 },
2226 ChainIdentity::V3 {
2227 caller_role: "admin",
2228 event_tip: "",
2229 caller_principal: "token:vsock",
2230 },
2231 ChainIdentity::V3 {
2232 caller_role: "observer",
2233 event_tip: "",
2234 caller_principal: &unattributed,
2235 },
2236 ],
2237 );
2238
2239 let verification = verify_all(&key, &rows, &[]);
2240 assert_eq!(
2241 verification.chain,
2242 VerifyOutcome::Intact { rows_checked: 6 },
2243 "every encoding must keep verifying; the census is not a verdict"
2244 );
2245 let census = verification
2246 .attribution
2247 .expect("rows were read, so a census was taken");
2248 assert_eq!(census.named(), 3);
2249 assert_eq!(census.attribution_failed(), 1);
2250 assert_eq!(census.not_recorded(), 2);
2251 assert_eq!(census.unattested(), 0);
2252 assert_eq!(census.rows(), 6);
2253 assert_eq!(
2254 census.unnamed(),
2255 3,
2256 "three of the six rows cannot name an account, by either reason"
2257 );
2258 }
2259
2260 #[test]
2270 fn a_principal_written_into_an_encoding_that_does_not_sign_it_is_never_an_account() {
2271 let key = fixed_key();
2272 let mut rows = chain_of(
2273 &key,
2274 vec![
2275 ChainIdentity::LegacyV1,
2276 ChainIdentity::V2 {
2277 caller_role: "dev",
2278 event_tip: "",
2279 },
2280 ],
2281 );
2282 rows[0].caller_principal = Some("uid:0".to_string());
2285 rows[1].caller_principal = Some("uid:0".to_string());
2286
2287 let verification = verify_all(&key, &rows, &[]);
2288 assert_eq!(
2289 verification.chain,
2290 VerifyOutcome::Intact { rows_checked: 2 },
2291 "no signature covers that column on v1/v2, so tampering with it is \
2292 invisible to verification -- which is exactly why the census must \
2293 not believe it"
2294 );
2295 let census = verification.attribution.expect("rows were read");
2296 assert_eq!(
2297 census.named(),
2298 0,
2299 "an unsigned column must never be credited as an account"
2300 );
2301 assert_eq!(
2302 census.unattested(),
2303 2,
2304 "and it must be reported as unattested, because nothing in SysKnife \
2305 writes that column on those encodings"
2306 );
2307 assert_eq!(census.unnamed(), 2);
2308 }
2309
2310 #[test]
2314 fn a_blank_principal_column_is_counted_as_naming_nobody() {
2315 let key = fixed_key();
2316 let mut content = sample_content(1, "tx-blank-census");
2317 content.identity = ChainIdentity::V3 {
2318 caller_role: "admin",
2319 event_tip: "",
2320 caller_principal: "",
2321 };
2322 let hash = key.chain_hash(&content, "");
2323 let row = row_for(&content, "", hash);
2324 assert_eq!(row.caller_principal.as_deref(), Some(""));
2325
2326 let verification = verify_all(&key, &[row], &[]);
2327 let census = verification.attribution.expect("a row was read");
2328 assert_eq!(census.named(), 0);
2329 assert_eq!(census.not_recorded(), 1);
2330 assert_eq!(census.rows(), 1);
2331 assert!(
2332 matches!(verification.chain, VerifyOutcome::Broken { .. }),
2333 "and the row is still reported as broken once the walk reaches it, \
2334 so census and verdict agree"
2335 );
2336 }
2337
2338 #[test]
2346 fn a_principal_this_binary_cannot_read_is_counted_as_unattested_not_as_an_account() {
2347 let key = fixed_key();
2348 for forged in [
2349 "garbage",
2350 "uid:",
2351 "none:overflow",
2352 " ",
2357 "UID:1000",
2358 "uid:notanumber",
2360 "uid:1000:extra",
2361 "token:not-vsock",
2362 ] {
2363 let mut content = sample_content(1, "tx-forged");
2364 content.identity = ChainIdentity::V3 {
2365 caller_role: "admin",
2366 event_tip: "",
2367 caller_principal: forged,
2368 };
2369 let hash = key.chain_hash(&content, "");
2370 let row = row_for(&content, "", hash);
2371 let census = verify_all(&key, &[row], &[])
2372 .attribution
2373 .expect("a row was read");
2374 assert_eq!(
2375 census.named(),
2376 0,
2377 "{forged:?} names no account and must not be counted as one"
2378 );
2379 assert_eq!(
2380 census.unnamed(),
2381 1,
2382 "{forged:?} must be counted among the rows naming nobody"
2383 );
2384 assert_eq!(
2385 census.unattested(),
2386 1,
2387 "{forged:?} is signed content the daemon never writes, so it is a \
2388 finding to investigate, not history to explain away"
2389 );
2390 assert_eq!(
2391 census.not_recorded(),
2392 0,
2393 "{forged:?} is present, so it must not be reported as a row that \
2394 predates the column"
2395 );
2396 }
2397 }
2398
2399 #[test]
2404 fn a_chain_predating_the_principal_field_reports_no_row_as_attributed() {
2405 let key = fixed_key();
2406 let rows = chain_of(
2407 &key,
2408 vec![
2409 ChainIdentity::LegacyV1,
2410 ChainIdentity::V2 {
2411 caller_role: "dev",
2412 event_tip: "",
2413 },
2414 ],
2415 );
2416
2417 let verification = verify_all(&key, &rows, &[]);
2418 assert_eq!(
2419 verification.chain,
2420 VerifyOutcome::Intact { rows_checked: 2 },
2421 "legacy rows must keep verifying; the census is not a verdict"
2422 );
2423 let census = verification.attribution.expect("rows were read");
2424 assert_eq!(census.attribution_failed(), 0);
2425 assert_eq!(census.named(), 0);
2426 assert_eq!(census.not_recorded(), 2);
2427 assert_eq!(census.unnamed(), 2);
2428 }
2429
2430 #[test]
2436 fn a_broken_chain_censuses_more_rows_than_it_verified() {
2437 let key = fixed_key();
2438 let mut rows = chain_of(
2439 &key,
2440 vec![
2441 ChainIdentity::V3 {
2442 caller_role: "admin",
2443 event_tip: "",
2444 caller_principal: "uid:1000",
2445 },
2446 ChainIdentity::V3 {
2447 caller_role: "admin",
2448 event_tip: "",
2449 caller_principal: "uid:1000",
2450 },
2451 ChainIdentity::V3 {
2452 caller_role: "admin",
2453 event_tip: "",
2454 caller_principal: "uid:1000",
2455 },
2456 ],
2457 );
2458 rows[1].summary = "tampered".to_string();
2459
2460 let verification = verify_all(&key, &rows, &[]);
2461 let rows_checked = match verification.chain {
2462 VerifyOutcome::Broken { rows_checked, .. } => rows_checked,
2463 ref other => panic!("expected a detected tamper, got {other:?}"),
2464 };
2465 let census = verification.attribution.expect("rows were read");
2466 assert_eq!(rows_checked, 1, "the walk stops at the first broken row");
2467 assert_eq!(
2468 census.rows(),
2469 3,
2470 "while the census still describes every row read"
2471 );
2472 assert!(
2473 census.rows() > rows_checked,
2474 "the report must be able to show that more was counted than checked"
2475 );
2476 }
2477
2478 #[test]
2482 fn the_pubkey_only_path_takes_the_same_census() {
2483 let key = fixed_key();
2484 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2485 let rows = chain_of(
2486 &key,
2487 vec![
2488 ChainIdentity::V3 {
2489 caller_role: "admin",
2490 event_tip: "",
2491 caller_principal: "uid:1000",
2492 },
2493 ChainIdentity::V3 {
2494 caller_role: "observer",
2495 event_tip: "",
2496 caller_principal: &unattributed,
2497 },
2498 ],
2499 );
2500
2501 let with_key = verify_all(&key, &rows, &[]).attribution;
2502 let with_pubkey = verify_all_with_pubkey(&key.verifying_key_hex(), &rows, &[]).attribution;
2503 assert_eq!(
2504 with_key, with_pubkey,
2505 "the auditor must see the same attribution numbers as the keyholder"
2506 );
2507 let census = with_pubkey.expect("rows were read");
2508 assert_eq!(census.named(), 1);
2509 assert_eq!(census.attribution_failed(), 1);
2510 }
2511
2512 #[test]
2517 fn a_chain_that_names_nobody_still_exits_zero() {
2518 let key = fixed_key();
2519 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2520 let rows = chain_of(
2521 &key,
2522 vec![
2523 ChainIdentity::LegacyV1,
2524 ChainIdentity::V3 {
2525 caller_role: "observer",
2526 event_tip: "",
2527 caller_principal: &unattributed,
2528 },
2529 ],
2530 );
2531
2532 let verification = verify_all(&key, &rows, &[]);
2533 let census = verification.attribution.expect("rows were read");
2534 assert_eq!(census.unnamed(), 2, "not one row names an account");
2535 assert_eq!(
2536 verification.exit_code(),
2537 0,
2538 "yet nothing was tampered with, so the command must succeed"
2539 );
2540 }
2541
2542 #[test]
2547 fn a_future_encoding_reports_cannot_verify_with_the_supported_range() {
2548 let key = fixed_key();
2549 let content = sample_content(1, "tx1");
2550 let hash = key.chain_hash(&content, "");
2551 let mut row = row_for(&content, "", hash);
2552 row.chain_version = CHAIN_VERSION_CURRENT + 1;
2553
2554 match verify_chain(&key, &[row]) {
2555 VerifyOutcome::CannotVerify { reason } => {
2556 assert!(
2557 reason.contains(&format!("chain_version={}", CHAIN_VERSION_CURRENT + 1)),
2558 "name the version found, got: {reason}"
2559 );
2560 assert!(
2561 reason.contains(&CHAIN_VERSION_CURRENT.to_string()),
2562 "and the newest version understood, got: {reason}"
2563 );
2564 }
2565 other => panic!("a future encoding must not be reported as tampering: {other:?}"),
2566 }
2567 }
2568
2569 #[test]
2582 fn a_row_written_by_the_previous_release_still_verifies() {
2583 const GOLDEN_V2_CHAIN_HASH: &str = "ba12cbe49a149387898bc30f1e8d409effa025ab8e440e6e72bedf060afc831e45d9be264d6b18b7e0ef2f2b3584dbc6953b535546cecbbdc3d7ac666cae4d0a";
2584
2585 let key = fixed_key();
2586 let row = ChainRow {
2587 seq: 7,
2588 key_id: CURRENT_KEY_ID.to_string(),
2589 transaction_id: "tx-golden".to_string(),
2590 request_id: "req-golden".to_string(),
2591 request_hash: "hash-golden".to_string(),
2592 action_name: "AptInstall".to_string(),
2593 risk_level: RiskLevel::Medium,
2594 summary: "install ripgrep".to_string(),
2595 approval_id: Some("appr-golden".to_string()),
2596 warnings_json: "[]".to_string(),
2597 created_at: "2026-07-29T00:00:00Z".to_string(),
2598 prev_chain_hash: String::new(),
2599 chain_hash: GOLDEN_V2_CHAIN_HASH.to_string(),
2600 chain_version: 2,
2601 caller_role: Some("admin".to_string()),
2602 event_tip: Some("eventtip-golden".to_string()),
2603 caller_principal: None,
2604 };
2605
2606 assert_eq!(
2607 verify_chain(&key, std::slice::from_ref(&row)),
2608 VerifyOutcome::Intact { rows_checked: 1 },
2609 "a v2 row on disk must verify under this binary; if this fails, the v2 \
2610 encoding or its version dispatch changed and existing audit logs are unreadable"
2611 );
2612
2613 let mut tampered = row;
2617 let flipped = match GOLDEN_V2_CHAIN_HASH.strip_prefix('b') {
2618 Some(rest) => format!("a{rest}"),
2619 None => format!("b{}", &GOLDEN_V2_CHAIN_HASH[1..]),
2620 };
2621 tampered.chain_hash = flipped;
2622 assert!(
2623 matches!(
2624 verify_chain(&key, &[tampered]),
2625 VerifyOutcome::Broken { .. }
2626 ),
2627 "a golden row with one nibble changed must not verify"
2628 );
2629 }
2630
2631 #[test]
2635 fn a_legacy_v1_row_on_disk_still_verifies() {
2636 const GOLDEN_V1_CHAIN_HASH: &str = "ce8b47c15414e989e099303b826fcd9985360b794220cefbbf9689d25f31fe173283687feb6d1dd265ae5aca94c8bcfb87ce33ba8c954fafdad18a59cd97c702";
2637
2638 let row = ChainRow {
2639 chain_version: 1,
2640 caller_role: None,
2641 event_tip: None,
2642 caller_principal: None,
2643 chain_hash: GOLDEN_V1_CHAIN_HASH.to_string(),
2644 ..golden_row_shape()
2645 };
2646 assert_eq!(
2647 verify_chain(&fixed_key(), &[row]),
2648 VerifyOutcome::Intact { rows_checked: 1 }
2649 );
2650 }
2651
2652 #[test]
2656 fn a_v3_row_on_disk_still_verifies() {
2657 const GOLDEN_V3_CHAIN_HASH: &str = "3c0d05f6ccaf9e65ccb796d4ffe96eb31a2ea67e446217c5234af916dc959d0f2c0ee3d3e5b525aece791ee7d6507efb98dcdd79f0d74dde4273859aa9627d08";
2658
2659 let row = ChainRow {
2660 chain_version: 3,
2661 caller_role: Some("admin".to_string()),
2662 event_tip: Some("eventtip-golden".to_string()),
2663 caller_principal: Some("uid:1000".to_string()),
2664 chain_hash: GOLDEN_V3_CHAIN_HASH.to_string(),
2665 ..golden_row_shape()
2666 };
2667 assert_eq!(
2668 verify_chain(&fixed_key(), &[row]),
2669 VerifyOutcome::Intact { rows_checked: 1 }
2670 );
2671 }
2672
2673 #[test]
2680 fn the_escape_table_and_absent_approval_encoding_are_frozen() {
2681 const GOLDEN_V2_AWKWARD_CHAIN_HASH: &str = "e6face6fe2346fc0f6c5c1e5845255579c8f08738406b88b0a1f6dbdf8ff85090a4b80d95bd0927ef54b0a2c4810d52ebb4bbf76cb68c865a7748df26442aa05";
2682
2683 let key = fixed_key();
2684 let content = ChainContent {
2685 seq: 8,
2686 approval_id: None,
2687 summary: "back\\slash and \x1funit and \x1erecord and nul\0byte",
2688 identity: ChainIdentity::V2 {
2689 caller_role: "dev",
2690 event_tip: "",
2691 },
2692 ..golden_content_shape()
2693 };
2694 assert_eq!(
2695 key.chain_hash(&content, "ba12cbe4"),
2696 GOLDEN_V2_AWKWARD_CHAIN_HASH,
2697 "the canonical encoding of escapes, an absent approval id, or a \
2698 non-empty prev_chain_hash changed"
2699 );
2700 }
2701
2702 fn golden_content_shape() -> ChainContent<'static> {
2706 ChainContent {
2707 seq: 7,
2708 key_id: CURRENT_KEY_ID,
2709 transaction_id: "tx-golden",
2710 request_id: "req-golden",
2711 request_hash: "hash-golden",
2712 action_name: "AptInstall",
2713 risk_level: RiskLevel::Medium,
2714 summary: "install ripgrep",
2715 approval_id: Some("appr-golden"),
2716 warnings_json: "[]",
2717 created_at: "2026-07-29T00:00:00Z",
2718 identity: ChainIdentity::LegacyV1,
2719 }
2720 }
2721
2722 fn golden_row_shape() -> ChainRow {
2724 ChainRow {
2725 seq: 7,
2726 key_id: CURRENT_KEY_ID.to_string(),
2727 transaction_id: "tx-golden".to_string(),
2728 request_id: "req-golden".to_string(),
2729 request_hash: "hash-golden".to_string(),
2730 action_name: "AptInstall".to_string(),
2731 risk_level: RiskLevel::Medium,
2732 summary: "install ripgrep".to_string(),
2733 approval_id: Some("appr-golden".to_string()),
2734 warnings_json: "[]".to_string(),
2735 created_at: "2026-07-29T00:00:00Z".to_string(),
2736 prev_chain_hash: String::new(),
2737 chain_hash: String::new(),
2738 chain_version: 1,
2739 caller_role: None,
2740 event_tip: None,
2741 caller_principal: None,
2742 }
2743 }
2744
2745 #[test]
2746 fn a_chain_that_spans_the_migration_verifies_end_to_end() {
2747 let key = fixed_key();
2750 let mut rows = Vec::new();
2751 let mut prev = String::new();
2752 for (i, identity) in [
2753 ChainIdentity::LegacyV1,
2754 ChainIdentity::LegacyV1,
2755 ChainIdentity::V2 {
2756 caller_role: "Dev",
2757 event_tip: "",
2758 },
2759 ChainIdentity::V2 {
2760 caller_role: "Boot",
2761 event_tip: "",
2762 },
2763 ]
2764 .into_iter()
2765 .enumerate()
2766 {
2767 let txid = format!("tx{i}");
2768 let mut content = sample_content((i + 1) as u64, &txid);
2769 content.identity = identity;
2770 let hash = key.chain_hash(&content, &prev);
2771 rows.push(row_for(&content, &prev, hash.clone()));
2772 prev = hash;
2773 }
2774 assert_eq!(
2775 verify_chain(&key, &rows),
2776 VerifyOutcome::Intact { rows_checked: 4 }
2777 );
2778 }
2779
2780 #[test]
2781 fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
2782 let key = fixed_key();
2787 let mut rows = build_chain(&key, 2);
2788 rows[1].chain_version = CHAIN_VERSION_LEGACY;
2789 rows[1].caller_role = None;
2790 rows[1].event_tip = None;
2791 match verify_chain(&key, &rows) {
2792 VerifyOutcome::Broken {
2793 first_broken_seq, ..
2794 } => assert_eq!(first_broken_seq, 2),
2795 other => panic!("expected Broken, got {other:?}"),
2796 }
2797 }
2798
2799 #[test]
2800 fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
2801 let key = fixed_key();
2806 let mut rows = build_chain(&key, 1);
2807 rows[0].caller_role = None;
2808 let outcome = verify_chain(&key, &rows);
2809 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2810 assert_eq!(outcome_to_exit_code(&outcome), 1);
2811 }
2812
2813 #[test]
2814 fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
2815 let key = fixed_key();
2818 let mut rows = build_chain(&key, 1);
2819 rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
2820 let outcome = verify_chain(&key, &rows);
2821 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
2822 assert_eq!(outcome_to_exit_code(&outcome), 2);
2823 }
2824
2825 fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
2828 EventContent {
2829 seq,
2830 key_id: CURRENT_KEY_ID,
2831 kind,
2832 transaction_id: txid,
2833 receipt_digest: "digest-abc",
2834 created_at: "2026-04-24T12:00:00Z",
2835 }
2836 }
2837
2838 fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
2839 let mut rows = Vec::with_capacity(kinds.len());
2840 let mut prev = String::new();
2841 for (i, kind) in kinds.iter().enumerate() {
2842 let seq = (i + 1) as u64;
2843 let txid = format!("tx{i}");
2844 let content = event_content(seq, *kind, &txid);
2845 let hash = key.event_hash(&content, &prev);
2846 rows.push(EventRow {
2847 seq,
2848 key_id: content.key_id.to_string(),
2849 kind: content.kind.as_str().to_string(),
2850 transaction_id: content.transaction_id.to_string(),
2851 receipt_digest: content.receipt_digest.to_string(),
2852 created_at: content.created_at.to_string(),
2853 prev_chain_hash: prev.clone(),
2854 chain_hash: hash.clone(),
2855 });
2856 prev = hash;
2857 }
2858 rows
2859 }
2860
2861 #[test]
2862 fn event_kind_spellings_are_stable() {
2863 assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
2866 assert_eq!(
2867 AuditEventKind::ApprovalConsumed.as_str(),
2868 "approval_consumed"
2869 );
2870 assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
2871 }
2872
2873 #[test]
2874 fn intact_event_chain_verifies() {
2875 let key = fixed_key();
2876 let rows = build_event_chain(
2877 &key,
2878 &[
2879 AuditEventKind::ApprovalGranted,
2880 AuditEventKind::ApprovalConsumed,
2881 ],
2882 );
2883 assert_eq!(
2884 verify_event_chain(&key, &rows),
2885 VerifyOutcome::Intact { rows_checked: 2 }
2886 );
2887 }
2888
2889 #[test]
2890 fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
2891 let key = fixed_key();
2894 let mut rows = build_event_chain(
2895 &key,
2896 &[
2897 AuditEventKind::ApprovalGranted,
2898 AuditEventKind::ApprovalConsumed,
2899 AuditEventKind::ApprovalRevoked,
2900 ],
2901 );
2902 rows.remove(1);
2903 match verify_event_chain(&key, &rows) {
2904 VerifyOutcome::Broken {
2905 first_broken_seq, ..
2906 } => assert_eq!(first_broken_seq, 3),
2907 other => panic!("expected Broken, got {other:?}"),
2908 }
2909 }
2910
2911 #[test]
2912 fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
2913 let key = fixed_key();
2914 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
2915 rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
2916 assert!(matches!(
2917 verify_event_chain(&key, &rows),
2918 VerifyOutcome::Broken { .. }
2919 ));
2920 }
2921
2922 #[test]
2923 fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
2924 let key = fixed_key();
2925 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
2926 rows[0].kind = "approval_unicorned".to_string();
2927 assert!(matches!(
2928 verify_event_chain(&key, &rows),
2929 VerifyOutcome::Broken { .. }
2930 ));
2931 }
2932
2933 #[test]
2934 fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
2935 let key = fixed_key();
2939 let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
2940 let event_sig = key.event_hash(&content, "");
2941 let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
2942 assert_ne!(event_sig, row_sig);
2943
2944 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
2945 rows[0].chain_hash = row_sig;
2946 assert!(matches!(
2947 verify_event_chain(&key, &rows),
2948 VerifyOutcome::Broken { .. }
2949 ));
2950 }
2951
2952 #[test]
2955 fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
2956 let key = fixed_key();
2961 let events = build_event_chain(
2962 &key,
2963 &[
2964 AuditEventKind::ApprovalGranted,
2965 AuditEventKind::ApprovalConsumed,
2966 ],
2967 );
2968 let mut content = sample_content(1, "tx-after");
2969 content.identity = ChainIdentity::V2 {
2970 caller_role: "Dev",
2971 event_tip: &events[1].chain_hash,
2972 };
2973 let hash = key.chain_hash(&content, "");
2974 let tx_rows = vec![row_for(&content, "", hash)];
2975
2976 assert_eq!(
2977 verify_event_binding(&tx_rows, &events),
2978 BindingOutcome::Consistent {
2979 bindings_checked: 1
2980 }
2981 );
2982
2983 let truncated = &events[..1];
2984 assert_eq!(
2985 verify_event_chain(&key, truncated),
2986 VerifyOutcome::Intact { rows_checked: 1 },
2987 "a truncated event chain still walks clean; the binding is the detector"
2988 );
2989 match verify_event_binding(&tx_rows, truncated) {
2990 BindingOutcome::MissingEvent {
2991 transaction_seq, ..
2992 } => assert_eq!(transaction_seq, 1),
2993 other => panic!("expected MissingEvent, got {other:?}"),
2994 }
2995 }
2996
2997 #[test]
2998 fn legacy_rows_carry_no_binding_to_check() {
2999 let key = fixed_key();
3002 let mut content = sample_content(1, "tx-legacy");
3003 content.identity = ChainIdentity::LegacyV1;
3004 let hash = key.chain_hash(&content, "");
3005 let rows = vec![row_for(&content, "", hash)];
3006 assert_eq!(
3007 verify_event_binding(&rows, &[]),
3008 BindingOutcome::Consistent {
3009 bindings_checked: 0
3010 }
3011 );
3012 }
3013
3014 #[test]
3015 fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
3016 let verification = AuditVerification {
3021 chain: VerifyOutcome::Broken {
3022 rows_checked: 1,
3023 first_broken_seq: 2,
3024 first_broken_transaction_id: "tx".to_string(),
3025 expected: "e".to_string(),
3026 actual: "a".to_string(),
3027 },
3028 events: VerifyOutcome::CannotVerify {
3029 reason: "no key".to_string(),
3030 },
3031 binding: BindingOutcome::Consistent {
3032 bindings_checked: 0,
3033 },
3034 attribution: None,
3035 };
3036 assert_eq!(verification.exit_code(), 1);
3037 }
3038
3039 #[test]
3040 fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
3041 let verification = AuditVerification {
3042 chain: VerifyOutcome::Intact { rows_checked: 3 },
3043 events: VerifyOutcome::Intact { rows_checked: 1 },
3044 binding: BindingOutcome::MissingEvent {
3045 transaction_seq: 3,
3046 event_tip: "abc".to_string(),
3047 },
3048 attribution: None,
3049 };
3050 assert_eq!(verification.exit_code(), 1);
3051 }
3052
3053 #[test]
3054 fn all_three_clean_is_exit_zero() {
3055 let verification = AuditVerification {
3056 chain: VerifyOutcome::Intact { rows_checked: 3 },
3057 events: VerifyOutcome::Intact { rows_checked: 2 },
3058 binding: BindingOutcome::Consistent {
3059 bindings_checked: 1,
3060 },
3061 attribution: None,
3062 };
3063 assert_eq!(verification.exit_code(), 0);
3064 }
3065
3066 #[test]
3067 fn binding_exit_codes_split_clean_tampered_and_unchecked() {
3068 assert_eq!(
3069 binding_outcome_to_exit_code(&BindingOutcome::Consistent {
3070 bindings_checked: 0
3071 }),
3072 0
3073 );
3074 assert_eq!(
3075 binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
3076 transaction_seq: 7,
3077 event_tip: "abc".to_string()
3078 }),
3079 1
3080 );
3081 assert_eq!(binding_outcome_to_exit_code(&BindingOutcome::NotChecked), 2);
3082 }
3083
3084 #[test]
3085 fn intact_chain_verifies() {
3086 let key = fixed_key();
3087 let rows = build_chain(&key, 5);
3088 let outcome = verify_chain(&key, &rows);
3089 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
3090 assert_eq!(outcome_to_exit_code(&outcome), 0);
3091 }
3092
3093 #[test]
3094 fn empty_chain_verifies() {
3095 let key = fixed_key();
3096 let outcome = verify_chain(&key, &[]);
3097 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
3098 }
3099
3100 #[test]
3101 fn tampered_summary_breaks_chain_at_first_offending_row() {
3102 let key = fixed_key();
3103 let mut rows = build_chain(&key, 3);
3104 rows[1].summary = "TAMPERED".to_string();
3106 let outcome = verify_chain(&key, &rows);
3107 match outcome {
3108 VerifyOutcome::Broken {
3109 first_broken_seq, ..
3110 } => assert_eq!(first_broken_seq, 2),
3111 other => panic!("expected Broken, got {other:?}"),
3112 }
3113 }
3114
3115 #[test]
3116 fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
3117 let key = fixed_key();
3118 let mut rows = build_chain(&key, 4);
3119 rows.remove(1);
3121 let outcome = verify_chain(&key, &rows);
3122 match outcome {
3123 VerifyOutcome::Broken {
3124 first_broken_seq, ..
3125 } => assert_eq!(
3126 first_broken_seq, 3,
3127 "first broken row is the one whose prev_hash refers to deleted row"
3128 ),
3129 other => panic!("expected Broken, got {other:?}"),
3130 }
3131 }
3132
3133 #[test]
3134 fn inserted_forged_row_breaks_chain() {
3135 let key = fixed_key();
3142 let mut rows = build_chain(&key, 3);
3143
3144 let forged = ChainRow {
3146 caller_principal: Some("uid:1000".to_string()),
3150 seq: 2,
3151 key_id: CURRENT_KEY_ID.to_string(),
3152 transaction_id: "tx-forged".to_string(),
3153 request_id: "req-forged".to_string(),
3154 request_hash: "hash-forged".to_string(),
3155 action_name: "InstallFlatpak".to_string(),
3156 risk_level: RiskLevel::Medium,
3157 summary: "Forged row".to_string(),
3158 approval_id: None,
3159 warnings_json: "[]".to_string(),
3160 created_at: "2026-04-25T13:00:00Z".to_string(),
3161 prev_chain_hash: rows[0].chain_hash.clone(),
3163 chain_hash: "0".repeat(HASH_HEX_LEN),
3165 chain_version: CHAIN_VERSION_CURRENT,
3166 caller_role: Some("Dev".to_string()),
3167 event_tip: Some(String::new()),
3168 };
3169
3170 let mut rest: Vec<ChainRow> = rows.split_off(1);
3172 for r in rest.iter_mut() {
3173 r.seq += 1;
3174 }
3175 rows.push(forged);
3176 rows.extend(rest);
3177
3178 let outcome = verify_chain(&key, &rows);
3182 match outcome {
3183 VerifyOutcome::Broken {
3184 first_broken_seq, ..
3185 } => assert_eq!(
3186 first_broken_seq, 2,
3187 "the verifier must flag the forged row itself, not a later one"
3188 ),
3189 other => panic!("expected Broken, got {other:?}"),
3190 }
3191 }
3192
3193 #[test]
3196 fn signature_verifies_under_exported_public_key() {
3197 let key = fixed_key();
3199 let rows = build_chain(&key, 4);
3200 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3201 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
3202 }
3203
3204 #[test]
3205 fn foreign_public_key_cannot_validate_chain() {
3206 let signer = AuditKey::from_bytes(vec![0x11; 32]);
3210 let rows = build_chain(&signer, 3);
3211 let other = AuditKey::from_bytes(vec![0x22; 32]);
3212 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
3213 assert!(matches!(
3214 outcome,
3215 VerifyOutcome::Broken {
3216 first_broken_seq: 1,
3217 ..
3218 }
3219 ));
3220 }
3221
3222 #[test]
3223 fn verifying_key_hex_is_a_32_byte_public_key() {
3224 let key = fixed_key();
3225 let vk_hex = key.verifying_key_hex();
3226 assert_eq!(
3227 vk_hex.len(),
3228 64,
3229 "32-byte ed25519 public key = 64 hex chars"
3230 );
3231 assert!(hex::decode(&vk_hex).is_ok());
3232 }
3233
3234 #[test]
3235 fn malformed_signature_hex_is_broken_not_panic() {
3236 let key = fixed_key();
3237 let mut rows = build_chain(&key, 2);
3238 rows[0].chain_hash = "not-valid-hex!!".to_string();
3239 let outcome = verify_chain(&key, &rows);
3240 assert!(matches!(
3241 outcome,
3242 VerifyOutcome::Broken {
3243 first_broken_seq: 1,
3244 ..
3245 }
3246 ));
3247 }
3248
3249 #[test]
3250 fn bad_public_key_hex_yields_cannot_verify() {
3251 let key = fixed_key();
3252 let rows = build_chain(&key, 1);
3253 let outcome = verify_chain_with_pubkey("zz", &rows);
3254 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3255 }
3256
3257 #[test]
3258 fn load_or_generate_writes_public_key_sidecar() {
3259 let dir = tempfile::tempdir().unwrap();
3260 let path = dir.path().join("audit-key");
3261 let key = AuditKey::load_or_generate(&path).unwrap();
3262 let pub_path = dir.path().join("audit-key.pub");
3263 assert!(pub_path.exists(), "public key sidecar must be written");
3264 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
3265 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
3266 }
3267
3268 #[test]
3269 fn wrong_key_id_yields_cannot_verify() {
3270 let key = fixed_key();
3271 let mut rows = build_chain(&key, 2);
3272 rows[0].key_id = "v99".to_string();
3273 let outcome = verify_chain(&key, &rows);
3274 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3275 assert_eq!(outcome_to_exit_code(&outcome), 2);
3276 }
3277
3278 #[test]
3279 fn exit_code_for_each_outcome() {
3280 assert_eq!(
3281 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
3282 0
3283 );
3284 assert_eq!(
3285 outcome_to_exit_code(&VerifyOutcome::Broken {
3286 rows_checked: 0,
3287 first_broken_seq: 1,
3288 first_broken_transaction_id: String::new(),
3289 expected: String::new(),
3290 actual: String::new(),
3291 }),
3292 1
3293 );
3294 assert_eq!(
3295 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
3296 reason: String::new()
3297 }),
3298 2
3299 );
3300 }
3301
3302 #[test]
3305 fn load_or_generate_creates_with_0600_mode() {
3306 use std::os::unix::fs::PermissionsExt;
3307 let dir = tempfile::tempdir().unwrap();
3308 let path = dir.path().join("audit-key");
3309 let _key = AuditKey::load_or_generate(&path).unwrap();
3310 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
3311 assert_eq!(mode, 0o600);
3312 }
3313
3314 #[test]
3315 fn load_or_generate_is_idempotent() {
3316 let dir = tempfile::tempdir().unwrap();
3317 let path = dir.path().join("audit-key");
3318 let key1_bytes = {
3319 let _ = AuditKey::load_or_generate(&path).unwrap();
3320 std::fs::read(&path).unwrap()
3321 };
3322 let _ = AuditKey::load_or_generate(&path).unwrap();
3323 let key2_bytes = std::fs::read(&path).unwrap();
3324 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
3325 }
3326
3327 #[test]
3328 fn rejects_world_readable_key_file() {
3329 use std::os::unix::fs::PermissionsExt;
3330 let dir = tempfile::tempdir().unwrap();
3331 let path = dir.path().join("audit-key");
3332 std::fs::write(&path, vec![0u8; 32]).unwrap();
3333 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3334 let result = AuditKey::load_or_generate(&path);
3335 assert!(matches!(
3336 result,
3337 Err(AuditKeyError::UnsafePermissions { .. })
3338 ));
3339 }
3340
3341 #[test]
3342 fn rejects_short_key_file() {
3343 use std::os::unix::fs::PermissionsExt;
3344 let dir = tempfile::tempdir().unwrap();
3345 let path = dir.path().join("audit-key");
3346 std::fs::write(&path, vec![0u8; 8]).unwrap();
3347 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
3348 let result = AuditKey::load_or_generate(&path);
3349 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
3350 }
3351
3352 #[test]
3358 fn debug_redacts_key_bytes_and_their_hex_encoding() {
3359 let raw = (0u8..32).collect::<Vec<u8>>();
3361 let key = AuditKey::from_bytes(raw.clone());
3362 let dbg = format!("{key:?}");
3363
3364 assert!(
3365 dbg.contains("redacted"),
3366 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
3367 );
3368
3369 let hex_lower = hex::encode(&raw);
3371 let hex_upper = hex::encode_upper(&raw);
3372 assert!(
3373 !dbg.contains(&hex_lower),
3374 "Debug output leaks lowercase hex of key bytes: {dbg}"
3375 );
3376 assert!(
3377 !dbg.contains(&hex_upper),
3378 "Debug output leaks uppercase hex of key bytes: {dbg}"
3379 );
3380
3381 for b in &raw {
3385 let pair = format!("{b:02x}");
3386 if *b >= 0x10 && pair != "1e" && pair != "1f" {
3391 assert!(
3392 !dbg.contains(&pair),
3393 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
3394 );
3395 }
3396 }
3397 }
3398
3399 #[test]
3402 fn debug_preserves_key_id() {
3403 let key = AuditKey::from_bytes(vec![0xff; 32]);
3404 let dbg = format!("{key:?}");
3405 assert!(
3406 dbg.contains("key_id"),
3407 "Debug output must label the key_id field: {dbg}"
3408 );
3409 assert!(
3410 dbg.contains(CURRENT_KEY_ID),
3411 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
3412 );
3413 }
3414
3415 #[test]
3418 fn checkpoint_consistent_with_intact_chain() {
3419 let key = fixed_key();
3420 let rows = build_chain(&key, 5);
3421 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3422 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3423 assert_eq!(
3424 outcome,
3425 CheckpointOutcome::Consistent {
3426 checkpoints_checked: 1
3427 }
3428 );
3429 }
3430
3431 #[test]
3432 fn checkpoint_detects_truncation() {
3433 let key = fixed_key();
3434 let full = build_chain(&key, 5);
3435 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
3437 let truncated = &full[..3];
3438 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
3439 assert!(matches!(
3440 outcome,
3441 CheckpointOutcome::Truncated {
3442 checkpoint_seq: 5,
3443 current_max_seq: 3
3444 }
3445 ));
3446 }
3447
3448 #[test]
3449 fn checkpoint_detects_rewrite() {
3450 let key = fixed_key();
3451 let mut rows = build_chain(&key, 4);
3452 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3453 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
3455 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3456 assert!(matches!(
3457 outcome,
3458 CheckpointOutcome::TipMismatch { seq: 3, .. }
3459 ));
3460 }
3461
3462 #[test]
3463 fn checkpoint_bad_signature_detected() {
3464 let key = fixed_key();
3465 let rows = build_chain(&key, 3);
3466 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
3467 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3469 assert!(matches!(
3470 outcome,
3471 CheckpointOutcome::BadSignature { seq: 2 }
3472 ));
3473 }
3474
3475 #[test]
3476 fn checkpoint_foreign_key_rejected() {
3477 let signer = AuditKey::from_bytes(vec![0x11; 32]);
3479 let rows = build_chain(&signer, 3);
3480 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
3481 let other = AuditKey::from_bytes(vec![0x22; 32]);
3482 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
3483 assert!(matches!(
3484 outcome,
3485 CheckpointOutcome::BadSignature { seq: 2 }
3486 ));
3487 }
3488
3489 #[test]
3490 fn pubkey_verify_detects_tampered_middle_row() {
3491 let key = fixed_key();
3494 let mut rows = build_chain(&key, 4);
3495 rows[2].summary = "TAMPERED".to_string();
3496 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3497 assert!(matches!(
3498 outcome,
3499 VerifyOutcome::Broken {
3500 first_broken_seq: 3,
3501 ..
3502 }
3503 ));
3504 }
3505
3506 #[test]
3507 fn multiple_checkpoints_all_consistent() {
3508 let key = fixed_key();
3509 let rows = build_chain(&key, 5);
3510 let cps = vec![
3511 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
3512 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
3513 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
3514 ];
3515 assert_eq!(
3516 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
3517 CheckpointOutcome::Consistent {
3518 checkpoints_checked: 3
3519 }
3520 );
3521 }
3522
3523 #[test]
3524 fn middle_checkpoint_failure_is_reported() {
3525 let key = fixed_key();
3527 let mut rows = build_chain(&key, 5);
3528 let cps = vec![
3529 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
3530 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
3531 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
3532 ];
3533 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
3535 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
3536 CheckpointOutcome::TipMismatch { seq: 3, .. }
3537 ));
3538 }
3539
3540 #[test]
3541 fn checkpoint_created_at_is_signed() {
3542 let key = fixed_key();
3544 let rows = build_chain(&key, 3);
3545 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3546 cp.created_at = "2020-01-01T00:00:00Z".to_string();
3547 assert!(matches!(
3548 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
3549 CheckpointOutcome::BadSignature { .. }
3550 ));
3551 }
3552
3553 #[test]
3554 fn checkpoint_seq_is_signed() {
3555 let key = fixed_key();
3557 let rows = build_chain(&key, 3);
3558 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3559 cp.seq = 2;
3560 assert!(matches!(
3561 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
3562 CheckpointOutcome::BadSignature { seq: 2 }
3563 ));
3564 }
3565
3566 #[test]
3567 fn checkpoint_detects_full_wipe() {
3568 let key = fixed_key();
3570 let full = build_chain(&key, 3);
3571 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
3572 assert!(matches!(
3573 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
3574 CheckpointOutcome::Truncated {
3575 checkpoint_seq: 3,
3576 current_max_seq: 0
3577 }
3578 ));
3579 }
3580
3581 #[test]
3582 fn domain_tags_are_distinct_and_prefix_free() {
3583 let tags: [(&str, &[u8]); 4] = [
3590 ("row", ROW_DOMAIN),
3591 ("checkpoint", CHECKPOINT_DOMAIN),
3592 ("approval", APPROVAL_DOMAIN),
3593 ("event", EVENT_DOMAIN),
3594 ];
3595 for (i, (name_a, a)) in tags.iter().enumerate() {
3596 for (name_b, b) in tags.iter().skip(i + 1) {
3597 assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
3598 assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
3599 assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
3600 }
3601 }
3602 }
3603
3604 #[test]
3605 fn approval_receipt_framing_is_injective() {
3606 let key = fixed_key();
3611 let sep = "\u{1f}";
3612 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
3613 let b = key.approval_receipt("tx", &format!("b{sep}c"));
3614 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
3615
3616 assert_eq!(
3618 key.approval_receipt("tx-1", "hash-1"),
3619 key.approval_receipt("tx-1", "hash-1"),
3620 );
3621 assert_eq!(
3623 key.approval_commitment("tx-1", "hash-1"),
3624 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
3625 );
3626 }
3627}