Skip to main content

loop_agent_sdk/
webhook.rs

1//! Loop Agent SDK - Webhook Handler
2//! 
3//! Unified handler for Fidel, Square, and Stripe webhooks.
4//! 
5//! ## Security
6//! 
7//! - Signature verification BEFORE any processing
8//! - Privacy layer hashing BEFORE any logging
9//! - Reject spoofed webhooks at the door
10//! 
11//! ## Flow
12//! 
13//! ```text
14//! Webhook arrives
15//!     ↓
16//! Verify signature (reject if invalid)
17//!     ↓
18//! Extract card_id
19//!     ↓
20//! IMMEDIATELY hash to loop_fp_* (purge raw card_id)
21//!     ↓
22//! Lookup user by fingerprint
23//!     ↓
24//! Execute capture
25//! ```
26
27use crate::privacy::{LoopFingerprint, PrivacyLayer, PrivacyError};
28use hmac::{Hmac, Mac};
29use sha2::Sha256;
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32use tracing::{error, info, warn, instrument};
33
34type HmacSha256 = Hmac<Sha256>;
35
36// ============================================================================
37// WEBHOOK CONFIGURATION
38// ============================================================================
39
40/// Webhook secrets for signature verification
41#[derive(Debug, Clone)]
42pub struct WebhookSecrets {
43    /// Fidel webhook secrets by event type
44    pub fidel: FidelSecrets,
45    /// Square webhook signature key
46    pub square: Option<String>,
47    /// Stripe webhook signing secret
48    pub stripe: Option<String>,
49}
50
51#[derive(Debug, Clone)]
52pub struct FidelSecrets {
53    /// transaction.auth event
54    pub auth: String,
55    /// transaction.clearing event (main one for captures)
56    pub clearing: String,
57    /// card.linked event
58    pub card_linked: String,
59}
60
61impl WebhookSecrets {
62    /// Load from environment variables
63    pub fn from_env() -> Self {
64        Self {
65            fidel: FidelSecrets {
66                auth: std::env::var("FIDEL_WEBHOOK_SECRET_AUTH")
67                    .unwrap_or_default(),
68                clearing: std::env::var("FIDEL_WEBHOOK_SECRET_CLEARING")
69                    .unwrap_or_default(),
70                card_linked: std::env::var("FIDEL_WEBHOOK_SECRET_CARD_LINKED")
71                    .unwrap_or_default(),
72            },
73            square: std::env::var("SQUARE_WEBHOOK_SIGNATURE_KEY").ok(),
74            stripe: std::env::var("STRIPE_WEBHOOK_SECRET").ok(),
75        }
76    }
77}
78
79// ============================================================================
80// WEBHOOK SOURCE DETECTION
81// ============================================================================
82
83/// Detected webhook source
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85pub enum WebhookSource {
86    Fidel,
87    Square,
88    Stripe,
89    Unknown,
90}
91
92impl WebhookSource {
93    /// Detect source from headers
94    pub fn detect(headers: &HashMap<String, String>) -> Self {
95        // Fidel uses X-Fidel-Signature
96        if headers.contains_key("x-fidel-signature") || headers.contains_key("X-Fidel-Signature") {
97            return Self::Fidel;
98        }
99        
100        // Square uses X-Square-Signature
101        if headers.contains_key("x-square-signature") || headers.contains_key("X-Square-Signature") {
102            return Self::Square;
103        }
104        
105        // Stripe uses Stripe-Signature
106        if headers.contains_key("stripe-signature") || headers.contains_key("Stripe-Signature") {
107            return Self::Stripe;
108        }
109        
110        Self::Unknown
111    }
112}
113
114// ============================================================================
115// NORMALIZED TRANSACTION (Source-Agnostic)
116// ============================================================================
117
118/// Normalized transaction data from any webhook source
119/// 
120/// This is the internal representation after parsing webhook payloads.
121/// All source-specific fields are mapped to this common structure.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct NormalizedTransaction {
124    /// Loop fingerprint (hashed card_id)
125    pub fingerprint: String,
126    /// Last 4 digits of card (for display only)
127    pub card_last4: String,
128    /// Card brand (VISA, MASTERCARD, etc.)
129    pub card_brand: String,
130    /// Transaction amount in cents
131    pub amount_cents: u64,
132    /// Currency code (USD, EUR, etc.)
133    pub currency: String,
134    /// Merchant identifier
135    pub merchant_id: String,
136    /// Merchant name (for display)
137    pub merchant_name: Option<String>,
138    /// Location identifier (if available)
139    pub location_id: Option<String>,
140    /// Transaction timestamp (Unix seconds)
141    pub timestamp: i64,
142    /// Source-specific transaction ID (for dedup)
143    pub source_txn_id: String,
144    /// Original webhook source
145    pub source: WebhookSource,
146    /// Transaction type
147    pub txn_type: TransactionType,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151pub enum TransactionType {
152    /// Authorization (pending)
153    Auth,
154    /// Clearing (settled) - this is what we capture
155    Clearing,
156    /// Refund
157    Refund,
158}
159
160// ============================================================================
161// FIDEL WEBHOOK HANDLING
162// ============================================================================
163
164/// Fidel webhook payload (transaction.clearing event)
165#[derive(Debug, Deserialize)]
166pub struct FidelWebhookPayload {
167    /// Transaction data
168    pub transaction: FidelTransaction,
169    /// Event type
170    #[serde(rename = "type")]
171    pub event_type: String,
172}
173
174#[derive(Debug, Deserialize)]
175pub struct FidelTransaction {
176    /// Fidel's transaction ID
177    pub id: String,
178    /// Card ID (THIS IS WHAT WE HASH)
179    #[serde(rename = "cardId")]
180    pub card_id: String,
181    /// Last 4 digits
182    #[serde(rename = "lastNumbers")]
183    pub last_numbers: String,
184    /// Card scheme (VISA, MASTERCARD)
185    pub scheme: String,
186    /// Amount in original currency
187    pub amount: f64,
188    /// Currency code
189    pub currency: String,
190    /// Merchant info
191    #[serde(rename = "brandId")]
192    pub brand_id: Option<String>,
193    #[serde(rename = "merchantName")]
194    pub merchant_name: Option<String>,
195    #[serde(rename = "locationId")]
196    pub location_id: Option<String>,
197    /// Transaction date (ISO 8601)
198    #[serde(rename = "datetime")]
199    pub datetime: String,
200    /// Auth vs Clearing
201    #[serde(rename = "auth")]
202    pub is_auth: Option<bool>,
203}
204
205/// Verify Fidel webhook signature
206/// 
207/// Fidel signs webhooks with HMAC-SHA256.
208/// Header: X-Fidel-Signature
209#[instrument(skip(body, secret))]
210pub fn verify_fidel_signature(
211    signature_header: &str,
212    body: &[u8],
213    secret: &str,
214) -> Result<bool, WebhookError> {
215    // Fidel signature format: sha256=<hex>
216    let signature = signature_header
217        .strip_prefix("sha256=")
218        .ok_or_else(|| WebhookError::InvalidSignature("Missing sha256= prefix".into()))?;
219    
220    let expected_bytes = hex::decode(signature)
221        .map_err(|e| WebhookError::InvalidSignature(format!("Invalid hex: {}", e)))?;
222    
223    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
224        .map_err(|e| WebhookError::InvalidSignature(format!("HMAC error: {}", e)))?;
225    
226    mac.update(body);
227    
228    // Use constant-time comparison
229    match mac.verify_slice(&expected_bytes) {
230        Ok(_) => {
231            info!("Fidel signature verified");
232            Ok(true)
233        }
234        Err(_) => {
235            warn!("Fidel signature verification failed");
236            Ok(false)
237        }
238    }
239}
240
241/// Parse Fidel webhook and normalize
242/// 
243/// # Privacy Protocol
244/// 
245/// This function implements Double-Blind Vaulting:
246/// 1. Parse the raw payload
247/// 2. IMMEDIATELY hash card_id to fingerprint
248/// 3. Drop the raw card_id (it's moved into the hasher)
249/// 4. Return only normalized data with fingerprint
250#[instrument(skip(body, privacy))]
251pub fn parse_fidel_webhook(
252    body: &[u8],
253    privacy: &PrivacyLayer,
254) -> Result<NormalizedTransaction, WebhookError> {
255    // Parse JSON
256    let payload: FidelWebhookPayload = serde_json::from_slice(body)
257        .map_err(|e| WebhookError::ParseError(format!("Invalid JSON: {}", e)))?;
258    
259    let txn = payload.transaction;
260    
261    // CRITICAL: Hash the card_id IMMEDIATELY
262    // The hash_card_id function takes ownership and zeroizes the original
263    let fingerprint = privacy.hash_card_id(txn.card_id);
264    
265    // After this point, raw card_id no longer exists in memory
266    // All subsequent operations use only the fingerprint
267    
268    // Parse timestamp
269    let timestamp = chrono::DateTime::parse_from_rfc3339(&txn.datetime)
270        .map(|dt| dt.timestamp())
271        .unwrap_or_else(|_| chrono::Utc::now().timestamp());
272    
273    // Determine transaction type
274    let txn_type = match payload.event_type.as_str() {
275        "transaction.auth" => TransactionType::Auth,
276        "transaction.clearing" => TransactionType::Clearing,
277        "transaction.refund" => TransactionType::Refund,
278        _ => TransactionType::Clearing, // Default to clearing
279    };
280    
281    // Convert amount to cents
282    let amount_cents = (txn.amount * 100.0).round() as u64;
283    
284    Ok(NormalizedTransaction {
285        fingerprint: fingerprint.into_string(),
286        card_last4: txn.last_numbers,
287        card_brand: txn.scheme,
288        amount_cents,
289        currency: txn.currency,
290        merchant_id: txn.brand_id.unwrap_or_else(|| "unknown".to_string()),
291        merchant_name: txn.merchant_name,
292        location_id: txn.location_id,
293        timestamp,
294        source_txn_id: txn.id,
295        source: WebhookSource::Fidel,
296        txn_type,
297    })
298}
299
300// ============================================================================
301// SQUARE WEBHOOK HANDLING
302// ============================================================================
303
304/// Square webhook payload (payment.completed event)
305#[derive(Debug, Deserialize)]
306pub struct SquareWebhookPayload {
307    /// Event type
308    #[serde(rename = "type")]
309    pub event_type: String,
310    /// Event data
311    pub data: SquareEventData,
312}
313
314#[derive(Debug, Deserialize)]
315pub struct SquareEventData {
316    /// Object containing payment details
317    pub object: SquarePaymentObject,
318}
319
320#[derive(Debug, Deserialize)]
321pub struct SquarePaymentObject {
322    pub payment: SquarePayment,
323}
324
325#[derive(Debug, Deserialize)]
326pub struct SquarePayment {
327    pub id: String,
328    #[serde(rename = "amount_money")]
329    pub amount_money: SquareMoney,
330    #[serde(rename = "card_details")]
331    pub card_details: Option<SquareCardDetails>,
332    #[serde(rename = "location_id")]
333    pub location_id: String,
334    #[serde(rename = "created_at")]
335    pub created_at: String,
336}
337
338#[derive(Debug, Deserialize)]
339pub struct SquareMoney {
340    pub amount: i64,
341    pub currency: String,
342}
343
344#[derive(Debug, Deserialize)]
345pub struct SquareCardDetails {
346    pub card: SquareCard,
347}
348
349#[derive(Debug, Deserialize)]
350pub struct SquareCard {
351    /// Square's card fingerprint (we hash this too)
352    pub fingerprint: Option<String>,
353    #[serde(rename = "last_4")]
354    pub last_4: Option<String>,
355    #[serde(rename = "card_brand")]
356    pub card_brand: Option<String>,
357}
358
359/// Verify Square webhook signature
360#[instrument(skip(body, signature_key))]
361pub fn verify_square_signature(
362    signature_header: &str,
363    body: &[u8],
364    notification_url: &str,
365    signature_key: &str,
366) -> Result<bool, WebhookError> {
367    // Square signature = Base64(HMAC-SHA256(notification_url + body))
368    let mut mac = HmacSha256::new_from_slice(signature_key.as_bytes())
369        .map_err(|e| WebhookError::InvalidSignature(format!("HMAC error: {}", e)))?;
370    
371    mac.update(notification_url.as_bytes());
372    mac.update(body);
373    
374    let computed = base64::Engine::encode(
375        &base64::engine::general_purpose::STANDARD,
376        mac.finalize().into_bytes(),
377    );
378    
379    if computed == signature_header {
380        info!("Square signature verified");
381        Ok(true)
382    } else {
383        warn!("Square signature verification failed");
384        Ok(false)
385    }
386}
387
388/// Parse Square webhook and normalize
389#[instrument(skip(body, privacy))]
390pub fn parse_square_webhook(
391    body: &[u8],
392    privacy: &PrivacyLayer,
393    merchant_id: &str, // Square merchant ID
394) -> Result<NormalizedTransaction, WebhookError> {
395    let payload: SquareWebhookPayload = serde_json::from_slice(body)
396        .map_err(|e| WebhookError::ParseError(format!("Invalid JSON: {}", e)))?;
397    
398    let payment = payload.data.object.payment;
399    
400    // Get card details
401    let card_details = payment.card_details
402        .ok_or_else(|| WebhookError::ParseError("No card details".into()))?;
403    
404    let card = card_details.card;
405    
406    // Get Square's fingerprint and hash it
407    let raw_fingerprint = card.fingerprint
408        .ok_or_else(|| WebhookError::ParseError("No card fingerprint".into()))?;
409    
410    // CRITICAL: Hash immediately
411    let fingerprint = privacy.hash_card_id(raw_fingerprint);
412    
413    // Parse timestamp
414    let timestamp = chrono::DateTime::parse_from_rfc3339(&payment.created_at)
415        .map(|dt| dt.timestamp())
416        .unwrap_or_else(|_| chrono::Utc::now().timestamp());
417    
418    Ok(NormalizedTransaction {
419        fingerprint: fingerprint.into_string(),
420        card_last4: card.last_4.unwrap_or_default(),
421        card_brand: card.card_brand.unwrap_or_default(),
422        amount_cents: payment.amount_money.amount as u64,
423        currency: payment.amount_money.currency,
424        merchant_id: merchant_id.to_string(),
425        merchant_name: None,
426        location_id: Some(payment.location_id),
427        timestamp,
428        source_txn_id: payment.id,
429        source: WebhookSource::Square,
430        txn_type: TransactionType::Clearing,
431    })
432}
433
434// ============================================================================
435// STRIPE WEBHOOK HANDLING
436// ============================================================================
437
438/// Verify Stripe webhook signature
439#[instrument(skip(body, secret))]
440pub fn verify_stripe_signature(
441    signature_header: &str,
442    body: &[u8],
443    secret: &str,
444) -> Result<bool, WebhookError> {
445    // Stripe signature format: t=timestamp,v1=signature,v1=signature...
446    let parts: HashMap<&str, &str> = signature_header
447        .split(',')
448        .filter_map(|part| {
449            let mut kv = part.splitn(2, '=');
450            Some((kv.next()?, kv.next()?))
451        })
452        .collect();
453    
454    let timestamp = parts.get("t")
455        .ok_or_else(|| WebhookError::InvalidSignature("Missing timestamp".into()))?;
456    
457    let signature = parts.get("v1")
458        .ok_or_else(|| WebhookError::InvalidSignature("Missing v1 signature".into()))?;
459    
460    // Construct signed payload
461    let signed_payload = format!("{}.{}", timestamp, String::from_utf8_lossy(body));
462    
463    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
464        .map_err(|e| WebhookError::InvalidSignature(format!("HMAC error: {}", e)))?;
465    
466    mac.update(signed_payload.as_bytes());
467    
468    let computed = hex::encode(mac.finalize().into_bytes());
469    
470    if computed == *signature {
471        info!("Stripe signature verified");
472        Ok(true)
473    } else {
474        warn!("Stripe signature verification failed");
475        Ok(false)
476    }
477}
478
479// ============================================================================
480// UNIFIED WEBHOOK HANDLER
481// ============================================================================
482
483/// Unified webhook handler
484/// 
485/// This is the main entry point for all webhook processing.
486/// It detects the source, verifies the signature, and normalizes the transaction.
487pub struct WebhookHandler {
488    privacy: PrivacyLayer,
489    secrets: WebhookSecrets,
490}
491
492impl WebhookHandler {
493    /// Create new webhook handler
494    pub async fn new(secrets: WebhookSecrets) -> Result<Self, WebhookError> {
495        let privacy = PrivacyLayer::new(&Default::default()).await
496            .map_err(|e| WebhookError::InitError(e.to_string()))?;
497        
498        Ok(Self { privacy, secrets })
499    }
500    
501    /// Process incoming webhook
502    /// 
503    /// Returns normalized transaction if valid, error if signature fails or parsing fails.
504    #[instrument(skip(self, body))]
505    pub async fn process(
506        &self,
507        headers: &HashMap<String, String>,
508        body: &[u8],
509        context: &WebhookContext,
510    ) -> Result<NormalizedTransaction, WebhookError> {
511        // 1. Detect source
512        let source = WebhookSource::detect(headers);
513        info!(?source, "Detected webhook source");
514        
515        // 2. Verify signature (BEFORE any parsing)
516        match source {
517            WebhookSource::Fidel => {
518                let sig = headers.get("x-fidel-signature")
519                    .or_else(|| headers.get("X-Fidel-Signature"))
520                    .ok_or_else(|| WebhookError::InvalidSignature("Missing signature header".into()))?;
521                
522                // Determine which secret based on event type (peek at body)
523                let secret = self.get_fidel_secret(body)?;
524                
525                if !verify_fidel_signature(sig, body, &secret)? {
526                    return Err(WebhookError::SignatureVerificationFailed);
527                }
528            }
529            WebhookSource::Square => {
530                let sig = headers.get("x-square-signature")
531                    .or_else(|| headers.get("X-Square-Signature"))
532                    .ok_or_else(|| WebhookError::InvalidSignature("Missing signature header".into()))?;
533                
534                let secret = self.secrets.square.as_ref()
535                    .ok_or_else(|| WebhookError::MissingSecret("Square".into()))?;
536                
537                let url = context.webhook_url.as_ref()
538                    .ok_or_else(|| WebhookError::MissingContext("webhook_url".into()))?;
539                
540                if !verify_square_signature(sig, body, url, secret)? {
541                    return Err(WebhookError::SignatureVerificationFailed);
542                }
543            }
544            WebhookSource::Stripe => {
545                let sig = headers.get("stripe-signature")
546                    .or_else(|| headers.get("Stripe-Signature"))
547                    .ok_or_else(|| WebhookError::InvalidSignature("Missing signature header".into()))?;
548                
549                let secret = self.secrets.stripe.as_ref()
550                    .ok_or_else(|| WebhookError::MissingSecret("Stripe".into()))?;
551                
552                if !verify_stripe_signature(sig, body, secret)? {
553                    return Err(WebhookError::SignatureVerificationFailed);
554                }
555            }
556            WebhookSource::Unknown => {
557                return Err(WebhookError::UnknownSource);
558            }
559        }
560        
561        // 3. Parse and normalize (with privacy layer)
562        let transaction = match source {
563            WebhookSource::Fidel => parse_fidel_webhook(body, &self.privacy)?,
564            WebhookSource::Square => {
565                let merchant_id = context.merchant_id.as_ref()
566                    .ok_or_else(|| WebhookError::MissingContext("merchant_id".into()))?;
567                parse_square_webhook(body, &self.privacy, merchant_id)?
568            }
569            WebhookSource::Stripe => {
570                // TODO: Implement Stripe parsing
571                return Err(WebhookError::NotImplemented("Stripe parsing".into()));
572            }
573            WebhookSource::Unknown => unreachable!(),
574        };
575        
576        info!(
577            source = ?source,
578            fingerprint = %transaction.fingerprint,
579            amount_cents = transaction.amount_cents,
580            "Transaction normalized"
581        );
582        
583        Ok(transaction)
584    }
585    
586    /// Get the appropriate Fidel secret based on event type
587    fn get_fidel_secret(&self, body: &[u8]) -> Result<String, WebhookError> {
588        // Quick peek to determine event type
589        #[derive(Deserialize)]
590        struct EventPeek {
591            #[serde(rename = "type")]
592            event_type: String,
593        }
594        
595        let peek: EventPeek = serde_json::from_slice(body)
596            .map_err(|e| WebhookError::ParseError(format!("Cannot determine event type: {}", e)))?;
597        
598        let secret = match peek.event_type.as_str() {
599            "transaction.auth" => &self.secrets.fidel.auth,
600            "transaction.clearing" => &self.secrets.fidel.clearing,
601            "card.linked" => &self.secrets.fidel.card_linked,
602            _ => &self.secrets.fidel.clearing, // Default
603        };
604        
605        if secret.is_empty() {
606            return Err(WebhookError::MissingSecret(format!("Fidel {}", peek.event_type)));
607        }
608        
609        Ok(secret.clone())
610    }
611}
612
613/// Additional context for webhook processing
614#[derive(Debug, Default)]
615pub struct WebhookContext {
616    /// Webhook URL (needed for Square signature)
617    pub webhook_url: Option<String>,
618    /// Merchant ID (for Square)
619    pub merchant_id: Option<String>,
620}
621
622// ============================================================================
623// ERRORS
624// ============================================================================
625
626#[derive(Debug, Clone)]
627pub enum WebhookError {
628    /// Signature header invalid or malformed
629    InvalidSignature(String),
630    /// Signature verification failed
631    SignatureVerificationFailed,
632    /// Could not parse webhook payload
633    ParseError(String),
634    /// Unknown webhook source
635    UnknownSource,
636    /// Missing required secret
637    MissingSecret(String),
638    /// Missing required context
639    MissingContext(String),
640    /// Feature not implemented
641    NotImplemented(String),
642    /// Initialization error
643    InitError(String),
644}
645
646impl std::fmt::Display for WebhookError {
647    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
648        match self {
649            Self::InvalidSignature(msg) => write!(f, "Invalid signature: {}", msg),
650            Self::SignatureVerificationFailed => write!(f, "Signature verification failed"),
651            Self::ParseError(msg) => write!(f, "Parse error: {}", msg),
652            Self::UnknownSource => write!(f, "Unknown webhook source"),
653            Self::MissingSecret(name) => write!(f, "Missing secret: {}", name),
654            Self::MissingContext(name) => write!(f, "Missing context: {}", name),
655            Self::NotImplemented(feature) => write!(f, "Not implemented: {}", feature),
656            Self::InitError(msg) => write!(f, "Initialization error: {}", msg),
657        }
658    }
659}
660
661impl std::error::Error for WebhookError {}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    
667    #[test]
668    fn detect_fidel_source() {
669        let mut headers = HashMap::new();
670        headers.insert("x-fidel-signature".to_string(), "sha256=abc".to_string());
671        
672        assert_eq!(WebhookSource::detect(&headers), WebhookSource::Fidel);
673    }
674    
675    #[test]
676    fn detect_square_source() {
677        let mut headers = HashMap::new();
678        headers.insert("x-square-signature".to_string(), "abc".to_string());
679        
680        assert_eq!(WebhookSource::detect(&headers), WebhookSource::Square);
681    }
682    
683    #[test]
684    fn detect_unknown_source() {
685        let headers = HashMap::new();
686        assert_eq!(WebhookSource::detect(&headers), WebhookSource::Unknown);
687    }
688}