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 unattributed_rows: u64,
1140}
1141
1142impl AuditVerification {
1143 pub fn exit_code(&self) -> i32 {
1149 let codes = [
1150 outcome_to_exit_code(&self.chain),
1151 outcome_to_exit_code(&self.events),
1152 binding_outcome_to_exit_code(&self.binding),
1153 ];
1154 if codes.contains(&1) {
1155 1
1156 } else if codes.contains(&2) {
1157 2
1158 } else {
1159 0
1160 }
1161 }
1162}
1163
1164fn count_unattributed(tx_rows: &[ChainRow]) -> u64 {
1169 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
1170 tx_rows
1171 .iter()
1172 .filter(|row| row.caller_principal.as_deref() == Some(unattributed.as_str()))
1173 .count() as u64
1174}
1175
1176pub fn verify_all(
1178 key: &AuditKey,
1179 tx_rows: &[ChainRow],
1180 event_rows: &[EventRow],
1181) -> AuditVerification {
1182 AuditVerification {
1183 chain: verify_chain(key, tx_rows),
1184 events: verify_event_chain(key, event_rows),
1185 binding: verify_event_binding(tx_rows, event_rows),
1186 unattributed_rows: count_unattributed(tx_rows),
1187 }
1188}
1189
1190pub fn verify_all_with_pubkey(
1192 verifying_key_hex: &str,
1193 tx_rows: &[ChainRow],
1194 event_rows: &[EventRow],
1195) -> AuditVerification {
1196 AuditVerification {
1197 chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
1198 events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
1199 binding: verify_event_binding(tx_rows, event_rows),
1200 unattributed_rows: count_unattributed(tx_rows),
1201 }
1202}
1203
1204pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
1207 match outcome {
1208 BindingOutcome::Consistent { .. } => 0,
1209 BindingOutcome::MissingEvent { .. } => 1,
1210 }
1211}
1212
1213#[derive(Debug, Clone, PartialEq, Eq)]
1229pub struct Checkpoint {
1230 pub seq: u64,
1233 pub chain_tip: String,
1235 pub created_at: String,
1237 pub signature: String,
1239}
1240
1241fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
1244 let mut buf = CHECKPOINT_DOMAIN.to_vec();
1247 push_field(&mut buf, "seq", &seq.to_string());
1248 push_field(&mut buf, "chain_tip", chain_tip);
1249 push_field(&mut buf, "created_at", created_at);
1250 buf
1251}
1252
1253impl AuditKey {
1254 pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
1256 let sig = self
1257 .signing
1258 .sign(&checkpoint_message(seq, chain_tip, created_at));
1259 Checkpoint {
1260 seq,
1261 chain_tip: chain_tip.to_string(),
1262 created_at: created_at.to_string(),
1263 signature: hex::encode(sig.to_bytes()),
1264 }
1265 }
1266}
1267
1268#[derive(Debug, Clone, PartialEq, Eq)]
1270pub enum CheckpointOutcome {
1271 Consistent { checkpoints_checked: u64 },
1274 BadSignature { seq: u64 },
1276 Truncated {
1279 checkpoint_seq: u64,
1280 current_max_seq: u64,
1281 },
1282 TipMismatch {
1285 seq: u64,
1286 anchored: String,
1287 actual: String,
1288 },
1289 CannotVerify { reason: String },
1291}
1292
1293pub fn verify_checkpoints(
1299 verifying_key_hex: &str,
1300 rows: &[ChainRow],
1301 checkpoints: &[Checkpoint],
1302) -> CheckpointOutcome {
1303 let vk = match parse_verifying_key(verifying_key_hex) {
1304 Some(vk) => vk,
1305 None => {
1306 return CheckpointOutcome::CannotVerify {
1307 reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
1308 };
1309 }
1310 };
1311 let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
1314 let mut checked = 0u64;
1315 for cp in checkpoints {
1316 let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
1317 if !signature_ok(&vk, &msg, &cp.signature) {
1318 return CheckpointOutcome::BadSignature { seq: cp.seq };
1319 }
1320 match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
1321 Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
1322 Ok(idx) => {
1323 return CheckpointOutcome::TipMismatch {
1324 seq: cp.seq,
1325 anchored: cp.chain_tip.clone(),
1326 actual: rows[idx].chain_hash.clone(),
1327 };
1328 }
1329 Err(_) => {
1330 return CheckpointOutcome::Truncated {
1331 checkpoint_seq: cp.seq,
1332 current_max_seq,
1333 };
1334 }
1335 }
1336 checked += 1;
1337 }
1338 CheckpointOutcome::Consistent {
1339 checkpoints_checked: checked,
1340 }
1341}
1342
1343pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
1348 match outcome {
1349 CheckpointOutcome::Consistent { .. } => 0,
1350 CheckpointOutcome::BadSignature { .. }
1351 | CheckpointOutcome::Truncated { .. }
1352 | CheckpointOutcome::TipMismatch { .. } => 1,
1353 CheckpointOutcome::CannotVerify { .. } => 2,
1354 }
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359 use super::*;
1360
1361 fn fixed_key() -> AuditKey {
1362 AuditKey::from_bytes(vec![0x42; 32])
1363 }
1364
1365 fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
1366 ChainContent {
1367 seq,
1368 key_id: CURRENT_KEY_ID,
1369 transaction_id: txid,
1370 request_id: "req-1",
1371 request_hash: "hash-abc",
1372 action_name: "UpdateSystem",
1373 risk_level: RiskLevel::High,
1374 summary: "Upgrade",
1375 approval_id: None,
1376 warnings_json: "[]",
1377 created_at: "2026-04-24T12:00:00Z",
1378 identity: ChainIdentity::V2 {
1379 caller_role: "Dev",
1380 event_tip: "",
1381 },
1382 }
1383 }
1384
1385 #[test]
1388 fn same_inputs_yield_same_hash() {
1389 let key = fixed_key();
1390 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1391 let h2 = key.chain_hash(&sample_content(1, "txa"), "");
1392 assert_eq!(h1, h2);
1393 assert_eq!(h1.len(), HASH_HEX_LEN);
1394 }
1395
1396 #[test]
1397 fn different_seq_yields_different_hash() {
1398 let key = fixed_key();
1399 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1400 let h2 = key.chain_hash(&sample_content(2, "txa"), "");
1401 assert_ne!(h1, h2);
1402 }
1403
1404 #[test]
1405 fn different_prev_hash_yields_different_hash() {
1406 let key = fixed_key();
1407 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1408 let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
1409 assert_ne!(h1, h2);
1410 }
1411
1412 #[test]
1413 fn different_keys_yield_different_hashes() {
1414 let key1 = AuditKey::from_bytes(vec![0x01; 32]);
1415 let key2 = AuditKey::from_bytes(vec![0x02; 32]);
1416 let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
1417 let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
1418 assert_ne!(h1, h2);
1419 }
1420
1421 #[test]
1424 fn canonical_bytes_have_stable_field_order() {
1425 let c = sample_content(1, "txa");
1426 let bytes = c.canonical_bytes();
1427 let s = String::from_utf8_lossy(&bytes);
1428 let order = [
1430 "seq",
1431 "key_id",
1432 "transaction_id",
1433 "request_id",
1434 "request_hash",
1435 "action_name",
1436 "risk_level",
1437 "summary",
1438 "approval_id",
1439 "warnings_json",
1440 "created_at",
1441 ];
1442 let mut last_idx = 0;
1443 for tag in order {
1444 let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
1445 assert!(idx >= last_idx, "{tag} out of order");
1446 last_idx = idx;
1447 }
1448 }
1449
1450 #[test]
1451 fn nul_bytes_in_field_value_are_escaped() {
1452 let mut c = sample_content(1, "txa");
1453 let summary = "before\0after";
1454 c.summary = summary;
1455 let bytes = c.canonical_bytes();
1456 assert!(!bytes.contains(&0x00));
1458 assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
1459 }
1460
1461 #[test]
1467 fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
1468 let mut a = sample_content(1, "txa");
1469 a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
1471 b.summary = "before\0after"; assert_ne!(
1473 a.canonical_bytes(),
1474 b.canonical_bytes(),
1475 "backslash escape must run before NUL escape to prevent collision"
1476 );
1477 }
1478
1479 fn key_for_collision_test() -> AuditKey {
1480 AuditKey::from_bytes(vec![0xab; 32])
1481 }
1482
1483 #[test]
1484 fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
1485 let key = key_for_collision_test();
1486 let mut a = sample_content(1, "txa");
1487 a.summary = "before\\0after";
1488 let mut b = sample_content(1, "txa");
1489 b.summary = "before\0after";
1490 assert_ne!(
1491 key.chain_hash(&a, ""),
1492 key.chain_hash(&b, ""),
1493 "chain signature must distinguish escape from raw byte"
1494 );
1495 }
1496
1497 fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
1503 let (chain_version, caller_role, event_tip, caller_principal) = match content.identity {
1504 ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None, None),
1505 ChainIdentity::V2 {
1506 caller_role,
1507 event_tip,
1508 } => (
1509 CHAIN_VERSION_V2,
1510 Some(caller_role.to_string()),
1511 Some(event_tip.to_string()),
1512 None,
1513 ),
1514 ChainIdentity::V3 {
1515 caller_role,
1516 event_tip,
1517 caller_principal,
1518 } => (
1519 CHAIN_VERSION_V3,
1520 Some(caller_role.to_string()),
1521 Some(event_tip.to_string()),
1522 Some(caller_principal.to_string()),
1523 ),
1524 };
1525 ChainRow {
1526 seq: content.seq,
1527 key_id: content.key_id.to_string(),
1528 transaction_id: content.transaction_id.to_string(),
1529 request_id: content.request_id.to_string(),
1530 request_hash: content.request_hash.to_string(),
1531 action_name: content.action_name.to_string(),
1532 risk_level: content.risk_level,
1533 summary: content.summary.to_string(),
1534 approval_id: content.approval_id.map(str::to_string),
1535 warnings_json: content.warnings_json.to_string(),
1536 created_at: content.created_at.to_string(),
1537 prev_chain_hash: prev.to_string(),
1538 chain_hash,
1539 chain_version,
1540 caller_role,
1541 event_tip,
1542 caller_principal,
1543 }
1544 }
1545
1546 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
1547 let mut rows = Vec::with_capacity(count);
1548 let mut prev = String::new();
1549 for i in 0..count {
1550 let seq = (i + 1) as u64;
1551 let txid = format!("tx{i}");
1552 let content = sample_content(seq, &txid);
1553 let hash = key.chain_hash(&content, &prev);
1554 rows.push(row_for(&content, &prev, hash.clone()));
1555 prev = hash;
1556 }
1557 rows
1558 }
1559
1560 #[test]
1563 fn caller_role_is_covered_by_the_row_signature() {
1564 let key = fixed_key();
1568 let mut dev = sample_content(1, "txa");
1569 dev.identity = ChainIdentity::V2 {
1570 caller_role: "Dev",
1571 event_tip: "",
1572 };
1573 let mut boot = sample_content(1, "txa");
1574 boot.identity = ChainIdentity::V2 {
1575 caller_role: "Boot",
1576 event_tip: "",
1577 };
1578 assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
1579 }
1580
1581 #[test]
1582 fn event_tip_is_covered_by_the_row_signature() {
1583 let key = fixed_key();
1584 let mut empty = sample_content(1, "txa");
1585 empty.identity = ChainIdentity::V2 {
1586 caller_role: "Dev",
1587 event_tip: "",
1588 };
1589 let mut bound = sample_content(1, "txa");
1590 bound.identity = ChainIdentity::V2 {
1591 caller_role: "Dev",
1592 event_tip: "abc123",
1593 };
1594 assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
1595 }
1596
1597 #[test]
1598 fn a_legacy_row_written_before_the_migration_still_verifies() {
1599 let key = fixed_key();
1604 let mut content = sample_content(1, "tx-legacy");
1605 content.identity = ChainIdentity::LegacyV1;
1606 let hash = key.chain_hash(&content, "");
1607 let row = row_for(&content, "", hash);
1608 assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
1609 assert_eq!(row.caller_role, None);
1610 assert_eq!(
1611 verify_chain(&key, &[row]),
1612 VerifyOutcome::Intact { rows_checked: 1 }
1613 );
1614 }
1615
1616 #[test]
1622 fn two_admins_are_distinguishable_in_the_signed_record() {
1623 let key = fixed_key();
1624 let mut alice = sample_content(1, "tx-alice");
1625 alice.identity = ChainIdentity::V3 {
1626 caller_role: "admin",
1627 event_tip: "",
1628 caller_principal: "uid:1000",
1629 };
1630 let mut bob = sample_content(1, "tx-alice");
1631 bob.identity = ChainIdentity::V3 {
1632 caller_role: "admin",
1633 event_tip: "",
1634 caller_principal: "uid:1001",
1635 };
1636
1637 assert_ne!(
1638 key.chain_hash(&alice, ""),
1639 key.chain_hash(&bob, ""),
1640 "identical rows differing only in principal must sign differently, \
1641 otherwise the chain still cannot answer which account acted"
1642 );
1643 }
1644
1645 #[test]
1648 fn editing_the_stored_principal_breaks_the_row() {
1649 let key = fixed_key();
1650 let mut content = sample_content(1, "tx1");
1651 content.identity = ChainIdentity::V3 {
1652 caller_role: "admin",
1653 event_tip: "",
1654 caller_principal: "uid:1000",
1655 };
1656 let hash = key.chain_hash(&content, "");
1657 let mut row = row_for(&content, "", hash);
1658 row.caller_principal = Some("uid:0".to_string());
1659
1660 assert!(
1661 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1662 "a principal swapped to root must not verify"
1663 );
1664 }
1665
1666 #[test]
1670 fn downgrading_a_v3_row_to_v2_to_hide_the_account_breaks_it() {
1671 let key = fixed_key();
1672 let mut content = sample_content(1, "tx1");
1673 content.identity = ChainIdentity::V3 {
1674 caller_role: "admin",
1675 event_tip: "",
1676 caller_principal: "uid:1000",
1677 };
1678 let hash = key.chain_hash(&content, "");
1679 let mut row = row_for(&content, "", hash);
1680 row.chain_version = CHAIN_VERSION_V2;
1681 row.caller_principal = None;
1682
1683 assert!(
1684 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1685 "a v3 row relabelled as v2 must not verify"
1686 );
1687 }
1688
1689 #[test]
1694 fn a_v3_row_without_a_usable_principal_is_broken() {
1695 let key = fixed_key();
1696 let mut content = sample_content(1, "tx1");
1697 content.identity = ChainIdentity::V3 {
1698 caller_role: "admin",
1699 event_tip: "",
1700 caller_principal: "uid:1000",
1701 };
1702 let hash = key.chain_hash(&content, "");
1703
1704 for absent in [None, Some(String::new())] {
1705 let mut row = row_for(&content, "", hash.clone());
1706 row.caller_principal = absent.clone();
1707 assert!(
1708 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1709 "a v3 row with principal {absent:?} must be reported as broken"
1710 );
1711 }
1712 }
1713
1714 #[test]
1724 fn a_row_signed_with_a_blank_principal_is_rejected_even_though_it_signs() {
1725 let key = fixed_key();
1726 let mut content = sample_content(1, "tx-blank");
1727 content.identity = ChainIdentity::V3 {
1728 caller_role: "admin",
1729 event_tip: "",
1730 caller_principal: "",
1731 };
1732 let hash = key.chain_hash(&content, "");
1733 let row = row_for(&content, "", hash);
1738 assert_eq!(
1739 row.caller_principal.as_deref(),
1740 Some(""),
1741 "fixture must store the blank principal it signed"
1742 );
1743 assert_eq!(row.chain_version, CHAIN_VERSION_V3);
1744 assert!(
1745 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
1746 "a v3 row naming nobody must be broken, not accepted"
1747 );
1748 }
1749
1750 #[test]
1755 fn a_row_recording_an_attribution_failure_verifies() {
1756 let key = fixed_key();
1757 let principal = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
1758 let mut content = sample_content(1, "tx-unattributed");
1759 content.identity = ChainIdentity::V3 {
1760 caller_role: "observer",
1761 event_tip: "",
1762 caller_principal: &principal,
1763 };
1764 let hash = key.chain_hash(&content, "");
1765 let row = row_for(&content, "", hash);
1766
1767 assert_eq!(
1768 verify_chain(&key, &[row]),
1769 VerifyOutcome::Intact { rows_checked: 1 }
1770 );
1771 }
1772
1773 #[test]
1777 fn a_chain_spanning_all_three_encodings_verifies() {
1778 let key = fixed_key();
1779 let mut rows = Vec::new();
1780 let mut prev = String::new();
1781 for (i, identity) in [
1782 ChainIdentity::LegacyV1,
1783 ChainIdentity::V2 {
1784 caller_role: "dev",
1785 event_tip: "",
1786 },
1787 ChainIdentity::V3 {
1788 caller_role: "admin",
1789 event_tip: "",
1790 caller_principal: "uid:1000",
1791 },
1792 ChainIdentity::V3 {
1793 caller_role: "admin",
1794 event_tip: "",
1795 caller_principal: "token:vsock",
1796 },
1797 ]
1798 .into_iter()
1799 .enumerate()
1800 {
1801 let txid = format!("tx{i}");
1802 let mut content = sample_content((i + 1) as u64, &txid);
1803 content.identity = identity;
1804 let hash = key.chain_hash(&content, &prev);
1805 rows.push(row_for(&content, &prev, hash.clone()));
1806 prev = hash;
1807 }
1808
1809 assert_eq!(
1810 verify_chain(&key, &rows),
1811 VerifyOutcome::Intact { rows_checked: 4 }
1812 );
1813 }
1814
1815 #[test]
1820 fn a_future_encoding_reports_cannot_verify_with_the_supported_range() {
1821 let key = fixed_key();
1822 let content = sample_content(1, "tx1");
1823 let hash = key.chain_hash(&content, "");
1824 let mut row = row_for(&content, "", hash);
1825 row.chain_version = CHAIN_VERSION_CURRENT + 1;
1826
1827 match verify_chain(&key, &[row]) {
1828 VerifyOutcome::CannotVerify { reason } => {
1829 assert!(
1830 reason.contains(&format!("chain_version={}", CHAIN_VERSION_CURRENT + 1)),
1831 "name the version found, got: {reason}"
1832 );
1833 assert!(
1834 reason.contains(&CHAIN_VERSION_CURRENT.to_string()),
1835 "and the newest version understood, got: {reason}"
1836 );
1837 }
1838 other => panic!("a future encoding must not be reported as tampering: {other:?}"),
1839 }
1840 }
1841
1842 #[test]
1855 fn a_row_written_by_the_previous_release_still_verifies() {
1856 const GOLDEN_V2_CHAIN_HASH: &str = "ba12cbe49a149387898bc30f1e8d409effa025ab8e440e6e72bedf060afc831e45d9be264d6b18b7e0ef2f2b3584dbc6953b535546cecbbdc3d7ac666cae4d0a";
1857
1858 let key = fixed_key();
1859 let row = ChainRow {
1860 seq: 7,
1861 key_id: CURRENT_KEY_ID.to_string(),
1862 transaction_id: "tx-golden".to_string(),
1863 request_id: "req-golden".to_string(),
1864 request_hash: "hash-golden".to_string(),
1865 action_name: "AptInstall".to_string(),
1866 risk_level: RiskLevel::Medium,
1867 summary: "install ripgrep".to_string(),
1868 approval_id: Some("appr-golden".to_string()),
1869 warnings_json: "[]".to_string(),
1870 created_at: "2026-07-29T00:00:00Z".to_string(),
1871 prev_chain_hash: String::new(),
1872 chain_hash: GOLDEN_V2_CHAIN_HASH.to_string(),
1873 chain_version: 2,
1874 caller_role: Some("admin".to_string()),
1875 event_tip: Some("eventtip-golden".to_string()),
1876 caller_principal: None,
1877 };
1878
1879 assert_eq!(
1880 verify_chain(&key, std::slice::from_ref(&row)),
1881 VerifyOutcome::Intact { rows_checked: 1 },
1882 "a v2 row on disk must verify under this binary; if this fails, the v2 \
1883 encoding or its version dispatch changed and existing audit logs are unreadable"
1884 );
1885
1886 let mut tampered = row;
1890 let flipped = match GOLDEN_V2_CHAIN_HASH.strip_prefix('b') {
1891 Some(rest) => format!("a{rest}"),
1892 None => format!("b{}", &GOLDEN_V2_CHAIN_HASH[1..]),
1893 };
1894 tampered.chain_hash = flipped;
1895 assert!(
1896 matches!(
1897 verify_chain(&key, &[tampered]),
1898 VerifyOutcome::Broken { .. }
1899 ),
1900 "a golden row with one nibble changed must not verify"
1901 );
1902 }
1903
1904 #[test]
1908 fn a_legacy_v1_row_on_disk_still_verifies() {
1909 const GOLDEN_V1_CHAIN_HASH: &str = "ce8b47c15414e989e099303b826fcd9985360b794220cefbbf9689d25f31fe173283687feb6d1dd265ae5aca94c8bcfb87ce33ba8c954fafdad18a59cd97c702";
1910
1911 let row = ChainRow {
1912 chain_version: 1,
1913 caller_role: None,
1914 event_tip: None,
1915 caller_principal: None,
1916 chain_hash: GOLDEN_V1_CHAIN_HASH.to_string(),
1917 ..golden_row_shape()
1918 };
1919 assert_eq!(
1920 verify_chain(&fixed_key(), &[row]),
1921 VerifyOutcome::Intact { rows_checked: 1 }
1922 );
1923 }
1924
1925 #[test]
1929 fn a_v3_row_on_disk_still_verifies() {
1930 const GOLDEN_V3_CHAIN_HASH: &str = "3c0d05f6ccaf9e65ccb796d4ffe96eb31a2ea67e446217c5234af916dc959d0f2c0ee3d3e5b525aece791ee7d6507efb98dcdd79f0d74dde4273859aa9627d08";
1931
1932 let row = ChainRow {
1933 chain_version: 3,
1934 caller_role: Some("admin".to_string()),
1935 event_tip: Some("eventtip-golden".to_string()),
1936 caller_principal: Some("uid:1000".to_string()),
1937 chain_hash: GOLDEN_V3_CHAIN_HASH.to_string(),
1938 ..golden_row_shape()
1939 };
1940 assert_eq!(
1941 verify_chain(&fixed_key(), &[row]),
1942 VerifyOutcome::Intact { rows_checked: 1 }
1943 );
1944 }
1945
1946 #[test]
1953 fn the_escape_table_and_absent_approval_encoding_are_frozen() {
1954 const GOLDEN_V2_AWKWARD_CHAIN_HASH: &str = "e6face6fe2346fc0f6c5c1e5845255579c8f08738406b88b0a1f6dbdf8ff85090a4b80d95bd0927ef54b0a2c4810d52ebb4bbf76cb68c865a7748df26442aa05";
1955
1956 let key = fixed_key();
1957 let content = ChainContent {
1958 seq: 8,
1959 approval_id: None,
1960 summary: "back\\slash and \x1funit and \x1erecord and nul\0byte",
1961 identity: ChainIdentity::V2 {
1962 caller_role: "dev",
1963 event_tip: "",
1964 },
1965 ..golden_content_shape()
1966 };
1967 assert_eq!(
1968 key.chain_hash(&content, "ba12cbe4"),
1969 GOLDEN_V2_AWKWARD_CHAIN_HASH,
1970 "the canonical encoding of escapes, an absent approval id, or a \
1971 non-empty prev_chain_hash changed"
1972 );
1973 }
1974
1975 fn golden_content_shape() -> ChainContent<'static> {
1979 ChainContent {
1980 seq: 7,
1981 key_id: CURRENT_KEY_ID,
1982 transaction_id: "tx-golden",
1983 request_id: "req-golden",
1984 request_hash: "hash-golden",
1985 action_name: "AptInstall",
1986 risk_level: RiskLevel::Medium,
1987 summary: "install ripgrep",
1988 approval_id: Some("appr-golden"),
1989 warnings_json: "[]",
1990 created_at: "2026-07-29T00:00:00Z",
1991 identity: ChainIdentity::LegacyV1,
1992 }
1993 }
1994
1995 fn golden_row_shape() -> ChainRow {
1997 ChainRow {
1998 seq: 7,
1999 key_id: CURRENT_KEY_ID.to_string(),
2000 transaction_id: "tx-golden".to_string(),
2001 request_id: "req-golden".to_string(),
2002 request_hash: "hash-golden".to_string(),
2003 action_name: "AptInstall".to_string(),
2004 risk_level: RiskLevel::Medium,
2005 summary: "install ripgrep".to_string(),
2006 approval_id: Some("appr-golden".to_string()),
2007 warnings_json: "[]".to_string(),
2008 created_at: "2026-07-29T00:00:00Z".to_string(),
2009 prev_chain_hash: String::new(),
2010 chain_hash: String::new(),
2011 chain_version: 1,
2012 caller_role: None,
2013 event_tip: None,
2014 caller_principal: None,
2015 }
2016 }
2017
2018 #[test]
2019 fn a_chain_that_spans_the_migration_verifies_end_to_end() {
2020 let key = fixed_key();
2023 let mut rows = Vec::new();
2024 let mut prev = String::new();
2025 for (i, identity) in [
2026 ChainIdentity::LegacyV1,
2027 ChainIdentity::LegacyV1,
2028 ChainIdentity::V2 {
2029 caller_role: "Dev",
2030 event_tip: "",
2031 },
2032 ChainIdentity::V2 {
2033 caller_role: "Boot",
2034 event_tip: "",
2035 },
2036 ]
2037 .into_iter()
2038 .enumerate()
2039 {
2040 let txid = format!("tx{i}");
2041 let mut content = sample_content((i + 1) as u64, &txid);
2042 content.identity = identity;
2043 let hash = key.chain_hash(&content, &prev);
2044 rows.push(row_for(&content, &prev, hash.clone()));
2045 prev = hash;
2046 }
2047 assert_eq!(
2048 verify_chain(&key, &rows),
2049 VerifyOutcome::Intact { rows_checked: 4 }
2050 );
2051 }
2052
2053 #[test]
2054 fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
2055 let key = fixed_key();
2060 let mut rows = build_chain(&key, 2);
2061 rows[1].chain_version = CHAIN_VERSION_LEGACY;
2062 rows[1].caller_role = None;
2063 rows[1].event_tip = None;
2064 match verify_chain(&key, &rows) {
2065 VerifyOutcome::Broken {
2066 first_broken_seq, ..
2067 } => assert_eq!(first_broken_seq, 2),
2068 other => panic!("expected Broken, got {other:?}"),
2069 }
2070 }
2071
2072 #[test]
2073 fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
2074 let key = fixed_key();
2079 let mut rows = build_chain(&key, 1);
2080 rows[0].caller_role = None;
2081 let outcome = verify_chain(&key, &rows);
2082 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2083 assert_eq!(outcome_to_exit_code(&outcome), 1);
2084 }
2085
2086 #[test]
2087 fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
2088 let key = fixed_key();
2091 let mut rows = build_chain(&key, 1);
2092 rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
2093 let outcome = verify_chain(&key, &rows);
2094 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
2095 assert_eq!(outcome_to_exit_code(&outcome), 2);
2096 }
2097
2098 fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
2101 EventContent {
2102 seq,
2103 key_id: CURRENT_KEY_ID,
2104 kind,
2105 transaction_id: txid,
2106 receipt_digest: "digest-abc",
2107 created_at: "2026-04-24T12:00:00Z",
2108 }
2109 }
2110
2111 fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
2112 let mut rows = Vec::with_capacity(kinds.len());
2113 let mut prev = String::new();
2114 for (i, kind) in kinds.iter().enumerate() {
2115 let seq = (i + 1) as u64;
2116 let txid = format!("tx{i}");
2117 let content = event_content(seq, *kind, &txid);
2118 let hash = key.event_hash(&content, &prev);
2119 rows.push(EventRow {
2120 seq,
2121 key_id: content.key_id.to_string(),
2122 kind: content.kind.as_str().to_string(),
2123 transaction_id: content.transaction_id.to_string(),
2124 receipt_digest: content.receipt_digest.to_string(),
2125 created_at: content.created_at.to_string(),
2126 prev_chain_hash: prev.clone(),
2127 chain_hash: hash.clone(),
2128 });
2129 prev = hash;
2130 }
2131 rows
2132 }
2133
2134 #[test]
2135 fn event_kind_spellings_are_stable() {
2136 assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
2139 assert_eq!(
2140 AuditEventKind::ApprovalConsumed.as_str(),
2141 "approval_consumed"
2142 );
2143 assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
2144 }
2145
2146 #[test]
2147 fn intact_event_chain_verifies() {
2148 let key = fixed_key();
2149 let rows = build_event_chain(
2150 &key,
2151 &[
2152 AuditEventKind::ApprovalGranted,
2153 AuditEventKind::ApprovalConsumed,
2154 ],
2155 );
2156 assert_eq!(
2157 verify_event_chain(&key, &rows),
2158 VerifyOutcome::Intact { rows_checked: 2 }
2159 );
2160 }
2161
2162 #[test]
2163 fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
2164 let key = fixed_key();
2167 let mut rows = build_event_chain(
2168 &key,
2169 &[
2170 AuditEventKind::ApprovalGranted,
2171 AuditEventKind::ApprovalConsumed,
2172 AuditEventKind::ApprovalRevoked,
2173 ],
2174 );
2175 rows.remove(1);
2176 match verify_event_chain(&key, &rows) {
2177 VerifyOutcome::Broken {
2178 first_broken_seq, ..
2179 } => assert_eq!(first_broken_seq, 3),
2180 other => panic!("expected Broken, got {other:?}"),
2181 }
2182 }
2183
2184 #[test]
2185 fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
2186 let key = fixed_key();
2187 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
2188 rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
2189 assert!(matches!(
2190 verify_event_chain(&key, &rows),
2191 VerifyOutcome::Broken { .. }
2192 ));
2193 }
2194
2195 #[test]
2196 fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
2197 let key = fixed_key();
2198 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
2199 rows[0].kind = "approval_unicorned".to_string();
2200 assert!(matches!(
2201 verify_event_chain(&key, &rows),
2202 VerifyOutcome::Broken { .. }
2203 ));
2204 }
2205
2206 #[test]
2207 fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
2208 let key = fixed_key();
2212 let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
2213 let event_sig = key.event_hash(&content, "");
2214 let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
2215 assert_ne!(event_sig, row_sig);
2216
2217 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
2218 rows[0].chain_hash = row_sig;
2219 assert!(matches!(
2220 verify_event_chain(&key, &rows),
2221 VerifyOutcome::Broken { .. }
2222 ));
2223 }
2224
2225 #[test]
2228 fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
2229 let key = fixed_key();
2234 let events = build_event_chain(
2235 &key,
2236 &[
2237 AuditEventKind::ApprovalGranted,
2238 AuditEventKind::ApprovalConsumed,
2239 ],
2240 );
2241 let mut content = sample_content(1, "tx-after");
2242 content.identity = ChainIdentity::V2 {
2243 caller_role: "Dev",
2244 event_tip: &events[1].chain_hash,
2245 };
2246 let hash = key.chain_hash(&content, "");
2247 let tx_rows = vec![row_for(&content, "", hash)];
2248
2249 assert_eq!(
2250 verify_event_binding(&tx_rows, &events),
2251 BindingOutcome::Consistent {
2252 bindings_checked: 1
2253 }
2254 );
2255
2256 let truncated = &events[..1];
2257 assert_eq!(
2258 verify_event_chain(&key, truncated),
2259 VerifyOutcome::Intact { rows_checked: 1 },
2260 "a truncated event chain still walks clean; the binding is the detector"
2261 );
2262 match verify_event_binding(&tx_rows, truncated) {
2263 BindingOutcome::MissingEvent {
2264 transaction_seq, ..
2265 } => assert_eq!(transaction_seq, 1),
2266 other => panic!("expected MissingEvent, got {other:?}"),
2267 }
2268 }
2269
2270 #[test]
2271 fn legacy_rows_carry_no_binding_to_check() {
2272 let key = fixed_key();
2275 let mut content = sample_content(1, "tx-legacy");
2276 content.identity = ChainIdentity::LegacyV1;
2277 let hash = key.chain_hash(&content, "");
2278 let rows = vec![row_for(&content, "", hash)];
2279 assert_eq!(
2280 verify_event_binding(&rows, &[]),
2281 BindingOutcome::Consistent {
2282 bindings_checked: 0
2283 }
2284 );
2285 }
2286
2287 #[test]
2288 fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
2289 let verification = AuditVerification {
2294 chain: VerifyOutcome::Broken {
2295 rows_checked: 1,
2296 first_broken_seq: 2,
2297 first_broken_transaction_id: "tx".to_string(),
2298 expected: "e".to_string(),
2299 actual: "a".to_string(),
2300 },
2301 events: VerifyOutcome::CannotVerify {
2302 reason: "no key".to_string(),
2303 },
2304 binding: BindingOutcome::Consistent {
2305 bindings_checked: 0,
2306 },
2307 unattributed_rows: 0,
2308 };
2309 assert_eq!(verification.exit_code(), 1);
2310 }
2311
2312 #[test]
2313 fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
2314 let verification = AuditVerification {
2315 chain: VerifyOutcome::Intact { rows_checked: 3 },
2316 events: VerifyOutcome::Intact { rows_checked: 1 },
2317 binding: BindingOutcome::MissingEvent {
2318 transaction_seq: 3,
2319 event_tip: "abc".to_string(),
2320 },
2321 unattributed_rows: 0,
2322 };
2323 assert_eq!(verification.exit_code(), 1);
2324 }
2325
2326 #[test]
2327 fn all_three_clean_is_exit_zero() {
2328 let verification = AuditVerification {
2329 chain: VerifyOutcome::Intact { rows_checked: 3 },
2330 events: VerifyOutcome::Intact { rows_checked: 2 },
2331 binding: BindingOutcome::Consistent {
2332 bindings_checked: 1,
2333 },
2334 unattributed_rows: 0,
2335 };
2336 assert_eq!(verification.exit_code(), 0);
2337 }
2338
2339 #[test]
2340 fn binding_exit_codes_split_clean_from_tampered() {
2341 assert_eq!(
2342 binding_outcome_to_exit_code(&BindingOutcome::Consistent {
2343 bindings_checked: 0
2344 }),
2345 0
2346 );
2347 assert_eq!(
2348 binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
2349 transaction_seq: 7,
2350 event_tip: "abc".to_string()
2351 }),
2352 1
2353 );
2354 }
2355
2356 #[test]
2357 fn intact_chain_verifies() {
2358 let key = fixed_key();
2359 let rows = build_chain(&key, 5);
2360 let outcome = verify_chain(&key, &rows);
2361 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
2362 assert_eq!(outcome_to_exit_code(&outcome), 0);
2363 }
2364
2365 #[test]
2366 fn empty_chain_verifies() {
2367 let key = fixed_key();
2368 let outcome = verify_chain(&key, &[]);
2369 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
2370 }
2371
2372 #[test]
2373 fn tampered_summary_breaks_chain_at_first_offending_row() {
2374 let key = fixed_key();
2375 let mut rows = build_chain(&key, 3);
2376 rows[1].summary = "TAMPERED".to_string();
2378 let outcome = verify_chain(&key, &rows);
2379 match outcome {
2380 VerifyOutcome::Broken {
2381 first_broken_seq, ..
2382 } => assert_eq!(first_broken_seq, 2),
2383 other => panic!("expected Broken, got {other:?}"),
2384 }
2385 }
2386
2387 #[test]
2388 fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
2389 let key = fixed_key();
2390 let mut rows = build_chain(&key, 4);
2391 rows.remove(1);
2393 let outcome = verify_chain(&key, &rows);
2394 match outcome {
2395 VerifyOutcome::Broken {
2396 first_broken_seq, ..
2397 } => assert_eq!(
2398 first_broken_seq, 3,
2399 "first broken row is the one whose prev_hash refers to deleted row"
2400 ),
2401 other => panic!("expected Broken, got {other:?}"),
2402 }
2403 }
2404
2405 #[test]
2406 fn inserted_forged_row_breaks_chain() {
2407 let key = fixed_key();
2414 let mut rows = build_chain(&key, 3);
2415
2416 let forged = ChainRow {
2418 caller_principal: Some("uid:1000".to_string()),
2422 seq: 2,
2423 key_id: CURRENT_KEY_ID.to_string(),
2424 transaction_id: "tx-forged".to_string(),
2425 request_id: "req-forged".to_string(),
2426 request_hash: "hash-forged".to_string(),
2427 action_name: "InstallFlatpak".to_string(),
2428 risk_level: RiskLevel::Medium,
2429 summary: "Forged row".to_string(),
2430 approval_id: None,
2431 warnings_json: "[]".to_string(),
2432 created_at: "2026-04-25T13:00:00Z".to_string(),
2433 prev_chain_hash: rows[0].chain_hash.clone(),
2435 chain_hash: "0".repeat(HASH_HEX_LEN),
2437 chain_version: CHAIN_VERSION_CURRENT,
2438 caller_role: Some("Dev".to_string()),
2439 event_tip: Some(String::new()),
2440 };
2441
2442 let mut rest: Vec<ChainRow> = rows.split_off(1);
2444 for r in rest.iter_mut() {
2445 r.seq += 1;
2446 }
2447 rows.push(forged);
2448 rows.extend(rest);
2449
2450 let outcome = verify_chain(&key, &rows);
2454 match outcome {
2455 VerifyOutcome::Broken {
2456 first_broken_seq, ..
2457 } => assert_eq!(
2458 first_broken_seq, 2,
2459 "the verifier must flag the forged row itself, not a later one"
2460 ),
2461 other => panic!("expected Broken, got {other:?}"),
2462 }
2463 }
2464
2465 #[test]
2468 fn signature_verifies_under_exported_public_key() {
2469 let key = fixed_key();
2471 let rows = build_chain(&key, 4);
2472 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
2473 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
2474 }
2475
2476 #[test]
2477 fn foreign_public_key_cannot_validate_chain() {
2478 let signer = AuditKey::from_bytes(vec![0x11; 32]);
2482 let rows = build_chain(&signer, 3);
2483 let other = AuditKey::from_bytes(vec![0x22; 32]);
2484 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
2485 assert!(matches!(
2486 outcome,
2487 VerifyOutcome::Broken {
2488 first_broken_seq: 1,
2489 ..
2490 }
2491 ));
2492 }
2493
2494 #[test]
2495 fn verifying_key_hex_is_a_32_byte_public_key() {
2496 let key = fixed_key();
2497 let vk_hex = key.verifying_key_hex();
2498 assert_eq!(
2499 vk_hex.len(),
2500 64,
2501 "32-byte ed25519 public key = 64 hex chars"
2502 );
2503 assert!(hex::decode(&vk_hex).is_ok());
2504 }
2505
2506 #[test]
2507 fn malformed_signature_hex_is_broken_not_panic() {
2508 let key = fixed_key();
2509 let mut rows = build_chain(&key, 2);
2510 rows[0].chain_hash = "not-valid-hex!!".to_string();
2511 let outcome = verify_chain(&key, &rows);
2512 assert!(matches!(
2513 outcome,
2514 VerifyOutcome::Broken {
2515 first_broken_seq: 1,
2516 ..
2517 }
2518 ));
2519 }
2520
2521 #[test]
2522 fn bad_public_key_hex_yields_cannot_verify() {
2523 let key = fixed_key();
2524 let rows = build_chain(&key, 1);
2525 let outcome = verify_chain_with_pubkey("zz", &rows);
2526 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
2527 }
2528
2529 #[test]
2530 fn load_or_generate_writes_public_key_sidecar() {
2531 let dir = tempfile::tempdir().unwrap();
2532 let path = dir.path().join("audit-key");
2533 let key = AuditKey::load_or_generate(&path).unwrap();
2534 let pub_path = dir.path().join("audit-key.pub");
2535 assert!(pub_path.exists(), "public key sidecar must be written");
2536 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
2537 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
2538 }
2539
2540 #[test]
2541 fn wrong_key_id_yields_cannot_verify() {
2542 let key = fixed_key();
2543 let mut rows = build_chain(&key, 2);
2544 rows[0].key_id = "v99".to_string();
2545 let outcome = verify_chain(&key, &rows);
2546 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
2547 assert_eq!(outcome_to_exit_code(&outcome), 2);
2548 }
2549
2550 #[test]
2551 fn exit_code_for_each_outcome() {
2552 assert_eq!(
2553 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
2554 0
2555 );
2556 assert_eq!(
2557 outcome_to_exit_code(&VerifyOutcome::Broken {
2558 rows_checked: 0,
2559 first_broken_seq: 1,
2560 first_broken_transaction_id: String::new(),
2561 expected: String::new(),
2562 actual: String::new(),
2563 }),
2564 1
2565 );
2566 assert_eq!(
2567 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
2568 reason: String::new()
2569 }),
2570 2
2571 );
2572 }
2573
2574 #[test]
2577 fn load_or_generate_creates_with_0600_mode() {
2578 use std::os::unix::fs::PermissionsExt;
2579 let dir = tempfile::tempdir().unwrap();
2580 let path = dir.path().join("audit-key");
2581 let _key = AuditKey::load_or_generate(&path).unwrap();
2582 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2583 assert_eq!(mode, 0o600);
2584 }
2585
2586 #[test]
2587 fn load_or_generate_is_idempotent() {
2588 let dir = tempfile::tempdir().unwrap();
2589 let path = dir.path().join("audit-key");
2590 let key1_bytes = {
2591 let _ = AuditKey::load_or_generate(&path).unwrap();
2592 std::fs::read(&path).unwrap()
2593 };
2594 let _ = AuditKey::load_or_generate(&path).unwrap();
2595 let key2_bytes = std::fs::read(&path).unwrap();
2596 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
2597 }
2598
2599 #[test]
2600 fn rejects_world_readable_key_file() {
2601 use std::os::unix::fs::PermissionsExt;
2602 let dir = tempfile::tempdir().unwrap();
2603 let path = dir.path().join("audit-key");
2604 std::fs::write(&path, vec![0u8; 32]).unwrap();
2605 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2606 let result = AuditKey::load_or_generate(&path);
2607 assert!(matches!(
2608 result,
2609 Err(AuditKeyError::UnsafePermissions { .. })
2610 ));
2611 }
2612
2613 #[test]
2614 fn rejects_short_key_file() {
2615 use std::os::unix::fs::PermissionsExt;
2616 let dir = tempfile::tempdir().unwrap();
2617 let path = dir.path().join("audit-key");
2618 std::fs::write(&path, vec![0u8; 8]).unwrap();
2619 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2620 let result = AuditKey::load_or_generate(&path);
2621 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
2622 }
2623
2624 #[test]
2630 fn debug_redacts_key_bytes_and_their_hex_encoding() {
2631 let raw = (0u8..32).collect::<Vec<u8>>();
2633 let key = AuditKey::from_bytes(raw.clone());
2634 let dbg = format!("{key:?}");
2635
2636 assert!(
2637 dbg.contains("redacted"),
2638 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
2639 );
2640
2641 let hex_lower = hex::encode(&raw);
2643 let hex_upper = hex::encode_upper(&raw);
2644 assert!(
2645 !dbg.contains(&hex_lower),
2646 "Debug output leaks lowercase hex of key bytes: {dbg}"
2647 );
2648 assert!(
2649 !dbg.contains(&hex_upper),
2650 "Debug output leaks uppercase hex of key bytes: {dbg}"
2651 );
2652
2653 for b in &raw {
2657 let pair = format!("{b:02x}");
2658 if *b >= 0x10 && pair != "1e" && pair != "1f" {
2663 assert!(
2664 !dbg.contains(&pair),
2665 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
2666 );
2667 }
2668 }
2669 }
2670
2671 #[test]
2674 fn debug_preserves_key_id() {
2675 let key = AuditKey::from_bytes(vec![0xff; 32]);
2676 let dbg = format!("{key:?}");
2677 assert!(
2678 dbg.contains("key_id"),
2679 "Debug output must label the key_id field: {dbg}"
2680 );
2681 assert!(
2682 dbg.contains(CURRENT_KEY_ID),
2683 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
2684 );
2685 }
2686
2687 #[test]
2690 fn checkpoint_consistent_with_intact_chain() {
2691 let key = fixed_key();
2692 let rows = build_chain(&key, 5);
2693 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
2694 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
2695 assert_eq!(
2696 outcome,
2697 CheckpointOutcome::Consistent {
2698 checkpoints_checked: 1
2699 }
2700 );
2701 }
2702
2703 #[test]
2704 fn checkpoint_detects_truncation() {
2705 let key = fixed_key();
2706 let full = build_chain(&key, 5);
2707 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
2709 let truncated = &full[..3];
2710 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
2711 assert!(matches!(
2712 outcome,
2713 CheckpointOutcome::Truncated {
2714 checkpoint_seq: 5,
2715 current_max_seq: 3
2716 }
2717 ));
2718 }
2719
2720 #[test]
2721 fn checkpoint_detects_rewrite() {
2722 let key = fixed_key();
2723 let mut rows = build_chain(&key, 4);
2724 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
2725 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
2727 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
2728 assert!(matches!(
2729 outcome,
2730 CheckpointOutcome::TipMismatch { seq: 3, .. }
2731 ));
2732 }
2733
2734 #[test]
2735 fn checkpoint_bad_signature_detected() {
2736 let key = fixed_key();
2737 let rows = build_chain(&key, 3);
2738 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
2739 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
2741 assert!(matches!(
2742 outcome,
2743 CheckpointOutcome::BadSignature { seq: 2 }
2744 ));
2745 }
2746
2747 #[test]
2748 fn checkpoint_foreign_key_rejected() {
2749 let signer = AuditKey::from_bytes(vec![0x11; 32]);
2751 let rows = build_chain(&signer, 3);
2752 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
2753 let other = AuditKey::from_bytes(vec![0x22; 32]);
2754 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
2755 assert!(matches!(
2756 outcome,
2757 CheckpointOutcome::BadSignature { seq: 2 }
2758 ));
2759 }
2760
2761 #[test]
2762 fn pubkey_verify_detects_tampered_middle_row() {
2763 let key = fixed_key();
2766 let mut rows = build_chain(&key, 4);
2767 rows[2].summary = "TAMPERED".to_string();
2768 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
2769 assert!(matches!(
2770 outcome,
2771 VerifyOutcome::Broken {
2772 first_broken_seq: 3,
2773 ..
2774 }
2775 ));
2776 }
2777
2778 #[test]
2779 fn multiple_checkpoints_all_consistent() {
2780 let key = fixed_key();
2781 let rows = build_chain(&key, 5);
2782 let cps = vec![
2783 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
2784 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
2785 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
2786 ];
2787 assert_eq!(
2788 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
2789 CheckpointOutcome::Consistent {
2790 checkpoints_checked: 3
2791 }
2792 );
2793 }
2794
2795 #[test]
2796 fn middle_checkpoint_failure_is_reported() {
2797 let key = fixed_key();
2799 let mut rows = build_chain(&key, 5);
2800 let cps = vec![
2801 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
2802 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
2803 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
2804 ];
2805 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
2807 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
2808 CheckpointOutcome::TipMismatch { seq: 3, .. }
2809 ));
2810 }
2811
2812 #[test]
2813 fn checkpoint_created_at_is_signed() {
2814 let key = fixed_key();
2816 let rows = build_chain(&key, 3);
2817 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
2818 cp.created_at = "2020-01-01T00:00:00Z".to_string();
2819 assert!(matches!(
2820 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
2821 CheckpointOutcome::BadSignature { .. }
2822 ));
2823 }
2824
2825 #[test]
2826 fn checkpoint_seq_is_signed() {
2827 let key = fixed_key();
2829 let rows = build_chain(&key, 3);
2830 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
2831 cp.seq = 2;
2832 assert!(matches!(
2833 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
2834 CheckpointOutcome::BadSignature { seq: 2 }
2835 ));
2836 }
2837
2838 #[test]
2839 fn checkpoint_detects_full_wipe() {
2840 let key = fixed_key();
2842 let full = build_chain(&key, 3);
2843 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
2844 assert!(matches!(
2845 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
2846 CheckpointOutcome::Truncated {
2847 checkpoint_seq: 3,
2848 current_max_seq: 0
2849 }
2850 ));
2851 }
2852
2853 #[test]
2854 fn domain_tags_are_distinct_and_prefix_free() {
2855 let tags: [(&str, &[u8]); 4] = [
2862 ("row", ROW_DOMAIN),
2863 ("checkpoint", CHECKPOINT_DOMAIN),
2864 ("approval", APPROVAL_DOMAIN),
2865 ("event", EVENT_DOMAIN),
2866 ];
2867 for (i, (name_a, a)) in tags.iter().enumerate() {
2868 for (name_b, b) in tags.iter().skip(i + 1) {
2869 assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
2870 assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
2871 assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
2872 }
2873 }
2874 }
2875
2876 #[test]
2877 fn approval_receipt_framing_is_injective() {
2878 let key = fixed_key();
2883 let sep = "\u{1f}";
2884 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
2885 let b = key.approval_receipt("tx", &format!("b{sep}c"));
2886 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
2887
2888 assert_eq!(
2890 key.approval_receipt("tx-1", "hash-1"),
2891 key.approval_receipt("tx-1", "hash-1"),
2892 );
2893 assert_eq!(
2895 key.approval_commitment("tx-1", "hash-1"),
2896 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
2897 );
2898 }
2899}