UNPKG

@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
#!/usr/bin/env node 'use strict'; 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();