use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::prelude::*;
use chrono::{DateTime, TimeZone, Utc};
use hmac::{Hmac, Mac};
use rand::random;
use sha2::Sha256;
const SEPARATOR: &str = "--";
const META_PREFIX: &[u8] = b"RRS1";
const FLAG_PURPOSE: u8 = 0b0000_0001;
const FLAG_EXPIRES_AT: u8 = 0b0000_0010;
const NONCE_LENGTH: usize = 12;
type HmacSha256 = Hmac<Sha256>;
struct DecodedPayload {
data: Vec<u8>,
purpose: Option<String>,
expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum VerifierError {
#[error("invalid signature")]
InvalidSignature,
#[error("message expired")]
Expired,
#[error("purpose mismatch")]
PurposeMismatch,
#[error("encoding error: {0}")]
Encoding(String),
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum EncryptorError {
#[error("decryption failed")]
DecryptionFailed,
#[error("invalid key length")]
InvalidKeyLength,
#[error("message expired")]
Expired,
#[error("purpose mismatch")]
PurposeMismatch,
#[error("encoding error: {0}")]
Encoding(String),
}
pub struct MessageVerifier {
secret: Vec<u8>,
}
impl MessageVerifier {
pub fn new(secret: &[u8]) -> Self {
Self {
secret: secret.to_vec(),
}
}
pub fn generate(&self, data: &[u8]) -> String {
self.generate_signed_payload(encode_metadata(data, None, None))
}
pub fn generate_with_purpose(
&self,
data: &[u8],
purpose: &str,
expires_at: Option<DateTime<Utc>>,
) -> String {
let payload = encode_metadata(data, Some(purpose), expires_at);
self.generate_signed_payload(payload)
}
pub fn verify(&self, signed_message: &str) -> Result<Vec<u8>, VerifierError> {
self.verify_internal(signed_message, None)
}
pub fn verify_with_purpose(
&self,
signed_message: &str,
purpose: &str,
) -> Result<Vec<u8>, VerifierError> {
self.verify_internal(signed_message, Some(purpose))
}
pub fn valid_message(&self, signed_message: &str) -> bool {
let Some((encoded_payload, encoded_signature)) = split_signed_message(signed_message)
else {
return false;
};
verify_hmac(&self.secret, encoded_payload, encoded_signature).is_ok()
&& BASE64_STANDARD.decode(encoded_payload).is_ok()
}
fn generate_signed_payload(&self, payload: Vec<u8>) -> String {
let encoded_payload = BASE64_STANDARD.encode(payload);
let signature = sign_hmac(&self.secret, encoded_payload.as_bytes());
format!("{encoded_payload}{SEPARATOR}{signature}")
}
fn verify_internal(
&self,
signed_message: &str,
expected_purpose: Option<&str>,
) -> Result<Vec<u8>, VerifierError> {
let (encoded_payload, encoded_signature) =
split_signed_message(signed_message).ok_or(VerifierError::InvalidSignature)?;
verify_hmac(&self.secret, encoded_payload, encoded_signature)?;
let payload = BASE64_STANDARD
.decode(encoded_payload)
.map_err(|_| VerifierError::InvalidSignature)?;
let decoded = parse_payload(&payload).map_err(VerifierError::Encoding)?;
validate_payload(decoded, expected_purpose)
}
}
pub struct MessageEncryptor {
secret: Vec<u8>,
sign_secret: Option<Vec<u8>>,
}
impl MessageEncryptor {
pub fn new(secret: &[u8]) -> Result<Self, EncryptorError> {
if secret.len() != 32 {
return Err(EncryptorError::InvalidKeyLength);
}
Ok(Self {
secret: secret.to_vec(),
sign_secret: None,
})
}
pub fn encrypt_and_sign(&self, data: &[u8]) -> Result<String, EncryptorError> {
self.encrypt_payload(encode_metadata(data, None, None))
}
pub fn encrypt_and_sign_with_purpose(
&self,
data: &[u8],
purpose: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<String, EncryptorError> {
self.encrypt_payload(encode_metadata(data, Some(purpose), expires_at))
}
pub fn decrypt_and_verify(&self, encrypted_message: &str) -> Result<Vec<u8>, EncryptorError> {
self.decrypt_internal(encrypted_message, None)
}
pub fn decrypt_and_verify_with_purpose(
&self,
encrypted_message: &str,
purpose: &str,
) -> Result<Vec<u8>, EncryptorError> {
self.decrypt_internal(encrypted_message, Some(purpose))
}
fn encrypt_payload(&self, payload: Vec<u8>) -> Result<String, EncryptorError> {
let cipher = Aes256Gcm::new_from_slice(&self.secret)
.map_err(|_| EncryptorError::InvalidKeyLength)?;
let nonce: [u8; NONCE_LENGTH] = random();
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), payload.as_ref())
.map_err(|_| EncryptorError::Encoding("encryption failed".to_owned()))?;
let encoded_nonce = BASE64_STANDARD.encode(nonce);
let encoded_ciphertext = BASE64_STANDARD.encode(ciphertext);
Ok(format!("{encoded_nonce}{SEPARATOR}{encoded_ciphertext}"))
}
fn decrypt_internal(
&self,
encrypted_message: &str,
expected_purpose: Option<&str>,
) -> Result<Vec<u8>, EncryptorError> {
let (encoded_nonce, encoded_ciphertext) = split_signed_message(encrypted_message)
.ok_or_else(|| {
EncryptorError::Encoding("invalid encrypted message format".to_owned())
})?;
let nonce = BASE64_STANDARD
.decode(encoded_nonce)
.map_err(|_| EncryptorError::Encoding("invalid nonce encoding".to_owned()))?;
if nonce.len() != NONCE_LENGTH {
return Err(EncryptorError::Encoding("invalid nonce length".to_owned()));
}
let ciphertext = BASE64_STANDARD
.decode(encoded_ciphertext)
.map_err(|_| EncryptorError::Encoding("invalid ciphertext encoding".to_owned()))?;
let cipher = Aes256Gcm::new_from_slice(&self.secret)
.map_err(|_| EncryptorError::InvalidKeyLength)?;
let plaintext = cipher
.decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref())
.map_err(|_| EncryptorError::DecryptionFailed)?;
let decoded = parse_payload(&plaintext).map_err(EncryptorError::Encoding)?;
validate_payload_encryptor(decoded, expected_purpose)
}
#[cfg(test)]
fn sign_secret(&self) -> Option<&[u8]> {
self.sign_secret.as_deref()
}
}
pub struct RotatingVerifier {
verifiers: Vec<MessageVerifier>,
}
impl RotatingVerifier {
pub fn new(verifiers: Vec<MessageVerifier>) -> Self {
assert!(
!verifiers.is_empty(),
"rotating verifier requires at least one verifier"
);
Self { verifiers }
}
pub fn generate(&self, data: &[u8]) -> String {
self.verifiers[0].generate(data)
}
pub fn generate_with_purpose(
&self,
data: &[u8],
purpose: &str,
expires_at: Option<DateTime<Utc>>,
) -> String {
self.verifiers[0].generate_with_purpose(data, purpose, expires_at)
}
pub fn verify(&self, signed_message: &str) -> Result<Vec<u8>, VerifierError> {
self.verify_with(signed_message, |verifier| verifier.verify(signed_message))
}
pub fn verify_with_purpose(
&self,
signed_message: &str,
purpose: &str,
) -> Result<Vec<u8>, VerifierError> {
self.verify_with(signed_message, |verifier| {
verifier.verify_with_purpose(signed_message, purpose)
})
}
pub fn valid_message(&self, signed_message: &str) -> bool {
self.verifiers
.iter()
.any(|verifier| verifier.valid_message(signed_message))
}
fn verify_with<F>(
&self,
_signed_message: &str,
mut operation: F,
) -> Result<Vec<u8>, VerifierError>
where
F: FnMut(&MessageVerifier) -> Result<Vec<u8>, VerifierError>,
{
let mut best_error = None;
for verifier in &self.verifiers {
match operation(verifier) {
Ok(value) => return Ok(value),
Err(error) => best_error = Some(prefer_verifier_error(best_error, error)),
}
}
Err(best_error.unwrap_or(VerifierError::InvalidSignature))
}
}
pub struct RotatingEncryptor {
encryptors: Vec<MessageEncryptor>,
}
impl RotatingEncryptor {
pub fn new(encryptors: Vec<MessageEncryptor>) -> Self {
assert!(
!encryptors.is_empty(),
"rotating encryptor requires at least one encryptor"
);
Self { encryptors }
}
pub fn encrypt_and_sign(&self, data: &[u8]) -> Result<String, EncryptorError> {
self.encryptors[0].encrypt_and_sign(data)
}
pub fn encrypt_and_sign_with_purpose(
&self,
data: &[u8],
purpose: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<String, EncryptorError> {
self.encryptors[0].encrypt_and_sign_with_purpose(data, purpose, expires_at)
}
pub fn decrypt_and_verify(&self, encrypted_message: &str) -> Result<Vec<u8>, EncryptorError> {
self.decrypt_with(encrypted_message, |encryptor| {
encryptor.decrypt_and_verify(encrypted_message)
})
}
pub fn decrypt_and_verify_with_purpose(
&self,
encrypted_message: &str,
purpose: &str,
) -> Result<Vec<u8>, EncryptorError> {
self.decrypt_with(encrypted_message, |encryptor| {
encryptor.decrypt_and_verify_with_purpose(encrypted_message, purpose)
})
}
fn decrypt_with<F>(
&self,
_encrypted_message: &str,
mut operation: F,
) -> Result<Vec<u8>, EncryptorError>
where
F: FnMut(&MessageEncryptor) -> Result<Vec<u8>, EncryptorError>,
{
let mut best_error = None;
for encryptor in &self.encryptors {
match operation(encryptor) {
Ok(value) => return Ok(value),
Err(error) => best_error = Some(prefer_encryptor_error(best_error, error)),
}
}
Err(best_error.unwrap_or(EncryptorError::DecryptionFailed))
}
}
fn split_signed_message(message: &str) -> Option<(&str, &str)> {
message.split_once(SEPARATOR)
}
fn sign_hmac(secret: &[u8], data: &[u8]) -> String {
let mut mac = new_hmac(secret);
mac.update(data);
BASE64_STANDARD.encode(mac.finalize().into_bytes())
}
fn verify_hmac(
secret: &[u8],
encoded_payload: &str,
encoded_signature: &str,
) -> Result<(), VerifierError> {
let signature = BASE64_STANDARD
.decode(encoded_signature)
.map_err(|_| VerifierError::InvalidSignature)?;
let mut mac = new_hmac(secret);
mac.update(encoded_payload.as_bytes());
mac.verify_slice(&signature)
.map_err(|_| VerifierError::InvalidSignature)
}
fn new_hmac(secret: &[u8]) -> HmacSha256 {
match <HmacSha256 as Mac>::new_from_slice(secret) {
Ok(mac) => mac,
Err(_) => unreachable!("HMAC accepts secrets of any size"),
}
}
fn encode_metadata(
data: &[u8],
purpose: Option<&str>,
expires_at: Option<DateTime<Utc>>,
) -> Vec<u8> {
let purpose_bytes = purpose.unwrap_or_default().as_bytes();
let mut flags = 0_u8;
let mut payload =
Vec::with_capacity(META_PREFIX.len() + 1 + 4 + purpose_bytes.len() + 8 + data.len());
if purpose.is_some() {
flags |= FLAG_PURPOSE;
}
if expires_at.is_some() {
flags |= FLAG_EXPIRES_AT;
}
payload.extend_from_slice(META_PREFIX);
payload.push(flags);
if purpose.is_some() {
let length = purpose_bytes.len() as u32;
payload.extend_from_slice(&length.to_be_bytes());
payload.extend_from_slice(purpose_bytes);
}
if let Some(expires_at) = expires_at {
payload.extend_from_slice(&expires_at.timestamp_millis().to_be_bytes());
}
payload.extend_from_slice(data);
payload
}
fn parse_payload(payload: &[u8]) -> Result<DecodedPayload, String> {
if !payload.starts_with(META_PREFIX) {
return Ok(DecodedPayload {
data: payload.to_vec(),
purpose: None,
expires_at: None,
});
}
let mut cursor = META_PREFIX.len();
let flags = *payload
.get(cursor)
.ok_or_else(|| "missing metadata flags".to_owned())?;
cursor += 1;
let purpose = if flags & FLAG_PURPOSE != 0 {
let length_bytes: [u8; 4] = payload
.get(cursor..cursor + 4)
.ok_or_else(|| "missing purpose length".to_owned())?
.try_into()
.map_err(|_| "invalid purpose length".to_owned())?;
cursor += 4;
let length = u32::from_be_bytes(length_bytes) as usize;
let purpose_bytes = payload
.get(cursor..cursor + length)
.ok_or_else(|| "truncated purpose".to_owned())?;
cursor += length;
Some(
std::str::from_utf8(purpose_bytes)
.map_err(|_| "invalid purpose encoding".to_owned())?
.to_owned(),
)
} else {
None
};
let expires_at = if flags & FLAG_EXPIRES_AT != 0 {
let timestamp_bytes: [u8; 8] = payload
.get(cursor..cursor + 8)
.ok_or_else(|| "missing expiration timestamp".to_owned())?
.try_into()
.map_err(|_| "invalid expiration timestamp".to_owned())?;
cursor += 8;
Some(
Utc.timestamp_millis_opt(i64::from_be_bytes(timestamp_bytes))
.single()
.ok_or_else(|| "invalid expiration timestamp".to_owned())?,
)
} else {
None
};
Ok(DecodedPayload {
data: payload[cursor..].to_vec(),
purpose,
expires_at,
})
}
fn validate_payload(
payload: DecodedPayload,
expected_purpose: Option<&str>,
) -> Result<Vec<u8>, VerifierError> {
validate_common_payload(payload, expected_purpose).map_err(|error| match error {
ValidationError::Expired => VerifierError::Expired,
ValidationError::PurposeMismatch => VerifierError::PurposeMismatch,
})
}
fn validate_payload_encryptor(
payload: DecodedPayload,
expected_purpose: Option<&str>,
) -> Result<Vec<u8>, EncryptorError> {
validate_common_payload(payload, expected_purpose).map_err(|error| match error {
ValidationError::Expired => EncryptorError::Expired,
ValidationError::PurposeMismatch => EncryptorError::PurposeMismatch,
})
}
fn validate_common_payload(
payload: DecodedPayload,
expected_purpose: Option<&str>,
) -> Result<Vec<u8>, ValidationError> {
if payload.purpose.as_deref() != expected_purpose
&& (payload.purpose.is_some() || expected_purpose.is_some())
{
return Err(ValidationError::PurposeMismatch);
}
if let Some(expires_at) = payload.expires_at
&& expires_at <= Utc::now()
{
return Err(ValidationError::Expired);
}
Ok(payload.data)
}
fn prefer_verifier_error(
current: Option<VerifierError>,
candidate: VerifierError,
) -> VerifierError {
match current {
None => candidate,
Some(existing) => {
if verifier_error_priority(&candidate) > verifier_error_priority(&existing) {
candidate
} else {
existing
}
}
}
}
fn prefer_encryptor_error(
current: Option<EncryptorError>,
candidate: EncryptorError,
) -> EncryptorError {
match current {
None => candidate,
Some(existing) => {
if encryptor_error_priority(&candidate) > encryptor_error_priority(&existing) {
candidate
} else {
existing
}
}
}
}
fn verifier_error_priority(error: &VerifierError) -> u8 {
match error {
VerifierError::Expired => 3,
VerifierError::PurposeMismatch => 2,
VerifierError::Encoding(_) => 1,
VerifierError::InvalidSignature => 0,
}
}
fn encryptor_error_priority(error: &EncryptorError) -> u8 {
match error {
EncryptorError::Expired => 3,
EncryptorError::PurposeMismatch => 2,
EncryptorError::Encoding(_) => 1,
EncryptorError::DecryptionFailed | EncryptorError::InvalidKeyLength => 0,
}
}
enum ValidationError {
Expired,
PurposeMismatch,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn verifier() -> MessageVerifier {
MessageVerifier::new(b"verifier-secret")
}
fn encryptor() -> MessageEncryptor {
MessageEncryptor::new(&[7_u8; 32]).unwrap()
}
#[test]
fn verifier_round_trip() {
let verifier = verifier();
let message = verifier.generate(b"hello");
assert_eq!(verifier.verify(&message).unwrap(), b"hello");
}
#[test]
fn verifier_rejects_tampering() {
let verifier = verifier();
let mut message = verifier.generate(b"hello");
message.push('x');
assert_eq!(
verifier.verify(&message),
Err(VerifierError::InvalidSignature)
);
}
#[test]
fn verifier_rejects_wrong_key() {
let message = verifier().generate(b"hello");
let wrong = MessageVerifier::new(b"wrong-secret");
assert_eq!(wrong.verify(&message), Err(VerifierError::InvalidSignature));
}
#[test]
fn verifier_checks_purpose() {
let verifier = verifier();
let message = verifier.generate_with_purpose(b"hello", "login", None);
assert_eq!(
verifier.verify_with_purpose(&message, "login").unwrap(),
b"hello"
);
assert_eq!(
verifier.verify_with_purpose(&message, "reset"),
Err(VerifierError::PurposeMismatch)
);
assert_eq!(
verifier.verify(&message),
Err(VerifierError::PurposeMismatch)
);
}
#[test]
fn verifier_checks_expiry() {
let verifier = verifier();
let message = verifier.generate_with_purpose(
b"hello",
"login",
Some(Utc::now() - Duration::seconds(1)),
);
assert_eq!(
verifier.verify_with_purpose(&message, "login"),
Err(VerifierError::Expired)
);
}
#[test]
fn verifier_valid_message_only_checks_signature() {
let verifier = verifier();
let valid = verifier.generate(b"hello");
let expired = verifier.generate_with_purpose(
b"hello",
"login",
Some(Utc::now() - Duration::seconds(1)),
);
assert!(verifier.valid_message(&valid));
assert!(verifier.valid_message(&expired));
assert!(!verifier.valid_message("not-a-message"));
}
#[test]
fn verifier_handles_empty_long_and_unicode_payloads() {
let verifier = verifier();
let payloads = [
Vec::new(),
"héllø 🌍".as_bytes().to_vec(),
vec![b'x'; 8 * 1024],
];
for payload in payloads {
let message = verifier.generate(&payload);
assert_eq!(verifier.verify(&message).unwrap(), payload);
}
}
#[test]
fn encryptor_round_trip() {
let encryptor = encryptor();
let message = encryptor.encrypt_and_sign(b"secret").unwrap();
assert_eq!(encryptor.decrypt_and_verify(&message).unwrap(), b"secret");
}
#[test]
fn encryptor_uses_random_nonces() {
let encryptor = encryptor();
let first = encryptor.encrypt_and_sign(b"secret").unwrap();
let second = encryptor.encrypt_and_sign(b"secret").unwrap();
assert_ne!(first, second);
}
#[test]
fn encryptor_rejects_wrong_key() {
let message = encryptor().encrypt_and_sign(b"secret").unwrap();
let wrong = MessageEncryptor::new(&[8_u8; 32]).unwrap();
assert_eq!(
wrong.decrypt_and_verify(&message),
Err(EncryptorError::DecryptionFailed)
);
}
#[test]
fn encryptor_checks_purpose() {
let encryptor = encryptor();
let message = encryptor
.encrypt_and_sign_with_purpose(b"secret", "login", None)
.unwrap();
assert_eq!(
encryptor
.decrypt_and_verify_with_purpose(&message, "login")
.unwrap(),
b"secret"
);
assert_eq!(
encryptor.decrypt_and_verify_with_purpose(&message, "reset"),
Err(EncryptorError::PurposeMismatch)
);
assert_eq!(
encryptor.decrypt_and_verify(&message),
Err(EncryptorError::PurposeMismatch)
);
}
#[test]
fn encryptor_checks_expiry() {
let encryptor = encryptor();
let message = encryptor
.encrypt_and_sign_with_purpose(
b"secret",
"login",
Some(Utc::now() - Duration::seconds(1)),
)
.unwrap();
assert_eq!(
encryptor.decrypt_and_verify_with_purpose(&message, "login"),
Err(EncryptorError::Expired)
);
}
#[test]
fn encryptor_requires_a_32_byte_key() {
assert!(matches!(
MessageEncryptor::new(&[1_u8; 16]),
Err(EncryptorError::InvalidKeyLength)
));
}
#[test]
fn rotating_verifier_accepts_old_keys_and_uses_newest_for_generation() {
let old = MessageVerifier::new(b"old-secret");
let new = MessageVerifier::new(b"new-secret");
let rotating = RotatingVerifier::new(vec![new, old]);
let old_message = MessageVerifier::new(b"old-secret").generate(b"legacy");
assert_eq!(rotating.verify(&old_message).unwrap(), b"legacy");
let new_message = rotating.generate(b"current");
assert_eq!(
MessageVerifier::new(b"new-secret")
.verify(&new_message)
.unwrap(),
b"current"
);
assert_eq!(
MessageVerifier::new(b"old-secret").verify(&new_message),
Err(VerifierError::InvalidSignature)
);
}
#[test]
fn rotating_encryptor_accepts_old_keys_and_uses_newest_for_generation() {
let old = MessageEncryptor::new(&[1_u8; 32]).unwrap();
let new = MessageEncryptor::new(&[2_u8; 32]).unwrap();
let rotating = RotatingEncryptor::new(vec![new, old]);
let old_message = MessageEncryptor::new(&[1_u8; 32])
.unwrap()
.encrypt_and_sign(b"legacy")
.unwrap();
assert_eq!(
rotating.decrypt_and_verify(&old_message).unwrap(),
b"legacy"
);
let new_message = rotating.encrypt_and_sign(b"current").unwrap();
assert_eq!(
MessageEncryptor::new(&[2_u8; 32])
.unwrap()
.decrypt_and_verify(&new_message)
.unwrap(),
b"current"
);
assert_eq!(
MessageEncryptor::new(&[1_u8; 32])
.unwrap()
.decrypt_and_verify(&new_message),
Err(EncryptorError::DecryptionFailed)
);
}
#[test]
fn encryptor_handles_empty_long_and_unicode_payloads() {
let encryptor = encryptor();
let payloads = [
Vec::new(),
"héllø 🌍".as_bytes().to_vec(),
vec![b'x'; 8 * 1024],
];
for payload in payloads {
let message = encryptor.encrypt_and_sign(&payload).unwrap();
assert_eq!(encryptor.decrypt_and_verify(&message).unwrap(), payload);
}
}
#[test]
fn encryptor_does_not_set_sign_secret_in_aead_mode() {
let encryptor = encryptor();
assert_eq!(encryptor.sign_secret(), None);
}
#[test]
fn verifier_accepts_future_expiry_for_multiple_binary_sizes() {
let verifier = verifier();
let payloads = [
Vec::new(),
vec![0_u8],
(0_u8..=32).collect::<Vec<_>>(),
vec![0xAB; 4 * 1024],
];
for payload in payloads {
let message = verifier.generate_with_purpose(
&payload,
"download",
Some(Utc::now() + Duration::minutes(5)),
);
assert_eq!(
verifier.verify_with_purpose(&message, "download").unwrap(),
payload
);
}
}
#[test]
fn verifier_rejects_payload_and_signature_mutation() {
let verifier = verifier();
let message = verifier.generate(b"hello");
let (payload, signature) = message.split_once(SEPARATOR).unwrap();
let mut payload_bytes = base64::Engine::decode(&BASE64_STANDARD, payload).unwrap();
payload_bytes[0] ^= 0x01;
let tampered_payload = format!(
"{}{}{}",
base64::Engine::encode(&BASE64_STANDARD, payload_bytes),
SEPARATOR,
signature
);
assert_eq!(
verifier.verify(&tampered_payload),
Err(VerifierError::InvalidSignature)
);
let mut signature_bytes = base64::Engine::decode(&BASE64_STANDARD, signature).unwrap();
signature_bytes[0] ^= 0x01;
let tampered_signature = format!(
"{}{}{}",
payload,
SEPARATOR,
base64::Engine::encode(&BASE64_STANDARD, signature_bytes),
);
assert_eq!(
verifier.verify(&tampered_signature),
Err(VerifierError::InvalidSignature)
);
}
#[test]
fn encryptor_accepts_future_expiry_for_multiple_binary_sizes() {
let encryptor = encryptor();
let payloads = [
Vec::new(),
vec![0_u8],
(0_u8..=32).collect::<Vec<_>>(),
vec![0xCD; 4 * 1024],
];
for payload in payloads {
let message = encryptor
.encrypt_and_sign_with_purpose(
&payload,
"download",
Some(Utc::now() + Duration::minutes(5)),
)
.unwrap();
assert_eq!(
encryptor
.decrypt_and_verify_with_purpose(&message, "download")
.unwrap(),
payload
);
}
}
#[test]
fn encryptor_rejects_nonce_and_ciphertext_mutation() {
let encryptor = encryptor();
let message = encryptor.encrypt_and_sign(b"secret").unwrap();
let (nonce, ciphertext) = message.split_once(SEPARATOR).unwrap();
let mut nonce_bytes = base64::Engine::decode(&BASE64_STANDARD, nonce).unwrap();
nonce_bytes[0] ^= 0x01;
let tampered_nonce = format!(
"{}{}{}",
base64::Engine::encode(&BASE64_STANDARD, nonce_bytes),
SEPARATOR,
ciphertext
);
assert_eq!(
encryptor.decrypt_and_verify(&tampered_nonce),
Err(EncryptorError::DecryptionFailed)
);
let mut ciphertext_bytes = base64::Engine::decode(&BASE64_STANDARD, ciphertext).unwrap();
ciphertext_bytes[0] ^= 0x01;
let tampered_ciphertext = format!(
"{}{}{}",
nonce,
SEPARATOR,
base64::Engine::encode(&BASE64_STANDARD, ciphertext_bytes),
);
assert_eq!(
encryptor.decrypt_and_verify(&tampered_ciphertext),
Err(EncryptorError::DecryptionFailed)
);
}
#[test]
fn rotating_verifier_preserves_purpose_and_expiry_errors_for_old_keys() {
let old = MessageVerifier::new(b"old-secret");
let new = MessageVerifier::new(b"new-secret");
let rotating = RotatingVerifier::new(vec![new, old]);
let old_message = MessageVerifier::new(b"old-secret").generate_with_purpose(
b"legacy",
"login",
Some(Utc::now() + Duration::minutes(5)),
);
assert_eq!(
rotating.verify_with_purpose(&old_message, "login").unwrap(),
b"legacy"
);
assert_eq!(
rotating.verify_with_purpose(&old_message, "reset"),
Err(VerifierError::PurposeMismatch)
);
let expired_message = MessageVerifier::new(b"old-secret").generate_with_purpose(
b"legacy",
"login",
Some(Utc::now() - Duration::seconds(1)),
);
assert_eq!(
rotating.verify_with_purpose(&expired_message, "login"),
Err(VerifierError::Expired)
);
}
#[test]
fn rotating_encryptor_preserves_purpose_and_expiry_errors_for_old_keys() {
let old = MessageEncryptor::new(&[1_u8; 32]).unwrap();
let new = MessageEncryptor::new(&[2_u8; 32]).unwrap();
let rotating = RotatingEncryptor::new(vec![new, old]);
let old_message = MessageEncryptor::new(&[1_u8; 32])
.unwrap()
.encrypt_and_sign_with_purpose(
b"legacy",
"login",
Some(Utc::now() + Duration::minutes(5)),
)
.unwrap();
assert_eq!(
rotating
.decrypt_and_verify_with_purpose(&old_message, "login")
.unwrap(),
b"legacy"
);
assert_eq!(
rotating.decrypt_and_verify_with_purpose(&old_message, "reset"),
Err(EncryptorError::PurposeMismatch)
);
let expired_message = MessageEncryptor::new(&[1_u8; 32])
.unwrap()
.encrypt_and_sign_with_purpose(
b"legacy",
"login",
Some(Utc::now() - Duration::seconds(1)),
)
.unwrap();
assert_eq!(
rotating.decrypt_and_verify_with_purpose(&expired_message, "login"),
Err(EncryptorError::Expired)
);
}
fn verifier_signed_message(payload: Vec<u8>) -> String {
let encoded_payload = BASE64_STANDARD.encode(payload);
let signature = sign_hmac(b"verifier-secret", encoded_payload.as_bytes());
format!("{encoded_payload}{SEPARATOR}{signature}")
}
fn encryptor_message(payload: Vec<u8>) -> String {
encryptor().encrypt_payload(payload).unwrap()
}
fn bytes(len: usize) -> Vec<u8> {
(0..len).map(|index| (index % 251) as u8).collect()
}
fn metadata_prefixed_payload() -> Vec<u8> {
let mut payload = META_PREFIX.to_vec();
payload.extend_from_slice(&[0, b'h', b'e', b'l', b'l', b'o', 0xFF]);
payload
}
macro_rules! verifier_round_trip_size_case {
($name:ident, $len:expr) => {
#[test]
fn $name() {
let verifier = verifier();
let payload = bytes($len);
let message = verifier.generate(&payload);
assert_eq!(verifier.verify(&message).unwrap(), payload);
}
};
}
macro_rules! encryptor_round_trip_size_case {
($name:ident, $len:expr) => {
#[test]
fn $name() {
let encryptor = encryptor();
let payload = bytes($len);
let message = encryptor.encrypt_and_sign(&payload).unwrap();
assert_eq!(encryptor.decrypt_and_verify(&message).unwrap(), payload);
}
};
}
macro_rules! verifier_future_expiry_case {
($name:ident, $len:expr) => {
#[test]
fn $name() {
let verifier = verifier();
let payload = bytes($len);
let message = verifier.generate_with_purpose(
&payload,
"download",
Some(Utc::now() + Duration::minutes(5)),
);
assert_eq!(
verifier.verify_with_purpose(&message, "download").unwrap(),
payload
);
}
};
}
macro_rules! encryptor_future_expiry_case {
($name:ident, $len:expr) => {
#[test]
fn $name() {
let encryptor = encryptor();
let payload = bytes($len);
let message = encryptor
.encrypt_and_sign_with_purpose(
&payload,
"download",
Some(Utc::now() + Duration::minutes(5)),
)
.unwrap();
assert_eq!(
encryptor
.decrypt_and_verify_with_purpose(&message, "download")
.unwrap(),
payload
);
}
};
}
verifier_round_trip_size_case!(verifier_round_trip_size_0, 0);
verifier_round_trip_size_case!(verifier_round_trip_size_1, 1);
verifier_round_trip_size_case!(verifier_round_trip_size_2, 2);
verifier_round_trip_size_case!(verifier_round_trip_size_31, 31);
verifier_round_trip_size_case!(verifier_round_trip_size_32, 32);
verifier_round_trip_size_case!(verifier_round_trip_size_33, 33);
verifier_round_trip_size_case!(verifier_round_trip_size_255, 255);
verifier_round_trip_size_case!(verifier_round_trip_size_1024, 1024);
encryptor_round_trip_size_case!(encryptor_round_trip_size_0, 0);
encryptor_round_trip_size_case!(encryptor_round_trip_size_1, 1);
encryptor_round_trip_size_case!(encryptor_round_trip_size_2, 2);
encryptor_round_trip_size_case!(encryptor_round_trip_size_31, 31);
encryptor_round_trip_size_case!(encryptor_round_trip_size_32, 32);
encryptor_round_trip_size_case!(encryptor_round_trip_size_33, 33);
encryptor_round_trip_size_case!(encryptor_round_trip_size_255, 255);
encryptor_round_trip_size_case!(encryptor_round_trip_size_1024, 1024);
verifier_future_expiry_case!(verifier_future_expiry_size_0, 0);
verifier_future_expiry_case!(verifier_future_expiry_size_7, 7);
verifier_future_expiry_case!(verifier_future_expiry_size_64, 64);
verifier_future_expiry_case!(verifier_future_expiry_size_2048, 2048);
encryptor_future_expiry_case!(encryptor_future_expiry_size_0, 0);
encryptor_future_expiry_case!(encryptor_future_expiry_size_7, 7);
encryptor_future_expiry_case!(encryptor_future_expiry_size_64, 64);
encryptor_future_expiry_case!(encryptor_future_expiry_size_2048, 2048);
#[test]
fn verifier_raw_message_requires_no_purpose() {
let verifier = verifier();
let message = verifier.generate(b"hello");
assert_eq!(
verifier.verify_with_purpose(&message, "login"),
Err(VerifierError::PurposeMismatch)
);
}
#[test]
fn verifier_empty_purpose_round_trip() {
let verifier = verifier();
let message = verifier.generate_with_purpose(b"hello", "", None);
assert_eq!(
verifier.verify_with_purpose(&message, "").unwrap(),
b"hello"
);
assert_eq!(
verifier.verify(&message),
Err(VerifierError::PurposeMismatch)
);
}
#[test]
fn verifier_valid_message_rejects_missing_separator() {
assert!(!verifier().valid_message("not-a-message"));
}
#[test]
fn verifier_valid_message_rejects_invalid_payload_base64() {
let encoded_payload = "***";
let signature = sign_hmac(b"verifier-secret", encoded_payload.as_bytes());
let message = format!("{encoded_payload}{SEPARATOR}{signature}");
assert!(!verifier().valid_message(&message));
}
#[test]
fn verifier_valid_message_rejects_invalid_signature_base64() {
let message = format!("{}{}***", BASE64_STANDARD.encode(b"hello"), SEPARATOR);
assert!(!verifier().valid_message(&message));
}
#[test]
fn verifier_signed_prefix_without_flags_returns_encoding_error() {
let message = verifier_signed_message(META_PREFIX.to_vec());
assert_eq!(
verifier().verify(&message),
Err(VerifierError::Encoding("missing metadata flags".to_owned()))
);
}
#[test]
fn verifier_truncated_purpose_returns_encoding_error() {
let mut payload = META_PREFIX.to_vec();
payload.push(FLAG_PURPOSE);
payload.extend_from_slice(&4_u32.to_be_bytes());
payload.extend_from_slice(b"ab");
let message = verifier_signed_message(payload);
assert_eq!(
verifier().verify(&message),
Err(VerifierError::Encoding("truncated purpose".to_owned()))
);
}
#[test]
fn verifier_invalid_utf8_purpose_returns_encoding_error() {
let mut payload = META_PREFIX.to_vec();
payload.push(FLAG_PURPOSE);
payload.extend_from_slice(&2_u32.to_be_bytes());
payload.extend_from_slice(&[0xFF, 0xFE]);
let message = verifier_signed_message(payload);
assert_eq!(
verifier().verify(&message),
Err(VerifierError::Encoding(
"invalid purpose encoding".to_owned(),
))
);
}
#[test]
fn verifier_missing_expiration_timestamp_returns_encoding_error() {
let mut payload = META_PREFIX.to_vec();
payload.push(FLAG_EXPIRES_AT);
let message = verifier_signed_message(payload);
assert_eq!(
verifier().verify(&message),
Err(VerifierError::Encoding(
"missing expiration timestamp".to_owned(),
))
);
}
#[test]
fn verifier_invalid_expiration_timestamp_returns_encoding_error() {
let mut payload = META_PREFIX.to_vec();
payload.push(FLAG_EXPIRES_AT);
payload.extend_from_slice(&i64::MAX.to_be_bytes());
let message = verifier_signed_message(payload);
assert_eq!(
verifier().verify(&message),
Err(VerifierError::Encoding(
"invalid expiration timestamp".to_owned(),
))
);
}
#[test]
fn verifier_missing_purpose_length_returns_encoding_error() {
let mut payload = META_PREFIX.to_vec();
payload.push(FLAG_PURPOSE);
let message = verifier_signed_message(payload);
assert_eq!(
verifier().verify(&message),
Err(VerifierError::Encoding("missing purpose length".to_owned()))
);
}
#[test]
fn verifier_rejects_empty_messages() {
assert_eq!(verifier().verify(""), Err(VerifierError::InvalidSignature));
}
#[test]
fn verifier_rejects_extra_separator_in_signature() {
let verifier = verifier();
let message = format!("{}--extra", verifier.generate(b"hello"));
assert_eq!(
verifier.verify(&message),
Err(VerifierError::InvalidSignature)
);
}
#[test]
fn verifier_purpose_mismatch_precedes_expiry() {
let verifier = verifier();
let message = verifier.generate_with_purpose(
b"hello",
"login",
Some(Utc::now() - Duration::seconds(1)),
);
assert_eq!(
verifier.verify_with_purpose(&message, "reset"),
Err(VerifierError::PurposeMismatch)
);
}
#[test]
fn verifier_preserves_payloads_starting_with_metadata_prefix() {
let verifier = verifier();
let payload = metadata_prefixed_payload();
let message = verifier.generate(&payload);
assert_eq!(verifier.verify(&message).unwrap(), payload);
}
#[test]
fn rotating_verifier_valid_message_accepts_old_key() {
let rotating = RotatingVerifier::new(vec![
MessageVerifier::new(b"new-secret"),
MessageVerifier::new(b"old-secret"),
]);
let old_message = MessageVerifier::new(b"old-secret").generate(b"legacy");
assert!(rotating.valid_message(&old_message));
}
#[test]
fn rotating_verifier_prefers_purpose_mismatch_over_invalid_signature() {
let rotating = RotatingVerifier::new(vec![
MessageVerifier::new(b"new-secret"),
MessageVerifier::new(b"old-secret"),
]);
let old_message = MessageVerifier::new(b"old-secret").generate_with_purpose(
b"legacy",
"login",
Some(Utc::now() + Duration::minutes(5)),
);
assert_eq!(
rotating.verify_with_purpose(&old_message, "reset"),
Err(VerifierError::PurposeMismatch)
);
}
#[test]
fn rotating_verifier_prefers_expired_over_invalid_signature() {
let rotating = RotatingVerifier::new(vec![
MessageVerifier::new(b"new-secret"),
MessageVerifier::new(b"old-secret"),
]);
let old_message = MessageVerifier::new(b"old-secret").generate_with_purpose(
b"legacy",
"login",
Some(Utc::now() - Duration::seconds(1)),
);
assert_eq!(
rotating.verify_with_purpose(&old_message, "login"),
Err(VerifierError::Expired)
);
}
#[test]
fn rotating_verifier_generate_with_purpose_uses_newest_key() {
let rotating = RotatingVerifier::new(vec![
MessageVerifier::new(b"new-secret"),
MessageVerifier::new(b"old-secret"),
]);
let message = rotating.generate_with_purpose(
b"fresh",
"login",
Some(Utc::now() + Duration::minutes(5)),
);
assert_eq!(
MessageVerifier::new(b"new-secret")
.verify_with_purpose(&message, "login")
.unwrap(),
b"fresh"
);
}
#[test]
fn rotating_verifier_prefers_encoding_over_invalid_signature() {
let rotating = RotatingVerifier::new(vec![
MessageVerifier::new(b"new-secret"),
MessageVerifier::new(b"old-secret"),
]);
let old_message = {
let encoded_payload = BASE64_STANDARD.encode(META_PREFIX);
let signature = sign_hmac(b"old-secret", encoded_payload.as_bytes());
format!("{encoded_payload}{SEPARATOR}{signature}")
};
assert_eq!(
rotating.verify(&old_message),
Err(VerifierError::Encoding("missing metadata flags".to_owned()))
);
}
#[test]
fn encryptor_raw_message_requires_no_purpose() {
let encryptor = encryptor();
let message = encryptor.encrypt_and_sign(b"hello").unwrap();
assert_eq!(
encryptor.decrypt_and_verify_with_purpose(&message, "login"),
Err(EncryptorError::PurposeMismatch)
);
}
#[test]
fn encryptor_empty_purpose_round_trip() {
let encryptor = encryptor();
let message = encryptor
.encrypt_and_sign_with_purpose(b"hello", "", None)
.unwrap();
assert_eq!(
encryptor
.decrypt_and_verify_with_purpose(&message, "")
.unwrap(),
b"hello"
);
assert_eq!(
encryptor.decrypt_and_verify(&message),
Err(EncryptorError::PurposeMismatch)
);
}
#[test]
fn encryptor_missing_separator_returns_encoding_error() {
assert_eq!(
encryptor().decrypt_and_verify("not-a-message"),
Err(EncryptorError::Encoding(
"invalid encrypted message format".to_owned(),
))
);
}
#[test]
fn encryptor_invalid_nonce_base64_returns_encoding_error() {
let message = format!("***{SEPARATOR}{}", BASE64_STANDARD.encode(b"ciphertext"));
assert_eq!(
encryptor().decrypt_and_verify(&message),
Err(EncryptorError::Encoding(
"invalid nonce encoding".to_owned()
))
);
}
#[test]
fn encryptor_invalid_nonce_length_returns_encoding_error() {
let message = format!(
"{}{SEPARATOR}{}",
BASE64_STANDARD.encode([1_u8, 2_u8]),
BASE64_STANDARD.encode(b"ciphertext")
);
assert_eq!(
encryptor().decrypt_and_verify(&message),
Err(EncryptorError::Encoding("invalid nonce length".to_owned()))
);
}
#[test]
fn encryptor_invalid_ciphertext_base64_returns_encoding_error() {
let message = format!("{}{SEPARATOR}***", BASE64_STANDARD.encode([0_u8; 12]));
assert_eq!(
encryptor().decrypt_and_verify(&message),
Err(EncryptorError::Encoding(
"invalid ciphertext encoding".to_owned(),
))
);
}
#[test]
fn encryptor_signed_prefix_without_flags_returns_encoding_error() {
let message = encryptor_message(META_PREFIX.to_vec());
assert_eq!(
encryptor().decrypt_and_verify(&message),
Err(EncryptorError::Encoding(
"missing metadata flags".to_owned()
))
);
}
#[test]
fn encryptor_truncated_purpose_returns_encoding_error() {
let mut payload = META_PREFIX.to_vec();
payload.push(FLAG_PURPOSE);
payload.extend_from_slice(&4_u32.to_be_bytes());
payload.extend_from_slice(b"ab");
let message = encryptor_message(payload);
assert_eq!(
encryptor().decrypt_and_verify(&message),
Err(EncryptorError::Encoding("truncated purpose".to_owned()))
);
}
#[test]
fn encryptor_invalid_utf8_purpose_returns_encoding_error() {
let mut payload = META_PREFIX.to_vec();
payload.push(FLAG_PURPOSE);
payload.extend_from_slice(&2_u32.to_be_bytes());
payload.extend_from_slice(&[0xFF, 0xFE]);
let message = encryptor_message(payload);
assert_eq!(
encryptor().decrypt_and_verify(&message),
Err(EncryptorError::Encoding(
"invalid purpose encoding".to_owned(),
))
);
}
#[test]
fn encryptor_missing_expiration_timestamp_returns_encoding_error() {
let mut payload = META_PREFIX.to_vec();
payload.push(FLAG_EXPIRES_AT);
let message = encryptor_message(payload);
assert_eq!(
encryptor().decrypt_and_verify(&message),
Err(EncryptorError::Encoding(
"missing expiration timestamp".to_owned(),
))
);
}
#[test]
fn encryptor_invalid_expiration_timestamp_returns_encoding_error() {
let mut payload = META_PREFIX.to_vec();
payload.push(FLAG_EXPIRES_AT);
payload.extend_from_slice(&i64::MAX.to_be_bytes());
let message = encryptor_message(payload);
assert_eq!(
encryptor().decrypt_and_verify(&message),
Err(EncryptorError::Encoding(
"invalid expiration timestamp".to_owned(),
))
);
}
#[test]
fn encryptor_missing_purpose_length_returns_encoding_error() {
let mut payload = META_PREFIX.to_vec();
payload.push(FLAG_PURPOSE);
let message = encryptor_message(payload);
assert_eq!(
encryptor().decrypt_and_verify(&message),
Err(EncryptorError::Encoding(
"missing purpose length".to_owned()
))
);
}
#[test]
fn encryptor_purpose_mismatch_precedes_expiry() {
let encryptor = encryptor();
let message = encryptor
.encrypt_and_sign_with_purpose(
b"hello",
"login",
Some(Utc::now() - Duration::seconds(1)),
)
.unwrap();
assert_eq!(
encryptor.decrypt_and_verify_with_purpose(&message, "reset"),
Err(EncryptorError::PurposeMismatch)
);
}
#[test]
fn encryptor_preserves_payloads_starting_with_metadata_prefix() {
let encryptor = encryptor();
let payload = metadata_prefixed_payload();
let message = encryptor.encrypt_and_sign(&payload).unwrap();
assert_eq!(encryptor.decrypt_and_verify(&message).unwrap(), payload);
}
#[test]
fn rotating_encryptor_prefers_encoding_over_decryption_failed() {
let rotating = RotatingEncryptor::new(vec![
MessageEncryptor::new(&[2_u8; 32]).unwrap(),
MessageEncryptor::new(&[1_u8; 32]).unwrap(),
]);
let old_message = MessageEncryptor::new(&[1_u8; 32])
.unwrap()
.encrypt_payload(META_PREFIX.to_vec())
.unwrap();
assert_eq!(
rotating.decrypt_and_verify(&old_message),
Err(EncryptorError::Encoding(
"missing metadata flags".to_owned()
))
);
}
#[test]
fn rotating_encryptor_generate_with_purpose_uses_newest_key() {
let rotating = RotatingEncryptor::new(vec![
MessageEncryptor::new(&[2_u8; 32]).unwrap(),
MessageEncryptor::new(&[1_u8; 32]).unwrap(),
]);
let message = rotating
.encrypt_and_sign_with_purpose(
b"fresh",
"login",
Some(Utc::now() + Duration::minutes(5)),
)
.unwrap();
assert_eq!(
MessageEncryptor::new(&[2_u8; 32])
.unwrap()
.decrypt_and_verify_with_purpose(&message, "login")
.unwrap(),
b"fresh"
);
}
}