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);
1030 match outcome {
1031 VerifyOutcome::Broken {
1032 first_broken_seq, ..
1033 } => assert!(
1034 first_broken_seq == 2 || first_broken_seq == 3,
1035 "verifier must flag the inserted row or the row that follows it (got {first_broken_seq})"
1036 ),
1037 other => panic!("expected Broken, got {other:?}"),
1038 }
1039 }
1040
1041 #[test]
1044 fn signature_verifies_under_exported_public_key() {
1045 let key = fixed_key();
1047 let rows = build_chain(&key, 4);
1048 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
1049 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
1050 }
1051
1052 #[test]
1053 fn foreign_public_key_cannot_validate_chain() {
1054 let signer = AuditKey::from_bytes(vec![0x11; 32]);
1058 let rows = build_chain(&signer, 3);
1059 let other = AuditKey::from_bytes(vec![0x22; 32]);
1060 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
1061 assert!(matches!(
1062 outcome,
1063 VerifyOutcome::Broken {
1064 first_broken_seq: 1,
1065 ..
1066 }
1067 ));
1068 }
1069
1070 #[test]
1071 fn verifying_key_hex_is_a_32_byte_public_key() {
1072 let key = fixed_key();
1073 let vk_hex = key.verifying_key_hex();
1074 assert_eq!(
1075 vk_hex.len(),
1076 64,
1077 "32-byte ed25519 public key = 64 hex chars"
1078 );
1079 assert!(hex::decode(&vk_hex).is_ok());
1080 }
1081
1082 #[test]
1083 fn malformed_signature_hex_is_broken_not_panic() {
1084 let key = fixed_key();
1085 let mut rows = build_chain(&key, 2);
1086 rows[0].chain_hash = "not-valid-hex!!".to_string();
1087 let outcome = verify_chain(&key, &rows);
1088 assert!(matches!(
1089 outcome,
1090 VerifyOutcome::Broken {
1091 first_broken_seq: 1,
1092 ..
1093 }
1094 ));
1095 }
1096
1097 #[test]
1098 fn bad_public_key_hex_yields_cannot_verify() {
1099 let key = fixed_key();
1100 let rows = build_chain(&key, 1);
1101 let outcome = verify_chain_with_pubkey("zz", &rows);
1102 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1103 }
1104
1105 #[test]
1106 fn load_or_generate_writes_public_key_sidecar() {
1107 let dir = tempfile::tempdir().unwrap();
1108 let path = dir.path().join("audit-key");
1109 let key = AuditKey::load_or_generate(&path).unwrap();
1110 let pub_path = dir.path().join("audit-key.pub");
1111 assert!(pub_path.exists(), "public key sidecar must be written");
1112 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
1113 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
1114 }
1115
1116 #[test]
1117 fn wrong_key_id_yields_cannot_verify() {
1118 let key = fixed_key();
1119 let mut rows = build_chain(&key, 2);
1120 rows[0].key_id = "v99".to_string();
1121 let outcome = verify_chain(&key, &rows);
1122 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1123 assert_eq!(outcome_to_exit_code(&outcome), 2);
1124 }
1125
1126 #[test]
1127 fn exit_code_for_each_outcome() {
1128 assert_eq!(
1129 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
1130 0
1131 );
1132 assert_eq!(
1133 outcome_to_exit_code(&VerifyOutcome::Broken {
1134 rows_checked: 0,
1135 first_broken_seq: 1,
1136 first_broken_transaction_id: String::new(),
1137 expected: String::new(),
1138 actual: String::new(),
1139 }),
1140 1
1141 );
1142 assert_eq!(
1143 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
1144 reason: String::new()
1145 }),
1146 2
1147 );
1148 }
1149
1150 #[test]
1153 fn load_or_generate_creates_with_0600_mode() {
1154 use std::os::unix::fs::PermissionsExt;
1155 let dir = tempfile::tempdir().unwrap();
1156 let path = dir.path().join("audit-key");
1157 let _key = AuditKey::load_or_generate(&path).unwrap();
1158 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1159 assert_eq!(mode, 0o600);
1160 }
1161
1162 #[test]
1163 fn load_or_generate_is_idempotent() {
1164 let dir = tempfile::tempdir().unwrap();
1165 let path = dir.path().join("audit-key");
1166 let key1_bytes = {
1167 let _ = AuditKey::load_or_generate(&path).unwrap();
1168 std::fs::read(&path).unwrap()
1169 };
1170 let _ = AuditKey::load_or_generate(&path).unwrap();
1171 let key2_bytes = std::fs::read(&path).unwrap();
1172 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
1173 }
1174
1175 #[test]
1176 fn rejects_world_readable_key_file() {
1177 use std::os::unix::fs::PermissionsExt;
1178 let dir = tempfile::tempdir().unwrap();
1179 let path = dir.path().join("audit-key");
1180 std::fs::write(&path, vec![0u8; 32]).unwrap();
1181 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1182 let result = AuditKey::load_or_generate(&path);
1183 assert!(matches!(
1184 result,
1185 Err(AuditKeyError::UnsafePermissions { .. })
1186 ));
1187 }
1188
1189 #[test]
1190 fn rejects_short_key_file() {
1191 use std::os::unix::fs::PermissionsExt;
1192 let dir = tempfile::tempdir().unwrap();
1193 let path = dir.path().join("audit-key");
1194 std::fs::write(&path, vec![0u8; 8]).unwrap();
1195 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1196 let result = AuditKey::load_or_generate(&path);
1197 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
1198 }
1199
1200 #[test]
1206 fn debug_redacts_key_bytes_and_their_hex_encoding() {
1207 let raw = (0u8..32).collect::<Vec<u8>>();
1209 let key = AuditKey::from_bytes(raw.clone());
1210 let dbg = format!("{key:?}");
1211
1212 assert!(
1213 dbg.contains("redacted"),
1214 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
1215 );
1216
1217 let hex_lower = hex::encode(&raw);
1219 let hex_upper = hex::encode_upper(&raw);
1220 assert!(
1221 !dbg.contains(&hex_lower),
1222 "Debug output leaks lowercase hex of key bytes: {dbg}"
1223 );
1224 assert!(
1225 !dbg.contains(&hex_upper),
1226 "Debug output leaks uppercase hex of key bytes: {dbg}"
1227 );
1228
1229 for b in &raw {
1233 let pair = format!("{b:02x}");
1234 if *b >= 0x10 && pair != "1e" && pair != "1f" {
1239 assert!(
1240 !dbg.contains(&pair),
1241 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
1242 );
1243 }
1244 }
1245 }
1246
1247 #[test]
1250 fn debug_preserves_key_id() {
1251 let key = AuditKey::from_bytes(vec![0xff; 32]);
1252 let dbg = format!("{key:?}");
1253 assert!(
1254 dbg.contains("key_id"),
1255 "Debug output must label the key_id field: {dbg}"
1256 );
1257 assert!(
1258 dbg.contains(CURRENT_KEY_ID),
1259 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
1260 );
1261 }
1262
1263 #[test]
1266 fn checkpoint_consistent_with_intact_chain() {
1267 let key = fixed_key();
1268 let rows = build_chain(&key, 5);
1269 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1270 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
1271 assert_eq!(
1272 outcome,
1273 CheckpointOutcome::Consistent {
1274 checkpoints_checked: 1
1275 }
1276 );
1277 }
1278
1279 #[test]
1280 fn checkpoint_detects_truncation() {
1281 let key = fixed_key();
1282 let full = build_chain(&key, 5);
1283 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
1285 let truncated = &full[..3];
1286 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
1287 assert!(matches!(
1288 outcome,
1289 CheckpointOutcome::Truncated {
1290 checkpoint_seq: 5,
1291 current_max_seq: 3
1292 }
1293 ));
1294 }
1295
1296 #[test]
1297 fn checkpoint_detects_rewrite() {
1298 let key = fixed_key();
1299 let mut rows = build_chain(&key, 4);
1300 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1301 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
1303 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
1304 assert!(matches!(
1305 outcome,
1306 CheckpointOutcome::TipMismatch { seq: 3, .. }
1307 ));
1308 }
1309
1310 #[test]
1311 fn checkpoint_bad_signature_detected() {
1312 let key = fixed_key();
1313 let rows = build_chain(&key, 3);
1314 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
1315 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
1317 assert!(matches!(
1318 outcome,
1319 CheckpointOutcome::BadSignature { seq: 2 }
1320 ));
1321 }
1322
1323 #[test]
1324 fn checkpoint_foreign_key_rejected() {
1325 let signer = AuditKey::from_bytes(vec![0x11; 32]);
1327 let rows = build_chain(&signer, 3);
1328 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
1329 let other = AuditKey::from_bytes(vec![0x22; 32]);
1330 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
1331 assert!(matches!(
1332 outcome,
1333 CheckpointOutcome::BadSignature { seq: 2 }
1334 ));
1335 }
1336
1337 #[test]
1338 fn pubkey_verify_detects_tampered_middle_row() {
1339 let key = fixed_key();
1342 let mut rows = build_chain(&key, 4);
1343 rows[2].summary = "TAMPERED".to_string();
1344 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
1345 assert!(matches!(
1346 outcome,
1347 VerifyOutcome::Broken {
1348 first_broken_seq: 3,
1349 ..
1350 }
1351 ));
1352 }
1353
1354 #[test]
1355 fn multiple_checkpoints_all_consistent() {
1356 let key = fixed_key();
1357 let rows = build_chain(&key, 5);
1358 let cps = vec![
1359 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
1360 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
1361 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
1362 ];
1363 assert_eq!(
1364 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
1365 CheckpointOutcome::Consistent {
1366 checkpoints_checked: 3
1367 }
1368 );
1369 }
1370
1371 #[test]
1372 fn middle_checkpoint_failure_is_reported() {
1373 let key = fixed_key();
1375 let mut rows = build_chain(&key, 5);
1376 let cps = vec![
1377 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
1378 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
1379 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
1380 ];
1381 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
1383 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
1384 CheckpointOutcome::TipMismatch { seq: 3, .. }
1385 ));
1386 }
1387
1388 #[test]
1389 fn checkpoint_created_at_is_signed() {
1390 let key = fixed_key();
1392 let rows = build_chain(&key, 3);
1393 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1394 cp.created_at = "2020-01-01T00:00:00Z".to_string();
1395 assert!(matches!(
1396 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
1397 CheckpointOutcome::BadSignature { .. }
1398 ));
1399 }
1400
1401 #[test]
1402 fn checkpoint_seq_is_signed() {
1403 let key = fixed_key();
1405 let rows = build_chain(&key, 3);
1406 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1407 cp.seq = 2;
1408 assert!(matches!(
1409 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
1410 CheckpointOutcome::BadSignature { seq: 2 }
1411 ));
1412 }
1413
1414 #[test]
1415 fn checkpoint_detects_full_wipe() {
1416 let key = fixed_key();
1418 let full = build_chain(&key, 3);
1419 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
1420 assert!(matches!(
1421 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
1422 CheckpointOutcome::Truncated {
1423 checkpoint_seq: 3,
1424 current_max_seq: 0
1425 }
1426 ));
1427 }
1428
1429 #[test]
1430 fn domain_tags_are_distinct_and_prefix_free() {
1431 assert_ne!(ROW_DOMAIN, CHECKPOINT_DOMAIN);
1435 assert_ne!(ROW_DOMAIN, APPROVAL_DOMAIN);
1436 assert_ne!(CHECKPOINT_DOMAIN, APPROVAL_DOMAIN);
1437 assert!(!ROW_DOMAIN.starts_with(CHECKPOINT_DOMAIN));
1438 assert!(!CHECKPOINT_DOMAIN.starts_with(ROW_DOMAIN));
1439 assert!(!ROW_DOMAIN.starts_with(APPROVAL_DOMAIN));
1440 assert!(!APPROVAL_DOMAIN.starts_with(ROW_DOMAIN));
1441 assert!(!CHECKPOINT_DOMAIN.starts_with(APPROVAL_DOMAIN));
1442 assert!(!APPROVAL_DOMAIN.starts_with(CHECKPOINT_DOMAIN));
1443 }
1444
1445 #[test]
1446 fn approval_receipt_framing_is_injective() {
1447 let key = fixed_key();
1452 let sep = "\u{1f}";
1453 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
1454 let b = key.approval_receipt("tx", &format!("b{sep}c"));
1455 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
1456
1457 assert_eq!(
1459 key.approval_receipt("tx-1", "hash-1"),
1460 key.approval_receipt("tx-1", "hash-1"),
1461 );
1462 assert_eq!(
1464 key.approval_commitment("tx-1", "hash-1"),
1465 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
1466 );
1467 }
1468}