/** * Implementación pura del esquema Fernet (spec oficial), compatible byte a byte con * `cryptography.fernet.Fernet` de Python — que es lo que usa a24c para descifrar la * contraseña SQL de cada nodo (`database_nodes.sql_password`). * * Fernet = AES-128-CBC (PKCS7) + HMAC-SHA256, sobre una clave de 32 bytes: * - bytes [0..16) → clave de firma (HMAC) * - bytes [16..32) → clave de cifrado (AES-128) * * Formato del token (antes de base64-url): * 0x80 | timestamp(8, big-endian) | iv(16) | ciphertext(múltiplo de 16) | hmac(32) * * a24c deriva la clave así: base64.urlsafe_b64encode(sha256(SECRET_KEY).digest()) * que Fernet vuelve a decodificar a los 32 bytes crudos de `sha256(SECRET_KEY)`. * Aquí replicamos exactamente esa derivación con `deriveFernetKey`. */ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; const FERNET_VERSION = 0x80; const KEY_LENGTH = 32; // 16 firma + 16 cifrado const IV_LENGTH = 16; const HMAC_LENGTH = 32; const HEADER_LENGTH = 1 + 8 + IV_LENGTH; // version + timestamp + iv /** Longitud mínima de un token válido: header + 1 bloque AES + hmac. */ const MIN_TOKEN_BYTES = HEADER_LENGTH + 16 + HMAC_LENGTH; function toUrlSafeBase64(buf: Buffer): string { // Padded url-safe base64 (con `=`), como produce Python; su decoder lo exige. return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); } function fromUrlSafeBase64(token: string): Buffer { return Buffer.from(token.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); } /** Deriva la clave Fernet de 32 bytes desde SECRET_KEY, idéntica a la de a24c. */ export function deriveFernetKey(secret: string): Buffer { if (!secret || !secret.trim()) { throw new Error('SECRET_KEY no está configurada (debe coincidir con la de a24c).'); } return createHash('sha256').update(secret, 'utf8').digest(); // 32 bytes } /** * Cifra `plaintext` como token Fernet. `timestampSec` permite inyectar el tiempo * (segundos Unix) para pruebas deterministas; en producción se pasa el reloj real. */ export function fernetEncrypt(plaintext: string, key32: Buffer, timestampSec: number): string { if (key32.length !== KEY_LENGTH) { throw new Error(`La clave Fernet debe ser de ${KEY_LENGTH} bytes; se recibieron ${key32.length}.`); } const signingKey = key32.subarray(0, 16); const encKey = key32.subarray(16, 32); const iv = randomBytes(IV_LENGTH); const ts = Buffer.alloc(8); ts.writeBigUInt64BE(BigInt(Math.floor(timestampSec))); const cipher = createCipheriv('aes-128-cbc', encKey, iv); // PKCS7 automático const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const parts = Buffer.concat([Buffer.from([FERNET_VERSION]), ts, iv, ciphertext]); const hmac = createHmac('sha256', signingKey).update(parts).digest(); return toUrlSafeBase64(Buffer.concat([parts, hmac])); } /** Descifra un token Fernet. Lanza si el HMAC no valida o el formato es inválido. */ export function fernetDecrypt(token: string, key32: Buffer): string { if (key32.length !== KEY_LENGTH) { throw new Error(`La clave Fernet debe ser de ${KEY_LENGTH} bytes; se recibieron ${key32.length}.`); } const signingKey = key32.subarray(0, 16); const encKey = key32.subarray(16, 32); const data = fromUrlSafeBase64(String(token ?? '')); if (data.length < MIN_TOKEN_BYTES) throw new Error('Token Fernet demasiado corto.'); if (data[0] !== FERNET_VERSION) throw new Error('Versión de token Fernet inválida.'); const hmacOffset = data.length - HMAC_LENGTH; const signed = data.subarray(0, hmacOffset); const providedHmac = data.subarray(hmacOffset); const expectedHmac = createHmac('sha256', signingKey).update(signed).digest(); if (!timingSafeEqual(providedHmac, expectedHmac)) { throw new Error('HMAC del token Fernet no coincide (clave incorrecta o dato manipulado).'); } const iv = data.subarray(9, 9 + IV_LENGTH); const ciphertext = data.subarray(9 + IV_LENGTH, hmacOffset); const decipher = createDecipheriv('aes-128-cbc', encKey, iv); return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); } /** * Heurística sin clave para distinguir un token Fernet de texto plano legado: * base64-url válido que decodifica a un blob con versión 0x80 y longitud mínima. */ export function isFernetToken(value: unknown): boolean { if (typeof value !== 'string') return false; const v = value.trim(); if (v.length < 100 || !/^[A-Za-z0-9_-]+={0,2}$/.test(v)) return false; try { const data = fromUrlSafeBase64(v); return data.length >= MIN_TOKEN_BYTES && data[0] === FERNET_VERSION; } catch { return false; } }