1use time::OffsetDateTime;
5
6#[derive(thiserror::Error, Debug)]
7pub enum WebhookError {
8 #[error("failed to parse timestamp")]
9 InvalidTimestamp,
10
11 #[error("invalid secret")]
12 InvalidSecret(#[from] base64::DecodeError),
13
14 #[error("invalid header {0}")]
15 InvalidHeader(&'static str),
16
17 #[error("signature timestamp too old")]
18 TimestampTooOldError,
19
20 #[error("signature timestamp too far in future")]
21 FutureTimestampError,
22
23 #[error("missing header {0}")]
24 MissingHeader(&'static str),
25
26 #[error("signature invalid")]
27 InvalidSignature,
28
29 #[error("payload invalid")]
30 InvalidPayload,
31}
32
33#[derive(Debug, Clone)]
34pub struct Webhook {
35 key: Vec<u8>,
36}
37
38const PREFIX: &str = "whsec_";
39const SVIX_MSG_ID_KEY: &str = "svix-id";
40const SVIX_MSG_SIGNATURE_KEY: &str = "svix-signature";
41const SVIX_MSG_TIMESTAMP_KEY: &str = "svix-timestamp";
42const UNBRANDED_MSG_ID_KEY: &str = "webhook-id";
43const UNBRANDED_MSG_SIGNATURE_KEY: &str = "webhook-signature";
44const UNBRANDED_MSG_TIMESTAMP_KEY: &str = "webhook-timestamp";
45const TOLERANCE_IN_SECONDS: i64 = 5 * 60;
46const SIGNATURE_VERSION: &str = "v1";
47
48impl Webhook {
49 pub fn new(secret: &str) -> Result<Self, WebhookError> {
50 let secret = secret.strip_prefix(PREFIX).unwrap_or(secret);
51 let key = base64::decode(secret)?;
52
53 Ok(Webhook { key })
54 }
55
56 pub fn from_bytes(secret: Vec<u8>) -> Result<Self, WebhookError> {
57 Ok(Webhook { key: secret })
58 }
59
60 pub fn verify<HM: HeaderMap>(&self, payload: &[u8], headers: &HM) -> Result<(), WebhookError> {
61 let msg_id = Self::get_header(headers, SVIX_MSG_ID_KEY, UNBRANDED_MSG_ID_KEY, "id")?;
62 let msg_signature = Self::get_header(
63 headers,
64 SVIX_MSG_SIGNATURE_KEY,
65 UNBRANDED_MSG_SIGNATURE_KEY,
66 "signature",
67 )?;
68 let msg_ts = Self::get_header(
69 headers,
70 SVIX_MSG_TIMESTAMP_KEY,
71 UNBRANDED_MSG_TIMESTAMP_KEY,
72 "timestamp",
73 )
74 .and_then(Self::parse_timestamp)?;
75
76 Self::verify_timestamp(msg_ts)?;
77
78 let versioned_signature = self.sign(msg_id, msg_ts, payload)?;
79 let expected_signature = versioned_signature
80 .split_once(',')
81 .map(|x| x.1)
82 .ok_or(WebhookError::InvalidSignature)?;
83
84 msg_signature
85 .split(' ')
86 .filter_map(|x| x.split_once(','))
87 .filter(|x| x.0 == SIGNATURE_VERSION)
88 .any(|x| {
89 (x.1.len() == expected_signature.len())
90 && (x
91 .1
92 .bytes()
93 .zip(expected_signature.bytes())
94 .fold(0, |acc, (a, b)| acc | (a ^ b))
95 == 0)
96 })
97 .then_some(())
98 .ok_or(WebhookError::InvalidSignature)
99 }
100
101 pub fn sign(
102 &self,
103 msg_id: &str,
104 timestamp: i64,
105 payload: &[u8],
106 ) -> Result<String, WebhookError> {
107 let payload = std::str::from_utf8(payload).map_err(|_| WebhookError::InvalidPayload)?;
108 let to_sign = format!("{msg_id}.{timestamp}.{payload}",);
109 let signed = hmac_sha256::HMAC::mac(to_sign.as_bytes(), &self.key);
110 let encoded = base64::encode(signed);
111
112 Ok(format!("{SIGNATURE_VERSION},{encoded}"))
113 }
114
115 fn get_header<'a, HM: HeaderMap>(
116 headers: &'a HM,
117 svix_hdr: &'static str,
118 unbranded_hdr: &'static str,
119 err_name: &'static str,
120 ) -> Result<&'a str, WebhookError> {
121 use private::HeaderValueSealed as _;
122
123 headers
124 ._get(svix_hdr)
125 .or_else(|| headers._get(unbranded_hdr))
126 .ok_or(WebhookError::MissingHeader(err_name))?
127 ._to_str()
128 .ok_or(WebhookError::InvalidHeader(err_name))
129 }
130
131 fn parse_timestamp(hdr: &str) -> Result<i64, WebhookError> {
132 str::parse::<i64>(hdr).map_err(|_| WebhookError::InvalidTimestamp)
133 }
134
135 fn verify_timestamp(ts: i64) -> Result<(), WebhookError> {
136 let now = OffsetDateTime::now_utc().unix_timestamp();
137 if now - ts > TOLERANCE_IN_SECONDS {
138 Err(WebhookError::TimestampTooOldError)
139 } else if ts > now + TOLERANCE_IN_SECONDS {
140 Err(WebhookError::FutureTimestampError)
141 } else {
142 Ok(())
143 }
144 }
145}
146
147pub trait HeaderMap: private::HeaderMapSealed {}
150
151impl HeaderMap for http02::HeaderMap {}
152impl HeaderMap for http1::HeaderMap {}
153
154mod private {
155 pub trait HeaderMapSealed {
156 type HeaderValue: HeaderValueSealed;
157 fn _get(&self, name: &str) -> Option<&Self::HeaderValue>;
158 }
159
160 impl HeaderMapSealed for http02::HeaderMap {
161 type HeaderValue = http02::HeaderValue;
162 fn _get(&self, name: &str) -> Option<&Self::HeaderValue> {
163 self.get(name)
164 }
165 }
166 impl HeaderMapSealed for http1::HeaderMap {
167 type HeaderValue = http1::HeaderValue;
168 fn _get(&self, name: &str) -> Option<&Self::HeaderValue> {
169 self.get(name)
170 }
171 }
172
173 pub trait HeaderValueSealed {
174 fn _to_str(&self) -> Option<&str>;
175 }
176
177 impl HeaderValueSealed for http02::HeaderValue {
178 fn _to_str(&self) -> Option<&str> {
179 self.to_str().ok()
180 }
181 }
182 impl HeaderValueSealed for http1::HeaderValue {
183 fn _to_str(&self) -> Option<&str> {
184 self.to_str().ok()
185 }
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use http02::HeaderMap;
192 use time::OffsetDateTime;
193
194 use super::{
195 Webhook, SVIX_MSG_ID_KEY, SVIX_MSG_SIGNATURE_KEY, SVIX_MSG_TIMESTAMP_KEY,
196 UNBRANDED_MSG_ID_KEY, UNBRANDED_MSG_SIGNATURE_KEY, UNBRANDED_MSG_TIMESTAMP_KEY,
197 };
198
199 fn get_svix_headers(msg_id: &str, signature: &str) -> HeaderMap {
200 let mut headers = HeaderMap::new();
201 headers.insert(SVIX_MSG_ID_KEY, msg_id.parse().unwrap());
202 headers.insert(SVIX_MSG_SIGNATURE_KEY, signature.parse().unwrap());
203 headers.insert(
204 SVIX_MSG_TIMESTAMP_KEY,
205 OffsetDateTime::now_utc()
206 .unix_timestamp()
207 .to_string()
208 .parse()
209 .unwrap(),
210 );
211 headers
212 }
213
214 fn get_unbranded_headers(msg_id: &str, signature: &str) -> HeaderMap {
215 let mut headers = HeaderMap::new();
216 headers.insert(UNBRANDED_MSG_ID_KEY, msg_id.parse().unwrap());
217 headers.insert(UNBRANDED_MSG_SIGNATURE_KEY, signature.parse().unwrap());
218 headers.insert(
219 UNBRANDED_MSG_TIMESTAMP_KEY,
220 OffsetDateTime::now_utc()
221 .unix_timestamp()
222 .to_string()
223 .parse()
224 .unwrap(),
225 );
226 headers
227 }
228
229 #[test]
230 fn test_sign() {
231 let wh = Webhook::new("whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD").unwrap();
232 assert_eq!(
233 "v1,tZ1I4/hDygAJgO5TYxiSd6Sd0kDW6hPenDe+bTa3Kkw=".to_owned(),
234 wh.sign(
235 "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk",
236 1649367553,
237 br#"{"email":"test@example.com","username":"test_user"}"#
238 )
239 .unwrap()
240 );
241 }
242
243 #[test]
244 fn test_verify() {
245 let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
246 let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
247 let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
248 let wh = Webhook::new(&secret).unwrap();
249
250 let signature = wh
251 .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
252 .unwrap();
253 for headers in [
254 get_svix_headers(msg_id, &signature),
255 get_unbranded_headers(msg_id, &signature),
256 ] {
257 wh.verify(payload, &headers).unwrap();
258 }
259 }
260
261 #[test]
262 fn test_no_verify() {
263 let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
264 let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
265 let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
266 let wh = Webhook::new(&secret).unwrap();
267
268 let signature = "v1,R3PTzyfHASBKHH98a7yexTwaJ4yNIcGhFQc1yuN+BPU=".to_owned();
269 for headers in [
270 get_svix_headers(msg_id, &signature),
271 get_unbranded_headers(msg_id, &signature),
272 ] {
273 assert!(wh.verify(payload, &headers).is_err());
274 }
275 }
276
277 #[test]
278 fn test_verify_partial_signature() {
279 let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
280 let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
281 let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
282 let wh = Webhook::new(&secret).unwrap();
283
284 let signature = wh
285 .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
286 .unwrap();
287
288 for mut headers in [
290 get_svix_headers(msg_id, &signature),
291 get_unbranded_headers(msg_id, &signature),
292 ] {
293 let partial = format!(
294 "{},",
295 signature.split(',').collect::<Vec<&str>>().first().unwrap()
296 );
297 headers.insert(SVIX_MSG_SIGNATURE_KEY, partial.parse().unwrap());
298 headers.insert(UNBRANDED_MSG_SIGNATURE_KEY, partial.parse().unwrap());
299 assert!(wh.verify(payload, &headers).is_err());
300 }
301
302 for mut headers in [
304 get_svix_headers(msg_id, &signature),
305 get_unbranded_headers(msg_id, &signature),
306 ] {
307 let partial = &signature[0..8];
308 headers.insert(SVIX_MSG_SIGNATURE_KEY, partial.parse().unwrap());
309 headers.insert(UNBRANDED_MSG_SIGNATURE_KEY, partial.parse().unwrap());
310 assert!(wh.verify(payload, &headers).is_err());
311 }
312 }
313
314 #[test]
315 fn test_verify_incorrect_timestamp() {
316 let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
317 let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
318 let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
319 let wh = Webhook::new(&secret).unwrap();
320
321 let signature = wh
322 .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
323 .unwrap();
324
325 let mut headers = get_svix_headers(msg_id, &signature);
326 for ts in [
327 OffsetDateTime::now_utc().unix_timestamp() - (super::TOLERANCE_IN_SECONDS + 1),
328 OffsetDateTime::now_utc().unix_timestamp() + (super::TOLERANCE_IN_SECONDS + 1),
329 ] {
330 headers.insert(
331 super::SVIX_MSG_TIMESTAMP_KEY,
332 ts.to_string().parse().unwrap(),
333 );
334
335 assert!(wh.verify(payload, &headers,).is_err());
336 }
337 }
338
339 #[test]
340 fn test_verify_with_multiple_signatures() {
341 let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
342 let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
343 let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
344 let wh = Webhook::new(&secret).unwrap();
345
346 let signature = wh
347 .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
348 .unwrap();
349
350 let multi_sig = format!(
351 "{} {} {} {}",
352 "v1,tFtCZ5RDCPxzWQRWXWPgrCgE2frDBe9gjpbWQxnVfsQ=",
353 "v1,Mm7xgUVICxZfQ3bgf0h0Dof65L/IFx+PnZvnDWPCX6Q=",
354 signature,
355 "v1,9DfC1c3eeOrXB6w/5dIDydLNQaEyww5KalE5jLBZucE=",
356 );
357
358 let headers = get_svix_headers(msg_id, &multi_sig);
359
360 wh.verify(payload, &headers).unwrap();
361 }
362
363 #[test]
364 fn test_no_verify_with_multiple_signatures() {
365 let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
366 let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
367 let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
368 let wh = Webhook::new(&secret).unwrap();
369
370 let missing_sig = format!(
371 "{} {} {}",
372 "v1,tFtCZ5RDCPxzWQRWXWPgrCgE2frDBe9gjpbWQxnVfsQ=",
373 "v1,Mm7xgUVICxZfQ3bgf0h0Dof65L/IFx+PnZvnDWPCX6Q=",
374 "v1,9DfC1c3eeOrXB6w/5dIDydLNQaEyww5KalE5jLBZucE=",
375 );
376
377 let headers = get_svix_headers(msg_id, &missing_sig);
378
379 assert!(wh.verify(payload, &headers).is_err());
380 }
381
382 #[test]
383 fn test_missing_headers() {
384 let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
385 let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
386 let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
387 let wh = Webhook::new(&secret).unwrap();
388
389 let signature = wh
390 .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
391 .unwrap();
392 for (mut hdr_map, hdrs) in [
393 (
394 get_svix_headers(msg_id, &signature),
395 [
396 SVIX_MSG_ID_KEY,
397 SVIX_MSG_SIGNATURE_KEY,
398 SVIX_MSG_TIMESTAMP_KEY,
399 ],
400 ),
401 (
402 get_unbranded_headers(msg_id, &signature),
403 [
404 UNBRANDED_MSG_ID_KEY,
405 UNBRANDED_MSG_SIGNATURE_KEY,
406 UNBRANDED_MSG_TIMESTAMP_KEY,
407 ],
408 ),
409 ] {
410 for hdr in hdrs {
411 hdr_map.remove(hdr);
412 assert!(wh.verify(payload, &hdr_map).is_err());
413 }
414 }
415 }
416}