use getrandom::getrandom;
use zeroize::Zeroizing;
use crate::error::KwtError;
use crate::primitive::hkdf::hkdf_sha256;
use crate::primitive::xchacha20poly1305;
const HKDF_INFO_V1: &[u8] = b"kwt-v1-encryption";
pub const KEY_SIZE: usize = 32;
pub const NONCE_SIZE: usize = 24;
#[derive(Clone)]
pub struct MasterKey(pub Zeroizing<[u8; KEY_SIZE]>);
impl MasterKey {
pub fn from_bytes(bytes: &[u8]) -> Result<Self, KwtError> {
if bytes.len() != KEY_SIZE {
return Err(KwtError::InvalidClaim(format!(
"master key must be exactly {} bytes, got {}",
KEY_SIZE,
bytes.len()
)));
}
let mut arr = [0u8; KEY_SIZE];
arr.copy_from_slice(bytes);
Ok(MasterKey(Zeroizing::new(arr)))
}
pub fn generate() -> Result<Self, KwtError> {
let mut key = Zeroizing::new([0u8; KEY_SIZE]);
getrandom(key.as_mut()).map_err(|_| KwtError::EntropyUnavailable)?;
Ok(MasterKey(key))
}
}
fn derive_session_key(
master_key: &MasterKey,
nonce: &[u8; NONCE_SIZE],
) -> Result<Zeroizing<[u8; KEY_SIZE]>, KwtError> {
let mut salt = [0u8; KEY_SIZE];
salt[..NONCE_SIZE].copy_from_slice(nonce);
let mut session_key = Zeroizing::new([0u8; KEY_SIZE]);
if !hkdf_sha256(&salt, master_key.0.as_ref(), HKDF_INFO_V1, session_key.as_mut()) {
return Err(KwtError::KeyDerivationFailed);
}
Ok(session_key)
}
pub fn encrypt(
plaintext: &[u8],
master_key: &MasterKey,
) -> Result<([u8; NONCE_SIZE], Vec<u8>), KwtError> {
let mut nonce = [0u8; NONCE_SIZE];
getrandom(&mut nonce).map_err(|_| KwtError::EntropyUnavailable)?;
let session_key = derive_session_key(master_key, &nonce)?;
let sk: &[u8; KEY_SIZE] = &session_key;
let ciphertext = xchacha20poly1305::seal(sk, &nonce, &[], plaintext)
.ok_or(KwtError::AuthenticationFailed)?;
Ok((nonce, ciphertext))
}
pub fn decrypt(
ciphertext: &[u8],
nonce: &[u8; NONCE_SIZE],
master_key: &MasterKey,
) -> Result<Zeroizing<Vec<u8>>, KwtError> {
if ciphertext.len() < 16 {
return Err(KwtError::MalformedToken(
"ciphertext too short to contain authentication tag".into(),
));
}
let session_key = derive_session_key(master_key, nonce)?;
let sk: &[u8; KEY_SIZE] = &session_key;
let pt = xchacha20poly1305::open(sk, nonce, &[], ciphertext).ok_or(KwtError::AuthenticationFailed)?;
Ok(Zeroizing::new(pt))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypt_decrypt_round_trip() {
let key = MasterKey::generate().unwrap();
let plaintext = b"hello from kwt crypto layer";
let (nonce, ciphertext) = encrypt(plaintext, &key).expect("encrypt failed");
let recovered = decrypt(&ciphertext, &nonce, &key).expect("decrypt failed");
assert_eq!(plaintext.as_slice(), recovered.as_slice());
}
#[test]
fn wrong_key_fails_auth() {
let key1 = MasterKey::generate().unwrap();
let key2 = MasterKey::generate().unwrap();
let plaintext = b"sensitive token payload";
let (nonce, ciphertext) = encrypt(plaintext, &key1).expect("encrypt failed");
let result = decrypt(&ciphertext, &nonce, &key2);
assert!(result.is_err(), "decryption with wrong key should fail");
}
#[test]
fn tampered_ciphertext_fails_auth() {
let key = MasterKey::generate().unwrap();
let plaintext = b"sensitive token payload";
let (nonce, mut ciphertext) = encrypt(plaintext, &key).expect("encrypt failed");
let mid = ciphertext.len() / 2;
ciphertext[mid] ^= 0xFF;
let result = decrypt(&ciphertext, &nonce, &key);
assert!(result.is_err(), "tampered ciphertext should fail auth");
}
#[test]
fn tampered_nonce_fails_auth() {
let key = MasterKey::generate().unwrap();
let plaintext = b"sensitive token payload";
let (mut nonce, ciphertext) = encrypt(plaintext, &key).expect("encrypt failed");
nonce[0] ^= 0x01;
let result = decrypt(&ciphertext, &nonce, &key);
assert!(result.is_err(), "wrong nonce should cause auth failure");
}
#[test]
fn each_encryption_uses_different_nonce() {
let key = MasterKey::generate().unwrap();
let plaintext = b"same plaintext every time";
let (nonce1, ct1) = encrypt(plaintext, &key).unwrap();
let (nonce2, ct2) = encrypt(plaintext, &key).unwrap();
assert_ne!(nonce1, nonce2, "nonces should be random and unique");
assert_ne!(ct1, ct2, "ciphertexts should differ even for same plaintext");
}
#[test]
fn key_derivation_is_deterministic() {
let key = MasterKey::generate().unwrap();
let nonce = [0x42u8; NONCE_SIZE];
let k1 = derive_session_key(&key, &nonce).unwrap();
let k2 = derive_session_key(&key, &nonce).unwrap();
assert_eq!(k1.as_ref(), k2.as_ref());
let nonce2 = [0x43u8; NONCE_SIZE];
let k3 = derive_session_key(&key, &nonce2).unwrap();
assert_ne!(k1.as_ref(), k3.as_ref());
}
#[test]
fn empty_plaintext_round_trip() {
let key = MasterKey::generate().unwrap();
let (nonce, ciphertext) = encrypt(b"", &key).expect("encrypt empty");
assert_eq!(ciphertext.len(), 16, "AEAD output is tag only for empty plaintext");
let recovered = decrypt(&ciphertext, &nonce, &key).expect("decrypt empty");
assert!(recovered.is_empty());
}
}