Skip to main content

zeph_common/
anchor.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Vault-anchor downgrade-resistance primitives (issue #6449).
5//!
6//! [`crate::hash_chain`] defends against in-place edits, reordering, and a *partial* strip of
7//! chain metadata, but explicitly does **not** defend against a **fully consistent whole-file
8//! strip** (delete every `chain` field so a chained file looks pre-feature-legacy) — see that
9//! module's "Threat model and honest scope" docs. This module closes that gap for
10//! already-anchored content: a small per-file record (an [`Anchor`]) is written to the age vault
11//! on finalize/close and checked on read. Because an age vault entry can only be removed by an
12//! attacker who holds the age private key (decrypt → re-encrypt → rename), and the threat model
13//! here is a file-write-only attacker, a whole-file strip can never make the anchor disappear —
14//! so "legacy-looking file, but an anchor exists for its identity" is an unambiguous tamper
15//! signature.
16//!
17//! Mirrors [`crate::hash_chain`]'s layering: this module is pure (no vault dependency) so the
18//! adapter crates (`zeph-subagent`, `zeph-session`) depend only on the [`AnchorStore`] trait,
19//! never on `zeph-vault` directly (INV-1). The binary provides the concrete
20//! age-vault-backed implementation and installs it into each adapter's process-global slot at
21//! bootstrap, exactly as it already does for [`crate::hash_chain::ChainKeyRing`].
22//!
23//! # Absent anchor is never a tamper signature
24//!
25//! A chained file with **no** anchor is not suspicious: it can only mean the anchor feature was
26//! not active when the file was finalized (pre-feature content, `anchor = "none"`, or a vault
27//! outage during finalize), never an attacker having deleted a vault entry (that requires the age
28//! key). Callers must trust "chained + anchor absent" exactly like plain #6453 behavior — never
29//! fail-closed on it, or every session/transcript that predates this feature bricks.
30
31use std::future::Future;
32use std::pin::Pin;
33
34use crate::hash_chain::ChainHash;
35
36/// Vault secret name prefix for every anchor key. Also the prefix the reconcile-and-cap sweep
37/// filters on when listing vault keys.
38pub const ANCHOR_KEY_PREFIX: &str = "ZEPH_HISTORY_ANCHOR_";
39
40/// Which subsystem a given anchor belongs to — folded into the vault key so the two subsystems'
41/// anchors never collide even if a `file_id` happened to coincide.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum AnchorSubsystem {
44    /// `zeph-subagent` transcript (`<task_id>.jsonl`).
45    SubagentTranscript,
46    /// `zeph-session` event log (`events.jsonl`).
47    SessionLog,
48}
49
50impl AnchorSubsystem {
51    /// The vault-key segment for this subsystem (`SUBAGENT` / `SESSION`).
52    #[must_use]
53    pub const fn key_segment(self) -> &'static str {
54        match self {
55            Self::SubagentTranscript => "SUBAGENT",
56            Self::SessionLog => "SESSION",
57        }
58    }
59}
60
61/// A per-file downgrade-resistance record, stored as an age-vault secret keyed by
62/// [`anchor_key`].
63///
64/// Authenticity comes entirely from the vault's own AEAD encryption — the anchor carries no MAC
65/// of its own, since an attacker who cannot decrypt the vault cannot forge or delete an entry
66/// either way.
67#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
68pub struct Anchor {
69    /// Format version, for forward compatibility.
70    pub version: u8,
71    /// The finalizing key epoch (cross-check + operator diagnostics only — not required to
72    /// match on read, since a legitimately re-keyed file may resolve under a different epoch).
73    pub epoch: u32,
74    /// Total on-disk entry count at the time this anchor was written.
75    pub count: u64,
76    /// The verified chain head at exactly `count` entries, hex-encoded.
77    pub head_hex: String,
78    /// Wall-clock milliseconds at write time, embedded **inside** this AEAD-protected value so
79    /// it is unforgeable by a file-write-only attacker (unlike filesystem mtime, which such an
80    /// attacker can freely rewrite via `utimensat`). Used by the session-anchor reconcile-and-cap
81    /// sweep to select the true oldest anchor for eviction (issue #6449 rev2 critic S3) — eviction
82    /// ordering must never depend on an attacker-controlled signal.
83    pub written_at: u64,
84    /// Wall-clock milliseconds at which the reconcile-and-cap sweep first observed this anchor's
85    /// backing file/session-directory absent. `None` while the file exists. Set on the first
86    /// sweep that finds the file gone, cleared if the file reappears before the grace window
87    /// elapses (self-heal), and used to gate orphan reap behind a grace window so a
88    /// delete→wait-out-a-sweep→recreate-forged-legacy sequence cannot make the sweep delete the
89    /// anchor on the attacker's behalf (issue #6462). `#[serde(default)]` means pre-existing
90    /// persisted anchors deserialize with `None`, no vault migration needed;
91    /// `skip_serializing_if` keeps steady-state (never-orphaned) anchors byte-identical to their
92    /// pre-#6462 serialization.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub orphaned_since: Option<u64>,
95}
96
97/// Current [`Anchor::version`].
98pub const ANCHOR_VERSION: u8 = 1;
99
100impl Anchor {
101    /// Construct a new anchor for a file finalized with `epoch`/`count`/`head`, stamping
102    /// [`Self::written_at`] with the current wall-clock time.
103    #[must_use]
104    pub fn new(epoch: u32, count: u64, head: ChainHash) -> Self {
105        Self {
106            version: ANCHOR_VERSION,
107            epoch,
108            count,
109            head_hex: head.to_hex(),
110            written_at: now_unix_millis(),
111            orphaned_since: None,
112        }
113    }
114
115    /// Parse [`Self::head_hex`] back into a [`ChainHash`].
116    ///
117    /// # Errors
118    ///
119    /// Returns [`AnchorError::Malformed`] if the stored hex is not a valid chain hash — this can
120    /// only happen if the vault entry was corrupted or hand-edited by the age-key holder, not by
121    /// a file-write-only attacker.
122    pub fn head(&self) -> Result<ChainHash, AnchorError> {
123        ChainHash::from_hex(&self.head_hex).map_err(|_| AnchorError::Malformed)
124    }
125}
126
127/// Current wall-clock time in Unix milliseconds, saturating to `u64::MAX` rather than panicking
128/// on an unrepresentable (pre-epoch or post-overflow) system clock.
129///
130/// Shared by [`Anchor::new`] (stamps [`Anchor::written_at`]) and the reconcile-and-cap sweep
131/// (stamps/checks [`Anchor::orphaned_since`], issue #6462) so both use the same time source.
132#[must_use]
133pub fn now_unix_millis() -> u64 {
134    u64::try_from(
135        std::time::SystemTime::now()
136            .duration_since(std::time::UNIX_EPOCH)
137            .unwrap_or_default()
138            .as_millis(),
139    )
140    .unwrap_or(u64::MAX)
141}
142
143/// Errors from an [`AnchorStore`] operation.
144#[derive(Debug, thiserror::Error)]
145pub enum AnchorError {
146    /// The underlying vault operation failed.
147    #[error("anchor store I/O failed: {0}")]
148    Store(String),
149    /// A stored anchor's `head_hex` was not a valid chain hash.
150    #[error("stored anchor is malformed")]
151    Malformed,
152}
153
154/// Storage abstraction for per-file vault anchors (issue #6449).
155///
156/// Implementors persist an [`Anchor`] keyed by [`anchor_key`] in a medium a file-write-only
157/// attacker cannot forge or delete — in practice, an age vault secret. The adapter crates
158/// (`zeph-subagent`, `zeph-session`) depend only on this trait, never on a concrete vault type
159/// (INV-1); the binary provides the concrete implementation.
160///
161/// Both an async and a sync accessor are provided for [`get`][Self::get]/[`get_sync`][Self::get_sync]:
162/// the session-log read path is already fully async, but the transcript read path
163/// (`TranscriptReader::load`/`load_strict`) is a plain synchronous function with call sites
164/// spread across 3+ crates outside this feature's ownership — making it async would be a large,
165/// out-of-scope blast radius (mirrors why `zeph_core::history_integrity` already exposes both
166/// [`resolve_key_ring`](../../zeph_core/history_integrity/fn.resolve_key_ring.html) and a `_sync`
167/// counterpart for the same reason). `get_sync` is cheap (an in-memory map lookup behind a
168/// `std::sync::RwLock`, never a blocking disk read), so calling it from a sync context never
169/// risks stalling a tokio worker thread for longer than a brief lock hold.
170pub trait AnchorStore: Send + Sync {
171    /// Fetch the anchor for `(subsystem, file_id)`, if one is configured and present.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`AnchorError`] on a store-level failure. A simply-absent anchor is `Ok(None)`,
176    /// never an error — see the module docs' "absent anchor is never a tamper signature" note.
177    fn get(
178        &self,
179        subsystem: AnchorSubsystem,
180        file_id: &[u8],
181    ) -> Pin<Box<dyn Future<Output = Result<Option<Anchor>, AnchorError>> + Send + '_>>;
182
183    /// Synchronous variant of [`get`][Self::get] — see the trait docs for why this exists
184    /// alongside the async version.
185    ///
186    /// # Errors
187    ///
188    /// Same as [`get`][Self::get].
189    fn get_sync(
190        &self,
191        subsystem: AnchorSubsystem,
192        file_id: &[u8],
193    ) -> Result<Option<Anchor>, AnchorError>;
194
195    /// Persist `anchor` for `(subsystem, file_id)`, overwriting any prior anchor for the same
196    /// identity.
197    ///
198    /// Implementations performing blocking I/O (age vault re-encryption) **must** route it
199    /// through `zeph_common::task_supervisor::TaskSupervisor::spawn_blocking`, never a raw
200    /// `tokio::task::spawn_blocking` or an inline blocking call on the calling task.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`AnchorError`] on a store-level failure.
205    fn put(
206        &self,
207        subsystem: AnchorSubsystem,
208        file_id: &[u8],
209        anchor: Anchor,
210    ) -> Pin<Box<dyn Future<Output = Result<(), AnchorError>> + Send + '_>>;
211
212    /// Remove the anchor for `(subsystem, file_id)`, if present. A no-op (not an error) if no
213    /// anchor exists for this identity.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`AnchorError`] on a store-level failure.
218    fn delete(
219        &self,
220        subsystem: AnchorSubsystem,
221        file_id: &[u8],
222    ) -> Pin<Box<dyn Future<Output = Result<(), AnchorError>> + Send + '_>>;
223}
224
225/// Encode `file_id` bytes into the ASCII-safe segment used inside a vault key.
226///
227/// `file_id`s in this codebase are always a UUID or a directory/file stem, already restricted to
228/// `[A-Za-z0-9._-]` in practice — this is a defense-in-depth guard, not a real-world path: any
229/// byte outside that set falls back to a `hex:`-prefixed hex encoding so the resulting key is
230/// always safe to embed in a vault key string.
231fn encode_file_id(file_id: &[u8]) -> String {
232    use std::fmt::Write as _;
233    let safe = file_id
234        .iter()
235        .all(|&b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
236    if safe {
237        // Safety of the assumption checked above: every byte is ASCII, so this is valid UTF-8.
238        String::from_utf8_lossy(file_id).into_owned()
239    } else {
240        let mut out = String::with_capacity(4 + file_id.len() * 2);
241        out.push_str("hex:");
242        for b in file_id {
243            let _ = write!(out, "{b:02x}");
244        }
245        out
246    }
247}
248
249/// Decode an [`encode_file_id`]-produced segment back into raw bytes.
250fn decode_file_id(segment: &str) -> Option<Vec<u8>> {
251    if let Some(hex) = segment.strip_prefix("hex:") {
252        if hex.len() % 2 != 0 {
253            return None;
254        }
255        let mut out = Vec::with_capacity(hex.len() / 2);
256        let bytes = hex.as_bytes();
257        for chunk in bytes.chunks(2) {
258            let hi = (chunk[0] as char).to_digit(16)?;
259            let lo = (chunk[1] as char).to_digit(16)?;
260            out.push(u8::try_from(hi * 16 + lo).ok()?);
261        }
262        Some(out)
263    } else {
264        Some(segment.as_bytes().to_vec())
265    }
266}
267
268/// Derive the vault key for a given subsystem + file identity.
269///
270/// Format: `ZEPH_HISTORY_ANCHOR_<SUBSYSTEM>_<file_id>` (ASCII-safe-encoded, falling back to a
271/// `hex:`-prefixed hex encoding for any byte outside `[A-Za-z0-9._-]`).
272///
273/// # Examples
274///
275/// The common, ASCII-safe path:
276///
277/// ```
278/// use zeph_common::anchor::{AnchorSubsystem, anchor_key, parse_anchor_key};
279///
280/// let key = anchor_key(AnchorSubsystem::SubagentTranscript, b"task-42");
281/// assert_eq!(key, "ZEPH_HISTORY_ANCHOR_SUBAGENT_task-42");
282///
283/// let (subsystem, file_id) = parse_anchor_key(&key).unwrap();
284/// assert_eq!(subsystem, AnchorSubsystem::SubagentTranscript);
285/// assert_eq!(file_id, b"task-42");
286/// ```
287///
288/// A `file_id` containing a byte outside `[A-Za-z0-9._-]` falls back to the `hex:`-prefixed
289/// encoding, which round-trips through [`parse_anchor_key`] the same way:
290///
291/// ```
292/// use zeph_common::anchor::{AnchorSubsystem, anchor_key, parse_anchor_key};
293///
294/// let key = anchor_key(AnchorSubsystem::SessionLog, b"a/b");
295/// assert_eq!(key, "ZEPH_HISTORY_ANCHOR_SESSION_hex:612f62");
296///
297/// let (subsystem, file_id) = parse_anchor_key(&key).unwrap();
298/// assert_eq!(subsystem, AnchorSubsystem::SessionLog);
299/// assert_eq!(file_id, b"a/b");
300/// ```
301#[must_use]
302pub fn anchor_key(subsystem: AnchorSubsystem, file_id: &[u8]) -> String {
303    format!(
304        "{ANCHOR_KEY_PREFIX}{}_{}",
305        subsystem.key_segment(),
306        encode_file_id(file_id)
307    )
308}
309
310/// Parse a vault key back into `(subsystem, file_id)`, if it matches [`ANCHOR_KEY_PREFIX`].
311///
312/// Used by the reconcile-and-cap sweep to enumerate anchor keys and map them back to an on-disk
313/// identity without needing to track a separate index.
314#[must_use]
315pub fn parse_anchor_key(key: &str) -> Option<(AnchorSubsystem, Vec<u8>)> {
316    let rest = key.strip_prefix(ANCHOR_KEY_PREFIX)?;
317    let (subsystem, file_id_segment) = if let Some(id) = rest.strip_prefix("SUBAGENT_") {
318        (AnchorSubsystem::SubagentTranscript, id)
319    } else {
320        let id = rest.strip_prefix("SESSION_")?;
321        (AnchorSubsystem::SessionLog, id)
322    };
323    decode_file_id(file_id_segment).map(|id| (subsystem, id))
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::hash_chain::{ChainKey, chain_next, genesis};
330
331    fn sample_head() -> ChainHash {
332        let key = ChainKey::new([1u8; 32]);
333        let base = genesis(&key, "d", b"f", 0);
334        chain_next(&key, &base, b"content")
335    }
336
337    #[test]
338    fn anchor_key_round_trips_for_safe_ids() {
339        let key = anchor_key(AnchorSubsystem::SubagentTranscript, b"abc-123.task");
340        assert_eq!(key, "ZEPH_HISTORY_ANCHOR_SUBAGENT_abc-123.task");
341        let (subsystem, id) = parse_anchor_key(&key).unwrap();
342        assert_eq!(subsystem, AnchorSubsystem::SubagentTranscript);
343        assert_eq!(id, b"abc-123.task");
344    }
345
346    #[test]
347    fn anchor_key_round_trips_for_unsafe_bytes() {
348        let file_id = vec![0xffu8, 0x00, b'/'];
349        let key = anchor_key(AnchorSubsystem::SessionLog, &file_id);
350        assert!(key.starts_with("ZEPH_HISTORY_ANCHOR_SESSION_hex:"));
351        let (subsystem, id) = parse_anchor_key(&key).unwrap();
352        assert_eq!(subsystem, AnchorSubsystem::SessionLog);
353        assert_eq!(id, file_id);
354    }
355
356    #[test]
357    fn parse_anchor_key_rejects_unrelated_keys() {
358        assert!(parse_anchor_key("ZEPH_OPENAI_API_KEY").is_none());
359        assert!(parse_anchor_key("ZEPH_HISTORY_ANCHOR_BOGUS_x").is_none());
360    }
361
362    #[test]
363    fn anchor_new_stamps_written_at_and_round_trips_head() {
364        let head = sample_head();
365        let anchor = Anchor::new(3, 42, head);
366        assert_eq!(anchor.version, ANCHOR_VERSION);
367        assert_eq!(anchor.epoch, 3);
368        assert_eq!(anchor.count, 42);
369        assert!(anchor.written_at > 0);
370        assert_eq!(anchor.head().unwrap(), head);
371    }
372
373    #[test]
374    fn anchor_serializes_to_json_and_back() {
375        let anchor = Anchor::new(0, 7, sample_head());
376        let json = serde_json::to_string(&anchor).unwrap();
377        let round_tripped: Anchor = serde_json::from_str(&json).unwrap();
378        assert_eq!(round_tripped.count, 7);
379        assert_eq!(round_tripped.head_hex, anchor.head_hex);
380        assert_eq!(round_tripped.written_at, anchor.written_at);
381        assert_eq!(round_tripped.orphaned_since, None);
382    }
383
384    /// A freshly constructed anchor omits `orphaned_since` from its JSON entirely
385    /// (`skip_serializing_if`), so a pre-#6462 vault entry stays byte-identical until first
386    /// observed orphaned.
387    #[test]
388    fn anchor_new_omits_orphaned_since_from_serialized_json() {
389        let anchor = Anchor::new(0, 1, sample_head());
390        let json = serde_json::to_string(&anchor).unwrap();
391        assert!(!json.contains("orphaned_since"));
392    }
393
394    /// A pre-#6462 anchor (no `orphaned_since` key at all) deserializes with `None` — no vault
395    /// migration required.
396    #[test]
397    fn anchor_deserializes_legacy_json_without_orphaned_since_field() {
398        let legacy_json =
399            r#"{"version":1,"epoch":0,"count":3,"head_hex":"ab12","written_at":1000}"#;
400        let anchor: Anchor = serde_json::from_str(legacy_json).unwrap();
401        assert_eq!(anchor.orphaned_since, None);
402    }
403
404    /// A stamped anchor (`orphaned_since = Some(_)`) round-trips its value.
405    #[test]
406    fn anchor_round_trips_orphaned_since_when_set() {
407        let mut anchor = Anchor::new(0, 7, sample_head());
408        anchor.orphaned_since = Some(123_456);
409        let json = serde_json::to_string(&anchor).unwrap();
410        assert!(json.contains("orphaned_since"));
411        let round_tripped: Anchor = serde_json::from_str(&json).unwrap();
412        assert_eq!(round_tripped.orphaned_since, Some(123_456));
413    }
414
415    #[test]
416    fn anchor_head_rejects_malformed_hex() {
417        let mut anchor = Anchor::new(0, 1, sample_head());
418        anchor.head_hex = "not-hex".to_owned();
419        assert!(matches!(anchor.head(), Err(AnchorError::Malformed)));
420    }
421}