diff --git a/functions/dist/billing/revenueCatWebhook.js b/functions/dist/billing/revenueCatWebhook.js index c680faaf..719c70de 100644 --- a/functions/dist/billing/revenueCatWebhook.js +++ b/functions/dist/billing/revenueCatWebhook.js @@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.revenueCatWebhook = void 0; +const crypto = __importStar(require("crypto")); const functions = __importStar(require("firebase-functions")); const entitlementLogic_1 = require("./entitlementLogic"); /** @@ -42,29 +43,31 @@ const entitlementLogic_1 = require("./entitlementLogic"); * Path: POST /revenueCatWebhook (registered as an HTTPS function) * Triggered by RevenueCat server-to-server events. * - * Security: - * - Real signature verification is intentionally a placeholder. Add Ed25519 - * signature verification using process.env.REVENUECAT_SIGNING_KEY before - * shipping to production. - * - The function immediately acknowledges valid-looking events so RevenueCat - * does not retry; downstream failures are logged and should be monitored. + * Authentication: + * - Verifies the Ed25519 signature in the X-Signature request header. + * - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519 + * public key from your RevenueCat project dashboard. + * - Missing key → 500 (our config error). Invalid/absent signature → 401. * * Processing: * - Parses the RevenueCat event payload. * - Writes entitlement state to Firestore at users/{userId}/entitlements. - * - Handles TRANSFER, RENEWAL, CANCELLATION, EXPIRATION, and BILLING_ISSUE. */ exports.revenueCatWebhook = functions.https.onRequest(async (req, res) => { var _a; - // Only accept POST requests. if (req.method !== 'POST') { res.status(405).json({ error: 'method_not_allowed' }); return; } - // TODO: verify RevenueCat webhook signature / secret before trusting payload. - // Placeholder verification returns true for now. - const verified = verifyRevenueCatWebhook(req); - if (!verified) { + try { + verifySignature(req); + } + catch (err) { + if (err instanceof ConfigError) { + console.error('[revenueCatWebhook] configuration error:', err.message); + res.status(500).json({ error: 'internal_error' }); + return; + } res.status(401).json({ error: 'unauthorized' }); return; } @@ -82,13 +85,59 @@ exports.revenueCatWebhook = functions.https.onRequest(async (req, res) => { console.error('[revenueCatWebhook] entitlement sync failed:', err); } }); +class ConfigError extends Error { +} +class AuthError extends Error { +} /** - * Placeholder webhook verification. - * Replace with real Ed25519 signature verification before production. + * Verifies the Ed25519 signature RevenueCat sends in the X-Signature header. + * + * Throws ConfigError when REVENUECAT_SIGNING_KEY is absent or malformed — + * these are deployment problems, not auth failures. + * Throws AuthError for any missing or invalid signature — these are 401s. */ -function verifyRevenueCatWebhook( -// eslint-disable-next-line @typescript-eslint/no-unused-vars -_req) { - return true; +function verifySignature(req) { + const signingKey = process.env.REVENUECAT_SIGNING_KEY; + if (!signingKey) { + throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set'); + } + // Firebase Functions expose the raw request body as req.rawBody (Buffer). + const rawBody = req.rawBody; + if (!rawBody || rawBody.length === 0) { + throw new AuthError('request body missing'); + } + const sigHeader = req.headers['x-signature']; + const signature = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader; + if (!signature) { + throw new AuthError('x-signature header missing'); + } + let publicKey; + try { + publicKey = crypto.createPublicKey({ + key: Buffer.from(signingKey, 'base64'), + format: 'der', + type: 'spki', + }); + } + catch (_a) { + throw new ConfigError('REVENUECAT_SIGNING_KEY is malformed — expected base64-encoded DER/SPKI Ed25519 public key'); + } + let sigBuffer; + try { + sigBuffer = Buffer.from(signature, 'base64'); + } + catch (_b) { + throw new AuthError('x-signature is not valid base64'); + } + let valid; + try { + valid = crypto.verify(null, rawBody, publicKey, sigBuffer); + } + catch (_c) { + throw new AuthError('signature verification failed'); + } + if (!valid) { + throw new AuthError('signature mismatch'); + } } //# sourceMappingURL=revenueCatWebhook.js.map \ No newline at end of file diff --git a/functions/dist/billing/revenueCatWebhook.js.map b/functions/dist/billing/revenueCatWebhook.js.map index 000a1389..3e324b99 100644 --- a/functions/dist/billing/revenueCatWebhook.js.map +++ b/functions/dist/billing/revenueCatWebhook.js.map @@ -1 +1 @@ -{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,yDAA4E;AAE5E;;;;;;;;;;;;;;;;;GAiBG;AACU,QAAA,iBAAiB,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE;;IAC5E,6BAA6B;IAC7B,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QAC1B,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC,CAAA;QACrD,OAAM;IACR,CAAC;IAED,8EAA8E;IAC9E,iDAAiD;IACjD,MAAM,QAAQ,GAAG,uBAAuB,CAAC,GAAG,CAAC,CAAA;IAC7C,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAA;QAC/C,OAAM;IACR,CAAC;IAED,MAAM,KAAK,GAAG,MAAA,GAAG,CAAC,IAAI,0CAAE,KAAqC,CAAA;IAC7D,IAAI,CAAC,KAAK,IAAI,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;QAChD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,EAAE,CAAC,CAAA;QACpD,OAAM;IACR,CAAC;IAED,uDAAuD;IACvD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IAExC,IAAI,CAAC;QACH,MAAM,IAAA,wCAAqB,EAAC,KAAK,CAAC,CAAA;IACpC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,8CAA8C,EAAE,GAAG,CAAC,CAAA;IACpE,CAAC;AACH,CAAC,CAAC,CAAA;AAEF;;;GAGG;AACH,SAAS,uBAAuB;AAC9B,6DAA6D;AAC7D,IAA6B;IAE7B,OAAO,IAAI,CAAA;AACb,CAAC"} \ No newline at end of file +{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/src/billing/revenueCatWebhook.ts b/functions/src/billing/revenueCatWebhook.ts index 89d4cd70..46c8136c 100644 --- a/functions/src/billing/revenueCatWebhook.ts +++ b/functions/src/billing/revenueCatWebhook.ts @@ -1,3 +1,4 @@ +import * as crypto from 'crypto' import * as functions from 'firebase-functions' import { applyEntitlementEvent, EntitlementEvent } from './entitlementLogic' @@ -7,29 +8,30 @@ import { applyEntitlementEvent, EntitlementEvent } from './entitlementLogic' * Path: POST /revenueCatWebhook (registered as an HTTPS function) * Triggered by RevenueCat server-to-server events. * - * Security: - * - Real signature verification is intentionally a placeholder. Add Ed25519 - * signature verification using process.env.REVENUECAT_SIGNING_KEY before - * shipping to production. - * - The function immediately acknowledges valid-looking events so RevenueCat - * does not retry; downstream failures are logged and should be monitored. + * Authentication: + * - Verifies the Ed25519 signature in the X-Signature request header. + * - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519 + * public key from your RevenueCat project dashboard. + * - Missing key → 500 (our config error). Invalid/absent signature → 401. * * Processing: * - Parses the RevenueCat event payload. * - Writes entitlement state to Firestore at users/{userId}/entitlements. - * - Handles TRANSFER, RENEWAL, CANCELLATION, EXPIRATION, and BILLING_ISSUE. */ export const revenueCatWebhook = functions.https.onRequest(async (req, res) => { - // Only accept POST requests. if (req.method !== 'POST') { res.status(405).json({ error: 'method_not_allowed' }) return } - // TODO: verify RevenueCat webhook signature / secret before trusting payload. - // Placeholder verification returns true for now. - const verified = verifyRevenueCatWebhook(req) - if (!verified) { + try { + verifySignature(req) + } catch (err) { + if (err instanceof ConfigError) { + console.error('[revenueCatWebhook] configuration error:', err.message) + res.status(500).json({ error: 'internal_error' }) + return + } res.status(401).json({ error: 'unauthorized' }) return } @@ -50,13 +52,62 @@ export const revenueCatWebhook = functions.https.onRequest(async (req, res) => { } }) +class ConfigError extends Error {} +class AuthError extends Error {} + /** - * Placeholder webhook verification. - * Replace with real Ed25519 signature verification before production. + * Verifies the Ed25519 signature RevenueCat sends in the X-Signature header. + * + * Throws ConfigError when REVENUECAT_SIGNING_KEY is absent or malformed — + * these are deployment problems, not auth failures. + * Throws AuthError for any missing or invalid signature — these are 401s. */ -function verifyRevenueCatWebhook( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _req: functions.https.Request -): boolean { - return true +function verifySignature(req: functions.https.Request): void { + const signingKey = process.env.REVENUECAT_SIGNING_KEY + if (!signingKey) { + throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set') + } + + // Firebase Functions expose the raw request body as req.rawBody (Buffer). + const rawBody = (req as unknown as { rawBody?: Buffer }).rawBody + if (!rawBody || rawBody.length === 0) { + throw new AuthError('request body missing') + } + + const sigHeader = req.headers['x-signature'] + const signature = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader + if (!signature) { + throw new AuthError('x-signature header missing') + } + + let publicKey: crypto.KeyObject + try { + publicKey = crypto.createPublicKey({ + key: Buffer.from(signingKey, 'base64'), + format: 'der', + type: 'spki', + }) + } catch { + throw new ConfigError( + 'REVENUECAT_SIGNING_KEY is malformed — expected base64-encoded DER/SPKI Ed25519 public key' + ) + } + + let sigBuffer: Buffer + try { + sigBuffer = Buffer.from(signature, 'base64') + } catch { + throw new AuthError('x-signature is not valid base64') + } + + let valid: boolean + try { + valid = crypto.verify(null, rawBody, publicKey, sigBuffer) + } catch { + throw new AuthError('signature verification failed') + } + + if (!valid) { + throw new AuthError('signature mismatch') + } }