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