use chacha20poly1305::{
XChaCha20Poly1305, XNonce,
aead::{AeadInPlace, KeyInit},
};
use hmac::{Hmac, Mac};
use rand::{RngCore, rngs::OsRng};
use sha2::Sha256;
#[cfg(test)]
use subtle::ConstantTimeEq;
use crate::types::{Entry, SessionKey};
pub fn generate_session_key() -> SessionKey {
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
SessionKey::from_bytes(key)
}
pub(crate) fn encrypt_secret(
session_key: &SessionKey,
fake: Vec<u8>,
plaintext: &[u8],
) -> Result<Entry, Error> {
let mut nonce_bytes = [0u8; 24];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = XNonce::from(nonce_bytes);
let cipher = XChaCha20Poly1305::new_from_slice(session_key.as_bytes())
.expect("32-byte key is always valid for XChaCha20Poly1305");
let mut buffer = plaintext.to_vec();
cipher
.encrypt_in_place(&nonce, &fake, &mut buffer)
.map_err(|_| Error::EncryptionFailed)?;
Ok(Entry {
fake,
nonce: nonce_bytes.to_vec(),
ciphertext: buffer,
})
}
pub(crate) fn decrypt_entry(session_key: &SessionKey, entry: &Entry) -> Result<Vec<u8>, Error> {
let nonce_arr: [u8; 24] = entry
.nonce
.as_slice()
.try_into()
.map_err(|_| Error::InvalidNonce)?;
let nonce = XNonce::from(nonce_arr);
let cipher = XChaCha20Poly1305::new_from_slice(session_key.as_bytes())
.expect("32-byte key is always valid for XChaCha20Poly1305");
let mut buffer = entry.ciphertext.clone();
cipher
.decrypt_in_place(&nonce, &entry.fake, &mut buffer)
.map_err(|_| Error::AeadTagFailure)?;
Ok(buffer)
}
pub(crate) fn hmac_sha256(salt: &[u8], data: &[u8]) -> [u8; 32] {
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(salt).expect("HMAC accepts any key size");
mac.update(data);
mac.finalize().into_bytes().into()
}
#[cfg(test)]
pub(crate) fn verify_hmac(salt: &[u8], data: &[u8], expected: &[u8; 32]) -> bool {
let computed = hmac_sha256(salt, data);
computed.ct_eq(expected).into()
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
#[error("AEAD tag verification failed")]
AeadTagFailure,
#[error("invalid nonce length")]
InvalidNonce,
#[error("AEAD encryption failed")]
EncryptionFailed,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Entry;
#[test]
fn test_encrypt_decrypt_roundtrip() {
let key = generate_session_key();
let plaintext = b"my-secret-api-key-value";
let fake = b"sk-fake-aaabbbccc".to_vec();
let entry = encrypt_secret(&key, fake.clone(), plaintext).unwrap();
assert_eq!(entry.fake, fake);
assert_eq!(entry.nonce.len(), 24);
let recovered = decrypt_entry(&key, &entry).unwrap();
assert_eq!(recovered, plaintext);
}
#[test]
fn test_tampered_tag_returns_err() {
let key = generate_session_key();
let plaintext = b"secret";
let fake = b"fake".to_vec();
let mut entry = encrypt_secret(&key, fake, plaintext).unwrap();
let last = entry.ciphertext.len() - 1;
entry.ciphertext[last] ^= 0xFF;
let result = decrypt_entry(&key, &entry);
assert!(result.is_err(), "tampered tag must return Err");
}
#[test]
fn test_session_key_not_in_entry_serialization() {
let key = generate_session_key();
let plaintext = b"secret";
let fake = b"fake".to_vec();
let entry = encrypt_secret(&key, fake, plaintext).unwrap();
let json = Entry::serialize_entries(&[entry]).unwrap();
assert!(!json.windows(32).any(|w| w == key.as_bytes().as_slice()));
}
#[test]
fn test_tampered_fake_returns_err() {
let key = generate_session_key();
let plaintext = b"secret";
let fake = b"original-fake".to_vec();
let mut entry = encrypt_secret(&key, fake, plaintext).unwrap();
entry.fake = b"attacker-trigger".to_vec();
let result = decrypt_entry(&key, &entry);
assert!(result.is_err(), "tampered fake must return Err");
}
}