use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SignatureError {
#[error("invalid webhook secret")]
InvalidSecret,
#[error("invalid or missing signature header")]
InvalidHeader,
#[error("webhook signature mismatch")]
Mismatch,
#[error("webhook timestamp outside tolerance")]
StaleTimestamp,
}
pub const DEFAULT_WEBHOOK_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(300);
pub fn verify_webhook_signature(
secret: &str,
svix_id: &str,
svix_timestamp: &str,
svix_signature: &str,
body: &[u8],
) -> Result<(), SignatureError> {
let key_bytes = decode_secret(secret)?;
let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, &key_bytes);
let mut signed = Vec::with_capacity(svix_id.len() + svix_timestamp.len() + body.len() + 2);
signed.extend_from_slice(svix_id.as_bytes());
signed.push(b'.');
signed.extend_from_slice(svix_timestamp.as_bytes());
signed.push(b'.');
signed.extend_from_slice(body);
let mut saw_candidate = false;
for candidate in svix_signature.split(' ') {
let Some((version, sig_b64)) = candidate.split_once(',') else {
continue;
};
if version != "v1" {
continue;
}
saw_candidate = true;
let Ok(sig) = BASE64.decode(sig_b64) else {
continue;
};
if ring::hmac::verify(&key, &signed, &sig).is_ok() {
return Ok(());
}
}
if saw_candidate {
Err(SignatureError::Mismatch)
} else {
Err(SignatureError::InvalidHeader)
}
}
pub fn verify_webhook_timestamp(
svix_timestamp: &str,
tolerance: std::time::Duration,
) -> Result<(), SignatureError> {
let ts: i64 = svix_timestamp
.parse()
.map_err(|_| SignatureError::StaleTimestamp)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|_| SignatureError::StaleTimestamp)?
.as_secs() as i64;
if (now - ts).unsigned_abs() <= tolerance.as_secs() {
Ok(())
} else {
Err(SignatureError::StaleTimestamp)
}
}
fn decode_secret(secret: &str) -> Result<Vec<u8>, SignatureError> {
if secret.is_empty() {
return Err(SignatureError::InvalidSecret);
}
let b64 = secret.strip_prefix("whsec_").unwrap_or(secret);
BASE64
.decode(b64)
.map_err(|_| SignatureError::InvalidSecret)
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET_B64: &str = "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
const ID: &str = "msg_p5jXN8AQM9LWM0D4loKWxJek";
const TS: &str = "1614265330";
const BODY: &[u8] = b"{\"test\": 2432232314}";
const SIG: &str = "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=";
fn secret() -> String {
format!("whsec_{SECRET_B64}")
}
#[test]
fn known_vector_verifies() {
assert!(verify_webhook_signature(&secret(), ID, TS, SIG, BODY).is_ok());
}
#[test]
fn accepts_when_one_of_several_candidates_matches() {
let multi = format!("v1,aaaa v2,bbbb {SIG}");
assert!(verify_webhook_signature(&secret(), ID, TS, &multi, BODY).is_ok());
}
#[test]
fn tampered_body_is_rejected() {
let bad = verify_webhook_signature(&secret(), ID, TS, SIG, b"{\"test\": 9999}");
assert_eq!(bad, Err(SignatureError::Mismatch));
}
#[test]
fn empty_or_malformed_secret_is_rejected() {
assert_eq!(
verify_webhook_signature("", ID, TS, SIG, BODY),
Err(SignatureError::InvalidSecret),
);
assert_eq!(
verify_webhook_signature(&format!("whsec_{}", "!!!not base64!!!"), ID, TS, SIG, BODY),
Err(SignatureError::InvalidSecret),
);
}
#[test]
fn no_v1_candidate_is_invalid_header() {
assert_eq!(
verify_webhook_signature(&secret(), ID, TS, "v2,whatever", BODY),
Err(SignatureError::InvalidHeader),
);
}
#[test]
fn timestamp_tolerance() {
assert_eq!(
verify_webhook_timestamp(TS, DEFAULT_WEBHOOK_TOLERANCE),
Err(SignatureError::StaleTimestamp),
);
assert_eq!(
verify_webhook_timestamp("not-a-number", DEFAULT_WEBHOOK_TOLERANCE),
Err(SignatureError::StaleTimestamp),
);
}
}