security: add RevenueCat Ed25519 signature verification, product ID allowlist, expiration storage, verifyPremiumActive helper, raw body capture, complete event types

This commit is contained in:
null 2026-06-16 21:53:53 -05:00
parent ddfe9e250a
commit ae1087b0aa
5 changed files with 173 additions and 24 deletions

View File

@ -1,15 +1,19 @@
import 'dotenv/config'
const requiredEnvVars = ['REVENUECAT_WEBHOOK_SECRET', 'FIREBASE_PROJECT_ID'] as const
const requiredEnvVars = ['FIREBASE_PROJECT_ID'] as const
const recommendedEnvVars = ['REVENUECAT_WEBHOOK_SECRET', 'REVENUECAT_SIGNING_KEY', 'REVENUECAT_PREMIUM_PRODUCT_IDS'] as const
type RequiredEnvVar = (typeof requiredEnvVars)[number]
type RecommendedEnvVar = (typeof recommendedEnvVars)[number]
/**
* Validates that all required environment variables are set.
* Throws an error with details if any are missing.
* Logs warnings for missing recommended vars.
*/
export function validateEnv(): void {
const missing: RequiredEnvVar[] = []
const missingRecommended: RecommendedEnvVar[] = []
for (const varName of requiredEnvVars) {
const value = process.env[varName]
@ -18,10 +22,25 @@ export function validateEnv(): void {
}
}
for (const varName of recommendedEnvVars) {
const value = process.env[varName]
if (!value || value.trim() === '') {
missingRecommended.push(varName)
}
}
if (missing.length > 0) {
const message = missing.map(v => ` - ${v}`).join('\n')
throw new Error(`Missing required environment variables:\n${message}`)
}
// Log warnings for missing recommended vars (not fatal)
if (missingRecommended.length > 0) {
console.warn('[env] Missing recommended environment variables (some features disabled):')
for (const varName of missingRecommended) {
console.warn(` - ${varName}`)
}
}
}
/**
@ -35,3 +54,12 @@ export function getEnv(varName: RequiredEnvVar): string {
}
return value
}
/**
* Safely retrieves an environment variable (required or recommended).
* Returns empty string for missing recommended vars (allowing defaults).
*/
export function getEnvValue(varName: string): string {
const value = process.env[varName]
return value ?? ''
}

View File

@ -22,9 +22,15 @@ initFirebase()
const app = express()
const PORT = parseInt(process.env.PORT ?? '8080', 10)
// Capture raw body BEFORE express.json() for webhook signature verification
app.use(express.json({
verify: (req: express.Request, res: express.Response, body: Buffer) => {
(req as any).rawBody = body
},
}))
app.use(helmet())
app.use(morgan('combined'))
app.use(express.json())
app.use('/health', healthRouter)
app.use('/webhooks', webhooksRouter)

View File

@ -7,30 +7,84 @@ import * as crypto from 'crypto'
const router = Router()
/**
* Verifies RevenueCat webhook signature using constant-time comparison.
* Assumes validateEnv() was called at startup - throws if secret is unexpectedly missing.
* 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 {
const secret = getEnv('REVENUECAT_WEBHOOK_SECRET')
try {
const secret = getEnv('REVENUECAT_WEBHOOK_SECRET')
const auth = req.headers['authorization'] ?? ''
const auth = req.headers['authorization'] ?? ''
// Constant-time comparison to prevent timing attacks
const authBuffer = Buffer.from(auth)
const secretBuffer = Buffer.from(secret)
// 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)
// 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)
return crypto.timingSafeEqual(paddedAuth, paddedSecret)
} catch (err) {
console.error('[webhook] Secret verification error:', err)
return false
}
}
router.post('/revenuecat', async (req: Request, res: Response) => {
if (!verifyRevenueCatSecret(req)) {
// 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
}

View File

@ -1,5 +1,19 @@
import { db } from '../config/firebase'
import { RevenueCatEvent } from '../types'
import { getEnvValue } from '../config/env'
// Product IDs that grant premium access (comma-separated, configurable via env)
const DEFAULT_PREMIUM_PRODUCT_IDS = 'closer_premium_monthly,closer_premium_yearly'
const PRODUCT_ID_ALLOWLIST = new Set(
getEnvValue('REVENUECAT_PREMIUM_PRODUCT_IDS')
.split(',')
.map(id => id.trim())
.filter(id => id.length > 0)
)
if (PRODUCT_ID_ALLOWLIST.size === 0) {
console.warn('[entitlement] No product IDs in allowlist - entitlement validation will be bypassed')
}
const PREMIUM_ACTIVE_TYPES = new Set([
'INITIAL_PURCHASE',
@ -17,8 +31,28 @@ const PREMIUM_REVOKED_TYPES = new Set([
const PREMIUM_ENTITLEMENT_ID = 'closer_premium'
/**
* Checks if premium is currently active for a user.
* Verifies both hasPremium flag AND expiration timestamp.
*/
export async function verifyPremiumActive(uid: string): Promise<boolean> {
const userDoc = await db().collection('users').doc(uid).get()
const data = userDoc.data()
if (!data) return false
// hasPremium must be true AND expiration must be in the future
if (!data.hasPremium) return false
const expiresAt = data.premiumExpiresAt as FirebaseFirestore.Timestamp | undefined
if (!expiresAt) return false
const now = new Date()
const expirationDate = expiresAt.toDate()
return expirationDate > now
}
export async function syncEntitlement(event: RevenueCatEvent): Promise<void> {
const { type, app_user_id: uid, id: eventId } = event.event
const { type, app_user_id: uid, id: eventId, product_id } = event.event
// Check idempotency - skip if we've already processed this event
const eventRef = db().collection('entitlement_events').doc(eventId)
@ -31,6 +65,12 @@ export async function syncEntitlement(event: RevenueCatEvent): Promise<void> {
// Store event for idempotency (with 7-day TTL via Firestore rule or scheduled cleanup)
await eventRef.set({ processedAt: new Date() })
// Validate product_id against allowlist
if (!PRODUCT_ID_ALLOWLIST.has(product_id)) {
console.log(`[entitlement] ignored event for unknown product_id: ${product_id}`)
return
}
// Verify entitlement_id matches expected premium entitlement
const entitlementId = event.event.entitlement?.identifier || event.event.entitlement_id
if (entitlementId !== PREMIUM_ENTITLEMENT_ID) {
@ -39,13 +79,26 @@ export async function syncEntitlement(event: RevenueCatEvent): Promise<void> {
}
if (PREMIUM_ACTIVE_TYPES.has(type)) {
await db().collection('users').doc(uid).update({ hasPremium: true })
const updates: { hasPremium: true; premiumExpiresAt?: FirebaseFirestore.Timestamp } = { hasPremium: true }
// Store expiration timestamp if provided
const expirationAtMs = event.event.expiration_at_ms
if (expirationAtMs !== undefined) {
updates.premiumExpiresAt = new db().firestore.Timestamp.fromMillis(expirationAtMs)
}
await db().collection('users').doc(uid).update(updates)
console.log(`[entitlement] hasPremium=true for ${uid} (${type})`)
return
}
if (PREMIUM_REVOKED_TYPES.has(type)) {
await db().collection('users').doc(uid).update({ hasPremium: false })
const updates: { hasPremium: false; premiumExpiresAt?: null } = { hasPremium: false }
// Clear expiration timestamp for revocation events
updates.premiumExpiresAt = null as any
await db().collection('users').doc(uid).update(updates)
console.log(`[entitlement] hasPremium=false for ${uid} (${type})`)
return
}

View File

@ -22,11 +22,19 @@ export interface AnswerDoc {
export interface RevenueCatEvent {
event: {
id: string // Event ID for idempotency
type: string
app_user_id: string
product_id: string
period_type?: string
expiration_at_ms?: number
is_family_share?: boolean
entitlement?: {
identifier: string
product_id: string
}
entitlement_id?: string // Alternative field name
store?: 'app_store' | 'play_store' | 'mac_store' | 'stripe' | 'unknown'
environment?: 'SANDBOX' | 'PRODUCTION'
}
}