use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use rand::RngCore;
use crate::crypto::sha256_digest;
use crate::error::CryptoError;
pub const SEALED_ENVELOPE_PREFIX: &str = "enc:v1:";
#[derive(Clone)]
pub struct SealedBox {
key: [u8; 32],
}
impl std::fmt::Debug for SealedBox {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SealedBox")
.field("key", &"[REDACTED]")
.finish()
}
}
impl SealedBox {
#[must_use]
pub fn new(key: [u8; 32]) -> Self {
Self { key }
}
#[must_use]
pub fn from_secret_passphrase(secret: &str) -> Self {
Self {
key: sha256_digest(secret.as_bytes()),
}
}
pub fn from_hex(hex: &str) -> Result<Self, CryptoError> {
let bytes = hex.as_bytes();
if bytes.len() != 64 {
return Err(CryptoError::InvalidKey(format!(
"Expected a 64-character hex key, got {} characters",
bytes.len()
)));
}
let key = bytes
.as_chunks::<2>()
.0
.iter()
.map(|chunk| {
let pair = std::str::from_utf8(chunk)
.map_err(|e| CryptoError::InvalidKey(format!("Key is not valid UTF-8: {e}")))?;
u8::from_str_radix(pair, 16)
.map_err(|e| CryptoError::InvalidKey(format!("Key is not valid hex: {e}")))
})
.collect::<Result<Vec<u8>, CryptoError>>()?
.try_into()
.map_err(|_| CryptoError::InvalidKey("Key must decode to 32 bytes".to_string()))?;
Ok(Self { key })
}
pub fn seal(&self, plaintext: &[u8]) -> Result<String, CryptoError> {
self.seal_with_aad(plaintext, &[])
}
pub fn seal_with_aad(&self, plaintext: &[u8], aad: &[u8]) -> Result<String, CryptoError> {
let cipher = Aes256Gcm::new_from_slice(&self.key)
.map_err(|e| CryptoError::InvalidKey(format!("Invalid AES-256-GCM key: {e}")))?;
let mut nonce_bytes = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from(nonce_bytes);
let payload = Payload {
msg: plaintext,
aad,
};
let ciphertext = cipher
.encrypt(&nonce, payload)
.map_err(|e| CryptoError::Seal(format!("AES-256-GCM sealing failed: {e}")))?;
let mut combined = Vec::with_capacity(12 + ciphertext.len());
combined.extend_from_slice(&nonce_bytes);
combined.extend_from_slice(&ciphertext);
Ok(format!(
"{SEALED_ENVELOPE_PREFIX}{}",
STANDARD.encode(combined)
))
}
pub fn open(&self, raw: &str) -> Result<Vec<u8>, CryptoError> {
self.open_with_aad(raw, &[])
}
pub fn open_with_aad(&self, raw: &str, aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
let raw = raw.trim();
let Some(encoded) = raw.strip_prefix(SEALED_ENVELOPE_PREFIX) else {
return Ok(raw.as_bytes().to_vec());
};
let combined = STANDARD
.decode(encoded)
.map_err(|e| CryptoError::Base64Decode(format!("Sealed payload Base64 decode: {e}")))?;
if combined.len() < 28 {
return Err(CryptoError::InvalidEnvelope(
"Sealed payload is truncated".to_string(),
));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let mut nonce_arr = [0u8; 12];
nonce_arr.copy_from_slice(nonce_bytes);
let nonce = Nonce::from(nonce_arr);
let cipher = Aes256Gcm::new_from_slice(&self.key)
.map_err(|e| CryptoError::InvalidKey(format!("Invalid AES-256-GCM key: {e}")))?;
let decrypt = |associated: &[u8]| -> Result<Vec<u8>, CryptoError> {
cipher
.decrypt(
&nonce,
Payload {
msg: ciphertext,
aad: associated,
},
)
.map_err(|_| {
CryptoError::Open(
"Authentication tag mismatch or invalid key while opening sealed payload"
.to_string(),
)
})
};
match decrypt(aad) {
Ok(plaintext) => Ok(plaintext),
Err(err) if !aad.is_empty() => decrypt(&[]).or(Err(err)),
Err(err) => Err(err),
}
}
pub fn open_string(&self, raw: &str) -> Result<String, CryptoError> {
self.open_string_with_aad(raw, &[])
}
pub fn open_string_with_aad(&self, raw: &str, aad: &[u8]) -> Result<String, CryptoError> {
let bytes = self.open_with_aad(raw, aad)?;
String::from_utf8(bytes).map_err(|e| CryptoError::Utf8(e.to_string()))
}
}