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 CannotVerify { detail: String },
1358}
1359
1360pub fn status_outcome_to_exit_code(outcome: &StatusOutcome) -> i32 {
1364 match outcome {
1365 StatusOutcome::Agrees { .. } => 0,
1366 StatusOutcome::Disagrees { .. } => 1,
1367 StatusOutcome::CannotVerify { .. } => 2,
1368 }
1369}
1370
1371#[derive(Debug, Clone, PartialEq, Eq)]
1374pub enum BindingOutcome {
1375 Consistent { bindings_checked: u64 },
1377 NotChecked,
1380 MissingEvent {
1384 transaction_seq: u64,
1385 event_tip: String,
1386 },
1387}
1388
1389pub fn verify_event_binding(tx_rows: &[ChainRow], event_rows: &[EventRow]) -> BindingOutcome {
1399 let known: std::collections::HashSet<&str> = event_rows
1400 .iter()
1401 .map(|row| row.chain_hash.as_str())
1402 .collect();
1403 let mut bindings_checked = 0u64;
1404 for row in tx_rows {
1405 let Some(tip) = row.event_tip.as_deref() else {
1406 continue;
1407 };
1408 if tip.is_empty() {
1409 continue;
1410 }
1411 if !known.contains(tip) {
1412 return BindingOutcome::MissingEvent {
1413 transaction_seq: row.seq,
1414 event_tip: tip.to_string(),
1415 };
1416 }
1417 bindings_checked += 1;
1418 }
1419 BindingOutcome::Consistent { bindings_checked }
1420}
1421
1422#[derive(Debug, Clone, PartialEq, Eq)]
1429pub struct AuditVerification {
1430 pub chain: VerifyOutcome,
1431 pub events: VerifyOutcome,
1432 pub binding: BindingOutcome,
1433 pub attribution: Option<AttributionCensus>,
1449 pub status: Option<StatusOutcome>,
1457}
1458
1459#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1464enum RowStanding {
1465 NamesAccount,
1467 AttributionFailed,
1470 NotRecorded,
1474 Unattested,
1484}
1485
1486fn standing(row: &ChainRow) -> RowStanding {
1498 let stored = row.caller_principal.as_deref().filter(|p| !p.is_empty());
1506 match row.chain_version {
1507 CHAIN_VERSION_LEGACY | CHAIN_VERSION_V2 => match stored {
1508 None => RowStanding::NotRecorded,
1509 Some(_) => RowStanding::Unattested,
1510 },
1511 CHAIN_VERSION_V3 => match stored {
1512 None => RowStanding::NotRecorded,
1513 Some(p) => match crate::auth::CallerPrincipal::classify(p) {
1514 crate::auth::PrincipalClaim::Account => RowStanding::NamesAccount,
1515 crate::auth::PrincipalClaim::AttributionFailed => RowStanding::AttributionFailed,
1516 crate::auth::PrincipalClaim::Unrecognized => RowStanding::Unattested,
1517 },
1518 },
1519 _ => RowStanding::Unattested,
1525 }
1526}
1527
1528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1560pub struct AttributionCensus {
1561 named: u64,
1562 attribution_failed: u64,
1563 not_recorded: u64,
1564 unattested: u64,
1565 rows: u64,
1566}
1567
1568impl AttributionCensus {
1569 pub fn of(tx_rows: &[ChainRow]) -> Self {
1571 let mut census = Self {
1572 named: 0,
1573 attribution_failed: 0,
1574 not_recorded: 0,
1575 unattested: 0,
1576 rows: tx_rows.len() as u64,
1577 };
1578 for row in tx_rows {
1579 match standing(row) {
1580 RowStanding::NamesAccount => census.named += 1,
1581 RowStanding::AttributionFailed => census.attribution_failed += 1,
1582 RowStanding::NotRecorded => census.not_recorded += 1,
1583 RowStanding::Unattested => census.unattested += 1,
1584 }
1585 }
1586 census
1587 }
1588
1589 pub fn named(&self) -> u64 {
1595 self.named
1596 }
1597
1598 pub fn attribution_failed(&self) -> u64 {
1601 self.attribution_failed
1602 }
1603
1604 pub fn not_recorded(&self) -> u64 {
1607 self.not_recorded
1608 }
1609
1610 pub fn unattested(&self) -> u64 {
1613 self.unattested
1614 }
1615
1616 pub fn rows(&self) -> u64 {
1623 self.rows
1624 }
1625
1626 pub fn unnamed(&self) -> u64 {
1635 self.rows - self.named
1636 }
1637
1638 #[cfg(any(test, feature = "test-support"))]
1651 pub fn from_counts_for_tests(
1652 named: u64,
1653 attribution_failed: u64,
1654 not_recorded: u64,
1655 unattested: u64,
1656 ) -> Self {
1657 let rows = named
1658 .checked_add(attribution_failed)
1659 .and_then(|n| n.checked_add(not_recorded))
1660 .and_then(|n| n.checked_add(unattested))
1661 .expect("census counts must not overflow u64");
1662 Self {
1663 named,
1664 attribution_failed,
1665 not_recorded,
1666 unattested,
1667 rows,
1668 }
1669 }
1670}
1671
1672impl AuditVerification {
1673 pub fn exit_code(&self) -> i32 {
1679 let mut codes = vec![
1680 outcome_to_exit_code(&self.chain),
1681 outcome_to_exit_code(&self.events),
1682 binding_outcome_to_exit_code(&self.binding),
1683 ];
1684 if let Some(status) = &self.status {
1687 codes.push(status_outcome_to_exit_code(status));
1688 }
1689 if codes.contains(&1) {
1690 1
1691 } else if codes.contains(&2) {
1692 2
1693 } else {
1694 0
1695 }
1696 }
1697}
1698
1699pub fn verify_all(
1701 key: &AuditKey,
1702 tx_rows: &[ChainRow],
1703 event_rows: &[EventRow],
1704) -> AuditVerification {
1705 AuditVerification {
1706 chain: verify_chain(key, tx_rows),
1707 events: verify_event_chain(key, event_rows),
1708 binding: verify_event_binding(tx_rows, event_rows),
1709 attribution: Some(AttributionCensus::of(tx_rows)),
1710 status: None,
1711 }
1712}
1713
1714pub fn verify_all_with_pubkey(
1716 verifying_key_hex: &str,
1717 tx_rows: &[ChainRow],
1718 event_rows: &[EventRow],
1719) -> AuditVerification {
1720 AuditVerification {
1721 chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
1722 events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
1723 binding: verify_event_binding(tx_rows, event_rows),
1724 attribution: Some(AttributionCensus::of(tx_rows)),
1725 status: None,
1726 }
1727}
1728
1729pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
1733 match outcome {
1734 BindingOutcome::Consistent { .. } => 0,
1735 BindingOutcome::NotChecked => 2,
1736 BindingOutcome::MissingEvent { .. } => 1,
1737 }
1738}
1739
1740#[derive(Debug, Clone, PartialEq, Eq)]
1756pub struct Checkpoint {
1757 pub seq: u64,
1760 pub chain_tip: String,
1762 pub created_at: String,
1764 pub signature: String,
1766}
1767
1768fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
1771 let mut buf = CHECKPOINT_DOMAIN.to_vec();
1774 push_field(&mut buf, "seq", &seq.to_string());
1775 push_field(&mut buf, "chain_tip", chain_tip);
1776 push_field(&mut buf, "created_at", created_at);
1777 buf
1778}
1779
1780impl AuditKey {
1781 pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
1783 let sig = self
1784 .signing
1785 .sign(&checkpoint_message(seq, chain_tip, created_at));
1786 Checkpoint {
1787 seq,
1788 chain_tip: chain_tip.to_string(),
1789 created_at: created_at.to_string(),
1790 signature: hex::encode(sig.to_bytes()),
1791 }
1792 }
1793}
1794
1795#[derive(Debug, Clone, PartialEq, Eq)]
1797pub enum CheckpointOutcome {
1798 Consistent { checkpoints_checked: u64 },
1801 BadSignature { seq: u64 },
1803 Truncated {
1806 checkpoint_seq: u64,
1807 current_max_seq: u64,
1808 },
1809 TipMismatch {
1812 seq: u64,
1813 anchored: String,
1814 actual: String,
1815 },
1816 CannotVerify { reason: String },
1818}
1819
1820pub fn verify_checkpoints(
1826 verifying_key_hex: &str,
1827 rows: &[ChainRow],
1828 checkpoints: &[Checkpoint],
1829) -> CheckpointOutcome {
1830 let vk = match parse_verifying_key(verifying_key_hex) {
1831 Some(vk) => vk,
1832 None => {
1833 return CheckpointOutcome::CannotVerify {
1834 reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
1835 };
1836 }
1837 };
1838 let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
1841 let mut checked = 0u64;
1842 for cp in checkpoints {
1843 let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
1844 if !signature_ok(&vk, &msg, &cp.signature) {
1845 return CheckpointOutcome::BadSignature { seq: cp.seq };
1846 }
1847 match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
1848 Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
1849 Ok(idx) => {
1850 return CheckpointOutcome::TipMismatch {
1851 seq: cp.seq,
1852 anchored: cp.chain_tip.clone(),
1853 actual: rows[idx].chain_hash.clone(),
1854 };
1855 }
1856 Err(_) => {
1857 return CheckpointOutcome::Truncated {
1858 checkpoint_seq: cp.seq,
1859 current_max_seq,
1860 };
1861 }
1862 }
1863 checked += 1;
1864 }
1865 CheckpointOutcome::Consistent {
1866 checkpoints_checked: checked,
1867 }
1868}
1869
1870pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
1875 match outcome {
1876 CheckpointOutcome::Consistent { .. } => 0,
1877 CheckpointOutcome::BadSignature { .. }
1878 | CheckpointOutcome::Truncated { .. }
1879 | CheckpointOutcome::TipMismatch { .. } => 1,
1880 CheckpointOutcome::CannotVerify { .. } => 2,
1881 }
1882}
1883
1884#[cfg(test)]
1885mod tests {
1886 #[test]
1887 fn audit_key_env_resolution_is_centralised() {
1888 const INLINE_RESOLUTION: &str = "std::env::var(\"SYSKNIFE_AUDIT_KEY_PATH\")";
1889 let daemon_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
1890 let cli_src =
1891 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps/sysknife-cli/src");
1892
1893 let mut roots = vec![daemon_src.clone(), cli_src.clone()];
1894 let mut files_checked = 0;
1895 while let Some(dir) = roots.pop() {
1896 for entry in std::fs::read_dir(&dir).expect("source directory must be readable") {
1897 let path = entry.expect("source entry must be readable").path();
1898 if path.is_dir() {
1899 roots.push(path);
1900 continue;
1901 }
1902 if path.extension().and_then(|ext| ext.to_str()) != Some("rs")
1903 || path == daemon_src.join("audit_chain.rs")
1904 {
1905 continue;
1906 }
1907 files_checked += 1;
1908 let source = std::fs::read_to_string(&path).expect("Rust source must be readable");
1909 assert!(
1910 !source.contains(INLINE_RESOLUTION),
1911 "{} must resolve the audit key through resolve_audit_key_path",
1912 path.display()
1913 );
1914 }
1915 }
1916 assert!(
1917 files_checked >= 60,
1918 "source walk unexpectedly checked only {files_checked} Rust files"
1919 );
1920 }
1921
1922 #[test]
1930 fn every_variant_is_in_all() {
1931 fn spelling(kind: AuditEventKind) -> &'static str {
1932 match kind {
1933 AuditEventKind::ApprovalGranted => "approval_granted",
1934 AuditEventKind::ApprovalConsumed => "approval_consumed",
1935 AuditEventKind::ApprovalRevoked => "approval_revoked",
1936 AuditEventKind::StatusQueued => "status_queued",
1937 AuditEventKind::StatusRunning => "status_running",
1938 AuditEventKind::StatusSucceeded => "status_succeeded",
1939 AuditEventKind::StatusFailed => "status_failed",
1940 AuditEventKind::StatusCanceled => "status_canceled",
1941 AuditEventKind::StatusRolledBack => "status_rolled_back",
1942 AuditEventKind::StatusNeedsReboot => "status_needs_reboot",
1943 }
1944 }
1945 for kind in AuditEventKind::ALL {
1946 assert_eq!(
1947 spelling(*kind),
1948 kind.as_str(),
1949 "as_str and the exhaustive table disagree about {kind:?}"
1950 );
1951 assert_eq!(
1952 parse_event_kind(kind.as_str()),
1953 Some(*kind),
1954 "{kind:?} does not round-trip, so a row carrying it verifies as broken"
1955 );
1956 }
1957 for kind in AuditEventKind::ALL {
1960 assert!(
1961 AuditEventKind::ALL
1962 .iter()
1963 .any(|k| k.as_str() == spelling(*kind)),
1964 "{kind:?} has a spelling and is not reachable through ALL"
1965 );
1966 }
1967 assert!(
1968 parse_event_kind("not_a_kind").is_none(),
1969 "an unknown kind must not parse, or the check inspects nothing"
1970 );
1971 }
1972
1973 use super::*;
1974
1975 fn fixed_key() -> AuditKey {
1976 AuditKey::from_bytes(vec![0x42; 32])
1977 }
1978
1979 fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
1980 ChainContent {
1981 seq,
1982 key_id: CURRENT_KEY_ID,
1983 transaction_id: txid,
1984 request_id: "req-1",
1985 request_hash: "hash-abc",
1986 action_name: "UpdateSystem",
1987 risk_level: RiskLevel::High,
1988 summary: "Upgrade",
1989 approval_id: None,
1990 warnings_json: "[]",
1991 created_at: "2026-04-24T12:00:00Z",
1992 identity: ChainIdentity::V2 {
1993 caller_role: "Dev",
1994 event_tip: "",
1995 },
1996 }
1997 }
1998
1999 #[test]
2002 fn same_inputs_yield_same_hash() {
2003 let key = fixed_key();
2004 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
2005 let h2 = key.chain_hash(&sample_content(1, "txa"), "");
2006 assert_eq!(h1, h2);
2007 assert_eq!(h1.len(), HASH_HEX_LEN);
2008 }
2009
2010 #[test]
2011 fn different_seq_yields_different_hash() {
2012 let key = fixed_key();
2013 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
2014 let h2 = key.chain_hash(&sample_content(2, "txa"), "");
2015 assert_ne!(h1, h2);
2016 }
2017
2018 #[test]
2019 fn different_prev_hash_yields_different_hash() {
2020 let key = fixed_key();
2021 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
2022 let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
2023 assert_ne!(h1, h2);
2024 }
2025
2026 #[test]
2027 fn different_keys_yield_different_hashes() {
2028 let key1 = AuditKey::from_bytes(vec![0x01; 32]);
2029 let key2 = AuditKey::from_bytes(vec![0x02; 32]);
2030 let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
2031 let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
2032 assert_ne!(h1, h2);
2033 }
2034
2035 #[test]
2038 fn canonical_bytes_have_stable_field_order() {
2039 let c = sample_content(1, "txa");
2040 let bytes = c.canonical_bytes();
2041 let s = String::from_utf8_lossy(&bytes);
2042 let order = [
2044 "seq",
2045 "key_id",
2046 "transaction_id",
2047 "request_id",
2048 "request_hash",
2049 "action_name",
2050 "risk_level",
2051 "summary",
2052 "approval_id",
2053 "warnings_json",
2054 "created_at",
2055 ];
2056 let mut last_idx = 0;
2057 for tag in order {
2058 let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
2059 assert!(idx >= last_idx, "{tag} out of order");
2060 last_idx = idx;
2061 }
2062 }
2063
2064 #[test]
2065 fn nul_bytes_in_field_value_are_escaped() {
2066 let mut c = sample_content(1, "txa");
2067 let summary = "before\0after";
2068 c.summary = summary;
2069 let bytes = c.canonical_bytes();
2070 assert!(!bytes.contains(&0x00));
2072 assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
2073 }
2074
2075 #[test]
2081 fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
2082 let mut a = sample_content(1, "txa");
2083 a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
2085 b.summary = "before\0after"; assert_ne!(
2087 a.canonical_bytes(),
2088 b.canonical_bytes(),
2089 "backslash escape must run before NUL escape to prevent collision"
2090 );
2091 }
2092
2093 fn key_for_collision_test() -> AuditKey {
2094 AuditKey::from_bytes(vec![0xab; 32])
2095 }
2096
2097 #[test]
2098 fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
2099 let key = key_for_collision_test();
2100 let mut a = sample_content(1, "txa");
2101 a.summary = "before\\0after";
2102 let mut b = sample_content(1, "txa");
2103 b.summary = "before\0after";
2104 assert_ne!(
2105 key.chain_hash(&a, ""),
2106 key.chain_hash(&b, ""),
2107 "chain signature must distinguish escape from raw byte"
2108 );
2109 }
2110
2111 fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
2117 let (chain_version, caller_role, event_tip, caller_principal) = match content.identity {
2118 ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None, None),
2119 ChainIdentity::V2 {
2120 caller_role,
2121 event_tip,
2122 } => (
2123 CHAIN_VERSION_V2,
2124 Some(caller_role.to_string()),
2125 Some(event_tip.to_string()),
2126 None,
2127 ),
2128 ChainIdentity::V3 {
2129 caller_role,
2130 event_tip,
2131 caller_principal,
2132 } => (
2133 CHAIN_VERSION_V3,
2134 Some(caller_role.to_string()),
2135 Some(event_tip.to_string()),
2136 Some(caller_principal.to_string()),
2137 ),
2138 };
2139 ChainRow {
2140 seq: content.seq,
2141 key_id: content.key_id.to_string(),
2142 transaction_id: content.transaction_id.to_string(),
2143 request_id: content.request_id.to_string(),
2144 request_hash: content.request_hash.to_string(),
2145 action_name: content.action_name.to_string(),
2146 risk_level: content.risk_level,
2147 summary: content.summary.to_string(),
2148 approval_id: content.approval_id.map(str::to_string),
2149 warnings_json: content.warnings_json.to_string(),
2150 created_at: content.created_at.to_string(),
2151 prev_chain_hash: prev.to_string(),
2152 chain_hash,
2153 chain_version,
2154 caller_role,
2155 event_tip,
2156 caller_principal,
2157 }
2158 }
2159
2160 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
2161 let mut rows = Vec::with_capacity(count);
2162 let mut prev = String::new();
2163 for i in 0..count {
2164 let seq = (i + 1) as u64;
2165 let txid = format!("tx{i}");
2166 let content = sample_content(seq, &txid);
2167 let hash = key.chain_hash(&content, &prev);
2168 rows.push(row_for(&content, &prev, hash.clone()));
2169 prev = hash;
2170 }
2171 rows
2172 }
2173
2174 #[test]
2175 fn exported_chain_row_json_round_trips_without_changing_the_signature() {
2176 let key = fixed_key();
2177 let row = build_chain(&key, 1).remove(0);
2178
2179 let json = serde_json::to_string(&row).expect("chain row serializes");
2180 let decoded: ChainRow = serde_json::from_str(&json).expect("chain row deserializes");
2181
2182 assert_eq!(decoded, row);
2183 assert_eq!(decoded.chain_hash.as_bytes(), row.chain_hash.as_bytes());
2184 }
2185
2186 #[test]
2187 fn exported_chain_row_uses_the_stored_column_contract_and_json_nulls() {
2188 let key = fixed_key();
2189 let mut row = build_chain(&key, 1).remove(0);
2190 row.approval_id = None;
2191 row.caller_role = None;
2192 row.event_tip = None;
2193 row.caller_principal = None;
2194
2195 let value = serde_json::to_value(&row).expect("chain row serializes");
2196 let object = value.as_object().expect("chain row is a JSON object");
2197 let keys: Vec<&str> = object.keys().map(String::as_str).collect();
2198
2199 assert_eq!(
2200 keys,
2201 vec![
2202 "action_name",
2203 "approval_id",
2204 "caller_principal",
2205 "caller_role",
2206 "chain_hash",
2207 "chain_version",
2208 "created_at",
2209 "event_tip",
2210 "key_id",
2211 "prev_chain_hash",
2212 "request_hash",
2213 "request_id",
2214 "risk_level",
2215 "seq",
2216 "summary",
2217 "transaction_id",
2218 "warnings_json",
2219 ]
2220 );
2221 for optional in [
2222 "approval_id",
2223 "caller_role",
2224 "event_tip",
2225 "caller_principal",
2226 ] {
2227 assert!(object[optional].is_null(), "{optional} must be JSON null");
2228 }
2229 assert!(!object.contains_key("argv"));
2230 assert!(!object.contains_key("outcome"));
2231 assert!(!object.contains_key("signature"));
2232 }
2233
2234 #[test]
2235 fn export_since_is_inclusive_and_limit_preserves_chain_order() {
2236 let key = fixed_key();
2237 let mut rows = build_chain(&key, 4);
2238 for (row, created_at) in rows.iter_mut().zip([
2239 "2026-08-19T09:00:00Z",
2240 "2026-08-19T10:00:00Z",
2241 "2026-08-19T11:00:00Z",
2242 "2026-08-19T12:00:00Z",
2243 ]) {
2244 row.created_at = created_at.to_string();
2245 }
2246
2247 let selected =
2248 select_chain_rows_for_export(rows, Some("2026-08-19T10:00:00+00:00"), Some(2)).unwrap();
2249
2250 assert_eq!(
2251 selected.iter().map(|row| row.seq).collect::<Vec<_>>(),
2252 [2, 3]
2253 );
2254 }
2255
2256 #[test]
2257 fn export_rejects_an_invalid_since_timestamp() {
2258 let error = select_chain_rows_for_export(Vec::new(), Some("last Tuesday"), None)
2259 .expect_err("invalid --since must fail before reading rows");
2260 assert!(matches!(error, ChainExportError::InvalidSince(_)));
2261 }
2262
2263 #[test]
2266 fn caller_role_is_covered_by_the_row_signature() {
2267 let key = fixed_key();
2271 let mut dev = sample_content(1, "txa");
2272 dev.identity = ChainIdentity::V2 {
2273 caller_role: "Dev",
2274 event_tip: "",
2275 };
2276 let mut boot = sample_content(1, "txa");
2277 boot.identity = ChainIdentity::V2 {
2278 caller_role: "Boot",
2279 event_tip: "",
2280 };
2281 assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
2282 }
2283
2284 #[test]
2285 fn event_tip_is_covered_by_the_row_signature() {
2286 let key = fixed_key();
2287 let mut empty = sample_content(1, "txa");
2288 empty.identity = ChainIdentity::V2 {
2289 caller_role: "Dev",
2290 event_tip: "",
2291 };
2292 let mut bound = sample_content(1, "txa");
2293 bound.identity = ChainIdentity::V2 {
2294 caller_role: "Dev",
2295 event_tip: "abc123",
2296 };
2297 assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
2298 }
2299
2300 #[test]
2301 fn a_legacy_row_written_before_the_migration_still_verifies() {
2302 let key = fixed_key();
2307 let mut content = sample_content(1, "tx-legacy");
2308 content.identity = ChainIdentity::LegacyV1;
2309 let hash = key.chain_hash(&content, "");
2310 let row = row_for(&content, "", hash);
2311 assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
2312 assert_eq!(row.caller_role, None);
2313 assert_eq!(
2314 verify_chain(&key, &[row]),
2315 VerifyOutcome::Intact { rows_checked: 1 }
2316 );
2317 }
2318
2319 #[test]
2325 fn two_admins_are_distinguishable_in_the_signed_record() {
2326 let key = fixed_key();
2327 let mut alice = sample_content(1, "tx-alice");
2328 alice.identity = ChainIdentity::V3 {
2329 caller_role: "admin",
2330 event_tip: "",
2331 caller_principal: "uid:1000",
2332 };
2333 let mut bob = sample_content(1, "tx-alice");
2334 bob.identity = ChainIdentity::V3 {
2335 caller_role: "admin",
2336 event_tip: "",
2337 caller_principal: "uid:1001",
2338 };
2339
2340 assert_ne!(
2341 key.chain_hash(&alice, ""),
2342 key.chain_hash(&bob, ""),
2343 "identical rows differing only in principal must sign differently, \
2344 otherwise the chain still cannot answer which account acted"
2345 );
2346 }
2347
2348 #[test]
2351 fn editing_the_stored_principal_breaks_the_row() {
2352 let key = fixed_key();
2353 let mut content = sample_content(1, "tx1");
2354 content.identity = ChainIdentity::V3 {
2355 caller_role: "admin",
2356 event_tip: "",
2357 caller_principal: "uid:1000",
2358 };
2359 let hash = key.chain_hash(&content, "");
2360 let mut row = row_for(&content, "", hash);
2361 row.caller_principal = Some("uid:0".to_string());
2362
2363 assert!(
2364 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2365 "a principal swapped to root must not verify"
2366 );
2367 }
2368
2369 #[test]
2373 fn downgrading_a_v3_row_to_v2_to_hide_the_account_breaks_it() {
2374 let key = fixed_key();
2375 let mut content = sample_content(1, "tx1");
2376 content.identity = ChainIdentity::V3 {
2377 caller_role: "admin",
2378 event_tip: "",
2379 caller_principal: "uid:1000",
2380 };
2381 let hash = key.chain_hash(&content, "");
2382 let mut row = row_for(&content, "", hash);
2383 row.chain_version = CHAIN_VERSION_V2;
2384 row.caller_principal = None;
2385
2386 assert!(
2387 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2388 "a v3 row relabelled as v2 must not verify"
2389 );
2390 }
2391
2392 #[test]
2397 fn a_v3_row_without_a_usable_principal_is_broken() {
2398 let key = fixed_key();
2399 let mut content = sample_content(1, "tx1");
2400 content.identity = ChainIdentity::V3 {
2401 caller_role: "admin",
2402 event_tip: "",
2403 caller_principal: "uid:1000",
2404 };
2405 let hash = key.chain_hash(&content, "");
2406
2407 for absent in [None, Some(String::new())] {
2408 let mut row = row_for(&content, "", hash.clone());
2409 row.caller_principal = absent.clone();
2410 assert!(
2411 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2412 "a v3 row with principal {absent:?} must be reported as broken"
2413 );
2414 }
2415 }
2416
2417 #[test]
2427 fn a_row_signed_with_a_blank_principal_is_rejected_even_though_it_signs() {
2428 let key = fixed_key();
2429 let mut content = sample_content(1, "tx-blank");
2430 content.identity = ChainIdentity::V3 {
2431 caller_role: "admin",
2432 event_tip: "",
2433 caller_principal: "",
2434 };
2435 let hash = key.chain_hash(&content, "");
2436 let row = row_for(&content, "", hash);
2441 assert_eq!(
2442 row.caller_principal.as_deref(),
2443 Some(""),
2444 "fixture must store the blank principal it signed"
2445 );
2446 assert_eq!(row.chain_version, CHAIN_VERSION_V3);
2447 assert!(
2448 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2449 "a v3 row naming nobody must be broken, not accepted"
2450 );
2451 }
2452
2453 #[test]
2458 fn a_row_recording_an_attribution_failure_verifies() {
2459 let key = fixed_key();
2460 let principal = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2461 let mut content = sample_content(1, "tx-unattributed");
2462 content.identity = ChainIdentity::V3 {
2463 caller_role: "observer",
2464 event_tip: "",
2465 caller_principal: &principal,
2466 };
2467 let hash = key.chain_hash(&content, "");
2468 let row = row_for(&content, "", hash);
2469
2470 assert_eq!(
2471 verify_chain(&key, &[row]),
2472 VerifyOutcome::Intact { rows_checked: 1 }
2473 );
2474 }
2475
2476 #[test]
2480 fn a_chain_spanning_all_three_encodings_verifies() {
2481 let key = fixed_key();
2482 let mut rows = Vec::new();
2483 let mut prev = String::new();
2484 for (i, identity) in [
2485 ChainIdentity::LegacyV1,
2486 ChainIdentity::V2 {
2487 caller_role: "dev",
2488 event_tip: "",
2489 },
2490 ChainIdentity::V3 {
2491 caller_role: "admin",
2492 event_tip: "",
2493 caller_principal: "uid:1000",
2494 },
2495 ChainIdentity::V3 {
2496 caller_role: "admin",
2497 event_tip: "",
2498 caller_principal: "token:vsock",
2499 },
2500 ]
2501 .into_iter()
2502 .enumerate()
2503 {
2504 let txid = format!("tx{i}");
2505 let mut content = sample_content((i + 1) as u64, &txid);
2506 content.identity = identity;
2507 let hash = key.chain_hash(&content, &prev);
2508 rows.push(row_for(&content, &prev, hash.clone()));
2509 prev = hash;
2510 }
2511
2512 assert_eq!(
2513 verify_chain(&key, &rows),
2514 VerifyOutcome::Intact { rows_checked: 4 }
2515 );
2516 }
2517
2518 fn chain_of(key: &AuditKey, identities: Vec<ChainIdentity<'_>>) -> Vec<ChainRow> {
2521 let mut rows = Vec::new();
2522 let mut prev = String::new();
2523 for (i, identity) in identities.into_iter().enumerate() {
2524 let txid = format!("tx{i}");
2525 let mut content = sample_content((i + 1) as u64, &txid);
2526 content.identity = identity;
2527 let hash = key.chain_hash(&content, &prev);
2528 rows.push(row_for(&content, &prev, hash.clone()));
2529 prev = hash;
2530 }
2531 rows
2532 }
2533
2534 #[test]
2544 fn the_census_separates_attribution_failures_from_encodings_without_the_field() {
2545 let key = fixed_key();
2546 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2547 let rows = chain_of(
2548 &key,
2549 vec![
2550 ChainIdentity::LegacyV1,
2551 ChainIdentity::V2 {
2552 caller_role: "dev",
2553 event_tip: "",
2554 },
2555 ChainIdentity::V3 {
2556 caller_role: "admin",
2557 event_tip: "",
2558 caller_principal: "uid:1000",
2559 },
2560 ChainIdentity::V3 {
2561 caller_role: "admin",
2562 event_tip: "",
2563 caller_principal: "uid:1001",
2564 },
2565 ChainIdentity::V3 {
2566 caller_role: "admin",
2567 event_tip: "",
2568 caller_principal: "token:vsock",
2569 },
2570 ChainIdentity::V3 {
2571 caller_role: "observer",
2572 event_tip: "",
2573 caller_principal: &unattributed,
2574 },
2575 ],
2576 );
2577
2578 let verification = verify_all(&key, &rows, &[]);
2579 assert_eq!(
2580 verification.chain,
2581 VerifyOutcome::Intact { rows_checked: 6 },
2582 "every encoding must keep verifying; the census is not a verdict"
2583 );
2584 let census = verification
2585 .attribution
2586 .expect("rows were read, so a census was taken");
2587 assert_eq!(census.named(), 3);
2588 assert_eq!(census.attribution_failed(), 1);
2589 assert_eq!(census.not_recorded(), 2);
2590 assert_eq!(census.unattested(), 0);
2591 assert_eq!(census.rows(), 6);
2592 assert_eq!(
2593 census.unnamed(),
2594 3,
2595 "three of the six rows cannot name an account, by either reason"
2596 );
2597 }
2598
2599 #[test]
2609 fn a_principal_written_into_an_encoding_that_does_not_sign_it_is_never_an_account() {
2610 let key = fixed_key();
2611 let mut rows = chain_of(
2612 &key,
2613 vec![
2614 ChainIdentity::LegacyV1,
2615 ChainIdentity::V2 {
2616 caller_role: "dev",
2617 event_tip: "",
2618 },
2619 ],
2620 );
2621 rows[0].caller_principal = Some("uid:0".to_string());
2624 rows[1].caller_principal = Some("uid:0".to_string());
2625
2626 let verification = verify_all(&key, &rows, &[]);
2627 assert_eq!(
2628 verification.chain,
2629 VerifyOutcome::Intact { rows_checked: 2 },
2630 "no signature covers that column on v1/v2, so tampering with it is \
2631 invisible to verification -- which is exactly why the census must \
2632 not believe it"
2633 );
2634 let census = verification.attribution.expect("rows were read");
2635 assert_eq!(
2636 census.named(),
2637 0,
2638 "an unsigned column must never be credited as an account"
2639 );
2640 assert_eq!(
2641 census.unattested(),
2642 2,
2643 "and it must be reported as unattested, because nothing in SysKnife \
2644 writes that column on those encodings"
2645 );
2646 assert_eq!(census.unnamed(), 2);
2647 }
2648
2649 #[test]
2653 fn a_blank_principal_column_is_counted_as_naming_nobody() {
2654 let key = fixed_key();
2655 let mut content = sample_content(1, "tx-blank-census");
2656 content.identity = ChainIdentity::V3 {
2657 caller_role: "admin",
2658 event_tip: "",
2659 caller_principal: "",
2660 };
2661 let hash = key.chain_hash(&content, "");
2662 let row = row_for(&content, "", hash);
2663 assert_eq!(row.caller_principal.as_deref(), Some(""));
2664
2665 let verification = verify_all(&key, &[row], &[]);
2666 let census = verification.attribution.expect("a row was read");
2667 assert_eq!(census.named(), 0);
2668 assert_eq!(census.not_recorded(), 1);
2669 assert_eq!(census.rows(), 1);
2670 assert!(
2671 matches!(verification.chain, VerifyOutcome::Broken { .. }),
2672 "and the row is still reported as broken once the walk reaches it, \
2673 so census and verdict agree"
2674 );
2675 }
2676
2677 #[test]
2685 fn a_principal_this_binary_cannot_read_is_counted_as_unattested_not_as_an_account() {
2686 let key = fixed_key();
2687 for forged in [
2688 "garbage",
2689 "uid:",
2690 "none:overflow",
2691 " ",
2696 "UID:1000",
2697 "uid:notanumber",
2699 "uid:1000:extra",
2700 "token:not-vsock",
2701 ] {
2702 let mut content = sample_content(1, "tx-forged");
2703 content.identity = ChainIdentity::V3 {
2704 caller_role: "admin",
2705 event_tip: "",
2706 caller_principal: forged,
2707 };
2708 let hash = key.chain_hash(&content, "");
2709 let row = row_for(&content, "", hash);
2710 let census = verify_all(&key, &[row], &[])
2711 .attribution
2712 .expect("a row was read");
2713 assert_eq!(
2714 census.named(),
2715 0,
2716 "{forged:?} names no account and must not be counted as one"
2717 );
2718 assert_eq!(
2719 census.unnamed(),
2720 1,
2721 "{forged:?} must be counted among the rows naming nobody"
2722 );
2723 assert_eq!(
2724 census.unattested(),
2725 1,
2726 "{forged:?} is signed content the daemon never writes, so it is a \
2727 finding to investigate, not history to explain away"
2728 );
2729 assert_eq!(
2730 census.not_recorded(),
2731 0,
2732 "{forged:?} is present, so it must not be reported as a row that \
2733 predates the column"
2734 );
2735 }
2736 }
2737
2738 #[test]
2743 fn a_chain_predating_the_principal_field_reports_no_row_as_attributed() {
2744 let key = fixed_key();
2745 let rows = chain_of(
2746 &key,
2747 vec![
2748 ChainIdentity::LegacyV1,
2749 ChainIdentity::V2 {
2750 caller_role: "dev",
2751 event_tip: "",
2752 },
2753 ],
2754 );
2755
2756 let verification = verify_all(&key, &rows, &[]);
2757 assert_eq!(
2758 verification.chain,
2759 VerifyOutcome::Intact { rows_checked: 2 },
2760 "legacy rows must keep verifying; the census is not a verdict"
2761 );
2762 let census = verification.attribution.expect("rows were read");
2763 assert_eq!(census.attribution_failed(), 0);
2764 assert_eq!(census.named(), 0);
2765 assert_eq!(census.not_recorded(), 2);
2766 assert_eq!(census.unnamed(), 2);
2767 }
2768
2769 #[test]
2775 fn a_broken_chain_censuses_more_rows_than_it_verified() {
2776 let key = fixed_key();
2777 let mut rows = chain_of(
2778 &key,
2779 vec![
2780 ChainIdentity::V3 {
2781 caller_role: "admin",
2782 event_tip: "",
2783 caller_principal: "uid:1000",
2784 },
2785 ChainIdentity::V3 {
2786 caller_role: "admin",
2787 event_tip: "",
2788 caller_principal: "uid:1000",
2789 },
2790 ChainIdentity::V3 {
2791 caller_role: "admin",
2792 event_tip: "",
2793 caller_principal: "uid:1000",
2794 },
2795 ],
2796 );
2797 rows[1].summary = "tampered".to_string();
2798
2799 let verification = verify_all(&key, &rows, &[]);
2800 let rows_checked = match verification.chain {
2801 VerifyOutcome::Broken { rows_checked, .. } => rows_checked,
2802 ref other => panic!("expected a detected tamper, got {other:?}"),
2803 };
2804 let census = verification.attribution.expect("rows were read");
2805 assert_eq!(rows_checked, 1, "the walk stops at the first broken row");
2806 assert_eq!(
2807 census.rows(),
2808 3,
2809 "while the census still describes every row read"
2810 );
2811 assert!(
2812 census.rows() > rows_checked,
2813 "the report must be able to show that more was counted than checked"
2814 );
2815 }
2816
2817 #[test]
2821 fn the_pubkey_only_path_takes_the_same_census() {
2822 let key = fixed_key();
2823 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2824 let rows = chain_of(
2825 &key,
2826 vec![
2827 ChainIdentity::V3 {
2828 caller_role: "admin",
2829 event_tip: "",
2830 caller_principal: "uid:1000",
2831 },
2832 ChainIdentity::V3 {
2833 caller_role: "observer",
2834 event_tip: "",
2835 caller_principal: &unattributed,
2836 },
2837 ],
2838 );
2839
2840 let with_key = verify_all(&key, &rows, &[]).attribution;
2841 let with_pubkey = verify_all_with_pubkey(&key.verifying_key_hex(), &rows, &[]).attribution;
2842 assert_eq!(
2843 with_key, with_pubkey,
2844 "the auditor must see the same attribution numbers as the keyholder"
2845 );
2846 let census = with_pubkey.expect("rows were read");
2847 assert_eq!(census.named(), 1);
2848 assert_eq!(census.attribution_failed(), 1);
2849 }
2850
2851 #[test]
2856 fn a_chain_that_names_nobody_still_exits_zero() {
2857 let key = fixed_key();
2858 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2859 let rows = chain_of(
2860 &key,
2861 vec![
2862 ChainIdentity::LegacyV1,
2863 ChainIdentity::V3 {
2864 caller_role: "observer",
2865 event_tip: "",
2866 caller_principal: &unattributed,
2867 },
2868 ],
2869 );
2870
2871 let verification = verify_all(&key, &rows, &[]);
2872 let census = verification.attribution.expect("rows were read");
2873 assert_eq!(census.unnamed(), 2, "not one row names an account");
2874 assert_eq!(
2875 verification.exit_code(),
2876 0,
2877 "yet nothing was tampered with, so the command must succeed"
2878 );
2879 }
2880
2881 #[test]
2886 fn a_future_encoding_reports_cannot_verify_with_the_supported_range() {
2887 let key = fixed_key();
2888 let content = sample_content(1, "tx1");
2889 let hash = key.chain_hash(&content, "");
2890 let mut row = row_for(&content, "", hash);
2891 row.chain_version = CHAIN_VERSION_CURRENT + 1;
2892
2893 match verify_chain(&key, &[row]) {
2894 VerifyOutcome::CannotVerify { reason } => {
2895 assert!(
2896 reason.contains(&format!("chain_version={}", CHAIN_VERSION_CURRENT + 1)),
2897 "name the version found, got: {reason}"
2898 );
2899 assert!(
2900 reason.contains(&CHAIN_VERSION_CURRENT.to_string()),
2901 "and the newest version understood, got: {reason}"
2902 );
2903 }
2904 other => panic!("a future encoding must not be reported as tampering: {other:?}"),
2905 }
2906 }
2907
2908 #[test]
2921 fn a_row_written_by_the_previous_release_still_verifies() {
2922 const GOLDEN_V2_CHAIN_HASH: &str = "ba12cbe49a149387898bc30f1e8d409effa025ab8e440e6e72bedf060afc831e45d9be264d6b18b7e0ef2f2b3584dbc6953b535546cecbbdc3d7ac666cae4d0a";
2923
2924 let key = fixed_key();
2925 let row = ChainRow {
2926 seq: 7,
2927 key_id: CURRENT_KEY_ID.to_string(),
2928 transaction_id: "tx-golden".to_string(),
2929 request_id: "req-golden".to_string(),
2930 request_hash: "hash-golden".to_string(),
2931 action_name: "AptInstall".to_string(),
2932 risk_level: RiskLevel::Medium,
2933 summary: "install ripgrep".to_string(),
2934 approval_id: Some("appr-golden".to_string()),
2935 warnings_json: "[]".to_string(),
2936 created_at: "2026-07-29T00:00:00Z".to_string(),
2937 prev_chain_hash: String::new(),
2938 chain_hash: GOLDEN_V2_CHAIN_HASH.to_string(),
2939 chain_version: 2,
2940 caller_role: Some("admin".to_string()),
2941 event_tip: Some("eventtip-golden".to_string()),
2942 caller_principal: None,
2943 };
2944
2945 assert_eq!(
2946 verify_chain(&key, std::slice::from_ref(&row)),
2947 VerifyOutcome::Intact { rows_checked: 1 },
2948 "a v2 row on disk must verify under this binary; if this fails, the v2 \
2949 encoding or its version dispatch changed and existing audit logs are unreadable"
2950 );
2951
2952 let mut tampered = row;
2956 let flipped = match GOLDEN_V2_CHAIN_HASH.strip_prefix('b') {
2957 Some(rest) => format!("a{rest}"),
2958 None => format!("b{}", &GOLDEN_V2_CHAIN_HASH[1..]),
2959 };
2960 tampered.chain_hash = flipped;
2961 assert!(
2962 matches!(
2963 verify_chain(&key, &[tampered]),
2964 VerifyOutcome::Broken { .. }
2965 ),
2966 "a golden row with one nibble changed must not verify"
2967 );
2968 }
2969
2970 #[test]
2974 fn a_legacy_v1_row_on_disk_still_verifies() {
2975 const GOLDEN_V1_CHAIN_HASH: &str = "ce8b47c15414e989e099303b826fcd9985360b794220cefbbf9689d25f31fe173283687feb6d1dd265ae5aca94c8bcfb87ce33ba8c954fafdad18a59cd97c702";
2976
2977 let row = ChainRow {
2978 chain_version: 1,
2979 caller_role: None,
2980 event_tip: None,
2981 caller_principal: None,
2982 chain_hash: GOLDEN_V1_CHAIN_HASH.to_string(),
2983 ..golden_row_shape()
2984 };
2985 assert_eq!(
2986 verify_chain(&fixed_key(), &[row]),
2987 VerifyOutcome::Intact { rows_checked: 1 }
2988 );
2989 }
2990
2991 #[test]
2995 fn a_v3_row_on_disk_still_verifies() {
2996 const GOLDEN_V3_CHAIN_HASH: &str = "3c0d05f6ccaf9e65ccb796d4ffe96eb31a2ea67e446217c5234af916dc959d0f2c0ee3d3e5b525aece791ee7d6507efb98dcdd79f0d74dde4273859aa9627d08";
2997
2998 let row = ChainRow {
2999 chain_version: 3,
3000 caller_role: Some("admin".to_string()),
3001 event_tip: Some("eventtip-golden".to_string()),
3002 caller_principal: Some("uid:1000".to_string()),
3003 chain_hash: GOLDEN_V3_CHAIN_HASH.to_string(),
3004 ..golden_row_shape()
3005 };
3006 assert_eq!(
3007 verify_chain(&fixed_key(), &[row]),
3008 VerifyOutcome::Intact { rows_checked: 1 }
3009 );
3010 }
3011
3012 #[test]
3019 fn the_escape_table_and_absent_approval_encoding_are_frozen() {
3020 const GOLDEN_V2_AWKWARD_CHAIN_HASH: &str = "e6face6fe2346fc0f6c5c1e5845255579c8f08738406b88b0a1f6dbdf8ff85090a4b80d95bd0927ef54b0a2c4810d52ebb4bbf76cb68c865a7748df26442aa05";
3021
3022 let key = fixed_key();
3023 let content = ChainContent {
3024 seq: 8,
3025 approval_id: None,
3026 summary: "back\\slash and \x1funit and \x1erecord and nul\0byte",
3027 identity: ChainIdentity::V2 {
3028 caller_role: "dev",
3029 event_tip: "",
3030 },
3031 ..golden_content_shape()
3032 };
3033 assert_eq!(
3034 key.chain_hash(&content, "ba12cbe4"),
3035 GOLDEN_V2_AWKWARD_CHAIN_HASH,
3036 "the canonical encoding of escapes, an absent approval id, or a \
3037 non-empty prev_chain_hash changed"
3038 );
3039 }
3040
3041 fn golden_content_shape() -> ChainContent<'static> {
3045 ChainContent {
3046 seq: 7,
3047 key_id: CURRENT_KEY_ID,
3048 transaction_id: "tx-golden",
3049 request_id: "req-golden",
3050 request_hash: "hash-golden",
3051 action_name: "AptInstall",
3052 risk_level: RiskLevel::Medium,
3053 summary: "install ripgrep",
3054 approval_id: Some("appr-golden"),
3055 warnings_json: "[]",
3056 created_at: "2026-07-29T00:00:00Z",
3057 identity: ChainIdentity::LegacyV1,
3058 }
3059 }
3060
3061 fn golden_row_shape() -> ChainRow {
3063 ChainRow {
3064 seq: 7,
3065 key_id: CURRENT_KEY_ID.to_string(),
3066 transaction_id: "tx-golden".to_string(),
3067 request_id: "req-golden".to_string(),
3068 request_hash: "hash-golden".to_string(),
3069 action_name: "AptInstall".to_string(),
3070 risk_level: RiskLevel::Medium,
3071 summary: "install ripgrep".to_string(),
3072 approval_id: Some("appr-golden".to_string()),
3073 warnings_json: "[]".to_string(),
3074 created_at: "2026-07-29T00:00:00Z".to_string(),
3075 prev_chain_hash: String::new(),
3076 chain_hash: String::new(),
3077 chain_version: 1,
3078 caller_role: None,
3079 event_tip: None,
3080 caller_principal: None,
3081 }
3082 }
3083
3084 #[test]
3085 fn a_chain_that_spans_the_migration_verifies_end_to_end() {
3086 let key = fixed_key();
3089 let mut rows = Vec::new();
3090 let mut prev = String::new();
3091 for (i, identity) in [
3092 ChainIdentity::LegacyV1,
3093 ChainIdentity::LegacyV1,
3094 ChainIdentity::V2 {
3095 caller_role: "Dev",
3096 event_tip: "",
3097 },
3098 ChainIdentity::V2 {
3099 caller_role: "Boot",
3100 event_tip: "",
3101 },
3102 ]
3103 .into_iter()
3104 .enumerate()
3105 {
3106 let txid = format!("tx{i}");
3107 let mut content = sample_content((i + 1) as u64, &txid);
3108 content.identity = identity;
3109 let hash = key.chain_hash(&content, &prev);
3110 rows.push(row_for(&content, &prev, hash.clone()));
3111 prev = hash;
3112 }
3113 assert_eq!(
3114 verify_chain(&key, &rows),
3115 VerifyOutcome::Intact { rows_checked: 4 }
3116 );
3117 }
3118
3119 #[test]
3120 fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
3121 let key = fixed_key();
3126 let mut rows = build_chain(&key, 2);
3127 rows[1].chain_version = CHAIN_VERSION_LEGACY;
3128 rows[1].caller_role = None;
3129 rows[1].event_tip = None;
3130 match verify_chain(&key, &rows) {
3131 VerifyOutcome::Broken {
3132 first_broken_seq, ..
3133 } => assert_eq!(first_broken_seq, 2),
3134 other => panic!("expected Broken, got {other:?}"),
3135 }
3136 }
3137
3138 #[test]
3139 fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
3140 let key = fixed_key();
3145 let mut rows = build_chain(&key, 1);
3146 rows[0].caller_role = None;
3147 let outcome = verify_chain(&key, &rows);
3148 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
3149 assert_eq!(outcome_to_exit_code(&outcome), 1);
3150 }
3151
3152 #[test]
3153 fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
3154 let key = fixed_key();
3157 let mut rows = build_chain(&key, 1);
3158 rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
3159 let outcome = verify_chain(&key, &rows);
3160 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3161 assert_eq!(outcome_to_exit_code(&outcome), 2);
3162 }
3163
3164 fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
3167 EventContent {
3168 seq,
3169 key_id: CURRENT_KEY_ID,
3170 kind,
3171 transaction_id: txid,
3172 receipt_digest: "digest-abc",
3173 created_at: "2026-04-24T12:00:00Z",
3174 identity: EventIdentity::LegacyV1,
3178 }
3179 }
3180
3181 fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
3182 let mut rows = Vec::with_capacity(kinds.len());
3183 let mut prev = String::new();
3184 for (i, kind) in kinds.iter().enumerate() {
3185 let seq = (i + 1) as u64;
3186 let txid = format!("tx{i}");
3187 let content = event_content(seq, *kind, &txid);
3188 let hash = key.event_hash(&content, &prev);
3189 rows.push(EventRow {
3190 seq,
3191 key_id: content.key_id.to_string(),
3192 kind: content.kind.as_str().to_string(),
3193 transaction_id: content.transaction_id.to_string(),
3194 receipt_digest: content.receipt_digest.to_string(),
3195 created_at: content.created_at.to_string(),
3196 prev_chain_hash: prev.clone(),
3197 chain_hash: hash.clone(),
3198 chain_version: content.identity.version(),
3199 caller_principal: match content.identity {
3200 EventIdentity::V2 { caller_principal } => Some(caller_principal.to_string()),
3201 EventIdentity::LegacyV1 => None,
3202 },
3203 });
3204 prev = hash;
3205 }
3206 rows
3207 }
3208
3209 #[allow(clippy::too_many_arguments)]
3214 fn event_row(
3215 key: &AuditKey,
3216 seq: u64,
3217 kind: AuditEventKind,
3218 txid: &str,
3219 receipt_digest: &str,
3220 created_at: &str,
3221 identity: EventIdentity<'_>,
3222 prev: &str,
3223 ) -> EventRow {
3224 let content = EventContent {
3225 seq,
3226 key_id: CURRENT_KEY_ID,
3227 kind,
3228 transaction_id: txid,
3229 receipt_digest,
3230 created_at,
3231 identity,
3232 };
3233 let hash = key.event_hash(&content, prev);
3234 EventRow {
3235 seq,
3236 key_id: CURRENT_KEY_ID.to_string(),
3237 kind: kind.as_str().to_string(),
3238 transaction_id: txid.to_string(),
3239 receipt_digest: receipt_digest.to_string(),
3240 created_at: created_at.to_string(),
3241 prev_chain_hash: prev.to_string(),
3242 chain_hash: hash,
3243 chain_version: identity.version(),
3244 caller_principal: match identity {
3245 EventIdentity::V2 { caller_principal } => Some(caller_principal.to_string()),
3246 EventIdentity::LegacyV1 => None,
3247 },
3248 }
3249 }
3250
3251 #[test]
3252 fn a_legacy_event_row_signed_by_the_previous_release_still_verifies() {
3253 let key = fixed_key();
3264 let row = EventRow {
3265 seq: 1,
3266 key_id: CURRENT_KEY_ID.to_string(),
3267 kind: AuditEventKind::ApprovalGranted.as_str().to_string(),
3268 transaction_id: "tx-fixture-legacy".to_string(),
3269 receipt_digest: "digest-fixture-legacy".to_string(),
3270 created_at: "2026-08-18T09:00:00.000Z".to_string(),
3271 prev_chain_hash: String::new(),
3272 chain_hash: "1b7c2b864c13c8c2da5436b4a3d0d10ec37c129e97a4525228110c574fccf32b\
3273 d1a22001acb555faa016f6c50f26d5c783b447fbf866f8ff689ebec2fc7c340a"
3274 .to_string(),
3275 chain_version: EVENT_VERSION_LEGACY,
3276 caller_principal: None,
3277 };
3278 assert_eq!(
3279 verify_event_chain(&key, &[row]),
3280 VerifyOutcome::Intact { rows_checked: 1 },
3281 "the committed legacy event signature must still verify"
3282 );
3283 }
3284
3285 #[test]
3286 fn a_v2_event_signs_and_verifies_with_the_acting_account() {
3287 let key = fixed_key();
3288 let row = event_row(
3289 &key,
3290 1,
3291 AuditEventKind::ApprovalGranted,
3292 "tx1",
3293 "digest1",
3294 "2026-08-18T09:00:00.000Z",
3295 EventIdentity::V2 {
3296 caller_principal: "uid:1000",
3297 },
3298 "",
3299 );
3300 assert_eq!(row.chain_version, EVENT_VERSION_V2);
3301 assert_eq!(row.caller_principal.as_deref(), Some("uid:1000"));
3302 assert_eq!(
3303 verify_event_chain(&key, &[row]),
3304 VerifyOutcome::Intact { rows_checked: 1 }
3305 );
3306 }
3307
3308 #[test]
3309 fn a_grant_and_a_consume_by_different_accounts_are_distinguishable() {
3310 let key = fixed_key();
3315 let grant = event_row(
3316 &key,
3317 1,
3318 AuditEventKind::ApprovalGranted,
3319 "tx1",
3320 "digest1",
3321 "2026-08-18T09:00:00.000Z",
3322 EventIdentity::V2 {
3323 caller_principal: "uid:1000",
3324 },
3325 "",
3326 );
3327 let consume = event_row(
3328 &key,
3329 2,
3330 AuditEventKind::ApprovalConsumed,
3331 "tx1",
3332 "digest1",
3333 "2026-08-18T09:05:00.000Z",
3334 EventIdentity::V2 {
3335 caller_principal: "uid:1001",
3336 },
3337 &grant.chain_hash,
3338 );
3339 assert_eq!(
3340 verify_event_chain(&key, &[grant.clone(), consume.clone()]),
3341 VerifyOutcome::Intact { rows_checked: 2 }
3342 );
3343 assert_eq!(grant.caller_principal.as_deref(), Some("uid:1000"));
3344 assert_eq!(consume.caller_principal.as_deref(), Some("uid:1001"));
3345 assert_ne!(
3346 grant.caller_principal, consume.caller_principal,
3347 "grant and consume by different uids must not collapse to one record"
3348 );
3349 }
3350
3351 #[test]
3352 fn editing_a_stored_event_principal_reports_broken() {
3353 let key = fixed_key();
3357 let mut row = event_row(
3358 &key,
3359 1,
3360 AuditEventKind::ApprovalGranted,
3361 "tx1",
3362 "digest1",
3363 "2026-08-18T09:00:00.000Z",
3364 EventIdentity::V2 {
3365 caller_principal: "uid:1000",
3366 },
3367 "",
3368 );
3369 assert_eq!(
3370 verify_event_chain(&key, &[row.clone()]),
3371 VerifyOutcome::Intact { rows_checked: 1 }
3372 );
3373 row.caller_principal = Some("uid:9999".to_string());
3374 assert!(
3375 matches!(
3376 verify_event_chain(&key, &[row]),
3377 VerifyOutcome::Broken { .. }
3378 ),
3379 "an edited principal must not still verify"
3380 );
3381 }
3382
3383 #[test]
3384 fn downgrading_a_v2_event_to_legacy_reports_broken() {
3385 let key = fixed_key();
3389 let mut row = event_row(
3390 &key,
3391 1,
3392 AuditEventKind::ApprovalGranted,
3393 "tx1",
3394 "digest1",
3395 "2026-08-18T09:00:00.000Z",
3396 EventIdentity::V2 {
3397 caller_principal: "uid:1000",
3398 },
3399 "",
3400 );
3401 row.chain_version = EVENT_VERSION_LEGACY;
3402 row.caller_principal = None;
3403 assert!(matches!(
3404 verify_event_chain(&key, &[row]),
3405 VerifyOutcome::Broken { .. }
3406 ));
3407 }
3408
3409 #[test]
3410 fn a_v2_event_naming_nobody_is_broken_not_accepted() {
3411 let key = fixed_key();
3415 let mut blank = event_row(
3416 &key,
3417 1,
3418 AuditEventKind::ApprovalGranted,
3419 "tx1",
3420 "digest1",
3421 "2026-08-18T09:00:00.000Z",
3422 EventIdentity::V2 {
3423 caller_principal: "uid:1000",
3424 },
3425 "",
3426 );
3427 blank.caller_principal = Some(String::new());
3428 assert!(
3429 matches!(
3430 verify_event_chain(&key, &[blank]),
3431 VerifyOutcome::Broken { .. }
3432 ),
3433 "a blank principal on a v2 row must be broken"
3434 );
3435
3436 let mut absent = event_row(
3437 &key,
3438 1,
3439 AuditEventKind::ApprovalGranted,
3440 "tx1",
3441 "digest1",
3442 "2026-08-18T09:00:00.000Z",
3443 EventIdentity::V2 {
3444 caller_principal: "uid:1000",
3445 },
3446 "",
3447 );
3448 absent.caller_principal = None;
3449 assert!(matches!(
3450 verify_event_chain(&key, &[absent]),
3451 VerifyOutcome::Broken { .. }
3452 ));
3453 }
3454
3455 #[test]
3456 fn an_unknown_event_version_cannot_verify_rather_than_breaking() {
3457 let key = fixed_key();
3461 let mut row = event_row(
3462 &key,
3463 1,
3464 AuditEventKind::ApprovalGranted,
3465 "tx1",
3466 "digest1",
3467 "2026-08-18T09:00:00.000Z",
3468 EventIdentity::V2 {
3469 caller_principal: "uid:1000",
3470 },
3471 "",
3472 );
3473 row.chain_version = EVENT_VERSION_CURRENT + 1;
3474 let outcome = verify_event_chain(&key, &[row]);
3475 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3476 assert_eq!(outcome_to_exit_code(&outcome), 2);
3477 }
3478
3479 #[test]
3480 fn a_mixed_legacy_and_v2_event_chain_verifies() {
3481 let key = fixed_key();
3486 let legacy = event_row(
3487 &key,
3488 1,
3489 AuditEventKind::ApprovalGranted,
3490 "tx-old",
3491 "digest-old",
3492 "2026-08-18T09:00:00.000Z",
3493 EventIdentity::LegacyV1,
3494 "",
3495 );
3496 let v2 = event_row(
3497 &key,
3498 2,
3499 AuditEventKind::ApprovalGranted,
3500 "tx-new",
3501 "digest-new",
3502 "2026-09-01T09:00:00.000Z",
3503 EventIdentity::V2 {
3504 caller_principal: "uid:1000",
3505 },
3506 &legacy.chain_hash,
3507 );
3508 assert_eq!(legacy.chain_version, EVENT_VERSION_LEGACY);
3509 assert_eq!(v2.chain_version, EVENT_VERSION_V2);
3510 assert_eq!(
3511 verify_event_chain(&key, &[legacy, v2]),
3512 VerifyOutcome::Intact { rows_checked: 2 }
3513 );
3514 }
3515
3516 #[test]
3517 fn event_kind_spellings_are_stable() {
3518 assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
3521 assert_eq!(
3522 AuditEventKind::ApprovalConsumed.as_str(),
3523 "approval_consumed"
3524 );
3525 assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
3526 }
3527
3528 #[test]
3529 fn intact_event_chain_verifies() {
3530 let key = fixed_key();
3531 let rows = build_event_chain(
3532 &key,
3533 &[
3534 AuditEventKind::ApprovalGranted,
3535 AuditEventKind::ApprovalConsumed,
3536 ],
3537 );
3538 assert_eq!(
3539 verify_event_chain(&key, &rows),
3540 VerifyOutcome::Intact { rows_checked: 2 }
3541 );
3542 }
3543
3544 #[test]
3545 fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
3546 let key = fixed_key();
3549 let mut rows = build_event_chain(
3550 &key,
3551 &[
3552 AuditEventKind::ApprovalGranted,
3553 AuditEventKind::ApprovalConsumed,
3554 AuditEventKind::ApprovalRevoked,
3555 ],
3556 );
3557 rows.remove(1);
3558 match verify_event_chain(&key, &rows) {
3559 VerifyOutcome::Broken {
3560 first_broken_seq, ..
3561 } => assert_eq!(first_broken_seq, 3),
3562 other => panic!("expected Broken, got {other:?}"),
3563 }
3564 }
3565
3566 #[test]
3567 fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
3568 let key = fixed_key();
3569 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
3570 rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
3571 assert!(matches!(
3572 verify_event_chain(&key, &rows),
3573 VerifyOutcome::Broken { .. }
3574 ));
3575 }
3576
3577 #[test]
3578 fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
3579 let key = fixed_key();
3580 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
3581 rows[0].kind = "approval_unicorned".to_string();
3582 assert!(matches!(
3583 verify_event_chain(&key, &rows),
3584 VerifyOutcome::Broken { .. }
3585 ));
3586 }
3587
3588 #[test]
3589 fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
3590 let key = fixed_key();
3594 let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
3595 let event_sig = key.event_hash(&content, "");
3596 let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
3597 assert_ne!(event_sig, row_sig);
3598
3599 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
3600 rows[0].chain_hash = row_sig;
3601 assert!(matches!(
3602 verify_event_chain(&key, &rows),
3603 VerifyOutcome::Broken { .. }
3604 ));
3605 }
3606
3607 #[test]
3610 fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
3611 let key = fixed_key();
3616 let events = build_event_chain(
3617 &key,
3618 &[
3619 AuditEventKind::ApprovalGranted,
3620 AuditEventKind::ApprovalConsumed,
3621 ],
3622 );
3623 let mut content = sample_content(1, "tx-after");
3624 content.identity = ChainIdentity::V2 {
3625 caller_role: "Dev",
3626 event_tip: &events[1].chain_hash,
3627 };
3628 let hash = key.chain_hash(&content, "");
3629 let tx_rows = vec![row_for(&content, "", hash)];
3630
3631 assert_eq!(
3632 verify_event_binding(&tx_rows, &events),
3633 BindingOutcome::Consistent {
3634 bindings_checked: 1
3635 }
3636 );
3637
3638 let truncated = &events[..1];
3639 assert_eq!(
3640 verify_event_chain(&key, truncated),
3641 VerifyOutcome::Intact { rows_checked: 1 },
3642 "a truncated event chain still walks clean; the binding is the detector"
3643 );
3644 match verify_event_binding(&tx_rows, truncated) {
3645 BindingOutcome::MissingEvent {
3646 transaction_seq, ..
3647 } => assert_eq!(transaction_seq, 1),
3648 other => panic!("expected MissingEvent, got {other:?}"),
3649 }
3650 }
3651
3652 #[test]
3653 fn legacy_rows_carry_no_binding_to_check() {
3654 let key = fixed_key();
3657 let mut content = sample_content(1, "tx-legacy");
3658 content.identity = ChainIdentity::LegacyV1;
3659 let hash = key.chain_hash(&content, "");
3660 let rows = vec![row_for(&content, "", hash)];
3661 assert_eq!(
3662 verify_event_binding(&rows, &[]),
3663 BindingOutcome::Consistent {
3664 bindings_checked: 0
3665 }
3666 );
3667 }
3668
3669 #[test]
3670 fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
3671 let verification = AuditVerification {
3676 chain: VerifyOutcome::Broken {
3677 rows_checked: 1,
3678 first_broken_seq: 2,
3679 first_broken_transaction_id: "tx".to_string(),
3680 expected: "e".to_string(),
3681 actual: "a".to_string(),
3682 },
3683 events: VerifyOutcome::CannotVerify {
3684 reason: "no key".to_string(),
3685 },
3686 binding: BindingOutcome::Consistent {
3687 bindings_checked: 0,
3688 },
3689 attribution: None,
3690 status: None,
3691 };
3692 assert_eq!(verification.exit_code(), 1);
3693 }
3694
3695 #[test]
3696 fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
3697 let verification = AuditVerification {
3698 chain: VerifyOutcome::Intact { rows_checked: 3 },
3699 events: VerifyOutcome::Intact { rows_checked: 1 },
3700 binding: BindingOutcome::MissingEvent {
3701 transaction_seq: 3,
3702 event_tip: "abc".to_string(),
3703 },
3704 attribution: None,
3705 status: None,
3706 };
3707 assert_eq!(verification.exit_code(), 1);
3708 }
3709
3710 #[test]
3711 fn all_three_clean_is_exit_zero() {
3712 let verification = AuditVerification {
3713 chain: VerifyOutcome::Intact { rows_checked: 3 },
3714 events: VerifyOutcome::Intact { rows_checked: 2 },
3715 binding: BindingOutcome::Consistent {
3716 bindings_checked: 1,
3717 },
3718 attribution: None,
3719 status: None,
3720 };
3721 assert_eq!(verification.exit_code(), 0);
3722 }
3723
3724 #[test]
3725 fn binding_exit_codes_split_clean_tampered_and_unchecked() {
3726 assert_eq!(
3727 binding_outcome_to_exit_code(&BindingOutcome::Consistent {
3728 bindings_checked: 0
3729 }),
3730 0
3731 );
3732 assert_eq!(
3733 binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
3734 transaction_seq: 7,
3735 event_tip: "abc".to_string()
3736 }),
3737 1
3738 );
3739 assert_eq!(binding_outcome_to_exit_code(&BindingOutcome::NotChecked), 2);
3740 }
3741
3742 #[test]
3743 fn intact_chain_verifies() {
3744 let key = fixed_key();
3745 let rows = build_chain(&key, 5);
3746 let outcome = verify_chain(&key, &rows);
3747 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
3748 assert_eq!(outcome_to_exit_code(&outcome), 0);
3749 }
3750
3751 #[test]
3752 fn empty_chain_verifies() {
3753 let key = fixed_key();
3754 let outcome = verify_chain(&key, &[]);
3755 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
3756 }
3757
3758 #[test]
3759 fn tampered_summary_breaks_chain_at_first_offending_row() {
3760 let key = fixed_key();
3761 let mut rows = build_chain(&key, 3);
3762 rows[1].summary = "TAMPERED".to_string();
3764 let outcome = verify_chain(&key, &rows);
3765 match outcome {
3766 VerifyOutcome::Broken {
3767 first_broken_seq, ..
3768 } => assert_eq!(first_broken_seq, 2),
3769 other => panic!("expected Broken, got {other:?}"),
3770 }
3771 }
3772
3773 #[test]
3774 fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
3775 let key = fixed_key();
3776 let mut rows = build_chain(&key, 4);
3777 rows.remove(1);
3779 let outcome = verify_chain(&key, &rows);
3780 match outcome {
3781 VerifyOutcome::Broken {
3782 first_broken_seq, ..
3783 } => assert_eq!(
3784 first_broken_seq, 3,
3785 "first broken row is the one whose prev_hash refers to deleted row"
3786 ),
3787 other => panic!("expected Broken, got {other:?}"),
3788 }
3789 }
3790
3791 #[test]
3792 fn inserted_forged_row_breaks_chain() {
3793 let key = fixed_key();
3800 let mut rows = build_chain(&key, 3);
3801
3802 let forged = ChainRow {
3804 caller_principal: Some("uid:1000".to_string()),
3808 seq: 2,
3809 key_id: CURRENT_KEY_ID.to_string(),
3810 transaction_id: "tx-forged".to_string(),
3811 request_id: "req-forged".to_string(),
3812 request_hash: "hash-forged".to_string(),
3813 action_name: "InstallFlatpak".to_string(),
3814 risk_level: RiskLevel::Medium,
3815 summary: "Forged row".to_string(),
3816 approval_id: None,
3817 warnings_json: "[]".to_string(),
3818 created_at: "2026-04-25T13:00:00Z".to_string(),
3819 prev_chain_hash: rows[0].chain_hash.clone(),
3821 chain_hash: "0".repeat(HASH_HEX_LEN),
3823 chain_version: CHAIN_VERSION_CURRENT,
3824 caller_role: Some("Dev".to_string()),
3825 event_tip: Some(String::new()),
3826 };
3827
3828 let mut rest: Vec<ChainRow> = rows.split_off(1);
3830 for r in rest.iter_mut() {
3831 r.seq += 1;
3832 }
3833 rows.push(forged);
3834 rows.extend(rest);
3835
3836 let outcome = verify_chain(&key, &rows);
3840 match outcome {
3841 VerifyOutcome::Broken {
3842 first_broken_seq, ..
3843 } => assert_eq!(
3844 first_broken_seq, 2,
3845 "the verifier must flag the forged row itself, not a later one"
3846 ),
3847 other => panic!("expected Broken, got {other:?}"),
3848 }
3849 }
3850
3851 #[test]
3854 fn signature_verifies_under_exported_public_key() {
3855 let key = fixed_key();
3857 let rows = build_chain(&key, 4);
3858 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3859 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
3860 }
3861
3862 #[test]
3863 fn foreign_public_key_cannot_validate_chain() {
3864 let signer = AuditKey::from_bytes(vec![0x11; 32]);
3868 let rows = build_chain(&signer, 3);
3869 let other = AuditKey::from_bytes(vec![0x22; 32]);
3870 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
3871 assert!(matches!(
3872 outcome,
3873 VerifyOutcome::Broken {
3874 first_broken_seq: 1,
3875 ..
3876 }
3877 ));
3878 }
3879
3880 #[test]
3881 fn verifying_key_hex_is_a_32_byte_public_key() {
3882 let key = fixed_key();
3883 let vk_hex = key.verifying_key_hex();
3884 assert_eq!(
3885 vk_hex.len(),
3886 64,
3887 "32-byte ed25519 public key = 64 hex chars"
3888 );
3889 assert!(hex::decode(&vk_hex).is_ok());
3890 }
3891
3892 #[test]
3893 fn malformed_signature_hex_is_broken_not_panic() {
3894 let key = fixed_key();
3895 let mut rows = build_chain(&key, 2);
3896 rows[0].chain_hash = "not-valid-hex!!".to_string();
3897 let outcome = verify_chain(&key, &rows);
3898 assert!(matches!(
3899 outcome,
3900 VerifyOutcome::Broken {
3901 first_broken_seq: 1,
3902 ..
3903 }
3904 ));
3905 }
3906
3907 #[test]
3908 fn bad_public_key_hex_yields_cannot_verify() {
3909 let key = fixed_key();
3910 let rows = build_chain(&key, 1);
3911 let outcome = verify_chain_with_pubkey("zz", &rows);
3912 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3913 }
3914
3915 #[test]
3916 fn load_or_generate_writes_public_key_sidecar() {
3917 let dir = tempfile::tempdir().unwrap();
3918 let path = dir.path().join("audit-key");
3919 let key = AuditKey::load_or_generate(&path).unwrap();
3920 let pub_path = dir.path().join("audit-key.pub");
3921 assert!(pub_path.exists(), "public key sidecar must be written");
3922 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
3923 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
3924 }
3925
3926 #[test]
3927 fn wrong_key_id_yields_cannot_verify() {
3928 let key = fixed_key();
3929 let mut rows = build_chain(&key, 2);
3930 rows[0].key_id = "v99".to_string();
3931 let outcome = verify_chain(&key, &rows);
3932 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3933 assert_eq!(outcome_to_exit_code(&outcome), 2);
3934 }
3935
3936 #[test]
3937 fn exit_code_for_each_outcome() {
3938 assert_eq!(
3939 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
3940 0
3941 );
3942 assert_eq!(
3943 outcome_to_exit_code(&VerifyOutcome::Broken {
3944 rows_checked: 0,
3945 first_broken_seq: 1,
3946 first_broken_transaction_id: String::new(),
3947 expected: String::new(),
3948 actual: String::new(),
3949 }),
3950 1
3951 );
3952 assert_eq!(
3953 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
3954 reason: String::new()
3955 }),
3956 2
3957 );
3958 }
3959
3960 #[test]
3963 fn load_or_generate_creates_with_0600_mode() {
3964 use std::os::unix::fs::PermissionsExt;
3965 let dir = tempfile::tempdir().unwrap();
3966 let path = dir.path().join("audit-key");
3967 let _key = AuditKey::load_or_generate(&path).unwrap();
3968 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
3969 assert_eq!(mode, 0o600);
3970 }
3971
3972 #[test]
3973 fn load_or_generate_is_idempotent() {
3974 let dir = tempfile::tempdir().unwrap();
3975 let path = dir.path().join("audit-key");
3976 let key1_bytes = {
3977 let _ = AuditKey::load_or_generate(&path).unwrap();
3978 std::fs::read(&path).unwrap()
3979 };
3980 let _ = AuditKey::load_or_generate(&path).unwrap();
3981 let key2_bytes = std::fs::read(&path).unwrap();
3982 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
3983 }
3984
3985 #[test]
3986 fn rejects_world_readable_key_file() {
3987 use std::os::unix::fs::PermissionsExt;
3988 let dir = tempfile::tempdir().unwrap();
3989 let path = dir.path().join("audit-key");
3990 std::fs::write(&path, vec![0u8; 32]).unwrap();
3991 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3992 let result = AuditKey::load_or_generate(&path);
3993 assert!(matches!(
3994 result,
3995 Err(AuditKeyError::UnsafePermissions { .. })
3996 ));
3997 }
3998
3999 #[test]
4000 fn rejects_short_key_file() {
4001 use std::os::unix::fs::PermissionsExt;
4002 let dir = tempfile::tempdir().unwrap();
4003 let path = dir.path().join("audit-key");
4004 std::fs::write(&path, vec![0u8; 8]).unwrap();
4005 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
4006 let result = AuditKey::load_or_generate(&path);
4007 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
4008 }
4009
4010 #[test]
4016 fn debug_redacts_key_bytes_and_their_hex_encoding() {
4017 let raw = (0u8..32).collect::<Vec<u8>>();
4019 let key = AuditKey::from_bytes(raw.clone());
4020 let dbg = format!("{key:?}");
4021
4022 assert!(
4023 dbg.contains("redacted"),
4024 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
4025 );
4026
4027 let hex_lower = hex::encode(&raw);
4029 let hex_upper = hex::encode_upper(&raw);
4030 assert!(
4031 !dbg.contains(&hex_lower),
4032 "Debug output leaks lowercase hex of key bytes: {dbg}"
4033 );
4034 assert!(
4035 !dbg.contains(&hex_upper),
4036 "Debug output leaks uppercase hex of key bytes: {dbg}"
4037 );
4038
4039 for b in &raw {
4043 let pair = format!("{b:02x}");
4044 if *b >= 0x10 && pair != "1e" && pair != "1f" {
4049 assert!(
4050 !dbg.contains(&pair),
4051 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
4052 );
4053 }
4054 }
4055 }
4056
4057 #[test]
4060 fn debug_preserves_key_id() {
4061 let key = AuditKey::from_bytes(vec![0xff; 32]);
4062 let dbg = format!("{key:?}");
4063 assert!(
4064 dbg.contains("key_id"),
4065 "Debug output must label the key_id field: {dbg}"
4066 );
4067 assert!(
4068 dbg.contains(CURRENT_KEY_ID),
4069 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
4070 );
4071 }
4072
4073 #[test]
4076 fn checkpoint_consistent_with_intact_chain() {
4077 let key = fixed_key();
4078 let rows = build_chain(&key, 5);
4079 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4080 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
4081 assert_eq!(
4082 outcome,
4083 CheckpointOutcome::Consistent {
4084 checkpoints_checked: 1
4085 }
4086 );
4087 }
4088
4089 #[test]
4090 fn checkpoint_detects_truncation() {
4091 let key = fixed_key();
4092 let full = build_chain(&key, 5);
4093 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
4095 let truncated = &full[..3];
4096 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
4097 assert!(matches!(
4098 outcome,
4099 CheckpointOutcome::Truncated {
4100 checkpoint_seq: 5,
4101 current_max_seq: 3
4102 }
4103 ));
4104 }
4105
4106 #[test]
4107 fn checkpoint_detects_rewrite() {
4108 let key = fixed_key();
4109 let mut rows = build_chain(&key, 4);
4110 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4111 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
4113 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
4114 assert!(matches!(
4115 outcome,
4116 CheckpointOutcome::TipMismatch { seq: 3, .. }
4117 ));
4118 }
4119
4120 #[test]
4121 fn checkpoint_bad_signature_detected() {
4122 let key = fixed_key();
4123 let rows = build_chain(&key, 3);
4124 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
4125 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
4127 assert!(matches!(
4128 outcome,
4129 CheckpointOutcome::BadSignature { seq: 2 }
4130 ));
4131 }
4132
4133 #[test]
4134 fn checkpoint_foreign_key_rejected() {
4135 let signer = AuditKey::from_bytes(vec![0x11; 32]);
4137 let rows = build_chain(&signer, 3);
4138 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
4139 let other = AuditKey::from_bytes(vec![0x22; 32]);
4140 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
4141 assert!(matches!(
4142 outcome,
4143 CheckpointOutcome::BadSignature { seq: 2 }
4144 ));
4145 }
4146
4147 #[test]
4148 fn pubkey_verify_detects_tampered_middle_row() {
4149 let key = fixed_key();
4152 let mut rows = build_chain(&key, 4);
4153 rows[2].summary = "TAMPERED".to_string();
4154 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
4155 assert!(matches!(
4156 outcome,
4157 VerifyOutcome::Broken {
4158 first_broken_seq: 3,
4159 ..
4160 }
4161 ));
4162 }
4163
4164 #[test]
4165 fn multiple_checkpoints_all_consistent() {
4166 let key = fixed_key();
4167 let rows = build_chain(&key, 5);
4168 let cps = vec![
4169 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
4170 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
4171 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
4172 ];
4173 assert_eq!(
4174 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
4175 CheckpointOutcome::Consistent {
4176 checkpoints_checked: 3
4177 }
4178 );
4179 }
4180
4181 #[test]
4182 fn middle_checkpoint_failure_is_reported() {
4183 let key = fixed_key();
4185 let mut rows = build_chain(&key, 5);
4186 let cps = vec![
4187 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
4188 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
4189 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
4190 ];
4191 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
4193 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
4194 CheckpointOutcome::TipMismatch { seq: 3, .. }
4195 ));
4196 }
4197
4198 #[test]
4199 fn checkpoint_created_at_is_signed() {
4200 let key = fixed_key();
4202 let rows = build_chain(&key, 3);
4203 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4204 cp.created_at = "2020-01-01T00:00:00Z".to_string();
4205 assert!(matches!(
4206 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
4207 CheckpointOutcome::BadSignature { .. }
4208 ));
4209 }
4210
4211 #[test]
4212 fn checkpoint_seq_is_signed() {
4213 let key = fixed_key();
4215 let rows = build_chain(&key, 3);
4216 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
4217 cp.seq = 2;
4218 assert!(matches!(
4219 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
4220 CheckpointOutcome::BadSignature { seq: 2 }
4221 ));
4222 }
4223
4224 #[test]
4225 fn checkpoint_detects_full_wipe() {
4226 let key = fixed_key();
4228 let full = build_chain(&key, 3);
4229 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
4230 assert!(matches!(
4231 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
4232 CheckpointOutcome::Truncated {
4233 checkpoint_seq: 3,
4234 current_max_seq: 0
4235 }
4236 ));
4237 }
4238
4239 #[test]
4240 fn domain_tags_are_distinct_and_prefix_free() {
4241 let tags: [(&str, &[u8]); 4] = [
4248 ("row", ROW_DOMAIN),
4249 ("checkpoint", CHECKPOINT_DOMAIN),
4250 ("approval", APPROVAL_DOMAIN),
4251 ("event", EVENT_DOMAIN),
4252 ];
4253 for (i, (name_a, a)) in tags.iter().enumerate() {
4254 for (name_b, b) in tags.iter().skip(i + 1) {
4255 assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
4256 assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
4257 assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
4258 }
4259 }
4260 }
4261
4262 #[test]
4263 fn approval_receipt_framing_is_injective() {
4264 let key = fixed_key();
4269 let sep = "\u{1f}";
4270 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
4271 let b = key.approval_receipt("tx", &format!("b{sep}c"));
4272 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
4273
4274 assert_eq!(
4276 key.approval_receipt("tx-1", "hash-1"),
4277 key.approval_receipt("tx-1", "hash-1"),
4278 );
4279 assert_eq!(
4281 key.approval_commitment("tx-1", "hash-1"),
4282 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
4283 );
4284 }
4285}