import crypto from "node:crypto"; import { loadEnv } from "./env.ts"; export interface Cifrado { cipher: Buffer; nonce: Buffer; tag: Buffer; } /** * Cifrado de los tokens de subcuenta que se guardan en Postgres. * * AES-256-GCM, es decir cifrado **autenticado**, y eso es la decisión que * importa: si alguien manipula la fila en la base, `descifrar` lanza en vez de * devolver basura. Con un cifrado sin autenticar, una fila corrupta se * convertiría en una petición al CRM con una credencial mal formada, y el fallo * aparecería lejos de su causa. * * La clave maestra vive en el entorno, nunca en la base: quien consiga un * volcado de Postgres no consigue los tokens de los clientes. */ function clave(): Buffer { loadEnv(); const b64 = process.env.CRM_MASTER_KEY; if (!b64) { throw new Error( 'Falta CRM_MASTER_KEY. Genera una con: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))" y ponla en platform/.env' ); } const k = Buffer.from(b64, "base64"); if (k.length !== 32) { throw new Error( `CRM_MASTER_KEY debe ser de 32 bytes en base64; llegaron ${k.length}. Genera una nueva con randomBytes(32).` ); } return k; } export function cifrar(claro: string): Cifrado { const nonce = crypto.randomBytes(12); const c = crypto.createCipheriv("aes-256-gcm", clave(), nonce); const cipher = Buffer.concat([c.update(claro, "utf8"), c.final()]); return { cipher, nonce, tag: c.getAuthTag() }; } export function descifrar(c: Cifrado): string { // La clave se pide FUERA del try: si falta o mide mal, ese error debe salir // tal cual, no disfrazado de «fila corrupta». Son dos causas distintas y // llevan a dos arreglos distintos. const k = clave(); try { const d = crypto.createDecipheriv("aes-256-gcm", k, c.nonce); d.setAuthTag(c.tag); return Buffer.concat([d.update(c.cipher), d.final()]).toString("utf8"); } catch { throw new Error( "El token guardado no se pudo descifrar: la clave maestra cambió o la fila está corrupta. Hay que volver a vincular la subcuenta." ); } } /** * Los 6 últimos caracteres del token. Sirve para que la interfaz pueda decir * «termina en …f4a2c1» y para detectar una rotación, sin exponer nunca la * credencial completa ni en la API, ni en los registros, ni en la auditoría. */ export function huella(token: string): string { return token.length <= 6 ? token : token.slice(-6); }