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