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
192#[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 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 assert_ne!(a[KEY_ID_LEN..NONCE_END], b[KEY_ID_LEN..NONCE_END]);
308 assert_ne!(a, b);
309 }
310
311 #[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; 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 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 assert_eq!(rotated.open(&sealed, &aad).unwrap(), b"in-flight");
396 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 assert!(matches!(
406 XChaCha20Poly1305Cipher::from_vault_bytes(0, b"short"),
407 Err(CipherKeyError::InvalidKeyLength {
408 expected: 32,
409 actual: 5
410 })
411 ));
412 }
413}