1use 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
22pub const DEFAULT_TOLERANCE_SECS: i64 = 300;
25
26#[derive(Debug, thiserror::Error, PartialEq, Eq)]
31pub enum WebhookVerificationError {
32 #[error("Missing required webhook headers")]
35 MissingHeaders,
36 #[error("Invalid webhook timestamp format")]
38 InvalidTimestampFormat,
39 #[error("Webhook timestamp is too old")]
41 TimestampTooOld,
42 #[error("Webhook timestamp is too new")]
44 TimestampTooNew,
45 #[error("The given webhook signature does not match the expected signature")]
47 SignatureMismatch,
48 #[error("invalid webhook secret: {0}")]
50 InvalidSecret(String),
51 #[error("failed to parse webhook payload as JSON: {0}")]
53 InvalidPayload(String),
54}
55
56#[derive(Debug, Clone)]
60pub struct WebhookHeaders {
61 pub id: String,
63 pub timestamp: String,
66 pub signature: String,
69}
70
71impl WebhookHeaders {
72 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 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
111pub struct WebhookEvent {
112 pub id: String,
114 #[serde(rename = "type")]
116 pub r#type: String,
117 pub created_at: i64,
119 #[serde(default)]
121 pub data: serde_json::Value,
122}
123
124pub struct Webhooks {
126 secret: Vec<u8>,
127}
128
129impl Webhooks {
130 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 pub fn verify_signature(
157 &self,
158 payload: &[u8],
159 headers: &WebhookHeaders,
160 tolerance_secs: i64,
161 now_unix: i64,
162 ) -> Result<(), WebhookVerificationError> {
163 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 let signatures: Vec<&str> = headers
180 .signature
181 .split_whitespace()
182 .map(|part| part.strip_prefix("v1,").unwrap_or(part))
183 .collect();
184
185 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 let matched = signatures.iter().any(|sig| {
199 let sig_bytes = sig.as_bytes();
200 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 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 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
243fn 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 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 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); 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 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 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 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 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 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 assert!(wh
421 .verify_signature(PAYLOAD, &headers, DEFAULT_TOLERANCE_SECS, 1000)
422 .is_ok());
423 }
424}