use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand_core::{OsRng, RngCore};
use zeroize::Zeroizing;
pub(crate) const STATE_RECORD_KEY_BYTES: usize = 32;
const RECORD_MAGIC: [u8; 4] = *b"OPS1";
const NONCE_BYTES: usize = 12;
const TAG_BYTES: usize = 16;
pub(crate) const SEALED_RECORD_OVERHEAD: usize = RECORD_MAGIC.len() + NONCE_BYTES + TAG_BYTES;
const AAD_SEPARATOR: u8 = 0x1f;
#[derive(Clone)]
pub(crate) struct StateRecordCipher {
cipher: ChaCha20Poly1305,
}
impl std::fmt::Debug for StateRecordCipher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("StateRecordCipher(<redacted>)")
}
}
impl StateRecordCipher {
pub(crate) fn new(key: &[u8; STATE_RECORD_KEY_BYTES]) -> Self {
Self {
cipher: ChaCha20Poly1305::new(Key::from_slice(key)),
}
}
pub(crate) fn generate_key() -> Zeroizing<[u8; STATE_RECORD_KEY_BYTES]> {
let mut key = Zeroizing::new([0u8; STATE_RECORD_KEY_BYTES]);
OsRng.fill_bytes(&mut *key);
key
}
pub(crate) fn seal(&self, key_type: &str, key_id: &str, plaintext: &[u8]) -> Option<Vec<u8>> {
let mut nonce_bytes = [0u8; NONCE_BYTES];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let aad = Self::associated_data(key_type, key_id);
let ciphertext = self
.cipher
.encrypt(
nonce,
Payload {
msg: plaintext,
aad: &aad,
},
)
.ok()?;
let mut sealed = Vec::with_capacity(RECORD_MAGIC.len() + NONCE_BYTES + ciphertext.len());
sealed.extend_from_slice(&RECORD_MAGIC);
sealed.extend_from_slice(&nonce_bytes);
sealed.extend_from_slice(&ciphertext);
Some(sealed)
}
pub(crate) fn open(&self, key_type: &str, key_id: &str, sealed: &[u8]) -> Option<Vec<u8>> {
let header = RECORD_MAGIC.len() + NONCE_BYTES;
if sealed.len() <= header || sealed[..RECORD_MAGIC.len()] != RECORD_MAGIC {
return None;
}
let nonce = Nonce::from_slice(&sealed[RECORD_MAGIC.len()..header]);
let aad = Self::associated_data(key_type, key_id);
self.cipher
.decrypt(
nonce,
Payload {
msg: &sealed[header..],
aad: &aad,
},
)
.ok()
}
pub(crate) fn looks_sealed(data: &[u8]) -> bool {
data.len() > RECORD_MAGIC.len() && data[..RECORD_MAGIC.len()] == RECORD_MAGIC
}
fn associated_data(key_type: &str, key_id: &str) -> Vec<u8> {
let mut aad = Vec::with_capacity(RECORD_MAGIC.len() + key_type.len() + 1 + key_id.len());
aad.extend_from_slice(&RECORD_MAGIC);
aad.extend_from_slice(key_type.as_bytes());
aad.push(AAD_SEPARATOR);
aad.extend_from_slice(key_id.as_bytes());
aad
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cipher() -> StateRecordCipher {
StateRecordCipher::new(&[7u8; STATE_RECORD_KEY_BYTES])
}
#[test]
fn seal_then_open_roundtrips() {
let cipher = cipher();
let sealed = cipher.seal("pending_messages", "bob", b"secret").unwrap();
assert_ne!(sealed.windows(6).position(|w| w == b"secret"), Some(0));
assert!(!sealed.windows(6).any(|window| window == b"secret"));
assert_eq!(
cipher.open("pending_messages", "bob", &sealed).unwrap(),
b"secret"
);
}
#[test]
fn open_rejects_a_record_lifted_from_another_slot() {
let cipher = cipher();
let sealed = cipher.seal("pending_messages", "bob", b"secret").unwrap();
assert!(cipher.open("pending_messages", "carol", &sealed).is_none());
assert!(cipher.open("outbox", "bob", &sealed).is_none());
}
#[test]
fn open_rejects_a_foreign_key_tampering_and_plaintext() {
let sealed = cipher().seal("outbox", "msg-1", b"secret").unwrap();
let foreign = StateRecordCipher::new(&[9u8; STATE_RECORD_KEY_BYTES]);
assert!(foreign.open("outbox", "msg-1", &sealed).is_none());
let mut tampered = sealed.clone();
*tampered.last_mut().unwrap() ^= 0x01;
assert!(cipher().open("outbox", "msg-1", &tampered).is_none());
assert!(cipher().open("outbox", "msg-1", b"{\"a\":1}").is_none());
assert!(!StateRecordCipher::looks_sealed(b"{\"a\":1}"));
assert!(StateRecordCipher::looks_sealed(&sealed));
}
#[test]
fn sealed_overhead_matches_the_envelope() {
let sealed = cipher().seal("outbox", "msg-1", b"1234").unwrap();
assert_eq!(sealed.len(), 4 + SEALED_RECORD_OVERHEAD);
}
#[test]
fn nonces_do_not_repeat_across_seals() {
let cipher = cipher();
let first = cipher.seal("outbox", "msg-1", b"same").unwrap();
let second = cipher.seal("outbox", "msg-1", b"same").unwrap();
assert_ne!(first, second);
}
#[test]
fn generated_keys_are_not_constant() {
assert_ne!(
*StateRecordCipher::generate_key(),
*StateRecordCipher::generate_key()
);
}
}