Skip to main content

sysknife_daemon/
audit_chain.rs

1//! Forward Ed25519-signed hash chain for the audit log.
2//!
3//! Each `transactions` row stores `chain_hash` and `prev_chain_hash` columns.
4//! On insert, the daemon computes
5//!
6//! ```text
7//! chain_hash = ed25519_sign(ROW_DOMAIN || canonical(immutable_fields) || prev_chain_hash, signing_key)
8//! ```
9//!
10//! The `ROW_DOMAIN` prefix is **part of the signed message**, not decoration:
11//! it is what stops a row signature from ever verifying as a checkpoint or
12//! approval-receipt signature. An independent verifier that omits it will
13//! never reproduce a valid `chain_hash`.
14//!
15//! and stores both (the signature is hex-encoded; the first row in a chain has
16//! `prev_chain_hash = ""`). Ed25519 signatures are deterministic (RFC 8032), so
17//! the same content always produces the same `chain_hash`.
18//!
19//! Verification (`sysknife audit verify`) walks rows in `seq` order and checks
20//! each row's signature **with the public key**, reporting the first broken
21//! link. Because verification needs only the public key, an auditor or a
22//! central log aggregator can verify the chain **without holding the private
23//! key** — they cannot forge entries. This is the property a symmetric MAC
24//! (the previous HMAC-SHA256 design) could not provide: with an HMAC, the
25//! verifier and the forger are the same principal.
26//!
27//! ## Threat model
28//!
29//! - **Non-repudiation (asymmetric).** The daemon signs with the private key;
30//!   anyone with the exported public key (`<key>.pub`) can verify but not
31//!   forge. A compromise of the verifier does not enable forgery.
32//! - **Compromised host / root.** An attacker who reads the private key file
33//!   can forge *future* entries. Mitigation is to anchor signed checkpoints to
34//!   an append-only external sink (see `checkpoint_sink`) so that later
35//!   tampering with past entries becomes *detectable* and the tamper window is
36//!   bounded to "after compromise".
37//! - **Tail truncation** is undetectable by the chain walk alone: an attacker
38//!   who deletes the last K rows leaves a still-consistent chain. It is caught
39//!   by anchoring signed checkpoints to an independent append-only sink, since
40//!   a truncated chain can no longer reproduce a previously anchored
41//!   `(seq, chain_tip)` (see `verify_checkpoints` and `checkpoint_sink`). The
42//!   best-effort `audit_watermark` journald forward is a lighter complement.
43//! - **In-flight modification** between insert and read is mitigated by
44//!   computing the signature *before* INSERT and writing it in the same SQL
45//!   statement.
46//! - **Status mutations are not in the chain.** The mutable `status` field
47//!   is intentionally excluded — the chain protects the *authorisation
48//!   decision* (immutable fields captured at insert time), not the live
49//!   execution state. Status transitions ARE chained, separately: the
50//!   append-only `audit_events` table exists (created by migration v2 in
51//!   `transactions.rs`) and `verify_event_chain` / `verify_event_binding`
52//!   below verify it. This paragraph described that as future work long
53//!   after it shipped, which is worse than saying nothing — a reader would
54//!   conclude the capability is missing and either duplicate it or assume
55//!   status history is unprotected.
56//!
57//! ## Key management
58//!
59//! The Ed25519 private key (a 32-byte seed) lives in a file. By default the
60//! path is `<db_dir>/audit-key` (sibling of the SQLite database, or of
61//! whatever directory `sysknife_core::default_database_path` resolves to in
62//! production), and the env var `SYSKNIFE_AUDIT_KEY_PATH` overrides it for
63//! systemd unit drop-ins (typically `/etc/sysknife/audit-key`). The file is
64//! created with mode `0o600` on first daemon start if it does not exist;
65//! subsequent runs refuse to start if it is world-readable. The public key is
66//! written alongside as `<key>.pub` (hex) for auditors and aggregators.
67//!
68//! Future epochs (key rotation): each row already carries a `key_id`
69//! column. A planned rotation flow appends a checkpoint row signed with the
70//! outgoing key whose payload references both public-key fingerprints;
71//! verification walks the chain through epoch boundaries by looking up each
72//! row's `key_id` in a directory of retired public keys. For now, all rows use
73//! `key_id = "v1"` and rotation is manual (delete the chain, regenerate).
74
75use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
76use sha2::{Digest, Sha256};
77use std::path::{Path, PathBuf};
78use sysknife_types::RiskLevel;
79use zeroize::Zeroize;
80
81/// Stable identifier for the current key generation. Stored in every row.
82/// Tied to the schema, not the key bytes — rotation will introduce `"v2"` etc.
83pub const CURRENT_KEY_ID: &str = "v1";
84
85/// Hex-encoded length of an Ed25519 signature (64 raw bytes → 128 hex chars).
86pub const HASH_HEX_LEN: usize = 128;
87
88/// Domain-separation tags for Ed25519 signing contexts. They MUST stay distinct
89/// and prefix-free so a signature made in one context can never verify in any
90/// other. Enforced by the `domain_tags_are_distinct_and_prefix_free` test.
91const ROW_DOMAIN: &[u8] = b"sysknife-audit-row-v1\x1f";
92const CHECKPOINT_DOMAIN: &[u8] = b"sysknife-checkpoint-v1\x1f";
93const APPROVAL_DOMAIN: &[u8] = b"sysknife-approval-receipt-v1\x1f";
94const EVENT_DOMAIN: &[u8] = b"sysknife-audit-event-v1\x1f";
95
96/// Row encoding written before the caller-identity migration: no
97/// `caller_role`, no `event_tip`. Still verifiable — see [`ChainIdentity`].
98pub const CHAIN_VERSION_LEGACY: u32 = 1;
99/// Row encoding that added `caller_role` and `event_tip`.
100///
101/// This is a **stable literal, never an alias for the newest version**. The v2
102/// encoder signs this value, and `ChainRow::identity` dispatches stored rows
103/// against it. Point either at `CHAIN_VERSION_CURRENT` and the next encoding
104/// bump silently re-encodes every v2 row on disk, breaking audit logs while the
105/// unit suite stays green, because in-memory tests sign and verify under the
106/// same constant. `a_row_written_by_the_previous_release_still_verifies` is the
107/// golden vector that notices.
108pub const CHAIN_VERSION_V2: u32 = 2;
109/// Row encoding that added `caller_principal` on top of v2, so a row names the
110/// account that asked, not only its role class.
111///
112/// A stable literal for the same reason [`CHAIN_VERSION_V2`] is one: the v3
113/// encoder signs this value and `ChainRow::identity` dispatches stored v3 rows
114/// against it, so it must not move when a v4 encoding arrives.
115/// `a_v3_row_on_disk_still_verifies` is the golden vector that notices if it does.
116pub const CHAIN_VERSION_V3: u32 = 3;
117/// The encoding this binary *writes*. Always an alias for the newest versioned
118/// constant, never used to encode or dispatch a specific generation.
119///
120/// Kept distinct from the per-version literals because those two jobs pull in
121/// opposite directions: the writer must move with each new encoding, while every
122/// stored generation must keep being reproduced byte for byte forever.
123pub const CHAIN_VERSION_CURRENT: u32 = CHAIN_VERSION_V3;
124
125/// Loaded Ed25519 signing key + its identifier. Construct via
126/// [`AuditKey::load_or_generate`].
127///
128/// `Clone` is intentional: the audit-verify CLI needs to load the key once
129/// and share it between the SQLite read-only path and the Postgres pool.
130///
131/// `Debug` is implemented manually to redact the signing key. A derived
132/// `Debug` would dump the private key via any `tracing::debug!("{key:?}")`
133/// or `dbg!(key)` site, which would leak the audit secret into journald.
134/// We keep `key_id` visible because operators need to identify which key
135/// generation a record belongs to when triaging chain breaks.
136#[derive(Clone)]
137pub struct AuditKey {
138    key_id: String,
139    signing: SigningKey,
140}
141
142impl std::fmt::Debug for AuditKey {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        // Never render the private key. A derived `Debug` (or printing the
145        // signing key) would leak the audit secret into journald via any
146        // `tracing::debug!("{key:?}")`. `key_id` is not secret and is kept
147        // visible for triaging chain breaks.
148        f.debug_struct("AuditKey")
149            .field("key_id", &self.key_id)
150            .field("signing", &format_args!("<redacted signing key>"))
151            .finish()
152    }
153}
154
155// The `SigningKey` zeroizes its secret scalar on drop (ed25519-dalek `zeroize`
156// feature), so no manual `Drop` is needed to keep the private key out of
157// post-free memory.
158
159#[derive(Debug, thiserror::Error)]
160pub enum AuditKeyError {
161    #[error("io error reading audit key {path}: {source}")]
162    Io {
163        path: PathBuf,
164        #[source]
165        source: std::io::Error,
166    },
167    #[error(
168        "audit key file {path} has unsafe permissions {mode:#o}; \
169         tighten with `chmod 600 {path:?}` and restart"
170    )]
171    UnsafePermissions { path: PathBuf, mode: u32 },
172    #[error(
173        "audit key file {path} is too short ({len} bytes); \
174         expected at least 32 bytes of random material"
175    )]
176    KeyTooShort { path: PathBuf, len: usize },
177}
178
179impl AuditKey {
180    /// Load the audit key from `path`. If the file does not exist, generate a
181    /// 32-byte cryptographically random key and write it with mode `0o600`.
182    ///
183    /// On every load (including freshly generated), the file's permissions
184    /// are checked: any bit beyond `0o600` for owner-only access is rejected
185    /// — a world-readable audit key is a self-defeating audit chain.
186    pub fn load_or_generate(path: &Path) -> Result<Self, AuditKeyError> {
187        if !path.exists() {
188            generate_key_at(path)?;
189        }
190
191        let metadata = std::fs::metadata(path).map_err(|e| AuditKeyError::Io {
192            path: path.to_path_buf(),
193            source: e,
194        })?;
195
196        use std::os::unix::fs::PermissionsExt;
197        let mode = metadata.permissions().mode() & 0o777;
198        if mode & 0o077 != 0 {
199            // Group or world bits set — reject.
200            return Err(AuditKeyError::UnsafePermissions {
201                path: path.to_path_buf(),
202                mode,
203            });
204        }
205
206        let mut key_bytes = std::fs::read(path).map_err(|e| AuditKeyError::Io {
207            path: path.to_path_buf(),
208            source: e,
209        })?;
210        if key_bytes.len() < 32 {
211            return Err(AuditKeyError::KeyTooShort {
212                path: path.to_path_buf(),
213                len: key_bytes.len(),
214            });
215        }
216
217        let mut seed = [0u8; 32];
218        seed.copy_from_slice(&key_bytes[..32]);
219        let signing = SigningKey::from_bytes(&seed);
220        seed.zeroize();
221        key_bytes.zeroize();
222
223        Ok(Self {
224            key_id: CURRENT_KEY_ID.to_string(),
225            signing,
226        })
227    }
228
229    /// Construct a key from a 32-byte seed. For tests only — production builds
230    /// always go through [`Self::load_or_generate`] for the permission check.
231    #[cfg(test)]
232    pub fn from_bytes(bytes: Vec<u8>) -> Self {
233        let mut seed = [0u8; 32];
234        seed.copy_from_slice(&bytes[..32]);
235        let signing = SigningKey::from_bytes(&seed);
236        seed.zeroize();
237        Self {
238            key_id: CURRENT_KEY_ID.to_string(),
239            signing,
240        }
241    }
242
243    /// Stable identifier for this key generation.
244    pub fn key_id(&self) -> &str {
245        &self.key_id
246    }
247
248    /// Compute the chain signature for `content` linked to `prev_chain_hash`.
249    ///
250    /// Hex-encoded Ed25519 signature over `canonical(content) || prev_chain_hash`.
251    /// Deterministic (RFC 8032): identical inputs always yield the same value.
252    pub fn chain_hash(&self, content: &ChainContent, prev_chain_hash: &str) -> String {
253        let sig = self.signing.sign(&chain_message(content, prev_chain_hash));
254        hex::encode(sig.to_bytes())
255    }
256
257    /// The public verifying key for this audit key.
258    pub fn verifying_key(&self) -> VerifyingKey {
259        self.signing.verifying_key()
260    }
261
262    /// Hex-encoded 32-byte Ed25519 public key. Safe to publish; auditors use it
263    /// to verify the chain without the ability to forge entries.
264    pub fn verifying_key_hex(&self) -> String {
265        hex::encode(self.signing.verifying_key().to_bytes())
266    }
267
268    /// Derive the bearer receipt for one immutable preview. The signature is
269    /// deterministic, so a failed response delivery can be retried without
270    /// storing plaintext bearer credentials in the database.
271    pub fn approval_receipt(&self, transaction_id: &str, request_hash: &str) -> String {
272        // Frame both fields through `push_field` (the same escaping used for
273        // chain rows) so the signed message is injective in
274        // (transaction_id, request_hash). Raw concatenation with a bare 0x1F
275        // would alias distinct inputs if either field ever contained 0x1F;
276        // escaping removes that dependency on the callers' value shapes.
277        let mut message = APPROVAL_DOMAIN.to_vec();
278        push_field(&mut message, "txid", transaction_id);
279        push_field(&mut message, "reqhash", request_hash);
280        hex::encode(self.signing.sign(&message).to_bytes())
281    }
282
283    /// Chain-stored SHA-256 commitment to the deterministic approval receipt.
284    pub fn approval_commitment(&self, transaction_id: &str, request_hash: &str) -> String {
285        approval_receipt_digest(&self.approval_receipt(transaction_id, request_hash))
286    }
287}
288
289pub fn approval_receipt_digest(receipt: &str) -> String {
290    hex::encode(Sha256::digest(receipt.as_bytes()))
291}
292
293/// Message signed for a row: `ROW_DOMAIN || canonical(content) || prev_chain_hash`.
294///
295/// The leading domain tag separates row signatures from checkpoint signatures
296/// so a signature produced in one context can never verify in the other, even
297/// if the framed fields were ever to overlap.
298fn chain_message(content: &ChainContent, prev_chain_hash: &str) -> Vec<u8> {
299    let mut msg = ROW_DOMAIN.to_vec();
300    msg.extend_from_slice(&content.canonical_bytes());
301    msg.extend_from_slice(prev_chain_hash.as_bytes());
302    msg
303}
304
305/// Resolve the audit key file path: `$SYSKNIFE_AUDIT_KEY_PATH` if set,
306/// otherwise `<db_dir>/audit-key` (sibling of the database). This is the single
307/// definition of the key-location precedence documented in the module header;
308/// the CLI verify/checkpoint paths resolve through it so they always agree with
309/// the daemon on where the key lives.
310pub fn resolve_audit_key_path(db_path: &Path) -> PathBuf {
311    std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
312        .map(PathBuf::from)
313        .unwrap_or_else(|_| {
314            db_path
315                .parent()
316                .unwrap_or_else(|| Path::new("."))
317                .join("audit-key")
318        })
319}
320
321/// Generate a 32-byte random key and write it to `path` with mode `0o600`.
322/// Parent directory is created with mode `0o700` if missing.
323fn generate_key_at(path: &Path) -> Result<(), AuditKeyError> {
324    use std::io::Write as _;
325    use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
326
327    if let Some(parent) = path.parent() {
328        if !parent.exists() {
329            std::fs::DirBuilder::new()
330                .recursive(true)
331                .mode(0o700)
332                .create(parent)
333                .map_err(|e| AuditKeyError::Io {
334                    path: parent.to_path_buf(),
335                    source: e,
336                })?;
337        }
338    }
339
340    let mut bytes = [0u8; 32];
341    fill_random(&mut bytes).map_err(|e| AuditKeyError::Io {
342        path: path.to_path_buf(),
343        source: e,
344    })?;
345
346    let mut f = std::fs::OpenOptions::new()
347        .write(true)
348        .create_new(true)
349        .mode(0o600)
350        .open(path)
351        .map_err(|e| AuditKeyError::Io {
352            path: path.to_path_buf(),
353            source: e,
354        })?;
355    f.write_all(&bytes).map_err(|e| AuditKeyError::Io {
356        path: path.to_path_buf(),
357        source: e,
358    })?;
359    f.sync_all().map_err(|e| AuditKeyError::Io {
360        path: path.to_path_buf(),
361        source: e,
362    })?;
363
364    // Write the public key alongside as `<key>.pub` (hex). Not secret: it lets
365    // an auditor or aggregator verify the chain without the private key.
366    let signing = SigningKey::from_bytes(&bytes);
367    let pub_hex = hex::encode(signing.verifying_key().to_bytes());
368    bytes.zeroize();
369    let mut pub_path = path.as_os_str().to_os_string();
370    pub_path.push(".pub");
371    std::fs::write(&pub_path, format!("{pub_hex}\n")).map_err(|e| AuditKeyError::Io {
372        path: PathBuf::from(pub_path),
373        source: e,
374    })?;
375    Ok(())
376}
377
378/// Fill `buf` with bytes from the kernel CSPRNG via `/dev/urandom`.
379fn fill_random(buf: &mut [u8]) -> std::io::Result<()> {
380    use std::io::Read as _;
381    let mut f = std::fs::File::open("/dev/urandom")?;
382    f.read_exact(buf)
383}
384
385/// Immutable fields hashed into the chain. Status is intentionally absent —
386/// see module docs.
387///
388/// # Security contract — chain-content immutability
389///
390/// Every field in this struct is captured **once** at INSERT time and baked
391/// into `chain_hash = ed25519_sign(canonical(self) || prev_chain_hash, key)`.
392/// After the row is written the hash is a one-time commitment: **no field
393/// in this struct may ever be mutated in place**.
394///
395/// `summary` is the field most likely to attract a future "let me just fix
396/// that typo" API. **Do not add an `update_summary` (or similar) function.**
397/// If a correction is genuinely needed, choose one of the two safe options:
398///
399/// 1. **Insert a corrective row** — a new transaction row that references the
400///    original `transaction_id` in its own `summary`, leaving the original
401///    row and its chain hash untouched.
402/// 2. **Extend the chain protocol** — introduce a dedicated amendment record
403///    type with its own chain link, so that both the original commitment and
404///    the correction are auditable.
405///
406/// Any other approach silently breaks chain integrity: `verify_chain` will
407/// flag the modified row as `Broken` because the stored signature will no
408/// longer verify against the row's content.
409///
410/// The canonical serialisation is stable across SQLite/Postgres backends.
411/// Each field is emitted as
412///
413/// ```text
414///     <tag-name> 0x1E <tag-value> 0x1F
415/// ```
416///
417/// where `0x1E` is the *tag/value* separator within a single field and `0x1F`
418/// is the *field* separator that terminates the field and introduces the
419/// next one. We use the ASCII C0 byte values RS (0x1E) and US (0x1F)
420/// because they are guaranteed not to appear in any normal text field, but
421/// **our role assignment is the inverse of the ASCII C0 convention**
422/// (where RS = "record separator" and US = "unit separator"). The names
423/// are kept in the source for byte-level traceability against the canonical
424/// buffer, not as a claim about ASCII semantics.
425///
426/// Inside a value, the four
427/// bytes `\\`, NUL, `RS`, `US` are escaped to `\\\\`, `\\0`, `\\1E`, `\\1F`
428/// respectively. The escape table is **prefix-free** (every escape starts
429/// with `\\`), so any value can be injected without ambiguity. See
430/// `push_field` for the implementation and tests for the round-trip.
431/// Which generation of the canonical row encoding a row was signed under.
432///
433/// # Why this is an enum and not two `Option` fields
434///
435/// Adding a field to [`ChainContent`] changes the signed message, so every row
436/// written before the change would re-encode differently and report as
437/// `Broken`. Chains already exist in the field, and "delete your audit log to
438/// upgrade" is not an acceptable migration for an audit log. The stored
439/// `chain_version` column therefore selects the encoding **per row**: legacy
440/// rows keep the exact bytes they were signed over, new rows carry the
441/// identity fields.
442///
443/// A downgrade is not a hiding place. Rewriting a `V2` row as `LegacyV1` (to
444/// erase `caller_role`) makes verification re-encode it without the identity
445/// fields, so the stored signature — made over the `V2` message — no longer
446/// verifies and the row reports as `Broken`.
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub enum ChainIdentity<'a> {
449    /// `chain_version = 1`. Written before the identity migration.
450    LegacyV1,
451    /// `chain_version = 2`. Binds the row to the authenticated caller and to
452    /// the approval-event chain tip at insert time.
453    V2 {
454        /// Role the daemon resolved for the connection that requested this
455        /// action (`SO_PEERCRED` for Unix sockets, token for vsock). Signing it
456        /// lets an auditor answer which privilege tier asked; naming the account
457        /// is v3's `caller_principal`.
458        caller_role: &'a str,
459        /// `chain_hash` of the last [`EventRow`] at insert time, or `""` when
460        /// the event chain is empty. This is the cross-chain binding: because
461        /// checkpoints anchor the *transaction* chain tip, a committed
462        /// `event_tip` transitively anchors the event chain, so deleting
463        /// approval events below it becomes detectable.
464        event_tip: &'a str,
465    },
466    /// `chain_version = 3`. Everything v2 binds, plus the individual account.
467    ///
468    /// v2 answers "an Admin did this". On a host with two members of
469    /// `sysknife-admin` their signed records were indistinguishable, so the
470    /// trail could not answer the first question an investigation asks. v3
471    /// signs a principal as well.
472    V3 {
473        /// As v2.
474        caller_role: &'a str,
475        /// As v2.
476        event_tip: &'a str,
477        /// Scheme-prefixed identity of the caller, produced by
478        /// [`crate::auth::CallerPrincipal`]: `uid:1000` for a Unix-socket peer
479        /// whose credentials the kernel attested, `token:vsock` for a vsock
480        /// connection authenticated by the pre-shared token, and
481        /// `none:unattributed` when the daemon could establish neither.
482        ///
483        /// The scheme is signed along with the value on purpose. An auditor must
484        /// be able to tell a kernel-attested account from a shared secret that
485        /// any holder could have presented, and a bare string would erase that
486        /// difference.
487        caller_principal: &'a str,
488    },
489}
490
491impl ChainIdentity<'_> {
492    /// `chain_version` column value for this encoding.
493    pub fn version(&self) -> u32 {
494        match self {
495            Self::LegacyV1 => CHAIN_VERSION_LEGACY,
496            Self::V2 { .. } => CHAIN_VERSION_V2,
497            Self::V3 { .. } => CHAIN_VERSION_V3,
498        }
499    }
500}
501
502#[derive(Debug, Clone, PartialEq, Eq)]
503pub struct ChainContent<'a> {
504    pub seq: u64,
505    pub key_id: &'a str,
506    pub transaction_id: &'a str,
507    pub request_id: &'a str,
508    pub request_hash: &'a str,
509    pub action_name: &'a str,
510    pub risk_level: RiskLevel,
511    /// Human-readable description of the planned action.
512    ///
513    /// **Immutable after insert.** This field is included in the chain hash
514    /// (see [`ChainContent`] struct-level doc). It MUST NOT be updated after
515    /// the row is written — doing so silently invalidates `chain_hash` and
516    /// will be detected as `VerifyOutcome::Broken` by `sysknife audit verify`.
517    /// See the struct-level security contract for the only safe correction
518    /// strategies.
519    pub summary: &'a str,
520    /// Signed receipt commitment for daemon-created previews. Legacy/imported
521    /// rows may be `None`; serialised as empty for wire compatibility.
522    pub approval_id: Option<&'a str>,
523    /// JSON-canonical (sorted keys) array of warning strings.
524    pub warnings_json: &'a str,
525    pub created_at: &'a str,
526    /// Encoding generation. See [`ChainIdentity`] for why this is per-row.
527    pub identity: ChainIdentity<'a>,
528}
529
530impl<'a> ChainContent<'a> {
531    /// Stable canonical encoding of every field. Within each field the
532    /// `0x1E` byte separates tag from value, and `0x1F` terminates the field;
533    /// raw NUL becomes the two-byte escape `\0`.
534    pub fn canonical_bytes(&self) -> Vec<u8> {
535        let risk_level_str = match self.risk_level {
536            RiskLevel::Low => "low",
537            RiskLevel::Medium => "medium",
538            RiskLevel::High => "high",
539        };
540        let approval = self.approval_id.unwrap_or("");
541
542        let mut buf = Vec::with_capacity(512);
543        // Each field is `tag 0x1E value 0x1F`; tags make the canonical form
544        // self-describing for forensics.
545        push_field(&mut buf, "seq", &self.seq.to_string());
546        push_field(&mut buf, "key_id", self.key_id);
547        push_field(&mut buf, "transaction_id", self.transaction_id);
548        push_field(&mut buf, "request_id", self.request_id);
549        push_field(&mut buf, "request_hash", self.request_hash);
550        push_field(&mut buf, "action_name", self.action_name);
551        push_field(&mut buf, "risk_level", risk_level_str);
552        push_field(&mut buf, "summary", self.summary);
553        push_field(&mut buf, "approval_id", approval);
554        push_field(&mut buf, "warnings_json", self.warnings_json);
555        push_field(&mut buf, "created_at", self.created_at);
556        // Per-generation suffixes are *appended*, so a legacy row's bytes are
557        // unchanged and its signature still verifies. The `chain_version` tag
558        // leads each suffix, so no generation can alias another: two encodings
559        // differ in a signed field, not merely in field count.
560        //
561        // This is an exhaustive `match`, not a sequence of `if let`s, and that
562        // matters more than it looks. With `if let` chains, adding a variant and
563        // forgetting its arm compiles fine and silently encodes the new
564        // generation byte-identically to `LegacyV1` — signing an aliased message,
565        // which is exactly what the tag is supposed to prevent. As a `match`,
566        // the same omission is a compile error.
567        //
568        // Each arm spells its own fields out rather than sharing a helper. The
569        // duplication is deliberate: a frozen encoding must be able to stay
570        // frozen while a newer one changes.
571        match self.identity {
572            ChainIdentity::LegacyV1 => {}
573            ChainIdentity::V2 {
574                caller_role,
575                event_tip,
576            } => {
577                push_field(&mut buf, "chain_version", &CHAIN_VERSION_V2.to_string());
578                push_field(&mut buf, "caller_role", caller_role);
579                push_field(&mut buf, "event_tip", event_tip);
580            }
581            ChainIdentity::V3 {
582                caller_role,
583                event_tip,
584                caller_principal,
585            } => {
586                push_field(&mut buf, "chain_version", &CHAIN_VERSION_V3.to_string());
587                push_field(&mut buf, "caller_role", caller_role);
588                push_field(&mut buf, "event_tip", event_tip);
589                push_field(&mut buf, "caller_principal", caller_principal);
590            }
591        }
592        buf
593    }
594}
595
596/// Append `tag<RS>value<US>` to `buf`, escaping any byte that could otherwise
597/// alias one of the framing characters or another escape sequence.
598///
599/// The escape table is **prefix-free**: every escape starts with `\` (0x5C)
600/// followed by a tag that cannot itself be the start of a different escape.
601/// Concretely:
602///
603/// | Raw byte | Escape       |
604/// |---------:|--------------|
605/// | `\\`     | `\\\\`       |
606/// | `\x00`   | `\\0`        |
607/// | `\x1E`   | `\\1E`       |
608/// | `\x1F`   | `\\1F`       |
609///
610/// The `\\` escape MUST come first: without it, a field value containing the
611/// literal two-byte sequence `\` + `0` would canonicalise to the same bytes
612/// as a raw NUL and produce a chain-signature collision.
613fn push_field(buf: &mut Vec<u8>, tag: &str, value: &str) {
614    buf.extend_from_slice(tag.as_bytes());
615    buf.push(0x1E); // tag/value separator (ASCII RS byte, but used inversely — see ChainContent doc)
616    for b in value.bytes() {
617        match b {
618            b'\\' => buf.extend_from_slice(b"\\\\"),
619            0x00 => buf.extend_from_slice(b"\\0"),
620            0x1E => buf.extend_from_slice(b"\\1E"),
621            0x1F => buf.extend_from_slice(b"\\1F"),
622            other => buf.push(other),
623        }
624    }
625    buf.push(0x1F); // field terminator (ASCII US byte, but used inversely — see ChainContent doc)
626}
627
628/// Result of `verify_chain`.
629#[derive(Debug, Clone, PartialEq, Eq)]
630pub enum VerifyOutcome {
631    /// Chain is intact across all rows checked.
632    Intact { rows_checked: u64 },
633    /// One or more rows fail to chain. First broken row is reported.
634    Broken {
635        rows_checked: u64,
636        first_broken_seq: u64,
637        first_broken_transaction_id: String,
638        expected: String,
639        actual: String,
640    },
641    /// Verification could not be completed (missing key, unreachable storage,
642    /// retired key not on disk, etc.). Distinct from `Broken` — callers map
643    /// this to exit code 2.
644    CannotVerify { reason: String },
645}
646
647/// Process exit code for `sysknife audit verify`.
648///
649/// `0` intact, `1` broken, `2` cannot-verify. The split between 1 and 2
650/// matters: a CI pipeline expecting 0 or 1 must not silently pass on a
651/// missing key file.
652pub fn outcome_to_exit_code(outcome: &VerifyOutcome) -> i32 {
653    match outcome {
654        VerifyOutcome::Intact { .. } => 0,
655        VerifyOutcome::Broken { .. } => 1,
656        VerifyOutcome::CannotVerify { .. } => 2,
657    }
658}
659
660/// One row's worth of chain data, as fetched from the store.
661#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
662pub struct ChainRow {
663    pub seq: u64,
664    pub key_id: String,
665    pub transaction_id: String,
666    pub request_id: String,
667    pub request_hash: String,
668    pub action_name: String,
669    pub risk_level: RiskLevel,
670    pub summary: String,
671    pub approval_id: Option<String>,
672    pub warnings_json: String,
673    pub created_at: String,
674    pub prev_chain_hash: String,
675    pub chain_hash: String,
676    /// Which encoding this row was signed under. See [`ChainIdentity`].
677    pub chain_version: u32,
678    /// `NULL` for legacy rows; required for `chain_version` 2 and 3.
679    pub caller_role: Option<String>,
680    /// `NULL` for legacy rows; required for `chain_version` 2 and 3. May be the
681    /// empty string when the event chain was empty at insert time.
682    pub event_tip: Option<String>,
683    /// `NULL` for rows written before the principal migration (v1 and v2);
684    /// required for `chain_version = 3`. Scheme-prefixed, see
685    /// [`ChainIdentity::V3`].
686    pub caller_principal: Option<String>,
687}
688
689/// Invalid input or stored data encountered while selecting rows for export.
690#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
691pub enum ChainExportError {
692    #[error("--since: {0:?} is not a valid RFC 3339 timestamp")]
693    InvalidSince(String),
694    #[error("audit row {seq} has an invalid RFC 3339 created_at value {created_at:?}")]
695    InvalidCreatedAt { seq: u64, created_at: String },
696}
697
698/// Validate the timestamp accepted by `sysknife audit export --since`.
699pub fn validate_export_since(since: &str) -> Result<(), ChainExportError> {
700    chrono::DateTime::parse_from_rfc3339(since)
701        .map(|_| ())
702        .map_err(|_| ChainExportError::InvalidSince(since.to_string()))
703}
704
705/// Select stored chain rows for export while preserving their `seq` order.
706///
707/// The storage readers return rows in ascending `seq` order. Filtering here,
708/// rather than re-encoding any fields, keeps every exported byte-for-byte
709/// value (especially `chain_hash` and `warnings_json`) exactly as stored and
710/// gives SQLite and PostgreSQL identical `--since` semantics.
711pub fn select_chain_rows_for_export(
712    rows: Vec<ChainRow>,
713    since: Option<&str>,
714    limit: Option<u32>,
715) -> Result<Vec<ChainRow>, ChainExportError> {
716    let since = since
717        .map(|value| {
718            chrono::DateTime::parse_from_rfc3339(value)
719                .map_err(|_| ChainExportError::InvalidSince(value.to_string()))
720        })
721        .transpose()?;
722    let limit = limit.map_or(usize::MAX, |value| value as usize);
723
724    rows.into_iter()
725        .filter_map(|row| {
726            let keep = match since.as_ref() {
727                None => true,
728                Some(since) => match chrono::DateTime::parse_from_rfc3339(&row.created_at) {
729                    Ok(created_at) => created_at >= *since,
730                    Err(_) => {
731                        return Some(Err(ChainExportError::InvalidCreatedAt {
732                            seq: row.seq,
733                            created_at: row.created_at,
734                        }));
735                    }
736                },
737            };
738            keep.then_some(Ok(row))
739        })
740        .take(limit)
741        .collect()
742}
743
744/// Why a stored row's columns do not describe a verifiable encoding.
745#[derive(Debug)]
746pub(crate) enum RowIdentityError {
747    /// The row claims an encoding this binary does not know how to reproduce.
748    /// Genuinely unverifiable, not evidence of tampering — an older binary
749    /// reading a newer chain lands here.
750    UnknownVersion(u32),
751    /// The row claims an identity encoding (2 or 3) but is missing one of its
752    /// columns. For v3 that includes a blank `caller_principal`, treated as
753    /// absent: a row naming nobody must not pass for one naming an account.
754    /// No message can be reconstructed, and the shape is self-contradictory,
755    /// so this counts as a detected break rather than an inability to check.
756    MissingField(&'static str),
757}
758
759impl ChainRow {
760    /// Recover the encoding this row was signed under, so a caller can rebuild
761    /// the exact message that produced `chain_hash`.
762    pub(crate) fn identity(&self) -> Result<ChainIdentity<'_>, RowIdentityError> {
763        match self.chain_version {
764            CHAIN_VERSION_LEGACY => Ok(ChainIdentity::LegacyV1),
765            CHAIN_VERSION_V2 => Ok(ChainIdentity::V2 {
766                caller_role: self
767                    .caller_role
768                    .as_deref()
769                    .ok_or(RowIdentityError::MissingField("caller_role"))?,
770                event_tip: self
771                    .event_tip
772                    .as_deref()
773                    .ok_or(RowIdentityError::MissingField("event_tip"))?,
774            }),
775            CHAIN_VERSION_V3 => Ok(ChainIdentity::V3 {
776                caller_role: self
777                    .caller_role
778                    .as_deref()
779                    .ok_or(RowIdentityError::MissingField("caller_role"))?,
780                event_tip: self
781                    .event_tip
782                    .as_deref()
783                    .ok_or(RowIdentityError::MissingField("event_tip"))?,
784                // An empty principal is treated as absent: a v3 row must name
785                // who asked, and "" names nobody. Accepting it would let a
786                // blank pass for an identity.
787                caller_principal: self
788                    .caller_principal
789                    .as_deref()
790                    .filter(|p| !p.is_empty())
791                    .ok_or(RowIdentityError::MissingField("caller_principal"))?,
792            }),
793            other => Err(RowIdentityError::UnknownVersion(other)),
794        }
795    }
796}
797
798/// Verify a chain using the daemon's key. Verification uses the **public** key
799/// (so it proves, but cannot forge) and also asserts every row was written
800/// under this key generation (`key_id`).
801pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
802    verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
803}
804
805/// Verify a chain with only the hex-encoded Ed25519 **public** key. This is the
806/// auditor / aggregator path: it proves the chain without the private key and
807/// cannot be used to forge entries. `key_id` is not checked — the public key
808/// itself identifies the signer.
809pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
810    match parse_verifying_key(verifying_key_hex) {
811        Some(vk) => verify_rows(&vk, None, rows),
812        None => VerifyOutcome::CannotVerify {
813            reason: format!(
814                "invalid public key hex ({} chars); expected 64 hex chars of a \
815                 32-byte Ed25519 public key",
816                verifying_key_hex.len()
817            ),
818        },
819    }
820}
821
822fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
823    let bytes = hex::decode(hex_str.trim()).ok()?;
824    let arr: [u8; 32] = bytes.try_into().ok()?;
825    VerifyingKey::from_bytes(&arr).ok()
826}
827
828/// Verify a stored hex signature over a row's message. Any malformed signature
829/// (bad hex, wrong length, invalid point) is a failed check, never a panic.
830fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
831    let Ok(bytes) = hex::decode(sig_hex) else {
832        return false;
833    };
834    let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
835        return false;
836    };
837    vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
838}
839
840/// Walk `rows` in seq order, verifying each row's signature with `vk` and its
841/// `prev_chain_hash` linkage. Returns the first break (or `Intact`). When
842/// `expect_key_id` is `Some`, every row must carry that `key_id`.
843fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
844    let mut last_hash = String::new();
845    let mut rows_checked = 0u64;
846    for row in rows {
847        if let Some(kid) = expect_key_id {
848            if row.key_id != kid {
849                return VerifyOutcome::CannotVerify {
850                    reason: format!(
851                        "row seq={} uses key_id={:?} but only {:?} is loaded; \
852                         epoch keys not yet supported",
853                        row.seq, row.key_id, kid
854                    ),
855                };
856            }
857        }
858        if row.prev_chain_hash != last_hash {
859            return VerifyOutcome::Broken {
860                rows_checked,
861                first_broken_seq: row.seq,
862                first_broken_transaction_id: row.transaction_id.clone(),
863                expected: format!("prev_chain_hash={last_hash}"),
864                actual: format!("prev_chain_hash={}", row.prev_chain_hash),
865            };
866        }
867        let identity = match row.identity() {
868            Ok(identity) => identity,
869            Err(RowIdentityError::UnknownVersion(v)) => {
870                return VerifyOutcome::CannotVerify {
871                    reason: format!(
872                        "row seq={} declares chain_version={v}, which this binary cannot \
873                         reproduce (it understands {CHAIN_VERSION_LEGACY}..={CHAIN_VERSION_CURRENT}); \
874                         verify with a build at least as new as the one that wrote the chain",
875                        row.seq
876                    ),
877                };
878            }
879            Err(RowIdentityError::MissingField(field)) => {
880                // Report the version the row itself declares. Formatting
881                // CHAIN_VERSION_CURRENT here sent an operator investigating a
882                // v2 row to the wrong encoding, and got worse every time the
883                // newest version moved.
884                let stored = match field {
885                    "caller_role" => row.caller_role.as_deref(),
886                    "event_tip" => row.event_tip.as_deref(),
887                    "caller_principal" => row.caller_principal.as_deref(),
888                    _ => None,
889                };
890                return VerifyOutcome::Broken {
891                    rows_checked,
892                    first_broken_seq: row.seq,
893                    first_broken_transaction_id: row.transaction_id.clone(),
894                    expected: format!(
895                        "chain_version={} row carrying a non-empty {field}",
896                        row.chain_version
897                    ),
898                    // An empty column and an absent one send an operator to
899                    // different SQL, so they must not print the same way.
900                    actual: match stored {
901                        None => format!("{field}=NULL"),
902                        Some("") => format!("{field}='' (empty, not NULL)"),
903                        Some(other) => format!("{field}={other:?}"),
904                    },
905                };
906            }
907        };
908        let content = ChainContent {
909            seq: row.seq,
910            key_id: &row.key_id,
911            transaction_id: &row.transaction_id,
912            request_id: &row.request_id,
913            request_hash: &row.request_hash,
914            action_name: &row.action_name,
915            risk_level: row.risk_level,
916            summary: &row.summary,
917            approval_id: row.approval_id.as_deref(),
918            warnings_json: &row.warnings_json,
919            created_at: &row.created_at,
920            identity,
921        };
922        let msg = chain_message(&content, &row.prev_chain_hash);
923        if !signature_ok(vk, &msg, &row.chain_hash) {
924            return VerifyOutcome::Broken {
925                rows_checked,
926                first_broken_seq: row.seq,
927                first_broken_transaction_id: row.transaction_id.clone(),
928                expected: "valid ed25519 signature".to_string(),
929                actual: row.chain_hash.clone(),
930            };
931        }
932        last_hash = row.chain_hash.clone();
933        rows_checked += 1;
934    }
935    VerifyOutcome::Intact { rows_checked }
936}
937
938// ── Approval-event chain ─────────────────────────────────────────────────────
939
940/// A lifecycle event on an approval receipt.
941///
942/// The `transactions` chain commits to the *authorisation decision* at preview
943/// time. It says nothing about whether a human ever approved, whether that
944/// approval was spent, or whether it was retracted — those live in
945/// `transaction_approvals`, a plain mutable table. Deleting a row from it used
946/// to leave `sysknife audit verify` reporting `Intact`, so the record that an
947/// approval happened was the one part of the trail an attacker could erase
948/// without leaving a mark. These events are chained so that erasure breaks a
949/// signature.
950#[derive(Debug, Clone, Copy, PartialEq, Eq)]
951pub enum AuditEventKind {
952    /// A receipt was minted for a queued transaction.
953    ApprovalGranted,
954    /// A receipt was spent to move a transaction into `Running`.
955    ApprovalConsumed,
956    /// An undelivered receipt was retracted before it could be spent.
957    ApprovalRevoked,
958}
959
960impl AuditEventKind {
961    /// Stored and signed spelling. Stable on the wire — changing one of these
962    /// strings invalidates every event signature already written.
963    pub fn as_str(&self) -> &'static str {
964        match self {
965            Self::ApprovalGranted => "approval_granted",
966            Self::ApprovalConsumed => "approval_consumed",
967            Self::ApprovalRevoked => "approval_revoked",
968        }
969    }
970}
971
972/// Immutable content of one approval event, signed into the event chain.
973#[derive(Debug, Clone, PartialEq, Eq)]
974pub struct EventContent<'a> {
975    pub seq: u64,
976    pub key_id: &'a str,
977    pub kind: AuditEventKind,
978    pub transaction_id: &'a str,
979    /// The receipt digest the event concerns. Binds the event to the specific
980    /// receipt, so swapping in a different approval's digest breaks the
981    /// signature.
982    pub receipt_digest: &'a str,
983    pub created_at: &'a str,
984}
985
986impl EventContent<'_> {
987    /// Canonical encoding, using the same prefix-free field framing as
988    /// [`ChainContent::canonical_bytes`].
989    pub fn canonical_bytes(&self) -> Vec<u8> {
990        let mut buf = Vec::with_capacity(256);
991        push_field(&mut buf, "seq", &self.seq.to_string());
992        push_field(&mut buf, "key_id", self.key_id);
993        push_field(&mut buf, "kind", self.kind.as_str());
994        push_field(&mut buf, "transaction_id", self.transaction_id);
995        push_field(&mut buf, "receipt_digest", self.receipt_digest);
996        push_field(&mut buf, "created_at", self.created_at);
997        buf
998    }
999}
1000
1001fn event_message(content: &EventContent, prev_chain_hash: &str) -> Vec<u8> {
1002    let mut msg = EVENT_DOMAIN.to_vec();
1003    msg.extend_from_slice(&content.canonical_bytes());
1004    msg.extend_from_slice(prev_chain_hash.as_bytes());
1005    msg
1006}
1007
1008impl AuditKey {
1009    /// Compute the event-chain signature for `content` linked to
1010    /// `prev_chain_hash`. Separate domain tag from rows and checkpoints, so an
1011    /// event signature can never be replayed as a transaction row.
1012    pub fn event_hash(&self, content: &EventContent, prev_chain_hash: &str) -> String {
1013        hex::encode(
1014            self.signing
1015                .sign(&event_message(content, prev_chain_hash))
1016                .to_bytes(),
1017        )
1018    }
1019}
1020
1021/// One approval event as fetched from the store.
1022#[derive(Debug, Clone, PartialEq, Eq)]
1023pub struct EventRow {
1024    pub seq: u64,
1025    pub key_id: String,
1026    /// Stored spelling of an [`AuditEventKind`]. Kept as a string so an
1027    /// unrecognised value read from the database is a verification failure
1028    /// rather than a deserialisation panic.
1029    pub kind: String,
1030    pub transaction_id: String,
1031    pub receipt_digest: String,
1032    pub created_at: String,
1033    pub prev_chain_hash: String,
1034    pub chain_hash: String,
1035}
1036
1037/// Verify the approval-event chain with the daemon's key.
1038pub fn verify_event_chain(key: &AuditKey, rows: &[EventRow]) -> VerifyOutcome {
1039    verify_event_rows(&key.verifying_key(), Some(key.key_id()), rows)
1040}
1041
1042/// Verify the approval-event chain with only the exported public key.
1043pub fn verify_event_chain_with_pubkey(verifying_key_hex: &str, rows: &[EventRow]) -> VerifyOutcome {
1044    match parse_verifying_key(verifying_key_hex) {
1045        Some(vk) => verify_event_rows(&vk, None, rows),
1046        None => VerifyOutcome::CannotVerify {
1047            reason: format!(
1048                "invalid public key hex ({} chars); expected 64 hex chars of a \
1049                 32-byte Ed25519 public key",
1050                verifying_key_hex.len()
1051            ),
1052        },
1053    }
1054}
1055
1056fn verify_event_rows(
1057    vk: &VerifyingKey,
1058    expect_key_id: Option<&str>,
1059    rows: &[EventRow],
1060) -> VerifyOutcome {
1061    let mut last_hash = String::new();
1062    let mut rows_checked = 0u64;
1063    for row in rows {
1064        if let Some(kid) = expect_key_id {
1065            if row.key_id != kid {
1066                return VerifyOutcome::CannotVerify {
1067                    reason: format!(
1068                        "event seq={} uses key_id={:?} but only {:?} is loaded; \
1069                         epoch keys not yet supported",
1070                        row.seq, row.key_id, kid
1071                    ),
1072                };
1073            }
1074        }
1075        if row.prev_chain_hash != last_hash {
1076            return VerifyOutcome::Broken {
1077                rows_checked,
1078                first_broken_seq: row.seq,
1079                first_broken_transaction_id: row.transaction_id.clone(),
1080                expected: format!("prev_chain_hash={last_hash}"),
1081                actual: format!("prev_chain_hash={}", row.prev_chain_hash),
1082            };
1083        }
1084        // An unknown `kind` cannot be re-encoded, so it can never reproduce a
1085        // valid signature. Report it as the break it is instead of guessing.
1086        let Some(kind) = parse_event_kind(&row.kind) else {
1087            return VerifyOutcome::Broken {
1088                rows_checked,
1089                first_broken_seq: row.seq,
1090                first_broken_transaction_id: row.transaction_id.clone(),
1091                expected: "a known event kind".to_string(),
1092                actual: format!("kind={:?}", row.kind),
1093            };
1094        };
1095        let content = EventContent {
1096            seq: row.seq,
1097            key_id: &row.key_id,
1098            kind,
1099            transaction_id: &row.transaction_id,
1100            receipt_digest: &row.receipt_digest,
1101            created_at: &row.created_at,
1102        };
1103        if !signature_ok(
1104            vk,
1105            &event_message(&content, &row.prev_chain_hash),
1106            &row.chain_hash,
1107        ) {
1108            return VerifyOutcome::Broken {
1109                rows_checked,
1110                first_broken_seq: row.seq,
1111                first_broken_transaction_id: row.transaction_id.clone(),
1112                expected: "valid ed25519 signature".to_string(),
1113                actual: row.chain_hash.clone(),
1114            };
1115        }
1116        last_hash = row.chain_hash.clone();
1117        rows_checked += 1;
1118    }
1119    VerifyOutcome::Intact { rows_checked }
1120}
1121
1122fn parse_event_kind(raw: &str) -> Option<AuditEventKind> {
1123    // Exhaustive by construction: the match below fails to compile if a
1124    // variant is added without a spelling here.
1125    [
1126        AuditEventKind::ApprovalGranted,
1127        AuditEventKind::ApprovalConsumed,
1128        AuditEventKind::ApprovalRevoked,
1129    ]
1130    .into_iter()
1131    .find(|kind| kind.as_str() == raw)
1132}
1133
1134/// Result of checking that the transaction chain's committed `event_tip`
1135/// values are still reproducible from the event chain.
1136#[derive(Debug, Clone, PartialEq, Eq)]
1137pub enum BindingOutcome {
1138    /// Every committed tip is present in the event chain.
1139    Consistent { bindings_checked: u64 },
1140    /// The transaction or approval-event rows could not be read, so the
1141    /// cross-chain binding check did not run.
1142    NotChecked,
1143    /// A transaction row committed to an event tip that no longer exists —
1144    /// approval events were deleted from the end of the event chain, which the
1145    /// event chain walk alone cannot see.
1146    MissingEvent {
1147        transaction_seq: u64,
1148        event_tip: String,
1149    },
1150}
1151
1152/// Check the cross-chain binding: each `chain_version = 2` transaction row
1153/// signs the event-chain tip as of its insert, so a later deletion of trailing
1154/// approval events leaves a tip that can no longer be found.
1155///
1156/// This is what extends checkpoint anchoring to the event chain for free: the
1157/// checkpoints commit to the transaction tip, the transaction rows commit to
1158/// event tips. Events appended *after* the last transaction row are still
1159/// unanchored until the next row is written — the same bounded tail exposure
1160/// the transaction chain has between checkpoints.
1161pub fn verify_event_binding(tx_rows: &[ChainRow], event_rows: &[EventRow]) -> BindingOutcome {
1162    let known: std::collections::HashSet<&str> = event_rows
1163        .iter()
1164        .map(|row| row.chain_hash.as_str())
1165        .collect();
1166    let mut bindings_checked = 0u64;
1167    for row in tx_rows {
1168        let Some(tip) = row.event_tip.as_deref() else {
1169            continue;
1170        };
1171        if tip.is_empty() {
1172            continue;
1173        }
1174        if !known.contains(tip) {
1175            return BindingOutcome::MissingEvent {
1176                transaction_seq: row.seq,
1177                event_tip: tip.to_string(),
1178            };
1179        }
1180        bindings_checked += 1;
1181    }
1182    BindingOutcome::Consistent { bindings_checked }
1183}
1184
1185/// Everything `sysknife audit verify` checks, in one value.
1186///
1187/// Three independent questions, deliberately not collapsed into one enum:
1188/// is the authorisation record intact, is the approval record intact, and do
1189/// the two still agree. Collapsing them would let a clean transaction chain
1190/// mask a tampered approval trail in the summary line.
1191#[derive(Debug, Clone, PartialEq, Eq)]
1192pub struct AuditVerification {
1193    pub chain: VerifyOutcome,
1194    pub events: VerifyOutcome,
1195    pub binding: BindingOutcome,
1196    /// How many rows can name the account that acted, and why the rest cannot.
1197    ///
1198    /// Reported separately because `Intact` alone would be true and misleading: a
1199    /// host where every connection failed attribution produces a chain that
1200    /// verifies perfectly and answers "who acted" with nobody. The verdict is
1201    /// about tampering; this census is about how much the trail can tell you.
1202    ///
1203    /// `None` when the store could not be read at all, so no census was taken: a
1204    /// missing database, an unopenable one, an absent key. A readable but empty
1205    /// store is `Some`, with every count zero.
1206    ///
1207    /// Deliberately not a census of zero rows for the unreadable case: "nothing
1208    /// is known about attribution" and "the chain holds no rows" are different
1209    /// claims, and a zero that means the first reads as the second, which is the
1210    /// exact confusion this census exists to end.
1211    pub attribution: Option<AttributionCensus>,
1212}
1213
1214/// What one row's principal column can attest, given the encoding that signed it.
1215///
1216/// Four outcomes, because the remedies differ and an audit report that collapses
1217/// them tells the operator to do the wrong thing.
1218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1219enum RowStanding {
1220    /// A `chain_version = 3` row whose signed principal names an account.
1221    NamesAccount,
1222    /// A `chain_version = 3` row signing `none:unattributed`: the daemon tried
1223    /// to attribute the connection and could not.
1224    AttributionFailed,
1225    /// Nothing the signature covers. A v1 or v2 row, whose encoding had no
1226    /// principal field, or a v3 row with a blank column, which also verifies as
1227    /// `Broken` once the walk reaches it.
1228    NotRecorded,
1229    /// Nothing here that a signature vouches for. Three sources: the column is
1230    /// populated on an encoding that does not sign it, or the value is one this
1231    /// binary cannot read back as something the daemon could have written, or the
1232    /// row declares an encoding this build does not know, in which case the
1233    /// principal may be absent or held somewhere this build cannot see.
1234    ///
1235    /// This build never writes any of those, so the first two are evidence of an
1236    /// out-of-band write and the third of a newer daemon. None of them is evidence
1237    /// of who acted.
1238    Unattested,
1239}
1240
1241/// Which encoding, if any, signs the `caller_principal` column, and therefore
1242/// whether the column may be believed.
1243///
1244/// This is the load-bearing check in the census. [`ChainContent::canonical_bytes`]
1245/// pushes `caller_principal` into the signed bytes **only** in the v3 arm, so on a
1246/// v1 or v2 row that column is unsigned free space: anyone with write access to
1247/// the table can set it to `uid:0` and the chain still verifies `Intact`, because
1248/// there is no signature over it to break. Bucketing by the column instead of by
1249/// the encoding would let a plain `UPDATE` manufacture attribution, which is a
1250/// worse failure than the one this census was written to fix. Losing attribution
1251/// is a gap; inventing it is a lie.
1252fn standing(row: &ChainRow) -> RowStanding {
1253    // Empty treated as absent, and nothing trimmed, so this agrees with
1254    // `ChainRow::identity` exactly: it filters on `is_empty` and no more. An
1255    // earlier version trimmed first, which sent a whitespace-only v3 principal
1256    // into "written before the column existed" -- a row that in fact verifies
1257    // Intact and is an anomaly worth investigating. The schema stores `''` as
1258    // well as `NULL`, and `verify_chain` prints those two differently on purpose,
1259    // so both are reachable on real data.
1260    let stored = row.caller_principal.as_deref().filter(|p| !p.is_empty());
1261    match row.chain_version {
1262        CHAIN_VERSION_LEGACY | CHAIN_VERSION_V2 => match stored {
1263            None => RowStanding::NotRecorded,
1264            Some(_) => RowStanding::Unattested,
1265        },
1266        CHAIN_VERSION_V3 => match stored {
1267            None => RowStanding::NotRecorded,
1268            Some(p) => match crate::auth::CallerPrincipal::classify(p) {
1269                crate::auth::PrincipalClaim::Account => RowStanding::NamesAccount,
1270                crate::auth::PrincipalClaim::AttributionFailed => RowStanding::AttributionFailed,
1271                crate::auth::PrincipalClaim::Unrecognized => RowStanding::Unattested,
1272            },
1273        },
1274        // An encoding this binary does not know, which is any value outside 1..=3:
1275        // a newer daemon's, or a corrupt column. `verify_rows` already reports
1276        // `CannotVerify` for such a row, and the census must not claim an account
1277        // either, because this binary cannot say which fields that encoding signs
1278        // -- including whether it signs this column at all.
1279        _ => RowStanding::Unattested,
1280    }
1281}
1282
1283/// Attribution census over every transaction row read.
1284///
1285/// Counted over all rows, not only the ones that verified, so a chain that
1286/// breaks at row 5 still reports what the remaining rows *claim* about who
1287/// acted. Those claims are only as good as the chain verdict beside them: unless
1288/// it is `Intact`, treat every count here as unproven, and note that
1289/// [`Self::rows`] can then exceed the verdict's `rows_checked`. A row past the
1290/// break may be perfectly authentic -- a deleted or reordered row breaks the link
1291/// while leaving every later signature valid -- so the surplus is the part of the
1292/// trail this command cannot vouch for, not proof of forgery. [`Self::rows`]
1293/// exists so a reader can see that gap instead of inferring it.
1294///
1295/// One counter is not enough, because a row can name no account for reasons with
1296/// different remedies. `none:unattributed` says the daemon tried and failed on a
1297/// host that could have attributed the call, which is a configuration problem to
1298/// chase in the daemon log. A v1 or v2 row says the encoding had no principal
1299/// field when the row was signed, which is history and cannot be repaired:
1300/// backfilling it would rewrite the bytes the signature covers. An unattested
1301/// value says something wrote to the column that should not have.
1302///
1303/// Splitting them keeps the report honest on an upgraded database. A single
1304/// "unattributed" count of `0` over a chain of pre-v3 rows reads as "every
1305/// action is attributed" when in fact none of them is.
1306///
1307/// Fields are private and [`Self::of`] is the only constructor that reads rows, so
1308/// a census cannot state totals that contradict the rows it describes.
1309/// `from_counts_for_tests` is the one other way to build one, and it is
1310/// not for production. That is not hypothetical tidiness: in 0.3.0 the MCP report
1311/// built its own all-zero attribution count on the `cannot_verify` path, where a
1312/// real count had already been computed, and the MCP tool and `audit verify
1313/// --json` then published different numbers for one database.
1314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1315pub struct AttributionCensus {
1316    named: u64,
1317    attribution_failed: u64,
1318    not_recorded: u64,
1319    unattested: u64,
1320    rows: u64,
1321}
1322
1323impl AttributionCensus {
1324    /// Count the rows. The only way to obtain a census of real data.
1325    pub fn of(tx_rows: &[ChainRow]) -> Self {
1326        let mut census = Self {
1327            named: 0,
1328            attribution_failed: 0,
1329            not_recorded: 0,
1330            unattested: 0,
1331            rows: tx_rows.len() as u64,
1332        };
1333        for row in tx_rows {
1334            match standing(row) {
1335                RowStanding::NamesAccount => census.named += 1,
1336                RowStanding::AttributionFailed => census.attribution_failed += 1,
1337                RowStanding::NotRecorded => census.not_recorded += 1,
1338                RowStanding::Unattested => census.unattested += 1,
1339            }
1340        }
1341        census
1342    }
1343
1344    /// Rows whose signed principal names an account (`uid:` or `token:`).
1345    ///
1346    /// Names an *account*, which is not a person: see `SECURITY.md` on shared
1347    /// logins, `su`, uid reuse, and on `token:vsock` proving possession of a
1348    /// file.
1349    pub fn named(&self) -> u64 {
1350        self.named
1351    }
1352
1353    /// Rows signing `none:unattributed`, the daemon's admission that it could
1354    /// not name the caller.
1355    pub fn attribution_failed(&self) -> u64 {
1356        self.attribution_failed
1357    }
1358
1359    /// Rows with no principal the signature covers, normally written before the
1360    /// column existed.
1361    pub fn not_recorded(&self) -> u64 {
1362        self.not_recorded
1363    }
1364
1365    /// Rows carrying a principal no signature vouches for. Non-zero means
1366    /// investigate: nothing in SysKnife writes such a value.
1367    pub fn unattested(&self) -> u64 {
1368        self.unattested
1369    }
1370
1371    /// Rows censused.
1372    ///
1373    /// The four row outcomes partition the rows, and [`Self::of`]
1374    /// increments exactly one bucket per row, so this equals their sum. State the
1375    /// partition rather than the arithmetic: a fifth bucket keeps the partition
1376    /// true, and any sum written out by hand somewhere else would not survive it.
1377    pub fn rows(&self) -> u64 {
1378        self.rows
1379    }
1380
1381    /// Rows that name no account, for any reason. What an operator asking "can
1382    /// this trail tell me who acted" has to subtract.
1383    ///
1384    /// Subtraction rather than addition on purpose: a fifth *non-naming* bucket
1385    /// cannot make this number stale, whereas summing the reasons would need
1386    /// editing here too. A future bucket that does name an account would have to
1387    /// be added to `named` instead, which is why `named` is the one counter this
1388    /// subtracts.
1389    pub fn unnamed(&self) -> u64 {
1390        self.rows - self.named
1391    }
1392
1393    /// Build a census from counts, for rendering tests only.
1394    ///
1395    /// Behind the `test-support` feature rather than merely `#[doc(hidden)]`, which
1396    /// hides a function from documentation but not from callers. The CLI renderers
1397    /// live in another crate, where `cfg(test)` does not reach, and making them
1398    /// build signed `ChainRow` fixtures to assert on one line of prose would buy no
1399    /// safety; enabling the feature only as a dev-dependency keeps the door shut
1400    /// for production builds. [`Self::of`] is the constructor that reads rows.
1401    ///
1402    /// Panics if the counts overflow `u64`, rather than wrapping into a census
1403    /// whose `rows` is smaller than its buckets and whose [`Self::unnamed`] would
1404    /// then underflow.
1405    #[cfg(any(test, feature = "test-support"))]
1406    pub fn from_counts_for_tests(
1407        named: u64,
1408        attribution_failed: u64,
1409        not_recorded: u64,
1410        unattested: u64,
1411    ) -> Self {
1412        let rows = named
1413            .checked_add(attribution_failed)
1414            .and_then(|n| n.checked_add(not_recorded))
1415            .and_then(|n| n.checked_add(unattested))
1416            .expect("census counts must not overflow u64");
1417        Self {
1418            named,
1419            attribution_failed,
1420            not_recorded,
1421            unattested,
1422            rows,
1423        }
1424    }
1425}
1426
1427impl AuditVerification {
1428    /// Worst result across all three checks.
1429    ///
1430    /// A detected tamper (`1`) outranks an inability to check (`2`): if the
1431    /// chain is provably broken, reporting "could not verify" because some
1432    /// *other* check was inconclusive would understate what is known.
1433    pub fn exit_code(&self) -> i32 {
1434        let codes = [
1435            outcome_to_exit_code(&self.chain),
1436            outcome_to_exit_code(&self.events),
1437            binding_outcome_to_exit_code(&self.binding),
1438        ];
1439        if codes.contains(&1) {
1440            1
1441        } else if codes.contains(&2) {
1442            2
1443        } else {
1444            0
1445        }
1446    }
1447}
1448
1449/// Run all three checks with the daemon's key.
1450pub fn verify_all(
1451    key: &AuditKey,
1452    tx_rows: &[ChainRow],
1453    event_rows: &[EventRow],
1454) -> AuditVerification {
1455    AuditVerification {
1456        chain: verify_chain(key, tx_rows),
1457        events: verify_event_chain(key, event_rows),
1458        binding: verify_event_binding(tx_rows, event_rows),
1459        attribution: Some(AttributionCensus::of(tx_rows)),
1460    }
1461}
1462
1463/// Run all three checks with only the exported public key (the auditor path).
1464pub fn verify_all_with_pubkey(
1465    verifying_key_hex: &str,
1466    tx_rows: &[ChainRow],
1467    event_rows: &[EventRow],
1468) -> AuditVerification {
1469    AuditVerification {
1470        chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
1471        events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
1472        binding: verify_event_binding(tx_rows, event_rows),
1473        attribution: Some(AttributionCensus::of(tx_rows)),
1474    }
1475}
1476
1477/// Exit code for the binding check, on the same scale as
1478/// [`outcome_to_exit_code`]: a missing event is a detected tamper (`1`), while
1479/// a check that could not run is inconclusive (`2`).
1480pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
1481    match outcome {
1482        BindingOutcome::Consistent { .. } => 0,
1483        BindingOutcome::NotChecked => 2,
1484        BindingOutcome::MissingEvent { .. } => 1,
1485    }
1486}
1487
1488// ── Signed checkpoints (external anchoring / tail-truncation detection) ──────
1489
1490/// A signed commitment to the chain tip at a point in time. Periodically
1491/// emitted and anchored to an independent, append-only sink (a separate
1492/// database, a WORM store, or an RFC 3161 timestamp) so that a later attempt to
1493/// rewrite or **truncate** the local chain is detectable: the anchored
1494/// `(seq, chain_tip)` can no longer be reproduced from the shortened chain.
1495///
1496/// This is the Certificate-Transparency "signed checkpoint" idiom. The
1497/// signature is Ed25519 over the canonical `(seq, chain_tip, created_at)`, so
1498/// an auditor verifies it with only the public key.
1499///
1500/// A `Checkpoint` value carries no validity guarantee on its own: validity is
1501/// established solely by [`verify_checkpoints`] under the public key. A
1502/// checkpoint loaded from a sink is untrusted input until verified.
1503#[derive(Debug, Clone, PartialEq, Eq)]
1504pub struct Checkpoint {
1505    /// `seq` of the chain tip this checkpoint commits to (the last row at
1506    /// emit time).
1507    pub seq: u64,
1508    /// `chain_hash` of the row at `seq` (the committed chain tip).
1509    pub chain_tip: String,
1510    /// RFC 3339 timestamp when the checkpoint was signed.
1511    pub created_at: String,
1512    /// Hex Ed25519 signature over `canonical(seq, chain_tip, created_at)`.
1513    pub signature: String,
1514}
1515
1516/// Canonical message signed into a checkpoint. Reuses the prefix-free field
1517/// framing so the encoding is unambiguous and stable across backends.
1518fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
1519    // Leading domain tag: separates checkpoint signatures from row signatures
1520    // (see `chain_message`) so the two contexts can never cross-verify.
1521    let mut buf = CHECKPOINT_DOMAIN.to_vec();
1522    push_field(&mut buf, "seq", &seq.to_string());
1523    push_field(&mut buf, "chain_tip", chain_tip);
1524    push_field(&mut buf, "created_at", created_at);
1525    buf
1526}
1527
1528impl AuditKey {
1529    /// Sign a checkpoint committing to `(seq, chain_tip)` at `created_at`.
1530    pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
1531        let sig = self
1532            .signing
1533            .sign(&checkpoint_message(seq, chain_tip, created_at));
1534        Checkpoint {
1535            seq,
1536            chain_tip: chain_tip.to_string(),
1537            created_at: created_at.to_string(),
1538            signature: hex::encode(sig.to_bytes()),
1539        }
1540    }
1541}
1542
1543/// Result of checking anchored checkpoints against the current chain.
1544#[derive(Debug, Clone, PartialEq, Eq)]
1545pub enum CheckpointOutcome {
1546    /// Every checkpoint's signature is valid and its committed tip is still
1547    /// present in the chain at the committed `seq`.
1548    Consistent { checkpoints_checked: u64 },
1549    /// A checkpoint signature failed to verify under the public key.
1550    BadSignature { seq: u64 },
1551    /// A checkpoint commits to a `seq` no longer present in the chain — the
1552    /// chain has been **truncated** below a previously anchored tip.
1553    Truncated {
1554        checkpoint_seq: u64,
1555        current_max_seq: u64,
1556    },
1557    /// A checkpoint's committed `chain_tip` does not match the chain's
1558    /// `chain_hash` at that `seq` — the chain was **rewritten**.
1559    TipMismatch {
1560        seq: u64,
1561        anchored: String,
1562        actual: String,
1563    },
1564    /// Could not verify (e.g. malformed public key).
1565    CannotVerify { reason: String },
1566}
1567
1568/// Verify anchored `checkpoints` against `rows` (the current chain) with the
1569/// hex **public** key. Detects truncation (a checkpoint seq no longer in the
1570/// chain) and rewrite (tip mismatch at a checkpoint seq). `rows` must be in
1571/// seq order. This is the anti-root guarantee: a host attacker who shortens or
1572/// edits the local chain cannot reproduce a previously anchored signed tip.
1573pub fn verify_checkpoints(
1574    verifying_key_hex: &str,
1575    rows: &[ChainRow],
1576    checkpoints: &[Checkpoint],
1577) -> CheckpointOutcome {
1578    let vk = match parse_verifying_key(verifying_key_hex) {
1579        Some(vk) => vk,
1580        None => {
1581            return CheckpointOutcome::CannotVerify {
1582                reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
1583            };
1584        }
1585    };
1586    // `rows` is seq-sorted (ORDER BY seq ASC in the store; chain build order in
1587    // tests), so the tip seq is the last element and we can binary-search below.
1588    let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
1589    let mut checked = 0u64;
1590    for cp in checkpoints {
1591        let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
1592        if !signature_ok(&vk, &msg, &cp.signature) {
1593            return CheckpointOutcome::BadSignature { seq: cp.seq };
1594        }
1595        match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
1596            Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
1597            Ok(idx) => {
1598                return CheckpointOutcome::TipMismatch {
1599                    seq: cp.seq,
1600                    anchored: cp.chain_tip.clone(),
1601                    actual: rows[idx].chain_hash.clone(),
1602                };
1603            }
1604            Err(_) => {
1605                return CheckpointOutcome::Truncated {
1606                    checkpoint_seq: cp.seq,
1607                    current_max_seq,
1608                };
1609            }
1610        }
1611        checked += 1;
1612    }
1613    CheckpointOutcome::Consistent {
1614        checkpoints_checked: checked,
1615    }
1616}
1617
1618/// Process exit code for `sysknife audit checkpoint` verification, mirroring
1619/// [`outcome_to_exit_code`]: `0` consistent, `1` a detected tamper (bad
1620/// signature / truncation / rewrite), `2` cannot-verify (e.g. malformed public
1621/// key). The 1-vs-2 split matters for CI exactly as it does for chain verify.
1622pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
1623    match outcome {
1624        CheckpointOutcome::Consistent { .. } => 0,
1625        CheckpointOutcome::BadSignature { .. }
1626        | CheckpointOutcome::Truncated { .. }
1627        | CheckpointOutcome::TipMismatch { .. } => 1,
1628        CheckpointOutcome::CannotVerify { .. } => 2,
1629    }
1630}
1631
1632#[cfg(test)]
1633mod tests {
1634    use super::*;
1635
1636    fn fixed_key() -> AuditKey {
1637        AuditKey::from_bytes(vec![0x42; 32])
1638    }
1639
1640    fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
1641        ChainContent {
1642            seq,
1643            key_id: CURRENT_KEY_ID,
1644            transaction_id: txid,
1645            request_id: "req-1",
1646            request_hash: "hash-abc",
1647            action_name: "UpdateSystem",
1648            risk_level: RiskLevel::High,
1649            summary: "Upgrade",
1650            approval_id: None,
1651            warnings_json: "[]",
1652            created_at: "2026-04-24T12:00:00Z",
1653            identity: ChainIdentity::V2 {
1654                caller_role: "Dev",
1655                event_tip: "",
1656            },
1657        }
1658    }
1659
1660    // ── chain_hash determinism + linkage ──────────────────────────────────
1661
1662    #[test]
1663    fn same_inputs_yield_same_hash() {
1664        let key = fixed_key();
1665        let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1666        let h2 = key.chain_hash(&sample_content(1, "txa"), "");
1667        assert_eq!(h1, h2);
1668        assert_eq!(h1.len(), HASH_HEX_LEN);
1669    }
1670
1671    #[test]
1672    fn different_seq_yields_different_hash() {
1673        let key = fixed_key();
1674        let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1675        let h2 = key.chain_hash(&sample_content(2, "txa"), "");
1676        assert_ne!(h1, h2);
1677    }
1678
1679    #[test]
1680    fn different_prev_hash_yields_different_hash() {
1681        let key = fixed_key();
1682        let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1683        let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
1684        assert_ne!(h1, h2);
1685    }
1686
1687    #[test]
1688    fn different_keys_yield_different_hashes() {
1689        let key1 = AuditKey::from_bytes(vec![0x01; 32]);
1690        let key2 = AuditKey::from_bytes(vec![0x02; 32]);
1691        let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
1692        let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
1693        assert_ne!(h1, h2);
1694    }
1695
1696    // ── canonical encoding stability ──────────────────────────────────────
1697
1698    #[test]
1699    fn canonical_bytes_have_stable_field_order() {
1700        let c = sample_content(1, "txa");
1701        let bytes = c.canonical_bytes();
1702        let s = String::from_utf8_lossy(&bytes);
1703        // Tags must appear in a fixed order.
1704        let order = [
1705            "seq",
1706            "key_id",
1707            "transaction_id",
1708            "request_id",
1709            "request_hash",
1710            "action_name",
1711            "risk_level",
1712            "summary",
1713            "approval_id",
1714            "warnings_json",
1715            "created_at",
1716        ];
1717        let mut last_idx = 0;
1718        for tag in order {
1719            let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
1720            assert!(idx >= last_idx, "{tag} out of order");
1721            last_idx = idx;
1722        }
1723    }
1724
1725    #[test]
1726    fn nul_bytes_in_field_value_are_escaped() {
1727        let mut c = sample_content(1, "txa");
1728        let summary = "before\0after";
1729        c.summary = summary;
1730        let bytes = c.canonical_bytes();
1731        // Raw NUL must NOT appear; escape sequence must.
1732        assert!(!bytes.contains(&0x00));
1733        assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
1734    }
1735
1736    /// Backslash-NUL collision regression: an attacker who could craft a
1737    /// row with `summary = "before\\0after"` (literal backslash + zero) must
1738    /// NOT produce the same canonical bytes as one with raw `\x00`. Without
1739    /// the `\\` → `\\\\` escape, the two collide and the attacker can
1740    /// substitute one for the other while the chain hash matches.
1741    #[test]
1742    fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
1743        let mut a = sample_content(1, "txa");
1744        a.summary = "before\\0after"; // literal backslash + '0'
1745        let mut b = sample_content(1, "txa");
1746        b.summary = "before\0after"; // raw NUL
1747        assert_ne!(
1748            a.canonical_bytes(),
1749            b.canonical_bytes(),
1750            "backslash escape must run before NUL escape to prevent collision"
1751        );
1752    }
1753
1754    fn key_for_collision_test() -> AuditKey {
1755        AuditKey::from_bytes(vec![0xab; 32])
1756    }
1757
1758    #[test]
1759    fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
1760        let key = key_for_collision_test();
1761        let mut a = sample_content(1, "txa");
1762        a.summary = "before\\0after";
1763        let mut b = sample_content(1, "txa");
1764        b.summary = "before\0after";
1765        assert_ne!(
1766            key.chain_hash(&a, ""),
1767            key.chain_hash(&b, ""),
1768            "chain signature must distinguish escape from raw byte"
1769        );
1770    }
1771
1772    // ── verify_chain ──────────────────────────────────────────────────────
1773
1774    /// Materialise the stored row that `content` would produce, so a test
1775    /// never hand-copies field-by-field and accidentally signs one thing while
1776    /// storing another.
1777    fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
1778        let (chain_version, caller_role, event_tip, caller_principal) = match content.identity {
1779            ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None, None),
1780            ChainIdentity::V2 {
1781                caller_role,
1782                event_tip,
1783            } => (
1784                CHAIN_VERSION_V2,
1785                Some(caller_role.to_string()),
1786                Some(event_tip.to_string()),
1787                None,
1788            ),
1789            ChainIdentity::V3 {
1790                caller_role,
1791                event_tip,
1792                caller_principal,
1793            } => (
1794                CHAIN_VERSION_V3,
1795                Some(caller_role.to_string()),
1796                Some(event_tip.to_string()),
1797                Some(caller_principal.to_string()),
1798            ),
1799        };
1800        ChainRow {
1801            seq: content.seq,
1802            key_id: content.key_id.to_string(),
1803            transaction_id: content.transaction_id.to_string(),
1804            request_id: content.request_id.to_string(),
1805            request_hash: content.request_hash.to_string(),
1806            action_name: content.action_name.to_string(),
1807            risk_level: content.risk_level,
1808            summary: content.summary.to_string(),
1809            approval_id: content.approval_id.map(str::to_string),
1810            warnings_json: content.warnings_json.to_string(),
1811            created_at: content.created_at.to_string(),
1812            prev_chain_hash: prev.to_string(),
1813            chain_hash,
1814            chain_version,
1815            caller_role,
1816            event_tip,
1817            caller_principal,
1818        }
1819    }
1820
1821    fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
1822        let mut rows = Vec::with_capacity(count);
1823        let mut prev = String::new();
1824        for i in 0..count {
1825            let seq = (i + 1) as u64;
1826            let txid = format!("tx{i}");
1827            let content = sample_content(seq, &txid);
1828            let hash = key.chain_hash(&content, &prev);
1829            rows.push(row_for(&content, &prev, hash.clone()));
1830            prev = hash;
1831        }
1832        rows
1833    }
1834
1835    #[test]
1836    fn exported_chain_row_json_round_trips_without_changing_the_signature() {
1837        let key = fixed_key();
1838        let row = build_chain(&key, 1).remove(0);
1839
1840        let json = serde_json::to_string(&row).expect("chain row serializes");
1841        let decoded: ChainRow = serde_json::from_str(&json).expect("chain row deserializes");
1842
1843        assert_eq!(decoded, row);
1844        assert_eq!(decoded.chain_hash.as_bytes(), row.chain_hash.as_bytes());
1845    }
1846
1847    #[test]
1848    fn exported_chain_row_uses_the_stored_column_contract_and_json_nulls() {
1849        let key = fixed_key();
1850        let mut row = build_chain(&key, 1).remove(0);
1851        row.approval_id = None;
1852        row.caller_role = None;
1853        row.event_tip = None;
1854        row.caller_principal = None;
1855
1856        let value = serde_json::to_value(&row).expect("chain row serializes");
1857        let object = value.as_object().expect("chain row is a JSON object");
1858        let keys: Vec<&str> = object.keys().map(String::as_str).collect();
1859
1860        assert_eq!(
1861            keys,
1862            vec![
1863                "action_name",
1864                "approval_id",
1865                "caller_principal",
1866                "caller_role",
1867                "chain_hash",
1868                "chain_version",
1869                "created_at",
1870                "event_tip",
1871                "key_id",
1872                "prev_chain_hash",
1873                "request_hash",
1874                "request_id",
1875                "risk_level",
1876                "seq",
1877                "summary",
1878                "transaction_id",
1879                "warnings_json",
1880            ]
1881        );
1882        for optional in [
1883            "approval_id",
1884            "caller_role",
1885            "event_tip",
1886            "caller_principal",
1887        ] {
1888            assert!(object[optional].is_null(), "{optional} must be JSON null");
1889        }
1890        assert!(!object.contains_key("argv"));
1891        assert!(!object.contains_key("outcome"));
1892        assert!(!object.contains_key("signature"));
1893    }
1894
1895    #[test]
1896    fn export_since_is_inclusive_and_limit_preserves_chain_order() {
1897        let key = fixed_key();
1898        let mut rows = build_chain(&key, 4);
1899        for (row, created_at) in rows.iter_mut().zip([
1900            "2026-08-19T09:00:00Z",
1901            "2026-08-19T10:00:00Z",
1902            "2026-08-19T11:00:00Z",
1903            "2026-08-19T12:00:00Z",
1904        ]) {
1905            row.created_at = created_at.to_string();
1906        }
1907
1908        let selected =
1909            select_chain_rows_for_export(rows, Some("2026-08-19T10:00:00+00:00"), Some(2)).unwrap();
1910
1911        assert_eq!(
1912            selected.iter().map(|row| row.seq).collect::<Vec<_>>(),
1913            [2, 3]
1914        );
1915    }
1916
1917    #[test]
1918    fn export_rejects_an_invalid_since_timestamp() {
1919        let error = select_chain_rows_for_export(Vec::new(), Some("last Tuesday"), None)
1920            .expect_err("invalid --since must fail before reading rows");
1921        assert!(matches!(error, ChainExportError::InvalidSince(_)));
1922    }
1923
1924    // ── caller identity in the signed content ─────────────────────────────
1925
1926    #[test]
1927    fn caller_role_is_covered_by_the_row_signature() {
1928        // The whole point of the v2 encoding: two rows identical except for
1929        // who asked must not share a signature, or the chain cannot answer
1930        // "which role requested this".
1931        let key = fixed_key();
1932        let mut dev = sample_content(1, "txa");
1933        dev.identity = ChainIdentity::V2 {
1934            caller_role: "Dev",
1935            event_tip: "",
1936        };
1937        let mut boot = sample_content(1, "txa");
1938        boot.identity = ChainIdentity::V2 {
1939            caller_role: "Boot",
1940            event_tip: "",
1941        };
1942        assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
1943    }
1944
1945    #[test]
1946    fn event_tip_is_covered_by_the_row_signature() {
1947        let key = fixed_key();
1948        let mut empty = sample_content(1, "txa");
1949        empty.identity = ChainIdentity::V2 {
1950            caller_role: "Dev",
1951            event_tip: "",
1952        };
1953        let mut bound = sample_content(1, "txa");
1954        bound.identity = ChainIdentity::V2 {
1955            caller_role: "Dev",
1956            event_tip: "abc123",
1957        };
1958        assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
1959    }
1960
1961    #[test]
1962    fn a_legacy_row_written_before_the_migration_still_verifies() {
1963        // The migration contract. A chain written by v0.2.12 or earlier has no
1964        // caller_role column; re-encoding those rows with an empty identity
1965        // would report every historical row as Broken, i.e. an upgrade would
1966        // look exactly like a compromise.
1967        let key = fixed_key();
1968        let mut content = sample_content(1, "tx-legacy");
1969        content.identity = ChainIdentity::LegacyV1;
1970        let hash = key.chain_hash(&content, "");
1971        let row = row_for(&content, "", hash);
1972        assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
1973        assert_eq!(row.caller_role, None);
1974        assert_eq!(
1975            verify_chain(&key, &[row]),
1976            VerifyOutcome::Intact { rows_checked: 1 }
1977        );
1978    }
1979
1980    // ── v3: the row names the account, not just the role ──────────────────
1981
1982    /// The point of the encoding: two callers who share a role must produce
1983    /// distinguishable signed records. If the principal were unsigned, or merely
1984    /// stored, this would pass while proving nothing.
1985    #[test]
1986    fn two_admins_are_distinguishable_in_the_signed_record() {
1987        let key = fixed_key();
1988        let mut alice = sample_content(1, "tx-alice");
1989        alice.identity = ChainIdentity::V3 {
1990            caller_role: "admin",
1991            event_tip: "",
1992            caller_principal: "uid:1000",
1993        };
1994        let mut bob = sample_content(1, "tx-alice");
1995        bob.identity = ChainIdentity::V3 {
1996            caller_role: "admin",
1997            event_tip: "",
1998            caller_principal: "uid:1001",
1999        };
2000
2001        assert_ne!(
2002            key.chain_hash(&alice, ""),
2003            key.chain_hash(&bob, ""),
2004            "identical rows differing only in principal must sign differently, \
2005             otherwise the chain still cannot answer which account acted"
2006        );
2007    }
2008
2009    /// Rewriting the stored principal must break verification. A field that can
2010    /// be edited after the fact records nothing an auditor can rely on.
2011    #[test]
2012    fn editing_the_stored_principal_breaks_the_row() {
2013        let key = fixed_key();
2014        let mut content = sample_content(1, "tx1");
2015        content.identity = ChainIdentity::V3 {
2016            caller_role: "admin",
2017            event_tip: "",
2018            caller_principal: "uid:1000",
2019        };
2020        let hash = key.chain_hash(&content, "");
2021        let mut row = row_for(&content, "", hash);
2022        row.caller_principal = Some("uid:0".to_string());
2023
2024        assert!(
2025            matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2026            "a principal swapped to root must not verify"
2027        );
2028    }
2029
2030    /// Downgrade is the other direction of the same attack: strip the principal
2031    /// and claim the row was written under v2, hiding which account acted. The
2032    /// v3 message signed a different byte string, so it cannot verify as v2.
2033    #[test]
2034    fn downgrading_a_v3_row_to_v2_to_hide_the_account_breaks_it() {
2035        let key = fixed_key();
2036        let mut content = sample_content(1, "tx1");
2037        content.identity = ChainIdentity::V3 {
2038            caller_role: "admin",
2039            event_tip: "",
2040            caller_principal: "uid:1000",
2041        };
2042        let hash = key.chain_hash(&content, "");
2043        let mut row = row_for(&content, "", hash);
2044        row.chain_version = CHAIN_VERSION_V2;
2045        row.caller_principal = None;
2046
2047        assert!(
2048            matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2049            "a v3 row relabelled as v2 must not verify"
2050        );
2051    }
2052
2053    /// A v3 row with no principal is self-contradictory, and so is one whose
2054    /// principal is blank: both claim an encoding that names an account while
2055    /// naming none. Treated as a break, not as "cannot verify", because the row
2056    /// contradicts itself rather than being written by a newer binary.
2057    #[test]
2058    fn a_v3_row_without_a_usable_principal_is_broken() {
2059        let key = fixed_key();
2060        let mut content = sample_content(1, "tx1");
2061        content.identity = ChainIdentity::V3 {
2062            caller_role: "admin",
2063            event_tip: "",
2064            caller_principal: "uid:1000",
2065        };
2066        let hash = key.chain_hash(&content, "");
2067
2068        for absent in [None, Some(String::new())] {
2069            let mut row = row_for(&content, "", hash.clone());
2070            row.caller_principal = absent.clone();
2071            assert!(
2072                matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2073                "a v3 row with principal {absent:?} must be reported as broken"
2074            );
2075        }
2076    }
2077
2078    /// A row *signed with* a blank principal must be rejected, and this is the
2079    /// only test that can prove the guard exists.
2080    ///
2081    /// The sibling test that blanks a stored principal proves something weaker:
2082    /// that row was signed with `uid:1000`, so it fails on signature mismatch
2083    /// whether or not `identity()` filters empties. Here the signature is
2084    /// perfectly valid for the blank principal, so the row verifies unless the
2085    /// filter refuses it. Without the guard a daemon could sign rows that name
2086    /// nobody and every one of them would report as intact.
2087    #[test]
2088    fn a_row_signed_with_a_blank_principal_is_rejected_even_though_it_signs() {
2089        let key = fixed_key();
2090        let mut content = sample_content(1, "tx-blank");
2091        content.identity = ChainIdentity::V3 {
2092            caller_role: "admin",
2093            event_tip: "",
2094            caller_principal: "",
2095        };
2096        let hash = key.chain_hash(&content, "");
2097        // Derived from the very content that was signed, so every other field
2098        // matches by construction. Building it by hand is how the first version
2099        // of this test came to pass for the wrong reason: a mismatched seq made
2100        // the row fail on signature, guard or no guard.
2101        let row = row_for(&content, "", hash);
2102        assert_eq!(
2103            row.caller_principal.as_deref(),
2104            Some(""),
2105            "fixture must store the blank principal it signed"
2106        );
2107        assert_eq!(row.chain_version, CHAIN_VERSION_V3);
2108        assert!(
2109            matches!(verify_chain(&key, &[row]), VerifyOutcome::Broken { .. }),
2110            "a v3 row naming nobody must be broken, not accepted"
2111        );
2112    }
2113
2114    /// An attribution failure is a legitimate, verifiable identity: the daemon
2115    /// admits it could not name the caller, signs that admission, and the chain
2116    /// stays intact. If this ever reports Broken, every honest row on a host
2117    /// where `SO_PEERCRED` fails becomes a false tamper report.
2118    #[test]
2119    fn a_row_recording_an_attribution_failure_verifies() {
2120        let key = fixed_key();
2121        let principal = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2122        let mut content = sample_content(1, "tx-unattributed");
2123        content.identity = ChainIdentity::V3 {
2124            caller_role: "observer",
2125            event_tip: "",
2126            caller_principal: &principal,
2127        };
2128        let hash = key.chain_hash(&content, "");
2129        let row = row_for(&content, "", hash);
2130
2131        assert_eq!(
2132            verify_chain(&key, &[row]),
2133            VerifyOutcome::Intact { rows_checked: 1 }
2134        );
2135    }
2136
2137    /// The realistic database after two upgrades: legacy rows, v2 rows, then v3
2138    /// rows, all in one chain. Every generation has to keep verifying, which is
2139    /// the whole reason the encoding is selected per row.
2140    #[test]
2141    fn a_chain_spanning_all_three_encodings_verifies() {
2142        let key = fixed_key();
2143        let mut rows = Vec::new();
2144        let mut prev = String::new();
2145        for (i, identity) in [
2146            ChainIdentity::LegacyV1,
2147            ChainIdentity::V2 {
2148                caller_role: "dev",
2149                event_tip: "",
2150            },
2151            ChainIdentity::V3 {
2152                caller_role: "admin",
2153                event_tip: "",
2154                caller_principal: "uid:1000",
2155            },
2156            ChainIdentity::V3 {
2157                caller_role: "admin",
2158                event_tip: "",
2159                caller_principal: "token:vsock",
2160            },
2161        ]
2162        .into_iter()
2163        .enumerate()
2164        {
2165            let txid = format!("tx{i}");
2166            let mut content = sample_content((i + 1) as u64, &txid);
2167            content.identity = identity;
2168            let hash = key.chain_hash(&content, &prev);
2169            rows.push(row_for(&content, &prev, hash.clone()));
2170            prev = hash;
2171        }
2172
2173        assert_eq!(
2174            verify_chain(&key, &rows),
2175            VerifyOutcome::Intact { rows_checked: 4 }
2176        );
2177    }
2178
2179    /// Build a chain from a list of identities, signing each row properly, so no
2180    /// test signs one thing and stores another.
2181    fn chain_of(key: &AuditKey, identities: Vec<ChainIdentity<'_>>) -> Vec<ChainRow> {
2182        let mut rows = Vec::new();
2183        let mut prev = String::new();
2184        for (i, identity) in identities.into_iter().enumerate() {
2185            let txid = format!("tx{i}");
2186            let mut content = sample_content((i + 1) as u64, &txid);
2187            content.identity = identity;
2188            let hash = key.chain_hash(&content, &prev);
2189            rows.push(row_for(&content, &prev, hash.clone()));
2190            prev = hash;
2191        }
2192        rows
2193    }
2194
2195    /// The census has to distinguish *why* a row names no account, because the
2196    /// reasons have different remedies: a `none:unattributed` row means
2197    /// `SO_PEERCRED` failed on a host that could have attributed it, while a v1
2198    /// or v2 row means the encoding had no principal field at the time it was
2199    /// signed. Reporting only the first understates an upgraded database.
2200    ///
2201    /// Every bucket gets a distinct count, so no permutation of the four can
2202    /// satisfy this assertion. The first version used 2/1/2 and still passed with
2203    /// the `named` and `not_recorded` arms swapped.
2204    #[test]
2205    fn the_census_separates_attribution_failures_from_encodings_without_the_field() {
2206        let key = fixed_key();
2207        let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2208        let rows = chain_of(
2209            &key,
2210            vec![
2211                ChainIdentity::LegacyV1,
2212                ChainIdentity::V2 {
2213                    caller_role: "dev",
2214                    event_tip: "",
2215                },
2216                ChainIdentity::V3 {
2217                    caller_role: "admin",
2218                    event_tip: "",
2219                    caller_principal: "uid:1000",
2220                },
2221                ChainIdentity::V3 {
2222                    caller_role: "admin",
2223                    event_tip: "",
2224                    caller_principal: "uid:1001",
2225                },
2226                ChainIdentity::V3 {
2227                    caller_role: "admin",
2228                    event_tip: "",
2229                    caller_principal: "token:vsock",
2230                },
2231                ChainIdentity::V3 {
2232                    caller_role: "observer",
2233                    event_tip: "",
2234                    caller_principal: &unattributed,
2235                },
2236            ],
2237        );
2238
2239        let verification = verify_all(&key, &rows, &[]);
2240        assert_eq!(
2241            verification.chain,
2242            VerifyOutcome::Intact { rows_checked: 6 },
2243            "every encoding must keep verifying; the census is not a verdict"
2244        );
2245        let census = verification
2246            .attribution
2247            .expect("rows were read, so a census was taken");
2248        assert_eq!(census.named(), 3);
2249        assert_eq!(census.attribution_failed(), 1);
2250        assert_eq!(census.not_recorded(), 2);
2251        assert_eq!(census.unattested(), 0);
2252        assert_eq!(census.rows(), 6);
2253        assert_eq!(
2254            census.unnamed(),
2255            3,
2256            "three of the six rows cannot name an account, by either reason"
2257        );
2258    }
2259
2260    /// The forgery this census must not enable.
2261    ///
2262    /// `ChainContent::canonical_bytes` signs `caller_principal` only in the v3
2263    /// arm, so on a v1 or v2 row the column is unsigned free space. Anyone with write access
2264    /// to the table can set it, and no signature breaks. If the census bucketed
2265    /// by the column rather than by the encoding, a single `UPDATE` would turn
2266    /// "none of these rows names an account" into "every row names root", with an
2267    /// `Intact` verdict beside it. Losing attribution is a gap; inventing it is a
2268    /// lie, and this is the test that keeps the lie unavailable.
2269    #[test]
2270    fn a_principal_written_into_an_encoding_that_does_not_sign_it_is_never_an_account() {
2271        let key = fixed_key();
2272        let mut rows = chain_of(
2273            &key,
2274            vec![
2275                ChainIdentity::LegacyV1,
2276                ChainIdentity::V2 {
2277                    caller_role: "dev",
2278                    event_tip: "",
2279                },
2280            ],
2281        );
2282        // The out-of-band write. Not signed by either encoding, so the chain is
2283        // expected to stay Intact: the signature cannot see this.
2284        rows[0].caller_principal = Some("uid:0".to_string());
2285        rows[1].caller_principal = Some("uid:0".to_string());
2286
2287        let verification = verify_all(&key, &rows, &[]);
2288        assert_eq!(
2289            verification.chain,
2290            VerifyOutcome::Intact { rows_checked: 2 },
2291            "no signature covers that column on v1/v2, so tampering with it is \
2292             invisible to verification -- which is exactly why the census must \
2293             not believe it"
2294        );
2295        let census = verification.attribution.expect("rows were read");
2296        assert_eq!(
2297            census.named(),
2298            0,
2299            "an unsigned column must never be credited as an account"
2300        );
2301        assert_eq!(
2302            census.unattested(),
2303            2,
2304            "and it must be reported as unattested, because nothing in SysKnife \
2305             writes that column on those encodings"
2306        );
2307        assert_eq!(census.unnamed(), 2);
2308    }
2309
2310    /// A principal column that is present but empty must not be tallied as an
2311    /// account. The schema allows `''` as well as `NULL`, and `identity()`
2312    /// already reads the two the same way, so the census has to agree with it.
2313    #[test]
2314    fn a_blank_principal_column_is_counted_as_naming_nobody() {
2315        let key = fixed_key();
2316        let mut content = sample_content(1, "tx-blank-census");
2317        content.identity = ChainIdentity::V3 {
2318            caller_role: "admin",
2319            event_tip: "",
2320            caller_principal: "",
2321        };
2322        let hash = key.chain_hash(&content, "");
2323        let row = row_for(&content, "", hash);
2324        assert_eq!(row.caller_principal.as_deref(), Some(""));
2325
2326        let verification = verify_all(&key, &[row], &[]);
2327        let census = verification.attribution.expect("a row was read");
2328        assert_eq!(census.named(), 0);
2329        assert_eq!(census.not_recorded(), 1);
2330        assert_eq!(census.rows(), 1);
2331        assert!(
2332            matches!(verification.chain, VerifyOutcome::Broken { .. }),
2333            "and the row is still reported as broken once the walk reaches it, \
2334             so census and verdict agree"
2335        );
2336    }
2337
2338    /// Values that look like a principal but name nobody. None of these can be
2339    /// written by the daemon (`CallerPrincipal::as_signed_str` always renders
2340    /// `scheme:value` with a non-empty value), so each one is evidence of an
2341    /// out-of-band write. Counting them as accounts would be this commit's own
2342    /// defect one level down: `none:overflow` in particular is a plausible future
2343    /// spelling of a *failure*, and crediting it as an account would invert its
2344    /// meaning.
2345    #[test]
2346    fn a_principal_this_binary_cannot_read_is_counted_as_unattested_not_as_an_account() {
2347        let key = fixed_key();
2348        for forged in [
2349            "garbage",
2350            "uid:",
2351            "none:overflow",
2352            // Whitespace is signed content like any other, so this row verifies
2353            // Intact. It still names nobody, and it belongs in the bucket that
2354            // asks for an investigation rather than the one that explains
2355            // pre-0.3.0 history: nothing in SysKnife writes it.
2356            "  ",
2357            "UID:1000",
2358            // A known scheme with a value the daemon could not have rendered.
2359            "uid:notanumber",
2360            "uid:1000:extra",
2361            "token:not-vsock",
2362        ] {
2363            let mut content = sample_content(1, "tx-forged");
2364            content.identity = ChainIdentity::V3 {
2365                caller_role: "admin",
2366                event_tip: "",
2367                caller_principal: forged,
2368            };
2369            let hash = key.chain_hash(&content, "");
2370            let row = row_for(&content, "", hash);
2371            let census = verify_all(&key, &[row], &[])
2372                .attribution
2373                .expect("a row was read");
2374            assert_eq!(
2375                census.named(),
2376                0,
2377                "{forged:?} names no account and must not be counted as one"
2378            );
2379            assert_eq!(
2380                census.unnamed(),
2381                1,
2382                "{forged:?} must be counted among the rows naming nobody"
2383            );
2384            assert_eq!(
2385                census.unattested(),
2386                1,
2387                "{forged:?} is signed content the daemon never writes, so it is a \
2388                 finding to investigate, not history to explain away"
2389            );
2390            assert_eq!(
2391                census.not_recorded(),
2392                0,
2393                "{forged:?} is present, so it must not be reported as a row that \
2394                 predates the column"
2395            );
2396        }
2397    }
2398
2399    /// The case that made the single counter misleading: a database written
2400    /// entirely before the principal column existed reports zero attribution
2401    /// failures, which reads as "everything is attributed". Every row in it names
2402    /// nobody, so `unnamed` must account for all of them.
2403    #[test]
2404    fn a_chain_predating_the_principal_field_reports_no_row_as_attributed() {
2405        let key = fixed_key();
2406        let rows = chain_of(
2407            &key,
2408            vec![
2409                ChainIdentity::LegacyV1,
2410                ChainIdentity::V2 {
2411                    caller_role: "dev",
2412                    event_tip: "",
2413                },
2414            ],
2415        );
2416
2417        let verification = verify_all(&key, &rows, &[]);
2418        assert_eq!(
2419            verification.chain,
2420            VerifyOutcome::Intact { rows_checked: 2 },
2421            "legacy rows must keep verifying; the census is not a verdict"
2422        );
2423        let census = verification.attribution.expect("rows were read");
2424        assert_eq!(census.attribution_failed(), 0);
2425        assert_eq!(census.named(), 0);
2426        assert_eq!(census.not_recorded(), 2);
2427        assert_eq!(census.unnamed(), 2);
2428    }
2429
2430    /// The census spans every row read, while verification stops at the first
2431    /// break. That is deliberate, and it is also the reason the counts are claims
2432    /// rather than findings: on a broken chain the rows after the break were
2433    /// written by whoever broke it. `rows()` is what lets a reader see the gap
2434    /// between what was counted and what was checked.
2435    #[test]
2436    fn a_broken_chain_censuses_more_rows_than_it_verified() {
2437        let key = fixed_key();
2438        let mut rows = chain_of(
2439            &key,
2440            vec![
2441                ChainIdentity::V3 {
2442                    caller_role: "admin",
2443                    event_tip: "",
2444                    caller_principal: "uid:1000",
2445                },
2446                ChainIdentity::V3 {
2447                    caller_role: "admin",
2448                    event_tip: "",
2449                    caller_principal: "uid:1000",
2450                },
2451                ChainIdentity::V3 {
2452                    caller_role: "admin",
2453                    event_tip: "",
2454                    caller_principal: "uid:1000",
2455                },
2456            ],
2457        );
2458        rows[1].summary = "tampered".to_string();
2459
2460        let verification = verify_all(&key, &rows, &[]);
2461        let rows_checked = match verification.chain {
2462            VerifyOutcome::Broken { rows_checked, .. } => rows_checked,
2463            ref other => panic!("expected a detected tamper, got {other:?}"),
2464        };
2465        let census = verification.attribution.expect("rows were read");
2466        assert_eq!(rows_checked, 1, "the walk stops at the first broken row");
2467        assert_eq!(
2468            census.rows(),
2469            3,
2470            "while the census still describes every row read"
2471        );
2472        assert!(
2473            census.rows() > rows_checked,
2474            "the report must be able to show that more was counted than checked"
2475        );
2476    }
2477
2478    /// The auditor path takes the same census as the keyholder path. It is the
2479    /// one an external reviewer runs, so a census wired only into `verify_all`
2480    /// would leave exactly that audience with nothing.
2481    #[test]
2482    fn the_pubkey_only_path_takes_the_same_census() {
2483        let key = fixed_key();
2484        let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2485        let rows = chain_of(
2486            &key,
2487            vec![
2488                ChainIdentity::V3 {
2489                    caller_role: "admin",
2490                    event_tip: "",
2491                    caller_principal: "uid:1000",
2492                },
2493                ChainIdentity::V3 {
2494                    caller_role: "observer",
2495                    event_tip: "",
2496                    caller_principal: &unattributed,
2497                },
2498            ],
2499        );
2500
2501        let with_key = verify_all(&key, &rows, &[]).attribution;
2502        let with_pubkey = verify_all_with_pubkey(&key.verifying_key_hex(), &rows, &[]).attribution;
2503        assert_eq!(
2504            with_key, with_pubkey,
2505            "the auditor must see the same attribution numbers as the keyholder"
2506        );
2507        let census = with_pubkey.expect("rows were read");
2508        assert_eq!(census.named(), 1);
2509        assert_eq!(census.attribution_failed(), 1);
2510    }
2511
2512    /// Attribution must not move the exit code. `Intact` is a statement about
2513    /// tampering, and `SECURITY.md` leans on that separation: a host where every
2514    /// connection failed attribution still has an untampered chain, and turning
2515    /// that into a non-zero exit would train operators to ignore the real signal.
2516    #[test]
2517    fn a_chain_that_names_nobody_still_exits_zero() {
2518        let key = fixed_key();
2519        let unattributed = crate::auth::CallerPrincipal::Unattributed.as_signed_str();
2520        let rows = chain_of(
2521            &key,
2522            vec![
2523                ChainIdentity::LegacyV1,
2524                ChainIdentity::V3 {
2525                    caller_role: "observer",
2526                    event_tip: "",
2527                    caller_principal: &unattributed,
2528                },
2529            ],
2530        );
2531
2532        let verification = verify_all(&key, &rows, &[]);
2533        let census = verification.attribution.expect("rows were read");
2534        assert_eq!(census.unnamed(), 2, "not one row names an account");
2535        assert_eq!(
2536            verification.exit_code(),
2537            0,
2538            "yet nothing was tampered with, so the command must succeed"
2539        );
2540    }
2541
2542    /// A row from a future encoding is "cannot verify", never "broken": an older
2543    /// binary reading a newer chain has found no tampering, only work it cannot
2544    /// reproduce. The message must say the supported range so the operator knows
2545    /// the fix is a newer build.
2546    #[test]
2547    fn a_future_encoding_reports_cannot_verify_with_the_supported_range() {
2548        let key = fixed_key();
2549        let content = sample_content(1, "tx1");
2550        let hash = key.chain_hash(&content, "");
2551        let mut row = row_for(&content, "", hash);
2552        row.chain_version = CHAIN_VERSION_CURRENT + 1;
2553
2554        match verify_chain(&key, &[row]) {
2555            VerifyOutcome::CannotVerify { reason } => {
2556                assert!(
2557                    reason.contains(&format!("chain_version={}", CHAIN_VERSION_CURRENT + 1)),
2558                    "name the version found, got: {reason}"
2559                );
2560                assert!(
2561                    reason.contains(&CHAIN_VERSION_CURRENT.to_string()),
2562                    "and the newest version understood, got: {reason}"
2563                );
2564            }
2565            other => panic!("a future encoding must not be reported as tampering: {other:?}"),
2566        }
2567    }
2568
2569    /// A row encoded exactly as the v0.2.16 binary would have encoded it: the
2570    /// content is synthetic, the hash is what that release's v2 encoder produced
2571    /// over it. No in-memory test can prove this property, because every other
2572    /// test in this module signs and verifies inside one process, so both halves
2573    /// move together whenever a constant changes.
2574    ///
2575    /// Concretely, this catches the aliasing hazard the `ChainIdentity` docs warn
2576    /// about: `identity()` dispatches stored `chain_version` values against
2577    /// `CHAIN_VERSION_CURRENT`, and the v2 encoder used to *sign* that same
2578    /// constant. Bump it for a new encoding and both the dispatch and the message
2579    /// shift, so every v2 row on disk stops verifying while the whole unit suite
2580    /// stays green. Only a golden vector notices.
2581    #[test]
2582    fn a_row_written_by_the_previous_release_still_verifies() {
2583        const GOLDEN_V2_CHAIN_HASH: &str = "ba12cbe49a149387898bc30f1e8d409effa025ab8e440e6e72bedf060afc831e45d9be264d6b18b7e0ef2f2b3584dbc6953b535546cecbbdc3d7ac666cae4d0a";
2584
2585        let key = fixed_key();
2586        let row = ChainRow {
2587            seq: 7,
2588            key_id: CURRENT_KEY_ID.to_string(),
2589            transaction_id: "tx-golden".to_string(),
2590            request_id: "req-golden".to_string(),
2591            request_hash: "hash-golden".to_string(),
2592            action_name: "AptInstall".to_string(),
2593            risk_level: RiskLevel::Medium,
2594            summary: "install ripgrep".to_string(),
2595            approval_id: Some("appr-golden".to_string()),
2596            warnings_json: "[]".to_string(),
2597            created_at: "2026-07-29T00:00:00Z".to_string(),
2598            prev_chain_hash: String::new(),
2599            chain_hash: GOLDEN_V2_CHAIN_HASH.to_string(),
2600            chain_version: 2,
2601            caller_role: Some("admin".to_string()),
2602            event_tip: Some("eventtip-golden".to_string()),
2603            caller_principal: None,
2604        };
2605
2606        assert_eq!(
2607            verify_chain(&key, std::slice::from_ref(&row)),
2608            VerifyOutcome::Intact { rows_checked: 1 },
2609            "a v2 row on disk must verify under this binary; if this fails, the v2 \
2610             encoding or its version dispatch changed and existing audit logs are unreadable"
2611        );
2612
2613        // Without this half the frozen constant is decorative: a build that
2614        // skipped signature checking entirely would still pass the assertion
2615        // above. Flipping one nibble must be rejected.
2616        let mut tampered = row;
2617        let flipped = match GOLDEN_V2_CHAIN_HASH.strip_prefix('b') {
2618            Some(rest) => format!("a{rest}"),
2619            None => format!("b{}", &GOLDEN_V2_CHAIN_HASH[1..]),
2620        };
2621        tampered.chain_hash = flipped;
2622        assert!(
2623            matches!(
2624                verify_chain(&key, &[tampered]),
2625                VerifyOutcome::Broken { .. }
2626            ),
2627            "a golden row with one nibble changed must not verify"
2628        );
2629    }
2630
2631    /// The v1 encoding is still claimed verifiable, and until now was protected
2632    /// only by tests that sign and verify in one process. Frozen here for the
2633    /// same reason as v2.
2634    #[test]
2635    fn a_legacy_v1_row_on_disk_still_verifies() {
2636        const GOLDEN_V1_CHAIN_HASH: &str = "ce8b47c15414e989e099303b826fcd9985360b794220cefbbf9689d25f31fe173283687feb6d1dd265ae5aca94c8bcfb87ce33ba8c954fafdad18a59cd97c702";
2637
2638        let row = ChainRow {
2639            chain_version: 1,
2640            caller_role: None,
2641            event_tip: None,
2642            caller_principal: None,
2643            chain_hash: GOLDEN_V1_CHAIN_HASH.to_string(),
2644            ..golden_row_shape()
2645        };
2646        assert_eq!(
2647            verify_chain(&fixed_key(), &[row]),
2648            VerifyOutcome::Intact { rows_checked: 1 }
2649        );
2650    }
2651
2652    /// And v3, frozen now while the encoder that produces it is the one under
2653    /// review. When v4 arrives, v3 rows will be in exactly the position v2 rows
2654    /// were in before this change, and this is the test that will notice.
2655    #[test]
2656    fn a_v3_row_on_disk_still_verifies() {
2657        const GOLDEN_V3_CHAIN_HASH: &str = "3c0d05f6ccaf9e65ccb796d4ffe96eb31a2ea67e446217c5234af916dc959d0f2c0ee3d3e5b525aece791ee7d6507efb98dcdd79f0d74dde4273859aa9627d08";
2658
2659        let row = ChainRow {
2660            chain_version: 3,
2661            caller_role: Some("admin".to_string()),
2662            event_tip: Some("eventtip-golden".to_string()),
2663            caller_principal: Some("uid:1000".to_string()),
2664            chain_hash: GOLDEN_V3_CHAIN_HASH.to_string(),
2665            ..golden_row_shape()
2666        };
2667        assert_eq!(
2668            verify_chain(&fixed_key(), &[row]),
2669            VerifyOutcome::Intact { rows_checked: 1 }
2670        );
2671    }
2672
2673    /// The golden rows above all have an empty `prev_chain_hash`, an approval id,
2674    /// and content with no bytes the escape table touches. This one has the
2675    /// opposite of each: a non-empty predecessor, no approval id, and a summary
2676    /// carrying a backslash, `0x1F`, `0x1E`, and a NUL. A refactor of the escape
2677    /// table or of the `approval_id: None` mapping changes this hash and nothing
2678    /// else in the suite.
2679    #[test]
2680    fn the_escape_table_and_absent_approval_encoding_are_frozen() {
2681        const GOLDEN_V2_AWKWARD_CHAIN_HASH: &str = "e6face6fe2346fc0f6c5c1e5845255579c8f08738406b88b0a1f6dbdf8ff85090a4b80d95bd0927ef54b0a2c4810d52ebb4bbf76cb68c865a7748df26442aa05";
2682
2683        let key = fixed_key();
2684        let content = ChainContent {
2685            seq: 8,
2686            approval_id: None,
2687            summary: "back\\slash and \x1funit and \x1erecord and nul\0byte",
2688            identity: ChainIdentity::V2 {
2689                caller_role: "dev",
2690                event_tip: "",
2691            },
2692            ..golden_content_shape()
2693        };
2694        assert_eq!(
2695            key.chain_hash(&content, "ba12cbe4"),
2696            GOLDEN_V2_AWKWARD_CHAIN_HASH,
2697            "the canonical encoding of escapes, an absent approval id, or a \
2698             non-empty prev_chain_hash changed"
2699        );
2700    }
2701
2702    /// Shared literal content for the golden vectors. Spelled out rather than
2703    /// derived from `sample_content` so a future edit to that helper cannot move
2704    /// what the frozen hashes describe.
2705    fn golden_content_shape() -> ChainContent<'static> {
2706        ChainContent {
2707            seq: 7,
2708            key_id: CURRENT_KEY_ID,
2709            transaction_id: "tx-golden",
2710            request_id: "req-golden",
2711            request_hash: "hash-golden",
2712            action_name: "AptInstall",
2713            risk_level: RiskLevel::Medium,
2714            summary: "install ripgrep",
2715            approval_id: Some("appr-golden"),
2716            warnings_json: "[]",
2717            created_at: "2026-07-29T00:00:00Z",
2718            identity: ChainIdentity::LegacyV1,
2719        }
2720    }
2721
2722    /// The stored-row twin of `golden_content_shape`.
2723    fn golden_row_shape() -> ChainRow {
2724        ChainRow {
2725            seq: 7,
2726            key_id: CURRENT_KEY_ID.to_string(),
2727            transaction_id: "tx-golden".to_string(),
2728            request_id: "req-golden".to_string(),
2729            request_hash: "hash-golden".to_string(),
2730            action_name: "AptInstall".to_string(),
2731            risk_level: RiskLevel::Medium,
2732            summary: "install ripgrep".to_string(),
2733            approval_id: Some("appr-golden".to_string()),
2734            warnings_json: "[]".to_string(),
2735            created_at: "2026-07-29T00:00:00Z".to_string(),
2736            prev_chain_hash: String::new(),
2737            chain_hash: String::new(),
2738            chain_version: 1,
2739            caller_role: None,
2740            event_tip: None,
2741            caller_principal: None,
2742        }
2743    }
2744
2745    #[test]
2746    fn a_chain_that_spans_the_migration_verifies_end_to_end() {
2747        // The realistic upgrade shape: old rows, then new rows appended by the
2748        // upgraded daemon, in one chain.
2749        let key = fixed_key();
2750        let mut rows = Vec::new();
2751        let mut prev = String::new();
2752        for (i, identity) in [
2753            ChainIdentity::LegacyV1,
2754            ChainIdentity::LegacyV1,
2755            ChainIdentity::V2 {
2756                caller_role: "Dev",
2757                event_tip: "",
2758            },
2759            ChainIdentity::V2 {
2760                caller_role: "Boot",
2761                event_tip: "",
2762            },
2763        ]
2764        .into_iter()
2765        .enumerate()
2766        {
2767            let txid = format!("tx{i}");
2768            let mut content = sample_content((i + 1) as u64, &txid);
2769            content.identity = identity;
2770            let hash = key.chain_hash(&content, &prev);
2771            rows.push(row_for(&content, &prev, hash.clone()));
2772            prev = hash;
2773        }
2774        assert_eq!(
2775            verify_chain(&key, &rows),
2776            VerifyOutcome::Intact { rows_checked: 4 }
2777        );
2778    }
2779
2780    #[test]
2781    fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
2782        // The attack the version column might invite: relabel a v2 row as
2783        // legacy and drop the identity columns, so verification re-encodes it
2784        // without caller_role. The stored signature was made over the v2
2785        // message, so it cannot verify against the shorter one.
2786        let key = fixed_key();
2787        let mut rows = build_chain(&key, 2);
2788        rows[1].chain_version = CHAIN_VERSION_LEGACY;
2789        rows[1].caller_role = None;
2790        rows[1].event_tip = None;
2791        match verify_chain(&key, &rows) {
2792            VerifyOutcome::Broken {
2793                first_broken_seq, ..
2794            } => assert_eq!(first_broken_seq, 2),
2795            other => panic!("expected Broken, got {other:?}"),
2796        }
2797    }
2798
2799    #[test]
2800    fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
2801        // Nulling the column while leaving chain_version=2 is a
2802        // self-contradictory row, not an infrastructure problem. It must map
2803        // to exit code 1 (tamper detected), never 2 (could not check) — a CI
2804        // gate that treats 2 as "skip" would otherwise wave it through.
2805        let key = fixed_key();
2806        let mut rows = build_chain(&key, 1);
2807        rows[0].caller_role = None;
2808        let outcome = verify_chain(&key, &rows);
2809        assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
2810        assert_eq!(outcome_to_exit_code(&outcome), 1);
2811    }
2812
2813    #[test]
2814    fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
2815        // An older binary reading a newer chain genuinely cannot reproduce the
2816        // message. That is exit 2, distinct from a detected break.
2817        let key = fixed_key();
2818        let mut rows = build_chain(&key, 1);
2819        rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
2820        let outcome = verify_chain(&key, &rows);
2821        assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
2822        assert_eq!(outcome_to_exit_code(&outcome), 2);
2823    }
2824
2825    // ── approval-event chain ──────────────────────────────────────────────
2826
2827    fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
2828        EventContent {
2829            seq,
2830            key_id: CURRENT_KEY_ID,
2831            kind,
2832            transaction_id: txid,
2833            receipt_digest: "digest-abc",
2834            created_at: "2026-04-24T12:00:00Z",
2835        }
2836    }
2837
2838    fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
2839        let mut rows = Vec::with_capacity(kinds.len());
2840        let mut prev = String::new();
2841        for (i, kind) in kinds.iter().enumerate() {
2842            let seq = (i + 1) as u64;
2843            let txid = format!("tx{i}");
2844            let content = event_content(seq, *kind, &txid);
2845            let hash = key.event_hash(&content, &prev);
2846            rows.push(EventRow {
2847                seq,
2848                key_id: content.key_id.to_string(),
2849                kind: content.kind.as_str().to_string(),
2850                transaction_id: content.transaction_id.to_string(),
2851                receipt_digest: content.receipt_digest.to_string(),
2852                created_at: content.created_at.to_string(),
2853                prev_chain_hash: prev.clone(),
2854                chain_hash: hash.clone(),
2855            });
2856            prev = hash;
2857        }
2858        rows
2859    }
2860
2861    #[test]
2862    fn event_kind_spellings_are_stable() {
2863        // These strings are inside the signed message. Renaming one silently
2864        // invalidates every event already written.
2865        assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
2866        assert_eq!(
2867            AuditEventKind::ApprovalConsumed.as_str(),
2868            "approval_consumed"
2869        );
2870        assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
2871    }
2872
2873    #[test]
2874    fn intact_event_chain_verifies() {
2875        let key = fixed_key();
2876        let rows = build_event_chain(
2877            &key,
2878            &[
2879                AuditEventKind::ApprovalGranted,
2880                AuditEventKind::ApprovalConsumed,
2881            ],
2882        );
2883        assert_eq!(
2884            verify_event_chain(&key, &rows),
2885            VerifyOutcome::Intact { rows_checked: 2 }
2886        );
2887    }
2888
2889    #[test]
2890    fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
2891        // The finding this chain exists for: before it, deleting the record
2892        // that an approval happened left `audit verify` reporting Intact.
2893        let key = fixed_key();
2894        let mut rows = build_event_chain(
2895            &key,
2896            &[
2897                AuditEventKind::ApprovalGranted,
2898                AuditEventKind::ApprovalConsumed,
2899                AuditEventKind::ApprovalRevoked,
2900            ],
2901        );
2902        rows.remove(1);
2903        match verify_event_chain(&key, &rows) {
2904            VerifyOutcome::Broken {
2905                first_broken_seq, ..
2906            } => assert_eq!(first_broken_seq, 3),
2907            other => panic!("expected Broken, got {other:?}"),
2908        }
2909    }
2910
2911    #[test]
2912    fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
2913        let key = fixed_key();
2914        let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
2915        rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
2916        assert!(matches!(
2917            verify_event_chain(&key, &rows),
2918            VerifyOutcome::Broken { .. }
2919        ));
2920    }
2921
2922    #[test]
2923    fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
2924        let key = fixed_key();
2925        let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
2926        rows[0].kind = "approval_unicorned".to_string();
2927        assert!(matches!(
2928            verify_event_chain(&key, &rows),
2929            VerifyOutcome::Broken { .. }
2930        ));
2931    }
2932
2933    #[test]
2934    fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
2935        // Domain separation, checked functionally rather than by comparing the
2936        // tag constants: the tags test proves the bytes differ, this proves the
2937        // difference actually reaches the verifier.
2938        let key = fixed_key();
2939        let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
2940        let event_sig = key.event_hash(&content, "");
2941        let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
2942        assert_ne!(event_sig, row_sig);
2943
2944        let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
2945        rows[0].chain_hash = row_sig;
2946        assert!(matches!(
2947            verify_event_chain(&key, &rows),
2948            VerifyOutcome::Broken { .. }
2949        ));
2950    }
2951
2952    // ── cross-chain binding ───────────────────────────────────────────────
2953
2954    #[test]
2955    fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
2956        // Truncating the *tail* of the event chain leaves a self-consistent
2957        // event chain — the walk alone cannot see it. It is caught because a
2958        // later transaction row signed the tip that no longer exists, and the
2959        // transaction chain is what checkpoints anchor.
2960        let key = fixed_key();
2961        let events = build_event_chain(
2962            &key,
2963            &[
2964                AuditEventKind::ApprovalGranted,
2965                AuditEventKind::ApprovalConsumed,
2966            ],
2967        );
2968        let mut content = sample_content(1, "tx-after");
2969        content.identity = ChainIdentity::V2 {
2970            caller_role: "Dev",
2971            event_tip: &events[1].chain_hash,
2972        };
2973        let hash = key.chain_hash(&content, "");
2974        let tx_rows = vec![row_for(&content, "", hash)];
2975
2976        assert_eq!(
2977            verify_event_binding(&tx_rows, &events),
2978            BindingOutcome::Consistent {
2979                bindings_checked: 1
2980            }
2981        );
2982
2983        let truncated = &events[..1];
2984        assert_eq!(
2985            verify_event_chain(&key, truncated),
2986            VerifyOutcome::Intact { rows_checked: 1 },
2987            "a truncated event chain still walks clean; the binding is the detector"
2988        );
2989        match verify_event_binding(&tx_rows, truncated) {
2990            BindingOutcome::MissingEvent {
2991                transaction_seq, ..
2992            } => assert_eq!(transaction_seq, 1),
2993            other => panic!("expected MissingEvent, got {other:?}"),
2994        }
2995    }
2996
2997    #[test]
2998    fn legacy_rows_carry_no_binding_to_check() {
2999        // Rows written before the migration never committed to an event tip.
3000        // They must not be counted as bindings, and must not fail the check.
3001        let key = fixed_key();
3002        let mut content = sample_content(1, "tx-legacy");
3003        content.identity = ChainIdentity::LegacyV1;
3004        let hash = key.chain_hash(&content, "");
3005        let rows = vec![row_for(&content, "", hash)];
3006        assert_eq!(
3007            verify_event_binding(&rows, &[]),
3008            BindingOutcome::Consistent {
3009                bindings_checked: 0
3010            }
3011        );
3012    }
3013
3014    #[test]
3015    fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
3016        // If one check proves tampering and another merely cannot run, the
3017        // command must report the tamper. Reporting 2 ("could not verify")
3018        // would let a CI gate that treats 2 as a skip wave a broken chain
3019        // through.
3020        let verification = AuditVerification {
3021            chain: VerifyOutcome::Broken {
3022                rows_checked: 1,
3023                first_broken_seq: 2,
3024                first_broken_transaction_id: "tx".to_string(),
3025                expected: "e".to_string(),
3026                actual: "a".to_string(),
3027            },
3028            events: VerifyOutcome::CannotVerify {
3029                reason: "no key".to_string(),
3030            },
3031            binding: BindingOutcome::Consistent {
3032                bindings_checked: 0,
3033            },
3034            attribution: None,
3035        };
3036        assert_eq!(verification.exit_code(), 1);
3037    }
3038
3039    #[test]
3040    fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
3041        let verification = AuditVerification {
3042            chain: VerifyOutcome::Intact { rows_checked: 3 },
3043            events: VerifyOutcome::Intact { rows_checked: 1 },
3044            binding: BindingOutcome::MissingEvent {
3045                transaction_seq: 3,
3046                event_tip: "abc".to_string(),
3047            },
3048            attribution: None,
3049        };
3050        assert_eq!(verification.exit_code(), 1);
3051    }
3052
3053    #[test]
3054    fn all_three_clean_is_exit_zero() {
3055        let verification = AuditVerification {
3056            chain: VerifyOutcome::Intact { rows_checked: 3 },
3057            events: VerifyOutcome::Intact { rows_checked: 2 },
3058            binding: BindingOutcome::Consistent {
3059                bindings_checked: 1,
3060            },
3061            attribution: None,
3062        };
3063        assert_eq!(verification.exit_code(), 0);
3064    }
3065
3066    #[test]
3067    fn binding_exit_codes_split_clean_tampered_and_unchecked() {
3068        assert_eq!(
3069            binding_outcome_to_exit_code(&BindingOutcome::Consistent {
3070                bindings_checked: 0
3071            }),
3072            0
3073        );
3074        assert_eq!(
3075            binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
3076                transaction_seq: 7,
3077                event_tip: "abc".to_string()
3078            }),
3079            1
3080        );
3081        assert_eq!(binding_outcome_to_exit_code(&BindingOutcome::NotChecked), 2);
3082    }
3083
3084    #[test]
3085    fn intact_chain_verifies() {
3086        let key = fixed_key();
3087        let rows = build_chain(&key, 5);
3088        let outcome = verify_chain(&key, &rows);
3089        assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
3090        assert_eq!(outcome_to_exit_code(&outcome), 0);
3091    }
3092
3093    #[test]
3094    fn empty_chain_verifies() {
3095        let key = fixed_key();
3096        let outcome = verify_chain(&key, &[]);
3097        assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
3098    }
3099
3100    #[test]
3101    fn tampered_summary_breaks_chain_at_first_offending_row() {
3102        let key = fixed_key();
3103        let mut rows = build_chain(&key, 3);
3104        // Mutate summary on row 1 (seq=2). Hash mismatch should be detected.
3105        rows[1].summary = "TAMPERED".to_string();
3106        let outcome = verify_chain(&key, &rows);
3107        match outcome {
3108            VerifyOutcome::Broken {
3109                first_broken_seq, ..
3110            } => assert_eq!(first_broken_seq, 2),
3111            other => panic!("expected Broken, got {other:?}"),
3112        }
3113    }
3114
3115    #[test]
3116    fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
3117        let key = fixed_key();
3118        let mut rows = build_chain(&key, 4);
3119        // Remove row at seq=2 entirely. Row 3's prev_chain_hash now mismatches.
3120        rows.remove(1);
3121        let outcome = verify_chain(&key, &rows);
3122        match outcome {
3123            VerifyOutcome::Broken {
3124                first_broken_seq, ..
3125            } => assert_eq!(
3126                first_broken_seq, 3,
3127                "first broken row is the one whose prev_hash refers to deleted row"
3128            ),
3129            other => panic!("expected Broken, got {other:?}"),
3130        }
3131    }
3132
3133    #[test]
3134    fn inserted_forged_row_breaks_chain() {
3135        // Counterpart to `deleted_middle_row_…`. An attacker who managed to
3136        // insert a fabricated row between two genuine ones still cannot
3137        // produce a `chain_hash` that links the insertion back to the prior
3138        // row's hash, so verification must flag the forgery at the inserted
3139        // row (or at the immediately following genuine row whose
3140        // `prev_chain_hash` no longer matches its real predecessor).
3141        let key = fixed_key();
3142        let mut rows = build_chain(&key, 3);
3143
3144        // Splice in a new row between seq=1 and seq=2 with a fabricated hash.
3145        let forged = ChainRow {
3146            // Carries a principal on purpose: without one the row is rejected as
3147            // self-contradictory *before* any signature is checked, and this test
3148            // exists to prove the walk stops on a bad signature.
3149            caller_principal: Some("uid:1000".to_string()),
3150            seq: 2,
3151            key_id: CURRENT_KEY_ID.to_string(),
3152            transaction_id: "tx-forged".to_string(),
3153            request_id: "req-forged".to_string(),
3154            request_hash: "hash-forged".to_string(),
3155            action_name: "InstallFlatpak".to_string(),
3156            risk_level: RiskLevel::Medium,
3157            summary: "Forged row".to_string(),
3158            approval_id: None,
3159            warnings_json: "[]".to_string(),
3160            created_at: "2026-04-25T13:00:00Z".to_string(),
3161            // Plausible prev_chain_hash chosen to look intact at boundary.
3162            prev_chain_hash: rows[0].chain_hash.clone(),
3163            // Not a valid signature; verification must reject this.
3164            chain_hash: "0".repeat(HASH_HEX_LEN),
3165            chain_version: CHAIN_VERSION_CURRENT,
3166            caller_role: Some("Dev".to_string()),
3167            event_tip: Some(String::new()),
3168        };
3169
3170        // Renumber the genuine seq=2/3 rows so seq is still 1..=4.
3171        let mut rest: Vec<ChainRow> = rows.split_off(1);
3172        for r in rest.iter_mut() {
3173            r.seq += 1;
3174        }
3175        rows.push(forged);
3176        rows.extend(rest);
3177
3178        // The fixture is deterministic: the forged row sits at seq=2 with a
3179        // signature that cannot verify, so the walk must stop exactly there.
3180        // Accepting "2 or 3" let an off-by-one in `verify_rows` pass.
3181        let outcome = verify_chain(&key, &rows);
3182        match outcome {
3183            VerifyOutcome::Broken {
3184                first_broken_seq, ..
3185            } => assert_eq!(
3186                first_broken_seq, 2,
3187                "the verifier must flag the forged row itself, not a later one"
3188            ),
3189            other => panic!("expected Broken, got {other:?}"),
3190        }
3191    }
3192
3193    // ── Ed25519 public-key verification / non-repudiation ─────────────────
3194
3195    #[test]
3196    fn signature_verifies_under_exported_public_key() {
3197        // The auditor path: verify with only the public key.
3198        let key = fixed_key();
3199        let rows = build_chain(&key, 4);
3200        let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3201        assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
3202    }
3203
3204    #[test]
3205    fn foreign_public_key_cannot_validate_chain() {
3206        // Non-repudiation: a different keypair's public key neither validates
3207        // the chain nor (by construction) could forge it. This is the property
3208        // the old symmetric HMAC could not provide.
3209        let signer = AuditKey::from_bytes(vec![0x11; 32]);
3210        let rows = build_chain(&signer, 3);
3211        let other = AuditKey::from_bytes(vec![0x22; 32]);
3212        let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
3213        assert!(matches!(
3214            outcome,
3215            VerifyOutcome::Broken {
3216                first_broken_seq: 1,
3217                ..
3218            }
3219        ));
3220    }
3221
3222    #[test]
3223    fn verifying_key_hex_is_a_32_byte_public_key() {
3224        let key = fixed_key();
3225        let vk_hex = key.verifying_key_hex();
3226        assert_eq!(
3227            vk_hex.len(),
3228            64,
3229            "32-byte ed25519 public key = 64 hex chars"
3230        );
3231        assert!(hex::decode(&vk_hex).is_ok());
3232    }
3233
3234    #[test]
3235    fn malformed_signature_hex_is_broken_not_panic() {
3236        let key = fixed_key();
3237        let mut rows = build_chain(&key, 2);
3238        rows[0].chain_hash = "not-valid-hex!!".to_string();
3239        let outcome = verify_chain(&key, &rows);
3240        assert!(matches!(
3241            outcome,
3242            VerifyOutcome::Broken {
3243                first_broken_seq: 1,
3244                ..
3245            }
3246        ));
3247    }
3248
3249    #[test]
3250    fn bad_public_key_hex_yields_cannot_verify() {
3251        let key = fixed_key();
3252        let rows = build_chain(&key, 1);
3253        let outcome = verify_chain_with_pubkey("zz", &rows);
3254        assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3255    }
3256
3257    #[test]
3258    fn load_or_generate_writes_public_key_sidecar() {
3259        let dir = tempfile::tempdir().unwrap();
3260        let path = dir.path().join("audit-key");
3261        let key = AuditKey::load_or_generate(&path).unwrap();
3262        let pub_path = dir.path().join("audit-key.pub");
3263        assert!(pub_path.exists(), "public key sidecar must be written");
3264        let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
3265        assert_eq!(pub_hex.trim(), key.verifying_key_hex());
3266    }
3267
3268    #[test]
3269    fn wrong_key_id_yields_cannot_verify() {
3270        let key = fixed_key();
3271        let mut rows = build_chain(&key, 2);
3272        rows[0].key_id = "v99".to_string();
3273        let outcome = verify_chain(&key, &rows);
3274        assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
3275        assert_eq!(outcome_to_exit_code(&outcome), 2);
3276    }
3277
3278    #[test]
3279    fn exit_code_for_each_outcome() {
3280        assert_eq!(
3281            outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
3282            0
3283        );
3284        assert_eq!(
3285            outcome_to_exit_code(&VerifyOutcome::Broken {
3286                rows_checked: 0,
3287                first_broken_seq: 1,
3288                first_broken_transaction_id: String::new(),
3289                expected: String::new(),
3290                actual: String::new(),
3291            }),
3292            1
3293        );
3294        assert_eq!(
3295            outcome_to_exit_code(&VerifyOutcome::CannotVerify {
3296                reason: String::new()
3297            }),
3298            2
3299        );
3300    }
3301
3302    // ── AuditKey file management ──────────────────────────────────────────
3303
3304    #[test]
3305    fn load_or_generate_creates_with_0600_mode() {
3306        use std::os::unix::fs::PermissionsExt;
3307        let dir = tempfile::tempdir().unwrap();
3308        let path = dir.path().join("audit-key");
3309        let _key = AuditKey::load_or_generate(&path).unwrap();
3310        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
3311        assert_eq!(mode, 0o600);
3312    }
3313
3314    #[test]
3315    fn load_or_generate_is_idempotent() {
3316        let dir = tempfile::tempdir().unwrap();
3317        let path = dir.path().join("audit-key");
3318        let key1_bytes = {
3319            let _ = AuditKey::load_or_generate(&path).unwrap();
3320            std::fs::read(&path).unwrap()
3321        };
3322        let _ = AuditKey::load_or_generate(&path).unwrap();
3323        let key2_bytes = std::fs::read(&path).unwrap();
3324        assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
3325    }
3326
3327    #[test]
3328    fn rejects_world_readable_key_file() {
3329        use std::os::unix::fs::PermissionsExt;
3330        let dir = tempfile::tempdir().unwrap();
3331        let path = dir.path().join("audit-key");
3332        std::fs::write(&path, vec![0u8; 32]).unwrap();
3333        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3334        let result = AuditKey::load_or_generate(&path);
3335        assert!(matches!(
3336            result,
3337            Err(AuditKeyError::UnsafePermissions { .. })
3338        ));
3339    }
3340
3341    #[test]
3342    fn rejects_short_key_file() {
3343        use std::os::unix::fs::PermissionsExt;
3344        let dir = tempfile::tempdir().unwrap();
3345        let path = dir.path().join("audit-key");
3346        std::fs::write(&path, vec![0u8; 8]).unwrap();
3347        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
3348        let result = AuditKey::load_or_generate(&path);
3349        assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
3350    }
3351
3352    // ── Debug redaction (HI-17 secret hygiene) ────────────────────────────
3353
3354    /// `Debug` must never expose the Ed25519 private key material, neither raw bytes,
3355    /// nor their hex encoding. A derived `Debug` would dump the bytes the
3356    /// moment anyone wrote `tracing::debug!("{key:?}")`.
3357    #[test]
3358    fn debug_redacts_key_bytes_and_their_hex_encoding() {
3359        // Use a distinctive byte pattern so accidental leaks are obvious.
3360        let raw = (0u8..32).collect::<Vec<u8>>();
3361        let key = AuditKey::from_bytes(raw.clone());
3362        let dbg = format!("{key:?}");
3363
3364        assert!(
3365            dbg.contains("redacted"),
3366            "Debug output must contain the literal 'redacted' marker, got: {dbg}"
3367        );
3368
3369        // Hex encoding of the bytes must NOT appear, in either case.
3370        let hex_lower = hex::encode(&raw);
3371        let hex_upper = hex::encode_upper(&raw);
3372        assert!(
3373            !dbg.contains(&hex_lower),
3374            "Debug output leaks lowercase hex of key bytes: {dbg}"
3375        );
3376        assert!(
3377            !dbg.contains(&hex_upper),
3378            "Debug output leaks uppercase hex of key bytes: {dbg}"
3379        );
3380
3381        // Each individual byte's two-hex-char form must also be absent. This
3382        // catches the case where a future change splits the bytes across the
3383        // formatter and prints them piecewise.
3384        for b in &raw {
3385            let pair = format!("{b:02x}");
3386            // 1-byte values 0x00..0x0f render as "00".."0f" — too short to
3387            // assert against safely (collides with key_id "v1" etc.). Only
3388            // check 2-char forms that cannot incidentally match the rest of
3389            // the Debug output.
3390            if *b >= 0x10 && pair != "1e" && pair != "1f" {
3391                assert!(
3392                    !dbg.contains(&pair),
3393                    "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
3394                );
3395            }
3396        }
3397    }
3398
3399    /// Operators triaging a chain break need to know which key generation
3400    /// produced a row. `key_id` is not secret and must remain visible.
3401    #[test]
3402    fn debug_preserves_key_id() {
3403        let key = AuditKey::from_bytes(vec![0xff; 32]);
3404        let dbg = format!("{key:?}");
3405        assert!(
3406            dbg.contains("key_id"),
3407            "Debug output must label the key_id field: {dbg}"
3408        );
3409        assert!(
3410            dbg.contains(CURRENT_KEY_ID),
3411            "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
3412        );
3413    }
3414
3415    // ── signed checkpoints (anti-truncation / anti-rewrite) ───────────────
3416
3417    #[test]
3418    fn checkpoint_consistent_with_intact_chain() {
3419        let key = fixed_key();
3420        let rows = build_chain(&key, 5);
3421        let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3422        let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3423        assert_eq!(
3424            outcome,
3425            CheckpointOutcome::Consistent {
3426                checkpoints_checked: 1
3427            }
3428        );
3429    }
3430
3431    #[test]
3432    fn checkpoint_detects_truncation() {
3433        let key = fixed_key();
3434        let full = build_chain(&key, 5);
3435        // Anchor a checkpoint at the tip (seq=5); later the chain is cut to 3.
3436        let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
3437        let truncated = &full[..3];
3438        let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
3439        assert!(matches!(
3440            outcome,
3441            CheckpointOutcome::Truncated {
3442                checkpoint_seq: 5,
3443                current_max_seq: 3
3444            }
3445        ));
3446    }
3447
3448    #[test]
3449    fn checkpoint_detects_rewrite() {
3450        let key = fixed_key();
3451        let mut rows = build_chain(&key, 4);
3452        let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3453        // Rewrite the row at seq=3 after the checkpoint was anchored.
3454        rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
3455        let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3456        assert!(matches!(
3457            outcome,
3458            CheckpointOutcome::TipMismatch { seq: 3, .. }
3459        ));
3460    }
3461
3462    #[test]
3463    fn checkpoint_bad_signature_detected() {
3464        let key = fixed_key();
3465        let rows = build_chain(&key, 3);
3466        let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
3467        cp.signature = "0".repeat(HASH_HEX_LEN); // not a valid signature
3468        let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
3469        assert!(matches!(
3470            outcome,
3471            CheckpointOutcome::BadSignature { seq: 2 }
3472        ));
3473    }
3474
3475    #[test]
3476    fn checkpoint_foreign_key_rejected() {
3477        // A checkpoint signed by one key must not verify under another key.
3478        let signer = AuditKey::from_bytes(vec![0x11; 32]);
3479        let rows = build_chain(&signer, 3);
3480        let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
3481        let other = AuditKey::from_bytes(vec![0x22; 32]);
3482        let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
3483        assert!(matches!(
3484            outcome,
3485            CheckpointOutcome::BadSignature { seq: 2 }
3486        ));
3487    }
3488
3489    #[test]
3490    fn pubkey_verify_detects_tampered_middle_row() {
3491        // The core auditor claim: with only the public key, a mutated field in
3492        // a non-first row is detected at that exact seq.
3493        let key = fixed_key();
3494        let mut rows = build_chain(&key, 4);
3495        rows[2].summary = "TAMPERED".to_string();
3496        let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
3497        assert!(matches!(
3498            outcome,
3499            VerifyOutcome::Broken {
3500                first_broken_seq: 3,
3501                ..
3502            }
3503        ));
3504    }
3505
3506    #[test]
3507    fn multiple_checkpoints_all_consistent() {
3508        let key = fixed_key();
3509        let rows = build_chain(&key, 5);
3510        let cps = vec![
3511            key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
3512            key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
3513            key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
3514        ];
3515        assert_eq!(
3516            verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
3517            CheckpointOutcome::Consistent {
3518                checkpoints_checked: 3
3519            }
3520        );
3521    }
3522
3523    #[test]
3524    fn middle_checkpoint_failure_is_reported() {
3525        // Earlier-consistent checkpoints must not mask a later failure.
3526        let key = fixed_key();
3527        let mut rows = build_chain(&key, 5);
3528        let cps = vec![
3529            key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
3530            key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
3531            key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
3532        ];
3533        rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); // rewrite what cp #2 commits to
3534        assert!(matches!(
3535            verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
3536            CheckpointOutcome::TipMismatch { seq: 3, .. }
3537        ));
3538    }
3539
3540    #[test]
3541    fn checkpoint_created_at_is_signed() {
3542        // created_at is inside the signed message: backdating invalidates it.
3543        let key = fixed_key();
3544        let rows = build_chain(&key, 3);
3545        let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3546        cp.created_at = "2020-01-01T00:00:00Z".to_string();
3547        assert!(matches!(
3548            verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
3549            CheckpointOutcome::BadSignature { .. }
3550        ));
3551    }
3552
3553    #[test]
3554    fn checkpoint_seq_is_signed() {
3555        // seq is inside the signed message: moving it invalidates the signature.
3556        let key = fixed_key();
3557        let rows = build_chain(&key, 3);
3558        let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
3559        cp.seq = 2;
3560        assert!(matches!(
3561            verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
3562            CheckpointOutcome::BadSignature { seq: 2 }
3563        ));
3564    }
3565
3566    #[test]
3567    fn checkpoint_detects_full_wipe() {
3568        // The whole chain deleted: the anchored tip cannot be reproduced.
3569        let key = fixed_key();
3570        let full = build_chain(&key, 3);
3571        let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
3572        assert!(matches!(
3573            verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
3574            CheckpointOutcome::Truncated {
3575                checkpoint_seq: 3,
3576                current_max_seq: 0
3577            }
3578        ));
3579    }
3580
3581    #[test]
3582    fn domain_tags_are_distinct_and_prefix_free() {
3583        // Security invariant: row and checkpoint signatures can never
3584        // cross-verify because their signed messages start with distinct,
3585        // prefix-free domain tags.
3586        // Checked pairwise over the whole set rather than as a hand-written
3587        // list: adding a fourth tag to a hand-written list silently leaves the
3588        // new tag unchecked against the old ones.
3589        let tags: [(&str, &[u8]); 4] = [
3590            ("row", ROW_DOMAIN),
3591            ("checkpoint", CHECKPOINT_DOMAIN),
3592            ("approval", APPROVAL_DOMAIN),
3593            ("event", EVENT_DOMAIN),
3594        ];
3595        for (i, (name_a, a)) in tags.iter().enumerate() {
3596            for (name_b, b) in tags.iter().skip(i + 1) {
3597                assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
3598                assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
3599                assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
3600            }
3601        }
3602    }
3603
3604    #[test]
3605    fn approval_receipt_framing_is_injective() {
3606        // The signed receipt message must be injective in
3607        // (transaction_id, request_hash). Without `push_field` escaping, the
3608        // ambiguous pair below would collide because a raw 0x1F separator
3609        // cannot be distinguished from a 0x1F inside a field value.
3610        let key = fixed_key();
3611        let sep = "\u{1f}";
3612        let a = key.approval_receipt(&format!("tx{sep}b"), "c");
3613        let b = key.approval_receipt("tx", &format!("b{sep}c"));
3614        assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
3615
3616        // Sanity: the derivation is deterministic (Ed25519, same inputs).
3617        assert_eq!(
3618            key.approval_receipt("tx-1", "hash-1"),
3619            key.approval_receipt("tx-1", "hash-1"),
3620        );
3621        // And the commitment is exactly SHA-256 of the receipt.
3622        assert_eq!(
3623            key.approval_commitment("tx-1", "hash-1"),
3624            approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
3625        );
3626    }
3627}