1use base64::Engine;
26use base64::engine::general_purpose::STANDARD as BASE64;
27
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
31pub enum SignatureError {
32 #[error("invalid webhook secret")]
34 InvalidSecret,
35 #[error("invalid or missing signature header")]
37 InvalidHeader,
38 #[error("webhook signature mismatch")]
40 Mismatch,
41 #[error("webhook timestamp outside tolerance")]
43 StaleTimestamp,
44}
45
46pub const DEFAULT_WEBHOOK_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(300);
48
49pub fn verify_webhook_signature(
60 secret: &str,
61 svix_id: &str,
62 svix_timestamp: &str,
63 svix_signature: &str,
64 body: &[u8],
65) -> Result<(), SignatureError> {
66 let key_bytes = decode_secret(secret)?;
67 let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, &key_bytes);
68
69 let mut signed = Vec::with_capacity(svix_id.len() + svix_timestamp.len() + body.len() + 2);
71 signed.extend_from_slice(svix_id.as_bytes());
72 signed.push(b'.');
73 signed.extend_from_slice(svix_timestamp.as_bytes());
74 signed.push(b'.');
75 signed.extend_from_slice(body);
76
77 let mut saw_candidate = false;
78 for candidate in svix_signature.split(' ') {
79 let Some((version, sig_b64)) = candidate.split_once(',') else {
80 continue;
81 };
82 if version != "v1" {
83 continue;
84 }
85 saw_candidate = true;
86 let Ok(sig) = BASE64.decode(sig_b64) else {
87 continue;
88 };
89 if ring::hmac::verify(&key, &signed, &sig).is_ok() {
91 return Ok(());
92 }
93 }
94
95 if saw_candidate {
96 Err(SignatureError::Mismatch)
97 } else {
98 Err(SignatureError::InvalidHeader)
99 }
100}
101
102pub fn verify_webhook_timestamp(
106 svix_timestamp: &str,
107 tolerance: std::time::Duration,
108) -> Result<(), SignatureError> {
109 let ts: i64 = svix_timestamp
110 .parse()
111 .map_err(|_| SignatureError::StaleTimestamp)?;
112 let now = std::time::SystemTime::now()
113 .duration_since(std::time::UNIX_EPOCH)
114 .map_err(|_| SignatureError::StaleTimestamp)?
115 .as_secs() as i64;
116 if (now - ts).unsigned_abs() <= tolerance.as_secs() {
117 Ok(())
118 } else {
119 Err(SignatureError::StaleTimestamp)
120 }
121}
122
123fn decode_secret(secret: &str) -> Result<Vec<u8>, SignatureError> {
125 if secret.is_empty() {
126 return Err(SignatureError::InvalidSecret);
127 }
128 let b64 = secret.strip_prefix("whsec_").unwrap_or(secret);
129 BASE64
130 .decode(b64)
131 .map_err(|_| SignatureError::InvalidSecret)
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 const SECRET_B64: &str = "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
142 const ID: &str = "msg_p5jXN8AQM9LWM0D4loKWxJek";
143 const TS: &str = "1614265330";
144 const BODY: &[u8] = b"{\"test\": 2432232314}";
145 const SIG: &str = "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=";
146
147 fn secret() -> String {
148 format!("whsec_{SECRET_B64}")
149 }
150
151 #[test]
152 fn known_vector_verifies() {
153 assert!(verify_webhook_signature(&secret(), ID, TS, SIG, BODY).is_ok());
154 }
155
156 #[test]
157 fn accepts_when_one_of_several_candidates_matches() {
158 let multi = format!("v1,aaaa v2,bbbb {SIG}");
159 assert!(verify_webhook_signature(&secret(), ID, TS, &multi, BODY).is_ok());
160 }
161
162 #[test]
163 fn tampered_body_is_rejected() {
164 let bad = verify_webhook_signature(&secret(), ID, TS, SIG, b"{\"test\": 9999}");
165 assert_eq!(bad, Err(SignatureError::Mismatch));
166 }
167
168 #[test]
169 fn empty_or_malformed_secret_is_rejected() {
170 assert_eq!(
171 verify_webhook_signature("", ID, TS, SIG, BODY),
172 Err(SignatureError::InvalidSecret),
173 );
174 assert_eq!(
175 verify_webhook_signature(&format!("whsec_{}", "!!!not base64!!!"), ID, TS, SIG, BODY),
176 Err(SignatureError::InvalidSecret),
177 );
178 }
179
180 #[test]
181 fn no_v1_candidate_is_invalid_header() {
182 assert_eq!(
183 verify_webhook_signature(&secret(), ID, TS, "v2,whatever", BODY),
184 Err(SignatureError::InvalidHeader),
185 );
186 }
187
188 #[test]
189 fn timestamp_tolerance() {
190 assert_eq!(
192 verify_webhook_timestamp(TS, DEFAULT_WEBHOOK_TOLERANCE),
193 Err(SignatureError::StaleTimestamp),
194 );
195 assert_eq!(
197 verify_webhook_timestamp("not-a-number", DEFAULT_WEBHOOK_TOLERANCE),
198 Err(SignatureError::StaleTimestamp),
199 );
200 }
201}