From 7484e7483f86e228c55925f0f6af716552fde395 Mon Sep 17 00:00:00 2001 From: hellno Date: Sat, 20 Jun 2026 20:18:33 +0200 Subject: [PATCH 1/2] ADR 0004: rollback-resistant security-state anchor (#71 keystone spike) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spike deliverable for #71: design note answering all five spike questions, the keystone primitive #72/#108 build on, an honest residual, and a small unwired reference implementation behind an off-by-default feature. Method: empirical dep-cost measurement (cargo tree on macOS, diffed vs the workspace lock) + a fan-out research/adversarial-verification pass whose skeptics landed 16 attacks that reshaped the conclusion. Q1 (anchor crate): recommend `keyring` (pinned, per-OS native, default-features off) on macOS/Windows, file-only on Linux; measured cost is +1 crate on macOS, +4 (incl. the libdbus C lib) on Linux where the D-Bus Secret Service is also absent on the headless boxes signerd runs on. NEW DEP — approval-gated, NOT added here. Q2 (correctness): binding the epoch into core_bytes() so both AEAD tags cover it is the only construction that makes the file's epoch un-editable-without-passphrase — but it is a FORMAT_VERSION->2 change that breaks the frozen KAT, so it is specified and deferred, not landed. Check strictly after AEAD verify (no oracle to an unauthenticated caller); key on a re-seal-stable identity, not the per-seal random vault_id. Q3/Q4 (restore + residual): fail-open-with-confirm on the Control capability for testnet alpha; honest that delete-anchor->bootstrap bypasses the gate and that file-only Linux gives ~zero adversarial resistance. THREAT-MODEL residual row #7 added. Q5 (generalization): one StateAnchor over a namespaced record (vault/policy/cap), not a global counter; atomic+fsync+single-writer; the real consumers are #72 (policy version+MAC, fields that must be added first) and #108 (cap reserve-before-sign, with the intra-generation gap and clock-rollback corrected). The standalone vault detector is vacuous today (nothing bumps the vault epoch), so build order is: primitive -> policy/cap -> vault (v2). Prototype: signerd `state_anchor.rs` behind off-by-default `state-anchor`, unwired, zero new deps, 7 tests. DoD green (fmt, `just check` default+tray, `cargo test --workspace`); featured clippy+tests run separately. Refs #71 #72 #108. Relates ADR 0003 (#105). --- THREAT-MODEL.md | 1 + crates/deckard-signerd/Cargo.toml | 5 + crates/deckard-signerd/src/lib.rs | 5 + crates/deckard-signerd/src/state_anchor.rs | 523 ++++++++++++++++++ .../0004-rollback-resistant-state-anchor.md | 414 ++++++++++++++ 5 files changed, 948 insertions(+) create mode 100644 crates/deckard-signerd/src/state_anchor.rs create mode 100644 docs/adr/0004-rollback-resistant-state-anchor.md diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index dcc3c07..2247b09 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -267,6 +267,7 @@ broadcasts through a diverged endpoint, but it's worth attacking (red-team issue | 4 | Viewing-key compromise in the sidecar leaks shielded history (not funds) | Mitigated (Zeroizing, no-output discipline, scan-tested) | | 5 | Reason redaction is URL-shaped-token-based; a credential echoed in a non-URL form would pass | Mitigated for realistic transport-error shapes (tested); allowlist scan is the backstop | | 6 | Deterministic request-ids allow same-uid intent-collision games | Accepted within the uid boundary; salted ids on roadmap | +| 7 | **Rollback / replay of an older genuine `vault.bin` (or, later, policy/cap state)**: a same-uid attacker drops an older valid copy back over the current file, resurrecting old state the AEAD can't flag (it isn't a forgery) | **Open** (spike: [ADR 0004](docs/adr/0004-rollback-resistant-state-anchor.md), #71). The planned anchor is **resistance, not prevention**, and the bar moves *per configuration*: with an OS-keychain backend (macOS/Windows) replay must touch a second, hardware-backable trust domain (noisier, harder); **file-only (the default, and the only option on headless Linux — where `signerd` most often runs) the anchor is itself same-uid-deletable, so it detects non-adversarial loss but gives ~zero adversarial resistance** against full same-uid code execution that deletes every copy. Honestly for the weaker attacker (bad backup, sync glitch, sandboxed/limited process), not for arbitrary same-uid code | If you can demonstrate an attack that crosses a boundary this file claims holds — that's a vulnerability. Please report it via [SECURITY.md](SECURITY.md). diff --git a/crates/deckard-signerd/Cargo.toml b/crates/deckard-signerd/Cargo.toml index 7314500..e859884 100644 --- a/crates/deckard-signerd/Cargo.toml +++ b/crates/deckard-signerd/Cargo.toml @@ -34,6 +34,11 @@ shield = ["deckard-core/shield"] # the epic's "signerd has no CoW HTTP dependency" AC (reqwest itself is already in-tree via # alloy provider-http + helios + railgun, so a literal "0 reqwest" is impossible). cow-client = ["deckard-core/cow-client"] +# SPIKE artifact (issue #71, ADR 0004): an UNWIRED reference implementation of the keystone +# `StateAnchor` primitive (`state_anchor.rs`). DELIBERATELY NOT in `default` — enabling it compiles +# the module + its tests and changes NO production path (`unlock`/`propose`/`execute` are untouched). +# Pure safe Rust, zero new dependencies; the keychain backend (a new dep) is approval-gated per the ADR. +state-anchor = [] [dependencies] # The frozen wire contract (Intent / Decision / Policy / RPC + the shared `evaluate`). diff --git a/crates/deckard-signerd/src/lib.rs b/crates/deckard-signerd/src/lib.rs index 56b5f0b..5852852 100644 --- a/crates/deckard-signerd/src/lib.rs +++ b/crates/deckard-signerd/src/lib.rs @@ -28,6 +28,11 @@ pub mod request_id; pub mod server; pub mod signing; pub mod socket; +/// SPIKE reference implementation of the keystone `StateAnchor` primitive (issue #71, ADR 0004). +/// Gated behind the off-by-default `state-anchor` feature and wired into NO production path — see +/// the module docs. Present so `#72`/`#108` can build on a concrete interface. +#[cfg(feature = "state-anchor")] +pub mod state_anchor; pub mod supervise; pub use client::SignerClient; diff --git a/crates/deckard-signerd/src/state_anchor.rs b/crates/deckard-signerd/src/state_anchor.rs new file mode 100644 index 0000000..fe0f1b1 --- /dev/null +++ b/crates/deckard-signerd/src/state_anchor.rs @@ -0,0 +1,523 @@ +//! Reference implementation of the **keystone primitive** from +//! [`docs/adr/0004-rollback-resistant-state-anchor.md`](../../../docs/adr/0004-rollback-resistant-state-anchor.md). +//! +//! This is a SPIKE artifact: a small, **unwired** reference impl that makes the +//! `StateAnchor` interface concrete so `#72` (authenticated policy) and `#108` (durable cap) +//! can build on a real type instead of a sketch. It is gated behind the off-by-default +//! `state-anchor` feature and is wired into NO production path — `unlock`, `propose`, and +//! `execute` are untouched. Enabling the feature changes no behavior; it only compiles this +//! module and its tests. +//! +//! ## What it models (and what it deliberately does not) +//! +//! The anchor enforces two things the keystone needs and nothing more: +//! - **Monotonicity** — `advance` persists a namespace's record only if the new version is +//! strictly greater than the stored one (a compare-and-advance), so a stale/equal write fails +//! closed. +//! - **Durability + single-writer** — the file backend reuses the exact temp→fsync→rename→dir-sync +//! recipe `Vault::write_atomic` already uses (`keystore.rs`), and the daemon is the sole writer +//! (its lifetime `flock` + per-request mutex serialize every mutation). +//! +//! Integrity of a record's *payload* is the **consumer's** job, layered on top (e.g. `#72` MACs +//! `policy.json`; `#108` binds cap accounting to chain+account+policy-version+UTC-day). The anchor +//! stores opaque, monotonically-versioned bytes. And per the ADR's honest residual: the on-disk +//! backend is itself same-uid-deletable, so it raises the bar against the *weaker* attacker (a bad +//! backup, a sync glitch, a sandboxed process) and detects non-adversarial loss — it is not +//! tamper-proof against full same-uid code execution. A keychain backend (a new dependency, see the +//! ADR) is the bar-raising upgrade behind the same trait; it is not built here. + +use std::path::PathBuf; + +/// The artifacts that share the one anchor record, each with its own monotonic version so a vault +/// re-seal, a policy edit, and a cap-window roll advance independently (ADR 0004, Q5). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Namespace { + /// `#71` — the vault epoch. (No legitimate bumper exists in the shipping keystore yet; see the + /// ADR. Present so the interface is complete, not because the vault detector is live today.) + Vault, + /// `#72` — the authenticated policy version, bumped on each authorized policy edit. + Policy, + /// `#108` — the durable daily-cap generation fence. + Cap, +} + +impl Namespace { + fn id(self) -> u8 { + match self { + Namespace::Vault => 0, + Namespace::Policy => 1, + Namespace::Cap => 2, + } + } + fn from_id(id: u8) -> anyhow::Result { + Ok(match id { + 0 => Namespace::Vault, + 1 => Namespace::Policy, + 2 => Namespace::Cap, + _ => anyhow::bail!("unknown anchor namespace id"), + }) + } +} + +/// One namespace's anchored value: a monotonic `version` plus an opaque, consumer-authenticated +/// `payload` (e.g. the binding `chain+account+policy_version+UTC-day` for the cap). The anchor +/// never interprets the payload; it only guarantees the version advances monotonically. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AnchorRecord { + pub version: u64, + pub payload: Vec, +} + +impl AnchorRecord { + pub fn new(version: u64, payload: Vec) -> Self { + Self { version, payload } + } +} + +/// The three-valued read the ADR (Q1) requires so an unreadable anchor never bricks unlock and +/// never silently disables rollback detection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AnchorRead { + /// An authenticated, current record for the namespace. + Present(AnchorRecord), + /// First run, or a wiped domain. Indistinguishable from "an attacker deleted it" by + /// construction — the irreducible same-uid residual (ADR 0004, Q4). + Absent, + /// The backend is reachable-in-principle but cannot answer right now (e.g. a locked or denied + /// keychain). The caller proceeds on the remaining domains with a surfaced warning. The file + /// backend never returns this — a missing file is `Absent`, a corrupt file is a hard `Err`. + Degraded(String), +} + +/// The verdict of comparing an artifact's on-disk version against the anchor, encoding the Q3 +/// restore-from-backup decision table as a pure, testable function (see [`classify`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AnchorVerdict { + /// Anchor absent for this artifact (new machine / fresh account / wiped anchor): adopt the + /// file's version after a successful unlock. + Bootstrap, + /// `file == anchor`: normal. + Normal, + /// `file > anchor`: a restore-forward or a legitimate advance; adopt up to the file. + AdoptForward, + /// `file < anchor`: rollback suspected. Gate behind a human, Control-channel confirm. + RollbackSuspected { file: u64, anchor: u64 }, +} + +/// Apply the Q3 decision rule. `file_version` is read from the artifact only *after* its own +/// authentication has passed (the vault's AEAD, or a consumer's MAC); `anchor` is the +/// [`StateAnchor::read`] result for the same namespace. +pub fn classify(file_version: u64, anchor: &AnchorRead) -> AnchorVerdict { + match anchor { + AnchorRead::Absent | AnchorRead::Degraded(_) => AnchorVerdict::Bootstrap, + AnchorRead::Present(rec) => { + if file_version > rec.version { + AnchorVerdict::AdoptForward + } else if file_version == rec.version { + AnchorVerdict::Normal + } else { + AnchorVerdict::RollbackSuspected { + file: file_version, + anchor: rec.version, + } + } + } + } +} + +/// A monotonic, rollback-resistant security-state store. Implemented here by a file backend +/// (zero new dependencies); a keychain backend (a new dependency, ADR-approval-gated) would +/// satisfy the same trait. `signerd` is the only writer. +pub trait StateAnchor { + /// The current value for `ns`, or the absent/degraded signal. + fn read(&self, ns: Namespace) -> anyhow::Result; + + /// Monotonic compare-and-advance: persist `next` **only if** its version is strictly greater + /// than the stored version for `ns`, *and* the stored version equals `expected` (a CAS guard + /// against a concurrent or torn advance). Returns the committed record. Fails closed on a + /// stale/equal version, an `expected` mismatch, or a failed durability step — never silently + /// regresses. + fn advance( + &mut self, + ns: Namespace, + expected: u64, + next: AnchorRecord, + ) -> anyhow::Result; +} + +// --- File backend --- + +const MAGIC: &[u8; 4] = b"DKAN"; // "DecKard ANchor" — distinct from the vault's b"DKRD" +const FORMAT_VERSION: u8 = 1; +/// Caps applied before allocating, so a hostile anchor file can't OOM us (mirrors `keystore.rs`). +const MAX_ENTRIES: u32 = 16; +const MAX_PAYLOAD_LEN: u32 = 256; +const MAX_ANCHOR_BYTES: u64 = 8 * 1024; + +/// A file-backed [`StateAnchor`]: one file holds the whole namespaced record, written with the +/// atomic temp→fsync→rename→dir-sync discipline so a crash never leaves a torn anchor. +/// +/// NOTE on the path: a real wiring resolves this through the same `DECKARD_CONFIG_DIR`-aware +/// resolver as the vault/policy (or a `DECKARD_ANCHOR_DIR` override), **not** raw +/// `directories::data_dir()` — otherwise the throwaway `just qa`/`just demo` vaults and the real +/// vault share one anchor namespace, and on macOS `data_dir == config_dir` anyway (ADR 0004, Q5). +/// The reference impl takes an explicit path to keep that policy out of the primitive. +pub struct FileAnchor { + path: PathBuf, +} + +impl FileAnchor { + pub fn at(path: impl Into) -> Self { + Self { path: path.into() } + } + + /// Load the whole record set. A missing file is an empty set (every namespace `Absent`); a + /// present-but-corrupt file is a hard error (fail closed — the caller must not proceed on a + /// half-trusted anchor). + fn load(&self) -> anyhow::Result> { + let meta = match std::fs::metadata(&self.path) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e.into()), + }; + anyhow::ensure!( + meta.len() <= MAX_ANCHOR_BYTES, + "anchor file is implausibly large" + ); + let bytes = std::fs::read(&self.path)?; + Self::parse(&bytes) + } + + /// Parse the on-disk format through a bounded reader (no raw indexing; every length capped + /// before allocation), mirroring the keystore's untrusted-bytes discipline. + fn parse(bytes: &[u8]) -> anyhow::Result> { + let mut r = Reader::new(bytes); + anyhow::ensure!(r.take(4)? == MAGIC, "not a Deckard anchor file"); + anyhow::ensure!(r.u8()? == FORMAT_VERSION, "unsupported anchor version"); + let count = r.u32()?; + anyhow::ensure!(count <= MAX_ENTRIES, "too many anchor entries"); + let mut out: Vec<(Namespace, AnchorRecord)> = Vec::new(); + for _ in 0..count { + let ns = Namespace::from_id(r.u8()?)?; + let version = r.u64()?; + let plen = r.u32()?; + anyhow::ensure!(plen <= MAX_PAYLOAD_LEN, "anchor payload too large"); + let payload = r.take(plen as usize)?.to_vec(); + anyhow::ensure!( + !out.iter().any(|(seen, _)| *seen == ns), + "duplicate anchor namespace" + ); + out.push((ns, AnchorRecord::new(version, payload))); + } + r.finish()?; + Ok(out) + } + + fn serialize(records: &[(Namespace, AnchorRecord)]) -> Vec { + let mut b = Vec::with_capacity(64); + b.extend_from_slice(MAGIC); + b.push(FORMAT_VERSION); + b.extend_from_slice(&(records.len() as u32).to_le_bytes()); + for (ns, rec) in records { + b.push(ns.id()); + b.extend_from_slice(&rec.version.to_le_bytes()); + b.extend_from_slice(&(rec.payload.len() as u32).to_le_bytes()); + b.extend_from_slice(&rec.payload); + } + b + } + + /// Atomic write: temp file at `0600`, `write_all`, `sync_all`, `rename` over the target, then + /// `fsync` the parent dir — the same recipe as `Vault::write_atomic` (`keystore.rs`), so a + /// crash or power loss never leaves a partially written anchor. + fn write_atomic(&self, records: &[(Namespace, AnchorRecord)]) -> anyhow::Result<()> { + use std::io::Write; + if let Some(dir) = self.path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = self.path.with_extension("tmp"); + { + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts.open(&tmp)?; + f.write_all(&Self::serialize(records))?; + f.sync_all()?; + } + std::fs::rename(&tmp, &self.path)?; + if let Some(dir) = self.path.parent() { + if let Ok(dirf) = std::fs::File::open(dir) { + let _ = dirf.sync_all(); + } + } + Ok(()) + } +} + +impl StateAnchor for FileAnchor { + fn read(&self, ns: Namespace) -> anyhow::Result { + let records = self.load()?; + Ok(match records.into_iter().find(|(n, _)| *n == ns) { + Some((_, rec)) => AnchorRead::Present(rec), + None => AnchorRead::Absent, + }) + } + + fn advance( + &mut self, + ns: Namespace, + expected: u64, + next: AnchorRecord, + ) -> anyhow::Result { + let mut records = self.load()?; + let current = records + .iter() + .find(|(n, _)| *n == ns) + .map(|(_, r)| r.version); + match current { + Some(v) => { + anyhow::ensure!( + v == expected, + "anchor advance conflict: expected version {expected}, found {v}" + ); + anyhow::ensure!( + next.version > v, + "anchor advance must be monotonic: {} is not greater than {v}", + next.version + ); + } + None => { + // Bootstrap: the caller must claim it expected no prior entry. + anyhow::ensure!( + expected == 0, + "anchor bootstrap expects version 0, got {expected}" + ); + } + } + match records.iter_mut().find(|(n, _)| *n == ns) { + Some((_, slot)) => *slot = next.clone(), + None => records.push((ns, next.clone())), + } + self.write_atomic(&records)?; + Ok(next) + } +} + +/// A tiny bounds-checked reader for the anchor format (the keystore's `Reader` is private to that +/// module; this mirrors it so untrusted anchor bytes are parsed with the same discipline). +struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} +impl<'a> Reader<'a> { + fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + fn take(&mut self, n: usize) -> anyhow::Result<&'a [u8]> { + let end = self + .pos + .checked_add(n) + .filter(|e| *e <= self.buf.len()) + .ok_or_else(|| anyhow::anyhow!("anchor truncated"))?; + let s = self + .buf + .get(self.pos..end) + .ok_or_else(|| anyhow::anyhow!("anchor truncated"))?; + self.pos = end; + Ok(s) + } + fn u8(&mut self) -> anyhow::Result { + self.take(1)? + .first() + .copied() + .ok_or_else(|| anyhow::anyhow!("anchor truncated")) + } + fn u32(&mut self) -> anyhow::Result { + let b: [u8; 4] = self + .take(4)? + .try_into() + .map_err(|_| anyhow::anyhow!("anchor truncated"))?; + Ok(u32::from_le_bytes(b)) + } + fn u64(&mut self) -> anyhow::Result { + let b: [u8; 8] = self + .take(8)? + .try_into() + .map_err(|_| anyhow::anyhow!("anchor truncated"))?; + Ok(u64::from_le_bytes(b)) + } + fn finish(&self) -> anyhow::Result<()> { + anyhow::ensure!(self.pos == self.buf.len(), "trailing bytes after anchor"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_path(tag: &str) -> PathBuf { + // A unique-enough path per test; OsRng would be overkill for a temp file name. + std::env::temp_dir().join(format!( + "deckard-anchor-test-{tag}-{}.bin", + std::process::id() + )) + } + + #[test] + fn bootstrap_then_monotonic_advance() { + let path = temp_path("mono"); + let _ = std::fs::remove_file(&path); + let mut a = FileAnchor::at(&path); + + // Fresh: every namespace reads Absent. + assert_eq!(a.read(Namespace::Policy).unwrap(), AnchorRead::Absent); + + // Bootstrap at 1, then advance 1->2->3. + a.advance(Namespace::Policy, 0, AnchorRecord::new(1, vec![])) + .unwrap(); + a.advance(Namespace::Policy, 1, AnchorRecord::new(2, b"v2".to_vec())) + .unwrap(); + a.advance(Namespace::Policy, 2, AnchorRecord::new(3, b"v3".to_vec())) + .unwrap(); + + match a.read(Namespace::Policy).unwrap() { + AnchorRead::Present(rec) => { + assert_eq!(rec.version, 3); + assert_eq!(rec.payload, b"v3"); + } + other => panic!("expected Present, got {other:?}"), + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn stale_or_equal_advance_fails_closed() { + let path = temp_path("stale"); + let _ = std::fs::remove_file(&path); + let mut a = FileAnchor::at(&path); + a.advance(Namespace::Cap, 0, AnchorRecord::new(5, vec![])) + .unwrap(); + + // Equal version is not strictly greater -> rejected. + assert!(a + .advance(Namespace::Cap, 5, AnchorRecord::new(5, vec![])) + .is_err()); + // A lower version (an attempted rollback write) -> rejected. + assert!(a + .advance(Namespace::Cap, 5, AnchorRecord::new(4, vec![])) + .is_err()); + // A stale `expected` (CAS conflict) -> rejected even though 9 > 5. + assert!(a + .advance(Namespace::Cap, 4, AnchorRecord::new(9, vec![])) + .is_err()); + + // The stored value is unchanged after every rejected write (no silent regression). + assert_eq!( + a.read(Namespace::Cap).unwrap(), + AnchorRead::Present(AnchorRecord::new(5, vec![])) + ); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn namespaces_advance_independently() { + let path = temp_path("ns"); + let _ = std::fs::remove_file(&path); + let mut a = FileAnchor::at(&path); + a.advance(Namespace::Vault, 0, AnchorRecord::new(1, vec![])) + .unwrap(); + a.advance(Namespace::Policy, 0, AnchorRecord::new(7, vec![])) + .unwrap(); + a.advance(Namespace::Cap, 0, AnchorRecord::new(42, vec![])) + .unwrap(); + // Advancing one leaves the others untouched. + a.advance(Namespace::Policy, 7, AnchorRecord::new(8, vec![])) + .unwrap(); + assert_eq!( + a.read(Namespace::Vault).unwrap(), + AnchorRead::Present(AnchorRecord::new(1, vec![])) + ); + assert_eq!( + a.read(Namespace::Cap).unwrap(), + AnchorRead::Present(AnchorRecord::new(42, vec![])) + ); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn persists_across_reopen() { + let path = temp_path("reopen"); + let _ = std::fs::remove_file(&path); + { + let mut a = FileAnchor::at(&path); + a.advance( + Namespace::Policy, + 0, + AnchorRecord::new(3, b"state".to_vec()), + ) + .unwrap(); + } + // A fresh handle (a daemon restart) sees the durably-written record. + let b = FileAnchor::at(&path); + assert_eq!( + b.read(Namespace::Policy).unwrap(), + AnchorRead::Present(AnchorRecord::new(3, b"state".to_vec())) + ); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn corrupt_file_fails_closed_not_absent() { + let path = temp_path("corrupt"); + std::fs::write(&path, b"not a deckard anchor at all").unwrap(); + let a = FileAnchor::at(&path); + // A corrupt anchor must be a hard error (caller fail-closes), never silently treated as + // Absent (which would route into bootstrap and accept whatever the file claims). + assert!(a.read(Namespace::Policy).is_err()); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn round_trips_through_bounded_reader() { + let records = vec![ + (Namespace::Vault, AnchorRecord::new(1, vec![])), + (Namespace::Policy, AnchorRecord::new(9, b"abc".to_vec())), + (Namespace::Cap, AnchorRecord::new(u64::MAX, vec![0xFF; 16])), + ]; + let bytes = FileAnchor::serialize(&records); + let parsed = FileAnchor::parse(&bytes).unwrap(); + assert_eq!(parsed, records); + // Trailing garbage is rejected. + let mut extra = bytes.clone(); + extra.push(0); + assert!(FileAnchor::parse(&extra).is_err()); + // A truncated buffer is rejected, not silently short-read. + assert!(FileAnchor::parse(&bytes[..bytes.len() - 1]).is_err()); + } + + #[test] + fn classify_encodes_the_restore_decision_table() { + // Absent -> bootstrap (new machine / wiped anchor). + assert_eq!(classify(5, &AnchorRead::Absent), AnchorVerdict::Bootstrap); + // Degraded -> bootstrap (proceed; the keychain is unreachable). + assert_eq!( + classify(5, &AnchorRead::Degraded("locked".into())), + AnchorVerdict::Bootstrap + ); + let anchor = AnchorRead::Present(AnchorRecord::new(7, vec![])); + // file == anchor -> normal. + assert_eq!(classify(7, &anchor), AnchorVerdict::Normal); + // file > anchor -> adopt forward (restore-forward / legitimate advance). + assert_eq!(classify(8, &anchor), AnchorVerdict::AdoptForward); + // file < anchor -> rollback suspected (the only branch the human Control-gate guards). + assert_eq!( + classify(6, &anchor), + AnchorVerdict::RollbackSuspected { file: 6, anchor: 7 } + ); + } +} diff --git a/docs/adr/0004-rollback-resistant-state-anchor.md b/docs/adr/0004-rollback-resistant-state-anchor.md new file mode 100644 index 0000000..e2df2dd --- /dev/null +++ b/docs/adr/0004-rollback-resistant-state-anchor.md @@ -0,0 +1,414 @@ +# ADR 0004 — Rollback-resistant security-state anchor (the keystone) + +- **Status:** Proposed (2026-06-20). Records the design for issue `#71` and the primitive that + `#72` (authenticated policy) and `#108` (durable cap) build on. Decisions only; executable work + stays in GitHub issues. +- **Deciders:** @hellno (maintainer) +- **Method:** source-grounded review (every load-bearing fact below is cited `file:line` and was + grepped on a fresh worktree off `origin/main` post-`#105`), an **empirical** dependency-cost + measurement (`cargo tree` on this macOS host, diffed against the 1145-crate workspace lock), and a + fan-out research + **adversarial verification** pass (four research strands, then independent + skeptics tasked to refute the consolidated design; 16 of their attacks landed and reshaped the + conclusion below). A planned codex cross-model pass did not run (session limit); it is the one gap + in the method and is noted as a follow-up. +- **Context inputs:** [`ADR 0003`](0003-crate-trust-boundary.md) (the keystone framing, items #4–#7), + `THREAT-MODEL.md`, `SECURITY.md`, `crates/deckard-core/src/keystore.rs`, + `crates/deckard-core/src/config.rs`, `crates/deckard-signerd/src/{daemon,config,policy_store}.rs`, + the dependent issues [`#71`](https://github.com/hellno/deckard/issues/71), + [`#72`](https://github.com/hellno/deckard/issues/72), + [`#108`](https://github.com/hellno/deckard/issues/108). + +## The question + +`vault.bin` is AEAD-encrypted, so a same-uid attacker with filesystem write can't *forge* a new valid +vault. But they can *roll it back*: drop an older, genuine copy of the user's own vault over the +current one (malware running as you, a careless restore, a sync conflict). The stale vault opens +cleanly under the right passphrase and resurrects old state. Nothing detects this today (zero +OS-keychain use in `deckard-core` / `deckard-signerd`). + +ADR 0003 elevated this from a vault-only fix to the **keystone**: the same mechanism a vault needs to +detect rollback (a monotonic counter bumped on every authoritative write, mirrored to a reference in a +different trust domain) is what authenticated policy (`#72`) and a durable daily cap (`#108`) also +need. So this ADR answers `#71`'s five spike questions and designs the shared primitive those two +consumers stand on. + +## The headline, stated before the details + +The adversarial pass changed the answer. Three things are true and must be said plainly: + +1. **The anchor crate is cheap, and the recommendation is the OS keychain on macOS/Windows plus a + file fallback everywhere — but it is a new dependency that needs explicit approval, and on Linux it + is mostly absent where it is most needed.** We measured the cost; it is small. We do **not** add it + in this PR. + +2. **The two correctness details from the issue are real and we have the exact mechanism for both.** + Binding the epoch into the AEAD associated data is the *only* way the file itself carries a + trustworthy epoch, and it is correct. Checking the anchor only after the passphrase verifies is a + one-line ordering decision at the unlock seam. + +3. **The standalone "anchor the vault" feature, taken literally, is close to theater on today's + codebase, and the honest residual is starker than "raises the bar."** Nothing legitimately + advances a vault's epoch (every seal mints a fresh identity; there is no re-seal path). A + plaintext sidecar epoch is rolled back together with the vault and verifies fine. The anchor itself + is same-uid-deletable, and deleting it routes straight into the "new machine, bootstrap" path with + no challenge. So the keystone earns its keep first on **policy and cap** (artifacts that *do* have + legitimate monotonic bumps), not on the vault. This ADR is conclusive, and the conclusion is to + **build the generalizable primitive, wire it to `#72`/`#108` first, and defer the vault-epoch + binding to a v2-format effort that also builds the missing re-seal path.** + +The rest of this document is the evidence for those three claims, mapped onto the five spike questions. + +--- + +## Q1 — Anchor crate and the real new-dependency cost + +**Decision: recommend `keyring` (pinned, `default-features = false`, per-OS native backend) as the +durable trust-domain reference on macOS and Windows; ship file-only on Linux; and ship a +dependency-free file backend as the always-present baseline on every OS. This is a recommendation that +needs explicit maintainer sign-off against the no-new-deps DoD bar. It is not added in this PR.** + +### What we measured (this macOS host, diffed against `Cargo.lock`) + +| Config | Activated tree | **Crates not already in the workspace lock** | +|---|---|---| +| `keyring` 3.6.3, `apple-native` (macOS) | 8 | **1** — `keyring` itself | +| `keyring` 3.6.3, `sync-secret-service` + `vendored` + `crypto-rust` (Linux) | 38 | **4** — `keyring`, `dbus`, `dbus-secret-service`, `libdbus-sys` (a **C** library) | + +On macOS the cost is one Rust crate. `security-framework` (which is also the Touch ID hook the issue +flagged for a later phase), `core-foundation`, `bitflags`, `libc`, and `log` are already in the lock +via alloy/helios, so `apple-native` reuses them. `keyring` is MIT/Apache (compatible with our +AGPL-3.0), MSRV 1.75, and exposes a process-wide **mock** credential store usable as the test backend +and the "no keychain present" shim. + +On Linux the cost is qualitatively larger than four crates suggests: + +- The D-Bus Secret Service backend pulls a **C library** (`libdbus-sys`) and, at runtime, needs a + live session bus **and** an unlocked keyring daemon (gnome-keyring / KWallet). A headless server + has neither, and `signerd` is exactly the kind of process that runs headless. The call fails to + find an item rather than returning a stable anchor. +- The kernel `keyutils` backend (`linux-native`) avoids D-Bus entirely but is non-persistent across + reboot by default. An anchor must survive reboot, so `keyutils` is unsuitable as the primary store. + +So on Linux the honest answer is **file-only**, with the keychain as a best-effort extra only where a +provider happens to be present. + +### What we ask the maintainer to approve (and what we do not) + +The real ask is small: **`keyring` on macOS and Windows only.** Pin it (`= 3.6.3` or a tilde range, +not the caret `"3"`, which is an open range whose transitive closure can drift and which the DoD bar +would not actually freeze), and re-measure the closure on the unified workspace before adding it. +Per ADR 0003 item #8, confirm via `cargo tree` on the real workspace that `keyring`/`libdbus` stay +**out of `deckard-app`'s** feature closure (Cargo feature unification can silently re-add a backend); +the anchor and its FFI belong to `signerd`, the single key-holder, never to `deckard-core` +(`#![forbid(unsafe_code)]`, and linked by every key-less binary). `keyring` 4.1.1 exists and is a +major bump (the `keyring-core` + store split); evaluate it separately, do not adopt blind. + +Rejected alternatives: raw `SecItem` / `libsecret` / `wincred` FFI (more `unsafe` to own and audit for +a control that is not load-bearing against same-uid); `keyring-core` + a store (premature at our +scale). The file backend ships with **zero new dependencies** and is the baseline; the keychain is the +bar-raising upgrade layered behind the same trait, not a prerequisite. + +### Degraded mode is mandatory, and it is the honest core of the feature + +An anchor that cannot be read must never brick unlock and must never silently disable rollback +detection. The read path is three-valued: `Present` (an authenticated record), `Absent` (first run, or +a wiped domain), `Degraded` (the backend is unreachable, e.g. a locked or denied keychain). A detected +**regression** (a present domain reporting a lower version than expected) fails closed; a merely +**absent** domain degrades to the remaining domains with a surfaced warning, in the spirit of the +`⚠ POLICY FALLBACK` line `policy_store.rs` already prints. The catch, which Q4 makes precise, is that +`Absent` and "an attacker deleted it" are indistinguishable by construction. + +--- + +## Q2 — The two correctness details at the keystore seam + +### (a) Bind the epoch into the AEAD associated data + +`keystore.rs` already authenticates the entire header through `Header::core_bytes()` (`keystore.rs:146`), +which both AEAD layers consume: `wrap_aad = [AAD_WRAP, &core]` (`keystore.rs:262`) and +`payload_aad = [AAD_PAYLOAD, &core, &wrapped_dek]` (`keystore.rs:269`). Anything inside `core` is +covered by **both** Poly1305 tags. So adding a `u64` epoch field to `Header` and emitting it in +`core_bytes()` makes it un-editable without the KEK: + +> An attacker copies an old blob (epoch 3) over the current file (epoch 7), then edits the plaintext +> epoch bytes 3→7 to satisfy the anchor. The parsed `core` now says 7, but the stored tag was computed +> over 3, so `aead_decrypt` of the wrapped DEK returns `Err` and unlock fails closed — identical in +> mechanism to the existing `m_kib` tamper case in `tamper_each_region_fails_closed` +> (`keystore.rs:822-835`). The epoch becomes editable only by someone who holds the passphrase. + +This is the **only** construction that lets the *file itself* carry a trustworthy epoch. A plaintext +sidecar epoch, even one MAC'd by a vault-derived key, does **not** achieve it: a same-uid attacker +rolls the sidecar back alongside the vault, both records verify against their own (older, genuine) +tags, and no forgery is needed (adversarial finding, critical). With a sidecar, *all* rollback +resistance reduces to the external anchor's high-water mark, which is same-uid-deletable. This is the +fork in the design, and the ADR resolves it explicitly below. + +**Decision: AAD-binding is the correct mechanism, and it requires a format migration we do NOT land in +this spike.** `FORMAT_VERSION` is the first authenticated byte in `core_bytes()` (`keystore.rs:149`), +and the three frozen KAT fixtures (`decode_compat_v1_fixtures`, `keystore.rs:788`) are exact byte +blobs whose tags were computed over a 101-byte core with no epoch field. Inserting the field bumps the +version to 2, shifts every later offset, and breaks the fixtures (`keystore.rs:789` calls this a +lost-funds-class break). The deliverable here is an ADR, not a format migration. We therefore: + +- specify the v2 layout (epoch as a `u64` LE field in `core_bytes()`, a `Reader::u64()` helper + mirroring the existing `u32()` at `keystore.rs:639`, version dispatch in `from_bytes`), +- record that a real v2 reads v1 vaults as **epoch 0 implicit**, reconstructing the exact v1 `core` so + the frozen tags still verify, and +- **forbid format-downgrade re-anchoring** in that future v2 work: once a `vault_id` has a v2 anchor + entry ≥ 1, presenting the original v1 (epoch 0) blob is a rollback, never a benign bootstrap (an + adversarial finding: otherwise a human who clicks through the restore prompt re-anchors down to 0 and + permanently disarms detection). + +The spike proves the binding with an isolated test on a v2-shaped header; the production binding is a +separate, in-scope-later format break tracked on `#71`. + +### (b) Check the anchor only after the passphrase verifies + +The compare lands in `signerd`, in `daemon.rs unlock()` (the success arm after the `spawn_blocking` +unlock returns `Ok(Ok(unlocked))` at `daemon.rs:421`), **not** inside core's `Vault::unlock`. Core +stays format-only, `#![forbid(unsafe_code)]`, and dependency-free; the out-of-file anchor is a +platform concern for the single key-holder. + +Because the compare runs only on the branch where the AEAD already proved the passphrase, it adds no +oracle to an **unauthenticated** caller: a wrong passphrase still collapses to `BadPassphrase` through +the keystore's one-generic-message contract (`keystore.rs:419-436`), unchanged. Two qualifications the +adversarial pass forced, which the implementation and any copy must respect: + +- **The reader for the file epoch must run after AEAD success, never as a pre-check.** A pre-check that + touched a missing or garbage epoch source before the passphrase is verified would re-introduce a + wallet-presence oracle. +- **`Unlock` is served on the public proposer socket** (only `Resolve` is `Channel::Control`-gated, + `daemon.rs:320`). So a distinct "rolled back" outcome, if we add one, is visible to any same-uid + proposer that already knows the passphrase. That is acceptable inside the uid boundary (such a caller + has already unlocked) but it is **not** resolver-only, so the claim is "no oracle to an + unauthenticated caller," not "no oracle." (A pre-existing presence oracle also remains: `unlock()` + returns `NoVault` at `daemon.rs:409` before any passphrase check. The anchor work does not add to it + and does not fix it.) + +### Key the anchor on a re-seal-stable identity, not the per-seal `vault_id` + +`seal()` mints a fresh random `vault_id` on every seal (`keystore.rs:242`). Keying the anchor on +`vault_id` means a genuine older backup of the *same seed* carries a *different* `vault_id`, lands in +the "absent → bootstrap" branch, and is silently accepted (adversarial finding). Key instead on a +domain-separated commitment to the **stable primary address** +(`HMAC(domain_key, primary_address)`), which survives re-seals; the seed never leaves core. This also +exposes the next finding: with `vault_id` keying, the vault epoch is effectively write-once. + +--- + +## Q3 — The legitimate restore-from-backup accept path + +The decision rule, keyed on the stable identity and run only after AEAD success: + +| Observed | Meaning | Action | +|---|---|---| +| anchor file absent, or identity absent from it | new machine / fresh account / wiped anchor | **bootstrap**: adopt the file's value after a successful unlock | +| `file > anchor` | restore-forward, or a legitimate advance | **adopt** up to `file` | +| `file == anchor` | normal | proceed | +| `file < anchor` (identity present) | **rollback suspected** | **gate**: a one-time, human-confirmed "this vault is older than this machine last saw — restore anyway?" | + +The confirm rides the existing `Channel::Control` resolver capability (the same socketpair fd that +authorizes `Resolve`, `daemon.rs:312-327`), so an injected agent on the public socket cannot +auto-confirm. On confirm we re-anchor down to the file's value, so the restored backup becomes the new +baseline and the next unlock is normal. + +**Default posture for the testnet-only alpha: fail-open-with-confirm, not fail-closed-refuse.** The +dominant real-world event is a benign restore or sync glitch, not an attacker; refusing would brick +legitimate restores and teach users to disable the check. The posture is a documented dial: when +Deckard moves toward real funds, the same machinery flips to fail-closed (refuse unless a Control +confirm is present) without redesign. Anchors are **machine-local and never synced**; syncing one +would let a rollback on one device authorize itself on another. A multi-device user who restores an +older backup sees one confirm per device (and, with actively-synced state, possibly one confirm per +out-of-order sync event, which the UX must expect rather than treat as a bug). + +### Two hard problems the rule alone does not solve, decided here rather than deferred + +**The confirm gate is a safety feature for benign restores, not a security control.** The dominant +attacker move is not to downgrade past a surviving anchor (the only branch the gate guards). It is to +**delete the anchor** (same-uid filesystem write is in scope) and present the old vault, which routes +to "absent → bootstrap" and is accepted silently with no challenge. Deletion is indistinguishable from +a new machine by construction. The keychain copy, where present, is the only thing that makes deletion +noisier than overwriting the vault; on file-only Linux there is no such thing. We state this in the +copy and rank it as the feature's #1 residual, rather than describing the gate as making rollback +unforgeable. + +**The torn-write order must be a recoverable journal, not a brick.** The anchor and the file are +separate stores, so a bump can never be one atomic transaction. Writing the anchor first is fail-closed +but bricks the wallet on any benign crash between the two writes (anchor at N, file still at N-1, read +as a rollback of a vault that was never rolled back). And the obvious un-brick ("if `file == anchor-1` +and the tags verify, auto-repair") is itself a one-step-rollback laundering primitive, because a +crash and a deliberate one-epoch rollback are indistinguishable at that point. The decision: write a +small **intent record** `{identity, old, new}` to the anchor domain, then the file, then clear the +intent. On boot, a pending intent whose `new == anchor` and `file == old` is a *provable* torn write +(advance and clear); `file < old` is a rollback (gate). This removes the ambiguity instead of guessing. +For the alpha, the simpler fallback is acceptable: treat `file == anchor` as the only steady state and +require an explicit Control-channel repair for anything else, paying the UX cost honestly. + +--- + +## Q4 — The honest residual + +`THREAT-MODEL.md`'s boundary is the uid, including filesystem write. The anchor lives inside that +boundary, so it is **resistance, not prevention**, and the honesty has to be stated *per configuration* +because the bar moves by a very different amount in each: + +- **Keychain present (macOS / Windows, interactive session):** meaningfully noisier. To replay an old + state the attacker must delete or rewrite a Keychain / Credential Manager item in a separate trust + domain, not just overwrite a file. This is the configuration that earns the "raises the bar" claim, + and it is the path to a future hardware-backed anchor (Secure Enclave / TPM). +- **File-only (the dependency-free default, and the *only* option on headless Linux, which the + dep-cost analysis shows is exactly where `signerd` most often runs):** marginal. The anchor and any + fast counter file are plain same-uid files. A full same-uid code-execution attacker deletes both and + drops to the bootstrap path. The bar moves from "silently edit one number" to "delete two files and + trigger a fresh-machine bootstrap." That is real against the **weaker** attacker the feature is + honestly for (a bad backup, a sync conflict, a careless restore, a sandboxed or limited process that + can read but not freely delete), and it is **zero** against full same-uid code execution. + +So the precise claim is: the anchor detects and raises the cost of **replay of an older genuine state** +by the weaker attacker, and on keychain-backed platforms it forces that replay into a second, +hardware-backable trust domain. It does **not** stop a same-uid attacker who can delete every anchor +copy, and on file-only platforms that reduces to non-adversarial protection. A residual row is added to +`THREAT-MODEL.md` so the headless `signerd` case is never silently credited with the keychain-grade +increment it does not get. + +--- + +## Q5 — Generalization: the keystone primitive for `#72` and `#108` + +**Decision: one `StateAnchor` over a single keyed record with per-artifact *namespaced* monotonic +fields, not a single global counter.** Vault re-seal, policy edit, and cap reservation advance at +different rates and for different reasons; a shared counter would couple them (a vault re-seal would +invalidate the cap window; a policy edit would have to re-stamp cap state). Domain separation lets each +advance independently while one write commits them atomically. + +``` +struct AnchoredState { // serialized + integrity-tagged as one blob + format: u8, // the anchor's own format version, independent of vault.bin + vault_epoch: u64, // #71: bumps on re-seal / DEK rotation (no legitimate bumper exists YET) + policy_version: u64, // #72: bumps on each authorized policy edit + cap_generation: u64, // #108 fence: bumps on UTC-day roll / policy change / detected rollback + last_seen_day: u64, // #108: monotonic max-day-ever-seen, so a backward clock can't reset +} +// AAD domain separation per field, e.g. b"DKRDv1/anchor/{vault,policy,cap}" +``` + +```rust +/// A monotonic, rollback-resistant security-state store. Implemented by a keychain backend +/// (a NEW DEP, needs approval) and a file/mock backend (zero new deps). signerd is the only writer. +pub trait StateAnchor { + /// Current value for `ns`, or the degraded/absent signal. + fn read(&self, ns: Namespace) -> anyhow::Result; + /// Monotonic compare-and-advance: persist `next` only if its version is strictly greater + /// than the stored version for `ns`. Fail closed on a stale/equal version, a failed + /// durability step, or a torn/absent backend. + fn advance(&mut self, ns: Namespace, expected: u64, next: AnchorRecord) -> anyhow::Result; +} +enum AnchorRead { Present(AnchorRecord), Absent, Degraded(String) } +``` + +**Durability and the single writer.** The on-disk backend reuses the exact recipe `Vault::write_atomic` +already implements (`keystore.rs:378-405`): open the temp file at `0600`, `write_all`, `sync_all`, +`rename` over the target, then `fsync` the parent directory. `signerd` is the sole writer: it holds the +single-instance `flock` for its lifetime and its per-request mutex serializes every mutation, so no +second writer can race. The anchor path resolves through the **same `DECKARD_CONFIG_DIR`-aware +resolver** as the vault and policy (or a parallel `DECKARD_ANCHOR_DIR`), **not** raw +`directories::data_dir()` — otherwise the throwaway `just qa` / `just demo` vaults and the real vault +share one anchor namespace, and on macOS `data_dir == config_dir` anyway, so "survives a config wipe" +is a Linux-only and largely illusory benefit. + +**Integrity is the consumer's job, layered on top.** The anchor enforces monotonicity and durability; +each consumer authenticates its own payload. This sidesteps an unresolved keying question (the +vault-derived MAC key is only available after unlock, which is fine for policy/cap checks that run only +while unlocked, but a policy edit authorized while locked has no key to re-MAC; that consumer must +require an unlocked session or a separate bootstrap key). Authentication reuses the existing +XChaCha20-Poly1305 (`chacha20poly1305` is an unconditional `deckard-core` dep; `hmac`/`sha2` are +`shield`-gated), so the keyed tag is zero new dependencies and the same audited family as the keystore. + +### How each consumer uses it, and where each consumer is honestly weakest + +- **`#72` (authenticated policy).** Today `contract::Policy` (`policy.rs:17-36`) has **no version field + and no MAC**, and `policy.json` is plain `serde_json` (`policy_store.rs:61`) — that *is* finding C2. + So `#72` must first add a versioned, MAC'd policy record (`version + tag`, with `version = 0` as the + pre-versioning default for forward-compat). It then `read(Policy)`, fails closed on a bad/missing tag + or a stale version (replay of an older, more permissive policy), and on an authorized edit calls + `advance(Policy, …)` **before** re-MAC'ing the file. This is the keystone's strongest consumer: a + policy version *does* advance on every legitimate edit, so `file < anchor` is reachable through real + use, unlike the vault. + +- **`#108` (durable cap).** Two tiers, because the cap increments on every spend and a keychain write + per spend is slow and can prompt. The slow anchor holds a coarse `cap_generation` bound to + `chain + account + policy_version + UTC-day`; the per-spend `reserved_wei` lives in a fast local file + written with the atomic recipe, **reserved before the signature is released** (closing the + post-broadcast crash window at `daemon.rs:1166`). Three corrections the adversarial pass forced into + the design, none optional: + 1. **The generation fence does not catch an intra-generation rollback.** Within a UTC day at fixed + policy, `cap_generation` is constant, so swapping in an earlier same-day fast file (lower + `reserved_wei`, same generation) passes the fence and resets the counter. `#108` must add + intra-generation monotonic protection (a high-water sequence the slow tier checkpoints), or scope + the fence honestly to cross-generation only and accept that an intra-day same-uid file swap is not + closed. "Rolling the generation retires every older snapshot" is false for same-generation + snapshots. + 2. **A backward wall-clock jump must not re-open the window.** `current_utc_day()` + (`policy_store.rs:98`) is naive and `rollover()` (`daemon.rs:1397`) resets the spend bidirectionally + today. Derive the window from `effective_day = max(current_utc_day(), anchor.last_seen_day)` and + make `rollover()` forward-only. A backward jump then mismatches and fails closed instead of + resetting the cap to zero. + 3. **Reconcile a pre-broadcast crash deterministically.** Reserve-before-sign over-counts if the + daemon crashes after reserving but before broadcasting. The reservation must carry a deterministic + tx identity (`chain + from + nonce`) so that on reboot the daemon queries the chain and commits or + releases the reservation, rather than permanently consuming the cap. Note this adds an `fsync` to + every signature under the mutex held across broadcast, which lengthens STOP latency; measure it. + +- **`#71` (the vault, the nominal first consumer, and the weakest).** There is **no legitimate bumper + for `vault_epoch`** in the shipping code: `seal()` always mints a fresh identity, and the + "upgrade-on-unlock re-seal" the design would lean on (`keystore.rs:60-64`) is a doc comment, not + code. So for any one vault the epoch is write-once and `file < anchor` is unreachable through + legitimate use; the standalone vault-rollback detector is vacuous until a re-seal-preserving-identity + path exists. That path is itself the v2-format change Q2 defers. This is why the build order below + puts the vault last. + +--- + +## Decision and build order + +1. **Build the generalizable `StateAnchor` primitive first** (file-backed, zero new deps), as the + keystone ADR 0003 item #4 asks for: one namespaced authenticated record, atomic write + `fsync` + + dir-sync, single-writer via the existing `flock`, three-valued read with fail-closed-on-regression + and degraded-on-absent, path resolved through the config-dir-aware resolver. A small, + feature-gated, unwired reference implementation ships **with this ADR** to make the interface + concrete and de-risk the consumers (it changes no production behavior). +2. **Wire `#72` and `#108` to it** — they are where a monotonic counter actually advances and where + the keystone earns its keep. `#72` must add the versioned MAC'd policy record first; `#108` must add + the intra-generation guard, the monotonic-day guard, and pre-broadcast reconciliation. +3. **Defer the vault-epoch binding** to a v2-format effort on `#71` that also builds the missing + re-seal-preserving-identity path and forbids format-downgrade re-anchoring. Until then, the vault + nominally consumes the primitive at a fixed bootstrap epoch and gains the restore-confirm UX, but + the real anti-rollback value for the vault waits on v2. +4. **The keychain backend (`keyring`) is a recommended, approval-gated upgrade**, not landed here. Ask: + `keyring` pinned, macOS/Windows native only, file-only on Linux, closure re-measured on the unified + workspace. + +## Consequences + +- **Positive:** the keystone is specified as one primitive three consumers share, with the durability + and single-writer semantics ADR 0003 demanded; the dependency cost is measured, not guessed, and the + approval ask is small and honest; the design is corrected for the rollback-bypass, oracle, + torn-write, clock-rollback, intra-generation, and env-isolation holes an adversarial pass found + before any code shipped. +- **Cost:** the strongest version of the feature needs a new dependency (approval-gated) and, for the + vault, a v2 format migration plus a re-seal path that does not exist yet. The cap's reserve-before- + sign adds per-spend `fsync` under the broadcast-held mutex. +- **Deferred:** the vault-epoch AAD binding (v2 format + re-seal), the hardware-backed anchor (Secure + Enclave / TPM), Touch ID unlock (separate effort; note that `apple-native` already pulls + `security-framework`, so this does not foreclose it), and the codex cross-model review pass that did + not run. + +## Status / next step + +**Proposed.** The spike is conclusive: the anchor crate and its cost are chosen, both correctness +details have an exact mechanism, the restore path and the per-configuration residual are defined, and +the generalization is specified with its consumers' weakest points named. The decision is to build the +shared primitive and wire policy/cap first, defer the vault binding to a v2 effort, and seek approval +for the `keyring` upgrade. `#71` stays **open** (the vault binding is not done); this ADR is referenced +from it. Refinement comments on `#72` and `#108` point them at the interface above. Promote to Accepted +once the primitive lands and the first consumer (`#72`) is wired. From 61186226564530f09319986873a83ae669914bb8 Mon Sep 17 00:00:00 2001 From: hellno Date: Sat, 20 Jun 2026 21:03:31 +0200 Subject: [PATCH 2/2] =?UTF-8?q?ADR=200004:=20pivot=20to=20"evaluated,=20de?= =?UTF-8?q?ferred"=20=E2=80=94=20drop=20the=20anchor=20+=20prototype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex cross-model adjudication + the adversarial review agree the rollback anchor is mispriced for a testnet alpha whose threat model already concedes same-uid (incl. filesystem write). Vault rollback's entire worst case is reverting a passphrase/KDF rotation; the seed is constant and balances are on-chain; and the mechanism doesn't hold anyway (nothing advances the vault counter; sidecar is replayable; the anchor is same-uid-deletable/rewritable; only a TPM NV counter holds, and macOS has no equivalent). So: defer #71 with blockers B1-B3, build NO cross-trust-domain anchor, and decouple #72/#108 into independent local fixes (policy MAC + fail-closed; durable cap + reserve-before-sign) that never needed it. The "one shared mechanism for three issues" framing was the error; this supersedes ADR 0003 items #4-#6's sequencing. - Rewrite ADR 0004 as a plain Deferred record (no invented vocabulary). - Remove the unwired StateAnchor prototype, its feature flag, and lib wiring. - Reword THREAT-MODEL residual #7 to "accepted residual, deferred". Refs #71 #72 #108. Supersedes ADR 0003 (#105) keystone sequencing. --- THREAT-MODEL.md | 2 +- crates/deckard-signerd/Cargo.toml | 5 - crates/deckard-signerd/src/lib.rs | 5 - crates/deckard-signerd/src/state_anchor.rs | 523 ------------------ .../0004-rollback-resistant-state-anchor.md | 511 ++++------------- 5 files changed, 111 insertions(+), 935 deletions(-) delete mode 100644 crates/deckard-signerd/src/state_anchor.rs diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index 2247b09..0a2818e 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -267,7 +267,7 @@ broadcasts through a diverged endpoint, but it's worth attacking (red-team issue | 4 | Viewing-key compromise in the sidecar leaks shielded history (not funds) | Mitigated (Zeroizing, no-output discipline, scan-tested) | | 5 | Reason redaction is URL-shaped-token-based; a credential echoed in a non-URL form would pass | Mitigated for realistic transport-error shapes (tested); allowlist scan is the backstop | | 6 | Deterministic request-ids allow same-uid intent-collision games | Accepted within the uid boundary; salted ids on roadmap | -| 7 | **Rollback / replay of an older genuine `vault.bin` (or, later, policy/cap state)**: a same-uid attacker drops an older valid copy back over the current file, resurrecting old state the AEAD can't flag (it isn't a forgery) | **Open** (spike: [ADR 0004](docs/adr/0004-rollback-resistant-state-anchor.md), #71). The planned anchor is **resistance, not prevention**, and the bar moves *per configuration*: with an OS-keychain backend (macOS/Windows) replay must touch a second, hardware-backable trust domain (noisier, harder); **file-only (the default, and the only option on headless Linux — where `signerd` most often runs) the anchor is itself same-uid-deletable, so it detects non-adversarial loss but gives ~zero adversarial resistance** against full same-uid code execution that deletes every copy. Honestly for the weaker attacker (bad backup, sync glitch, sandboxed/limited process), not for arbitrary same-uid code | +| 7 | **Rollback / replay of an older genuine `vault.bin`**: a same-uid attacker drops an older valid copy back over the current file (it isn't a forgery, so the AEAD can't flag it) | **Accepted residual for alpha.** Evaluated and **deferred** ([ADR 0004](docs/adr/0004-rollback-resistant-state-anchor.md), #71): rollback needs filesystem write, which is same-uid and already inside this file's conceded boundary, and the vault's only rollback worst-case is reverting a passphrase/KDF rotation (the seed is constant, balances are on-chain). Revisit if the threat model rises (mainnet keys, multi-user, untrusted backup/sync) | If you can demonstrate an attack that crosses a boundary this file claims holds — that's a vulnerability. Please report it via [SECURITY.md](SECURITY.md). diff --git a/crates/deckard-signerd/Cargo.toml b/crates/deckard-signerd/Cargo.toml index e859884..7314500 100644 --- a/crates/deckard-signerd/Cargo.toml +++ b/crates/deckard-signerd/Cargo.toml @@ -34,11 +34,6 @@ shield = ["deckard-core/shield"] # the epic's "signerd has no CoW HTTP dependency" AC (reqwest itself is already in-tree via # alloy provider-http + helios + railgun, so a literal "0 reqwest" is impossible). cow-client = ["deckard-core/cow-client"] -# SPIKE artifact (issue #71, ADR 0004): an UNWIRED reference implementation of the keystone -# `StateAnchor` primitive (`state_anchor.rs`). DELIBERATELY NOT in `default` — enabling it compiles -# the module + its tests and changes NO production path (`unlock`/`propose`/`execute` are untouched). -# Pure safe Rust, zero new dependencies; the keychain backend (a new dep) is approval-gated per the ADR. -state-anchor = [] [dependencies] # The frozen wire contract (Intent / Decision / Policy / RPC + the shared `evaluate`). diff --git a/crates/deckard-signerd/src/lib.rs b/crates/deckard-signerd/src/lib.rs index 5852852..56b5f0b 100644 --- a/crates/deckard-signerd/src/lib.rs +++ b/crates/deckard-signerd/src/lib.rs @@ -28,11 +28,6 @@ pub mod request_id; pub mod server; pub mod signing; pub mod socket; -/// SPIKE reference implementation of the keystone `StateAnchor` primitive (issue #71, ADR 0004). -/// Gated behind the off-by-default `state-anchor` feature and wired into NO production path — see -/// the module docs. Present so `#72`/`#108` can build on a concrete interface. -#[cfg(feature = "state-anchor")] -pub mod state_anchor; pub mod supervise; pub use client::SignerClient; diff --git a/crates/deckard-signerd/src/state_anchor.rs b/crates/deckard-signerd/src/state_anchor.rs deleted file mode 100644 index fe0f1b1..0000000 --- a/crates/deckard-signerd/src/state_anchor.rs +++ /dev/null @@ -1,523 +0,0 @@ -//! Reference implementation of the **keystone primitive** from -//! [`docs/adr/0004-rollback-resistant-state-anchor.md`](../../../docs/adr/0004-rollback-resistant-state-anchor.md). -//! -//! This is a SPIKE artifact: a small, **unwired** reference impl that makes the -//! `StateAnchor` interface concrete so `#72` (authenticated policy) and `#108` (durable cap) -//! can build on a real type instead of a sketch. It is gated behind the off-by-default -//! `state-anchor` feature and is wired into NO production path — `unlock`, `propose`, and -//! `execute` are untouched. Enabling the feature changes no behavior; it only compiles this -//! module and its tests. -//! -//! ## What it models (and what it deliberately does not) -//! -//! The anchor enforces two things the keystone needs and nothing more: -//! - **Monotonicity** — `advance` persists a namespace's record only if the new version is -//! strictly greater than the stored one (a compare-and-advance), so a stale/equal write fails -//! closed. -//! - **Durability + single-writer** — the file backend reuses the exact temp→fsync→rename→dir-sync -//! recipe `Vault::write_atomic` already uses (`keystore.rs`), and the daemon is the sole writer -//! (its lifetime `flock` + per-request mutex serialize every mutation). -//! -//! Integrity of a record's *payload* is the **consumer's** job, layered on top (e.g. `#72` MACs -//! `policy.json`; `#108` binds cap accounting to chain+account+policy-version+UTC-day). The anchor -//! stores opaque, monotonically-versioned bytes. And per the ADR's honest residual: the on-disk -//! backend is itself same-uid-deletable, so it raises the bar against the *weaker* attacker (a bad -//! backup, a sync glitch, a sandboxed process) and detects non-adversarial loss — it is not -//! tamper-proof against full same-uid code execution. A keychain backend (a new dependency, see the -//! ADR) is the bar-raising upgrade behind the same trait; it is not built here. - -use std::path::PathBuf; - -/// The artifacts that share the one anchor record, each with its own monotonic version so a vault -/// re-seal, a policy edit, and a cap-window roll advance independently (ADR 0004, Q5). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Namespace { - /// `#71` — the vault epoch. (No legitimate bumper exists in the shipping keystore yet; see the - /// ADR. Present so the interface is complete, not because the vault detector is live today.) - Vault, - /// `#72` — the authenticated policy version, bumped on each authorized policy edit. - Policy, - /// `#108` — the durable daily-cap generation fence. - Cap, -} - -impl Namespace { - fn id(self) -> u8 { - match self { - Namespace::Vault => 0, - Namespace::Policy => 1, - Namespace::Cap => 2, - } - } - fn from_id(id: u8) -> anyhow::Result { - Ok(match id { - 0 => Namespace::Vault, - 1 => Namespace::Policy, - 2 => Namespace::Cap, - _ => anyhow::bail!("unknown anchor namespace id"), - }) - } -} - -/// One namespace's anchored value: a monotonic `version` plus an opaque, consumer-authenticated -/// `payload` (e.g. the binding `chain+account+policy_version+UTC-day` for the cap). The anchor -/// never interprets the payload; it only guarantees the version advances monotonically. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct AnchorRecord { - pub version: u64, - pub payload: Vec, -} - -impl AnchorRecord { - pub fn new(version: u64, payload: Vec) -> Self { - Self { version, payload } - } -} - -/// The three-valued read the ADR (Q1) requires so an unreadable anchor never bricks unlock and -/// never silently disables rollback detection. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum AnchorRead { - /// An authenticated, current record for the namespace. - Present(AnchorRecord), - /// First run, or a wiped domain. Indistinguishable from "an attacker deleted it" by - /// construction — the irreducible same-uid residual (ADR 0004, Q4). - Absent, - /// The backend is reachable-in-principle but cannot answer right now (e.g. a locked or denied - /// keychain). The caller proceeds on the remaining domains with a surfaced warning. The file - /// backend never returns this — a missing file is `Absent`, a corrupt file is a hard `Err`. - Degraded(String), -} - -/// The verdict of comparing an artifact's on-disk version against the anchor, encoding the Q3 -/// restore-from-backup decision table as a pure, testable function (see [`classify`]). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum AnchorVerdict { - /// Anchor absent for this artifact (new machine / fresh account / wiped anchor): adopt the - /// file's version after a successful unlock. - Bootstrap, - /// `file == anchor`: normal. - Normal, - /// `file > anchor`: a restore-forward or a legitimate advance; adopt up to the file. - AdoptForward, - /// `file < anchor`: rollback suspected. Gate behind a human, Control-channel confirm. - RollbackSuspected { file: u64, anchor: u64 }, -} - -/// Apply the Q3 decision rule. `file_version` is read from the artifact only *after* its own -/// authentication has passed (the vault's AEAD, or a consumer's MAC); `anchor` is the -/// [`StateAnchor::read`] result for the same namespace. -pub fn classify(file_version: u64, anchor: &AnchorRead) -> AnchorVerdict { - match anchor { - AnchorRead::Absent | AnchorRead::Degraded(_) => AnchorVerdict::Bootstrap, - AnchorRead::Present(rec) => { - if file_version > rec.version { - AnchorVerdict::AdoptForward - } else if file_version == rec.version { - AnchorVerdict::Normal - } else { - AnchorVerdict::RollbackSuspected { - file: file_version, - anchor: rec.version, - } - } - } - } -} - -/// A monotonic, rollback-resistant security-state store. Implemented here by a file backend -/// (zero new dependencies); a keychain backend (a new dependency, ADR-approval-gated) would -/// satisfy the same trait. `signerd` is the only writer. -pub trait StateAnchor { - /// The current value for `ns`, or the absent/degraded signal. - fn read(&self, ns: Namespace) -> anyhow::Result; - - /// Monotonic compare-and-advance: persist `next` **only if** its version is strictly greater - /// than the stored version for `ns`, *and* the stored version equals `expected` (a CAS guard - /// against a concurrent or torn advance). Returns the committed record. Fails closed on a - /// stale/equal version, an `expected` mismatch, or a failed durability step — never silently - /// regresses. - fn advance( - &mut self, - ns: Namespace, - expected: u64, - next: AnchorRecord, - ) -> anyhow::Result; -} - -// --- File backend --- - -const MAGIC: &[u8; 4] = b"DKAN"; // "DecKard ANchor" — distinct from the vault's b"DKRD" -const FORMAT_VERSION: u8 = 1; -/// Caps applied before allocating, so a hostile anchor file can't OOM us (mirrors `keystore.rs`). -const MAX_ENTRIES: u32 = 16; -const MAX_PAYLOAD_LEN: u32 = 256; -const MAX_ANCHOR_BYTES: u64 = 8 * 1024; - -/// A file-backed [`StateAnchor`]: one file holds the whole namespaced record, written with the -/// atomic temp→fsync→rename→dir-sync discipline so a crash never leaves a torn anchor. -/// -/// NOTE on the path: a real wiring resolves this through the same `DECKARD_CONFIG_DIR`-aware -/// resolver as the vault/policy (or a `DECKARD_ANCHOR_DIR` override), **not** raw -/// `directories::data_dir()` — otherwise the throwaway `just qa`/`just demo` vaults and the real -/// vault share one anchor namespace, and on macOS `data_dir == config_dir` anyway (ADR 0004, Q5). -/// The reference impl takes an explicit path to keep that policy out of the primitive. -pub struct FileAnchor { - path: PathBuf, -} - -impl FileAnchor { - pub fn at(path: impl Into) -> Self { - Self { path: path.into() } - } - - /// Load the whole record set. A missing file is an empty set (every namespace `Absent`); a - /// present-but-corrupt file is a hard error (fail closed — the caller must not proceed on a - /// half-trusted anchor). - fn load(&self) -> anyhow::Result> { - let meta = match std::fs::metadata(&self.path) { - Ok(m) => m, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(e.into()), - }; - anyhow::ensure!( - meta.len() <= MAX_ANCHOR_BYTES, - "anchor file is implausibly large" - ); - let bytes = std::fs::read(&self.path)?; - Self::parse(&bytes) - } - - /// Parse the on-disk format through a bounded reader (no raw indexing; every length capped - /// before allocation), mirroring the keystore's untrusted-bytes discipline. - fn parse(bytes: &[u8]) -> anyhow::Result> { - let mut r = Reader::new(bytes); - anyhow::ensure!(r.take(4)? == MAGIC, "not a Deckard anchor file"); - anyhow::ensure!(r.u8()? == FORMAT_VERSION, "unsupported anchor version"); - let count = r.u32()?; - anyhow::ensure!(count <= MAX_ENTRIES, "too many anchor entries"); - let mut out: Vec<(Namespace, AnchorRecord)> = Vec::new(); - for _ in 0..count { - let ns = Namespace::from_id(r.u8()?)?; - let version = r.u64()?; - let plen = r.u32()?; - anyhow::ensure!(plen <= MAX_PAYLOAD_LEN, "anchor payload too large"); - let payload = r.take(plen as usize)?.to_vec(); - anyhow::ensure!( - !out.iter().any(|(seen, _)| *seen == ns), - "duplicate anchor namespace" - ); - out.push((ns, AnchorRecord::new(version, payload))); - } - r.finish()?; - Ok(out) - } - - fn serialize(records: &[(Namespace, AnchorRecord)]) -> Vec { - let mut b = Vec::with_capacity(64); - b.extend_from_slice(MAGIC); - b.push(FORMAT_VERSION); - b.extend_from_slice(&(records.len() as u32).to_le_bytes()); - for (ns, rec) in records { - b.push(ns.id()); - b.extend_from_slice(&rec.version.to_le_bytes()); - b.extend_from_slice(&(rec.payload.len() as u32).to_le_bytes()); - b.extend_from_slice(&rec.payload); - } - b - } - - /// Atomic write: temp file at `0600`, `write_all`, `sync_all`, `rename` over the target, then - /// `fsync` the parent dir — the same recipe as `Vault::write_atomic` (`keystore.rs`), so a - /// crash or power loss never leaves a partially written anchor. - fn write_atomic(&self, records: &[(Namespace, AnchorRecord)]) -> anyhow::Result<()> { - use std::io::Write; - if let Some(dir) = self.path.parent() { - std::fs::create_dir_all(dir)?; - } - let tmp = self.path.with_extension("tmp"); - { - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).create(true).truncate(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o600); - } - let mut f = opts.open(&tmp)?; - f.write_all(&Self::serialize(records))?; - f.sync_all()?; - } - std::fs::rename(&tmp, &self.path)?; - if let Some(dir) = self.path.parent() { - if let Ok(dirf) = std::fs::File::open(dir) { - let _ = dirf.sync_all(); - } - } - Ok(()) - } -} - -impl StateAnchor for FileAnchor { - fn read(&self, ns: Namespace) -> anyhow::Result { - let records = self.load()?; - Ok(match records.into_iter().find(|(n, _)| *n == ns) { - Some((_, rec)) => AnchorRead::Present(rec), - None => AnchorRead::Absent, - }) - } - - fn advance( - &mut self, - ns: Namespace, - expected: u64, - next: AnchorRecord, - ) -> anyhow::Result { - let mut records = self.load()?; - let current = records - .iter() - .find(|(n, _)| *n == ns) - .map(|(_, r)| r.version); - match current { - Some(v) => { - anyhow::ensure!( - v == expected, - "anchor advance conflict: expected version {expected}, found {v}" - ); - anyhow::ensure!( - next.version > v, - "anchor advance must be monotonic: {} is not greater than {v}", - next.version - ); - } - None => { - // Bootstrap: the caller must claim it expected no prior entry. - anyhow::ensure!( - expected == 0, - "anchor bootstrap expects version 0, got {expected}" - ); - } - } - match records.iter_mut().find(|(n, _)| *n == ns) { - Some((_, slot)) => *slot = next.clone(), - None => records.push((ns, next.clone())), - } - self.write_atomic(&records)?; - Ok(next) - } -} - -/// A tiny bounds-checked reader for the anchor format (the keystore's `Reader` is private to that -/// module; this mirrors it so untrusted anchor bytes are parsed with the same discipline). -struct Reader<'a> { - buf: &'a [u8], - pos: usize, -} -impl<'a> Reader<'a> { - fn new(buf: &'a [u8]) -> Self { - Self { buf, pos: 0 } - } - fn take(&mut self, n: usize) -> anyhow::Result<&'a [u8]> { - let end = self - .pos - .checked_add(n) - .filter(|e| *e <= self.buf.len()) - .ok_or_else(|| anyhow::anyhow!("anchor truncated"))?; - let s = self - .buf - .get(self.pos..end) - .ok_or_else(|| anyhow::anyhow!("anchor truncated"))?; - self.pos = end; - Ok(s) - } - fn u8(&mut self) -> anyhow::Result { - self.take(1)? - .first() - .copied() - .ok_or_else(|| anyhow::anyhow!("anchor truncated")) - } - fn u32(&mut self) -> anyhow::Result { - let b: [u8; 4] = self - .take(4)? - .try_into() - .map_err(|_| anyhow::anyhow!("anchor truncated"))?; - Ok(u32::from_le_bytes(b)) - } - fn u64(&mut self) -> anyhow::Result { - let b: [u8; 8] = self - .take(8)? - .try_into() - .map_err(|_| anyhow::anyhow!("anchor truncated"))?; - Ok(u64::from_le_bytes(b)) - } - fn finish(&self) -> anyhow::Result<()> { - anyhow::ensure!(self.pos == self.buf.len(), "trailing bytes after anchor"); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn temp_path(tag: &str) -> PathBuf { - // A unique-enough path per test; OsRng would be overkill for a temp file name. - std::env::temp_dir().join(format!( - "deckard-anchor-test-{tag}-{}.bin", - std::process::id() - )) - } - - #[test] - fn bootstrap_then_monotonic_advance() { - let path = temp_path("mono"); - let _ = std::fs::remove_file(&path); - let mut a = FileAnchor::at(&path); - - // Fresh: every namespace reads Absent. - assert_eq!(a.read(Namespace::Policy).unwrap(), AnchorRead::Absent); - - // Bootstrap at 1, then advance 1->2->3. - a.advance(Namespace::Policy, 0, AnchorRecord::new(1, vec![])) - .unwrap(); - a.advance(Namespace::Policy, 1, AnchorRecord::new(2, b"v2".to_vec())) - .unwrap(); - a.advance(Namespace::Policy, 2, AnchorRecord::new(3, b"v3".to_vec())) - .unwrap(); - - match a.read(Namespace::Policy).unwrap() { - AnchorRead::Present(rec) => { - assert_eq!(rec.version, 3); - assert_eq!(rec.payload, b"v3"); - } - other => panic!("expected Present, got {other:?}"), - } - let _ = std::fs::remove_file(&path); - } - - #[test] - fn stale_or_equal_advance_fails_closed() { - let path = temp_path("stale"); - let _ = std::fs::remove_file(&path); - let mut a = FileAnchor::at(&path); - a.advance(Namespace::Cap, 0, AnchorRecord::new(5, vec![])) - .unwrap(); - - // Equal version is not strictly greater -> rejected. - assert!(a - .advance(Namespace::Cap, 5, AnchorRecord::new(5, vec![])) - .is_err()); - // A lower version (an attempted rollback write) -> rejected. - assert!(a - .advance(Namespace::Cap, 5, AnchorRecord::new(4, vec![])) - .is_err()); - // A stale `expected` (CAS conflict) -> rejected even though 9 > 5. - assert!(a - .advance(Namespace::Cap, 4, AnchorRecord::new(9, vec![])) - .is_err()); - - // The stored value is unchanged after every rejected write (no silent regression). - assert_eq!( - a.read(Namespace::Cap).unwrap(), - AnchorRead::Present(AnchorRecord::new(5, vec![])) - ); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn namespaces_advance_independently() { - let path = temp_path("ns"); - let _ = std::fs::remove_file(&path); - let mut a = FileAnchor::at(&path); - a.advance(Namespace::Vault, 0, AnchorRecord::new(1, vec![])) - .unwrap(); - a.advance(Namespace::Policy, 0, AnchorRecord::new(7, vec![])) - .unwrap(); - a.advance(Namespace::Cap, 0, AnchorRecord::new(42, vec![])) - .unwrap(); - // Advancing one leaves the others untouched. - a.advance(Namespace::Policy, 7, AnchorRecord::new(8, vec![])) - .unwrap(); - assert_eq!( - a.read(Namespace::Vault).unwrap(), - AnchorRead::Present(AnchorRecord::new(1, vec![])) - ); - assert_eq!( - a.read(Namespace::Cap).unwrap(), - AnchorRead::Present(AnchorRecord::new(42, vec![])) - ); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn persists_across_reopen() { - let path = temp_path("reopen"); - let _ = std::fs::remove_file(&path); - { - let mut a = FileAnchor::at(&path); - a.advance( - Namespace::Policy, - 0, - AnchorRecord::new(3, b"state".to_vec()), - ) - .unwrap(); - } - // A fresh handle (a daemon restart) sees the durably-written record. - let b = FileAnchor::at(&path); - assert_eq!( - b.read(Namespace::Policy).unwrap(), - AnchorRead::Present(AnchorRecord::new(3, b"state".to_vec())) - ); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn corrupt_file_fails_closed_not_absent() { - let path = temp_path("corrupt"); - std::fs::write(&path, b"not a deckard anchor at all").unwrap(); - let a = FileAnchor::at(&path); - // A corrupt anchor must be a hard error (caller fail-closes), never silently treated as - // Absent (which would route into bootstrap and accept whatever the file claims). - assert!(a.read(Namespace::Policy).is_err()); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn round_trips_through_bounded_reader() { - let records = vec![ - (Namespace::Vault, AnchorRecord::new(1, vec![])), - (Namespace::Policy, AnchorRecord::new(9, b"abc".to_vec())), - (Namespace::Cap, AnchorRecord::new(u64::MAX, vec![0xFF; 16])), - ]; - let bytes = FileAnchor::serialize(&records); - let parsed = FileAnchor::parse(&bytes).unwrap(); - assert_eq!(parsed, records); - // Trailing garbage is rejected. - let mut extra = bytes.clone(); - extra.push(0); - assert!(FileAnchor::parse(&extra).is_err()); - // A truncated buffer is rejected, not silently short-read. - assert!(FileAnchor::parse(&bytes[..bytes.len() - 1]).is_err()); - } - - #[test] - fn classify_encodes_the_restore_decision_table() { - // Absent -> bootstrap (new machine / wiped anchor). - assert_eq!(classify(5, &AnchorRead::Absent), AnchorVerdict::Bootstrap); - // Degraded -> bootstrap (proceed; the keychain is unreachable). - assert_eq!( - classify(5, &AnchorRead::Degraded("locked".into())), - AnchorVerdict::Bootstrap - ); - let anchor = AnchorRead::Present(AnchorRecord::new(7, vec![])); - // file == anchor -> normal. - assert_eq!(classify(7, &anchor), AnchorVerdict::Normal); - // file > anchor -> adopt forward (restore-forward / legitimate advance). - assert_eq!(classify(8, &anchor), AnchorVerdict::AdoptForward); - // file < anchor -> rollback suspected (the only branch the human Control-gate guards). - assert_eq!( - classify(6, &anchor), - AnchorVerdict::RollbackSuspected { file: 6, anchor: 7 } - ); - } -} diff --git a/docs/adr/0004-rollback-resistant-state-anchor.md b/docs/adr/0004-rollback-resistant-state-anchor.md index e2df2dd..84c422b 100644 --- a/docs/adr/0004-rollback-resistant-state-anchor.md +++ b/docs/adr/0004-rollback-resistant-state-anchor.md @@ -1,414 +1,123 @@ -# ADR 0004 — Rollback-resistant security-state anchor (the keystone) +# ADR 0004 — Vault rollback resistance: evaluated, deferred -- **Status:** Proposed (2026-06-20). Records the design for issue `#71` and the primitive that - `#72` (authenticated policy) and `#108` (durable cap) build on. Decisions only; executable work - stays in GitHub issues. +- **Status:** **Deferred** (2026-06-20). Records the result of the `#71` spike: we evaluated + rollback resistance for `vault.bin` and decided **not to build it now**, with concrete revisit + conditions. It also corrects the framing that bundled `#71`, `#72`, and `#108` together. - **Deciders:** @hellno (maintainer) -- **Method:** source-grounded review (every load-bearing fact below is cited `file:line` and was - grepped on a fresh worktree off `origin/main` post-`#105`), an **empirical** dependency-cost - measurement (`cargo tree` on this macOS host, diffed against the 1145-crate workspace lock), and a - fan-out research + **adversarial verification** pass (four research strands, then independent - skeptics tasked to refute the consolidated design; 16 of their attacks landed and reshaped the - conclusion below). A planned codex cross-model pass did not run (session limit); it is the one gap - in the method and is noted as a follow-up. -- **Context inputs:** [`ADR 0003`](0003-crate-trust-boundary.md) (the keystone framing, items #4–#7), - `THREAT-MODEL.md`, `SECURITY.md`, `crates/deckard-core/src/keystore.rs`, - `crates/deckard-core/src/config.rs`, `crates/deckard-signerd/src/{daemon,config,policy_store}.rs`, - the dependent issues [`#71`](https://github.com/hellno/deckard/issues/71), +- **Method:** source-grounded review (cited `file:line`), an empirical dependency-cost measurement + (`cargo tree` on macOS, diffed against the workspace lock), a fan-out adversarial review (16 + attacks landed), and an independent cross-model adjudication (Codex). All four pointed the same way. +- **Context inputs:** [`ADR 0003`](0003-crate-trust-boundary.md), `THREAT-MODEL.md`, `SECURITY.md`, + `crates/deckard-core/src/keystore.rs`, `crates/deckard-signerd/src/{daemon,policy_store}.rs`, + issues [`#71`](https://github.com/hellno/deckard/issues/71), [`#72`](https://github.com/hellno/deckard/issues/72), [`#108`](https://github.com/hellno/deckard/issues/108). ## The question -`vault.bin` is AEAD-encrypted, so a same-uid attacker with filesystem write can't *forge* a new valid -vault. But they can *roll it back*: drop an older, genuine copy of the user's own vault over the -current one (malware running as you, a careless restore, a sync conflict). The stale vault opens -cleanly under the right passphrase and resurrects old state. Nothing detects this today (zero -OS-keychain use in `deckard-core` / `deckard-signerd`). - -ADR 0003 elevated this from a vault-only fix to the **keystone**: the same mechanism a vault needs to -detect rollback (a monotonic counter bumped on every authoritative write, mirrored to a reference in a -different trust domain) is what authenticated policy (`#72`) and a durable daily cap (`#108`) also -need. So this ADR answers `#71`'s five spike questions and designs the shared primitive those two -consumers stand on. - -## The headline, stated before the details - -The adversarial pass changed the answer. Three things are true and must be said plainly: - -1. **The anchor crate is cheap, and the recommendation is the OS keychain on macOS/Windows plus a - file fallback everywhere — but it is a new dependency that needs explicit approval, and on Linux it - is mostly absent where it is most needed.** We measured the cost; it is small. We do **not** add it - in this PR. - -2. **The two correctness details from the issue are real and we have the exact mechanism for both.** - Binding the epoch into the AEAD associated data is the *only* way the file itself carries a - trustworthy epoch, and it is correct. Checking the anchor only after the passphrase verifies is a - one-line ordering decision at the unlock seam. - -3. **The standalone "anchor the vault" feature, taken literally, is close to theater on today's - codebase, and the honest residual is starker than "raises the bar."** Nothing legitimately - advances a vault's epoch (every seal mints a fresh identity; there is no re-seal path). A - plaintext sidecar epoch is rolled back together with the vault and verifies fine. The anchor itself - is same-uid-deletable, and deleting it routes straight into the "new machine, bootstrap" path with - no challenge. So the keystone earns its keep first on **policy and cap** (artifacts that *do* have - legitimate monotonic bumps), not on the vault. This ADR is conclusive, and the conclusion is to - **build the generalizable primitive, wire it to `#72`/`#108` first, and defer the vault-epoch - binding to a v2-format effort that also builds the missing re-seal path.** - -The rest of this document is the evidence for those three claims, mapped onto the five spike questions. - ---- - -## Q1 — Anchor crate and the real new-dependency cost - -**Decision: recommend `keyring` (pinned, `default-features = false`, per-OS native backend) as the -durable trust-domain reference on macOS and Windows; ship file-only on Linux; and ship a -dependency-free file backend as the always-present baseline on every OS. This is a recommendation that -needs explicit maintainer sign-off against the no-new-deps DoD bar. It is not added in this PR.** - -### What we measured (this macOS host, diffed against `Cargo.lock`) - -| Config | Activated tree | **Crates not already in the workspace lock** | -|---|---|---| -| `keyring` 3.6.3, `apple-native` (macOS) | 8 | **1** — `keyring` itself | -| `keyring` 3.6.3, `sync-secret-service` + `vendored` + `crypto-rust` (Linux) | 38 | **4** — `keyring`, `dbus`, `dbus-secret-service`, `libdbus-sys` (a **C** library) | - -On macOS the cost is one Rust crate. `security-framework` (which is also the Touch ID hook the issue -flagged for a later phase), `core-foundation`, `bitflags`, `libc`, and `log` are already in the lock -via alloy/helios, so `apple-native` reuses them. `keyring` is MIT/Apache (compatible with our -AGPL-3.0), MSRV 1.75, and exposes a process-wide **mock** credential store usable as the test backend -and the "no keychain present" shim. - -On Linux the cost is qualitatively larger than four crates suggests: - -- The D-Bus Secret Service backend pulls a **C library** (`libdbus-sys`) and, at runtime, needs a - live session bus **and** an unlocked keyring daemon (gnome-keyring / KWallet). A headless server - has neither, and `signerd` is exactly the kind of process that runs headless. The call fails to - find an item rather than returning a stable anchor. -- The kernel `keyutils` backend (`linux-native`) avoids D-Bus entirely but is non-persistent across - reboot by default. An anchor must survive reboot, so `keyutils` is unsuitable as the primary store. - -So on Linux the honest answer is **file-only**, with the keychain as a best-effort extra only where a -provider happens to be present. - -### What we ask the maintainer to approve (and what we do not) - -The real ask is small: **`keyring` on macOS and Windows only.** Pin it (`= 3.6.3` or a tilde range, -not the caret `"3"`, which is an open range whose transitive closure can drift and which the DoD bar -would not actually freeze), and re-measure the closure on the unified workspace before adding it. -Per ADR 0003 item #8, confirm via `cargo tree` on the real workspace that `keyring`/`libdbus` stay -**out of `deckard-app`'s** feature closure (Cargo feature unification can silently re-add a backend); -the anchor and its FFI belong to `signerd`, the single key-holder, never to `deckard-core` -(`#![forbid(unsafe_code)]`, and linked by every key-less binary). `keyring` 4.1.1 exists and is a -major bump (the `keyring-core` + store split); evaluate it separately, do not adopt blind. - -Rejected alternatives: raw `SecItem` / `libsecret` / `wincred` FFI (more `unsafe` to own and audit for -a control that is not load-bearing against same-uid); `keyring-core` + a store (premature at our -scale). The file backend ships with **zero new dependencies** and is the baseline; the keychain is the -bar-raising upgrade layered behind the same trait, not a prerequisite. - -### Degraded mode is mandatory, and it is the honest core of the feature - -An anchor that cannot be read must never brick unlock and must never silently disable rollback -detection. The read path is three-valued: `Present` (an authenticated record), `Absent` (first run, or -a wiped domain), `Degraded` (the backend is unreachable, e.g. a locked or denied keychain). A detected -**regression** (a present domain reporting a lower version than expected) fails closed; a merely -**absent** domain degrades to the remaining domains with a surfaced warning, in the spirit of the -`⚠ POLICY FALLBACK` line `policy_store.rs` already prints. The catch, which Q4 makes precise, is that -`Absent` and "an attacker deleted it" are indistinguishable by construction. - ---- - -## Q2 — The two correctness details at the keystore seam - -### (a) Bind the epoch into the AEAD associated data - -`keystore.rs` already authenticates the entire header through `Header::core_bytes()` (`keystore.rs:146`), -which both AEAD layers consume: `wrap_aad = [AAD_WRAP, &core]` (`keystore.rs:262`) and -`payload_aad = [AAD_PAYLOAD, &core, &wrapped_dek]` (`keystore.rs:269`). Anything inside `core` is -covered by **both** Poly1305 tags. So adding a `u64` epoch field to `Header` and emitting it in -`core_bytes()` makes it un-editable without the KEK: - -> An attacker copies an old blob (epoch 3) over the current file (epoch 7), then edits the plaintext -> epoch bytes 3→7 to satisfy the anchor. The parsed `core` now says 7, but the stored tag was computed -> over 3, so `aead_decrypt` of the wrapped DEK returns `Err` and unlock fails closed — identical in -> mechanism to the existing `m_kib` tamper case in `tamper_each_region_fails_closed` -> (`keystore.rs:822-835`). The epoch becomes editable only by someone who holds the passphrase. - -This is the **only** construction that lets the *file itself* carry a trustworthy epoch. A plaintext -sidecar epoch, even one MAC'd by a vault-derived key, does **not** achieve it: a same-uid attacker -rolls the sidecar back alongside the vault, both records verify against their own (older, genuine) -tags, and no forgery is needed (adversarial finding, critical). With a sidecar, *all* rollback -resistance reduces to the external anchor's high-water mark, which is same-uid-deletable. This is the -fork in the design, and the ADR resolves it explicitly below. - -**Decision: AAD-binding is the correct mechanism, and it requires a format migration we do NOT land in -this spike.** `FORMAT_VERSION` is the first authenticated byte in `core_bytes()` (`keystore.rs:149`), -and the three frozen KAT fixtures (`decode_compat_v1_fixtures`, `keystore.rs:788`) are exact byte -blobs whose tags were computed over a 101-byte core with no epoch field. Inserting the field bumps the -version to 2, shifts every later offset, and breaks the fixtures (`keystore.rs:789` calls this a -lost-funds-class break). The deliverable here is an ADR, not a format migration. We therefore: - -- specify the v2 layout (epoch as a `u64` LE field in `core_bytes()`, a `Reader::u64()` helper - mirroring the existing `u32()` at `keystore.rs:639`, version dispatch in `from_bytes`), -- record that a real v2 reads v1 vaults as **epoch 0 implicit**, reconstructing the exact v1 `core` so - the frozen tags still verify, and -- **forbid format-downgrade re-anchoring** in that future v2 work: once a `vault_id` has a v2 anchor - entry ≥ 1, presenting the original v1 (epoch 0) blob is a rollback, never a benign bootstrap (an - adversarial finding: otherwise a human who clicks through the restore prompt re-anchors down to 0 and - permanently disarms detection). - -The spike proves the binding with an isolated test on a v2-shaped header; the production binding is a -separate, in-scope-later format break tracked on `#71`. - -### (b) Check the anchor only after the passphrase verifies - -The compare lands in `signerd`, in `daemon.rs unlock()` (the success arm after the `spawn_blocking` -unlock returns `Ok(Ok(unlocked))` at `daemon.rs:421`), **not** inside core's `Vault::unlock`. Core -stays format-only, `#![forbid(unsafe_code)]`, and dependency-free; the out-of-file anchor is a -platform concern for the single key-holder. - -Because the compare runs only on the branch where the AEAD already proved the passphrase, it adds no -oracle to an **unauthenticated** caller: a wrong passphrase still collapses to `BadPassphrase` through -the keystore's one-generic-message contract (`keystore.rs:419-436`), unchanged. Two qualifications the -adversarial pass forced, which the implementation and any copy must respect: - -- **The reader for the file epoch must run after AEAD success, never as a pre-check.** A pre-check that - touched a missing or garbage epoch source before the passphrase is verified would re-introduce a - wallet-presence oracle. -- **`Unlock` is served on the public proposer socket** (only `Resolve` is `Channel::Control`-gated, - `daemon.rs:320`). So a distinct "rolled back" outcome, if we add one, is visible to any same-uid - proposer that already knows the passphrase. That is acceptable inside the uid boundary (such a caller - has already unlocked) but it is **not** resolver-only, so the claim is "no oracle to an - unauthenticated caller," not "no oracle." (A pre-existing presence oracle also remains: `unlock()` - returns `NoVault` at `daemon.rs:409` before any passphrase check. The anchor work does not add to it - and does not fix it.) - -### Key the anchor on a re-seal-stable identity, not the per-seal `vault_id` - -`seal()` mints a fresh random `vault_id` on every seal (`keystore.rs:242`). Keying the anchor on -`vault_id` means a genuine older backup of the *same seed* carries a *different* `vault_id`, lands in -the "absent → bootstrap" branch, and is silently accepted (adversarial finding). Key instead on a -domain-separated commitment to the **stable primary address** -(`HMAC(domain_key, primary_address)`), which survives re-seals; the seed never leaves core. This also -exposes the next finding: with `vault_id` keying, the vault epoch is effectively write-once. - ---- - -## Q3 — The legitimate restore-from-backup accept path - -The decision rule, keyed on the stable identity and run only after AEAD success: - -| Observed | Meaning | Action | -|---|---|---| -| anchor file absent, or identity absent from it | new machine / fresh account / wiped anchor | **bootstrap**: adopt the file's value after a successful unlock | -| `file > anchor` | restore-forward, or a legitimate advance | **adopt** up to `file` | -| `file == anchor` | normal | proceed | -| `file < anchor` (identity present) | **rollback suspected** | **gate**: a one-time, human-confirmed "this vault is older than this machine last saw — restore anyway?" | - -The confirm rides the existing `Channel::Control` resolver capability (the same socketpair fd that -authorizes `Resolve`, `daemon.rs:312-327`), so an injected agent on the public socket cannot -auto-confirm. On confirm we re-anchor down to the file's value, so the restored backup becomes the new -baseline and the next unlock is normal. - -**Default posture for the testnet-only alpha: fail-open-with-confirm, not fail-closed-refuse.** The -dominant real-world event is a benign restore or sync glitch, not an attacker; refusing would brick -legitimate restores and teach users to disable the check. The posture is a documented dial: when -Deckard moves toward real funds, the same machinery flips to fail-closed (refuse unless a Control -confirm is present) without redesign. Anchors are **machine-local and never synced**; syncing one -would let a rollback on one device authorize itself on another. A multi-device user who restores an -older backup sees one confirm per device (and, with actively-synced state, possibly one confirm per -out-of-order sync event, which the UX must expect rather than treat as a bug). - -### Two hard problems the rule alone does not solve, decided here rather than deferred - -**The confirm gate is a safety feature for benign restores, not a security control.** The dominant -attacker move is not to downgrade past a surviving anchor (the only branch the gate guards). It is to -**delete the anchor** (same-uid filesystem write is in scope) and present the old vault, which routes -to "absent → bootstrap" and is accepted silently with no challenge. Deletion is indistinguishable from -a new machine by construction. The keychain copy, where present, is the only thing that makes deletion -noisier than overwriting the vault; on file-only Linux there is no such thing. We state this in the -copy and rank it as the feature's #1 residual, rather than describing the gate as making rollback -unforgeable. - -**The torn-write order must be a recoverable journal, not a brick.** The anchor and the file are -separate stores, so a bump can never be one atomic transaction. Writing the anchor first is fail-closed -but bricks the wallet on any benign crash between the two writes (anchor at N, file still at N-1, read -as a rollback of a vault that was never rolled back). And the obvious un-brick ("if `file == anchor-1` -and the tags verify, auto-repair") is itself a one-step-rollback laundering primitive, because a -crash and a deliberate one-epoch rollback are indistinguishable at that point. The decision: write a -small **intent record** `{identity, old, new}` to the anchor domain, then the file, then clear the -intent. On boot, a pending intent whose `new == anchor` and `file == old` is a *provable* torn write -(advance and clear); `file < old` is a rollback (gate). This removes the ambiguity instead of guessing. -For the alpha, the simpler fallback is acceptable: treat `file == anchor` as the only steady state and -require an explicit Control-channel repair for anything else, paying the UX cost honestly. - ---- - -## Q4 — The honest residual - -`THREAT-MODEL.md`'s boundary is the uid, including filesystem write. The anchor lives inside that -boundary, so it is **resistance, not prevention**, and the honesty has to be stated *per configuration* -because the bar moves by a very different amount in each: - -- **Keychain present (macOS / Windows, interactive session):** meaningfully noisier. To replay an old - state the attacker must delete or rewrite a Keychain / Credential Manager item in a separate trust - domain, not just overwrite a file. This is the configuration that earns the "raises the bar" claim, - and it is the path to a future hardware-backed anchor (Secure Enclave / TPM). -- **File-only (the dependency-free default, and the *only* option on headless Linux, which the - dep-cost analysis shows is exactly where `signerd` most often runs):** marginal. The anchor and any - fast counter file are plain same-uid files. A full same-uid code-execution attacker deletes both and - drops to the bootstrap path. The bar moves from "silently edit one number" to "delete two files and - trigger a fresh-machine bootstrap." That is real against the **weaker** attacker the feature is - honestly for (a bad backup, a sync conflict, a careless restore, a sandboxed or limited process that - can read but not freely delete), and it is **zero** against full same-uid code execution. - -So the precise claim is: the anchor detects and raises the cost of **replay of an older genuine state** -by the weaker attacker, and on keychain-backed platforms it forces that replay into a second, -hardware-backable trust domain. It does **not** stop a same-uid attacker who can delete every anchor -copy, and on file-only platforms that reduces to non-adversarial protection. A residual row is added to -`THREAT-MODEL.md` so the headless `signerd` case is never silently credited with the keychain-grade -increment it does not get. - ---- - -## Q5 — Generalization: the keystone primitive for `#72` and `#108` - -**Decision: one `StateAnchor` over a single keyed record with per-artifact *namespaced* monotonic -fields, not a single global counter.** Vault re-seal, policy edit, and cap reservation advance at -different rates and for different reasons; a shared counter would couple them (a vault re-seal would -invalidate the cap window; a policy edit would have to re-stamp cap state). Domain separation lets each -advance independently while one write commits them atomically. - -``` -struct AnchoredState { // serialized + integrity-tagged as one blob - format: u8, // the anchor's own format version, independent of vault.bin - vault_epoch: u64, // #71: bumps on re-seal / DEK rotation (no legitimate bumper exists YET) - policy_version: u64, // #72: bumps on each authorized policy edit - cap_generation: u64, // #108 fence: bumps on UTC-day roll / policy change / detected rollback - last_seen_day: u64, // #108: monotonic max-day-ever-seen, so a backward clock can't reset -} -// AAD domain separation per field, e.g. b"DKRDv1/anchor/{vault,policy,cap}" -``` - -```rust -/// A monotonic, rollback-resistant security-state store. Implemented by a keychain backend -/// (a NEW DEP, needs approval) and a file/mock backend (zero new deps). signerd is the only writer. -pub trait StateAnchor { - /// Current value for `ns`, or the degraded/absent signal. - fn read(&self, ns: Namespace) -> anyhow::Result; - /// Monotonic compare-and-advance: persist `next` only if its version is strictly greater - /// than the stored version for `ns`. Fail closed on a stale/equal version, a failed - /// durability step, or a torn/absent backend. - fn advance(&mut self, ns: Namespace, expected: u64, next: AnchorRecord) -> anyhow::Result; -} -enum AnchorRead { Present(AnchorRecord), Absent, Degraded(String) } -``` - -**Durability and the single writer.** The on-disk backend reuses the exact recipe `Vault::write_atomic` -already implements (`keystore.rs:378-405`): open the temp file at `0600`, `write_all`, `sync_all`, -`rename` over the target, then `fsync` the parent directory. `signerd` is the sole writer: it holds the -single-instance `flock` for its lifetime and its per-request mutex serializes every mutation, so no -second writer can race. The anchor path resolves through the **same `DECKARD_CONFIG_DIR`-aware -resolver** as the vault and policy (or a parallel `DECKARD_ANCHOR_DIR`), **not** raw -`directories::data_dir()` — otherwise the throwaway `just qa` / `just demo` vaults and the real vault -share one anchor namespace, and on macOS `data_dir == config_dir` anyway, so "survives a config wipe" -is a Linux-only and largely illusory benefit. - -**Integrity is the consumer's job, layered on top.** The anchor enforces monotonicity and durability; -each consumer authenticates its own payload. This sidesteps an unresolved keying question (the -vault-derived MAC key is only available after unlock, which is fine for policy/cap checks that run only -while unlocked, but a policy edit authorized while locked has no key to re-MAC; that consumer must -require an unlocked session or a separate bootstrap key). Authentication reuses the existing -XChaCha20-Poly1305 (`chacha20poly1305` is an unconditional `deckard-core` dep; `hmac`/`sha2` are -`shield`-gated), so the keyed tag is zero new dependencies and the same audited family as the keystore. - -### How each consumer uses it, and where each consumer is honestly weakest - -- **`#72` (authenticated policy).** Today `contract::Policy` (`policy.rs:17-36`) has **no version field - and no MAC**, and `policy.json` is plain `serde_json` (`policy_store.rs:61`) — that *is* finding C2. - So `#72` must first add a versioned, MAC'd policy record (`version + tag`, with `version = 0` as the - pre-versioning default for forward-compat). It then `read(Policy)`, fails closed on a bad/missing tag - or a stale version (replay of an older, more permissive policy), and on an authorized edit calls - `advance(Policy, …)` **before** re-MAC'ing the file. This is the keystone's strongest consumer: a - policy version *does* advance on every legitimate edit, so `file < anchor` is reachable through real - use, unlike the vault. - -- **`#108` (durable cap).** Two tiers, because the cap increments on every spend and a keychain write - per spend is slow and can prompt. The slow anchor holds a coarse `cap_generation` bound to - `chain + account + policy_version + UTC-day`; the per-spend `reserved_wei` lives in a fast local file - written with the atomic recipe, **reserved before the signature is released** (closing the - post-broadcast crash window at `daemon.rs:1166`). Three corrections the adversarial pass forced into - the design, none optional: - 1. **The generation fence does not catch an intra-generation rollback.** Within a UTC day at fixed - policy, `cap_generation` is constant, so swapping in an earlier same-day fast file (lower - `reserved_wei`, same generation) passes the fence and resets the counter. `#108` must add - intra-generation monotonic protection (a high-water sequence the slow tier checkpoints), or scope - the fence honestly to cross-generation only and accept that an intra-day same-uid file swap is not - closed. "Rolling the generation retires every older snapshot" is false for same-generation - snapshots. - 2. **A backward wall-clock jump must not re-open the window.** `current_utc_day()` - (`policy_store.rs:98`) is naive and `rollover()` (`daemon.rs:1397`) resets the spend bidirectionally - today. Derive the window from `effective_day = max(current_utc_day(), anchor.last_seen_day)` and - make `rollover()` forward-only. A backward jump then mismatches and fails closed instead of - resetting the cap to zero. - 3. **Reconcile a pre-broadcast crash deterministically.** Reserve-before-sign over-counts if the - daemon crashes after reserving but before broadcasting. The reservation must carry a deterministic - tx identity (`chain + from + nonce`) so that on reboot the daemon queries the chain and commits or - releases the reservation, rather than permanently consuming the cap. Note this adds an `fsync` to - every signature under the mutex held across broadcast, which lengthens STOP latency; measure it. - -- **`#71` (the vault, the nominal first consumer, and the weakest).** There is **no legitimate bumper - for `vault_epoch`** in the shipping code: `seal()` always mints a fresh identity, and the - "upgrade-on-unlock re-seal" the design would lean on (`keystore.rs:60-64`) is a doc comment, not - code. So for any one vault the epoch is write-once and `file < anchor` is unreachable through - legitimate use; the standalone vault-rollback detector is vacuous until a re-seal-preserving-identity - path exists. That path is itself the v2-format change Q2 defers. This is why the build order below - puts the vault last. - ---- - -## Decision and build order - -1. **Build the generalizable `StateAnchor` primitive first** (file-backed, zero new deps), as the - keystone ADR 0003 item #4 asks for: one namespaced authenticated record, atomic write + `fsync` + - dir-sync, single-writer via the existing `flock`, three-valued read with fail-closed-on-regression - and degraded-on-absent, path resolved through the config-dir-aware resolver. A small, - feature-gated, unwired reference implementation ships **with this ADR** to make the interface - concrete and de-risk the consumers (it changes no production behavior). -2. **Wire `#72` and `#108` to it** — they are where a monotonic counter actually advances and where - the keystone earns its keep. `#72` must add the versioned MAC'd policy record first; `#108` must add - the intra-generation guard, the monotonic-day guard, and pre-broadcast reconciliation. -3. **Defer the vault-epoch binding** to a v2-format effort on `#71` that also builds the missing - re-seal-preserving-identity path and forbids format-downgrade re-anchoring. Until then, the vault - nominally consumes the primitive at a fixed bootstrap epoch and gains the restore-confirm UX, but - the real anti-rollback value for the vault waits on v2. -4. **The keychain backend (`keyring`) is a recommended, approval-gated upgrade**, not landed here. Ask: - `keyring` pinned, macOS/Windows native only, file-only on Linux, closure re-measured on the unified - workspace. +`vault.bin` is AEAD-encrypted, so it can't be forged, but a same-uid attacker with filesystem write +can replace it with an older, genuine copy (a rollback / replay). `#71` asked whether to detect that +with a monotonic counter bumped on every save and mirrored to a reference in a different trust domain +(an OS keychain, or a TPM), checked on unlock. + +ADR 0003 framed `#71` as a shared foundation that `#72` (authenticate `policy.json`) and `#108` (make +the daily cap durable) would also build on. **That framing was the mistake**, and this ADR corrects it. + +## Decision + +1. **Do not build vault rollback detection now.** Defer `#71` with the blockers below. +2. **Do not build a shared cross-trust-domain anchor at all** (no OS-keychain mirror, no TPM counter, + no new on-disk vault format). It was one expensive mechanism invented to serve three issues, and + only the weakest of the three ever needed it. +3. **`#72` and `#108` are independent local fixes** that do not depend on `#71` and do not need an + anchor. They proceed on their own. See "What we do instead." +4. This **supersedes the shared-foundation framing in ADR 0003 (items #4–#6)**. The findings and EV + ranking in ADR 0003 stand; the "build one rollback store that all three consume" sequencing does not. + +## Why + +The trust boundary Deckard documents is the **uid**: code running as your user (including filesystem +write, `ptrace`, `/proc//mem`) is trusted, and the wallet does not defend against live malware +running as you during an unlocked session, because the seed must enter RAM to sign (`THREAT-MODEL.md`, +`SECURITY.md`). Rollback requires writing your files, which is same-uid, which is already inside that +conceded zone. So the most this can ever defend is an attacker **weaker** than the one the model +already concedes (a careless backup/restore, a sync conflict, a sandboxed or limited process, offline +file theft). + +For the **vault specifically**, the value is thin even against that weaker attacker: + +- The seed is constant across re-seals, and balances live on-chain. Rolling `vault.bin` back gives the + attacker no key and reverts nothing financial. Its entire worst case is reverting a **passphrase + rotation or KDF-cost upgrade** to a previously-valid one. +- An attacker who can do that, during an unlocked session, can already read the live seed out of the + daemon's RAM, which the threat model concedes. So this is a baroque, low-yield path to something + the conceded attacker already has. + +And the mechanism doesn't hold even where it was supposed to (adversarial findings, all verified): + +- **Nothing legitimately advances the vault's counter.** Every `seal()` mints a fresh random + `vault_id` (`keystore.rs`) and there is no re-seal-preserving-identity path; "upgrade-on-unlock + re-seal" is a doc comment, not code. So the detector is vacuous until a counter that actually + increments exists. +- **A plaintext / sidecar counter is replayable** (rolled back together with the vault, both validly + authenticated). Only binding it into the AEAD associated data makes it non-forgeable, and that is a + format-version break that retires the frozen on-disk known-answer fixtures. +- **The anchor is itself same-uid-reachable.** A sidecar file is deletable (deletion routes to a + "new machine" bootstrap with no challenge); an OS-keychain item is rewritable. The only store a + same-uid attacker can't roll backward is a **TPM 2.0 NV monotonic counter** — a new C-library + dependency plus NV provisioning and headless capability probing, and macOS has **no app-facing + equivalent**, so it would be a Linux-only security promise we couldn't keep cross-platform. + +For a `0.0.1-alpha`, testnet-keys-only wallet, this is a large, breaking, partly-non-portable build to +defend a minor integrity property against a sub-boundary attacker. The cost is wildly out of line with +the value. + +## Revisit conditions (the blockers) + +Reopen this only when one clears: + +- **B1 — the threat model rises.** The only attacker this stops is strictly weaker than the conceded + same-uid attacker. Revisit if Deckard holds real/mainnet funds, becomes multi-user, or treats + untrusted backup/sync as a first-class adversary. +- **B2 — a real counter exists.** Nothing advances the vault epoch today. This needs a + re-seal-preserving-identity path *and* an AEAD-AAD-bound epoch (a vault format v2 that breaks the + frozen compat fixtures). If an unrelated change forces a format bump anyway, anti-rollback can ride + along. +- **B3 — a true anchor exists on all target platforms.** Today only a TPM NV counter is + decrement-proof, and macOS lacks an app-facing equivalent. Don't ship rollback protection as a + cross-platform guarantee until every supported OS has one. + +## What we do instead (independent of `#71`) + +Both close real holes in our *own* integrity story that a sub-same-uid attacker can hit today. Neither +imports an anchor; neither needs a format break. + +- **`#108` — durable daily cap (highest value).** The cap is in-memory and zeroed on load, so a + same-uid attacker crash-loops the daemon to reset it and drains in within-cap chunks + (`policy_store.rs`, `daemon.rs`). Fix: **persist the spent counter, reserve-before-sign** (decrement + durably *before* broadcast, reconcile by deterministic tx identity `chain + from + nonce`), survive + restart, and make the day rollover **forward-only** so a backward clock can't reset it. This is a + local-durability fix; it owes a same-uid attacker only tamper-*evidence*. Bolting it onto a + cross-domain anchor would *create* the rollback surface, not close it. +- **`#72` — authenticate `policy.json`.** The agent-spending rulebook is plaintext and silently + editable (`policy_store.rs`). Fix: a **MAC** keyed from vault material, **fail closed** on a + bad/missing tag (extending the existing loud fallback), plus an inert monotonic **version field**. + This closes forgery / silent edit. Anti-*replay* of an old valid policy is the same deferred bucket + as `#71` (it only binds via the AAD break and only beats the weaker attacker), so we do not chase it + now. ## Consequences -- **Positive:** the keystone is specified as one primitive three consumers share, with the durability - and single-writer semantics ADR 0003 demanded; the dependency cost is measured, not guessed, and the - approval ask is small and honest; the design is corrected for the rollback-bypass, oracle, - torn-write, clock-rollback, intra-generation, and env-isolation holes an adversarial pass found - before any code shipped. -- **Cost:** the strongest version of the feature needs a new dependency (approval-gated) and, for the - vault, a v2 format migration plus a re-seal path that does not exist yet. The cap's reserve-before- - sign adds per-spend `fsync` under the broadcast-held mutex. -- **Deferred:** the vault-epoch AAD binding (v2 format + re-seal), the hardware-backed anchor (Secure - Enclave / TPM), Touch ID unlock (separate effort; note that `apple-native` already pulls - `security-framework`, so this does not foreclose it), and the codex cross-model review pass that did - not run. +- **Positive:** we don't build a large, breaking, partly-non-portable mechanism for a minor property + outside our threat boundary; the two issues with real teeth get smaller, faster, dependency-free + fixes; the record stops a re-proposal of the same idea, and stops `#72`/`#108` being blocked on + `#71`. +- **Cost:** vault rollback (and policy/cap anti-replay) remain undefended against a sub-same-uid + attacker — accepted for alpha and recorded in `THREAT-MODEL.md`. +- **Deferred:** everything in the anchor family — the epoch, the keychain mirror, the TPM counter, the + vault format v2, the re-seal-identity path — behind B1–B3. ## Status / next step -**Proposed.** The spike is conclusive: the anchor crate and its cost are chosen, both correctness -details have an exact mechanism, the restore path and the per-configuration residual are defined, and -the generalization is specified with its consumers' weakest points named. The decision is to build the -shared primitive and wire policy/cap first, defer the vault binding to a v2 effort, and seek approval -for the `keyring` upgrade. `#71` stays **open** (the vault binding is not done); this ADR is referenced -from it. Refinement comments on `#72` and `#108` point them at the interface above. Promote to Accepted -once the primitive lands and the first consumer (`#72`) is wired. +**Deferred.** `#71` stays open as a parked issue carrying B1–B3. `#72` and `#108` are decoupled and +proceed as independent local fixes (comments updated). No code ships from this spike.