#[cfg(test)]
mod tests;
use std::time::{SystemTime, UNIX_EPOCH};
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
pub const STRIPE_DEFAULT_TOLERANCE_SECS: u64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebhookProvider {
GitHub,
Shopify,
Stripe,
}
pub fn verify_webhook_signature(
provider: WebhookProvider,
body: &[u8],
secret: &[u8],
header_value: &str,
) -> bool {
match provider {
WebhookProvider::GitHub => verify_github_signature(body, secret, header_value),
WebhookProvider::Shopify => verify_shopify_signature(body, secret, header_value),
WebhookProvider::Stripe => verify_stripe_signature(body, secret, header_value),
}
}
pub fn verify_github_signature(body: &[u8], secret: &[u8], header_value: &str) -> bool {
let Some(hex_sig) = header_value.strip_prefix("sha256=") else {
return false;
};
let Some(expected) = from_hex(hex_sig) else {
return false;
};
hmac_verify(secret, body, &expected)
}
pub fn verify_shopify_signature(body: &[u8], secret: &[u8], header_value: &str) -> bool {
let Some(expected) = base64_decode(header_value) else {
return false;
};
hmac_verify(secret, body, &expected)
}
pub fn verify_stripe_signature(body: &[u8], secret: &[u8], header_value: &str) -> bool {
verify_stripe_signature_with_tolerance(body, secret, header_value, STRIPE_DEFAULT_TOLERANCE_SECS)
}
pub fn verify_stripe_signature_with_tolerance(
body: &[u8],
secret: &[u8],
header_value: &str,
tolerance_secs: u64,
) -> bool {
let mut timestamp: Option<u64> = None;
let mut signatures: Vec<&str> = Vec::new();
for part in header_value.split(',') {
let part = part.trim();
if let Some(t) = part.strip_prefix("t=") {
timestamp = t.parse().ok();
} else if let Some(v1) = part.strip_prefix("v1=") {
signatures.push(v1);
}
}
let Some(timestamp) = timestamp else {
return false;
};
if signatures.is_empty() {
return false;
}
let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
return false;
};
if now.as_secs().abs_diff(timestamp) > tolerance_secs {
return false;
}
let signed_payload = [timestamp.to_string().as_bytes(), b".", body].concat();
signatures
.iter()
.any(|hex_sig| from_hex(hex_sig).is_some_and(|expected| hmac_verify(secret, &signed_payload, &expected)))
}
fn hmac_verify(secret: &[u8], message: &[u8], expected: &[u8]) -> bool {
let Ok(mut mac) = <HmacSha256 as Mac>::new_from_slice(secret) else {
return false;
};
mac.update(message);
mac.verify_slice(expected).is_ok()
}
fn from_hex(s: &str) -> Option<Vec<u8>> {
if s.is_empty() || s.len() % 2 != 0 {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}
fn base64_decode(input: &str) -> Option<Vec<u8>> {
let bytes: Vec<u8> = input.bytes().filter(|&b| b != b'=').collect();
if bytes.is_empty() || bytes.len() % 4 == 1 {
return None;
}
let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
for chunk in bytes.chunks(4) {
let a = b64_val(chunk[0])?;
let b = b64_val(chunk[1])?;
out.push((a << 2) | (b >> 4));
if chunk.len() > 2 {
let c = b64_val(chunk[2])?;
out.push((b << 4) | (c >> 2));
if chunk.len() > 3 {
let d = b64_val(chunk[3])?;
out.push((c << 6) | d);
}
}
}
Some(out)
}
fn b64_val(b: u8) -> Option<u8> {
match b {
b'A'..=b'Z' => Some(b - b'A'),
b'a'..=b'z' => Some(b - b'a' + 26),
b'0'..=b'9' => Some(b - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}