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, Generate, 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).into());
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        Self::from_vault_b64_with_id(DURABLE_KEY_ID, b64_key)
162    }
163
164    /// Construct the current cipher from a base64-encoded vault value with an explicit `key_id`.
165    ///
166    /// Like [`from_vault_b64`](Self::from_vault_b64) but for an operator-controlled `key_id`
167    /// (`[durable].key_id`, `zeph durable rotate-key`, #6447) rather than the hardcoded
168    /// [`DURABLE_KEY_ID`] default — the current cipher's decode path used by
169    /// `load_durable_cipher` once a rotation has bumped the config's `key_id`.
170    ///
171    /// # Errors
172    ///
173    /// Returns [`CipherKeyError::MalformedEncoding`] when `b64_key` is not valid base64, or
174    /// [`CipherKeyError::InvalidKeyLength`] when the decoded key is not exactly 32 bytes.
175    ///
176    /// # Examples
177    ///
178    /// ```
179    /// use zeph_core::durable::{XChaCha20Poly1305Cipher, generate_durable_key_b64};
180    ///
181    /// let key = generate_durable_key_b64();
182    /// assert!(XChaCha20Poly1305Cipher::from_vault_b64_with_id(1, &key).is_ok());
183    /// ```
184    pub fn from_vault_b64_with_id(key_id: u8, b64_key: &str) -> Result<Self, CipherKeyError> {
185        let bytes = decode_vault_key_bytes(b64_key)?;
186        Ok(Self::new(key_id, bytes))
187    }
188
189    /// Register a previous key for the rotation window.
190    ///
191    /// `open` will select this key for blobs whose leading key-id byte matches `key_id`; `seal`
192    /// always uses the current key. Use this so in-flight executions sealed under the old key can
193    /// still be replayed after a rotation.
194    #[must_use]
195    pub fn with_previous(mut self, key_id: u8, key: [u8; KEY_LEN]) -> Self {
196        self.previous = Some(KeySlot::new(key_id, key));
197        self
198    }
199
200    /// Select the AEAD state for a given on-disk key-id.
201    fn select(&self, key_id: u8) -> Option<&XChaCha20Poly1305> {
202        if key_id == self.current.key_id {
203            Some(&self.current.cipher)
204        } else {
205            self.previous
206                .as_ref()
207                .filter(|slot| slot.key_id == key_id)
208                .map(|slot| &slot.cipher)
209        }
210    }
211}
212
213/// Domain-separation context for deriving the control-entry HMAC key (INV-8) from
214/// `ZEPH_DURABLE_KEY` via BLAKE3 `derive_key`.
215const CONTROL_HMAC_CONTEXT: &str = "zeph-durable v1 control-entry HMAC key 2026";
216
217/// Derive the row-level control-entry HMAC key (INV-8) from the base64-encoded `ZEPH_DURABLE_KEY`
218/// vault value.
219///
220/// The HMAC key is not a separate vault secret: it is a BLAKE3 `derive_key` subkey of the same
221/// `ZEPH_DURABLE_KEY` used for the AEAD payload cipher, domain-separated by a fixed context string
222/// so the two keys are cryptographically independent even though they share one root secret —
223/// the same pattern used for the promise resolver-token hash in `zeph-durable`'s `promise.rs`.
224///
225/// # Errors
226///
227/// Returns [`CipherKeyError::MalformedEncoding`] when `b64_key` is not valid base64, or
228/// [`CipherKeyError::InvalidKeyLength`] when the decoded key is not exactly 32 bytes.
229///
230/// # Examples
231///
232/// ```
233/// use zeph_core::durable::{derive_control_hmac_key_b64, generate_durable_key_b64};
234///
235/// let key = generate_durable_key_b64();
236/// assert!(derive_control_hmac_key_b64(&key).is_ok());
237/// assert!(derive_control_hmac_key_b64("not base64!").is_err());
238/// ```
239pub fn derive_control_hmac_key_b64(b64_key: &str) -> Result<[u8; KEY_LEN], CipherKeyError> {
240    use base64::Engine as _;
241    let bytes = base64::engine::general_purpose::STANDARD
242        .decode(b64_key.trim())
243        .map_err(|_| CipherKeyError::MalformedEncoding)?;
244    if bytes.len() != KEY_LEN {
245        return Err(CipherKeyError::InvalidKeyLength {
246            expected: KEY_LEN,
247            actual: bytes.len(),
248        });
249    }
250    Ok(blake3::derive_key(CONTROL_HMAC_CONTEXT, &bytes))
251}
252
253/// Domain-separation context for deriving the high-water-mark HMAC key (issue #6360) from
254/// `ZEPH_DURABLE_KEY` via BLAKE3 `derive_key`.
255const HWM_CONTEXT: &str = "zeph-durable v1 execution high-water-mark HMAC key 2026";
256
257/// Derive the high-water-mark key (issue #6360) from the base64-encoded `ZEPH_DURABLE_KEY` vault
258/// value.
259///
260/// Not a separate vault secret: it is a BLAKE3 `derive_key` subkey of the same `ZEPH_DURABLE_KEY`
261/// used for the AEAD payload cipher and the control-entry HMAC, domain-separated by a fixed
262/// context string distinct from the control-entry HMAC's own context so all three keys are
263/// cryptographically independent despite sharing one root secret — the same pattern
264/// [`derive_control_hmac_key_b64`] already establishes.
265///
266/// Unlike [`derive_control_hmac_key_b64`] (attached only on a declared/detected shared database),
267/// the high-water-mark key is meant to be attached unconditionally (FR-009): it is the only
268/// mechanism that detects deletion of a committed `StepResult` row, a threat the AEAD payload seal
269/// and the row HMAC do not cover on any deployment, single-user local included.
270///
271/// This function derives only the key bytes; the non-secret rotation epoch stamped alongside them
272/// (FR-008) is `config.durable.key_id` (current) / `previous_key_id` (previous) — the same
273/// `rotate-key`-driven lifecycle the AEAD cipher's `key_id` uses — resolved by the caller
274/// (`load_write_hwm_key` in `src/commands/durable.rs`), not by this module.
275///
276/// # Errors
277///
278/// Returns [`CipherKeyError::MalformedEncoding`] when `b64_key` is not valid base64, or
279/// [`CipherKeyError::InvalidKeyLength`] when the decoded key is not exactly 32 bytes.
280///
281/// # Examples
282///
283/// ```
284/// use zeph_core::durable::{derive_hwm_key_b64, generate_durable_key_b64};
285///
286/// let key = generate_durable_key_b64();
287/// assert!(derive_hwm_key_b64(&key).is_ok());
288/// assert!(derive_hwm_key_b64("not base64!").is_err());
289/// ```
290pub fn derive_hwm_key_b64(b64_key: &str) -> Result<[u8; KEY_LEN], CipherKeyError> {
291    use base64::Engine as _;
292    let bytes = base64::engine::general_purpose::STANDARD
293        .decode(b64_key.trim())
294        .map_err(|_| CipherKeyError::MalformedEncoding)?;
295    if bytes.len() != KEY_LEN {
296        return Err(CipherKeyError::InvalidKeyLength {
297            expected: KEY_LEN,
298            actual: bytes.len(),
299        });
300    }
301    Ok(blake3::derive_key(HWM_CONTEXT, &bytes))
302}
303
304/// Generate a fresh random 32-byte durable payload key, base64-encoded for vault storage.
305///
306/// Stored under `ZEPH_DURABLE_KEY` (never inline in TOML); decode it back with
307/// [`XChaCha20Poly1305Cipher::from_vault_b64`]. Drawn from the OS CSPRNG.
308///
309/// # Examples
310///
311/// ```
312/// use zeph_core::durable::{generate_durable_key_b64, XChaCha20Poly1305Cipher};
313///
314/// let key = generate_durable_key_b64();
315/// assert!(XChaCha20Poly1305Cipher::from_vault_b64(&key).is_ok());
316/// ```
317#[must_use]
318pub fn generate_durable_key_b64() -> String {
319    use base64::Engine as _;
320    let key = Key::generate();
321    base64::engine::general_purpose::STANDARD.encode(key.as_slice())
322}
323
324/// Decode a base64-encoded 32-byte vault key value into raw key bytes.
325///
326/// Shared by [`XChaCha20Poly1305Cipher::from_vault_b64_with_id`] and callers that need to
327/// register a previous key for [`XChaCha20Poly1305Cipher::with_previous`] directly — e.g. `zeph
328/// durable rotate-key`'s `load_durable_cipher` chokepoint decoding `ZEPH_DURABLE_KEY_PREVIOUS`
329/// (#6447) — so the base64 decode path is not duplicated outside this module.
330///
331/// # Errors
332///
333/// Returns [`CipherKeyError::MalformedEncoding`] when `b64_key` is not valid base64, or
334/// [`CipherKeyError::InvalidKeyLength`] when the decoded key is not exactly 32 bytes.
335///
336/// # Examples
337///
338/// ```
339/// use zeph_core::durable::{decode_vault_key_bytes, generate_durable_key_b64};
340///
341/// let key = generate_durable_key_b64();
342/// assert_eq!(decode_vault_key_bytes(&key).unwrap().len(), 32);
343/// assert!(decode_vault_key_bytes("not base64!").is_err());
344/// ```
345pub fn decode_vault_key_bytes(b64_key: &str) -> Result<[u8; KEY_LEN], CipherKeyError> {
346    use base64::Engine as _;
347    let bytes = base64::engine::general_purpose::STANDARD
348        .decode(b64_key.trim())
349        .map_err(|_| CipherKeyError::MalformedEncoding)?;
350    bytes
351        .as_slice()
352        .try_into()
353        .map_err(|_| CipherKeyError::InvalidKeyLength {
354            expected: KEY_LEN,
355            actual: bytes.len(),
356        })
357}
358
359impl PayloadCipher for XChaCha20Poly1305Cipher {
360    fn seal(&self, plaintext: &[u8], aad: &PayloadAad) -> Result<Vec<u8>, CipherError> {
361        let aad_bytes = aad.canonical_bytes();
362        let nonce = XNonce::generate();
363        let ciphertext = self
364            .current
365            .cipher
366            .encrypt(
367                &nonce,
368                Payload {
369                    msg: plaintext,
370                    aad: &aad_bytes,
371                },
372            )
373            .map_err(|_| CipherError::Authentication)?;
374
375        let mut blob = Vec::with_capacity(KEY_ID_LEN + NONCE_LEN + ciphertext.len());
376        blob.push(self.current.key_id);
377        blob.extend_from_slice(nonce.as_slice());
378        blob.extend_from_slice(&ciphertext);
379        Ok(blob)
380    }
381
382    fn open(&self, sealed: &[u8], aad: &PayloadAad) -> Result<Vec<u8>, CipherError> {
383        if sealed.len() < MIN_SEALED_LEN {
384            return Err(CipherError::Malformed {
385                context: "sealed blob shorter than key-id + nonce + tag",
386            });
387        }
388        let key_id = sealed[0];
389        let cipher = self
390            .select(key_id)
391            .ok_or(CipherError::UnknownKeyId { key_id })?;
392
393        let nonce = XNonce::try_from(&sealed[KEY_ID_LEN..NONCE_END]).map_err(|_| {
394            CipherError::Malformed {
395                context: "nonce slice is not exactly NONCE_LEN bytes",
396            }
397        })?;
398        let ciphertext = &sealed[NONCE_END..];
399        let aad_bytes = aad.canonical_bytes();
400
401        cipher
402            .decrypt(
403                &nonce,
404                Payload {
405                    msg: ciphertext,
406                    aad: &aad_bytes,
407                },
408            )
409            .map_err(|_| CipherError::Authentication)
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use std::assert_matches;
416    use std::collections::HashSet;
417
418    use zeph_durable::cipher::EntryKindTag;
419    use zeph_durable::{DurableError, ExecutionId, StepId};
420
421    use super::*;
422
423    fn aad_for(exec: ExecutionId, step: u32) -> PayloadAad {
424        PayloadAad::new(exec, StepId::new(step), EntryKindTag::StepResult, None)
425    }
426
427    #[test]
428    fn seal_open_round_trip() {
429        let cipher = XChaCha20Poly1305Cipher::new(0, [1u8; 32]);
430        let aad = aad_for(ExecutionId::new(), 0);
431        for plaintext in [
432            b"".as_slice(),
433            b"x",
434            b"a longer journaled tool result payload",
435        ] {
436            let sealed = cipher.seal(plaintext, &aad).unwrap();
437            assert_eq!(cipher.open(&sealed, &aad).unwrap(), plaintext);
438        }
439    }
440
441    #[test]
442    fn sealed_blob_uses_key_id_nonce_tag_layout() {
443        let cipher = XChaCha20Poly1305Cipher::new(3, [2u8; 32]);
444        let aad = aad_for(ExecutionId::new(), 0);
445        let sealed = cipher.seal(b"", &aad).unwrap();
446        // key-id byte, then 24-byte nonce, then a 16-byte tag for empty plaintext.
447        assert_eq!(sealed.len(), KEY_ID_LEN + NONCE_LEN + TAG_LEN);
448        assert_eq!(sealed[0], 3, "leading byte is the current key-id");
449    }
450
451    #[test]
452    fn nonce_is_fresh_per_seal() {
453        let cipher = XChaCha20Poly1305Cipher::new(0, [9u8; 32]);
454        let aad = aad_for(ExecutionId::new(), 0);
455        let a = cipher.seal(b"same", &aad).unwrap();
456        let b = cipher.seal(b"same", &aad).unwrap();
457        // Identical plaintext + identical AAD must still yield distinct nonces (and ciphertext).
458        assert_ne!(a[KEY_ID_LEN..NONCE_END], b[KEY_ID_LEN..NONCE_END]);
459        assert_ne!(a, b);
460    }
461
462    // NFR-DE-06: a CSPRNG nonce of 192 bits must not repeat across 10^6 seals.
463    #[test]
464    #[ignore = "slow: 1M seal iterations — run explicitly or in integration suite"]
465    fn one_million_seals_produce_distinct_nonces() {
466        const SEALS: usize = 1_000_000;
467        let cipher = XChaCha20Poly1305Cipher::new(0, [4u8; 32]);
468        let aad = aad_for(ExecutionId::new(), 0);
469        let mut nonces: HashSet<[u8; NONCE_LEN]> = HashSet::with_capacity(SEALS);
470        for _ in 0..SEALS {
471            let sealed = cipher.seal(b"", &aad).unwrap();
472            let mut nonce = [0u8; NONCE_LEN];
473            nonce.copy_from_slice(&sealed[KEY_ID_LEN..NONCE_END]);
474            assert!(nonces.insert(nonce), "nonce reuse detected");
475        }
476        assert_eq!(nonces.len(), SEALS);
477    }
478
479    #[test]
480    fn open_under_different_step_fails_replay_integrity() {
481        let cipher = XChaCha20Poly1305Cipher::new(0, [5u8; 32]);
482        let exec = ExecutionId::new();
483        let sealed = cipher.seal(b"result", &aad_for(exec, 7)).unwrap();
484
485        let err = cipher.open(&sealed, &aad_for(exec, 8)).unwrap_err();
486        assert_matches!(err, CipherError::Authentication);
487        assert_matches!(DurableError::from(err), DurableError::ReplayIntegrity);
488    }
489
490    #[test]
491    fn open_under_different_execution_fails_replay_integrity() {
492        let cipher = XChaCha20Poly1305Cipher::new(0, [6u8; 32]);
493        let sealed = cipher
494            .seal(b"result", &aad_for(ExecutionId::new(), 0))
495            .unwrap();
496
497        let err = cipher
498            .open(&sealed, &aad_for(ExecutionId::new(), 0))
499            .unwrap_err();
500        assert_matches!(DurableError::from(err), DurableError::ReplayIntegrity);
501    }
502
503    #[test]
504    fn tampered_ciphertext_fails_authentication() {
505        let cipher = XChaCha20Poly1305Cipher::new(0, [7u8; 32]);
506        let aad = aad_for(ExecutionId::new(), 0);
507        let mut sealed = cipher.seal(b"result", &aad).unwrap();
508        let last = sealed.len() - 1;
509        sealed[last] ^= 0xFF;
510        assert_matches!(
511            cipher.open(&sealed, &aad).unwrap_err(),
512            CipherError::Authentication
513        );
514    }
515
516    #[test]
517    fn short_blob_is_malformed() {
518        let cipher = XChaCha20Poly1305Cipher::new(0, [0u8; 32]);
519        let aad = aad_for(ExecutionId::new(), 0);
520        let err = cipher.open(&[0u8; MIN_SEALED_LEN - 1], &aad).unwrap_err();
521        assert_matches!(err, CipherError::Malformed { .. });
522        assert_matches!(DurableError::from(err), DurableError::Decode { .. });
523    }
524
525    #[test]
526    fn unknown_key_id_fails_closed() {
527        let cipher = XChaCha20Poly1305Cipher::new(0, [1u8; 32]);
528        let aad = aad_for(ExecutionId::new(), 0);
529        let mut sealed = cipher.seal(b"x", &aad).unwrap();
530        sealed[0] = 200; // no key registered under id 200
531        assert_matches!(
532            cipher.open(&sealed, &aad).unwrap_err(),
533            CipherError::UnknownKeyId { key_id: 200 }
534        );
535    }
536
537    #[test]
538    fn previous_key_opens_during_rotation_window() {
539        // Seal under the old key (id 0), then rotate: current is id 1, previous is id 0.
540        let old = XChaCha20Poly1305Cipher::new(0, [1u8; 32]);
541        let aad = aad_for(ExecutionId::new(), 0);
542        let sealed = old.seal(b"in-flight", &aad).unwrap();
543
544        let rotated = XChaCha20Poly1305Cipher::new(1, [2u8; 32]).with_previous(0, [1u8; 32]);
545        // The old blob still opens via the previous key...
546        assert_eq!(rotated.open(&sealed, &aad).unwrap(), b"in-flight");
547        // ...while new seals use the current key-id.
548        assert_eq!(rotated.seal(b"new", &aad).unwrap()[0], 1);
549    }
550
551    #[test]
552    fn from_vault_bytes_validates_length() {
553        assert!(XChaCha20Poly1305Cipher::from_vault_bytes(0, &[0u8; 32]).is_ok());
554        // The cipher deliberately does not implement `Debug` (it holds key material), so match on
555        // the `Result` directly rather than calling `unwrap_err`.
556        assert!(matches!(
557            XChaCha20Poly1305Cipher::from_vault_bytes(0, b"short"),
558            Err(CipherKeyError::InvalidKeyLength {
559                expected: 32,
560                actual: 5
561            })
562        ));
563    }
564
565    #[test]
566    fn control_hmac_key_derives_deterministically_and_independently_of_the_aead_key() {
567        use base64::Engine as _;
568
569        let vault_key = generate_durable_key_b64();
570        let hmac_key = derive_control_hmac_key_b64(&vault_key).unwrap();
571
572        // Deterministic: the same vault value always derives the same subkey.
573        assert_eq!(derive_control_hmac_key_b64(&vault_key).unwrap(), hmac_key);
574
575        // Cryptographically independent of the raw AEAD key material (domain separation via a
576        // fixed `derive_key` context distinct from the AEAD cipher's own use of the raw bytes).
577        let raw_aead_key = base64::engine::general_purpose::STANDARD
578            .decode(vault_key.trim())
579            .unwrap();
580        assert_ne!(hmac_key.as_slice(), raw_aead_key.as_slice());
581    }
582
583    #[test]
584    fn control_hmac_key_rejects_malformed_or_mislength_input() {
585        use base64::Engine as _;
586
587        assert!(matches!(
588            derive_control_hmac_key_b64("not base64!"),
589            Err(CipherKeyError::MalformedEncoding)
590        ));
591        let short = base64::engine::general_purpose::STANDARD.encode(b"too short");
592        assert!(matches!(
593            derive_control_hmac_key_b64(&short),
594            Err(CipherKeyError::InvalidKeyLength {
595                expected: 32,
596                actual: 9
597            })
598        ));
599    }
600}