security: add RevenueCat Ed25519 signature verification, product ID allowlist, expiration storage, verifyPremiumActive helper, raw body capture, complete event types
This commit is contained in:
parent
ddfe9e250a
commit
ae1087b0aa
|
|
@ -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 ?? ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue