From: Justin Wind Date: Fri, 25 Jul 2025 22:05:56 +0000 (-0700) Subject: add some key helpers X-Git-Url: https://git.squeep.com/?a=commitdiff_plain;h=6ec79b03a82cd8c332bc1604bbc9ca70ead48de9;p=squeep-db-helper add some key helpers --- diff --git a/lib/encryption-helper.js b/lib/encryption-helper.js index 11ecfb5..9fa2b30 100644 --- a/lib/encryption-helper.js +++ b/lib/encryption-helper.js @@ -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} 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); } diff --git a/test/lib/encryption-helper.js b/test/lib/encryption-helper.js index 44d8a5b..7a00372 100644 --- a/test/lib/encryption-helper.js +++ b/test/lib/encryption-helper.js @@ -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';