Skip to main content

zeph_core/
durable.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Concrete cryptographic backing for the durable execution layer.
5//!
6//! `zeph-durable` defines the durable execution *contract* as a pure Layer-0 abstraction and
7//! deliberately carries no cryptographic dependency (INV-1). This module supplies the concrete
8//! [`XChaCha20Poly1305Cipher`] that satisfies [`zeph_durable::PayloadCipher`]. The binary
9//! constructs it from the vault-resolved `ZEPH_DURABLE_KEY` and injects it into a backend as
10//! `Option<Arc<dyn PayloadCipher>>`, exactly as a database pool is handed in.
11//!
12//! `XChaCha20-Poly1305` is chosen for its 192-bit extended nonce: a fresh random nonce per seal
13//! (INV-7) has a negligible collision probability even across the lifetime of a long-lived key, so
14//! no nonce-sequencing state has to be persisted.
15//!
16//! # Examples
17//!
18//! ```
19//! use zeph_core::durable::XChaCha20Poly1305Cipher;
20//! use zeph_durable::{ExecutionId, StepId, PayloadCipher};
21//! use zeph_durable::cipher::{EntryKindTag, PayloadAad};
22//!
23//! let cipher = XChaCha20Poly1305Cipher::new(0, [7u8; 32]);
24//! let aad = PayloadAad::new(ExecutionId::new(), StepId::new(0), EntryKindTag::StepResult, None);
25//!
26//! let sealed = cipher.seal(b"tool result", &aad).unwrap();
27//! assert_eq!(cipher.open(&sealed, &aad).unwrap(), b"tool result");
28//! ```
29
30use chacha20poly1305::{
31    Key, KeyInit, XChaCha20Poly1305, XNonce,
32    aead::{Aead, AeadCore, OsRng, Payload},
33};
34use zeph_durable::{CipherError, PayloadAad, PayloadCipher};
35use zeroize::Zeroize;
36
37/// `XChaCha20-Poly1305` key size, in bytes.
38const KEY_LEN: usize = 32;
39/// `XChaCha20` extended nonce size, in bytes.
40const NONCE_LEN: usize = 24;
41/// `Poly1305` authentication tag size, in bytes.
42const TAG_LEN: usize = 16;
43/// Length of the leading key-id selector byte.
44const KEY_ID_LEN: usize = 1;
45/// Offset one past the nonce, where the ciphertext begins.
46const NONCE_END: usize = KEY_ID_LEN + NONCE_LEN;
47/// Smallest valid sealed blob: `key_id || nonce || tag` (empty ciphertext).
48const MIN_SEALED_LEN: usize = NONCE_END + TAG_LEN;
49
50/// The key-id byte stamped on every payload sealed with the current `ZEPH_DURABLE_KEY`.
51///
52/// `seal` writes this as the leading byte and `open` selects the current key by it. Both the
53/// agent-loop engine and the `zeph durable --reveal` CLI build the cipher with this id so a sealed
54/// blob round-trips. Rotating to a fresh key bumps the id and registers the old one as the previous
55/// slot ([`XChaCha20Poly1305Cipher::with_previous`]).
56pub const DURABLE_KEY_ID: u8 = 0;
57
58/// Failure constructing an [`XChaCha20Poly1305Cipher`] from raw vault bytes.
59#[derive(Debug, thiserror::Error)]
60#[non_exhaustive]
61pub enum CipherKeyError {
62    /// The vault-resolved key was not exactly 32 bytes.
63    #[error("durable cipher key must be {expected} bytes, got {actual}")]
64    InvalidKeyLength {
65        /// The required key length in bytes (32).
66        expected: usize,
67        /// The length of the supplied key material.
68        actual: usize,
69    },
70    /// The vault-resolved key string was not valid base64.
71    #[error("durable cipher key is not valid base64")]
72    MalformedEncoding,
73}
74
75/// One key registered with the cipher, addressed by its on-disk key-id byte.
76struct KeySlot {
77    key_id: u8,
78    cipher: XChaCha20Poly1305,
79}
80
81impl KeySlot {
82    /// Build a slot, copying the key into the AEAD state and zeroizing the transient input.
83    fn new(key_id: u8, mut key: [u8; KEY_LEN]) -> Self {
84        let cipher = XChaCha20Poly1305::new(Key::from_slice(&key));
85        key.zeroize();
86        Self { key_id, cipher }
87    }
88}
89
90/// A vault-keyed `XChaCha20-Poly1305` [`PayloadCipher`] with a one-key rotation window.
91///
92/// The cipher holds a *current* key used for all seals, plus an optional *previous* key that
93/// [`open`](PayloadCipher::open) can still select during a rotation window. The on-disk layout
94/// `key_id(1) || nonce(24) || ciphertext || tag(16)` lets `open` pick the right key by its leading
95/// byte; an unrecognized key-id fails closed with [`CipherError::UnknownKeyId`].
96///
97/// Key rotation is otherwise drain-based: see `book` vault documentation for the operational
98/// policy. See [`zeph_durable::PayloadCipher`] for the full contract.
99pub struct XChaCha20Poly1305Cipher {
100    current: KeySlot,
101    previous: Option<KeySlot>,
102}
103
104impl XChaCha20Poly1305Cipher {
105    /// Construct a cipher with a single current key identified by `key_id`.
106    ///
107    /// The `key` array is zeroized once copied into the AEAD state.
108    #[must_use]
109    pub fn new(key_id: u8, key: [u8; KEY_LEN]) -> Self {
110        Self {
111            current: KeySlot::new(key_id, key),
112            previous: None,
113        }
114    }
115
116    /// Construct a cipher from vault-resolved key bytes, validating the length.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`CipherKeyError::InvalidKeyLength`] when `key` is not exactly 32 bytes.
121    ///
122    /// # Examples
123    ///
124    /// ```
125    /// use zeph_core::durable::XChaCha20Poly1305Cipher;
126    ///
127    /// assert!(XChaCha20Poly1305Cipher::from_vault_bytes(0, &[0u8; 32]).is_ok());
128    /// assert!(XChaCha20Poly1305Cipher::from_vault_bytes(0, b"too short").is_err());
129    /// ```
130    pub fn from_vault_bytes(key_id: u8, key: &[u8]) -> Result<Self, CipherKeyError> {
131        let array: [u8; KEY_LEN] =
132            key.try_into()
133                .map_err(|_| CipherKeyError::InvalidKeyLength {
134                    expected: KEY_LEN,
135                    actual: key.len(),
136                })?;
137        Ok(Self::new(key_id, array))
138    }
139
140    /// Construct the current cipher from the base64-encoded `ZEPH_DURABLE_KEY` vault value.
141    ///
142    /// This is the single decode path shared by the agent-loop engine and the `zeph durable
143    /// --reveal` CLI; both use [`DURABLE_KEY_ID`] so a sealed blob round-trips. The key is generated
144    /// in this same encoding by [`generate_durable_key_b64`].
145    ///
146    /// # Errors
147    ///
148    /// Returns [`CipherKeyError::MalformedEncoding`] when `b64_key` is not valid base64, or
149    /// [`CipherKeyError::InvalidKeyLength`] when the decoded key is not exactly 32 bytes.
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// use zeph_core::durable::{XChaCha20Poly1305Cipher, generate_durable_key_b64};
155    ///
156    /// let key = generate_durable_key_b64();
157    /// assert!(XChaCha20Poly1305Cipher::from_vault_b64(&key).is_ok());
158    /// assert!(XChaCha20Poly1305Cipher::from_vault_b64("not base64!").is_err());
159    /// ```
160    pub fn from_vault_b64(b64_key: &str) -> Result<Self, CipherKeyError> {
161        use base64::Engine as _;
162        let bytes = base64::engine::general_purpose::STANDARD
163            .decode(b64_key.trim())
164            .map_err(|_| CipherKeyError::MalformedEncoding)?;
165        Self::from_vault_bytes(DURABLE_KEY_ID, &bytes)
166    }
167
168    /// Register a previous key for the rotation window.
169    ///
170    /// `open` will select this key for blobs whose leading key-id byte matches `key_id`; `seal`
171    /// always uses the current key. Use this so in-flight executions sealed under the old key can
172    /// still be replayed after a rotation.
173    #[must_use]
174    pub fn with_previous(mut self, key_id: u8, key: [u8; KEY_LEN]) -> Self {
175        self.previous = Some(KeySlot::new(key_id, key));
176        self
177    }
178
179    /// Select the AEAD state for a given on-disk key-id.
180    fn select(&self, key_id: u8) -> Option<&XChaCha20Poly1305> {
181        if key_id == self.current.key_id {
182            Some(&self.current.cipher)
183        } else {
184            self.previous
185                .as_ref()
186                .filter(|slot| slot.key_id == key_id)
187                .map(|slot| &slot.cipher)
188        }
189    }
190}
191
192/// Domain-separation context for deriving the control-entry HMAC key (INV-8) from
193/// `ZEPH_DURABLE_KEY` via BLAKE3 `derive_key`.
194const CONTROL_HMAC_CONTEXT: &str = "zeph-durable v1 control-entry HMAC key 2026";
195
196/// Derive the row-level control-entry HMAC key (INV-8) from the base64-encoded `ZEPH_DURABLE_KEY`
197/// vault value.
198///
199/// The HMAC key is not a separate vault secret: it is a BLAKE3 `derive_key` subkey of the same
200/// `ZEPH_DURABLE_KEY` used for the AEAD payload cipher, domain-separated by a fixed context string
201/// so the two keys are cryptographically independent even though they share one root secret —
202/// the same pattern used for the promise resolver-token hash in `zeph-durable`'s `promise.rs`.
203///
204/// # Errors
205///
206/// Returns [`CipherKeyError::MalformedEncoding`] when `b64_key` is not valid base64, or
207/// [`CipherKeyError::InvalidKeyLength`] when the decoded key is not exactly 32 bytes.
208///
209/// # Examples
210///
211/// ```
212/// use zeph_core::durable::{derive_control_hmac_key_b64, generate_durable_key_b64};
213///
214/// let key = generate_durable_key_b64();
215/// assert!(derive_control_hmac_key_b64(&key).is_ok());
216/// assert!(derive_control_hmac_key_b64("not base64!").is_err());
217/// ```
218pub fn derive_control_hmac_key_b64(b64_key: &str) -> Result<[u8; KEY_LEN], CipherKeyError> {
219    use base64::Engine as _;
220    let bytes = base64::engine::general_purpose::STANDARD
221        .decode(b64_key.trim())
222        .map_err(|_| CipherKeyError::MalformedEncoding)?;
223    if bytes.len() != KEY_LEN {
224        return Err(CipherKeyError::InvalidKeyLength {
225            expected: KEY_LEN,
226            actual: bytes.len(),
227        });
228    }
229    Ok(blake3::derive_key(CONTROL_HMAC_CONTEXT, &bytes))
230}
231
232/// Generate a fresh random 32-byte durable payload key, base64-encoded for vault storage.
233///
234/// Stored under `ZEPH_DURABLE_KEY` (never inline in TOML); decode it back with
235/// [`XChaCha20Poly1305Cipher::from_vault_b64`]. Drawn from the OS CSPRNG.
236///
237/// # Examples
238///
239/// ```
240/// use zeph_core::durable::{generate_durable_key_b64, XChaCha20Poly1305Cipher};
241///
242/// let key = generate_durable_key_b64();
243/// assert!(XChaCha20Poly1305Cipher::from_vault_b64(&key).is_ok());
244/// ```
245#[must_use]
246pub fn generate_durable_key_b64() -> String {
247    use base64::Engine as _;
248    let key = XChaCha20Poly1305::generate_key(&mut OsRng);
249    base64::engine::general_purpose::STANDARD.encode(key.as_slice())
250}
251
252impl PayloadCipher for XChaCha20Poly1305Cipher {
253    fn seal(&self, plaintext: &[u8], aad: &PayloadAad) -> Result<Vec<u8>, CipherError> {
254        let aad_bytes = aad.canonical_bytes();
255        let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
256        let ciphertext = self
257            .current
258            .cipher
259            .encrypt(
260                &nonce,
261                Payload {
262                    msg: plaintext,
263                    aad: &aad_bytes,
264                },
265            )
266            .map_err(|_| CipherError::Authentication)?;
267
268        let mut blob = Vec::with_capacity(KEY_ID_LEN + NONCE_LEN + ciphertext.len());
269        blob.push(self.current.key_id);
270        blob.extend_from_slice(nonce.as_slice());
271        blob.extend_from_slice(&ciphertext);
272        Ok(blob)
273    }
274
275    fn open(&self, sealed: &[u8], aad: &PayloadAad) -> Result<Vec<u8>, CipherError> {
276        if sealed.len() < MIN_SEALED_LEN {
277            return Err(CipherError::Malformed {
278                context: "sealed blob shorter than key-id + nonce + tag",
279            });
280        }
281        let key_id = sealed[0];
282        let cipher = self
283            .select(key_id)
284            .ok_or(CipherError::UnknownKeyId { key_id })?;
285
286        let nonce = XNonce::from_slice(&sealed[KEY_ID_LEN..NONCE_END]);
287        let ciphertext = &sealed[NONCE_END..];
288        let aad_bytes = aad.canonical_bytes();
289
290        cipher
291            .decrypt(
292                nonce,
293                Payload {
294                    msg: ciphertext,
295                    aad: &aad_bytes,
296                },
297            )
298            .map_err(|_| CipherError::Authentication)
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use std::assert_matches;
305    use std::collections::HashSet;
306
307    use zeph_durable::cipher::EntryKindTag;
308    use zeph_durable::{DurableError, ExecutionId, StepId};
309
310    use super::*;
311
312    fn aad_for(exec: ExecutionId, step: u32) -> PayloadAad {
313        PayloadAad::new(exec, StepId::new(step), EntryKindTag::StepResult, None)
314    }
315
316    #[test]
317    fn seal_open_round_trip() {
318        let cipher = XChaCha20Poly1305Cipher::new(0, [1u8; 32]);
319        let aad = aad_for(ExecutionId::new(), 0);
320        for plaintext in [
321            b"".as_slice(),
322            b"x",
323            b"a longer journaled tool result payload",
324        ] {
325            let sealed = cipher.seal(plaintext, &aad).unwrap();
326            assert_eq!(cipher.open(&sealed, &aad).unwrap(), plaintext);
327        }
328    }
329
330    #[test]
331    fn sealed_blob_uses_key_id_nonce_tag_layout() {
332        let cipher = XChaCha20Poly1305Cipher::new(3, [2u8; 32]);
333        let aad = aad_for(ExecutionId::new(), 0);
334        let sealed = cipher.seal(b"", &aad).unwrap();
335        // key-id byte, then 24-byte nonce, then a 16-byte tag for empty plaintext.
336        assert_eq!(sealed.len(), KEY_ID_LEN + NONCE_LEN + TAG_LEN);
337        assert_eq!(sealed[0], 3, "leading byte is the current key-id");
338    }
339
340    #[test]
341    fn nonce_is_fresh_per_seal() {
342        let cipher = XChaCha20Poly1305Cipher::new(0, [9u8; 32]);
343        let aad = aad_for(ExecutionId::new(), 0);
344        let a = cipher.seal(b"same", &aad).unwrap();
345        let b = cipher.seal(b"same", &aad).unwrap();
346        // Identical plaintext + identical AAD must still yield distinct nonces (and ciphertext).
347        assert_ne!(a[KEY_ID_LEN..NONCE_END], b[KEY_ID_LEN..NONCE_END]);
348        assert_ne!(a, b);
349    }
350
351    // NFR-DE-06: a CSPRNG nonce of 192 bits must not repeat across 10^6 seals.
352    #[test]
353    #[ignore = "slow: 1M seal iterations — run explicitly or in integration suite"]
354    fn one_million_seals_produce_distinct_nonces() {
355        const SEALS: usize = 1_000_000;
356        let cipher = XChaCha20Poly1305Cipher::new(0, [4u8; 32]);
357        let aad = aad_for(ExecutionId::new(), 0);
358        let mut nonces: HashSet<[u8; NONCE_LEN]> = HashSet::with_capacity(SEALS);
359        for _ in 0..SEALS {
360            let sealed = cipher.seal(b"", &aad).unwrap();
361            let mut nonce = [0u8; NONCE_LEN];
362            nonce.copy_from_slice(&sealed[KEY_ID_LEN..NONCE_END]);
363            assert!(nonces.insert(nonce), "nonce reuse detected");
364        }
365        assert_eq!(nonces.len(), SEALS);
366    }
367
368    #[test]
369    fn open_under_different_step_fails_replay_integrity() {
370        let cipher = XChaCha20Poly1305Cipher::new(0, [5u8; 32]);
371        let exec = ExecutionId::new();
372        let sealed = cipher.seal(b"result", &aad_for(exec, 7)).unwrap();
373
374        let err = cipher.open(&sealed, &aad_for(exec, 8)).unwrap_err();
375        assert_matches!(err, CipherError::Authentication);
376        assert_matches!(DurableError::from(err), DurableError::ReplayIntegrity);
377    }
378
379    #[test]
380    fn open_under_different_execution_fails_replay_integrity() {
381        let cipher = XChaCha20Poly1305Cipher::new(0, [6u8; 32]);
382        let sealed = cipher
383            .seal(b"result", &aad_for(ExecutionId::new(), 0))
384            .unwrap();
385
386        let err = cipher
387            .open(&sealed, &aad_for(ExecutionId::new(), 0))
388            .unwrap_err();
389        assert_matches!(DurableError::from(err), DurableError::ReplayIntegrity);
390    }
391
392    #[test]
393    fn tampered_ciphertext_fails_authentication() {
394        let cipher = XChaCha20Poly1305Cipher::new(0, [7u8; 32]);
395        let aad = aad_for(ExecutionId::new(), 0);
396        let mut sealed = cipher.seal(b"result", &aad).unwrap();
397        let last = sealed.len() - 1;
398        sealed[last] ^= 0xFF;
399        assert_matches!(
400            cipher.open(&sealed, &aad).unwrap_err(),
401            CipherError::Authentication
402        );
403    }
404
405    #[test]
406    fn short_blob_is_malformed() {
407        let cipher = XChaCha20Poly1305Cipher::new(0, [0u8; 32]);
408        let aad = aad_for(ExecutionId::new(), 0);
409        let err = cipher.open(&[0u8; MIN_SEALED_LEN - 1], &aad).unwrap_err();
410        assert_matches!(err, CipherError::Malformed { .. });
411        assert_matches!(DurableError::from(err), DurableError::Decode { .. });
412    }
413
414    #[test]
415    fn unknown_key_id_fails_closed() {
416        let cipher = XChaCha20Poly1305Cipher::new(0, [1u8; 32]);
417        let aad = aad_for(ExecutionId::new(), 0);
418        let mut sealed = cipher.seal(b"x", &aad).unwrap();
419        sealed[0] = 200; // no key registered under id 200
420        assert_matches!(
421            cipher.open(&sealed, &aad).unwrap_err(),
422            CipherError::UnknownKeyId { key_id: 200 }
423        );
424    }
425
426    #[test]
427    fn previous_key_opens_during_rotation_window() {
428        // Seal under the old key (id 0), then rotate: current is id 1, previous is id 0.
429        let old = XChaCha20Poly1305Cipher::new(0, [1u8; 32]);
430        let aad = aad_for(ExecutionId::new(), 0);
431        let sealed = old.seal(b"in-flight", &aad).unwrap();
432
433        let rotated = XChaCha20Poly1305Cipher::new(1, [2u8; 32]).with_previous(0, [1u8; 32]);
434        // The old blob still opens via the previous key...
435        assert_eq!(rotated.open(&sealed, &aad).unwrap(), b"in-flight");
436        // ...while new seals use the current key-id.
437        assert_eq!(rotated.seal(b"new", &aad).unwrap()[0], 1);
438    }
439
440    #[test]
441    fn from_vault_bytes_validates_length() {
442        assert!(XChaCha20Poly1305Cipher::from_vault_bytes(0, &[0u8; 32]).is_ok());
443        // The cipher deliberately does not implement `Debug` (it holds key material), so match on
444        // the `Result` directly rather than calling `unwrap_err`.
445        assert!(matches!(
446            XChaCha20Poly1305Cipher::from_vault_bytes(0, b"short"),
447            Err(CipherKeyError::InvalidKeyLength {
448                expected: 32,
449                actual: 5
450            })
451        ));
452    }
453
454    #[test]
455    fn control_hmac_key_derives_deterministically_and_independently_of_the_aead_key() {
456        use base64::Engine as _;
457
458        let vault_key = generate_durable_key_b64();
459        let hmac_key = derive_control_hmac_key_b64(&vault_key).unwrap();
460
461        // Deterministic: the same vault value always derives the same subkey.
462        assert_eq!(derive_control_hmac_key_b64(&vault_key).unwrap(), hmac_key);
463
464        // Cryptographically independent of the raw AEAD key material (domain separation via a
465        // fixed `derive_key` context distinct from the AEAD cipher's own use of the raw bytes).
466        let raw_aead_key = base64::engine::general_purpose::STANDARD
467            .decode(vault_key.trim())
468            .unwrap();
469        assert_ne!(hmac_key.as_slice(), raw_aead_key.as_slice());
470    }
471
472    #[test]
473    fn control_hmac_key_rejects_malformed_or_mislength_input() {
474        use base64::Engine as _;
475
476        assert!(matches!(
477            derive_control_hmac_key_b64("not base64!"),
478            Err(CipherKeyError::MalformedEncoding)
479        ));
480        let short = base64::engine::general_purpose::STANDARD.encode(b"too short");
481        assert!(matches!(
482            derive_control_hmac_key_b64(&short),
483            Err(CipherKeyError::InvalidKeyLength {
484                expected: 32,
485                actual: 9
486            })
487        ));
488    }
489}