Skip to main content

openai_compat/
webhooks.rs

1//! Webhook signature verification, mirroring the `openai-python`
2//! `resources/webhooks/webhooks.py` algorithm.
3//!
4//! OpenAI signs webhooks with an HMAC-SHA256 over the string
5//! `"{webhook-id}.{webhook-timestamp}.{payload}"`, base64 (standard alphabet)
6//! encoded. The `webhook-signature` header may carry several space-separated
7//! signatures, each optionally prefixed with `"v1,"`; a request is accepted if
8//! **any** of them matches, using a constant-time comparison.
9//!
10//! This module is standalone (no [`crate::Config`] dependency) so it can be
11//! unit-tested in isolation and wired into the client in a later phase.
12
13use base64::engine::general_purpose::STANDARD;
14use base64::Engine;
15use hmac::{Hmac, Mac};
16use sha2::Sha256;
17use std::time::{SystemTime, UNIX_EPOCH};
18use subtle::ConstantTimeEq;
19
20type HmacSha256 = Hmac<Sha256>;
21
22/// Default maximum age (and future skew) of a webhook, in seconds, mirroring
23/// the Python SDK's `tolerance = 300`.
24pub const DEFAULT_TOLERANCE_SECS: i64 = 300;
25
26/// Errors produced while verifying a webhook signature.
27///
28/// This mirrors the messages raised by
29/// `_exceptions.py::InvalidWebhookSignatureError` in `openai-python`.
30#[derive(Debug, thiserror::Error, PartialEq, Eq)]
31pub enum WebhookVerificationError {
32    /// One of the `webhook-id`/`webhook-timestamp`/`webhook-signature`
33    /// headers is missing.
34    #[error("Missing required webhook headers")]
35    MissingHeaders,
36    /// The `webhook-timestamp` header was not a valid integer.
37    #[error("Invalid webhook timestamp format")]
38    InvalidTimestampFormat,
39    /// The webhook is older than the allowed tolerance (replay protection).
40    #[error("Webhook timestamp is too old")]
41    TimestampTooOld,
42    /// The webhook timestamp is further in the future than the tolerance.
43    #[error("Webhook timestamp is too new")]
44    TimestampTooNew,
45    /// None of the provided signatures matched the expected signature.
46    #[error("The given webhook signature does not match the expected signature")]
47    SignatureMismatch,
48    /// The `whsec_` secret could not be base64-decoded.
49    #[error("invalid webhook secret: {0}")]
50    InvalidSecret(String),
51    /// The payload could not be parsed as JSON (only used by [`Webhooks::unwrap`]).
52    #[error("failed to parse webhook payload as JSON: {0}")]
53    InvalidPayload(String),
54}
55
56/// The three headers required to verify a webhook, looked up case-insensitively
57/// by the caller before constructing this struct:
58/// `webhook-id`, `webhook-timestamp`, `webhook-signature`.
59#[derive(Debug, Clone)]
60pub struct WebhookHeaders {
61    /// Value of the `webhook-id` header.
62    pub id: String,
63    /// Value of the `webhook-timestamp` header (kept as the original string;
64    /// it is used verbatim when building the signed payload).
65    pub timestamp: String,
66    /// Value of the `webhook-signature` header (one or more space-separated
67    /// signatures, each optionally prefixed with `"v1,"`).
68    pub signature: String,
69}
70
71impl WebhookHeaders {
72    /// Convenience constructor.
73    pub fn new(
74        id: impl Into<String>,
75        timestamp: impl Into<String>,
76        signature: impl Into<String>,
77    ) -> Self {
78        Self {
79            id: id.into(),
80            timestamp: timestamp.into(),
81            signature: signature.into(),
82        }
83    }
84
85    /// Extract the three `webhook-*` headers via a lookup function, so any
86    /// HTTP framework's header map plugs in without a dependency here:
87    ///
88    /// ```no_run
89    /// # use openai_compat::webhooks::WebhookHeaders;
90    /// # fn get_header(name: &str) -> Option<String> { None }
91    /// let headers = WebhookHeaders::from_lookup(|name| get_header(name))
92    ///     .expect("missing webhook headers");
93    /// ```
94    ///
95    /// Lookups are performed with lowercase header names.
96    pub fn from_lookup(
97        get: impl Fn(&str) -> Option<String>,
98    ) -> Result<Self, WebhookVerificationError> {
99        let field = |name: &str| get(name).ok_or(WebhookVerificationError::MissingHeaders);
100        Ok(Self {
101            id: field("webhook-id")?,
102            timestamp: field("webhook-timestamp")?,
103            signature: field("webhook-signature")?,
104        })
105    }
106}
107
108/// A verified webhook event envelope. The event body is kept as a raw
109/// [`serde_json::Value`]; a fully typed union of event types is out of scope.
110#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
111pub struct WebhookEvent {
112    /// Unique identifier of the event.
113    pub id: String,
114    /// The event type, e.g. `response.completed`.
115    #[serde(rename = "type")]
116    pub r#type: String,
117    /// Unix timestamp (seconds) of when the event was created.
118    pub created_at: i64,
119    /// The event payload, kept untyped.
120    #[serde(default)]
121    pub data: serde_json::Value,
122}
123
124/// Verifies webhook signatures against a shared secret.
125pub struct Webhooks {
126    secret: Vec<u8>,
127}
128
129impl Webhooks {
130    /// Create a new verifier from a webhook secret.
131    ///
132    /// If the secret starts with `"whsec_"`, the remainder is base64-decoded
133    /// (standard alphabet) into the raw key bytes; otherwise the secret's raw
134    /// bytes are used directly.
135    ///
136    /// Divergence from `webhooks.py`: the Python SDK decodes the secret lazily
137    /// inside `verify_signature`; we decode eagerly here so an invalid
138    /// `whsec_` secret is surfaced at construction time.
139    pub fn new(secret: &str) -> Result<Self, WebhookVerificationError> {
140        let secret = if let Some(rest) = secret.strip_prefix("whsec_") {
141            STANDARD
142                .decode(rest)
143                .map_err(|e| WebhookVerificationError::InvalidSecret(e.to_string()))?
144        } else {
145            secret.as_bytes().to_vec()
146        };
147        Ok(Self { secret })
148    }
149
150    /// Verify the signature of a webhook payload.
151    ///
152    /// `now_unix` is the current time in Unix seconds (injected for testability;
153    /// see [`Webhooks::verify`] for a variant that reads the system clock).
154    /// `tolerance_secs` bounds how far in the past or future the webhook
155    /// timestamp may be (default [`DEFAULT_TOLERANCE_SECS`]).
156    pub fn verify_signature(
157        &self,
158        payload: &[u8],
159        headers: &WebhookHeaders,
160        tolerance_secs: i64,
161        now_unix: i64,
162    ) -> Result<(), WebhookVerificationError> {
163        // 1. Parse and validate the timestamp (replay protection).
164        let timestamp_seconds: i64 = headers
165            .timestamp
166            .trim()
167            .parse()
168            .map_err(|_| WebhookVerificationError::InvalidTimestampFormat)?;
169
170        if now_unix - timestamp_seconds > tolerance_secs {
171            return Err(WebhookVerificationError::TimestampTooOld);
172        }
173        if timestamp_seconds > now_unix + tolerance_secs {
174            return Err(WebhookVerificationError::TimestampTooNew);
175        }
176
177        // 2. Extract candidate signatures ("v1,<base64>" or bare "<base64>"),
178        //    split on ASCII whitespace.
179        let signatures: Vec<&str> = headers
180            .signature
181            .split_whitespace()
182            .map(|part| part.strip_prefix("v1,").unwrap_or(part))
183            .collect();
184
185        // 3. Compute the expected signature over "{id}.{timestamp}.{body}",
186        //    feeding the payload as raw bytes (no lossy UTF-8 conversion).
187        let mut mac = HmacSha256::new_from_slice(&self.secret)
188            .expect("HMAC-SHA256 accepts keys of any length");
189        mac.update(headers.id.as_bytes());
190        mac.update(b".");
191        mac.update(headers.timestamp.as_bytes());
192        mac.update(b".");
193        mac.update(payload);
194        let expected = STANDARD.encode(mac.finalize().into_bytes());
195        let expected_bytes = expected.as_bytes();
196
197        // 4. Accept if any provided signature matches (constant-time).
198        let matched = signatures.iter().any(|sig| {
199            let sig_bytes = sig.as_bytes();
200            // Guard length before the constant-time compare; unequal lengths
201            // can never match.
202            sig_bytes.len() == expected_bytes.len()
203                && expected_bytes.ct_eq(sig_bytes).into()
204        });
205
206        if matched {
207            Ok(())
208        } else {
209            Err(WebhookVerificationError::SignatureMismatch)
210        }
211    }
212
213    /// Convenience wrapper that reads the current system time and uses the
214    /// default tolerance.
215    pub fn verify(
216        &self,
217        payload: &[u8],
218        id: &str,
219        timestamp: &str,
220        signature_header: &str,
221    ) -> Result<(), WebhookVerificationError> {
222        let headers = WebhookHeaders::new(id, timestamp, signature_header);
223        self.verify_signature(payload, &headers, DEFAULT_TOLERANCE_SECS, now_unix())
224    }
225
226    /// Verify the payload and parse it into a [`WebhookEvent`].
227    ///
228    /// Divergence from the parent shorthand: verification requires the webhook
229    /// headers, so `unwrap` takes them explicitly (the Python `unwrap(payload,
230    /// headers, secret)` does the same). Uses the system clock and default
231    /// tolerance.
232    pub fn unwrap(
233        &self,
234        payload: &[u8],
235        headers: &WebhookHeaders,
236    ) -> Result<WebhookEvent, WebhookVerificationError> {
237        self.verify_signature(payload, headers, DEFAULT_TOLERANCE_SECS, now_unix())?;
238        serde_json::from_slice(payload)
239            .map_err(|e| WebhookVerificationError::InvalidPayload(e.to_string()))
240    }
241}
242
243/// Current time in Unix seconds. Falls back to 0 if the system clock is set
244/// before the epoch (which makes every timestamp check fail closed).
245fn now_unix() -> i64 {
246    SystemTime::now()
247        .duration_since(UNIX_EPOCH)
248        .map(|d| d.as_secs() as i64)
249        .unwrap_or(0)
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    /// Compute a valid signature for a payload the same way the server would,
257    /// so tests can self-check without hard-coding opaque base64 blobs.
258    fn sign(secret_bytes: &[u8], id: &str, timestamp: &str, payload: &[u8]) -> String {
259        let body = String::from_utf8_lossy(payload);
260        let signed = format!("{id}.{timestamp}.{body}");
261        let mut mac = HmacSha256::new_from_slice(secret_bytes).unwrap();
262        mac.update(signed.as_bytes());
263        STANDARD.encode(mac.finalize().into_bytes())
264    }
265
266    const SECRET: &str = "my-webhook-secret";
267    const ID: &str = "wh_123";
268    const PAYLOAD: &[u8] = br#"{"id":"evt_1","type":"response.completed","created_at":100,"data":{"foo":"bar"}}"#;
269
270    #[test]
271    fn accepts_valid_signature() {
272        let ts = "1000";
273        let sig = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
274        let wh = Webhooks::new(SECRET).unwrap();
275        let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
276        // now within tolerance of ts.
277        assert!(wh
278            .verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000)
279            .is_ok());
280    }
281
282    #[test]
283    fn accepts_bare_signature_without_v1_prefix() {
284        let ts = "1000";
285        let sig = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
286        let wh = Webhooks::new(SECRET).unwrap();
287        let headers = WebhookHeaders::new(ID, ts, sig); // no "v1," prefix
288        assert!(wh
289            .verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000)
290            .is_ok());
291    }
292
293    #[test]
294    fn rejects_wrong_signature() {
295        let ts = "1000";
296        let wh = Webhooks::new(SECRET).unwrap();
297        let headers = WebhookHeaders::new(ID, ts, "v1,not-the-right-signature");
298        assert_eq!(
299            wh.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000),
300            Err(WebhookVerificationError::SignatureMismatch)
301        );
302    }
303
304    #[test]
305    fn rejects_signature_from_wrong_secret() {
306        let ts = "1000";
307        let sig = sign(b"a-different-secret", ID, ts, PAYLOAD);
308        let wh = Webhooks::new(SECRET).unwrap();
309        let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
310        assert_eq!(
311            wh.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000),
312            Err(WebhookVerificationError::SignatureMismatch)
313        );
314    }
315
316    #[test]
317    fn rejects_tampered_payload() {
318        let ts = "1000";
319        let sig = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
320        let wh = Webhooks::new(SECRET).unwrap();
321        let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
322        let tampered = br#"{"id":"evt_1","type":"response.completed","created_at":100,"data":{"foo":"BAZ"}}"#;
323        assert_eq!(
324            wh.verify_signature(tampered, &headers, DEFAULT_TOLERANCE_SECS, 1000),
325            Err(WebhookVerificationError::SignatureMismatch)
326        );
327    }
328
329    #[test]
330    fn rejects_expired_timestamp() {
331        let ts = "1000";
332        let sig = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
333        let wh = Webhooks::new(SECRET).unwrap();
334        let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
335        // now is 1000 + 301 -> older than tolerance 300.
336        assert_eq!(
337            wh.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1301),
338            Err(WebhookVerificationError::TimestampTooOld)
339        );
340    }
341
342    #[test]
343    fn rejects_future_timestamp() {
344        let ts = "1000";
345        let sig = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
346        let wh = Webhooks::new(SECRET).unwrap();
347        let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
348        // ts is 301 seconds ahead of now -> too new.
349        assert_eq!(
350            wh.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 699),
351            Err(WebhookVerificationError::TimestampTooNew)
352        );
353    }
354
355    #[test]
356    fn rejects_non_integer_timestamp() {
357        let wh = Webhooks::new(SECRET).unwrap();
358        let headers = WebhookHeaders::new(ID, "not-a-number", "v1,whatever");
359        assert_eq!(
360            wh.verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000),
361            Err(WebhookVerificationError::InvalidTimestampFormat)
362        );
363    }
364
365    #[test]
366    fn decodes_whsec_prefixed_secret() {
367        // Raw key bytes, then encode into a whsec_ secret.
368        let raw_key = b"raw-secret-bytes-32-chars-long!!";
369        let whsec = format!("whsec_{}", STANDARD.encode(raw_key));
370        let ts = "1000";
371        let sig = sign(raw_key, ID, ts, PAYLOAD);
372
373        let wh = Webhooks::new(&whsec).unwrap();
374        let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
375        assert!(wh
376            .verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000)
377            .is_ok());
378    }
379
380    #[test]
381    fn invalid_whsec_secret_is_rejected_at_construction() {
382        let result = Webhooks::new("whsec_!!!not base64!!!");
383        assert!(matches!(
384            result,
385            Err(WebhookVerificationError::InvalidSecret(_))
386        ));
387    }
388
389    #[test]
390    fn accepts_when_only_second_of_multiple_signatures_matches() {
391        let ts = "1000";
392        let good = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
393        let wh = Webhooks::new(SECRET).unwrap();
394        // First signature is garbage, second is valid; space-separated.
395        let header = format!("v1,aGVsbG8gd29ybGQ v1,{good}");
396        let headers = WebhookHeaders::new(ID, ts, header);
397        assert!(wh
398            .verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000)
399            .is_ok());
400    }
401
402    #[test]
403    fn unwrap_verifies_then_parses() {
404        let ts = "1000";
405        let sig = sign(SECRET.as_bytes(), ID, ts, PAYLOAD);
406        let wh = Webhooks::new(SECRET).unwrap();
407        let headers = WebhookHeaders::new(ID, ts, format!("v1,{sig}"));
408
409        // unwrap uses the system clock, so use a fresh timestamp for this test.
410        let now = now_unix().to_string();
411        let sig_now = sign(SECRET.as_bytes(), ID, &now, PAYLOAD);
412        let headers_now = WebhookHeaders::new(ID, &now, format!("v1,{sig_now}"));
413        let event = wh.unwrap(PAYLOAD, &headers_now).unwrap();
414        assert_eq!(event.id, "evt_1");
415        assert_eq!(event.r#type, "response.completed");
416        assert_eq!(event.created_at, 100);
417        assert_eq!(event.data["foo"], "bar");
418
419        // Sanity: the fixed-time verifier still works with injected now.
420        assert!(wh
421            .verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000)
422            .is_ok());
423    }
424}