/** * Lógica pura de cifrado AES-256-GCM (estándar Aduanasoft §4). Sin dependencias de * SvelteKit ni de entorno, para ser testeable de forma aislada. La clave de 256 bits * se inyecta como parámetro; quien la resuelve desde el entorno es `crypto.ts`. * * Formato del sobre: `gcm:::`. */ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; export const ALGORITHM = 'aes-256-gcm'; export const IV_LENGTH = 12; // 96 bits, recomendado para GCM export const KEY_LENGTH = 32; // 256 bits export const VERSION_PREFIX = 'gcm'; /** Cifra `plaintext` con la clave dada y devuelve el sobre versionado. */ export function encryptWithKey(plaintext: string, key: Buffer): string { if (key.length !== KEY_LENGTH) { throw new Error(`La clave debe ser de ${KEY_LENGTH} bytes; se recibieron ${key.length}.`); } const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const authTag = cipher.getAuthTag(); return [ VERSION_PREFIX, iv.toString('base64'), authTag.toString('base64'), ciphertext.toString('base64') ].join(':'); } /** * Descifra un sobre producido por `encryptWithKey`. Lanza si el formato es inválido * o si la autenticación GCM falla (dato manipulado o clave incorrecta). */ export function decryptWithKey(payload: string, key: Buffer): string { if (key.length !== KEY_LENGTH) { throw new Error(`La clave debe ser de ${KEY_LENGTH} bytes; se recibieron ${key.length}.`); } const parts = String(payload ?? '').split(':'); if (parts.length !== 4 || parts[0] !== VERSION_PREFIX) { throw new Error('Formato de texto cifrado inválido (se esperaba gcm:iv:tag:ciphertext).'); } const iv = Buffer.from(parts[1], 'base64'); const authTag = Buffer.from(parts[2], 'base64'); const ciphertext = Buffer.from(parts[3], 'base64'); const decipher = createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); } /** Indica si un valor ya está cifrado con este esquema (para detectar datos legados en plano). */ export function isEncrypted(value: string | null | undefined): boolean { return typeof value === 'string' && value.startsWith(`${VERSION_PREFIX}:`); } /** * Decodifica una clave de 32 bytes desde una cadena base64 o hex. * Lanza con mensaje claro si no decodifica a la longitud esperada. */ export function decodeKey(raw: string | undefined): Buffer { if (!raw || !raw.trim()) { throw new Error( 'ENCRYPTION_KEY no está configurada. Genera una con: ' + 'node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))"' ); } const trimmed = raw.trim(); let key = Buffer.from(trimmed, 'base64'); if (key.length !== KEY_LENGTH) { key = Buffer.from(trimmed, 'hex'); } if (key.length !== KEY_LENGTH) { throw new Error( `ENCRYPTION_KEY debe decodificar a ${KEY_LENGTH} bytes (256 bits) en base64 o hex; ` + `se obtuvieron ${key.length} bytes.` ); } return key; }