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