From 89d06eeb7d3a5b3245bbc39828e951f731eb1c79 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 9 Jul 2026 03:43:59 -0500 Subject: [PATCH] fix(billing): correct RevenueCat webhook auth to HMAC-SHA256 + enable export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webhook verified an Ed25519 signature in an X-Signature header, but RevenueCat offers no public-key signing — it sends HMAC-SHA256 in X-RevenueCat-Webhook-Signature (t=,v1=) computed over ".". As written, every real event would have 401'd and premium would never sync for the partner. - Rewrite verification to HMAC-SHA256 with a +/-5-min timestamp replay guard and a constant-time compare; extract a pure verifyWebhookSignature() for unit testing. - Rename secret REVENUECAT_SIGNING_KEY -> REVENUECAT_WEBHOOK_SECRET (it is an HMAC secret, not an Ed25519 key). Never deployed, so no migration. - Uncomment the export in index.ts (deploy still gated on seeding the secret). - Add revenueCatWebhook.test.ts: valid / tampered / wrong-secret / missing / stale. - Reconcile Future.md + Engineering_Reference_Manual.md to the real scheme. Verified against the live account: the entitlement identifier is now closer_premium, so events match entitlementLogic. Build + 80 tests green. Co-Authored-By: Claude Opus 4.8 --- Future.md | 19 +-- docs/Engineering_Reference_Manual.md | 6 +- functions/dist/billing/revenueCatWebhook.js | 117 +++++++++------- .../dist/billing/revenueCatWebhook.js.map | 2 +- .../dist/billing/revenueCatWebhook.test.js | 90 +++++++++++++ .../billing/revenueCatWebhook.test.js.map | 1 + functions/dist/index.js | 15 ++- functions/dist/index.js.map | 2 +- .../src/billing/revenueCatWebhook.test.ts | 74 ++++++++++ functions/src/billing/revenueCatWebhook.ts | 126 +++++++++++------- functions/src/index.ts | 12 +- 11 files changed, 345 insertions(+), 119 deletions(-) create mode 100644 functions/dist/billing/revenueCatWebhook.test.js create mode 100644 functions/dist/billing/revenueCatWebhook.test.js.map create mode 100644 functions/src/billing/revenueCatWebhook.test.ts diff --git a/Future.md b/Future.md index 8e38c7cd..5d528688 100644 --- a/Future.md +++ b/Future.md @@ -195,14 +195,17 @@ no `auth.user().onDelete`). Deploy-day learnings + deferred items: nodejs24 was evaluated and rejected: **2nd-gen only**, and the runtime is codebase-wide (gen-1 `onUserDelete` blocks it). **Next forced bump:** Node 22 is deprecated 2027-04-30 / decommissioned 2027-10-31 — by then either nodejs24 supports gen 1, or `onUserDelete` needs migrating off the gen-1 auth trigger first. -- **RevenueCat webhook re-enable (when billing is set up).** RevenueCat has no account yet, so the migrated v2 - `revenueCatWebhook` is **not deployed** — its export is commented out in `functions/src/index.ts` because - `defineSecret('REVENUECAT_SIGNING_KEY')` is validated codebase-wide at deploy (a missing secret fails *every* - function's deploy, even with `--only`). To enable: ① create the RevenueCat project + webhook, ② seed the real - Ed25519 public key — `firebase functions:secrets:set REVENUECAT_SIGNING_KEY`, ③ uncomment the export, - ④ **delete the old dormant v1 `revenueCatWebhook` first** (still deployed; same-name v1→v2 is blocked - in-place), ⑤ `firebase deploy --only functions:revenueCatWebhook`, ⑥ point RevenueCat at the function URL and - send a test event. +- **RevenueCat webhook deploy (gated on the signing secret).** The v2 `revenueCatWebhook` is code-complete + and its export is now **live** in `functions/src/index.ts`, but it is **not deployed yet** — deploy waits on + the secret. Auth is **HMAC-SHA256** (RevenueCat's webhook signature verification): the handler reads + `X-RevenueCat-Webhook-Signature: t=,v1=` and verifies `HMAC-SHA256(".")` against + `REVENUECAT_WEBHOOK_SECRET`, constant-time, with a ±5-min timestamp replay guard. Because that + `defineSecret` is validated codebase-wide at deploy (a missing secret fails *every* function's deploy, even + with `--only`), the secret must exist first. To deploy: ① in the RevenueCat dashboard create the webhook + + enable signature verification, copy the signing secret, ② `firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET`, + ③ **delete the old dormant v1 `revenueCatWebhook` first** (still deployed; same-name v1→v2 is blocked + in-place), ④ `firebase deploy --only functions:revenueCatWebhook`, ⑤ point RevenueCat at the function URL and + send a test event. (The `closer_premium` entitlement already exists in the RevenueCat project.) ## Roadmap — features & strategy (consolidated from the old `FUTURE.md`, 2026-06-28) diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 8cc4daee..0cbf059c 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -843,7 +843,7 @@ Every function module follows the same shape: `revenueCatWebhook` processes the entitlement write (`applyEntitlementEvent`) **before** returning the HTTP 200 acknowledgement. If processing fails, the function returns HTTP 500 so RevenueCat will retry. This changed from an earlier implementation that acked 200 immediately and silently dropped failures. `applyEntitlementEvent` is idempotent, so retries are safe. `entitlement_events/{eventId}` remains the idempotency marker. -**The webhook handler is currently NOT exported from `index.ts` and is therefore not deployed.** RevenueCat has no project yet, so exporting it would force a `REVENUECAT_SIGNING_KEY` Secret Manager entry to validate codebase-wide (a missing secret fails *every* function's deploy). To enable: create the RevenueCat project, seed the real Ed25519 public key (`firebase functions:secrets:set REVENUECAT_SIGNING_KEY`), uncomment the export, delete the old dormant v1 webhook (same-name v1->v2 is blocked in-place), and deploy. See `functions/src/index.ts` for the precise comments. The webhook handler does not currently use a dead-letter queue. **Risk**: repeated 500s could still lose events if RevenueCat gives up. The mitigation today is the idempotency marker: re-running the webhook for a missing event would dedup on event ID. A future fix should add a Cloud Tasks-based retry or a dead-letter `entitlement_events_failed/` collection. +**The webhook handler is now exported from `index.ts` but is NOT deployed yet — deploy is gated on the signing secret.** The export binds `defineSecret('REVENUECAT_WEBHOOK_SECRET')`, validated codebase-wide at deploy (a missing secret fails *every* function's deploy). To deploy: in the RevenueCat dashboard create the webhook and enable signature verification, copy the signing secret, `firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET`, delete the old dormant v1 webhook (same-name v1->v2 is blocked in-place), and deploy. See `functions/src/index.ts` for the precise comments. The webhook handler does not currently use a dead-letter queue. **Risk**: repeated 500s could still lose events if RevenueCat gives up. The mitigation today is the idempotency marker: re-running the webhook for a missing event would dedup on event ID. A future fix should add a Cloud Tasks-based retry or a dead-letter `entitlement_events_failed/` collection. ### Schedule @@ -902,12 +902,12 @@ Callers collect `isPremium()` reactively rather than caching a one-time snapshot `functions/src/billing/revenueCatWebhook.ts`: - **Path**: HTTPS, POST only. GETs return 405. -- **Auth**: Ed25519 signature verification. `REVENUECAT_SIGNING_KEY` env var holds the base64-encoded DER/SPKI public key. Missing key → 500 (config error). Invalid/missing signature → 401. +- **Auth**: HMAC-SHA256 signature verification (RevenueCat's webhook signing — there is no Ed25519/public-key option). RevenueCat sends `X-RevenueCat-Webhook-Signature: t=,v1=`; the handler recomputes `HMAC-SHA256(".")` with `REVENUECAT_WEBHOOK_SECRET` (the integration's signing secret), constant-time-compares against `v1`, and rejects timestamps outside a ±5-min window (replay guard). Missing secret → 500 (config error). Invalid/missing signature or stale timestamp → 401. - **Body**: RevenueCat event payload. Malformed payload → 400. - **Flow**: `applyEntitlementEvent(event)` runs **before** the HTTP 200 acknowledgement. If it throws, the function returns HTTP 500 so RevenueCat retries. The function is idempotent, so retries are safe. - **Idempotency**: `entitlement_events/{eventId}` records processed events. Re-delivered events are dropped. -**Currently NOT deployed** -- the export is commented out in `functions/src/index.ts`. See [Webhook reliability](#webhook-reliability) for the enable steps. +**Not deployed yet** -- the export is live in `functions/src/index.ts` but deploy is gated on seeding `REVENUECAT_WEBHOOK_SECRET`. See [Webhook reliability](#webhook-reliability) for the deploy steps. ### Premium-gated features and gate pattern diff --git a/functions/dist/billing/revenueCatWebhook.js b/functions/dist/billing/revenueCatWebhook.js index a8d1cbae..37c4114b 100644 --- a/functions/dist/billing/revenueCatWebhook.js +++ b/functions/dist/billing/revenueCatWebhook.js @@ -33,7 +33,8 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -exports.revenueCatWebhook = void 0; +exports.AuthError = exports.ConfigError = exports.revenueCatWebhook = void 0; +exports.verifyWebhookSignature = verifyWebhookSignature; const crypto = __importStar(require("crypto")); const https_1 = require("firebase-functions/v2/https"); const params_1 = require("firebase-functions/params"); @@ -45,26 +46,34 @@ const log_1 = require("../log"); * Path: POST /revenueCatWebhook (registered as an HTTPS function) * Triggered by RevenueCat server-to-server events. * - * 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. It is bound as a Secret - * Manager secret (below) and injected into process.env at runtime. - * - Missing key → 500 (our config error). Invalid/absent signature → 401. + * Authentication — HMAC-SHA256 (RevenueCat's webhook "signature verification"): + * - RevenueCat sends `X-RevenueCat-Webhook-Signature: t=,v1=`. + * - v1 = HMAC-SHA256(secret, "."), hex-encoded, computed over the RAW request + * body bytes exactly as received (before any JSON parsing / re-serialization). + * - REVENUECAT_WEBHOOK_SECRET is the integration's signing secret from the RevenueCat + * dashboard (Integrations → Webhooks → enable signature verification). It is bound as a + * Secret Manager secret (below) and injected into process.env at runtime. + * - Missing secret → 500 (our config error). Missing/invalid signature, or a timestamp + * outside the tolerance window (replay guard) → 401. + * + * NOTE: RevenueCat does NOT offer an Ed25519 / public-key signing scheme — HMAC-SHA256 with a + * shared secret is the only cryptographic option (plus the optional static Authorization header). * * Processing: * - Parses the RevenueCat event payload. * - Writes entitlement state to Firestore at users/{userId}/entitlements. */ -const revenueCatSigningKey = (0, params_1.defineSecret)('REVENUECAT_SIGNING_KEY'); -exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatSigningKey] }, async (req, res) => { +const revenueCatWebhookSecret = (0, params_1.defineSecret)('REVENUECAT_WEBHOOK_SECRET'); +/** Reject signatures whose timestamp is further than this from now (replay protection). */ +const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000; +exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatWebhookSecret] }, async (req, res) => { var _a; if (req.method !== 'POST') { res.status(405).json({ error: 'method_not_allowed' }); return; } try { - verifySignature(req); + verifyRequest(req, Date.now()); } catch (err) { if (err instanceof ConfigError) { @@ -96,56 +105,74 @@ exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatSigning }); class ConfigError extends Error { } +exports.ConfigError = ConfigError; class AuthError extends Error { } +exports.AuthError = AuthError; /** - * 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. + * Reads the secret + signature header off the request and delegates to the pure verifier. + * Throws ConfigError when our secret is unset (→500); AuthError for any auth failure (→401). */ -function verifySignature(req) { - const signingKey = process.env.REVENUECAT_SIGNING_KEY; - if (!signingKey) { - throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set'); +function verifyRequest(req, nowMs) { + const secret = process.env.REVENUECAT_WEBHOOK_SECRET; + if (!secret) { + throw new ConfigError('REVENUECAT_WEBHOOK_SECRET environment variable is not set'); } // Firebase Functions expose the raw request body as req.rawBody (Buffer). const rawBody = req.rawBody; + const sigHeader = req.headers['x-revenuecat-webhook-signature']; + const header = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader; + verifyWebhookSignature({ rawBody, header, secret, nowMs }); +} +/** + * Pure, testable HMAC-SHA256 verification of a RevenueCat webhook signature. + * + * Parses `t=,v1=` from the header, recomputes HMAC-SHA256 over "." + * with the signing secret, constant-time-compares against v1, and rejects timestamps outside + * the tolerance window. Throws AuthError on any failure (caller maps to 401). + */ +function verifyWebhookSignature(opts) { + var _a; + const { rawBody, header, secret, nowMs } = opts; + const toleranceMs = (_a = opts.toleranceMs) !== null && _a !== void 0 ? _a : SIGNATURE_TOLERANCE_MS; 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'); + if (!header) { + throw new AuthError('signature header missing'); } - let publicKey; - try { - publicKey = crypto.createPublicKey({ - key: Buffer.from(signingKey, 'base64'), - format: 'der', - type: 'spki', - }); + // Parse "t=,v1=" — order-independent, tolerant of extra fields. + let t; + let v1; + for (const part of header.split(',')) { + const seg = part.trim(); + const eq = seg.indexOf('='); + if (eq < 0) + continue; + const key = seg.slice(0, eq); + const val = seg.slice(eq + 1); + if (key === 't') + t = val; + else if (key === 'v1') + v1 = val; } - catch (_a) { - throw new ConfigError('REVENUECAT_SIGNING_KEY is malformed — expected base64-encoded DER/SPKI Ed25519 public key'); + if (!t || !v1) { + throw new AuthError('signature header malformed'); } - let sigBuffer; - try { - sigBuffer = Buffer.from(signature, 'base64'); + const tsMs = Number(t) * 1000; + if (!Number.isFinite(tsMs)) { + throw new AuthError('signature timestamp invalid'); } - catch (_b) { - throw new AuthError('x-signature is not valid base64'); + if (Math.abs(nowMs - tsMs) > toleranceMs) { + throw new AuthError('signature timestamp outside tolerance'); } - let valid; - try { - valid = crypto.verify(null, rawBody, publicKey, sigBuffer); - } - catch (_c) { - throw new AuthError('signature verification failed'); - } - if (!valid) { + // HMAC over the RAW body bytes, prefixed with "." — never re-serialize the JSON. + const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]); + const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'); + // Constant-time compare. timingSafeEqual throws on length mismatch, so guard the length first. + const expectedBuf = Buffer.from(expected, 'utf8'); + const providedBuf = Buffer.from(v1, 'utf8'); + if (expectedBuf.length !== providedBuf.length || !crypto.timingSafeEqual(expectedBuf, providedBuf)) { throw new AuthError('signature mismatch'); } } diff --git a/functions/dist/billing/revenueCatWebhook.js.map b/functions/dist/billing/revenueCatWebhook.js.map index ce1ffff0..3a9b4301 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":"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"} \ 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/dist/billing/revenueCatWebhook.test.js b/functions/dist/billing/revenueCatWebhook.test.js new file mode 100644 index 00000000..84a98b6c --- /dev/null +++ b/functions/dist/billing/revenueCatWebhook.test.js @@ -0,0 +1,90 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +// The verifier under test is pure (crypto only), so we stub the Firebase side-effect +// imports that run at module load rather than exercising them. +jest.mock('firebase-functions/v2/https', () => ({ onRequest: jest.fn() })); +jest.mock('firebase-functions/params', () => ({ defineSecret: jest.fn(() => ({})) })); +jest.mock('../log', () => ({ logger: { log: jest.fn(), error: jest.fn(), warn: jest.fn() } })); +jest.mock('./entitlementLogic', () => ({ applyEntitlementEvent: jest.fn() })); +const crypto = __importStar(require("crypto")); +const revenueCatWebhook_1 = require("./revenueCatWebhook"); +const SECRET = 'whsec_test_shared_secret'; +const NOW = 1700000000000; // fixed "now" in ms +const nowSec = Math.floor(NOW / 1000); +const BODY = JSON.stringify({ event: { type: 'INITIAL_PURCHASE', app_user_id: 'uid1' } }); +/** Build the header RevenueCat would send: `t=,v1=.">`. */ +function sign(body, tsSec, secret = SECRET) { + const raw = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8'); + const payload = Buffer.concat([Buffer.from(`${tsSec}.`, 'utf8'), raw]); + const v1 = crypto.createHmac('sha256', secret).update(payload).digest('hex'); + return `t=${tsSec},v1=${v1}`; +} +function verify(overrides = {}) { + return (0, revenueCatWebhook_1.verifyWebhookSignature)(Object.assign({ rawBody: Buffer.from(BODY), header: sign(BODY, nowSec), secret: SECRET, nowMs: NOW }, overrides)); +} +describe('verifyWebhookSignature', () => { + it('accepts a valid signature', () => { + expect(() => verify()).not.toThrow(); + }); + it('accepts a timestamp within the tolerance window', () => { + expect(() => verify({ header: sign(BODY, nowSec - 4 * 60) })).not.toThrow(); + }); + it('rejects a tampered body', () => { + expect(() => verify({ rawBody: Buffer.from(BODY + ' ') })).toThrow(revenueCatWebhook_1.AuthError); + }); + it('rejects a signature made with the wrong secret', () => { + expect(() => verify({ header: sign(BODY, nowSec, 'wrong-secret') })).toThrow(revenueCatWebhook_1.AuthError); + }); + it('rejects a missing header', () => { + expect(() => verify({ header: undefined })).toThrow(revenueCatWebhook_1.AuthError); + }); + it('rejects a missing body', () => { + expect(() => verify({ rawBody: undefined })).toThrow(revenueCatWebhook_1.AuthError); + }); + it('rejects a header missing v1', () => { + expect(() => verify({ header: `t=${nowSec}` })).toThrow(revenueCatWebhook_1.AuthError); + }); + it('rejects a stale timestamp (replay guard)', () => { + expect(() => verify({ header: sign(BODY, nowSec - 10 * 60) })).toThrow(revenueCatWebhook_1.AuthError); + }); + it('rejects a far-future timestamp', () => { + expect(() => verify({ header: sign(BODY, nowSec + 10 * 60) })).toThrow(revenueCatWebhook_1.AuthError); + }); + it('rejects a wrong-length signature without crashing (timingSafeEqual guard)', () => { + expect(() => verify({ header: `t=${nowSec},v1=deadbeef` })).toThrow(revenueCatWebhook_1.AuthError); + }); +}); +//# sourceMappingURL=revenueCatWebhook.test.js.map \ No newline at end of file diff --git a/functions/dist/billing/revenueCatWebhook.test.js.map b/functions/dist/billing/revenueCatWebhook.test.js.map new file mode 100644 index 00000000..cc8acb20 --- /dev/null +++ b/functions/dist/billing/revenueCatWebhook.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"revenueCatWebhook.test.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.test.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,qFAAqF;AACrF,+DAA+D;AAC/D,IAAI,CAAC,IAAI,CAAC,6BAA6B,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;AAC3E,IAAI,CAAC,IAAI,CAAC,2BAA2B,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,YAAY,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AACtF,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;AAC/F,IAAI,CAAC,IAAI,CAAC,oBAAoB,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,qBAAqB,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;AAE9E,+CAAiC;AACjC,2DAAwE;AAExE,MAAM,MAAM,GAAG,0BAA0B,CAAC;AAC1C,MAAM,GAAG,GAAG,aAAiB,CAAC,CAAC,oBAAoB;AACnD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC;AACtC,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC;AAE1F,gGAAgG;AAChG,SAAS,IAAI,CAAC,IAAqB,EAAE,KAAa,EAAE,MAAM,GAAG,MAAM;IACjE,MAAM,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACrE,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,GAAG,EAAE,MAAM,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;IACvE,MAAM,EAAE,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC7E,OAAO,KAAK,KAAK,OAAO,EAAE,EAAE,CAAC;AAC/B,CAAC;AAED,SAAS,MAAM,CAAC,YAAmE,EAAE;IACnF,OAAO,IAAA,0CAAsB,kBAC3B,OAAO,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,EAC1B,MAAM,EAAE,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,EAC1B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,GAAG,IACP,SAAS,EACZ,CAAC;AACL,CAAC;AAED,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACtC,EAAE,CAAC,2BAA2B,EAAE,GAAG,EAAE;QACnC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iDAAiD,EAAE,GAAG,EAAE;QACzD,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC;IAC9E,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yBAAyB,EAAE,GAAG,EAAE;QACjC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IAChF,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;QACxD,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,cAAc,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IAC1F,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0BAA0B,EAAE,GAAG,EAAE;QAClC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IACjE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wBAAwB,EAAE,GAAG,EAAE;QAChC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IAClE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,6BAA6B,EAAE,GAAG,EAAE;QACrC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IACrE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0CAA0C,EAAE,GAAG,EAAE;QAClD,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IACpF,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gCAAgC,EAAE,GAAG,EAAE;QACxC,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IACpF,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2EAA2E,EAAE,GAAG,EAAE;QACnF,MAAM,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,cAAc,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,6BAAS,CAAC,CAAC;IACjF,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/functions/dist/index.js b/functions/dist/index.js index 1768723f..f258d11b 100644 --- a/functions/dist/index.js +++ b/functions/dist/index.js @@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -exports.wrapReleaseKeyCallable = exports.onDesireSyncPartFinished = exports.onHowWellPartFinished = exports.onWheelPartFinished = exports.onThisOrThatPartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.aggregateOutcomeStats = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.onRestoreFulfilled = exports.onRestoreRequested = exports.onDateHistoryCreated = exports.onDateReflectionRevealed = exports.onDateReflectionWritten = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.onEntitlementChanged = exports.syncEntitlement = void 0; +exports.wrapReleaseKeyCallable = exports.onDesireSyncPartFinished = exports.onHowWellPartFinished = exports.onWheelPartFinished = exports.onThisOrThatPartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.aggregateOutcomeStats = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.onRestoreFulfilled = exports.onRestoreRequested = exports.onDateHistoryCreated = exports.onDateReflectionRevealed = exports.onDateReflectionWritten = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0; // Global v2 options (region pin, instance cap) — MUST be imported before any function module so // setGlobalOptions runs before the functions below are defined. require("./options"); @@ -44,12 +44,13 @@ const admin = __importStar(require("firebase-admin")); if (admin.apps.length === 0) { admin.initializeApp(); } -// RevenueCat is not configured yet, so the webhook stays OUT of the deployed codebase. It binds -// defineSecret('REVENUECAT_SIGNING_KEY'), and that secret is validated for the whole codebase at -// deploy time — exporting it would force a Secret Manager entry for a service that doesn't exist. -// To enable billing later: uncomment this, seed the real key -// (`firebase functions:secrets:set REVENUECAT_SIGNING_KEY`), then deploy revenueCatWebhook. -// export { revenueCatWebhook } from './billing/revenueCatWebhook' +// RevenueCat entitlement webhook. Auth = HMAC-SHA256 over the raw body (see revenueCatWebhook.ts). +// Binds defineSecret('REVENUECAT_WEBHOOK_SECRET') — the integration's signing secret from the +// RevenueCat dashboard — which is validated for the whole codebase at deploy time, so the secret +// must exist before any deploy. Seed it with: +// firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET +var revenueCatWebhook_1 = require("./billing/revenueCatWebhook"); +Object.defineProperty(exports, "revenueCatWebhook", { enumerable: true, get: function () { return revenueCatWebhook_1.revenueCatWebhook; } }); var syncEntitlement_1 = require("./billing/syncEntitlement"); Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: function () { return syncEntitlement_1.syncEntitlement; } }); var onEntitlementChanged_1 = require("./billing/onEntitlementChanged"); diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index 47a71407..5aec25c9 100644 --- a/functions/dist/index.js.map +++ b/functions/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,gGAAgG;AAChG,gEAAgE;AAChE,qBAAkB;AAClB,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,gGAAgG;AAChG,iGAAiG;AACjG,kGAAkG;AAClG,6DAA6D;AAC7D,4FAA4F;AAC5F,kEAAkE;AAClE,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,6EAA2E;AAAlE,oIAAA,wBAAwB,OAAA;AACjC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,kEAAoF;AAA3E,wHAAA,kBAAkB,OAAA;AAAE,wHAAA,kBAAkB,OAAA;AAC/C,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iEAAmE;AAA1D,0HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAMoC;AALlC,0HAAA,mBAAmB,OAAA;AACnB,+HAAA,wBAAwB,OAAA;AACxB,0HAAA,mBAAmB,OAAA;AACnB,4HAAA,qBAAqB,OAAA;AACrB,+HAAA,wBAAwB,OAAA;AAG1B,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,gGAAgG;AAChG,gEAAgE;AAChE,qBAAkB;AAClB,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,mGAAmG;AACnG,8FAA8F;AAC9F,iGAAiG;AACjG,8CAA8C;AAC9C,6DAA6D;AAC7D,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,6EAA2E;AAAlE,oIAAA,wBAAwB,OAAA;AACjC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,kEAAoF;AAA3E,wHAAA,kBAAkB,OAAA;AAAE,wHAAA,kBAAkB,OAAA;AAC/C,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iEAAmE;AAA1D,0HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAMoC;AALlC,0HAAA,mBAAmB,OAAA;AACnB,+HAAA,wBAAwB,OAAA;AACxB,0HAAA,mBAAmB,OAAA;AACnB,4HAAA,qBAAqB,OAAA;AACrB,+HAAA,wBAAwB,OAAA;AAG1B,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} \ No newline at end of file diff --git a/functions/src/billing/revenueCatWebhook.test.ts b/functions/src/billing/revenueCatWebhook.test.ts new file mode 100644 index 00000000..4f281060 --- /dev/null +++ b/functions/src/billing/revenueCatWebhook.test.ts @@ -0,0 +1,74 @@ +// The verifier under test is pure (crypto only), so we stub the Firebase side-effect +// imports that run at module load rather than exercising them. +jest.mock('firebase-functions/v2/https', () => ({ onRequest: jest.fn() })); +jest.mock('firebase-functions/params', () => ({ defineSecret: jest.fn(() => ({})) })); +jest.mock('../log', () => ({ logger: { log: jest.fn(), error: jest.fn(), warn: jest.fn() } })); +jest.mock('./entitlementLogic', () => ({ applyEntitlementEvent: jest.fn() })); + +import * as crypto from 'crypto'; +import { verifyWebhookSignature, AuthError } from './revenueCatWebhook'; + +const SECRET = 'whsec_test_shared_secret'; +const NOW = 1_700_000_000_000; // fixed "now" in ms +const nowSec = Math.floor(NOW / 1000); +const BODY = JSON.stringify({ event: { type: 'INITIAL_PURCHASE', app_user_id: 'uid1' } }); + +/** Build the header RevenueCat would send: `t=,v1=.">`. */ +function sign(body: string | Buffer, tsSec: number, secret = SECRET): string { + const raw = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8'); + const payload = Buffer.concat([Buffer.from(`${tsSec}.`, 'utf8'), raw]); + const v1 = crypto.createHmac('sha256', secret).update(payload).digest('hex'); + return `t=${tsSec},v1=${v1}`; +} + +function verify(overrides: Partial[0]> = {}) { + return verifyWebhookSignature({ + rawBody: Buffer.from(BODY), + header: sign(BODY, nowSec), + secret: SECRET, + nowMs: NOW, + ...overrides, + }); +} + +describe('verifyWebhookSignature', () => { + it('accepts a valid signature', () => { + expect(() => verify()).not.toThrow(); + }); + + it('accepts a timestamp within the tolerance window', () => { + expect(() => verify({ header: sign(BODY, nowSec - 4 * 60) })).not.toThrow(); + }); + + it('rejects a tampered body', () => { + expect(() => verify({ rawBody: Buffer.from(BODY + ' ') })).toThrow(AuthError); + }); + + it('rejects a signature made with the wrong secret', () => { + expect(() => verify({ header: sign(BODY, nowSec, 'wrong-secret') })).toThrow(AuthError); + }); + + it('rejects a missing header', () => { + expect(() => verify({ header: undefined })).toThrow(AuthError); + }); + + it('rejects a missing body', () => { + expect(() => verify({ rawBody: undefined })).toThrow(AuthError); + }); + + it('rejects a header missing v1', () => { + expect(() => verify({ header: `t=${nowSec}` })).toThrow(AuthError); + }); + + it('rejects a stale timestamp (replay guard)', () => { + expect(() => verify({ header: sign(BODY, nowSec - 10 * 60) })).toThrow(AuthError); + }); + + it('rejects a far-future timestamp', () => { + expect(() => verify({ header: sign(BODY, nowSec + 10 * 60) })).toThrow(AuthError); + }); + + it('rejects a wrong-length signature without crashing (timingSafeEqual guard)', () => { + expect(() => verify({ header: `t=${nowSec},v1=deadbeef` })).toThrow(AuthError); + }); +}); diff --git a/functions/src/billing/revenueCatWebhook.ts b/functions/src/billing/revenueCatWebhook.ts index 017db079..494f6629 100644 --- a/functions/src/billing/revenueCatWebhook.ts +++ b/functions/src/billing/revenueCatWebhook.ts @@ -10,21 +10,30 @@ import { logger } from '../log' * Path: POST /revenueCatWebhook (registered as an HTTPS function) * Triggered by RevenueCat server-to-server events. * - * 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. It is bound as a Secret - * Manager secret (below) and injected into process.env at runtime. - * - Missing key → 500 (our config error). Invalid/absent signature → 401. + * Authentication — HMAC-SHA256 (RevenueCat's webhook "signature verification"): + * - RevenueCat sends `X-RevenueCat-Webhook-Signature: t=,v1=`. + * - v1 = HMAC-SHA256(secret, "."), hex-encoded, computed over the RAW request + * body bytes exactly as received (before any JSON parsing / re-serialization). + * - REVENUECAT_WEBHOOK_SECRET is the integration's signing secret from the RevenueCat + * dashboard (Integrations → Webhooks → enable signature verification). It is bound as a + * Secret Manager secret (below) and injected into process.env at runtime. + * - Missing secret → 500 (our config error). Missing/invalid signature, or a timestamp + * outside the tolerance window (replay guard) → 401. + * + * NOTE: RevenueCat does NOT offer an Ed25519 / public-key signing scheme — HMAC-SHA256 with a + * shared secret is the only cryptographic option (plus the optional static Authorization header). * * Processing: * - Parses the RevenueCat event payload. * - Writes entitlement state to Firestore at users/{userId}/entitlements. */ -const revenueCatSigningKey = defineSecret('REVENUECAT_SIGNING_KEY') +const revenueCatWebhookSecret = defineSecret('REVENUECAT_WEBHOOK_SECRET') + +/** Reject signatures whose timestamp is further than this from now (replay protection). */ +const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000 export const revenueCatWebhook = onRequest( - { secrets: [revenueCatSigningKey] }, + { secrets: [revenueCatWebhookSecret] }, async (req, res) => { if (req.method !== 'POST') { res.status(405).json({ error: 'method_not_allowed' }) @@ -32,7 +41,7 @@ export const revenueCatWebhook = onRequest( } try { - verifySignature(req) + verifyRequest(req, Date.now()) } catch (err) { if (err instanceof ConfigError) { logger.error('[revenueCatWebhook] configuration error', { error: err.message }) @@ -65,62 +74,83 @@ export const revenueCatWebhook = onRequest( } ) -class ConfigError extends Error {} -class AuthError extends Error {} +export class ConfigError extends Error {} +export class AuthError extends Error {} /** - * 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. + * Reads the secret + signature header off the request and delegates to the pure verifier. + * Throws ConfigError when our secret is unset (→500); AuthError for any auth failure (→401). */ -function verifySignature(req: Request): void { - const signingKey = process.env.REVENUECAT_SIGNING_KEY - if (!signingKey) { - throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set') +function verifyRequest(req: Request, nowMs: number): void { + const secret = process.env.REVENUECAT_WEBHOOK_SECRET + if (!secret) { + throw new ConfigError('REVENUECAT_WEBHOOK_SECRET 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 + const sigHeader = req.headers['x-revenuecat-webhook-signature'] + const header = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader + + verifyWebhookSignature({ rawBody, header, secret, nowMs }) +} + +/** + * Pure, testable HMAC-SHA256 verification of a RevenueCat webhook signature. + * + * Parses `t=,v1=` from the header, recomputes HMAC-SHA256 over "." + * with the signing secret, constant-time-compares against v1, and rejects timestamps outside + * the tolerance window. Throws AuthError on any failure (caller maps to 401). + */ +export function verifyWebhookSignature(opts: { + rawBody: Buffer | undefined + header: string | undefined + secret: string + nowMs: number + toleranceMs?: number +}): void { + const { rawBody, header, secret, nowMs } = opts + const toleranceMs = opts.toleranceMs ?? SIGNATURE_TOLERANCE_MS + 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') + if (!header) { + throw new AuthError('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' - ) + // Parse "t=,v1=" — order-independent, tolerant of extra fields. + let t: string | undefined + let v1: string | undefined + for (const part of header.split(',')) { + const seg = part.trim() + const eq = seg.indexOf('=') + if (eq < 0) continue + const key = seg.slice(0, eq) + const val = seg.slice(eq + 1) + if (key === 't') t = val + else if (key === 'v1') v1 = val + } + if (!t || !v1) { + throw new AuthError('signature header malformed') } - let sigBuffer: Buffer - try { - sigBuffer = Buffer.from(signature, 'base64') - } catch { - throw new AuthError('x-signature is not valid base64') + const tsMs = Number(t) * 1000 + if (!Number.isFinite(tsMs)) { + throw new AuthError('signature timestamp invalid') + } + if (Math.abs(nowMs - tsMs) > toleranceMs) { + throw new AuthError('signature timestamp outside tolerance') } - let valid: boolean - try { - valid = crypto.verify(null, rawBody, publicKey, sigBuffer) - } catch { - throw new AuthError('signature verification failed') - } + // HMAC over the RAW body bytes, prefixed with "." — never re-serialize the JSON. + const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]) + const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex') - if (!valid) { + // Constant-time compare. timingSafeEqual throws on length mismatch, so guard the length first. + const expectedBuf = Buffer.from(expected, 'utf8') + const providedBuf = Buffer.from(v1, 'utf8') + if (expectedBuf.length !== providedBuf.length || !crypto.timingSafeEqual(expectedBuf, providedBuf)) { throw new AuthError('signature mismatch') } } diff --git a/functions/src/index.ts b/functions/src/index.ts index a7aeedea..9f362f1b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -10,12 +10,12 @@ if (admin.apps.length === 0) { admin.initializeApp() } -// RevenueCat is not configured yet, so the webhook stays OUT of the deployed codebase. It binds -// defineSecret('REVENUECAT_SIGNING_KEY'), and that secret is validated for the whole codebase at -// deploy time — exporting it would force a Secret Manager entry for a service that doesn't exist. -// To enable billing later: uncomment this, seed the real key -// (`firebase functions:secrets:set REVENUECAT_SIGNING_KEY`), then deploy revenueCatWebhook. -// export { revenueCatWebhook } from './billing/revenueCatWebhook' +// RevenueCat entitlement webhook. Auth = HMAC-SHA256 over the raw body (see revenueCatWebhook.ts). +// Binds defineSecret('REVENUECAT_WEBHOOK_SECRET') — the integration's signing secret from the +// RevenueCat dashboard — which is validated for the whole codebase at deploy time, so the secret +// must exist before any deploy. Seed it with: +// firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET +export { revenueCatWebhook } from './billing/revenueCatWebhook' export { syncEntitlement } from './billing/syncEntitlement' export { onEntitlementChanged } from './billing/onEntitlementChanged' export { sendGentleReminderCallable } from './notifications/sendGentleReminderCallable'