use chacha20poly1305::{
XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit as AeadKeyInit, Payload},
};
use zeroize::Zeroizing;
use crate::CryptoError;
use crate::crypto::keys::{FILE_KEY_SIZE, FileKey};
pub const WRAP_NONCE_SIZE: usize = 24;
pub const TAG_SIZE: usize = 16;
pub const WRAPPED_FILE_KEY_SIZE: usize = FILE_KEY_SIZE + TAG_SIZE;
pub(crate) fn seal_file_key(
wrap_key: &[u8; 32],
wrap_nonce: &[u8; WRAP_NONCE_SIZE],
file_key: &FileKey,
) -> Result<[u8; WRAPPED_FILE_KEY_SIZE], CryptoError> {
let cipher = XChaCha20Poly1305::new(wrap_key.into());
let nonce = XNonce::from(*wrap_nonce);
let ciphertext = cipher
.encrypt(&nonce, file_key.expose().as_ref())
.map_err(|_| CryptoError::InternalCryptoFailure("Internal error: envelope seal failed"))?;
ciphertext.as_slice().try_into().map_err(|_| {
CryptoError::InternalInvariant("Internal error: envelope ciphertext size mismatch")
})
}
pub(crate) fn open_file_key(
wrap_key: &[u8; 32],
wrap_nonce: &[u8; WRAP_NONCE_SIZE],
wrapped: &[u8; WRAPPED_FILE_KEY_SIZE],
on_fail: impl FnOnce() -> CryptoError,
) -> Result<FileKey, CryptoError> {
let cipher = XChaCha20Poly1305::new(wrap_key.into());
let nonce = XNonce::from(*wrap_nonce);
let plaintext = Zeroizing::new(
cipher
.decrypt(&nonce, wrapped.as_ref())
.map_err(|_| on_fail())?,
);
let mut out = Zeroizing::new([0u8; FILE_KEY_SIZE]);
if plaintext.len() != FILE_KEY_SIZE {
return Err(CryptoError::InternalInvariant(
"Internal error: unwrapped file key size mismatch",
));
}
out.copy_from_slice(&plaintext);
Ok(FileKey::from_zeroizing(out))
}
pub(crate) fn seal_with_aad(
wrap_key: &[u8; 32],
wrap_nonce: &[u8; WRAP_NONCE_SIZE],
plaintext: &[u8],
aad: &[u8],
on_fail: impl FnOnce() -> CryptoError,
) -> Result<Vec<u8>, CryptoError> {
let cipher = XChaCha20Poly1305::new(wrap_key.into());
let nonce = XNonce::from(*wrap_nonce);
cipher
.encrypt(
&nonce,
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| on_fail())
}
pub(crate) fn open_with_aad(
wrap_key: &[u8; 32],
wrap_nonce: &[u8; WRAP_NONCE_SIZE],
ciphertext: &[u8],
aad: &[u8],
on_fail: impl FnOnce() -> CryptoError,
) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
let cipher = XChaCha20Poly1305::new(wrap_key.into());
let nonce = XNonce::from(*wrap_nonce);
Ok(Zeroizing::new(
cipher
.decrypt(
&nonce,
Payload {
msg: ciphertext,
aad,
},
)
.map_err(|_| on_fail())?,
))
}
#[cfg(test)]
mod tests {
use super::*;
fn test_inputs() -> ([u8; 32], [u8; WRAP_NONCE_SIZE], FileKey) {
(
[0x42u8; 32],
[0x24u8; WRAP_NONCE_SIZE],
FileKey::from_bytes_for_tests([0x11u8; FILE_KEY_SIZE]),
)
}
#[test]
fn wrapped_file_key_size_is_48() {
assert_eq!(WRAPPED_FILE_KEY_SIZE, 48);
}
#[test]
fn seal_open_round_trip() {
let (wrap_key, wrap_nonce, file_key) = test_inputs();
let wrapped = seal_file_key(&wrap_key, &wrap_nonce, &file_key).unwrap();
let opened = open_file_key(&wrap_key, &wrap_nonce, &wrapped, || {
CryptoError::KeyFileUnlockFailed
})
.unwrap();
assert_eq!(opened.expose(), file_key.expose());
}
#[test]
fn open_rejects_tampered_inputs() {
let (wrap_key, wrap_nonce, file_key) = test_inputs();
let wrapped = seal_file_key(&wrap_key, &wrap_nonce, &file_key).unwrap();
for index in [0, WRAPPED_FILE_KEY_SIZE - 1] {
let mut tampered = wrapped;
tampered[index] ^= 0x01;
match open_file_key(&wrap_key, &wrap_nonce, &tampered, || {
CryptoError::KeyFileUnlockFailed
}) {
Err(CryptoError::KeyFileUnlockFailed) => {}
other => panic!("expected KeyFileUnlockFailed for byte {index}, got {other:?}"),
}
}
let mut wrong_nonce = wrap_nonce;
wrong_nonce[0] ^= 0x01;
assert!(
open_file_key(&wrap_key, &wrong_nonce, &wrapped, || {
CryptoError::KeyFileUnlockFailed
})
.is_err()
);
}
#[test]
fn aad_round_trip_and_tamper() {
let (wrap_key, wrap_nonce, _) = test_inputs();
let sealed = seal_with_aad(&wrap_key, &wrap_nonce, b"secret", b"aad-bytes", || {
CryptoError::KeyFileUnlockFailed
})
.unwrap();
let opened = open_with_aad(&wrap_key, &wrap_nonce, &sealed, b"aad-bytes", || {
CryptoError::KeyFileUnlockFailed
})
.unwrap();
assert_eq!(opened.as_slice(), b"secret");
match open_with_aad(&wrap_key, &wrap_nonce, &sealed, b"AAD-bytes", || {
CryptoError::KeyFileUnlockFailed
}) {
Err(CryptoError::KeyFileUnlockFailed) => {}
other => panic!("expected KeyFileUnlockFailed on AAD tamper, got {other:?}"),
}
}
}