1use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
93use sha2::{Digest, Sha256};
94use std::path::{Path, PathBuf};
95use sysknife_types::RiskLevel;
96use zeroize::Zeroize;
97
98pub const CURRENT_KEY_ID: &str = "v1";
101
102pub const HASH_HEX_LEN: usize = 128;
104
105const ROW_DOMAIN: &[u8] = b"sysknife-audit-row-v1\x1f";
109const CHECKPOINT_DOMAIN: &[u8] = b"sysknife-checkpoint-v1\x1f";
110const APPROVAL_DOMAIN: &[u8] = b"sysknife-approval-receipt-v1\x1f";
111const EVENT_DOMAIN: &[u8] = b"sysknife-audit-event-v1\x1f";
112
113pub const CHAIN_VERSION_LEGACY: u32 = 1;
116pub const CHAIN_VERSION_V2: u32 = 2;
126pub const CHAIN_VERSION_V3: u32 = 3;
134pub const CHAIN_VERSION_CURRENT: u32 = CHAIN_VERSION_V3;
141
142pub const EVENT_VERSION_LEGACY: u32 = 1;
146pub const EVENT_VERSION_V2: u32 = 2;
155pub const EVENT_VERSION_CURRENT: u32 = EVENT_VERSION_V2;
159
160#[derive(Clone)]
172pub struct AuditKey {
173 key_id: String,
174 signing: SigningKey,
175}
176
177impl std::fmt::Debug for AuditKey {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 f.debug_struct("AuditKey")
184 .field("key_id", &self.key_id)
185 .field("signing", &format_args!("<redacted signing key>"))
186 .finish()
187 }
188}
189
190#[derive(Debug, thiserror::Error)]
195pub enum AuditKeyError {
196 #[error("io error reading audit key {path}: {source}")]
197 Io {
198 path: PathBuf,
199 #[source]
200 source: std::io::Error,
201 },
202 #[error(
203 "audit key file {path} has unsafe permissions {mode:#o}; \
204 tighten with `chmod 600 {path:?}` and restart"
205 )]
206 UnsafePermissions { path: PathBuf, mode: u32 },
207 #[error(
208 "audit key file {path} is too short ({len} bytes); \
209 expected at least 32 bytes of random material"
210 )]
211 KeyTooShort { path: PathBuf, len: usize },
212}
213
214impl AuditKey {
215 pub fn load_or_generate(path: &Path) -> Result<Self, AuditKeyError> {
222 if !path.exists() {
223 generate_key_at(path)?;
224 }
225
226 let metadata = std::fs::metadata(path).map_err(|e| AuditKeyError::Io {
227 path: path.to_path_buf(),
228 source: e,
229 })?;
230
231 use std::os::unix::fs::PermissionsExt;
232 let mode = metadata.permissions().mode() & 0o777;
233 if mode & 0o077 != 0 {
234 return Err(AuditKeyError::UnsafePermissions {
236 path: path.to_path_buf(),
237 mode,
238 });
239 }
240
241 let mut key_bytes = std::fs::read(path).map_err(|e| AuditKeyError::Io {
242 path: path.to_path_buf(),
243 source: e,
244 })?;
245 if key_bytes.len() < 32 {
246 return Err(AuditKeyError::KeyTooShort {
247 path: path.to_path_buf(),
248 len: key_bytes.len(),
249 });
250 }
251
252 let mut seed = [0u8; 32];
253 seed.copy_from_slice(&key_bytes[..32]);
254 let signing = SigningKey::from_bytes(&seed);
255 seed.zeroize();
256 key_bytes.zeroize();
257
258 Ok(Self {
259 key_id: CURRENT_KEY_ID.to_string(),
260 signing,
261 })
262 }
263
264 #[cfg(test)]
267 pub fn from_bytes(bytes: Vec<u8>) -> Self {
268 let mut seed = [0u8; 32];
269 seed.copy_from_slice(&bytes[..32]);
270 let signing = SigningKey::from_bytes(&seed);
271 seed.zeroize();
272 Self {
273 key_id: CURRENT_KEY_ID.to_string(),
274 signing,
275 }
276 }
277
278 pub fn key_id(&self) -> &str {
280 &self.key_id
281 }
282
283 pub fn chain_hash(&self, content: &ChainContent, prev_chain_hash: &str) -> String {
288 let sig = self.signing.sign(&chain_message(content, prev_chain_hash));
289 hex::encode(sig.to_bytes())
290 }
291
292 pub fn verifying_key(&self) -> VerifyingKey {
294 self.signing.verifying_key()
295 }
296
297 pub fn verifying_key_hex(&self) -> String {
300 hex::encode(self.signing.verifying_key().to_bytes())
301 }
302
303 pub fn approval_receipt(&self, transaction_id: &str, request_hash: &str) -> String {
307 let mut message = APPROVAL_DOMAIN.to_vec();
313 push_field(&mut message, "txid", transaction_id);
314 push_field(&mut message, "reqhash", request_hash);
315 hex::encode(self.signing.sign(&message).to_bytes())
316 }
317
318 pub fn approval_commitment(&self, transaction_id: &str, request_hash: &str) -> String {
320 approval_receipt_digest(&self.approval_receipt(transaction_id, request_hash))
321 }
322}
323
324pub fn approval_receipt_digest(receipt: &str) -> String {
325 hex::encode(Sha256::digest(receipt.as_bytes()))
326}
327
328fn chain_message(content: &ChainContent, prev_chain_hash: &str) -> Vec<u8> {
334 let mut msg = ROW_DOMAIN.to_vec();
335 msg.extend_from_slice(&content.canonical_bytes());
336 msg.extend_from_slice(prev_chain_hash.as_bytes());
337 msg
338}
339
340pub fn resolve_audit_key_path(db_path: &Path) -> PathBuf {
346 std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
347 .map(PathBuf::from)
348 .unwrap_or_else(|_| {
349 db_path
350 .parent()
351 .unwrap_or_else(|| Path::new("."))
352 .join("audit-key")
353 })
354}
355
356fn generate_key_at(path: &Path) -> Result<(), AuditKeyError> {
359 use std::io::Write as _;
360 use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
361
362 if let Some(parent) = path.parent() {
363 if !parent.exists() {
364 std::fs::DirBuilder::new()
365 .recursive(true)
366 .mode(0o700)
367 .create(parent)
368 .map_err(|e| AuditKeyError::Io {
369 path: parent.to_path_buf(),
370 source: e,
371 })?;
372 }
373 }
374
375 let mut bytes = [0u8; 32];
376 fill_random(&mut bytes).map_err(|e| AuditKeyError::Io {
377 path: path.to_path_buf(),
378 source: e,
379 })?;
380
381 let mut f = std::fs::OpenOptions::new()
382 .write(true)
383 .create_new(true)
384 .mode(0o600)
385 .open(path)
386 .map_err(|e| AuditKeyError::Io {
387 path: path.to_path_buf(),
388 source: e,
389 })?;
390 f.write_all(&bytes).map_err(|e| AuditKeyError::Io {
391 path: path.to_path_buf(),
392 source: e,
393 })?;
394 f.sync_all().map_err(|e| AuditKeyError::Io {
395 path: path.to_path_buf(),
396 source: e,
397 })?;
398
399 let signing = SigningKey::from_bytes(&bytes);
402 let pub_hex = hex::encode(signing.verifying_key().to_bytes());
403 bytes.zeroize();
404 let mut pub_path = path.as_os_str().to_os_string();
405 pub_path.push(".pub");
406 std::fs::write(&pub_path, format!("{pub_hex}\n")).map_err(|e| AuditKeyError::Io {
407 path: PathBuf::from(pub_path),
408 source: e,
409 })?;
410 Ok(())
411}
412
413fn fill_random(buf: &mut [u8]) -> std::io::Result<()> {
415 use std::io::Read as _;
416 let mut f = std::fs::File::open("/dev/urandom")?;
417 f.read_exact(buf)
418}
419
420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
483pub enum ChainIdentity<'a> {
484 LegacyV1,
486 V2 {
489 caller_role: &'a str,
494 event_tip: &'a str,
500 },
501 V3 {
508 caller_role: &'a str,
510 event_tip: &'a str,
512 caller_principal: &'a str,
523 },
524}
525
526impl ChainIdentity<'_> {
527 pub fn version(&self) -> u32 {
529 match self {
530 Self::LegacyV1 => CHAIN_VERSION_LEGACY,
531 Self::V2 { .. } => CHAIN_VERSION_V2,
532 Self::V3 { .. } => CHAIN_VERSION_V3,
533 }
534 }
535}
536
537#[derive(Debug, Clone, PartialEq, Eq)]
538pub struct ChainContent<'a> {
539 pub seq: u64,
540 pub key_id: &'a str,
541 pub transaction_id: &'a str,
542 pub request_id: &'a str,
543 pub request_hash: &'a str,
544 pub action_name: &'a str,
545 pub risk_level: RiskLevel,
546 pub summary: &'a str,
555 pub approval_id: Option<&'a str>,
558 pub warnings_json: &'a str,
560 pub created_at: &'a str,
561 pub identity: ChainIdentity<'a>,
563}
564
565impl<'a> ChainContent<'a> {
566 pub fn canonical_bytes(&self) -> Vec<u8> {
570 let risk_level_str = match self.risk_level {
571 RiskLevel::Low => "low",
572 RiskLevel::Medium => "medium",
573 RiskLevel::High => "high",
574 };
575 let approval = self.approval_id.unwrap_or("");
576
577 let mut buf = Vec::with_capacity(512);
578 push_field(&mut buf, "seq", &self.seq.to_string());
581 push_field(&mut buf, "key_id", self.key_id);
582 push_field(&mut buf, "transaction_id", self.transaction_id);
583 push_field(&mut buf, "request_id", self.request_id);
584 push_field(&mut buf, "request_hash", self.request_hash);
585 push_field(&mut buf, "action_name", self.action_name);
586 push_field(&mut buf, "risk_level", risk_level_str);
587 push_field(&mut buf, "summary", self.summary);
588 push_field(&mut buf, "approval_id", approval);
589 push_field(&mut buf, "warnings_json", self.warnings_json);
590 push_field(&mut buf, "created_at", self.created_at);
591 match self.identity {
607 ChainIdentity::LegacyV1 => {}
608 ChainIdentity::V2 {
609 caller_role,
610 event_tip,
611 } => {
612 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V2.to_string());
613 push_field(&mut buf, "caller_role", caller_role);
614 push_field(&mut buf, "event_tip", event_tip);
615 }
616 ChainIdentity::V3 {
617 caller_role,
618 event_tip,
619 caller_principal,
620 } => {
621 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V3.to_string());
622 push_field(&mut buf, "caller_role", caller_role);
623 push_field(&mut buf, "event_tip", event_tip);
624 push_field(&mut buf, "caller_principal", caller_principal);
625 }
626 }
627 buf
628 }
629}
630
631fn push_field(buf: &mut Vec<u8>, tag: &str, value: &str) {
649 buf.extend_from_slice(tag.as_bytes());
650 buf.push(0x1E); for b in value.bytes() {
652 match b {
653 b'\\' => buf.extend_from_slice(b"\\\\"),
654 0x00 => buf.extend_from_slice(b"\\0"),
655 0x1E => buf.extend_from_slice(b"\\1E"),
656 0x1F => buf.extend_from_slice(b"\\1F"),
657 other => buf.push(other),
658 }
659 }
660 buf.push(0x1F); }
662
663#[derive(Debug, Clone, PartialEq, Eq)]
665pub enum VerifyOutcome {
666 Intact { rows_checked: u64 },
668 Broken {
670 rows_checked: u64,
671 first_broken_seq: u64,
672 first_broken_transaction_id: String,
673 expected: String,
674 actual: String,
675 },
676 CannotVerify { reason: String },
680}
681
682pub fn outcome_to_exit_code(outcome: &VerifyOutcome) -> i32 {
688 match outcome {
689 VerifyOutcome::Intact { .. } => 0,
690 VerifyOutcome::Broken { .. } => 1,
691 VerifyOutcome::CannotVerify { .. } => 2,
692 }
693}
694
695#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
697pub struct ChainRow {
698 pub seq: u64,
699 pub key_id: String,
700 pub transaction_id: String,
701 pub request_id: String,
702 pub request_hash: String,
703 pub action_name: String,
704 pub risk_level: RiskLevel,
705 pub summary: String,
706 pub approval_id: Option<String>,
707 pub warnings_json: String,
708 pub created_at: String,
709 pub prev_chain_hash: String,
710 pub chain_hash: String,
711 pub chain_version: u32,
713 pub caller_role: Option<String>,
715 pub event_tip: Option<String>,
718 pub caller_principal: Option<String>,
722}
723
724#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
726pub enum ChainExportError {
727 #[error("--since: {0:?} is not a valid RFC 3339 timestamp")]
728 InvalidSince(String),
729 #[error("audit row {seq} has an invalid RFC 3339 created_at value {created_at:?}")]
730 InvalidCreatedAt { seq: u64, created_at: String },
731}
732
733pub fn validate_export_since(since: &str) -> Result<(), ChainExportError> {
735 chrono::DateTime::parse_from_rfc3339(since)
736 .map(|_| ())
737 .map_err(|_| ChainExportError::InvalidSince(since.to_string()))
738}
739
740pub fn select_chain_rows_for_export(
747 rows: Vec<ChainRow>,
748 since: Option<&str>,
749 limit: Option<u32>,
750) -> Result<Vec<ChainRow>, ChainExportError> {
751 let since = since
752 .map(|value| {
753 chrono::DateTime::parse_from_rfc3339(value)
754 .map_err(|_| ChainExportError::InvalidSince(value.to_string()))
755 })
756 .transpose()?;
757 let limit = limit.map_or(usize::MAX, |value| value as usize);
758
759 rows.into_iter()
760 .filter_map(|row| {
761 let keep = match since.as_ref() {
762 None => true,
763 Some(since) => match chrono::DateTime::parse_from_rfc3339(&row.created_at) {
764 Ok(created_at) => created_at >= *since,
765 Err(_) => {
766 return Some(Err(ChainExportError::InvalidCreatedAt {
767 seq: row.seq,
768 created_at: row.created_at,
769 }));
770 }
771 },
772 };
773 keep.then_some(Ok(row))
774 })
775 .take(limit)
776 .collect()
777}
778
779#[derive(Debug)]
781pub(crate) enum RowIdentityError {
782 UnknownVersion(u32),
786 MissingField(&'static str),
792}
793
794impl ChainRow {
795 pub(crate) fn identity(&self) -> Result<ChainIdentity<'_>, RowIdentityError> {
798 match self.chain_version {
799 CHAIN_VERSION_LEGACY => Ok(ChainIdentity::LegacyV1),
800 CHAIN_VERSION_V2 => Ok(ChainIdentity::V2 {
801 caller_role: self
802 .caller_role
803 .as_deref()
804 .ok_or(RowIdentityError::MissingField("caller_role"))?,
805 event_tip: self
806 .event_tip
807 .as_deref()
808 .ok_or(RowIdentityError::MissingField("event_tip"))?,
809 }),
810 CHAIN_VERSION_V3 => Ok(ChainIdentity::V3 {
811 caller_role: self
812 .caller_role
813 .as_deref()
814 .ok_or(RowIdentityError::MissingField("caller_role"))?,
815 event_tip: self
816 .event_tip
817 .as_deref()
818 .ok_or(RowIdentityError::MissingField("event_tip"))?,
819 caller_principal: self
823 .caller_principal
824 .as_deref()
825 .filter(|p| !p.is_empty())
826 .ok_or(RowIdentityError::MissingField("caller_principal"))?,
827 }),
828 other => Err(RowIdentityError::UnknownVersion(other)),
829 }
830 }
831}
832
833pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
837 verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
838}
839
840pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
845 match parse_verifying_key(verifying_key_hex) {
846 Some(vk) => verify_rows(&vk, None, rows),
847 None => VerifyOutcome::CannotVerify {
848 reason: format!(
849 "invalid public key hex ({} chars); expected 64 hex chars of a \
850 32-byte Ed25519 public key",
851 verifying_key_hex.len()
852 ),
853 },
854 }
855}
856
857fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
858 let bytes = hex::decode(hex_str.trim()).ok()?;
859 let arr: [u8; 32] = bytes.try_into().ok()?;
860 VerifyingKey::from_bytes(&arr).ok()
861}
862
863fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
866 let Ok(bytes) = hex::decode(sig_hex) else {
867 return false;
868 };
869 let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
870 return false;
871 };
872 vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
873}
874
875fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
879 let mut last_hash = String::new();
880 let mut rows_checked = 0u64;
881 for row in rows {
882 if let Some(kid) = expect_key_id {
883 if row.key_id != kid {
884 return VerifyOutcome::CannotVerify {
885 reason: format!(
886 "row seq={} uses key_id={:?} but only {:?} is loaded; \
887 epoch keys not yet supported",
888 row.seq, row.key_id, kid
889 ),
890 };
891 }
892 }
893 if row.prev_chain_hash != last_hash {
894 return VerifyOutcome::Broken {
895 rows_checked,
896 first_broken_seq: row.seq,
897 first_broken_transaction_id: row.transaction_id.clone(),
898 expected: format!("prev_chain_hash={last_hash}"),
899 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
900 };
901 }
902 let identity = match row.identity() {
903 Ok(identity) => identity,
904 Err(RowIdentityError::UnknownVersion(v)) => {
905 return VerifyOutcome::CannotVerify {
906 reason: format!(
907 "row seq={} declares chain_version={v}, which this binary cannot \
908 reproduce (it understands {CHAIN_VERSION_LEGACY}..={CHAIN_VERSION_CURRENT}); \
909 verify with a build at least as new as the one that wrote the chain",
910 row.seq
911 ),
912 };
913 }
914 Err(RowIdentityError::MissingField(field)) => {
915 let stored = match field {
920 "caller_role" => row.caller_role.as_deref(),
921 "event_tip" => row.event_tip.as_deref(),
922 "caller_principal" => row.caller_principal.as_deref(),
923 _ => None,
924 };
925 return VerifyOutcome::Broken {
926 rows_checked,
927 first_broken_seq: row.seq,
928 first_broken_transaction_id: row.transaction_id.clone(),
929 expected: format!(
930 "chain_version={} row carrying a non-empty {field}",
931 row.chain_version
932 ),
933 actual: match stored {
936 None => format!("{field}=NULL"),
937 Some("") => format!("{field}='' (empty, not NULL)"),
938 Some(other) => format!("{field}={other:?}"),
939 },
940 };
941 }
942 };
943 let content = ChainContent {
944 seq: row.seq,
945 key_id: &row.key_id,
946 transaction_id: &row.transaction_id,
947 request_id: &row.request_id,
948 request_hash: &row.request_hash,
949 action_name: &row.action_name,
950 risk_level: row.risk_level,
951 summary: &row.summary,
952 approval_id: row.approval_id.as_deref(),
953 warnings_json: &row.warnings_json,
954 created_at: &row.created_at,
955 identity,
956 };
957 let msg = chain_message(&content, &row.prev_chain_hash);
958 if !signature_ok(vk, &msg, &row.chain_hash) {
959 return VerifyOutcome::Broken {
960 rows_checked,
961 first_broken_seq: row.seq,
962 first_broken_transaction_id: row.transaction_id.clone(),
963 expected: "valid ed25519 signature".to_string(),
964 actual: row.chain_hash.clone(),
965 };
966 }
967 last_hash = row.chain_hash.clone();
968 rows_checked += 1;
969 }
970 VerifyOutcome::Intact { rows_checked }
971}
972
973#[derive(Debug, Clone, Copy, PartialEq, Eq)]
986pub enum AuditEventKind {
987 ApprovalGranted,
989 ApprovalConsumed,
991 ApprovalRevoked,
993 StatusQueued,
1002 StatusRunning,
1003 StatusSucceeded,
1004 StatusFailed,
1005 StatusCanceled,
1006 StatusRolledBack,
1007 StatusNeedsReboot,
1008}
1009
1010impl From<sysknife_types::JobState> for AuditEventKind {
1011 fn from(state: sysknife_types::JobState) -> Self {
1012 use sysknife_types::JobState as S;
1013 match state {
1014 S::Queued => Self::StatusQueued,
1015 S::Running => Self::StatusRunning,
1016 S::Succeeded => Self::StatusSucceeded,
1017 S::Failed => Self::StatusFailed,
1018 S::Canceled => Self::StatusCanceled,
1019 S::RolledBack => Self::StatusRolledBack,
1020 S::NeedsReboot => Self::StatusNeedsReboot,
1021 }
1022 }
1023}
1024
1025impl AuditEventKind {
1026 pub const ALL: &'static [AuditEventKind] = &[
1037 Self::ApprovalGranted,
1038 Self::ApprovalConsumed,
1039 Self::ApprovalRevoked,
1040 Self::StatusQueued,
1041 Self::StatusRunning,
1042 Self::StatusSucceeded,
1043 Self::StatusFailed,
1044 Self::StatusCanceled,
1045 Self::StatusRolledBack,
1046 Self::StatusNeedsReboot,
1047 ];
1048
1049 pub fn as_str(&self) -> &'static str {
1052 match self {
1053 Self::ApprovalGranted => "approval_granted",
1054 Self::ApprovalConsumed => "approval_consumed",
1055 Self::ApprovalRevoked => "approval_revoked",
1056 Self::StatusQueued => "status_queued",
1057 Self::StatusRunning => "status_running",
1058 Self::StatusSucceeded => "status_succeeded",
1059 Self::StatusFailed => "status_failed",
1060 Self::StatusCanceled => "status_canceled",
1061 Self::StatusRolledBack => "status_rolled_back",
1062 Self::StatusNeedsReboot => "status_needs_reboot",
1063 }
1064 }
1065}
1066
1067#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1077pub enum EventIdentity<'a> {
1078 LegacyV1,
1082 V2 {
1089 caller_principal: &'a str,
1095 },
1096}
1097
1098impl EventIdentity<'_> {
1099 pub fn version(&self) -> u32 {
1101 match self {
1102 Self::LegacyV1 => EVENT_VERSION_LEGACY,
1103 Self::V2 { .. } => EVENT_VERSION_V2,
1104 }
1105 }
1106}
1107
1108#[derive(Debug, Clone, PartialEq, Eq)]
1110pub struct EventContent<'a> {
1111 pub seq: u64,
1112 pub key_id: &'a str,
1113 pub kind: AuditEventKind,
1114 pub transaction_id: &'a str,
1115 pub receipt_digest: &'a str,
1119 pub created_at: &'a str,
1120 pub identity: EventIdentity<'a>,
1122}
1123
1124impl EventContent<'_> {
1125 pub fn canonical_bytes(&self) -> Vec<u8> {
1128 let mut buf = Vec::with_capacity(256);
1129 push_field(&mut buf, "seq", &self.seq.to_string());
1130 push_field(&mut buf, "key_id", self.key_id);
1131 push_field(&mut buf, "kind", self.kind.as_str());
1132 push_field(&mut buf, "transaction_id", self.transaction_id);
1133 push_field(&mut buf, "receipt_digest", self.receipt_digest);
1134 push_field(&mut buf, "created_at", self.created_at);
1135 match self.identity {
1141 EventIdentity::LegacyV1 => {}
1142 EventIdentity::V2 { caller_principal } => {
1143 push_field(&mut buf, "event_version", &EVENT_VERSION_V2.to_string());
1144 push_field(&mut buf, "caller_principal", caller_principal);
1145 }
1146 }
1147 buf
1148 }
1149}
1150
1151fn event_message(content: &EventContent, prev_chain_hash: &str) -> Vec<u8> {
1152 let mut msg = EVENT_DOMAIN.to_vec();
1153 msg.extend_from_slice(&content.canonical_bytes());
1154 msg.extend_from_slice(prev_chain_hash.as_bytes());
1155 msg
1156}
1157
1158impl AuditKey {
1159 pub fn event_hash(&self, content: &EventContent, prev_chain_hash: &str) -> String {
1163 hex::encode(
1164 self.signing
1165 .sign(&event_message(content, prev_chain_hash))
1166 .to_bytes(),
1167 )
1168 }
1169}
1170
1171#[derive(Debug, Clone, PartialEq, Eq)]
1173pub struct EventRow {
1174 pub seq: u64,
1175 pub key_id: String,
1176 pub kind: String,
1180 pub transaction_id: String,
1181 pub receipt_digest: String,
1182 pub created_at: String,
1183 pub prev_chain_hash: String,
1184 pub chain_hash: String,
1185 pub chain_version: u32,
1188 pub caller_principal: Option<String>,
1194}
1195
1196impl EventRow {
1197 pub(crate) fn identity(&self) -> Result<EventIdentity<'_>, RowIdentityError> {
1201 match self.chain_version {
1202 EVENT_VERSION_LEGACY => Ok(EventIdentity::LegacyV1),
1203 EVENT_VERSION_V2 => Ok(EventIdentity::V2 {
1204 caller_principal: self
1205 .caller_principal
1206 .as_deref()
1207 .filter(|p| !p.is_empty())
1208 .ok_or(RowIdentityError::MissingField("caller_principal"))?,
1209 }),
1210 other => Err(RowIdentityError::UnknownVersion(other)),
1211 }
1212 }
1213}
1214
1215pub fn verify_event_chain(key: &AuditKey, rows: &[EventRow]) -> VerifyOutcome {
1217 verify_event_rows(&key.verifying_key(), Some(key.key_id()), rows)
1218}
1219
1220pub fn verify_event_chain_with_pubkey(verifying_key_hex: &str, rows: &[EventRow]) -> VerifyOutcome {
1222 match parse_verifying_key(verifying_key_hex) {
1223 Some(vk) => verify_event_rows(&vk, None, rows),
1224 None => VerifyOutcome::CannotVerify {
1225 reason: format!(
1226 "invalid public key hex ({} chars); expected 64 hex chars of a \
1227 32-byte Ed25519 public key",
1228 verifying_key_hex.len()
1229 ),
1230 },
1231 }
1232}
1233
1234fn verify_event_rows(
1235 vk: &VerifyingKey,
1236 expect_key_id: Option<&str>,
1237 rows: &[EventRow],
1238) -> VerifyOutcome {
1239 let mut last_hash = String::new();
1240 let mut rows_checked = 0u64;
1241 for row in rows {
1242 if let Some(kid) = expect_key_id {
1243 if row.key_id != kid {
1244 return VerifyOutcome::CannotVerify {
1245 reason: format!(
1246 "event seq={} uses key_id={:?} but only {:?} is loaded; \
1247 epoch keys not yet supported",
1248 row.seq, row.key_id, kid
1249 ),
1250 };
1251 }
1252 }
1253 if row.prev_chain_hash != last_hash {
1254 return VerifyOutcome::Broken {
1255 rows_checked,
1256 first_broken_seq: row.seq,
1257 first_broken_transaction_id: row.transaction_id.clone(),
1258 expected: format!("prev_chain_hash={last_hash}"),
1259 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
1260 };
1261 }
1262 let Some(kind) = parse_event_kind(&row.kind) else {
1265 return VerifyOutcome::Broken {
1266 rows_checked,
1267 first_broken_seq: row.seq,
1268 first_broken_transaction_id: row.transaction_id.clone(),
1269 expected: "a known event kind".to_string(),
1270 actual: format!("kind={:?}", row.kind),
1271 };
1272 };
1273 let identity = match row.identity() {
1279 Ok(identity) => identity,
1280 Err(RowIdentityError::UnknownVersion(v)) => {
1281 return VerifyOutcome::CannotVerify {
1282 reason: format!(
1283 "event seq={} declares chain_version={v}, which this binary cannot \
1284 reproduce (it understands {EVENT_VERSION_LEGACY}..={EVENT_VERSION_CURRENT}); \
1285 verify with a build at least as new as the one that wrote the chain",
1286 row.seq
1287 ),
1288 };
1289 }
1290 Err(RowIdentityError::MissingField(field)) => {
1291 return VerifyOutcome::Broken {
1292 rows_checked,
1293 first_broken_seq: row.seq,
1294 first_broken_transaction_id: row.transaction_id.clone(),
1295 expected: format!(
1296 "a non-empty {field} on a chain_version={} row",
1297 row.chain_version
1298 ),
1299 actual: format!("{field}={:?}", row.caller_principal),
1300 };
1301 }
1302 };
1303 let content = EventContent {
1304 seq: row.seq,
1305 key_id: &row.key_id,
1306 kind,
1307 transaction_id: &row.transaction_id,
1308 receipt_digest: &row.receipt_digest,
1309 created_at: &row.created_at,
1310 identity,
1311 };
1312 if !signature_ok(
1313 vk,
1314 &event_message(&content, &row.prev_chain_hash),
1315 &row.chain_hash,
1316 ) {
1317 return VerifyOutcome::Broken {
1318 rows_checked,
1319 first_broken_seq: row.seq,
1320 first_broken_transaction_id: row.transaction_id.clone(),
1321 expected: "valid ed25519 signature".to_string(),
1322 actual: row.chain_hash.clone(),
1323 };
1324 }
1325 last_hash = row.chain_hash.clone();
1326 rows_checked += 1;
1327 }
1328 VerifyOutcome::Intact { rows_checked }
1329}
1330
1331fn parse_event_kind(raw: &str) -> Option<AuditEventKind> {
1332 AuditEventKind::ALL
1333 .iter()
1334 .copied()
1335 .find(|kind| kind.as_str() == raw)
1336}
1337
1338#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1347#[serde(tag = "state", rename_all = "snake_case")]
1348pub enum StatusOutcome {
1349 Agrees { rows_checked: u64 },
1351 Disagrees { detail: String },
1353}
1354
1355pub fn status_outcome_to_exit_code(outcome: &StatusOutcome) -> i32 {
1359 match outcome {
1360 StatusOutcome::Agrees { .. } => 0,
1361 StatusOutcome::Disagrees { .. } => 1,
1362 }
1363}
1364
1365#[derive(Debug, Clone, PartialEq, Eq)]
1368pub enum BindingOutcome {
1369 Consistent { bindings_checked: u64 },
1371 NotChecked,
1374 MissingEvent {
1378 transaction_seq: u64,
1379 event_tip: String,
1380 },
1381}
1382
1383pub fn verify_event_binding(tx_rows: &[ChainRow], event_rows: &[EventRow]) -> BindingOutcome {
1393 let known: std::collections::HashSet<&str> = event_rows
1394 .iter()
1395 .map(|row| row.chain_hash.as_str())
1396 .collect();
1397 let mut bindings_checked = 0u64;
1398 for row in tx_rows {
1399 let Some(tip) = row.event_tip.as_deref() else {
1400 continue;
1401 };
1402 if tip.is_empty() {
1403 continue;
1404 }
1405 if !known.contains(tip) {
1406 return BindingOutcome::MissingEvent {
1407 transaction_seq: row.seq,
1408 event_tip: tip.to_string(),
1409 };
1410 }
1411 bindings_checked += 1;
1412 }
1413 BindingOutcome::Consistent { bindings_checked }
1414}
1415
1416#[derive(Debug, Clone, PartialEq, Eq)]
1423pub struct AuditVerification {
1424 pub chain: VerifyOutcome,
1425 pub events: VerifyOutcome,
1426 pub binding: BindingOutcome,
1427 pub attribution: Option<AttributionCensus>,
1443 pub status: Option<StatusOutcome>,
1451}
1452
1453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1458enum RowStanding {
1459 NamesAccount,
1461 AttributionFailed,
1464 NotRecorded,
1468 Unattested,
1478}
1479
1480fn standing(row: &ChainRow) -> RowStanding {
1492 let stored = row.caller_principal.as_deref().filter(|p| !p.is_empty());
1500 match row.chain_version {
1501 CHAIN_VERSION_LEGACY | CHAIN_VERSION_V2 => match stored {
1502 None => RowStanding::NotRecorded,
1503 Some(_) => RowStanding::Unattested,
1504 },
1505 CHAIN_VERSION_V3 => match stored {
1506 None => RowStanding::NotRecorded,
1507 Some(p) => match crate::auth::CallerPrincipal::classify(p) {
1508 crate::auth::PrincipalClaim::Account => RowStanding::NamesAccount,
1509 crate::auth::PrincipalClaim::AttributionFailed => RowStanding::AttributionFailed,
1510 crate::auth::PrincipalClaim::Unrecognized => RowStanding::Unattested,
1511 },
1512 },
1513 _ => RowStanding::Unattested,
1519 }
1520}
1521
1522#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1554pub struct AttributionCensus {
1555 named: u64,
1556 attribution_failed: u64,
1557 not_recorded: u64,
1558 unattested: u64,
1559 rows: u64,
1560}
1561
1562impl AttributionCensus {
1563 pub fn of(tx_rows: &[ChainRow]) -> Self {
1565 let mut census = Self {
1566 named: 0,
1567 attribution_failed: 0,
1568 not_recorded: 0,
1569 unattested: 0,
1570 rows: tx_rows.len() as u64,
1571 };
1572 for row in tx_rows {
1573 match standing(row) {
1574 RowStanding::NamesAccount => census.named += 1,
1575 RowStanding::AttributionFailed => census.attribution_failed += 1,
1576 RowStanding::NotRecorded => census.not_recorded += 1,
1577 RowStanding::Unattested => census.unattested += 1,
1578 }
1579 }
1580 census
1581 }
1582
1583 pub fn named(&self) -> u64 {
1589 self.named
1590 }
1591
1592 pub fn attribution_failed(&self) -> u64 {
1595 self.attribution_failed
1596 }
1597
1598 pub fn not_recorded(&self) -> u64 {
1601 self.not_recorded
1602 }
1603
1604 pub fn unattested(&self) -> u64 {
1607 self.unattested
1608 }
1609
1610 pub fn rows(&self) -> u64 {
1617 self.rows
1618 }
1619
1620 pub fn unnamed(&self) -> u64 {
1629 self.rows - self.named
1630 }
1631
1632 #[cfg(any(test, feature = "test-support"))]
1645 pub fn from_counts_for_tests(
1646 named: u64,
1647 attribution_failed: u64,
1648 not_recorded: u64,
1649 unattested: u64,
1650 ) -> Self {
1651 let rows = named
1652 .checked_add(attribution_failed)
1653 .and_then(|n| n.checked_add(not_recorded))
1654 .and_then(|n| n.checked_add(unattested))
1655 .expect("census counts must not overflow u64");
1656 Self {
1657 named,
1658 attribution_failed,
1659 not_recorded,
1660 unattested,
1661 rows,
1662 }
1663 }
1664}
1665
1666impl AuditVerification {
1667 pub fn exit_code(&self) -> i32 {
1673 let mut codes = vec![
1674 outcome_to_exit_code(&self.chain),
1675 outcome_to_exit_code(&self.events),
1676 binding_outcome_to_exit_code(&self.binding),
1677 ];
1678 if let Some(status) = &self.status {
1681 codes.push(status_outcome_to_exit_code(status));
1682 }
1683 if codes.contains(&1) {
1684 1
1685 } else if codes.contains(&2) {
1686 2
1687 } else {
1688 0
1689 }
1690 }
1691}
1692
1693pub fn verify_all(
1695 key: &AuditKey,
1696 tx_rows: &[ChainRow],
1697 event_rows: &[EventRow],
1698) -> AuditVerification {
1699 AuditVerification {
1700 chain: verify_chain(key, tx_rows),
1701 events: verify_event_chain(key, event_rows),
1702 binding: verify_event_binding(tx_rows, event_rows),
1703 attribution: Some(AttributionCensus::of(tx_rows)),
1704 status: None,
1705 }
1706}
1707
1708pub fn verify_all_with_pubkey(
1710 verifying_key_hex: &str,
1711 tx_rows: &[ChainRow],
1712 event_rows: &[EventRow],
1713) -> AuditVerification {
1714 AuditVerification {
1715 chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
1716 events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
1717 binding: verify_event_binding(tx_rows, event_rows),
1718 attribution: Some(AttributionCensus::of(tx_rows)),
1719 status: None,
1720 }
1721}
1722
1723pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
1727 match outcome {
1728 BindingOutcome::Consistent { .. } => 0,
1729 BindingOutcome::NotChecked => 2,
1730 BindingOutcome::MissingEvent { .. } => 1,
1731 }
1732}
1733
1734#[derive(Debug, Clone, PartialEq, Eq)]
1750pub struct Checkpoint {
1751 pub seq: u64,
1754 pub chain_tip: String,
1756 pub created_at: String,
1758 pub signature: String,
1760}
1761
1762fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
1765 let mut buf = CHECKPOINT_DOMAIN.to_vec();
1768 push_field(&mut buf, "seq", &seq.to_string());
1769 push_field(&mut buf, "chain_tip", chain_tip);
1770 push_field(&mut buf, "created_at", created_at);
1771 buf
1772}
1773
1774impl AuditKey {
1775 pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
1777 let sig = self
1778 .signing
1779 .sign(&checkpoint_message(seq, chain_tip, created_at));
1780 Checkpoint {
1781 seq,
1782 chain_tip: chain_tip.to_string(),
1783 created_at: created_at.to_string(),
1784 signature: hex::encode(sig.to_bytes()),
1785 }
1786 }
1787}
1788
1789#[derive(Debug, Clone, PartialEq, Eq)]
1791pub enum CheckpointOutcome {
1792 Consistent { checkpoints_checked: u64 },
1795 BadSignature { seq: u64 },
1797 Truncated {
1800 checkpoint_seq: u64,
1801 current_max_seq: u64,
1802 },
1803 TipMismatch {
1806 seq: u64,
1807 anchored: String,
1808 actual: String,
1809 },
1810 CannotVerify { reason: String },
1812}
1813
1814pub fn verify_checkpoints(
1820 verifying_key_hex: &str,
1821 rows: &[ChainRow],
1822 checkpoints: &[Checkpoint],
1823) -> CheckpointOutcome {
1824 let vk = match parse_verifying_key(verifying_key_hex) {
1825 Some(vk) => vk,
1826 None => {
1827 return CheckpointOutcome::CannotVerify {
1828 reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
1829 };
1830 }
1831 };
1832 let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
1835 let mut checked = 0u64;
1836 for cp in checkpoints {
1837 let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
1838 if !signature_ok(&vk, &msg, &cp.signature) {
1839 return CheckpointOutcome::BadSignature { seq: cp.seq };
1840 }
1841 match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
1842 Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
1843 Ok(idx) => {
1844 return CheckpointOutcome::TipMismatch {
1845 seq: cp.seq,
1846 anchored: cp.chain_tip.clone(),
1847 actual: rows[idx].chain_hash.clone(),
1848 };
1849 }
1850 Err(_) => {
1851 return CheckpointOutcome::Truncated {
1852 checkpoint_seq: cp.seq,
1853 current_max_seq,
1854 };
1855 }
1856 }
1857 checked += 1;
1858 }
1859 CheckpointOutcome::Consistent {
1860 checkpoints_checked: checked,
1861 }
1862}
1863
1864pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
1869 match outcome {
1870 CheckpointOutcome::Consistent { .. } => 0,
1871 CheckpointOutcome::BadSignature { .. }
1872 | CheckpointOutcome::Truncated { .. }
1873 | CheckpointOutcome::TipMismatch { .. } => 1,
1874 CheckpointOutcome::CannotVerify { .. } => 2,
1875 }
1876}
1877
1878#[cfg(test)]
1879mod tests {
1880 #[test]
1881 fn audit_key_env_resolution_is_centralised() {
1882 const INLINE_RESOLUTION: &str = "std::env::var(\"SYSKNIFE_AUDIT_KEY_PATH\")";
1883 let daemon_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
1884 let cli_src =
1885 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps/sysknife-cli/src");
1886
1887 let mut roots = vec![daemon_src.clone(), cli_src.clone()];
1888 let mut files_checked = 0;
1889 while let Some(dir) = roots.pop() {
1890 for entry in std::fs::read_dir(&dir).expect("source directory must be readable") {
1891 let path = entry.expect("source entry must be readable").path();
1892 if path.is_dir() {
1893 roots.push(path);
1894 continue;
1895 }
1896 if path.extension().and_then(|ext| ext.to_str()) != Some("rs")
1897 || path == daemon_src.join("audit_chain.rs")
1898 {
1899 continue;
1900 }
1901 files_checked += 1;
1902 let source = std::fs::read_to_string(&path).expect("Rust source must be readable");
1903 assert!(
1904 !source.contains(INLINE_RESOLUTION),
1905 "{} must resolve the audit key through resolve_audit_key_path",
1906 path.display()
1907 );
1908 }
1909 }
1910 assert!(
1911 files_checked >= 60,
1912 "source walk unexpectedly checked only {files_checked} Rust files"
1913 );
1914 }
1915
1916 #[test]
1924 fn every_variant_is_in_all() {
1925 fn spelling(kind: AuditEventKind) -> &'static str {
1926 match kind {
1927 AuditEventKind::ApprovalGranted => "approval_granted",
1928 AuditEventKind::ApprovalConsumed => "approval_consumed",
1929 AuditEventKind::ApprovalRevoked => "approval_revoked",
1930 AuditEventKind::StatusQueued => "status_queued",
1931 AuditEventKind::StatusRunning => "status_running",
1932 AuditEventKind::StatusSucceeded => "status_succeeded",
1933 AuditEventKind::StatusFailed => "status_failed",
1934 AuditEventKind::StatusCanceled => "status_canceled",
1935 AuditEventKind::StatusRolledBack => "status_rolled_back",
1936 AuditEventKind::StatusNeedsReboot => "status_needs_reboot",
1937 }
1938 }
1939 for kind in AuditEventKind::ALL {
1940 assert_eq!(
1941 spelling(*kind),
1942 kind.as_str(),
1943 "as_str and the exhaustive table disagree about {kind:?}"
1944 );
1945 assert_eq!(
1946 parse_event_kind(kind.as_str()),
1947 Some(*kind),
1948 "{kind:?} does not round-trip, so a row carrying it verifies as broken"
1949 );
1950 }
1951 for kind in AuditEventKind::ALL {
1954 assert!(
1955 AuditEventKind::ALL
1956 .iter()
1957 .any(|k| k.as_str() == spelling(*kind)),
1958 "{kind:?} has a spelling and is not reachable through ALL"
1959 );
1960 }
1961 assert!(
1962 parse_event_kind("not_a_kind").is_none(),
1963 "an unknown kind must not parse, or the check inspects nothing"
1964 );
1965 }
1966
1967 use super::*;
1968
1969 fn fixed_key() -> AuditKey {
1970 AuditKey::from_bytes(vec![0x42; 32])
1971 }
1972
1973 fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
1974 ChainContent {
1975 seq,
1976 key_id: CURRENT_KEY_ID,
1977 transaction_id: txid,
1978 request_id: "req-1",
1979 request_hash: "hash-abc",
1980 action_name: "UpdateSystem",
1981 risk_level: RiskLevel::High,
1982 summary: "Upgrade",
1983 approval_id: None,
1984 warnings_json: "[]",
1985 created_at: "2026-04-24T12:00:00Z",
1986 identity: ChainIdentity::V2 {
1987 caller_role: "Dev",
1988 event_tip: "",
1989 },
1990 }
1991 }
1992
1993 #[test]
1996 fn same_inputs_yield_same_hash() {
1997 let key = fixed_key();
1998 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1999 let h2 = key.chain_hash(&sample_content(1, "txa"), "");
2000 assert_eq!(h1, h2);
2001 assert_eq!(h1.len(), HASH_HEX_LEN);
2002 }
2003
2004 #[test]
2005 fn different_seq_yields_different_hash() {
2006 let key = fixed_key();
2007 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
2008 let h2 = key.chain_hash(&sample_content(2, "txa"), "");
2009 assert_ne!(h1, h2);
2010 }
2011
2012 #[test]
2013 fn different_prev_hash_yields_different_hash() {
2014 let key = fixed_key();
2015 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
2016 let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
2017 assert_ne!(h1, h2);
2018 }
2019
2020 #[test]
2021 fn different_keys_yield_different_hashes() {
2022 let key1 = AuditKey::from_bytes(vec![0x01; 32]);
2023 let key2 = AuditKey::from_bytes(vec![0x02; 32]);
2024 let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
2025 let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
2026 assert_ne!(h1, h2);
2027 }
2028
2029 #[test]
2032 fn canonical_bytes_have_stable_field_order() {
2033 let c = sample_content(1, "txa");
2034 let bytes = c.canonical_bytes();
2035 let s = String::from_utf8_lossy(&bytes);
2036 let order = [
2038 "seq",
2039 "key_id",
2040 "transaction_id",
2041 "request_id",
2042 "request_hash",
2043 "action_name",
2044 "risk_level",
2045 "summary",
2046 "approval_id",
2047 "warnings_json",
2048 "created_at",
2049 ];
2050 let mut last_idx = 0;
2051 for tag in order {
2052 let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
2053 assert!(idx >= last_idx, "{tag} out of order");
2054 last_idx = idx;
2055 }
2056 }
2057
2058 #[test]
2059 fn nul_bytes_in_field_value_are_escaped() {
2060 let mut c = sample_content(1, "txa");
2061 let summary = "before\0after";
2062 c.summary = summary;
2063 let bytes = c.canonical_bytes();
2064 assert!(!bytes.contains(&0x00));
2066 assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
2067 }
2068
2069 #[test]
2075 fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
2076 let mut a = sample_content(1, "txa");
2077 a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
2079 b.summary = "before\0after"; assert_ne!(
2081 a.canonical_bytes(),
2082 b.canonical_bytes(),
2083 "backslash escape must run before NUL escape to prevent collision"
2084 );
2085 }
2086
2087 fn key_for_collision_test() -> AuditKey {
2088 AuditKey::from_bytes(vec![0xab; 32])
2089 }
2090
2091 #[test]
2092 fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
2093 let key = key_for_collision_test();
2094 let mut a = sample_content(1, "txa");
2095 a.summary = "before\\0after";
2096 let mut b = sample_content(1, "txa");
2097 b.summary = "before\0after";
2098 assert_ne!(
2099 key.chain_hash(&a, ""),
2100 key.chain_hash(&b, ""),
2101 "chain signature must distinguish escape from raw byte"
2102 );
2103 }
2104
2105 fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
2111 let (chain_version, caller_role, event_tip, caller_principal) = match content.identity {
2112 ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None, None),
2113 ChainIdentity::V2 {
2114 caller_role,
2115 event_tip,
2116 } => (
2117 CHAIN_VERSION_V2,
2118 Some(caller_role.to_string()),
2119 Some(event_tip.to_string()),
2120 None,
2121 ),
2122 ChainIdentity::V3 {
2123 caller_role,
2124 event_tip,
2125 caller_principal,
2126 } => (
2127 CHAIN_VERSION_V3,
2128 Some(caller_role.to_string()),
2129 Some(event_tip.to_string()),
2130 Some(caller_principal.to_string()),
2131 ),
2132 };
2133 ChainRow {
2134 seq: content.seq,
2135 key_id: content.key_id.to_string(),
2136 transaction_id: content.transaction_id.to_string(),
2137 request_id: content.request_id.to_string(),
2138 request_hash: content.request_hash.to_string(),
2139 action_name: content.action_name.to_string(),
2140 risk_level: content.risk_level,
2141 summary: content.summary.to_string(),
2142 approval_id: content.approval_id.map(str::to_string),
2143 warnings_json: content.warnings_json.to_string(),
2144 created_at: content.created_at.to_string(),
2145 prev_chain_hash: prev.to_string(),
2146 chain_hash,
2147 chain_version,
2148 caller_role,
2149 event_tip,
2150 caller_principal,
2151 }
2152 }
2153
2154 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
2155 let mut rows = Vec::with_capacity(count);
2156 let mut prev = String::new();
2157 for i in 0..count {
2158 let seq = (i + 1) as u64;
2159 let txid = format!("tx{i}");
2160 let content = sample_content(seq, &txid);
2161 let hash = key.chain_hash(&content, &prev);
2162 rows.push(row_for(&content, &prev, hash.clone()));
2163 prev = hash;
2164 }
2165 rows
2166 }
2167
2168 #[test]
2169 fn exported_chain_row_json_round_trips_without_changing_the_signature() {
2170 let key = fixed_key();
2171 let row = build_chain(&key, 1).remove(0);
2172
2173 let json = serde_json::to_string(&row).expect("chain row serializes");
2174 let decoded: ChainRow = serde_json::from_str(&json).expect("chain row deserializes");
2175
2176 assert_eq!(decoded, row);
2177 assert_eq!(decoded.chain_hash.as_bytes(), row.chain_hash.as_bytes());
2178 }
2179
2180 #[test]
2181 fn exported_chain_row_uses_the_stored_column_contract_and_json_nulls() {
2182 let key = fixed_key();
2183 let mut row = build_chain(&key, 1).remove(0);
2184 row.approval_id = None;
2185 row.caller_role = None;
2186 row.event_tip = None;
2187 row.caller_principal = None;
2188
2189 let value = serde_json::to_value(&row).expect("chain row serializes");
2190 let object = value.as_object().expect("chain row is a JSON object");
2191 let keys: Vec<&str> = object.keys().map(String::as_str).collect();
2192
2193 assert_eq!(
2194 keys,
2195 vec![
2196 "action_name",
2197 "approval_id",
2198 "caller_principal",
2199 "caller_role",
2200 "chain_hash",
2201 "chain_version",
2202 "created_at",
2203 "event_tip",
2204 "key_id",
2205 "prev_chain_hash",
2206 "request_hash",
2207 "request_id",
2208 "risk_level",
2209 "seq",
2210 "summary",
2211 "transaction_id",
2212 "warnings_json",
2213 ]
2214 );
2215 for optional in [
2216 "approval_id",
2217 "caller_role",
2218 "event_tip",
2219 "caller_principal",
2220 ] {
2221 assert!(object[optional].is_null(), "{optional} must be JSON null");
2222 }
2223 assert!(!object.contains_key("argv"));
2224 assert!(!object.contains_key("outcome"));
2225 assert!(!object.contains_key("signature"));
2226 }
2227
2228 #[test]
2229 fn export_since_is_inclusive_and_limit_preserves_chain_order() {
2230 let key = fixed_key();
2231 let mut rows = build_chain(&key, 4);
2232 for (row, created_at) in rows.iter_mut().zip([
2233 "2026-08-19T09:00:00Z",
2234 "2026-08-19T10:00:00Z",
2235 "2026-08-19T11:00:00Z",
2236 "2026-08-19T12:00:00Z",
2237 ]) {
2238 row.created_at = created_at.to_string();
2239 }
2240
2241 let selected =
2242 select_chain_rows_for_export(rows, Some("2026-08-19T10:00:00+00:00"), Some(2)).unwrap();
2243
2244 assert_eq!(
2245 selected.iter().map(|row| row.seq).collect::<Vec<_>>(),
2246 [2, 3]
2247 );
2248 }
2249
2250 #[test]
2251 fn export_rejects_an_invalid_since_timestamp() {
2252 let error = select_chain_rows_for_export(Vec::new(), Some("last Tuesday"), None)
2253 .expect_err("invalid --since must fail before reading rows");
2254 assert!(matches!(error, ChainExportError::InvalidSince(_)));
2255 }
2256
2257 #[test]
2260 fn caller_role_is_covered_by_the_row_signature() {
2261 let key = fixed_key();
2265 let mut dev = sample_content(1, "txa");
2266 dev.identity = ChainIdentity::V2 {
2267 caller_role: "Dev",
2268 event_tip: "",
2269 };
2270 let mut boot = sample_content(1, "txa");
2271 boot.identity = ChainIdentity::V2 {
2272 caller_role: "Boot",
2273 event_tip: "",
2274 };
2275 assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
2276 }
2277
2278 #[test]
2279 fn event_tip_is_covered_by_the_row_signature() {
2280 let key = fixed_key();
2281 let mut empty = sample_content(1, "txa");
2282 empty.identity = ChainIdentity::V2 {
2283 caller_role: "Dev",
2284 event_tip: "",
2285 };
2286 let mut bound = sample_content(1, "txa");
2287 bound.identity = ChainIdentity::V2 {
2288 caller_role: "Dev",
2289 event_tip: "abc123",
2290 };
2291 assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
2292 }
2293
2294 #[test]
2295 fn a_legacy_row_written_before_the_migration_still_verifies() {
2296 let key = fixed_key();
2301 let mut content = sample_content(1, "tx-legacy");
2302 content.identity = ChainIdentity::LegacyV1;
2303 let hash = key.chain_hash(&content, "");
2304 let row = row_for(&content, "", hash);
2305 assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
2306 assert_eq!(row.caller_role, None);
2307 assert_eq!(
2308 verify_chain(&key, &[row]),
2309 VerifyOutcome::Intact { rows_checked: 1 }
2310 );
2311 }
2312
2313 #[test]
2319 fn two_admins_are_distinguishable_in_the_signed_record() {
2320 let key = fixed_key();
2321 let mut alice = sample_content(1, "tx-alice");
2322 alice.identity = ChainIdentity::V3 {
2323 caller_role: "admin",
2324 event_tip: "",
2325 caller_principal: "uid:1000",
2326 };
2327 let mut bob = sample_content(1, "tx-alice");
2328 bob.identity = ChainIdentity::V3 {
2329 caller_role: "admin",
2330 event_tip: "",
2331 caller_principal: "uid:1001",
2332 };
2333
2334 assert_ne!(
2335 key.chain_hash(&alice, ""),
2336 key.chain_hash(&bob, ""),
2337 "identical rows differing only in principal must sign differently, \
2338 otherwise the chain still cannot answer which account acted"
2339 );
2340 }
2341
2342 #[test]
2345 fn editing_the_stored_principal_breaks_the_row() {
2346 let key = fixed_key();
2347 let mut content = sample_content(1, "tx1");
2348 content.identity = ChainIdentity::V3 {
2349 caller_role: "admin",
2350 event_tip: "",
2351 caller_principal: "uid:1000",
2352 };
2353 let hash = key.chain_hash(&content, "");
2354 let mut row = row_for(&content, "", hash);
2355 row.caller_principal = Some("uid:0".to_string());
2356
2357 assert!(
2358 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2359 "a principal swapped to root must not verify"
2360 );
2361 }
2362
2363 #[test]
2367 fn downgrading_a_v3_row_to_v2_to_hide_the_account_breaks_it() {
2368 let key = fixed_key();
2369 let mut content = sample_content(1, "tx1");
2370 content.identity = ChainIdentity::V3 {
2371 caller_role: "admin",
2372 event_tip: "",
2373 caller_principal: "uid:1000",
2374 };
2375 let hash = key.chain_hash(&content, "");
2376 let mut row = row_for(&content, "", hash);
2377 row.chain_version = CHAIN_VERSION_V2;
2378 row.caller_principal = None;
2379
2380 assert!(
2381 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2382 "a v3 row relabelled as v2 must not verify"
2383 );
2384 }
2385
2386 #[test]
2391 fn a_v3_row_without_a_usable_principal_is_broken() {
2392 let key = fixed_key();
2393 let mut content = sample_content(1, "tx1");
2394 content.identity = ChainIdentity::V3 {
2395 caller_role: "admin",
2396 event_tip: "",
2397 caller_principal: "uid:1000",
2398 };
2399 let hash = key.chain_hash(&content, "");
2400
2401 for absent in [None, Some(String::new())] {
2402 let mut row = row_for(&content, "", hash.clone());
2403 row.caller_principal = absent.clone();
2404 assert!(
2405 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2406 "a v3 row with principal {absent:?} must be reported as broken"
2407 );
2408 }
2409 }
2410
2411 #[test]
2421 fn a_row_signed_with_a_blank_principal_is_rejected_even_though_it_signs() {
2422 let key = fixed_key();
2423 let mut content = sample_content(1, "tx-blank");
2424 content.identity = ChainIdentity::V3 {
2425 caller_role: "admin",
2426 event_tip: "",
2427 caller_principal: "",
2428 };
2429 let hash = key.chain_hash(&content, "");
2430 let row = row_for(&content, "", hash);
2435 assert_eq!(
2436 row.caller_principal.as_deref(),
2437 Some(""),
2438 "fixture must store the blank principal it signed"
2439 );
2440 assert_eq!(row.chain_version, CHAIN_VERSION_V3);
2441 assert!(
2442 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2443 "a v3 row naming nobody must be broken, not accepted"
2444 );
2445 }
2446
2447 #[test]
2452 fn a_row_recording_an_attribution_failure_verifies() {
2453 let key = fixed_key();
2454 let principal = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2455 let mut content = sample_content(1, "tx-unattributed");
2456 content.identity = ChainIdentity::V3 {
2457 caller_role: "observer",
2458 event_tip: "",
2459 caller_principal: &principal,
2460 };
2461 let hash = key.chain_hash(&content, "");
2462 let row = row_for(&content, "", hash);
2463
2464 assert_eq!(
2465 verify_chain(&key, &[row]),
2466 VerifyOutcome::Intact { rows_checked: 1 }
2467 );
2468 }
2469
2470 #[test]
2474 fn a_chain_spanning_all_three_encodings_verifies() {
2475 let key = fixed_key();
2476 let mut rows = Vec::new();
2477 let mut prev = String::new();
2478 for (i, identity) in [
2479 ChainIdentity::LegacyV1,
2480 ChainIdentity::V2 {
2481 caller_role: "dev",
2482 event_tip: "",
2483 },
2484 ChainIdentity::V3 {
2485 caller_role: "admin",
2486 event_tip: "",
2487 caller_principal: "uid:1000",
2488 },
2489 ChainIdentity::V3 {
2490 caller_role: "admin",
2491 event_tip: "",
2492 caller_principal: "token:vsock",
2493 },
2494 ]
2495 .into_iter()
2496 .enumerate()
2497 {
2498 let txid = format!("tx{i}");
2499 let mut content = sample_content((i + 1) as u64, &txid);
2500 content.identity = identity;
2501 let hash = key.chain_hash(&content, &prev);
2502 rows.push(row_for(&content, &prev, hash.clone()));
2503 prev = hash;
2504 }
2505
2506 assert_eq!(
2507 verify_chain(&key, &rows),
2508 VerifyOutcome::Intact { rows_checked: 4 }
2509 );
2510 }
2511
2512 fn chain_of(key: &AuditKey, identities: Vec<ChainIdentity<'_>>) -> Vec<ChainRow> {
2515 let mut rows = Vec::new();
2516 let mut prev = String::new();
2517 for (i, identity) in identities.into_iter().enumerate() {
2518 let txid = format!("tx{i}");
2519 let mut content = sample_content((i + 1) as u64, &txid);
2520 content.identity = identity;
2521 let hash = key.chain_hash(&content, &prev);
2522 rows.push(row_for(&content, &prev, hash.clone()));
2523 prev = hash;
2524 }
2525 rows
2526 }
2527
2528 #[test]
2538 fn the_census_separates_attribution_failures_from_encodings_without_the_field() {
2539 let key = fixed_key();
2540 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2541 let rows = chain_of(
2542 &key,
2543 vec![
2544 ChainIdentity::LegacyV1,
2545 ChainIdentity::V2 {
2546 caller_role: "dev",
2547 event_tip: "",
2548 },
2549 ChainIdentity::V3 {
2550 caller_role: "admin",
2551 event_tip: "",
2552 caller_principal: "uid:1000",
2553 },
2554 ChainIdentity::V3 {
2555 caller_role: "admin",
2556 event_tip: "",
2557 caller_principal: "uid:1001",
2558 },
2559 ChainIdentity::V3 {
2560 caller_role: "admin",
2561 event_tip: "",
2562 caller_principal: "token:vsock",
2563 },
2564 ChainIdentity::V3 {
2565 caller_role: "observer",
2566 event_tip: "",
2567 caller_principal: &unattributed,
2568 },
2569 ],
2570 );
2571
2572 let verification = verify_all(&key, &rows, &[]);
2573 assert_eq!(
2574 verification.chain,
2575 VerifyOutcome::Intact { rows_checked: 6 },
2576 "every encoding must keep verifying; the census is not a verdict"
2577 );
2578 let census = verification
2579 .attribution
2580 .expect("rows were read, so a census was taken");
2581 assert_eq!(census.named(), 3);
2582 assert_eq!(census.attribution_failed(), 1);
2583 assert_eq!(census.not_recorded(), 2);
2584 assert_eq!(census.unattested(), 0);
2585 assert_eq!(census.rows(), 6);
2586 assert_eq!(
2587 census.unnamed(),
2588 3,
2589 "three of the six rows cannot name an account, by either reason"
2590 );
2591 }
2592
2593 #[test]
2603 fn a_principal_written_into_an_encoding_that_does_not_sign_it_is_never_an_account() {
2604 let key = fixed_key();
2605 let mut rows = chain_of(
2606 &key,
2607 vec![
2608 ChainIdentity::LegacyV1,
2609 ChainIdentity::V2 {
2610 caller_role: "dev",
2611 event_tip: "",
2612 },
2613 ],
2614 );
2615 rows[0].caller_principal = Some("uid:0".to_string());
2618 rows[1].caller_principal = Some("uid:0".to_string());
2619
2620 let verification = verify_all(&key, &rows, &[]);
2621 assert_eq!(
2622 verification.chain,
2623 VerifyOutcome::Intact { rows_checked: 2 },
2624 "no signature covers that column on v1/v2, so tampering with it is \
2625 invisible to verification -- which is exactly why the census must \
2626 not believe it"
2627 );
2628 let census = verification.attribution.expect("rows were read");
2629 assert_eq!(
2630 census.named(),
2631 0,
2632 "an unsigned column must never be credited as an account"
2633 );
2634 assert_eq!(
2635 census.unattested(),
2636 2,
2637 "and it must be reported as unattested, because nothing in SysKnife \
2638 writes that column on those encodings"
2639 );
2640 assert_eq!(census.unnamed(), 2);
2641 }
2642
2643 #[test]
2647 fn a_blank_principal_column_is_counted_as_naming_nobody() {
2648 let key = fixed_key();
2649 let mut content = sample_content(1, "tx-blank-census");
2650 content.identity = ChainIdentity::V3 {
2651 caller_role: "admin",
2652 event_tip: "",
2653 caller_principal: "",
2654 };
2655 let hash = key.chain_hash(&content, "");
2656 let row = row_for(&content, "", hash);
2657 assert_eq!(row.caller_principal.as_deref(), Some(""));
2658
2659 let verification = verify_all(&key, &[row], &[]);
2660 let census = verification.attribution.expect("a row was read");
2661 assert_eq!(census.named(), 0);
2662 assert_eq!(census.not_recorded(), 1);
2663 assert_eq!(census.rows(), 1);
2664 assert!(
2665 matches!(verification.chain, VerifyOutcome::Broken { .. }),
2666 "and the row is still reported as broken once the walk reaches it, \
2667 so census and verdict agree"
2668 );
2669 }
2670
2671 #[test]
2679 fn a_principal_this_binary_cannot_read_is_counted_as_unattested_not_as_an_account() {
2680 let key = fixed_key();
2681 for forged in [
2682 "garbage",
2683 "uid:",
2684 "none:overflow",
2685 " ",
2690 "UID:1000",
2691 "uid:notanumber",
2693 "uid:1000:extra",
2694 "token:not-vsock",
2695 ] {
2696 let mut content = sample_content(1, "tx-forged");
2697 content.identity = ChainIdentity::V3 {
2698 caller_role: "admin",
2699 event_tip: "",
2700 caller_principal: forged,
2701 };
2702 let hash = key.chain_hash(&content, "");
2703 let row = row_for(&content, "", hash);
2704 let census = verify_all(&key, &[row], &[])
2705 .attribution
2706 .expect("a row was read");
2707 assert_eq!(
2708 census.named(),
2709 0,
2710 "{forged:?} names no account and must not be counted as one"
2711 );
2712 assert_eq!(
2713 census.unnamed(),
2714 1,
2715 "{forged:?} must be counted among the rows naming nobody"
2716 );
2717 assert_eq!(
2718 census.unattested(),
2719 1,
2720 "{forged:?} is signed content the daemon never writes, so it is a \
2721 finding to investigate, not history to explain away"
2722 );
2723 assert_eq!(
2724 census.not_recorded(),
2725 0,
2726 "{forged:?} is present, so it must not be reported as a row that \
2727 predates the column"
2728 );
2729 }
2730 }
2731
2732 #[test]
2737 fn a_chain_predating_the_principal_field_reports_no_row_as_attributed() {
2738 let key = fixed_key();
2739 let rows = chain_of(
2740 &key,
2741 vec![
2742 ChainIdentity::LegacyV1,
2743 ChainIdentity::V2 {
2744 caller_role: "dev",
2745 event_tip: "",
2746 },
2747 ],
2748 );
2749
2750 let verification = verify_all(&key, &rows, &[]);
2751 assert_eq!(
2752 verification.chain,
2753 VerifyOutcome::Intact { rows_checked: 2 },
2754 "legacy rows must keep verifying; the census is not a verdict"
2755 );
2756 let census = verification.attribution.expect("rows were read");
2757 assert_eq!(census.attribution_failed(), 0);
2758 assert_eq!(census.named(), 0);
2759 assert_eq!(census.not_recorded(), 2);
2760 assert_eq!(census.unnamed(), 2);
2761 }
2762
2763 #[test]
2769 fn a_broken_chain_censuses_more_rows_than_it_verified() {
2770 let key = fixed_key();
2771 let mut rows = chain_of(
2772 &key,
2773 vec![
2774 ChainIdentity::V3 {
2775 caller_role: "admin",
2776 event_tip: "",
2777 caller_principal: "uid:1000",
2778 },
2779 ChainIdentity::V3 {
2780 caller_role: "admin",
2781 event_tip: "",
2782 caller_principal: "uid:1000",
2783 },
2784 ChainIdentity::V3 {
2785 caller_role: "admin",
2786 event_tip: "",
2787 caller_principal: "uid:1000",
2788 },
2789 ],
2790 );
2791 rows[1].summary = "tampered".to_string();
2792
2793 let verification = verify_all(&key, &rows, &[]);
2794 let rows_checked = match verification.chain {
2795 VerifyOutcome::Broken { rows_checked, .. } => rows_checked,
2796 ref other => panic!("expected a detected tamper, got {other:?}"),
2797 };
2798 let census = verification.attribution.expect("rows were read");
2799 assert_eq!(rows_checked, 1, "the walk stops at the first broken row");
2800 assert_eq!(
2801 census.rows(),
2802 3,
2803 "while the census still describes every row read"
2804 );
2805 assert!(
2806 census.rows() > rows_checked,
2807 "the report must be able to show that more was counted than checked"
2808 );
2809 }
2810
2811 #[test]
2815 fn the_pubkey_only_path_takes_the_same_census() {
2816 let key = fixed_key();
2817 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2818 let rows = chain_of(
2819 &key,
2820 vec![
2821 ChainIdentity::V3 {
2822 caller_role: "admin",
2823 event_tip: "",
2824 caller_principal: "uid:1000",
2825 },
2826 ChainIdentity::V3 {
2827 caller_role: "observer",
2828 event_tip: "",
2829 caller_principal: &unattributed,
2830 },
2831 ],
2832 );
2833
2834 let with_key = verify_all(&key, &rows, &[]).attribution;
2835 let with_pubkey = verify_all_with_pubkey(&key.verifying_key_hex(), &rows, &[]).attribution;
2836 assert_eq!(
2837 with_key, with_pubkey,
2838 "the auditor must see the same attribution numbers as the keyholder"
2839 );
2840 let census = with_pubkey.expect("rows were read");
2841 assert_eq!(census.named(), 1);
2842 assert_eq!(census.attribution_failed(), 1);
2843 }
2844
2845 #[test]
2850 fn a_chain_that_names_nobody_still_exits_zero() {
2851 let key = fixed_key();
2852 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2853 let rows = chain_of(
2854 &key,
2855 vec![
2856 ChainIdentity::LegacyV1,
2857 ChainIdentity::V3 {
2858 caller_role: "observer",
2859 event_tip: "",
2860 caller_principal: &unattributed,
2861 },
2862 ],
2863 );
2864
2865 let verification = verify_all(&key, &rows, &[]);
2866 let census = verification.attribution.expect("rows were read");
2867 assert_eq!(census.unnamed(), 2, "not one row names an account");
2868 assert_eq!(
2869 verification.exit_code(),
2870 0,
2871 "yet nothing was tampered with, so the command must succeed"
2872 );
2873 }
2874
2875 #[test]
2880 fn a_future_encoding_reports_cannot_verify_with_the_supported_range() {
2881 let key = fixed_key();
2882 let content = sample_content(1, "tx1");
2883 let hash = key.chain_hash(&content, "");
2884 let mut row = row_for(&content, "", hash);
2885 row.chain_version = CHAIN_VERSION_CURRENT + 1;
2886
2887 match verify_chain(&key, &[row]) {
2888 VerifyOutcome::CannotVerify { reason } => {
2889 assert!(
2890 reason.contains(&format!("chain_version={}", CHAIN_VERSION_CURRENT + 1)),
2891 "name the version found, got: {reason}"
2892 );
2893 assert!(
2894 reason.contains(&CHAIN_VERSION_CURRENT.to_string()),
2895 "and the newest version understood, got: {reason}"
2896 );
2897 }
2898 other => panic!("a future encoding must not be reported as tampering: {other:?}"),
2899 }
2900 }
2901
2902 #[test]
2915 fn a_row_written_by_the_previous_release_still_verifies() {
2916 const GOLDEN_V2_CHAIN_HASH: &str = "ba12cbe49a149387898bc30f1e8d409effa025ab8e440e6e72bedf060afc831e45d9be264d6b18b7e0ef2f2b3584dbc6953b535546cecbbdc3d7ac666cae4d0a";
2917
2918 let key = fixed_key();
2919 let row = ChainRow {
2920 seq: 7,
2921 key_id: CURRENT_KEY_ID.to_string(),
2922 transaction_id: "tx-golden".to_string(),
2923 request_id: "req-golden".to_string(),
2924 request_hash: "hash-golden".to_string(),
2925 action_name: "AptInstall".to_string(),
2926 risk_level: RiskLevel::Medium,
2927 summary: "install ripgrep".to_string(),
2928 approval_id: Some("appr-golden".to_string()),
2929 warnings_json: "[]".to_string(),
2930 created_at: "2026-07-29T00:00:00Z".to_string(),
2931 prev_chain_hash: String::new(),
2932 chain_hash: GOLDEN_V2_CHAIN_HASH.to_string(),
2933 chain_version: 2,
2934 caller_role: Some("admin".to_string()),
2935 event_tip: Some("eventtip-golden".to_string()),
2936 caller_principal: None,
2937 };
2938
2939 assert_eq!(
2940 verify_chain(&key, std::slice::from_ref(&row)),
2941 VerifyOutcome::Intact { rows_checked: 1 },
2942 "a v2 row on disk must verify under this binary; if this fails, the v2 \
2943 encoding or its version dispatch changed and existing audit logs are unreadable"
2944 );
2945
2946 let mut tampered = row;
2950 let flipped = match GOLDEN_V2_CHAIN_HASH.strip_prefix('b') {
2951 Some(rest) => format!("a{rest}"),
2952 None => format!("b{}", &GOLDEN_V2_CHAIN_HASH[1..]),
2953 };
2954 tampered.chain_hash = flipped;
2955 assert!(
2956 matches!(
2957 verify_chain(&key, &[tampered]),
2958 VerifyOutcome::Broken { .. }
2959 ),
2960 "a golden row with one nibble changed must not verify"
2961 );
2962 }
2963
2964 #[test]
2968 fn a_legacy_v1_row_on_disk_still_verifies() {
2969 const GOLDEN_V1_CHAIN_HASH: &str = "ce8b47c15414e989e099303b826fcd9985360b794220cefbbf9689d25f31fe173283687feb6d1dd265ae5aca94c8bcfb87ce33ba8c954fafdad18a59cd97c702";
2970
2971 let row = ChainRow {
2972 chain_version: 1,
2973 caller_role: None,
2974 event_tip: None,
2975 caller_principal: None,
2976 chain_hash: GOLDEN_V1_CHAIN_HASH.to_string(),
2977 ..golden_row_shape()
2978 };
2979 assert_eq!(
2980 verify_chain(&fixed_key(), &[row]),
2981 VerifyOutcome::Intact { rows_checked: 1 }
2982 );
2983 }
2984
2985 #[test]
2989 fn a_v3_row_on_disk_still_verifies() {
2990 const GOLDEN_V3_CHAIN_HASH: &str = "3c0d05f6ccaf9e65ccb796d4ffe96eb31a2ea67e446217c5234af916dc959d0f2c0ee3d3e5b525aece791ee7d6507efb98dcdd79f0d74dde4273859aa9627d08";
2991
2992 let row = ChainRow {
2993 chain_version: 3,
2994 caller_role: Some("admin".to_string()),
2995 event_tip: Some("eventtip-golden".to_string()),
2996 caller_principal: Some("uid:1000".to_string()),
2997 chain_hash: GOLDEN_V3_CHAIN_HASH.to_string(),
2998 ..golden_row_shape()
2999 };
3000 assert_eq!(
3001 verify_chain(&fixed_key(), &[row]),
3002 VerifyOutcome::Intact { rows_checked: 1 }
3003 );
3004 }
3005
3006 #[test]
3013 fn the_escape_table_and_absent_approval_encoding_are_frozen() {
3014 const GOLDEN_V2_AWKWARD_CHAIN_HASH: &str = "e6face6fe2346fc0f6c5c1e5845255579c8f08738406b88b0a1f6dbdf8ff85090a4b80d95bd0927ef54b0a2c4810d52ebb4bbf76cb68c865a7748df26442aa05";
3015
3016 let key = fixed_key();
3017 let content = ChainContent {
3018 seq: 8,
3019 approval_id: None,
3020 summary: "back\\slash and \x1funit and \x1erecord and nul\0byte",
3021 identity: ChainIdentity::V2 {
3022 caller_role: "dev",
3023 event_tip: "",
3024 },
3025 ..golden_content_shape()
3026 };
3027 assert_eq!(
3028 key.chain_hash(&content, "ba12cbe4"),
3029 GOLDEN_V2_AWKWARD_CHAIN_HASH,
3030 "the canonical encoding of escapes, an absent approval id, or a \
3031 non-empty prev_chain_hash changed"
3032 );
3033 }
3034
3035 fn golden_content_shape() -> ChainContent<'static> {
3039 ChainContent {
3040 seq: 7,
3041 key_id: CURRENT_KEY_ID,
3042 transaction_id: "tx-golden",
3043 request_id: "req-golden",
3044 request_hash: "hash-golden",
3045 action_name: "AptInstall",
3046 risk_level: RiskLevel::Medium,
3047 summary: "install ripgrep",
3048 approval_id: Some("appr-golden"),
3049 warnings_json: "[]",
3050 created_at: "2026-07-29T00:00:00Z",
3051 identity: ChainIdentity::LegacyV1,
3052 }
3053 }
3054
3055 fn golden_row_shape() -> ChainRow {
3057 ChainRow {
3058 seq: 7,
3059 key_id: CURRENT_KEY_ID.to_string(),
3060 transaction_id: "tx-golden".to_string(),
3061 request_id: "req-golden".to_string(),
3062 request_hash: "hash-golden".to_string(),
3063 action_name: "AptInstall".to_string(),
3064 risk_level: RiskLevel::Medium,
3065 summary: "install ripgrep".to_string(),
3066 approval_id: Some("appr-golden".to_string()),
3067 warnings_json: "[]".to_string(),
3068 created_at: "2026-07-29T00:00:00Z".to_string(),
3069 prev_chain_hash: String::new(),
3070 chain_hash: String::new(),
3071 chain_version: 1,
3072 caller_role: None,
3073 event_tip: None,
3074 caller_principal: None,
3075 }
3076 }
3077
3078 #[test]
3079 fn a_chain_that_spans_the_migration_verifies_end_to_end() {
3080 let key = fixed_key();
3083 let mut rows = Vec::new();
3084 let mut prev = String::new();
3085 for (i, identity) in [
3086 ChainIdentity::LegacyV1,
3087 ChainIdentity::LegacyV1,
3088 ChainIdentity::V2 {
3089 caller_role: "Dev",
3090 event_tip: "",
3091 },
3092 ChainIdentity::V2 {
3093 caller_role: "Boot",
3094 event_tip: "",
3095 },
3096 ]
3097 .into_iter()
3098 .enumerate()
3099 {
3100 let txid = format!("tx{i}");
3101 let mut content = sample_content((i + 1) as u64, &txid);
3102 content.identity = identity;
3103 let hash = key.chain_hash(&content, &prev);
3104 rows.push(row_for(&content, &prev, hash.clone()));
3105 prev = hash;
3106 }
3107 assert_eq!(
3108 verify_chain(&key, &rows),
3109 VerifyOutcome::Intact { rows_checked: 4 }
3110 );
3111 }
3112
3113 #[test]
3114 fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
3115 let key = fixed_key();
3120 let mut rows = build_chain(&key, 2);
3121 rows[1].chain_version = CHAIN_VERSION_LEGACY;
3122 rows[1].caller_role = None;
3123 rows[1].event_tip = None;
3124 match verify_chain(&key, &rows) {
3125 VerifyOutcome::Broken {
3126 first_broken_seq, ..
3127 } => assert_eq!(first_broken_seq, 2),
3128 other => panic!("expected Broken, got {other:?}"),
3129 }
3130 }
3131
3132 #[test]
3133 fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
3134 let key = fixed_key();
3139 let mut rows = build_chain(&key, 1);
3140 rows[0].caller_role = None;
3141 let outcome = verify_chain(&key, &rows);
3142 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
3143 assert_eq!(outcome_to_exit_code(&outcome), 1);
3144 }
3145
3146 #[test]
3147 fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
3148 let key = fixed_key();
3151 let mut rows = build_chain(&key, 1);
3152 rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
3153 let outcome = verify_chain(&key, &rows);
3154 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3155 assert_eq!(outcome_to_exit_code(&outcome), 2);
3156 }
3157
3158 fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
3161 EventContent {
3162 seq,
3163 key_id: CURRENT_KEY_ID,
3164 kind,
3165 transaction_id: txid,
3166 receipt_digest: "digest-abc",
3167 created_at: "2026-04-24T12:00:00Z",
3168 identity: EventIdentity::LegacyV1,
3172 }
3173 }
3174
3175 fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
3176 let mut rows = Vec::with_capacity(kinds.len());
3177 let mut prev = String::new();
3178 for (i, kind) in kinds.iter().enumerate() {
3179 let seq = (i + 1) as u64;
3180 let txid = format!("tx{i}");
3181 let content = event_content(seq, *kind, &txid);
3182 let hash = key.event_hash(&content, &prev);
3183 rows.push(EventRow {
3184 seq,
3185 key_id: content.key_id.to_string(),
3186 kind: content.kind.as_str().to_string(),
3187 transaction_id: content.transaction_id.to_string(),
3188 receipt_digest: content.receipt_digest.to_string(),
3189 created_at: content.created_at.to_string(),
3190 prev_chain_hash: prev.clone(),
3191 chain_hash: hash.clone(),
3192 chain_version: content.identity.version(),
3193 caller_principal: match content.identity {
3194 EventIdentity::V2 { caller_principal } => Some(caller_principal.to_string()),
3195 EventIdentity::LegacyV1 => None,
3196 },
3197 });
3198 prev = hash;
3199 }
3200 rows
3201 }
3202
3203 #[allow(clippy::too_many_arguments)]
3208 fn event_row(
3209 key: &AuditKey,
3210 seq: u64,
3211 kind: AuditEventKind,
3212 txid: &str,
3213 receipt_digest: &str,
3214 created_at: &str,
3215 identity: EventIdentity<'_>,
3216 prev: &str,
3217 ) -> EventRow {
3218 let content = EventContent {
3219 seq,
3220 key_id: CURRENT_KEY_ID,
3221 kind,
3222 transaction_id: txid,
3223 receipt_digest,
3224 created_at,
3225 identity,
3226 };
3227 let hash = key.event_hash(&content, prev);
3228 EventRow {
3229 seq,
3230 key_id: CURRENT_KEY_ID.to_string(),
3231 kind: kind.as_str().to_string(),
3232 transaction_id: txid.to_string(),
3233 receipt_digest: receipt_digest.to_string(),
3234 created_at: created_at.to_string(),
3235 prev_chain_hash: prev.to_string(),
3236 chain_hash: hash,
3237 chain_version: identity.version(),
3238 caller_principal: match identity {
3239 EventIdentity::V2 { caller_principal } => Some(caller_principal.to_string()),
3240 EventIdentity::LegacyV1 => None,
3241 },
3242 }
3243 }
3244
3245 #[test]
3246 fn a_legacy_event_row_signed_by_the_previous_release_still_verifies() {
3247 let key = fixed_key();
3258 let row = EventRow {
3259 seq: 1,
3260 key_id: CURRENT_KEY_ID.to_string(),
3261 kind: AuditEventKind::ApprovalGranted.as_str().to_string(),
3262 transaction_id: "tx-fixture-legacy".to_string(),
3263 receipt_digest: "digest-fixture-legacy".to_string(),
3264 created_at: "2026-08-18T09:00:00.000Z".to_string(),
3265 prev_chain_hash: String::new(),
3266 chain_hash: "1b7c2b864c13c8c2da5436b4a3d0d10ec37c129e97a4525228110c574fccf32b\
3267 d1a22001acb555faa016f6c50f26d5c783b447fbf866f8ff689ebec2fc7c340a"
3268 .to_string(),
3269 chain_version: EVENT_VERSION_LEGACY,
3270 caller_principal: None,
3271 };
3272 assert_eq!(
3273 verify_event_chain(&key, &[row]),
3274 VerifyOutcome::Intact { rows_checked: 1 },
3275 "the committed legacy event signature must still verify"
3276 );
3277 }
3278
3279 #[test]
3280 fn a_v2_event_signs_and_verifies_with_the_acting_account() {
3281 let key = fixed_key();
3282 let row = event_row(
3283 &key,
3284 1,
3285 AuditEventKind::ApprovalGranted,
3286 "tx1",
3287 "digest1",
3288 "2026-08-18T09:00:00.000Z",
3289 EventIdentity::V2 {
3290 caller_principal: "uid:1000",
3291 },
3292 "",
3293 );
3294 assert_eq!(row.chain_version, EVENT_VERSION_V2);
3295 assert_eq!(row.caller_principal.as_deref(), Some("uid:1000"));
3296 assert_eq!(
3297 verify_event_chain(&key, &[row]),
3298 VerifyOutcome::Intact { rows_checked: 1 }
3299 );
3300 }
3301
3302 #[test]
3303 fn a_grant_and_a_consume_by_different_accounts_are_distinguishable() {
3304 let key = fixed_key();
3309 let grant = event_row(
3310 &key,
3311 1,
3312 AuditEventKind::ApprovalGranted,
3313 "tx1",
3314 "digest1",
3315 "2026-08-18T09:00:00.000Z",
3316 EventIdentity::V2 {
3317 caller_principal: "uid:1000",
3318 },
3319 "",
3320 );
3321 let consume = event_row(
3322 &key,
3323 2,
3324 AuditEventKind::ApprovalConsumed,
3325 "tx1",
3326 "digest1",
3327 "2026-08-18T09:05:00.000Z",
3328 EventIdentity::V2 {
3329 caller_principal: "uid:1001",
3330 },
3331 &grant.chain_hash,
3332 );
3333 assert_eq!(
3334 verify_event_chain(&key, &[grant.clone(), consume.clone()]),
3335 VerifyOutcome::Intact { rows_checked: 2 }
3336 );
3337 assert_eq!(grant.caller_principal.as_deref(), Some("uid:1000"));
3338 assert_eq!(consume.caller_principal.as_deref(), Some("uid:1001"));
3339 assert_ne!(
3340 grant.caller_principal, consume.caller_principal,
3341 "grant and consume by different uids must not collapse to one record"
3342 );
3343 }
3344
3345 #[test]
3346 fn editing_a_stored_event_principal_reports_broken() {
3347 let key = fixed_key();
3351 let mut row = event_row(
3352 &key,
3353 1,
3354 AuditEventKind::ApprovalGranted,
3355 "tx1",
3356 "digest1",
3357 "2026-08-18T09:00:00.000Z",
3358 EventIdentity::V2 {
3359 caller_principal: "uid:1000",
3360 },
3361 "",
3362 );
3363 assert_eq!(
3364 verify_event_chain(&key, &[row.clone()]),
3365 VerifyOutcome::Intact { rows_checked: 1 }
3366 );
3367 row.caller_principal = Some("uid:9999".to_string());
3368 assert!(
3369 matches!(
3370 verify_event_chain(&key, &[row]),
3371 VerifyOutcome::Broken { .. }
3372 ),
3373 "an edited principal must not still verify"
3374 );
3375 }
3376
3377 #[test]
3378 fn downgrading_a_v2_event_to_legacy_reports_broken() {
3379 let key = fixed_key();
3383 let mut row = event_row(
3384 &key,
3385 1,
3386 AuditEventKind::ApprovalGranted,
3387 "tx1",
3388 "digest1",
3389 "2026-08-18T09:00:00.000Z",
3390 EventIdentity::V2 {
3391 caller_principal: "uid:1000",
3392 },
3393 "",
3394 );
3395 row.chain_version = EVENT_VERSION_LEGACY;
3396 row.caller_principal = None;
3397 assert!(matches!(
3398 verify_event_chain(&key, &[row]),
3399 VerifyOutcome::Broken { .. }
3400 ));
3401 }
3402
3403 #[test]
3404 fn a_v2_event_naming_nobody_is_broken_not_accepted() {
3405 let key = fixed_key();
3409 let mut blank = event_row(
3410 &key,
3411 1,
3412 AuditEventKind::ApprovalGranted,
3413 "tx1",
3414 "digest1",
3415 "2026-08-18T09:00:00.000Z",
3416 EventIdentity::V2 {
3417 caller_principal: "uid:1000",
3418 },
3419 "",
3420 );
3421 blank.caller_principal = Some(String::new());
3422 assert!(
3423 matches!(
3424 verify_event_chain(&key, &[blank]),
3425 VerifyOutcome::Broken { .. }
3426 ),
3427 "a blank principal on a v2 row must be broken"
3428 );
3429
3430 let mut absent = event_row(
3431 &key,
3432 1,
3433 AuditEventKind::ApprovalGranted,
3434 "tx1",
3435 "digest1",
3436 "2026-08-18T09:00:00.000Z",
3437 EventIdentity::V2 {
3438 caller_principal: "uid:1000",
3439 },
3440 "",
3441 );
3442 absent.caller_principal = None;
3443 assert!(matches!(
3444 verify_event_chain(&key, &[absent]),
3445 VerifyOutcome::Broken { .. }
3446 ));
3447 }
3448
3449 #[test]
3450 fn an_unknown_event_version_cannot_verify_rather_than_breaking() {
3451 let key = fixed_key();
3455 let mut row = event_row(
3456 &key,
3457 1,
3458 AuditEventKind::ApprovalGranted,
3459 "tx1",
3460 "digest1",
3461 "2026-08-18T09:00:00.000Z",
3462 EventIdentity::V2 {
3463 caller_principal: "uid:1000",
3464 },
3465 "",
3466 );
3467 row.chain_version = EVENT_VERSION_CURRENT + 1;
3468 let outcome = verify_event_chain(&key, &[row]);
3469 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3470 assert_eq!(outcome_to_exit_code(&outcome), 2);
3471 }
3472
3473 #[test]
3474 fn a_mixed_legacy_and_v2_event_chain_verifies() {
3475 let key = fixed_key();
3480 let legacy = event_row(
3481 &key,
3482 1,
3483 AuditEventKind::ApprovalGranted,
3484 "tx-old",
3485 "digest-old",
3486 "2026-08-18T09:00:00.000Z",
3487 EventIdentity::LegacyV1,
3488 "",
3489 );
3490 let v2 = event_row(
3491 &key,
3492 2,
3493 AuditEventKind::ApprovalGranted,
3494 "tx-new",
3495 "digest-new",
3496 "2026-09-01T09:00:00.000Z",
3497 EventIdentity::V2 {
3498 caller_principal: "uid:1000",
3499 },
3500 &legacy.chain_hash,
3501 );
3502 assert_eq!(legacy.chain_version, EVENT_VERSION_LEGACY);
3503 assert_eq!(v2.chain_version, EVENT_VERSION_V2);
3504 assert_eq!(
3505 verify_event_chain(&key, &[legacy, v2]),
3506 VerifyOutcome::Intact { rows_checked: 2 }
3507 );
3508 }
3509
3510 #[test]
3511 fn event_kind_spellings_are_stable() {
3512 assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
3515 assert_eq!(
3516 AuditEventKind::ApprovalConsumed.as_str(),
3517 "approval_consumed"
3518 );
3519 assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
3520 }
3521
3522 #[test]
3523 fn intact_event_chain_verifies() {
3524 let key = fixed_key();
3525 let rows = build_event_chain(
3526 &key,
3527 &[
3528 AuditEventKind::ApprovalGranted,
3529 AuditEventKind::ApprovalConsumed,
3530 ],
3531 );
3532 assert_eq!(
3533 verify_event_chain(&key, &rows),
3534 VerifyOutcome::Intact { rows_checked: 2 }
3535 );
3536 }
3537
3538 #[test]
3539 fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
3540 let key = fixed_key();
3543 let mut rows = build_event_chain(
3544 &key,
3545 &[
3546 AuditEventKind::ApprovalGranted,
3547 AuditEventKind::ApprovalConsumed,
3548 AuditEventKind::ApprovalRevoked,
3549 ],
3550 );
3551 rows.remove(1);
3552 match verify_event_chain(&key, &rows) {
3553 VerifyOutcome::Broken {
3554 first_broken_seq, ..
3555 } => assert_eq!(first_broken_seq, 3),
3556 other => panic!("expected Broken, got {other:?}"),
3557 }
3558 }
3559
3560 #[test]
3561 fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
3562 let key = fixed_key();
3563 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
3564 rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
3565 assert!(matches!(
3566 verify_event_chain(&key, &rows),
3567 VerifyOutcome::Broken { .. }
3568 ));
3569 }
3570
3571 #[test]
3572 fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
3573 let key = fixed_key();
3574 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
3575 rows[0].kind = "approval_unicorned".to_string();
3576 assert!(matches!(
3577 verify_event_chain(&key, &rows),
3578 VerifyOutcome::Broken { .. }
3579 ));
3580 }
3581
3582 #[test]
3583 fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
3584 let key = fixed_key();
3588 let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
3589 let event_sig = key.event_hash(&content, "");
3590 let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
3591 assert_ne!(event_sig, row_sig);
3592
3593 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
3594 rows[0].chain_hash = row_sig;
3595 assert!(matches!(
3596 verify_event_chain(&key, &rows),
3597 VerifyOutcome::Broken { .. }
3598 ));
3599 }
3600
3601 #[test]
3604 fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
3605 let key = fixed_key();
3610 let events = build_event_chain(
3611 &key,
3612 &[
3613 AuditEventKind::ApprovalGranted,
3614 AuditEventKind::ApprovalConsumed,
3615 ],
3616 );
3617 let mut content = sample_content(1, "tx-after");
3618 content.identity = ChainIdentity::V2 {
3619 caller_role: "Dev",
3620 event_tip: &events[1].chain_hash,
3621 };
3622 let hash = key.chain_hash(&content, "");
3623 let tx_rows = vec![row_for(&content, "", hash)];
3624
3625 assert_eq!(
3626 verify_event_binding(&tx_rows, &events),
3627 BindingOutcome::Consistent {
3628 bindings_checked: 1
3629 }
3630 );
3631
3632 let truncated = &events[..1];
3633 assert_eq!(
3634 verify_event_chain(&key, truncated),
3635 VerifyOutcome::Intact { rows_checked: 1 },
3636 "a truncated event chain still walks clean; the binding is the detector"
3637 );
3638 match verify_event_binding(&tx_rows, truncated) {
3639 BindingOutcome::MissingEvent {
3640 transaction_seq, ..
3641 } => assert_eq!(transaction_seq, 1),
3642 other => panic!("expected MissingEvent, got {other:?}"),
3643 }
3644 }
3645
3646 #[test]
3647 fn legacy_rows_carry_no_binding_to_check() {
3648 let key = fixed_key();
3651 let mut content = sample_content(1, "tx-legacy");
3652 content.identity = ChainIdentity::LegacyV1;
3653 let hash = key.chain_hash(&content, "");
3654 let rows = vec![row_for(&content, "", hash)];
3655 assert_eq!(
3656 verify_event_binding(&rows, &[]),
3657 BindingOutcome::Consistent {
3658 bindings_checked: 0
3659 }
3660 );
3661 }
3662
3663 #[test]
3664 fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
3665 let verification = AuditVerification {
3670 chain: VerifyOutcome::Broken {
3671 rows_checked: 1,
3672 first_broken_seq: 2,
3673 first_broken_transaction_id: "tx".to_string(),
3674 expected: "e".to_string(),
3675 actual: "a".to_string(),
3676 },
3677 events: VerifyOutcome::CannotVerify {
3678 reason: "no key".to_string(),
3679 },
3680 binding: BindingOutcome::Consistent {
3681 bindings_checked: 0,
3682 },
3683 attribution: None,
3684 status: None,
3685 };
3686 assert_eq!(verification.exit_code(), 1);
3687 }
3688
3689 #[test]
3690 fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
3691 let verification = AuditVerification {
3692 chain: VerifyOutcome::Intact { rows_checked: 3 },
3693 events: VerifyOutcome::Intact { rows_checked: 1 },
3694 binding: BindingOutcome::MissingEvent {
3695 transaction_seq: 3,
3696 event_tip: "abc".to_string(),
3697 },
3698 attribution: None,
3699 status: None,
3700 };
3701 assert_eq!(verification.exit_code(), 1);
3702 }
3703
3704 #[test]
3705 fn all_three_clean_is_exit_zero() {
3706 let verification = AuditVerification {
3707 chain: VerifyOutcome::Intact { rows_checked: 3 },
3708 events: VerifyOutcome::Intact { rows_checked: 2 },
3709 binding: BindingOutcome::Consistent {
3710 bindings_checked: 1,
3711 },
3712 attribution: None,
3713 status: None,
3714 };
3715 assert_eq!(verification.exit_code(), 0);
3716 }
3717
3718 #[test]
3719 fn binding_exit_codes_split_clean_tampered_and_unchecked() {
3720 assert_eq!(
3721 binding_outcome_to_exit_code(&BindingOutcome::Consistent {
3722 bindings_checked: 0
3723 }),
3724 0
3725 );
3726 assert_eq!(
3727 binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
3728 transaction_seq: 7,
3729 event_tip: "abc".to_string()
3730 }),
3731 1
3732 );
3733 assert_eq!(binding_outcome_to_exit_code(&BindingOutcome::NotChecked), 2);
3734 }
3735
3736 #[test]
3737 fn intact_chain_verifies() {
3738 let key = fixed_key();
3739 let rows = build_chain(&key, 5);
3740 let outcome = verify_chain(&key, &rows);
3741 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
3742 assert_eq!(outcome_to_exit_code(&outcome), 0);
3743 }
3744
3745 #[test]
3746 fn empty_chain_verifies() {
3747 let key = fixed_key();
3748 let outcome = verify_chain(&key, &[]);
3749 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
3750 }
3751
3752 #[test]
3753 fn tampered_summary_breaks_chain_at_first_offending_row() {
3754 let key = fixed_key();
3755 let mut rows = build_chain(&key, 3);
3756 rows[1].summary = "TAMPERED".to_string();
3758 let outcome = verify_chain(&key, &rows);
3759 match outcome {
3760 VerifyOutcome::Broken {
3761 first_broken_seq, ..
3762 } => assert_eq!(first_broken_seq, 2),
3763 other => panic!("expected Broken, got {other:?}"),
3764 }
3765 }
3766
3767 #[test]
3768 fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
3769 let key = fixed_key();
3770 let mut rows = build_chain(&key, 4);
3771 rows.remove(1);
3773 let outcome = verify_chain(&key, &rows);
3774 match outcome {
3775 VerifyOutcome::Broken {
3776 first_broken_seq, ..
3777 } => assert_eq!(
3778 first_broken_seq, 3,
3779 "first broken row is the one whose prev_hash refers to deleted row"
3780 ),
3781 other => panic!("expected Broken, got {other:?}"),
3782 }
3783 }
3784
3785 #[test]
3786 fn inserted_forged_row_breaks_chain() {
3787 let key = fixed_key();
3794 let mut rows = build_chain(&key, 3);
3795
3796 let forged = ChainRow {
3798 caller_principal: Some("uid:1000".to_string()),
3802 seq: 2,
3803 key_id: CURRENT_KEY_ID.to_string(),
3804 transaction_id: "tx-forged".to_string(),
3805 request_id: "req-forged".to_string(),
3806 request_hash: "hash-forged".to_string(),
3807 action_name: "InstallFlatpak".to_string(),
3808 risk_level: RiskLevel::Medium,
3809 summary: "Forged row".to_string(),
3810 approval_id: None,
3811 warnings_json: "[]".to_string(),
3812 created_at: "2026-04-25T13:00:00Z".to_string(),
3813 prev_chain_hash: rows[0].chain_hash.clone(),
3815 chain_hash: "0".repeat(HASH_HEX_LEN),
3817 chain_version: CHAIN_VERSION_CURRENT,
3818 caller_role: Some("Dev".to_string()),
3819 event_tip: Some(String::new()),
3820 };
3821
3822 let mut rest: Vec<ChainRow> = rows.split_off(1);
3824 for r in rest.iter_mut() {
3825 r.seq += 1;
3826 }
3827 rows.push(forged);
3828 rows.extend(rest);
3829
3830 let outcome = verify_chain(&key, &rows);
3834 match outcome {
3835 VerifyOutcome::Broken {
3836 first_broken_seq, ..
3837 } => assert_eq!(
3838 first_broken_seq, 2,
3839 "the verifier must flag the forged row itself, not a later one"
3840 ),
3841 other => panic!("expected Broken, got {other:?}"),
3842 }
3843 }
3844
3845 #[test]
3848 fn signature_verifies_under_exported_public_key() {
3849 let key = fixed_key();
3851 let rows = build_chain(&key, 4);
3852 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3853 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
3854 }
3855
3856 #[test]
3857 fn foreign_public_key_cannot_validate_chain() {
3858 let signer = AuditKey::from_bytes(vec![0x11; 32]);
3862 let rows = build_chain(&signer, 3);
3863 let other = AuditKey::from_bytes(vec![0x22; 32]);
3864 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
3865 assert!(matches!(
3866 outcome,
3867 VerifyOutcome::Broken {
3868 first_broken_seq: 1,
3869 ..
3870 }
3871 ));
3872 }
3873
3874 #[test]
3875 fn verifying_key_hex_is_a_32_byte_public_key() {
3876 let key = fixed_key();
3877 let vk_hex = key.verifying_key_hex();
3878 assert_eq!(
3879 vk_hex.len(),
3880 64,
3881 "32-byte ed25519 public key = 64 hex chars"
3882 );
3883 assert!(hex::decode(&vk_hex).is_ok());
3884 }
3885
3886 #[test]
3887 fn malformed_signature_hex_is_broken_not_panic() {
3888 let key = fixed_key();
3889 let mut rows = build_chain(&key, 2);
3890 rows[0].chain_hash = "not-valid-hex!!".to_string();
3891 let outcome = verify_chain(&key, &rows);
3892 assert!(matches!(
3893 outcome,
3894 VerifyOutcome::Broken {
3895 first_broken_seq: 1,
3896 ..
3897 }
3898 ));
3899 }
3900
3901 #[test]
3902 fn bad_public_key_hex_yields_cannot_verify() {
3903 let key = fixed_key();
3904 let rows = build_chain(&key, 1);
3905 let outcome = verify_chain_with_pubkey("zz", &rows);
3906 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3907 }
3908
3909 #[test]
3910 fn load_or_generate_writes_public_key_sidecar() {
3911 let dir = tempfile::tempdir().unwrap();
3912 let path = dir.path().join("audit-key");
3913 let key = AuditKey::load_or_generate(&path).unwrap();
3914 let pub_path = dir.path().join("audit-key.pub");
3915 assert!(pub_path.exists(), "public key sidecar must be written");
3916 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
3917 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
3918 }
3919
3920 #[test]
3921 fn wrong_key_id_yields_cannot_verify() {
3922 let key = fixed_key();
3923 let mut rows = build_chain(&key, 2);
3924 rows[0].key_id = "v99".to_string();
3925 let outcome = verify_chain(&key, &rows);
3926 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3927 assert_eq!(outcome_to_exit_code(&outcome), 2);
3928 }
3929
3930 #[test]
3931 fn exit_code_for_each_outcome() {
3932 assert_eq!(
3933 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
3934 0
3935 );
3936 assert_eq!(
3937 outcome_to_exit_code(&VerifyOutcome::Broken {
3938 rows_checked: 0,
3939 first_broken_seq: 1,
3940 first_broken_transaction_id: String::new(),
3941 expected: String::new(),
3942 actual: String::new(),
3943 }),
3944 1
3945 );
3946 assert_eq!(
3947 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
3948 reason: String::new()
3949 }),
3950 2
3951 );
3952 }
3953
3954 #[test]
3957 fn load_or_generate_creates_with_0600_mode() {
3958 use std::os::unix::fs::PermissionsExt;
3959 let dir = tempfile::tempdir().unwrap();
3960 let path = dir.path().join("audit-key");
3961 let _key = AuditKey::load_or_generate(&path).unwrap();
3962 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
3963 assert_eq!(mode, 0o600);
3964 }
3965
3966 #[test]
3967 fn load_or_generate_is_idempotent() {
3968 let dir = tempfile::tempdir().unwrap();
3969 let path = dir.path().join("audit-key");
3970 let key1_bytes = {
3971 let _ = AuditKey::load_or_generate(&path).unwrap();
3972 std::fs::read(&path).unwrap()
3973 };
3974 let _ = AuditKey::load_or_generate(&path).unwrap();
3975 let key2_bytes = std::fs::read(&path).unwrap();
3976 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
3977 }
3978
3979 #[test]
3980 fn rejects_world_readable_key_file() {
3981 use std::os::unix::fs::PermissionsExt;
3982 let dir = tempfile::tempdir().unwrap();
3983 let path = dir.path().join("audit-key");
3984 std::fs::write(&path, vec![0u8; 32]).unwrap();
3985 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3986 let result = AuditKey::load_or_generate(&path);
3987 assert!(matches!(
3988 result,
3989 Err(AuditKeyError::UnsafePermissions { .. })
3990 ));
3991 }
3992
3993 #[test]
3994 fn rejects_short_key_file() {
3995 use std::os::unix::fs::PermissionsExt;
3996 let dir = tempfile::tempdir().unwrap();
3997 let path = dir.path().join("audit-key");
3998 std::fs::write(&path, vec![0u8; 8]).unwrap();
3999 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
4000 let result = AuditKey::load_or_generate(&path);
4001 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
4002 }
4003
4004 #[test]
4010 fn debug_redacts_key_bytes_and_their_hex_encoding() {
4011 let raw = (0u8..32).collect::<Vec<u8>>();
4013 let key = AuditKey::from_bytes(raw.clone());
4014 let dbg = format!("{key:?}");
4015
4016 assert!(
4017 dbg.contains("redacted"),
4018 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
4019 );
4020
4021 let hex_lower = hex::encode(&raw);
4023 let hex_upper = hex::encode_upper(&raw);
4024 assert!(
4025 !dbg.contains(&hex_lower),
4026 "Debug output leaks lowercase hex of key bytes: {dbg}"
4027 );
4028 assert!(
4029 !dbg.contains(&hex_upper),
4030 "Debug output leaks uppercase hex of key bytes: {dbg}"
4031 );
4032
4033 for b in &raw {
4037 let pair = format!("{b:02x}");
4038 if *b >= 0x10 && pair != "1e" && pair != "1f" {
4043 assert!(
4044 !dbg.contains(&pair),
4045 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
4046 );
4047 }
4048 }
4049 }
4050
4051 #[test]
4054 fn debug_preserves_key_id() {
4055 let key = AuditKey::from_bytes(vec![0xff; 32]);
4056 let dbg = format!("{key:?}");
4057 assert!(
4058 dbg.contains("key_id"),
4059 "Debug output must label the key_id field: {dbg}"
4060 );
4061 assert!(
4062 dbg.contains(CURRENT_KEY_ID),
4063 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
4064 );
4065 }
4066
4067 #[test]
4070 fn checkpoint_consistent_with_intact_chain() {
4071 let key = fixed_key();
4072 let rows = build_chain(&key, 5);
4073 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4074 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
4075 assert_eq!(
4076 outcome,
4077 CheckpointOutcome::Consistent {
4078 checkpoints_checked: 1
4079 }
4080 );
4081 }
4082
4083 #[test]
4084 fn checkpoint_detects_truncation() {
4085 let key = fixed_key();
4086 let full = build_chain(&key, 5);
4087 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
4089 let truncated = &full[..3];
4090 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
4091 assert!(matches!(
4092 outcome,
4093 CheckpointOutcome::Truncated {
4094 checkpoint_seq: 5,
4095 current_max_seq: 3
4096 }
4097 ));
4098 }
4099
4100 #[test]
4101 fn checkpoint_detects_rewrite() {
4102 let key = fixed_key();
4103 let mut rows = build_chain(&key, 4);
4104 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4105 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
4107 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
4108 assert!(matches!(
4109 outcome,
4110 CheckpointOutcome::TipMismatch { seq: 3, .. }
4111 ));
4112 }
4113
4114 #[test]
4115 fn checkpoint_bad_signature_detected() {
4116 let key = fixed_key();
4117 let rows = build_chain(&key, 3);
4118 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
4119 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
4121 assert!(matches!(
4122 outcome,
4123 CheckpointOutcome::BadSignature { seq: 2 }
4124 ));
4125 }
4126
4127 #[test]
4128 fn checkpoint_foreign_key_rejected() {
4129 let signer = AuditKey::from_bytes(vec![0x11; 32]);
4131 let rows = build_chain(&signer, 3);
4132 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
4133 let other = AuditKey::from_bytes(vec![0x22; 32]);
4134 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
4135 assert!(matches!(
4136 outcome,
4137 CheckpointOutcome::BadSignature { seq: 2 }
4138 ));
4139 }
4140
4141 #[test]
4142 fn pubkey_verify_detects_tampered_middle_row() {
4143 let key = fixed_key();
4146 let mut rows = build_chain(&key, 4);
4147 rows[2].summary = "TAMPERED".to_string();
4148 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
4149 assert!(matches!(
4150 outcome,
4151 VerifyOutcome::Broken {
4152 first_broken_seq: 3,
4153 ..
4154 }
4155 ));
4156 }
4157
4158 #[test]
4159 fn multiple_checkpoints_all_consistent() {
4160 let key = fixed_key();
4161 let rows = build_chain(&key, 5);
4162 let cps = vec![
4163 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
4164 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
4165 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
4166 ];
4167 assert_eq!(
4168 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
4169 CheckpointOutcome::Consistent {
4170 checkpoints_checked: 3
4171 }
4172 );
4173 }
4174
4175 #[test]
4176 fn middle_checkpoint_failure_is_reported() {
4177 let key = fixed_key();
4179 let mut rows = build_chain(&key, 5);
4180 let cps = vec![
4181 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
4182 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
4183 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
4184 ];
4185 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
4187 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
4188 CheckpointOutcome::TipMismatch { seq: 3, .. }
4189 ));
4190 }
4191
4192 #[test]
4193 fn checkpoint_created_at_is_signed() {
4194 let key = fixed_key();
4196 let rows = build_chain(&key, 3);
4197 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4198 cp.created_at = "2020-01-01T00:00:00Z".to_string();
4199 assert!(matches!(
4200 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
4201 CheckpointOutcome::BadSignature { .. }
4202 ));
4203 }
4204
4205 #[test]
4206 fn checkpoint_seq_is_signed() {
4207 let key = fixed_key();
4209 let rows = build_chain(&key, 3);
4210 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4211 cp.seq = 2;
4212 assert!(matches!(
4213 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
4214 CheckpointOutcome::BadSignature { seq: 2 }
4215 ));
4216 }
4217
4218 #[test]
4219 fn checkpoint_detects_full_wipe() {
4220 let key = fixed_key();
4222 let full = build_chain(&key, 3);
4223 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
4224 assert!(matches!(
4225 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
4226 CheckpointOutcome::Truncated {
4227 checkpoint_seq: 3,
4228 current_max_seq: 0
4229 }
4230 ));
4231 }
4232
4233 #[test]
4234 fn domain_tags_are_distinct_and_prefix_free() {
4235 let tags: [(&str, &[u8]); 4] = [
4242 ("row", ROW_DOMAIN),
4243 ("checkpoint", CHECKPOINT_DOMAIN),
4244 ("approval", APPROVAL_DOMAIN),
4245 ("event", EVENT_DOMAIN),
4246 ];
4247 for (i, (name_a, a)) in tags.iter().enumerate() {
4248 for (name_b, b) in tags.iter().skip(i + 1) {
4249 assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
4250 assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
4251 assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
4252 }
4253 }
4254 }
4255
4256 #[test]
4257 fn approval_receipt_framing_is_injective() {
4258 let key = fixed_key();
4263 let sep = "\u{1f}";
4264 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
4265 let b = key.approval_receipt("tx", &format!("b{sep}c"));
4266 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
4267
4268 assert_eq!(
4270 key.approval_receipt("tx-1", "hash-1"),
4271 key.approval_receipt("tx-1", "hash-1"),
4272 );
4273 assert_eq!(
4275 key.approval_commitment("tx-1", "hash-1"),
4276 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
4277 );
4278 }
4279}