1use hmac::{Hmac, Mac};
2use sha2::Sha256;
3
4use crate::error::VortexError;
5use crate::webhook_types::VortexEvent;
6
7type HmacSha256 = Hmac<Sha256>;
8
9pub struct VortexWebhooks {
19 secret: String,
20}
21
22impl VortexWebhooks {
23 pub fn new(secret: impl Into<String>) -> Result<Self, VortexError> {
29 let secret = secret.into();
30 if secret.is_empty() {
31 return Err(VortexError::WebhookSignatureError(
32 "Webhook secret must not be empty.".into(),
33 ));
34 }
35 Ok(Self { secret })
36 }
37
38 pub fn verify_signature(&self, payload: &[u8], signature: &str) -> bool {
42 let Ok(mut mac) = HmacSha256::new_from_slice(self.secret.as_bytes()) else {
43 return false;
44 };
45 mac.update(payload);
46
47 let expected = hex_encode(mac.finalize().into_bytes().as_slice());
48
49 constant_time_eq(expected.as_bytes(), signature.as_bytes())
51 }
52
53 pub fn construct_event(&self, payload: &[u8], signature: &str) -> Result<VortexEvent, VortexError> {
63 if !self.verify_signature(payload, signature) {
64 return Err(VortexError::WebhookSignatureError(
65 "Webhook signature verification failed. Ensure you are using the raw request body and the correct signing secret.".into(),
66 ));
67 }
68
69 serde_json::from_slice(payload).map_err(|e| {
70 VortexError::SerializationError(format!("Failed to parse webhook payload: {}", e))
71 })
72 }
73}
74
75fn hex_encode(bytes: &[u8]) -> String {
77 bytes.iter().map(|b| format!("{:02x}", b)).collect()
78}
79
80fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
82 if a.len() != b.len() {
83 return false;
84 }
85 let mut diff = 0u8;
86 for (x, y) in a.iter().zip(b.iter()) {
87 diff |= x ^ y;
88 }
89 diff == 0
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 const TEST_SECRET: &str = "whsec_test_secret";
97
98 fn sign(payload: &[u8]) -> String {
99 let mut mac = HmacSha256::new_from_slice(TEST_SECRET.as_bytes()).unwrap();
100 mac.update(payload);
101 hex_encode(mac.finalize().into_bytes().as_slice())
102 }
103
104 const SAMPLE_WEBHOOK: &str = r#"{"id":"evt_1","type":"invitation.accepted","timestamp":"2026-02-25T12:00:00Z","accountId":"acc_1","environmentId":null,"sourceTable":"invitations","operation":"update","data":{"targetEmail":"user@test.com"}}"#;
105
106 const SAMPLE_ANALYTICS: &str = r#"{"id":"ae_1","name":"widget_loaded","accountId":"acc_1","organizationId":"org_1","projectId":"proj_1","environmentId":"env_1","deploymentId":null,"widgetConfigurationId":null,"foreignUserId":null,"sessionId":null,"payload":null,"platform":"web","segmentation":null,"timestamp":"2026-02-25T12:00:00Z"}"#;
107
108 #[test]
109 fn test_verify_valid_signature() {
110 let webhooks = VortexWebhooks::new(TEST_SECRET).unwrap();
111 let sig = sign(SAMPLE_WEBHOOK.as_bytes());
112 assert!(webhooks.verify_signature(SAMPLE_WEBHOOK.as_bytes(), &sig));
113 }
114
115 #[test]
116 fn test_verify_invalid_signature() {
117 let webhooks = VortexWebhooks::new(TEST_SECRET).unwrap();
118 assert!(!webhooks.verify_signature(SAMPLE_WEBHOOK.as_bytes(), "bad_sig"));
119 }
120
121 #[test]
122 fn test_verify_tampered_payload() {
123 let webhooks = VortexWebhooks::new(TEST_SECRET).unwrap();
124 let sig = sign(SAMPLE_WEBHOOK.as_bytes());
125 let tampered = SAMPLE_WEBHOOK.replace("evt_1", "evt_hacked");
126 assert!(!webhooks.verify_signature(tampered.as_bytes(), &sig));
127 }
128
129 #[test]
130 fn test_construct_webhook_event() {
131 let webhooks = VortexWebhooks::new(TEST_SECRET).unwrap();
132 let sig = sign(SAMPLE_WEBHOOK.as_bytes());
133 let event = webhooks.construct_event(SAMPLE_WEBHOOK.as_bytes(), &sig).unwrap();
134 assert!(event.is_webhook_event());
135 let wh = event.as_webhook_event().unwrap();
136 assert_eq!(wh.event_type, "invitation.accepted");
137 }
138
139 #[test]
140 fn test_construct_analytics_event() {
141 let webhooks = VortexWebhooks::new(TEST_SECRET).unwrap();
142 let sig = sign(SAMPLE_ANALYTICS.as_bytes());
143 let event = webhooks.construct_event(SAMPLE_ANALYTICS.as_bytes(), &sig).unwrap();
144 assert!(event.is_analytics_event());
145 let ae = event.as_analytics_event().unwrap();
146 assert_eq!(ae.name, "widget_loaded");
147 }
148
149 #[test]
150 fn test_construct_event_invalid_signature() {
151 let webhooks = VortexWebhooks::new(TEST_SECRET).unwrap();
152 let result = webhooks.construct_event(SAMPLE_WEBHOOK.as_bytes(), "bad");
153 assert!(result.is_err());
154 assert!(matches!(result.unwrap_err(), VortexError::WebhookSignatureError(_)));
155 }
156}