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}