use chacha20poly1305::aead::Aead;
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
use rand::Rng;
pub const FORMAT_TAG: u8 = 0x06;
pub const NONCE_LEN: usize = 24;
pub const TAG_LEN: usize = 16;
pub const KEY_LEN: usize = 32;
const MIN_BLOB_LEN: usize = 1 + NONCE_LEN + TAG_LEN;
fn cipher(key: &[u8; KEY_LEN]) -> XChaCha20Poly1305 {
XChaCha20Poly1305::new_from_slice(key).expect("key is exactly 32 bytes")
}
pub fn is_v6_blob(blob: &[u8]) -> bool {
blob.len() >= MIN_BLOB_LEN && blob[0] == FORMAT_TAG
}
pub fn seal(key: &[u8; KEY_LEN], plaintext: &[u8]) -> Result<Vec<u8>, String> {
let mut nonce_bytes = [0u8; NONCE_LEN];
rand::rng().fill_bytes(&mut nonce_bytes);
let nonce = XNonce::from(nonce_bytes);
let ct = cipher(key)
.encrypt(&nonce, plaintext)
.map_err(|_| "AEAD seal failed".to_string())?;
let mut out = Vec::with_capacity(1 + NONCE_LEN + ct.len());
out.push(FORMAT_TAG);
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ct);
Ok(out)
}
pub fn open(key: &[u8; KEY_LEN], blob: &[u8]) -> Result<Vec<u8>, String> {
if blob.len() < MIN_BLOB_LEN {
return Err("AEAD blob too short".to_string());
}
if blob[0] != FORMAT_TAG {
return Err("not a v6 AEAD blob".to_string());
}
let mut nonce_bytes = [0u8; NONCE_LEN];
nonce_bytes.copy_from_slice(&blob[1..1 + NONCE_LEN]);
let nonce = XNonce::from(nonce_bytes);
let ciphertext = &blob[1 + NONCE_LEN..];
cipher(key)
.decrypt(&nonce, ciphertext)
.map_err(|_| "AEAD open failed (authentication)".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: [u8; KEY_LEN] = [7u8; KEY_LEN];
#[test]
fn roundtrip() {
let blob = seal(&KEY, b"hello secret").unwrap();
assert_eq!(open(&KEY, &blob).unwrap(), b"hello secret");
}
#[test]
fn roundtrip_empty_plaintext() {
let blob = seal(&KEY, b"").unwrap();
assert!(is_v6_blob(&blob));
assert_eq!(open(&KEY, &blob).unwrap(), b"");
}
#[test]
fn roundtrip_unicode() {
let msg = "Привет мир! 你好世界! مرحبا".as_bytes();
let blob = seal(&KEY, msg).unwrap();
assert_eq!(open(&KEY, &blob).unwrap(), msg);
}
#[test]
fn blob_is_self_describing() {
let blob = seal(&KEY, b"x").unwrap();
assert_eq!(blob[0], FORMAT_TAG);
assert!(is_v6_blob(&blob));
assert_eq!(blob.len(), 1 + NONCE_LEN + 1 + TAG_LEN);
}
#[test]
fn two_seals_differ_distinct_nonces() {
let a = seal(&KEY, b"same plaintext").unwrap();
let b = seal(&KEY, b"same plaintext").unwrap();
assert_ne!(a, b, "nonce reuse would make these equal");
assert_eq!(open(&KEY, &a).unwrap(), open(&KEY, &b).unwrap());
}
#[test]
fn wrong_key_fails() {
let blob = seal(&KEY, b"secret").unwrap();
let wrong = [9u8; KEY_LEN];
assert!(open(&wrong, &blob).is_err());
}
#[test]
fn single_bit_tamper_is_detected() {
let mut blob = seal(&KEY, b"important value").unwrap();
let last = blob.len() - 1;
blob[last] ^= 0x01; assert!(open(&KEY, &blob).is_err(), "tamper must be rejected, not returned as garbage");
}
#[test]
fn ciphertext_tamper_is_detected() {
let mut blob = seal(&KEY, b"important value").unwrap();
blob[1 + NONCE_LEN] ^= 0x01; assert!(open(&KEY, &blob).is_err());
}
#[test]
fn truncated_blob_fails() {
let blob = seal(&KEY, b"secret").unwrap();
assert!(open(&KEY, &blob[..MIN_BLOB_LEN - 1]).is_err());
}
#[test]
fn legacy_blob_rejected() {
let legacy = vec![0x53u8, 0xda, 0x92, 0xa5];
assert!(!is_v6_blob(&legacy));
assert!(open(&KEY, &legacy).is_err());
}
#[test]
fn wrong_tag_but_long_enough_is_rejected() {
let blob = vec![0u8; MIN_BLOB_LEN + 4];
assert!(!is_v6_blob(&blob));
assert!(open(&KEY, &blob).is_err());
}
}