1use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
66use sha2::{Digest, Sha256};
67use std::path::{Path, PathBuf};
68use sysknife_types::RiskLevel;
69use zeroize::Zeroize;
70
71pub const CURRENT_KEY_ID: &str = "v1";
74
75pub const HASH_HEX_LEN: usize = 128;
77
78const ROW_DOMAIN: &[u8] = b"sysknife-audit-row-v1\x1f";
82const CHECKPOINT_DOMAIN: &[u8] = b"sysknife-checkpoint-v1\x1f";
83const APPROVAL_DOMAIN: &[u8] = b"sysknife-approval-receipt-v1\x1f";
84
85#[derive(Clone)]
97pub struct AuditKey {
98 key_id: String,
99 signing: SigningKey,
100}
101
102impl std::fmt::Debug for AuditKey {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.debug_struct("AuditKey")
109 .field("key_id", &self.key_id)
110 .field("signing", &format_args!("<redacted signing key>"))
111 .finish()
112 }
113}
114
115#[derive(Debug, thiserror::Error)]
120pub enum AuditKeyError {
121 #[error("io error reading audit key {path}: {source}")]
122 Io {
123 path: PathBuf,
124 #[source]
125 source: std::io::Error,
126 },
127 #[error(
128 "audit key file {path} has unsafe permissions {mode:#o}; \
129 tighten with `chmod 600 {path:?}` and restart"
130 )]
131 UnsafePermissions { path: PathBuf, mode: u32 },
132 #[error(
133 "audit key file {path} is too short ({len} bytes); \
134 expected at least 32 bytes of random material"
135 )]
136 KeyTooShort { path: PathBuf, len: usize },
137}
138
139impl AuditKey {
140 pub fn load_or_generate(path: &Path) -> Result<Self, AuditKeyError> {
147 if !path.exists() {
148 generate_key_at(path)?;
149 }
150
151 let metadata = std::fs::metadata(path).map_err(|e| AuditKeyError::Io {
152 path: path.to_path_buf(),
153 source: e,
154 })?;
155
156 use std::os::unix::fs::PermissionsExt;
157 let mode = metadata.permissions().mode() & 0o777;
158 if mode & 0o077 != 0 {
159 return Err(AuditKeyError::UnsafePermissions {
161 path: path.to_path_buf(),
162 mode,
163 });
164 }
165
166 let mut key_bytes = std::fs::read(path).map_err(|e| AuditKeyError::Io {
167 path: path.to_path_buf(),
168 source: e,
169 })?;
170 if key_bytes.len() < 32 {
171 return Err(AuditKeyError::KeyTooShort {
172 path: path.to_path_buf(),
173 len: key_bytes.len(),
174 });
175 }
176
177 let mut seed = [0u8; 32];
178 seed.copy_from_slice(&key_bytes[..32]);
179 let signing = SigningKey::from_bytes(&seed);
180 seed.zeroize();
181 key_bytes.zeroize();
182
183 Ok(Self {
184 key_id: CURRENT_KEY_ID.to_string(),
185 signing,
186 })
187 }
188
189 #[cfg(test)]
192 pub fn from_bytes(bytes: Vec<u8>) -> Self {
193 let mut seed = [0u8; 32];
194 seed.copy_from_slice(&bytes[..32]);
195 let signing = SigningKey::from_bytes(&seed);
196 seed.zeroize();
197 Self {
198 key_id: CURRENT_KEY_ID.to_string(),
199 signing,
200 }
201 }
202
203 pub fn key_id(&self) -> &str {
205 &self.key_id
206 }
207
208 pub fn chain_hash(&self, content: &ChainContent, prev_chain_hash: &str) -> String {
213 let sig = self.signing.sign(&chain_message(content, prev_chain_hash));
214 hex::encode(sig.to_bytes())
215 }
216
217 pub fn verifying_key(&self) -> VerifyingKey {
219 self.signing.verifying_key()
220 }
221
222 pub fn verifying_key_hex(&self) -> String {
225 hex::encode(self.signing.verifying_key().to_bytes())
226 }
227
228 pub fn approval_receipt(&self, transaction_id: &str, request_hash: &str) -> String {
232 let mut message = APPROVAL_DOMAIN.to_vec();
238 push_field(&mut message, "txid", transaction_id);
239 push_field(&mut message, "reqhash", request_hash);
240 hex::encode(self.signing.sign(&message).to_bytes())
241 }
242
243 pub fn approval_commitment(&self, transaction_id: &str, request_hash: &str) -> String {
245 approval_receipt_digest(&self.approval_receipt(transaction_id, request_hash))
246 }
247}
248
249pub fn approval_receipt_digest(receipt: &str) -> String {
250 hex::encode(Sha256::digest(receipt.as_bytes()))
251}
252
253fn chain_message(content: &ChainContent, prev_chain_hash: &str) -> Vec<u8> {
259 let mut msg = ROW_DOMAIN.to_vec();
260 msg.extend_from_slice(&content.canonical_bytes());
261 msg.extend_from_slice(prev_chain_hash.as_bytes());
262 msg
263}
264
265pub fn resolve_audit_key_path(db_path: &Path) -> PathBuf {
271 std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
272 .map(PathBuf::from)
273 .unwrap_or_else(|_| {
274 db_path
275 .parent()
276 .unwrap_or_else(|| Path::new("."))
277 .join("audit-key")
278 })
279}
280
281fn generate_key_at(path: &Path) -> Result<(), AuditKeyError> {
284 use std::io::Write as _;
285 use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
286
287 if let Some(parent) = path.parent() {
288 if !parent.exists() {
289 std::fs::DirBuilder::new()
290 .recursive(true)
291 .mode(0o700)
292 .create(parent)
293 .map_err(|e| AuditKeyError::Io {
294 path: parent.to_path_buf(),
295 source: e,
296 })?;
297 }
298 }
299
300 let mut bytes = [0u8; 32];
301 fill_random(&mut bytes).map_err(|e| AuditKeyError::Io {
302 path: path.to_path_buf(),
303 source: e,
304 })?;
305
306 let mut f = std::fs::OpenOptions::new()
307 .write(true)
308 .create_new(true)
309 .mode(0o600)
310 .open(path)
311 .map_err(|e| AuditKeyError::Io {
312 path: path.to_path_buf(),
313 source: e,
314 })?;
315 f.write_all(&bytes).map_err(|e| AuditKeyError::Io {
316 path: path.to_path_buf(),
317 source: e,
318 })?;
319 f.sync_all().map_err(|e| AuditKeyError::Io {
320 path: path.to_path_buf(),
321 source: e,
322 })?;
323
324 let signing = SigningKey::from_bytes(&bytes);
327 let pub_hex = hex::encode(signing.verifying_key().to_bytes());
328 bytes.zeroize();
329 let mut pub_path = path.as_os_str().to_os_string();
330 pub_path.push(".pub");
331 std::fs::write(&pub_path, format!("{pub_hex}\n")).map_err(|e| AuditKeyError::Io {
332 path: PathBuf::from(pub_path),
333 source: e,
334 })?;
335 Ok(())
336}
337
338fn fill_random(buf: &mut [u8]) -> std::io::Result<()> {
340 use std::io::Read as _;
341 let mut f = std::fs::File::open("/dev/urandom")?;
342 f.read_exact(buf)
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct ChainContent<'a> {
393 pub seq: u64,
394 pub key_id: &'a str,
395 pub transaction_id: &'a str,
396 pub request_id: &'a str,
397 pub request_hash: &'a str,
398 pub action_name: &'a str,
399 pub risk_level: RiskLevel,
400 pub summary: &'a str,
409 pub approval_id: Option<&'a str>,
412 pub warnings_json: &'a str,
414 pub created_at: &'a str,
415}
416
417impl<'a> ChainContent<'a> {
418 pub fn canonical_bytes(&self) -> Vec<u8> {
422 let risk_level_str = match self.risk_level {
423 RiskLevel::Low => "low",
424 RiskLevel::Medium => "medium",
425 RiskLevel::High => "high",
426 };
427 let approval = self.approval_id.unwrap_or("");
428
429 let mut buf = Vec::with_capacity(512);
430 push_field(&mut buf, "seq", &self.seq.to_string());
433 push_field(&mut buf, "key_id", self.key_id);
434 push_field(&mut buf, "transaction_id", self.transaction_id);
435 push_field(&mut buf, "request_id", self.request_id);
436 push_field(&mut buf, "request_hash", self.request_hash);
437 push_field(&mut buf, "action_name", self.action_name);
438 push_field(&mut buf, "risk_level", risk_level_str);
439 push_field(&mut buf, "summary", self.summary);
440 push_field(&mut buf, "approval_id", approval);
441 push_field(&mut buf, "warnings_json", self.warnings_json);
442 push_field(&mut buf, "created_at", self.created_at);
443 buf
444 }
445}
446
447fn push_field(buf: &mut Vec<u8>, tag: &str, value: &str) {
465 buf.extend_from_slice(tag.as_bytes());
466 buf.push(0x1E); for b in value.bytes() {
468 match b {
469 b'\\' => buf.extend_from_slice(b"\\\\"),
470 0x00 => buf.extend_from_slice(b"\\0"),
471 0x1E => buf.extend_from_slice(b"\\1E"),
472 0x1F => buf.extend_from_slice(b"\\1F"),
473 other => buf.push(other),
474 }
475 }
476 buf.push(0x1F); }
478
479#[derive(Debug, Clone, PartialEq, Eq)]
481pub enum VerifyOutcome {
482 Intact { rows_checked: u64 },
484 Broken {
486 rows_checked: u64,
487 first_broken_seq: u64,
488 first_broken_transaction_id: String,
489 expected: String,
490 actual: String,
491 },
492 CannotVerify { reason: String },
496}
497
498pub fn outcome_to_exit_code(outcome: &VerifyOutcome) -> i32 {
504 match outcome {
505 VerifyOutcome::Intact { .. } => 0,
506 VerifyOutcome::Broken { .. } => 1,
507 VerifyOutcome::CannotVerify { .. } => 2,
508 }
509}
510
511#[derive(Debug, Clone)]
513pub struct ChainRow {
514 pub seq: u64,
515 pub key_id: String,
516 pub transaction_id: String,
517 pub request_id: String,
518 pub request_hash: String,
519 pub action_name: String,
520 pub risk_level: RiskLevel,
521 pub summary: String,
522 pub approval_id: Option<String>,
523 pub warnings_json: String,
524 pub created_at: String,
525 pub prev_chain_hash: String,
526 pub chain_hash: String,
527}
528
529pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
533 verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
534}
535
536pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
541 match parse_verifying_key(verifying_key_hex) {
542 Some(vk) => verify_rows(&vk, None, rows),
543 None => VerifyOutcome::CannotVerify {
544 reason: format!(
545 "invalid public key hex ({} chars); expected 64 hex chars of a \
546 32-byte Ed25519 public key",
547 verifying_key_hex.len()
548 ),
549 },
550 }
551}
552
553fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
554 let bytes = hex::decode(hex_str.trim()).ok()?;
555 let arr: [u8; 32] = bytes.try_into().ok()?;
556 VerifyingKey::from_bytes(&arr).ok()
557}
558
559fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
562 let Ok(bytes) = hex::decode(sig_hex) else {
563 return false;
564 };
565 let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
566 return false;
567 };
568 vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
569}
570
571fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
575 let mut last_hash = String::new();
576 let mut rows_checked = 0u64;
577 for row in rows {
578 if let Some(kid) = expect_key_id {
579 if row.key_id != kid {
580 return VerifyOutcome::CannotVerify {
581 reason: format!(
582 "row seq={} uses key_id={:?} but only {:?} is loaded; \
583 epoch keys not yet supported",
584 row.seq, row.key_id, kid
585 ),
586 };
587 }
588 }
589 if row.prev_chain_hash != last_hash {
590 return VerifyOutcome::Broken {
591 rows_checked,
592 first_broken_seq: row.seq,
593 first_broken_transaction_id: row.transaction_id.clone(),
594 expected: format!("prev_chain_hash={last_hash}"),
595 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
596 };
597 }
598 let content = ChainContent {
599 seq: row.seq,
600 key_id: &row.key_id,
601 transaction_id: &row.transaction_id,
602 request_id: &row.request_id,
603 request_hash: &row.request_hash,
604 action_name: &row.action_name,
605 risk_level: row.risk_level,
606 summary: &row.summary,
607 approval_id: row.approval_id.as_deref(),
608 warnings_json: &row.warnings_json,
609 created_at: &row.created_at,
610 };
611 let msg = chain_message(&content, &row.prev_chain_hash);
612 if !signature_ok(vk, &msg, &row.chain_hash) {
613 return VerifyOutcome::Broken {
614 rows_checked,
615 first_broken_seq: row.seq,
616 first_broken_transaction_id: row.transaction_id.clone(),
617 expected: "valid ed25519 signature".to_string(),
618 actual: row.chain_hash.clone(),
619 };
620 }
621 last_hash = row.chain_hash.clone();
622 rows_checked += 1;
623 }
624 VerifyOutcome::Intact { rows_checked }
625}
626
627#[derive(Debug, Clone, PartialEq, Eq)]
643pub struct Checkpoint {
644 pub seq: u64,
647 pub chain_tip: String,
649 pub created_at: String,
651 pub signature: String,
653}
654
655fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
658 let mut buf = CHECKPOINT_DOMAIN.to_vec();
661 push_field(&mut buf, "seq", &seq.to_string());
662 push_field(&mut buf, "chain_tip", chain_tip);
663 push_field(&mut buf, "created_at", created_at);
664 buf
665}
666
667impl AuditKey {
668 pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
670 let sig = self
671 .signing
672 .sign(&checkpoint_message(seq, chain_tip, created_at));
673 Checkpoint {
674 seq,
675 chain_tip: chain_tip.to_string(),
676 created_at: created_at.to_string(),
677 signature: hex::encode(sig.to_bytes()),
678 }
679 }
680}
681
682#[derive(Debug, Clone, PartialEq, Eq)]
684pub enum CheckpointOutcome {
685 Consistent { checkpoints_checked: u64 },
688 BadSignature { seq: u64 },
690 Truncated {
693 checkpoint_seq: u64,
694 current_max_seq: u64,
695 },
696 TipMismatch {
699 seq: u64,
700 anchored: String,
701 actual: String,
702 },
703 CannotVerify { reason: String },
705}
706
707pub fn verify_checkpoints(
713 verifying_key_hex: &str,
714 rows: &[ChainRow],
715 checkpoints: &[Checkpoint],
716) -> CheckpointOutcome {
717 let vk = match parse_verifying_key(verifying_key_hex) {
718 Some(vk) => vk,
719 None => {
720 return CheckpointOutcome::CannotVerify {
721 reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
722 };
723 }
724 };
725 let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
728 let mut checked = 0u64;
729 for cp in checkpoints {
730 let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
731 if !signature_ok(&vk, &msg, &cp.signature) {
732 return CheckpointOutcome::BadSignature { seq: cp.seq };
733 }
734 match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
735 Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
736 Ok(idx) => {
737 return CheckpointOutcome::TipMismatch {
738 seq: cp.seq,
739 anchored: cp.chain_tip.clone(),
740 actual: rows[idx].chain_hash.clone(),
741 };
742 }
743 Err(_) => {
744 return CheckpointOutcome::Truncated {
745 checkpoint_seq: cp.seq,
746 current_max_seq,
747 };
748 }
749 }
750 checked += 1;
751 }
752 CheckpointOutcome::Consistent {
753 checkpoints_checked: checked,
754 }
755}
756
757pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
762 match outcome {
763 CheckpointOutcome::Consistent { .. } => 0,
764 CheckpointOutcome::BadSignature { .. }
765 | CheckpointOutcome::Truncated { .. }
766 | CheckpointOutcome::TipMismatch { .. } => 1,
767 CheckpointOutcome::CannotVerify { .. } => 2,
768 }
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774
775 fn fixed_key() -> AuditKey {
776 AuditKey::from_bytes(vec![0x42; 32])
777 }
778
779 fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
780 ChainContent {
781 seq,
782 key_id: CURRENT_KEY_ID,
783 transaction_id: txid,
784 request_id: "req-1",
785 request_hash: "hash-abc",
786 action_name: "UpdateSystem",
787 risk_level: RiskLevel::High,
788 summary: "Upgrade",
789 approval_id: None,
790 warnings_json: "[]",
791 created_at: "2026-04-24T12:00:00Z",
792 }
793 }
794
795 #[test]
798 fn same_inputs_yield_same_hash() {
799 let key = fixed_key();
800 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
801 let h2 = key.chain_hash(&sample_content(1, "txa"), "");
802 assert_eq!(h1, h2);
803 assert_eq!(h1.len(), HASH_HEX_LEN);
804 }
805
806 #[test]
807 fn different_seq_yields_different_hash() {
808 let key = fixed_key();
809 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
810 let h2 = key.chain_hash(&sample_content(2, "txa"), "");
811 assert_ne!(h1, h2);
812 }
813
814 #[test]
815 fn different_prev_hash_yields_different_hash() {
816 let key = fixed_key();
817 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
818 let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
819 assert_ne!(h1, h2);
820 }
821
822 #[test]
823 fn different_keys_yield_different_hashes() {
824 let key1 = AuditKey::from_bytes(vec![0x01; 32]);
825 let key2 = AuditKey::from_bytes(vec![0x02; 32]);
826 let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
827 let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
828 assert_ne!(h1, h2);
829 }
830
831 #[test]
834 fn canonical_bytes_have_stable_field_order() {
835 let c = sample_content(1, "txa");
836 let bytes = c.canonical_bytes();
837 let s = String::from_utf8_lossy(&bytes);
838 let order = [
840 "seq",
841 "key_id",
842 "transaction_id",
843 "request_id",
844 "request_hash",
845 "action_name",
846 "risk_level",
847 "summary",
848 "approval_id",
849 "warnings_json",
850 "created_at",
851 ];
852 let mut last_idx = 0;
853 for tag in order {
854 let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
855 assert!(idx >= last_idx, "{tag} out of order");
856 last_idx = idx;
857 }
858 }
859
860 #[test]
861 fn nul_bytes_in_field_value_are_escaped() {
862 let mut c = sample_content(1, "txa");
863 let summary = "before\0after";
864 c.summary = summary;
865 let bytes = c.canonical_bytes();
866 assert!(!bytes.contains(&0x00));
868 assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
869 }
870
871 #[test]
877 fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
878 let mut a = sample_content(1, "txa");
879 a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
881 b.summary = "before\0after"; assert_ne!(
883 a.canonical_bytes(),
884 b.canonical_bytes(),
885 "backslash escape must run before NUL escape to prevent collision"
886 );
887 }
888
889 fn key_for_collision_test() -> AuditKey {
890 AuditKey::from_bytes(vec![0xab; 32])
891 }
892
893 #[test]
894 fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
895 let key = key_for_collision_test();
896 let mut a = sample_content(1, "txa");
897 a.summary = "before\\0after";
898 let mut b = sample_content(1, "txa");
899 b.summary = "before\0after";
900 assert_ne!(
901 key.chain_hash(&a, ""),
902 key.chain_hash(&b, ""),
903 "chain signature must distinguish escape from raw byte"
904 );
905 }
906
907 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
910 let mut rows = Vec::with_capacity(count);
911 let mut prev = String::new();
912 for i in 0..count {
913 let seq = (i + 1) as u64;
914 let txid = format!("tx{i}");
915 let content = sample_content(seq, &txid);
916 let hash = key.chain_hash(&content, &prev);
917 rows.push(ChainRow {
918 seq,
919 key_id: content.key_id.to_string(),
920 transaction_id: content.transaction_id.to_string(),
921 request_id: content.request_id.to_string(),
922 request_hash: content.request_hash.to_string(),
923 action_name: content.action_name.to_string(),
924 risk_level: content.risk_level,
925 summary: content.summary.to_string(),
926 approval_id: content.approval_id.map(str::to_string),
927 warnings_json: content.warnings_json.to_string(),
928 created_at: content.created_at.to_string(),
929 prev_chain_hash: prev.clone(),
930 chain_hash: hash.clone(),
931 });
932 prev = hash;
933 }
934 rows
935 }
936
937 #[test]
938 fn intact_chain_verifies() {
939 let key = fixed_key();
940 let rows = build_chain(&key, 5);
941 let outcome = verify_chain(&key, &rows);
942 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
943 assert_eq!(outcome_to_exit_code(&outcome), 0);
944 }
945
946 #[test]
947 fn empty_chain_verifies() {
948 let key = fixed_key();
949 let outcome = verify_chain(&key, &[]);
950 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
951 }
952
953 #[test]
954 fn tampered_summary_breaks_chain_at_first_offending_row() {
955 let key = fixed_key();
956 let mut rows = build_chain(&key, 3);
957 rows[1].summary = "TAMPERED".to_string();
959 let outcome = verify_chain(&key, &rows);
960 match outcome {
961 VerifyOutcome::Broken {
962 first_broken_seq, ..
963 } => assert_eq!(first_broken_seq, 2),
964 other => panic!("expected Broken, got {other:?}"),
965 }
966 }
967
968 #[test]
969 fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
970 let key = fixed_key();
971 let mut rows = build_chain(&key, 4);
972 rows.remove(1);
974 let outcome = verify_chain(&key, &rows);
975 match outcome {
976 VerifyOutcome::Broken {
977 first_broken_seq, ..
978 } => assert_eq!(
979 first_broken_seq, 3,
980 "first broken row is the one whose prev_hash refers to deleted row"
981 ),
982 other => panic!("expected Broken, got {other:?}"),
983 }
984 }
985
986 #[test]
987 fn inserted_forged_row_breaks_chain() {
988 let key = fixed_key();
995 let mut rows = build_chain(&key, 3);
996
997 let forged = ChainRow {
999 seq: 2,
1000 key_id: CURRENT_KEY_ID.to_string(),
1001 transaction_id: "tx-forged".to_string(),
1002 request_id: "req-forged".to_string(),
1003 request_hash: "hash-forged".to_string(),
1004 action_name: "InstallFlatpak".to_string(),
1005 risk_level: RiskLevel::Medium,
1006 summary: "Forged row".to_string(),
1007 approval_id: None,
1008 warnings_json: "[]".to_string(),
1009 created_at: "2026-04-25T13:00:00Z".to_string(),
1010 prev_chain_hash: rows[0].chain_hash.clone(),
1012 chain_hash: "0".repeat(HASH_HEX_LEN),
1014 };
1015
1016 let mut rest: Vec<ChainRow> = rows.split_off(1);
1018 for r in rest.iter_mut() {
1019 r.seq += 1;
1020 }
1021 rows.push(forged);
1022 rows.extend(rest);
1023
1024 let outcome = verify_chain(&key, &rows);
1025 match outcome {
1026 VerifyOutcome::Broken {
1027 first_broken_seq, ..
1028 } => assert!(
1029 first_broken_seq == 2 || first_broken_seq == 3,
1030 "verifier must flag the inserted row or the row that follows it (got {first_broken_seq})"
1031 ),
1032 other => panic!("expected Broken, got {other:?}"),
1033 }
1034 }
1035
1036 #[test]
1039 fn signature_verifies_under_exported_public_key() {
1040 let key = fixed_key();
1042 let rows = build_chain(&key, 4);
1043 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
1044 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
1045 }
1046
1047 #[test]
1048 fn foreign_public_key_cannot_validate_chain() {
1049 let signer = AuditKey::from_bytes(vec![0x11; 32]);
1053 let rows = build_chain(&signer, 3);
1054 let other = AuditKey::from_bytes(vec![0x22; 32]);
1055 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
1056 assert!(matches!(
1057 outcome,
1058 VerifyOutcome::Broken {
1059 first_broken_seq: 1,
1060 ..
1061 }
1062 ));
1063 }
1064
1065 #[test]
1066 fn verifying_key_hex_is_a_32_byte_public_key() {
1067 let key = fixed_key();
1068 let vk_hex = key.verifying_key_hex();
1069 assert_eq!(
1070 vk_hex.len(),
1071 64,
1072 "32-byte ed25519 public key = 64 hex chars"
1073 );
1074 assert!(hex::decode(&vk_hex).is_ok());
1075 }
1076
1077 #[test]
1078 fn malformed_signature_hex_is_broken_not_panic() {
1079 let key = fixed_key();
1080 let mut rows = build_chain(&key, 2);
1081 rows[0].chain_hash = "not-valid-hex!!".to_string();
1082 let outcome = verify_chain(&key, &rows);
1083 assert!(matches!(
1084 outcome,
1085 VerifyOutcome::Broken {
1086 first_broken_seq: 1,
1087 ..
1088 }
1089 ));
1090 }
1091
1092 #[test]
1093 fn bad_public_key_hex_yields_cannot_verify() {
1094 let key = fixed_key();
1095 let rows = build_chain(&key, 1);
1096 let outcome = verify_chain_with_pubkey("zz", &rows);
1097 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1098 }
1099
1100 #[test]
1101 fn load_or_generate_writes_public_key_sidecar() {
1102 let dir = tempfile::tempdir().unwrap();
1103 let path = dir.path().join("audit-key");
1104 let key = AuditKey::load_or_generate(&path).unwrap();
1105 let pub_path = dir.path().join("audit-key.pub");
1106 assert!(pub_path.exists(), "public key sidecar must be written");
1107 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
1108 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
1109 }
1110
1111 #[test]
1112 fn wrong_key_id_yields_cannot_verify() {
1113 let key = fixed_key();
1114 let mut rows = build_chain(&key, 2);
1115 rows[0].key_id = "v99".to_string();
1116 let outcome = verify_chain(&key, &rows);
1117 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1118 assert_eq!(outcome_to_exit_code(&outcome), 2);
1119 }
1120
1121 #[test]
1122 fn exit_code_for_each_outcome() {
1123 assert_eq!(
1124 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
1125 0
1126 );
1127 assert_eq!(
1128 outcome_to_exit_code(&VerifyOutcome::Broken {
1129 rows_checked: 0,
1130 first_broken_seq: 1,
1131 first_broken_transaction_id: String::new(),
1132 expected: String::new(),
1133 actual: String::new(),
1134 }),
1135 1
1136 );
1137 assert_eq!(
1138 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
1139 reason: String::new()
1140 }),
1141 2
1142 );
1143 }
1144
1145 #[test]
1148 fn load_or_generate_creates_with_0600_mode() {
1149 use std::os::unix::fs::PermissionsExt;
1150 let dir = tempfile::tempdir().unwrap();
1151 let path = dir.path().join("audit-key");
1152 let _key = AuditKey::load_or_generate(&path).unwrap();
1153 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1154 assert_eq!(mode, 0o600);
1155 }
1156
1157 #[test]
1158 fn load_or_generate_is_idempotent() {
1159 let dir = tempfile::tempdir().unwrap();
1160 let path = dir.path().join("audit-key");
1161 let key1_bytes = {
1162 let _ = AuditKey::load_or_generate(&path).unwrap();
1163 std::fs::read(&path).unwrap()
1164 };
1165 let _ = AuditKey::load_or_generate(&path).unwrap();
1166 let key2_bytes = std::fs::read(&path).unwrap();
1167 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
1168 }
1169
1170 #[test]
1171 fn rejects_world_readable_key_file() {
1172 use std::os::unix::fs::PermissionsExt;
1173 let dir = tempfile::tempdir().unwrap();
1174 let path = dir.path().join("audit-key");
1175 std::fs::write(&path, vec![0u8; 32]).unwrap();
1176 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1177 let result = AuditKey::load_or_generate(&path);
1178 assert!(matches!(
1179 result,
1180 Err(AuditKeyError::UnsafePermissions { .. })
1181 ));
1182 }
1183
1184 #[test]
1185 fn rejects_short_key_file() {
1186 use std::os::unix::fs::PermissionsExt;
1187 let dir = tempfile::tempdir().unwrap();
1188 let path = dir.path().join("audit-key");
1189 std::fs::write(&path, vec![0u8; 8]).unwrap();
1190 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1191 let result = AuditKey::load_or_generate(&path);
1192 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
1193 }
1194
1195 #[test]
1201 fn debug_redacts_key_bytes_and_their_hex_encoding() {
1202 let raw = (0u8..32).collect::<Vec<u8>>();
1204 let key = AuditKey::from_bytes(raw.clone());
1205 let dbg = format!("{key:?}");
1206
1207 assert!(
1208 dbg.contains("redacted"),
1209 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
1210 );
1211
1212 let hex_lower = hex::encode(&raw);
1214 let hex_upper = hex::encode_upper(&raw);
1215 assert!(
1216 !dbg.contains(&hex_lower),
1217 "Debug output leaks lowercase hex of key bytes: {dbg}"
1218 );
1219 assert!(
1220 !dbg.contains(&hex_upper),
1221 "Debug output leaks uppercase hex of key bytes: {dbg}"
1222 );
1223
1224 for b in &raw {
1228 let pair = format!("{b:02x}");
1229 if *b >= 0x10 && pair != "1e" && pair != "1f" {
1234 assert!(
1235 !dbg.contains(&pair),
1236 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
1237 );
1238 }
1239 }
1240 }
1241
1242 #[test]
1245 fn debug_preserves_key_id() {
1246 let key = AuditKey::from_bytes(vec![0xff; 32]);
1247 let dbg = format!("{key:?}");
1248 assert!(
1249 dbg.contains("key_id"),
1250 "Debug output must label the key_id field: {dbg}"
1251 );
1252 assert!(
1253 dbg.contains(CURRENT_KEY_ID),
1254 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
1255 );
1256 }
1257
1258 #[test]
1261 fn checkpoint_consistent_with_intact_chain() {
1262 let key = fixed_key();
1263 let rows = build_chain(&key, 5);
1264 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1265 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
1266 assert_eq!(
1267 outcome,
1268 CheckpointOutcome::Consistent {
1269 checkpoints_checked: 1
1270 }
1271 );
1272 }
1273
1274 #[test]
1275 fn checkpoint_detects_truncation() {
1276 let key = fixed_key();
1277 let full = build_chain(&key, 5);
1278 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
1280 let truncated = &full[..3];
1281 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
1282 assert!(matches!(
1283 outcome,
1284 CheckpointOutcome::Truncated {
1285 checkpoint_seq: 5,
1286 current_max_seq: 3
1287 }
1288 ));
1289 }
1290
1291 #[test]
1292 fn checkpoint_detects_rewrite() {
1293 let key = fixed_key();
1294 let mut rows = build_chain(&key, 4);
1295 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1296 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
1298 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
1299 assert!(matches!(
1300 outcome,
1301 CheckpointOutcome::TipMismatch { seq: 3, .. }
1302 ));
1303 }
1304
1305 #[test]
1306 fn checkpoint_bad_signature_detected() {
1307 let key = fixed_key();
1308 let rows = build_chain(&key, 3);
1309 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
1310 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
1312 assert!(matches!(
1313 outcome,
1314 CheckpointOutcome::BadSignature { seq: 2 }
1315 ));
1316 }
1317
1318 #[test]
1319 fn checkpoint_foreign_key_rejected() {
1320 let signer = AuditKey::from_bytes(vec![0x11; 32]);
1322 let rows = build_chain(&signer, 3);
1323 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
1324 let other = AuditKey::from_bytes(vec![0x22; 32]);
1325 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
1326 assert!(matches!(
1327 outcome,
1328 CheckpointOutcome::BadSignature { seq: 2 }
1329 ));
1330 }
1331
1332 #[test]
1333 fn pubkey_verify_detects_tampered_middle_row() {
1334 let key = fixed_key();
1337 let mut rows = build_chain(&key, 4);
1338 rows[2].summary = "TAMPERED".to_string();
1339 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
1340 assert!(matches!(
1341 outcome,
1342 VerifyOutcome::Broken {
1343 first_broken_seq: 3,
1344 ..
1345 }
1346 ));
1347 }
1348
1349 #[test]
1350 fn multiple_checkpoints_all_consistent() {
1351 let key = fixed_key();
1352 let rows = build_chain(&key, 5);
1353 let cps = vec![
1354 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
1355 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
1356 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
1357 ];
1358 assert_eq!(
1359 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
1360 CheckpointOutcome::Consistent {
1361 checkpoints_checked: 3
1362 }
1363 );
1364 }
1365
1366 #[test]
1367 fn middle_checkpoint_failure_is_reported() {
1368 let key = fixed_key();
1370 let mut rows = build_chain(&key, 5);
1371 let cps = vec![
1372 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
1373 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
1374 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
1375 ];
1376 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
1378 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
1379 CheckpointOutcome::TipMismatch { seq: 3, .. }
1380 ));
1381 }
1382
1383 #[test]
1384 fn checkpoint_created_at_is_signed() {
1385 let key = fixed_key();
1387 let rows = build_chain(&key, 3);
1388 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1389 cp.created_at = "2020-01-01T00:00:00Z".to_string();
1390 assert!(matches!(
1391 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
1392 CheckpointOutcome::BadSignature { .. }
1393 ));
1394 }
1395
1396 #[test]
1397 fn checkpoint_seq_is_signed() {
1398 let key = fixed_key();
1400 let rows = build_chain(&key, 3);
1401 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1402 cp.seq = 2;
1403 assert!(matches!(
1404 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
1405 CheckpointOutcome::BadSignature { seq: 2 }
1406 ));
1407 }
1408
1409 #[test]
1410 fn checkpoint_detects_full_wipe() {
1411 let key = fixed_key();
1413 let full = build_chain(&key, 3);
1414 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
1415 assert!(matches!(
1416 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
1417 CheckpointOutcome::Truncated {
1418 checkpoint_seq: 3,
1419 current_max_seq: 0
1420 }
1421 ));
1422 }
1423
1424 #[test]
1425 fn domain_tags_are_distinct_and_prefix_free() {
1426 assert_ne!(ROW_DOMAIN, CHECKPOINT_DOMAIN);
1430 assert_ne!(ROW_DOMAIN, APPROVAL_DOMAIN);
1431 assert_ne!(CHECKPOINT_DOMAIN, APPROVAL_DOMAIN);
1432 assert!(!ROW_DOMAIN.starts_with(CHECKPOINT_DOMAIN));
1433 assert!(!CHECKPOINT_DOMAIN.starts_with(ROW_DOMAIN));
1434 assert!(!ROW_DOMAIN.starts_with(APPROVAL_DOMAIN));
1435 assert!(!APPROVAL_DOMAIN.starts_with(ROW_DOMAIN));
1436 assert!(!CHECKPOINT_DOMAIN.starts_with(APPROVAL_DOMAIN));
1437 assert!(!APPROVAL_DOMAIN.starts_with(CHECKPOINT_DOMAIN));
1438 }
1439
1440 #[test]
1441 fn approval_receipt_framing_is_injective() {
1442 let key = fixed_key();
1447 let sep = "\u{1f}";
1448 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
1449 let b = key.approval_receipt("tx", &format!("b{sep}c"));
1450 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
1451
1452 assert_eq!(
1454 key.approval_receipt("tx-1", "hash-1"),
1455 key.approval_receipt("tx-1", "hash-1"),
1456 );
1457 assert_eq!(
1459 key.approval_commitment("tx-1", "hash-1"),
1460 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
1461 );
1462 }
1463}