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, throws an error (fail-closed). */ function verifyRevenueCatSignature(req: Request): boolean { const signingKey = process.env.REVENUECAT_SIGNING_KEY // If signing key is not configured, fail closed (not dev mode) if (!signingKey || signingKey.trim() === '') { console.error('[webhook] REVENUECAT_SIGNING_KEY not set — rejecting all requests (fail-closed)') throw new Error('REVENUECAT_SIGNING_KEY must be set in production') } 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, prefered) try { if (!verifyRevenueCatSignature(req)) { res.status(401).json({ error: 'unauthorized' }) return } } catch (err: any) { // If signature verification throws (e.g., missing key), reject with 500 console.error('[webhook] Signature verification error:', err) res.status(500).json({ error: 'signature_verification_failed', message: err.message }) 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