use hmac::{Hmac, Mac};
use sha2::Sha256;
use thiserror::Error;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum WebhookHmacError {
#[error("failed to initialize HMAC key")]
InvalidKey,
}
fn new_hmac(secret: &[u8]) -> Result<HmacSha256, WebhookHmacError> {
HmacSha256::new_from_slice(secret).map_err(|_| WebhookHmacError::InvalidKey)
}
#[must_use]
pub fn sign_webhook(secret: &[u8], payload: &[u8]) -> String {
try_sign_webhook(secret, payload).unwrap_or_default()
}
pub fn try_sign_webhook(secret: &[u8], payload: &[u8]) -> Result<String, WebhookHmacError> {
let mut mac = new_hmac(secret)?;
mac.update(payload);
Ok(hex::encode(mac.finalize().into_bytes()))
}
#[must_use]
pub fn verify_webhook(secret: &[u8], payload: &[u8], signature: &str) -> bool {
try_verify_webhook(secret, payload, signature).unwrap_or(false)
}
pub fn try_verify_webhook(
secret: &[u8],
payload: &[u8],
signature: &str,
) -> Result<bool, WebhookHmacError> {
let mut mac = new_hmac(secret)?;
mac.update(payload);
let Ok(sig_bytes) = hex::decode(signature) else {
return Ok(false);
};
Ok(mac.verify_slice(&sig_bytes).is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_produces_64_char_hex() {
let sig = sign_webhook(b"secret", b"payload");
assert_eq!(sig.len(), 64);
assert!(hex::decode(&sig).is_ok());
}
#[test]
fn sign_deterministic() {
let sig1 = sign_webhook(b"key", b"data");
let sig2 = sign_webhook(b"key", b"data");
assert_eq!(sig1, sig2);
}
#[test]
fn sign_different_secrets_differ() {
let sig1 = sign_webhook(b"secret1", b"payload");
let sig2 = sign_webhook(b"secret2", b"payload");
assert_ne!(sig1, sig2);
}
#[test]
fn sign_different_payloads_differ() {
let sig1 = sign_webhook(b"secret", b"payload1");
let sig2 = sign_webhook(b"secret", b"payload2");
assert_ne!(sig1, sig2);
}
#[test]
fn verify_correct_signature() {
let sig = sign_webhook(b"secret", b"payload");
assert!(verify_webhook(b"secret", b"payload", &sig));
}
#[test]
fn verify_wrong_secret() {
let sig = sign_webhook(b"secret", b"payload");
assert!(!verify_webhook(b"wrong", b"payload", &sig));
}
#[test]
fn verify_wrong_payload() {
let sig = sign_webhook(b"secret", b"payload");
assert!(!verify_webhook(b"secret", b"tampered", &sig));
}
#[test]
fn verify_invalid_hex() {
assert!(!verify_webhook(b"secret", b"payload", "not-hex!@#$"));
}
#[test]
fn verify_wrong_length() {
assert!(!verify_webhook(b"secret", b"payload", "abcd"));
}
#[test]
fn sign_empty_secret() {
let sig = sign_webhook(b"", b"payload");
assert!(verify_webhook(b"", b"payload", &sig));
}
#[test]
fn sign_empty_payload() {
let sig = sign_webhook(b"secret", b"");
assert!(verify_webhook(b"secret", b"", &sig));
}
#[test]
fn sign_both_empty() {
let sig = sign_webhook(b"", b"");
assert!(verify_webhook(b"", b"", &sig));
}
#[test]
fn sign_json_payload() {
let payload = br#"{"event_type":"payment.completed","amount":100}"#;
let sig = sign_webhook(b"whsec_abc123", payload);
assert!(verify_webhook(b"whsec_abc123", payload, &sig));
}
#[test]
fn known_vector() {
let sig = sign_webhook(b"key", b"The quick brown fox jumps over the lazy dog");
assert_eq!(sig, "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8");
}
#[test]
fn try_sign_and_verify_success() {
let sig = try_sign_webhook(b"secret", b"payload").unwrap();
assert!(try_verify_webhook(b"secret", b"payload", &sig).unwrap());
}
}