standardwebhooks/
lib.rs

1// SPDX-FileCopyrightText: © 2022 Svix Authors
2// SPDX-License-Identifier: MIT
3
4#![warn(clippy::all)]
5#![forbid(unsafe_code)]
6
7use base64::Engine;
8use http::HeaderMap;
9use time::OffsetDateTime;
10
11#[derive(thiserror::Error, Debug)]
12pub enum WebhookError {
13    #[error("failed to parse timestamp")]
14    InvalidTimestamp,
15
16    #[error("invalid secret")]
17    InvalidSecret(#[from] base64::DecodeError),
18
19    #[error("invalid header {0}")]
20    InvalidHeader(&'static str),
21
22    #[error("signature timestamp too old")]
23    TimestampTooOldError,
24
25    #[error("signature timestamp too far in future")]
26    FutureTimestampError,
27
28    #[error("missing header {0}")]
29    MissingHeader(&'static str),
30
31    #[error("signature invalid")]
32    InvalidSignature,
33
34    #[error("payload invalid")]
35    InvalidPayload,
36}
37
38pub struct Webhook {
39    key: Vec<u8>,
40}
41
42pub const HEADER_WEBHOOK_ID: &str = "webhook-id";
43pub const HEADER_WEBHOOK_SIGNATURE: &str = "webhook-signature";
44pub const HEADER_WEBHOOK_TIMESTAMP: &str = "webhook-timestamp";
45
46const PREFIX: &str = "whsec_";
47const TOLERANCE_IN_SECONDS: i64 = 5 * 60;
48const SIGNATURE_VERSION: &str = "v1";
49
50impl Webhook {
51    pub fn new(secret: &str) -> Result<Self, WebhookError> {
52        let secret = secret.strip_prefix(PREFIX).unwrap_or(secret);
53        let key = base64::engine::general_purpose::STANDARD.decode(secret)?;
54
55        Ok(Webhook { key })
56    }
57
58    pub fn from_bytes(secret: Vec<u8>) -> Result<Self, WebhookError> {
59        Ok(Webhook { key: secret })
60    }
61
62    pub fn verify(&self, payload: &[u8], headers: &HeaderMap) -> Result<(), WebhookError> {
63        let msg_id = Self::get_header(headers, HEADER_WEBHOOK_ID, "id")?;
64        let msg_signature = Self::get_header(headers, HEADER_WEBHOOK_SIGNATURE, "signature")?;
65        let msg_ts = Self::get_header(headers, HEADER_WEBHOOK_TIMESTAMP, "timestamp")
66            .and_then(Self::parse_timestamp)?;
67
68        Self::verify_timestamp(msg_ts)?;
69
70        let versioned_signature = self.sign(msg_id, msg_ts, payload)?;
71        let expected_signature = versioned_signature
72            .split_once(',')
73            .map(|x| x.1)
74            .ok_or(WebhookError::InvalidSignature)?;
75
76        msg_signature
77            .split(' ')
78            .filter_map(|x| x.split_once(','))
79            .filter(|x| x.0 == SIGNATURE_VERSION)
80            .any(|x| {
81                (x.1.len() == expected_signature.len())
82                    && (x
83                        .1
84                        .bytes()
85                        .zip(expected_signature.bytes())
86                        .fold(0, |acc, (a, b)| acc | (a ^ b))
87                        == 0)
88            })
89            .then_some(())
90            .ok_or(WebhookError::InvalidSignature)
91    }
92
93    pub fn sign(
94        &self,
95        msg_id: &str,
96        timestamp: i64,
97        payload: &[u8],
98    ) -> Result<String, WebhookError> {
99        let payload = std::str::from_utf8(payload).map_err(|_| WebhookError::InvalidPayload)?;
100        let to_sign = format!("{msg_id}.{timestamp}.{payload}",);
101        let signed = hmac_sha256::HMAC::mac(to_sign.as_bytes(), &self.key);
102        let encoded = base64::engine::general_purpose::STANDARD.encode(signed);
103
104        Ok(format!("{SIGNATURE_VERSION},{encoded}"))
105    }
106
107    fn get_header<'a>(
108        headers: &'a HeaderMap,
109        unbranded_hdr: &'static str,
110        err_name: &'static str,
111    ) -> Result<&'a str, WebhookError> {
112        headers
113            .get(unbranded_hdr)
114            .ok_or(WebhookError::MissingHeader(err_name))?
115            .to_str()
116            .map_err(|_| WebhookError::InvalidHeader(err_name))
117    }
118
119    fn parse_timestamp(hdr: &str) -> Result<i64, WebhookError> {
120        str::parse::<i64>(hdr).map_err(|_| WebhookError::InvalidTimestamp)
121    }
122
123    fn verify_timestamp(ts: i64) -> Result<(), WebhookError> {
124        let now = OffsetDateTime::now_utc().unix_timestamp();
125        if now - ts > TOLERANCE_IN_SECONDS {
126            Err(WebhookError::TimestampTooOldError)
127        } else if ts > now + TOLERANCE_IN_SECONDS {
128            Err(WebhookError::FutureTimestampError)
129        } else {
130            Ok(())
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137
138    use super::*;
139    use http::HeaderMap;
140
141    fn get_unbranded_headers(msg_id: &str, signature: &str) -> HeaderMap {
142        let mut headers = http::header::HeaderMap::new();
143        headers.insert(HEADER_WEBHOOK_ID, msg_id.parse().unwrap());
144        headers.insert(HEADER_WEBHOOK_SIGNATURE, signature.parse().unwrap());
145        headers.insert(
146            HEADER_WEBHOOK_TIMESTAMP,
147            OffsetDateTime::now_utc()
148                .unix_timestamp()
149                .to_string()
150                .parse()
151                .unwrap(),
152        );
153        headers
154    }
155
156    #[test]
157    fn test_sign() {
158        let wh = Webhook::new("whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD").unwrap();
159        assert_eq!(
160            "v1,tZ1I4/hDygAJgO5TYxiSd6Sd0kDW6hPenDe+bTa3Kkw=".to_owned(),
161            wh.sign(
162                "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk",
163                1649367553,
164                br#"{"email":"test@example.com","username":"test_user"}"#
165            )
166            .unwrap()
167        );
168    }
169
170    #[test]
171    fn test_verify() {
172        let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
173        let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
174        let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
175        let wh = Webhook::new(&secret).unwrap();
176
177        let signature = wh
178            .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
179            .unwrap();
180        wh.verify(payload, &get_unbranded_headers(msg_id, &signature))
181            .unwrap();
182    }
183
184    #[test]
185    fn test_no_verify() {
186        let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
187        let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
188        let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
189        let wh = Webhook::new(&secret).unwrap();
190
191        let signature = "v1,R3PTzyfHASBKHH98a7yexTwaJ4yNIcGhFQc1yuN+BPU=".to_owned();
192        let headers = get_unbranded_headers(msg_id, &signature);
193        assert!(wh.verify(payload, &headers).is_err());
194    }
195
196    #[test]
197    fn test_verify_partial_signature() {
198        let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
199        let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
200        let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
201        let wh = Webhook::new(&secret).unwrap();
202
203        let signature = wh
204            .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
205            .unwrap();
206
207        // Just `v1,`
208        let mut headers = get_unbranded_headers(msg_id, &signature);
209        let partial = format!(
210            "{},",
211            signature.split(',').collect::<Vec<&str>>().first().unwrap()
212        );
213        headers.insert(HEADER_WEBHOOK_SIGNATURE, partial.parse().unwrap());
214        assert!(wh.verify(payload, &headers).is_err());
215
216        // Non-empty but still partial signature (first few bytes)
217        let mut headers = get_unbranded_headers(msg_id, &signature);
218        let partial = &signature[0..8];
219        headers.insert(HEADER_WEBHOOK_SIGNATURE, partial.parse().unwrap());
220        assert!(wh.verify(payload, &headers).is_err());
221    }
222
223    #[test]
224    fn test_verify_incorrect_timestamp() {
225        let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
226        let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
227        let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
228        let wh = Webhook::new(&secret).unwrap();
229
230        let signature = wh
231            .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
232            .unwrap();
233
234        let mut headers = get_unbranded_headers(msg_id, &signature);
235        for ts in [
236            OffsetDateTime::now_utc().unix_timestamp() - (super::TOLERANCE_IN_SECONDS + 1),
237            OffsetDateTime::now_utc().unix_timestamp() + (super::TOLERANCE_IN_SECONDS + 1),
238        ] {
239            headers.insert(
240                super::HEADER_WEBHOOK_TIMESTAMP,
241                ts.to_string().parse().unwrap(),
242            );
243
244            assert!(wh.verify(payload, &headers,).is_err());
245        }
246    }
247
248    #[test]
249    fn test_verify_with_multiple_signatures() {
250        let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
251        let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
252        let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
253        let wh = Webhook::new(&secret).unwrap();
254
255        let signature = wh
256            .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
257            .unwrap();
258
259        let multi_sig = format!(
260            "{} {} {} {}",
261            "v1,tFtCZ5RDCPxzWQRWXWPgrCgE2frDBe9gjpbWQxnVfsQ=",
262            "v1,Mm7xgUVICxZfQ3bgf0h0Dof65L/IFx+PnZvnDWPCX6Q=",
263            signature,
264            "v1,9DfC1c3eeOrXB6w/5dIDydLNQaEyww5KalE5jLBZucE=",
265        );
266
267        let headers = get_unbranded_headers(msg_id, &multi_sig);
268
269        wh.verify(payload, &headers).unwrap();
270    }
271
272    #[test]
273    fn test_no_verify_with_multiple_signatures() {
274        let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
275        let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
276        let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
277        let wh = Webhook::new(&secret).unwrap();
278
279        let missing_sig = format!(
280            "{} {} {}",
281            "v1,tFtCZ5RDCPxzWQRWXWPgrCgE2frDBe9gjpbWQxnVfsQ=",
282            "v1,Mm7xgUVICxZfQ3bgf0h0Dof65L/IFx+PnZvnDWPCX6Q=",
283            "v1,9DfC1c3eeOrXB6w/5dIDydLNQaEyww5KalE5jLBZucE=",
284        );
285
286        let headers = get_unbranded_headers(msg_id, &missing_sig);
287
288        assert!(wh.verify(payload, &headers).is_err());
289    }
290
291    #[test]
292    fn test_missing_headers() {
293        let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
294        let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
295        let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
296        let wh = Webhook::new(&secret).unwrap();
297
298        let signature = wh
299            .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
300            .unwrap();
301        for (mut hdr_map, hdrs) in [(
302            get_unbranded_headers(msg_id, &signature),
303            [
304                HEADER_WEBHOOK_ID,
305                HEADER_WEBHOOK_SIGNATURE,
306                HEADER_WEBHOOK_TIMESTAMP,
307            ],
308        )] {
309            for hdr in hdrs {
310                hdr_map.remove(hdr);
311                assert!(wh.verify(payload, &hdr_map).is_err());
312            }
313        }
314    }
315}