use std::time::{SystemTime, UNIX_EPOCH};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use crate::error::WebhookVerificationError;
use crate::types::WebhookEvent;
pub const WEBHOOK_TOLERANCE_SECONDS: u64 = 300;
pub const SIGNATURE_HEADER: &str = "x-sendclipper-signature-v2";
pub enum Headers<'a> {
Map(&'a http::HeaderMap),
Pairs(Vec<(&'a str, &'a str)>),
Value(&'a str),
}
impl<'a> From<&'a http::HeaderMap> for Headers<'a> {
fn from(h: &'a http::HeaderMap) -> Self {
Headers::Map(h)
}
}
impl<'a> From<&'a str> for Headers<'a> {
fn from(v: &'a str) -> Self {
Headers::Value(v)
}
}
impl<'a> From<&'a String> for Headers<'a> {
fn from(v: &'a String) -> Self {
Headers::Value(v.as_str())
}
}
impl<'a> From<Vec<(&'a str, &'a str)>> for Headers<'a> {
fn from(v: Vec<(&'a str, &'a str)>) -> Self {
Headers::Pairs(v)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct VerifyOptions {
pub tolerance_seconds: Option<u64>,
pub now: Option<u64>,
}
fn refuse(reason: &'static str, message: impl Into<String>) -> WebhookVerificationError {
WebhookVerificationError {
reason,
message: message.into(),
}
}
pub fn verify_webhook<'h>(
payload: &[u8],
headers: impl Into<Headers<'h>>,
secret: &str,
options: VerifyOptions,
) -> Result<WebhookEvent, WebhookVerificationError> {
if secret.is_empty() {
return Err(refuse("missing_header", "No webhook signing secret was given."));
}
let header = match headers.into() {
Headers::Value(v) => v.to_owned(),
Headers::Map(m) => m
.get_all(SIGNATURE_HEADER)
.iter()
.filter_map(|v| v.to_str().ok())
.collect::<Vec<_>>()
.join(","),
Headers::Pairs(p) => p
.iter()
.filter(|(k, _)| {
let n = k.to_ascii_lowercase().replace('_', "-");
n == SIGNATURE_HEADER || n == format!("http-{SIGNATURE_HEADER}")
})
.map(|(_, v)| *v)
.collect::<Vec<_>>()
.join(","),
};
if header.is_empty() {
return Err(refuse(
"missing_header",
"The request carries no X-SendClipper-Signature-V2 header.",
));
}
let (timestamp, signatures) = parse_signature_header(&header);
let Some(timestamp) = timestamp.filter(|_| !signatures.is_empty()) else {
return Err(refuse("malformed_header", "X-SendClipper-Signature-V2 has no t= or no v1= value."));
};
let tolerance = options.tolerance_seconds.unwrap_or(WEBHOOK_TOLERANCE_SECONDS);
let now = options
.now
.unwrap_or_else(|| SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0));
let drift = now.abs_diff(timestamp);
if drift > tolerance {
return Err(refuse(
"timestamp_out_of_tolerance",
format!("The delivery was signed {drift} s from now, outside the {tolerance} s tolerance."),
));
}
let mut matched = false;
for candidate in &signatures {
let Ok(bytes) = hex::decode(candidate) else { continue };
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC takes any key length");
mac.update(format!("{timestamp}.").as_bytes());
mac.update(payload);
if mac.verify_slice(&bytes).is_ok() {
matched = true;
}
}
if !matched {
return Err(refuse("signature_mismatch", "The signature does not match this secret and body."));
}
serde_json::from_slice(payload).map_err(|e| refuse("invalid_payload", format!("The signed body is not a webhook event: {e}")))
}
pub fn sign_payload(secret: &str, timestamp: u64, raw_body: &[u8]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC takes any key length");
mac.update(format!("{timestamp}.").as_bytes());
mac.update(raw_body);
hex::encode(mac.finalize().into_bytes())
}
pub fn webhook_signature_header(secret: &str, raw_body: &[u8], timestamp: Option<u64>) -> String {
let t = timestamp.unwrap_or_else(|| SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0));
format!("t={t},v1={}", sign_payload(secret, t, raw_body))
}
pub fn parse_signature_header(header: &str) -> (Option<u64>, Vec<String>) {
let mut timestamp = None;
let mut signatures = Vec::new();
for part in header.split(',') {
let Some((k, v)) = part.split_once('=') else { continue };
let (k, v) = (k.trim(), v.trim());
if k == "t" && !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) {
timestamp = v.parse().ok();
} else if k == "v1" && !v.is_empty() {
signatures.push(v.to_owned());
}
}
(timestamp, signatures)
}