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