@socialgouv/streaming-file-encryption
Version:
Streaming file encryption for Node.js using AES-256-GCM, HKDF and HMAC.
275 lines (266 loc) • 8.7 kB
JavaScript
'use strict';
var crypto4 = require('crypto');
var stream = require('stream');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
var crypto4__default = /*#__PURE__*/_interopDefault(crypto4);
// src/mainSecret.ts
function generateSerializedMainSecret() {
return crypto4__default.default.randomBytes(64).toString("hex");
}
function decodeMainSecret(serialized) {
if (/^[0-9a-f]{128}$/i.test(serialized) === false) {
throw new TypeError(
"Invalid main secret format: should be 64 bytes hex-encoded."
);
}
return Buffer.from(serialized, "hex");
}
// src/constants.ts
var KDF_HASH = "sha512";
var KDF_SALT_LENGTH = 32;
var CIPHER_KEY_LENGTH = 32;
var CIPHER_IV_LENGTH = 12;
var CIPHER_AUTH_TAG_LENGTH = 16;
var HMAC_HASH = "sha512";
var HMAC_KEY_LENGTH = 64;
var HMAC_OUTPUT_LENGTH = 64;
var HEADER_VERSION_LENGTH = 4;
var HEADER_VERSION_1a2g = "1a2g";
var HEADER_VERSION_1c2p = "1c2p";
var HEADER_IV_OFFSET = HEADER_VERSION_LENGTH;
var HEADER_SALT_OFFSET = HEADER_IV_OFFSET + CIPHER_IV_LENGTH;
var HEADER_LENGTH = HEADER_SALT_OFFSET + KDF_SALT_LENGTH;
var PAGE_LENGTH = 16 * 1024;
var CLEARTEXT_PAGE_LENGTH = PAGE_LENGTH;
var CIPHERTEXT_PAGE_LENGTH = 2 + PAGE_LENGTH + CIPHER_AUTH_TAG_LENGTH;
function memzero(buffer) {
for (let i = 0; i < buffer.byteLength; ++i) {
buffer[i] = 0;
}
}
function incrementLE(buffer) {
let add = 0;
let carry = 1;
for (let i = 0; i < buffer.byteLength; ++i) {
add = buffer[i] + carry;
buffer[i] = add & 255;
carry = add >>> 8;
}
}
function compare(a, b) {
if (a.byteLength !== b.byteLength) {
return false;
}
return crypto4__default.default.timingSafeEqual(a, b);
}
function numberToUint32LE(input) {
const buffer = new ArrayBuffer(4);
const u32 = new Uint32Array(buffer);
u32[0] = input;
return Buffer.from(buffer);
}
// src/kdf.ts
function deriveKey(mainSecret, salt, context, outputSize) {
return new Promise((resolve, reject) => {
crypto4__default.default.hkdf(
KDF_HASH,
mainSecret,
salt,
context,
outputSize,
(error, derivedKey) => {
if (error) {
return reject(error);
}
resolve(Buffer.from(derivedKey));
}
);
});
}
async function deriveKeys(mainSecret, mainSalt, context) {
const hmacSalt = Buffer.from(mainSalt);
incrementLE(hmacSalt);
const [cipherKey, hmacKey] = await Promise.all([
deriveKey(mainSecret, mainSalt, context, CIPHER_KEY_LENGTH),
deriveKey(mainSecret, hmacSalt, context, HMAC_KEY_LENGTH)
]);
memzero(hmacSalt);
const hmac = crypto4__default.default.createHmac(HMAC_HASH, hmacKey);
memzero(hmacKey);
return { cipherKey, hmac };
}
// src/streams.ts
function rebuffer(headerLength, bufferLength) {
return async function* rebuffer2(source) {
const header = headerLength ? Buffer.alloc(headerLength) : null;
const buffer = Buffer.alloc(bufferLength);
let hi = 0;
let bi = 0;
let headerEmitted = false;
for await (const chunk of source) {
let ci = 0;
let chunkBytesToRead = chunk.byteLength;
if (!headerEmitted && header) {
const headerFreeSpace = headerLength - hi;
const chunkBytesToHeader = Math.min(headerFreeSpace, chunkBytesToRead);
const slice = chunk.subarray(ci, ci + chunkBytesToHeader);
header.set(slice, hi);
chunkBytesToRead -= chunkBytesToHeader;
hi += chunkBytesToHeader;
ci += chunkBytesToHeader;
if (hi === headerLength) {
yield Buffer.from(header);
headerEmitted = true;
}
}
while (chunkBytesToRead > 0) {
const bufferFreeSpace = bufferLength - bi;
const chunkBytesToBuffer = Math.min(bufferFreeSpace, chunkBytesToRead);
const slice = chunk.subarray(ci, ci + chunkBytesToBuffer);
buffer.set(slice, bi);
chunkBytesToRead -= chunkBytesToBuffer;
bi += chunkBytesToBuffer;
ci += chunkBytesToBuffer;
if (bi === bufferLength) {
yield Buffer.from(buffer);
bi = 0;
}
}
}
if (header && !headerEmitted) {
throw new Error("Not enough data to emit header");
}
if (bi !== 0) {
yield buffer.subarray(0, bi);
}
};
}
function pagedEncryption(mainSecret, context, algorithm) {
return async function* encryptPage(source) {
let pageIndex = 0;
const pageBuffer = Buffer.alloc(2 + CLEARTEXT_PAGE_LENGTH);
const iv = crypto4__default.default.randomBytes(CIPHER_IV_LENGTH);
const salt = crypto4__default.default.randomBytes(KDF_SALT_LENGTH);
const { cipherKey, hmac } = await deriveKeys(mainSecret, salt, context);
const version = Buffer.from(
algorithm === "chacha20-poly1305" ? HEADER_VERSION_1c2p : HEADER_VERSION_1a2g
);
const header = Buffer.concat([version, iv, salt]);
hmac.update(header);
yield header;
for await (const cleartext of source) {
const cipher = crypto4__default.default.createCipheriv(algorithm, cipherKey, iv, {
// @ts-ignore
authTagLength: CIPHER_AUTH_TAG_LENGTH
});
cipher.setAAD(numberToUint32LE(pageIndex));
pageBuffer.fill(0);
pageBuffer[0] = cleartext.byteLength & 255;
pageBuffer[1] = cleartext.byteLength >> 8 & 255;
pageBuffer.set(cleartext, 2);
const ciphertext = cipher.update(pageBuffer);
const final = cipher.final();
const authTag = cipher.getAuthTag();
hmac.update(ciphertext);
hmac.update(final);
hmac.update(authTag);
yield ciphertext;
if (final.byteLength > 0) {
yield final;
}
yield authTag;
incrementLE(iv);
pageIndex++;
}
yield hmac.digest();
memzero(cipherKey);
};
}
function pagedDecryption(mainSecret, context) {
return async function* decryptPage(source) {
let cipherKey = Buffer.from([]);
let iv = Buffer.from([]);
let hmac = void 0;
let isDone = false;
let algorithm = "aes-256-gcm";
let pageIndex = 0;
for await (const page of source) {
if (page.byteLength === HEADER_LENGTH) {
const v = page.subarray(0, HEADER_VERSION_LENGTH);
if (v.toString() === HEADER_VERSION_1a2g) {
algorithm = "aes-256-gcm";
} else if (v.toString() === HEADER_VERSION_1c2p) {
algorithm = "chacha20-poly1305";
} else {
throw new Error("Unsupported file type");
}
iv = page.subarray(
HEADER_IV_OFFSET,
HEADER_IV_OFFSET + CIPHER_IV_LENGTH
);
const salt = page.subarray(
HEADER_SALT_OFFSET,
HEADER_SALT_OFFSET + KDF_SALT_LENGTH
);
({ cipherKey, hmac } = await deriveKeys(mainSecret, salt, context));
hmac.update(page);
continue;
}
if (page.byteLength === HMAC_OUTPUT_LENGTH) {
if (!compare(page, hmac.digest())) {
throw new Error(
"File decryption error: invalid HMAC (failed integrity check)"
);
}
isDone = true;
continue;
}
if (isDone) {
throw new Error(
"File decryption error: no more data is expected after HMAC verification"
);
}
hmac.update(page);
const authTagLength = CIPHER_AUTH_TAG_LENGTH;
const cipher = crypto4__default.default.createDecipheriv(algorithm, cipherKey, iv, {
// @ts-ignore
authTagLength
});
cipher.setAAD(numberToUint32LE(pageIndex));
const authTag = page.subarray(-authTagLength, page.byteLength);
const ciphertext = page.subarray(0, -authTagLength);
cipher.setAuthTag(authTag);
const paddedCleartext = cipher.update(ciphertext);
const final = cipher.final();
const pageLength = paddedCleartext[0] | paddedCleartext[1] << 8;
yield paddedCleartext.subarray(2, 2 + pageLength);
if (final.byteLength > 0) {
yield final;
}
incrementLE(iv);
pageIndex++;
}
memzero(cipherKey);
if (!isDone) {
throw new Error(
"File decryption error: stream terminated before HMAC verification"
);
}
};
}
function encryptFile(mainSecret, context, algorithm = "aes-256-gcm") {
return stream.compose(
rebuffer(0, CLEARTEXT_PAGE_LENGTH),
pagedEncryption(mainSecret, context, algorithm)
);
}
function decryptFile(mainSecret, context) {
return stream.compose(
rebuffer(HEADER_LENGTH, CIPHERTEXT_PAGE_LENGTH),
pagedDecryption(mainSecret, context)
);
}
exports.decodeMainSecret = decodeMainSecret;
exports.decryptFile = decryptFile;
exports.encryptFile = encryptFile;
exports.generateSerializedMainSecret = generateSerializedMainSecret;