use crate::error::{HeaderMap, LingerError};
use base64::prelude::{Engine as _, BASE64_STANDARD};
use hmac::{Hmac, Mac};
use serde::de::DeserializeOwned;
use sha2::Sha256;
use std::fmt;
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone)]
#[non_exhaustive]
pub struct WebhookVerifier {
secret: Vec<u8>,
}
impl WebhookVerifier {
pub fn new(secret: impl AsRef<str>) -> Result<Self, LingerError> {
let secret = secret.as_ref().trim();
if secret.is_empty() {
return Err(LingerError::invalid_config("webhook secret is required"));
}
Ok(Self {
secret: decode_secret(secret),
})
}
pub fn verify(&self, headers: &HeaderMap, body: &[u8]) -> Result<(), LingerError> {
let webhook_id = required_header(headers, "webhook-id")?;
let timestamp = required_header(headers, "webhook-timestamp")?;
let signature = required_header(headers, "webhook-signature")?;
let expected = signed_payload(webhook_id, timestamp, body);
for candidate in signature_candidates(signature) {
let decoded = BASE64_STANDARD
.decode(candidate)
.map_err(|_| LingerError::invalid_config("webhook signature is invalid"))?;
let mut mac = HmacSha256::new_from_slice(&self.secret)
.map_err(|_| LingerError::invalid_config("webhook secret is invalid"))?;
mac.update(&expected);
if mac.verify_slice(&decoded).is_ok() {
return Ok(());
}
}
Err(LingerError::invalid_config(
"webhook signature verification failed",
))
}
pub fn parse<T>(&self, headers: &HeaderMap, body: &[u8]) -> Result<T, LingerError>
where
T: DeserializeOwned,
{
self.verify(headers, body)?;
Ok(serde_json::from_slice(body)?)
}
}
impl fmt::Debug for WebhookVerifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WebhookVerifier")
.field("secret", &"<redacted>")
.finish()
}
}
fn decode_secret(secret: &str) -> Vec<u8> {
secret
.strip_prefix("whsec_")
.and_then(|encoded| BASE64_STANDARD.decode(encoded).ok())
.unwrap_or_else(|| secret.as_bytes().to_vec())
}
fn required_header<'a>(headers: &'a HeaderMap, name: &str) -> Result<&'a str, LingerError> {
headers
.get(name)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| LingerError::invalid_config(format!("{name} header is required")))
}
fn signed_payload(webhook_id: &str, timestamp: &str, body: &[u8]) -> Vec<u8> {
let mut payload = Vec::with_capacity(webhook_id.len() + timestamp.len() + body.len() + 2);
payload.extend_from_slice(webhook_id.as_bytes());
payload.push(b'.');
payload.extend_from_slice(timestamp.as_bytes());
payload.push(b'.');
payload.extend_from_slice(body);
payload
}
fn signature_candidates(header: &str) -> impl Iterator<Item = &str> {
header
.split(',')
.filter_map(|part| part.trim().strip_prefix("v1,").or(Some(part.trim())))
.filter(|part| !part.is_empty() && *part != "v1")
}