--- /dev/null
+'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
--- /dev/null
+'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