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";
89
90#[derive(Clone)]
102pub struct AuditKey {
103 key_id: String,
104 signing: SigningKey,
105}
106
107impl std::fmt::Debug for AuditKey {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.debug_struct("AuditKey")
114 .field("key_id", &self.key_id)
115 .field("signing", &format_args!("<redacted signing key>"))
116 .finish()
117 }
118}
119
120#[derive(Debug, thiserror::Error)]
125pub enum AuditKeyError {
126 #[error("io error reading audit key {path}: {source}")]
127 Io {
128 path: PathBuf,
129 #[source]
130 source: std::io::Error,
131 },
132 #[error(
133 "audit key file {path} has unsafe permissions {mode:#o}; \
134 tighten with `chmod 600 {path:?}` and restart"
135 )]
136 UnsafePermissions { path: PathBuf, mode: u32 },
137 #[error(
138 "audit key file {path} is too short ({len} bytes); \
139 expected at least 32 bytes of random material"
140 )]
141 KeyTooShort { path: PathBuf, len: usize },
142}
143
144impl AuditKey {
145 pub fn load_or_generate(path: &Path) -> Result<Self, AuditKeyError> {
152 if !path.exists() {
153 generate_key_at(path)?;
154 }
155
156 let metadata = std::fs::metadata(path).map_err(|e| AuditKeyError::Io {
157 path: path.to_path_buf(),
158 source: e,
159 })?;
160
161 use std::os::unix::fs::PermissionsExt;
162 let mode = metadata.permissions().mode() & 0o777;
163 if mode & 0o077 != 0 {
164 return Err(AuditKeyError::UnsafePermissions {
166 path: path.to_path_buf(),
167 mode,
168 });
169 }
170
171 let mut key_bytes = std::fs::read(path).map_err(|e| AuditKeyError::Io {
172 path: path.to_path_buf(),
173 source: e,
174 })?;
175 if key_bytes.len() < 32 {
176 return Err(AuditKeyError::KeyTooShort {
177 path: path.to_path_buf(),
178 len: key_bytes.len(),
179 });
180 }
181
182 let mut seed = [0u8; 32];
183 seed.copy_from_slice(&key_bytes[..32]);
184 let signing = SigningKey::from_bytes(&seed);
185 seed.zeroize();
186 key_bytes.zeroize();
187
188 Ok(Self {
189 key_id: CURRENT_KEY_ID.to_string(),
190 signing,
191 })
192 }
193
194 #[cfg(test)]
197 pub fn from_bytes(bytes: Vec<u8>) -> Self {
198 let mut seed = [0u8; 32];
199 seed.copy_from_slice(&bytes[..32]);
200 let signing = SigningKey::from_bytes(&seed);
201 seed.zeroize();
202 Self {
203 key_id: CURRENT_KEY_ID.to_string(),
204 signing,
205 }
206 }
207
208 pub fn key_id(&self) -> &str {
210 &self.key_id
211 }
212
213 pub fn chain_hash(&self, content: &ChainContent, prev_chain_hash: &str) -> String {
218 let sig = self.signing.sign(&chain_message(content, prev_chain_hash));
219 hex::encode(sig.to_bytes())
220 }
221
222 pub fn verifying_key(&self) -> VerifyingKey {
224 self.signing.verifying_key()
225 }
226
227 pub fn verifying_key_hex(&self) -> String {
230 hex::encode(self.signing.verifying_key().to_bytes())
231 }
232
233 pub fn approval_receipt(&self, transaction_id: &str, request_hash: &str) -> String {
237 let mut message = APPROVAL_DOMAIN.to_vec();
243 push_field(&mut message, "txid", transaction_id);
244 push_field(&mut message, "reqhash", request_hash);
245 hex::encode(self.signing.sign(&message).to_bytes())
246 }
247
248 pub fn approval_commitment(&self, transaction_id: &str, request_hash: &str) -> String {
250 approval_receipt_digest(&self.approval_receipt(transaction_id, request_hash))
251 }
252}
253
254pub fn approval_receipt_digest(receipt: &str) -> String {
255 hex::encode(Sha256::digest(receipt.as_bytes()))
256}
257
258fn chain_message(content: &ChainContent, prev_chain_hash: &str) -> Vec<u8> {
264 let mut msg = ROW_DOMAIN.to_vec();
265 msg.extend_from_slice(&content.canonical_bytes());
266 msg.extend_from_slice(prev_chain_hash.as_bytes());
267 msg
268}
269
270pub fn resolve_audit_key_path(db_path: &Path) -> PathBuf {
276 std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
277 .map(PathBuf::from)
278 .unwrap_or_else(|_| {
279 db_path
280 .parent()
281 .unwrap_or_else(|| Path::new("."))
282 .join("audit-key")
283 })
284}
285
286fn generate_key_at(path: &Path) -> Result<(), AuditKeyError> {
289 use std::io::Write as _;
290 use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
291
292 if let Some(parent) = path.parent() {
293 if !parent.exists() {
294 std::fs::DirBuilder::new()
295 .recursive(true)
296 .mode(0o700)
297 .create(parent)
298 .map_err(|e| AuditKeyError::Io {
299 path: parent.to_path_buf(),
300 source: e,
301 })?;
302 }
303 }
304
305 let mut bytes = [0u8; 32];
306 fill_random(&mut bytes).map_err(|e| AuditKeyError::Io {
307 path: path.to_path_buf(),
308 source: e,
309 })?;
310
311 let mut f = std::fs::OpenOptions::new()
312 .write(true)
313 .create_new(true)
314 .mode(0o600)
315 .open(path)
316 .map_err(|e| AuditKeyError::Io {
317 path: path.to_path_buf(),
318 source: e,
319 })?;
320 f.write_all(&bytes).map_err(|e| AuditKeyError::Io {
321 path: path.to_path_buf(),
322 source: e,
323 })?;
324 f.sync_all().map_err(|e| AuditKeyError::Io {
325 path: path.to_path_buf(),
326 source: e,
327 })?;
328
329 let signing = SigningKey::from_bytes(&bytes);
332 let pub_hex = hex::encode(signing.verifying_key().to_bytes());
333 bytes.zeroize();
334 let mut pub_path = path.as_os_str().to_os_string();
335 pub_path.push(".pub");
336 std::fs::write(&pub_path, format!("{pub_hex}\n")).map_err(|e| AuditKeyError::Io {
337 path: PathBuf::from(pub_path),
338 source: e,
339 })?;
340 Ok(())
341}
342
343fn fill_random(buf: &mut [u8]) -> std::io::Result<()> {
345 use std::io::Read as _;
346 let mut f = std::fs::File::open("/dev/urandom")?;
347 f.read_exact(buf)
348}
349
350#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct ChainContent<'a> {
398 pub seq: u64,
399 pub key_id: &'a str,
400 pub transaction_id: &'a str,
401 pub request_id: &'a str,
402 pub request_hash: &'a str,
403 pub action_name: &'a str,
404 pub risk_level: RiskLevel,
405 pub summary: &'a str,
414 pub approval_id: Option<&'a str>,
417 pub warnings_json: &'a str,
419 pub created_at: &'a str,
420}
421
422impl<'a> ChainContent<'a> {
423 pub fn canonical_bytes(&self) -> Vec<u8> {
427 let risk_level_str = match self.risk_level {
428 RiskLevel::Low => "low",
429 RiskLevel::Medium => "medium",
430 RiskLevel::High => "high",
431 };
432 let approval = self.approval_id.unwrap_or("");
433
434 let mut buf = Vec::with_capacity(512);
435 push_field(&mut buf, "seq", &self.seq.to_string());
438 push_field(&mut buf, "key_id", self.key_id);
439 push_field(&mut buf, "transaction_id", self.transaction_id);
440 push_field(&mut buf, "request_id", self.request_id);
441 push_field(&mut buf, "request_hash", self.request_hash);
442 push_field(&mut buf, "action_name", self.action_name);
443 push_field(&mut buf, "risk_level", risk_level_str);
444 push_field(&mut buf, "summary", self.summary);
445 push_field(&mut buf, "approval_id", approval);
446 push_field(&mut buf, "warnings_json", self.warnings_json);
447 push_field(&mut buf, "created_at", self.created_at);
448 buf
449 }
450}
451
452fn push_field(buf: &mut Vec<u8>, tag: &str, value: &str) {
470 buf.extend_from_slice(tag.as_bytes());
471 buf.push(0x1E); for b in value.bytes() {
473 match b {
474 b'\\' => buf.extend_from_slice(b"\\\\"),
475 0x00 => buf.extend_from_slice(b"\\0"),
476 0x1E => buf.extend_from_slice(b"\\1E"),
477 0x1F => buf.extend_from_slice(b"\\1F"),
478 other => buf.push(other),
479 }
480 }
481 buf.push(0x1F); }
483
484#[derive(Debug, Clone, PartialEq, Eq)]
486pub enum VerifyOutcome {
487 Intact { rows_checked: u64 },
489 Broken {
491 rows_checked: u64,
492 first_broken_seq: u64,
493 first_broken_transaction_id: String,
494 expected: String,
495 actual: String,
496 },
497 CannotVerify { reason: String },
501}
502
503pub fn outcome_to_exit_code(outcome: &VerifyOutcome) -> i32 {
509 match outcome {
510 VerifyOutcome::Intact { .. } => 0,
511 VerifyOutcome::Broken { .. } => 1,
512 VerifyOutcome::CannotVerify { .. } => 2,
513 }
514}
515
516#[derive(Debug, Clone)]
518pub struct ChainRow {
519 pub seq: u64,
520 pub key_id: String,
521 pub transaction_id: String,
522 pub request_id: String,
523 pub request_hash: String,
524 pub action_name: String,
525 pub risk_level: RiskLevel,
526 pub summary: String,
527 pub approval_id: Option<String>,
528 pub warnings_json: String,
529 pub created_at: String,
530 pub prev_chain_hash: String,
531 pub chain_hash: String,
532}
533
534pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
538 verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
539}
540
541pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
546 match parse_verifying_key(verifying_key_hex) {
547 Some(vk) => verify_rows(&vk, None, rows),
548 None => VerifyOutcome::CannotVerify {
549 reason: format!(
550 "invalid public key hex ({} chars); expected 64 hex chars of a \
551 32-byte Ed25519 public key",
552 verifying_key_hex.len()
553 ),
554 },
555 }
556}
557
558fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
559 let bytes = hex::decode(hex_str.trim()).ok()?;
560 let arr: [u8; 32] = bytes.try_into().ok()?;
561 VerifyingKey::from_bytes(&arr).ok()
562}
563
564fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
567 let Ok(bytes) = hex::decode(sig_hex) else {
568 return false;
569 };
570 let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
571 return false;
572 };
573 vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
574}
575
576fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
580 let mut last_hash = String::new();
581 let mut rows_checked = 0u64;
582 for row in rows {
583 if let Some(kid) = expect_key_id {
584 if row.key_id != kid {
585 return VerifyOutcome::CannotVerify {
586 reason: format!(
587 "row seq={} uses key_id={:?} but only {:?} is loaded; \
588 epoch keys not yet supported",
589 row.seq, row.key_id, kid
590 ),
591 };
592 }
593 }
594 if row.prev_chain_hash != last_hash {
595 return VerifyOutcome::Broken {
596 rows_checked,
597 first_broken_seq: row.seq,
598 first_broken_transaction_id: row.transaction_id.clone(),
599 expected: format!("prev_chain_hash={last_hash}"),
600 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
601 };
602 }
603 let content = ChainContent {
604 seq: row.seq,
605 key_id: &row.key_id,
606 transaction_id: &row.transaction_id,
607 request_id: &row.request_id,
608 request_hash: &row.request_hash,
609 action_name: &row.action_name,
610 risk_level: row.risk_level,
611 summary: &row.summary,
612 approval_id: row.approval_id.as_deref(),
613 warnings_json: &row.warnings_json,
614 created_at: &row.created_at,
615 };
616 let msg = chain_message(&content, &row.prev_chain_hash);
617 if !signature_ok(vk, &msg, &row.chain_hash) {
618 return VerifyOutcome::Broken {
619 rows_checked,
620 first_broken_seq: row.seq,
621 first_broken_transaction_id: row.transaction_id.clone(),
622 expected: "valid ed25519 signature".to_string(),
623 actual: row.chain_hash.clone(),
624 };
625 }
626 last_hash = row.chain_hash.clone();
627 rows_checked += 1;
628 }
629 VerifyOutcome::Intact { rows_checked }
630}
631
632#[derive(Debug, Clone, PartialEq, Eq)]
648pub struct Checkpoint {
649 pub seq: u64,
652 pub chain_tip: String,
654 pub created_at: String,
656 pub signature: String,
658}
659
660fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
663 let mut buf = CHECKPOINT_DOMAIN.to_vec();
666 push_field(&mut buf, "seq", &seq.to_string());
667 push_field(&mut buf, "chain_tip", chain_tip);
668 push_field(&mut buf, "created_at", created_at);
669 buf
670}
671
672impl AuditKey {
673 pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
675 let sig = self
676 .signing
677 .sign(&checkpoint_message(seq, chain_tip, created_at));
678 Checkpoint {
679 seq,
680 chain_tip: chain_tip.to_string(),
681 created_at: created_at.to_string(),
682 signature: hex::encode(sig.to_bytes()),
683 }
684 }
685}
686
687#[derive(Debug, Clone, PartialEq, Eq)]
689pub enum CheckpointOutcome {
690 Consistent { checkpoints_checked: u64 },
693 BadSignature { seq: u64 },
695 Truncated {
698 checkpoint_seq: u64,
699 current_max_seq: u64,
700 },
701 TipMismatch {
704 seq: u64,
705 anchored: String,
706 actual: String,
707 },
708 CannotVerify { reason: String },
710}
711
712pub fn verify_checkpoints(
718 verifying_key_hex: &str,
719 rows: &[ChainRow],
720 checkpoints: &[Checkpoint],
721) -> CheckpointOutcome {
722 let vk = match parse_verifying_key(verifying_key_hex) {
723 Some(vk) => vk,
724 None => {
725 return CheckpointOutcome::CannotVerify {
726 reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
727 };
728 }
729 };
730 let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
733 let mut checked = 0u64;
734 for cp in checkpoints {
735 let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
736 if !signature_ok(&vk, &msg, &cp.signature) {
737 return CheckpointOutcome::BadSignature { seq: cp.seq };
738 }
739 match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
740 Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
741 Ok(idx) => {
742 return CheckpointOutcome::TipMismatch {
743 seq: cp.seq,
744 anchored: cp.chain_tip.clone(),
745 actual: rows[idx].chain_hash.clone(),
746 };
747 }
748 Err(_) => {
749 return CheckpointOutcome::Truncated {
750 checkpoint_seq: cp.seq,
751 current_max_seq,
752 };
753 }
754 }
755 checked += 1;
756 }
757 CheckpointOutcome::Consistent {
758 checkpoints_checked: checked,
759 }
760}
761
762pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
767 match outcome {
768 CheckpointOutcome::Consistent { .. } => 0,
769 CheckpointOutcome::BadSignature { .. }
770 | CheckpointOutcome::Truncated { .. }
771 | CheckpointOutcome::TipMismatch { .. } => 1,
772 CheckpointOutcome::CannotVerify { .. } => 2,
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779
780 fn fixed_key() -> AuditKey {
781 AuditKey::from_bytes(vec![0x42; 32])
782 }
783
784 fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
785 ChainContent {
786 seq,
787 key_id: CURRENT_KEY_ID,
788 transaction_id: txid,
789 request_id: "req-1",
790 request_hash: "hash-abc",
791 action_name: "UpdateSystem",
792 risk_level: RiskLevel::High,
793 summary: "Upgrade",
794 approval_id: None,
795 warnings_json: "[]",
796 created_at: "2026-04-24T12:00:00Z",
797 }
798 }
799
800 #[test]
803 fn same_inputs_yield_same_hash() {
804 let key = fixed_key();
805 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
806 let h2 = key.chain_hash(&sample_content(1, "txa"), "");
807 assert_eq!(h1, h2);
808 assert_eq!(h1.len(), HASH_HEX_LEN);
809 }
810
811 #[test]
812 fn different_seq_yields_different_hash() {
813 let key = fixed_key();
814 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
815 let h2 = key.chain_hash(&sample_content(2, "txa"), "");
816 assert_ne!(h1, h2);
817 }
818
819 #[test]
820 fn different_prev_hash_yields_different_hash() {
821 let key = fixed_key();
822 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
823 let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
824 assert_ne!(h1, h2);
825 }
826
827 #[test]
828 fn different_keys_yield_different_hashes() {
829 let key1 = AuditKey::from_bytes(vec![0x01; 32]);
830 let key2 = AuditKey::from_bytes(vec![0x02; 32]);
831 let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
832 let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
833 assert_ne!(h1, h2);
834 }
835
836 #[test]
839 fn canonical_bytes_have_stable_field_order() {
840 let c = sample_content(1, "txa");
841 let bytes = c.canonical_bytes();
842 let s = String::from_utf8_lossy(&bytes);
843 let order = [
845 "seq",
846 "key_id",
847 "transaction_id",
848 "request_id",
849 "request_hash",
850 "action_name",
851 "risk_level",
852 "summary",
853 "approval_id",
854 "warnings_json",
855 "created_at",
856 ];
857 let mut last_idx = 0;
858 for tag in order {
859 let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
860 assert!(idx >= last_idx, "{tag} out of order");
861 last_idx = idx;
862 }
863 }
864
865 #[test]
866 fn nul_bytes_in_field_value_are_escaped() {
867 let mut c = sample_content(1, "txa");
868 let summary = "before\0after";
869 c.summary = summary;
870 let bytes = c.canonical_bytes();
871 assert!(!bytes.contains(&0x00));
873 assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
874 }
875
876 #[test]
882 fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
883 let mut a = sample_content(1, "txa");
884 a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
886 b.summary = "before\0after"; assert_ne!(
888 a.canonical_bytes(),
889 b.canonical_bytes(),
890 "backslash escape must run before NUL escape to prevent collision"
891 );
892 }
893
894 fn key_for_collision_test() -> AuditKey {
895 AuditKey::from_bytes(vec![0xab; 32])
896 }
897
898 #[test]
899 fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
900 let key = key_for_collision_test();
901 let mut a = sample_content(1, "txa");
902 a.summary = "before\\0after";
903 let mut b = sample_content(1, "txa");
904 b.summary = "before\0after";
905 assert_ne!(
906 key.chain_hash(&a, ""),
907 key.chain_hash(&b, ""),
908 "chain signature must distinguish escape from raw byte"
909 );
910 }
911
912 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
915 let mut rows = Vec::with_capacity(count);
916 let mut prev = String::new();
917 for i in 0..count {
918 let seq = (i + 1) as u64;
919 let txid = format!("tx{i}");
920 let content = sample_content(seq, &txid);
921 let hash = key.chain_hash(&content, &prev);
922 rows.push(ChainRow {
923 seq,
924 key_id: content.key_id.to_string(),
925 transaction_id: content.transaction_id.to_string(),
926 request_id: content.request_id.to_string(),
927 request_hash: content.request_hash.to_string(),
928 action_name: content.action_name.to_string(),
929 risk_level: content.risk_level,
930 summary: content.summary.to_string(),
931 approval_id: content.approval_id.map(str::to_string),
932 warnings_json: content.warnings_json.to_string(),
933 created_at: content.created_at.to_string(),
934 prev_chain_hash: prev.clone(),
935 chain_hash: hash.clone(),
936 });
937 prev = hash;
938 }
939 rows
940 }
941
942 #[test]
943 fn intact_chain_verifies() {
944 let key = fixed_key();
945 let rows = build_chain(&key, 5);
946 let outcome = verify_chain(&key, &rows);
947 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
948 assert_eq!(outcome_to_exit_code(&outcome), 0);
949 }
950
951 #[test]
952 fn empty_chain_verifies() {
953 let key = fixed_key();
954 let outcome = verify_chain(&key, &[]);
955 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
956 }
957
958 #[test]
959 fn tampered_summary_breaks_chain_at_first_offending_row() {
960 let key = fixed_key();
961 let mut rows = build_chain(&key, 3);
962 rows[1].summary = "TAMPERED".to_string();
964 let outcome = verify_chain(&key, &rows);
965 match outcome {
966 VerifyOutcome::Broken {
967 first_broken_seq, ..
968 } => assert_eq!(first_broken_seq, 2),
969 other => panic!("expected Broken, got {other:?}"),
970 }
971 }
972
973 #[test]
974 fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
975 let key = fixed_key();
976 let mut rows = build_chain(&key, 4);
977 rows.remove(1);
979 let outcome = verify_chain(&key, &rows);
980 match outcome {
981 VerifyOutcome::Broken {
982 first_broken_seq, ..
983 } => assert_eq!(
984 first_broken_seq, 3,
985 "first broken row is the one whose prev_hash refers to deleted row"
986 ),
987 other => panic!("expected Broken, got {other:?}"),
988 }
989 }
990
991 #[test]
992 fn inserted_forged_row_breaks_chain() {
993 let key = fixed_key();
1000 let mut rows = build_chain(&key, 3);
1001
1002 let forged = ChainRow {
1004 seq: 2,
1005 key_id: CURRENT_KEY_ID.to_string(),
1006 transaction_id: "tx-forged".to_string(),
1007 request_id: "req-forged".to_string(),
1008 request_hash: "hash-forged".to_string(),
1009 action_name: "InstallFlatpak".to_string(),
1010 risk_level: RiskLevel::Medium,
1011 summary: "Forged row".to_string(),
1012 approval_id: None,
1013 warnings_json: "[]".to_string(),
1014 created_at: "2026-04-25T13:00:00Z".to_string(),
1015 prev_chain_hash: rows[0].chain_hash.clone(),
1017 chain_hash: "0".repeat(HASH_HEX_LEN),
1019 };
1020
1021 let mut rest: Vec<ChainRow> = rows.split_off(1);
1023 for r in rest.iter_mut() {
1024 r.seq += 1;
1025 }
1026 rows.push(forged);
1027 rows.extend(rest);
1028
1029 let outcome = verify_chain(&key, &rows);
1033 match outcome {
1034 VerifyOutcome::Broken {
1035 first_broken_seq, ..
1036 } => assert_eq!(
1037 first_broken_seq, 2,
1038 "the verifier must flag the forged row itself, not a later one"
1039 ),
1040 other => panic!("expected Broken, got {other:?}"),
1041 }
1042 }
1043
1044 #[test]
1047 fn signature_verifies_under_exported_public_key() {
1048 let key = fixed_key();
1050 let rows = build_chain(&key, 4);
1051 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
1052 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
1053 }
1054
1055 #[test]
1056 fn foreign_public_key_cannot_validate_chain() {
1057 let signer = AuditKey::from_bytes(vec![0x11; 32]);
1061 let rows = build_chain(&signer, 3);
1062 let other = AuditKey::from_bytes(vec![0x22; 32]);
1063 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
1064 assert!(matches!(
1065 outcome,
1066 VerifyOutcome::Broken {
1067 first_broken_seq: 1,
1068 ..
1069 }
1070 ));
1071 }
1072
1073 #[test]
1074 fn verifying_key_hex_is_a_32_byte_public_key() {
1075 let key = fixed_key();
1076 let vk_hex = key.verifying_key_hex();
1077 assert_eq!(
1078 vk_hex.len(),
1079 64,
1080 "32-byte ed25519 public key = 64 hex chars"
1081 );
1082 assert!(hex::decode(&vk_hex).is_ok());
1083 }
1084
1085 #[test]
1086 fn malformed_signature_hex_is_broken_not_panic() {
1087 let key = fixed_key();
1088 let mut rows = build_chain(&key, 2);
1089 rows[0].chain_hash = "not-valid-hex!!".to_string();
1090 let outcome = verify_chain(&key, &rows);
1091 assert!(matches!(
1092 outcome,
1093 VerifyOutcome::Broken {
1094 first_broken_seq: 1,
1095 ..
1096 }
1097 ));
1098 }
1099
1100 #[test]
1101 fn bad_public_key_hex_yields_cannot_verify() {
1102 let key = fixed_key();
1103 let rows = build_chain(&key, 1);
1104 let outcome = verify_chain_with_pubkey("zz", &rows);
1105 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1106 }
1107
1108 #[test]
1109 fn load_or_generate_writes_public_key_sidecar() {
1110 let dir = tempfile::tempdir().unwrap();
1111 let path = dir.path().join("audit-key");
1112 let key = AuditKey::load_or_generate(&path).unwrap();
1113 let pub_path = dir.path().join("audit-key.pub");
1114 assert!(pub_path.exists(), "public key sidecar must be written");
1115 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
1116 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
1117 }
1118
1119 #[test]
1120 fn wrong_key_id_yields_cannot_verify() {
1121 let key = fixed_key();
1122 let mut rows = build_chain(&key, 2);
1123 rows[0].key_id = "v99".to_string();
1124 let outcome = verify_chain(&key, &rows);
1125 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1126 assert_eq!(outcome_to_exit_code(&outcome), 2);
1127 }
1128
1129 #[test]
1130 fn exit_code_for_each_outcome() {
1131 assert_eq!(
1132 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
1133 0
1134 );
1135 assert_eq!(
1136 outcome_to_exit_code(&VerifyOutcome::Broken {
1137 rows_checked: 0,
1138 first_broken_seq: 1,
1139 first_broken_transaction_id: String::new(),
1140 expected: String::new(),
1141 actual: String::new(),
1142 }),
1143 1
1144 );
1145 assert_eq!(
1146 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
1147 reason: String::new()
1148 }),
1149 2
1150 );
1151 }
1152
1153 #[test]
1156 fn load_or_generate_creates_with_0600_mode() {
1157 use std::os::unix::fs::PermissionsExt;
1158 let dir = tempfile::tempdir().unwrap();
1159 let path = dir.path().join("audit-key");
1160 let _key = AuditKey::load_or_generate(&path).unwrap();
1161 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1162 assert_eq!(mode, 0o600);
1163 }
1164
1165 #[test]
1166 fn load_or_generate_is_idempotent() {
1167 let dir = tempfile::tempdir().unwrap();
1168 let path = dir.path().join("audit-key");
1169 let key1_bytes = {
1170 let _ = AuditKey::load_or_generate(&path).unwrap();
1171 std::fs::read(&path).unwrap()
1172 };
1173 let _ = AuditKey::load_or_generate(&path).unwrap();
1174 let key2_bytes = std::fs::read(&path).unwrap();
1175 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
1176 }
1177
1178 #[test]
1179 fn rejects_world_readable_key_file() {
1180 use std::os::unix::fs::PermissionsExt;
1181 let dir = tempfile::tempdir().unwrap();
1182 let path = dir.path().join("audit-key");
1183 std::fs::write(&path, vec![0u8; 32]).unwrap();
1184 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1185 let result = AuditKey::load_or_generate(&path);
1186 assert!(matches!(
1187 result,
1188 Err(AuditKeyError::UnsafePermissions { .. })
1189 ));
1190 }
1191
1192 #[test]
1193 fn rejects_short_key_file() {
1194 use std::os::unix::fs::PermissionsExt;
1195 let dir = tempfile::tempdir().unwrap();
1196 let path = dir.path().join("audit-key");
1197 std::fs::write(&path, vec![0u8; 8]).unwrap();
1198 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1199 let result = AuditKey::load_or_generate(&path);
1200 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
1201 }
1202
1203 #[test]
1209 fn debug_redacts_key_bytes_and_their_hex_encoding() {
1210 let raw = (0u8..32).collect::<Vec<u8>>();
1212 let key = AuditKey::from_bytes(raw.clone());
1213 let dbg = format!("{key:?}");
1214
1215 assert!(
1216 dbg.contains("redacted"),
1217 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
1218 );
1219
1220 let hex_lower = hex::encode(&raw);
1222 let hex_upper = hex::encode_upper(&raw);
1223 assert!(
1224 !dbg.contains(&hex_lower),
1225 "Debug output leaks lowercase hex of key bytes: {dbg}"
1226 );
1227 assert!(
1228 !dbg.contains(&hex_upper),
1229 "Debug output leaks uppercase hex of key bytes: {dbg}"
1230 );
1231
1232 for b in &raw {
1236 let pair = format!("{b:02x}");
1237 if *b >= 0x10 && pair != "1e" && pair != "1f" {
1242 assert!(
1243 !dbg.contains(&pair),
1244 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
1245 );
1246 }
1247 }
1248 }
1249
1250 #[test]
1253 fn debug_preserves_key_id() {
1254 let key = AuditKey::from_bytes(vec![0xff; 32]);
1255 let dbg = format!("{key:?}");
1256 assert!(
1257 dbg.contains("key_id"),
1258 "Debug output must label the key_id field: {dbg}"
1259 );
1260 assert!(
1261 dbg.contains(CURRENT_KEY_ID),
1262 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
1263 );
1264 }
1265
1266 #[test]
1269 fn checkpoint_consistent_with_intact_chain() {
1270 let key = fixed_key();
1271 let rows = build_chain(&key, 5);
1272 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1273 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
1274 assert_eq!(
1275 outcome,
1276 CheckpointOutcome::Consistent {
1277 checkpoints_checked: 1
1278 }
1279 );
1280 }
1281
1282 #[test]
1283 fn checkpoint_detects_truncation() {
1284 let key = fixed_key();
1285 let full = build_chain(&key, 5);
1286 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
1288 let truncated = &full[..3];
1289 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
1290 assert!(matches!(
1291 outcome,
1292 CheckpointOutcome::Truncated {
1293 checkpoint_seq: 5,
1294 current_max_seq: 3
1295 }
1296 ));
1297 }
1298
1299 #[test]
1300 fn checkpoint_detects_rewrite() {
1301 let key = fixed_key();
1302 let mut rows = build_chain(&key, 4);
1303 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1304 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
1306 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
1307 assert!(matches!(
1308 outcome,
1309 CheckpointOutcome::TipMismatch { seq: 3, .. }
1310 ));
1311 }
1312
1313 #[test]
1314 fn checkpoint_bad_signature_detected() {
1315 let key = fixed_key();
1316 let rows = build_chain(&key, 3);
1317 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
1318 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
1320 assert!(matches!(
1321 outcome,
1322 CheckpointOutcome::BadSignature { seq: 2 }
1323 ));
1324 }
1325
1326 #[test]
1327 fn checkpoint_foreign_key_rejected() {
1328 let signer = AuditKey::from_bytes(vec![0x11; 32]);
1330 let rows = build_chain(&signer, 3);
1331 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
1332 let other = AuditKey::from_bytes(vec![0x22; 32]);
1333 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
1334 assert!(matches!(
1335 outcome,
1336 CheckpointOutcome::BadSignature { seq: 2 }
1337 ));
1338 }
1339
1340 #[test]
1341 fn pubkey_verify_detects_tampered_middle_row() {
1342 let key = fixed_key();
1345 let mut rows = build_chain(&key, 4);
1346 rows[2].summary = "TAMPERED".to_string();
1347 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
1348 assert!(matches!(
1349 outcome,
1350 VerifyOutcome::Broken {
1351 first_broken_seq: 3,
1352 ..
1353 }
1354 ));
1355 }
1356
1357 #[test]
1358 fn multiple_checkpoints_all_consistent() {
1359 let key = fixed_key();
1360 let rows = build_chain(&key, 5);
1361 let cps = vec![
1362 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
1363 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
1364 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
1365 ];
1366 assert_eq!(
1367 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
1368 CheckpointOutcome::Consistent {
1369 checkpoints_checked: 3
1370 }
1371 );
1372 }
1373
1374 #[test]
1375 fn middle_checkpoint_failure_is_reported() {
1376 let key = fixed_key();
1378 let mut rows = build_chain(&key, 5);
1379 let cps = vec![
1380 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
1381 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
1382 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
1383 ];
1384 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
1386 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
1387 CheckpointOutcome::TipMismatch { seq: 3, .. }
1388 ));
1389 }
1390
1391 #[test]
1392 fn checkpoint_created_at_is_signed() {
1393 let key = fixed_key();
1395 let rows = build_chain(&key, 3);
1396 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1397 cp.created_at = "2020-01-01T00:00:00Z".to_string();
1398 assert!(matches!(
1399 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
1400 CheckpointOutcome::BadSignature { .. }
1401 ));
1402 }
1403
1404 #[test]
1405 fn checkpoint_seq_is_signed() {
1406 let key = fixed_key();
1408 let rows = build_chain(&key, 3);
1409 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1410 cp.seq = 2;
1411 assert!(matches!(
1412 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
1413 CheckpointOutcome::BadSignature { seq: 2 }
1414 ));
1415 }
1416
1417 #[test]
1418 fn checkpoint_detects_full_wipe() {
1419 let key = fixed_key();
1421 let full = build_chain(&key, 3);
1422 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
1423 assert!(matches!(
1424 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
1425 CheckpointOutcome::Truncated {
1426 checkpoint_seq: 3,
1427 current_max_seq: 0
1428 }
1429 ));
1430 }
1431
1432 #[test]
1433 fn domain_tags_are_distinct_and_prefix_free() {
1434 assert_ne!(ROW_DOMAIN, CHECKPOINT_DOMAIN);
1438 assert_ne!(ROW_DOMAIN, APPROVAL_DOMAIN);
1439 assert_ne!(CHECKPOINT_DOMAIN, APPROVAL_DOMAIN);
1440 assert!(!ROW_DOMAIN.starts_with(CHECKPOINT_DOMAIN));
1441 assert!(!CHECKPOINT_DOMAIN.starts_with(ROW_DOMAIN));
1442 assert!(!ROW_DOMAIN.starts_with(APPROVAL_DOMAIN));
1443 assert!(!APPROVAL_DOMAIN.starts_with(ROW_DOMAIN));
1444 assert!(!CHECKPOINT_DOMAIN.starts_with(APPROVAL_DOMAIN));
1445 assert!(!APPROVAL_DOMAIN.starts_with(CHECKPOINT_DOMAIN));
1446 }
1447
1448 #[test]
1449 fn approval_receipt_framing_is_injective() {
1450 let key = fixed_key();
1455 let sep = "\u{1f}";
1456 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
1457 let b = key.approval_receipt("tx", &format!("b{sep}c"));
1458 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
1459
1460 assert_eq!(
1462 key.approval_receipt("tx-1", "hash-1"),
1463 key.approval_receipt("tx-1", "hash-1"),
1464 );
1465 assert_eq!(
1467 key.approval_commitment("tx-1", "hash-1"),
1468 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
1469 );
1470 }
1471}