use hmac::{Hmac, Mac};
use sha2::Sha256;
use crate::error::VortexError;
use crate::webhook_types::VortexEvent;
type HmacSha256 = Hmac<Sha256>;
pub struct VortexWebhooks {
secret: String,
}
impl VortexWebhooks {
pub fn new(secret: impl Into<String>) -> Result<Self, VortexError> {
let secret = secret.into();
if secret.is_empty() {
return Err(VortexError::WebhookSignatureError(
"Webhook secret must not be empty.".into(),
));
}
Ok(Self { secret })
}
pub fn verify_signature(&self, payload: &[u8], signature: &str) -> bool {
let Ok(mut mac) = HmacSha256::new_from_slice(self.secret.as_bytes()) else {
return false;
};
mac.update(payload);
let expected = hex_encode(mac.finalize().into_bytes().as_slice());
constant_time_eq(expected.as_bytes(), signature.as_bytes())
}
pub fn construct_event(&self, payload: &[u8], signature: &str) -> Result<VortexEvent, VortexError> {
if !self.verify_signature(payload, signature) {
return Err(VortexError::WebhookSignatureError(
"Webhook signature verification failed. Ensure you are using the raw request body and the correct signing secret.".into(),
));
}
serde_json::from_slice(payload).map_err(|e| {
VortexError::SerializationError(format!("Failed to parse webhook payload: {}", e))
})
}
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SECRET: &str = "whsec_test_secret";
fn sign(payload: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(TEST_SECRET.as_bytes()).unwrap();
mac.update(payload);
hex_encode(mac.finalize().into_bytes().as_slice())
}
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"}}"#;
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"}"#;
#[test]
fn test_verify_valid_signature() {
let webhooks = VortexWebhooks::new(TEST_SECRET).unwrap();
let sig = sign(SAMPLE_WEBHOOK.as_bytes());
assert!(webhooks.verify_signature(SAMPLE_WEBHOOK.as_bytes(), &sig));
}
#[test]
fn test_verify_invalid_signature() {
let webhooks = VortexWebhooks::new(TEST_SECRET).unwrap();
assert!(!webhooks.verify_signature(SAMPLE_WEBHOOK.as_bytes(), "bad_sig"));
}
#[test]
fn test_verify_tampered_payload() {
let webhooks = VortexWebhooks::new(TEST_SECRET).unwrap();
let sig = sign(SAMPLE_WEBHOOK.as_bytes());
let tampered = SAMPLE_WEBHOOK.replace("evt_1", "evt_hacked");
assert!(!webhooks.verify_signature(tampered.as_bytes(), &sig));
}
#[test]
fn test_construct_webhook_event() {
let webhooks = VortexWebhooks::new(TEST_SECRET).unwrap();
let sig = sign(SAMPLE_WEBHOOK.as_bytes());
let event = webhooks.construct_event(SAMPLE_WEBHOOK.as_bytes(), &sig).unwrap();
assert!(event.is_webhook_event());
let wh = event.as_webhook_event().unwrap();
assert_eq!(wh.event_type, "invitation.accepted");
}
#[test]
fn test_construct_analytics_event() {
let webhooks = VortexWebhooks::new(TEST_SECRET).unwrap();
let sig = sign(SAMPLE_ANALYTICS.as_bytes());
let event = webhooks.construct_event(SAMPLE_ANALYTICS.as_bytes(), &sig).unwrap();
assert!(event.is_analytics_event());
let ae = event.as_analytics_event().unwrap();
assert_eq!(ae.name, "widget_loaded");
}
#[test]
fn test_construct_event_invalid_signature() {
let webhooks = VortexWebhooks::new(TEST_SECRET).unwrap();
let result = webhooks.construct_event(SAMPLE_WEBHOOK.as_bytes(), "bad");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), VortexError::WebhookSignatureError(_)));
}
}