use bon::bon;
use hmac::{Hmac, KeyInit, Mac};
use secrecy::{ExposeSecret, SecretString};
use sha2::Sha256;
use thiserror::Error;
type HmacSha256 = Hmac<Sha256>;
const DEFAULT_TOLERANCE: u64 = 300;
#[derive(Debug, Error)]
pub enum WebhookError {
#[error("webhook secret must not be empty")]
EmptySecret,
#[error("invalid signature format: {0}")]
InvalidFormat(String),
#[error("signature mismatch")]
InvalidSignature,
#[error("timestamp outside tolerance window ({tolerance}s)")]
TimestampTolerance { tolerance: u64 },
#[error("invalid JSON payload: {0}")]
JsonDecode(#[from] serde_json::Error),
#[error("system clock is set before Unix epoch")]
SystemClock,
}
#[derive(Debug, Clone)]
pub struct WebhookEvent {
pub payload: serde_json::Value,
pub event: Option<String>,
pub delivery_timestamp: Option<u64>,
pub attempt: Option<u32>,
}
#[derive(Debug)]
pub struct Webhook {
secret: SecretString,
tolerance: u64,
}
#[bon]
impl Webhook {
#[builder]
pub fn new(
#[builder(into)] secret: SecretString,
#[builder(default = DEFAULT_TOLERANCE)] tolerance: u64,
) -> Result<Self, WebhookError> {
if secret.expose_secret().is_empty() {
return Err(WebhookError::EmptySecret);
}
Ok(Self { secret, tolerance })
}
#[tracing::instrument(name = "lettermint.webhook.verify", skip_all)]
pub fn verify(
&self,
payload: &str,
signature_header: &str,
) -> Result<serde_json::Value, WebhookError> {
let (timestamp, signature) = parse_signature_header(signature_header)?;
verify_signature(
payload,
&signature,
self.secret.expose_secret(),
timestamp,
self.tolerance,
)
.inspect_err(|e| tracing::warn!(error = %e, "webhook verification failed"))?;
tracing::debug!("webhook signature verified");
Ok(serde_json::from_str(payload)?)
}
#[tracing::instrument(
name = "lettermint.webhook.verify_headers",
skip_all,
fields(event = event_header, attempt = attempt_header),
)]
pub fn verify_headers(
&self,
signature_header: &str,
delivery_header: Option<&str>,
event_header: Option<&str>,
attempt_header: Option<&str>,
payload: &str,
) -> Result<WebhookEvent, WebhookError> {
let (timestamp, signature) = parse_signature_header(signature_header)?;
let delivery_timestamp = if let Some(delivery) = delivery_header {
let delivery_ts: u64 = delivery
.parse()
.map_err(|_| WebhookError::InvalidFormat("invalid delivery timestamp".into()))?;
if delivery_ts != timestamp {
return Err(WebhookError::InvalidFormat(
"signature timestamp does not match delivery header".into(),
));
}
Some(delivery_ts)
} else {
None
};
let attempt = attempt_header.and_then(|a| a.parse::<u32>().ok());
verify_signature(
payload,
&signature,
self.secret.expose_secret(),
timestamp,
self.tolerance,
)
.inspect_err(|e| tracing::warn!(error = %e, "webhook verification failed"))?;
tracing::debug!("webhook signature verified");
Ok(WebhookEvent {
payload: serde_json::from_str(payload)?,
event: event_header.map(String::from),
delivery_timestamp,
attempt,
})
}
}
fn parse_signature_header(header: &str) -> Result<(u64, String), WebhookError> {
let mut timestamp = None;
let mut signature = None;
for part in header.split(',') {
let part = part.trim();
if let Some(ts) = part.strip_prefix("t=") {
timestamp = Some(ts.parse::<u64>().map_err(|_| {
WebhookError::InvalidFormat("invalid timestamp in signature".into())
})?);
} else if let Some(sig) = part.strip_prefix("v1=") {
signature = Some(sig.to_string());
}
}
match (timestamp, signature) {
(Some(ts), Some(sig)) => Ok((ts, sig)),
_ => Err(WebhookError::InvalidFormat(
"missing t= or v1= in signature header".into(),
)),
}
}
fn verify_signature(
payload: &str,
expected_signature: &str,
secret: &str,
timestamp: u64,
tolerance: u64,
) -> Result<(), WebhookError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|_| WebhookError::SystemClock)?
.as_secs();
if now.abs_diff(timestamp) > tolerance {
return Err(WebhookError::TimestampTolerance { tolerance });
}
let signed_content = format!("{timestamp}.{payload}");
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
mac.update(signed_content.as_bytes());
let expected_bytes = hex::decode(expected_signature)
.map_err(|_| WebhookError::InvalidFormat("invalid hex in signature".into()))?;
mac.verify_slice(&expected_bytes)
.map_err(|_| WebhookError::InvalidSignature)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_signature(payload: &str, secret: &str, timestamp: u64) -> String {
let signed = format!("{timestamp}.{payload}");
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
mac.update(signed.as_bytes());
let sig = hex::encode(mac.finalize().into_bytes());
format!("t={timestamp},v1={sig}")
}
#[test]
fn valid_signature() {
let secret = "test-secret";
let payload = r#"{"event":"delivered"}"#;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let header = make_signature(payload, secret, now);
let wh = Webhook::builder().secret(secret).build().unwrap();
let result = wh.verify(payload, &header);
assert!(result.is_ok());
}
#[test]
fn invalid_signature() {
let payload = r#"{"event":"delivered"}"#;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let header = make_signature(payload, "correct-secret", now);
let wh = Webhook::builder().secret("wrong-secret").build().unwrap();
let result = wh.verify(payload, &header);
assert!(matches!(result, Err(WebhookError::InvalidSignature)));
}
#[test]
fn expired_timestamp() {
let secret = "test-secret";
let payload = r#"{"event":"delivered"}"#;
let old_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
- 600;
let header = make_signature(payload, secret, old_ts);
let wh = Webhook::builder().secret(secret).build().unwrap();
let result = wh.verify(payload, &header);
assert!(matches!(
result,
Err(WebhookError::TimestampTolerance { .. })
));
}
#[test]
fn parse_signature_header_valid() {
let (ts, sig) = parse_signature_header("t=1234567890,v1=abcdef").unwrap();
assert_eq!(ts, 1234567890);
assert_eq!(sig, "abcdef");
}
#[test]
fn parse_signature_header_missing_parts() {
assert!(parse_signature_header("t=123").is_err());
assert!(parse_signature_header("v1=abc").is_err());
assert!(parse_signature_header("garbage").is_err());
}
#[test]
fn custom_tolerance() {
let secret = "test-secret";
let payload = r#"{"event":"delivered"}"#;
let old_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
- 60;
let header = make_signature(payload, secret, old_ts);
let wh = Webhook::builder().secret(secret).build().unwrap();
assert!(wh.verify(payload, &header).is_ok());
let wh_tight = Webhook::builder()
.secret(secret)
.tolerance(10)
.build()
.unwrap();
assert!(matches!(
wh_tight.verify(payload, &header),
Err(WebhookError::TimestampTolerance { .. })
));
}
#[test]
fn verify_headers_with_event_metadata() {
let secret = "test-secret";
let payload = r#"{"event":"delivered"}"#;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let sig_header = make_signature(payload, secret, now);
let wh = Webhook::builder().secret(secret).build().unwrap();
let event = wh
.verify_headers(
&sig_header,
Some(&now.to_string()),
Some("message.delivered"),
Some("1"),
payload,
)
.unwrap();
assert_eq!(event.event.as_deref(), Some("message.delivered"));
assert_eq!(event.delivery_timestamp, Some(now));
assert_eq!(event.attempt, Some(1));
assert_eq!(event.payload["event"], "delivered");
}
#[test]
fn verify_headers_without_optional_headers() {
let secret = "test-secret";
let payload = r#"{"event":"delivered"}"#;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let sig_header = make_signature(payload, secret, now);
let wh = Webhook::builder().secret(secret).build().unwrap();
let event = wh
.verify_headers(&sig_header, None, None, None, payload)
.unwrap();
assert!(event.event.is_none());
assert!(event.delivery_timestamp.is_none());
assert!(event.attempt.is_none());
}
#[test]
fn empty_secret_returns_error() {
assert!(matches!(
Webhook::builder().secret("").build(),
Err(WebhookError::EmptySecret)
));
}
#[test]
fn empty_secret_with_tolerance_returns_error() {
assert!(matches!(
Webhook::builder().secret("").tolerance(300).build(),
Err(WebhookError::EmptySecret)
));
}
}