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";
89
90/// Loaded Ed25519 signing key + its identifier. Construct via
91/// [`AuditKey::load_or_generate`].
92///
93/// `Clone` is intentional: the audit-verify CLI needs to load the key once
94/// and share it between the SQLite read-only path and the Postgres pool.
95///
96/// `Debug` is implemented manually to redact the signing key. A derived
97/// `Debug` would dump the private key via any `tracing::debug!("{key:?}")`
98/// or `dbg!(key)` site, which would leak the audit secret into journald.
99/// We keep `key_id` visible because operators need to identify which key
100/// generation a record belongs to when triaging chain breaks.
101#[derive(Clone)]
102pub struct AuditKey {
103    key_id: String,
104    signing: SigningKey,
105}
106
107impl std::fmt::Debug for AuditKey {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        // Never render the private key. A derived `Debug` (or printing the
110        // signing key) would leak the audit secret into journald via any
111        // `tracing::debug!("{key:?}")`. `key_id` is not secret and is kept
112        // visible for triaging chain breaks.
113        f.debug_struct("AuditKey")
114            .field("key_id", &self.key_id)
115            .field("signing", &format_args!("<redacted signing key>"))
116            .finish()
117    }
118}
119
120// The `SigningKey` zeroizes its secret scalar on drop (ed25519-dalek `zeroize`
121// feature), so no manual `Drop` is needed to keep the private key out of
122// post-free memory.
123
124#[derive(Debug, thiserror::Error)]
125pub enum AuditKeyError {
126    #[error("io error reading audit key {path}: {source}")]
127    Io {
128        path: PathBuf,
129        #[source]
130        source: std::io::Error,
131    },
132    #[error(
133        "audit key file {path} has unsafe permissions {mode:#o}; \
134         tighten with `chmod 600 {path:?}` and restart"
135    )]
136    UnsafePermissions { path: PathBuf, mode: u32 },
137    #[error(
138        "audit key file {path} is too short ({len} bytes); \
139         expected at least 32 bytes of random material"
140    )]
141    KeyTooShort { path: PathBuf, len: usize },
142}
143
144impl AuditKey {
145    /// Load the audit key from `path`. If the file does not exist, generate a
146    /// 32-byte cryptographically random key and write it with mode `0o600`.
147    ///
148    /// On every load (including freshly generated), the file's permissions
149    /// are checked: any bit beyond `0o600` for owner-only access is rejected
150    /// — a world-readable audit key is a self-defeating audit chain.
151    pub fn load_or_generate(path: &Path) -> Result<Self, AuditKeyError> {
152        if !path.exists() {
153            generate_key_at(path)?;
154        }
155
156        let metadata = std::fs::metadata(path).map_err(|e| AuditKeyError::Io {
157            path: path.to_path_buf(),
158            source: e,
159        })?;
160
161        use std::os::unix::fs::PermissionsExt;
162        let mode = metadata.permissions().mode() & 0o777;
163        if mode & 0o077 != 0 {
164            // Group or world bits set — reject.
165            return Err(AuditKeyError::UnsafePermissions {
166                path: path.to_path_buf(),
167                mode,
168            });
169        }
170
171        let mut key_bytes = std::fs::read(path).map_err(|e| AuditKeyError::Io {
172            path: path.to_path_buf(),
173            source: e,
174        })?;
175        if key_bytes.len() < 32 {
176            return Err(AuditKeyError::KeyTooShort {
177                path: path.to_path_buf(),
178                len: key_bytes.len(),
179            });
180        }
181
182        let mut seed = [0u8; 32];
183        seed.copy_from_slice(&key_bytes[..32]);
184        let signing = SigningKey::from_bytes(&seed);
185        seed.zeroize();
186        key_bytes.zeroize();
187
188        Ok(Self {
189            key_id: CURRENT_KEY_ID.to_string(),
190            signing,
191        })
192    }
193
194    /// Construct a key from a 32-byte seed. For tests only — production builds
195    /// always go through [`Self::load_or_generate`] for the permission check.
196    #[cfg(test)]
197    pub fn from_bytes(bytes: Vec<u8>) -> Self {
198        let mut seed = [0u8; 32];
199        seed.copy_from_slice(&bytes[..32]);
200        let signing = SigningKey::from_bytes(&seed);
201        seed.zeroize();
202        Self {
203            key_id: CURRENT_KEY_ID.to_string(),
204            signing,
205        }
206    }
207
208    /// Stable identifier for this key generation.
209    pub fn key_id(&self) -> &str {
210        &self.key_id
211    }
212
213    /// Compute the chain signature for `content` linked to `prev_chain_hash`.
214    ///
215    /// Hex-encoded Ed25519 signature over `canonical(content) || prev_chain_hash`.
216    /// Deterministic (RFC 8032): identical inputs always yield the same value.
217    pub fn chain_hash(&self, content: &ChainContent, prev_chain_hash: &str) -> String {
218        let sig = self.signing.sign(&chain_message(content, prev_chain_hash));
219        hex::encode(sig.to_bytes())
220    }
221
222    /// The public verifying key for this audit key.
223    pub fn verifying_key(&self) -> VerifyingKey {
224        self.signing.verifying_key()
225    }
226
227    /// Hex-encoded 32-byte Ed25519 public key. Safe to publish; auditors use it
228    /// to verify the chain without the ability to forge entries.
229    pub fn verifying_key_hex(&self) -> String {
230        hex::encode(self.signing.verifying_key().to_bytes())
231    }
232
233    /// Derive the bearer receipt for one immutable preview. The signature is
234    /// deterministic, so a failed response delivery can be retried without
235    /// storing plaintext bearer credentials in the database.
236    pub fn approval_receipt(&self, transaction_id: &str, request_hash: &str) -> String {
237        // Frame both fields through `push_field` (the same escaping used for
238        // chain rows) so the signed message is injective in
239        // (transaction_id, request_hash). Raw concatenation with a bare 0x1F
240        // would alias distinct inputs if either field ever contained 0x1F;
241        // escaping removes that dependency on the callers' value shapes.
242        let mut message = APPROVAL_DOMAIN.to_vec();
243        push_field(&mut message, "txid", transaction_id);
244        push_field(&mut message, "reqhash", request_hash);
245        hex::encode(self.signing.sign(&message).to_bytes())
246    }
247
248    /// Chain-stored SHA-256 commitment to the deterministic approval receipt.
249    pub fn approval_commitment(&self, transaction_id: &str, request_hash: &str) -> String {
250        approval_receipt_digest(&self.approval_receipt(transaction_id, request_hash))
251    }
252}
253
254pub fn approval_receipt_digest(receipt: &str) -> String {
255    hex::encode(Sha256::digest(receipt.as_bytes()))
256}
257
258/// Message signed for a row: `ROW_DOMAIN || canonical(content) || prev_chain_hash`.
259///
260/// The leading domain tag separates row signatures from checkpoint signatures
261/// so a signature produced in one context can never verify in the other, even
262/// if the framed fields were ever to overlap.
263fn chain_message(content: &ChainContent, prev_chain_hash: &str) -> Vec<u8> {
264    let mut msg = ROW_DOMAIN.to_vec();
265    msg.extend_from_slice(&content.canonical_bytes());
266    msg.extend_from_slice(prev_chain_hash.as_bytes());
267    msg
268}
269
270/// Resolve the audit key file path: `$SYSKNIFE_AUDIT_KEY_PATH` if set,
271/// otherwise `<db_dir>/audit-key` (sibling of the database). This is the single
272/// definition of the key-location precedence documented in the module header;
273/// the CLI verify/checkpoint paths resolve through it so they always agree with
274/// the daemon on where the key lives.
275pub fn resolve_audit_key_path(db_path: &Path) -> PathBuf {
276    std::env::var("SYSKNIFE_AUDIT_KEY_PATH")
277        .map(PathBuf::from)
278        .unwrap_or_else(|_| {
279            db_path
280                .parent()
281                .unwrap_or_else(|| Path::new("."))
282                .join("audit-key")
283        })
284}
285
286/// Generate a 32-byte random key and write it to `path` with mode `0o600`.
287/// Parent directory is created with mode `0o700` if missing.
288fn generate_key_at(path: &Path) -> Result<(), AuditKeyError> {
289    use std::io::Write as _;
290    use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
291
292    if let Some(parent) = path.parent() {
293        if !parent.exists() {
294            std::fs::DirBuilder::new()
295                .recursive(true)
296                .mode(0o700)
297                .create(parent)
298                .map_err(|e| AuditKeyError::Io {
299                    path: parent.to_path_buf(),
300                    source: e,
301                })?;
302        }
303    }
304
305    let mut bytes = [0u8; 32];
306    fill_random(&mut bytes).map_err(|e| AuditKeyError::Io {
307        path: path.to_path_buf(),
308        source: e,
309    })?;
310
311    let mut f = std::fs::OpenOptions::new()
312        .write(true)
313        .create_new(true)
314        .mode(0o600)
315        .open(path)
316        .map_err(|e| AuditKeyError::Io {
317            path: path.to_path_buf(),
318            source: e,
319        })?;
320    f.write_all(&bytes).map_err(|e| AuditKeyError::Io {
321        path: path.to_path_buf(),
322        source: e,
323    })?;
324    f.sync_all().map_err(|e| AuditKeyError::Io {
325        path: path.to_path_buf(),
326        source: e,
327    })?;
328
329    // Write the public key alongside as `<key>.pub` (hex). Not secret: it lets
330    // an auditor or aggregator verify the chain without the private key.
331    let signing = SigningKey::from_bytes(&bytes);
332    let pub_hex = hex::encode(signing.verifying_key().to_bytes());
333    bytes.zeroize();
334    let mut pub_path = path.as_os_str().to_os_string();
335    pub_path.push(".pub");
336    std::fs::write(&pub_path, format!("{pub_hex}\n")).map_err(|e| AuditKeyError::Io {
337        path: PathBuf::from(pub_path),
338        source: e,
339    })?;
340    Ok(())
341}
342
343/// Fill `buf` with bytes from the kernel CSPRNG via `/dev/urandom`.
344fn fill_random(buf: &mut [u8]) -> std::io::Result<()> {
345    use std::io::Read as _;
346    let mut f = std::fs::File::open("/dev/urandom")?;
347    f.read_exact(buf)
348}
349
350/// Immutable fields hashed into the chain. Status is intentionally absent —
351/// see module docs.
352///
353/// # Security contract — chain-content immutability
354///
355/// Every field in this struct is captured **once** at INSERT time and baked
356/// into `chain_hash = ed25519_sign(canonical(self) || prev_chain_hash, key)`.
357/// After the row is written the hash is a one-time commitment: **no field
358/// in this struct may ever be mutated in place**.
359///
360/// `summary` is the field most likely to attract a future "let me just fix
361/// that typo" API. **Do not add an `update_summary` (or similar) function.**
362/// If a correction is genuinely needed, choose one of the two safe options:
363///
364/// 1. **Insert a corrective row** — a new transaction row that references the
365///    original `transaction_id` in its own `summary`, leaving the original
366///    row and its chain hash untouched.
367/// 2. **Extend the chain protocol** — introduce a dedicated amendment record
368///    type with its own chain link, so that both the original commitment and
369///    the correction are auditable.
370///
371/// Any other approach silently breaks chain integrity: `verify_chain` will
372/// flag the modified row as `Broken` because the stored signature will no
373/// longer verify against the row's content.
374///
375/// The canonical serialisation is stable across SQLite/Postgres backends.
376/// Each field is emitted as
377///
378/// ```text
379///     <tag-name> 0x1E <tag-value> 0x1F
380/// ```
381///
382/// where `0x1E` is the *tag/value* separator within a single field and `0x1F`
383/// is the *field* separator that terminates the field and introduces the
384/// next one. We use the ASCII C0 byte values RS (0x1E) and US (0x1F)
385/// because they are guaranteed not to appear in any normal text field, but
386/// **our role assignment is the inverse of the ASCII C0 convention**
387/// (where RS = "record separator" and US = "unit separator"). The names
388/// are kept in the source for byte-level traceability against the canonical
389/// buffer, not as a claim about ASCII semantics.
390///
391/// Inside a value, the four
392/// bytes `\\`, NUL, `RS`, `US` are escaped to `\\\\`, `\\0`, `\\1E`, `\\1F`
393/// respectively. The escape table is **prefix-free** (every escape starts
394/// with `\\`), so any value can be injected without ambiguity. See
395/// `push_field` for the implementation and tests for the round-trip.
396#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct ChainContent<'a> {
398    pub seq: u64,
399    pub key_id: &'a str,
400    pub transaction_id: &'a str,
401    pub request_id: &'a str,
402    pub request_hash: &'a str,
403    pub action_name: &'a str,
404    pub risk_level: RiskLevel,
405    /// Human-readable description of the planned action.
406    ///
407    /// **Immutable after insert.** This field is included in the chain hash
408    /// (see [`ChainContent`] struct-level doc). It MUST NOT be updated after
409    /// the row is written — doing so silently invalidates `chain_hash` and
410    /// will be detected as `VerifyOutcome::Broken` by `sysknife audit verify`.
411    /// See the struct-level security contract for the only safe correction
412    /// strategies.
413    pub summary: &'a str,
414    /// Signed receipt commitment for daemon-created previews. Legacy/imported
415    /// rows may be `None`; serialised as empty for wire compatibility.
416    pub approval_id: Option<&'a str>,
417    /// JSON-canonical (sorted keys) array of warning strings.
418    pub warnings_json: &'a str,
419    pub created_at: &'a str,
420}
421
422impl<'a> ChainContent<'a> {
423    /// Stable canonical encoding of every field. Within each field the
424    /// `0x1E` byte separates tag from value, and `0x1F` terminates the field;
425    /// raw NUL becomes the two-byte escape `\0`.
426    pub fn canonical_bytes(&self) -> Vec<u8> {
427        let risk_level_str = match self.risk_level {
428            RiskLevel::Low => "low",
429            RiskLevel::Medium => "medium",
430            RiskLevel::High => "high",
431        };
432        let approval = self.approval_id.unwrap_or("");
433
434        let mut buf = Vec::with_capacity(512);
435        // Each field is `tag 0x1E value 0x1F`; tags make the canonical form
436        // self-describing for forensics.
437        push_field(&mut buf, "seq", &self.seq.to_string());
438        push_field(&mut buf, "key_id", self.key_id);
439        push_field(&mut buf, "transaction_id", self.transaction_id);
440        push_field(&mut buf, "request_id", self.request_id);
441        push_field(&mut buf, "request_hash", self.request_hash);
442        push_field(&mut buf, "action_name", self.action_name);
443        push_field(&mut buf, "risk_level", risk_level_str);
444        push_field(&mut buf, "summary", self.summary);
445        push_field(&mut buf, "approval_id", approval);
446        push_field(&mut buf, "warnings_json", self.warnings_json);
447        push_field(&mut buf, "created_at", self.created_at);
448        buf
449    }
450}
451
452/// Append `tag<RS>value<US>` to `buf`, escaping any byte that could otherwise
453/// alias one of the framing characters or another escape sequence.
454///
455/// The escape table is **prefix-free**: every escape starts with `\` (0x5C)
456/// followed by a tag that cannot itself be the start of a different escape.
457/// Concretely:
458///
459/// | Raw byte | Escape       |
460/// |---------:|--------------|
461/// | `\\`     | `\\\\`       |
462/// | `\x00`   | `\\0`        |
463/// | `\x1E`   | `\\1E`       |
464/// | `\x1F`   | `\\1F`       |
465///
466/// The `\\` escape MUST come first: without it, a field value containing the
467/// literal two-byte sequence `\` + `0` would canonicalise to the same bytes
468/// as a raw NUL and produce a chain-signature collision.
469fn push_field(buf: &mut Vec<u8>, tag: &str, value: &str) {
470    buf.extend_from_slice(tag.as_bytes());
471    buf.push(0x1E); // tag/value separator (ASCII RS byte, but used inversely — see ChainContent doc)
472    for b in value.bytes() {
473        match b {
474            b'\\' => buf.extend_from_slice(b"\\\\"),
475            0x00 => buf.extend_from_slice(b"\\0"),
476            0x1E => buf.extend_from_slice(b"\\1E"),
477            0x1F => buf.extend_from_slice(b"\\1F"),
478            other => buf.push(other),
479        }
480    }
481    buf.push(0x1F); // field terminator (ASCII US byte, but used inversely — see ChainContent doc)
482}
483
484/// Result of `verify_chain`.
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub enum VerifyOutcome {
487    /// Chain is intact across all rows checked.
488    Intact { rows_checked: u64 },
489    /// One or more rows fail to chain. First broken row is reported.
490    Broken {
491        rows_checked: u64,
492        first_broken_seq: u64,
493        first_broken_transaction_id: String,
494        expected: String,
495        actual: String,
496    },
497    /// Verification could not be completed (missing key, unreachable storage,
498    /// retired key not on disk, etc.). Distinct from `Broken` — callers map
499    /// this to exit code 2.
500    CannotVerify { reason: String },
501}
502
503/// Process exit code for `sysknife audit verify`.
504///
505/// `0` intact, `1` broken, `2` cannot-verify. The split between 1 and 2
506/// matters: a CI pipeline expecting 0 or 1 must not silently pass on a
507/// missing key file.
508pub fn outcome_to_exit_code(outcome: &VerifyOutcome) -> i32 {
509    match outcome {
510        VerifyOutcome::Intact { .. } => 0,
511        VerifyOutcome::Broken { .. } => 1,
512        VerifyOutcome::CannotVerify { .. } => 2,
513    }
514}
515
516/// One row's worth of chain data, as fetched from the store.
517#[derive(Debug, Clone)]
518pub struct ChainRow {
519    pub seq: u64,
520    pub key_id: String,
521    pub transaction_id: String,
522    pub request_id: String,
523    pub request_hash: String,
524    pub action_name: String,
525    pub risk_level: RiskLevel,
526    pub summary: String,
527    pub approval_id: Option<String>,
528    pub warnings_json: String,
529    pub created_at: String,
530    pub prev_chain_hash: String,
531    pub chain_hash: String,
532}
533
534/// Verify a chain using the daemon's key. Verification uses the **public** key
535/// (so it proves, but cannot forge) and also asserts every row was written
536/// under this key generation (`key_id`).
537pub fn verify_chain(key: &AuditKey, rows: &[ChainRow]) -> VerifyOutcome {
538    verify_rows(&key.verifying_key(), Some(key.key_id()), rows)
539}
540
541/// Verify a chain with only the hex-encoded Ed25519 **public** key. This is the
542/// auditor / aggregator path: it proves the chain without the private key and
543/// cannot be used to forge entries. `key_id` is not checked — the public key
544/// itself identifies the signer.
545pub fn verify_chain_with_pubkey(verifying_key_hex: &str, rows: &[ChainRow]) -> VerifyOutcome {
546    match parse_verifying_key(verifying_key_hex) {
547        Some(vk) => verify_rows(&vk, None, rows),
548        None => VerifyOutcome::CannotVerify {
549            reason: format!(
550                "invalid public key hex ({} chars); expected 64 hex chars of a \
551                 32-byte Ed25519 public key",
552                verifying_key_hex.len()
553            ),
554        },
555    }
556}
557
558fn parse_verifying_key(hex_str: &str) -> Option<VerifyingKey> {
559    let bytes = hex::decode(hex_str.trim()).ok()?;
560    let arr: [u8; 32] = bytes.try_into().ok()?;
561    VerifyingKey::from_bytes(&arr).ok()
562}
563
564/// Verify a stored hex signature over a row's message. Any malformed signature
565/// (bad hex, wrong length, invalid point) is a failed check, never a panic.
566fn signature_ok(vk: &VerifyingKey, msg: &[u8], sig_hex: &str) -> bool {
567    let Ok(bytes) = hex::decode(sig_hex) else {
568        return false;
569    };
570    let Ok(arr): Result<[u8; 64], _> = bytes.try_into() else {
571        return false;
572    };
573    vk.verify_strict(msg, &Signature::from_bytes(&arr)).is_ok()
574}
575
576/// Walk `rows` in seq order, verifying each row's signature with `vk` and its
577/// `prev_chain_hash` linkage. Returns the first break (or `Intact`). When
578/// `expect_key_id` is `Some`, every row must carry that `key_id`.
579fn verify_rows(vk: &VerifyingKey, expect_key_id: Option<&str>, rows: &[ChainRow]) -> VerifyOutcome {
580    let mut last_hash = String::new();
581    let mut rows_checked = 0u64;
582    for row in rows {
583        if let Some(kid) = expect_key_id {
584            if row.key_id != kid {
585                return VerifyOutcome::CannotVerify {
586                    reason: format!(
587                        "row seq={} uses key_id={:?} but only {:?} is loaded; \
588                         epoch keys not yet supported",
589                        row.seq, row.key_id, kid
590                    ),
591                };
592            }
593        }
594        if row.prev_chain_hash != last_hash {
595            return VerifyOutcome::Broken {
596                rows_checked,
597                first_broken_seq: row.seq,
598                first_broken_transaction_id: row.transaction_id.clone(),
599                expected: format!("prev_chain_hash={last_hash}"),
600                actual: format!("prev_chain_hash={}", row.prev_chain_hash),
601            };
602        }
603        let content = ChainContent {
604            seq: row.seq,
605            key_id: &row.key_id,
606            transaction_id: &row.transaction_id,
607            request_id: &row.request_id,
608            request_hash: &row.request_hash,
609            action_name: &row.action_name,
610            risk_level: row.risk_level,
611            summary: &row.summary,
612            approval_id: row.approval_id.as_deref(),
613            warnings_json: &row.warnings_json,
614            created_at: &row.created_at,
615        };
616        let msg = chain_message(&content, &row.prev_chain_hash);
617        if !signature_ok(vk, &msg, &row.chain_hash) {
618            return VerifyOutcome::Broken {
619                rows_checked,
620                first_broken_seq: row.seq,
621                first_broken_transaction_id: row.transaction_id.clone(),
622                expected: "valid ed25519 signature".to_string(),
623                actual: row.chain_hash.clone(),
624            };
625        }
626        last_hash = row.chain_hash.clone();
627        rows_checked += 1;
628    }
629    VerifyOutcome::Intact { rows_checked }
630}
631
632// ── Signed checkpoints (external anchoring / tail-truncation detection) ──────
633
634/// A signed commitment to the chain tip at a point in time. Periodically
635/// emitted and anchored to an independent, append-only sink (a separate
636/// database, a WORM store, or an RFC 3161 timestamp) so that a later attempt to
637/// rewrite or **truncate** the local chain is detectable: the anchored
638/// `(seq, chain_tip)` can no longer be reproduced from the shortened chain.
639///
640/// This is the Certificate-Transparency "signed checkpoint" idiom. The
641/// signature is Ed25519 over the canonical `(seq, chain_tip, created_at)`, so
642/// an auditor verifies it with only the public key.
643///
644/// A `Checkpoint` value carries no validity guarantee on its own: validity is
645/// established solely by [`verify_checkpoints`] under the public key. A
646/// checkpoint loaded from a sink is untrusted input until verified.
647#[derive(Debug, Clone, PartialEq, Eq)]
648pub struct Checkpoint {
649    /// `seq` of the chain tip this checkpoint commits to (the last row at
650    /// emit time).
651    pub seq: u64,
652    /// `chain_hash` of the row at `seq` (the committed chain tip).
653    pub chain_tip: String,
654    /// RFC 3339 timestamp when the checkpoint was signed.
655    pub created_at: String,
656    /// Hex Ed25519 signature over `canonical(seq, chain_tip, created_at)`.
657    pub signature: String,
658}
659
660/// Canonical message signed into a checkpoint. Reuses the prefix-free field
661/// framing so the encoding is unambiguous and stable across backends.
662fn checkpoint_message(seq: u64, chain_tip: &str, created_at: &str) -> Vec<u8> {
663    // Leading domain tag: separates checkpoint signatures from row signatures
664    // (see `chain_message`) so the two contexts can never cross-verify.
665    let mut buf = CHECKPOINT_DOMAIN.to_vec();
666    push_field(&mut buf, "seq", &seq.to_string());
667    push_field(&mut buf, "chain_tip", chain_tip);
668    push_field(&mut buf, "created_at", created_at);
669    buf
670}
671
672impl AuditKey {
673    /// Sign a checkpoint committing to `(seq, chain_tip)` at `created_at`.
674    pub fn sign_checkpoint(&self, seq: u64, chain_tip: &str, created_at: &str) -> Checkpoint {
675        let sig = self
676            .signing
677            .sign(&checkpoint_message(seq, chain_tip, created_at));
678        Checkpoint {
679            seq,
680            chain_tip: chain_tip.to_string(),
681            created_at: created_at.to_string(),
682            signature: hex::encode(sig.to_bytes()),
683        }
684    }
685}
686
687/// Result of checking anchored checkpoints against the current chain.
688#[derive(Debug, Clone, PartialEq, Eq)]
689pub enum CheckpointOutcome {
690    /// Every checkpoint's signature is valid and its committed tip is still
691    /// present in the chain at the committed `seq`.
692    Consistent { checkpoints_checked: u64 },
693    /// A checkpoint signature failed to verify under the public key.
694    BadSignature { seq: u64 },
695    /// A checkpoint commits to a `seq` no longer present in the chain — the
696    /// chain has been **truncated** below a previously anchored tip.
697    Truncated {
698        checkpoint_seq: u64,
699        current_max_seq: u64,
700    },
701    /// A checkpoint's committed `chain_tip` does not match the chain's
702    /// `chain_hash` at that `seq` — the chain was **rewritten**.
703    TipMismatch {
704        seq: u64,
705        anchored: String,
706        actual: String,
707    },
708    /// Could not verify (e.g. malformed public key).
709    CannotVerify { reason: String },
710}
711
712/// Verify anchored `checkpoints` against `rows` (the current chain) with the
713/// hex **public** key. Detects truncation (a checkpoint seq no longer in the
714/// chain) and rewrite (tip mismatch at a checkpoint seq). `rows` must be in
715/// seq order. This is the anti-root guarantee: a host attacker who shortens or
716/// edits the local chain cannot reproduce a previously anchored signed tip.
717pub fn verify_checkpoints(
718    verifying_key_hex: &str,
719    rows: &[ChainRow],
720    checkpoints: &[Checkpoint],
721) -> CheckpointOutcome {
722    let vk = match parse_verifying_key(verifying_key_hex) {
723        Some(vk) => vk,
724        None => {
725            return CheckpointOutcome::CannotVerify {
726                reason: format!("invalid public key hex ({} chars)", verifying_key_hex.len()),
727            };
728        }
729    };
730    // `rows` is seq-sorted (ORDER BY seq ASC in the store; chain build order in
731    // tests), so the tip seq is the last element and we can binary-search below.
732    let current_max_seq = rows.last().map(|r| r.seq).unwrap_or(0);
733    let mut checked = 0u64;
734    for cp in checkpoints {
735        let msg = checkpoint_message(cp.seq, &cp.chain_tip, &cp.created_at);
736        if !signature_ok(&vk, &msg, &cp.signature) {
737            return CheckpointOutcome::BadSignature { seq: cp.seq };
738        }
739        match rows.binary_search_by_key(&cp.seq, |r| r.seq) {
740            Ok(idx) if rows[idx].chain_hash == cp.chain_tip => {}
741            Ok(idx) => {
742                return CheckpointOutcome::TipMismatch {
743                    seq: cp.seq,
744                    anchored: cp.chain_tip.clone(),
745                    actual: rows[idx].chain_hash.clone(),
746                };
747            }
748            Err(_) => {
749                return CheckpointOutcome::Truncated {
750                    checkpoint_seq: cp.seq,
751                    current_max_seq,
752                };
753            }
754        }
755        checked += 1;
756    }
757    CheckpointOutcome::Consistent {
758        checkpoints_checked: checked,
759    }
760}
761
762/// Process exit code for `sysknife audit checkpoint` verification, mirroring
763/// [`outcome_to_exit_code`]: `0` consistent, `1` a detected tamper (bad
764/// signature / truncation / rewrite), `2` cannot-verify (e.g. malformed public
765/// key). The 1-vs-2 split matters for CI exactly as it does for chain verify.
766pub fn checkpoint_outcome_to_exit_code(outcome: &CheckpointOutcome) -> i32 {
767    match outcome {
768        CheckpointOutcome::Consistent { .. } => 0,
769        CheckpointOutcome::BadSignature { .. }
770        | CheckpointOutcome::Truncated { .. }
771        | CheckpointOutcome::TipMismatch { .. } => 1,
772        CheckpointOutcome::CannotVerify { .. } => 2,
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779
780    fn fixed_key() -> AuditKey {
781        AuditKey::from_bytes(vec![0x42; 32])
782    }
783
784    fn sample_content<'a>(seq: u64, txid: &'a str) -> ChainContent<'a> {
785        ChainContent {
786            seq,
787            key_id: CURRENT_KEY_ID,
788            transaction_id: txid,
789            request_id: "req-1",
790            request_hash: "hash-abc",
791            action_name: "UpdateSystem",
792            risk_level: RiskLevel::High,
793            summary: "Upgrade",
794            approval_id: None,
795            warnings_json: "[]",
796            created_at: "2026-04-24T12:00:00Z",
797        }
798    }
799
800    // ── chain_hash determinism + linkage ──────────────────────────────────
801
802    #[test]
803    fn same_inputs_yield_same_hash() {
804        let key = fixed_key();
805        let h1 = key.chain_hash(&sample_content(1, "txa"), "");
806        let h2 = key.chain_hash(&sample_content(1, "txa"), "");
807        assert_eq!(h1, h2);
808        assert_eq!(h1.len(), HASH_HEX_LEN);
809    }
810
811    #[test]
812    fn different_seq_yields_different_hash() {
813        let key = fixed_key();
814        let h1 = key.chain_hash(&sample_content(1, "txa"), "");
815        let h2 = key.chain_hash(&sample_content(2, "txa"), "");
816        assert_ne!(h1, h2);
817    }
818
819    #[test]
820    fn different_prev_hash_yields_different_hash() {
821        let key = fixed_key();
822        let h1 = key.chain_hash(&sample_content(1, "txa"), "");
823        let h2 = key.chain_hash(&sample_content(1, "txa"), "deadbeef");
824        assert_ne!(h1, h2);
825    }
826
827    #[test]
828    fn different_keys_yield_different_hashes() {
829        let key1 = AuditKey::from_bytes(vec![0x01; 32]);
830        let key2 = AuditKey::from_bytes(vec![0x02; 32]);
831        let h1 = key1.chain_hash(&sample_content(1, "txa"), "");
832        let h2 = key2.chain_hash(&sample_content(1, "txa"), "");
833        assert_ne!(h1, h2);
834    }
835
836    // ── canonical encoding stability ──────────────────────────────────────
837
838    #[test]
839    fn canonical_bytes_have_stable_field_order() {
840        let c = sample_content(1, "txa");
841        let bytes = c.canonical_bytes();
842        let s = String::from_utf8_lossy(&bytes);
843        // Tags must appear in a fixed order.
844        let order = [
845            "seq",
846            "key_id",
847            "transaction_id",
848            "request_id",
849            "request_hash",
850            "action_name",
851            "risk_level",
852            "summary",
853            "approval_id",
854            "warnings_json",
855            "created_at",
856        ];
857        let mut last_idx = 0;
858        for tag in order {
859            let idx = s.find(tag).unwrap_or_else(|| panic!("missing {tag}"));
860            assert!(idx >= last_idx, "{tag} out of order");
861            last_idx = idx;
862        }
863    }
864
865    #[test]
866    fn nul_bytes_in_field_value_are_escaped() {
867        let mut c = sample_content(1, "txa");
868        let summary = "before\0after";
869        c.summary = summary;
870        let bytes = c.canonical_bytes();
871        // Raw NUL must NOT appear; escape sequence must.
872        assert!(!bytes.contains(&0x00));
873        assert!(String::from_utf8_lossy(&bytes).contains("before\\0after"));
874    }
875
876    /// Backslash-NUL collision regression: an attacker who could craft a
877    /// row with `summary = "before\\0after"` (literal backslash + zero) must
878    /// NOT produce the same canonical bytes as one with raw `\x00`. Without
879    /// the `\\` → `\\\\` escape, the two collide and the attacker can
880    /// substitute one for the other while the chain hash matches.
881    #[test]
882    fn literal_backslash_zero_does_not_collide_with_raw_nul_escape() {
883        let mut a = sample_content(1, "txa");
884        a.summary = "before\\0after"; // literal backslash + '0'
885        let mut b = sample_content(1, "txa");
886        b.summary = "before\0after"; // raw NUL
887        assert_ne!(
888            a.canonical_bytes(),
889            b.canonical_bytes(),
890            "backslash escape must run before NUL escape to prevent collision"
891        );
892    }
893
894    fn key_for_collision_test() -> AuditKey {
895        AuditKey::from_bytes(vec![0xab; 32])
896    }
897
898    #[test]
899    fn literal_backslash_zero_chain_hash_differs_from_raw_nul() {
900        let key = key_for_collision_test();
901        let mut a = sample_content(1, "txa");
902        a.summary = "before\\0after";
903        let mut b = sample_content(1, "txa");
904        b.summary = "before\0after";
905        assert_ne!(
906            key.chain_hash(&a, ""),
907            key.chain_hash(&b, ""),
908            "chain signature must distinguish escape from raw byte"
909        );
910    }
911
912    // ── verify_chain ──────────────────────────────────────────────────────
913
914    fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
915        let mut rows = Vec::with_capacity(count);
916        let mut prev = String::new();
917        for i in 0..count {
918            let seq = (i + 1) as u64;
919            let txid = format!("tx{i}");
920            let content = sample_content(seq, &txid);
921            let hash = key.chain_hash(&content, &prev);
922            rows.push(ChainRow {
923                seq,
924                key_id: content.key_id.to_string(),
925                transaction_id: content.transaction_id.to_string(),
926                request_id: content.request_id.to_string(),
927                request_hash: content.request_hash.to_string(),
928                action_name: content.action_name.to_string(),
929                risk_level: content.risk_level,
930                summary: content.summary.to_string(),
931                approval_id: content.approval_id.map(str::to_string),
932                warnings_json: content.warnings_json.to_string(),
933                created_at: content.created_at.to_string(),
934                prev_chain_hash: prev.clone(),
935                chain_hash: hash.clone(),
936            });
937            prev = hash;
938        }
939        rows
940    }
941
942    #[test]
943    fn intact_chain_verifies() {
944        let key = fixed_key();
945        let rows = build_chain(&key, 5);
946        let outcome = verify_chain(&key, &rows);
947        assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 5 });
948        assert_eq!(outcome_to_exit_code(&outcome), 0);
949    }
950
951    #[test]
952    fn empty_chain_verifies() {
953        let key = fixed_key();
954        let outcome = verify_chain(&key, &[]);
955        assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 0 });
956    }
957
958    #[test]
959    fn tampered_summary_breaks_chain_at_first_offending_row() {
960        let key = fixed_key();
961        let mut rows = build_chain(&key, 3);
962        // Mutate summary on row 1 (seq=2). Hash mismatch should be detected.
963        rows[1].summary = "TAMPERED".to_string();
964        let outcome = verify_chain(&key, &rows);
965        match outcome {
966            VerifyOutcome::Broken {
967                first_broken_seq, ..
968            } => assert_eq!(first_broken_seq, 2),
969            other => panic!("expected Broken, got {other:?}"),
970        }
971    }
972
973    #[test]
974    fn deleted_middle_row_breaks_chain_via_prev_hash_mismatch() {
975        let key = fixed_key();
976        let mut rows = build_chain(&key, 4);
977        // Remove row at seq=2 entirely. Row 3's prev_chain_hash now mismatches.
978        rows.remove(1);
979        let outcome = verify_chain(&key, &rows);
980        match outcome {
981            VerifyOutcome::Broken {
982                first_broken_seq, ..
983            } => assert_eq!(
984                first_broken_seq, 3,
985                "first broken row is the one whose prev_hash refers to deleted row"
986            ),
987            other => panic!("expected Broken, got {other:?}"),
988        }
989    }
990
991    #[test]
992    fn inserted_forged_row_breaks_chain() {
993        // Counterpart to `deleted_middle_row_…`. An attacker who managed to
994        // insert a fabricated row between two genuine ones still cannot
995        // produce a `chain_hash` that links the insertion back to the prior
996        // row's hash, so verification must flag the forgery at the inserted
997        // row (or at the immediately following genuine row whose
998        // `prev_chain_hash` no longer matches its real predecessor).
999        let key = fixed_key();
1000        let mut rows = build_chain(&key, 3);
1001
1002        // Splice in a new row between seq=1 and seq=2 with a fabricated hash.
1003        let forged = ChainRow {
1004            seq: 2,
1005            key_id: CURRENT_KEY_ID.to_string(),
1006            transaction_id: "tx-forged".to_string(),
1007            request_id: "req-forged".to_string(),
1008            request_hash: "hash-forged".to_string(),
1009            action_name: "InstallFlatpak".to_string(),
1010            risk_level: RiskLevel::Medium,
1011            summary: "Forged row".to_string(),
1012            approval_id: None,
1013            warnings_json: "[]".to_string(),
1014            created_at: "2026-04-25T13:00:00Z".to_string(),
1015            // Plausible prev_chain_hash chosen to look intact at boundary.
1016            prev_chain_hash: rows[0].chain_hash.clone(),
1017            // Not a valid signature; verification must reject this.
1018            chain_hash: "0".repeat(HASH_HEX_LEN),
1019        };
1020
1021        // Renumber the genuine seq=2/3 rows so seq is still 1..=4.
1022        let mut rest: Vec<ChainRow> = rows.split_off(1);
1023        for r in rest.iter_mut() {
1024            r.seq += 1;
1025        }
1026        rows.push(forged);
1027        rows.extend(rest);
1028
1029        let outcome = verify_chain(&key, &rows);
1030        match outcome {
1031            VerifyOutcome::Broken {
1032                first_broken_seq, ..
1033            } => assert!(
1034                first_broken_seq == 2 || first_broken_seq == 3,
1035                "verifier must flag the inserted row or the row that follows it (got {first_broken_seq})"
1036            ),
1037            other => panic!("expected Broken, got {other:?}"),
1038        }
1039    }
1040
1041    // ── Ed25519 public-key verification / non-repudiation ─────────────────
1042
1043    #[test]
1044    fn signature_verifies_under_exported_public_key() {
1045        // The auditor path: verify with only the public key.
1046        let key = fixed_key();
1047        let rows = build_chain(&key, 4);
1048        let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
1049        assert_eq!(outcome, VerifyOutcome::Intact { rows_checked: 4 });
1050    }
1051
1052    #[test]
1053    fn foreign_public_key_cannot_validate_chain() {
1054        // Non-repudiation: a different keypair's public key neither validates
1055        // the chain nor (by construction) could forge it. This is the property
1056        // the old symmetric HMAC could not provide.
1057        let signer = AuditKey::from_bytes(vec![0x11; 32]);
1058        let rows = build_chain(&signer, 3);
1059        let other = AuditKey::from_bytes(vec![0x22; 32]);
1060        let outcome = verify_chain_with_pubkey(&other.verifying_key_hex(), &rows);
1061        assert!(matches!(
1062            outcome,
1063            VerifyOutcome::Broken {
1064                first_broken_seq: 1,
1065                ..
1066            }
1067        ));
1068    }
1069
1070    #[test]
1071    fn verifying_key_hex_is_a_32_byte_public_key() {
1072        let key = fixed_key();
1073        let vk_hex = key.verifying_key_hex();
1074        assert_eq!(
1075            vk_hex.len(),
1076            64,
1077            "32-byte ed25519 public key = 64 hex chars"
1078        );
1079        assert!(hex::decode(&vk_hex).is_ok());
1080    }
1081
1082    #[test]
1083    fn malformed_signature_hex_is_broken_not_panic() {
1084        let key = fixed_key();
1085        let mut rows = build_chain(&key, 2);
1086        rows[0].chain_hash = "not-valid-hex!!".to_string();
1087        let outcome = verify_chain(&key, &rows);
1088        assert!(matches!(
1089            outcome,
1090            VerifyOutcome::Broken {
1091                first_broken_seq: 1,
1092                ..
1093            }
1094        ));
1095    }
1096
1097    #[test]
1098    fn bad_public_key_hex_yields_cannot_verify() {
1099        let key = fixed_key();
1100        let rows = build_chain(&key, 1);
1101        let outcome = verify_chain_with_pubkey("zz", &rows);
1102        assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1103    }
1104
1105    #[test]
1106    fn load_or_generate_writes_public_key_sidecar() {
1107        let dir = tempfile::tempdir().unwrap();
1108        let path = dir.path().join("audit-key");
1109        let key = AuditKey::load_or_generate(&path).unwrap();
1110        let pub_path = dir.path().join("audit-key.pub");
1111        assert!(pub_path.exists(), "public key sidecar must be written");
1112        let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
1113        assert_eq!(pub_hex.trim(), key.verifying_key_hex());
1114    }
1115
1116    #[test]
1117    fn wrong_key_id_yields_cannot_verify() {
1118        let key = fixed_key();
1119        let mut rows = build_chain(&key, 2);
1120        rows[0].key_id = "v99".to_string();
1121        let outcome = verify_chain(&key, &rows);
1122        assert!(matches!(outcome, VerifyOutcome::CannotVerify { .. }));
1123        assert_eq!(outcome_to_exit_code(&outcome), 2);
1124    }
1125
1126    #[test]
1127    fn exit_code_for_each_outcome() {
1128        assert_eq!(
1129            outcome_to_exit_code(&VerifyOutcome::Intact { rows_checked: 0 }),
1130            0
1131        );
1132        assert_eq!(
1133            outcome_to_exit_code(&VerifyOutcome::Broken {
1134                rows_checked: 0,
1135                first_broken_seq: 1,
1136                first_broken_transaction_id: String::new(),
1137                expected: String::new(),
1138                actual: String::new(),
1139            }),
1140            1
1141        );
1142        assert_eq!(
1143            outcome_to_exit_code(&VerifyOutcome::CannotVerify {
1144                reason: String::new()
1145            }),
1146            2
1147        );
1148    }
1149
1150    // ── AuditKey file management ──────────────────────────────────────────
1151
1152    #[test]
1153    fn load_or_generate_creates_with_0600_mode() {
1154        use std::os::unix::fs::PermissionsExt;
1155        let dir = tempfile::tempdir().unwrap();
1156        let path = dir.path().join("audit-key");
1157        let _key = AuditKey::load_or_generate(&path).unwrap();
1158        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1159        assert_eq!(mode, 0o600);
1160    }
1161
1162    #[test]
1163    fn load_or_generate_is_idempotent() {
1164        let dir = tempfile::tempdir().unwrap();
1165        let path = dir.path().join("audit-key");
1166        let key1_bytes = {
1167            let _ = AuditKey::load_or_generate(&path).unwrap();
1168            std::fs::read(&path).unwrap()
1169        };
1170        let _ = AuditKey::load_or_generate(&path).unwrap();
1171        let key2_bytes = std::fs::read(&path).unwrap();
1172        assert_eq!(key1_bytes, key2_bytes, "second load must not regenerate");
1173    }
1174
1175    #[test]
1176    fn rejects_world_readable_key_file() {
1177        use std::os::unix::fs::PermissionsExt;
1178        let dir = tempfile::tempdir().unwrap();
1179        let path = dir.path().join("audit-key");
1180        std::fs::write(&path, vec![0u8; 32]).unwrap();
1181        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1182        let result = AuditKey::load_or_generate(&path);
1183        assert!(matches!(
1184            result,
1185            Err(AuditKeyError::UnsafePermissions { .. })
1186        ));
1187    }
1188
1189    #[test]
1190    fn rejects_short_key_file() {
1191        use std::os::unix::fs::PermissionsExt;
1192        let dir = tempfile::tempdir().unwrap();
1193        let path = dir.path().join("audit-key");
1194        std::fs::write(&path, vec![0u8; 8]).unwrap();
1195        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1196        let result = AuditKey::load_or_generate(&path);
1197        assert!(matches!(result, Err(AuditKeyError::KeyTooShort { .. })));
1198    }
1199
1200    // ── Debug redaction (HI-17 secret hygiene) ────────────────────────────
1201
1202    /// `Debug` must never expose the Ed25519 private key material, neither raw bytes,
1203    /// nor their hex encoding. A derived `Debug` would dump the bytes the
1204    /// moment anyone wrote `tracing::debug!("{key:?}")`.
1205    #[test]
1206    fn debug_redacts_key_bytes_and_their_hex_encoding() {
1207        // Use a distinctive byte pattern so accidental leaks are obvious.
1208        let raw = (0u8..32).collect::<Vec<u8>>();
1209        let key = AuditKey::from_bytes(raw.clone());
1210        let dbg = format!("{key:?}");
1211
1212        assert!(
1213            dbg.contains("redacted"),
1214            "Debug output must contain the literal 'redacted' marker, got: {dbg}"
1215        );
1216
1217        // Hex encoding of the bytes must NOT appear, in either case.
1218        let hex_lower = hex::encode(&raw);
1219        let hex_upper = hex::encode_upper(&raw);
1220        assert!(
1221            !dbg.contains(&hex_lower),
1222            "Debug output leaks lowercase hex of key bytes: {dbg}"
1223        );
1224        assert!(
1225            !dbg.contains(&hex_upper),
1226            "Debug output leaks uppercase hex of key bytes: {dbg}"
1227        );
1228
1229        // Each individual byte's two-hex-char form must also be absent. This
1230        // catches the case where a future change splits the bytes across the
1231        // formatter and prints them piecewise.
1232        for b in &raw {
1233            let pair = format!("{b:02x}");
1234            // 1-byte values 0x00..0x0f render as "00".."0f" — too short to
1235            // assert against safely (collides with key_id "v1" etc.). Only
1236            // check 2-char forms that cannot incidentally match the rest of
1237            // the Debug output.
1238            if *b >= 0x10 && pair != "1e" && pair != "1f" {
1239                assert!(
1240                    !dbg.contains(&pair),
1241                    "Debug output leaks byte {b:#04x} as {pair:?}: {dbg}"
1242                );
1243            }
1244        }
1245    }
1246
1247    /// Operators triaging a chain break need to know which key generation
1248    /// produced a row. `key_id` is not secret and must remain visible.
1249    #[test]
1250    fn debug_preserves_key_id() {
1251        let key = AuditKey::from_bytes(vec![0xff; 32]);
1252        let dbg = format!("{key:?}");
1253        assert!(
1254            dbg.contains("key_id"),
1255            "Debug output must label the key_id field: {dbg}"
1256        );
1257        assert!(
1258            dbg.contains(CURRENT_KEY_ID),
1259            "Debug output must contain the key_id value {CURRENT_KEY_ID:?}: {dbg}"
1260        );
1261    }
1262
1263    // ── signed checkpoints (anti-truncation / anti-rewrite) ───────────────
1264
1265    #[test]
1266    fn checkpoint_consistent_with_intact_chain() {
1267        let key = fixed_key();
1268        let rows = build_chain(&key, 5);
1269        let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1270        let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
1271        assert_eq!(
1272            outcome,
1273            CheckpointOutcome::Consistent {
1274                checkpoints_checked: 1
1275            }
1276        );
1277    }
1278
1279    #[test]
1280    fn checkpoint_detects_truncation() {
1281        let key = fixed_key();
1282        let full = build_chain(&key, 5);
1283        // Anchor a checkpoint at the tip (seq=5); later the chain is cut to 3.
1284        let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
1285        let truncated = &full[..3];
1286        let outcome = verify_checkpoints(&key.verifying_key_hex(), truncated, &[cp]);
1287        assert!(matches!(
1288            outcome,
1289            CheckpointOutcome::Truncated {
1290                checkpoint_seq: 5,
1291                current_max_seq: 3
1292            }
1293        ));
1294    }
1295
1296    #[test]
1297    fn checkpoint_detects_rewrite() {
1298        let key = fixed_key();
1299        let mut rows = build_chain(&key, 4);
1300        let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1301        // Rewrite the row at seq=3 after the checkpoint was anchored.
1302        rows[2].chain_hash = "0".repeat(HASH_HEX_LEN);
1303        let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
1304        assert!(matches!(
1305            outcome,
1306            CheckpointOutcome::TipMismatch { seq: 3, .. }
1307        ));
1308    }
1309
1310    #[test]
1311    fn checkpoint_bad_signature_detected() {
1312        let key = fixed_key();
1313        let rows = build_chain(&key, 3);
1314        let mut cp = key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
1315        cp.signature = "0".repeat(HASH_HEX_LEN); // not a valid signature
1316        let outcome = verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]);
1317        assert!(matches!(
1318            outcome,
1319            CheckpointOutcome::BadSignature { seq: 2 }
1320        ));
1321    }
1322
1323    #[test]
1324    fn checkpoint_foreign_key_rejected() {
1325        // A checkpoint signed by one key must not verify under another key.
1326        let signer = AuditKey::from_bytes(vec![0x11; 32]);
1327        let rows = build_chain(&signer, 3);
1328        let cp = signer.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z");
1329        let other = AuditKey::from_bytes(vec![0x22; 32]);
1330        let outcome = verify_checkpoints(&other.verifying_key_hex(), &rows, &[cp]);
1331        assert!(matches!(
1332            outcome,
1333            CheckpointOutcome::BadSignature { seq: 2 }
1334        ));
1335    }
1336
1337    #[test]
1338    fn pubkey_verify_detects_tampered_middle_row() {
1339        // The core auditor claim: with only the public key, a mutated field in
1340        // a non-first row is detected at that exact seq.
1341        let key = fixed_key();
1342        let mut rows = build_chain(&key, 4);
1343        rows[2].summary = "TAMPERED".to_string();
1344        let outcome = verify_chain_with_pubkey(&key.verifying_key_hex(), &rows);
1345        assert!(matches!(
1346            outcome,
1347            VerifyOutcome::Broken {
1348                first_broken_seq: 3,
1349                ..
1350            }
1351        ));
1352    }
1353
1354    #[test]
1355    fn multiple_checkpoints_all_consistent() {
1356        let key = fixed_key();
1357        let rows = build_chain(&key, 5);
1358        let cps = vec![
1359            key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
1360            key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
1361            key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
1362        ];
1363        assert_eq!(
1364            verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
1365            CheckpointOutcome::Consistent {
1366                checkpoints_checked: 3
1367            }
1368        );
1369    }
1370
1371    #[test]
1372    fn middle_checkpoint_failure_is_reported() {
1373        // Earlier-consistent checkpoints must not mask a later failure.
1374        let key = fixed_key();
1375        let mut rows = build_chain(&key, 5);
1376        let cps = vec![
1377            key.sign_checkpoint(2, &rows[1].chain_hash, "2026-04-24T12:00:00Z"),
1378            key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:05:00Z"),
1379            key.sign_checkpoint(5, &rows[4].chain_hash, "2026-04-24T12:10:00Z"),
1380        ];
1381        rows[2].chain_hash = "0".repeat(HASH_HEX_LEN); // rewrite what cp #2 commits to
1382        assert!(matches!(
1383            verify_checkpoints(&key.verifying_key_hex(), &rows, &cps),
1384            CheckpointOutcome::TipMismatch { seq: 3, .. }
1385        ));
1386    }
1387
1388    #[test]
1389    fn checkpoint_created_at_is_signed() {
1390        // created_at is inside the signed message: backdating invalidates it.
1391        let key = fixed_key();
1392        let rows = build_chain(&key, 3);
1393        let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1394        cp.created_at = "2020-01-01T00:00:00Z".to_string();
1395        assert!(matches!(
1396            verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
1397            CheckpointOutcome::BadSignature { .. }
1398        ));
1399    }
1400
1401    #[test]
1402    fn checkpoint_seq_is_signed() {
1403        // seq is inside the signed message: moving it invalidates the signature.
1404        let key = fixed_key();
1405        let rows = build_chain(&key, 3);
1406        let mut cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
1407        cp.seq = 2;
1408        assert!(matches!(
1409            verify_checkpoints(&key.verifying_key_hex(), &rows, &[cp]),
1410            CheckpointOutcome::BadSignature { seq: 2 }
1411        ));
1412    }
1413
1414    #[test]
1415    fn checkpoint_detects_full_wipe() {
1416        // The whole chain deleted: the anchored tip cannot be reproduced.
1417        let key = fixed_key();
1418        let full = build_chain(&key, 3);
1419        let cp = key.sign_checkpoint(3, &full[2].chain_hash, "2026-04-24T12:00:00Z");
1420        assert!(matches!(
1421            verify_checkpoints(&key.verifying_key_hex(), &[], &[cp]),
1422            CheckpointOutcome::Truncated {
1423                checkpoint_seq: 3,
1424                current_max_seq: 0
1425            }
1426        ));
1427    }
1428
1429    #[test]
1430    fn domain_tags_are_distinct_and_prefix_free() {
1431        // Security invariant: row and checkpoint signatures can never
1432        // cross-verify because their signed messages start with distinct,
1433        // prefix-free domain tags.
1434        assert_ne!(ROW_DOMAIN, CHECKPOINT_DOMAIN);
1435        assert_ne!(ROW_DOMAIN, APPROVAL_DOMAIN);
1436        assert_ne!(CHECKPOINT_DOMAIN, APPROVAL_DOMAIN);
1437        assert!(!ROW_DOMAIN.starts_with(CHECKPOINT_DOMAIN));
1438        assert!(!CHECKPOINT_DOMAIN.starts_with(ROW_DOMAIN));
1439        assert!(!ROW_DOMAIN.starts_with(APPROVAL_DOMAIN));
1440        assert!(!APPROVAL_DOMAIN.starts_with(ROW_DOMAIN));
1441        assert!(!CHECKPOINT_DOMAIN.starts_with(APPROVAL_DOMAIN));
1442        assert!(!APPROVAL_DOMAIN.starts_with(CHECKPOINT_DOMAIN));
1443    }
1444
1445    #[test]
1446    fn approval_receipt_framing_is_injective() {
1447        // The signed receipt message must be injective in
1448        // (transaction_id, request_hash). Without `push_field` escaping, the
1449        // ambiguous pair below would collide because a raw 0x1F separator
1450        // cannot be distinguished from a 0x1F inside a field value.
1451        let key = fixed_key();
1452        let sep = "\u{1f}";
1453        let a = key.approval_receipt(&format!("tx{sep}b"), "c");
1454        let b = key.approval_receipt("tx", &format!("b{sep}c"));
1455        assert_ne!(a, b, "0x1F in a field must not alias distinct receipts");
1456
1457        // Sanity: the derivation is deterministic (Ed25519, same inputs).
1458        assert_eq!(
1459            key.approval_receipt("tx-1", "hash-1"),
1460            key.approval_receipt("tx-1", "hash-1"),
1461        );
1462        // And the commitment is exactly SHA-256 of the receipt.
1463        assert_eq!(
1464            key.approval_commitment("tx-1", "hash-1"),
1465            approval_receipt_digest(&key.approval_receipt("tx-1", "hash-1")),
1466        );
1467    }
1468}