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