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#[must_use]
273pub fn anchor_key(subsystem: AnchorSubsystem, file_id: &[u8]) -> String {
274 format!(
275 "{ANCHOR_KEY_PREFIX}{}_{}",
276 subsystem.key_segment(),
277 encode_file_id(file_id)
278 )
279}
280
281/// Parse a vault key back into `(subsystem, file_id)`, if it matches [`ANCHOR_KEY_PREFIX`].
282///
283/// Used by the reconcile-and-cap sweep to enumerate anchor keys and map them back to an on-disk
284/// identity without needing to track a separate index.
285#[must_use]
286pub fn parse_anchor_key(key: &str) -> Option<(AnchorSubsystem, Vec<u8>)> {
287 let rest = key.strip_prefix(ANCHOR_KEY_PREFIX)?;
288 let (subsystem, file_id_segment) = if let Some(id) = rest.strip_prefix("SUBAGENT_") {
289 (AnchorSubsystem::SubagentTranscript, id)
290 } else {
291 let id = rest.strip_prefix("SESSION_")?;
292 (AnchorSubsystem::SessionLog, id)
293 };
294 decode_file_id(file_id_segment).map(|id| (subsystem, id))
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::hash_chain::{ChainKey, chain_next, genesis};
301
302 fn sample_head() -> ChainHash {
303 let key = ChainKey::new([1u8; 32]);
304 let base = genesis(&key, "d", b"f", 0);
305 chain_next(&key, &base, b"content")
306 }
307
308 #[test]
309 fn anchor_key_round_trips_for_safe_ids() {
310 let key = anchor_key(AnchorSubsystem::SubagentTranscript, b"abc-123.task");
311 assert_eq!(key, "ZEPH_HISTORY_ANCHOR_SUBAGENT_abc-123.task");
312 let (subsystem, id) = parse_anchor_key(&key).unwrap();
313 assert_eq!(subsystem, AnchorSubsystem::SubagentTranscript);
314 assert_eq!(id, b"abc-123.task");
315 }
316
317 #[test]
318 fn anchor_key_round_trips_for_unsafe_bytes() {
319 let file_id = vec![0xffu8, 0x00, b'/'];
320 let key = anchor_key(AnchorSubsystem::SessionLog, &file_id);
321 assert!(key.starts_with("ZEPH_HISTORY_ANCHOR_SESSION_hex:"));
322 let (subsystem, id) = parse_anchor_key(&key).unwrap();
323 assert_eq!(subsystem, AnchorSubsystem::SessionLog);
324 assert_eq!(id, file_id);
325 }
326
327 #[test]
328 fn parse_anchor_key_rejects_unrelated_keys() {
329 assert!(parse_anchor_key("ZEPH_OPENAI_API_KEY").is_none());
330 assert!(parse_anchor_key("ZEPH_HISTORY_ANCHOR_BOGUS_x").is_none());
331 }
332
333 #[test]
334 fn anchor_new_stamps_written_at_and_round_trips_head() {
335 let head = sample_head();
336 let anchor = Anchor::new(3, 42, head);
337 assert_eq!(anchor.version, ANCHOR_VERSION);
338 assert_eq!(anchor.epoch, 3);
339 assert_eq!(anchor.count, 42);
340 assert!(anchor.written_at > 0);
341 assert_eq!(anchor.head().unwrap(), head);
342 }
343
344 #[test]
345 fn anchor_serializes_to_json_and_back() {
346 let anchor = Anchor::new(0, 7, sample_head());
347 let json = serde_json::to_string(&anchor).unwrap();
348 let round_tripped: Anchor = serde_json::from_str(&json).unwrap();
349 assert_eq!(round_tripped.count, 7);
350 assert_eq!(round_tripped.head_hex, anchor.head_hex);
351 assert_eq!(round_tripped.written_at, anchor.written_at);
352 assert_eq!(round_tripped.orphaned_since, None);
353 }
354
355 /// A freshly constructed anchor omits `orphaned_since` from its JSON entirely
356 /// (`skip_serializing_if`), so a pre-#6462 vault entry stays byte-identical until first
357 /// observed orphaned.
358 #[test]
359 fn anchor_new_omits_orphaned_since_from_serialized_json() {
360 let anchor = Anchor::new(0, 1, sample_head());
361 let json = serde_json::to_string(&anchor).unwrap();
362 assert!(!json.contains("orphaned_since"));
363 }
364
365 /// A pre-#6462 anchor (no `orphaned_since` key at all) deserializes with `None` — no vault
366 /// migration required.
367 #[test]
368 fn anchor_deserializes_legacy_json_without_orphaned_since_field() {
369 let legacy_json =
370 r#"{"version":1,"epoch":0,"count":3,"head_hex":"ab12","written_at":1000}"#;
371 let anchor: Anchor = serde_json::from_str(legacy_json).unwrap();
372 assert_eq!(anchor.orphaned_since, None);
373 }
374
375 /// A stamped anchor (`orphaned_since = Some(_)`) round-trips its value.
376 #[test]
377 fn anchor_round_trips_orphaned_since_when_set() {
378 let mut anchor = Anchor::new(0, 7, sample_head());
379 anchor.orphaned_since = Some(123_456);
380 let json = serde_json::to_string(&anchor).unwrap();
381 assert!(json.contains("orphaned_since"));
382 let round_tripped: Anchor = serde_json::from_str(&json).unwrap();
383 assert_eq!(round_tripped.orphaned_since, Some(123_456));
384 }
385
386 #[test]
387 fn anchor_head_rejects_malformed_hex() {
388 let mut anchor = Anchor::new(0, 1, sample_head());
389 anchor.head_hex = "not-hex".to_owned();
390 assert!(matches!(anchor.head(), Err(AnchorError::Malformed)));
391 }
392}