From b332fb028a9edd7c42c4e2e70f0eb98144a14a29 Mon Sep 17 00:00:00 2001 From: Justin Wind Date: Sat, 21 Jun 2025 14:13:56 -0700 Subject: [PATCH] add encryption helpers --- index.js | 2 + lib/encryption-helper.js | 191 ++++++++++++++++++++++++++++++++++ test/lib/encryption-helper.js | 158 ++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 lib/encryption-helper.js create mode 100644 test/lib/encryption-helper.js diff --git a/index.js b/index.js index 0f595ec..ca29e39 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ 'use strict'; const Abstract = require('./lib/abstract'); +const EncryptionHelper = require('./lib/encryption-helper'); const Errors = require('./lib/errors'); const Factory = require('./lib/factory'); const SchemaVersionHelper = require('./lib/schema-version-helper'); @@ -12,6 +13,7 @@ const { itChecksImplementation } = require('./test/helpers'); module.exports = { Abstract, + EncryptionHelper, Errors, Factory, SchemaVersionHelper, diff --git a/lib/encryption-helper.js b/lib/encryption-helper.js new file mode 100644 index 0000000..11ecfb5 --- /dev/null +++ b/lib/encryption-helper.js @@ -0,0 +1,191 @@ +'use strict'; +const { EventEmitter } = require('node:events'); +const { performance } = require('node:perf_hooks'); +const { promisify } = require('node:util'); +const crypto = require('node:crypto'); +const randomBytesAsync = promisify(crypto.randomBytes); + + +class EncryptionHelper extends EventEmitter { + static cipher = 'chacha20-poly1305'; + static ivLength = 12; + static authTagLength = 16; + static indexHmac = 'sha256'; + static statisticsEvent = 'statistics'; + keys; + indexKey; + + /** + * + * @param {any[]} eventArgs args for event constructor + */ + constructor(...eventArgs) { + super(...eventArgs); + this.keys = []; + this.indexKey = undefined; + } + + + /** + * Return the most favored key. + * @returns {*} key + */ + get key() { + return this.keys[0]; + } + + + /** + * Set the key(s) used for encrypting and decrypting data. + * First added key has precidence, additional keys are attempted for decryption. + * @param {*} key key + */ + loadKey(key) { + this.keys.push(key); + } + + + /** + * Set the key used for hmac index generation. + * @param {*} key key + */ + loadIndexKey(key) { + this.indexKey = key; + } + + /** + * + * @param {string} table table name + * @param {string} field column name + * @param {*} primaryKey value of record's primary-key + * @param {*} data data + * @returns {Buffer} encrypted data + */ + async encrypt(table, field, primaryKey, data) { + const startMs = performance.now(); + const iv = await randomBytesAsync(this.constructor.ivLength); + const cipher = crypto.createCipheriv(this.constructor.cipher, this.key, iv, { authTagLength: this.constructor.authTagLength }); + const aadBuffer = this.constructor._canonicalize(table, field, primaryKey); + cipher.setAAD(aadBuffer); + const update = cipher.update(data); + const final = cipher.final(); + const tag = cipher.getAuthTag(); + const encrypted = Buffer.concat([iv, tag, update, final]); + this.emit(this.constructor.statisticsEvent, { method: 'encrypt', elapsedMs: performance.now() - startMs }); + return encrypted; + } + + + /** + * + * @param {string} table table name + * @param {string} field column name + * @param {*} primaryKey value of record's primary key + * @param {Buffer} data encrypted data + * @returns {Buffer} decrypted data + */ + decrypt(table, field, primaryKey, data) { + const startMs = performance.now(); + const iv = data.subarray(0, this.constructor.ivLength); + const tag = data.subarray(this.constructor.ivLength, this.constructor.ivLength + this.constructor.authTagLength); + const payload = data.subarray(this.constructor.ivLength + this.constructor.authTagLength, data.length); + const aadBuffer = this.constructor._canonicalize(table, field, primaryKey); + let key = this.key; + let keyIndex = 0; + while (true) { + try { + const decipher = crypto.createDecipheriv(this.constructor.cipher, key, iv, { authTagLength: this.constructor.authTagLength }); + decipher.setAAD(aadBuffer); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(payload), decipher.final()]); + this.emit(this.constructor.statisticsEvent, { method: 'decrypt', elasedMs: performance.now() - startMs }); + return decrypted; + } catch (e) { + keyIndex += 1; + if (keyIndex in this.keys) { + key = this.keys[keyIndex]; // eslint-disable-line security/detect-object-injection + continue; + } + throw e; + } + } + } + + + /** + * Locate correct record of multiple encrypted records matching truncated index search. + * @param {string} table name + * @param {string} field name + * @param {string} primaryKeyField name + * @param {Buffer} data searched data as buffer + * @param {any[]} records matceed records + * @returns {*} result + */ + resolveIndexSearchResults(table, field, primaryKeyField, data, records) { + for (const record of records) { + const primaryKey = record[primaryKeyField]; // eslint-disable-line security/detect-object-injection + const payload = record[field]; // eslint-disable-line security/detect-object-injection + const decrypted = this.decrypt(table, field, primaryKey, payload); + if (decrypted.compare(data) === 0) { + return record; + } + } + return undefined; + } + + /** + * Return a truncated hmac of a data field for k-anonymity indexing. + * @param {*} data data + * @param {number} indexBits bits to truncate + * @returns {Buffer} truncated hash for indexing data + */ + truncatedIndex(data, indexBits = 13) { + const bytes = Math.ceil(indexBits / 8); + const firstByteBits = indexBits % 8; + const firstByteMask = 0xff >> (8 - firstByteBits); + const hmac = crypto.createHmac(this.constructor.indexHmac, this.indexKey); + hmac.update(data); + const index = hmac.digest().subarray(0 - bytes); + index[0] = index[0] & firstByteMask; + return index; + } + + + /** + * Rendeer number as 8-byte buffer. + * @param {number|bigint} number value + * @returns {Buffer} number as buffer + */ + static _toUInt64Buffer(number) { + const b = Buffer.alloc(8); + b.writeBigUInt64BE(BigInt(number)); + return b; + } + + + /** + * + * @param {...any} pieces parts + * @returns {Buffer} canonicalized buffer of pieces + */ + static _canonicalize(...pieces) { + const result = [ + this._toUInt64Buffer(pieces.length), + ]; + + for (const piece of pieces) { + const pieceBuffer = Buffer.from(piece); + result.push(this._toUInt64Buffer(pieceBuffer.length)); + result.push(pieceBuffer); + } + + return Buffer.concat(result); + } + + +} + + + + +module.exports = EncryptionHelper; \ No newline at end of file diff --git a/test/lib/encryption-helper.js b/test/lib/encryption-helper.js new file mode 100644 index 0000000..44d8a5b --- /dev/null +++ b/test/lib/encryption-helper.js @@ -0,0 +1,158 @@ +'use strict'; + +const assert = require('node:assert'); +const crypto = require('node:crypto'); +const EncryptionHelper = require('../../lib/encryption-helper'); + +describe('Encryption Helper', function () { + let eh, key, indexKey; + + beforeEach(function () { + indexKey = crypto.generateKeySync('hmac', { length: 256 }); + key = crypto.generateKeySync('hmac', { length: 256 }); + eh = new EncryptionHelper(); + eh.loadIndexKey(indexKey); + eh.loadKey(key); + if (process.env.VERBOSE_TESTS) { + eh.on(EncryptionHelper.statisticsEvent, function (stats) { + console.log(EncryptionHelper.statisticsEvent, stats); + }); + } + }); + + describe('encrypts and decrypts', function () { + it('reciprocates', async function () { + const table = 'table_name'; + const field = 'field_name'; + const primaryKey = '12345'; + const data = 'this is my secret data'; + const encryptedField = await eh.encrypt(table, field, primaryKey, data); + const decryptedField = eh.decrypt(table, field, primaryKey, encryptedField); + assert.deepStrictEqual(decryptedField.toString(), data); + }); + it('falls back to older key', async function () { + const table = 'table_name'; + const field = 'field_name'; + const primaryKey = '12345'; + const data = 'this is my secret data'; + const encryptedField = await eh.encrypt(table, field, primaryKey, data); + key = crypto.generateKeySync('hmac', { length: 256 }); + eh.keys.unshift(key); + const decryptedField = eh.decrypt(table, field, primaryKey, encryptedField); + assert.deepStrictEqual(decryptedField.toString(), data); + }); + it('fails with invalid key', async function () { + const table = 'table_name'; + const field = 'field_name'; + const primaryKey = '12345'; + const data = 'this is my secret data'; + const encryptedField = await eh.encrypt(table, field, primaryKey, data); + key = crypto.generateKeySync('hmac', { length: 256 }); + eh.keys[0] = key; + assert.throws(() => eh.decrypt(table, field, primaryKey, encryptedField)); + }); + }); // encrypts and decrypts + + describe('truncatedIndex', function () { + it('covers default', function () { + const data = 'searchable information'; + const result = eh.truncatedIndex(data); + assert.strictEqual(result.length, 2); + }); + it('covers other', function () { + const data = 'searchable information'; + const result = eh.truncatedIndex(data, 6); + assert.strictEqual(result.length, 1); + }); + it('covers other', function () { + const data = 'searchable information'; + const result = eh.truncatedIndex(data, 32); + assert.strictEqual(result.length, 4); + }); + }); // truncatedIndex + + describe('resolveIndexSearchResults', function () { + it('locates correct record', async function () { + const table = 'my_table'; + const field = 'secretField'; + const primaryKeyField = 'primaryKey'; + const records = [ + { + primaryKey: '1234', + secretField: await eh.encrypt(table, field, '1234', 'secret entry one'), + }, + { + primaryKey: '2345', + secretField: await eh.encrypt(table, field, '2345', 'secret entry two'), + }, + { + primaryKey: '3456', + secretField: await eh.encrypt(table, field, '3456', 'secret entry three'), + }, + ]; + const result = eh.resolveIndexSearchResults(table, field, primaryKeyField, Buffer.from('secret entry two'), records); + assert.strictEqual(result?.primaryKey, '2345'); + }); + it('returns undefined for no matching record', async function () { + const table = 'my_table'; + const field = 'secretField'; + const primaryKeyField = 'primaryKey'; + const records = [ + { + primaryKey: '1234', + secretField: await eh.encrypt(table, field, '1234', 'secret entry one'), + }, + { + primaryKey: '2345', + secretField: await eh.encrypt(table, field, '2345', 'secret entry two'), + }, + { + primaryKey: '3456', + secretField: await eh.encrypt(table, field, '3456', 'secret entry three'), + }, + ]; + const result = eh.resolveIndexSearchResults(table, field, primaryKeyField, Buffer.from('secret entry four'), records); + assert.strictEqual(result, undefined); + }); + }); // resolveIndexSearchResults + + describe('_toUInt64Buffer', function () { + it('renders expected buffer', function () { + const result = EncryptionHelper._toUInt64Buffer(0); + const expected = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + assert.deepStrictEqual(result, expected); + }); + it('renders expected buffer', function () { + const result = EncryptionHelper._toUInt64Buffer(385); + const expected = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x81]); + assert.deepStrictEqual(result, expected); + }); + it('renders expected buffer', function () { + const result = EncryptionHelper._toUInt64Buffer(18446744073709551615n); + const expected = Buffer.from([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); + assert.deepStrictEqual(result, expected); + }); + }); // _toUInt64Buffer + + describe('_canonicalize', function () { + it('covers no pieces', function () { + const result = EncryptionHelper._canonicalize(); + const expected = Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + assert.deepStrictEqual(result, expected); + }); + it('covers some pieces', function () { + const result = EncryptionHelper._canonicalize('a', 'bcd'); + /* eslint-disable array-element-newline */ + const expected = Buffer.from([ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x61, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x62, 0x63, 0x64, + ]); + /* eslint-enable array-element-newline */ + assert.deepStrictEqual(result, expected); + }); + }); // _canonicalize + +}); // Encryption Helper \ No newline at end of file -- 2.49.0