use std::time::{Duration, SystemTime, UNIX_EPOCH};
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
use thiserror::Error;
type HmacSha256 = Hmac<Sha256>;
pub const DEFAULT_TOLERANCE: Duration = Duration::from_secs(300);
pub struct VerifyOptions {
pub tolerance: Duration,
pub now: Option<u64>,
}
impl Default for VerifyOptions {
fn default() -> Self {
Self {
tolerance: DEFAULT_TOLERANCE,
now: None,
}
}
}
#[derive(Debug, Error)]
pub enum WebhookError {
#[error("missing signature header")]
MissingSignature,
#[error("malformed signature header")]
MalformedSignature,
#[error("invalid signature")]
InvalidSignature,
#[error("timestamp outside tolerance: drift of {drift_secs}s exceeds {tolerance_secs}s")]
TimestampOutsideTolerance {
drift_secs: u64,
tolerance_secs: u64,
},
#[error("invalid payload: {0}")]
InvalidPayload(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Deserialize)]
#[allow(missing_docs)] pub struct PayloadApplication {
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(missing_docs)] pub struct PayloadEnvironment {
pub name: String,
pub env_id: String,
pub url: String,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(missing_docs)] pub struct Payload {
pub event: String,
#[serde(default)]
pub timestamp: Option<String>,
#[serde(default)]
pub application: Option<PayloadApplication>,
#[serde(default)]
pub environment: Option<PayloadEnvironment>,
#[serde(default)]
pub changes: Option<serde_json::Value>,
#[serde(default)]
pub values: Option<serde_json::Value>,
}
pub fn verify(
body: &[u8],
signature: Option<&str>,
secret: &str,
opts: &VerifyOptions,
) -> Result<Payload, WebhookError> {
let Some(signature) = signature else {
return Err(WebhookError::MissingSignature);
};
if signature.is_empty() {
return Err(WebhookError::MissingSignature);
}
if secret.is_empty() {
return Err(WebhookError::InvalidSignature);
}
let trimmed = signature.trim();
let Some((ts_str, sig_hex)) = parse_signature(trimmed) else {
return Err(WebhookError::MalformedSignature);
};
let Ok(ts) = ts_str.parse::<u64>() else {
return Err(WebhookError::MalformedSignature);
};
let Ok(provided) = hex::decode(sig_hex) else {
return Err(WebhookError::MalformedSignature);
};
let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
return Err(WebhookError::InvalidSignature);
};
mac.update(format!("{ts}:").as_bytes());
mac.update(body);
if mac.verify_slice(&provided).is_err() {
return Err(WebhookError::InvalidSignature);
}
if opts.tolerance > Duration::ZERO {
let now = opts.now.unwrap_or_else(|| {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
});
let drift = now.abs_diff(ts);
if drift > opts.tolerance.as_secs() {
return Err(WebhookError::TimestampOutsideTolerance {
drift_secs: drift,
tolerance_secs: opts.tolerance.as_secs(),
});
}
}
Ok(serde_json::from_slice(body)?)
}
fn parse_signature(s: &str) -> Option<(&str, &str)> {
let mut ts: Option<&str> = None;
let mut sig: Option<&str> = None;
for part in s.split(';') {
if let Some(value) = part.strip_prefix("ts=") {
ts = Some(validated(value, |b| b.is_ascii_digit())?);
} else {
let value = part.strip_prefix("sig=")?;
sig = Some(validated(value, |b| b.is_ascii_hexdigit())?);
}
}
Some((ts?, sig?))
}
fn validated(value: &str, valid: impl Fn(u8) -> bool) -> Option<&str> {
(!value.is_empty() && value.bytes().all(valid)).then_some(value)
}