Skip to main content

allowthem_core/
webhook_sig.rs

1//! HMAC-SHA256 signing for outbound webhook payloads.
2//!
3//! Format on the wire: `X-Allowthem-Signature: t=<unix-seconds>,v1=<hex>`,
4//! where the hex is `HMAC-SHA256(key, "{timestamp}.{body}")`. Mirrors
5//! Stripe's signed-payload scheme so integrators with prior Stripe
6//! work can reuse verification snippets.
7//!
8//! Used by:
9//! - [`crate::WebhookEmailSender`] (epic c8m.2)
10//! - `WebhookWorker` (epic 7xw.2) — adopt `sign_payload` during that
11//!   implementation instead of a separate local signer.
12
13use hmac::{Hmac, Mac};
14use sha2::Sha256;
15use subtle::ConstantTimeEq;
16
17type HmacSha256 = Hmac<Sha256>;
18
19/// Error returned by [`verify_payload`].
20#[derive(Debug, thiserror::Error)]
21pub enum SigError {
22    /// Header could not be parsed (missing `t=` or `v1=` parts).
23    #[error("malformed signature header")]
24    Malformed,
25    /// HMAC did not match.
26    #[error("signature mismatch")]
27    Mismatch,
28    /// Timestamp is outside the allowed tolerance window.
29    #[error("signature timestamp out of tolerance")]
30    Stale,
31}
32
33/// Sign `body` with `secret` and `timestamp` (Unix seconds).
34///
35/// Returns the value for the `X-Allowthem-Signature` header:
36/// `t=<timestamp>,v1=<hex>`.
37///
38/// The signed material is `"{timestamp}.{body}"` — same scheme as Stripe
39/// so customers with prior integrations can reuse verification snippets.
40pub fn sign_payload(secret: &[u8], timestamp: i64, body: &[u8]) -> String {
41    let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
42    mac.update(format!("{timestamp}.").as_bytes());
43    mac.update(body);
44    let tag = mac.finalize().into_bytes();
45    format!("t={timestamp},v1={}", hex::encode(tag))
46}
47
48/// Verify a `X-Allowthem-Signature` header value.
49///
50/// `now_unix_seconds` is the current time; `tolerance_seconds` is the
51/// maximum allowed age of the timestamp (replay protection). Pass `0`
52/// for `tolerance_seconds` to skip the freshness check entirely.
53///
54/// Returns `Ok(())` on success, `Err(SigError)` otherwise.
55pub fn verify_payload(
56    secret: &[u8],
57    body: &[u8],
58    signature_header: &str,
59    now_unix_seconds: i64,
60    tolerance_seconds: i64,
61) -> Result<(), SigError> {
62    // Parse "t=<ts>,v1=<hex>" — both parts required.
63    let mut ts_part: Option<i64> = None;
64    let mut sig_hex: Option<String> = None;
65    for part in signature_header.split(',') {
66        if let Some(v) = part.strip_prefix("t=") {
67            ts_part = v.parse().ok();
68        } else if let Some(v) = part.strip_prefix("v1=") {
69            sig_hex = Some(v.to_owned());
70        }
71    }
72    let timestamp = ts_part.ok_or(SigError::Malformed)?;
73    let sig_bytes =
74        hex::decode(sig_hex.ok_or(SigError::Malformed)?).map_err(|_| SigError::Malformed)?;
75
76    // Freshness check.
77    if tolerance_seconds > 0 {
78        let age = now_unix_seconds.saturating_sub(timestamp);
79        if age > tolerance_seconds {
80            return Err(SigError::Stale);
81        }
82    }
83
84    // Recompute and compare in constant time.
85    let expected = sign_payload(secret, timestamp, body);
86    // Extract the hex portion after "v1=".
87    let expected_hex = expected.split("v1=").nth(1).ok_or(SigError::Malformed)?;
88    let expected_bytes = hex::decode(expected_hex).map_err(|_| SigError::Malformed)?;
89
90    if sig_bytes.ct_eq(&expected_bytes).into() {
91        Ok(())
92    } else {
93        Err(SigError::Mismatch)
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    const SECRET: &[u8] = b"test-secret";
102    const TS: i64 = 1_700_000_000;
103    const BODY: &[u8] = b"{}";
104
105    #[test]
106    fn golden_value() {
107        // Hard-pinned: catches drift in either the format or the algorithm
108        // (key derivation, separator byte, hash function). The previous
109        // version of this test recomputed the HMAC on both sides, which only
110        // pinned the wire format — swapping the `.` separator for any other
111        // byte still passed. This pin is the integration contract surfaced
112        // to webhook receivers.
113        assert_eq!(
114            sign_payload(SECRET, TS, BODY),
115            "t=1700000000,v1=87d3ed18b9b403e7da0fc3a3ae8b9394303805a049ea06f87c2ef4380b521fa9"
116        );
117    }
118
119    #[test]
120    fn round_trip() {
121        let sig = sign_payload(SECRET, TS, BODY);
122        assert!(verify_payload(SECRET, BODY, &sig, TS, 0).is_ok());
123    }
124
125    #[test]
126    fn tampered_body_fails() {
127        let sig = sign_payload(SECRET, TS, BODY);
128        let tampered = b"{\"x\":1}";
129        assert!(matches!(
130            verify_payload(SECRET, tampered, &sig, TS, 0),
131            Err(SigError::Mismatch)
132        ));
133    }
134
135    #[test]
136    fn tampered_secret_fails() {
137        let sig = sign_payload(b"key-a", TS, BODY);
138        assert!(matches!(
139            verify_payload(b"key-b", BODY, &sig, TS, 0),
140            Err(SigError::Mismatch)
141        ));
142    }
143
144    #[test]
145    fn stale_timestamp() {
146        let old_ts = TS - 600;
147        let sig = sign_payload(SECRET, old_ts, BODY);
148        assert!(matches!(
149            verify_payload(SECRET, BODY, &sig, TS, 300),
150            Err(SigError::Stale)
151        ));
152    }
153
154    #[test]
155    fn malformed_header_no_t() {
156        // "v1=abc" — missing t= part
157        let sig = sign_payload(SECRET, TS, BODY);
158        let v1_only = sig.split(',').find(|p| p.starts_with("v1=")).unwrap();
159        assert!(matches!(
160            verify_payload(SECRET, BODY, v1_only, TS, 0),
161            Err(SigError::Malformed)
162        ));
163    }
164}