use crate::{Error, Result};
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Key, Nonce,
};
use base64::{engine::general_purpose::STANDARD, Engine};
use sha2::{Digest, Sha256};
#[derive(Clone)]
pub struct Secret {
key: [u8; 32],
}
impl Secret {
pub fn from_passphrase(passphrase: &str) -> Self {
let mut hasher = Sha256::new();
hasher.update(passphrase.as_bytes());
let digest = hasher.finalize();
let mut key = [0u8; 32];
key.copy_from_slice(&digest);
Self { key }
}
pub fn from_env(var: &str) -> Result<Self> {
match std::env::var(var) {
Ok(v) if !v.trim().is_empty() => Ok(Self::from_passphrase(v.trim())),
_ => Err(Error::Other(format!(
"{var} is not set; cannot derive an encryption key"
))),
}
}
pub fn encrypt(&self, plaintext: &str) -> Result<String> {
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&self.key));
let nonce_bytes = random_nonce();
let nonce = Nonce::from_slice(&nonce_bytes);
let ct = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|_| Error::Other("encryption failed".into()))?;
let mut out = Vec::with_capacity(nonce_bytes.len() + ct.len());
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ct);
Ok(STANDARD.encode(out))
}
pub fn decrypt(&self, sealed: &str) -> Result<String> {
let raw = STANDARD
.decode(sealed.as_bytes())
.map_err(|_| Error::Other("not valid base64".into()))?;
if raw.len() < 12 {
return Err(Error::Other("ciphertext too short".into()));
}
let (nonce_bytes, ct) = raw.split_at(12);
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&self.key));
let plaintext = cipher
.decrypt(Nonce::from_slice(nonce_bytes), ct)
.map_err(|_| Error::Other("decryption failed (wrong key or corrupt data)".into()))?;
String::from_utf8(plaintext)
.map_err(|_| Error::Other("decrypted bytes aren't UTF-8".into()))
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Secret(<redacted>)")
}
}
fn random_nonce() -> [u8; 12] {
use rand::RngCore;
let mut nonce = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce);
nonce
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip() {
let s = Secret::from_passphrase("hunter2");
let sealed = s.encrypt("hello world").unwrap();
assert_eq!(s.decrypt(&sealed).unwrap(), "hello world");
}
#[test]
fn ciphertext_is_opaque_and_nondeterministic() {
let s = Secret::from_passphrase("key");
let a = s.encrypt("same").unwrap();
let b = s.encrypt("same").unwrap();
assert_ne!(a, b);
assert_ne!(a, "same");
assert_eq!(s.decrypt(&a).unwrap(), "same");
assert_eq!(s.decrypt(&b).unwrap(), "same");
}
#[test]
fn wrong_key_fails() {
let sealed = Secret::from_passphrase("right").encrypt("secret").unwrap();
assert!(Secret::from_passphrase("wrong").decrypt(&sealed).is_err());
}
#[test]
fn garbage_fails_cleanly() {
let s = Secret::from_passphrase("k");
assert!(s.decrypt("not base64!!!").is_err());
assert!(s.decrypt("QQ==").is_err()); }
#[test]
fn unicode_round_trips() {
let s = Secret::from_passphrase("ключ");
let sealed = s.encrypt("привет 🌍 système").unwrap();
assert_eq!(s.decrypt(&sealed).unwrap(), "привет 🌍 système");
}
}