1use std::borrow::Cow;
35use std::sync::Arc;
36
37use base64::Engine;
38use base64::engine::general_purpose::STANDARD;
39use hmac::{Hmac, KeyInit, Mac};
40use http::HeaderMap;
41use sha1::Sha1;
42use sha2::Sha256;
43use subtle::ConstantTimeEq;
44
45type HmacSha256 = Hmac<Sha256>;
46type HmacSha1 = Hmac<Sha1>;
47
48pub const DEFAULT_TOLERANCE_SECS: i64 = 300;
52
53#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct SignedDelivery {
58 pub signed_payload: Vec<u8>,
62 pub candidates: Vec<Vec<u8>>,
64 pub timestamp: Option<i64>,
68}
69
70pub trait SignatureScheme: Send + Sync {
75 fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery>;
79
80 fn secret_key<'a>(&self, secret: &'a str) -> Option<Cow<'a, [u8]>> {
84 let trimmed = secret.trim();
85 (!trimmed.is_empty()).then_some(Cow::Borrowed(trimmed.as_bytes()))
86 }
87
88 fn digest(&self) -> Digest {
92 Digest::Sha256
93 }
94
95 fn expected_signature(&self, key: &[u8], signed_payload: &[u8]) -> Vec<u8> {
104 hmac_bytes(self.digest(), key, signed_payload)
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
115pub enum Digest {
116 #[default]
118 Sha256,
119 Sha1,
121}
122
123fn hmac_bytes(digest: Digest, key: &[u8], payload: &[u8]) -> Vec<u8> {
126 match digest {
127 Digest::Sha256 => {
128 let mut mac =
129 <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
130 mac.update(payload);
131 mac.finalize().into_bytes().to_vec()
132 }
133 Digest::Sha1 => {
134 let mut mac =
135 <HmacSha1 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
136 mac.update(payload);
137 mac.finalize().into_bytes().to_vec()
138 }
139 }
140}
141
142#[derive(Clone)]
148pub struct WebhookVerifier {
149 scheme: Arc<dyn SignatureScheme>,
150 tolerance_secs: i64,
151}
152
153impl WebhookVerifier {
154 #[must_use]
166 pub fn new(scheme: impl SignatureScheme + 'static) -> Self {
167 Self {
168 scheme: Arc::new(scheme),
169 tolerance_secs: DEFAULT_TOLERANCE_SECS,
170 }
171 }
172
173 #[must_use]
175 pub const fn tolerance_secs(mut self, secs: i64) -> Self {
176 self.tolerance_secs = secs;
177 self
178 }
179
180 #[must_use]
189 pub fn verify(&self, secret: &str, headers: &HeaderMap, body: &[u8], now_unix: i64) -> bool {
190 if secret.trim().is_empty() {
191 return false; }
193 let Some(delivery) = self.scheme.extract(headers, body) else {
194 return false;
195 };
196 if let Some(sent) = delivery.timestamp
197 && now_unix
198 .saturating_sub(sent)
199 .checked_abs()
200 .is_none_or(|delta| delta > self.tolerance_secs)
201 {
202 return false; }
204 let Some(key) = self.scheme.secret_key(secret) else {
205 return false;
206 };
207 let expected = self
212 .scheme
213 .expected_signature(&key, &delivery.signed_payload);
214 let mut matched = false;
218 for candidate in &delivery.candidates {
219 matched |= bool::from(candidate.as_slice().ct_eq(expected.as_slice()));
220 }
221 matched
222 }
223}
224
225impl std::fmt::Debug for WebhookVerifier {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 f.debug_struct("WebhookVerifier")
228 .field("tolerance_secs", &self.tolerance_secs)
229 .finish_non_exhaustive()
230 }
231}
232
233#[derive(Debug, Clone, Copy, Default)]
239pub struct Svix;
240
241const SVIX_HEADERS: [(&str, &str, &str); 2] = [
243 ("svix-id", "svix-timestamp", "svix-signature"),
244 ("webhook-id", "webhook-timestamp", "webhook-signature"),
245];
246
247impl SignatureScheme for Svix {
248 fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
249 let (id, timestamp, signatures) = SVIX_HEADERS.iter().find_map(|(id, at, sig)| {
250 Some((
251 header(headers, id)?,
252 header(headers, at)?,
253 header(headers, sig)?,
254 ))
255 })?;
256 let sent = timestamp.trim().parse::<i64>().ok()?;
257 let mut candidates = Vec::new();
258 for entry in signatures.split_whitespace() {
259 let Some((version, value)) = entry.split_once(',') else {
260 continue;
261 };
262 if version == "v1"
265 && let Ok(bytes) = STANDARD.decode(value)
266 {
267 candidates.push(bytes);
268 }
269 }
270 if candidates.is_empty() {
271 return None;
272 }
273 Some(SignedDelivery {
274 signed_payload: [id.as_bytes(), b".", timestamp.as_bytes(), b".", body].concat(),
275 candidates,
276 timestamp: Some(sent),
277 })
278 }
279
280 fn secret_key<'a>(&self, secret: &'a str) -> Option<Cow<'a, [u8]>> {
281 svix_secret_key(secret).map(Cow::Owned)
282 }
283}
284
285#[must_use]
288pub fn svix_secret_key(secret: &str) -> Option<Vec<u8>> {
289 let trimmed = secret.trim();
290 let encoded = trimmed.strip_prefix("whsec_").unwrap_or(trimmed);
291 STANDARD.decode(encoded).ok().filter(|key| !key.is_empty())
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub struct StripeStyle {
300 pub header: &'static str,
302}
303
304impl Default for StripeStyle {
305 fn default() -> Self {
306 Self {
307 header: "Stripe-Signature",
308 }
309 }
310}
311
312impl SignatureScheme for StripeStyle {
313 fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
314 let raw = header(headers, self.header)?;
315 let mut timestamp = None;
316 let mut candidates = Vec::new();
317 for part in raw.split(',') {
318 let Some((key, value)) = part.split_once('=') else {
319 continue;
320 };
321 match key.trim() {
322 "t" => timestamp = value.trim().parse::<i64>().ok(),
323 "v1" => {
324 if let Some(bytes) = hex_decode(value.trim()) {
325 candidates.push(bytes);
326 }
327 }
328 _ => {} }
330 }
331 let sent = timestamp?;
332 if candidates.is_empty() {
333 return None;
334 }
335 Some(SignedDelivery {
336 signed_payload: [sent.to_string().as_bytes(), b".", body].concat(),
337 candidates,
338 timestamp: Some(sent),
339 })
340 }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub struct ProviderScheme {
348 pub signature: &'static str,
350 pub encoding: SignatureEncoding,
352 pub prefix: Option<&'static str>,
354 pub timestamp: Option<&'static str>,
358}
359
360impl SignatureScheme for ProviderScheme {
361 fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
362 let raw = header(headers, self.signature)?;
363 let timestamp = match self.timestamp {
366 Some(name) => Some(header(headers, name)?.trim().parse::<i64>().ok()?),
367 None => None,
368 };
369 let mut candidates = Vec::new();
370 for entry in raw.split([',', ' ']) {
371 let Some(entry) = entry.strip_prefix(self.prefix.unwrap_or("")) else {
372 continue;
373 };
374 let decoded = match self.encoding {
375 SignatureEncoding::Hex => hex_decode(entry),
376 SignatureEncoding::Base64 => STANDARD.decode(entry).ok(),
377 };
378 if let Some(bytes) = decoded {
379 candidates.push(bytes);
380 }
381 }
382 if candidates.is_empty() {
383 return None;
384 }
385 Some(SignedDelivery {
386 signed_payload: match timestamp {
387 Some(sent) => [sent.to_string().as_bytes(), b".", body].concat(),
388 None => body.to_vec(),
389 },
390 candidates,
391 timestamp,
392 })
393 }
394}
395
396#[derive(Debug, Clone, Copy, Default)]
408pub struct Github;
409
410const GITHUB_SCHEME: ProviderScheme = ProviderScheme {
413 signature: "X-Hub-Signature-256",
414 encoding: SignatureEncoding::Hex,
415 prefix: Some("sha256="),
416 timestamp: None,
417};
418
419impl SignatureScheme for Github {
420 fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
421 GITHUB_SCHEME.extract(headers, body)
422 }
423}
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub enum SignatureEncoding {
428 Hex,
430 Base64,
432}
433
434const VERCEL_SIGNATURE_HEADER: &str = "x-vercel-signature";
436const SHA1_TAG_LEN: usize = 20;
438
439#[derive(Debug, Clone, Copy, Default)]
445pub struct Vercel;
446
447impl SignatureScheme for Vercel {
448 fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
449 let raw = header(headers, VERCEL_SIGNATURE_HEADER)?;
450 let candidate = hex_decode(raw.trim())?;
451 if candidate.len() != SHA1_TAG_LEN {
454 return None;
455 }
456 Some(SignedDelivery {
457 signed_payload: body.to_vec(),
458 candidates: vec![candidate],
459 timestamp: None,
460 })
461 }
462
463 fn digest(&self) -> Digest {
464 Digest::Sha1
465 }
466}
467
468const GITLAB_TOKEN_HEADER: &str = "X-Gitlab-Token";
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479pub struct SharedTokenScheme {
480 pub header: &'static str,
482}
483
484impl SignatureScheme for SharedTokenScheme {
485 fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
486 let _ = body;
489 let token = header(headers, self.header)?;
490 if token.is_empty() {
491 return None; }
493 Some(SignedDelivery {
494 signed_payload: Vec::new(),
495 candidates: vec![token.as_bytes().to_vec()],
496 timestamp: None,
497 })
498 }
499
500 fn expected_signature(&self, key: &[u8], signed_payload: &[u8]) -> Vec<u8> {
501 let _ = signed_payload;
502 key.to_vec()
503 }
504}
505
506#[derive(Debug, Clone, Copy, Default)]
509pub struct Gitlab;
510
511const GITLAB_TOKEN: SharedTokenScheme = SharedTokenScheme {
513 header: GITLAB_TOKEN_HEADER,
514};
515
516impl SignatureScheme for Gitlab {
517 fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
518 GITLAB_TOKEN.extract(headers, body)
519 }
520
521 fn expected_signature(&self, key: &[u8], signed_payload: &[u8]) -> Vec<u8> {
522 GITLAB_TOKEN.expected_signature(key, signed_payload)
523 }
524}
525
526fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
529 headers.get(name)?.to_str().ok()
530}
531
532fn hex_decode(value: &str) -> Option<Vec<u8>> {
534 if !value.len().is_multiple_of(2) {
535 return None;
536 }
537 (0..value.len())
538 .step_by(2)
539 .map(|at| u8::from_str_radix(&value[at..at + 2], 16).ok())
540 .collect()
541}
542
543#[cfg(test)]
544mod tests {
545 use super::SignatureEncoding::{Base64, Hex};
546 use super::*;
547
548 const SECRET: &str = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
549 const WRONG: &str = "whsec_AAAAAAAA"; const ID: &str = "msg_p5jXN8AQM9LWM0D4loKWxJek";
551 const NOW: i64 = 1_800_000_000;
552 const STRIPE_SECRET: &str = "stripe-endpoint-secret-dummy";
553 const PROVIDER_SECRET: &str = "provider-endpoint-secret-dummy";
554 const VERCEL_SECRET: &str = "vercel-webhook-secret-dummy";
555 const GITLAB_SECRET: &str = "gitlab-shared-token-dummy";
556 const VERCEL_BODY: &[u8] =
559 br#"{"type":"deployment.succeeded","payload":{"deployment":{"id":"dpl_1"}}}"#;
560
561 fn mac_sha1(key: &[u8], parts: &[&[u8]]) -> Vec<u8> {
563 let mut mac =
564 <HmacSha1 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
565 for part in parts {
566 mac.update(part);
567 }
568 mac.finalize().into_bytes().to_vec()
569 }
570
571 fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
572 pairs
573 .iter()
574 .map(|(name, value)| {
575 let name = name
576 .parse::<http::header::HeaderName>()
577 .expect("test header name");
578 (name, value.parse().expect("test header value"))
579 })
580 .collect()
581 }
582
583 fn hex(bytes: &[u8]) -> String {
584 use std::fmt::Write as _;
585 bytes.iter().fold(String::new(), |mut out, byte| {
586 let _ = write!(out, "{byte:02x}");
587 out
588 })
589 }
590
591 fn mac(key: &[u8], parts: &[&[u8]]) -> Vec<u8> {
592 let mut mac =
593 <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
594 for part in parts {
595 mac.update(part);
596 }
597 mac.finalize().into_bytes().to_vec()
598 }
599
600 fn svix_sign(secret: &str, id: &str, at: i64, body: &[u8]) -> String {
602 let key = svix_secret_key(secret).expect("the fixture secret decodes");
603 let ts = at.to_string();
604 let payload = [id.as_bytes(), b".", ts.as_bytes(), b".", body];
605 format!("v1,{}", STANDARD.encode(mac(&key, &payload)))
606 }
607
608 fn svix_headers(at: &str, sigs: &str) -> HeaderMap {
609 headers(&[
610 ("svix-id", ID),
611 ("svix-timestamp", at),
612 ("svix-signature", sigs),
613 ])
614 }
615
616 fn svix_verifies(secret: &str, at: &str, sigs: &str, body: &[u8], now: i64) -> bool {
618 WebhookVerifier::new(Svix).verify(secret, &svix_headers(at, sigs), body, now)
619 }
620
621 fn stripe_sign(secret: &str, at: i64, body: &[u8]) -> String {
623 let ts = at.to_string();
624 let payload = [ts.as_bytes(), b".", body];
625 let digest = hex(&mac(secret.as_bytes(), &payload));
626 format!("t={at},v1={digest}")
627 }
628
629 fn stripe_verifies(secret: &str, header: &str, body: &[u8], now: i64) -> bool {
631 let got = headers(&[("Stripe-Signature", header)]);
632 WebhookVerifier::new(StripeStyle::default()).verify(secret, &got, body, now)
633 }
634
635 fn provider(
636 enc: SignatureEncoding,
637 prefix: Option<&'static str>,
638 at: Option<&'static str>,
639 ) -> WebhookVerifier {
640 WebhookVerifier::new(ProviderScheme {
641 signature: "X-Signature",
642 encoding: enc,
643 prefix,
644 timestamp: at,
645 })
646 }
647
648 fn provider_sign(at: Option<i64>, body: &[u8]) -> String {
650 let key = PROVIDER_SECRET.as_bytes();
651 let digest = match at {
652 Some(sent) => mac(key, &[sent.to_string().as_bytes(), b".", body]),
653 None => mac(key, &[body]),
654 };
655 hex(&digest)
656 }
657
658 #[test]
659 fn an_svix_delivery_verifies() {
660 let body = br#"{"type":"pos.order.created"}"#;
661 let header = svix_sign(SECRET, ID, NOW, body);
662 assert!(svix_verifies(SECRET, "1800000000", &header, body, NOW));
663 let aliased = headers(&[
665 ("webhook-id", ID),
666 ("webhook-timestamp", "1800000000"),
667 ("webhook-signature", &header),
668 ]);
669 assert!(WebhookVerifier::new(Svix).verify(SECRET, &aliased, body, NOW));
670 let bare = SECRET.strip_prefix("whsec_").expect("prefixed");
671 assert!(svix_verifies(bare, "1800000000", &header, body, NOW));
672 }
673
674 #[test]
675 fn a_tampered_delivery_or_wrong_secret_refuses_but_a_rotation_matches() {
676 let body = b"{}";
677 let header = svix_sign(SECRET, ID, NOW, body);
678 let old = svix_sign(WRONG, ID, NOW, body);
679 let tampered = br#"{"changed":true}"#;
680 assert!(!svix_verifies(SECRET, "1800000000", &header, tampered, NOW));
681 assert!(!svix_verifies(WRONG, "1800000000", &header, body, NOW));
682 let swapped = svix_sign(SECRET, "msg_another", NOW, body);
684 assert!(!svix_verifies(SECRET, "1800000000", &swapped, body, NOW));
685 let both = format!("{old} v0,AAAA {header}");
689 assert!(svix_verifies(SECRET, "1800000000", &both, body, NOW));
690 assert!(!svix_verifies(SECRET, "1800000000", "v0,AAAA", body, NOW));
691 }
692
693 #[test]
694 fn a_delivery_outside_the_tolerance_is_refused() {
695 let body = b"{}";
696 let sent = NOW - DEFAULT_TOLERANCE_SECS - 1;
697 let stale = svix_sign(SECRET, ID, sent, body);
698 assert!(svix_verifies(SECRET, &sent.to_string(), &stale, body, sent));
701 assert!(!svix_verifies(SECRET, &sent.to_string(), &stale, body, NOW));
702 let future = NOW + DEFAULT_TOLERANCE_SECS + 1;
703 let late = svix_sign(SECRET, ID, future, body);
704 assert!(!svix_verifies(
705 SECRET,
706 &future.to_string(),
707 &late,
708 body,
709 NOW
710 ));
711 let wide = WebhookVerifier::new(Svix).tolerance_secs(DEFAULT_TOLERANCE_SECS * 2);
713 assert!(wide.verify(SECRET, &svix_headers(&sent.to_string(), &stale), body, NOW));
714 }
715
716 #[test]
717 fn unreadable_svix_input_refuses() {
718 let body = b"{}";
719 let header = svix_sign(SECRET, ID, NOW, body);
720 assert!(!WebhookVerifier::new(Svix).verify(SECRET, &headers(&[]), body, NOW));
723 assert!(!svix_verifies(SECRET, "recently", &header, body, NOW));
724 assert!(!svix_verifies(SECRET, "1800000000", "garbage", body, NOW));
725 assert!(!svix_verifies(
728 "whsec_!!!!",
729 "1800000000",
730 &header,
731 body,
732 NOW
733 ));
734 assert!(!svix_verifies("", "1800000000", &header, body, NOW));
735 }
736
737 #[test]
738 fn a_stripe_delivery_verifies_and_malformed_ones_refuse() {
739 let body = b"{}";
740 let header = stripe_sign(STRIPE_SECRET, NOW, body);
741 assert!(stripe_verifies(STRIPE_SECRET, &header, body, NOW));
742 let renamed = WebhookVerifier::new(StripeStyle {
744 header: "X-Signature",
745 });
746 let under = headers(&[("X-Signature", &header)]);
747 assert!(renamed.verify(STRIPE_SECRET, &under, body, NOW));
748 assert!(!stripe_verifies(STRIPE_SECRET, &header, b"x", NOW));
750 assert!(!stripe_verifies("another-secret-dummy", &header, body, NOW));
751 let digest = hex(&mac(
754 STRIPE_SECRET.as_bytes(),
755 &[NOW.to_string().as_bytes(), b".", body],
756 ));
757 assert!(!stripe_verifies(
758 STRIPE_SECRET,
759 &format!("v1={digest}"),
760 body,
761 NOW
762 ));
763 assert!(!stripe_verifies(
764 STRIPE_SECRET,
765 &format!("t=recently,v1={digest}"),
766 body,
767 NOW
768 ));
769 assert!(!stripe_verifies(STRIPE_SECRET, "t=1800000000", body, NOW));
770 let sent = NOW - DEFAULT_TOLERANCE_SECS - 1;
772 let stale = stripe_sign(STRIPE_SECRET, sent, body);
773 assert!(!stripe_verifies(STRIPE_SECRET, &stale, body, NOW));
774 }
775
776 #[test]
777 fn a_provider_scheme_verifies_raw_and_timestamped_deliveries() {
778 let body = br#"{"event":"order.created"}"#;
779 let raw = provider(Hex, None, None);
780 let got = headers(&[("X-Signature", &provider_sign(None, body))]);
781 assert!(raw.verify(PROVIDER_SECRET, &got, body, NOW));
784 assert!(raw.verify(PROVIDER_SECRET, &got, body, NOW + 10_000));
785 assert!(!raw.verify(PROVIDER_SECRET, &got, b"x", NOW));
786 assert!(!raw.verify("other", &got, body, NOW));
787 assert!(
788 !raw.verify("", &got, body, NOW),
789 "an empty secret is not a key"
790 );
791 let stamped = provider(Hex, Some("sha256="), Some("X-At"));
795 let header = format!("sha256={}", provider_sign(Some(NOW), body));
796 let with_at = headers(&[("X-Signature", &header), ("X-At", "1800000000")]);
797 assert!(stamped.verify(PROVIDER_SECRET, &with_at, body, NOW));
798 let no_at = headers(&[("X-Signature", &header)]);
799 assert!(!stamped.verify(PROVIDER_SECRET, &no_at, body, NOW));
800 let no_sig = headers(&[("X-At", "1800000000")]);
801 assert!(!stamped.verify(PROVIDER_SECRET, &no_sig, body, NOW));
802 let b64 = provider(Base64, None, None);
805 let encoded = STANDARD.encode(mac(PROVIDER_SECRET.as_bytes(), &[body]));
806 let b64hdr = headers(&[("X-Signature", &encoded)]);
807 assert!(b64.verify(PROVIDER_SECRET, &b64hdr, body, NOW));
808 let junk = headers(&[("X-Signature", "!!!!")]);
809 assert!(!b64.verify(PROVIDER_SECRET, &junk, body, NOW));
810 }
811
812 #[test]
816 fn a_github_delivery_verifies() {
817 const GITHUB_SECRET: &str = "It's a Secret to Everybody";
818 const GITHUB_BODY: &[u8] = b"Hello, World!";
819 const GITHUB_SIGNATURE: &str =
820 "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
821 let got = headers(&[("X-Hub-Signature-256", GITHUB_SIGNATURE)]);
822 let verifier = WebhookVerifier::new(Github);
823 assert!(verifier.verify(GITHUB_SECRET, &got, GITHUB_BODY, NOW));
826 assert!(verifier.verify(GITHUB_SECRET, &got, GITHUB_BODY, NOW + 10_000));
827 }
828
829 #[test]
830 fn a_tampered_github_delivery_or_wrong_secret_refuses() {
831 const GITHUB_SECRET: &str = "It's a Secret to Everybody";
832 const GITHUB_BODY: &[u8] = b"Hello, World!";
833 const GITHUB_SIGNATURE: &str =
834 "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
835 let verifier = WebhookVerifier::new(Github);
836 let got = headers(&[("X-Hub-Signature-256", GITHUB_SIGNATURE)]);
837 assert!(!verifier.verify(GITHUB_SECRET, &got, b"Hello, World?", NOW));
838 assert!(!verifier.verify("another-secret-dummy", &got, GITHUB_BODY, NOW));
839 assert!(
840 !verifier.verify("", &got, GITHUB_BODY, NOW),
841 "an empty secret is not a key"
842 );
843 }
844
845 #[test]
846 fn unreadable_github_input_refuses() {
847 const GITHUB_SECRET: &str = "It's a Secret to Everybody";
848 const GITHUB_BODY: &[u8] = b"Hello, World!";
849 const GITHUB_SIGNATURE: &str =
850 "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
851 let verifier = WebhookVerifier::new(Github);
852 assert!(!verifier.verify(GITHUB_SECRET, &headers(&[]), GITHUB_BODY, NOW));
855 assert!(!verifier.verify(
856 GITHUB_SECRET,
857 &headers(&[("X-Hub-Signature", "sha1=deadbeef")]),
858 GITHUB_BODY,
859 NOW
860 ));
861 assert!(!verifier.verify(
862 GITHUB_SECRET,
863 &headers(&[("X-Hub-Signature-256", "deadbeef")]),
864 GITHUB_BODY,
865 NOW
866 ));
867 let unprefixed = GITHUB_SIGNATURE.strip_prefix("sha256=").expect("prefixed");
868 assert!(!verifier.verify(
869 GITHUB_SECRET,
870 &headers(&[("X-Hub-Signature-256", unprefixed)]),
871 GITHUB_BODY,
872 NOW
873 ));
874 }
875
876 fn vercel_verifies(secret: &str, signature: &str, body: &[u8], now: i64) -> bool {
878 let got = headers(&[("x-vercel-signature", signature)]);
879 WebhookVerifier::new(Vercel).verify(secret, &got, body, now)
880 }
881
882 #[test]
883 fn hmac_sha1_matches_the_rfc_2202_vector() {
884 let tag = hex(&mac_sha1(b"Jefe", &[b"what do ya want for nothing?"]));
888 assert_eq!(tag, "effcdf6ae5eb2fa2d27416d5f184df9c259a7c79");
889 }
890
891 #[test]
892 fn a_vercel_delivery_verifies_and_tampered_ones_refuse() {
893 let signature = hex(&mac_sha1(VERCEL_SECRET.as_bytes(), &[VERCEL_BODY]));
896 assert_eq!(signature, "d6543f159dfdf17c47130f1019ba14ed71bd4ca5");
897 assert!(vercel_verifies(VERCEL_SECRET, &signature, VERCEL_BODY, NOW));
898 assert!(vercel_verifies(
900 VERCEL_SECRET,
901 &signature,
902 VERCEL_BODY,
903 NOW + 10_000
904 ));
905 let tampered =
907 br#"{"type":"deployment.succeeded","payload":{"deployment":{"id":"dpl_2"}}}"#;
908 assert!(!vercel_verifies(VERCEL_SECRET, &signature, tampered, NOW));
909 assert!(!vercel_verifies(
910 "another-secret-dummy",
911 &signature,
912 VERCEL_BODY,
913 NOW
914 ));
915 assert!(!vercel_verifies("", &signature, VERCEL_BODY, NOW));
917 }
918
919 #[test]
920 fn unreadable_vercel_input_refuses() {
921 assert!(!WebhookVerifier::new(Vercel).verify(
923 VERCEL_SECRET,
924 &headers(&[]),
925 VERCEL_BODY,
926 NOW
927 ));
928 assert!(!vercel_verifies(
932 VERCEL_SECRET,
933 &"a".repeat(39),
934 VERCEL_BODY,
935 NOW
936 ));
937 assert!(!vercel_verifies(
938 VERCEL_SECRET,
939 "not-hex!!",
940 VERCEL_BODY,
941 NOW
942 ));
943 assert!(!vercel_verifies(VERCEL_SECRET, "zzzz", VERCEL_BODY, NOW));
944 assert!(!vercel_verifies(VERCEL_SECRET, "abcd", VERCEL_BODY, NOW));
945 assert!(!vercel_verifies(
946 VERCEL_SECRET,
947 &"a".repeat(41),
948 VERCEL_BODY,
949 NOW
950 ));
951 }
952
953 fn gitlab_verifies(secret: &str, token: &str, body: &[u8], now: i64) -> bool {
955 let got = headers(&[("X-Gitlab-Token", token)]);
956 WebhookVerifier::new(Gitlab).verify(secret, &got, body, now)
957 }
958
959 #[test]
960 fn a_gitlab_shared_token_verifies_and_ignores_the_body() {
961 let body = br#"{"object_kind":"push"}"#;
962 assert!(gitlab_verifies(GITLAB_SECRET, GITLAB_SECRET, body, NOW));
963 assert!(gitlab_verifies(GITLAB_SECRET, GITLAB_SECRET, b"", NOW));
965 assert!(gitlab_verifies(
966 GITLAB_SECRET,
967 GITLAB_SECRET,
968 b"a wholly different body",
969 NOW + 10_000
970 ));
971 let lower = headers(&[("x-gitlab-token", GITLAB_SECRET)]);
973 assert!(WebhookVerifier::new(Gitlab).verify(GITLAB_SECRET, &lower, body, NOW));
974 }
975
976 #[test]
977 fn wrong_or_unreadable_gitlab_tokens_refuse() {
978 let body = b"{}";
979 assert!(!gitlab_verifies(
982 GITLAB_SECRET,
983 "other-token-dummy",
984 body,
985 NOW
986 ));
987 assert!(!gitlab_verifies(
988 GITLAB_SECRET,
989 "gitlab-shared-token-dummy-plus",
990 body,
991 NOW
992 ));
993 assert!(!WebhookVerifier::new(Gitlab).verify(GITLAB_SECRET, &headers(&[]), body, NOW));
995 assert!(!gitlab_verifies(GITLAB_SECRET, "", body, NOW));
996 assert!(!gitlab_verifies("", GITLAB_SECRET, body, NOW));
997 let mut raw = HeaderMap::new();
999 raw.insert(
1000 "X-Gitlab-Token",
1001 http::HeaderValue::from_bytes(&[0xff, 0xfe, 0x80]).expect("opaque header value"),
1002 );
1003 assert!(!WebhookVerifier::new(Gitlab).verify(GITLAB_SECRET, &raw, body, NOW));
1004 let custom = WebhookVerifier::new(SharedTokenScheme {
1006 header: "X-Custom-Token",
1007 });
1008 assert!(custom.verify(
1009 GITLAB_SECRET,
1010 &headers(&[("X-Custom-Token", GITLAB_SECRET)]),
1011 body,
1012 NOW
1013 ));
1014 assert!(!custom.verify(
1015 GITLAB_SECRET,
1016 &headers(&[("X-Gitlab-Token", GITLAB_SECRET)]),
1017 body,
1018 NOW
1019 ));
1020 }
1021}