1use hmac::{Hmac, Mac};
33use serde::{Deserialize, Serialize};
34use sha2::Sha256;
35use std::collections::HashMap;
36
37type HmacSha256 = Hmac<Sha256>;
39
40#[derive(Debug, Clone)]
44pub struct WebhookHandler {
45 secret: String,
47 path: String,
49}
50
51impl WebhookHandler {
52 pub fn new(secret: String, path: String) -> Self {
70 Self { secret, path }
71 }
72
73 pub fn path(&self) -> &str {
75 &self.path
76 }
77
78 pub fn verify_signature(&self, payload: &[u8], signature: &str) -> bool {
98 let signature_hex = signature.strip_prefix("sha256=").unwrap_or(signature);
100
101 let expected_signature = match hex::decode(signature_hex) {
103 Ok(sig) => sig,
104 Err(_) => return false,
105 };
106
107 let mut mac = match HmacSha256::new_from_slice(self.secret.as_bytes()) {
109 Ok(mac) => mac,
110 Err(_) => return false,
111 };
112
113 mac.update(payload);
115
116 mac.verify_slice(&expected_signature).is_ok()
118 }
119
120 pub fn parse_request(&self, body: &[u8]) -> Result<WebhookRequest, String> {
136 serde_json::from_slice(body).map_err(|e| format!("Failed to parse request body: {}", e))
137 }
138
139 pub fn handle_request(&self, body: &[u8], signature: &str) -> WebhookResult {
159 if !self.verify_signature(body, signature) {
161 return WebhookResult::InvalidSignature;
162 }
163
164 match self.parse_request(body) {
166 Ok(request) => WebhookResult::Triggered { request },
167 Err(err) => WebhookResult::ParseError(err),
168 }
169 }
170
171 pub fn compute_signature(&self, payload: &[u8]) -> String {
183 let mut mac = HmacSha256::new_from_slice(self.secret.as_bytes())
184 .expect("HMAC can take key of any size");
185 mac.update(payload);
186 let result = mac.finalize();
187 hex::encode(result.into_bytes())
188 }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
199pub struct WebhookRequest {
200 pub content: String,
202 #[serde(default)]
204 pub sender_id: Option<String>,
205 #[serde(default)]
207 pub metadata: HashMap<String, serde_json::Value>,
208}
209
210impl WebhookRequest {
211 pub fn new(content: String) -> Self {
213 Self {
214 content,
215 sender_id: None,
216 metadata: HashMap::new(),
217 }
218 }
219
220 pub fn with_sender_id(mut self, sender_id: String) -> Self {
222 self.sender_id = Some(sender_id);
223 self
224 }
225
226 pub fn with_metadata(mut self, key: String, value: serde_json::Value) -> Self {
228 self.metadata.insert(key, value);
229 self
230 }
231}
232
233#[derive(Debug, Clone, PartialEq)]
242pub enum WebhookResult {
243 Triggered {
245 request: WebhookRequest,
247 },
248 InvalidSignature,
250 ParseError(String),
252}
253
254impl WebhookResult {
255 pub fn is_triggered(&self) -> bool {
257 matches!(self, WebhookResult::Triggered { .. })
258 }
259
260 pub fn is_invalid_signature(&self) -> bool {
262 matches!(self, WebhookResult::InvalidSignature)
263 }
264
265 pub fn is_parse_error(&self) -> bool {
267 matches!(self, WebhookResult::ParseError(_))
268 }
269
270 pub fn into_request(self) -> Option<WebhookRequest> {
272 match self {
273 WebhookResult::Triggered { request } => Some(request),
274 _ => None,
275 }
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use proptest::prelude::*;
283
284 fn create_test_handler() -> WebhookHandler {
286 WebhookHandler::new("test-secret".to_string(), "/webhook/test".to_string())
287 }
288
289 fn create_test_body() -> Vec<u8> {
291 r#"{"content":"Hello, World!","sender_id":"user-123"}"#
292 .as_bytes()
293 .to_vec()
294 }
295
296 #[test]
301 fn test_verify_signature_valid() {
302 let handler = create_test_handler();
303 let body = create_test_body();
304
305 let signature = handler.compute_signature(&body);
307
308 assert!(handler.verify_signature(&body, &signature));
309 }
310
311 #[test]
314 fn test_verify_signature_with_prefix() {
315 let handler = create_test_handler();
316 let body = create_test_body();
317
318 let signature = handler.compute_signature(&body);
319 let prefixed_signature = format!("sha256={}", signature);
320
321 assert!(handler.verify_signature(&body, &prefixed_signature));
322 }
323
324 #[test]
327 fn test_verify_signature_invalid() {
328 let handler = create_test_handler();
329 let body = create_test_body();
330
331 let invalid_signature = "0000000000000000000000000000000000000000000000000000000000000000";
333
334 assert!(!handler.verify_signature(&body, invalid_signature));
335 }
336
337 #[test]
340 fn test_verify_signature_invalid_hex() {
341 let handler = create_test_handler();
342 let body = create_test_body();
343
344 assert!(!handler.verify_signature(&body, "not-a-hex-string"));
346 assert!(!handler.verify_signature(&body, "zzzz"));
347 }
348
349 #[test]
352 fn test_verify_signature_empty() {
353 let handler = create_test_handler();
354 let body = create_test_body();
355
356 assert!(!handler.verify_signature(&body, ""));
357 }
358
359 #[test]
362 fn test_verify_signature_tampered_payload() {
363 let handler = create_test_handler();
364 let body = create_test_body();
365
366 let signature = handler.compute_signature(&body);
368
369 let tampered_body = r#"{"content":"Tampered!","sender_id":"user-123"}"#.as_bytes();
371
372 assert!(!handler.verify_signature(tampered_body, &signature));
374 }
375
376 #[test]
381 fn test_parse_request_valid() {
382 let handler = create_test_handler();
383 let body = create_test_body();
384
385 let result = handler.parse_request(&body);
386 assert!(result.is_ok());
387
388 let request = result.unwrap();
389 assert_eq!(request.content, "Hello, World!");
390 assert_eq!(request.sender_id, Some("user-123".to_string()));
391 }
392
393 #[test]
396 fn test_parse_request_minimal() {
397 let handler = create_test_handler();
398 let body = r#"{"content":"Minimal message"}"#.as_bytes();
399
400 let result = handler.parse_request(body);
401 assert!(result.is_ok());
402
403 let request = result.unwrap();
404 assert_eq!(request.content, "Minimal message");
405 assert_eq!(request.sender_id, None);
406 assert!(request.metadata.is_empty());
407 }
408
409 #[test]
412 fn test_parse_request_with_metadata() {
413 let handler = create_test_handler();
414 let body = r#"{
415 "content": "Message with metadata",
416 "sender_id": "user-456",
417 "metadata": {
418 "source": "github",
419 "priority": 1
420 }
421 }"#
422 .as_bytes();
423
424 let result = handler.parse_request(body);
425 assert!(result.is_ok());
426
427 let request = result.unwrap();
428 assert_eq!(request.content, "Message with metadata");
429 assert_eq!(request.sender_id, Some("user-456".to_string()));
430 assert_eq!(
431 request.metadata.get("source"),
432 Some(&serde_json::json!("github"))
433 );
434 assert_eq!(
435 request.metadata.get("priority"),
436 Some(&serde_json::json!(1))
437 );
438 }
439
440 #[test]
442 fn test_parse_request_invalid_json() {
443 let handler = create_test_handler();
444 let body = b"not valid json";
445
446 let result = handler.parse_request(body);
447 assert!(result.is_err());
448 }
449
450 #[test]
452 fn test_parse_request_missing_content() {
453 let handler = create_test_handler();
454 let body = r#"{"sender_id":"user-123"}"#.as_bytes();
455
456 let result = handler.parse_request(body);
457 assert!(result.is_err());
458 }
459
460 #[test]
465 fn test_handle_request_success() {
466 let handler = create_test_handler();
467 let body = create_test_body();
468 let signature = handler.compute_signature(&body);
469
470 let result = handler.handle_request(&body, &signature);
471
472 assert!(result.is_triggered());
473 let request = result.into_request().unwrap();
474 assert_eq!(request.content, "Hello, World!");
475 }
476
477 #[test]
480 fn test_handle_request_invalid_signature() {
481 let handler = create_test_handler();
482 let body = create_test_body();
483 let invalid_signature = "invalid";
484
485 let result = handler.handle_request(&body, invalid_signature);
486
487 assert!(result.is_invalid_signature());
488 assert_eq!(result, WebhookResult::InvalidSignature);
489 }
490
491 #[test]
494 fn test_handle_request_parse_error() {
495 let handler = create_test_handler();
496 let body = b"not valid json";
497 let signature = handler.compute_signature(body);
498
499 let result = handler.handle_request(body, &signature);
500
501 assert!(result.is_parse_error());
502 }
503
504 #[test]
508 fn test_webhook_request_builder() {
509 let request = WebhookRequest::new("Test content".to_string())
510 .with_sender_id("sender-1".to_string())
511 .with_metadata("key".to_string(), serde_json::json!("value"));
512
513 assert_eq!(request.content, "Test content");
514 assert_eq!(request.sender_id, Some("sender-1".to_string()));
515 assert_eq!(
516 request.metadata.get("key"),
517 Some(&serde_json::json!("value"))
518 );
519 }
520
521 #[test]
523 fn test_webhook_request_serde() {
524 let request = WebhookRequest::new("Test".to_string()).with_sender_id("user".to_string());
525
526 let json = serde_json::to_string(&request).unwrap();
527 let parsed: WebhookRequest = serde_json::from_str(&json).unwrap();
528
529 assert_eq!(request, parsed);
530 }
531
532 #[test]
537 fn test_configurable_path() {
538 let handler1 = WebhookHandler::new("secret".to_string(), "/api/webhook".to_string());
539 let handler2 = WebhookHandler::new("secret".to_string(), "/custom/path".to_string());
540
541 assert_eq!(handler1.path(), "/api/webhook");
542 assert_eq!(handler2.path(), "/custom/path");
543 }
544
545 #[test]
549 fn test_different_secrets_different_signatures() {
550 let handler1 = WebhookHandler::new("secret1".to_string(), "/webhook".to_string());
551 let handler2 = WebhookHandler::new("secret2".to_string(), "/webhook".to_string());
552 let body = create_test_body();
553
554 let sig1 = handler1.compute_signature(&body);
555 let sig2 = handler2.compute_signature(&body);
556
557 assert_ne!(sig1, sig2);
558
559 assert!(!handler2.verify_signature(&body, &sig1));
561 }
562
563 fn arb_secret() -> impl Strategy<Value = String> {
569 prop::string::string_regex("[a-zA-Z0-9_-]{8,64}")
571 .unwrap()
572 .prop_filter("Secret must not be empty", |s| !s.is_empty())
573 }
574
575 fn arb_payload() -> impl Strategy<Value = Vec<u8>> {
577 prop::collection::vec(any::<u8>(), 1..1024)
579 }
580
581 fn arb_content() -> impl Strategy<Value = String> {
583 prop::string::string_regex("[a-zA-Z0-9 .,!?]{1,256}")
585 .unwrap()
586 .prop_filter("Content must not be empty", |s| !s.is_empty())
587 }
588
589 fn arb_sender_id() -> impl Strategy<Value = Option<String>> {
591 prop::option::of(prop::string::string_regex("[a-zA-Z0-9_-]{1,32}").unwrap())
592 }
593
594 fn arb_webhook_request() -> impl Strategy<Value = WebhookRequest> {
596 (arb_content(), arb_sender_id()).prop_map(|(content, sender_id)| {
597 let mut request = WebhookRequest::new(content);
598 if let Some(id) = sender_id {
599 request = request.with_sender_id(id);
600 }
601 request
602 })
603 }
604
605 fn arb_path() -> impl Strategy<Value = String> {
607 prop::string::string_regex("/[a-z0-9/_-]{1,64}")
608 .unwrap()
609 .prop_filter("Path must start with /", |s| s.starts_with('/'))
610 }
611
612 fn arb_tampered_payload(original: &[u8]) -> impl Strategy<Value = Vec<u8>> {
614 let original_len = original.len();
615 let original_clone = original.to_vec();
616
617 prop::strategy::Union::new_weighted(vec![
618 (3, {
620 let orig = original_clone.clone();
621 any::<prop::sample::Index>()
622 .prop_flat_map(move |idx| {
623 let orig = orig.clone();
624 let pos = idx.index(orig.len().max(1));
625 any::<u8>().prop_map(move |new_byte| {
626 let mut result = orig.clone();
627 if !result.is_empty() {
628 result[pos] = if result[pos] == new_byte {
630 new_byte.wrapping_add(1)
631 } else {
632 new_byte
633 };
634 }
635 result
636 })
637 })
638 .boxed()
639 }),
640 (2, {
642 let orig = original_clone.clone();
643 prop::collection::vec(any::<u8>(), 1..10)
644 .prop_map(move |extra| {
645 let mut result = orig.clone();
646 result.extend(extra);
647 result
648 })
649 .boxed()
650 }),
651 (1, {
653 let orig = original_clone.clone();
654 if original_len > 1 {
655 any::<prop::sample::Index>()
656 .prop_map(move |idx| {
657 let mut result = orig.clone();
658 let pos = idx.index(result.len());
659 result.remove(pos);
660 result
661 })
662 .boxed()
663 } else {
664 any::<u8>()
666 .prop_map(move |extra| {
667 let mut result = orig.clone();
668 result.push(extra);
669 result
670 })
671 .boxed()
672 }
673 }),
674 ])
675 }
676
677 fn arb_invalid_signature() -> impl Strategy<Value = String> {
679 prop::strategy::Union::new_weighted(vec![
680 (
682 3,
683 prop::string::string_regex("[g-z]{32,64}").unwrap().boxed(),
684 ),
685 (1, Just("".to_string()).boxed()),
687 (
689 2,
690 prop::string::string_regex("[0-9a-f]{1,10}")
691 .unwrap()
692 .boxed(),
693 ),
694 (
696 2,
697 prop::string::string_regex("[0-9a-f]{20}[xyz]{5}[0-9a-f]{20}")
698 .unwrap()
699 .boxed(),
700 ),
701 ])
702 }
703
704 proptest! {
705 #![proptest_config(ProptestConfig::with_cases(100))]
706
707 #[test]
713 fn prop_valid_signature_always_passes(
714 secret in arb_secret(),
715 payload in arb_payload(),
716 path in arb_path()
717 ) {
718 let handler = WebhookHandler::new(secret, path);
722
723 let signature = handler.compute_signature(&payload);
725
726 prop_assert!(
728 handler.verify_signature(&payload, &signature),
729 "Valid signature should always pass verification"
730 );
731 }
732
733 #[test]
738 fn prop_valid_signature_with_prefix_passes(
739 secret in arb_secret(),
740 payload in arb_payload(),
741 path in arb_path()
742 ) {
743 let handler = WebhookHandler::new(secret, path);
747
748 let signature = handler.compute_signature(&payload);
750 let prefixed_signature = format!("sha256={}", signature);
751
752 prop_assert!(
754 handler.verify_signature(&payload, &prefixed_signature),
755 "Valid signature with sha256= prefix should pass verification"
756 );
757 }
758
759 #[test]
764 fn prop_invalid_signature_always_fails(
765 secret in arb_secret(),
766 payload in arb_payload(),
767 path in arb_path(),
768 invalid_sig in arb_invalid_signature()
769 ) {
770 let handler = WebhookHandler::new(secret, path);
774
775 prop_assert!(
777 !handler.verify_signature(&payload, &invalid_sig),
778 "Invalid signature '{}' should be rejected",
779 invalid_sig
780 );
781 }
782
783 #[test]
788 fn prop_tampered_payload_fails_verification(
789 secret in arb_secret(),
790 payload in arb_payload().prop_filter("Need non-empty payload", |p| !p.is_empty()),
791 path in arb_path()
792 ) {
793 let handler = WebhookHandler::new(secret, path);
797
798 let signature = handler.compute_signature(&payload);
800
801 let tampered = arb_tampered_payload(&payload);
803
804 proptest!(|(tampered_payload in tampered)| {
806 if tampered_payload != payload {
808 prop_assert!(
809 !handler.verify_signature(&tampered_payload, &signature),
810 "Tampered payload should fail verification with original signature"
811 );
812 }
813 });
814 }
815
816 #[test]
821 fn prop_different_secrets_produce_different_signatures(
822 secret1 in arb_secret(),
823 secret2 in arb_secret().prop_filter("Secrets must be different", |s| !s.is_empty()),
824 payload in arb_payload(),
825 path in arb_path()
826 ) {
827 prop_assume!(secret1 != secret2);
832
833 let handler1 = WebhookHandler::new(secret1, path.clone());
834 let handler2 = WebhookHandler::new(secret2, path);
835
836 let sig1 = handler1.compute_signature(&payload);
837 let sig2 = handler2.compute_signature(&payload);
838
839 prop_assert_ne!(
841 &sig1, &sig2,
842 "Different secrets should produce different signatures"
843 );
844
845 prop_assert!(
847 !handler2.verify_signature(&payload, &sig1),
848 "Signature from secret1 should not pass verification with secret2"
849 );
850
851 prop_assert!(
853 !handler1.verify_signature(&payload, &sig2),
854 "Signature from secret2 should not pass verification with secret1"
855 );
856 }
857
858 #[test]
863 fn prop_same_payload_same_secret_same_signature(
864 secret in arb_secret(),
865 payload in arb_payload(),
866 path in arb_path()
867 ) {
868 let handler = WebhookHandler::new(secret, path);
872
873 let sig1 = handler.compute_signature(&payload);
875 let sig2 = handler.compute_signature(&payload);
876 let sig3 = handler.compute_signature(&payload);
877
878 prop_assert_eq!(
880 &sig1, &sig2,
881 "Same payload and secret should produce same signature (1 vs 2)"
882 );
883 prop_assert_eq!(
884 &sig2, &sig3,
885 "Same payload and secret should produce same signature (2 vs 3)"
886 );
887 }
888
889 #[test]
894 fn prop_handle_request_with_valid_signature_succeeds(
895 secret in arb_secret(),
896 request in arb_webhook_request(),
897 path in arb_path()
898 ) {
899 let handler = WebhookHandler::new(secret, path);
903
904 let body = serde_json::to_vec(&request).unwrap();
906
907 let signature = handler.compute_signature(&body);
909
910 let result = handler.handle_request(&body, &signature);
912
913 prop_assert!(
915 result.is_triggered(),
916 "Valid request with valid signature should trigger"
917 );
918
919 if let WebhookResult::Triggered { request: parsed } = result {
921 prop_assert_eq!(
922 parsed.content, request.content,
923 "Parsed content should match original"
924 );
925 prop_assert_eq!(
926 parsed.sender_id, request.sender_id,
927 "Parsed sender_id should match original"
928 );
929 }
930 }
931
932 #[test]
937 fn prop_handle_request_with_invalid_signature_fails(
938 secret in arb_secret(),
939 request in arb_webhook_request(),
940 path in arb_path(),
941 invalid_sig in arb_invalid_signature()
942 ) {
943 let handler = WebhookHandler::new(secret, path);
947
948 let body = serde_json::to_vec(&request).unwrap();
950
951 let result = handler.handle_request(&body, &invalid_sig);
953
954 prop_assert!(
956 result.is_invalid_signature(),
957 "Request with invalid signature should return InvalidSignature"
958 );
959 }
960
961 #[test]
966 fn prop_signature_length_is_fixed(
967 secret in arb_secret(),
968 payload in arb_payload(),
969 path in arb_path()
970 ) {
971 let handler = WebhookHandler::new(secret, path);
975 let signature = handler.compute_signature(&payload);
976
977 prop_assert_eq!(
979 signature.len(), 64,
980 "Signature should be 64 hex characters (SHA256), got {} characters",
981 signature.len()
982 );
983
984 prop_assert!(
986 signature.chars().all(|c| c.is_ascii_hexdigit()),
987 "Signature should only contain hex characters"
988 );
989 }
990
991 #[test]
996 fn prop_empty_payload_signature_works(
997 secret in arb_secret(),
998 path in arb_path()
999 ) {
1000 let handler = WebhookHandler::new(secret, path);
1004 let empty_payload: &[u8] = &[];
1005
1006 let signature = handler.compute_signature(empty_payload);
1008
1009 prop_assert_eq!(signature.len(), 64);
1011
1012 prop_assert!(
1014 handler.verify_signature(empty_payload, &signature),
1015 "Empty payload signature should verify correctly"
1016 );
1017 }
1018 }
1019}