Skip to main content

zeph_core/
history_integrity.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Vault-key resolution for the transcript/session-log hash-chain (issue #6360).
5//!
6//! `zeph-common::hash_chain` and the `zeph-subagent`/`zeph-session` adapters deliberately carry
7//! no vault dependency (mirrors `zeph-durable`'s INV-1: the abstraction stays pure, the binary
8//! resolves concrete key material). This module is the concrete vault-key resolution layer,
9//! exactly parallel to [`crate::durable::derive_control_hmac_key_b64`] — this module derives the
10//! *history-chain* subkeys from a **separate** root secret (`ZEPH_HISTORY_KEY`), not
11//! `ZEPH_DURABLE_KEY`, so history-log integrity works even when durable encryption is disabled
12//! (spec-069 §6 "decoupled from durable").
13//!
14//! # Examples
15//!
16//! ```
17//! use zeph_core::history_integrity::{derive_history_chain_key_b64, generate_history_key_b64};
18//!
19//! let key = generate_history_key_b64();
20//! assert!(derive_history_chain_key_b64(&key, "zeph-session log v1").is_ok());
21//! assert!(derive_history_chain_key_b64("not base64!", "zeph-session log v1").is_err());
22//! ```
23
24use zeph_common::hash_chain::{ChainKey, ChainKeyRing};
25use zeph_vault::VaultProvider;
26
27/// 32-byte key length shared by the root secret and every derived subkey.
28const KEY_LEN: usize = 32;
29
30/// Vault secret name for the current root history-integrity key.
31pub const HISTORY_KEY_SECRET: &str = "ZEPH_HISTORY_KEY";
32/// Vault secret name for the current root key's epoch number (decimal `u32`). Absent means
33/// epoch 0 — the common case before any rotation has occurred.
34pub const HISTORY_KEY_EPOCH_SECRET: &str = "ZEPH_HISTORY_KEY_EPOCH";
35/// Vault secret name for a previous-epoch root key retained during a rotation window (FR-008).
36/// Absent means no rotation window is configured.
37pub const HISTORY_KEY_PREVIOUS_SECRET: &str = "ZEPH_HISTORY_KEY_PREVIOUS";
38/// Vault secret name for `ZEPH_HISTORY_KEY_PREVIOUS`'s epoch number (decimal `u32`).
39pub const HISTORY_KEY_PREVIOUS_EPOCH_SECRET: &str = "ZEPH_HISTORY_KEY_PREVIOUS_EPOCH";
40
41/// Domain-separation context prefix for deriving a subsystem's chain key from
42/// `ZEPH_HISTORY_KEY` via BLAKE3 `derive_key`. Combined with the caller-supplied domain (e.g.
43/// `"zeph-session log v1"`) so each subsystem's chain key is cryptographically independent even
44/// though all subsystems share one root secret.
45const HISTORY_CHAIN_KEY_CONTEXT_PREFIX: &str = "zeph-history v1 chain key domain=";
46
47/// Errors resolving or deriving history-chain key material.
48#[derive(Debug, thiserror::Error)]
49pub enum HistoryKeyError {
50    /// The vault-resolved key was not exactly 32 bytes.
51    #[error("history chain key must be {KEY_LEN} bytes, got {actual}")]
52    InvalidKeyLength {
53        /// The length of the supplied key material.
54        actual: usize,
55    },
56    /// The vault-resolved key string was not valid base64.
57    #[error("history chain key is not valid base64")]
58    MalformedEncoding,
59    /// `ZEPH_HISTORY_KEY_EPOCH` or `ZEPH_HISTORY_KEY_PREVIOUS_EPOCH` was not a valid `u32`.
60    #[error("history key epoch is not a valid non-negative integer")]
61    MalformedEpoch,
62}
63
64/// Derive one subsystem's chain key (a BLAKE3 `derive_key` subkey of the base64-encoded root
65/// key) domain-separated by `domain`.
66///
67/// The chain key is not a separate vault secret: it is derived the same way
68/// [`crate::durable::derive_control_hmac_key_b64`] derives the control-entry HMAC key from
69/// `ZEPH_DURABLE_KEY` — one root secret, many cryptographically independent subkeys.
70///
71/// # Errors
72///
73/// Returns [`HistoryKeyError::MalformedEncoding`] when `b64_key` is not valid base64, or
74/// [`HistoryKeyError::InvalidKeyLength`] when the decoded key is not exactly 32 bytes.
75pub fn derive_history_chain_key_b64(
76    b64_key: &str,
77    domain: &str,
78) -> Result<ChainKey, HistoryKeyError> {
79    use base64::Engine as _;
80    let bytes = base64::engine::general_purpose::STANDARD
81        .decode(b64_key.trim())
82        .map_err(|_| HistoryKeyError::MalformedEncoding)?;
83    if bytes.len() != KEY_LEN {
84        return Err(HistoryKeyError::InvalidKeyLength {
85            actual: bytes.len(),
86        });
87    }
88    let context = format!("{HISTORY_CHAIN_KEY_CONTEXT_PREFIX}{domain}");
89    Ok(ChainKey::new(blake3::derive_key(&context, &bytes)))
90}
91
92/// Generate a fresh random 32-byte history-integrity root key, base64-encoded for vault storage
93/// under [`HISTORY_KEY_SECRET`]. Drawn from the OS CSPRNG via the workspace `rand` dependency.
94///
95/// # Examples
96///
97/// ```
98/// use zeph_core::history_integrity::{derive_history_chain_key_b64, generate_history_key_b64};
99///
100/// let key = generate_history_key_b64();
101/// assert!(derive_history_chain_key_b64(&key, "zeph-subagent transcript v1").is_ok());
102/// ```
103#[must_use]
104pub fn generate_history_key_b64() -> String {
105    use base64::Engine as _;
106    use rand::Rng as _;
107    let mut bytes = [0u8; KEY_LEN];
108    rand::rng().fill_bytes(&mut bytes);
109    base64::engine::general_purpose::STANDARD.encode(bytes)
110}
111
112/// Resolve one subsystem's [`ChainKeyRing`] from the vault, reading the current root key
113/// ([`HISTORY_KEY_SECRET`] + optional [`HISTORY_KEY_EPOCH_SECRET`]) and an optional
114/// previous-epoch root key ([`HISTORY_KEY_PREVIOUS_SECRET`] + [`HISTORY_KEY_PREVIOUS_EPOCH_SECRET`])
115/// for the rotation window (FR-008).
116///
117/// Returns `Ok(None)` — not an error — when [`HISTORY_KEY_SECRET`] is not present in the vault:
118/// the caller (bootstrap code) should log a loud warning and continue with history-chain
119/// verification disabled for this process, per spec-069's generate-on-first-use bootstrap
120/// posture (M2) rather than fail process startup outright.
121///
122/// # Errors
123///
124/// Returns [`HistoryKeyError`] when a present secret is malformed (not valid base64, wrong
125/// length, or a non-numeric epoch) — a *misconfigured* key is distinct from an *absent* one and
126/// must not be silently treated as "chaining disabled" (NFR-004).
127pub async fn resolve_key_ring(
128    vault: &dyn VaultProvider,
129    domain: &str,
130) -> Result<Option<ChainKeyRing>, HistoryKeyError> {
131    let get = |key: &'static str| async move { vault.get_secret(key).await.ok().flatten() };
132    build_key_ring(
133        get(HISTORY_KEY_SECRET).await,
134        get(HISTORY_KEY_EPOCH_SECRET).await,
135        get(HISTORY_KEY_PREVIOUS_SECRET).await,
136        get(HISTORY_KEY_PREVIOUS_EPOCH_SECRET).await,
137        domain,
138    )
139}
140
141/// Synchronous variant of [`resolve_key_ring`] for CLI/bootstrap call sites that already hold a
142/// loaded [`zeph_vault::AgeVaultProvider`] and use its synchronous
143/// [`get`](zeph_vault::AgeVaultProvider::get) accessor — the same pattern
144/// `crate::durable`'s vault-key loading (`load_write_hwm_key` et al.) uses in `src/commands/`,
145/// rather than the async [`VaultProvider`] trait method.
146///
147/// # Errors
148///
149/// Same as [`resolve_key_ring`].
150pub fn resolve_key_ring_sync(
151    provider: &zeph_vault::AgeVaultProvider,
152    domain: &str,
153) -> Result<Option<ChainKeyRing>, HistoryKeyError> {
154    build_key_ring(
155        provider.get(HISTORY_KEY_SECRET).map(str::to_owned),
156        provider.get(HISTORY_KEY_EPOCH_SECRET).map(str::to_owned),
157        provider.get(HISTORY_KEY_PREVIOUS_SECRET).map(str::to_owned),
158        provider
159            .get(HISTORY_KEY_PREVIOUS_EPOCH_SECRET)
160            .map(str::to_owned),
161        domain,
162    )
163}
164
165/// Shared epoch-parsing/key-derivation core for [`resolve_key_ring`] and
166/// [`resolve_key_ring_sync`].
167fn build_key_ring(
168    current_b64: Option<String>,
169    current_epoch: Option<String>,
170    previous_b64: Option<String>,
171    previous_epoch: Option<String>,
172    domain: &str,
173) -> Result<Option<ChainKeyRing>, HistoryKeyError> {
174    let Some(current_b64) = current_b64 else {
175        return Ok(None);
176    };
177    let current_epoch = match current_epoch {
178        Some(s) => s
179            .trim()
180            .parse::<u32>()
181            .map_err(|_| HistoryKeyError::MalformedEpoch)?,
182        None => 0,
183    };
184    let current_key = derive_history_chain_key_b64(&current_b64, domain)?;
185    let mut ring = ChainKeyRing::new(current_epoch, current_key);
186
187    if let Some(previous_b64) = previous_b64 {
188        let previous_epoch = match previous_epoch {
189            Some(s) => s
190                .trim()
191                .parse::<u32>()
192                .map_err(|_| HistoryKeyError::MalformedEpoch)?,
193            None => current_epoch.saturating_sub(1),
194        };
195        let previous_key = derive_history_chain_key_b64(&previous_b64, domain)?;
196        ring = ring.with_previous(previous_epoch, previous_key);
197    }
198
199    Ok(Some(ring))
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn derive_history_chain_key_rejects_malformed_base64() {
208        assert!(matches!(
209            derive_history_chain_key_b64("not base64!", "d"),
210            Err(HistoryKeyError::MalformedEncoding)
211        ));
212    }
213
214    #[test]
215    fn derive_history_chain_key_rejects_wrong_length() {
216        use base64::Engine as _;
217        let short = base64::engine::general_purpose::STANDARD.encode(b"short");
218        assert!(matches!(
219            derive_history_chain_key_b64(&short, "d"),
220            Err(HistoryKeyError::InvalidKeyLength { .. })
221        ));
222    }
223
224    #[test]
225    fn derive_history_chain_key_is_domain_separated() {
226        let key = generate_history_key_b64();
227        let a = derive_history_chain_key_b64(&key, "domain-a").unwrap();
228        let b = derive_history_chain_key_b64(&key, "domain-b").unwrap();
229        // ChainKey has no PartialEq (deliberately, to discourage timing-unsafe comparisons of
230        // key material) — compare via a derived hash instead, which is exactly what domain
231        // separation is meant to protect.
232        let probe = zeph_common::hash_chain::genesis(&a, "x", b"y", 0);
233        let probe_b = zeph_common::hash_chain::genesis(&b, "x", b"y", 0);
234        assert_ne!(
235            probe, probe_b,
236            "chain keys for different domains must differ"
237        );
238    }
239
240    #[test]
241    fn derive_history_chain_key_independent_from_durable_key_derivation() {
242        // Same root secret bytes, but this module's context prefix must differ from
243        // CONTROL_HMAC_CONTEXT in crate::durable, so the two derived keys are independent even
244        // when an operator (mistakenly) reuses ZEPH_DURABLE_KEY's value for ZEPH_HISTORY_KEY.
245        let key = generate_history_key_b64();
246        let history_key = derive_history_chain_key_b64(&key, "zeph-session log v1").unwrap();
247        let durable_hmac_key = crate::durable::derive_control_hmac_key_b64(&key).unwrap();
248        let probe = zeph_common::hash_chain::genesis(&history_key, "x", b"y", 0);
249        let probe_durable = zeph_common::hash_chain::genesis(
250            &zeph_common::hash_chain::ChainKey::new(durable_hmac_key),
251            "x",
252            b"y",
253            0,
254        );
255        assert_ne!(probe, probe_durable);
256    }
257
258    #[tokio::test]
259    async fn resolve_key_ring_returns_none_when_unprovisioned() {
260        let vault = zeph_vault::MockVaultProvider::new();
261        let ring = resolve_key_ring(&vault, "zeph-session log v1")
262            .await
263            .unwrap();
264        assert!(ring.is_none());
265    }
266
267    #[tokio::test]
268    async fn resolve_key_ring_resolves_current_only() {
269        let key = generate_history_key_b64();
270        let vault = zeph_vault::MockVaultProvider::new().with_secret(HISTORY_KEY_SECRET, &key);
271        let ring = resolve_key_ring(&vault, "zeph-session log v1")
272            .await
273            .unwrap()
274            .expect("key ring must resolve once the root secret is provisioned");
275        assert_eq!(ring.current_epoch(), 0);
276    }
277
278    #[tokio::test]
279    async fn resolve_key_ring_resolves_rotation_window() {
280        let current = generate_history_key_b64();
281        let previous = generate_history_key_b64();
282        let vault = zeph_vault::MockVaultProvider::new()
283            .with_secret(HISTORY_KEY_SECRET, &current)
284            .with_secret(HISTORY_KEY_EPOCH_SECRET, "2")
285            .with_secret(HISTORY_KEY_PREVIOUS_SECRET, &previous)
286            .with_secret(HISTORY_KEY_PREVIOUS_EPOCH_SECRET, "1");
287
288        let ring = resolve_key_ring(&vault, "zeph-subagent transcript v1")
289            .await
290            .unwrap()
291            .expect("must resolve");
292        assert_eq!(ring.current_epoch(), 2);
293
294        // Build a chain under the previous epoch's key and confirm the ring resolves it as
295        // Rekeyed, not Unverifiable — end-to-end confirmation that the vault-sourced ring
296        // plumbs correctly into `verify_chained_prefix`.
297        let previous_key =
298            derive_history_chain_key_b64(&previous, "zeph-subagent transcript v1").unwrap();
299        let base = zeph_common::hash_chain::genesis(
300            &previous_key,
301            "zeph-subagent transcript v1",
302            b"file",
303            1,
304        );
305        let h0 = zeph_common::hash_chain::chain_next(&previous_key, &base, b"entry");
306        let entries = vec![(b"entry".to_vec(), h0)];
307        let (_head, resolution) = zeph_common::hash_chain::verify_chained_prefix(
308            &ring,
309            "zeph-subagent transcript v1",
310            b"file",
311            &entries,
312        )
313        .unwrap();
314        assert_eq!(
315            resolution,
316            zeph_common::hash_chain::KeyResolution::Rekeyed(1)
317        );
318    }
319
320    #[tokio::test]
321    async fn resolve_key_ring_fails_on_malformed_epoch() {
322        let key = generate_history_key_b64();
323        let vault = zeph_vault::MockVaultProvider::new()
324            .with_secret(HISTORY_KEY_SECRET, &key)
325            .with_secret(HISTORY_KEY_EPOCH_SECRET, "not-a-number");
326        let err = resolve_key_ring(&vault, "d").await.unwrap_err();
327        assert!(matches!(err, HistoryKeyError::MalformedEpoch));
328    }
329}