use chacha20poly1305::{
aead::{Aead, KeyInit, Payload},
XChaCha20Poly1305, XNonce,
};
use rand::{rngs::OsRng, RngCore};
use sparrowdb_common::{Error, Result};
pub struct EncryptionContext {
cipher: Option<XChaCha20Poly1305>,
}
impl EncryptionContext {
pub fn none() -> Self {
Self { cipher: None }
}
pub fn with_key(key: [u8; 32]) -> Self {
let cipher = XChaCha20Poly1305::new(&key.into());
Self {
cipher: Some(cipher),
}
}
pub fn is_encrypted(&self) -> bool {
self.cipher.is_some()
}
pub fn encrypt_wal_payload(&self, lsn: u64, plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = match &self.cipher {
None => return Ok(plaintext.to_vec()),
Some(c) => c,
};
let mut nonce_bytes = [0u8; 24];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = *XNonce::from_slice(&nonce_bytes);
let aad = lsn.to_le_bytes();
let ciphertext = cipher
.encrypt(
&nonce,
Payload {
msg: plaintext,
aad: &aad,
},
)
.map_err(|_| Error::Corruption("XChaCha20-Poly1305 WAL encrypt failed".into()))?;
let mut output = Vec::with_capacity(24 + ciphertext.len());
output.extend_from_slice(nonce.as_slice());
output.extend_from_slice(&ciphertext);
Ok(output)
}
pub fn decrypt_wal_payload(&self, lsn: u64, encrypted: &[u8]) -> Result<Vec<u8>> {
let cipher = match &self.cipher {
None => return Ok(encrypted.to_vec()),
Some(c) => c,
};
if encrypted.len() < 40 {
return Err(Error::InvalidArgument(format!(
"encrypted WAL payload is {} bytes; minimum is 40 (24-byte nonce + 16-byte tag)",
encrypted.len()
)));
}
let nonce = XNonce::from_slice(&encrypted[..24]);
let aad = lsn.to_le_bytes();
let plaintext = cipher
.decrypt(
nonce,
Payload {
msg: &encrypted[24..],
aad: &aad,
},
)
.map_err(|_| Error::EncryptionAuthFailed)?;
Ok(plaintext)
}
pub fn encrypt_page(&self, page_id: u64, plaintext: &[u8]) -> Result<Vec<u8>> {
let cipher = match &self.cipher {
None => return Ok(plaintext.to_vec()),
Some(c) => c,
};
let mut nonce_bytes = [0u8; 24];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = *XNonce::from_slice(&nonce_bytes);
let aad = page_id.to_le_bytes();
let ciphertext = cipher
.encrypt(
&nonce,
Payload {
msg: plaintext,
aad: &aad,
},
)
.map_err(|_| Error::Corruption("XChaCha20-Poly1305 encrypt failed".into()))?;
let mut output = Vec::with_capacity(24 + ciphertext.len());
output.extend_from_slice(nonce.as_slice());
output.extend_from_slice(&ciphertext);
Ok(output)
}
pub fn decrypt_page(&self, page_id: u64, encrypted: &[u8]) -> Result<Vec<u8>> {
let cipher = match &self.cipher {
None => return Ok(encrypted.to_vec()),
Some(c) => c,
};
if encrypted.len() < 40 {
return Err(Error::InvalidArgument(format!(
"encrypted page is {} bytes; minimum is 40 (24-byte nonce + 16-byte tag)",
encrypted.len()
)));
}
let nonce = XNonce::from_slice(&encrypted[..24]);
let aad = page_id.to_le_bytes();
let plaintext = cipher
.decrypt(
nonce,
Payload {
msg: &encrypted[24..],
aad: &aad,
},
)
.map_err(|_| Error::EncryptionAuthFailed)?;
Ok(plaintext)
}
}
#[cfg(test)]
mod unit_tests {
use super::*;
#[test]
fn nonces_are_random() {
let ctx = EncryptionContext::with_key([0x01; 32]);
let pt = vec![0u8; 32];
let ct0 = ctx.encrypt_page(0, &pt).unwrap();
let ct1 = ctx.encrypt_page(0, &pt).unwrap();
assert_ne!(
&ct0[..24],
&ct1[..24],
"nonces must be random, not deterministic"
);
}
#[test]
fn encrypt_output_length() {
let ctx = EncryptionContext::with_key([0x01; 32]);
let pt = vec![0u8; 512];
let ct = ctx.encrypt_page(0, &pt).unwrap();
assert_eq!(ct.len(), 512 + 40);
}
#[test]
fn passthrough_is_identity() {
let ctx = EncryptionContext::none();
let data = vec![0xFFu8; 256];
assert_eq!(ctx.encrypt_page(5, &data).unwrap(), data);
assert_eq!(ctx.decrypt_page(5, &data).unwrap(), data);
}
#[test]
fn too_short_ciphertext_is_rejected() {
let ctx = EncryptionContext::with_key([0x01; 32]);
let result = ctx.decrypt_page(0, &[0u8; 39]);
assert!(matches!(result, Err(Error::InvalidArgument(_))));
}
}