use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
use crate::error::CoreError;
use crate::record::SecretRecord;
pub const KEY_LEN: usize = 32;
pub const NONCE_LEN: usize = 12;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SealedRecord {
pub nonce: Vec<u8>,
pub ciphertext: Vec<u8>,
}
pub fn seal_bytes(plaintext: &[u8], key: &[u8; KEY_LEN]) -> Result<SealedRecord, CoreError> {
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|_| CoreError::Crypto)?;
Ok(SealedRecord {
nonce: nonce.to_vec(),
ciphertext,
})
}
pub fn open_bytes(sealed: &SealedRecord, key: &[u8; KEY_LEN]) -> Result<Vec<u8>, CoreError> {
if sealed.nonce.len() != NONCE_LEN {
return Err(CoreError::Crypto);
}
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce = Nonce::from_slice(&sealed.nonce);
cipher
.decrypt(nonce, sealed.ciphertext.as_slice())
.map_err(|_| CoreError::Crypto)
}
pub fn seal(record: &SecretRecord, key: &[u8; KEY_LEN]) -> Result<SealedRecord, CoreError> {
let mut plaintext =
serde_json::to_vec(record).map_err(|e| CoreError::Serialization(e.to_string()))?;
let sealed = seal_bytes(&plaintext, key);
plaintext.zeroize();
sealed
}
pub fn open(sealed: &SealedRecord, key: &[u8; KEY_LEN]) -> Result<SecretRecord, CoreError> {
let mut plaintext = open_bytes(sealed, key)?;
let record = serde_json::from_slice::<SecretRecord>(&plaintext)
.map_err(|e| CoreError::Serialization(e.to_string()));
plaintext.zeroize();
record
}
#[cfg(test)]
mod tests {
use super::*;
use crate::secret::SecretValue;
use crate::sensitivity::Sensitivity;
fn key() -> [u8; KEY_LEN] {
[7u8; KEY_LEN]
}
fn literal(value: &str) -> SecretRecord {
SecretRecord::Literal {
value: SecretValue::from(value),
sensitivity: Sensitivity::High,
revealable: false,
environment: "prod".to_string(),
component: "db".to_string(),
key: "password".to_string(),
description: None,
created: "2026-05-30T00:00:00Z".to_string(),
updated: "2026-05-30T00:00:00Z".to_string(),
}
}
#[test]
fn seal_open_round_trip() {
let record = literal("hunter2");
let sealed = seal(&record, &key()).unwrap();
let opened = open(&sealed, &key()).unwrap();
assert_eq!(opened, record);
}
#[test]
fn nonce_is_unique_per_write() {
let record = literal("hunter2");
let a = seal(&record, &key()).unwrap();
let b = seal(&record, &key()).unwrap();
assert_ne!(a.nonce, b.nonce);
assert_ne!(a.ciphertext, b.ciphertext);
}
#[test]
fn sealed_bytes_do_not_contain_plaintext() {
let sealed = seal(&literal("hunter2"), &key()).unwrap();
assert!(!sealed.ciphertext.windows(7).any(|w| w == b"hunter2"));
assert!(!sealed.ciphertext.windows(8).any(|w| w == b"password"));
}
#[test]
fn wrong_key_fails_to_open() {
let sealed = seal(&literal("hunter2"), &key()).unwrap();
let err = open(&sealed, &[9u8; KEY_LEN]).unwrap_err();
assert!(matches!(err, CoreError::Crypto));
}
#[test]
fn tampered_ciphertext_fails_to_open() {
let mut sealed = seal(&literal("hunter2"), &key()).unwrap();
sealed.ciphertext[0] ^= 0xff;
assert!(matches!(open(&sealed, &key()), Err(CoreError::Crypto)));
}
#[test]
fn malformed_nonce_fails_to_open() {
let mut sealed = seal(&literal("hunter2"), &key()).unwrap();
sealed.nonce.truncate(4);
assert!(matches!(open(&sealed, &key()), Err(CoreError::Crypto)));
}
}