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 written by this binary: adds `caller_role` and `event_tip`.
95pub const CHAIN_VERSION_CURRENT: u32 = 2;
96
97/// Loaded Ed25519 signing key + its identifier. Construct via
98/// [`AuditKey::load_or_generate`].
99///
100/// `Clone` is intentional: the audit-verify CLI needs to load the key once
101/// and share it between the SQLite read-only path and the Postgres pool.
102///
103/// `Debug` is implemented manually to redact the signing key. A derived
104/// `Debug` would dump the private key via any `tracing::debug!("{key:?}")`
105/// or `dbg!(key)` site, which would leak the audit secret into journald.
106/// We keep `key_id` visible because operators need to identify which key
107/// generation a record belongs to when triaging chain breaks.
108#[derive(Clone)]
109pub struct AuditKey {
110    key_id: String,
111    signing: SigningKey,
112}
113
114impl std::fmt::Debug for AuditKey {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        // Never render the private key. A derived `Debug` (or printing the
117        // signing key) would leak the audit secret into journald via any
118        // `tracing::debug!("{key:?}")`. `key_id` is not secret and is kept
119        // visible for triaging chain breaks.
120        f.debug_struct("AuditKey")
121            .field("key_id", &self.key_id)
122            .field("signing", &format_args!("<redacted signing key>"))
123            .finish()
124    }
125}
126
127// The `SigningKey` zeroizes its secret scalar on drop (ed25519-dalek `zeroize`
128// feature), so no manual `Drop` is needed to keep the private key out of
129// post-free memory.
130
131#[derive(Debug, thiserror::Error)]
132pub enum AuditKeyError {
133    #[error("io error reading audit key {path}: {source}")]
134    Io {
135        path: PathBuf,
136        #[source]
137        source: std::io::Error,
138    },
139    #[error(
140        "audit key file {path} has unsafe permissions {mode:#o}; \
141         tighten with `chmod 600 {path:?}` and restart"
142    )]
143    UnsafePermissions { path: PathBuf, mode: u32 },
144    #[error(
145        "audit key file {path} is too short ({len} bytes); \
146         expected at least 32 bytes of random material"
147    )]
148    KeyTooShort { path: PathBuf, len: usize },
149}
150
151impl AuditKey {
152    /// Load the audit key from `path`. If the file does not exist, generate a
153    /// 32-byte cryptographically random key and write it with mode `0o600`.
154    ///
155    /// On every load (including freshly generated), the file's permissions
156    /// are checked: any bit beyond `0o600` for owner-only access is rejected
157    /// — a world-readable audit key is a self-defeating audit chain.
158    pub fn load_or_generate(path: &Path) -> Result<Self, AuditKeyError> {
159        if !path.exists() {
160            generate_key_at(path)?;
161        }
162
163        let metadata = std::fs::metadata(path).map_err(|e| AuditKeyError::Io {
164            path: path.to_path_buf(),
165            source: e,
166        })?;
167
168        use std::os::unix::fs::PermissionsExt;
169        let mode = metadata.permissions().mode() & 0o777;
170        if mode & 0o077 != 0 {
171            // Group or world bits set — reject.
172            return Err(AuditKeyError::UnsafePermissions {
173                path: path.to_path_buf(),
174                mode,
175            });
176        }
177
178        let mut key_bytes = std::fs::read(path).map_err(|e| AuditKeyError::Io {
179            path: path.to_path_buf(),
180            source: e,
181        })?;
182        if key_bytes.len() < 32 {
183            return Err(AuditKeyError::KeyTooShort {
184                path: path.to_path_buf(),
185                len: key_bytes.len(),
186            });
187        }
188
189        let mut seed = [0u8; 32];
190        seed.copy_from_slice(&key_bytes[..32]);
191        let signing = SigningKey::from_bytes(&seed);
192        seed.zeroize();
193        key_bytes.zeroize();
194
195        Ok(Self {
196            key_id: CURRENT_KEY_ID.to_string(),
197            signing,
198        })
199    }
200
201    /// Construct a key from a 32-byte seed. For tests only — production builds
202    /// always go through [`Self::load_or_generate`] for the permission check.
203    #[cfg(test)]
204    pub fn from_bytes(bytes: Vec<u8>) -> Self {
205        let mut seed = [0u8; 32];
206        seed.copy_from_slice(&bytes[..32]);
207        let signing = SigningKey::from_bytes(&seed);
208        seed.zeroize();
209        Self {
210            key_id: CURRENT_KEY_ID.to_string(),
211            signing,
212        }
213    }
214
215    /// Stable identifier for this key generation.
216    pub fn key_id(&self) -> &str {
217        &self.key_id
218    }
219
220    /// Compute the chain signature for `content` linked to `prev_chain_hash`.
221    ///
222    /// Hex-encoded Ed25519 signature over `canonical(content) || prev_chain_hash`.
223    /// Deterministic (RFC 8032): identical inputs always yield the same value.
224    pub fn chain_hash(&self, content: &ChainContent, prev_chain_hash: &str) -> String {
225        let sig = self.signing.sign(&chain_message(content, prev_chain_hash));
226        hex::encode(sig.to_bytes())
227    }
228
229    /// The public verifying key for this audit key.
230    pub fn verifying_key(&self) -> VerifyingKey {
231        self.signing.verifying_key()
232    }
233
234    /// Hex-encoded 32-byte Ed25519 public key. Safe to publish; auditors use it
235    /// to verify the chain without the ability to forge entries.
236    pub fn verifying_key_hex(&self) -> String {
237        hex::encode(self.signing.verifying_key().to_bytes())
238    }
239
240    /// Derive the bearer receipt for one immutable preview. The signature is
241    /// deterministic, so a failed response delivery can be retried without
242    /// storing plaintext bearer credentials in the database.
243    pub fn approval_receipt(&self, transaction_id: &str, request_hash: &str) -> String {
244        // Frame both fields through `push_field` (the same escaping used for
245        // chain rows) so the signed message is injective in
246        // (transaction_id, request_hash). Raw concatenation with a bare 0x1F
247        // would alias distinct inputs if either field ever contained 0x1F;
248        // escaping removes that dependency on the callers' value shapes.
249        let mut message = APPROVAL_DOMAIN.to_vec();
250        push_field(&mut message, "txid", transaction_id);
251        push_field(&mut message, "reqhash", request_hash);
252        hex::encode(self.signing.sign(&message).to_bytes())
253    }
254
255    /// Chain-stored SHA-256 commitment to the deterministic approval receipt.
256    pub fn approval_commitment(&self, transaction_id: &str, request_hash: &str) -> String {
257        approval_receipt_digest(&self.approval_receipt(transaction_id, request_hash))
258    }
259}
260
261pub fn approval_receipt_digest(receipt: &str) -> String {
262    hex::encode(Sha256::digest(receipt.as_bytes()))
263}
264
265/// Message signed for a row: `ROW_DOMAIN || canonical(content) || prev_chain_hash`.
266///
267/// The leading domain tag separates row signatures from checkpoint signatures
268/// so a signature produced in one context can never verify in the other, even
269/// if the framed fields were ever to overlap.
270fn chain_message(content: &ChainContent, prev_chain_hash: &str) -> Vec<u8> {
271    let mut msg = ROW_DOMAIN.to_vec();
272    msg.extend_from_slice(&content.canonical_bytes());
273    msg.extend_from_slice(prev_chain_hash.as_bytes());
274    msg
275}
276
277/// Resolve the audit key file path: `$SYSKNIFE_AUDIT_KEY_PATH` if set,
278/// otherwise `<db_dir>/audit-key` (sibling of the database). This is the single
279/// definition of the key-location precedence documented in the module header;
280/// the CLI verify/checkpoint paths resolve through it so they always agree with
281/// the daemon on where the key lives.
282pub fn resolve_audit_key_path(db_path: &Path) -> PathBuf {
283    std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
284        .map(PathBuf::from)
285        .unwrap_or_else(|_| {
286            db_path
287                .parent()
288                .unwrap_or_else(|| Path::new("."))
289                .join("audit-key")
290        })
291}
292
293/// Generate a 32-byte random key and write it to `path` with mode `0o600`.
294/// Parent directory is created with mode `0o700` if missing.
295fn generate_key_at(path: &Path) -> Result<(), AuditKeyError> {
296    use std::io::Write as _;
297    use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
298
299    if let Some(parent) = path.parent() {
300        if !parent.exists() {
301            std::fs::DirBuilder::new()
302                .recursive(true)
303                .mode(0o700)
304                .create(parent)
305                .map_err(|e| AuditKeyError::Io {
306                    path: parent.to_path_buf(),
307                    source: e,
308                })?;
309        }
310    }
311
312    let mut bytes = [0u8; 32];
313    fill_random(&mut bytes).map_err(|e| AuditKeyError::Io {
314        path: path.to_path_buf(),
315        source: e,
316    })?;
317
318    let mut f = std::fs::OpenOptions::new()
319        .write(true)
320        .create_new(true)
321        .mode(0o600)
322        .open(path)
323        .map_err(|e| AuditKeyError::Io {
324            path: path.to_path_buf(),
325            source: e,
326        })?;
327    f.write_all(&bytes).map_err(|e| AuditKeyError::Io {
328        path: path.to_path_buf(),
329        source: e,
330    })?;
331    f.sync_all().map_err(|e| AuditKeyError::Io {
332        path: path.to_path_buf(),
333        source: e,
334    })?;
335
336    // Write the public key alongside as `<key>.pub` (hex). Not secret: it lets
337    // an auditor or aggregator verify the chain without the private key.
338    let signing = SigningKey::from_bytes(&bytes);
339    let pub_hex = hex::encode(signing.verifying_key().to_bytes());
340    bytes.zeroize();
341    let mut pub_path = path.as_os_str().to_os_string();
342    pub_path.push(".pub");
343    std::fs::write(&pub_path, format!("{pub_hex}\n")).map_err(|e| AuditKeyError::Io {
344        path: PathBuf::from(pub_path),
345        source: e,
346    })?;
347    Ok(())
348}
349
350/// Fill `buf` with bytes from the kernel CSPRNG via `/dev/urandom`.
351fn fill_random(buf: &mut [u8]) -> std::io::Result<()> {
352    use std::io::Read as _;
353    let mut f = std::fs::File::open("/dev/urandom")?;
354    f.read_exact(buf)
355}
356
357/// Immutable fields hashed into the chain. Status is intentionally absent —
358/// see module docs.
359///
360/// # Security contract — chain-content immutability
361///
362/// Every field in this struct is captured **once** at INSERT time and baked
363/// into `chain_hash = ed25519_sign(canonical(self) || prev_chain_hash, key)`.
364/// After the row is written the hash is a one-time commitment: **no field
365/// in this struct may ever be mutated in place**.
366///
367/// `summary` is the field most likely to attract a future "let me just fix
368/// that typo" API. **Do not add an `update_summary` (or similar) function.**
369/// If a correction is genuinely needed, choose one of the two safe options:
370///
371/// 1. **Insert a corrective row** — a new transaction row that references the
372///    original `transaction_id` in its own `summary`, leaving the original
373///    row and its chain hash untouched.
374/// 2. **Extend the chain protocol** — introduce a dedicated amendment record
375///    type with its own chain link, so that both the original commitment and
376///    the correction are auditable.
377///
378/// Any other approach silently breaks chain integrity: `verify_chain` will
379/// flag the modified row as `Broken` because the stored signature will no
380/// longer verify against the row's content.
381///
382/// The canonical serialisation is stable across SQLite/Postgres backends.
383/// Each field is emitted as
384///
385/// ```text
386///     <tag-name> 0x1E <tag-value> 0x1F
387/// ```
388///
389/// where `0x1E` is the *tag/value* separator within a single field and `0x1F`
390/// is the *field* separator that terminates the field and introduces the
391/// next one. We use the ASCII C0 byte values RS (0x1E) and US (0x1F)
392/// because they are guaranteed not to appear in any normal text field, but
393/// **our role assignment is the inverse of the ASCII C0 convention**
394/// (where RS = "record separator" and US = "unit separator"). The names
395/// are kept in the source for byte-level traceability against the canonical
396/// buffer, not as a claim about ASCII semantics.
397///
398/// Inside a value, the four
399/// bytes `\\`, NUL, `RS`, `US` are escaped to `\\\\`, `\\0`, `\\1E`, `\\1F`
400/// respectively. The escape table is **prefix-free** (every escape starts
401/// with `\\`), so any value can be injected without ambiguity. See
402/// `push_field` for the implementation and tests for the round-trip.
403/// Which generation of the canonical row encoding a row was signed under.
404///
405/// # Why this is an enum and not two `Option` fields
406///
407/// Adding a field to [`ChainContent`] changes the signed message, so every row
408/// written before the change would re-encode differently and report as
409/// `Broken`. Chains already exist in the field, and "delete your audit log to
410/// upgrade" is not an acceptable migration for an audit log. The stored
411/// `chain_version` column therefore selects the encoding **per row**: legacy
412/// rows keep the exact bytes they were signed over, new rows carry the
413/// identity fields.
414///
415/// A downgrade is not a hiding place. Rewriting a `V2` row as `LegacyV1` (to
416/// erase `caller_role`) makes verification re-encode it without the identity
417/// fields, so the stored signature — made over the `V2` message — no longer
418/// verifies and the row reports as `Broken`.
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum ChainIdentity<'a> {
421    /// `chain_version = 1`. Written before the identity migration.
422    LegacyV1,
423    /// `chain_version = 2`. Binds the row to the authenticated caller and to
424    /// the approval-event chain tip at insert time.
425    V2 {
426        /// Role the daemon resolved for the connection that requested this
427        /// action (`SO_PEERCRED` for Unix sockets, token for vsock). Signing
428        /// it is what lets an auditor answer "who asked for this".
429        caller_role: &'a str,
430        /// `chain_hash` of the last [`EventRow`] at insert time, or `""` when
431        /// the event chain is empty. This is the cross-chain binding: because
432        /// checkpoints anchor the *transaction* chain tip, a committed
433        /// `event_tip` transitively anchors the event chain, so deleting
434        /// approval events below it becomes detectable.
435        event_tip: &'a str,
436    },
437}
438
439impl ChainIdentity<'_> {
440    /// `chain_version` column value for this encoding.
441    pub fn version(&self) -> u32 {
442        match self {
443            Self::LegacyV1 => CHAIN_VERSION_LEGACY,
444            Self::V2 { .. } => CHAIN_VERSION_CURRENT,
445        }
446    }
447}
448
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct ChainContent<'a> {
451    pub seq: u64,
452    pub key_id: &'a str,
453    pub transaction_id: &'a str,
454    pub request_id: &'a str,
455    pub request_hash: &'a str,
456    pub action_name: &'a str,
457    pub risk_level: RiskLevel,
458    /// Human-readable description of the planned action.
459    ///
460    /// **Immutable after insert.** This field is included in the chain hash
461    /// (see [`ChainContent`] struct-level doc). It MUST NOT be updated after
462    /// the row is written — doing so silently invalidates `chain_hash` and
463    /// will be detected as `VerifyOutcome::Broken` by `sysknife audit verify`.
464    /// See the struct-level security contract for the only safe correction
465    /// strategies.
466    pub summary: &'a str,
467    /// Signed receipt commitment for daemon-created previews. Legacy/imported
468    /// rows may be `None`; serialised as empty for wire compatibility.
469    pub approval_id: Option<&'a str>,
470    /// JSON-canonical (sorted keys) array of warning strings.
471    pub warnings_json: &'a str,
472    pub created_at: &'a str,
473    /// Encoding generation. See [`ChainIdentity`] for why this is per-row.
474    pub identity: ChainIdentity<'a>,
475}
476
477impl<'a> ChainContent<'a> {
478    /// Stable canonical encoding of every field. Within each field the
479    /// `0x1E` byte separates tag from value, and `0x1F` terminates the field;
480    /// raw NUL becomes the two-byte escape `\0`.
481    pub fn canonical_bytes(&self) -> Vec<u8> {
482        let risk_level_str = match self.risk_level {
483            RiskLevel::Low => "low",
484            RiskLevel::Medium => "medium",
485            RiskLevel::High => "high",
486        };
487        let approval = self.approval_id.unwrap_or("");
488
489        let mut buf = Vec::with_capacity(512);
490        // Each field is `tag 0x1E value 0x1F`; tags make the canonical form
491        // self-describing for forensics.
492        push_field(&mut buf, "seq", &self.seq.to_string());
493        push_field(&mut buf, "key_id", self.key_id);
494        push_field(&mut buf, "transaction_id", self.transaction_id);
495        push_field(&mut buf, "request_id", self.request_id);
496        push_field(&mut buf, "request_hash", self.request_hash);
497        push_field(&mut buf, "action_name", self.action_name);
498        push_field(&mut buf, "risk_level", risk_level_str);
499        push_field(&mut buf, "summary", self.summary);
500        push_field(&mut buf, "approval_id", approval);
501        push_field(&mut buf, "warnings_json", self.warnings_json);
502        push_field(&mut buf, "created_at", self.created_at);
503        // v2 fields are *appended*, so a legacy row's bytes are unchanged and
504        // its signature still verifies. The `chain_version` tag leads the
505        // suffix so a future v3 can never alias a v2 message: the two differ
506        // in a signed field, not merely in field count.
507        if let ChainIdentity::V2 {
508            caller_role,
509            event_tip,
510        } = self.identity
511        {
512            push_field(
513                &mut buf,
514                "chain_version",
515                &CHAIN_VERSION_CURRENT.to_string(),
516            );
517            push_field(&mut buf, "caller_role", caller_role);
518            push_field(&mut buf, "event_tip", event_tip);
519        }
520        buf
521    }
522}
523
524/// Append `tag<RS>value<US>` to `buf`, escaping any byte that could otherwise
525/// alias one of the framing characters or another escape sequence.
526///
527/// The escape table is **prefix-free**: every escape starts with `\` (0x5C)
528/// followed by a tag that cannot itself be the start of a different escape.
529/// Concretely:
530///
531/// | Raw byte | Escape       |
532/// |---------:|--------------|
533/// | `\\`     | `\\\\`       |
534/// | `\x00`   | `\\0`        |
535/// | `\x1E`   | `\\1E`       |
536/// | `\x1F`   | `\\1F`       |
537///
538/// The `\\` escape MUST come first: without it, a field value containing the
539/// literal two-byte sequence `\` + `0` would canonicalise to the same bytes
540/// as a raw NUL and produce a chain-signature collision.
541fn push_field(buf: &mut Vec<u8>, tag: &str, value: &str) {
542    buf.extend_from_slice(tag.as_bytes());
543    buf.push(0x1E); // tag/value separator (ASCII RS byte, but used inversely — see ChainContent doc)
544    for b in value.bytes() {
545        match b {
546            b'\\' => buf.extend_from_slice(b"\\\\"),
547            0x00 => buf.extend_from_slice(b"\\0"),
548            0x1E => buf.extend_from_slice(b"\\1E"),
549            0x1F => buf.extend_from_slice(b"\\1F"),
550            other => buf.push(other),
551        }
552    }
553    buf.push(0x1F); // field terminator (ASCII US byte, but used inversely — see ChainContent doc)
554}
555
556/// Result of `verify_chain`.
557#[derive(Debug, Clone, PartialEq, Eq)]
558pub enum VerifyOutcome {
559    /// Chain is intact across all rows checked.
560    Intact { rows_checked: u64 },
561    /// One or more rows fail to chain. First broken row is reported.
562    Broken {
563        rows_checked: u64,
564        first_broken_seq: u64,
565        first_broken_transaction_id: String,
566        expected: String,
567        actual: String,
568    },
569    /// Verification could not be completed (missing key, unreachable storage,
570    /// retired key not on disk, etc.). Distinct from `Broken` — callers map
571    /// this to exit code 2.
572    CannotVerify { reason: String },
573}
574
575/// Process exit code for `sysknife audit verify`.
576///
577/// `0` intact, `1` broken, `2` cannot-verify. The split between 1 and 2
578/// matters: a CI pipeline expecting 0 or 1 must not silently pass on a
579/// missing key file.
580pub fn outcome_to_exit_code(outcome: &VerifyOutcome) -> i32 {
581    match outcome {
582        VerifyOutcome::Intact { .. } => 0,
583        VerifyOutcome::Broken { .. } => 1,
584        VerifyOutcome::CannotVerify { .. } => 2,
585    }
586}
587
588/// One row's worth of chain data, as fetched from the store.
589#[derive(Debug, Clone)]
590pub struct ChainRow {
591    pub seq: u64,
592    pub key_id: String,
593    pub transaction_id: String,
594    pub request_id: String,
595    pub request_hash: String,
596    pub action_name: String,
597    pub risk_level: RiskLevel,
598    pub summary: String,
599    pub approval_id: Option<String>,
600    pub warnings_json: String,
601    pub created_at: String,
602    pub prev_chain_hash: String,
603    pub chain_hash: String,
604    /// Which encoding this row was signed under. See [`ChainIdentity`].
605    pub chain_version: u32,
606    /// `NULL` for legacy rows; required for `chain_version = 2`.
607    pub caller_role: Option<String>,
608    /// `NULL` for legacy rows; required for `chain_version = 2`. May be the
609    /// empty string when the event chain was empty at insert time.
610    pub event_tip: Option<String>,
611}
612
613/// Why a stored row's columns do not describe a verifiable encoding.
614#[derive(Debug)]
615pub(crate) enum RowIdentityError {
616    /// The row claims an encoding this binary does not know how to reproduce.
617    /// Genuinely unverifiable, not evidence of tampering — an older binary
618    /// reading a newer chain lands here.
619    UnknownVersion(u32),
620    /// The row claims `chain_version = 2` but is missing an identity column.
621    /// No message can be reconstructed, and the shape is self-contradictory,
622    /// so this counts as a detected break rather than an inability to check.
623    MissingField(&'static str),
624}
625
626impl ChainRow {
627    /// Recover the encoding this row was signed under, so a caller can rebuild
628    /// the exact message that produced `chain_hash`.
629    pub(crate) fn identity(&self) -> Result<ChainIdentity<'_>, RowIdentityError> {
630        match self.chain_version {
631            CHAIN_VERSION_LEGACY => Ok(ChainIdentity::LegacyV1),
632            CHAIN_VERSION_CURRENT => Ok(ChainIdentity::V2 {
633                caller_role: self
634                    .caller_role
635                    .as_deref()
636                    .ok_or(RowIdentityError::MissingField("caller_role"))?,
637                event_tip: self
638                    .event_tip
639                    .as_deref()
640                    .ok_or(RowIdentityError::MissingField("event_tip"))?,
641            }),
642            other => Err(RowIdentityError::UnknownVersion(other)),
643        }
644    }
645}
646
647/// Verify a chain using the daemon's key. Verification uses the **public** key
648/// (so it proves, but cannot forge) and also asserts every row was written
649/// under this key generation (`key_id`).
650pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
651    verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
652}
653
654/// Verify a chain with only the hex-encoded Ed25519 **public** key. This is the
655/// auditor / aggregator path: it proves the chain without the private key and
656/// cannot be used to forge entries. `key_id` is not checked — the public key
657/// itself identifies the signer.
658pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
659    match parse_verifying_key(verifying_key_hex) {
660        Some(vk) => verify_rows(&vk, None, rows),
661        None => VerifyOutcome::CannotVerify {
662            reason: format!(
663                "invalid public key hex ({} chars); expected 64 hex chars of a \
664                 32-byte Ed25519 public key",
665                verifying_key_hex.len()
666            ),
667        },
668    }
669}
670
671fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
672    let bytes = hex::decode(hex_str.trim()).ok()?;
673    let arr: [u8; 32] = bytes.try_into().ok()?;
674    VerifyingKey::from_bytes(&arr).ok()
675}
676
677/// Verify a stored hex signature over a row's message. Any malformed signature
678/// (bad hex, wrong length, invalid point) is a failed check, never a panic.
679fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
680    let Ok(bytes) = hex::decode(sig_hex) else {
681        return false;
682    };
683    let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
684        return false;
685    };
686    vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
687}
688
689/// Walk `rows` in seq order, verifying each row's signature with `vk` and its
690/// `prev_chain_hash` linkage. Returns the first break (or `Intact`). When
691/// `expect_key_id` is `Some`, every row must carry that `key_id`.
692fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
693    let mut last_hash = String::new();
694    let mut rows_checked = 0u64;
695    for row in rows {
696        if let Some(kid) = expect_key_id {
697            if row.key_id != kid {
698                return VerifyOutcome::CannotVerify {
699                    reason: format!(
700                        "row seq={} uses key_id={:?} but only {:?} is loaded; \
701                         epoch keys not yet supported",
702                        row.seq, row.key_id, kid
703                    ),
704                };
705            }
706        }
707        if row.prev_chain_hash != last_hash {
708            return VerifyOutcome::Broken {
709                rows_checked,
710                first_broken_seq: row.seq,
711                first_broken_transaction_id: row.transaction_id.clone(),
712                expected: format!("prev_chain_hash={last_hash}"),
713                actual: format!("prev_chain_hash={}", row.prev_chain_hash),
714            };
715        }
716        let identity = match row.identity() {
717            Ok(identity) => identity,
718            Err(RowIdentityError::UnknownVersion(v)) => {
719                return VerifyOutcome::CannotVerify {
720                    reason: format!(
721                        "row seq={} declares chain_version={v}, which this binary cannot \
722                         reproduce (it understands {CHAIN_VERSION_LEGACY}..={CHAIN_VERSION_CURRENT}); \
723                         verify with a build at least as new as the one that wrote the chain",
724                        row.seq
725                    ),
726                };
727            }
728            Err(RowIdentityError::MissingField(field)) => {
729                return VerifyOutcome::Broken {
730                    rows_checked,
731                    first_broken_seq: row.seq,
732                    first_broken_transaction_id: row.transaction_id.clone(),
733                    expected: format!("chain_version={CHAIN_VERSION_CURRENT} row carrying {field}"),
734                    actual: format!("{field}=NULL"),
735                };
736            }
737        };
738        let content = ChainContent {
739            seq: row.seq,
740            key_id: &row.key_id,
741            transaction_id: &row.transaction_id,
742            request_id: &row.request_id,
743            request_hash: &row.request_hash,
744            action_name: &row.action_name,
745            risk_level: row.risk_level,
746            summary: &row.summary,
747            approval_id: row.approval_id.as_deref(),
748            warnings_json: &row.warnings_json,
749            created_at: &row.created_at,
750            identity,
751        };
752        let msg = chain_message(&content, &row.prev_chain_hash);
753        if !signature_ok(vk, &msg, &row.chain_hash) {
754            return VerifyOutcome::Broken {
755                rows_checked,
756                first_broken_seq: row.seq,
757                first_broken_transaction_id: row.transaction_id.clone(),
758                expected: "valid ed25519 signature".to_string(),
759                actual: row.chain_hash.clone(),
760            };
761        }
762        last_hash = row.chain_hash.clone();
763        rows_checked += 1;
764    }
765    VerifyOutcome::Intact { rows_checked }
766}
767
768// ── Approval-event chain ─────────────────────────────────────────────────────
769
770/// A lifecycle event on an approval receipt.
771///
772/// The `transactions` chain commits to the *authorisation decision* at preview
773/// time. It says nothing about whether a human ever approved, whether that
774/// approval was spent, or whether it was retracted — those live in
775/// `transaction_approvals`, a plain mutable table. Deleting a row from it used
776/// to leave `sysknife audit verify` reporting `Intact`, so the record that an
777/// approval happened was the one part of the trail an attacker could erase
778/// without leaving a mark. These events are chained so that erasure breaks a
779/// signature.
780#[derive(Debug, Clone, Copy, PartialEq, Eq)]
781pub enum AuditEventKind {
782    /// A receipt was minted for a queued transaction.
783    ApprovalGranted,
784    /// A receipt was spent to move a transaction into `Running`.
785    ApprovalConsumed,
786    /// An undelivered receipt was retracted before it could be spent.
787    ApprovalRevoked,
788}
789
790impl AuditEventKind {
791    /// Stored and signed spelling. Stable on the wire — changing one of these
792    /// strings invalidates every event signature already written.
793    pub fn as_str(&self) -> &'static str {
794        match self {
795            Self::ApprovalGranted => "approval_granted",
796            Self::ApprovalConsumed => "approval_consumed",
797            Self::ApprovalRevoked => "approval_revoked",
798        }
799    }
800}
801
802/// Immutable content of one approval event, signed into the event chain.
803#[derive(Debug, Clone, PartialEq, Eq)]
804pub struct EventContent<'a> {
805    pub seq: u64,
806    pub key_id: &'a str,
807    pub kind: AuditEventKind,
808    pub transaction_id: &'a str,
809    /// The receipt digest the event concerns. Binds the event to the specific
810    /// receipt, so swapping in a different approval's digest breaks the
811    /// signature.
812    pub receipt_digest: &'a str,
813    pub created_at: &'a str,
814}
815
816impl EventContent<'_> {
817    /// Canonical encoding, using the same prefix-free field framing as
818    /// [`ChainContent::canonical_bytes`].
819    pub fn canonical_bytes(&self) -> Vec<u8> {
820        let mut buf = Vec::with_capacity(256);
821        push_field(&mut buf, "seq", &self.seq.to_string());
822        push_field(&mut buf, "key_id", self.key_id);
823        push_field(&mut buf, "kind", self.kind.as_str());
824        push_field(&mut buf, "transaction_id", self.transaction_id);
825        push_field(&mut buf, "receipt_digest", self.receipt_digest);
826        push_field(&mut buf, "created_at", self.created_at);
827        buf
828    }
829}
830
831fn event_message(content: &EventContent, prev_chain_hash: &str) -> Vec<u8> {
832    let mut msg = EVENT_DOMAIN.to_vec();
833    msg.extend_from_slice(&content.canonical_bytes());
834    msg.extend_from_slice(prev_chain_hash.as_bytes());
835    msg
836}
837
838impl AuditKey {
839    /// Compute the event-chain signature for `content` linked to
840    /// `prev_chain_hash`. Separate domain tag from rows and checkpoints, so an
841    /// event signature can never be replayed as a transaction row.
842    pub fn event_hash(&self, content: &EventContent, prev_chain_hash: &str) -> String {
843        hex::encode(
844            self.signing
845                .sign(&event_message(content, prev_chain_hash))
846                .to_bytes(),
847        )
848    }
849}
850
851/// One approval event as fetched from the store.
852#[derive(Debug, Clone, PartialEq, Eq)]
853pub struct EventRow {
854    pub seq: u64,
855    pub key_id: String,
856    /// Stored spelling of an [`AuditEventKind`]. Kept as a string so an
857    /// unrecognised value read from the database is a verification failure
858    /// rather than a deserialisation panic.
859    pub kind: String,
860    pub transaction_id: String,
861    pub receipt_digest: String,
862    pub created_at: String,
863    pub prev_chain_hash: String,
864    pub chain_hash: String,
865}
866
867/// Verify the approval-event chain with the daemon's key.
868pub fn verify_event_chain(key: &AuditKey, rows: &[EventRow]) -> VerifyOutcome {
869    verify_event_rows(&key.verifying_key(), Some(key.key_id()), rows)
870}
871
872/// Verify the approval-event chain with only the exported public key.
873pub fn verify_event_chain_with_pubkey(verifying_key_hex: &str, rows: &[EventRow]) -> VerifyOutcome {
874    match parse_verifying_key(verifying_key_hex) {
875        Some(vk) => verify_event_rows(&vk, None, rows),
876        None => VerifyOutcome::CannotVerify {
877            reason: format!(
878                "invalid public key hex ({} chars); expected 64 hex chars of a \
879                 32-byte Ed25519 public key",
880                verifying_key_hex.len()
881            ),
882        },
883    }
884}
885
886fn verify_event_rows(
887    vk: &VerifyingKey,
888    expect_key_id: Option<&str>,
889    rows: &[EventRow],
890) -> VerifyOutcome {
891    let mut last_hash = String::new();
892    let mut rows_checked = 0u64;
893    for row in rows {
894        if let Some(kid) = expect_key_id {
895            if row.key_id != kid {
896                return VerifyOutcome::CannotVerify {
897                    reason: format!(
898                        "event seq={} uses key_id={:?} but only {:?} is loaded; \
899                         epoch keys not yet supported",
900                        row.seq, row.key_id, kid
901                    ),
902                };
903            }
904        }
905        if row.prev_chain_hash != last_hash {
906            return VerifyOutcome::Broken {
907                rows_checked,
908                first_broken_seq: row.seq,
909                first_broken_transaction_id: row.transaction_id.clone(),
910                expected: format!("prev_chain_hash={last_hash}"),
911                actual: format!("prev_chain_hash={}", row.prev_chain_hash),
912            };
913        }
914        // An unknown `kind` cannot be re-encoded, so it can never reproduce a
915        // valid signature. Report it as the break it is instead of guessing.
916        let Some(kind) = parse_event_kind(&row.kind) else {
917            return VerifyOutcome::Broken {
918                rows_checked,
919                first_broken_seq: row.seq,
920                first_broken_transaction_id: row.transaction_id.clone(),
921                expected: "a known event kind".to_string(),
922                actual: format!("kind={:?}", row.kind),
923            };
924        };
925        let content = EventContent {
926            seq: row.seq,
927            key_id: &row.key_id,
928            kind,
929            transaction_id: &row.transaction_id,
930            receipt_digest: &row.receipt_digest,
931            created_at: &row.created_at,
932        };
933        if !signature_ok(
934            vk,
935            &event_message(&content, &row.prev_chain_hash),
936            &row.chain_hash,
937        ) {
938            return VerifyOutcome::Broken {
939                rows_checked,
940                first_broken_seq: row.seq,
941                first_broken_transaction_id: row.transaction_id.clone(),
942                expected: "valid ed25519 signature".to_string(),
943                actual: row.chain_hash.clone(),
944            };
945        }
946        last_hash = row.chain_hash.clone();
947        rows_checked += 1;
948    }
949    VerifyOutcome::Intact { rows_checked }
950}
951
952fn parse_event_kind(raw: &str) -> Option<AuditEventKind> {
953    // Exhaustive by construction: the match below fails to compile if a
954    // variant is added without a spelling here.
955    [
956        AuditEventKind::ApprovalGranted,
957        AuditEventKind::ApprovalConsumed,
958        AuditEventKind::ApprovalRevoked,
959    ]
960    .into_iter()
961    .find(|kind| kind.as_str() == raw)
962}
963
964/// Result of checking that the transaction chain's committed `event_tip`
965/// values are still reproducible from the event chain.
966#[derive(Debug, Clone, PartialEq, Eq)]
967pub enum BindingOutcome {
968    /// Every committed tip is present in the event chain.
969    Consistent { bindings_checked: u64 },
970    /// A transaction row committed to an event tip that no longer exists —
971    /// approval events were deleted from the end of the event chain, which the
972    /// event chain walk alone cannot see.
973    MissingEvent {
974        transaction_seq: u64,
975        event_tip: String,
976    },
977}
978
979/// Check the cross-chain binding: each `chain_version = 2` transaction row
980/// signs the event-chain tip as of its insert, so a later deletion of trailing
981/// approval events leaves a tip that can no longer be found.
982///
983/// This is what extends checkpoint anchoring to the event chain for free: the
984/// checkpoints commit to the transaction tip, the transaction rows commit to
985/// event tips. Events appended *after* the last transaction row are still
986/// unanchored until the next row is written — the same bounded tail exposure
987/// the transaction chain has between checkpoints.
988pub fn verify_event_binding(tx_rows: &[ChainRow], event_rows: &[EventRow]) -> BindingOutcome {
989    let known: std::collections::HashSet<&str> = event_rows
990        .iter()
991        .map(|row| row.chain_hash.as_str())
992        .collect();
993    let mut bindings_checked = 0u64;
994    for row in tx_rows {
995        let Some(tip) = row.event_tip.as_deref() else {
996            continue;
997        };
998        if tip.is_empty() {
999            continue;
1000        }
1001        if !known.contains(tip) {
1002            return BindingOutcome::MissingEvent {
1003                transaction_seq: row.seq,
1004                event_tip: tip.to_string(),
1005            };
1006        }
1007        bindings_checked += 1;
1008    }
1009    BindingOutcome::Consistent { bindings_checked }
1010}
1011
1012/// Everything `sysknife audit verify` checks, in one value.
1013///
1014/// Three independent questions, deliberately not collapsed into one enum:
1015/// is the authorisation record intact, is the approval record intact, and do
1016/// the two still agree. Collapsing them would let a clean transaction chain
1017/// mask a tampered approval trail in the summary line.
1018#[derive(Debug, Clone, PartialEq, Eq)]
1019pub struct AuditVerification {
1020    pub chain: VerifyOutcome,
1021    pub events: VerifyOutcome,
1022    pub binding: BindingOutcome,
1023}
1024
1025impl AuditVerification {
1026    /// Worst result across all three checks.
1027    ///
1028    /// A detected tamper (`1`) outranks an inability to check (`2`): if the
1029    /// chain is provably broken, reporting "could not verify" because some
1030    /// *other* check was inconclusive would understate what is known.
1031    pub fn exit_code(&self) -> i32 {
1032        let codes = [
1033            outcome_to_exit_code(&self.chain),
1034            outcome_to_exit_code(&self.events),
1035            binding_outcome_to_exit_code(&self.binding),
1036        ];
1037        if codes.contains(&1) {
1038            1
1039        } else if codes.contains(&2) {
1040            2
1041        } else {
1042            0
1043        }
1044    }
1045}
1046
1047/// Run all three checks with the daemon's key.
1048pub fn verify_all(
1049    key: &AuditKey,
1050    tx_rows: &[ChainRow],
1051    event_rows: &[EventRow],
1052) -> AuditVerification {
1053    AuditVerification {
1054        chain: verify_chain(key, tx_rows),
1055        events: verify_event_chain(key, event_rows),
1056        binding: verify_event_binding(tx_rows, event_rows),
1057    }
1058}
1059
1060/// Run all three checks with only the exported public key (the auditor path).
1061pub fn verify_all_with_pubkey(
1062    verifying_key_hex: &str,
1063    tx_rows: &[ChainRow],
1064    event_rows: &[EventRow],
1065) -> AuditVerification {
1066    AuditVerification {
1067        chain: verify_chain_with_pubkey(verifying_key_hex, tx_rows),
1068        events: verify_event_chain_with_pubkey(verifying_key_hex, event_rows),
1069        binding: verify_event_binding(tx_rows, event_rows),
1070    }
1071}
1072
1073/// Exit code for the binding check, on the same scale as
1074/// [`outcome_to_exit_code`]: a missing event is a detected tamper (`1`).
1075pub fn binding_outcome_to_exit_code(outcome: &BindingOutcome) -> i32 {
1076    match outcome {
1077        BindingOutcome::Consistent { .. } => 0,
1078        BindingOutcome::MissingEvent { .. } => 1,
1079    }
1080}
1081
1082// ── Signed checkpoints (external anchoring / tail-truncation detection) ──────
1083
1084/// A signed commitment to the chain tip at a point in time. Periodically
1085/// emitted and anchored to an independent, append-only sink (a separate
1086/// database, a WORM store, or an RFC 3161 timestamp) so that a later attempt to
1087/// rewrite or **truncate** the local chain is detectable: the anchored
1088/// `(seq, chain_tip)` can no longer be reproduced from the shortened chain.
1089///
1090/// This is the Certificate-Transparency "signed checkpoint" idiom. The
1091/// signature is Ed25519 over the canonical `(seq, chain_tip, created_at)`, so
1092/// an auditor verifies it with only the public key.
1093///
1094/// A `Checkpoint` value carries no validity guarantee on its own: validity is
1095/// established solely by [`verify_checkpoints`] under the public key. A
1096/// checkpoint loaded from a sink is untrusted input until verified.
1097#[derive(Debug, Clone, PartialEq, Eq)]
1098pub struct Checkpoint {
1099    /// `seq` of the chain tip this checkpoint commits to (the last row at
1100    /// emit time).
1101    pub seq: u64,
1102    /// `chain_hash` of the row at `seq` (the committed chain tip).
1103    pub chain_tip: String,
1104    /// RFC 3339 timestamp when the checkpoint was signed.
1105    pub created_at: String,
1106    /// Hex Ed25519 signature over `canonical(seq, chain_tip, created_at)`.
1107    pub signature: String,
1108}
1109
1110/// Canonical message signed into a checkpoint. Reuses the prefix-free field
1111/// framing so the encoding is unambiguous and stable across backends.
1112fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
1113    // Leading domain tag: separates checkpoint signatures from row signatures
1114    // (see `chain_message`) so the two contexts can never cross-verify.
1115    let mut buf = CHECKPOINT_DOMAIN.to_vec();
1116    push_field(&mut buf, "seq", &seq.to_string());
1117    push_field(&mut buf, "chain_tip", chain_tip);
1118    push_field(&mut buf, "created_at", created_at);
1119    buf
1120}
1121
1122impl AuditKey {
1123    /// Sign a checkpoint committing to `(seq, chain_tip)` at `created_at`.
1124    pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
1125        let sig = self
1126            .signing
1127            .sign(&checkpoint_message(seq, chain_tip, created_at));
1128        Checkpoint {
1129            seq,
1130            chain_tip: chain_tip.to_string(),
1131            created_at: created_at.to_string(),
1132            signature: hex::encode(sig.to_bytes()),
1133        }
1134    }
1135}
1136
1137/// Result of checking anchored checkpoints against the current chain.
1138#[derive(Debug, Clone, PartialEq, Eq)]
1139pub enum CheckpointOutcome {
1140    /// Every checkpoint's signature is valid and its committed tip is still
1141    /// present in the chain at the committed `seq`.
1142    Consistent { checkpoints_checked: u64 },
1143    /// A checkpoint signature failed to verify under the public key.
1144    BadSignature { seq: u64 },
1145    /// A checkpoint commits to a `seq` no longer present in the chain — the
1146    /// chain has been **truncated** below a previously anchored tip.
1147    Truncated {
1148        checkpoint_seq: u64,
1149        current_max_seq: u64,
1150    },
1151    /// A checkpoint's committed `chain_tip` does not match the chain's
1152    /// `chain_hash` at that `seq` — the chain was **rewritten**.
1153    TipMismatch {
1154        seq: u64,
1155        anchored: String,
1156        actual: String,
1157    },
1158    /// Could not verify (e.g. malformed public key).
1159    CannotVerify { reason: String },
1160}
1161
1162/// Verify anchored `checkpoints` against `rows` (the current chain) with the
1163/// hex **public** key. Detects truncation (a checkpoint seq no longer in the
1164/// chain) and rewrite (tip mismatch at a checkpoint seq). `rows` must be in
1165/// seq order. This is the anti-root guarantee: a host attacker who shortens or
1166/// edits the local chain cannot reproduce a previously anchored signed tip.
1167pub fn verify_checkpoints(
1168    verifying_key_hex: &str,
1169    rows: &[ChainRow],
1170    checkpoints: &[Checkpoint],
1171) -> CheckpointOutcome {
1172    let vk = match parse_verifying_key(verifying_key_hex) {
1173        Some(vk) => vk,
1174        None => {
1175            return CheckpointOutcome::CannotVerify {
1176                reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
1177            };
1178        }
1179    };
1180    // `rows` is seq-sorted (ORDER BY seq ASC in the store; chain build order in
1181    // tests), so the tip seq is the last element and we can binary-search below.
1182    let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
1183    let mut checked = 0u64;
1184    for cp in checkpoints {
1185        let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
1186        if !signature_ok(&vk, &msg, &cp.signature) {
1187            return CheckpointOutcome::BadSignature { seq: cp.seq };
1188        }
1189        match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
1190            Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
1191            Ok(idx) => {
1192                return CheckpointOutcome::TipMismatch {
1193                    seq: cp.seq,
1194                    anchored: cp.chain_tip.clone(),
1195                    actual: rows[idx].chain_hash.clone(),
1196                };
1197            }
1198            Err(_) => {
1199                return CheckpointOutcome::Truncated {
1200                    checkpoint_seq: cp.seq,
1201                    current_max_seq,
1202                };
1203            }
1204        }
1205        checked += 1;
1206    }
1207    CheckpointOutcome::Consistent {
1208        checkpoints_checked: checked,
1209    }
1210}
1211
1212/// Process exit code for `sysknife audit checkpoint` verification, mirroring
1213/// [`outcome_to_exit_code`]: `0` consistent, `1` a detected tamper (bad
1214/// signature / truncation / rewrite), `2` cannot-verify (e.g. malformed public
1215/// key). The 1-vs-2 split matters for CI exactly as it does for chain verify.
1216pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
1217    match outcome {
1218        CheckpointOutcome::Consistent { .. } => 0,
1219        CheckpointOutcome::BadSignature { .. }
1220        | CheckpointOutcome::Truncated { .. }
1221        | CheckpointOutcome::TipMismatch { .. } => 1,
1222        CheckpointOutcome::CannotVerify { .. } => 2,
1223    }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229
1230    fn fixed_key() -> AuditKey {
1231        AuditKey::from_bytes(vec![0x42; 32])
1232    }
1233
1234    fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
1235        ChainContent {
1236            seq,
1237            key_id: CURRENT_KEY_ID,
1238            transaction_id: txid,
1239            request_id: "req-1",
1240            request_hash: "hash-abc",
1241            action_name: "UpdateSystem",
1242            risk_level: RiskLevel::High,
1243            summary: "Upgrade",
1244            approval_id: None,
1245            warnings_json: "[]",
1246            created_at: "2026-04-24T12:00:00Z",
1247            identity: ChainIdentity::V2 {
1248                caller_role: "Dev",
1249                event_tip: "",
1250            },
1251        }
1252    }
1253
1254    // ── chain_hash determinism + linkage ──────────────────────────────────
1255
1256    #[test]
1257    fn same_inputs_yield_same_hash() {
1258        let key = fixed_key();
1259        let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1260        let h2 = key.chain_hash(&sample_content(1, "txa"), "");
1261        assert_eq!(h1, h2);
1262        assert_eq!(h1.len(), HASH_HEX_LEN);
1263    }
1264
1265    #[test]
1266    fn different_seq_yields_different_hash() {
1267        let key = fixed_key();
1268        let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1269        let h2 = key.chain_hash(&sample_content(2, "txa"), "");
1270        assert_ne!(h1, h2);
1271    }
1272
1273    #[test]
1274    fn different_prev_hash_yields_different_hash() {
1275        let key = fixed_key();
1276        let h1 = key.chain_hash(&sample_content(1, "txa"), "");
1277        let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
1278        assert_ne!(h1, h2);
1279    }
1280
1281    #[test]
1282    fn different_keys_yield_different_hashes() {
1283        let key1 = AuditKey::from_bytes(vec![0x01; 32]);
1284        let key2 = AuditKey::from_bytes(vec![0x02; 32]);
1285        let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
1286        let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
1287        assert_ne!(h1, h2);
1288    }
1289
1290    // ── canonical encoding stability ──────────────────────────────────────
1291
1292    #[test]
1293    fn canonical_bytes_have_stable_field_order() {
1294        let c = sample_content(1, "txa");
1295        let bytes = c.canonical_bytes();
1296        let s = String::from_utf8_lossy(&bytes);
1297        // Tags must appear in a fixed order.
1298        let order = [
1299            "seq",
1300            "key_id",
1301            "transaction_id",
1302            "request_id",
1303            "request_hash",
1304            "action_name",
1305            "risk_level",
1306            "summary",
1307            "approval_id",
1308            "warnings_json",
1309            "created_at",
1310        ];
1311        let mut last_idx = 0;
1312        for tag in order {
1313            let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
1314            assert!(idx >= last_idx, "{tag} out of order");
1315            last_idx = idx;
1316        }
1317    }
1318
1319    #[test]
1320    fn nul_bytes_in_field_value_are_escaped() {
1321        let mut c = sample_content(1, "txa");
1322        let summary = "before\0after";
1323        c.summary = summary;
1324        let bytes = c.canonical_bytes();
1325        // Raw NUL must NOT appear; escape sequence must.
1326        assert!(!bytes.contains(&0x00));
1327        assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
1328    }
1329
1330    /// Backslash-NUL collision regression: an attacker who could craft a
1331    /// row with `summary = "before\\0after"` (literal backslash + zero) must
1332    /// NOT produce the same canonical bytes as one with raw `\x00`. Without
1333    /// the `\\` → `\\\\` escape, the two collide and the attacker can
1334    /// substitute one for the other while the chain hash matches.
1335    #[test]
1336    fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
1337        let mut a = sample_content(1, "txa");
1338        a.summary = "before\\0after"; // literal backslash + '0'
1339        let mut b = sample_content(1, "txa");
1340        b.summary = "before\0after"; // raw NUL
1341        assert_ne!(
1342            a.canonical_bytes(),
1343            b.canonical_bytes(),
1344            "backslash escape must run before NUL escape to prevent collision"
1345        );
1346    }
1347
1348    fn key_for_collision_test() -> AuditKey {
1349        AuditKey::from_bytes(vec![0xab; 32])
1350    }
1351
1352    #[test]
1353    fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
1354        let key = key_for_collision_test();
1355        let mut a = sample_content(1, "txa");
1356        a.summary = "before\\0after";
1357        let mut b = sample_content(1, "txa");
1358        b.summary = "before\0after";
1359        assert_ne!(
1360            key.chain_hash(&a, ""),
1361            key.chain_hash(&b, ""),
1362            "chain signature must distinguish escape from raw byte"
1363        );
1364    }
1365
1366    // ── verify_chain ──────────────────────────────────────────────────────
1367
1368    /// Materialise the stored row that `content` would produce, so a test
1369    /// never hand-copies field-by-field and accidentally signs one thing while
1370    /// storing another.
1371    fn row_for(content: &ChainContent<'_>, prev: &str, chain_hash: String) -> ChainRow {
1372        let (chain_version, caller_role, event_tip) = match content.identity {
1373            ChainIdentity::LegacyV1 => (CHAIN_VERSION_LEGACY, None, None),
1374            ChainIdentity::V2 {
1375                caller_role,
1376                event_tip,
1377            } => (
1378                CHAIN_VERSION_CURRENT,
1379                Some(caller_role.to_string()),
1380                Some(event_tip.to_string()),
1381            ),
1382        };
1383        ChainRow {
1384            seq: content.seq,
1385            key_id: content.key_id.to_string(),
1386            transaction_id: content.transaction_id.to_string(),
1387            request_id: content.request_id.to_string(),
1388            request_hash: content.request_hash.to_string(),
1389            action_name: content.action_name.to_string(),
1390            risk_level: content.risk_level,
1391            summary: content.summary.to_string(),
1392            approval_id: content.approval_id.map(str::to_string),
1393            warnings_json: content.warnings_json.to_string(),
1394            created_at: content.created_at.to_string(),
1395            prev_chain_hash: prev.to_string(),
1396            chain_hash,
1397            chain_version,
1398            caller_role,
1399            event_tip,
1400        }
1401    }
1402
1403    fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
1404        let mut rows = Vec::with_capacity(count);
1405        let mut prev = String::new();
1406        for i in 0..count {
1407            let seq = (i + 1) as u64;
1408            let txid = format!("tx{i}");
1409            let content = sample_content(seq, &txid);
1410            let hash = key.chain_hash(&content, &prev);
1411            rows.push(row_for(&content, &prev, hash.clone()));
1412            prev = hash;
1413        }
1414        rows
1415    }
1416
1417    // ── caller identity in the signed content ─────────────────────────────
1418
1419    #[test]
1420    fn caller_role_is_covered_by_the_row_signature() {
1421        // The whole point of the v2 encoding: two rows identical except for
1422        // who asked must not share a signature, or the chain cannot answer
1423        // "which role requested this".
1424        let key = fixed_key();
1425        let mut dev = sample_content(1, "txa");
1426        dev.identity = ChainIdentity::V2 {
1427            caller_role: "Dev",
1428            event_tip: "",
1429        };
1430        let mut boot = sample_content(1, "txa");
1431        boot.identity = ChainIdentity::V2 {
1432            caller_role: "Boot",
1433            event_tip: "",
1434        };
1435        assert_ne!(key.chain_hash(&dev, ""), key.chain_hash(&boot, ""));
1436    }
1437
1438    #[test]
1439    fn event_tip_is_covered_by_the_row_signature() {
1440        let key = fixed_key();
1441        let mut empty = sample_content(1, "txa");
1442        empty.identity = ChainIdentity::V2 {
1443            caller_role: "Dev",
1444            event_tip: "",
1445        };
1446        let mut bound = sample_content(1, "txa");
1447        bound.identity = ChainIdentity::V2 {
1448            caller_role: "Dev",
1449            event_tip: "abc123",
1450        };
1451        assert_ne!(key.chain_hash(&empty, ""), key.chain_hash(&bound, ""));
1452    }
1453
1454    #[test]
1455    fn a_legacy_row_written_before_the_migration_still_verifies() {
1456        // The migration contract. A chain written by v0.2.12 or earlier has no
1457        // caller_role column; re-encoding those rows with an empty identity
1458        // would report every historical row as Broken, i.e. an upgrade would
1459        // look exactly like a compromise.
1460        let key = fixed_key();
1461        let mut content = sample_content(1, "tx-legacy");
1462        content.identity = ChainIdentity::LegacyV1;
1463        let hash = key.chain_hash(&content, "");
1464        let row = row_for(&content, "", hash);
1465        assert_eq!(row.chain_version, CHAIN_VERSION_LEGACY);
1466        assert_eq!(row.caller_role, None);
1467        assert_eq!(
1468            verify_chain(&key, &[row]),
1469            VerifyOutcome::Intact { rows_checked: 1 }
1470        );
1471    }
1472
1473    #[test]
1474    fn a_chain_that_spans_the_migration_verifies_end_to_end() {
1475        // The realistic upgrade shape: old rows, then new rows appended by the
1476        // upgraded daemon, in one chain.
1477        let key = fixed_key();
1478        let mut rows = Vec::new();
1479        let mut prev = String::new();
1480        for (i, identity) in [
1481            ChainIdentity::LegacyV1,
1482            ChainIdentity::LegacyV1,
1483            ChainIdentity::V2 {
1484                caller_role: "Dev",
1485                event_tip: "",
1486            },
1487            ChainIdentity::V2 {
1488                caller_role: "Boot",
1489                event_tip: "",
1490            },
1491        ]
1492        .into_iter()
1493        .enumerate()
1494        {
1495            let txid = format!("tx{i}");
1496            let mut content = sample_content((i + 1) as u64, &txid);
1497            content.identity = identity;
1498            let hash = key.chain_hash(&content, &prev);
1499            rows.push(row_for(&content, &prev, hash.clone()));
1500            prev = hash;
1501        }
1502        assert_eq!(
1503            verify_chain(&key, &rows),
1504            VerifyOutcome::Intact { rows_checked: 4 }
1505        );
1506    }
1507
1508    #[test]
1509    fn downgrading_a_v2_row_to_hide_the_caller_role_breaks_it() {
1510        // The attack the version column might invite: relabel a v2 row as
1511        // legacy and drop the identity columns, so verification re-encodes it
1512        // without caller_role. The stored signature was made over the v2
1513        // message, so it cannot verify against the shorter one.
1514        let key = fixed_key();
1515        let mut rows = build_chain(&key, 2);
1516        rows[1].chain_version = CHAIN_VERSION_LEGACY;
1517        rows[1].caller_role = None;
1518        rows[1].event_tip = None;
1519        match verify_chain(&key, &rows) {
1520            VerifyOutcome::Broken {
1521                first_broken_seq, ..
1522            } => assert_eq!(first_broken_seq, 2),
1523            other => panic!("expected Broken, got {other:?}"),
1524        }
1525    }
1526
1527    #[test]
1528    fn a_v2_row_with_a_nulled_caller_role_is_broken_not_merely_unverifiable() {
1529        // Nulling the column while leaving chain_version=2 is a
1530        // self-contradictory row, not an infrastructure problem. It must map
1531        // to exit code 1 (tamper detected), never 2 (could not check) — a CI
1532        // gate that treats 2 as "skip" would otherwise wave it through.
1533        let key = fixed_key();
1534        let mut rows = build_chain(&key, 1);
1535        rows[0].caller_role = None;
1536        let outcome = verify_chain(&key, &rows);
1537        assert!(matches!(outcome, VerifyOutcome::Broken { .. }));
1538        assert_eq!(outcome_to_exit_code(&outcome), 1);
1539    }
1540
1541    #[test]
1542    fn a_chain_version_this_binary_does_not_know_is_cannot_verify() {
1543        // An older binary reading a newer chain genuinely cannot reproduce the
1544        // message. That is exit 2, distinct from a detected break.
1545        let key = fixed_key();
1546        let mut rows = build_chain(&key, 1);
1547        rows[0].chain_version = CHAIN_VERSION_CURRENT + 1;
1548        let outcome = verify_chain(&key, &rows);
1549        assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1550        assert_eq!(outcome_to_exit_code(&outcome), 2);
1551    }
1552
1553    // ── approval-event chain ──────────────────────────────────────────────
1554
1555    fn event_content<'a>(seq: u64, kind: AuditEventKind, txid: &'a str) -> EventContent<'a> {
1556        EventContent {
1557            seq,
1558            key_id: CURRENT_KEY_ID,
1559            kind,
1560            transaction_id: txid,
1561            receipt_digest: "digest-abc",
1562            created_at: "2026-04-24T12:00:00Z",
1563        }
1564    }
1565
1566    fn build_event_chain(key: &AuditKey, kinds: &[AuditEventKind]) -> Vec<EventRow> {
1567        let mut rows = Vec::with_capacity(kinds.len());
1568        let mut prev = String::new();
1569        for (i, kind) in kinds.iter().enumerate() {
1570            let seq = (i + 1) as u64;
1571            let txid = format!("tx{i}");
1572            let content = event_content(seq, *kind, &txid);
1573            let hash = key.event_hash(&content, &prev);
1574            rows.push(EventRow {
1575                seq,
1576                key_id: content.key_id.to_string(),
1577                kind: content.kind.as_str().to_string(),
1578                transaction_id: content.transaction_id.to_string(),
1579                receipt_digest: content.receipt_digest.to_string(),
1580                created_at: content.created_at.to_string(),
1581                prev_chain_hash: prev.clone(),
1582                chain_hash: hash.clone(),
1583            });
1584            prev = hash;
1585        }
1586        rows
1587    }
1588
1589    #[test]
1590    fn event_kind_spellings_are_stable() {
1591        // These strings are inside the signed message. Renaming one silently
1592        // invalidates every event already written.
1593        assert_eq!(AuditEventKind::ApprovalGranted.as_str(), "approval_granted");
1594        assert_eq!(
1595            AuditEventKind::ApprovalConsumed.as_str(),
1596            "approval_consumed"
1597        );
1598        assert_eq!(AuditEventKind::ApprovalRevoked.as_str(), "approval_revoked");
1599    }
1600
1601    #[test]
1602    fn intact_event_chain_verifies() {
1603        let key = fixed_key();
1604        let rows = build_event_chain(
1605            &key,
1606            &[
1607                AuditEventKind::ApprovalGranted,
1608                AuditEventKind::ApprovalConsumed,
1609            ],
1610        );
1611        assert_eq!(
1612            verify_event_chain(&key, &rows),
1613            VerifyOutcome::Intact { rows_checked: 2 }
1614        );
1615    }
1616
1617    #[test]
1618    fn deleting_an_approval_event_from_the_middle_breaks_the_event_chain() {
1619        // The finding this chain exists for: before it, deleting the record
1620        // that an approval happened left `audit verify` reporting Intact.
1621        let key = fixed_key();
1622        let mut rows = build_event_chain(
1623            &key,
1624            &[
1625                AuditEventKind::ApprovalGranted,
1626                AuditEventKind::ApprovalConsumed,
1627                AuditEventKind::ApprovalRevoked,
1628            ],
1629        );
1630        rows.remove(1);
1631        match verify_event_chain(&key, &rows) {
1632            VerifyOutcome::Broken {
1633                first_broken_seq, ..
1634            } => assert_eq!(first_broken_seq, 3),
1635            other => panic!("expected Broken, got {other:?}"),
1636        }
1637    }
1638
1639    #[test]
1640    fn relabelling_a_revocation_as_a_grant_breaks_the_event_chain() {
1641        let key = fixed_key();
1642        let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalRevoked]);
1643        rows[0].kind = AuditEventKind::ApprovalGranted.as_str().to_string();
1644        assert!(matches!(
1645            verify_event_chain(&key, &rows),
1646            VerifyOutcome::Broken { .. }
1647        ));
1648    }
1649
1650    #[test]
1651    fn an_unrecognised_event_kind_is_a_break_not_a_panic() {
1652        let key = fixed_key();
1653        let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
1654        rows[0].kind = "approval_unicorned".to_string();
1655        assert!(matches!(
1656            verify_event_chain(&key, &rows),
1657            VerifyOutcome::Broken { .. }
1658        ));
1659    }
1660
1661    #[test]
1662    fn a_row_signature_cannot_be_replayed_as_an_event_signature() {
1663        // Domain separation, checked functionally rather than by comparing the
1664        // tag constants: the tags test proves the bytes differ, this proves the
1665        // difference actually reaches the verifier.
1666        let key = fixed_key();
1667        let content = event_content(1, AuditEventKind::ApprovalGranted, "tx0");
1668        let event_sig = key.event_hash(&content, "");
1669        let row_sig = key.chain_hash(&sample_content(1, "tx0"), "");
1670        assert_ne!(event_sig, row_sig);
1671
1672        let mut rows = build_event_chain(&key, &[AuditEventKind::ApprovalGranted]);
1673        rows[0].chain_hash = row_sig;
1674        assert!(matches!(
1675            verify_event_chain(&key, &rows),
1676            VerifyOutcome::Broken { .. }
1677        ));
1678    }
1679
1680    // ── cross-chain binding ───────────────────────────────────────────────
1681
1682    #[test]
1683    fn deleting_the_last_approval_event_is_caught_by_the_transaction_binding() {
1684        // Truncating the *tail* of the event chain leaves a self-consistent
1685        // event chain — the walk alone cannot see it. It is caught because a
1686        // later transaction row signed the tip that no longer exists, and the
1687        // transaction chain is what checkpoints anchor.
1688        let key = fixed_key();
1689        let events = build_event_chain(
1690            &key,
1691            &[
1692                AuditEventKind::ApprovalGranted,
1693                AuditEventKind::ApprovalConsumed,
1694            ],
1695        );
1696        let mut content = sample_content(1, "tx-after");
1697        content.identity = ChainIdentity::V2 {
1698            caller_role: "Dev",
1699            event_tip: &events[1].chain_hash,
1700        };
1701        let hash = key.chain_hash(&content, "");
1702        let tx_rows = vec![row_for(&content, "", hash)];
1703
1704        assert_eq!(
1705            verify_event_binding(&tx_rows, &events),
1706            BindingOutcome::Consistent {
1707                bindings_checked: 1
1708            }
1709        );
1710
1711        let truncated = &events[..1];
1712        assert_eq!(
1713            verify_event_chain(&key, truncated),
1714            VerifyOutcome::Intact { rows_checked: 1 },
1715            "a truncated event chain still walks clean; the binding is the detector"
1716        );
1717        match verify_event_binding(&tx_rows, truncated) {
1718            BindingOutcome::MissingEvent {
1719                transaction_seq, ..
1720            } => assert_eq!(transaction_seq, 1),
1721            other => panic!("expected MissingEvent, got {other:?}"),
1722        }
1723    }
1724
1725    #[test]
1726    fn legacy_rows_carry_no_binding_to_check() {
1727        // Rows written before the migration never committed to an event tip.
1728        // They must not be counted as bindings, and must not fail the check.
1729        let key = fixed_key();
1730        let mut content = sample_content(1, "tx-legacy");
1731        content.identity = ChainIdentity::LegacyV1;
1732        let hash = key.chain_hash(&content, "");
1733        let rows = vec![row_for(&content, "", hash)];
1734        assert_eq!(
1735            verify_event_binding(&rows, &[]),
1736            BindingOutcome::Consistent {
1737                bindings_checked: 0
1738            }
1739        );
1740    }
1741
1742    #[test]
1743    fn a_detected_break_outranks_an_inconclusive_check_in_the_exit_code() {
1744        // If one check proves tampering and another merely cannot run, the
1745        // command must report the tamper. Reporting 2 ("could not verify")
1746        // would let a CI gate that treats 2 as a skip wave a broken chain
1747        // through.
1748        let verification = AuditVerification {
1749            chain: VerifyOutcome::Broken {
1750                rows_checked: 1,
1751                first_broken_seq: 2,
1752                first_broken_transaction_id: "tx".to_string(),
1753                expected: "e".to_string(),
1754                actual: "a".to_string(),
1755            },
1756            events: VerifyOutcome::CannotVerify {
1757                reason: "no key".to_string(),
1758            },
1759            binding: BindingOutcome::Consistent {
1760                bindings_checked: 0,
1761            },
1762        };
1763        assert_eq!(verification.exit_code(), 1);
1764    }
1765
1766    #[test]
1767    fn a_clean_transaction_chain_does_not_mask_a_broken_event_chain() {
1768        let verification = AuditVerification {
1769            chain: VerifyOutcome::Intact { rows_checked: 3 },
1770            events: VerifyOutcome::Intact { rows_checked: 1 },
1771            binding: BindingOutcome::MissingEvent {
1772                transaction_seq: 3,
1773                event_tip: "abc".to_string(),
1774            },
1775        };
1776        assert_eq!(verification.exit_code(), 1);
1777    }
1778
1779    #[test]
1780    fn all_three_clean_is_exit_zero() {
1781        let verification = AuditVerification {
1782            chain: VerifyOutcome::Intact { rows_checked: 3 },
1783            events: VerifyOutcome::Intact { rows_checked: 2 },
1784            binding: BindingOutcome::Consistent {
1785                bindings_checked: 1,
1786            },
1787        };
1788        assert_eq!(verification.exit_code(), 0);
1789    }
1790
1791    #[test]
1792    fn binding_exit_codes_split_clean_from_tampered() {
1793        assert_eq!(
1794            binding_outcome_to_exit_code(&BindingOutcome::Consistent {
1795                bindings_checked: 0
1796            }),
1797            0
1798        );
1799        assert_eq!(
1800            binding_outcome_to_exit_code(&BindingOutcome::MissingEvent {
1801                transaction_seq: 7,
1802                event_tip: "abc".to_string()
1803            }),
1804            1
1805        );
1806    }
1807
1808    #[test]
1809    fn intact_chain_verifies() {
1810        let key = fixed_key();
1811        let rows = build_chain(&key, 5);
1812        let outcome = verify_chain(&key, &rows);
1813        assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
1814        assert_eq!(outcome_to_exit_code(&outcome), 0);
1815    }
1816
1817    #[test]
1818    fn empty_chain_verifies() {
1819        let key = fixed_key();
1820        let outcome = verify_chain(&key, &[]);
1821        assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
1822    }
1823
1824    #[test]
1825    fn tampered_summary_breaks_chain_at_first_offending_row() {
1826        let key = fixed_key();
1827        let mut rows = build_chain(&key, 3);
1828        // Mutate summary on row 1 (seq=2). Hash mismatch should be detected.
1829        rows[1].summary = "TAMPERED".to_string();
1830        let outcome = verify_chain(&key, &rows);
1831        match outcome {
1832            VerifyOutcome::Broken {
1833                first_broken_seq, ..
1834            } => assert_eq!(first_broken_seq, 2),
1835            other => panic!("expected Broken, got {other:?}"),
1836        }
1837    }
1838
1839    #[test]
1840    fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
1841        let key = fixed_key();
1842        let mut rows = build_chain(&key, 4);
1843        // Remove row at seq=2 entirely. Row 3's prev_chain_hash now mismatches.
1844        rows.remove(1);
1845        let outcome = verify_chain(&key, &rows);
1846        match outcome {
1847            VerifyOutcome::Broken {
1848                first_broken_seq, ..
1849            } => assert_eq!(
1850                first_broken_seq, 3,
1851                "first broken row is the one whose prev_hash refers to deleted row"
1852            ),
1853            other => panic!("expected Broken, got {other:?}"),
1854        }
1855    }
1856
1857    #[test]
1858    fn inserted_forged_row_breaks_chain() {
1859        // Counterpart to `deleted_middle_row_…`. An attacker who managed to
1860        // insert a fabricated row between two genuine ones still cannot
1861        // produce a `chain_hash` that links the insertion back to the prior
1862        // row's hash, so verification must flag the forgery at the inserted
1863        // row (or at the immediately following genuine row whose
1864        // `prev_chain_hash` no longer matches its real predecessor).
1865        let key = fixed_key();
1866        let mut rows = build_chain(&key, 3);
1867
1868        // Splice in a new row between seq=1 and seq=2 with a fabricated hash.
1869        let forged = ChainRow {
1870            seq: 2,
1871            key_id: CURRENT_KEY_ID.to_string(),
1872            transaction_id: "tx-forged".to_string(),
1873            request_id: "req-forged".to_string(),
1874            request_hash: "hash-forged".to_string(),
1875            action_name: "InstallFlatpak".to_string(),
1876            risk_level: RiskLevel::Medium,
1877            summary: "Forged row".to_string(),
1878            approval_id: None,
1879            warnings_json: "[]".to_string(),
1880            created_at: "2026-04-25T13:00:00Z".to_string(),
1881            // Plausible prev_chain_hash chosen to look intact at boundary.
1882            prev_chain_hash: rows[0].chain_hash.clone(),
1883            // Not a valid signature; verification must reject this.
1884            chain_hash: "0".repeat(HASH_HEX_LEN),
1885            chain_version: CHAIN_VERSION_CURRENT,
1886            caller_role: Some("Dev".to_string()),
1887            event_tip: Some(String::new()),
1888        };
1889
1890        // Renumber the genuine seq=2/3 rows so seq is still 1..=4.
1891        let mut rest: Vec<ChainRow> = rows.split_off(1);
1892        for r in rest.iter_mut() {
1893            r.seq += 1;
1894        }
1895        rows.push(forged);
1896        rows.extend(rest);
1897
1898        // The fixture is deterministic: the forged row sits at seq=2 with a
1899        // signature that cannot verify, so the walk must stop exactly there.
1900        // Accepting "2 or 3" let an off-by-one in `verify_rows` pass.
1901        let outcome = verify_chain(&key, &rows);
1902        match outcome {
1903            VerifyOutcome::Broken {
1904                first_broken_seq, ..
1905            } => assert_eq!(
1906                first_broken_seq, 2,
1907                "the verifier must flag the forged row itself, not a later one"
1908            ),
1909            other => panic!("expected Broken, got {other:?}"),
1910        }
1911    }
1912
1913    // ── Ed25519 public-key verification / non-repudiation ─────────────────
1914
1915    #[test]
1916    fn signature_verifies_under_exported_public_key() {
1917        // The auditor path: verify with only the public key.
1918        let key = fixed_key();
1919        let rows = build_chain(&key, 4);
1920        let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
1921        assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
1922    }
1923
1924    #[test]
1925    fn foreign_public_key_cannot_validate_chain() {
1926        // Non-repudiation: a different keypair's public key neither validates
1927        // the chain nor (by construction) could forge it. This is the property
1928        // the old symmetric HMAC could not provide.
1929        let signer = AuditKey::from_bytes(vec![0x11; 32]);
1930        let rows = build_chain(&signer, 3);
1931        let other = AuditKey::from_bytes(vec![0x22; 32]);
1932        let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
1933        assert!(matches!(
1934            outcome,
1935            VerifyOutcome::Broken {
1936                first_broken_seq: 1,
1937                ..
1938            }
1939        ));
1940    }
1941
1942    #[test]
1943    fn verifying_key_hex_is_a_32_byte_public_key() {
1944        let key = fixed_key();
1945        let vk_hex = key.verifying_key_hex();
1946        assert_eq!(
1947            vk_hex.len(),
1948            64,
1949            "32-byte ed25519 public key = 64 hex chars"
1950        );
1951        assert!(hex::decode(&vk_hex).is_ok());
1952    }
1953
1954    #[test]
1955    fn malformed_signature_hex_is_broken_not_panic() {
1956        let key = fixed_key();
1957        let mut rows = build_chain(&key, 2);
1958        rows[0].chain_hash = "not-valid-hex!!".to_string();
1959        let outcome = verify_chain(&key, &rows);
1960        assert!(matches!(
1961            outcome,
1962            VerifyOutcome::Broken {
1963                first_broken_seq: 1,
1964                ..
1965            }
1966        ));
1967    }
1968
1969    #[test]
1970    fn bad_public_key_hex_yields_cannot_verify() {
1971        let key = fixed_key();
1972        let rows = build_chain(&key, 1);
1973        let outcome = verify_chain_with_pubkey("zz", &rows);
1974        assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1975    }
1976
1977    #[test]
1978    fn load_or_generate_writes_public_key_sidecar() {
1979        let dir = tempfile::tempdir().unwrap();
1980        let path = dir.path().join("audit-key");
1981        let key = AuditKey::load_or_generate(&path).unwrap();
1982        let pub_path = dir.path().join("audit-key.pub");
1983        assert!(pub_path.exists(), "public key sidecar must be written");
1984        let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
1985        assert_eq!(pub_hex.trim(), key.verifying_key_hex());
1986    }
1987
1988    #[test]
1989    fn wrong_key_id_yields_cannot_verify() {
1990        let key = fixed_key();
1991        let mut rows = build_chain(&key, 2);
1992        rows[0].key_id = "v99".to_string();
1993        let outcome = verify_chain(&key, &rows);
1994        assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1995        assert_eq!(outcome_to_exit_code(&outcome), 2);
1996    }
1997
1998    #[test]
1999    fn exit_code_for_each_outcome() {
2000        assert_eq!(
2001            outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
2002            0
2003        );
2004        assert_eq!(
2005            outcome_to_exit_code(&VerifyOutcome::Broken {
2006                rows_checked: 0,
2007                first_broken_seq: 1,
2008                first_broken_transaction_id: String::new(),
2009                expected: String::new(),
2010                actual: String::new(),
2011            }),
2012            1
2013        );
2014        assert_eq!(
2015            outcome_to_exit_code(&VerifyOutcome::CannotVerify {
2016                reason: String::new()
2017            }),
2018            2
2019        );
2020    }
2021
2022    // ── AuditKey file management ──────────────────────────────────────────
2023
2024    #[test]
2025    fn load_or_generate_creates_with_0600_mode() {
2026        use std::os::unix::fs::PermissionsExt;
2027        let dir = tempfile::tempdir().unwrap();
2028        let path = dir.path().join("audit-key");
2029        let _key = AuditKey::load_or_generate(&path).unwrap();
2030        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2031        assert_eq!(mode, 0o600);
2032    }
2033
2034    #[test]
2035    fn load_or_generate_is_idempotent() {
2036        let dir = tempfile::tempdir().unwrap();
2037        let path = dir.path().join("audit-key");
2038        let key1_bytes = {
2039            let _ = AuditKey::load_or_generate(&path).unwrap();
2040            std::fs::read(&path).unwrap()
2041        };
2042        let _ = AuditKey::load_or_generate(&path).unwrap();
2043        let key2_bytes = std::fs::read(&path).unwrap();
2044        assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
2045    }
2046
2047    #[test]
2048    fn rejects_world_readable_key_file() {
2049        use std::os::unix::fs::PermissionsExt;
2050        let dir = tempfile::tempdir().unwrap();
2051        let path = dir.path().join("audit-key");
2052        std::fs::write(&path, vec![0u8; 32]).unwrap();
2053        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2054        let result = AuditKey::load_or_generate(&path);
2055        assert!(matches!(
2056            result,
2057            Err(AuditKeyError::UnsafePermissions { .. })
2058        ));
2059    }
2060
2061    #[test]
2062    fn rejects_short_key_file() {
2063        use std::os::unix::fs::PermissionsExt;
2064        let dir = tempfile::tempdir().unwrap();
2065        let path = dir.path().join("audit-key");
2066        std::fs::write(&path, vec![0u8; 8]).unwrap();
2067        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2068        let result = AuditKey::load_or_generate(&path);
2069        assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
2070    }
2071
2072    // ── Debug redaction (HI-17 secret hygiene) ────────────────────────────
2073
2074    /// `Debug` must never expose the Ed25519 private key material, neither raw bytes,
2075    /// nor their hex encoding. A derived `Debug` would dump the bytes the
2076    /// moment anyone wrote `tracing::debug!("{key:?}")`.
2077    #[test]
2078    fn debug_redacts_key_bytes_and_their_hex_encoding() {
2079        // Use a distinctive byte pattern so accidental leaks are obvious.
2080        let raw = (0u8..32).collect::<Vec<u8>>();
2081        let key = AuditKey::from_bytes(raw.clone());
2082        let dbg = format!("{key:?}");
2083
2084        assert!(
2085            dbg.contains("redacted"),
2086            "Debug output must contain the literal 'redacted' marker, got: {dbg}"
2087        );
2088
2089        // Hex encoding of the bytes must NOT appear, in either case.
2090        let hex_lower = hex::encode(&raw);
2091        let hex_upper = hex::encode_upper(&raw);
2092        assert!(
2093            !dbg.contains(&hex_lower),
2094            "Debug output leaks lowercase hex of key bytes: {dbg}"
2095        );
2096        assert!(
2097            !dbg.contains(&hex_upper),
2098            "Debug output leaks uppercase hex of key bytes: {dbg}"
2099        );
2100
2101        // Each individual byte's two-hex-char form must also be absent. This
2102        // catches the case where a future change splits the bytes across the
2103        // formatter and prints them piecewise.
2104        for b in &raw {
2105            let pair = format!("{b:02x}");
2106            // 1-byte values 0x00..0x0f render as "00".."0f" — too short to
2107            // assert against safely (collides with key_id "v1" etc.). Only
2108            // check 2-char forms that cannot incidentally match the rest of
2109            // the Debug output.
2110            if *b >= 0x10 && pair != "1e" && pair != "1f" {
2111                assert!(
2112                    !dbg.contains(&pair),
2113                    "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
2114                );
2115            }
2116        }
2117    }
2118
2119    /// Operators triaging a chain break need to know which key generation
2120    /// produced a row. `key_id` is not secret and must remain visible.
2121    #[test]
2122    fn debug_preserves_key_id() {
2123        let key = AuditKey::from_bytes(vec![0xff; 32]);
2124        let dbg = format!("{key:?}");
2125        assert!(
2126            dbg.contains("key_id"),
2127            "Debug output must label the key_id field: {dbg}"
2128        );
2129        assert!(
2130            dbg.contains(CURRENT_KEY_ID),
2131            "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
2132        );
2133    }
2134
2135    // ── signed checkpoints (anti-truncation / anti-rewrite) ───────────────
2136
2137    #[test]
2138    fn checkpoint_consistent_with_intact_chain() {
2139        let key = fixed_key();
2140        let rows = build_chain(&key, 5);
2141        let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
2142        let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
2143        assert_eq!(
2144            outcome,
2145            CheckpointOutcome::Consistent {
2146                checkpoints_checked: 1
2147            }
2148        );
2149    }
2150
2151    #[test]
2152    fn checkpoint_detects_truncation() {
2153        let key = fixed_key();
2154        let full = build_chain(&key, 5);
2155        // Anchor a checkpoint at the tip (seq=5); later the chain is cut to 3.
2156        let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
2157        let truncated = &full[..3];
2158        let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
2159        assert!(matches!(
2160            outcome,
2161            CheckpointOutcome::Truncated {
2162                checkpoint_seq: 5,
2163                current_max_seq: 3
2164            }
2165        ));
2166    }
2167
2168    #[test]
2169    fn checkpoint_detects_rewrite() {
2170        let key = fixed_key();
2171        let mut rows = build_chain(&key, 4);
2172        let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
2173        // Rewrite the row at seq=3 after the checkpoint was anchored.
2174        rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
2175        let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
2176        assert!(matches!(
2177            outcome,
2178            CheckpointOutcome::TipMismatch { seq: 3, .. }
2179        ));
2180    }
2181
2182    #[test]
2183    fn checkpoint_bad_signature_detected() {
2184        let key = fixed_key();
2185        let rows = build_chain(&key, 3);
2186        let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
2187        cp.signature = "0".repeat(HASH_HEX_LEN); // not a valid signature
2188        let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
2189        assert!(matches!(
2190            outcome,
2191            CheckpointOutcome::BadSignature { seq: 2 }
2192        ));
2193    }
2194
2195    #[test]
2196    fn checkpoint_foreign_key_rejected() {
2197        // A checkpoint signed by one key must not verify under another key.
2198        let signer = AuditKey::from_bytes(vec![0x11; 32]);
2199        let rows = build_chain(&signer, 3);
2200        let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
2201        let other = AuditKey::from_bytes(vec![0x22; 32]);
2202        let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
2203        assert!(matches!(
2204            outcome,
2205            CheckpointOutcome::BadSignature { seq: 2 }
2206        ));
2207    }
2208
2209    #[test]
2210    fn pubkey_verify_detects_tampered_middle_row() {
2211        // The core auditor claim: with only the public key, a mutated field in
2212        // a non-first row is detected at that exact seq.
2213        let key = fixed_key();
2214        let mut rows = build_chain(&key, 4);
2215        rows[2].summary = "TAMPERED".to_string();
2216        let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
2217        assert!(matches!(
2218            outcome,
2219            VerifyOutcome::Broken {
2220                first_broken_seq: 3,
2221                ..
2222            }
2223        ));
2224    }
2225
2226    #[test]
2227    fn multiple_checkpoints_all_consistent() {
2228        let key = fixed_key();
2229        let rows = build_chain(&key, 5);
2230        let cps = vec![
2231            key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
2232            key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
2233            key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
2234        ];
2235        assert_eq!(
2236            verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
2237            CheckpointOutcome::Consistent {
2238                checkpoints_checked: 3
2239            }
2240        );
2241    }
2242
2243    #[test]
2244    fn middle_checkpoint_failure_is_reported() {
2245        // Earlier-consistent checkpoints must not mask a later failure.
2246        let key = fixed_key();
2247        let mut rows = build_chain(&key, 5);
2248        let cps = vec![
2249            key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
2250            key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
2251            key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
2252        ];
2253        rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); // rewrite what cp #2 commits to
2254        assert!(matches!(
2255            verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
2256            CheckpointOutcome::TipMismatch { seq: 3, .. }
2257        ));
2258    }
2259
2260    #[test]
2261    fn checkpoint_created_at_is_signed() {
2262        // created_at is inside the signed message: backdating invalidates it.
2263        let key = fixed_key();
2264        let rows = build_chain(&key, 3);
2265        let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
2266        cp.created_at = "2020-01-01T00:00:00Z".to_string();
2267        assert!(matches!(
2268            verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
2269            CheckpointOutcome::BadSignature { .. }
2270        ));
2271    }
2272
2273    #[test]
2274    fn checkpoint_seq_is_signed() {
2275        // seq is inside the signed message: moving it invalidates the signature.
2276        let key = fixed_key();
2277        let rows = build_chain(&key, 3);
2278        let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
2279        cp.seq = 2;
2280        assert!(matches!(
2281            verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
2282            CheckpointOutcome::BadSignature { seq: 2 }
2283        ));
2284    }
2285
2286    #[test]
2287    fn checkpoint_detects_full_wipe() {
2288        // The whole chain deleted: the anchored tip cannot be reproduced.
2289        let key = fixed_key();
2290        let full = build_chain(&key, 3);
2291        let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
2292        assert!(matches!(
2293            verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
2294            CheckpointOutcome::Truncated {
2295                checkpoint_seq: 3,
2296                current_max_seq: 0
2297            }
2298        ));
2299    }
2300
2301    #[test]
2302    fn domain_tags_are_distinct_and_prefix_free() {
2303        // Security invariant: row and checkpoint signatures can never
2304        // cross-verify because their signed messages start with distinct,
2305        // prefix-free domain tags.
2306        // Checked pairwise over the whole set rather than as a hand-written
2307        // list: adding a fourth tag to a hand-written list silently leaves the
2308        // new tag unchecked against the old ones.
2309        let tags: [(&str, &[u8]); 4] = [
2310            ("row", ROW_DOMAIN),
2311            ("checkpoint", CHECKPOINT_DOMAIN),
2312            ("approval", APPROVAL_DOMAIN),
2313            ("event", EVENT_DOMAIN),
2314        ];
2315        for (i, (name_a, a)) in tags.iter().enumerate() {
2316            for (name_b, b) in tags.iter().skip(i + 1) {
2317                assert_ne!(a, b, "{name_a} and {name_b} share a domain tag");
2318                assert!(!a.starts_with(b), "{name_a} is prefixed by {name_b}");
2319                assert!(!b.starts_with(a), "{name_b} is prefixed by {name_a}");
2320            }
2321        }
2322    }
2323
2324    #[test]
2325    fn approval_receipt_framing_is_injective() {
2326        // The signed receipt message must be injective in
2327        // (transaction_id, request_hash). Without `push_field` escaping, the
2328        // ambiguous pair below would collide because a raw 0x1F separator
2329        // cannot be distinguished from a 0x1F inside a field value.
2330        let key = fixed_key();
2331        let sep = "\u{1f}";
2332        let a = key.approval_receipt(&format!("tx{sep}b"), "c");
2333        let b = key.approval_receipt("tx", &format!("b{sep}c"));
2334        assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
2335
2336        // Sanity: the derivation is deterministic (Ed25519, same inputs).
2337        assert_eq!(
2338            key.approval_receipt("tx-1", "hash-1"),
2339            key.approval_receipt("tx-1", "hash-1"),
2340        );
2341        // And the commitment is exactly SHA-256 of the receipt.
2342        assert_eq!(
2343            key.approval_commitment("tx-1", "hash-1"),
2344            approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
2345        );
2346    }
2347}