cross-crypto-ts
Version:
Cifrado híbrido AES-GCM + RSA-OAEP con interoperabilidad entre TypeScript y Python, con diseño compatible para Rust.
285 lines (284 loc) • 11.1 kB
JavaScript
"use strict";
// src/decrypt.ts
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.decryptHybrid = decryptHybrid;
const node_forge_1 = __importDefault(require("node-forge"));
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const v8_1 = __importDefault(require("v8"));
const GCM_TAG_BITS = 128;
const STREAM_ENVELOPE_MAGIC = Buffer.from("CCRYPT2\n", "utf8");
const STREAM_ENVELOPE_VERSION = 2;
function resolveOaepHash(encrypted, explicit) {
const resolved = explicit ?? encrypted.oaepHash ?? "sha256";
if (resolved !== "sha1" && resolved !== "sha256") {
throw new Error("oaepHash debe ser 'sha1' o 'sha256'.");
}
return resolved;
}
function getForgeMd(hash) {
if (hash === "sha256")
return node_forge_1.default.md.sha256.create();
return node_forge_1.default.md.sha1.create();
}
function normalizeAad(aad) {
if (aad === undefined || aad === null)
return undefined;
if (typeof aad === "string") {
return Buffer.from(aad, "utf8").toString("binary");
}
if (aad instanceof Uint8Array) {
return Buffer.from(aad).toString("binary");
}
return Buffer.from(JSON.stringify(aad), "utf8").toString("binary");
}
function isStreamEnvelopeFile(filePath) {
try {
if (!fs_1.default.existsSync(filePath) || !fs_1.default.statSync(filePath).isFile()) {
return false;
}
const fd = fs_1.default.openSync(filePath, "r");
const magic = Buffer.alloc(STREAM_ENVELOPE_MAGIC.length);
try {
const n = fs_1.default.readSync(fd, magic, 0, magic.length, 0);
return n === magic.length && magic.equals(STREAM_ENVELOPE_MAGIC);
}
finally {
fs_1.default.closeSync(fd);
}
}
catch {
return false;
}
}
function readStreamEnvelopeHeader(filePath) {
const fd = fs_1.default.openSync(filePath, "r");
try {
const magic = Buffer.alloc(STREAM_ENVELOPE_MAGIC.length);
const magicRead = fs_1.default.readSync(fd, magic, 0, magic.length, 0);
if (magicRead !== magic.length || !magic.equals(STREAM_ENVELOPE_MAGIC)) {
throw new Error("Archivo stream inválido: magic no coincide.");
}
const lenBuf = Buffer.alloc(4);
const lenRead = fs_1.default.readSync(fd, lenBuf, 0, 4, STREAM_ENVELOPE_MAGIC.length);
if (lenRead !== 4) {
throw new Error("Archivo stream inválido: header length incompleto.");
}
const headerLen = lenBuf.readUInt32BE(0);
if (headerLen <= 0) {
throw new Error("Archivo stream inválido: header vacío.");
}
const headerBuf = Buffer.alloc(headerLen);
const headerRead = fs_1.default.readSync(fd, headerBuf, 0, headerLen, STREAM_ENVELOPE_MAGIC.length + 4);
if (headerRead !== headerLen) {
throw new Error("Archivo stream inválido: header incompleto.");
}
const parsed = JSON.parse(headerBuf.toString("utf8"));
if (!parsed || typeof parsed !== "object") {
throw new Error("Archivo stream inválido: header no es objeto JSON.");
}
const header = parsed;
if (header.version !== STREAM_ENVELOPE_VERSION) {
throw new Error(`Versión de stream no soportada: ${header.version}`);
}
if (header.format !== "cross-crypto-stream") {
throw new Error("Formato de stream no soportado.");
}
if (header.streamFormat !== "envelope") {
throw new Error("streamFormat no soportado.");
}
return {
header,
ciphertextOffset: STREAM_ENVELOPE_MAGIC.length + 4 + headerLen,
};
}
finally {
fs_1.default.closeSync(fd);
}
}
function decryptAesKey(params) {
const privateKey = node_forge_1.default.pki.privateKeyFromPem(params.privateKeyPem);
const md = getForgeMd(params.oaepHash);
return privateKey.decrypt(node_forge_1.default.util.decode64(params.encryptedKeyB64), "RSA-OAEP", {
md,
mgf1: { md },
});
}
function decryptStreamEnvelope(params) {
const { header, ciphertextOffset } = readStreamEnvelopeHeader(params.envelopePath);
const oaepHash = resolveOaepHash(header, params.options.oaepHash);
const aadBytes = normalizeAad(params.options.aad);
const aesKey = decryptAesKey({
encryptedKeyB64: header.encryptedKey,
privateKeyPem: params.privateKeyPem,
oaepHash,
});
const nonce = node_forge_1.default.util.decode64(header.nonce);
const tag = node_forge_1.default.util.decode64(header.tag);
const decipher = node_forge_1.default.cipher.createDecipher("AES-GCM", aesKey);
decipher.start({
iv: node_forge_1.default.util.createBuffer(nonce),
tag: node_forge_1.default.util.createBuffer(tag),
tagLength: GCM_TAG_BITS,
additionalData: aadBytes,
});
const fdIn = fs_1.default.openSync(params.envelopePath, "r");
const chunkSize = 64 * 1024;
const buffer = Buffer.alloc(chunkSize);
if (params.options.returnBytes) {
const chunks = [];
try {
let pos = ciphertextOffset;
while (true) {
const n = fs_1.default.readSync(fdIn, buffer, 0, buffer.length, pos);
if (!n)
break;
pos += n;
decipher.update(node_forge_1.default.util.createBuffer(buffer.subarray(0, n).toString("binary")));
const outBytes = decipher.output.getBytes();
if (outBytes.length > 0) {
chunks.push(Buffer.from(outBytes, "binary"));
}
}
const success = decipher.finish();
if (!success)
throw new Error("Tag inválido.");
const finalBytes = decipher.output.getBytes();
if (finalBytes.length > 0) {
chunks.push(Buffer.from(finalBytes, "binary"));
}
return Buffer.concat(chunks);
}
finally {
fs_1.default.closeSync(fdIn);
}
}
const finalPath = params.outPath ||
params.envelopePath.replace(/\.ccenc$/, ".dec");
fs_1.default.mkdirSync(path_1.default.dirname(finalPath) || ".", { recursive: true });
const tmpPath = `${finalPath}.tmp-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`;
const fdOut = fs_1.default.openSync(tmpPath, "w");
try {
let pos = ciphertextOffset;
while (true) {
const n = fs_1.default.readSync(fdIn, buffer, 0, buffer.length, pos);
if (!n)
break;
pos += n;
decipher.update(node_forge_1.default.util.createBuffer(buffer.subarray(0, n).toString("binary")));
const outBytes = decipher.output.getBytes();
if (outBytes.length > 0) {
fs_1.default.writeSync(fdOut, Buffer.from(outBytes, "binary"));
}
}
const success = decipher.finish();
if (!success)
throw new Error("Tag inválido.");
const finalBytes = decipher.output.getBytes();
if (finalBytes.length > 0) {
fs_1.default.writeSync(fdOut, Buffer.from(finalBytes, "binary"));
}
fs_1.default.closeSync(fdOut);
fs_1.default.closeSync(fdIn);
fs_1.default.renameSync(tmpPath, finalPath);
return finalPath;
}
catch (error) {
try {
fs_1.default.closeSync(fdOut);
}
catch {
// noop
}
try {
fs_1.default.closeSync(fdIn);
}
catch {
// noop
}
if (fs_1.default.existsSync(tmpPath)) {
try {
fs_1.default.unlinkSync(tmpPath);
}
catch {
// noop
}
}
throw error;
}
}
function decryptHybrid(encrypted, privateKeyPem, outPathOrOptions, maybeOptions = {}) {
try {
const outPath = typeof outPathOrOptions === "string" ? outPathOrOptions : undefined;
const options = typeof outPathOrOptions === "object" && outPathOrOptions !== null
? outPathOrOptions
: maybeOptions;
if (typeof encrypted === "string") {
if (!isStreamEnvelopeFile(encrypted)) {
throw new Error("Ruta stream inválida: no es un envelope .ccenc.");
}
return decryptStreamEnvelope({
envelopePath: encrypted,
privateKeyPem,
outPath,
options,
});
}
if (encrypted.mode === "stream") {
if (!encrypted.encryptedPath) {
throw new Error("Path no encontrado en payload stream.");
}
if (!isStreamEnvelopeFile(encrypted.encryptedPath)) {
throw new Error("El modo stream v2 requiere un archivo envelope .ccenc válido.");
}
return decryptStreamEnvelope({
envelopePath: encrypted.encryptedPath,
privateKeyPem,
outPath,
options,
});
}
const oaepHash = resolveOaepHash(encrypted, options.oaepHash);
const aadBytes = normalizeAad(options.aad);
const aesKey = decryptAesKey({
encryptedKeyB64: encrypted.encryptedKey,
privateKeyPem,
oaepHash,
});
const nonce = node_forge_1.default.util.decode64(encrypted.nonce);
const tag = node_forge_1.default.util.decode64(encrypted.tag);
const decipher = node_forge_1.default.cipher.createDecipher("AES-GCM", aesKey);
decipher.start({
iv: node_forge_1.default.util.createBuffer(nonce),
tag: node_forge_1.default.util.createBuffer(tag),
tagLength: GCM_TAG_BITS,
additionalData: aadBytes,
});
const encryptedDataBytes = node_forge_1.default.util.decode64(encrypted.encryptedData);
decipher.update(node_forge_1.default.util.createBuffer(encryptedDataBytes));
const success = decipher.finish();
if (!success)
throw new Error("Tag inválido.");
if (encrypted.mode === "json") {
return JSON.parse(decipher.output.toString());
}
if (encrypted.mode === "binary") {
return Buffer.from(decipher.output.getBytes(), "binary");
}
if (encrypted.mode === "v8") {
const buffer = Buffer.from(decipher.output.getBytes(), "binary");
return v8_1.default.deserialize(buffer);
}
throw new Error(`Modo desconocido: ${encrypted}`);
}
catch (error) {
throw new Error(error instanceof Error
? `Desencriptación fallida: ${error.message}`
: "Desencriptación fallida");
}
}