Skip to main content

wi_api/
webhook.rs

1use bytes::Bytes;
2use hmac::{Hmac, Mac};
3use sha2::Sha256;
4
5use crate::{error::WiError, types::Event};
6
7type HmacSha256 = Hmac<Sha256>;
8
9const SIGNATURE_HEADER: &str = "x-wi-signature";
10const SIGNATURE_V2_HEADER: &str = "x-wi-signature-v2";
11
12/// Default max age of the signed timestamp (`x-wi-timestamp`), in seconds: 5 minutes.
13pub const DEFAULT_TOLERANCE_SECONDS: u64 = 300;
14
15/// Options for [`verify_webhook`].
16#[derive(Debug, Clone, Copy)]
17pub struct VerifyOptions {
18    /// Max distance between the signed timestamp and now, in seconds.
19    pub tolerance_seconds: u64,
20    /// Current unix time in seconds; `None` reads the system clock. For tests.
21    pub now: Option<i64>,
22}
23
24impl Default for VerifyOptions {
25    fn default() -> Self {
26        Self {
27            tolerance_seconds: DEFAULT_TOLERANCE_SECONDS,
28            now: None,
29        }
30    }
31}
32
33fn header_value<'a, K: AsRef<str>, V: AsRef<str>>(
34    headers: &'a [(K, V)],
35    name: &str,
36) -> Option<&'a str> {
37    headers
38        .iter()
39        .find(|(k, _)| k.as_ref().eq_ignore_ascii_case(name))
40        .map(|(_, v)| v.as_ref())
41}
42
43/// Verify a webhook delivery with replay protection.
44///
45/// Checks `x-wi-signature-v2: t=<unix seconds>,v1=<hex>`, the HMAC-SHA256 of
46/// `"<t>.<body>"`, and rejects it when `t` is further than
47/// `options.tolerance_seconds` from now: a captured request cannot be replayed
48/// later, and a tampered timestamp fails the signature. A delivery with no v2
49/// header (older servers) falls back to the legacy `x-wi-signature` body
50/// signature, which has no replay protection. A malformed v2 header is
51/// rejected, never downgraded.
52///
53/// `headers` is any list of `(name, value)` pairs; names match case-insensitively.
54pub fn verify_webhook<K: AsRef<str>, V: AsRef<str>>(
55    body: &[u8],
56    headers: &[(K, V)],
57    secret: &[u8],
58    options: VerifyOptions,
59) -> bool {
60    let Some(v2) = header_value(headers, SIGNATURE_V2_HEADER) else {
61        return header_value(headers, SIGNATURE_HEADER)
62            .is_some_and(|legacy| verify_signature(body, legacy, secret));
63    };
64
65    let (mut t, mut v1) = (None, None);
66    for part in v2.split(',') {
67        match part.trim().split_once('=') {
68            Some(("t", v)) => t = Some(v),
69            Some(("v1", v)) => v1 = Some(v),
70            _ => {}
71        }
72    }
73    let (Some(t_str), Some(v1)) = (t, v1) else {
74        return false;
75    };
76    let Ok(t_val) = t_str.parse::<i64>() else {
77        return false;
78    };
79    let Ok(expected) = hex::decode(v1) else {
80        return false;
81    };
82
83    let now = options.now.unwrap_or_else(|| {
84        std::time::SystemTime::now()
85            .duration_since(std::time::UNIX_EPOCH)
86            .map(|d| d.as_secs() as i64)
87            .unwrap_or(0)
88    });
89    if now.abs_diff(t_val) > options.tolerance_seconds {
90        return false;
91    }
92
93    let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
94    mac.update(t_str.as_bytes());
95    mac.update(b".");
96    mac.update(body);
97    mac.verify_slice(&expected).is_ok()
98}
99
100/// Verify the legacy body-only HMAC-SHA256 signature on a webhook payload
101/// (no replay protection). Prefer [`verify_webhook`].
102///
103/// The server sends `x-wi-signature: sha256=<hex>`. Pass the raw body bytes,
104/// the header value, and your webhook secret.
105///
106/// Returns `false` (not an error) when the signature does not match — treat
107/// that as an HTTP 401.
108pub fn verify_signature(body: &[u8], signature: &str, secret: &[u8]) -> bool {
109    let hex_part = match signature.strip_prefix("sha256=") {
110        Some(h) => h,
111        None => return false,
112    };
113
114    let expected = match hex::decode(hex_part) {
115        Ok(b) => b,
116        Err(_) => return false,
117    };
118
119    let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
120    mac.update(body);
121    mac.verify_slice(&expected).is_ok()
122}
123
124/// Parse raw webhook body bytes into an [`Event`].
125pub fn parse_event(body: &[u8]) -> Result<Event, WiError> {
126    serde_json::from_slice(body).map_err(WiError::Serialize)
127}
128
129/// A verified, parsed webhook request.
130pub struct WebhookPayload {
131    pub event: Event,
132    pub raw: Bytes,
133}
134
135impl WebhookPayload {
136    /// Verify the signature and parse the event from raw bytes and a header value.
137    ///
138    /// Returns `None` when the signature is invalid (caller should respond 401).
139    pub fn from_parts(
140        body: Bytes,
141        signature: &str,
142        secret: &[u8],
143    ) -> Option<Result<Self, WiError>> {
144        if !verify_signature(&body, signature, secret) {
145            return None;
146        }
147        Some(parse_event(&body).map(|event| WebhookPayload { event, raw: body }))
148    }
149
150    /// Verify with replay protection ([`verify_webhook`]) and parse the event.
151    ///
152    /// Returns `None` when verification fails (caller should respond 401).
153    pub fn from_headers<K: AsRef<str>, V: AsRef<str>>(
154        body: Bytes,
155        headers: &[(K, V)],
156        secret: &[u8],
157        options: VerifyOptions,
158    ) -> Option<Result<Self, WiError>> {
159        if !verify_webhook(&body, headers, secret, options) {
160            return None;
161        }
162        Some(parse_event(&body).map(|event| WebhookPayload { event, raw: body }))
163    }
164
165    /// Signature header name: `x-wi-signature`
166    pub const HEADER: &'static str = SIGNATURE_HEADER;
167}