import { Router, Request, Response } from 'express' import { syncEntitlement } from '../services/entitlement' import { RevenueCatEvent } from '../types' import { getEnv } from '../config/env' import * as crypto from 'crypto' const router = Router() /** * Verifies RevenueCat webhook signature using Ed25519. * RevenueCat signs the raw request body with their signing key. * If REVENUECAT_SIGNING_KEY is not set, logs warning but skips verification (dev mode). */ function verifyRevenueCatSignature(req: Request): boolean { const signingKey = process.env.REVENUECAT_SIGNING_KEY // If signing key is not configured, skip verification (development mode) if (!signingKey || signingKey.trim() === '') { console.warn('[webhook] REVENUECAT_SIGNING_KEY not set — skipping signature verification') return true } const signatureHeader = req.headers['x-revenuecat-signature'] if (!signatureHeader) { console.error('[webhook] Missing X-RevenueCat-Signature header') return false } // The raw body should have been captured by middleware const rawBody = (req as any).rawBody if (!rawBody || !Buffer.isBuffer(rawBody)) { console.error('[webhook] Raw body not available for signature verification') return false } try { // RevenueCat sends base64-encoded Ed25519 signature const signature = Buffer.from(signatureHeader, 'base64') const publicKey = Buffer.from(signingKey, 'base64') // Verify using Ed25519 const verify = crypto.createVerify('Ed25519') verify.update(rawBody) return verify.verify(publicKey, signature) } catch (err) { console.error('[webhook] Signature verification failed:', err) return false } } /** * Verifies RevenueCat webhook secret using constant-time comparison. * DEPRECATED: Now uses signature verification. Kept for backwards compatibility. */ function verifyRevenueCatSecret(req: Request): boolean { try { const secret = getEnv('REVENUECAT_WEBHOOK_SECRET') const auth = req.headers['authorization'] ?? '' // Constant-time comparison to prevent timing attacks const authBuffer = Buffer.from(auth) const secretBuffer = Buffer.from(secret) // Use timingSafeEqual with fixed buffer length to prevent length leakage const maxLength = Math.max(authBuffer.length, secretBuffer.length) const paddedAuth = Buffer.alloc(maxLength) const paddedSecret = Buffer.alloc(maxLength) authBuffer.copy(paddedAuth) secretBuffer.copy(paddedSecret) return crypto.timingSafeEqual(paddedAuth, paddedSecret) } catch (err) { console.error('[webhook] Secret verification error:', err) return false } } router.post('/revenuecat', async (req: Request, res: Response) => { // Try signature verification first (modern) const signatureValid = verifyRevenueCatSignature(req) if (!signatureValid) { res.status(401).json({ error: 'unauthorized' }) return } // Fallback to secret verification if no signature (legacy) if (!signatureValid && !verifyRevenueCatSecret(req)) { res.status(401).json({ error: 'unauthorized' }) return } const body = req.body as RevenueCatEvent if (!body?.event?.type || !body?.event?.app_user_id) { res.status(400).json({ error: 'malformed payload' }) return } // Acknowledge immediately — RC retries if we don't respond within 10s res.status(200).json({ received: true }) try { await syncEntitlement(body) } catch (err) { console.error('[webhook] entitlement sync failed:', err) } }) export default router