add encryption helpers
authorJustin Wind <justin.wind+git@gmail.com>
Sat, 21 Jun 2025 21:13:56 +0000 (14:13 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Sat, 21 Jun 2025 21:13:56 +0000 (14:13 -0700)
index.js
lib/encryption-helper.js [new file with mode: 0644]
test/lib/encryption-helper.js [new file with mode: 0644]

index 0f595ecafd3db745ddfa6c84cc2859e03dec5b7d..ca29e3987217572de23af54dd19ea55260431254 100644 (file)
--- 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 (file)
index 0000000..11ecfb5
--- /dev/null
@@ -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 (file)
index 0000000..44d8a5b
--- /dev/null
@@ -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