use std::time::{Duration, SystemTime, UNIX_EPOCH};
use hmac::{Hmac, Mac};
use sha2::Sha256;
pub const HEADER_TIMESTAMP: &str = "X-Paratro-Timestamp";
pub const HEADER_SIGNATURE: &str = "X-Paratro-Signature";
const SIGNATURE_VERSION: &str = "v1";
pub const DEFAULT_TOLERANCE: Duration = Duration::from_secs(5 * 60);
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, thiserror::Error)]
pub enum WebhookError {
#[error("webhook: invalid timestamp: {0}")]
InvalidTimestamp(String),
#[error("webhook: timestamp too old (age: {age_secs}s, tolerance: {tolerance_secs}s)")]
TimestampExpired { age_secs: u64, tolerance_secs: u64 },
#[error("webhook: signature mismatch")]
SignatureMismatch,
}
pub fn sign_payload(secret: &str, payload: &[u8]) -> (String, String) {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before epoch")
.as_secs()
.to_string();
let sig = compute_signature(secret, &ts, payload);
(ts, sig)
}
pub fn sign_payload_with_timestamp(secret: &str, payload: &[u8], timestamp: &str) -> String {
compute_signature(secret, timestamp, payload)
}
pub fn verify_payload(
secret: &str,
timestamp: &str,
payload: &[u8],
signature: &str,
tolerance: Duration,
) -> Result<(), WebhookError> {
let ts: i64 = timestamp
.parse()
.map_err(|_| WebhookError::InvalidTimestamp(timestamp.to_string()))?;
if !tolerance.is_zero() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before epoch")
.as_secs() as i64;
let diff = (now - ts).unsigned_abs();
if diff > tolerance.as_secs() {
return Err(WebhookError::TimestampExpired {
age_secs: diff,
tolerance_secs: tolerance.as_secs(),
});
}
}
let expected = compute_signature(secret, timestamp, payload);
if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
return Err(WebhookError::SignatureMismatch);
}
Ok(())
}
fn compute_signature(secret: &str, timestamp: &str, payload: &[u8]) -> String {
let mut canonical = Vec::with_capacity(timestamp.len() + 1 + payload.len());
canonical.extend_from_slice(timestamp.as_bytes());
canonical.push(b'.');
canonical.extend_from_slice(payload);
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
mac.update(&canonical);
format!(
"{}={}",
SIGNATURE_VERSION,
hex::encode(mac.finalize().into_bytes())
)
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter()
.zip(b.iter())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SECRET: &str = "whsec_test_secret_key_12345";
const TEST_PAYLOAD: &[u8] = br#"{"id":"evt_123","chain":"ethereum","txhash":"0xabc"}"#;
#[test]
fn test_sign_and_verify() {
let (ts, sig) = sign_payload(TEST_SECRET, TEST_PAYLOAD);
let result = verify_payload(TEST_SECRET, &ts, TEST_PAYLOAD, &sig, DEFAULT_TOLERANCE);
assert!(result.is_ok());
}
#[test]
fn test_wrong_secret() {
let (ts, sig) = sign_payload(TEST_SECRET, TEST_PAYLOAD);
let result = verify_payload("wrong_secret", &ts, TEST_PAYLOAD, &sig, DEFAULT_TOLERANCE);
assert!(matches!(result, Err(WebhookError::SignatureMismatch)));
}
#[test]
fn test_tampered_payload() {
let (ts, sig) = sign_payload(TEST_SECRET, TEST_PAYLOAD);
let tampered = br#"{"id":"evt_456","chain":"ethereum","txhash":"0xabc"}"#;
let result = verify_payload(TEST_SECRET, &ts, tampered, &sig, DEFAULT_TOLERANCE);
assert!(matches!(result, Err(WebhookError::SignatureMismatch)));
}
#[test]
fn test_expired_timestamp() {
let old_ts = "1000000000"; let sig = sign_payload_with_timestamp(TEST_SECRET, TEST_PAYLOAD, old_ts);
let result = verify_payload(TEST_SECRET, old_ts, TEST_PAYLOAD, &sig, DEFAULT_TOLERANCE);
assert!(matches!(result, Err(WebhookError::TimestampExpired { .. })));
}
#[test]
fn test_future_timestamp() {
let future_ts = "9999999999"; let sig = sign_payload_with_timestamp(TEST_SECRET, TEST_PAYLOAD, future_ts);
let result = verify_payload(
TEST_SECRET,
future_ts,
TEST_PAYLOAD,
&sig,
DEFAULT_TOLERANCE,
);
assert!(matches!(result, Err(WebhookError::TimestampExpired { .. })));
}
#[test]
fn test_invalid_timestamp() {
let result = verify_payload(
TEST_SECRET,
"not-a-number",
TEST_PAYLOAD,
"v1=abc",
DEFAULT_TOLERANCE,
);
assert!(matches!(result, Err(WebhookError::InvalidTimestamp(_))));
}
#[test]
fn test_zero_tolerance_skips_time_check() {
let old_ts = "1000000000";
let sig = sign_payload_with_timestamp(TEST_SECRET, TEST_PAYLOAD, old_ts);
let result = verify_payload(TEST_SECRET, old_ts, TEST_PAYLOAD, &sig, Duration::ZERO);
assert!(result.is_ok());
}
#[test]
fn test_signature_format() {
let (_, sig) = sign_payload(TEST_SECRET, TEST_PAYLOAD);
assert!(sig.starts_with("v1="), "signature must start with v1=");
assert_eq!(sig.len(), 67, "signature must be v1= + 64 hex chars");
}
#[test]
fn test_deterministic_signature() {
let ts = "1704067200";
let sig1 = sign_payload_with_timestamp(TEST_SECRET, TEST_PAYLOAD, ts);
let sig2 = sign_payload_with_timestamp(TEST_SECRET, TEST_PAYLOAD, ts);
assert_eq!(sig1, sig2);
}
#[test]
fn test_different_payloads_different_signatures() {
let ts = "1704067200";
let sig1 = sign_payload_with_timestamp(TEST_SECRET, b"payload1", ts);
let sig2 = sign_payload_with_timestamp(TEST_SECRET, b"payload2", ts);
assert_ne!(sig1, sig2);
}
#[test]
fn test_constant_time_eq() {
assert!(constant_time_eq(b"hello", b"hello"));
assert!(!constant_time_eq(b"hello", b"world"));
assert!(!constant_time_eq(b"hello", b"hell"));
assert!(!constant_time_eq(b"", b"a"));
assert!(constant_time_eq(b"", b""));
}
}