use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
pub const DEFAULT_TOLERANCE_SECS: i64 = 300;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum WebhookVerificationError {
#[error("Missing required webhook headers")]
MissingHeaders,
#[error("Invalid webhook timestamp format")]
InvalidTimestampFormat,
#[error("Webhook timestamp is too old")]
TimestampTooOld,
#[error("Webhook timestamp is too new")]
TimestampTooNew,
#[error("The given webhook signature does not match the expected signature")]
SignatureMismatch,
#[error("invalid webhook secret: {0}")]
InvalidSecret(String),
#[error("failed to parse webhook payload as JSON: {0}")]
InvalidPayload(String),
}
#[derive(Debug, Clone)]
pub struct WebhookHeaders {
pub id: String,
pub timestamp: String,
pub signature: String,
}
impl WebhookHeaders {
pub fn new(
id: impl Into<String>,
timestamp: impl Into<String>,
signature: impl Into<String>,
) -> Self {
Self {
id: id.into(),
timestamp: timestamp.into(),
signature: signature.into(),
}
}
pub fn from_lookup(
get: impl Fn(&str) -> Option<String>,
) -> Result<Self, WebhookVerificationError> {
let field = |name: &str| get(name).ok_or(WebhookVerificationError::MissingHeaders);
Ok(Self {
id: field("webhook-id")?,
timestamp: field("webhook-timestamp")?,
signature: field("webhook-signature")?,
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct WebhookEvent {
pub id: String,
#[serde(rename = "type")]
pub r#type: String,
pub created_at: i64,
#[serde(default)]
pub data: serde_json::Value,
}
pub struct Webhooks {
secret: Vec<u8>,
}
impl Webhooks {
pub fn new(secret: &str) -> Result<Self, WebhookVerificationError> {
let secret = if let Some(rest) = secret.strip_prefix("whsec_") {
STANDARD
.decode(rest)
.map_err(|e| WebhookVerificationError::InvalidSecret(e.to_string()))?
} else {
secret.as_bytes().to_vec()
};
Ok(Self { secret })
}
pub fn verify_signature(
&self,
payload: &[u8],
headers: &WebhookHeaders,
tolerance_secs: i64,
now_unix: i64,
) -> Result<(), WebhookVerificationError> {
let timestamp_seconds: i64 = headers
.timestamp
.trim()
.parse()
.map_err(|_| WebhookVerificationError::InvalidTimestampFormat)?;
if now_unix - timestamp_seconds > tolerance_secs {
return Err(WebhookVerificationError::TimestampTooOld);
}
if timestamp_seconds > now_unix + tolerance_secs {
return Err(WebhookVerificationError::TimestampTooNew);
}
let signatures: Vec<&str> = headers
.signature
.split_whitespace()
.map(|part| part.strip_prefix("v1,").unwrap_or(part))
.collect();
let mut mac = HmacSha256::new_from_slice(&self.secret)
.expect("HMAC-SHA256 accepts keys of any length");
mac.update(headers.id.as_bytes());
mac.update(b".");
mac.update(headers.timestamp.as_bytes());
mac.update(b".");
mac.update(payload);
let expected = STANDARD.encode(mac.finalize().into_bytes());
let expected_bytes = expected.as_bytes();
let matched = signatures.iter().any(|sig| {
let sig_bytes = sig.as_bytes();
sig_bytes.len() == expected_bytes.len()
&& expected_bytes.ct_eq(sig_bytes).into()
});
if matched {
Ok(())
} else {
Err(WebhookVerificationError::SignatureMismatch)
}
}
pub fn verify(
&self,
payload: &[u8],
id: &str,
timestamp: &str,
signature_header: &str,
) -> Result<(), WebhookVerificationError> {
let headers = WebhookHeaders::new(id, timestamp, signature_header);
self.verify_signature(payload, &headers, DEFAULT_TOLERANCE_SECS, now_unix())
}
pub fn unwrap(
&self,
payload: &[u8],
headers: &WebhookHeaders,
) -> Result<WebhookEvent, WebhookVerificationError> {
self.verify_signature(payload, headers, DEFAULT_TOLERANCE_SECS, now_unix())?;
serde_json::from_slice(payload)
.map_err(|e| WebhookVerificationError::InvalidPayload(e.to_string()))
}
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn sign(secret_bytes: &[u8], id: &str, timestamp: &str, payload: &[u8]) -> String {
let body = String::from_utf8_lossy(payload);
let signed = format!("{id}.{timestamp}.{body}");
let mut mac = HmacSha256::new_from_slice(secret_bytes).unwrap();
mac.update(signed.as_bytes());
STANDARD.encode(mac.finalize().into_bytes())
}
const SECRET: &str = "my-webhook-secret";
const ID: &str = "wh_123";
const PAYLOAD: &[u8] = br#"{"id":"evt_1","type":"response.completed","created_at":100,"data":{"foo":"bar"}}"#;
#[test]
fn accepts_valid_signature() {
let ts = "1000";
let sig = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
let wh = Webhooks::new(SECRET).unwrap();
let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
assert!(wh
.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000)
.is_ok());
}
#[test]
fn accepts_bare_signature_without_v1_prefix() {
let ts = "1000";
let sig = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
let wh = Webhooks::new(SECRET).unwrap();
let headers = WebhookHeaders::new(ID, ts, sig); assert!(wh
.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000)
.is_ok());
}
#[test]
fn rejects_wrong_signature() {
let ts = "1000";
let wh = Webhooks::new(SECRET).unwrap();
let headers = WebhookHeaders::new(ID, ts, "v1,not-the-right-signature");
assert_eq!(
wh.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000),
Err(WebhookVerificationError::SignatureMismatch)
);
}
#[test]
fn rejects_signature_from_wrong_secret() {
let ts = "1000";
let sig = sign(b"a-different-secret", ID, ts, PAYLOAD);
let wh = Webhooks::new(SECRET).unwrap();
let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
assert_eq!(
wh.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000),
Err(WebhookVerificationError::SignatureMismatch)
);
}
#[test]
fn rejects_tampered_payload() {
let ts = "1000";
let sig = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
let wh = Webhooks::new(SECRET).unwrap();
let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
let tampered = br#"{"id":"evt_1","type":"response.completed","created_at":100,"data":{"foo":"BAZ"}}"#;
assert_eq!(
wh.verify_signature(tampered, &headers, DEFAULT_TOLERANCE_SECS, 1000),
Err(WebhookVerificationError::SignatureMismatch)
);
}
#[test]
fn rejects_expired_timestamp() {
let ts = "1000";
let sig = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
let wh = Webhooks::new(SECRET).unwrap();
let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
assert_eq!(
wh.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1301),
Err(WebhookVerificationError::TimestampTooOld)
);
}
#[test]
fn rejects_future_timestamp() {
let ts = "1000";
let sig = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
let wh = Webhooks::new(SECRET).unwrap();
let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
assert_eq!(
wh.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 699),
Err(WebhookVerificationError::TimestampTooNew)
);
}
#[test]
fn rejects_non_integer_timestamp() {
let wh = Webhooks::new(SECRET).unwrap();
let headers = WebhookHeaders::new(ID, "not-a-number", "v1,whatever");
assert_eq!(
wh.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000),
Err(WebhookVerificationError::InvalidTimestampFormat)
);
}
#[test]
fn decodes_whsec_prefixed_secret() {
let raw_key = b"raw-secret-bytes-32-chars-long!!";
let whsec = format!("whsec_{}", STANDARD.encode(raw_key));
let ts = "1000";
let sig = sign(raw_key, ID, ts, PAYLOAD);
let wh = Webhooks::new(&whsec).unwrap();
let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
assert!(wh
.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000)
.is_ok());
}
#[test]
fn invalid_whsec_secret_is_rejected_at_construction() {
let result = Webhooks::new("whsec_!!!not base64!!!");
assert!(matches!(
result,
Err(WebhookVerificationError::InvalidSecret(_))
));
}
#[test]
fn accepts_when_only_second_of_multiple_signatures_matches() {
let ts = "1000";
let good = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
let wh = Webhooks::new(SECRET).unwrap();
let header = format!("v1,aGVsbG8gd29ybGQ v1,{good}");
let headers = WebhookHeaders::new(ID, ts, header);
assert!(wh
.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000)
.is_ok());
}
#[test]
fn unwrap_verifies_then_parses() {
let ts = "1000";
let sig = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
let wh = Webhooks::new(SECRET).unwrap();
let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
let now = now_unix().to_string();
let sig_now = sign(SECRET.as_bytes(), ID, &now, PAYLOAD);
let headers_now = WebhookHeaders::new(ID, &now, format!("v1,{sig_now}"));
let event = wh.unwrap(PAYLOAD, &headers_now).unwrap();
assert_eq!(event.id, "evt_1");
assert_eq!(event.r#type, "response.completed");
assert_eq!(event.created_at, 100);
assert_eq!(event.data["foo"], "bar");
assert!(wh
.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000)
.is_ok());
}
}