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