use aes_gcm::aead::{Aead as _, KeyInit as _};
use aes_gcm::{Aes256Gcm, Nonce};
use regex::Regex;
use sha2::{Digest as _, Sha256};
use zeroize::Zeroizing;
use crate::error::{ErrorCode, NotificationError, NotificationResult};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProtectedValue {
pub ciphertext: String,
pub key_reference: String,
}
pub trait SnapshotProtector: std::fmt::Debug + Send + Sync {
fn protect(&self, plaintext: &str) -> NotificationResult<ProtectedValue>;
fn reveal(&self, protected: &ProtectedValue) -> NotificationResult<String>;
}
#[derive(Clone)]
pub struct AeadSnapshotProtector {
key: Zeroizing<[u8; 32]>,
key_reference: String,
}
impl std::fmt::Debug for AeadSnapshotProtector {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AeadSnapshotProtector")
.field("key_reference", &self.key_reference)
.finish_non_exhaustive()
}
}
impl AeadSnapshotProtector {
pub fn from_base64_key(encoded: &str, key_reference: String) -> NotificationResult<Self> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|error| {
NotificationError::new(
ErrorCode::Validation,
"Notification snapshot key must be base64",
)
.with_source(error)
})?;
let key: [u8; 32] = bytes.try_into().map_err(|_| {
NotificationError::new(
ErrorCode::Validation,
"Notification snapshot key must contain exactly 32 bytes",
)
})?;
Ok(Self {
key: Zeroizing::new(key),
key_reference,
})
}
}
impl SnapshotProtector for AeadSnapshotProtector {
fn protect(&self, plaintext: &str) -> NotificationResult<ProtectedValue> {
use base64::Engine as _;
let cipher = Aes256Gcm::new_from_slice(self.key.as_ref()).map_err(|_| {
NotificationError::new(
ErrorCode::Validation,
"Notification snapshot key is invalid",
)
})?;
let mut nonce_bytes = [0_u8; 12];
getrandom::fill(&mut nonce_bytes).map_err(|error| {
NotificationError::new(
ErrorCode::Internal,
"Notification snapshot nonce generation failed",
)
.with_source(error)
})?;
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_bytes())
.map_err(|_| {
NotificationError::new(
ErrorCode::Internal,
"Notification snapshot encryption failed",
)
})?;
let mut envelope = nonce_bytes.to_vec();
envelope.extend(ciphertext);
Ok(ProtectedValue {
ciphertext: format!(
"v1:{}",
base64::engine::general_purpose::STANDARD.encode(envelope)
),
key_reference: self.key_reference.clone(),
})
}
fn reveal(&self, protected: &ProtectedValue) -> NotificationResult<String> {
use base64::Engine as _;
if protected.key_reference != self.key_reference {
return Err(NotificationError::new(
ErrorCode::Validation,
"Notification snapshot key reference does not match",
));
}
let encoded = protected.ciphertext.strip_prefix("v1:").ok_or_else(|| {
NotificationError::new(
ErrorCode::Validation,
"Notification snapshot envelope version is unsupported",
)
})?;
let envelope = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|error| {
NotificationError::new(ErrorCode::Validation, "invalid protected snapshot")
.with_source(error)
})?;
if envelope.len() <= 12 {
return Err(NotificationError::new(
ErrorCode::Validation,
"invalid protected snapshot envelope",
));
}
let (nonce_bytes, ciphertext) = envelope.split_at(12);
let cipher = Aes256Gcm::new_from_slice(self.key.as_ref()).map_err(|_| {
NotificationError::new(
ErrorCode::Validation,
"Notification snapshot key is invalid",
)
})?;
let plaintext = cipher
.decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
.map_err(|_| {
NotificationError::new(
ErrorCode::Validation,
"Notification snapshot authentication failed",
)
})?;
String::from_utf8(plaintext).map_err(|error| {
NotificationError::new(ErrorCode::Validation, "invalid protected snapshot encoding")
.with_source(error)
})
}
}
#[cfg(test)]
#[derive(Debug, Default)]
pub struct TestSnapshotProtector;
#[cfg(test)]
impl SnapshotProtector for TestSnapshotProtector {
fn protect(&self, plaintext: &str) -> NotificationResult<ProtectedValue> {
use base64::Engine as _;
Ok(ProtectedValue {
ciphertext: base64::engine::general_purpose::STANDARD.encode(plaintext),
key_reference: "test-only:base64".to_owned(),
})
}
fn reveal(&self, protected: &ProtectedValue) -> NotificationResult<String> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&protected.ciphertext)
.map_err(|error| {
NotificationError::new(ErrorCode::Validation, "invalid protected snapshot")
.with_source(error)
})?;
String::from_utf8(bytes).map_err(|error| {
NotificationError::new(ErrorCode::Validation, "invalid protected snapshot encoding")
.with_source(error)
})
}
}
pub fn content_digest(subject: &str, text: &str, html: &str) -> String {
let mut hasher = Sha256::new();
for part in [subject, text, html] {
hasher.update(u64::try_from(part.len()).unwrap_or(u64::MAX).to_be_bytes());
hasher.update(part.as_bytes());
}
format!("sha256:{}", hex::encode(hasher.finalize()))
}
pub fn request_digest(serializable: &impl serde::Serialize) -> NotificationResult<String> {
let encoded = serde_json::to_vec(serializable).map_err(|error| {
NotificationError::new(ErrorCode::Validation, "intent request cannot be serialized")
.with_source(error)
})?;
Ok(format!("sha256:{}", hex::encode(Sha256::digest(encoded))))
}
pub fn mask_email(address: &str) -> String {
let Some((local, domain)) = address.rsplit_once('@') else {
return "***".to_owned();
};
let first = local.chars().next().unwrap_or('*');
format!("{first}***@{domain}")
}
pub fn redact_preview(text: &str) -> String {
let url = Regex::new(r"https?://\S+").expect("valid URL redaction regex");
let email = Regex::new(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
.expect("valid email redaction regex");
let without_urls = url.replace_all(text, "[link redacted]");
let without_emails = email.replace_all(&without_urls, "[recipient redacted]");
without_emails.chars().take(160).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_and_recipient_mask_hide_token_and_address() {
let input = "Join at https://example.test/invitations/secret-token for alice@example.com";
let preview = redact_preview(input);
assert!(!preview.contains("secret-token"));
assert!(!preview.contains("alice@example.com"));
assert_eq!(mask_email("alice@example.com"), "a***@example.com");
}
#[test]
fn production_envelope_round_trips_without_plaintext() {
use base64::Engine as _;
let key = base64::engine::general_purpose::STANDARD.encode([7_u8; 32]);
let protector =
AeadSnapshotProtector::from_base64_key(&key, "secret:test-notification-key".to_owned())
.expect("protector");
let protected = protector
.protect("https://example.test/invitations/secret")
.expect("protected");
assert!(!protected.ciphertext.contains("secret"));
assert_eq!(
protector.reveal(&protected).expect("revealed"),
"https://example.test/invitations/secret"
);
}
}