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
142#[derive(Clone)]
154pub struct AuditKey {
155 key_id: String,
156 signing: SigningKey,
157}
158
159impl std::fmt::Debug for AuditKey {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 f.debug_struct("AuditKey")
166 .field("key_id", &self.key_id)
167 .field("signing", &format_args!("<redacted signing key>"))
168 .finish()
169 }
170}
171
172#[derive(Debug, thiserror::Error)]
177pub enum AuditKeyError {
178 #[error("io error reading audit key {path}: {source}")]
179 Io {
180 path: PathBuf,
181 #[source]
182 source: std::io::Error,
183 },
184 #[error(
185 "audit key file {path} has unsafe permissions {mode:#o}; \
186 tighten with `chmod 600 {path:?}` and restart"
187 )]
188 UnsafePermissions { path: PathBuf, mode: u32 },
189 #[error(
190 "audit key file {path} is too short ({len} bytes); \
191 expected at least 32 bytes of random material"
192 )]
193 KeyTooShort { path: PathBuf, len: usize },
194}
195
196impl AuditKey {
197 pub fn load_or_generate(path: &Path) -> Result<Self, AuditKeyError> {
204 if !path.exists() {
205 generate_key_at(path)?;
206 }
207
208 let metadata = std::fs::metadata(path).map_err(|e| AuditKeyError::Io {
209 path: path.to_path_buf(),
210 source: e,
211 })?;
212
213 use std::os::unix::fs::PermissionsExt;
214 let mode = metadata.permissions().mode() & 0o777;
215 if mode & 0o077 != 0 {
216 return Err(AuditKeyError::UnsafePermissions {
218 path: path.to_path_buf(),
219 mode,
220 });
221 }
222
223 let mut key_bytes = std::fs::read(path).map_err(|e| AuditKeyError::Io {
224 path: path.to_path_buf(),
225 source: e,
226 })?;
227 if key_bytes.len() < 32 {
228 return Err(AuditKeyError::KeyTooShort {
229 path: path.to_path_buf(),
230 len: key_bytes.len(),
231 });
232 }
233
234 let mut seed = [0u8; 32];
235 seed.copy_from_slice(&key_bytes[..32]);
236 let signing = SigningKey::from_bytes(&seed);
237 seed.zeroize();
238 key_bytes.zeroize();
239
240 Ok(Self {
241 key_id: CURRENT_KEY_ID.to_string(),
242 signing,
243 })
244 }
245
246 #[cfg(test)]
249 pub fn from_bytes(bytes: Vec<u8>) -> Self {
250 let mut seed = [0u8; 32];
251 seed.copy_from_slice(&bytes[..32]);
252 let signing = SigningKey::from_bytes(&seed);
253 seed.zeroize();
254 Self {
255 key_id: CURRENT_KEY_ID.to_string(),
256 signing,
257 }
258 }
259
260 pub fn key_id(&self) -> &str {
262 &self.key_id
263 }
264
265 pub fn chain_hash(&self, content: &ChainContent, prev_chain_hash: &str) -> String {
270 let sig = self.signing.sign(&chain_message(content, prev_chain_hash));
271 hex::encode(sig.to_bytes())
272 }
273
274 pub fn verifying_key(&self) -> VerifyingKey {
276 self.signing.verifying_key()
277 }
278
279 pub fn verifying_key_hex(&self) -> String {
282 hex::encode(self.signing.verifying_key().to_bytes())
283 }
284
285 pub fn approval_receipt(&self, transaction_id: &str, request_hash: &str) -> String {
289 let mut message = APPROVAL_DOMAIN.to_vec();
295 push_field(&mut message, "txid", transaction_id);
296 push_field(&mut message, "reqhash", request_hash);
297 hex::encode(self.signing.sign(&message).to_bytes())
298 }
299
300 pub fn approval_commitment(&self, transaction_id: &str, request_hash: &str) -> String {
302 approval_receipt_digest(&self.approval_receipt(transaction_id, request_hash))
303 }
304}
305
306pub fn approval_receipt_digest(receipt: &str) -> String {
307 hex::encode(Sha256::digest(receipt.as_bytes()))
308}
309
310fn chain_message(content: &ChainContent, prev_chain_hash: &str) -> Vec<u8> {
316 let mut msg = ROW_DOMAIN.to_vec();
317 msg.extend_from_slice(&content.canonical_bytes());
318 msg.extend_from_slice(prev_chain_hash.as_bytes());
319 msg
320}
321
322pub fn resolve_audit_key_path(db_path: &Path) -> PathBuf {
328 std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
329 .map(PathBuf::from)
330 .unwrap_or_else(|_| {
331 db_path
332 .parent()
333 .unwrap_or_else(|| Path::new("."))
334 .join("audit-key")
335 })
336}
337
338fn generate_key_at(path: &Path) -> Result<(), AuditKeyError> {
341 use std::io::Write as _;
342 use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
343
344 if let Some(parent) = path.parent() {
345 if !parent.exists() {
346 std::fs::DirBuilder::new()
347 .recursive(true)
348 .mode(0o700)
349 .create(parent)
350 .map_err(|e| AuditKeyError::Io {
351 path: parent.to_path_buf(),
352 source: e,
353 })?;
354 }
355 }
356
357 let mut bytes = [0u8; 32];
358 fill_random(&mut bytes).map_err(|e| AuditKeyError::Io {
359 path: path.to_path_buf(),
360 source: e,
361 })?;
362
363 let mut f = std::fs::OpenOptions::new()
364 .write(true)
365 .create_new(true)
366 .mode(0o600)
367 .open(path)
368 .map_err(|e| AuditKeyError::Io {
369 path: path.to_path_buf(),
370 source: e,
371 })?;
372 f.write_all(&bytes).map_err(|e| AuditKeyError::Io {
373 path: path.to_path_buf(),
374 source: e,
375 })?;
376 f.sync_all().map_err(|e| AuditKeyError::Io {
377 path: path.to_path_buf(),
378 source: e,
379 })?;
380
381 let signing = SigningKey::from_bytes(&bytes);
384 let pub_hex = hex::encode(signing.verifying_key().to_bytes());
385 bytes.zeroize();
386 let mut pub_path = path.as_os_str().to_os_string();
387 pub_path.push(".pub");
388 std::fs::write(&pub_path, format!("{pub_hex}\n")).map_err(|e| AuditKeyError::Io {
389 path: PathBuf::from(pub_path),
390 source: e,
391 })?;
392 Ok(())
393}
394
395fn fill_random(buf: &mut [u8]) -> std::io::Result<()> {
397 use std::io::Read as _;
398 let mut f = std::fs::File::open("/dev/urandom")?;
399 f.read_exact(buf)
400}
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub enum ChainIdentity<'a> {
466 LegacyV1,
468 V2 {
471 caller_role: &'a str,
476 event_tip: &'a str,
482 },
483 V3 {
490 caller_role: &'a str,
492 event_tip: &'a str,
494 caller_principal: &'a str,
505 },
506}
507
508impl ChainIdentity<'_> {
509 pub fn version(&self) -> u32 {
511 match self {
512 Self::LegacyV1 => CHAIN_VERSION_LEGACY,
513 Self::V2 { .. } => CHAIN_VERSION_V2,
514 Self::V3 { .. } => CHAIN_VERSION_V3,
515 }
516 }
517}
518
519#[derive(Debug, Clone, PartialEq, Eq)]
520pub struct ChainContent<'a> {
521 pub seq: u64,
522 pub key_id: &'a str,
523 pub transaction_id: &'a str,
524 pub request_id: &'a str,
525 pub request_hash: &'a str,
526 pub action_name: &'a str,
527 pub risk_level: RiskLevel,
528 pub summary: &'a str,
537 pub approval_id: Option<&'a str>,
540 pub warnings_json: &'a str,
542 pub created_at: &'a str,
543 pub identity: ChainIdentity<'a>,
545}
546
547impl<'a> ChainContent<'a> {
548 pub fn canonical_bytes(&self) -> Vec<u8> {
552 let risk_level_str = match self.risk_level {
553 RiskLevel::Low => "low",
554 RiskLevel::Medium => "medium",
555 RiskLevel::High => "high",
556 };
557 let approval = self.approval_id.unwrap_or("");
558
559 let mut buf = Vec::with_capacity(512);
560 push_field(&mut buf, "seq", &self.seq.to_string());
563 push_field(&mut buf, "key_id", self.key_id);
564 push_field(&mut buf, "transaction_id", self.transaction_id);
565 push_field(&mut buf, "request_id", self.request_id);
566 push_field(&mut buf, "request_hash", self.request_hash);
567 push_field(&mut buf, "action_name", self.action_name);
568 push_field(&mut buf, "risk_level", risk_level_str);
569 push_field(&mut buf, "summary", self.summary);
570 push_field(&mut buf, "approval_id", approval);
571 push_field(&mut buf, "warnings_json", self.warnings_json);
572 push_field(&mut buf, "created_at", self.created_at);
573 match self.identity {
589 ChainIdentity::LegacyV1 => {}
590 ChainIdentity::V2 {
591 caller_role,
592 event_tip,
593 } => {
594 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V2.to_string());
595 push_field(&mut buf, "caller_role", caller_role);
596 push_field(&mut buf, "event_tip", event_tip);
597 }
598 ChainIdentity::V3 {
599 caller_role,
600 event_tip,
601 caller_principal,
602 } => {
603 push_field(&mut buf, "chain_version", &CHAIN_VERSION_V3.to_string());
604 push_field(&mut buf, "caller_role", caller_role);
605 push_field(&mut buf, "event_tip", event_tip);
606 push_field(&mut buf, "caller_principal", caller_principal);
607 }
608 }
609 buf
610 }
611}
612
613fn push_field(buf: &mut Vec<u8>, tag: &str, value: &str) {
631 buf.extend_from_slice(tag.as_bytes());
632 buf.push(0x1E); for b in value.bytes() {
634 match b {
635 b'\\' => buf.extend_from_slice(b"\\\\"),
636 0x00 => buf.extend_from_slice(b"\\0"),
637 0x1E => buf.extend_from_slice(b"\\1E"),
638 0x1F => buf.extend_from_slice(b"\\1F"),
639 other => buf.push(other),
640 }
641 }
642 buf.push(0x1F); }
644
645#[derive(Debug, Clone, PartialEq, Eq)]
647pub enum VerifyOutcome {
648 Intact { rows_checked: u64 },
650 Broken {
652 rows_checked: u64,
653 first_broken_seq: u64,
654 first_broken_transaction_id: String,
655 expected: String,
656 actual: String,
657 },
658 CannotVerify { reason: String },
662}
663
664pub fn outcome_to_exit_code(outcome: &VerifyOutcome) -> i32 {
670 match outcome {
671 VerifyOutcome::Intact { .. } => 0,
672 VerifyOutcome::Broken { .. } => 1,
673 VerifyOutcome::CannotVerify { .. } => 2,
674 }
675}
676
677#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
679pub struct ChainRow {
680 pub seq: u64,
681 pub key_id: String,
682 pub transaction_id: String,
683 pub request_id: String,
684 pub request_hash: String,
685 pub action_name: String,
686 pub risk_level: RiskLevel,
687 pub summary: String,
688 pub approval_id: Option<String>,
689 pub warnings_json: String,
690 pub created_at: String,
691 pub prev_chain_hash: String,
692 pub chain_hash: String,
693 pub chain_version: u32,
695 pub caller_role: Option<String>,
697 pub event_tip: Option<String>,
700 pub caller_principal: Option<String>,
704}
705
706#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
708pub enum ChainExportError {
709 #[error("--since: {0:?} is not a valid RFC 3339 timestamp")]
710 InvalidSince(String),
711 #[error("audit row {seq} has an invalid RFC 3339 created_at value {created_at:?}")]
712 InvalidCreatedAt { seq: u64, created_at: String },
713}
714
715pub fn validate_export_since(since: &str) -> Result<(), ChainExportError> {
717 chrono::DateTime::parse_from_rfc3339(since)
718 .map(|_| ())
719 .map_err(|_| ChainExportError::InvalidSince(since.to_string()))
720}
721
722pub fn select_chain_rows_for_export(
729 rows: Vec<ChainRow>,
730 since: Option<&str>,
731 limit: Option<u32>,
732) -> Result<Vec<ChainRow>, ChainExportError> {
733 let since = since
734 .map(|value| {
735 chrono::DateTime::parse_from_rfc3339(value)
736 .map_err(|_| ChainExportError::InvalidSince(value.to_string()))
737 })
738 .transpose()?;
739 let limit = limit.map_or(usize::MAX, |value| value as usize);
740
741 rows.into_iter()
742 .filter_map(|row| {
743 let keep = match since.as_ref() {
744 None => true,
745 Some(since) => match chrono::DateTime::parse_from_rfc3339(&row.created_at) {
746 Ok(created_at) => created_at >= *since,
747 Err(_) => {
748 return Some(Err(ChainExportError::InvalidCreatedAt {
749 seq: row.seq,
750 created_at: row.created_at,
751 }));
752 }
753 },
754 };
755 keep.then_some(Ok(row))
756 })
757 .take(limit)
758 .collect()
759}
760
761#[derive(Debug)]
763pub(crate) enum RowIdentityError {
764 UnknownVersion(u32),
768 MissingField(&'static str),
774}
775
776impl ChainRow {
777 pub(crate) fn identity(&self) -> Result<ChainIdentity<'_>, RowIdentityError> {
780 match self.chain_version {
781 CHAIN_VERSION_LEGACY => Ok(ChainIdentity::LegacyV1),
782 CHAIN_VERSION_V2 => Ok(ChainIdentity::V2 {
783 caller_role: self
784 .caller_role
785 .as_deref()
786 .ok_or(RowIdentityError::MissingField("caller_role"))?,
787 event_tip: self
788 .event_tip
789 .as_deref()
790 .ok_or(RowIdentityError::MissingField("event_tip"))?,
791 }),
792 CHAIN_VERSION_V3 => Ok(ChainIdentity::V3 {
793 caller_role: self
794 .caller_role
795 .as_deref()
796 .ok_or(RowIdentityError::MissingField("caller_role"))?,
797 event_tip: self
798 .event_tip
799 .as_deref()
800 .ok_or(RowIdentityError::MissingField("event_tip"))?,
801 caller_principal: self
805 .caller_principal
806 .as_deref()
807 .filter(|p| !p.is_empty())
808 .ok_or(RowIdentityError::MissingField("caller_principal"))?,
809 }),
810 other => Err(RowIdentityError::UnknownVersion(other)),
811 }
812 }
813}
814
815pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
819 verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
820}
821
822pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
827 match parse_verifying_key(verifying_key_hex) {
828 Some(vk) => verify_rows(&vk, None, rows),
829 None => VerifyOutcome::CannotVerify {
830 reason: format!(
831 "invalid public key hex ({} chars); expected 64 hex chars of a \
832 32-byte Ed25519 public key",
833 verifying_key_hex.len()
834 ),
835 },
836 }
837}
838
839fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
840 let bytes = hex::decode(hex_str.trim()).ok()?;
841 let arr: [u8; 32] = bytes.try_into().ok()?;
842 VerifyingKey::from_bytes(&arr).ok()
843}
844
845fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
848 let Ok(bytes) = hex::decode(sig_hex) else {
849 return false;
850 };
851 let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
852 return false;
853 };
854 vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
855}
856
857fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
861 let mut last_hash = String::new();
862 let mut rows_checked = 0u64;
863 for row in rows {
864 if let Some(kid) = expect_key_id {
865 if row.key_id != kid {
866 return VerifyOutcome::CannotVerify {
867 reason: format!(
868 "row seq={} uses key_id={:?} but only {:?} is loaded; \
869 epoch keys not yet supported",
870 row.seq, row.key_id, kid
871 ),
872 };
873 }
874 }
875 if row.prev_chain_hash != last_hash {
876 return VerifyOutcome::Broken {
877 rows_checked,
878 first_broken_seq: row.seq,
879 first_broken_transaction_id: row.transaction_id.clone(),
880 expected: format!("prev_chain_hash={last_hash}"),
881 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
882 };
883 }
884 let identity = match row.identity() {
885 Ok(identity) => identity,
886 Err(RowIdentityError::UnknownVersion(v)) => {
887 return VerifyOutcome::CannotVerify {
888 reason: format!(
889 "row seq={} declares chain_version={v}, which this binary cannot \
890 reproduce (it understands {CHAIN_VERSION_LEGACY}..={CHAIN_VERSION_CURRENT}); \
891 verify with a build at least as new as the one that wrote the chain",
892 row.seq
893 ),
894 };
895 }
896 Err(RowIdentityError::MissingField(field)) => {
897 let stored = match field {
902 "caller_role" => row.caller_role.as_deref(),
903 "event_tip" => row.event_tip.as_deref(),
904 "caller_principal" => row.caller_principal.as_deref(),
905 _ => None,
906 };
907 return VerifyOutcome::Broken {
908 rows_checked,
909 first_broken_seq: row.seq,
910 first_broken_transaction_id: row.transaction_id.clone(),
911 expected: format!(
912 "chain_version={} row carrying a non-empty {field}",
913 row.chain_version
914 ),
915 actual: match stored {
918 None => format!("{field}=NULL"),
919 Some("") => format!("{field}='' (empty, not NULL)"),
920 Some(other) => format!("{field}={other:?}"),
921 },
922 };
923 }
924 };
925 let content = ChainContent {
926 seq: row.seq,
927 key_id: &row.key_id,
928 transaction_id: &row.transaction_id,
929 request_id: &row.request_id,
930 request_hash: &row.request_hash,
931 action_name: &row.action_name,
932 risk_level: row.risk_level,
933 summary: &row.summary,
934 approval_id: row.approval_id.as_deref(),
935 warnings_json: &row.warnings_json,
936 created_at: &row.created_at,
937 identity,
938 };
939 let msg = chain_message(&content, &row.prev_chain_hash);
940 if !signature_ok(vk, &msg, &row.chain_hash) {
941 return VerifyOutcome::Broken {
942 rows_checked,
943 first_broken_seq: row.seq,
944 first_broken_transaction_id: row.transaction_id.clone(),
945 expected: "valid ed25519 signature".to_string(),
946 actual: row.chain_hash.clone(),
947 };
948 }
949 last_hash = row.chain_hash.clone();
950 rows_checked += 1;
951 }
952 VerifyOutcome::Intact { rows_checked }
953}
954
955#[derive(Debug, Clone, Copy, PartialEq, Eq)]
968pub enum AuditEventKind {
969 ApprovalGranted,
971 ApprovalConsumed,
973 ApprovalRevoked,
975 StatusQueued,
984 StatusRunning,
985 StatusSucceeded,
986 StatusFailed,
987 StatusCanceled,
988 StatusRolledBack,
989 StatusNeedsReboot,
990}
991
992impl From<sysknife_types::JobState> for AuditEventKind {
993 fn from(state: sysknife_types::JobState) -> Self {
994 use sysknife_types::JobState as S;
995 match state {
996 S::Queued => Self::StatusQueued,
997 S::Running => Self::StatusRunning,
998 S::Succeeded => Self::StatusSucceeded,
999 S::Failed => Self::StatusFailed,
1000 S::Canceled => Self::StatusCanceled,
1001 S::RolledBack => Self::StatusRolledBack,
1002 S::NeedsReboot => Self::StatusNeedsReboot,
1003 }
1004 }
1005}
1006
1007impl AuditEventKind {
1008 pub const ALL: &'static [AuditEventKind] = &[
1019 Self::ApprovalGranted,
1020 Self::ApprovalConsumed,
1021 Self::ApprovalRevoked,
1022 Self::StatusQueued,
1023 Self::StatusRunning,
1024 Self::StatusSucceeded,
1025 Self::StatusFailed,
1026 Self::StatusCanceled,
1027 Self::StatusRolledBack,
1028 Self::StatusNeedsReboot,
1029 ];
1030
1031 pub fn as_str(&self) -> &'static str {
1034 match self {
1035 Self::ApprovalGranted => "approval_granted",
1036 Self::ApprovalConsumed => "approval_consumed",
1037 Self::ApprovalRevoked => "approval_revoked",
1038 Self::StatusQueued => "status_queued",
1039 Self::StatusRunning => "status_running",
1040 Self::StatusSucceeded => "status_succeeded",
1041 Self::StatusFailed => "status_failed",
1042 Self::StatusCanceled => "status_canceled",
1043 Self::StatusRolledBack => "status_rolled_back",
1044 Self::StatusNeedsReboot => "status_needs_reboot",
1045 }
1046 }
1047}
1048
1049#[derive(Debug, Clone, PartialEq, Eq)]
1051pub struct EventContent<'a> {
1052 pub seq: u64,
1053 pub key_id: &'a str,
1054 pub kind: AuditEventKind,
1055 pub transaction_id: &'a str,
1056 pub receipt_digest: &'a str,
1060 pub created_at: &'a str,
1061}
1062
1063impl EventContent<'_> {
1064 pub fn canonical_bytes(&self) -> Vec<u8> {
1067 let mut buf = Vec::with_capacity(256);
1068 push_field(&mut buf, "seq", &self.seq.to_string());
1069 push_field(&mut buf, "key_id", self.key_id);
1070 push_field(&mut buf, "kind", self.kind.as_str());
1071 push_field(&mut buf, "transaction_id", self.transaction_id);
1072 push_field(&mut buf, "receipt_digest", self.receipt_digest);
1073 push_field(&mut buf, "created_at", self.created_at);
1074 buf
1075 }
1076}
1077
1078fn event_message(content: &EventContent, prev_chain_hash: &str) -> Vec<u8> {
1079 let mut msg = EVENT_DOMAIN.to_vec();
1080 msg.extend_from_slice(&content.canonical_bytes());
1081 msg.extend_from_slice(prev_chain_hash.as_bytes());
1082 msg
1083}
1084
1085impl AuditKey {
1086 pub fn event_hash(&self, content: &EventContent, prev_chain_hash: &str) -> String {
1090 hex::encode(
1091 self.signing
1092 .sign(&event_message(content, prev_chain_hash))
1093 .to_bytes(),
1094 )
1095 }
1096}
1097
1098#[derive(Debug, Clone, PartialEq, Eq)]
1100pub struct EventRow {
1101 pub seq: u64,
1102 pub key_id: String,
1103 pub kind: String,
1107 pub transaction_id: String,
1108 pub receipt_digest: String,
1109 pub created_at: String,
1110 pub prev_chain_hash: String,
1111 pub chain_hash: String,
1112}
1113
1114pub fn verify_event_chain(key: &AuditKey, rows: &[EventRow]) -> VerifyOutcome {
1116 verify_event_rows(&key.verifying_key(), Some(key.key_id()), rows)
1117}
1118
1119pub fn verify_event_chain_with_pubkey(verifying_key_hex: &str, rows: &[EventRow]) -> VerifyOutcome {
1121 match parse_verifying_key(verifying_key_hex) {
1122 Some(vk) => verify_event_rows(&vk, None, rows),
1123 None => VerifyOutcome::CannotVerify {
1124 reason: format!(
1125 "invalid public key hex ({} chars); expected 64 hex chars of a \
1126 32-byte Ed25519 public key",
1127 verifying_key_hex.len()
1128 ),
1129 },
1130 }
1131}
1132
1133fn verify_event_rows(
1134 vk: &VerifyingKey,
1135 expect_key_id: Option<&str>,
1136 rows: &[EventRow],
1137) -> VerifyOutcome {
1138 let mut last_hash = String::new();
1139 let mut rows_checked = 0u64;
1140 for row in rows {
1141 if let Some(kid) = expect_key_id {
1142 if row.key_id != kid {
1143 return VerifyOutcome::CannotVerify {
1144 reason: format!(
1145 "event seq={} uses key_id={:?} but only {:?} is loaded; \
1146 epoch keys not yet supported",
1147 row.seq, row.key_id, kid
1148 ),
1149 };
1150 }
1151 }
1152 if row.prev_chain_hash != last_hash {
1153 return VerifyOutcome::Broken {
1154 rows_checked,
1155 first_broken_seq: row.seq,
1156 first_broken_transaction_id: row.transaction_id.clone(),
1157 expected: format!("prev_chain_hash={last_hash}"),
1158 actual: format!("prev_chain_hash={}", row.prev_chain_hash),
1159 };
1160 }
1161 let Some(kind) = parse_event_kind(&row.kind) else {
1164 return VerifyOutcome::Broken {
1165 rows_checked,
1166 first_broken_seq: row.seq,
1167 first_broken_transaction_id: row.transaction_id.clone(),
1168 expected: "a known event kind".to_string(),
1169 actual: format!("kind={:?}", row.kind),
1170 };
1171 };
1172 let content = EventContent {
1173 seq: row.seq,
1174 key_id: &row.key_id,
1175 kind,
1176 transaction_id: &row.transaction_id,
1177 receipt_digest: &row.receipt_digest,
1178 created_at: &row.created_at,
1179 };
1180 if !signature_ok(
1181 vk,
1182 &event_message(&content, &row.prev_chain_hash),
1183 &row.chain_hash,
1184 ) {
1185 return VerifyOutcome::Broken {
1186 rows_checked,
1187 first_broken_seq: row.seq,
1188 first_broken_transaction_id: row.transaction_id.clone(),
1189 expected: "valid ed25519 signature".to_string(),
1190 actual: row.chain_hash.clone(),
1191 };
1192 }
1193 last_hash = row.chain_hash.clone();
1194 rows_checked += 1;
1195 }
1196 VerifyOutcome::Intact { rows_checked }
1197}
1198
1199fn parse_event_kind(raw: &str) -> Option<AuditEventKind> {
1200 AuditEventKind::ALL
1201 .iter()
1202 .copied()
1203 .find(|kind| kind.as_str() == raw)
1204}
1205
1206#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1215#[serde(tag = "state", rename_all = "snake_case")]
1216pub enum StatusOutcome {
1217 Agrees { rows_checked: u64 },
1219 Disagrees { detail: String },
1221}
1222
1223pub fn status_outcome_to_exit_code(outcome: &StatusOutcome) -> i32 {
1227 match outcome {
1228 StatusOutcome::Agrees { .. } => 0,
1229 StatusOutcome::Disagrees { .. } => 1,
1230 }
1231}
1232
1233#[derive(Debug, Clone, PartialEq, Eq)]
1236pub enum BindingOutcome {
1237 Consistent { bindings_checked: u64 },
1239 NotChecked,
1242 MissingEvent {
1246 transaction_seq: u64,
1247 event_tip: String,
1248 },
1249}
1250
1251pub fn verify_event_binding(tx_rows: &[ChainRow], event_rows: &[EventRow]) -> BindingOutcome {
1261 let known: std::collections::HashSet<&str> = event_rows
1262 .iter()
1263 .map(|row| row.chain_hash.as_str())
1264 .collect();
1265 let mut bindings_checked = 0u64;
1266 for row in tx_rows {
1267 let Some(tip) = row.event_tip.as_deref() else {
1268 continue;
1269 };
1270 if tip.is_empty() {
1271 continue;
1272 }
1273 if !known.contains(tip) {
1274 return BindingOutcome::MissingEvent {
1275 transaction_seq: row.seq,
1276 event_tip: tip.to_string(),
1277 };
1278 }
1279 bindings_checked += 1;
1280 }
1281 BindingOutcome::Consistent { bindings_checked }
1282}
1283
1284#[derive(Debug, Clone, PartialEq, Eq)]
1291pub struct AuditVerification {
1292 pub chain: VerifyOutcome,
1293 pub events: VerifyOutcome,
1294 pub binding: BindingOutcome,
1295 pub attribution: Option<AttributionCensus>,
1311 pub status: Option<StatusOutcome>,
1319}
1320
1321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1326enum RowStanding {
1327 NamesAccount,
1329 AttributionFailed,
1332 NotRecorded,
1336 Unattested,
1346}
1347
1348fn standing(row: &ChainRow) -> RowStanding {
1360 let stored = row.caller_principal.as_deref().filter(|p| !p.is_empty());
1368 match row.chain_version {
1369 CHAIN_VERSION_LEGACY | CHAIN_VERSION_V2 => match stored {
1370 None => RowStanding::NotRecorded,
1371 Some(_) => RowStanding::Unattested,
1372 },
1373 CHAIN_VERSION_V3 => match stored {
1374 None => RowStanding::NotRecorded,
1375 Some(p) => match crate::auth::CallerPrincipal::classify(p) {
1376 crate::auth::PrincipalClaim::Account => RowStanding::NamesAccount,
1377 crate::auth::PrincipalClaim::AttributionFailed => RowStanding::AttributionFailed,
1378 crate::auth::PrincipalClaim::Unrecognized => RowStanding::Unattested,
1379 },
1380 },
1381 _ => RowStanding::Unattested,
1387 }
1388}
1389
1390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1422pub struct AttributionCensus {
1423 named: u64,
1424 attribution_failed: u64,
1425 not_recorded: u64,
1426 unattested: u64,
1427 rows: u64,
1428}
1429
1430impl AttributionCensus {
1431 pub fn of(tx_rows: &[ChainRow]) -> Self {
1433 let mut census = Self {
1434 named: 0,
1435 attribution_failed: 0,
1436 not_recorded: 0,
1437 unattested: 0,
1438 rows: tx_rows.len() as u64,
1439 };
1440 for row in tx_rows {
1441 match standing(row) {
1442 RowStanding::NamesAccount => census.named += 1,
1443 RowStanding::AttributionFailed => census.attribution_failed += 1,
1444 RowStanding::NotRecorded => census.not_recorded += 1,
1445 RowStanding::Unattested => census.unattested += 1,
1446 }
1447 }
1448 census
1449 }
1450
1451 pub fn named(&self) -> u64 {
1457 self.named
1458 }
1459
1460 pub fn attribution_failed(&self) -> u64 {
1463 self.attribution_failed
1464 }
1465
1466 pub fn not_recorded(&self) -> u64 {
1469 self.not_recorded
1470 }
1471
1472 pub fn unattested(&self) -> u64 {
1475 self.unattested
1476 }
1477
1478 pub fn rows(&self) -> u64 {
1485 self.rows
1486 }
1487
1488 pub fn unnamed(&self) -> u64 {
1497 self.rows - self.named
1498 }
1499
1500 #[cfg(any(test, feature = "test-support"))]
1513 pub fn from_counts_for_tests(
1514 named: u64,
1515 attribution_failed: u64,
1516 not_recorded: u64,
1517 unattested: u64,
1518 ) -> Self {
1519 let rows = named
1520 .checked_add(attribution_failed)
1521 .and_then(|n| n.checked_add(not_recorded))
1522 .and_then(|n| n.checked_add(unattested))
1523 .expect("census counts must not overflow u64");
1524 Self {
1525 named,
1526 attribution_failed,
1527 not_recorded,
1528 unattested,
1529 rows,
1530 }
1531 }
1532}
1533
1534impl AuditVerification {
1535 pub fn exit_code(&self) -> i32 {
1541 let mut codes = vec![
1542 outcome_to_exit_code(&self.chain),
1543 outcome_to_exit_code(&self.events),
1544 binding_outcome_to_exit_code(&self.binding),
1545 ];
1546 if let Some(status) = &self.status {
1549 codes.push(status_outcome_to_exit_code(status));
1550 }
1551 if codes.contains(&1) {
1552 1
1553 } else if codes.contains(&2) {
1554 2
1555 } else {
1556 0
1557 }
1558 }
1559}
1560
1561pub fn verify_all(
1563 key: &AuditKey,
1564 tx_rows: &[ChainRow],
1565 event_rows: &[EventRow],
1566) -> AuditVerification {
1567 AuditVerification {
1568 chain: verify_chain(key, tx_rows),
1569 events: verify_event_chain(key, event_rows),
1570 binding: verify_event_binding(tx_rows, event_rows),
1571 attribution: Some(AttributionCensus::of(tx_rows)),
1572 status: None,
1573 }
1574}
1575
1576pub fn verify_all_with_pubkey(
1578 verifying_key_hex: &str,
1579 tx_rows: &[ChainRow],
1580 event_rows: &[EventRow],
1581) -> AuditVerification {
1582 AuditVerification {
1583 chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
1584 events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
1585 binding: verify_event_binding(tx_rows, event_rows),
1586 attribution: Some(AttributionCensus::of(tx_rows)),
1587 status: None,
1588 }
1589}
1590
1591pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
1595 match outcome {
1596 BindingOutcome::Consistent { .. } => 0,
1597 BindingOutcome::NotChecked => 2,
1598 BindingOutcome::MissingEvent { .. } => 1,
1599 }
1600}
1601
1602#[derive(Debug, Clone, PartialEq, Eq)]
1618pub struct Checkpoint {
1619 pub seq: u64,
1622 pub chain_tip: String,
1624 pub created_at: String,
1626 pub signature: String,
1628}
1629
1630fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
1633 let mut buf = CHECKPOINT_DOMAIN.to_vec();
1636 push_field(&mut buf, "seq", &seq.to_string());
1637 push_field(&mut buf, "chain_tip", chain_tip);
1638 push_field(&mut buf, "created_at", created_at);
1639 buf
1640}
1641
1642impl AuditKey {
1643 pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
1645 let sig = self
1646 .signing
1647 .sign(&checkpoint_message(seq, chain_tip, created_at));
1648 Checkpoint {
1649 seq,
1650 chain_tip: chain_tip.to_string(),
1651 created_at: created_at.to_string(),
1652 signature: hex::encode(sig.to_bytes()),
1653 }
1654 }
1655}
1656
1657#[derive(Debug, Clone, PartialEq, Eq)]
1659pub enum CheckpointOutcome {
1660 Consistent { checkpoints_checked: u64 },
1663 BadSignature { seq: u64 },
1665 Truncated {
1668 checkpoint_seq: u64,
1669 current_max_seq: u64,
1670 },
1671 TipMismatch {
1674 seq: u64,
1675 anchored: String,
1676 actual: String,
1677 },
1678 CannotVerify { reason: String },
1680}
1681
1682pub fn verify_checkpoints(
1688 verifying_key_hex: &str,
1689 rows: &[ChainRow],
1690 checkpoints: &[Checkpoint],
1691) -> CheckpointOutcome {
1692 let vk = match parse_verifying_key(verifying_key_hex) {
1693 Some(vk) => vk,
1694 None => {
1695 return CheckpointOutcome::CannotVerify {
1696 reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
1697 };
1698 }
1699 };
1700 let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
1703 let mut checked = 0u64;
1704 for cp in checkpoints {
1705 let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
1706 if !signature_ok(&vk, &msg, &cp.signature) {
1707 return CheckpointOutcome::BadSignature { seq: cp.seq };
1708 }
1709 match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
1710 Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
1711 Ok(idx) => {
1712 return CheckpointOutcome::TipMismatch {
1713 seq: cp.seq,
1714 anchored: cp.chain_tip.clone(),
1715 actual: rows[idx].chain_hash.clone(),
1716 };
1717 }
1718 Err(_) => {
1719 return CheckpointOutcome::Truncated {
1720 checkpoint_seq: cp.seq,
1721 current_max_seq,
1722 };
1723 }
1724 }
1725 checked += 1;
1726 }
1727 CheckpointOutcome::Consistent {
1728 checkpoints_checked: checked,
1729 }
1730}
1731
1732pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
1737 match outcome {
1738 CheckpointOutcome::Consistent { .. } => 0,
1739 CheckpointOutcome::BadSignature { .. }
1740 | CheckpointOutcome::Truncated { .. }
1741 | CheckpointOutcome::TipMismatch { .. } => 1,
1742 CheckpointOutcome::CannotVerify { .. } => 2,
1743 }
1744}
1745
1746#[cfg(test)]
1747mod tests {
1748 #[test]
1756 fn every_variant_is_in_all() {
1757 fn spelling(kind: AuditEventKind) -> &'static str {
1758 match kind {
1759 AuditEventKind::ApprovalGranted => "approval_granted",
1760 AuditEventKind::ApprovalConsumed => "approval_consumed",
1761 AuditEventKind::ApprovalRevoked => "approval_revoked",
1762 AuditEventKind::StatusQueued => "status_queued",
1763 AuditEventKind::StatusRunning => "status_running",
1764 AuditEventKind::StatusSucceeded => "status_succeeded",
1765 AuditEventKind::StatusFailed => "status_failed",
1766 AuditEventKind::StatusCanceled => "status_canceled",
1767 AuditEventKind::StatusRolledBack => "status_rolled_back",
1768 AuditEventKind::StatusNeedsReboot => "status_needs_reboot",
1769 }
1770 }
1771 for kind in AuditEventKind::ALL {
1772 assert_eq!(
1773 spelling(*kind),
1774 kind.as_str(),
1775 "as_str and the exhaustive table disagree about {kind:?}"
1776 );
1777 assert_eq!(
1778 parse_event_kind(kind.as_str()),
1779 Some(*kind),
1780 "{kind:?} does not round-trip, so a row carrying it verifies as broken"
1781 );
1782 }
1783 for kind in AuditEventKind::ALL {
1786 assert!(
1787 AuditEventKind::ALL
1788 .iter()
1789 .any(|k| k.as_str() == spelling(*kind)),
1790 "{kind:?} has a spelling and is not reachable through ALL"
1791 );
1792 }
1793 assert!(
1794 parse_event_kind("not_a_kind").is_none(),
1795 "an unknown kind must not parse, or the check inspects nothing"
1796 );
1797 }
1798
1799 use super::*;
1800
1801 fn fixed_key() -> AuditKey {
1802 AuditKey::from_bytes(vec![0x42; 32])
1803 }
1804
1805 fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
1806 ChainContent {
1807 seq,
1808 key_id: CURRENT_KEY_ID,
1809 transaction_id: txid,
1810 request_id: "req-1",
1811 request_hash: "hash-abc",
1812 action_name: "UpdateSystem",
1813 risk_level: RiskLevel::High,
1814 summary: "Upgrade",
1815 approval_id: None,
1816 warnings_json: "[]",
1817 created_at: "2026-04-24T12:00:00Z",
1818 identity: ChainIdentity::V2 {
1819 caller_role: "Dev",
1820 event_tip: "",
1821 },
1822 }
1823 }
1824
1825 #[test]
1828 fn same_inputs_yield_same_hash() {
1829 let key = fixed_key();
1830 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1831 let h2 = key.chain_hash(&sample_content(1, "txa"), "");
1832 assert_eq!(h1, h2);
1833 assert_eq!(h1.len(), HASH_HEX_LEN);
1834 }
1835
1836 #[test]
1837 fn different_seq_yields_different_hash() {
1838 let key = fixed_key();
1839 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1840 let h2 = key.chain_hash(&sample_content(2, "txa"), "");
1841 assert_ne!(h1, h2);
1842 }
1843
1844 #[test]
1845 fn different_prev_hash_yields_different_hash() {
1846 let key = fixed_key();
1847 let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1848 let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
1849 assert_ne!(h1, h2);
1850 }
1851
1852 #[test]
1853 fn different_keys_yield_different_hashes() {
1854 let key1 = AuditKey::from_bytes(vec![0x01; 32]);
1855 let key2 = AuditKey::from_bytes(vec![0x02; 32]);
1856 let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
1857 let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
1858 assert_ne!(h1, h2);
1859 }
1860
1861 #[test]
1864 fn canonical_bytes_have_stable_field_order() {
1865 let c = sample_content(1, "txa");
1866 let bytes = c.canonical_bytes();
1867 let s = String::from_utf8_lossy(&bytes);
1868 let order = [
1870 "seq",
1871 "key_id",
1872 "transaction_id",
1873 "request_id",
1874 "request_hash",
1875 "action_name",
1876 "risk_level",
1877 "summary",
1878 "approval_id",
1879 "warnings_json",
1880 "created_at",
1881 ];
1882 let mut last_idx = 0;
1883 for tag in order {
1884 let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
1885 assert!(idx >= last_idx, "{tag} out of order");
1886 last_idx = idx;
1887 }
1888 }
1889
1890 #[test]
1891 fn nul_bytes_in_field_value_are_escaped() {
1892 let mut c = sample_content(1, "txa");
1893 let summary = "before\0after";
1894 c.summary = summary;
1895 let bytes = c.canonical_bytes();
1896 assert!(!bytes.contains(&0x00));
1898 assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
1899 }
1900
1901 #[test]
1907 fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
1908 let mut a = sample_content(1, "txa");
1909 a.summary = "before\\0after"; let mut b = sample_content(1, "txa");
1911 b.summary = "before\0after"; assert_ne!(
1913 a.canonical_bytes(),
1914 b.canonical_bytes(),
1915 "backslash escape must run before NUL escape to prevent collision"
1916 );
1917 }
1918
1919 fn key_for_collision_test() -> AuditKey {
1920 AuditKey::from_bytes(vec![0xab; 32])
1921 }
1922
1923 #[test]
1924 fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
1925 let key = key_for_collision_test();
1926 let mut a = sample_content(1, "txa");
1927 a.summary = "before\\0after";
1928 let mut b = sample_content(1, "txa");
1929 b.summary = "before\0after";
1930 assert_ne!(
1931 key.chain_hash(&a, ""),
1932 key.chain_hash(&b, ""),
1933 "chain signature must distinguish escape from raw byte"
1934 );
1935 }
1936
1937 fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
1943 let (chain_version, caller_role, event_tip, caller_principal) = match content.identity {
1944 ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None, None),
1945 ChainIdentity::V2 {
1946 caller_role,
1947 event_tip,
1948 } => (
1949 CHAIN_VERSION_V2,
1950 Some(caller_role.to_string()),
1951 Some(event_tip.to_string()),
1952 None,
1953 ),
1954 ChainIdentity::V3 {
1955 caller_role,
1956 event_tip,
1957 caller_principal,
1958 } => (
1959 CHAIN_VERSION_V3,
1960 Some(caller_role.to_string()),
1961 Some(event_tip.to_string()),
1962 Some(caller_principal.to_string()),
1963 ),
1964 };
1965 ChainRow {
1966 seq: content.seq,
1967 key_id: content.key_id.to_string(),
1968 transaction_id: content.transaction_id.to_string(),
1969 request_id: content.request_id.to_string(),
1970 request_hash: content.request_hash.to_string(),
1971 action_name: content.action_name.to_string(),
1972 risk_level: content.risk_level,
1973 summary: content.summary.to_string(),
1974 approval_id: content.approval_id.map(str::to_string),
1975 warnings_json: content.warnings_json.to_string(),
1976 created_at: content.created_at.to_string(),
1977 prev_chain_hash: prev.to_string(),
1978 chain_hash,
1979 chain_version,
1980 caller_role,
1981 event_tip,
1982 caller_principal,
1983 }
1984 }
1985
1986 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
1987 let mut rows = Vec::with_capacity(count);
1988 let mut prev = String::new();
1989 for i in 0..count {
1990 let seq = (i + 1) as u64;
1991 let txid = format!("tx{i}");
1992 let content = sample_content(seq, &txid);
1993 let hash = key.chain_hash(&content, &prev);
1994 rows.push(row_for(&content, &prev, hash.clone()));
1995 prev = hash;
1996 }
1997 rows
1998 }
1999
2000 #[test]
2001 fn exported_chain_row_json_round_trips_without_changing_the_signature() {
2002 let key = fixed_key();
2003 let row = build_chain(&key, 1).remove(0);
2004
2005 let json = serde_json::to_string(&row).expect("chain row serializes");
2006 let decoded: ChainRow = serde_json::from_str(&json).expect("chain row deserializes");
2007
2008 assert_eq!(decoded, row);
2009 assert_eq!(decoded.chain_hash.as_bytes(), row.chain_hash.as_bytes());
2010 }
2011
2012 #[test]
2013 fn exported_chain_row_uses_the_stored_column_contract_and_json_nulls() {
2014 let key = fixed_key();
2015 let mut row = build_chain(&key, 1).remove(0);
2016 row.approval_id = None;
2017 row.caller_role = None;
2018 row.event_tip = None;
2019 row.caller_principal = None;
2020
2021 let value = serde_json::to_value(&row).expect("chain row serializes");
2022 let object = value.as_object().expect("chain row is a JSON object");
2023 let keys: Vec<&str> = object.keys().map(String::as_str).collect();
2024
2025 assert_eq!(
2026 keys,
2027 vec![
2028 "action_name",
2029 "approval_id",
2030 "caller_principal",
2031 "caller_role",
2032 "chain_hash",
2033 "chain_version",
2034 "created_at",
2035 "event_tip",
2036 "key_id",
2037 "prev_chain_hash",
2038 "request_hash",
2039 "request_id",
2040 "risk_level",
2041 "seq",
2042 "summary",
2043 "transaction_id",
2044 "warnings_json",
2045 ]
2046 );
2047 for optional in [
2048 "approval_id",
2049 "caller_role",
2050 "event_tip",
2051 "caller_principal",
2052 ] {
2053 assert!(object[optional].is_null(), "{optional} must be JSON null");
2054 }
2055 assert!(!object.contains_key("argv"));
2056 assert!(!object.contains_key("outcome"));
2057 assert!(!object.contains_key("signature"));
2058 }
2059
2060 #[test]
2061 fn export_since_is_inclusive_and_limit_preserves_chain_order() {
2062 let key = fixed_key();
2063 let mut rows = build_chain(&key, 4);
2064 for (row, created_at) in rows.iter_mut().zip([
2065 "2026-08-19T09:00:00Z",
2066 "2026-08-19T10:00:00Z",
2067 "2026-08-19T11:00:00Z",
2068 "2026-08-19T12:00:00Z",
2069 ]) {
2070 row.created_at = created_at.to_string();
2071 }
2072
2073 let selected =
2074 select_chain_rows_for_export(rows, Some("2026-08-19T10:00:00+00:00"), Some(2)).unwrap();
2075
2076 assert_eq!(
2077 selected.iter().map(|row| row.seq).collect::<Vec<_>>(),
2078 [2, 3]
2079 );
2080 }
2081
2082 #[test]
2083 fn export_rejects_an_invalid_since_timestamp() {
2084 let error = select_chain_rows_for_export(Vec::new(), Some("last Tuesday"), None)
2085 .expect_err("invalid --since must fail before reading rows");
2086 assert!(matches!(error, ChainExportError::InvalidSince(_)));
2087 }
2088
2089 #[test]
2092 fn caller_role_is_covered_by_the_row_signature() {
2093 let key = fixed_key();
2097 let mut dev = sample_content(1, "txa");
2098 dev.identity = ChainIdentity::V2 {
2099 caller_role: "Dev",
2100 event_tip: "",
2101 };
2102 let mut boot = sample_content(1, "txa");
2103 boot.identity = ChainIdentity::V2 {
2104 caller_role: "Boot",
2105 event_tip: "",
2106 };
2107 assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
2108 }
2109
2110 #[test]
2111 fn event_tip_is_covered_by_the_row_signature() {
2112 let key = fixed_key();
2113 let mut empty = sample_content(1, "txa");
2114 empty.identity = ChainIdentity::V2 {
2115 caller_role: "Dev",
2116 event_tip: "",
2117 };
2118 let mut bound = sample_content(1, "txa");
2119 bound.identity = ChainIdentity::V2 {
2120 caller_role: "Dev",
2121 event_tip: "abc123",
2122 };
2123 assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
2124 }
2125
2126 #[test]
2127 fn a_legacy_row_written_before_the_migration_still_verifies() {
2128 let key = fixed_key();
2133 let mut content = sample_content(1, "tx-legacy");
2134 content.identity = ChainIdentity::LegacyV1;
2135 let hash = key.chain_hash(&content, "");
2136 let row = row_for(&content, "", hash);
2137 assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
2138 assert_eq!(row.caller_role, None);
2139 assert_eq!(
2140 verify_chain(&key, &[row]),
2141 VerifyOutcome::Intact { rows_checked: 1 }
2142 );
2143 }
2144
2145 #[test]
2151 fn two_admins_are_distinguishable_in_the_signed_record() {
2152 let key = fixed_key();
2153 let mut alice = sample_content(1, "tx-alice");
2154 alice.identity = ChainIdentity::V3 {
2155 caller_role: "admin",
2156 event_tip: "",
2157 caller_principal: "uid:1000",
2158 };
2159 let mut bob = sample_content(1, "tx-alice");
2160 bob.identity = ChainIdentity::V3 {
2161 caller_role: "admin",
2162 event_tip: "",
2163 caller_principal: "uid:1001",
2164 };
2165
2166 assert_ne!(
2167 key.chain_hash(&alice, ""),
2168 key.chain_hash(&bob, ""),
2169 "identical rows differing only in principal must sign differently, \
2170 otherwise the chain still cannot answer which account acted"
2171 );
2172 }
2173
2174 #[test]
2177 fn editing_the_stored_principal_breaks_the_row() {
2178 let key = fixed_key();
2179 let mut content = sample_content(1, "tx1");
2180 content.identity = ChainIdentity::V3 {
2181 caller_role: "admin",
2182 event_tip: "",
2183 caller_principal: "uid:1000",
2184 };
2185 let hash = key.chain_hash(&content, "");
2186 let mut row = row_for(&content, "", hash);
2187 row.caller_principal = Some("uid:0".to_string());
2188
2189 assert!(
2190 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2191 "a principal swapped to root must not verify"
2192 );
2193 }
2194
2195 #[test]
2199 fn downgrading_a_v3_row_to_v2_to_hide_the_account_breaks_it() {
2200 let key = fixed_key();
2201 let mut content = sample_content(1, "tx1");
2202 content.identity = ChainIdentity::V3 {
2203 caller_role: "admin",
2204 event_tip: "",
2205 caller_principal: "uid:1000",
2206 };
2207 let hash = key.chain_hash(&content, "");
2208 let mut row = row_for(&content, "", hash);
2209 row.chain_version = CHAIN_VERSION_V2;
2210 row.caller_principal = None;
2211
2212 assert!(
2213 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2214 "a v3 row relabelled as v2 must not verify"
2215 );
2216 }
2217
2218 #[test]
2223 fn a_v3_row_without_a_usable_principal_is_broken() {
2224 let key = fixed_key();
2225 let mut content = sample_content(1, "tx1");
2226 content.identity = ChainIdentity::V3 {
2227 caller_role: "admin",
2228 event_tip: "",
2229 caller_principal: "uid:1000",
2230 };
2231 let hash = key.chain_hash(&content, "");
2232
2233 for absent in [None, Some(String::new())] {
2234 let mut row = row_for(&content, "", hash.clone());
2235 row.caller_principal = absent.clone();
2236 assert!(
2237 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2238 "a v3 row with principal {absent:?} must be reported as broken"
2239 );
2240 }
2241 }
2242
2243 #[test]
2253 fn a_row_signed_with_a_blank_principal_is_rejected_even_though_it_signs() {
2254 let key = fixed_key();
2255 let mut content = sample_content(1, "tx-blank");
2256 content.identity = ChainIdentity::V3 {
2257 caller_role: "admin",
2258 event_tip: "",
2259 caller_principal: "",
2260 };
2261 let hash = key.chain_hash(&content, "");
2262 let row = row_for(&content, "", hash);
2267 assert_eq!(
2268 row.caller_principal.as_deref(),
2269 Some(""),
2270 "fixture must store the blank principal it signed"
2271 );
2272 assert_eq!(row.chain_version, CHAIN_VERSION_V3);
2273 assert!(
2274 matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2275 "a v3 row naming nobody must be broken, not accepted"
2276 );
2277 }
2278
2279 #[test]
2284 fn a_row_recording_an_attribution_failure_verifies() {
2285 let key = fixed_key();
2286 let principal = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2287 let mut content = sample_content(1, "tx-unattributed");
2288 content.identity = ChainIdentity::V3 {
2289 caller_role: "observer",
2290 event_tip: "",
2291 caller_principal: &principal,
2292 };
2293 let hash = key.chain_hash(&content, "");
2294 let row = row_for(&content, "", hash);
2295
2296 assert_eq!(
2297 verify_chain(&key, &[row]),
2298 VerifyOutcome::Intact { rows_checked: 1 }
2299 );
2300 }
2301
2302 #[test]
2306 fn a_chain_spanning_all_three_encodings_verifies() {
2307 let key = fixed_key();
2308 let mut rows = Vec::new();
2309 let mut prev = String::new();
2310 for (i, identity) in [
2311 ChainIdentity::LegacyV1,
2312 ChainIdentity::V2 {
2313 caller_role: "dev",
2314 event_tip: "",
2315 },
2316 ChainIdentity::V3 {
2317 caller_role: "admin",
2318 event_tip: "",
2319 caller_principal: "uid:1000",
2320 },
2321 ChainIdentity::V3 {
2322 caller_role: "admin",
2323 event_tip: "",
2324 caller_principal: "token:vsock",
2325 },
2326 ]
2327 .into_iter()
2328 .enumerate()
2329 {
2330 let txid = format!("tx{i}");
2331 let mut content = sample_content((i + 1) as u64, &txid);
2332 content.identity = identity;
2333 let hash = key.chain_hash(&content, &prev);
2334 rows.push(row_for(&content, &prev, hash.clone()));
2335 prev = hash;
2336 }
2337
2338 assert_eq!(
2339 verify_chain(&key, &rows),
2340 VerifyOutcome::Intact { rows_checked: 4 }
2341 );
2342 }
2343
2344 fn chain_of(key: &AuditKey, identities: Vec<ChainIdentity<'_>>) -> Vec<ChainRow> {
2347 let mut rows = Vec::new();
2348 let mut prev = String::new();
2349 for (i, identity) in identities.into_iter().enumerate() {
2350 let txid = format!("tx{i}");
2351 let mut content = sample_content((i + 1) as u64, &txid);
2352 content.identity = identity;
2353 let hash = key.chain_hash(&content, &prev);
2354 rows.push(row_for(&content, &prev, hash.clone()));
2355 prev = hash;
2356 }
2357 rows
2358 }
2359
2360 #[test]
2370 fn the_census_separates_attribution_failures_from_encodings_without_the_field() {
2371 let key = fixed_key();
2372 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2373 let rows = chain_of(
2374 &key,
2375 vec![
2376 ChainIdentity::LegacyV1,
2377 ChainIdentity::V2 {
2378 caller_role: "dev",
2379 event_tip: "",
2380 },
2381 ChainIdentity::V3 {
2382 caller_role: "admin",
2383 event_tip: "",
2384 caller_principal: "uid:1000",
2385 },
2386 ChainIdentity::V3 {
2387 caller_role: "admin",
2388 event_tip: "",
2389 caller_principal: "uid:1001",
2390 },
2391 ChainIdentity::V3 {
2392 caller_role: "admin",
2393 event_tip: "",
2394 caller_principal: "token:vsock",
2395 },
2396 ChainIdentity::V3 {
2397 caller_role: "observer",
2398 event_tip: "",
2399 caller_principal: &unattributed,
2400 },
2401 ],
2402 );
2403
2404 let verification = verify_all(&key, &rows, &[]);
2405 assert_eq!(
2406 verification.chain,
2407 VerifyOutcome::Intact { rows_checked: 6 },
2408 "every encoding must keep verifying; the census is not a verdict"
2409 );
2410 let census = verification
2411 .attribution
2412 .expect("rows were read, so a census was taken");
2413 assert_eq!(census.named(), 3);
2414 assert_eq!(census.attribution_failed(), 1);
2415 assert_eq!(census.not_recorded(), 2);
2416 assert_eq!(census.unattested(), 0);
2417 assert_eq!(census.rows(), 6);
2418 assert_eq!(
2419 census.unnamed(),
2420 3,
2421 "three of the six rows cannot name an account, by either reason"
2422 );
2423 }
2424
2425 #[test]
2435 fn a_principal_written_into_an_encoding_that_does_not_sign_it_is_never_an_account() {
2436 let key = fixed_key();
2437 let mut rows = chain_of(
2438 &key,
2439 vec![
2440 ChainIdentity::LegacyV1,
2441 ChainIdentity::V2 {
2442 caller_role: "dev",
2443 event_tip: "",
2444 },
2445 ],
2446 );
2447 rows[0].caller_principal = Some("uid:0".to_string());
2450 rows[1].caller_principal = Some("uid:0".to_string());
2451
2452 let verification = verify_all(&key, &rows, &[]);
2453 assert_eq!(
2454 verification.chain,
2455 VerifyOutcome::Intact { rows_checked: 2 },
2456 "no signature covers that column on v1/v2, so tampering with it is \
2457 invisible to verification -- which is exactly why the census must \
2458 not believe it"
2459 );
2460 let census = verification.attribution.expect("rows were read");
2461 assert_eq!(
2462 census.named(),
2463 0,
2464 "an unsigned column must never be credited as an account"
2465 );
2466 assert_eq!(
2467 census.unattested(),
2468 2,
2469 "and it must be reported as unattested, because nothing in SysKnife \
2470 writes that column on those encodings"
2471 );
2472 assert_eq!(census.unnamed(), 2);
2473 }
2474
2475 #[test]
2479 fn a_blank_principal_column_is_counted_as_naming_nobody() {
2480 let key = fixed_key();
2481 let mut content = sample_content(1, "tx-blank-census");
2482 content.identity = ChainIdentity::V3 {
2483 caller_role: "admin",
2484 event_tip: "",
2485 caller_principal: "",
2486 };
2487 let hash = key.chain_hash(&content, "");
2488 let row = row_for(&content, "", hash);
2489 assert_eq!(row.caller_principal.as_deref(), Some(""));
2490
2491 let verification = verify_all(&key, &[row], &[]);
2492 let census = verification.attribution.expect("a row was read");
2493 assert_eq!(census.named(), 0);
2494 assert_eq!(census.not_recorded(), 1);
2495 assert_eq!(census.rows(), 1);
2496 assert!(
2497 matches!(verification.chain, VerifyOutcome::Broken { .. }),
2498 "and the row is still reported as broken once the walk reaches it, \
2499 so census and verdict agree"
2500 );
2501 }
2502
2503 #[test]
2511 fn a_principal_this_binary_cannot_read_is_counted_as_unattested_not_as_an_account() {
2512 let key = fixed_key();
2513 for forged in [
2514 "garbage",
2515 "uid:",
2516 "none:overflow",
2517 " ",
2522 "UID:1000",
2523 "uid:notanumber",
2525 "uid:1000:extra",
2526 "token:not-vsock",
2527 ] {
2528 let mut content = sample_content(1, "tx-forged");
2529 content.identity = ChainIdentity::V3 {
2530 caller_role: "admin",
2531 event_tip: "",
2532 caller_principal: forged,
2533 };
2534 let hash = key.chain_hash(&content, "");
2535 let row = row_for(&content, "", hash);
2536 let census = verify_all(&key, &[row], &[])
2537 .attribution
2538 .expect("a row was read");
2539 assert_eq!(
2540 census.named(),
2541 0,
2542 "{forged:?} names no account and must not be counted as one"
2543 );
2544 assert_eq!(
2545 census.unnamed(),
2546 1,
2547 "{forged:?} must be counted among the rows naming nobody"
2548 );
2549 assert_eq!(
2550 census.unattested(),
2551 1,
2552 "{forged:?} is signed content the daemon never writes, so it is a \
2553 finding to investigate, not history to explain away"
2554 );
2555 assert_eq!(
2556 census.not_recorded(),
2557 0,
2558 "{forged:?} is present, so it must not be reported as a row that \
2559 predates the column"
2560 );
2561 }
2562 }
2563
2564 #[test]
2569 fn a_chain_predating_the_principal_field_reports_no_row_as_attributed() {
2570 let key = fixed_key();
2571 let rows = chain_of(
2572 &key,
2573 vec![
2574 ChainIdentity::LegacyV1,
2575 ChainIdentity::V2 {
2576 caller_role: "dev",
2577 event_tip: "",
2578 },
2579 ],
2580 );
2581
2582 let verification = verify_all(&key, &rows, &[]);
2583 assert_eq!(
2584 verification.chain,
2585 VerifyOutcome::Intact { rows_checked: 2 },
2586 "legacy rows must keep verifying; the census is not a verdict"
2587 );
2588 let census = verification.attribution.expect("rows were read");
2589 assert_eq!(census.attribution_failed(), 0);
2590 assert_eq!(census.named(), 0);
2591 assert_eq!(census.not_recorded(), 2);
2592 assert_eq!(census.unnamed(), 2);
2593 }
2594
2595 #[test]
2601 fn a_broken_chain_censuses_more_rows_than_it_verified() {
2602 let key = fixed_key();
2603 let mut rows = chain_of(
2604 &key,
2605 vec![
2606 ChainIdentity::V3 {
2607 caller_role: "admin",
2608 event_tip: "",
2609 caller_principal: "uid:1000",
2610 },
2611 ChainIdentity::V3 {
2612 caller_role: "admin",
2613 event_tip: "",
2614 caller_principal: "uid:1000",
2615 },
2616 ChainIdentity::V3 {
2617 caller_role: "admin",
2618 event_tip: "",
2619 caller_principal: "uid:1000",
2620 },
2621 ],
2622 );
2623 rows[1].summary = "tampered".to_string();
2624
2625 let verification = verify_all(&key, &rows, &[]);
2626 let rows_checked = match verification.chain {
2627 VerifyOutcome::Broken { rows_checked, .. } => rows_checked,
2628 ref other => panic!("expected a detected tamper, got {other:?}"),
2629 };
2630 let census = verification.attribution.expect("rows were read");
2631 assert_eq!(rows_checked, 1, "the walk stops at the first broken row");
2632 assert_eq!(
2633 census.rows(),
2634 3,
2635 "while the census still describes every row read"
2636 );
2637 assert!(
2638 census.rows() > rows_checked,
2639 "the report must be able to show that more was counted than checked"
2640 );
2641 }
2642
2643 #[test]
2647 fn the_pubkey_only_path_takes_the_same_census() {
2648 let key = fixed_key();
2649 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2650 let rows = chain_of(
2651 &key,
2652 vec![
2653 ChainIdentity::V3 {
2654 caller_role: "admin",
2655 event_tip: "",
2656 caller_principal: "uid:1000",
2657 },
2658 ChainIdentity::V3 {
2659 caller_role: "observer",
2660 event_tip: "",
2661 caller_principal: &unattributed,
2662 },
2663 ],
2664 );
2665
2666 let with_key = verify_all(&key, &rows, &[]).attribution;
2667 let with_pubkey = verify_all_with_pubkey(&key.verifying_key_hex(), &rows, &[]).attribution;
2668 assert_eq!(
2669 with_key, with_pubkey,
2670 "the auditor must see the same attribution numbers as the keyholder"
2671 );
2672 let census = with_pubkey.expect("rows were read");
2673 assert_eq!(census.named(), 1);
2674 assert_eq!(census.attribution_failed(), 1);
2675 }
2676
2677 #[test]
2682 fn a_chain_that_names_nobody_still_exits_zero() {
2683 let key = fixed_key();
2684 let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2685 let rows = chain_of(
2686 &key,
2687 vec![
2688 ChainIdentity::LegacyV1,
2689 ChainIdentity::V3 {
2690 caller_role: "observer",
2691 event_tip: "",
2692 caller_principal: &unattributed,
2693 },
2694 ],
2695 );
2696
2697 let verification = verify_all(&key, &rows, &[]);
2698 let census = verification.attribution.expect("rows were read");
2699 assert_eq!(census.unnamed(), 2, "not one row names an account");
2700 assert_eq!(
2701 verification.exit_code(),
2702 0,
2703 "yet nothing was tampered with, so the command must succeed"
2704 );
2705 }
2706
2707 #[test]
2712 fn a_future_encoding_reports_cannot_verify_with_the_supported_range() {
2713 let key = fixed_key();
2714 let content = sample_content(1, "tx1");
2715 let hash = key.chain_hash(&content, "");
2716 let mut row = row_for(&content, "", hash);
2717 row.chain_version = CHAIN_VERSION_CURRENT + 1;
2718
2719 match verify_chain(&key, &[row]) {
2720 VerifyOutcome::CannotVerify { reason } => {
2721 assert!(
2722 reason.contains(&format!("chain_version={}", CHAIN_VERSION_CURRENT + 1)),
2723 "name the version found, got: {reason}"
2724 );
2725 assert!(
2726 reason.contains(&CHAIN_VERSION_CURRENT.to_string()),
2727 "and the newest version understood, got: {reason}"
2728 );
2729 }
2730 other => panic!("a future encoding must not be reported as tampering: {other:?}"),
2731 }
2732 }
2733
2734 #[test]
2747 fn a_row_written_by_the_previous_release_still_verifies() {
2748 const GOLDEN_V2_CHAIN_HASH: &str = "ba12cbe49a149387898bc30f1e8d409effa025ab8e440e6e72bedf060afc831e45d9be264d6b18b7e0ef2f2b3584dbc6953b535546cecbbdc3d7ac666cae4d0a";
2749
2750 let key = fixed_key();
2751 let row = ChainRow {
2752 seq: 7,
2753 key_id: CURRENT_KEY_ID.to_string(),
2754 transaction_id: "tx-golden".to_string(),
2755 request_id: "req-golden".to_string(),
2756 request_hash: "hash-golden".to_string(),
2757 action_name: "AptInstall".to_string(),
2758 risk_level: RiskLevel::Medium,
2759 summary: "install ripgrep".to_string(),
2760 approval_id: Some("appr-golden".to_string()),
2761 warnings_json: "[]".to_string(),
2762 created_at: "2026-07-29T00:00:00Z".to_string(),
2763 prev_chain_hash: String::new(),
2764 chain_hash: GOLDEN_V2_CHAIN_HASH.to_string(),
2765 chain_version: 2,
2766 caller_role: Some("admin".to_string()),
2767 event_tip: Some("eventtip-golden".to_string()),
2768 caller_principal: None,
2769 };
2770
2771 assert_eq!(
2772 verify_chain(&key, std::slice::from_ref(&row)),
2773 VerifyOutcome::Intact { rows_checked: 1 },
2774 "a v2 row on disk must verify under this binary; if this fails, the v2 \
2775 encoding or its version dispatch changed and existing audit logs are unreadable"
2776 );
2777
2778 let mut tampered = row;
2782 let flipped = match GOLDEN_V2_CHAIN_HASH.strip_prefix('b') {
2783 Some(rest) => format!("a{rest}"),
2784 None => format!("b{}", &GOLDEN_V2_CHAIN_HASH[1..]),
2785 };
2786 tampered.chain_hash = flipped;
2787 assert!(
2788 matches!(
2789 verify_chain(&key, &[tampered]),
2790 VerifyOutcome::Broken { .. }
2791 ),
2792 "a golden row with one nibble changed must not verify"
2793 );
2794 }
2795
2796 #[test]
2800 fn a_legacy_v1_row_on_disk_still_verifies() {
2801 const GOLDEN_V1_CHAIN_HASH: &str = "ce8b47c15414e989e099303b826fcd9985360b794220cefbbf9689d25f31fe173283687feb6d1dd265ae5aca94c8bcfb87ce33ba8c954fafdad18a59cd97c702";
2802
2803 let row = ChainRow {
2804 chain_version: 1,
2805 caller_role: None,
2806 event_tip: None,
2807 caller_principal: None,
2808 chain_hash: GOLDEN_V1_CHAIN_HASH.to_string(),
2809 ..golden_row_shape()
2810 };
2811 assert_eq!(
2812 verify_chain(&fixed_key(), &[row]),
2813 VerifyOutcome::Intact { rows_checked: 1 }
2814 );
2815 }
2816
2817 #[test]
2821 fn a_v3_row_on_disk_still_verifies() {
2822 const GOLDEN_V3_CHAIN_HASH: &str = "3c0d05f6ccaf9e65ccb796d4ffe96eb31a2ea67e446217c5234af916dc959d0f2c0ee3d3e5b525aece791ee7d6507efb98dcdd79f0d74dde4273859aa9627d08";
2823
2824 let row = ChainRow {
2825 chain_version: 3,
2826 caller_role: Some("admin".to_string()),
2827 event_tip: Some("eventtip-golden".to_string()),
2828 caller_principal: Some("uid:1000".to_string()),
2829 chain_hash: GOLDEN_V3_CHAIN_HASH.to_string(),
2830 ..golden_row_shape()
2831 };
2832 assert_eq!(
2833 verify_chain(&fixed_key(), &[row]),
2834 VerifyOutcome::Intact { rows_checked: 1 }
2835 );
2836 }
2837
2838 #[test]
2845 fn the_escape_table_and_absent_approval_encoding_are_frozen() {
2846 const GOLDEN_V2_AWKWARD_CHAIN_HASH: &str = "e6face6fe2346fc0f6c5c1e5845255579c8f08738406b88b0a1f6dbdf8ff85090a4b80d95bd0927ef54b0a2c4810d52ebb4bbf76cb68c865a7748df26442aa05";
2847
2848 let key = fixed_key();
2849 let content = ChainContent {
2850 seq: 8,
2851 approval_id: None,
2852 summary: "back\\slash and \x1funit and \x1erecord and nul\0byte",
2853 identity: ChainIdentity::V2 {
2854 caller_role: "dev",
2855 event_tip: "",
2856 },
2857 ..golden_content_shape()
2858 };
2859 assert_eq!(
2860 key.chain_hash(&content, "ba12cbe4"),
2861 GOLDEN_V2_AWKWARD_CHAIN_HASH,
2862 "the canonical encoding of escapes, an absent approval id, or a \
2863 non-empty prev_chain_hash changed"
2864 );
2865 }
2866
2867 fn golden_content_shape() -> ChainContent<'static> {
2871 ChainContent {
2872 seq: 7,
2873 key_id: CURRENT_KEY_ID,
2874 transaction_id: "tx-golden",
2875 request_id: "req-golden",
2876 request_hash: "hash-golden",
2877 action_name: "AptInstall",
2878 risk_level: RiskLevel::Medium,
2879 summary: "install ripgrep",
2880 approval_id: Some("appr-golden"),
2881 warnings_json: "[]",
2882 created_at: "2026-07-29T00:00:00Z",
2883 identity: ChainIdentity::LegacyV1,
2884 }
2885 }
2886
2887 fn golden_row_shape() -> ChainRow {
2889 ChainRow {
2890 seq: 7,
2891 key_id: CURRENT_KEY_ID.to_string(),
2892 transaction_id: "tx-golden".to_string(),
2893 request_id: "req-golden".to_string(),
2894 request_hash: "hash-golden".to_string(),
2895 action_name: "AptInstall".to_string(),
2896 risk_level: RiskLevel::Medium,
2897 summary: "install ripgrep".to_string(),
2898 approval_id: Some("appr-golden".to_string()),
2899 warnings_json: "[]".to_string(),
2900 created_at: "2026-07-29T00:00:00Z".to_string(),
2901 prev_chain_hash: String::new(),
2902 chain_hash: String::new(),
2903 chain_version: 1,
2904 caller_role: None,
2905 event_tip: None,
2906 caller_principal: None,
2907 }
2908 }
2909
2910 #[test]
2911 fn a_chain_that_spans_the_migration_verifies_end_to_end() {
2912 let key = fixed_key();
2915 let mut rows = Vec::new();
2916 let mut prev = String::new();
2917 for (i, identity) in [
2918 ChainIdentity::LegacyV1,
2919 ChainIdentity::LegacyV1,
2920 ChainIdentity::V2 {
2921 caller_role: "Dev",
2922 event_tip: "",
2923 },
2924 ChainIdentity::V2 {
2925 caller_role: "Boot",
2926 event_tip: "",
2927 },
2928 ]
2929 .into_iter()
2930 .enumerate()
2931 {
2932 let txid = format!("tx{i}");
2933 let mut content = sample_content((i + 1) as u64, &txid);
2934 content.identity = identity;
2935 let hash = key.chain_hash(&content, &prev);
2936 rows.push(row_for(&content, &prev, hash.clone()));
2937 prev = hash;
2938 }
2939 assert_eq!(
2940 verify_chain(&key, &rows),
2941 VerifyOutcome::Intact { rows_checked: 4 }
2942 );
2943 }
2944
2945 #[test]
2946 fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
2947 let key = fixed_key();
2952 let mut rows = build_chain(&key, 2);
2953 rows[1].chain_version = CHAIN_VERSION_LEGACY;
2954 rows[1].caller_role = None;
2955 rows[1].event_tip = None;
2956 match verify_chain(&key, &rows) {
2957 VerifyOutcome::Broken {
2958 first_broken_seq, ..
2959 } => assert_eq!(first_broken_seq, 2),
2960 other => panic!("expected Broken, got {other:?}"),
2961 }
2962 }
2963
2964 #[test]
2965 fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
2966 let key = fixed_key();
2971 let mut rows = build_chain(&key, 1);
2972 rows[0].caller_role = None;
2973 let outcome = verify_chain(&key, &rows);
2974 assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2975 assert_eq!(outcome_to_exit_code(&outcome), 1);
2976 }
2977
2978 #[test]
2979 fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
2980 let key = fixed_key();
2983 let mut rows = build_chain(&key, 1);
2984 rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
2985 let outcome = verify_chain(&key, &rows);
2986 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
2987 assert_eq!(outcome_to_exit_code(&outcome), 2);
2988 }
2989
2990 fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
2993 EventContent {
2994 seq,
2995 key_id: CURRENT_KEY_ID,
2996 kind,
2997 transaction_id: txid,
2998 receipt_digest: "digest-abc",
2999 created_at: "2026-04-24T12:00:00Z",
3000 }
3001 }
3002
3003 fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
3004 let mut rows = Vec::with_capacity(kinds.len());
3005 let mut prev = String::new();
3006 for (i, kind) in kinds.iter().enumerate() {
3007 let seq = (i + 1) as u64;
3008 let txid = format!("tx{i}");
3009 let content = event_content(seq, *kind, &txid);
3010 let hash = key.event_hash(&content, &prev);
3011 rows.push(EventRow {
3012 seq,
3013 key_id: content.key_id.to_string(),
3014 kind: content.kind.as_str().to_string(),
3015 transaction_id: content.transaction_id.to_string(),
3016 receipt_digest: content.receipt_digest.to_string(),
3017 created_at: content.created_at.to_string(),
3018 prev_chain_hash: prev.clone(),
3019 chain_hash: hash.clone(),
3020 });
3021 prev = hash;
3022 }
3023 rows
3024 }
3025
3026 #[test]
3027 fn event_kind_spellings_are_stable() {
3028 assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
3031 assert_eq!(
3032 AuditEventKind::ApprovalConsumed.as_str(),
3033 "approval_consumed"
3034 );
3035 assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
3036 }
3037
3038 #[test]
3039 fn intact_event_chain_verifies() {
3040 let key = fixed_key();
3041 let rows = build_event_chain(
3042 &key,
3043 &[
3044 AuditEventKind::ApprovalGranted,
3045 AuditEventKind::ApprovalConsumed,
3046 ],
3047 );
3048 assert_eq!(
3049 verify_event_chain(&key, &rows),
3050 VerifyOutcome::Intact { rows_checked: 2 }
3051 );
3052 }
3053
3054 #[test]
3055 fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
3056 let key = fixed_key();
3059 let mut rows = build_event_chain(
3060 &key,
3061 &[
3062 AuditEventKind::ApprovalGranted,
3063 AuditEventKind::ApprovalConsumed,
3064 AuditEventKind::ApprovalRevoked,
3065 ],
3066 );
3067 rows.remove(1);
3068 match verify_event_chain(&key, &rows) {
3069 VerifyOutcome::Broken {
3070 first_broken_seq, ..
3071 } => assert_eq!(first_broken_seq, 3),
3072 other => panic!("expected Broken, got {other:?}"),
3073 }
3074 }
3075
3076 #[test]
3077 fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
3078 let key = fixed_key();
3079 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
3080 rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
3081 assert!(matches!(
3082 verify_event_chain(&key, &rows),
3083 VerifyOutcome::Broken { .. }
3084 ));
3085 }
3086
3087 #[test]
3088 fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
3089 let key = fixed_key();
3090 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
3091 rows[0].kind = "approval_unicorned".to_string();
3092 assert!(matches!(
3093 verify_event_chain(&key, &rows),
3094 VerifyOutcome::Broken { .. }
3095 ));
3096 }
3097
3098 #[test]
3099 fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
3100 let key = fixed_key();
3104 let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
3105 let event_sig = key.event_hash(&content, "");
3106 let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
3107 assert_ne!(event_sig, row_sig);
3108
3109 let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
3110 rows[0].chain_hash = row_sig;
3111 assert!(matches!(
3112 verify_event_chain(&key, &rows),
3113 VerifyOutcome::Broken { .. }
3114 ));
3115 }
3116
3117 #[test]
3120 fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
3121 let key = fixed_key();
3126 let events = build_event_chain(
3127 &key,
3128 &[
3129 AuditEventKind::ApprovalGranted,
3130 AuditEventKind::ApprovalConsumed,
3131 ],
3132 );
3133 let mut content = sample_content(1, "tx-after");
3134 content.identity = ChainIdentity::V2 {
3135 caller_role: "Dev",
3136 event_tip: &events[1].chain_hash,
3137 };
3138 let hash = key.chain_hash(&content, "");
3139 let tx_rows = vec![row_for(&content, "", hash)];
3140
3141 assert_eq!(
3142 verify_event_binding(&tx_rows, &events),
3143 BindingOutcome::Consistent {
3144 bindings_checked: 1
3145 }
3146 );
3147
3148 let truncated = &events[..1];
3149 assert_eq!(
3150 verify_event_chain(&key, truncated),
3151 VerifyOutcome::Intact { rows_checked: 1 },
3152 "a truncated event chain still walks clean; the binding is the detector"
3153 );
3154 match verify_event_binding(&tx_rows, truncated) {
3155 BindingOutcome::MissingEvent {
3156 transaction_seq, ..
3157 } => assert_eq!(transaction_seq, 1),
3158 other => panic!("expected MissingEvent, got {other:?}"),
3159 }
3160 }
3161
3162 #[test]
3163 fn legacy_rows_carry_no_binding_to_check() {
3164 let key = fixed_key();
3167 let mut content = sample_content(1, "tx-legacy");
3168 content.identity = ChainIdentity::LegacyV1;
3169 let hash = key.chain_hash(&content, "");
3170 let rows = vec![row_for(&content, "", hash)];
3171 assert_eq!(
3172 verify_event_binding(&rows, &[]),
3173 BindingOutcome::Consistent {
3174 bindings_checked: 0
3175 }
3176 );
3177 }
3178
3179 #[test]
3180 fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
3181 let verification = AuditVerification {
3186 chain: VerifyOutcome::Broken {
3187 rows_checked: 1,
3188 first_broken_seq: 2,
3189 first_broken_transaction_id: "tx".to_string(),
3190 expected: "e".to_string(),
3191 actual: "a".to_string(),
3192 },
3193 events: VerifyOutcome::CannotVerify {
3194 reason: "no key".to_string(),
3195 },
3196 binding: BindingOutcome::Consistent {
3197 bindings_checked: 0,
3198 },
3199 attribution: None,
3200 status: None,
3201 };
3202 assert_eq!(verification.exit_code(), 1);
3203 }
3204
3205 #[test]
3206 fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
3207 let verification = AuditVerification {
3208 chain: VerifyOutcome::Intact { rows_checked: 3 },
3209 events: VerifyOutcome::Intact { rows_checked: 1 },
3210 binding: BindingOutcome::MissingEvent {
3211 transaction_seq: 3,
3212 event_tip: "abc".to_string(),
3213 },
3214 attribution: None,
3215 status: None,
3216 };
3217 assert_eq!(verification.exit_code(), 1);
3218 }
3219
3220 #[test]
3221 fn all_three_clean_is_exit_zero() {
3222 let verification = AuditVerification {
3223 chain: VerifyOutcome::Intact { rows_checked: 3 },
3224 events: VerifyOutcome::Intact { rows_checked: 2 },
3225 binding: BindingOutcome::Consistent {
3226 bindings_checked: 1,
3227 },
3228 attribution: None,
3229 status: None,
3230 };
3231 assert_eq!(verification.exit_code(), 0);
3232 }
3233
3234 #[test]
3235 fn binding_exit_codes_split_clean_tampered_and_unchecked() {
3236 assert_eq!(
3237 binding_outcome_to_exit_code(&BindingOutcome::Consistent {
3238 bindings_checked: 0
3239 }),
3240 0
3241 );
3242 assert_eq!(
3243 binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
3244 transaction_seq: 7,
3245 event_tip: "abc".to_string()
3246 }),
3247 1
3248 );
3249 assert_eq!(binding_outcome_to_exit_code(&BindingOutcome::NotChecked), 2);
3250 }
3251
3252 #[test]
3253 fn intact_chain_verifies() {
3254 let key = fixed_key();
3255 let rows = build_chain(&key, 5);
3256 let outcome = verify_chain(&key, &rows);
3257 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
3258 assert_eq!(outcome_to_exit_code(&outcome), 0);
3259 }
3260
3261 #[test]
3262 fn empty_chain_verifies() {
3263 let key = fixed_key();
3264 let outcome = verify_chain(&key, &[]);
3265 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
3266 }
3267
3268 #[test]
3269 fn tampered_summary_breaks_chain_at_first_offending_row() {
3270 let key = fixed_key();
3271 let mut rows = build_chain(&key, 3);
3272 rows[1].summary = "TAMPERED".to_string();
3274 let outcome = verify_chain(&key, &rows);
3275 match outcome {
3276 VerifyOutcome::Broken {
3277 first_broken_seq, ..
3278 } => assert_eq!(first_broken_seq, 2),
3279 other => panic!("expected Broken, got {other:?}"),
3280 }
3281 }
3282
3283 #[test]
3284 fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
3285 let key = fixed_key();
3286 let mut rows = build_chain(&key, 4);
3287 rows.remove(1);
3289 let outcome = verify_chain(&key, &rows);
3290 match outcome {
3291 VerifyOutcome::Broken {
3292 first_broken_seq, ..
3293 } => assert_eq!(
3294 first_broken_seq, 3,
3295 "first broken row is the one whose prev_hash refers to deleted row"
3296 ),
3297 other => panic!("expected Broken, got {other:?}"),
3298 }
3299 }
3300
3301 #[test]
3302 fn inserted_forged_row_breaks_chain() {
3303 let key = fixed_key();
3310 let mut rows = build_chain(&key, 3);
3311
3312 let forged = ChainRow {
3314 caller_principal: Some("uid:1000".to_string()),
3318 seq: 2,
3319 key_id: CURRENT_KEY_ID.to_string(),
3320 transaction_id: "tx-forged".to_string(),
3321 request_id: "req-forged".to_string(),
3322 request_hash: "hash-forged".to_string(),
3323 action_name: "InstallFlatpak".to_string(),
3324 risk_level: RiskLevel::Medium,
3325 summary: "Forged row".to_string(),
3326 approval_id: None,
3327 warnings_json: "[]".to_string(),
3328 created_at: "2026-04-25T13:00:00Z".to_string(),
3329 prev_chain_hash: rows[0].chain_hash.clone(),
3331 chain_hash: "0".repeat(HASH_HEX_LEN),
3333 chain_version: CHAIN_VERSION_CURRENT,
3334 caller_role: Some("Dev".to_string()),
3335 event_tip: Some(String::new()),
3336 };
3337
3338 let mut rest: Vec<ChainRow> = rows.split_off(1);
3340 for r in rest.iter_mut() {
3341 r.seq += 1;
3342 }
3343 rows.push(forged);
3344 rows.extend(rest);
3345
3346 let outcome = verify_chain(&key, &rows);
3350 match outcome {
3351 VerifyOutcome::Broken {
3352 first_broken_seq, ..
3353 } => assert_eq!(
3354 first_broken_seq, 2,
3355 "the verifier must flag the forged row itself, not a later one"
3356 ),
3357 other => panic!("expected Broken, got {other:?}"),
3358 }
3359 }
3360
3361 #[test]
3364 fn signature_verifies_under_exported_public_key() {
3365 let key = fixed_key();
3367 let rows = build_chain(&key, 4);
3368 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3369 assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
3370 }
3371
3372 #[test]
3373 fn foreign_public_key_cannot_validate_chain() {
3374 let signer = AuditKey::from_bytes(vec![0x11; 32]);
3378 let rows = build_chain(&signer, 3);
3379 let other = AuditKey::from_bytes(vec![0x22; 32]);
3380 let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
3381 assert!(matches!(
3382 outcome,
3383 VerifyOutcome::Broken {
3384 first_broken_seq: 1,
3385 ..
3386 }
3387 ));
3388 }
3389
3390 #[test]
3391 fn verifying_key_hex_is_a_32_byte_public_key() {
3392 let key = fixed_key();
3393 let vk_hex = key.verifying_key_hex();
3394 assert_eq!(
3395 vk_hex.len(),
3396 64,
3397 "32-byte ed25519 public key = 64 hex chars"
3398 );
3399 assert!(hex::decode(&vk_hex).is_ok());
3400 }
3401
3402 #[test]
3403 fn malformed_signature_hex_is_broken_not_panic() {
3404 let key = fixed_key();
3405 let mut rows = build_chain(&key, 2);
3406 rows[0].chain_hash = "not-valid-hex!!".to_string();
3407 let outcome = verify_chain(&key, &rows);
3408 assert!(matches!(
3409 outcome,
3410 VerifyOutcome::Broken {
3411 first_broken_seq: 1,
3412 ..
3413 }
3414 ));
3415 }
3416
3417 #[test]
3418 fn bad_public_key_hex_yields_cannot_verify() {
3419 let key = fixed_key();
3420 let rows = build_chain(&key, 1);
3421 let outcome = verify_chain_with_pubkey("zz", &rows);
3422 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3423 }
3424
3425 #[test]
3426 fn load_or_generate_writes_public_key_sidecar() {
3427 let dir = tempfile::tempdir().unwrap();
3428 let path = dir.path().join("audit-key");
3429 let key = AuditKey::load_or_generate(&path).unwrap();
3430 let pub_path = dir.path().join("audit-key.pub");
3431 assert!(pub_path.exists(), "public key sidecar must be written");
3432 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
3433 assert_eq!(pub_hex.trim(), key.verifying_key_hex());
3434 }
3435
3436 #[test]
3437 fn wrong_key_id_yields_cannot_verify() {
3438 let key = fixed_key();
3439 let mut rows = build_chain(&key, 2);
3440 rows[0].key_id = "v99".to_string();
3441 let outcome = verify_chain(&key, &rows);
3442 assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3443 assert_eq!(outcome_to_exit_code(&outcome), 2);
3444 }
3445
3446 #[test]
3447 fn exit_code_for_each_outcome() {
3448 assert_eq!(
3449 outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
3450 0
3451 );
3452 assert_eq!(
3453 outcome_to_exit_code(&VerifyOutcome::Broken {
3454 rows_checked: 0,
3455 first_broken_seq: 1,
3456 first_broken_transaction_id: String::new(),
3457 expected: String::new(),
3458 actual: String::new(),
3459 }),
3460 1
3461 );
3462 assert_eq!(
3463 outcome_to_exit_code(&VerifyOutcome::CannotVerify {
3464 reason: String::new()
3465 }),
3466 2
3467 );
3468 }
3469
3470 #[test]
3473 fn load_or_generate_creates_with_0600_mode() {
3474 use std::os::unix::fs::PermissionsExt;
3475 let dir = tempfile::tempdir().unwrap();
3476 let path = dir.path().join("audit-key");
3477 let _key = AuditKey::load_or_generate(&path).unwrap();
3478 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
3479 assert_eq!(mode, 0o600);
3480 }
3481
3482 #[test]
3483 fn load_or_generate_is_idempotent() {
3484 let dir = tempfile::tempdir().unwrap();
3485 let path = dir.path().join("audit-key");
3486 let key1_bytes = {
3487 let _ = AuditKey::load_or_generate(&path).unwrap();
3488 std::fs::read(&path).unwrap()
3489 };
3490 let _ = AuditKey::load_or_generate(&path).unwrap();
3491 let key2_bytes = std::fs::read(&path).unwrap();
3492 assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
3493 }
3494
3495 #[test]
3496 fn rejects_world_readable_key_file() {
3497 use std::os::unix::fs::PermissionsExt;
3498 let dir = tempfile::tempdir().unwrap();
3499 let path = dir.path().join("audit-key");
3500 std::fs::write(&path, vec![0u8; 32]).unwrap();
3501 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3502 let result = AuditKey::load_or_generate(&path);
3503 assert!(matches!(
3504 result,
3505 Err(AuditKeyError::UnsafePermissions { .. })
3506 ));
3507 }
3508
3509 #[test]
3510 fn rejects_short_key_file() {
3511 use std::os::unix::fs::PermissionsExt;
3512 let dir = tempfile::tempdir().unwrap();
3513 let path = dir.path().join("audit-key");
3514 std::fs::write(&path, vec![0u8; 8]).unwrap();
3515 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
3516 let result = AuditKey::load_or_generate(&path);
3517 assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
3518 }
3519
3520 #[test]
3526 fn debug_redacts_key_bytes_and_their_hex_encoding() {
3527 let raw = (0u8..32).collect::<Vec<u8>>();
3529 let key = AuditKey::from_bytes(raw.clone());
3530 let dbg = format!("{key:?}");
3531
3532 assert!(
3533 dbg.contains("redacted"),
3534 "Debug output must contain the literal 'redacted' marker, got: {dbg}"
3535 );
3536
3537 let hex_lower = hex::encode(&raw);
3539 let hex_upper = hex::encode_upper(&raw);
3540 assert!(
3541 !dbg.contains(&hex_lower),
3542 "Debug output leaks lowercase hex of key bytes: {dbg}"
3543 );
3544 assert!(
3545 !dbg.contains(&hex_upper),
3546 "Debug output leaks uppercase hex of key bytes: {dbg}"
3547 );
3548
3549 for b in &raw {
3553 let pair = format!("{b:02x}");
3554 if *b >= 0x10 && pair != "1e" && pair != "1f" {
3559 assert!(
3560 !dbg.contains(&pair),
3561 "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
3562 );
3563 }
3564 }
3565 }
3566
3567 #[test]
3570 fn debug_preserves_key_id() {
3571 let key = AuditKey::from_bytes(vec![0xff; 32]);
3572 let dbg = format!("{key:?}");
3573 assert!(
3574 dbg.contains("key_id"),
3575 "Debug output must label the key_id field: {dbg}"
3576 );
3577 assert!(
3578 dbg.contains(CURRENT_KEY_ID),
3579 "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
3580 );
3581 }
3582
3583 #[test]
3586 fn checkpoint_consistent_with_intact_chain() {
3587 let key = fixed_key();
3588 let rows = build_chain(&key, 5);
3589 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3590 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3591 assert_eq!(
3592 outcome,
3593 CheckpointOutcome::Consistent {
3594 checkpoints_checked: 1
3595 }
3596 );
3597 }
3598
3599 #[test]
3600 fn checkpoint_detects_truncation() {
3601 let key = fixed_key();
3602 let full = build_chain(&key, 5);
3603 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
3605 let truncated = &full[..3];
3606 let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
3607 assert!(matches!(
3608 outcome,
3609 CheckpointOutcome::Truncated {
3610 checkpoint_seq: 5,
3611 current_max_seq: 3
3612 }
3613 ));
3614 }
3615
3616 #[test]
3617 fn checkpoint_detects_rewrite() {
3618 let key = fixed_key();
3619 let mut rows = build_chain(&key, 4);
3620 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3621 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
3623 let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3624 assert!(matches!(
3625 outcome,
3626 CheckpointOutcome::TipMismatch { seq: 3, .. }
3627 ));
3628 }
3629
3630 #[test]
3631 fn checkpoint_bad_signature_detected() {
3632 let key = fixed_key();
3633 let rows = build_chain(&key, 3);
3634 let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
3635 cp.signature = "0".repeat(HASH_HEX_LEN); let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3637 assert!(matches!(
3638 outcome,
3639 CheckpointOutcome::BadSignature { seq: 2 }
3640 ));
3641 }
3642
3643 #[test]
3644 fn checkpoint_foreign_key_rejected() {
3645 let signer = AuditKey::from_bytes(vec![0x11; 32]);
3647 let rows = build_chain(&signer, 3);
3648 let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
3649 let other = AuditKey::from_bytes(vec![0x22; 32]);
3650 let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
3651 assert!(matches!(
3652 outcome,
3653 CheckpointOutcome::BadSignature { seq: 2 }
3654 ));
3655 }
3656
3657 #[test]
3658 fn pubkey_verify_detects_tampered_middle_row() {
3659 let key = fixed_key();
3662 let mut rows = build_chain(&key, 4);
3663 rows[2].summary = "TAMPERED".to_string();
3664 let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3665 assert!(matches!(
3666 outcome,
3667 VerifyOutcome::Broken {
3668 first_broken_seq: 3,
3669 ..
3670 }
3671 ));
3672 }
3673
3674 #[test]
3675 fn multiple_checkpoints_all_consistent() {
3676 let key = fixed_key();
3677 let rows = build_chain(&key, 5);
3678 let cps = vec![
3679 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
3680 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
3681 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
3682 ];
3683 assert_eq!(
3684 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
3685 CheckpointOutcome::Consistent {
3686 checkpoints_checked: 3
3687 }
3688 );
3689 }
3690
3691 #[test]
3692 fn middle_checkpoint_failure_is_reported() {
3693 let key = fixed_key();
3695 let mut rows = build_chain(&key, 5);
3696 let cps = vec![
3697 key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
3698 key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
3699 key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
3700 ];
3701 rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); assert!(matches!(
3703 verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
3704 CheckpointOutcome::TipMismatch { seq: 3, .. }
3705 ));
3706 }
3707
3708 #[test]
3709 fn checkpoint_created_at_is_signed() {
3710 let key = fixed_key();
3712 let rows = build_chain(&key, 3);
3713 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3714 cp.created_at = "2020-01-01T00:00:00Z".to_string();
3715 assert!(matches!(
3716 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
3717 CheckpointOutcome::BadSignature { .. }
3718 ));
3719 }
3720
3721 #[test]
3722 fn checkpoint_seq_is_signed() {
3723 let key = fixed_key();
3725 let rows = build_chain(&key, 3);
3726 let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3727 cp.seq = 2;
3728 assert!(matches!(
3729 verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
3730 CheckpointOutcome::BadSignature { seq: 2 }
3731 ));
3732 }
3733
3734 #[test]
3735 fn checkpoint_detects_full_wipe() {
3736 let key = fixed_key();
3738 let full = build_chain(&key, 3);
3739 let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
3740 assert!(matches!(
3741 verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
3742 CheckpointOutcome::Truncated {
3743 checkpoint_seq: 3,
3744 current_max_seq: 0
3745 }
3746 ));
3747 }
3748
3749 #[test]
3750 fn domain_tags_are_distinct_and_prefix_free() {
3751 let tags: [(&str, &[u8]); 4] = [
3758 ("row", ROW_DOMAIN),
3759 ("checkpoint", CHECKPOINT_DOMAIN),
3760 ("approval", APPROVAL_DOMAIN),
3761 ("event", EVENT_DOMAIN),
3762 ];
3763 for (i, (name_a, a)) in tags.iter().enumerate() {
3764 for (name_b, b) in tags.iter().skip(i + 1) {
3765 assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
3766 assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
3767 assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
3768 }
3769 }
3770 }
3771
3772 #[test]
3773 fn approval_receipt_framing_is_injective() {
3774 let key = fixed_key();
3779 let sep = "\u{1f}";
3780 let a = key.approval_receipt(&format!("tx{sep}b"), "c");
3781 let b = key.approval_receipt("tx", &format!("b{sep}c"));
3782 assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
3783
3784 assert_eq!(
3786 key.approval_receipt("tx-1", "hash-1"),
3787 key.approval_receipt("tx-1", "hash-1"),
3788 );
3789 assert_eq!(
3791 key.approval_commitment("tx-1", "hash-1"),
3792 approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
3793 );
3794 }
3795}