Skip to main content

vortex_sdk/
webhooks.rs

1use hmac::{Hmac, Mac};
2use sha2::Sha256;
3
4use crate::error::VortexError;
5use crate::webhook_types::VortexEvent;
6
7type HmacSha256 = Hmac<Sha256>;
8
9/// Vortex webhook verification and parsing.
10///
11/// # Example
12///
13/// ```
14/// use vortex_sdk::VortexWebhooks;
15///
16/// let webhooks = VortexWebhooks::new("whsec_your_secret").unwrap();
17/// ```
18pub struct VortexWebhooks {
19    secret: String,
20}
21
22impl VortexWebhooks {
23    /// Create a new webhook verifier with the given signing secret.
24    ///
25    /// # Errors
26    ///
27    /// Returns `VortexError::WebhookSignatureError` if the secret is empty.
28    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    /// Verify the HMAC-SHA256 signature of an incoming webhook payload.
39    ///
40    /// Uses constant-time comparison to prevent timing attacks.
41    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 comparison
50        constant_time_eq(expected.as_bytes(), signature.as_bytes())
51    }
52
53    /// Verify and parse an incoming webhook payload.
54    ///
55    /// Returns a typed `VortexEvent` on success, or a `VortexError::WebhookSignatureError`
56    /// if the signature is invalid.
57    ///
58    /// # Arguments
59    ///
60    /// * `payload` - The raw request body bytes
61    /// * `signature` - The value of the `X-Vortex-Signature` header
62    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
75/// Hex-encode bytes (lowercase).
76fn hex_encode(bytes: &[u8]) -> String {
77    bytes.iter().map(|b| format!("{:02x}", b)).collect()
78}
79
80/// Constant-time byte comparison.
81fn 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}