use chacha20poly1305::{
Key, KeyInit, XChaCha20Poly1305, XNonce,
aead::{Aead, Generate, Payload},
};
use zeph_durable::{CipherError, PayloadAad, PayloadCipher};
use zeroize::Zeroize;
const KEY_LEN: usize = 32;
const NONCE_LEN: usize = 24;
const TAG_LEN: usize = 16;
const KEY_ID_LEN: usize = 1;
const NONCE_END: usize = KEY_ID_LEN + NONCE_LEN;
const MIN_SEALED_LEN: usize = NONCE_END + TAG_LEN;
pub const DURABLE_KEY_ID: u8 = 0;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CipherKeyError {
#[error("durable cipher key must be {expected} bytes, got {actual}")]
InvalidKeyLength {
expected: usize,
actual: usize,
},
#[error("durable cipher key is not valid base64")]
MalformedEncoding,
}
struct KeySlot {
key_id: u8,
cipher: XChaCha20Poly1305,
}
impl KeySlot {
fn new(key_id: u8, mut key: [u8; KEY_LEN]) -> Self {
let cipher = XChaCha20Poly1305::new((&key).into());
key.zeroize();
Self { key_id, cipher }
}
}
pub struct XChaCha20Poly1305Cipher {
current: KeySlot,
previous: Option<KeySlot>,
}
impl XChaCha20Poly1305Cipher {
#[must_use]
pub fn new(key_id: u8, key: [u8; KEY_LEN]) -> Self {
Self {
current: KeySlot::new(key_id, key),
previous: None,
}
}
pub fn from_vault_bytes(key_id: u8, key: &[u8]) -> Result<Self, CipherKeyError> {
let array: [u8; KEY_LEN] =
key.try_into()
.map_err(|_| CipherKeyError::InvalidKeyLength {
expected: KEY_LEN,
actual: key.len(),
})?;
Ok(Self::new(key_id, array))
}
pub fn from_vault_b64(b64_key: &str) -> Result<Self, CipherKeyError> {
Self::from_vault_b64_with_id(DURABLE_KEY_ID, b64_key)
}
pub fn from_vault_b64_with_id(key_id: u8, b64_key: &str) -> Result<Self, CipherKeyError> {
let bytes = decode_vault_key_bytes(b64_key)?;
Ok(Self::new(key_id, bytes))
}
#[must_use]
pub fn with_previous(mut self, key_id: u8, key: [u8; KEY_LEN]) -> Self {
self.previous = Some(KeySlot::new(key_id, key));
self
}
fn select(&self, key_id: u8) -> Option<&XChaCha20Poly1305> {
if key_id == self.current.key_id {
Some(&self.current.cipher)
} else {
self.previous
.as_ref()
.filter(|slot| slot.key_id == key_id)
.map(|slot| &slot.cipher)
}
}
}
const CONTROL_HMAC_CONTEXT: &str = "zeph-durable v1 control-entry HMAC key 2026";
pub fn derive_control_hmac_key_b64(b64_key: &str) -> Result<[u8; KEY_LEN], CipherKeyError> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64_key.trim())
.map_err(|_| CipherKeyError::MalformedEncoding)?;
if bytes.len() != KEY_LEN {
return Err(CipherKeyError::InvalidKeyLength {
expected: KEY_LEN,
actual: bytes.len(),
});
}
Ok(blake3::derive_key(CONTROL_HMAC_CONTEXT, &bytes))
}
const HWM_CONTEXT: &str = "zeph-durable v1 execution high-water-mark HMAC key 2026";
pub fn derive_hwm_key_b64(b64_key: &str) -> Result<[u8; KEY_LEN], CipherKeyError> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64_key.trim())
.map_err(|_| CipherKeyError::MalformedEncoding)?;
if bytes.len() != KEY_LEN {
return Err(CipherKeyError::InvalidKeyLength {
expected: KEY_LEN,
actual: bytes.len(),
});
}
Ok(blake3::derive_key(HWM_CONTEXT, &bytes))
}
#[must_use]
pub fn generate_durable_key_b64() -> String {
use base64::Engine as _;
let key = Key::generate();
base64::engine::general_purpose::STANDARD.encode(key.as_slice())
}
pub fn decode_vault_key_bytes(b64_key: &str) -> Result<[u8; KEY_LEN], CipherKeyError> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64_key.trim())
.map_err(|_| CipherKeyError::MalformedEncoding)?;
bytes
.as_slice()
.try_into()
.map_err(|_| CipherKeyError::InvalidKeyLength {
expected: KEY_LEN,
actual: bytes.len(),
})
}
impl PayloadCipher for XChaCha20Poly1305Cipher {
fn seal(&self, plaintext: &[u8], aad: &PayloadAad) -> Result<Vec<u8>, CipherError> {
let aad_bytes = aad.canonical_bytes();
let nonce = XNonce::generate();
let ciphertext = self
.current
.cipher
.encrypt(
&nonce,
Payload {
msg: plaintext,
aad: &aad_bytes,
},
)
.map_err(|_| CipherError::Authentication)?;
let mut blob = Vec::with_capacity(KEY_ID_LEN + NONCE_LEN + ciphertext.len());
blob.push(self.current.key_id);
blob.extend_from_slice(nonce.as_slice());
blob.extend_from_slice(&ciphertext);
Ok(blob)
}
fn open(&self, sealed: &[u8], aad: &PayloadAad) -> Result<Vec<u8>, CipherError> {
if sealed.len() < MIN_SEALED_LEN {
return Err(CipherError::Malformed {
context: "sealed blob shorter than key-id + nonce + tag",
});
}
let key_id = sealed[0];
let cipher = self
.select(key_id)
.ok_or(CipherError::UnknownKeyId { key_id })?;
let nonce = XNonce::try_from(&sealed[KEY_ID_LEN..NONCE_END]).map_err(|_| {
CipherError::Malformed {
context: "nonce slice is not exactly NONCE_LEN bytes",
}
})?;
let ciphertext = &sealed[NONCE_END..];
let aad_bytes = aad.canonical_bytes();
cipher
.decrypt(
&nonce,
Payload {
msg: ciphertext,
aad: &aad_bytes,
},
)
.map_err(|_| CipherError::Authentication)
}
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use std::collections::HashSet;
use zeph_durable::cipher::EntryKindTag;
use zeph_durable::{DurableError, ExecutionId, StepId};
use super::*;
fn aad_for(exec: ExecutionId, step: u32) -> PayloadAad {
PayloadAad::new(exec, StepId::new(step), EntryKindTag::StepResult, None)
}
#[test]
fn seal_open_round_trip() {
let cipher = XChaCha20Poly1305Cipher::new(0, [1u8; 32]);
let aad = aad_for(ExecutionId::new(), 0);
for plaintext in [
b"".as_slice(),
b"x",
b"a longer journaled tool result payload",
] {
let sealed = cipher.seal(plaintext, &aad).unwrap();
assert_eq!(cipher.open(&sealed, &aad).unwrap(), plaintext);
}
}
#[test]
fn sealed_blob_uses_key_id_nonce_tag_layout() {
let cipher = XChaCha20Poly1305Cipher::new(3, [2u8; 32]);
let aad = aad_for(ExecutionId::new(), 0);
let sealed = cipher.seal(b"", &aad).unwrap();
assert_eq!(sealed.len(), KEY_ID_LEN + NONCE_LEN + TAG_LEN);
assert_eq!(sealed[0], 3, "leading byte is the current key-id");
}
#[test]
fn nonce_is_fresh_per_seal() {
let cipher = XChaCha20Poly1305Cipher::new(0, [9u8; 32]);
let aad = aad_for(ExecutionId::new(), 0);
let a = cipher.seal(b"same", &aad).unwrap();
let b = cipher.seal(b"same", &aad).unwrap();
assert_ne!(a[KEY_ID_LEN..NONCE_END], b[KEY_ID_LEN..NONCE_END]);
assert_ne!(a, b);
}
#[test]
#[ignore = "slow: 1M seal iterations — run explicitly or in integration suite"]
fn one_million_seals_produce_distinct_nonces() {
const SEALS: usize = 1_000_000;
let cipher = XChaCha20Poly1305Cipher::new(0, [4u8; 32]);
let aad = aad_for(ExecutionId::new(), 0);
let mut nonces: HashSet<[u8; NONCE_LEN]> = HashSet::with_capacity(SEALS);
for _ in 0..SEALS {
let sealed = cipher.seal(b"", &aad).unwrap();
let mut nonce = [0u8; NONCE_LEN];
nonce.copy_from_slice(&sealed[KEY_ID_LEN..NONCE_END]);
assert!(nonces.insert(nonce), "nonce reuse detected");
}
assert_eq!(nonces.len(), SEALS);
}
#[test]
fn open_under_different_step_fails_replay_integrity() {
let cipher = XChaCha20Poly1305Cipher::new(0, [5u8; 32]);
let exec = ExecutionId::new();
let sealed = cipher.seal(b"result", &aad_for(exec, 7)).unwrap();
let err = cipher.open(&sealed, &aad_for(exec, 8)).unwrap_err();
assert_matches!(err, CipherError::Authentication);
assert_matches!(DurableError::from(err), DurableError::ReplayIntegrity);
}
#[test]
fn open_under_different_execution_fails_replay_integrity() {
let cipher = XChaCha20Poly1305Cipher::new(0, [6u8; 32]);
let sealed = cipher
.seal(b"result", &aad_for(ExecutionId::new(), 0))
.unwrap();
let err = cipher
.open(&sealed, &aad_for(ExecutionId::new(), 0))
.unwrap_err();
assert_matches!(DurableError::from(err), DurableError::ReplayIntegrity);
}
#[test]
fn tampered_ciphertext_fails_authentication() {
let cipher = XChaCha20Poly1305Cipher::new(0, [7u8; 32]);
let aad = aad_for(ExecutionId::new(), 0);
let mut sealed = cipher.seal(b"result", &aad).unwrap();
let last = sealed.len() - 1;
sealed[last] ^= 0xFF;
assert_matches!(
cipher.open(&sealed, &aad).unwrap_err(),
CipherError::Authentication
);
}
#[test]
fn short_blob_is_malformed() {
let cipher = XChaCha20Poly1305Cipher::new(0, [0u8; 32]);
let aad = aad_for(ExecutionId::new(), 0);
let err = cipher.open(&[0u8; MIN_SEALED_LEN - 1], &aad).unwrap_err();
assert_matches!(err, CipherError::Malformed { .. });
assert_matches!(DurableError::from(err), DurableError::Decode { .. });
}
#[test]
fn unknown_key_id_fails_closed() {
let cipher = XChaCha20Poly1305Cipher::new(0, [1u8; 32]);
let aad = aad_for(ExecutionId::new(), 0);
let mut sealed = cipher.seal(b"x", &aad).unwrap();
sealed[0] = 200; assert_matches!(
cipher.open(&sealed, &aad).unwrap_err(),
CipherError::UnknownKeyId { key_id: 200 }
);
}
#[test]
fn previous_key_opens_during_rotation_window() {
let old = XChaCha20Poly1305Cipher::new(0, [1u8; 32]);
let aad = aad_for(ExecutionId::new(), 0);
let sealed = old.seal(b"in-flight", &aad).unwrap();
let rotated = XChaCha20Poly1305Cipher::new(1, [2u8; 32]).with_previous(0, [1u8; 32]);
assert_eq!(rotated.open(&sealed, &aad).unwrap(), b"in-flight");
assert_eq!(rotated.seal(b"new", &aad).unwrap()[0], 1);
}
#[test]
fn from_vault_bytes_validates_length() {
assert!(XChaCha20Poly1305Cipher::from_vault_bytes(0, &[0u8; 32]).is_ok());
assert!(matches!(
XChaCha20Poly1305Cipher::from_vault_bytes(0, b"short"),
Err(CipherKeyError::InvalidKeyLength {
expected: 32,
actual: 5
})
));
}
#[test]
fn control_hmac_key_derives_deterministically_and_independently_of_the_aead_key() {
use base64::Engine as _;
let vault_key = generate_durable_key_b64();
let hmac_key = derive_control_hmac_key_b64(&vault_key).unwrap();
assert_eq!(derive_control_hmac_key_b64(&vault_key).unwrap(), hmac_key);
let raw_aead_key = base64::engine::general_purpose::STANDARD
.decode(vault_key.trim())
.unwrap();
assert_ne!(hmac_key.as_slice(), raw_aead_key.as_slice());
}
#[test]
fn control_hmac_key_rejects_malformed_or_mislength_input() {
use base64::Engine as _;
assert!(matches!(
derive_control_hmac_key_b64("not base64!"),
Err(CipherKeyError::MalformedEncoding)
));
let short = base64::engine::general_purpose::STANDARD.encode(b"too short");
assert!(matches!(
derive_control_hmac_key_b64(&short),
Err(CipherKeyError::InvalidKeyLength {
expected: 32,
actual: 9
})
));
}
}