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