cross-crypto-ts
Version:
Cifrado híbrido AES-GCM + RSA-OAEP con interoperabilidad entre TypeScript y Python, con diseño compatible para Rust.
236 lines (235 loc) • 8.92 kB
JavaScript
"use strict";
// src/encrypt.ts
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.encryptHybrid = encryptHybrid;
const node_forge_1 = __importDefault(require("node-forge"));
const fs_1 = __importDefault(require("fs"));
const os_1 = __importDefault(require("os"));
const path_1 = __importDefault(require("path"));
const v8_1 = __importDefault(require("v8"));
const AES_KEY_SIZE = 32;
const AES_NONCE_SIZE = 12;
const GCM_TAG_BITS = 128;
const STREAM_ENVELOPE_MAGIC = Buffer.from("CCRYPT2\n", "utf8");
const STREAM_ENVELOPE_VERSION = 2;
function resolveOaepHash(value) {
const resolved = value ?? "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 bufferToBinaryString(data) {
return Buffer.from(data).toString("binary");
}
function serializeData(data, mode) {
if (mode === "json") {
if (typeof data !== "object" || data instanceof Uint8Array || Buffer.isBuffer(data)) {
throw new TypeError("En modo 'json', data debe ser un objeto JSON.");
}
return Buffer.from(JSON.stringify(data), "utf8").toString("binary");
}
if (mode === "v8") {
return bufferToBinaryString(v8_1.default.serialize(data));
}
if (mode === "binary") {
if (typeof data === "string") {
return Buffer.from(data, "utf8").toString("binary");
}
if (data instanceof Uint8Array || Buffer.isBuffer(data)) {
return bufferToBinaryString(data);
}
throw new TypeError("En modo 'binary', data debe ser string, Buffer o Uint8Array.");
}
throw new Error(`Modo de cifrado no soportado: ${mode}`);
}
function writeStreamEnvelope(params) {
const header = {
version: STREAM_ENVELOPE_VERSION,
format: "cross-crypto-stream",
streamFormat: "envelope",
cipher: "AES-256-GCM",
keyWrap: "RSA-OAEP",
encryptedKey: params.encryptedKey,
nonce: params.nonce,
tag: params.tag,
mode: "stream",
contentMode: params.contentMode,
aad: params.aadPresent ? "present" : "none",
oaepHash: params.oaepHash,
};
const headerBytes = Buffer.from(JSON.stringify(header), "utf8");
if (headerBytes.byteLength > 0xffffffff) {
throw new Error("Header de stream demasiado grande.");
}
fs_1.default.mkdirSync(path_1.default.dirname(params.outputPath) || ".", { recursive: true });
const headerLen = Buffer.alloc(4);
headerLen.writeUInt32BE(headerBytes.byteLength, 0);
const fdOut = fs_1.default.openSync(params.outputPath, "w");
const fdCipher = fs_1.default.openSync(params.ciphertextPath, "r");
try {
fs_1.default.writeSync(fdOut, STREAM_ENVELOPE_MAGIC);
fs_1.default.writeSync(fdOut, headerLen);
fs_1.default.writeSync(fdOut, headerBytes);
const buffer = Buffer.alloc(1024 * 1024);
while (true) {
const n = fs_1.default.readSync(fdCipher, buffer, 0, buffer.length, null);
if (!n)
break;
fs_1.default.writeSync(fdOut, buffer.subarray(0, n));
}
}
finally {
fs_1.default.closeSync(fdCipher);
fs_1.default.closeSync(fdOut);
}
}
function makeTempCipherPath() {
const name = `cross_crypto_stream_${Date.now()}_${Math.random()
.toString(16)
.slice(2)}.cipher`;
return path_1.default.join(os_1.default.tmpdir(), name);
}
function encryptFileToTempCiphertext(params) {
const cipher = node_forge_1.default.cipher.createCipher("AES-GCM", params.aesKey);
cipher.start({
iv: params.nonce,
tagLength: GCM_TAG_BITS,
additionalData: params.aadBytes,
});
const fdIn = fs_1.default.openSync(params.inputPath, "r");
const fdOut = fs_1.default.openSync(params.tempCipherPath, "w");
const buffer = Buffer.alloc(params.chunkSize);
try {
while (true) {
const n = fs_1.default.readSync(fdIn, buffer, 0, buffer.length, null);
if (!n)
break;
const chunk = buffer.subarray(0, n);
cipher.update(node_forge_1.default.util.createBuffer(chunk.toString("binary")));
const outBytes = cipher.output.getBytes();
if (outBytes.length > 0) {
fs_1.default.writeSync(fdOut, Buffer.from(outBytes, "binary"));
}
}
const success = cipher.finish();
if (!success) {
throw new Error("No se pudo finalizar AES-GCM stream.");
}
const finalBytes = cipher.output.getBytes();
if (finalBytes.length > 0) {
fs_1.default.writeSync(fdOut, Buffer.from(finalBytes, "binary"));
}
return cipher.mode.tag.getBytes();
}
finally {
fs_1.default.closeSync(fdIn);
fs_1.default.closeSync(fdOut);
}
}
function encryptHybrid(data, publicKeyPem, mode = "json", options = {}) {
const oaepHash = resolveOaepHash(options.oaepHash);
const aadBytes = normalizeAad(options.aad);
const aesKey = node_forge_1.default.random.getBytesSync(AES_KEY_SIZE);
const nonce = node_forge_1.default.random.getBytesSync(AES_NONCE_SIZE);
const publicKey = node_forge_1.default.pki.publicKeyFromPem(publicKeyPem);
const md = getForgeMd(oaepHash);
const encryptedAesKey = publicKey.encrypt(aesKey, "RSA-OAEP", {
md,
mgf1: { md },
});
const encryptedKeyB64 = node_forge_1.default.util.encode64(encryptedAesKey);
const nonceB64 = node_forge_1.default.util.encode64(nonce);
if (mode === "stream") {
if (typeof data !== "string") {
throw new TypeError("Stream mode requiere path al archivo.");
}
if (!fs_1.default.existsSync(data) || !fs_1.default.statSync(data).isFile()) {
throw new Error(`Archivo no encontrado para stream: ${data}`);
}
const inputPath = data;
const finalPath = options.outputPath ?? inputPath + ".ccenc";
const contentMode = options.contentMode ?? "binary";
const chunkSize = options.streamChunkSize ?? 64 * 1024;
const tempCipherPath = makeTempCipherPath();
try {
const tag = encryptFileToTempCiphertext({
inputPath,
tempCipherPath,
aesKey,
nonce,
aadBytes,
chunkSize,
});
const tagB64 = node_forge_1.default.util.encode64(tag);
writeStreamEnvelope({
outputPath: finalPath,
encryptedKey: encryptedKeyB64,
nonce: nonceB64,
tag: tagB64,
oaepHash,
aadPresent: aadBytes !== undefined,
contentMode,
ciphertextPath: tempCipherPath,
});
return {
encryptedKey: encryptedKeyB64,
nonce: nonceB64,
tag: tagB64,
encryptedPath: finalPath,
mode: "stream",
contentMode,
streamFormat: "envelope",
aad: aadBytes ? "present" : "none",
oaepHash,
};
}
finally {
if (fs_1.default.existsSync(tempCipherPath)) {
fs_1.default.unlinkSync(tempCipherPath);
}
}
}
const plaintext = serializeData(data, mode);
const cipher = node_forge_1.default.cipher.createCipher("AES-GCM", aesKey);
cipher.start({
iv: nonce,
tagLength: GCM_TAG_BITS,
additionalData: aadBytes,
});
cipher.update(node_forge_1.default.util.createBuffer(plaintext));
const success = cipher.finish();
if (!success) {
throw new Error("No se pudo finalizar AES-GCM.");
}
const encrypted = cipher.output.getBytes();
const tag = cipher.mode.tag.getBytes();
return {
encryptedKey: encryptedKeyB64,
encryptedData: node_forge_1.default.util.encode64(encrypted),
nonce: nonceB64,
tag: node_forge_1.default.util.encode64(tag),
mode,
aad: aadBytes ? "present" : "none",
oaepHash,
};
}