Skip to main content

sysknife_daemon/
audit_chain.rs

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