1use chacha20poly1305::{
31 Key, KeyInit, XChaCha20Poly1305, XNonce,
32 aead::{Aead, AeadCore, OsRng, Payload},
33};
34use zeph_durable::{CipherError, PayloadAad, PayloadCipher};
35use zeroize::Zeroize;
36
37const KEY_LEN: usize = 32;
39const NONCE_LEN: usize = 24;
41const TAG_LEN: usize = 16;
43const KEY_ID_LEN: usize = 1;
45const NONCE_END: usize = KEY_ID_LEN + NONCE_LEN;
47const MIN_SEALED_LEN: usize = NONCE_END + TAG_LEN;
49
50pub const DURABLE_KEY_ID: u8 = 0;
57
58#[derive(Debug, thiserror::Error)]
60#[non_exhaustive]
61pub enum CipherKeyError {
62 #[error("durable cipher key must be {expected} bytes, got {actual}")]
64 InvalidKeyLength {
65 expected: usize,
67 actual: usize,
69 },
70 #[error("durable cipher key is not valid base64")]
72 MalformedEncoding,
73}
74
75struct KeySlot {
77 key_id: u8,
78 cipher: XChaCha20Poly1305,
79}
80
81impl KeySlot {
82 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
90pub struct XChaCha20Poly1305Cipher {
100 current: KeySlot,
101 previous: Option<KeySlot>,
102}
103
104impl XChaCha20Poly1305Cipher {
105 #[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 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 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 #[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 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
192const CONTROL_HMAC_CONTEXT: &str = "zeph-durable v1 control-entry HMAC key 2026";
195
196pub 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#[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 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 assert_ne!(a[KEY_ID_LEN..NONCE_END], b[KEY_ID_LEN..NONCE_END]);
348 assert_ne!(a, b);
349 }
350
351 #[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; 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 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 assert_eq!(rotated.open(&sealed, &aad).unwrap(), b"in-flight");
436 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 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 assert_eq!(derive_control_hmac_key_b64(&vault_key).unwrap(), hmac_key);
463
464 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}