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_CURRENT: u32 = 2;
96
97#[derive(Clone)]
109pub struct AuditKey {
110 key_id: String,
111 signing: SigningKey,
112}
113
114impl std::fmt::Debug for AuditKey {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.debug_struct("AuditKey")
121 .field("key_id", &self.key_id)
122 .field("signing", &format_args!("<redacted signing key>"))
123 .finish()
124 }
125}
126
127#[derive(Debug, thiserror::Error)]
132pub enum AuditKeyError {
133 #[error("io error reading audit key {path}: {source}")]
134 Io {
135 path: PathBuf,
136 #[source]
137 source: std::io::Error,
138 },
139 #[error(
140 "audit key file {path} has unsafe permissions {mode:#o}; \
141 tighten with `chmod 600 {path:?}` and restart"
142 )]
143 UnsafePermissions { path: PathBuf, mode: u32 },
144 #[error(
145 "audit key file {path} is too short ({len} bytes); \
146 expected at least 32 bytes of random material"
147 )]
148 KeyTooShort { path: PathBuf, len: usize },
149}
150
151impl AuditKey {
152 pub fn load_or_generate(path: &Path) -> Result<Self, AuditKeyError> {
159 if !path.exists() {
160 generate_key_at(path)?;
161 }
162
163 let metadata = std::fs::metadata(path).map_err(|e| AuditKeyError::Io {
164 path: path.to_path_buf(),
165 source: e,
166 })?;
167
168 use std::os::unix::fs::PermissionsExt;
169 let mode = metadata.permissions().mode() & 0o777;
170 if mode & 0o077 != 0 {
171 return Err(AuditKeyError::UnsafePermissions {
173 path: path.to_path_buf(),
174 mode,
175 });
176 }
177
178 let mut key_bytes = std::fs::read(path).map_err(|e| AuditKeyError::Io {
179 path: path.to_path_buf(),
180 source: e,
181 })?;
182 if key_bytes.len() < 32 {
183 return Err(AuditKeyError::KeyTooShort {
184 path: path.to_path_buf(),
185 len: key_bytes.len(),
186 });
187 }
188
189 let mut seed = [0u8; 32];
190 seed.copy_from_slice(&key_bytes[..32]);
191 let signing = SigningKey::from_bytes(&seed);
192 seed.zeroize();
193 key_bytes.zeroize();
194
195 Ok(Self {
196 key_id: CURRENT_KEY_ID.to_string(),
197 signing,
198 })
199 }
200
201 #[cfg(test)]
204 pub fn from_bytes(bytes: Vec<u8>) -> Self {
205 let mut seed = [0u8; 32];
206 seed.copy_from_slice(&bytes[..32]);
207 let signing = SigningKey::from_bytes(&seed);
208 seed.zeroize();
209 Self {
210 key_id: CURRENT_KEY_ID.to_string(),
211 signing,
212 }
213 }
214
215 pub fn key_id(&self) -> &str {
217 &self.key_id
218 }
219
220 pub fn chain_hash(&self, content: &ChainContent, prev_chain_hash: &str) -> String {
225 let sig = self.signing.sign(&chain_message(content, prev_chain_hash));
226 hex::encode(sig.to_bytes())
227 }
228
229 pub fn verifying_key(&self) -> VerifyingKey {
231 self.signing.verifying_key()
232 }
233
234 pub fn verifying_key_hex(&self) -> String {
237 hex::encode(self.signing.verifying_key().to_bytes())
238 }
239
240 pub fn approval_receipt(&self, transaction_id: &str, request_hash: &str) -> String {
244 let mut message = APPROVAL_DOMAIN.to_vec();
250 push_field(&mut message, "txid", transaction_id);
251 push_field(&mut message, "reqhash", request_hash);
252 hex::encode(self.signing.sign(&message).to_bytes())
253 }
254
255 pub fn approval_commitment(&self, transaction_id: &str, request_hash: &str) -> String {
257 approval_receipt_digest(&self.approval_receipt(transaction_id, request_hash))
258 }
259}
260
261pub fn approval_receipt_digest(receipt: &str) -> String {
262 hex::encode(Sha256::digest(receipt.as_bytes()))
263}
264
265fn chain_message(content: &ChainContent, prev_chain_hash: &str) -> Vec<u8> {
271 let mut msg = ROW_DOMAIN.to_vec();
272 msg.extend_from_slice(&content.canonical_bytes());
273 msg.extend_from_slice(prev_chain_hash.as_bytes());
274 msg
275}
276
277pub fn resolve_audit_key_path(db_path: &Path) -> PathBuf {
283 std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
284 .map(PathBuf::from)
285 .unwrap_or_else(|_| {
286 db_path
287 .parent()
288 .unwrap_or_else(|| Path::new("."))
289 .join("audit-key")
290 })
291}
292
293fn generate_key_at(path: &Path) -> Result<(), AuditKeyError> {
296 use std::io::Write as _;
297 use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
298
299 if let Some(parent) = path.parent() {
300 if !parent.exists() {
301 std::fs::DirBuilder::new()
302 .recursive(true)
303 .mode(0o700)
304 .create(parent)
305 .map_err(|e| AuditKeyError::Io {
306 path: parent.to_path_buf(),
307 source: e,
308 })?;
309 }
310 }
311
312 let mut bytes = [0u8; 32];
313 fill_random(&mut bytes).map_err(|e| AuditKeyError::Io {
314 path: path.to_path_buf(),
315 source: e,
316 })?;
317
318 let mut f = std::fs::OpenOptions::new()
319 .write(true)
320 .create_new(true)
321 .mode(0o600)
322 .open(path)
323 .map_err(|e| AuditKeyError::Io {
324 path: path.to_path_buf(),
325 source: e,
326 })?;
327 f.write_all(&bytes).map_err(|e| AuditKeyError::Io {
328 path: path.to_path_buf(),
329 source: e,
330 })?;
331 f.sync_all().map_err(|e| AuditKeyError::Io {
332 path: path.to_path_buf(),
333 source: e,
334 })?;
335
336 let signing = SigningKey::from_bytes(&bytes);
339 let pub_hex = hex::encode(signing.verifying_key().to_bytes());
340 bytes.zeroize();
341 let mut pub_path = path.as_os_str().to_os_string();
342 pub_path.push(".pub");
343 std::fs::write(&pub_path, format!("{pub_hex}\n")).map_err(|e| AuditKeyError::Io {
344 path: PathBuf::from(pub_path),
345 source: e,
346 })?;
347 Ok(())
348}
349
350fn fill_random(buf: &mut [u8]) -> std::io::Result<()> {
352 use std::io::Read as _;
353 let mut f = std::fs::File::open("/dev/urandom")?;
354 f.read_exact(buf)
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum ChainIdentity<'a> {
421 LegacyV1,
423 V2 {
426 caller_role: &'a str,
430 event_tip: &'a str,
436 },
437}
438
439impl ChainIdentity<'_> {
440 pub fn version(&self) -> u32 {
442 match self {
443 Self::LegacyV1 => CHAIN_VERSION_LEGACY,
444 Self::V2 { .. } => CHAIN_VERSION_CURRENT,
445 }
446 }
447}
448
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct ChainContent<'a> {
451 pub seq: u64,
452 pub key_id: &'a str,
453 pub transaction_id: &'a str,
454 pub request_id: &'a str,
455 pub request_hash: &'a str,
456 pub action_name: &'a str,
457 pub risk_level: RiskLevel,
458 pub summary: &'a str,
467 pub approval_id: Option<&'a str>,
470 pub warnings_json: &'a str,
472 pub created_at: &'a str,
473 pub identity: ChainIdentity<'a>,
475}
476
477impl<'a> ChainContent<'a> {
478 pub fn canonical_bytes(&self) -> Vec<u8> {
482 let risk_level_str = match self.risk_level {
483 RiskLevel::Low => "low",
484 RiskLevel::Medium => "medium",
485 RiskLevel::High => "high",
486 };
487 let approval = self.approval_id.unwrap_or("");
488
489 let mut buf = Vec::with_capacity(512);
490 push_field(&mut buf, "seq", &self.seq.to_string());
493 push_field(&mut buf, "key_id", self.key_id);
494 push_field(&mut buf, "transaction_id", self.transaction_id);
495 push_field(&mut buf, "request_id", self.request_id);
496 push_field(&mut buf, "request_hash", self.request_hash);
497 push_field(&mut buf, "action_name", self.action_name);
498 push_field(&mut buf, "risk_level", risk_level_str);
499 push_field(&mut buf, "summary", self.summary);
500 push_field(&mut buf, "approval_id", approval);
501 push_field(&mut buf, "warnings_json", self.warnings_json);
502 push_field(&mut buf, "created_at", self.created_at);
503 if let ChainIdentity::V2 {
508 caller_role,
509 event_tip,
510 } = self.identity
511 {
512 push_field(
513 &mut buf,
514 "chain_version",
515 &CHAIN_VERSION_CURRENT.to_string(),
516 );
517 push_field(&mut buf, "caller_role", caller_role);
518 push_field(&mut buf, "event_tip", event_tip);
519 }
520 buf
521 }
522}
523
524fn push_field(buf: &mut Vec<u8>, tag: &str, value: &str) {
542 buf.extend_from_slice(tag.as_bytes());
543 buf.push(0x1E); for b in value.bytes() {
545 match b {
546 b'\\' => buf.extend_from_slice(b"\\\\"),
547 0x00 => buf.extend_from_slice(b"\\0"),
548 0x1E => buf.extend_from_slice(b"\\1E"),
549 0x1F => buf.extend_from_slice(b"\\1F"),
550 other => buf.push(other),
551 }
552 }
553 buf.push(0x1F); }
555
556#[derive(Debug, Clone, PartialEq, Eq)]
558pub enum VerifyOutcome {
559 Intact { rows_checked: u64 },
561 Broken {
563 rows_checked: u64,
564 first_broken_seq: u64,
565 first_broken_transaction_id: String,
566 expected: String,
567 actual: String,
568 },
569 CannotVerify { reason: String },
573}
574
575pub fn outcome_to_exit_code(outcome: &VerifyOutcome) -> i32 {
581 match outcome {
582 VerifyOutcome::Intact { .. } => 0,
583 VerifyOutcome::Broken { .. } => 1,
584 VerifyOutcome::CannotVerify { .. } => 2,
585 }
586}
587
588#[derive(Debug, Clone)]
590pub struct ChainRow {
591 pub seq: u64,
592 pub key_id: String,
593 pub transaction_id: String,
594 pub request_id: String,
595 pub request_hash: String,
596 pub action_name: String,
597 pub risk_level: RiskLevel,
598 pub summary: String,
599 pub approval_id: Option<String>,
600 pub warnings_json: String,
601 pub created_at: String,
602 pub prev_chain_hash: String,
603 pub chain_hash: String,
604 pub chain_version: u32,
606 pub caller_role: Option<String>,
608 pub event_tip: Option<String>,
611}
612
613#[derive(Debug)]
615pub(crate) enum RowIdentityError {
616 UnknownVersion(u32),
620 MissingField(&'static str),
624}
625
626impl ChainRow {
627 pub(crate) fn identity(&self) -> Result<ChainIdentity<'_>, RowIdentityError> {
630 match self.chain_version {
631 CHAIN_VERSION_LEGACY => Ok(ChainIdentity::LegacyV1),
632 CHAIN_VERSION_CURRENT => Ok(ChainIdentity::V2 {
633 caller_role: self
634 .caller_role
635 .as_deref()
636 .ok_or(RowIdentityError::MissingField("caller_role"))?,
637 event_tip: self
638 .event_tip
639 .as_deref()
640 .ok_or(RowIdentityError::MissingField("event_tip"))?,
641 }),
642 other => Err(RowIdentityError::UnknownVersion(other)),
643 }
644 }
645}
646
647pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
651 verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
652}
653
654pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
659 match parse_verifying_key(verifying_key_hex) {
660 Some(vk) => verify_rows(&vk, None, rows),
661 None => VerifyOutcome::CannotVerify {
662 reason: format!(
663 "invalid public key hex ({} chars); expected 64 hex chars of a \
664 32-byte Ed25519 public key",
665 verifying_key_hex.len()
666 ),
667 },
668 }
669}
670
671fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
672 let bytes = hex::decode(hex_str.trim()).ok()?;
673 let arr: [u8; 32] = bytes.try_into().ok()?;
674 VerifyingKey::from_bytes(&arr).ok()
675}
676
677fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
680 let Ok(bytes) = hex::decode(sig_hex) else {
681 return false;
682 };
683 let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
684 return false;
685 };
686 vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
687}
688
689fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
693 let mut last_hash = String::new();
694 let mut rows_checked = 0u64;
695 for row in rows {
696 if let Some(kid) = expect_key_id {
697 if row.key_id != kid {
698 return VerifyOutcome::CannotVerify {
699 reason: format!(
700 "row seq={} uses key_id={:?} but only {:?} is loaded; \
701 epoch keys not yet supported",
702 row.seq, row.key_id, kid
703 ),
704 };
705 }
706 }
707 if row.prev_chain_hash != last_hash {
708 return VerifyOutcome::Broken {
709 rows_checked,
710 first_broken_seq: row.seq,
711 first_broken_transaction_id: row.transaction_id.clone(),
712 expected: format!("prev_chain_hash={last_hash}"),
713 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
714 };
715 }
716 let identity = match row.identity() {
717 Ok(identity) => identity,
718 Err(RowIdentityError::UnknownVersion(v)) => {
719 return VerifyOutcome::CannotVerify {
720 reason: format!(
721 "row seq={} declares chain_version={v}, which this binary cannot \
722 reproduce (it understands {CHAIN_VERSION_LEGACY}..={CHAIN_VERSION_CURRENT}); \
723 verify with a build at least as new as the one that wrote the chain",
724 row.seq
725 ),
726 };
727 }
728 Err(RowIdentityError::MissingField(field)) => {
729 return VerifyOutcome::Broken {
730 rows_checked,
731 first_broken_seq: row.seq,
732 first_broken_transaction_id: row.transaction_id.clone(),
733 expected: format!("chain_version={CHAIN_VERSION_CURRENT} row carrying {field}"),
734 actual: format!("{field}=NULL"),
735 };
736 }
737 };
738 let content = ChainContent {
739 seq: row.seq,
740 key_id: &row.key_id,
741 transaction_id: &row.transaction_id,
742 request_id: &row.request_id,
743 request_hash: &row.request_hash,
744 action_name: &row.action_name,
745 risk_level: row.risk_level,
746 summary: &row.summary,
747 approval_id: row.approval_id.as_deref(),
748 warnings_json: &row.warnings_json,
749 created_at: &row.created_at,
750 identity,
751 };
752 let msg = chain_message(&content, &row.prev_chain_hash);
753 if !signature_ok(vk, &msg, &row.chain_hash) {
754 return VerifyOutcome::Broken {
755 rows_checked,
756 first_broken_seq: row.seq,
757 first_broken_transaction_id: row.transaction_id.clone(),
758 expected: "valid ed25519 signature".to_string(),
759 actual: row.chain_hash.clone(),
760 };
761 }
762 last_hash = row.chain_hash.clone();
763 rows_checked += 1;
764 }
765 VerifyOutcome::Intact { rows_checked }
766}
767
768#[derive(Debug, Clone, Copy, PartialEq, Eq)]
781pub enum AuditEventKind {
782 ApprovalGranted,
784 ApprovalConsumed,
786 ApprovalRevoked,
788}
789
790impl AuditEventKind {
791 pub fn as_str(&self) -> &'static str {
794 match self {
795 Self::ApprovalGranted => "approval_granted",
796 Self::ApprovalConsumed => "approval_consumed",
797 Self::ApprovalRevoked => "approval_revoked",
798 }
799 }
800}
801
802#[derive(Debug, Clone, PartialEq, Eq)]
804pub struct EventContent<'a> {
805 pub seq: u64,
806 pub key_id: &'a str,
807 pub kind: AuditEventKind,
808 pub transaction_id: &'a str,
809 pub receipt_digest: &'a str,
813 pub created_at: &'a str,
814}
815
816impl EventContent<'_> {
817 pub fn canonical_bytes(&self) -> Vec<u8> {
820 let mut buf = Vec::with_capacity(256);
821 push_field(&mut buf, "seq", &self.seq.to_string());
822 push_field(&mut buf, "key_id", self.key_id);
823 push_field(&mut buf, "kind", self.kind.as_str());
824 push_field(&mut buf, "transaction_id", self.transaction_id);
825 push_field(&mut buf, "receipt_digest", self.receipt_digest);
826 push_field(&mut buf, "created_at", self.created_at);
827 buf
828 }
829}
830
831fn event_message(content: &EventContent, prev_chain_hash: &str) -> Vec<u8> {
832 let mut msg = EVENT_DOMAIN.to_vec();
833 msg.extend_from_slice(&content.canonical_bytes());
834 msg.extend_from_slice(prev_chain_hash.as_bytes());
835 msg
836}
837
838impl AuditKey {
839 pub fn event_hash(&self, content: &EventContent, prev_chain_hash: &str) -> String {
843 hex::encode(
844 self.signing
845 .sign(&event_message(content, prev_chain_hash))
846 .to_bytes(),
847 )
848 }
849}
850
851#[derive(Debug, Clone, PartialEq, Eq)]
853pub struct EventRow {
854 pub seq: u64,
855 pub key_id: String,
856 pub kind: String,
860 pub transaction_id: String,
861 pub receipt_digest: String,
862 pub created_at: String,
863 pub prev_chain_hash: String,
864 pub chain_hash: String,
865}
866
867pub fn verify_event_chain(key: &AuditKey, rows: &[EventRow]) -> VerifyOutcome {
869 verify_event_rows(&key.verifying_key(), Some(key.key_id()), rows)
870}
871
872pub fn verify_event_chain_with_pubkey(verifying_key_hex: &str, rows: &[EventRow]) -> VerifyOutcome {
874 match parse_verifying_key(verifying_key_hex) {
875 Some(vk) => verify_event_rows(&vk, None, rows),
876 None => VerifyOutcome::CannotVerify {
877 reason: format!(
878 "invalid public key hex ({} chars); expected 64 hex chars of a \
879 32-byte Ed25519 public key",
880 verifying_key_hex.len()
881 ),
882 },
883 }
884}
885
886fn verify_event_rows(
887 vk: &VerifyingKey,
888 expect_key_id: Option<&str>,
889 rows: &[EventRow],
890) -> VerifyOutcome {
891 let mut last_hash = String::new();
892 let mut rows_checked = 0u64;
893 for row in rows {
894 if let Some(kid) = expect_key_id {
895 if row.key_id != kid {
896 return VerifyOutcome::CannotVerify {
897 reason: format!(
898 "event seq={} uses key_id={:?} but only {:?} is loaded; \
899 epoch keys not yet supported",
900 row.seq, row.key_id, kid
901 ),
902 };
903 }
904 }
905 if row.prev_chain_hash != last_hash {
906 return VerifyOutcome::Broken {
907 rows_checked,
908 first_broken_seq: row.seq,
909 first_broken_transaction_id: row.transaction_id.clone(),
910 expected: format!("prev_chain_hash={last_hash}"),
911 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
912 };
913 }
914 let Some(kind) = parse_event_kind(&row.kind) else {
917 return VerifyOutcome::Broken {
918 rows_checked,
919 first_broken_seq: row.seq,
920 first_broken_transaction_id: row.transaction_id.clone(),
921 expected: "a known event kind".to_string(),
922 actual: format!("kind={:?}", row.kind),
923 };
924 };
925 let content = EventContent {
926 seq: row.seq,
927 key_id: &row.key_id,
928 kind,
929 transaction_id: &row.transaction_id,
930 receipt_digest: &row.receipt_digest,
931 created_at: &row.created_at,
932 };
933 if !signature_ok(
934 vk,
935 &event_message(&content, &row.prev_chain_hash),
936 &row.chain_hash,
937 ) {
938 return VerifyOutcome::Broken {
939 rows_checked,
940 first_broken_seq: row.seq,
941 first_broken_transaction_id: row.transaction_id.clone(),
942 expected: "valid ed25519 signature".to_string(),
943 actual: row.chain_hash.clone(),
944 };
945 }
946 last_hash = row.chain_hash.clone();
947 rows_checked += 1;
948 }
949 VerifyOutcome::Intact { rows_checked }
950}
951
952fn parse_event_kind(raw: &str) -> Option<AuditEventKind> {
953 [
956 AuditEventKind::ApprovalGranted,
957 AuditEventKind::ApprovalConsumed,
958 AuditEventKind::ApprovalRevoked,
959 ]
960 .into_iter()
961 .find(|kind| kind.as_str() == raw)
962}
963
964#[derive(Debug, Clone, PartialEq, Eq)]
967pub enum BindingOutcome {
968 Consistent { bindings_checked: u64 },
970 MissingEvent {
974 transaction_seq: u64,
975 event_tip: String,
976 },
977}
978
979pub fn verify_event_binding(tx_rows: &[ChainRow], event_rows: &[EventRow]) -> BindingOutcome {
989 let known: std::collections::HashSet<&str> = event_rows
990 .iter()
991 .map(|row| row.chain_hash.as_str())
992 .collect();
993 let mut bindings_checked = 0u64;
994 for row in tx_rows {
995 let Some(tip) = row.event_tip.as_deref() else {
996 continue;
997 };
998 if tip.is_empty() {
999 continue;
1000 }
1001 if !known.contains(tip) {
1002 return BindingOutcome::MissingEvent {
1003 transaction_seq: row.seq,
1004 event_tip: tip.to_string(),
1005 };
1006 }
1007 bindings_checked += 1;
1008 }
1009 BindingOutcome::Consistent { bindings_checked }
1010}
1011
1012#[derive(Debug, Clone, PartialEq, Eq)]
1019pub struct AuditVerification {
1020 pub chain: VerifyOutcome,
1021 pub events: VerifyOutcome,
1022 pub binding: BindingOutcome,
1023}
1024
1025impl AuditVerification {
1026 pub fn exit_code(&self) -> i32 {
1032 let codes = [
1033 outcome_to_exit_code(&self.chain),
1034 outcome_to_exit_code(&self.events),
1035 binding_outcome_to_exit_code(&self.binding),
1036 ];
1037 if codes.contains(&1) {
1038 1
1039 } else if codes.contains(&2) {
1040 2
1041 } else {
1042 0
1043 }
1044 }
1045}
1046
1047pub fn verify_all(
1049 key: &AuditKey,
1050 tx_rows: &[ChainRow],
1051 event_rows: &[EventRow],
1052) -> AuditVerification {
1053 AuditVerification {
1054 chain: verify_chain(key, tx_rows),
1055 events: verify_event_chain(key, event_rows),
1056 binding: verify_event_binding(tx_rows, event_rows),
1057 }
1058}
1059
1060pub fn verify_all_with_pubkey(
1062 verifying_key_hex: &str,
1063 tx_rows: &[ChainRow],
1064 event_rows: &[EventRow],
1065) -> AuditVerification {
1066 AuditVerification {
1067 chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
1068 events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
1069 binding: verify_event_binding(tx_rows, event_rows),
1070 }
1071}
1072
1073pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
1076 match outcome {
1077 BindingOutcome::Consistent { .. } => 0,
1078 BindingOutcome::MissingEvent { .. } => 1,
1079 }
1080}
1081
1082#[derive(Debug, Clone, PartialEq, Eq)]
1098pub struct Checkpoint {
1099 pub seq: u64,
1102 pub chain_tip: String,
1104 pub created_at: String,
1106 pub signature: String,
1108}
1109
1110fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
1113 let mut buf = CHECKPOINT_DOMAIN.to_vec();
1116 push_field(&mut buf, "seq", &seq.to_string());
1117 push_field(&mut buf, "chain_tip", chain_tip);
1118 push_field(&mut buf, "created_at", created_at);
1119 buf
1120}
1121
1122impl AuditKey {
1123 pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
1125 let sig = self
1126 .signing
1127 .sign(&checkpoint_message(seq, chain_tip, created_at));
1128 Checkpoint {
1129 seq,
1130 chain_tip: chain_tip.to_string(),
1131 created_at: created_at.to_string(),
1132 signature: hex::encode(sig.to_bytes()),
1133 }
1134 }
1135}
1136
1137#[derive(Debug, Clone, PartialEq, Eq)]
1139pub enum CheckpointOutcome {
1140 Consistent { checkpoints_checked: u64 },
1143 BadSignature { seq: u64 },
1145 Truncated {
1148 checkpoint_seq: u64,
1149 current_max_seq: u64,
1150 },
1151 TipMismatch {
1154 seq: u64,
1155 anchored: String,
1156 actual: String,
1157 },
1158 CannotVerify { reason: String },
1160}
1161
1162pub fn verify_checkpoints(
1168 verifying_key_hex: &str,
1169 rows: &[ChainRow],
1170 checkpoints: &[Checkpoint],
1171) -> CheckpointOutcome {
1172 let vk = match parse_verifying_key(verifying_key_hex) {
1173 Some(vk) => vk,
1174 None => {
1175 return CheckpointOutcome::CannotVerify {
1176 reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
1177 };
1178 }
1179 };
1180 let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
1183 let mut checked = 0u64;
1184 for cp in checkpoints {
1185 let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
1186 if !signature_ok(&vk, &msg, &cp.signature) {
1187 return CheckpointOutcome::BadSignature { seq: cp.seq };
1188 }
1189 match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
1190 Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
1191 Ok(idx) => {
1192 return CheckpointOutcome::TipMismatch {
1193 seq: cp.seq,
1194 anchored: cp.chain_tip.clone(),
1195 actual: rows[idx].chain_hash.clone(),
1196 };
1197 }
1198 Err(_) => {
1199 return CheckpointOutcome::Truncated {
1200 checkpoint_seq: cp.seq,
1201 current_max_seq,
1202 };
1203 }
1204 }
1205 checked += 1;
1206 }
1207 CheckpointOutcome::Consistent {
1208 checkpoints_checked: checked,
1209 }
1210}
1211
1212pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
1217 match outcome {
1218 CheckpointOutcome::Consistent { .. } => 0,
1219 CheckpointOutcome::BadSignature { .. }
1220 | CheckpointOutcome::Truncated { .. }
1221 | CheckpointOutcome::TipMismatch { .. } => 1,
1222 CheckpointOutcome::CannotVerify { .. } => 2,
1223 }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228 use super::*;
1229
1230 fn fixed_key() -> AuditKey {
1231 AuditKey::from_bytes(vec![0x42; 32])
1232 }
1233
1234 fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
1235 ChainContent {
1236 seq,
1237 key_id: CURRENT_KEY_ID,
1238 transaction_id: txid,
1239 request_id: "req-1",
1240 request_hash: "hash-abc",
1241 action_name: "UpdateSystem",
1242 risk_level: RiskLevel::High,
1243 summary: "Upgrade",
1244 approval_id: None,
1245 warnings_json: "[]",
1246 created_at: "2026-04-24T12:00:00Z",
1247 identity: ChainIdentity::V2 {
1248 caller_role: "Dev",
1249 event_tip: "",
1250 },
1251 }
1252 }
1253
1254 #[test]
1257 fn same_inputs_yield_same_hash() {
1258 let key = fixed_key();
1259 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1260 let h2 = key.chain_hash(&sample_content(1, "txa"), "");
1261 assert_eq!(h1, h2);
1262 assert_eq!(h1.len(), HASH_HEX_LEN);
1263 }
1264
1265 #[test]
1266 fn different_seq_yields_different_hash() {
1267 let key = fixed_key();
1268 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1269 let h2 = key.chain_hash(&sample_content(2, "txa"), "");
1270 assert_ne!(h1, h2);
1271 }
1272
1273 #[test]
1274 fn different_prev_hash_yields_different_hash() {
1275 let key = fixed_key();
1276 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1277 let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
1278 assert_ne!(h1, h2);
1279 }
1280
1281 #[test]
1282 fn different_keys_yield_different_hashes() {
1283 let key1 = AuditKey::from_bytes(vec![0x01; 32]);
1284 let key2 = AuditKey::from_bytes(vec![0x02; 32]);
1285 let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
1286 let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
1287 assert_ne!(h1, h2);
1288 }
1289
1290 #[test]
1293 fn canonical_bytes_have_stable_field_order() {
1294 let c = sample_content(1, "txa");
1295 let bytes = c.canonical_bytes();
1296 let s = String::from_utf8_lossy(&bytes);
1297 let order = [
1299 "seq",
1300 "key_id",
1301 "transaction_id",
1302 "request_id",
1303 "request_hash",
1304 "action_name",
1305 "risk_level",
1306 "summary",
1307 "approval_id",
1308 "warnings_json",
1309 "created_at",
1310 ];
1311 let mut last_idx = 0;
1312 for tag in order {
1313 let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
1314 assert!(idx >= last_idx, "{tag} out of order");
1315 last_idx = idx;
1316 }
1317 }
1318
1319 #[test]
1320 fn nul_bytes_in_field_value_are_escaped() {
1321 let mut c = sample_content(1, "txa");
1322 let summary = "before\0after";
1323 c.summary = summary;
1324 let bytes = c.canonical_bytes();
1325 assert!(!bytes.contains(&0x00));
1327 assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
1328 }
1329
1330 #[test]
1336 fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
1337 let mut a = sample_content(1, "txa");
1338 a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
1340 b.summary = "before\0after"; assert_ne!(
1342 a.canonical_bytes(),
1343 b.canonical_bytes(),
1344 "backslash escape must run before NUL escape to prevent collision"
1345 );
1346 }
1347
1348 fn key_for_collision_test() -> AuditKey {
1349 AuditKey::from_bytes(vec![0xab; 32])
1350 }
1351
1352 #[test]
1353 fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
1354 let key = key_for_collision_test();
1355 let mut a = sample_content(1, "txa");
1356 a.summary = "before\\0after";
1357 let mut b = sample_content(1, "txa");
1358 b.summary = "before\0after";
1359 assert_ne!(
1360 key.chain_hash(&a, ""),
1361 key.chain_hash(&b, ""),
1362 "chain signature must distinguish escape from raw byte"
1363 );
1364 }
1365
1366 fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
1372 let (chain_version, caller_role, event_tip) = match content.identity {
1373 ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None),
1374 ChainIdentity::V2 {
1375 caller_role,
1376 event_tip,
1377 } => (
1378 CHAIN_VERSION_CURRENT,
1379 Some(caller_role.to_string()),
1380 Some(event_tip.to_string()),
1381 ),
1382 };
1383 ChainRow {
1384 seq: content.seq,
1385 key_id: content.key_id.to_string(),
1386 transaction_id: content.transaction_id.to_string(),
1387 request_id: content.request_id.to_string(),
1388 request_hash: content.request_hash.to_string(),
1389 action_name: content.action_name.to_string(),
1390 risk_level: content.risk_level,
1391 summary: content.summary.to_string(),
1392 approval_id: content.approval_id.map(str::to_string),
1393 warnings_json: content.warnings_json.to_string(),
1394 created_at: content.created_at.to_string(),
1395 prev_chain_hash: prev.to_string(),
1396 chain_hash,
1397 chain_version,
1398 caller_role,
1399 event_tip,
1400 }
1401 }
1402
1403 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
1404 let mut rows = Vec::with_capacity(count);
1405 let mut prev = String::new();
1406 for i in 0..count {
1407 let seq = (i + 1) as u64;
1408 let txid = format!("tx{i}");
1409 let content = sample_content(seq, &txid);
1410 let hash = key.chain_hash(&content, &prev);
1411 rows.push(row_for(&content, &prev, hash.clone()));
1412 prev = hash;
1413 }
1414 rows
1415 }
1416
1417 #[test]
1420 fn caller_role_is_covered_by_the_row_signature() {
1421 let key = fixed_key();
1425 let mut dev = sample_content(1, "txa");
1426 dev.identity = ChainIdentity::V2 {
1427 caller_role: "Dev",
1428 event_tip: "",
1429 };
1430 let mut boot = sample_content(1, "txa");
1431 boot.identity = ChainIdentity::V2 {
1432 caller_role: "Boot",
1433 event_tip: "",
1434 };
1435 assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
1436 }
1437
1438 #[test]
1439 fn event_tip_is_covered_by_the_row_signature() {
1440 let key = fixed_key();
1441 let mut empty = sample_content(1, "txa");
1442 empty.identity = ChainIdentity::V2 {
1443 caller_role: "Dev",
1444 event_tip: "",
1445 };
1446 let mut bound = sample_content(1, "txa");
1447 bound.identity = ChainIdentity::V2 {
1448 caller_role: "Dev",
1449 event_tip: "abc123",
1450 };
1451 assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
1452 }
1453
1454 #[test]
1455 fn a_legacy_row_written_before_the_migration_still_verifies() {
1456 let key = fixed_key();
1461 let mut content = sample_content(1, "tx-legacy");
1462 content.identity = ChainIdentity::LegacyV1;
1463 let hash = key.chain_hash(&content, "");
1464 let row = row_for(&content, "", hash);
1465 assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
1466 assert_eq!(row.caller_role, None);
1467 assert_eq!(
1468 verify_chain(&key, &[row]),
1469 VerifyOutcome::Intact { rows_checked: 1 }
1470 );
1471 }
1472
1473 #[test]
1474 fn a_chain_that_spans_the_migration_verifies_end_to_end() {
1475 let key = fixed_key();
1478 let mut rows = Vec::new();
1479 let mut prev = String::new();
1480 for (i, identity) in [
1481 ChainIdentity::LegacyV1,
1482 ChainIdentity::LegacyV1,
1483 ChainIdentity::V2 {
1484 caller_role: "Dev",
1485 event_tip: "",
1486 },
1487 ChainIdentity::V2 {
1488 caller_role: "Boot",
1489 event_tip: "",
1490 },
1491 ]
1492 .into_iter()
1493 .enumerate()
1494 {
1495 let txid = format!("tx{i}");
1496 let mut content = sample_content((i + 1) as u64, &txid);
1497 content.identity = identity;
1498 let hash = key.chain_hash(&content, &prev);
1499 rows.push(row_for(&content, &prev, hash.clone()));
1500 prev = hash;
1501 }
1502 assert_eq!(
1503 verify_chain(&key, &rows),
1504 VerifyOutcome::Intact { rows_checked: 4 }
1505 );
1506 }
1507
1508 #[test]
1509 fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
1510 let key = fixed_key();
1515 let mut rows = build_chain(&key, 2);
1516 rows[1].chain_version = CHAIN_VERSION_LEGACY;
1517 rows[1].caller_role = None;
1518 rows[1].event_tip = None;
1519 match verify_chain(&key, &rows) {
1520 VerifyOutcome::Broken {
1521 first_broken_seq, ..
1522 } => assert_eq!(first_broken_seq, 2),
1523 other => panic!("expected Broken, got {other:?}"),
1524 }
1525 }
1526
1527 #[test]
1528 fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
1529 let key = fixed_key();
1534 let mut rows = build_chain(&key, 1);
1535 rows[0].caller_role = None;
1536 let outcome = verify_chain(&key, &rows);
1537 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1538 assert_eq!(outcome_to_exit_code(&outcome), 1);
1539 }
1540
1541 #[test]
1542 fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
1543 let key = fixed_key();
1546 let mut rows = build_chain(&key, 1);
1547 rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
1548 let outcome = verify_chain(&key, &rows);
1549 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1550 assert_eq!(outcome_to_exit_code(&outcome), 2);
1551 }
1552
1553 fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
1556 EventContent {
1557 seq,
1558 key_id: CURRENT_KEY_ID,
1559 kind,
1560 transaction_id: txid,
1561 receipt_digest: "digest-abc",
1562 created_at: "2026-04-24T12:00:00Z",
1563 }
1564 }
1565
1566 fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
1567 let mut rows = Vec::with_capacity(kinds.len());
1568 let mut prev = String::new();
1569 for (i, kind) in kinds.iter().enumerate() {
1570 let seq = (i + 1) as u64;
1571 let txid = format!("tx{i}");
1572 let content = event_content(seq, *kind, &txid);
1573 let hash = key.event_hash(&content, &prev);
1574 rows.push(EventRow {
1575 seq,
1576 key_id: content.key_id.to_string(),
1577 kind: content.kind.as_str().to_string(),
1578 transaction_id: content.transaction_id.to_string(),
1579 receipt_digest: content.receipt_digest.to_string(),
1580 created_at: content.created_at.to_string(),
1581 prev_chain_hash: prev.clone(),
1582 chain_hash: hash.clone(),
1583 });
1584 prev = hash;
1585 }
1586 rows
1587 }
1588
1589 #[test]
1590 fn event_kind_spellings_are_stable() {
1591 assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
1594 assert_eq!(
1595 AuditEventKind::ApprovalConsumed.as_str(),
1596 "approval_consumed"
1597 );
1598 assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
1599 }
1600
1601 #[test]
1602 fn intact_event_chain_verifies() {
1603 let key = fixed_key();
1604 let rows = build_event_chain(
1605 &key,
1606 &[
1607 AuditEventKind::ApprovalGranted,
1608 AuditEventKind::ApprovalConsumed,
1609 ],
1610 );
1611 assert_eq!(
1612 verify_event_chain(&key, &rows),
1613 VerifyOutcome::Intact { rows_checked: 2 }
1614 );
1615 }
1616
1617 #[test]
1618 fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
1619 let key = fixed_key();
1622 let mut rows = build_event_chain(
1623 &key,
1624 &[
1625 AuditEventKind::ApprovalGranted,
1626 AuditEventKind::ApprovalConsumed,
1627 AuditEventKind::ApprovalRevoked,
1628 ],
1629 );
1630 rows.remove(1);
1631 match verify_event_chain(&key, &rows) {
1632 VerifyOutcome::Broken {
1633 first_broken_seq, ..
1634 } => assert_eq!(first_broken_seq, 3),
1635 other => panic!("expected Broken, got {other:?}"),
1636 }
1637 }
1638
1639 #[test]
1640 fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
1641 let key = fixed_key();
1642 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
1643 rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
1644 assert!(matches!(
1645 verify_event_chain(&key, &rows),
1646 VerifyOutcome::Broken { .. }
1647 ));
1648 }
1649
1650 #[test]
1651 fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
1652 let key = fixed_key();
1653 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
1654 rows[0].kind = "approval_unicorned".to_string();
1655 assert!(matches!(
1656 verify_event_chain(&key, &rows),
1657 VerifyOutcome::Broken { .. }
1658 ));
1659 }
1660
1661 #[test]
1662 fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
1663 let key = fixed_key();
1667 let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
1668 let event_sig = key.event_hash(&content, "");
1669 let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
1670 assert_ne!(event_sig, row_sig);
1671
1672 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
1673 rows[0].chain_hash = row_sig;
1674 assert!(matches!(
1675 verify_event_chain(&key, &rows),
1676 VerifyOutcome::Broken { .. }
1677 ));
1678 }
1679
1680 #[test]
1683 fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
1684 let key = fixed_key();
1689 let events = build_event_chain(
1690 &key,
1691 &[
1692 AuditEventKind::ApprovalGranted,
1693 AuditEventKind::ApprovalConsumed,
1694 ],
1695 );
1696 let mut content = sample_content(1, "tx-after");
1697 content.identity = ChainIdentity::V2 {
1698 caller_role: "Dev",
1699 event_tip: &events[1].chain_hash,
1700 };
1701 let hash = key.chain_hash(&content, "");
1702 let tx_rows = vec![row_for(&content, "", hash)];
1703
1704 assert_eq!(
1705 verify_event_binding(&tx_rows, &events),
1706 BindingOutcome::Consistent {
1707 bindings_checked: 1
1708 }
1709 );
1710
1711 let truncated = &events[..1];
1712 assert_eq!(
1713 verify_event_chain(&key, truncated),
1714 VerifyOutcome::Intact { rows_checked: 1 },
1715 "a truncated event chain still walks clean; the binding is the detector"
1716 );
1717 match verify_event_binding(&tx_rows, truncated) {
1718 BindingOutcome::MissingEvent {
1719 transaction_seq, ..
1720 } => assert_eq!(transaction_seq, 1),
1721 other => panic!("expected MissingEvent, got {other:?}"),
1722 }
1723 }
1724
1725 #[test]
1726 fn legacy_rows_carry_no_binding_to_check() {
1727 let key = fixed_key();
1730 let mut content = sample_content(1, "tx-legacy");
1731 content.identity = ChainIdentity::LegacyV1;
1732 let hash = key.chain_hash(&content, "");
1733 let rows = vec![row_for(&content, "", hash)];
1734 assert_eq!(
1735 verify_event_binding(&rows, &[]),
1736 BindingOutcome::Consistent {
1737 bindings_checked: 0
1738 }
1739 );
1740 }
1741
1742 #[test]
1743 fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
1744 let verification = AuditVerification {
1749 chain: VerifyOutcome::Broken {
1750 rows_checked: 1,
1751 first_broken_seq: 2,
1752 first_broken_transaction_id: "tx".to_string(),
1753 expected: "e".to_string(),
1754 actual: "a".to_string(),
1755 },
1756 events: VerifyOutcome::CannotVerify {
1757 reason: "no key".to_string(),
1758 },
1759 binding: BindingOutcome::Consistent {
1760 bindings_checked: 0,
1761 },
1762 };
1763 assert_eq!(verification.exit_code(), 1);
1764 }
1765
1766 #[test]
1767 fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
1768 let verification = AuditVerification {
1769 chain: VerifyOutcome::Intact { rows_checked: 3 },
1770 events: VerifyOutcome::Intact { rows_checked: 1 },
1771 binding: BindingOutcome::MissingEvent {
1772 transaction_seq: 3,
1773 event_tip: "abc".to_string(),
1774 },
1775 };
1776 assert_eq!(verification.exit_code(), 1);
1777 }
1778
1779 #[test]
1780 fn all_three_clean_is_exit_zero() {
1781 let verification = AuditVerification {
1782 chain: VerifyOutcome::Intact { rows_checked: 3 },
1783 events: VerifyOutcome::Intact { rows_checked: 2 },
1784 binding: BindingOutcome::Consistent {
1785 bindings_checked: 1,
1786 },
1787 };
1788 assert_eq!(verification.exit_code(), 0);
1789 }
1790
1791 #[test]
1792 fn binding_exit_codes_split_clean_from_tampered() {
1793 assert_eq!(
1794 binding_outcome_to_exit_code(&BindingOutcome::Consistent {
1795 bindings_checked: 0
1796 }),
1797 0
1798 );
1799 assert_eq!(
1800 binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
1801 transaction_seq: 7,
1802 event_tip: "abc".to_string()
1803 }),
1804 1
1805 );
1806 }
1807
1808 #[test]
1809 fn intact_chain_verifies() {
1810 let key = fixed_key();
1811 let rows = build_chain(&key, 5);
1812 let outcome = verify_chain(&key, &rows);
1813 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
1814 assert_eq!(outcome_to_exit_code(&outcome), 0);
1815 }
1816
1817 #[test]
1818 fn empty_chain_verifies() {
1819 let key = fixed_key();
1820 let outcome = verify_chain(&key, &[]);
1821 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
1822 }
1823
1824 #[test]
1825 fn tampered_summary_breaks_chain_at_first_offending_row() {
1826 let key = fixed_key();
1827 let mut rows = build_chain(&key, 3);
1828 rows[1].summary = "TAMPERED".to_string();
1830 let outcome = verify_chain(&key, &rows);
1831 match outcome {
1832 VerifyOutcome::Broken {
1833 first_broken_seq, ..
1834 } => assert_eq!(first_broken_seq, 2),
1835 other => panic!("expected Broken, got {other:?}"),
1836 }
1837 }
1838
1839 #[test]
1840 fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
1841 let key = fixed_key();
1842 let mut rows = build_chain(&key, 4);
1843 rows.remove(1);
1845 let outcome = verify_chain(&key, &rows);
1846 match outcome {
1847 VerifyOutcome::Broken {
1848 first_broken_seq, ..
1849 } => assert_eq!(
1850 first_broken_seq, 3,
1851 "first broken row is the one whose prev_hash refers to deleted row"
1852 ),
1853 other => panic!("expected Broken, got {other:?}"),
1854 }
1855 }
1856
1857 #[test]
1858 fn inserted_forged_row_breaks_chain() {
1859 let key = fixed_key();
1866 let mut rows = build_chain(&key, 3);
1867
1868 let forged = ChainRow {
1870 seq: 2,
1871 key_id: CURRENT_KEY_ID.to_string(),
1872 transaction_id: "tx-forged".to_string(),
1873 request_id: "req-forged".to_string(),
1874 request_hash: "hash-forged".to_string(),
1875 action_name: "InstallFlatpak".to_string(),
1876 risk_level: RiskLevel::Medium,
1877 summary: "Forged row".to_string(),
1878 approval_id: None,
1879 warnings_json: "[]".to_string(),
1880 created_at: "2026-04-25T13:00:00Z".to_string(),
1881 prev_chain_hash: rows[0].chain_hash.clone(),
1883 chain_hash: "0".repeat(HASH_HEX_LEN),
1885 chain_version: CHAIN_VERSION_CURRENT,
1886 caller_role: Some("Dev".to_string()),
1887 event_tip: Some(String::new()),
1888 };
1889
1890 let mut rest: Vec<ChainRow> = rows.split_off(1);
1892 for r in rest.iter_mut() {
1893 r.seq += 1;
1894 }
1895 rows.push(forged);
1896 rows.extend(rest);
1897
1898 let outcome = verify_chain(&key, &rows);
1902 match outcome {
1903 VerifyOutcome::Broken {
1904 first_broken_seq, ..
1905 } => assert_eq!(
1906 first_broken_seq, 2,
1907 "the verifier must flag the forged row itself, not a later one"
1908 ),
1909 other => panic!("expected Broken, got {other:?}"),
1910 }
1911 }
1912
1913 #[test]
1916 fn signature_verifies_under_exported_public_key() {
1917 let key = fixed_key();
1919 let rows = build_chain(&key, 4);
1920 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
1921 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
1922 }
1923
1924 #[test]
1925 fn foreign_public_key_cannot_validate_chain() {
1926 let signer = AuditKey::from_bytes(vec![0x11; 32]);
1930 let rows = build_chain(&signer, 3);
1931 let other = AuditKey::from_bytes(vec![0x22; 32]);
1932 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
1933 assert!(matches!(
1934 outcome,
1935 VerifyOutcome::Broken {
1936 first_broken_seq: 1,
1937 ..
1938 }
1939 ));
1940 }
1941
1942 #[test]
1943 fn verifying_key_hex_is_a_32_byte_public_key() {
1944 let key = fixed_key();
1945 let vk_hex = key.verifying_key_hex();
1946 assert_eq!(
1947 vk_hex.len(),
1948 64,
1949 "32-byte ed25519 public key = 64 hex chars"
1950 );
1951 assert!(hex::decode(&vk_hex).is_ok());
1952 }
1953
1954 #[test]
1955 fn malformed_signature_hex_is_broken_not_panic() {
1956 let key = fixed_key();
1957 let mut rows = build_chain(&key, 2);
1958 rows[0].chain_hash = "not-valid-hex!!".to_string();
1959 let outcome = verify_chain(&key, &rows);
1960 assert!(matches!(
1961 outcome,
1962 VerifyOutcome::Broken {
1963 first_broken_seq: 1,
1964 ..
1965 }
1966 ));
1967 }
1968
1969 #[test]
1970 fn bad_public_key_hex_yields_cannot_verify() {
1971 let key = fixed_key();
1972 let rows = build_chain(&key, 1);
1973 let outcome = verify_chain_with_pubkey("zz", &rows);
1974 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1975 }
1976
1977 #[test]
1978 fn load_or_generate_writes_public_key_sidecar() {
1979 let dir = tempfile::tempdir().unwrap();
1980 let path = dir.path().join("audit-key");
1981 let key = AuditKey::load_or_generate(&path).unwrap();
1982 let pub_path = dir.path().join("audit-key.pub");
1983 assert!(pub_path.exists(), "public key sidecar must be written");
1984 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
1985 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
1986 }
1987
1988 #[test]
1989 fn wrong_key_id_yields_cannot_verify() {
1990 let key = fixed_key();
1991 let mut rows = build_chain(&key, 2);
1992 rows[0].key_id = "v99".to_string();
1993 let outcome = verify_chain(&key, &rows);
1994 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1995 assert_eq!(outcome_to_exit_code(&outcome), 2);
1996 }
1997
1998 #[test]
1999 fn exit_code_for_each_outcome() {
2000 assert_eq!(
2001 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
2002 0
2003 );
2004 assert_eq!(
2005 outcome_to_exit_code(&VerifyOutcome::Broken {
2006 rows_checked: 0,
2007 first_broken_seq: 1,
2008 first_broken_transaction_id: String::new(),
2009 expected: String::new(),
2010 actual: String::new(),
2011 }),
2012 1
2013 );
2014 assert_eq!(
2015 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
2016 reason: String::new()
2017 }),
2018 2
2019 );
2020 }
2021
2022 #[test]
2025 fn load_or_generate_creates_with_0600_mode() {
2026 use std::os::unix::fs::PermissionsExt;
2027 let dir = tempfile::tempdir().unwrap();
2028 let path = dir.path().join("audit-key");
2029 let _key = AuditKey::load_or_generate(&path).unwrap();
2030 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2031 assert_eq!(mode, 0o600);
2032 }
2033
2034 #[test]
2035 fn load_or_generate_is_idempotent() {
2036 let dir = tempfile::tempdir().unwrap();
2037 let path = dir.path().join("audit-key");
2038 let key1_bytes = {
2039 let _ = AuditKey::load_or_generate(&path).unwrap();
2040 std::fs::read(&path).unwrap()
2041 };
2042 let _ = AuditKey::load_or_generate(&path).unwrap();
2043 let key2_bytes = std::fs::read(&path).unwrap();
2044 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
2045 }
2046
2047 #[test]
2048 fn rejects_world_readable_key_file() {
2049 use std::os::unix::fs::PermissionsExt;
2050 let dir = tempfile::tempdir().unwrap();
2051 let path = dir.path().join("audit-key");
2052 std::fs::write(&path, vec![0u8; 32]).unwrap();
2053 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2054 let result = AuditKey::load_or_generate(&path);
2055 assert!(matches!(
2056 result,
2057 Err(AuditKeyError::UnsafePermissions { .. })
2058 ));
2059 }
2060
2061 #[test]
2062 fn rejects_short_key_file() {
2063 use std::os::unix::fs::PermissionsExt;
2064 let dir = tempfile::tempdir().unwrap();
2065 let path = dir.path().join("audit-key");
2066 std::fs::write(&path, vec![0u8; 8]).unwrap();
2067 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2068 let result = AuditKey::load_or_generate(&path);
2069 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
2070 }
2071
2072 #[test]
2078 fn debug_redacts_key_bytes_and_their_hex_encoding() {
2079 let raw = (0u8..32).collect::<Vec<u8>>();
2081 let key = AuditKey::from_bytes(raw.clone());
2082 let dbg = format!("{key:?}");
2083
2084 assert!(
2085 dbg.contains("redacted"),
2086 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
2087 );
2088
2089 let hex_lower = hex::encode(&raw);
2091 let hex_upper = hex::encode_upper(&raw);
2092 assert!(
2093 !dbg.contains(&hex_lower),
2094 "Debug output leaks lowercase hex of key bytes: {dbg}"
2095 );
2096 assert!(
2097 !dbg.contains(&hex_upper),
2098 "Debug output leaks uppercase hex of key bytes: {dbg}"
2099 );
2100
2101 for b in &raw {
2105 let pair = format!("{b:02x}");
2106 if *b >= 0x10 && pair != "1e" && pair != "1f" {
2111 assert!(
2112 !dbg.contains(&pair),
2113 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
2114 );
2115 }
2116 }
2117 }
2118
2119 #[test]
2122 fn debug_preserves_key_id() {
2123 let key = AuditKey::from_bytes(vec![0xff; 32]);
2124 let dbg = format!("{key:?}");
2125 assert!(
2126 dbg.contains("key_id"),
2127 "Debug output must label the key_id field: {dbg}"
2128 );
2129 assert!(
2130 dbg.contains(CURRENT_KEY_ID),
2131 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
2132 );
2133 }
2134
2135 #[test]
2138 fn checkpoint_consistent_with_intact_chain() {
2139 let key = fixed_key();
2140 let rows = build_chain(&key, 5);
2141 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
2142 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
2143 assert_eq!(
2144 outcome,
2145 CheckpointOutcome::Consistent {
2146 checkpoints_checked: 1
2147 }
2148 );
2149 }
2150
2151 #[test]
2152 fn checkpoint_detects_truncation() {
2153 let key = fixed_key();
2154 let full = build_chain(&key, 5);
2155 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
2157 let truncated = &full[..3];
2158 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
2159 assert!(matches!(
2160 outcome,
2161 CheckpointOutcome::Truncated {
2162 checkpoint_seq: 5,
2163 current_max_seq: 3
2164 }
2165 ));
2166 }
2167
2168 #[test]
2169 fn checkpoint_detects_rewrite() {
2170 let key = fixed_key();
2171 let mut rows = build_chain(&key, 4);
2172 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
2173 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
2175 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
2176 assert!(matches!(
2177 outcome,
2178 CheckpointOutcome::TipMismatch { seq: 3, .. }
2179 ));
2180 }
2181
2182 #[test]
2183 fn checkpoint_bad_signature_detected() {
2184 let key = fixed_key();
2185 let rows = build_chain(&key, 3);
2186 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
2187 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
2189 assert!(matches!(
2190 outcome,
2191 CheckpointOutcome::BadSignature { seq: 2 }
2192 ));
2193 }
2194
2195 #[test]
2196 fn checkpoint_foreign_key_rejected() {
2197 let signer = AuditKey::from_bytes(vec![0x11; 32]);
2199 let rows = build_chain(&signer, 3);
2200 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
2201 let other = AuditKey::from_bytes(vec![0x22; 32]);
2202 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
2203 assert!(matches!(
2204 outcome,
2205 CheckpointOutcome::BadSignature { seq: 2 }
2206 ));
2207 }
2208
2209 #[test]
2210 fn pubkey_verify_detects_tampered_middle_row() {
2211 let key = fixed_key();
2214 let mut rows = build_chain(&key, 4);
2215 rows[2].summary = "TAMPERED".to_string();
2216 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
2217 assert!(matches!(
2218 outcome,
2219 VerifyOutcome::Broken {
2220 first_broken_seq: 3,
2221 ..
2222 }
2223 ));
2224 }
2225
2226 #[test]
2227 fn multiple_checkpoints_all_consistent() {
2228 let key = fixed_key();
2229 let rows = build_chain(&key, 5);
2230 let cps = vec![
2231 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
2232 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
2233 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
2234 ];
2235 assert_eq!(
2236 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
2237 CheckpointOutcome::Consistent {
2238 checkpoints_checked: 3
2239 }
2240 );
2241 }
2242
2243 #[test]
2244 fn middle_checkpoint_failure_is_reported() {
2245 let key = fixed_key();
2247 let mut rows = build_chain(&key, 5);
2248 let cps = vec![
2249 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
2250 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
2251 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
2252 ];
2253 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
2255 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
2256 CheckpointOutcome::TipMismatch { seq: 3, .. }
2257 ));
2258 }
2259
2260 #[test]
2261 fn checkpoint_created_at_is_signed() {
2262 let key = fixed_key();
2264 let rows = build_chain(&key, 3);
2265 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
2266 cp.created_at = "2020-01-01T00:00:00Z".to_string();
2267 assert!(matches!(
2268 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
2269 CheckpointOutcome::BadSignature { .. }
2270 ));
2271 }
2272
2273 #[test]
2274 fn checkpoint_seq_is_signed() {
2275 let key = fixed_key();
2277 let rows = build_chain(&key, 3);
2278 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
2279 cp.seq = 2;
2280 assert!(matches!(
2281 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
2282 CheckpointOutcome::BadSignature { seq: 2 }
2283 ));
2284 }
2285
2286 #[test]
2287 fn checkpoint_detects_full_wipe() {
2288 let key = fixed_key();
2290 let full = build_chain(&key, 3);
2291 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
2292 assert!(matches!(
2293 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
2294 CheckpointOutcome::Truncated {
2295 checkpoint_seq: 3,
2296 current_max_seq: 0
2297 }
2298 ));
2299 }
2300
2301 #[test]
2302 fn domain_tags_are_distinct_and_prefix_free() {
2303 let tags: [(&str, &[u8]); 4] = [
2310 ("row", ROW_DOMAIN),
2311 ("checkpoint", CHECKPOINT_DOMAIN),
2312 ("approval", APPROVAL_DOMAIN),
2313 ("event", EVENT_DOMAIN),
2314 ];
2315 for (i, (name_a, a)) in tags.iter().enumerate() {
2316 for (name_b, b) in tags.iter().skip(i + 1) {
2317 assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
2318 assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
2319 assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
2320 }
2321 }
2322 }
2323
2324 #[test]
2325 fn approval_receipt_framing_is_injective() {
2326 let key = fixed_key();
2331 let sep = "\u{1f}";
2332 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
2333 let b = key.approval_receipt("tx", &format!("b{sep}c"));
2334 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
2335
2336 assert_eq!(
2338 key.approval_receipt("tx-1", "hash-1"),
2339 key.approval_receipt("tx-1", "hash-1"),
2340 );
2341 assert_eq!(
2343 key.approval_commitment("tx-1", "hash-1"),
2344 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
2345 );
2346 }
2347}