add some key helpers
authorJustin Wind <justin.wind+git@gmail.com>
Fri, 25 Jul 2025 22:05:56 +0000 (15:05 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Fri, 25 Jul 2025 22:05:56 +0000 (15:05 -0700)
lib/encryption-helper.js
test/lib/encryption-helper.js

index 11ecfb5ea04d757b1b4b06ebc9a6c940895d4d2e..9fa2b30e198f9d0c8b35aaf82fb64e955dd702b9 100644 (file)
@@ -5,22 +5,38 @@ const { promisify } = require('node:util');
 const crypto = require('node:crypto');
 const randomBytesAsync = promisify(crypto.randomBytes);
 
-
+/**
+ * @typedef {object} StatisticsObject
+ * @property {string} method method generating statistics, ['encrypt', 'decrypt']
+ * @property {number} elapsedMs time taken by method
+ */
+
+/**
+ * Methods for wrangling Field Level Encryption.
+ * @fires StatisticsObject statistics
+ */
 class EncryptionHelper extends EventEmitter {
   static cipher = 'chacha20-poly1305';
+  static keyBytes = 32;
   static ivLength = 12;
   static authTagLength = 16;
   static indexHmac = 'sha256';
+  static indexKeyBytes = 32;
   static statisticsEvent = 'statistics';
+  /**
+   * @type {crypto.KeyObject[]}
+   */
   keys;
+  /**
+   * @type {crypto.KeyObject}
+   */
   indexKey;
-  
+
   /**
-   * 
-   * @param {any[]} eventArgs args for event constructor
+   * @param {import('node:events').EventEmitterOptions} eventEmitterOptions options for event emitter constructor
    */
-  constructor(...eventArgs) {
-    super(...eventArgs);
+  constructor(eventEmitterOptions) {
+    super(eventEmitterOptions);
     this.keys = [];
     this.indexKey = undefined;
   }
@@ -28,7 +44,7 @@ class EncryptionHelper extends EventEmitter {
 
   /**
    * Return the most favored key.
-   * @returns {*} key
+   * @returns {crypto.KeyObject} key
    */
   get key() {
     return this.keys[0];
@@ -37,24 +53,57 @@ class EncryptionHelper extends EventEmitter {
 
   /**
    * Set the key(s) used for encrypting and decrypting data.
-   * First added key has precidence, additional keys are attempted for decryption.
-   * @param {*} key key
+   * First key added is used for encryption.
+   * Decryption is attempted with all keys, in insertion order.
+   * @param {crypto.KeyObject} key secret key
    */
   loadKey(key) {
+    if (!(key instanceof crypto.KeyObject)) {
+      // try to coerce it
+      key = crypto.createSecretKey(key);
+    }
+    if (key.symmetricKeySize !== this.constructor.keyBytes) {
+      throw new RangeError(`key size ${key.symmetricKeySize} does not match expected key size ${this.constructor.keyBytes}`);
+    }
     this.keys.push(key);
   }
 
 
   /**
    * Set the key used for hmac index generation.
-   * @param {*} key key
+   * @param {crypto.KeyObject} key index key
    */
   loadIndexKey(key) {
+    if (!(key instanceof crypto.KeyObject)) {
+      // try to coerce it
+      key = crypto.createSecretKey(key);
+    }
+    if (key.symmetricKeySize !== this.constructor.indexKeyBytes) {
+      throw new RangeError(`key size ${key.symmetricKeySize} does not match expected key size ${this.constructor.indexKeyBytes}`);
+    }
     this.indexKey = key;
   }
 
+
   /**
-   * 
+   * Return a KeyObject, either converted from a provided buffer, or randomly generated.
+   * @param {object} options options
+   * @param {Buffer} options.from create key from provided buffer instead of randomness
+   * @param {number=} options.keyBytes key length in bytes
+   * @returns {Promise<crypto.KeyObject>} key
+   */
+  async createKey(options) {
+    const keyBytes = options?.keyBytes ?? this.constructor.keyBytes;
+    if (options?.from && (options.from?.length !== keyBytes)) {
+      throw new RangeError(`provided buffer does not match expected key length ${keyBytes}`);
+    }
+    const from = options?.from ?? await randomBytesAsync(keyBytes);
+    return crypto.createSecretKey(from);
+  }
+
+
+  /**
+   * Encrypt a record's field.
    * @param {string} table table name
    * @param {string} field column name
    * @param {*} primaryKey value of record's primary-key
@@ -77,7 +126,7 @@ class EncryptionHelper extends EventEmitter {
 
 
   /**
-   * 
+   * Decrypt a record's field.
    * @param {string} table table name
    * @param {string} field column name
    * @param {*} primaryKey value of record's primary key
@@ -92,6 +141,7 @@ class EncryptionHelper extends EventEmitter {
     const aadBuffer = this.constructor._canonicalize(table, field, primaryKey);
     let key = this.key;
     let keyIndex = 0;
+    // while true so we can re-throw the last decryption error on failure
     while (true) {
       try {
         const decipher = crypto.createDecipheriv(this.constructor.cipher, key, iv, { authTagLength: this.constructor.authTagLength });
@@ -113,12 +163,13 @@ class EncryptionHelper extends EventEmitter {
 
 
   /**
-   * Locate correct record of multiple encrypted records matching truncated index search.
+   * Locate correct record of multiple encrypted records matching truncated index search,
+   * by iteratively decrypting each record's field.
    * @param {string} table name
    * @param {string} field name
    * @param {string} primaryKeyField name
    * @param {Buffer} data searched data as buffer
-   * @param {any[]} records matceed records
+   * @param {any[]} records matched records
    * @returns {*} result
    */
   resolveIndexSearchResults(table, field, primaryKeyField, data, records) {
@@ -133,8 +184,9 @@ class EncryptionHelper extends EventEmitter {
     return undefined;
   }
 
+
   /**
-   * Return a truncated hmac of a data field for k-anonymity indexing.
+   * 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
@@ -152,7 +204,7 @@ class EncryptionHelper extends EventEmitter {
 
 
   /**
-   * Rendeer number as 8-byte buffer.
+   * Rendeer number as a big0endian 8-byte buffer.
    * @param {number|bigint} number value
    * @returns {Buffer} number as buffer
    */
@@ -164,7 +216,7 @@ class EncryptionHelper extends EventEmitter {
 
 
   /**
-   * 
+   * Pack data into a buffer, each prefixed by a uint64 byte-length field.
    * @param  {...any} pieces parts
    * @returns {Buffer} canonicalized buffer of pieces
    */
@@ -175,7 +227,7 @@ class EncryptionHelper extends EventEmitter {
 
     for (const piece of pieces) {
       const pieceBuffer = Buffer.from(piece);
-      result.push(this._toUInt64Buffer(pieceBuffer.length));
+      result.push(this._toUInt64Buffer(pieceBuffer.byteLength));
       result.push(pieceBuffer);
     }
 
index 44d8a5b66aca26995fe8f1795c0087ab0a25943c..7a003720af9cdbf549c05e9480a38ee3318d1ba5 100644 (file)
@@ -41,7 +41,7 @@ describe('Encryption Helper', function () {
       const decryptedField = eh.decrypt(table, field, primaryKey, encryptedField);
       assert.deepStrictEqual(decryptedField.toString(), data);
     });
-    it('fails with invalid key', async function () {
+    it('fails with wrong key', async function () {
       const table = 'table_name';
       const field = 'field_name';
       const primaryKey = '12345';
@@ -53,6 +53,66 @@ describe('Encryption Helper', function () {
     });
   }); // encrypts and decrypts
 
+  describe('createKey', function () {
+    it('creates a key', async function () {
+      const k = await eh.createKey();
+      assert(k instanceof crypto.KeyObject);
+    });
+    it('creates a key from a buffer', async function () {
+      const k = await eh.createKey({ from: Buffer.alloc(32) });
+      assert(k instanceof crypto.KeyObject);
+    });
+    it('creates a key of specified size', async function () {
+      const keyBytes = 16;
+      const k = await eh.createKey({ keyBytes });
+      assert(k instanceof crypto.KeyObject);
+      assert.strictEqual(k.symmetricKeySize, keyBytes);
+    });
+    it('creates a key of specified size from a buffer', async function () {
+      const keyBytes = 16;
+      const k = await eh.createKey({ keyBytes, from: Buffer.alloc(keyBytes) });
+      assert(k instanceof crypto.KeyObject);
+      assert.strictEqual(k.symmetricKeySize, keyBytes);
+    });
+    it('comaplains about differing key size', async function () {
+      assert.rejects(async () => eh.createKey({ from: Buffer.alloc(17) }), RangeError);
+    });
+  }); // createKey
+
+  describe('loadKey', function () {
+    it('loads a key', async function () {
+      const key = await eh.createKey();
+      eh.loadKey(key);
+      assert(eh.keys[eh.keys.length -1 ].equals(key));
+    });
+    it('loads a key from a buffer', function () {
+      const key = Buffer.alloc(EncryptionHelper.keyBytes);
+      eh.loadKey(key);
+      assert.deepStrictEqual(eh.keys[eh.keys.length -1 ].export(), key);
+    });
+    it('complains about differing key size', function () {
+      const key = Buffer.alloc(17);
+      assert.throws(() => eh.loadKey(key), RangeError);
+    });
+  }); // loadKey
+
+  describe('loadIndexKey', function () {
+    it('loads a key', async function () {
+      const key = await eh.createKey();
+      eh.loadIndexKey(key);
+      assert(eh.indexKey.equals(key));
+    });
+    it('loads a key from a buffer', function () {
+      const key = Buffer.alloc(EncryptionHelper.keyBytes);
+      eh.loadIndexKey(key);
+      assert.deepStrictEqual(eh.indexKey.export(), key);
+    });
+    it('complains about differing key size', function () {
+      const key = Buffer.alloc(17);
+      assert.throws(() => eh.loadIndexKey(key), RangeError);
+    });
+  }); // loadIndexKey
+
   describe('truncatedIndex', function () {
     it('covers default', function () {
       const data = 'searchable information';