use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
pub(crate) const TOLERANCE_SECS: i64 = 300;
const SECRET_PREFIX: &str = "whsec_";
const VERSION: &str = "v1";
#[derive(Debug, Clone, Copy)]
pub(crate) struct Delivery<'a> {
pub id: &'a str,
pub timestamp: &'a str,
pub signatures: &'a str,
}
pub(crate) fn is_signed(secret: &str, delivery: &Delivery<'_>, body: &[u8], now_unix: i64) -> bool {
if !within_tolerance(delivery.timestamp, now_unix) {
return false;
}
let Some(key) = decode_secret(secret) else {
return false;
};
let expected = mac(&key, delivery.id, delivery.timestamp, body);
let mut matched = false;
for candidate in delivery.signatures.split_whitespace() {
let Some((version, value)) = candidate.split_once(',') else {
continue;
};
if version != VERSION {
continue;
}
let Ok(bytes) = STANDARD.decode(value) else {
continue;
};
matched |= bool::from(bytes.ct_eq(&expected));
}
matched
}
fn within_tolerance(timestamp: &str, now_unix: i64) -> bool {
let Ok(sent) = timestamp.trim().parse::<i64>() else {
return false;
};
now_unix.saturating_sub(sent).abs() <= TOLERANCE_SECS
}
fn decode_secret(secret: &str) -> Option<Vec<u8>> {
let trimmed = secret.trim();
let encoded = trimmed.strip_prefix(SECRET_PREFIX).unwrap_or(trimmed);
STANDARD.decode(encoded).ok().filter(|key| !key.is_empty())
}
fn mac(key: &[u8], id: &str, timestamp: &str, body: &[u8]) -> Vec<u8> {
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(id.as_bytes());
mac.update(b".");
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body);
mac.finalize().into_bytes().to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &str = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
const ID: &str = "msg_p5jXN8AQM9LWM0D4loKWxJek";
const NOW: i64 = 1_800_000_000;
fn sign(secret: &str, id: &str, timestamp: i64, body: &[u8]) -> String {
let key = decode_secret(secret).expect("the fixture secret decodes");
let digest = mac(&key, id, ×tamp.to_string(), body);
format!("v1,{}", STANDARD.encode(digest))
}
fn delivery<'a>(timestamp: &'a str, signatures: &'a str) -> Delivery<'a> {
Delivery {
id: ID,
timestamp,
signatures,
}
}
#[test]
fn a_delivery_signed_with_the_endpoint_secret_verifies() {
let body = br#"{"type":"email.bounced"}"#;
let header = sign(SECRET, ID, NOW, body);
assert!(is_signed(
SECRET,
&delivery("1800000000", &header),
body,
NOW
));
}
#[test]
fn the_secret_verifies_with_or_without_the_whsec_prefix() {
let body = b"{}";
let header = sign(SECRET, ID, NOW, body);
let bare = SECRET.strip_prefix(SECRET_PREFIX).expect("prefixed");
assert!(is_signed(bare, &delivery("1800000000", &header), body, NOW));
}
#[test]
fn a_body_changed_after_signing_does_not_verify() {
let body = br#"{"type":"email.bounced"}"#;
let header = sign(SECRET, ID, NOW, body);
let tampered = br#"{"type":"email.complained"}"#;
assert!(!is_signed(
SECRET,
&delivery("1800000000", &header),
tampered,
NOW
));
}
#[test]
fn another_secret_does_not_verify() {
let body = b"{}";
let header = sign("whsec_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", ID, NOW, body);
assert!(!is_signed(
SECRET,
&delivery("1800000000", &header),
body,
NOW
));
}
#[test]
fn the_id_is_part_of_what_is_signed() {
let body = b"{}";
let header = sign(SECRET, "msg_another", NOW, body);
assert!(!is_signed(
SECRET,
&delivery("1800000000", &header),
body,
NOW
));
}
#[test]
fn one_matching_signature_among_several_is_enough() {
let body = b"{}";
let old = sign("whsec_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", ID, NOW, body);
let new = sign(SECRET, ID, NOW, body);
let both = format!("{old} {new}");
assert!(is_signed(SECRET, &delivery("1800000000", &both), body, NOW));
}
#[test]
fn a_signature_of_an_unknown_version_is_not_read() {
let body = b"{}";
let header = sign(SECRET, ID, NOW, body).replace("v1,", "v0,");
assert!(!is_signed(
SECRET,
&delivery("1800000000", &header),
body,
NOW
));
}
#[test]
fn a_replay_outside_the_tolerance_is_refused() {
let body = b"{}";
let sent = NOW - TOLERANCE_SECS - 1;
let header = sign(SECRET, ID, sent, body);
assert!(is_signed(
SECRET,
&delivery(&sent.to_string(), &header),
body,
sent
));
assert!(!is_signed(
SECRET,
&delivery(&sent.to_string(), &header),
body,
NOW
));
}
#[test]
fn a_timestamp_far_in_the_future_is_refused_too() {
let body = b"{}";
let sent = NOW + TOLERANCE_SECS + 1;
let header = sign(SECRET, ID, sent, body);
assert!(!is_signed(
SECRET,
&delivery(&sent.to_string(), &header),
body,
NOW
));
}
#[test]
fn unreadable_input_refuses_rather_than_verifies() {
let body = b"{}";
let header = sign(SECRET, ID, NOW, body);
assert!(!is_signed(
SECRET,
&delivery("recently", &header),
body,
NOW
));
assert!(!is_signed(
SECRET,
&delivery("1800000000", "garbage"),
body,
NOW
));
assert!(!is_signed(
"whsec_!!!!",
&delivery("1800000000", &header),
body,
NOW
));
assert!(!is_signed("", &delivery("1800000000", &header), body, NOW));
}
}