use std::time::{SystemTime, UNIX_EPOCH};
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
pub const DEFAULT_TOLERANCE_SECS: u64 = 300;
#[allow(missing_docs)]
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum VerifyError {
#[error("malformed signature header")]
MalformedHeader,
#[error("timestamp outside tolerance: |now - {timestamp}| > {tolerance}s")]
TimestampOutOfTolerance { timestamp: i64, tolerance: u64 },
#[error("signature mismatch")]
Mismatch,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignatureHeader {
timestamp: i64,
signatures: Vec<Vec<u8>>,
}
impl SignatureHeader {
pub fn parse(value: &str) -> Result<Self, VerifyError> {
let mut timestamp: Option<i64> = None;
let mut signatures = Vec::new();
for part in value.split(',') {
let (k, v) = part.split_once('=').ok_or(VerifyError::MalformedHeader)?;
match k.trim() {
"t" => {
timestamp = Some(v.trim().parse().map_err(|_| VerifyError::MalformedHeader)?);
}
"v1" => {
let raw = hex::decode(v.trim()).map_err(|_| VerifyError::MalformedHeader)?;
signatures.push(raw);
}
_ => {} }
}
let timestamp = timestamp.ok_or(VerifyError::MalformedHeader)?;
if signatures.is_empty() {
return Err(VerifyError::MalformedHeader);
}
Ok(SignatureHeader {
timestamp,
signatures,
})
}
pub fn timestamp(&self) -> i64 {
self.timestamp
}
pub fn check_tolerance(&self, now: i64, tolerance_secs: u64) -> Result<(), VerifyError> {
if now.abs_diff(self.timestamp) > tolerance_secs {
Err(VerifyError::TimestampOutOfTolerance {
timestamp: self.timestamp,
tolerance: tolerance_secs,
})
} else {
Ok(())
}
}
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0))
}
pub fn verify(
secret: &[u8],
header_value: &str,
raw_body: &[u8],
tolerance: u64,
) -> Result<(), VerifyError> {
verify_at(secret, header_value, raw_body, tolerance, now_unix())
}
pub fn verify_default(
secret: &[u8],
header_value: &str,
raw_body: &[u8],
) -> Result<(), VerifyError> {
verify(secret, header_value, raw_body, DEFAULT_TOLERANCE_SECS)
}
pub fn verify_at(
secret: &[u8],
header_value: &str,
raw_body: &[u8],
tolerance: u64,
now: i64,
) -> Result<(), VerifyError> {
let header = SignatureHeader::parse(header_value)?;
header.check_tolerance(now, tolerance)?;
verify_preparsed(secret, &header, raw_body)
}
pub fn verify_preparsed(
secret: &[u8],
header: &SignatureHeader,
raw_body: &[u8],
) -> Result<(), VerifyError> {
let mut mac = HmacSha256::new_from_slice(secret).map_err(|_| VerifyError::MalformedHeader)?;
mac.update(header.timestamp.to_string().as_bytes());
mac.update(b".");
mac.update(raw_body);
let expected = mac.finalize().into_bytes();
let mut matched = 0u8;
for sig in &header.signatures {
matched |= u8::from(bool::from(expected.as_slice().ct_eq(sig.as_slice())));
}
if matched == 1 {
Ok(())
} else {
Err(VerifyError::Mismatch)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::print_stdout,
clippy::unreadable_literal
)]
mod tests {
use super::*;
const SECRET: &[u8] = b"whsec_test_secret";
fn sign(timestamp: i64, body: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(SECRET).unwrap();
mac.update(timestamp.to_string().as_bytes());
mac.update(b".");
mac.update(body);
let sig = hex::encode(mac.finalize().into_bytes());
format!("t={timestamp},v1={sig}")
}
#[test]
fn valid_signature_passes() {
let body = br#"{"event":"message.received"}"#;
let header = sign(1_700_000_000, body);
assert!(verify_at(SECRET, &header, body, 300, 1_700_000_000).is_ok());
}
#[test]
fn parses_header_and_exposes_timestamp() {
let body = b"{}";
let header = sign(1_700_000_000, body);
let parsed = SignatureHeader::parse(&header).unwrap();
assert_eq!(parsed.timestamp(), 1_700_000_000);
assert_eq!(parsed.signatures.len(), 1);
}
#[test]
fn tolerance_check_is_separate_from_hmac_verification() {
let body = b"{}";
let header = sign(1_700_000_000, body);
let parsed = SignatureHeader::parse(&header).unwrap();
assert!(parsed.check_tolerance(1_700_000_299, 300).is_ok());
assert_eq!(
parsed.check_tolerance(1_700_000_301, 300),
Err(VerifyError::TimestampOutOfTolerance {
timestamp: 1_700_000_000,
tolerance: 300,
})
);
}
#[test]
fn verify_preparsed_checks_hmac_without_rechecking_tolerance() {
let body = b"{}";
let header = sign(1_700_000_000, body);
let parsed = SignatureHeader::parse(&header).unwrap();
assert!(verify_preparsed(SECRET, &parsed, body).is_ok());
assert_eq!(
verify_preparsed(b"other", &parsed, body),
Err(VerifyError::Mismatch)
);
}
#[test]
fn multiple_v1_signatures_are_accepted() {
let body = b"{}";
let good = sign(1_700_000_000, body);
let good_sig = good.split_once("v1=").unwrap().1;
let header = format!("t=1700000000,v1=deadbeef,v1={good_sig}");
assert!(verify_at(SECRET, &header, body, 300, 1_700_000_000).is_ok());
}
#[test]
fn tampered_body_fails() {
let body = br#"{"event":"message.received"}"#;
let header = sign(1_700_000_000, body);
let tampered = br#"{"event":"message.read"}"#;
assert_eq!(
verify_at(SECRET, &header, tampered, 300, 1_700_000_000),
Err(VerifyError::Mismatch)
);
}
#[test]
fn expired_timestamp_fails() {
let body = b"{}";
let header = sign(1_700_000_000, body);
let now = 1_700_000_000 + 1000;
assert!(matches!(
verify_at(SECRET, &header, body, 300, now),
Err(VerifyError::TimestampOutOfTolerance { .. })
));
}
#[test]
fn malformed_header_fails() {
assert_eq!(
verify_at(SECRET, "garbage", b"{}", 300, 0),
Err(VerifyError::MalformedHeader)
);
assert_eq!(
verify_at(SECRET, "t=123", b"{}", 300, 123),
Err(VerifyError::MalformedHeader)
);
}
#[test]
fn wrong_secret_fails() {
let body = b"{}";
let header = sign(1_700_000_000, body);
assert_eq!(
verify_at(b"other", &header, body, 300, 1_700_000_000),
Err(VerifyError::Mismatch)
);
}
}