use hmac::{Hmac, Mac}; use sha2::Sha256; use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct StripeEvent { #[serde(rename = "type")] pub event_type: String, pub data: StripeEventData, } #[derive(Debug, Deserialize)] pub struct StripeEventData { pub object: serde_json::Value, } pub fn verify_webhook_signature(payload: &str, signature: &str, secret: &str) -> bool { let parts: std::collections::HashMap<&str, &str> = signature .split(',') .filter_map(|part| { let mut kv = part.splitn(2, '='); Some((kv.next()?, kv.next()?)) }) .collect(); let timestamp = match parts.get("t") { Some(t) => *t, None => return false, }; let expected_sig = match parts.get("v1") { Some(s) => *s, None => return false, }; let signed_payload = format!("{}.{}", timestamp, payload); let mut mac = match Hmac::::new_from_slice(secret.as_bytes()) { Ok(m) => m, Err(_) => return false, }; mac.update(signed_payload.as_bytes()); let expected_bytes = match hex::decode(expected_sig) { Ok(b) => b, Err(_) => return false, }; mac.verify_slice(&expected_bytes).is_ok() } #[cfg(test)] mod tests { use super::*; #[test] fn parse_checkout_completed_event() { let payload = r#"{"type":"checkout.session.completed","data":{"object":{"customer":"cus_123","metadata":{"user_id":"usr_456"}}}}"#; let event: StripeEvent = serde_json::from_str(payload).unwrap(); assert_eq!(event.event_type, "checkout.session.completed"); } #[test] fn parse_subscription_deleted_event() { let payload = r#"{"type":"customer.subscription.deleted","data":{"object":{"customer":"cus_789"}}}"#; let event: StripeEvent = serde_json::from_str(payload).unwrap(); assert_eq!(event.event_type, "customer.subscription.deleted"); } #[test] fn webhook_signature_rejects_bad_signature() { assert!(!verify_webhook_signature("payload", "t=123,v1=badsig", "secret")); } #[test] fn webhook_signature_accepts_valid() { use hmac::{Hmac, Mac}; use sha2::Sha256; let secret = "whsec_test"; let payload = "payload"; let timestamp = "1234"; let signed_payload = format!("{timestamp}.{payload}"); let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); mac.update(signed_payload.as_bytes()); let sig_hex = hex::encode(mac.finalize().into_bytes()); let header = format!("t={timestamp},v1={sig_hex}"); assert!(verify_webhook_signature(payload, &header, secret)); } #[test] fn webhook_signature_rejects_missing_timestamp() { assert!(!verify_webhook_signature("payload", "v1=abcdef", "secret")); } #[test] fn webhook_signature_rejects_missing_v1() { assert!(!verify_webhook_signature("payload", "t=123456", "secret")); } #[test] fn webhook_signature_rejects_invalid_hex() { assert!(!verify_webhook_signature("payload", "t=123,v1=zzzz", "secret")); } #[test] fn webhook_signature_rejects_empty() { assert!(!verify_webhook_signature("payload", "", "secret")); } }