/** * Verificación de un webhook de FCM (JWS compacto) ANTES de parsear el cuerpo. Node ≥ 22, sin dependencias. * * Los seis pasos del contrato, en orden: clave por `kid` con el `alg` del JWKS → firma → `iat`/`exp` (5 min, con * 30 s de margen) → `body_sha256` sobre los bytes crudos → `aud` = tu `entity_id` → `jti` no visto (anti-replay, * OBLIGATORIO). Claims desconocidos se ignoran: FCM puede sumar claims sin aviso de versión. */ import { createHash, createPublicKey, timingSafeEqual, verify, type JsonWebKey, type KeyObject } from "node:crypto"; /** Cuánto se recuerda cada `jti` (el contrato pide al menos 10 minutos). */ export const REPLAY_TTL_SECONDS = 600; export interface FcmJwk { kty: "OKP" | "EC"; crv: "Ed25519" | "P-256"; x: string; y?: string; kid: string; alg: "EdDSA" | "ES256"; use: "sig"; } export interface WebhookClaims { iss: string; aud: string; iat: number; exp: number; jti: string; evt: string; body_sha256: string; } /** * Anti-replay del `jti` (paso 5). `checkAndStore` devuelve false si ya lo vio en los últimos 10 minutos; si no, lo * guarda. En producción usá un almacén compartido y persistente (Postgres, Redis) y guardá el `jti` en la misma * transacción que el efecto de negocio. */ export interface ReplayGuard { checkAndStore(jti: string, nowUnix: number): boolean; } /** Guarda en memoria de un solo proceso, sólo para empezar: se pierde al reiniciar. */ export class InMemoryReplayGuard implements ReplayGuard { private readonly seen = new Map(); checkAndStore(jti: string, nowUnix: number): boolean { for (const [id, expires] of this.seen) if (expires <= nowUnix) this.seen.delete(id); if (this.seen.has(jti)) return false; this.seen.set(jti, nowUnix + REPLAY_TTL_SECONDS); return true; } } export interface VerifyWebhookOptions { token: string; rawBody: Uint8Array; jwks: readonly FcmJwk[]; /** Tu `entity_id`: el `aud` tiene que ser éste. */ entityId: string; /** Obligatorio. */ replayGuard: ReplayGuard; issuer?: string; /** `FCM-Event-Id` y `FCM-Delivery-Id` de los encabezados, si los querés atar al token. */ eventId?: string; deliveryId?: string; now?: number; } const CLOCK_SKEW_SECONDS = 30; const MAX_AGE_SECONDS = 300; function decodeSegment(value: string): Record { const parsed: unknown = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("malformed JWS segment"); return parsed as Record; } function text(claims: Record, name: string): string { const value = claims[name]; if (typeof value !== "string" || value === "") throw new Error(`missing claim ${name}`); return value; } function integer(claims: Record, name: string): number { const value = claims[name]; if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new Error(`invalid claim ${name}`); return value; } /** Sólo los claims conocidos: uno nuevo no rompe la verificación. */ function knownClaims(claims: Record): WebhookClaims { return { iss: text(claims, "iss"), aud: text(claims, "aud"), iat: integer(claims, "iat"), exp: integer(claims, "exp"), jti: text(claims, "jti"), evt: text(claims, "evt"), body_sha256: text(claims, "body_sha256"), }; } function joseEcdsaToDer(signature: Buffer): Buffer { if (signature.length !== 64) throw new Error("ES256 JWS signature must be 64 bytes"); const integerDer = (part: Buffer): Buffer => { let start = 0; while (start < part.length - 1 && part[start] === 0) start += 1; const trimmed = part.subarray(start); const first = trimmed[0] ?? 0; return (first & 0x80) !== 0 ? Buffer.concat([Buffer.from([0]), trimmed]) : trimmed; }; const r = integerDer(signature.subarray(0, 32)); const s = integerDer(signature.subarray(32)); const body = Buffer.concat([Buffer.from([0x02, r.length]), r, Buffer.from([0x02, s.length]), s]); return Buffer.concat([Buffer.from([0x30, body.length]), body]); } function publicKey(jwk: FcmJwk): KeyObject { const key: JsonWebKey = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, ...(jwk.y === undefined ? {} : { y: jwk.y }) }; return createPublicKey({ key, format: "jwk" }); } export function verifyFcmWebhook(options: VerifyWebhookOptions): WebhookClaims { const parts = options.token.split("."); const [headerPart, payloadPart, signaturePart] = parts; if (parts.length !== 3 || headerPart === undefined || payloadPart === undefined || signaturePart === undefined || parts.some((part) => part === "")) { throw new Error("malformed JWS"); } const header = decodeSegment(headerPart); const alg = header.alg; if (alg !== "EdDSA" && alg !== "ES256") throw new Error("unsupported webhook algorithm"); const jwk = options.jwks.find((candidate) => candidate.kid === header.kid); if (jwk === undefined || jwk.alg !== alg || jwk.use !== "sig") throw new Error("unknown or mismatched webhook key"); const signingInput = Buffer.from(`${headerPart}.${payloadPart}`, "ascii"); const encodedSignature = Buffer.from(signaturePart, "base64url"); const signature = alg === "ES256" ? joseEcdsaToDer(encodedSignature) : encodedSignature; if (!verify(alg === "ES256" ? "sha256" : null, signingInput, publicKey(jwk), signature)) throw new Error("invalid webhook signature"); const claims = knownClaims(decodeSegment(payloadPart)); const now = options.now ?? Math.floor(Date.now() / 1000); if (claims.iat > now + CLOCK_SKEW_SECONDS || claims.iat < now - MAX_AGE_SECONDS - CLOCK_SKEW_SECONDS || claims.exp <= now - CLOCK_SKEW_SECONDS || claims.exp - claims.iat !== MAX_AGE_SECONDS) { throw new Error("expired or not-yet-valid webhook"); } const expected = Buffer.from(createHash("sha256").update(options.rawBody).digest("base64url")); const received = Buffer.from(claims.body_sha256); if (expected.length !== received.length || !timingSafeEqual(expected, received)) throw new Error("webhook body hash mismatch"); if (claims.iss !== (options.issuer ?? "https://api.fcmpayments.com")) throw new Error("invalid webhook issuer"); if (claims.aud !== options.entityId) throw new Error("invalid webhook audience"); if (options.eventId !== undefined && claims.evt !== options.eventId) throw new Error("webhook event id mismatch"); if (options.deliveryId !== undefined && claims.jti !== options.deliveryId) throw new Error("webhook delivery id mismatch"); // Último: un token inválido nunca consume el jti de una entrega legítima. if (!options.replayGuard.checkAndStore(claims.jti, now)) throw new Error("webhook replay"); return claims; }