use base64::{engine::general_purpose::STANDARD, Engine as _};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
pub const DEFAULT_TOLERANCE_SECONDS: i64 = 300;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug)]
pub struct VerifyResult {
pub valid: bool,
pub event: Option<serde_json::Value>,
pub reason: Option<&'static str>,
pub matched_secret_index: Option<usize>,
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn resolve_key(secret: &str) -> Vec<u8> {
if let Some(hex_part) = secret.strip_prefix("whsec_") {
if !hex_part.is_empty() && hex_part.len() % 2 == 0 && hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
if let Ok(b) = hex::decode(hex_part) {
return b;
}
}
}
secret.as_bytes().to_vec()
}
fn hmac_bytes(secret: &str, message: &str) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(&resolve_key(secret)).expect("HMAC key");
mac.update(message.as_bytes());
mac.finalize().into_bytes().to_vec()
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
pub fn verify(raw_body: &str, signature_header: &str, secret: &str) -> VerifyResult {
verify_with_tolerance(raw_body, signature_header, secret, DEFAULT_TOLERANCE_SECONDS)
}
pub fn verify_with_tolerance(
raw_body: &str,
signature_header: &str,
secret: &str,
tolerance_seconds: i64,
) -> VerifyResult {
verify_legacy_multi(raw_body, signature_header, &[secret], tolerance_seconds)
}
pub fn verify_legacy_multi(
raw_body: &str,
signature_header: &str,
secrets: &[&str],
tolerance_seconds: i64,
) -> VerifyResult {
if signature_header.is_empty() || secrets.is_empty() {
return VerifyResult { valid: false, event: None, reason: Some("missing signature or secret"), matched_secret_index: None };
}
let mut timestamp: Option<&str> = None;
let mut sig_hex: Option<&str> = None;
for part in signature_header.split(',') {
if let Some((k, v)) = part.trim().split_once('=') {
match k {
"t" => timestamp = Some(v),
"v1" => sig_hex = Some(v),
_ => {}
}
}
}
let (Some(ts_str), Some(sig)) = (timestamp, sig_hex) else {
return VerifyResult { valid: false, event: None, reason: Some("malformed signature header"), matched_secret_index: None };
};
let ts: i64 = match ts_str.parse() {
Ok(n) => n,
Err(_) => return VerifyResult { valid: false, event: None, reason: Some("malformed timestamp"), matched_secret_index: None },
};
if (now_secs() - ts).abs() > tolerance_seconds {
return VerifyResult { valid: false, event: None, reason: Some("timestamp out of tolerance"), matched_secret_index: None };
}
let provided_lower = sig.to_lowercase();
let provided = match hex::decode(&provided_lower) {
Ok(b) => b,
Err(_) => return VerifyResult { valid: false, event: None, reason: Some("malformed signature bytes"), matched_secret_index: None },
};
let message = format!("{}.{}", ts_str, raw_body);
for (i, s) in secrets.iter().enumerate() {
let expected = hmac_bytes(s, &message);
if constant_time_eq(&expected, &provided) {
let event = serde_json::from_str(raw_body).ok();
return VerifyResult { valid: true, event, reason: None, matched_secret_index: Some(i) };
}
}
VerifyResult { valid: false, event: None, reason: Some("signature mismatch"), matched_secret_index: None }
}
pub trait HeaderSource {
fn header(&self, name: &str) -> Option<String>;
}
impl HeaderSource for HashMap<String, String> {
fn header(&self, name: &str) -> Option<String> {
let lower = name.to_ascii_lowercase();
self.iter()
.find(|(k, _)| k.to_ascii_lowercase() == lower)
.map(|(_, v)| v.clone())
}
}
impl HeaderSource for Vec<(String, String)> {
fn header(&self, name: &str) -> Option<String> {
let lower = name.to_ascii_lowercase();
self.iter()
.find(|(k, _)| k.to_ascii_lowercase() == lower)
.map(|(_, v)| v.clone())
}
}
pub fn verify_from_headers<H: HeaderSource>(
raw_body: &str,
headers: &H,
secrets: &[&str],
tolerance_seconds: i64,
) -> VerifyResult {
if secrets.is_empty() {
return VerifyResult { valid: false, event: None, reason: Some("missing secret"), matched_secret_index: None };
}
let std_id = headers.header("webhook-id");
let std_ts = headers.header("webhook-timestamp");
let std_sig = headers.header("webhook-signature");
if let (Some(id), Some(ts_str), Some(sig)) = (std_id, std_ts, std_sig) {
let ts: i64 = match ts_str.parse() {
Ok(n) => n,
Err(_) => return VerifyResult { valid: false, event: None, reason: Some("malformed timestamp"), matched_secret_index: None },
};
if (now_secs() - ts).abs() > tolerance_seconds {
return VerifyResult { valid: false, event: None, reason: Some("timestamp out of tolerance"), matched_secret_index: None };
}
let candidates: Vec<Vec<u8>> = sig
.split_whitespace()
.filter_map(|piece| piece.strip_prefix("v1,"))
.filter_map(|b| STANDARD.decode(b).ok())
.collect();
if candidates.is_empty() {
return VerifyResult { valid: false, event: None, reason: Some("malformed signature"), matched_secret_index: None };
}
let message = format!("{}.{}.{}", id, ts, raw_body);
for (i, s) in secrets.iter().enumerate() {
let expected = hmac_bytes(s, &message);
for c in &candidates {
if constant_time_eq(&expected, c) {
let event = serde_json::from_str(raw_body).ok();
return VerifyResult { valid: true, event, reason: None, matched_secret_index: Some(i) };
}
}
}
return VerifyResult { valid: false, event: None, reason: Some("signature mismatch"), matched_secret_index: None };
}
if let Some(legacy) = headers.header("x-coffrify-signature") {
return verify_legacy_multi(raw_body, &legacy, secrets, tolerance_seconds);
}
VerifyResult { valid: false, event: None, reason: Some("malformed"), matched_secret_index: None }
}