use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
pub const SIGNATURE_HEADER: &str = "X-Limiteron-Signature";
pub const TIMESTAMP_HEADER: &str = "X-Limiteron-Timestamp";
pub const DEFAULT_MAX_AGE_SECS: i64 = 300;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebhookVerifyError {
Expired,
InvalidSignature,
MalformedSignature,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebhookSignature {
pub timestamp: i64,
pub signature: String,
}
impl WebhookSignature {
pub fn header_value(&self) -> &str {
&self.signature
}
pub fn timestamp_header_value(&self) -> String {
self.timestamp.to_string()
}
}
#[derive(Clone)]
pub struct WebhookSigner {
secret: Vec<u8>,
max_age_secs: i64,
}
impl std::fmt::Debug for WebhookSigner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebhookSigner")
.field("secret", &"<redacted>")
.field("max_age_secs", &self.max_age_secs)
.finish()
}
}
impl WebhookSigner {
pub fn new(secret: impl Into<Vec<u8>>) -> Self {
Self {
secret: secret.into(),
max_age_secs: DEFAULT_MAX_AGE_SECS,
}
}
pub fn with_tolerance(mut self, max_age_secs: i64) -> Self {
self.max_age_secs = max_age_secs;
self
}
pub fn max_age_secs(&self) -> i64 {
self.max_age_secs
}
pub fn sign_payload(&self, payload: &str, timestamp: i64) -> String {
let mut mac = HmacSha256::new_from_slice(&self.secret).expect("HMAC accepts any key size");
mac.update(timestamp.to_string().as_bytes());
mac.update(b".");
mac.update(payload.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
pub fn sign(&self, payload: &str) -> WebhookSignature {
let timestamp = chrono::Utc::now().timestamp();
WebhookSignature {
timestamp,
signature: format!("sha256={}", self.sign_payload(payload, timestamp)),
}
}
pub fn verify(
&self,
payload: &str,
timestamp: i64,
signature: &str,
) -> Result<(), WebhookVerifyError> {
let now = chrono::Utc::now().timestamp();
let age = now - timestamp;
if age.abs() > self.max_age_secs {
return Err(WebhookVerifyError::Expired);
}
let hex_sig = signature
.strip_prefix("sha256=")
.unwrap_or(signature)
.trim();
if hex_sig.is_empty() || !hex_sig.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(WebhookVerifyError::MalformedSignature);
}
let expected = self.sign_payload(payload, timestamp);
if constant_time_eq(hex_sig.as_bytes(), expected.as_bytes()) {
Ok(())
} else {
Err(WebhookVerifyError::InvalidSignature)
}
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut result = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
result |= x ^ y;
}
result == 0
}
static GLOBAL_WEBHOOK_SIGNER: std::sync::OnceLock<WebhookSigner> = std::sync::OnceLock::new();
pub fn set_global_webhook_signer(signer: WebhookSigner) -> bool {
GLOBAL_WEBHOOK_SIGNER.set(signer).is_ok()
}
pub fn global_webhook_signer() -> Option<&'static WebhookSigner> {
GLOBAL_WEBHOOK_SIGNER.get()
}
#[cfg(test)]
mod tests {
use super::*;
const PAYLOAD: &str = r#"{"id":"evt_1","type":"RateLimitTriggered","key":"192.0.2.1"}"#;
#[test]
fn test_t614_sign_verify_roundtrip() {
let signer = WebhookSigner::new("secret-abc");
let sig = signer.sign(PAYLOAD);
assert!(
sig.signature.starts_with("sha256="),
"header form: {}",
sig.signature
);
assert!(
signer
.verify(PAYLOAD, sig.timestamp, &sig.signature)
.is_ok()
);
let bare = sig.signature.trim_start_matches("sha256=");
assert!(signer.verify(PAYLOAD, sig.timestamp, bare).is_ok());
}
#[test]
fn test_t614_sign_payload_deterministic() {
let signer = WebhookSigner::new("secret-abc");
let a = signer.sign_payload(PAYLOAD, 1_700_000_000);
let b = signer.sign_payload(PAYLOAD, 1_700_000_000);
assert_eq!(a, b);
assert_eq!(a.len(), 64, "SHA-256 hex length");
assert_ne!(a, signer.sign_payload(PAYLOAD, 1_700_000_001));
}
#[test]
fn test_t614_tampered_payload_rejected() {
let signer = WebhookSigner::new("secret-abc");
let sig = signer.sign(PAYLOAD);
let tampered = PAYLOAD.replace("evt_1", "evt_2");
assert_eq!(
signer.verify(&tampered, sig.timestamp, &sig.signature),
Err(WebhookVerifyError::InvalidSignature),
"any payload mutation must invalidate the signature"
);
}
#[test]
fn test_t614_wrong_secret_rejected() {
let signer = WebhookSigner::new("secret-abc");
let sig = signer.sign(PAYLOAD);
let attacker = WebhookSigner::new("secret-xyz");
assert_eq!(
attacker.verify(PAYLOAD, sig.timestamp, &sig.signature),
Err(WebhookVerifyError::InvalidSignature)
);
}
#[test]
fn test_t614_stale_timestamp_rejected_as_replay() {
let signer = WebhookSigner::new("secret-abc").with_tolerance(300);
let now = chrono::Utc::now().timestamp();
let sig_hex = signer.sign_payload(PAYLOAD, now - 301);
assert_eq!(
signer.verify(PAYLOAD, now - 301, &sig_hex),
Err(WebhookVerifyError::Expired),
"a signature replayed from 301s ago must be rejected"
);
let future_hex = signer.sign_payload(PAYLOAD, now + 301);
assert_eq!(
signer.verify(PAYLOAD, now + 301, &future_hex),
Err(WebhookVerifyError::Expired)
);
let edge_hex = signer.sign_payload(PAYLOAD, now - 300);
assert!(signer.verify(PAYLOAD, now - 300, &edge_hex).is_ok());
}
#[test]
fn test_t614_malformed_signature_rejected() {
let signer = WebhookSigner::new("secret-abc");
let now = chrono::Utc::now().timestamp();
assert_eq!(
signer.verify(PAYLOAD, now, "sha256=not-hex!"),
Err(WebhookVerifyError::MalformedSignature)
);
assert_eq!(
signer.verify(PAYLOAD, now, ""),
Err(WebhookVerifyError::MalformedSignature)
);
}
#[test]
fn test_t614_debug_redacts_secret() {
let signer = WebhookSigner::new("super-secret-value");
let rendered = format!("{signer:?}");
assert!(!rendered.contains("super-secret-value"));
assert!(rendered.contains("<redacted>"));
}
#[test]
fn test_t614_header_name_contract() {
assert_eq!(SIGNATURE_HEADER, "X-Limiteron-Signature");
assert_eq!(TIMESTAMP_HEADER, "X-Limiteron-Timestamp");
assert_eq!(DEFAULT_MAX_AGE_SECS, 300);
}
#[test]
fn test_t614_global_signer_set_once() {
let installed = super::set_global_webhook_signer(WebhookSigner::new("global-secret"));
let signer = super::global_webhook_signer().expect("global signer available");
if installed {
let sig = signer.sign(PAYLOAD);
assert!(
signer
.verify(PAYLOAD, sig.timestamp, &sig.signature)
.is_ok()
);
}
assert!(!super::set_global_webhook_signer(WebhookSigner::new(
"other"
)));
}
}