1#![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 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 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}