@socialgouv/streaming-file-encryption
Version:
Streaming file encryption for Node.js using AES-256-GCM, HKDF and HMAC.
359 lines (342 loc) • 11.3 kB
JavaScript
;
var parseArgs = require('minimist');
var promises = require('stream/promises');
var crypto4 = require('crypto');
var stream = require('stream');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
var parseArgs__default = /*#__PURE__*/_interopDefault(parseArgs);
var crypto4__default = /*#__PURE__*/_interopDefault(crypto4);
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)
);
}
// src/cli.ts
function loadCipherArguments(args) {
const mainSecret = process.env.MAIN_SECRET;
if (!mainSecret) {
console.error("Missing MAIN_SECRET environment variable");
process.exit(1);
}
if (!args.context) {
console.error("Missing --context argument");
process.exit(1);
}
return {
mainSecret: decodeMainSecret(mainSecret),
context: args.context
};
}
async function main() {
const args = parseArgs__default.default(process.argv.slice(2), {
alias: {
help: "h",
context: ["c", "ctx"],
algorithm: ["a", "alg"]
}
});
const action = args._[0];
if (args.help || action === "help") {
console.info(`Streaming File Encryption CLI
Commands:
generate Generate a main secret as an environment variable export
encrypt Encrypt a file
decrypt Decrypt a file
Encryption and decryption both require a main secret to be passed
via the \`MAIN_SECRET\` environment variable.
Encryption and decryption requires a context string to be passed
via the --context flag (aliases: -c or --ctx).
Encryption can optionally specify the cipher algorithm to use,
via the --algorithm flag (aliases: -a or --alg).
Values are \`aes-256-gcm\` (default) or \`chacha20-poly1305\`.
Files are read from the standard input, and written to standard output.
Example usage:
$ npx @socialgouv/streaming-file-encryption generate
$ export MAIN_SECRET=0123456789abcdef...
$ npx @socialgouv/streaming-file-encryption encrypt --context foo < document.pdf > document.pdf.sfe
$ npx @socialgouv/streaming-file-encryption decrypt --context foo < document.pdf.sfe > decrypted.pdf
`);
process.exit(0);
}
if (action === "encrypt") {
const { mainSecret, context } = loadCipherArguments(args);
const algorithm = args.algorithm ?? "aes-256-gcm";
if (!["aes-256-gcm", "chacha20-poly1305"].includes(algorithm)) {
console.error(
"Invalid algorithm: only `aes-256-gcm` and `chacha20-poly1305` are supported."
);
}
await promises.pipeline(
process.stdin,
encryptFile(mainSecret, context, algorithm),
process.stdout
);
} else if (action === "decrypt") {
const { mainSecret, context } = loadCipherArguments(args);
await promises.pipeline(
process.stdin,
decryptFile(mainSecret, context),
process.stdout
);
} else if (action === "generate") {
console.info(`export MAIN_SECRET=${generateSerializedMainSecret()}`);
} else {
console.error(
"Invalid action: first argument should be `encrypt`, `decrypt`, or `generate`."
);
process.exit(1);
}
}
main();