use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use sha2::{Digest, Sha256};
fn key_for(secret: &str) -> Aes256Gcm {
let digest = Sha256::digest(format!("runlight-mail:{secret}").as_bytes());
Aes256Gcm::new_from_slice(&digest).expect("a SHA-256 digest is a 256 bit key")
}
fn from_base64(text: &str) -> Option<Vec<u8>> {
let mut t: String = text.chars().filter(|c| !matches!(c, ' ' | '\t' | '\n' | '\x0c' | '\r')).collect();
if t.len().is_multiple_of(4) {
for _ in 0..2 {
if t.ends_with('=') {
t.pop();
}
}
}
if t.len() % 4 == 1 || !t.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/') {
return None;
}
const FORGIVING: base64::engine::GeneralPurpose = base64::engine::GeneralPurpose::new(
&base64::alphabet::STANDARD,
base64::engine::GeneralPurposeConfig::new()
.with_decode_allow_trailing_bits(true)
.with_decode_padding_mode(base64::engine::DecodePaddingMode::RequireNone),
);
FORGIVING.decode(t.as_bytes()).ok()
}
pub fn seal(value: &str, secret: Option<&str>) -> String {
let secret = match secret {
Some(s) if !s.is_empty() => s,
_ => return format!("plain:{value}"),
};
let iv = crate::hash::random_bytes(12);
let data =
key_for(secret).encrypt(Nonce::from_slice(&iv), value.as_bytes()).expect("AES-GCM seals any value under 64 GB");
format!("v1:{}:{}", STANDARD.encode(&iv), STANDARD.encode(&data))
}
pub fn unseal(sealed: &str, secret: Option<&str>) -> Option<String> {
if let Some(rest) = sealed.strip_prefix("plain:") {
return Some(rest.to_string());
}
let mut parts = sealed.split(':');
let (version, iv, data) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or(""));
let secret = secret.unwrap_or("");
if version != "v1" || iv.is_empty() || data.is_empty() || secret.is_empty() {
return None;
}
let iv = from_base64(iv)?;
let data = from_base64(data)?;
if iv.len() != 12 {
return None;
}
let plain = key_for(secret).decrypt(Nonce::from_slice(&iv), data.as_slice()).ok()?;
let plain = plain.strip_prefix(&[0xef, 0xbb, 0xbf][..]).unwrap_or(&plain);
Some(String::from_utf8_lossy(plain).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn atob_is_forgiving() {
assert_eq!(from_base64("SGk=").as_deref(), Some(&b"Hi"[..]));
assert_eq!(from_base64("SGk").as_deref(), Some(&b"Hi"[..]));
assert_eq!(from_base64(" S G k = ").as_deref(), Some(&b"Hi"[..]));
assert_eq!(from_base64("SGl="), Some(b"Hi".to_vec()), "trailing bits are dropped");
assert_eq!(from_base64("SG="), None, "padding that leaves a length of 3 is refused");
assert_eq!(from_base64("S"), None);
assert_eq!(from_base64("S*Gk"), None);
assert_eq!(from_base64(""), Some(Vec::new()));
}
}