/** * Firma de pedidos a FCM: RFC 9421 (HTTP Message Signatures) + RFC 9530 (Content-Digest). Node ≥ 22. * * La clave privada Ed25519 la carga quien llama y nunca se serializa en un pedido ni en un log. Cada pedido lleva un * `nonce` aleatorio nuevo (si repetís uno, FCM responde `signature_replayed`). Los componentes obligatorios son * `@method` y `@target-uri`; con cuerpo, además `content-type`, `content-digest` e `idempotency-key`; con * `FCM-Version`, `fcm-version`. El borde acepta también, si los cubrís, `date`, `content-length`, `@authority`, * `@path` y `@query` (lista cerrada). `Authorization` (la API key) va en el pedido pero NO se firma: si la cubrís, FCM * responde `signature_invalid` (la firma se tiene que poder verificar sin la API key). */ import { createHash, randomBytes, sign, type KeyObject } from "node:crypto"; type HeaderMap = Record; export interface RequestToSign { method: string; targetUri: string; authorization: string; privateKey: KeyObject; keyId: string; body?: Uint8Array; contentType?: string; idempotencyKey?: string; fcmVersion?: string; /** Componentes opcionales extra que el borde acepta. */ extraComponents?: readonly ("date" | "content-length" | "@authority" | "@path" | "@query")[]; /** Sólo pruebas: en producción, la hora actual. */ created?: number; /** Sólo vectores de prueba: en producción se genera uno aleatorio por pedido. */ nonce?: string; /** Si cubrís `date`, el valor que mandás en el encabezado. */ date?: string; } export interface SignedRequest { headers: HeaderMap; signatureBase: string; } const b64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64"); /** 16 bytes aleatorios en base64url (22 caracteres): cumple `^[A-Za-z0-9_-]{16,64}$`. */ export function newNonce(): string { return randomBytes(16).toString("base64url"); } function digestHeader(body: Uint8Array): string { return `sha-256=:${b64(createHash("sha256").update(body).digest())}:`; } function quote(component: string): string { return JSON.stringify(component); } function derived(component: string, targetUri: string): string | undefined { const url = new URL(targetUri); if (component === "@authority") return url.host.toLowerCase(); if (component === "@path") return url.pathname === "" ? "/" : url.pathname; if (component === "@query") return url.search === "" ? "?" : url.search; return undefined; } export function signFcmRequest(request: RequestToSign): SignedRequest { const method = request.method.toUpperCase(); const headers: HeaderMap = { Authorization: request.authorization }; const values: Record = { "@method": method, "@target-uri": request.targetUri }; const components = ["@method", "@target-uri"]; if (request.body !== undefined) { if (request.contentType === undefined || request.contentType === "") throw new Error("contentType is required when body is present"); if (request.idempotencyKey === undefined || request.idempotencyKey === "") throw new Error("idempotencyKey is required for POST bodies"); const digest = digestHeader(request.body); headers["Content-Type"] = request.contentType; headers["Content-Digest"] = digest; headers["Idempotency-Key"] = request.idempotencyKey; values["content-type"] = request.contentType; values["content-digest"] = digest; values["idempotency-key"] = request.idempotencyKey; components.push("content-type", "content-digest", "idempotency-key"); } if (request.fcmVersion !== undefined && request.fcmVersion !== "") { headers["FCM-Version"] = request.fcmVersion; values["fcm-version"] = request.fcmVersion; components.push("fcm-version"); } for (const extra of request.extraComponents ?? []) { if (extra === "date") { if (request.date === undefined) throw new Error("date is required to cover the date component"); headers.Date = request.date; values.date = request.date; } else if (extra === "content-length") { const length = String(request.body?.byteLength ?? 0); headers["Content-Length"] = length; values["content-length"] = length; } else { values[extra] = derived(extra, request.targetUri) ?? ""; } components.push(extra); } const created = request.created ?? Math.floor(Date.now() / 1000); const nonce = request.nonce ?? newNonce(); const params = `(${components.map(quote).join(" ")});created=${created};nonce=${quote(nonce)};keyid=${quote(request.keyId)};alg="ed25519"`; const lines = components.map((component) => `${quote(component)}: ${values[component] ?? ""}`); lines.push(`${quote("@signature-params")}: ${params}`); const signatureBase = lines.join("\n"); const signature = sign(null, Buffer.from(signatureBase, "utf8"), request.privateKey); headers["Signature-Input"] = `sig1=${params}`; headers.Signature = `sig1=:${signature.toString("base64")}:`; return { headers, signatureBase }; } // Ejemplo (el cuerpo tiene que ser exactamente los bytes que se mandan): // const body = Buffer.from(JSON.stringify({ account_number_id: "acn_…", amount: 1500000 })); // const signed = signFcmRequest({ // method: "POST", // targetUri: "https://empresas-sandbox.fcmpayments.com/v1/simulations/incoming_transfers", // authorization: "Bearer fcm_test_…", // privateKey, // keyId: "ck_…", // body, // contentType: "application/json", // idempotencyKey: "01JAT…", // });