Files
PANEL_BASES_ANEXO24/src/lib/server/fernet.ts
AlexeerCT 7f9d502cb1
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
feat(env): update environment configuration and add SQL Server password handling (#15)
- Added new environment variables for deduplication and SQL Server password encryption.
- Updated docker-compose files to include SECRET_KEY and ENCRYPTION_KEY for compatibility with a24c.
- Enhanced the navigation structure to reflect changes in admin-only views and report access.
- Introduced new functions for handling SQL Server connections and database management, including parsing server addresses and listing user databases.

This update improves security and functionality related to database management and user access control.

Reviewed-on: #15
Co-authored-by: AlexeerCT <acazares@aduanasoft.com.mx>
Co-committed-by: AlexeerCT <acazares@aduanasoft.com.mx>
2026-07-06 17:00:49 +00:00

116 lines
4.8 KiB
TypeScript

/**
* 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;
}
}