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;
}
/**
* Return the most favored key.
- * @returns {*} key
+ * @returns {crypto.KeyObject} 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
+ * 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
/**
- *
+ * Decrypt a record's field.
* @param {string} table table name
* @param {string} field column name
* @param {*} primaryKey value of record's primary key
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 });
/**
- * 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) {
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
/**
- * Rendeer number as 8-byte buffer.
+ * Rendeer number as a big0endian 8-byte buffer.
* @param {number|bigint} number value
* @returns {Buffer} number as buffer
*/
/**
- *
+ * Pack data into a buffer, each prefixed by a uint64 byte-length field.
* @param {...any} pieces parts
* @returns {Buffer} canonicalized buffer of pieces
*/
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);
}
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';
});
}); // 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';