feat: implement Ed25519 RevenueCat webhook signature verification

This commit is contained in:
null 2026-06-17 19:08:53 -05:00
parent ede7c70ea7
commit 534bb076c7
3 changed files with 138 additions and 38 deletions

View File

@ -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

View File

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

View File

@ -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')
}
}