1use std::time::Duration;
2
3use base64::Engine;
4use base64::engine::general_purpose::STANDARD as BASE64;
5use hmac::{Hmac, KeyInit, Mac};
6use sha2::Sha256;
7use subtle::ConstantTimeEq;
8
9use super::secret::WebhookSecret;
10use crate::error::{Error, Result};
11
12type HmacSha256 = Hmac<Sha256>;
13
14pub fn sign(secret: &WebhookSecret, content: &[u8]) -> String {
16 let mut mac =
17 HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
18 mac.update(content);
19 BASE64.encode(mac.finalize().into_bytes())
20}
21
22pub fn verify(secret: &WebhookSecret, content: &[u8], signature: &str) -> bool {
27 let sig_bytes = match BASE64.decode(signature) {
28 Ok(b) => b,
29 Err(_) => return false,
30 };
31 let mut mac =
32 HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
33 mac.update(content);
34 let expected = mac.finalize().into_bytes();
35 expected.ct_eq(&sig_bytes).into()
36}
37
38pub struct SignedHeaders {
40 pub webhook_id: String,
42 pub webhook_timestamp: i64,
44 pub webhook_signature: String,
50}
51
52pub fn sign_headers(
65 secrets: &[&WebhookSecret],
66 id: &str,
67 timestamp: i64,
68 body: &[u8],
69) -> SignedHeaders {
70 assert!(!secrets.is_empty(), "at least one secret required");
71
72 let content = build_signed_content(id, timestamp, body);
73 let sigs: Vec<String> = secrets
74 .iter()
75 .map(|s| format!("v1,{}", sign(s, &content)))
76 .collect();
77
78 SignedHeaders {
79 webhook_id: id.to_string(),
80 webhook_timestamp: timestamp,
81 webhook_signature: sigs.join(" "),
82 }
83}
84
85pub fn verify_headers(
101 secrets: &[&WebhookSecret],
102 headers: &http::HeaderMap,
103 body: &[u8],
104 tolerance: Duration,
105) -> Result<()> {
106 let id = header_str(headers, "webhook-id")?;
107 let ts_str = header_str(headers, "webhook-timestamp")?;
108 let sig_header = header_str(headers, "webhook-signature")?;
109
110 let timestamp: i64 = ts_str
111 .parse()
112 .map_err(|_| Error::bad_request("invalid webhook-timestamp"))?;
113
114 let now = chrono::Utc::now().timestamp();
116 let diff = (now - timestamp).unsigned_abs();
117 if diff > tolerance.as_secs() {
118 return Err(Error::bad_request("webhook timestamp outside tolerance"));
119 }
120
121 let content = build_signed_content(id, timestamp, body);
122
123 for sig_entry in sig_header.split(' ') {
125 let raw_sig = match sig_entry.strip_prefix("v1,") {
126 Some(s) => s,
127 None => continue, };
129 for secret in secrets {
130 if verify(secret, &content, raw_sig) {
131 return Ok(());
132 }
133 }
134 }
135
136 Err(Error::bad_request("no valid webhook signature found"))
137}
138
139fn build_signed_content(id: &str, timestamp: i64, body: &[u8]) -> Vec<u8> {
140 let prefix = format!("{id}.{timestamp}.");
141 let mut content = Vec::with_capacity(prefix.len() + body.len());
142 content.extend_from_slice(prefix.as_bytes());
143 content.extend_from_slice(body);
144 content
145}
146
147fn header_str<'a>(headers: &'a http::HeaderMap, name: &str) -> Result<&'a str> {
148 headers
149 .get(name)
150 .ok_or_else(|| Error::bad_request(format!("missing {name} header")))?
151 .to_str()
152 .map_err(|_| Error::bad_request(format!("invalid {name} header encoding")))
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn sign_produces_base64() {
161 let secret = WebhookSecret::new(b"test-key".to_vec());
162 let sig = sign(&secret, b"hello");
163 assert!(BASE64.decode(&sig).is_ok());
165 }
166
167 #[test]
168 fn verify_valid_signature() {
169 let secret = WebhookSecret::new(b"test-key".to_vec());
170 let sig = sign(&secret, b"hello");
171 assert!(verify(&secret, b"hello", &sig));
172 }
173
174 #[test]
175 fn verify_wrong_secret_fails() {
176 let secret1 = WebhookSecret::new(b"key-one".to_vec());
177 let secret2 = WebhookSecret::new(b"key-two".to_vec());
178 let sig = sign(&secret1, b"hello");
179 assert!(!verify(&secret2, b"hello", &sig));
180 }
181
182 #[test]
183 fn verify_tampered_content_fails() {
184 let secret = WebhookSecret::new(b"test-key".to_vec());
185 let sig = sign(&secret, b"hello");
186 assert!(!verify(&secret, b"tampered", &sig));
187 }
188
189 #[test]
190 fn verify_invalid_base64_returns_false() {
191 let secret = WebhookSecret::new(b"test-key".to_vec());
192 assert!(!verify(&secret, b"hello", "!!!not-base64!!!"));
193 }
194
195 #[test]
196 fn sign_empty_content() {
197 let secret = WebhookSecret::new(b"test-key".to_vec());
198 let sig = sign(&secret, b"");
199 assert!(verify(&secret, b"", &sig));
200 }
201
202 #[test]
203 fn known_test_vector() {
204 let secret = WebhookSecret::new(b"test-secret".to_vec());
206 let sig = sign(&secret, b"test-content");
207 assert!(verify(&secret, b"test-content", &sig));
209 assert!(!verify(&secret, b"other-content", &sig));
211 }
212
213 use std::time::Duration;
214
215 fn make_headers(id: &str, ts: i64, sig: &str) -> http::HeaderMap {
216 let mut headers = http::HeaderMap::new();
217 headers.insert("webhook-id", id.parse().unwrap());
218 headers.insert("webhook-timestamp", ts.to_string().parse().unwrap());
219 headers.insert("webhook-signature", sig.parse().unwrap());
220 headers
221 }
222
223 #[test]
224 fn sign_headers_single_secret() {
225 let secret = WebhookSecret::new(b"key".to_vec());
226 let sh = sign_headers(&[&secret], "msg_123", 1000, b"body");
227 assert_eq!(sh.webhook_id, "msg_123");
228 assert_eq!(sh.webhook_timestamp, 1000);
229 assert!(sh.webhook_signature.starts_with("v1,"));
230 assert!(!sh.webhook_signature.contains(' '));
231 }
232
233 #[test]
234 fn sign_headers_multiple_secrets() {
235 let s1 = WebhookSecret::new(b"key1".to_vec());
236 let s2 = WebhookSecret::new(b"key2".to_vec());
237 let sh = sign_headers(&[&s1, &s2], "msg_123", 1000, b"body");
238 let parts: Vec<&str> = sh.webhook_signature.split(' ').collect();
239 assert_eq!(parts.len(), 2);
240 assert!(parts[0].starts_with("v1,"));
241 assert!(parts[1].starts_with("v1,"));
242 assert_ne!(parts[0], parts[1]);
243 }
244
245 #[test]
246 #[should_panic(expected = "at least one secret")]
247 fn sign_headers_empty_secrets_panics() {
248 sign_headers(&[], "msg_123", 1000, b"body");
249 }
250
251 #[test]
252 fn verify_headers_valid() {
253 let secret = WebhookSecret::new(b"key".to_vec());
254 let now = chrono::Utc::now().timestamp();
255 let sh = sign_headers(&[&secret], "msg_1", now, b"payload");
256 let headers = make_headers(&sh.webhook_id, sh.webhook_timestamp, &sh.webhook_signature);
257 let result = verify_headers(&[&secret], &headers, b"payload", Duration::from_secs(300));
258 assert!(result.is_ok());
259 }
260
261 #[test]
262 fn verify_headers_wrong_secret_fails() {
263 let sign_secret = WebhookSecret::new(b"sign-key".to_vec());
264 let verify_secret = WebhookSecret::new(b"wrong-key".to_vec());
265 let now = chrono::Utc::now().timestamp();
266 let sh = sign_headers(&[&sign_secret], "msg_1", now, b"data");
267 let headers = make_headers(&sh.webhook_id, sh.webhook_timestamp, &sh.webhook_signature);
268 let result = verify_headers(
269 &[&verify_secret],
270 &headers,
271 b"data",
272 Duration::from_secs(300),
273 );
274 assert!(result.is_err());
275 }
276
277 #[test]
278 fn verify_headers_expired_timestamp() {
279 let secret = WebhookSecret::new(b"key".to_vec());
280 let old_ts = chrono::Utc::now().timestamp() - 600; let sh = sign_headers(&[&secret], "msg_1", old_ts, b"data");
282 let headers = make_headers(&sh.webhook_id, sh.webhook_timestamp, &sh.webhook_signature);
283 let result = verify_headers(&[&secret], &headers, b"data", Duration::from_secs(300));
284 assert!(result.is_err());
285 assert!(result.err().unwrap().message().contains("tolerance"));
286 }
287
288 #[test]
289 fn verify_headers_future_timestamp() {
290 let secret = WebhookSecret::new(b"key".to_vec());
291 let future_ts = chrono::Utc::now().timestamp() + 600; let sh = sign_headers(&[&secret], "msg_1", future_ts, b"data");
293 let headers = make_headers(&sh.webhook_id, sh.webhook_timestamp, &sh.webhook_signature);
294 let result = verify_headers(&[&secret], &headers, b"data", Duration::from_secs(300));
295 assert!(result.is_err());
296 }
297
298 #[test]
299 fn verify_headers_missing_header() {
300 let secret = WebhookSecret::new(b"key".to_vec());
301 let headers = http::HeaderMap::new(); let result = verify_headers(&[&secret], &headers, b"data", Duration::from_secs(300));
303 assert!(result.is_err());
304 assert!(result.err().unwrap().message().contains("missing"));
305 }
306
307 #[test]
308 fn verify_headers_multi_signature_rotation() {
309 let old_secret = WebhookSecret::new(b"old-key".to_vec());
310 let new_secret = WebhookSecret::new(b"new-key".to_vec());
311 let now = chrono::Utc::now().timestamp();
312 let sh = sign_headers(&[&old_secret, &new_secret], "msg_1", now, b"data");
314 let headers = make_headers(&sh.webhook_id, sh.webhook_timestamp, &sh.webhook_signature);
315 let result = verify_headers(&[&new_secret], &headers, b"data", Duration::from_secs(300));
317 assert!(result.is_ok());
318 }
319
320 #[test]
321 fn verify_headers_multi_secret_on_verify_side() {
322 let secret = WebhookSecret::new(b"the-key".to_vec());
323 let wrong_secret = WebhookSecret::new(b"wrong-key".to_vec());
324 let now = chrono::Utc::now().timestamp();
325 let sh = sign_headers(&[&secret], "msg_1", now, b"data");
327 let headers = make_headers(&sh.webhook_id, sh.webhook_timestamp, &sh.webhook_signature);
328 let result = verify_headers(
330 &[&wrong_secret, &secret],
331 &headers,
332 b"data",
333 Duration::from_secs(300),
334 );
335 assert!(result.is_ok());
336 }
337}