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/// Generate a fresh random 32-byte durable payload key, base64-encoded for vault storage.
193///
194/// Stored under `ZEPH_DURABLE_KEY` (never inline in TOML); decode it back with
195/// [`XChaCha20Poly1305Cipher::from_vault_b64`]. Drawn from the OS CSPRNG.
196///
197/// # Examples
198///
199/// ```
200/// use zeph_core::durable::{generate_durable_key_b64, XChaCha20Poly1305Cipher};
201///
202/// let key = generate_durable_key_b64();
203/// assert!(XChaCha20Poly1305Cipher::from_vault_b64(&key).is_ok());
204/// ```
205#[must_use]
206pub fn generate_durable_key_b64() -> String {
207    use base64::Engine as _;
208    let key = XChaCha20Poly1305::generate_key(&mut OsRng);
209    base64::engine::general_purpose::STANDARD.encode(key.as_slice())
210}
211
212impl PayloadCipher for XChaCha20Poly1305Cipher {
213    fn seal(&self, plaintext: &[u8], aad: &PayloadAad) -> Result<Vec<u8>, CipherError> {
214        let aad_bytes = aad.canonical_bytes();
215        let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
216        let ciphertext = self
217            .current
218            .cipher
219            .encrypt(
220                &nonce,
221                Payload {
222                    msg: plaintext,
223                    aad: &aad_bytes,
224                },
225            )
226            .map_err(|_| CipherError::Authentication)?;
227
228        let mut blob = Vec::with_capacity(KEY_ID_LEN + NONCE_LEN + ciphertext.len());
229        blob.push(self.current.key_id);
230        blob.extend_from_slice(nonce.as_slice());
231        blob.extend_from_slice(&ciphertext);
232        Ok(blob)
233    }
234
235    fn open(&self, sealed: &[u8], aad: &PayloadAad) -> Result<Vec<u8>, CipherError> {
236        if sealed.len() < MIN_SEALED_LEN {
237            return Err(CipherError::Malformed {
238                context: "sealed blob shorter than key-id + nonce + tag",
239            });
240        }
241        let key_id = sealed[0];
242        let cipher = self
243            .select(key_id)
244            .ok_or(CipherError::UnknownKeyId { key_id })?;
245
246        let nonce = XNonce::from_slice(&sealed[KEY_ID_LEN..NONCE_END]);
247        let ciphertext = &sealed[NONCE_END..];
248        let aad_bytes = aad.canonical_bytes();
249
250        cipher
251            .decrypt(
252                nonce,
253                Payload {
254                    msg: ciphertext,
255                    aad: &aad_bytes,
256                },
257            )
258            .map_err(|_| CipherError::Authentication)
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use std::assert_matches;
265    use std::collections::HashSet;
266
267    use zeph_durable::cipher::EntryKindTag;
268    use zeph_durable::{DurableError, ExecutionId, StepId};
269
270    use super::*;
271
272    fn aad_for(exec: ExecutionId, step: u32) -> PayloadAad {
273        PayloadAad::new(exec, StepId::new(step), EntryKindTag::StepResult, None)
274    }
275
276    #[test]
277    fn seal_open_round_trip() {
278        let cipher = XChaCha20Poly1305Cipher::new(0, [1u8; 32]);
279        let aad = aad_for(ExecutionId::new(), 0);
280        for plaintext in [
281            b"".as_slice(),
282            b"x",
283            b"a longer journaled tool result payload",
284        ] {
285            let sealed = cipher.seal(plaintext, &aad).unwrap();
286            assert_eq!(cipher.open(&sealed, &aad).unwrap(), plaintext);
287        }
288    }
289
290    #[test]
291    fn sealed_blob_uses_key_id_nonce_tag_layout() {
292        let cipher = XChaCha20Poly1305Cipher::new(3, [2u8; 32]);
293        let aad = aad_for(ExecutionId::new(), 0);
294        let sealed = cipher.seal(b"", &aad).unwrap();
295        // key-id byte, then 24-byte nonce, then a 16-byte tag for empty plaintext.
296        assert_eq!(sealed.len(), KEY_ID_LEN + NONCE_LEN + TAG_LEN);
297        assert_eq!(sealed[0], 3, "leading byte is the current key-id");
298    }
299
300    #[test]
301    fn nonce_is_fresh_per_seal() {
302        let cipher = XChaCha20Poly1305Cipher::new(0, [9u8; 32]);
303        let aad = aad_for(ExecutionId::new(), 0);
304        let a = cipher.seal(b"same", &aad).unwrap();
305        let b = cipher.seal(b"same", &aad).unwrap();
306        // Identical plaintext + identical AAD must still yield distinct nonces (and ciphertext).
307        assert_ne!(a[KEY_ID_LEN..NONCE_END], b[KEY_ID_LEN..NONCE_END]);
308        assert_ne!(a, b);
309    }
310
311    // NFR-DE-06: a CSPRNG nonce of 192 bits must not repeat across 10^6 seals.
312    #[test]
313    #[ignore = "slow: 1M seal iterations — run explicitly or in integration suite"]
314    fn one_million_seals_produce_distinct_nonces() {
315        const SEALS: usize = 1_000_000;
316        let cipher = XChaCha20Poly1305Cipher::new(0, [4u8; 32]);
317        let aad = aad_for(ExecutionId::new(), 0);
318        let mut nonces: HashSet<[u8; NONCE_LEN]> = HashSet::with_capacity(SEALS);
319        for _ in 0..SEALS {
320            let sealed = cipher.seal(b"", &aad).unwrap();
321            let mut nonce = [0u8; NONCE_LEN];
322            nonce.copy_from_slice(&sealed[KEY_ID_LEN..NONCE_END]);
323            assert!(nonces.insert(nonce), "nonce reuse detected");
324        }
325        assert_eq!(nonces.len(), SEALS);
326    }
327
328    #[test]
329    fn open_under_different_step_fails_replay_integrity() {
330        let cipher = XChaCha20Poly1305Cipher::new(0, [5u8; 32]);
331        let exec = ExecutionId::new();
332        let sealed = cipher.seal(b"result", &aad_for(exec, 7)).unwrap();
333
334        let err = cipher.open(&sealed, &aad_for(exec, 8)).unwrap_err();
335        assert_matches!(err, CipherError::Authentication);
336        assert_matches!(DurableError::from(err), DurableError::ReplayIntegrity);
337    }
338
339    #[test]
340    fn open_under_different_execution_fails_replay_integrity() {
341        let cipher = XChaCha20Poly1305Cipher::new(0, [6u8; 32]);
342        let sealed = cipher
343            .seal(b"result", &aad_for(ExecutionId::new(), 0))
344            .unwrap();
345
346        let err = cipher
347            .open(&sealed, &aad_for(ExecutionId::new(), 0))
348            .unwrap_err();
349        assert_matches!(DurableError::from(err), DurableError::ReplayIntegrity);
350    }
351
352    #[test]
353    fn tampered_ciphertext_fails_authentication() {
354        let cipher = XChaCha20Poly1305Cipher::new(0, [7u8; 32]);
355        let aad = aad_for(ExecutionId::new(), 0);
356        let mut sealed = cipher.seal(b"result", &aad).unwrap();
357        let last = sealed.len() - 1;
358        sealed[last] ^= 0xFF;
359        assert_matches!(
360            cipher.open(&sealed, &aad).unwrap_err(),
361            CipherError::Authentication
362        );
363    }
364
365    #[test]
366    fn short_blob_is_malformed() {
367        let cipher = XChaCha20Poly1305Cipher::new(0, [0u8; 32]);
368        let aad = aad_for(ExecutionId::new(), 0);
369        let err = cipher.open(&[0u8; MIN_SEALED_LEN - 1], &aad).unwrap_err();
370        assert_matches!(err, CipherError::Malformed { .. });
371        assert_matches!(DurableError::from(err), DurableError::Decode { .. });
372    }
373
374    #[test]
375    fn unknown_key_id_fails_closed() {
376        let cipher = XChaCha20Poly1305Cipher::new(0, [1u8; 32]);
377        let aad = aad_for(ExecutionId::new(), 0);
378        let mut sealed = cipher.seal(b"x", &aad).unwrap();
379        sealed[0] = 200; // no key registered under id 200
380        assert_matches!(
381            cipher.open(&sealed, &aad).unwrap_err(),
382            CipherError::UnknownKeyId { key_id: 200 }
383        );
384    }
385
386    #[test]
387    fn previous_key_opens_during_rotation_window() {
388        // Seal under the old key (id 0), then rotate: current is id 1, previous is id 0.
389        let old = XChaCha20Poly1305Cipher::new(0, [1u8; 32]);
390        let aad = aad_for(ExecutionId::new(), 0);
391        let sealed = old.seal(b"in-flight", &aad).unwrap();
392
393        let rotated = XChaCha20Poly1305Cipher::new(1, [2u8; 32]).with_previous(0, [1u8; 32]);
394        // The old blob still opens via the previous key...
395        assert_eq!(rotated.open(&sealed, &aad).unwrap(), b"in-flight");
396        // ...while new seals use the current key-id.
397        assert_eq!(rotated.seal(b"new", &aad).unwrap()[0], 1);
398    }
399
400    #[test]
401    fn from_vault_bytes_validates_length() {
402        assert!(XChaCha20Poly1305Cipher::from_vault_bytes(0, &[0u8; 32]).is_ok());
403        // The cipher deliberately does not implement `Debug` (it holds key material), so match on
404        // the `Result` directly rather than calling `unwrap_err`.
405        assert!(matches!(
406            XChaCha20Poly1305Cipher::from_vault_bytes(0, b"short"),
407            Err(CipherKeyError::InvalidKeyLength {
408                expected: 32,
409                actual: 5
410            })
411        ));
412    }
413}