use chacha20poly1305::aead::AeadInPlace;
use chacha20poly1305::{Key as AeadKey, KeyInit, XChaCha20Poly1305, XNonce};
use zeroize::Zeroizing;
pub const ENC_PAGE_SIZE: usize = 8232;
pub const NONCE_LEN: usize = 24;
pub const TAG_LEN: usize = 16;
pub const DEK_LEN: usize = 32;
pub const SALT_LEN: usize = 16;
#[derive(Clone)]
pub enum Key {
Raw(Zeroizing<Vec<u8>>),
Passphrase(Zeroizing<String>),
}
impl std::fmt::Debug for Key {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Key::Raw(_) => f.write_str("Key::Raw(<redacted>)"),
Key::Passphrase(_) => f.write_str("Key::Passphrase(<redacted>)"),
}
}
}
pub struct Dek(Zeroizing<[u8; DEK_LEN]>);
impl Dek {
pub fn from_bytes(bytes: [u8; DEK_LEN]) -> Self {
Dek(Zeroizing::new(bytes))
}
pub fn as_bytes(&self) -> &[u8; DEK_LEN] {
&self.0
}
}
impl Clone for Dek {
fn clone(&self) -> Self {
Dek(Zeroizing::new(*self.0))
}
}
pub struct Kek(Zeroizing<[u8; 32]>);
impl Kek {
#[cfg(test)]
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Kek(Zeroizing::new(bytes))
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum KdfId {
Hkdf = 1,
Argon2id = 2,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Argon2Params {
pub m_cost: u32, pub t_cost: u32, pub p_cost: u32, }
impl Default for Argon2Params {
fn default() -> Self {
Argon2Params {
m_cost: 19456,
t_cost: 2,
p_cost: 1,
}
}
}
#[derive(Debug, PartialEq)]
pub enum CryptoError {
Auth,
Kdf,
BadKeyLength,
}
use argon2::{Algorithm, Argon2, Params, Version};
use hkdf::Hkdf;
use sha2::Sha256;
const KEK_INFO: &[u8] = b"chisel-kek-v1";
pub fn derive_kek(
key: &Key,
kdf: KdfId,
salt: &[u8; SALT_LEN],
params: &Argon2Params,
) -> Result<Kek, CryptoError> {
let ikm: &[u8] = match key {
Key::Raw(bytes) => bytes.as_slice(),
Key::Passphrase(s) => s.as_bytes(),
};
if ikm.is_empty() {
return Err(CryptoError::BadKeyLength);
}
let mut okm = Zeroizing::new([0u8; 32]);
match kdf {
KdfId::Hkdf => {
let hk = Hkdf::<Sha256>::new(Some(salt), ikm);
hk.expand(KEK_INFO, okm.as_mut())
.map_err(|_| CryptoError::Kdf)?;
}
KdfId::Argon2id => {
let p = Params::new(params.m_cost, params.t_cost, params.p_cost, Some(32))
.map_err(|_| CryptoError::Kdf)?;
let a2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, p);
a2.hash_password_into(ikm, salt, okm.as_mut())
.map_err(|_| CryptoError::Kdf)?;
}
}
Ok(Kek(okm))
}
fn seal_detached(
key: &[u8; 32],
nonce: &[u8; NONCE_LEN],
aad: &[u8],
plaintext: &[u8],
) -> (Vec<u8>, [u8; TAG_LEN]) {
let cipher = XChaCha20Poly1305::new(AeadKey::from_slice(key));
let mut buf = plaintext.to_vec();
let tag = cipher
.encrypt_in_place_detached(XNonce::from_slice(nonce), aad, &mut buf)
.expect("XChaCha20-Poly1305 encrypt cannot fail for in-range lengths");
let mut tag_arr = [0u8; TAG_LEN];
tag_arr.copy_from_slice(&tag);
(buf, tag_arr)
}
fn open_detached(
key: &[u8; 32],
nonce: &[u8; NONCE_LEN],
aad: &[u8],
ciphertext: &[u8],
tag: &[u8; TAG_LEN],
) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
let cipher = XChaCha20Poly1305::new(AeadKey::from_slice(key));
let mut buf = Zeroizing::new(ciphertext.to_vec());
cipher
.decrypt_in_place_detached(
XNonce::from_slice(nonce),
aad,
&mut buf,
tag.as_slice().into(),
)
.map_err(|_| CryptoError::Auth)?;
Ok(buf)
}
pub fn wrap_dek(
kek: &Kek,
dek: &Dek,
wrap_nonce: &[u8; NONCE_LEN],
aad: &[u8],
) -> ([u8; DEK_LEN], [u8; TAG_LEN]) {
let (ct, tag) = seal_detached(kek.as_bytes(), wrap_nonce, aad, dek.as_bytes());
let mut wrapped = [0u8; DEK_LEN];
wrapped.copy_from_slice(&ct);
(wrapped, tag)
}
pub fn unwrap_dek(
kek: &Kek,
wrapped: &[u8; DEK_LEN],
tag: &[u8; TAG_LEN],
wrap_nonce: &[u8; NONCE_LEN],
aad: &[u8],
) -> Result<Dek, CryptoError> {
let pt = open_detached(kek.as_bytes(), wrap_nonce, aad, wrapped, tag)?;
let mut dek_bytes = Zeroizing::new([0u8; DEK_LEN]);
dek_bytes.copy_from_slice(&pt);
Ok(Dek::from_bytes(*dek_bytes))
}
pub fn random_array<const N: usize>() -> [u8; N] {
let mut b = [0u8; N];
getrandom::getrandom(&mut b).expect("OS RNG unavailable");
b
}
pub fn random_dek() -> Dek {
Dek::from_bytes(random_array::<DEK_LEN>())
}
#[derive(Clone)]
pub struct PageCipher {
dek: Dek,
}
impl PageCipher {
pub fn new(dek: Dek) -> Self {
PageCipher { dek }
}
pub fn seal(&self, page_id: u64, plaintext: &[u8; 8192]) -> [u8; ENC_PAGE_SIZE] {
let nonce = random_array::<NONCE_LEN>();
let aad = page_id.to_le_bytes();
let (ct, tag) = seal_detached(self.dek.as_bytes(), &nonce, &aad, plaintext);
let mut out = [0u8; ENC_PAGE_SIZE];
out[0..8192].copy_from_slice(&ct);
out[8192..8208].copy_from_slice(&tag);
out[8208..8232].copy_from_slice(&nonce);
out
}
pub fn open(
&self,
page_id: u64,
ondisk: &[u8; ENC_PAGE_SIZE],
) -> Result<[u8; 8192], CryptoError> {
let ct = &ondisk[0..8192];
let mut tag = [0u8; TAG_LEN];
tag.copy_from_slice(&ondisk[8192..8208]);
let mut nonce = [0u8; NONCE_LEN];
nonce.copy_from_slice(&ondisk[8208..8232]);
let aad = page_id.to_le_bytes();
let pt = open_detached(self.dek.as_bytes(), &nonce, &aad, ct, &tag)?;
let mut page = [0u8; 8192];
page.copy_from_slice(&pt);
Ok(page)
}
pub fn seal_body(
&self,
aad: &[u8],
plaintext: &[u8],
) -> ([u8; NONCE_LEN], [u8; TAG_LEN], Vec<u8>) {
let nonce = random_array::<NONCE_LEN>();
let (ct, tag) = seal_detached(self.dek.as_bytes(), &nonce, aad, plaintext);
(nonce, tag, ct)
}
pub fn open_body(
&self,
aad: &[u8],
nonce: &[u8; NONCE_LEN],
tag: &[u8; TAG_LEN],
ct: &[u8],
) -> Result<Vec<u8>, CryptoError> {
open_detached(self.dek.as_bytes(), nonce, aad, ct, tag).map(|z| z.to_vec())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constants_match_spec() {
assert_eq!(ENC_PAGE_SIZE, 8232);
assert_eq!(NONCE_LEN, 24);
assert_eq!(TAG_LEN, 16);
assert_eq!(DEK_LEN, 32);
assert_eq!(SALT_LEN, 16);
assert_eq!(ENC_PAGE_SIZE, 8192 + TAG_LEN + NONCE_LEN);
}
#[test]
fn argon2_params_default_is_owasp() {
let p = Argon2Params::default();
assert_eq!(p.m_cost, 19456); assert_eq!(p.t_cost, 2);
assert_eq!(p.p_cost, 1);
}
#[test]
fn kdf_id_discriminants_are_wire_stable() {
assert_eq!(KdfId::Hkdf as u8, 1);
assert_eq!(KdfId::Argon2id as u8, 2);
assert_ne!(KdfId::Hkdf, KdfId::Argon2id);
}
#[test]
fn random_array_is_os_filled_and_distinct() {
let a: [u8; 32] = random_array();
let b: [u8; 32] = random_array();
assert_ne!(a, b);
assert_ne!(a, [0u8; 32]);
}
#[test]
fn random_dek_differs_each_call() {
let d1 = random_dek();
let d2 = random_dek();
assert_ne!(d1.as_bytes(), d2.as_bytes());
}
#[test]
fn crypto_error_is_comparable() {
assert_eq!(CryptoError::Auth, CryptoError::Auth);
assert_ne!(CryptoError::Auth, CryptoError::Kdf);
}
#[test]
fn derive_kek_hkdf_matches_reference_construction() {
let ikm = [0x0bu8; 22];
let salt: [u8; SALT_LEN] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
0x0e, 0x0f,
];
let key = Key::Raw(zeroize::Zeroizing::new(ikm.to_vec()));
let kek = derive_kek(&key, KdfId::Hkdf, &salt, &Argon2Params::default()).unwrap();
use hkdf::Hkdf;
use sha2::Sha256;
let hk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
let mut expect = [0u8; 32];
hk.expand(b"chisel-kek-v1", &mut expect).unwrap();
assert_eq!(kek.as_bytes(), &expect);
}
#[test]
fn derive_kek_hkdf_is_deterministic_and_salt_sensitive() {
let key = Key::Raw(zeroize::Zeroizing::new(vec![7u8; 32]));
let salt_a = [1u8; SALT_LEN];
let salt_b = [2u8; SALT_LEN];
let p = Argon2Params::default();
let k1 = derive_kek(&key, KdfId::Hkdf, &salt_a, &p).unwrap();
let k2 = derive_kek(&key, KdfId::Hkdf, &salt_a, &p).unwrap();
let k3 = derive_kek(&key, KdfId::Hkdf, &salt_b, &p).unwrap();
assert_eq!(
k1.as_bytes(),
k2.as_bytes(),
"same input must be deterministic"
);
assert_ne!(k1.as_bytes(), k3.as_bytes(), "different salt must diverge");
}
#[test]
fn derive_kek_argon2_roundtrips_and_is_salt_sensitive() {
let fast = Argon2Params {
m_cost: 256,
t_cost: 1,
p_cost: 1,
};
let key = Key::Passphrase(zeroize::Zeroizing::new("correct horse".to_string()));
let salt_a = [9u8; SALT_LEN];
let salt_b = [8u8; SALT_LEN];
let k1 = derive_kek(&key, KdfId::Argon2id, &salt_a, &fast).unwrap();
let k2 = derive_kek(&key, KdfId::Argon2id, &salt_a, &fast).unwrap();
let k3 = derive_kek(&key, KdfId::Argon2id, &salt_b, &fast).unwrap();
assert_eq!(
k1.as_bytes(),
k2.as_bytes(),
"Argon2id must be deterministic"
);
assert_ne!(k1.as_bytes(), k3.as_bytes(), "different salt must diverge");
assert_ne!(k1.as_bytes(), &[0u8; 32]);
}
#[test]
fn derive_kek_argon2_rejects_zero_memory() {
let bad = Argon2Params {
m_cost: 0,
t_cost: 1,
p_cost: 1,
};
let key = Key::Passphrase(zeroize::Zeroizing::new("x".to_string()));
assert!(matches!(
derive_kek(&key, KdfId::Argon2id, &[0u8; SALT_LEN], &bad),
Err(CryptoError::Kdf)
));
}
#[test]
fn wrap_unwrap_roundtrip() {
let kek = Kek::from_bytes([3u8; 32]);
let dek = Dek::from_bytes([42u8; DEK_LEN]);
let nonce = [5u8; NONCE_LEN];
let aad = b"slot-meta";
let (wrapped, tag) = wrap_dek(&kek, &dek, &nonce, aad);
assert_ne!(
&wrapped,
dek.as_bytes(),
"wrapped DEK must not equal plaintext DEK"
);
let out = unwrap_dek(&kek, &wrapped, &tag, &nonce, aad).unwrap();
assert_eq!(out.as_bytes(), dek.as_bytes());
}
#[test]
fn unwrap_wrong_kek_is_auth() {
let dek = Dek::from_bytes([42u8; DEK_LEN]);
let nonce = [5u8; NONCE_LEN];
let aad = b"slot-meta";
let (wrapped, tag) = wrap_dek(&Kek::from_bytes([3u8; 32]), &dek, &nonce, aad);
assert!(matches!(
unwrap_dek(&Kek::from_bytes([4u8; 32]), &wrapped, &tag, &nonce, aad),
Err(CryptoError::Auth)
));
}
#[test]
fn unwrap_tampered_tag_is_auth() {
let kek = Kek::from_bytes([3u8; 32]);
let dek = Dek::from_bytes([42u8; DEK_LEN]);
let nonce = [5u8; NONCE_LEN];
let aad = b"slot-meta";
let (wrapped, mut tag) = wrap_dek(&kek, &dek, &nonce, aad);
tag[0] ^= 0x01;
assert!(matches!(
unwrap_dek(&kek, &wrapped, &tag, &nonce, aad),
Err(CryptoError::Auth)
));
}
#[test]
fn unwrap_tampered_ciphertext_is_auth() {
let kek = Kek::from_bytes([3u8; 32]);
let dek = Dek::from_bytes([42u8; DEK_LEN]);
let nonce = [5u8; NONCE_LEN];
let aad = b"slot-meta";
let (mut wrapped, tag) = wrap_dek(&kek, &dek, &nonce, aad);
wrapped[0] ^= 0x01;
assert!(matches!(
unwrap_dek(&kek, &wrapped, &tag, &nonce, aad),
Err(CryptoError::Auth)
));
}
#[test]
fn unwrap_wrong_aad_is_auth() {
let kek = Kek::from_bytes([3u8; 32]);
let dek = Dek::from_bytes([42u8; DEK_LEN]);
let nonce = [5u8; NONCE_LEN];
let (wrapped, tag) = wrap_dek(&kek, &dek, &nonce, b"slot-meta-A");
assert!(matches!(
unwrap_dek(&kek, &wrapped, &tag, &nonce, b"slot-meta-B"),
Err(CryptoError::Auth)
));
}
#[test]
fn page_seal_open_roundtrip() {
let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
let mut page = [0u8; 8192];
for (i, b) in page.iter_mut().enumerate() {
*b = (i % 251) as u8;
}
let blob = pc.seal(7, &page);
assert_eq!(blob.len(), ENC_PAGE_SIZE);
let out = pc.open(7, &blob).unwrap();
assert_eq!(out, page);
}
#[test]
fn page_seal_layout_is_ct_tag_nonce() {
let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
let page = [0xABu8; 8192];
let blob = pc.seal(0, &page);
assert_ne!(
&blob[0..8192],
&page[..],
"ciphertext must differ from plaintext"
);
}
#[test]
fn page_open_wrong_page_id_is_auth() {
let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
let page = [9u8; 8192];
let blob = pc.seal(7, &page);
assert_eq!(pc.open(8, &blob).unwrap_err(), CryptoError::Auth);
}
#[test]
fn page_open_byte_flip_is_auth() {
let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
let page = [9u8; 8192];
let mut blob = pc.seal(7, &page);
blob[100] ^= 0x01; assert_eq!(pc.open(7, &blob).unwrap_err(), CryptoError::Auth);
}
#[test]
fn page_two_seals_use_different_nonces() {
let pc = PageCipher::new(Dek::from_bytes([1u8; DEK_LEN]));
let page = [9u8; 8192];
let a = pc.seal(7, &page);
let b = pc.seal(7, &page);
assert_ne!(&a[..], &b[..], "nonce reuse: identical blobs for same page");
assert_eq!(pc.open(7, &a).unwrap(), page);
assert_eq!(pc.open(7, &b).unwrap(), page);
}
#[test]
fn body_seal_open_roundtrip() {
let pc = PageCipher::new(Dek::from_bytes([2u8; DEK_LEN]));
let body = b"root pointers + named_roots".to_vec();
let aad = b"sb-identity";
let (nonce, tag, ct) = pc.seal_body(aad, &body);
assert_eq!(ct.len(), body.len(), "body cipher is length-preserving");
let out = pc.open_body(aad, &nonce, &tag, &ct).unwrap();
assert_eq!(out, body);
}
#[test]
fn body_open_wrong_aad_is_auth() {
let pc = PageCipher::new(Dek::from_bytes([2u8; DEK_LEN]));
let body = b"secret".to_vec();
let (nonce, tag, ct) = pc.seal_body(b"sb-A", &body);
assert_eq!(
pc.open_body(b"sb-B", &nonce, &tag, &ct).unwrap_err(),
CryptoError::Auth
);
}
#[test]
fn dek_clone_is_independent_zeroizing_copy() {
let d = Dek::from_bytes([7u8; DEK_LEN]);
let c = d.clone();
assert_eq!(d.as_bytes(), c.as_bytes());
drop(c);
assert_eq!(d.as_bytes(), &[7u8; DEK_LEN]);
}
#[test]
fn key_variants_construct_from_zeroizing() {
let raw = Key::Raw(zeroize::Zeroizing::new(vec![1u8, 2, 3]));
let pass = Key::Passphrase(zeroize::Zeroizing::new("pw".to_string()));
let _r2 = raw.clone();
let _p2 = pass.clone();
}
#[test]
fn argon2id_known_answer_test() {
let key = Key::Passphrase(zeroize::Zeroizing::new("password".to_string()));
let salt = *b"somesalt12345678";
let params = Argon2Params {
m_cost: 8,
t_cost: 1,
p_cost: 1,
};
let kek = derive_kek(&key, KdfId::Argon2id, &salt, ¶ms).unwrap();
let expected: [u8; 32] = [
0xd8, 0x38, 0x04, 0x14, 0x00, 0x12, 0xc3, 0xe6, 0xd3, 0x50, 0x2a, 0x3e, 0xb5, 0x9f,
0xc2, 0x4a, 0x89, 0xa9, 0xec, 0x08, 0xb6, 0xac, 0x97, 0xbe, 0x1f, 0xec, 0xa1, 0x70,
0x0a, 0xbe, 0x0a, 0xfb,
];
assert_eq!(
kek.as_bytes(),
&expected,
"Argon2id output changed — KDF config or format break; update golden and bump FORMAT_VERSION"
);
}
}