integraciones para cloud recovery Reviewed-on: #9 Co-authored-by: hreyes <hreyes@aduanasoft.com.mx> Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
87 lines
3.3 KiB
TypeScript
87 lines
3.3 KiB
TypeScript
/**
|
|
* 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:<iv_b64>:<tag_b64>:<ciphertext_b64>`.
|
|
*/
|
|
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;
|
|
}
|