svix_webhook_with_clone/
webhooks.rs

1// SPDX-FileCopyrightText: © 2022 Svix Authors
2// SPDX-License-Identifier: MIT
3
4use 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
147/// Trait to abstract over the `HeaderMap` types from both v0.2 and v1.0 of the
148/// `http` crate.
149pub 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        // Just `v1,`
289        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        // Non-empty but still partial signature (first few bytes)
303        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}