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/// The signed content is `<id>.<timestamp>.<body>` (concatenated bytes), per the
55/// Standard Webhooks specification. Each secret produces one `v1,<base64>` entry;
56/// multiple entries are joined with a space, which supports key rotation on both
57/// sender and receiver sides.
58///
59/// # Panics
60///
61/// Panics if `secrets` is empty. [`WebhookSender::send`] validates this before calling.
62///
63/// [`WebhookSender::send`]: super::sender::WebhookSender::send
64pub 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
85/// Parse Standard Webhooks headers from an incoming request and verify the signature.
86///
87/// Reads `webhook-id`, `webhook-timestamp`, and `webhook-signature` from `headers`.
88/// Validates that the timestamp is within `tolerance` of now (replay-attack protection),
89/// then tries every `v1,` signature entry against every secret in `secrets`.
90/// Returns `Ok(())` as soon as one combination matches; returns an error if none does.
91///
92/// # Errors
93///
94/// Returns [`Error`](crate::Error) when:
95/// - Any of the three required headers (`webhook-id`, `webhook-timestamp`,
96///   `webhook-signature`) is missing or not valid UTF-8 (400 Bad Request)
97/// - `webhook-timestamp` is not a valid integer (400 Bad Request)
98/// - The timestamp is outside the `tolerance` window (400 Bad Request)
99/// - No signature entry matches any provided secret (400 Bad Request)
100pub 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    // Check timestamp tolerance
115    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    // Try each v1 signature against each secret
124    for sig_entry in sig_header.split(' ') {
125        let raw_sig = match sig_entry.strip_prefix("v1,") {
126            Some(s) => s,
127            None => continue, // skip non-v1 signatures
128        };
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        // Should be valid base64
164        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        // Precomputed: HMAC-SHA256("test-secret", "test-content") as base64
205        let secret = WebhookSecret::new(b"test-secret".to_vec());
206        let sig = sign(&secret, b"test-content");
207        // Verify round-trip; the exact value is deterministic
208        assert!(verify(&secret, b"test-content", &sig));
209        // Different content must fail
210        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; // 10 minutes ago
281        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; // 10 minutes ahead
292        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(); // empty
302        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        // Sign with both secrets (key rotation)
313        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        // Verify with only the new secret — should still pass (one signature matches)
316        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        // Sign with one secret
326        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        // Verify with both (wrong + correct) — should pass because one matches
329        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}