fix(billing): correct RevenueCat webhook auth to HMAC-SHA256 + enable export

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=<ts>,v1=<hex>) computed over "<ts>.<rawBody>". 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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-09 03:43:59 -05:00
parent 48217dd716
commit 89d06eeb7d
11 changed files with 345 additions and 119 deletions

View File

@ -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` 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 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. 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 - **RevenueCat webhook deploy (gated on the signing secret).** The v2 `revenueCatWebhook` is code-complete
`revenueCatWebhook` is **not deployed** — its export is commented out in `functions/src/index.ts` because and its export is now **live** in `functions/src/index.ts`, but it is **not deployed yet** — deploy waits on
`defineSecret('REVENUECAT_SIGNING_KEY')` is validated codebase-wide at deploy (a missing secret fails *every* the secret. Auth is **HMAC-SHA256** (RevenueCat's webhook signature verification): the handler reads
function's deploy, even with `--only`). To enable: ① create the RevenueCat project + webhook, ② seed the real `X-RevenueCat-Webhook-Signature: t=<ts>,v1=<hex>` and verifies `HMAC-SHA256("<ts>.<rawBody>")` against
Ed25519 public key — `firebase functions:secrets:set REVENUECAT_SIGNING_KEY`, ③ uncomment the export, `REVENUECAT_WEBHOOK_SECRET`, constant-time, with a ±5-min timestamp replay guard. Because that
**delete the old dormant v1 `revenueCatWebhook` first** (still deployed; same-name v1→v2 is blocked `defineSecret` is validated codebase-wide at deploy (a missing secret fails *every* function's deploy, even
in-place), ⑤ `firebase deploy --only functions:revenueCatWebhook`, ⑥ point RevenueCat at the function URL and with `--only`), the secret must exist first. To deploy: ① in the RevenueCat dashboard create the webhook +
send a test event. 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) ## Roadmap — features & strategy (consolidated from the old `FUTURE.md`, 2026-06-28)

View File

@ -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. `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 ### Schedule
@ -902,12 +902,12 @@ Callers collect `isPremium()` reactively rather than caching a one-time snapshot
`functions/src/billing/revenueCatWebhook.ts`: `functions/src/billing/revenueCatWebhook.ts`:
- **Path**: HTTPS, POST only. GETs return 405. - **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=<unix_ts>,v1=<hmac_sha256_hex>`; the handler recomputes `HMAC-SHA256("<t>.<rawBody>")` 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. - **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. - **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. - **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 ### Premium-gated features and gate pattern

View File

@ -33,7 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
}; };
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); 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 crypto = __importStar(require("crypto"));
const https_1 = require("firebase-functions/v2/https"); const https_1 = require("firebase-functions/v2/https");
const params_1 = require("firebase-functions/params"); const params_1 = require("firebase-functions/params");
@ -45,26 +46,34 @@ const log_1 = require("../log");
* Path: POST /revenueCatWebhook (registered as an HTTPS function) * Path: POST /revenueCatWebhook (registered as an HTTPS function)
* Triggered by RevenueCat server-to-server events. * Triggered by RevenueCat server-to-server events.
* *
* Authentication: * Authentication HMAC-SHA256 (RevenueCat's webhook "signature verification"):
* - Verifies the Ed25519 signature in the X-Signature request header. * - RevenueCat sends `X-RevenueCat-Webhook-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>`.
* - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519 * - v1 = HMAC-SHA256(secret, "<t>.<raw_body>"), hex-encoded, computed over the RAW request
* public key from your RevenueCat project dashboard. It is bound as a Secret * body bytes exactly as received (before any JSON parsing / re-serialization).
* Manager secret (below) and injected into process.env at runtime. * - REVENUECAT_WEBHOOK_SECRET is the integration's signing secret from the RevenueCat
* - Missing key 500 (our config error). Invalid/absent signature 401. * 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: * Processing:
* - Parses the RevenueCat event payload. * - Parses the RevenueCat event payload.
* - Writes entitlement state to Firestore at users/{userId}/entitlements. * - Writes entitlement state to Firestore at users/{userId}/entitlements.
*/ */
const revenueCatSigningKey = (0, params_1.defineSecret)('REVENUECAT_SIGNING_KEY'); const revenueCatWebhookSecret = (0, params_1.defineSecret)('REVENUECAT_WEBHOOK_SECRET');
exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatSigningKey] }, async (req, res) => { /** 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; var _a;
if (req.method !== 'POST') { if (req.method !== 'POST') {
res.status(405).json({ error: 'method_not_allowed' }); res.status(405).json({ error: 'method_not_allowed' });
return; return;
} }
try { try {
verifySignature(req); verifyRequest(req, Date.now());
} }
catch (err) { catch (err) {
if (err instanceof ConfigError) { if (err instanceof ConfigError) {
@ -96,56 +105,74 @@ exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatSigning
}); });
class ConfigError extends Error { class ConfigError extends Error {
} }
exports.ConfigError = ConfigError;
class AuthError extends Error { class AuthError extends Error {
} }
exports.AuthError = AuthError;
/** /**
* Verifies the Ed25519 signature RevenueCat sends in the X-Signature header. * 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).
* 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 verifySignature(req) { function verifyRequest(req, nowMs) {
const signingKey = process.env.REVENUECAT_SIGNING_KEY; const secret = process.env.REVENUECAT_WEBHOOK_SECRET;
if (!signingKey) { if (!secret) {
throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set'); throw new ConfigError('REVENUECAT_WEBHOOK_SECRET environment variable is not set');
} }
// Firebase Functions expose the raw request body as req.rawBody (Buffer). // Firebase Functions expose the raw request body as req.rawBody (Buffer).
const rawBody = req.rawBody; 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=<unix_ts>,v1=<hex>` from the header, recomputes HMAC-SHA256 over "<t>.<rawBody>"
* 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) { if (!rawBody || rawBody.length === 0) {
throw new AuthError('request body missing'); throw new AuthError('request body missing');
} }
const sigHeader = req.headers['x-signature']; if (!header) {
const signature = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader; throw new AuthError('signature header missing');
if (!signature) {
throw new AuthError('x-signature header missing');
} }
let publicKey; // Parse "t=<ts>,v1=<hex>" — order-independent, tolerant of extra fields.
try { let t;
publicKey = crypto.createPublicKey({ let v1;
key: Buffer.from(signingKey, 'base64'), for (const part of header.split(',')) {
format: 'der', const seg = part.trim();
type: 'spki', 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) { if (!t || !v1) {
throw new ConfigError('REVENUECAT_SIGNING_KEY is malformed — expected base64-encoded DER/SPKI Ed25519 public key'); throw new AuthError('signature header malformed');
} }
let sigBuffer; const tsMs = Number(t) * 1000;
try { if (!Number.isFinite(tsMs)) {
sigBuffer = Buffer.from(signature, 'base64'); throw new AuthError('signature timestamp invalid');
} }
catch (_b) { if (Math.abs(nowMs - tsMs) > toleranceMs) {
throw new AuthError('x-signature is not valid base64'); throw new AuthError('signature timestamp outside tolerance');
} }
let valid; // HMAC over the RAW body bytes, prefixed with "<t>." — never re-serialize the JSON.
try { const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
valid = crypto.verify(null, rawBody, publicKey, sigBuffer); const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
} // Constant-time compare. timingSafeEqual throws on length mismatch, so guard the length first.
catch (_c) { const expectedBuf = Buffer.from(expected, 'utf8');
throw new AuthError('signature verification failed'); const providedBuf = Buffer.from(v1, 'utf8');
} if (expectedBuf.length !== providedBuf.length || !crypto.timingSafeEqual(expectedBuf, providedBuf)) {
if (!valid) {
throw new AuthError('signature mismatch'); throw new AuthError('signature mismatch');
} }
} }

View File

@ -1 +1 @@
{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"} {"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"}

View File

@ -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=<sec>,v1=<hmac_sha256_hex of "<sec>.<body>">`. */
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

View File

@ -0,0 +1 @@
{"version":3,"file":"revenueCatWebhook.test.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.test.ts"],"names":[],"mappings":"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"}

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
}; };
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); 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 // Global v2 options (region pin, instance cap) — MUST be imported before any function module so
// setGlobalOptions runs before the functions below are defined. // setGlobalOptions runs before the functions below are defined.
require("./options"); require("./options");
@ -44,12 +44,13 @@ const admin = __importStar(require("firebase-admin"));
if (admin.apps.length === 0) { if (admin.apps.length === 0) {
admin.initializeApp(); admin.initializeApp();
} }
// RevenueCat is not configured yet, so the webhook stays OUT of the deployed codebase. It binds // RevenueCat entitlement webhook. Auth = HMAC-SHA256 over the raw body (see revenueCatWebhook.ts).
// defineSecret('REVENUECAT_SIGNING_KEY'), and that secret is validated for the whole codebase at // Binds defineSecret('REVENUECAT_WEBHOOK_SECRET') — the integration's signing secret from the
// deploy time — exporting it would force a Secret Manager entry for a service that doesn't exist. // RevenueCat dashboard — which is validated for the whole codebase at deploy time, so the secret
// To enable billing later: uncomment this, seed the real key // must exist before any deploy. Seed it with:
// (`firebase functions:secrets:set REVENUECAT_SIGNING_KEY`), then deploy revenueCatWebhook. // firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET
// export { revenueCatWebhook } from './billing/revenueCatWebhook' var revenueCatWebhook_1 = require("./billing/revenueCatWebhook");
Object.defineProperty(exports, "revenueCatWebhook", { enumerable: true, get: function () { return revenueCatWebhook_1.revenueCatWebhook; } });
var syncEntitlement_1 = require("./billing/syncEntitlement"); var syncEntitlement_1 = require("./billing/syncEntitlement");
Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: function () { return syncEntitlement_1.syncEntitlement; } }); Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: function () { return syncEntitlement_1.syncEntitlement; } });
var onEntitlementChanged_1 = require("./billing/onEntitlementChanged"); var onEntitlementChanged_1 = require("./billing/onEntitlementChanged");

View File

@ -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"} {"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"}

View File

@ -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=<sec>,v1=<hmac_sha256_hex of "<sec>.<body>">`. */
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<Parameters<typeof verifyWebhookSignature>[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);
});
});

View File

@ -10,21 +10,30 @@ import { logger } from '../log'
* Path: POST /revenueCatWebhook (registered as an HTTPS function) * Path: POST /revenueCatWebhook (registered as an HTTPS function)
* Triggered by RevenueCat server-to-server events. * Triggered by RevenueCat server-to-server events.
* *
* Authentication: * Authentication HMAC-SHA256 (RevenueCat's webhook "signature verification"):
* - Verifies the Ed25519 signature in the X-Signature request header. * - RevenueCat sends `X-RevenueCat-Webhook-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>`.
* - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519 * - v1 = HMAC-SHA256(secret, "<t>.<raw_body>"), hex-encoded, computed over the RAW request
* public key from your RevenueCat project dashboard. It is bound as a Secret * body bytes exactly as received (before any JSON parsing / re-serialization).
* Manager secret (below) and injected into process.env at runtime. * - REVENUECAT_WEBHOOK_SECRET is the integration's signing secret from the RevenueCat
* - Missing key 500 (our config error). Invalid/absent signature 401. * 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: * Processing:
* - Parses the RevenueCat event payload. * - Parses the RevenueCat event payload.
* - Writes entitlement state to Firestore at users/{userId}/entitlements. * - 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( export const revenueCatWebhook = onRequest(
{ secrets: [revenueCatSigningKey] }, { secrets: [revenueCatWebhookSecret] },
async (req, res) => { async (req, res) => {
if (req.method !== 'POST') { if (req.method !== 'POST') {
res.status(405).json({ error: 'method_not_allowed' }) res.status(405).json({ error: 'method_not_allowed' })
@ -32,7 +41,7 @@ export const revenueCatWebhook = onRequest(
} }
try { try {
verifySignature(req) verifyRequest(req, Date.now())
} catch (err) { } catch (err) {
if (err instanceof ConfigError) { if (err instanceof ConfigError) {
logger.error('[revenueCatWebhook] configuration error', { error: err.message }) logger.error('[revenueCatWebhook] configuration error', { error: err.message })
@ -65,62 +74,83 @@ export const revenueCatWebhook = onRequest(
} }
) )
class ConfigError extends Error {} export class ConfigError extends Error {}
class AuthError extends Error {} export class AuthError extends Error {}
/** /**
* Verifies the Ed25519 signature RevenueCat sends in the X-Signature header. * 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).
* 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 verifySignature(req: Request): void { function verifyRequest(req: Request, nowMs: number): void {
const signingKey = process.env.REVENUECAT_SIGNING_KEY const secret = process.env.REVENUECAT_WEBHOOK_SECRET
if (!signingKey) { if (!secret) {
throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set') throw new ConfigError('REVENUECAT_WEBHOOK_SECRET environment variable is not set')
} }
// Firebase Functions expose the raw request body as req.rawBody (Buffer). // Firebase Functions expose the raw request body as req.rawBody (Buffer).
const rawBody = (req as unknown as { rawBody?: Buffer }).rawBody 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=<unix_ts>,v1=<hex>` from the header, recomputes HMAC-SHA256 over "<t>.<rawBody>"
* 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) { if (!rawBody || rawBody.length === 0) {
throw new AuthError('request body missing') throw new AuthError('request body missing')
} }
if (!header) {
const sigHeader = req.headers['x-signature'] throw new AuthError('signature header missing')
const signature = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader
if (!signature) {
throw new AuthError('x-signature header missing')
} }
let publicKey: crypto.KeyObject // Parse "t=<ts>,v1=<hex>" — order-independent, tolerant of extra fields.
try { let t: string | undefined
publicKey = crypto.createPublicKey({ let v1: string | undefined
key: Buffer.from(signingKey, 'base64'), for (const part of header.split(',')) {
format: 'der', const seg = part.trim()
type: 'spki', const eq = seg.indexOf('=')
}) if (eq < 0) continue
} catch { const key = seg.slice(0, eq)
throw new ConfigError( const val = seg.slice(eq + 1)
'REVENUECAT_SIGNING_KEY is malformed — expected base64-encoded DER/SPKI Ed25519 public key' if (key === 't') t = val
) else if (key === 'v1') v1 = val
}
if (!t || !v1) {
throw new AuthError('signature header malformed')
} }
let sigBuffer: Buffer const tsMs = Number(t) * 1000
try { if (!Number.isFinite(tsMs)) {
sigBuffer = Buffer.from(signature, 'base64') throw new AuthError('signature timestamp invalid')
} catch { }
throw new AuthError('x-signature is not valid base64') if (Math.abs(nowMs - tsMs) > toleranceMs) {
throw new AuthError('signature timestamp outside tolerance')
} }
let valid: boolean // HMAC over the RAW body bytes, prefixed with "<t>." — never re-serialize the JSON.
try { const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody])
valid = crypto.verify(null, rawBody, publicKey, sigBuffer) const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex')
} catch {
throw new AuthError('signature verification failed')
}
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') throw new AuthError('signature mismatch')
} }
} }

View File

@ -10,12 +10,12 @@ if (admin.apps.length === 0) {
admin.initializeApp() admin.initializeApp()
} }
// RevenueCat is not configured yet, so the webhook stays OUT of the deployed codebase. It binds // RevenueCat entitlement webhook. Auth = HMAC-SHA256 over the raw body (see revenueCatWebhook.ts).
// defineSecret('REVENUECAT_SIGNING_KEY'), and that secret is validated for the whole codebase at // Binds defineSecret('REVENUECAT_WEBHOOK_SECRET') — the integration's signing secret from the
// deploy time — exporting it would force a Secret Manager entry for a service that doesn't exist. // RevenueCat dashboard — which is validated for the whole codebase at deploy time, so the secret
// To enable billing later: uncomment this, seed the real key // must exist before any deploy. Seed it with:
// (`firebase functions:secrets:set REVENUECAT_SIGNING_KEY`), then deploy revenueCatWebhook. // firebase functions:secrets:set REVENUECAT_WEBHOOK_SECRET
// export { revenueCatWebhook } from './billing/revenueCatWebhook' export { revenueCatWebhook } from './billing/revenueCatWebhook'
export { syncEntitlement } from './billing/syncEntitlement' export { syncEntitlement } from './billing/syncEntitlement'
export { onEntitlementChanged } from './billing/onEntitlementChanged' export { onEntitlementChanged } from './billing/onEntitlementChanged'
export { sendGentleReminderCallable } from './notifications/sendGentleReminderCallable' export { sendGentleReminderCallable } from './notifications/sendGentleReminderCallable'