Skip to main content

modo/webhook/
signature.rs

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
14/// Compute HMAC-SHA256 of `content` using `secret`, returned as standard base64.
15pub 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
22/// Verify a base64-encoded HMAC-SHA256 signature against `content` using `secret`.
23///
24/// Uses constant-time comparison to prevent timing attacks. Returns `false` if
25/// `signature` is not valid base64 or does not match.
26pub 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
38/// The three Standard Webhooks headers produced by [`sign_headers`].
39pub struct SignedHeaders {
40    /// Value for the `webhook-id` header.
41    pub webhook_id: String,
42    /// Value for the `webhook-timestamp` header (Unix seconds).
43    pub webhook_timestamp: i64,
44    /// Value for the `webhook-signature` header.
45    ///
46    /// Contains one `v1,<base64>` entry per secret, joined by spaces.
47    /// Multiple entries support key rotation — a receiver accepts the message
48    /// if any entry matches.
49    pub webhook_signature: String,
50}
51
52/// Build Standard Webhooks signed content and sign it with every secret in `secrets`.
53///
54/// Each secret produces one `v1,<base64>` entry; multiple entries are joined with
55/// a space, which supports key rotation on both sender and receiver sides.
56///
57/// # Panics
58///
59/// Panics if `secrets` is empty. [`WebhookSender::send`] validates this before calling.
60///
61/// [`WebhookSender::send`]: super::sender::WebhookSender::send
62pub 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
83/// Parse Standard Webhooks headers from an incoming request and verify the signature.
84///
85/// Reads `webhook-id`, `webhook-timestamp`, and `webhook-signature` from `headers`.
86/// Validates that the timestamp is within `tolerance` of now (replay-attack protection),
87/// then tries every `v1,` signature entry against every secret in `secrets`.
88/// Returns `Ok(())` as soon as one combination matches; returns an error if none does.
89///
90/// # Errors
91///
92/// Returns [`Error`](crate::Error) when:
93/// - Any of the three required headers (`webhook-id`, `webhook-timestamp`,
94///   `webhook-signature`) is missing or not valid UTF-8 (400 Bad Request)
95/// - `webhook-timestamp` is not a valid integer (400 Bad Request)
96/// - The timestamp is outside the `tolerance` window (400 Bad Request)
97/// - No signature entry matches any provided secret (400 Bad Request)
98pub 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    // Check timestamp tolerance
113    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    // Try each v1 signature against each secret
122    for sig_entry in sig_header.split(' ') {
123        let raw_sig = match sig_entry.strip_prefix("v1,") {
124            Some(s) => s,
125            None => continue, // skip non-v1 signatures
126        };
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        // Should be valid base64
162        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        // Precomputed: HMAC-SHA256("test-secret", "test-content") as base64
203        let secret = WebhookSecret::new(b"test-secret".to_vec());
204        let sig = sign(&secret, b"test-content");
205        // Verify round-trip; the exact value is deterministic
206        assert!(verify(&secret, b"test-content", &sig));
207        // Different content must fail
208        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; // 10 minutes ago
279        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; // 10 minutes ahead
290        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(); // empty
300        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        // Sign with both secrets (key rotation)
311        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        // Verify with only the new secret — should still pass (one signature matches)
314        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        // Sign with one secret
324        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        // Verify with both (wrong + correct) — should pass because one matches
327        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}