use aes_gcm::aead::rand_core::RngCore;
use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use base64::Engine;
use hkdf::Hkdf;
use sha2::Sha256;
use thiserror::Error;
use zeroize::Zeroize;
const KEY_SIZE: usize = 32;
const NONCE_SIZE: usize = 12;
const VERSION: u8 = 0x01;
const DEFAULT_CONTEXT: &str = "encryptman-v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Encoding {
Standard,
UrlSafeNoPad,
}
impl Encoding {
pub fn encode(&self, data: &[u8]) -> String {
match self {
Encoding::Standard => base64::engine::general_purpose::STANDARD.encode(data),
Encoding::UrlSafeNoPad => base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data),
}
}
pub fn decode(&self, data: &str) -> Result<Vec<u8>, base64::DecodeError> {
match self {
Encoding::Standard => base64::engine::general_purpose::STANDARD.decode(data),
Encoding::UrlSafeNoPad => base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(data),
}
}
}
#[derive(Debug, Error)]
pub enum CryptoError {
#[error("ciphertext too short: expected at least {expected} bytes, got {actual}")]
CiphertextTooShort {
expected: usize,
actual: usize,
},
#[error("invalid base64: {0}")]
InvalidBase64(#[from] base64::DecodeError),
#[error("unsupported version: {0}")]
UnsupportedVersion(u8),
#[error("decryption failed: wrong key or corrupted ciphertext")]
DecryptionFailed,
#[error("decrypted data is not valid UTF-8")]
InvalidUtf8(#[from] std::string::FromUtf8Error),
#[error("key derivation failed: {0}")]
KeyDerivation(String),
#[error("encryption failed: {0}")]
EncryptionFailed(String),
#[error("invalid key length: expected {expected} bytes, got {actual}")]
InvalidKeyLength {
expected: usize,
actual: usize,
},
}
#[derive(Zeroize)]
#[zeroize(drop)]
pub struct MasterKey([u8; KEY_SIZE]);
impl MasterKey {
pub fn generate() -> Self {
let mut key = [0u8; KEY_SIZE];
OsRng.fill_bytes(&mut key);
Self(key)
}
pub fn from_bytes(bytes: [u8; KEY_SIZE]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; KEY_SIZE] {
&self.0
}
pub fn into_bytes(mut self) -> [u8; KEY_SIZE] {
let bytes = self.0;
self.0.zeroize();
bytes
}
}
impl std::fmt::Debug for MasterKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "MasterKey(***)")
}
}
impl TryFrom<&[u8]> for MasterKey {
type Error = CryptoError;
fn try_from(slice: &[u8]) -> Result<Self, Self::Error> {
if slice.len() != KEY_SIZE {
return Err(CryptoError::InvalidKeyLength {
expected: KEY_SIZE,
actual: slice.len(),
});
}
let mut bytes = [0u8; KEY_SIZE];
bytes.copy_from_slice(slice);
Ok(Self(bytes))
}
}
impl TryFrom<Vec<u8>> for MasterKey {
type Error = CryptoError;
fn try_from(vec: Vec<u8>) -> Result<Self, Self::Error> {
Self::try_from(vec.as_slice())
}
}
pub fn encrypt(master_key: &MasterKey, plaintext: &str) -> Result<String, CryptoError> {
encrypt_with_context(master_key, DEFAULT_CONTEXT, plaintext)
}
pub fn decrypt(master_key: &MasterKey, encoded: &str) -> Result<String, CryptoError> {
decrypt_with_context(master_key, DEFAULT_CONTEXT, encoded)
}
pub fn encrypt_with_context(
master_key: &MasterKey,
context: &str,
plaintext: &str,
) -> Result<String, CryptoError> {
encrypt_bytes_with_context(master_key, context, plaintext.as_bytes())
.map(|bytes| Encoding::Standard.encode(&bytes))
}
pub fn decrypt_with_context(
master_key: &MasterKey,
context: &str,
encoded: &str,
) -> Result<String, CryptoError> {
decrypt_with_encoding(master_key, context, encoded, Encoding::Standard)
}
pub fn encrypt_with_encoding(
master_key: &MasterKey,
context: &str,
plaintext: &str,
encoding: Encoding,
) -> Result<String, CryptoError> {
encrypt_bytes_with_context(master_key, context, plaintext.as_bytes())
.map(|bytes| encoding.encode(&bytes))
}
pub fn decrypt_with_encoding(
master_key: &MasterKey,
context: &str,
encoded: &str,
encoding: Encoding,
) -> Result<String, CryptoError> {
let packed = encoding.decode(encoded)?;
let plaintext = decrypt_bytes_with_context(master_key, context, &packed)?;
Ok(String::from_utf8(plaintext)?)
}
pub fn encrypt_bytes_with_context(
master_key: &MasterKey,
context: &str,
plaintext: &[u8],
) -> Result<Vec<u8>, CryptoError> {
let key = derive_key(master_key, context)?;
let cipher = Aes256Gcm::new(&key);
let mut nonce_bytes = [0u8; NONCE_SIZE];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| CryptoError::EncryptionFailed(format!("{e}")))?;
let mut packed = Vec::with_capacity(1 + NONCE_SIZE + ciphertext.len());
packed.push(VERSION);
packed.extend_from_slice(&nonce_bytes);
packed.extend_from_slice(&ciphertext);
Ok(packed)
}
pub fn decrypt_bytes_with_context(
master_key: &MasterKey,
context: &str,
packed: &[u8],
) -> Result<Vec<u8>, CryptoError> {
let min_len = 1 + NONCE_SIZE + 1;
if packed.len() < min_len {
return Err(CryptoError::CiphertextTooShort {
expected: min_len,
actual: packed.len(),
});
}
let (version, rest) = packed
.split_first()
.ok_or(CryptoError::CiphertextTooShort {
expected: min_len,
actual: packed.len(),
})?;
if *version != VERSION {
return Err(CryptoError::UnsupportedVersion(*version));
}
let key = derive_key(master_key, context)?;
let cipher = Aes256Gcm::new(&key);
let (nonce_bytes, ciphertext) = rest.split_at(NONCE_SIZE);
let nonce = Nonce::from_slice(nonce_bytes);
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| CryptoError::DecryptionFailed)
}
pub fn generate_master_key() -> MasterKey {
MasterKey::generate()
}
fn derive_key(master_key: &MasterKey, context: &str) -> Result<Key<Aes256Gcm>, CryptoError> {
let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes());
let mut okm = [0u8; KEY_SIZE];
let info = format!("encryptman:{context}");
hk.expand(info.as_bytes(), &mut okm)
.map_err(|e| CryptoError::KeyDerivation(format!("{e}")))?;
Ok(*Key::<Aes256Gcm>::from_slice(&okm))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypt_decrypt_roundtrip() {
let key = generate_master_key();
let original = "my_secret_password_123!";
let encrypted = encrypt(&key, original).unwrap();
let decrypted = decrypt(&key, &encrypted).unwrap();
assert_eq!(
original, decrypted,
"decrypted text must match original plaintext"
);
}
#[test]
fn encrypt_produces_different_output_each_time() {
let key = generate_master_key();
let a = encrypt(&key, "same_password").unwrap();
let b = encrypt(&key, "same_password").unwrap();
assert_ne!(
a, b,
"same plaintext must produce different ciphertext (random nonce)"
);
}
#[test]
fn decrypt_wrong_key_fails() {
let key1 = generate_master_key();
let key2 = generate_master_key();
let encrypted = encrypt(&key1, "secret").unwrap();
assert!(
decrypt(&key2, &encrypted).is_err(),
"decryption with wrong key must fail"
);
}
#[test]
fn decrypt_invalid_base64_fails() {
let key = generate_master_key();
assert!(
decrypt(&key, "!!!invalid-base64!!!").is_err(),
"invalid base64 input must fail"
);
}
#[test]
fn decrypt_truncated_ciphertext_fails() {
let key = generate_master_key();
assert!(
decrypt(&key, "dHJ1bmNhdGVk").is_err(),
"truncated ciphertext must fail"
);
}
#[test]
fn different_contexts_produce_different_ciphertext() {
let key = generate_master_key();
let a = encrypt_with_context(&key, "context-a", "same").unwrap();
let b = encrypt_with_context(&key, "context-b", "same").unwrap();
assert_ne!(a, b, "different contexts must produce different ciphertext");
}
#[test]
fn context_isolation_decrypt_fails_cross_context() {
let key = generate_master_key();
let encrypted = encrypt_with_context(&key, "context-a", "secret").unwrap();
assert!(
decrypt_with_context(&key, "context-b", &encrypted).is_err(),
"cross-context decryption must fail"
);
}
#[test]
fn empty_plaintext_encrypts_and_decrypts() {
let key = generate_master_key();
let encrypted = encrypt(&key, "").unwrap();
let decrypted = decrypt(&key, &encrypted).unwrap();
assert_eq!(decrypted, "", "empty plaintext must roundtrip correctly");
}
#[test]
fn unicode_plaintext_roundtrip() {
let key = generate_master_key();
let original = "รหัสผ่านภาษาไทย 🔐";
let encrypted = encrypt(&key, original).unwrap();
let decrypted = decrypt(&key, &encrypted).unwrap();
assert_eq!(
original, decrypted,
"unicode plaintext must roundtrip correctly"
);
}
#[test]
fn master_key_from_bytes_roundtrip() {
let bytes = [42u8; 32];
let key = MasterKey::from_bytes(bytes);
assert_eq!(
key.as_bytes(),
&bytes,
"from_bytes must preserve key material"
);
let encrypted = encrypt(&key, "test").unwrap();
let decrypted = decrypt(&key, &encrypted).unwrap();
assert_eq!(
decrypted, "test",
"key from bytes must encrypt/decrypt correctly"
);
}
#[test]
fn master_key_debug_does_not_leak() {
let key = generate_master_key();
let debug = format!("{:?}", key);
assert_eq!(
debug, "MasterKey(***)",
"Debug output must not leak key bytes"
);
}
#[test]
fn master_key_into_bytes() {
let key = generate_master_key();
let bytes = *key.as_bytes();
let key2 = MasterKey::from_bytes(bytes);
let encrypted = encrypt(&key2, "test").unwrap();
let decrypted = decrypt(&key2, &encrypted).unwrap();
assert_eq!(decrypted, "test", "into_bytes roundtrip must preserve key");
}
#[test]
fn long_plaintext_roundtrip() {
let key = generate_master_key();
let original = "a".repeat(10_000);
let encrypted = encrypt(&key, &original).unwrap();
let decrypted = decrypt(&key, &encrypted).unwrap();
assert_eq!(
original, decrypted,
"long plaintext must roundtrip correctly"
);
}
#[test]
fn try_from_ref_slice_valid() {
let bytes = [1u8; 32];
let key = MasterKey::try_from(bytes.as_slice()).unwrap();
assert_eq!(key.as_bytes(), &bytes);
}
#[test]
fn try_from_ref_slice_wrong_length() {
let bytes = [1u8; 16];
let result = MasterKey::try_from(bytes.as_slice());
assert!(result.is_err(), "wrong length must fail");
}
#[test]
fn try_from_vec_valid() {
let vec = vec![2u8; 32];
let key = MasterKey::try_from(vec).unwrap();
assert_eq!(key.as_bytes(), &[2u8; 32]);
}
#[test]
fn try_from_vec_wrong_length() {
let vec = vec![2u8; 64];
let result = MasterKey::try_from(vec);
assert!(result.is_err(), "wrong length must fail");
}
#[test]
fn version_byte_in_ciphertext() {
let key = generate_master_key();
let encrypted = encrypt(&key, "test").unwrap();
let packed = Encoding::Standard.decode(&encrypted).unwrap();
assert_eq!(packed[0], VERSION, "first byte must be version");
}
#[test]
fn unsupported_version_fails() {
let key = generate_master_key();
let encrypted = encrypt(&key, "test").unwrap();
let mut packed = Encoding::Standard.decode(&encrypted).unwrap();
packed[0] = 0xFF;
let result = decrypt_bytes_with_context(&key, DEFAULT_CONTEXT, &packed);
assert!(result.is_err(), "unsupported version must fail");
}
#[test]
fn binary_plaintext_roundtrip() {
let key = generate_master_key();
let original: Vec<u8> = (0..=255).cycle().take(1000).collect();
let packed = encrypt_bytes_with_context(&key, "binary", &original).unwrap();
let decrypted = decrypt_bytes_with_context(&key, "binary", &packed).unwrap();
assert_eq!(
original, decrypted,
"binary plaintext must roundtrip correctly"
);
}
#[test]
fn url_safe_no_pad_encoding() {
let key = generate_master_key();
let packed = encrypt_bytes_with_context(&key, "test", b"hello").unwrap();
let encoded = Encoding::UrlSafeNoPad.encode(&packed);
assert!(
!encoded.contains('+') && !encoded.contains('/'),
"URL-safe encoding must not contain + or /"
);
assert!(
!encoded.contains('='),
"URL-safe no-pad encoding must not contain ="
);
}
#[test]
fn encrypt_with_encoding_standard_roundtrip() {
let key = generate_master_key();
let encrypted = encrypt_with_encoding(&key, "ctx", "secret", Encoding::Standard).unwrap();
let decrypted = decrypt_with_encoding(&key, "ctx", &encrypted, Encoding::Standard).unwrap();
assert_eq!(decrypted, "secret", "Standard encoding roundtrip must work");
}
#[test]
fn encrypt_with_encoding_url_safe_roundtrip() {
let key = generate_master_key();
let encrypted =
encrypt_with_encoding(&key, "ctx", "secret", Encoding::UrlSafeNoPad).unwrap();
let decrypted =
decrypt_with_encoding(&key, "ctx", &encrypted, Encoding::UrlSafeNoPad).unwrap();
assert_eq!(decrypted, "secret", "URL-safe encoding roundtrip must work");
}
#[test]
fn encoding_mismatch_fails() {
let key = generate_master_key();
let encrypted =
encrypt_with_encoding(&key, "ctx", "secret", Encoding::UrlSafeNoPad).unwrap();
let result = decrypt_with_encoding(&key, "ctx", &encrypted, Encoding::Standard);
assert!(result.is_err(), "decoding with wrong encoding must fail");
}
#[test]
fn encoding_produces_different_base64() {
let key = generate_master_key();
let packed = encrypt_bytes_with_context(&key, "ctx", b"test-data>?>").unwrap();
let std = Encoding::Standard.encode(&packed);
let url = Encoding::UrlSafeNoPad.encode(&packed);
assert_ne!(
std, url,
"Standard and URL-safe must produce different output"
);
assert!(
!url.contains('+') && !url.contains('/') && !url.contains('='),
"URL-safe output must not contain +, /, or ="
);
}
}