From: Justin Wind Date: Sat, 18 Mar 2023 19:30:45 +0000 (-0700) Subject: Merge branch 'v2.0-dev' X-Git-Tag: v2.0.0~1 X-Git-Url: https://git.squeep.com/?a=commitdiff_plain;h=db1b282631d29b21ca028ce39d8bb84a232612c3;hp=5ce60de5e64a735e575fb02cf8944d1b1d4f000c;p=squeep-mystery-box Merge branch 'v2.0-dev' --- diff --git a/index.js b/index.js index edc09e1..43cb295 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,9 @@ 'use strict'; const MysteryBox = require('./lib/mystery-box'); +const { MysteryBoxError } = require('./lib/errors'); module.exports = { MysteryBox, + MysteryBoxError, }; diff --git a/lib/common.js b/lib/common.js deleted file mode 100644 index 1d4223b..0000000 --- a/lib/common.js +++ /dev/null @@ -1,41 +0,0 @@ -'use strict'; - -const path = require('path'); -const { randomBytes } = require('crypto'); -const { promisify } = require('util'); -const randomBytesAsync = promisify(randomBytes); - - -/** - * Return a function which combines a part of the filename with a scope, for use in logging. - * @param {string} filename - */ -const fileScope = (filename) => { - let fScope = path.basename(filename, '.js'); - if (fScope === 'index') { - fScope = path.basename(path.dirname(filename)); - } - return (scope) => `${fScope}:${scope}`; -} - - -/** - * Return an array containing x if x is something and not an array - * @param {*} x - */ -const ensureArray = (x) => { - if (x === undefined) { - return []; - } - if (!Array.isArray(x)) { - return Array(x); - } - return x; -}; - - -module.exports = { - ensureArray, - fileScope, - randomBytesAsync, -}; \ No newline at end of file diff --git a/lib/errors.js b/lib/errors.js new file mode 100644 index 0000000..ec8d285 --- /dev/null +++ b/lib/errors.js @@ -0,0 +1,16 @@ +'use strict'; + +class MysteryBoxError extends Error { + constructor(...args) { + super(...args); + Error.captureStackTrace(MysteryBoxError); + } + + get name() { + return this.constructor.name; + } +} + +module.exports = { + MysteryBoxError, +}; \ No newline at end of file diff --git a/lib/mystery-box.js b/lib/mystery-box.js index abef55c..a2ea8a8 100644 --- a/lib/mystery-box.js +++ b/lib/mystery-box.js @@ -1,19 +1,25 @@ 'use strict'; +const { EventEmitter } = require('events'); const crypto = require('crypto'); const zlib = require('zlib'); const { promisify } = require('util'); -const common = require('./common'); +const { MysteryBoxError } = require('./errors'); const allVersions = require('./version-parameters'); const { performance } = require('perf_hooks'); +const { name: packageName, version: packageVersion } = require('../package.json'); -const _fileScope = common.fileScope(__filename); +const packageInfo = { + packageName, + packageVersion, +}; const brotliCompressAsync = promisify(zlib.brotliCompress); const brotliDecompressAsync = promisify(zlib.brotliDecompress); const deflateRawAsync = promisify(zlib.deflateRaw); const inflateRawAsync = promisify(zlib.inflateRaw); const scryptAsync = promisify(crypto.scrypt); +const randomBytesAsync = promisify(crypto.randomBytes); /** * Only you will know what's inside your... @@ -57,21 +63,17 @@ const compressionFlagsShift = 0; const payloadFlagsMask = (availableFlags.BufferPayload); const payloadFlagsShift = 7; - -class MysteryBox { +class MysteryBox extends EventEmitter { /** - * @param {Console} logger * @param {Object} options * @param {String|String[]} options.encryptionSecret - if an array, will always encrypt with first secret, will attempt to decrypt with all; useful for rolling secrets * @param {Number=} options.defaultFlags */ - constructor(logger, options = {}) { - this.logger = logger; - this.options = options; - - this.secrets = common.ensureArray(options.encryptionSecret); + constructor(options = {}, ...args) { + super(...args); + this.secrets = MysteryBox._ensureArray(options.encryptionSecret); if (!this.secrets.length) { - throw new Error('missing encryption secret'); + throw new MysteryBoxError('missing encryption secret'); } // Filter any unavailable algorithms @@ -91,14 +93,29 @@ class MysteryBox { // Default to highest this.bestVersion = Number(Object.keys(this.versionParameters).sort().pop()); if (Number.isNaN(this.bestVersion)) { - throw new Error('no supported versions available'); + throw new MysteryBoxError('no supported versions available'); } this.Flags = availableFlags; this.defaultFlags = 'defaultFlags' in options ? options.defaultFlags : availableFlags.Flate; if (this.defaultFlags < 0 || this.defaultFlags > 255) { - throw new RangeError('Invalid default flag value'); + throw new MysteryBoxError('Invalid default flag value'); + } + } + + + /** + * Return an array containing x if x is something and not an array + * @param {*} x + */ + static _ensureArray(x) { + if (x === undefined) { + return []; } + if (!Array.isArray(x)) { + return Array(x); + } + return x; } @@ -140,7 +157,7 @@ class MysteryBox { return scryptAsync(secret, salt, keyBytes); default: - throw new RangeError('unsupported key deriver'); + throw new MysteryBoxError('unsupported key deriver'); } } @@ -185,7 +202,7 @@ class MysteryBox { } } // Nine bytes would be an extravagence. - throw new RangeError(`unsupported version header (0x${firstByte.toString(16)})`); + throw new MysteryBoxError(`unsupported version header (0x${firstByte.toString(16)})`); } @@ -209,7 +226,7 @@ class MysteryBox { } if (versionBytes > 6) { - throw new RangeError(`unsupported version (${versionBytes} bytes)`); + throw new MysteryBoxError(`unsupported version (${versionBytes} bytes)`); } // Otherwise, update the masked first byte and parse the rest of the buffer. @@ -242,7 +259,7 @@ class MysteryBox { } else if (version <= 0x03ffffffffff) { // 34359738368-4398046511103 versionBytes = 6; } else { - throw new RangeError('version too large to encode'); + throw new MysteryBoxError(`version too large to encode (${version})`); } const buffer = Buffer.alloc(versionBytes); @@ -257,6 +274,32 @@ class MysteryBox { } + /** + * Stats tracked when packing/unpacking boxes. + * @returns {Object} + */ + static _newStats(method) { + return { + stats: { + method, + version: undefined, + flags: undefined, + flagsRaw: undefined, + serializedBytes: undefined, + compressedBytes: undefined, + }, + timingsMs: { + start: performance.now(), + preCompress: 0, + postCompress: 0, + preCrypt: 0, + postCrypt: 0, + end: 0, + }, + }; + } + + /** * Put contents into a mysterious box. * @param {Object|Buffer} contents @@ -265,27 +308,14 @@ class MysteryBox { * @returns {String} */ async pack(contents, version = this.bestVersion, flags = this.defaultFlags) { - const _scope = _fileScope('pack'); - const timingsMs = { - start: performance.now(), - preCompress: 0, - postCompress: 0, - preCrypt: 0, - postCrypt: 0, - end: 0, - }; - const stats = { - version, - flags: undefined, - serializedBytes: undefined, // original contents size in bytes - compressedBytes: undefined, // compressed contents size in bytes - }; + const { stats, timingsMs } = MysteryBox._newStats('pack'); if (!(version in this.versionParameters)) { - throw new RangeError(`MysteryBox format version ${version} not supported`); + throw new MysteryBoxError(`MysteryBox format version ${version} not supported`); } // eslint-disable-next-line security/detect-object-injection const v = this.versionParameters[version]; + stats.version = version; const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags); @@ -305,13 +335,13 @@ class MysteryBox { const { buffer: versionBuffer, versionBytes } = MysteryBox._versionEncode(v.version); if (versionBytes !== v.versionBytes) { - throw new Error('internal inconsistency, mismatched version byte length'); + throw new MysteryBoxError('internal inconsistency, mismatched version byte length'); } const [iv, salt] = await Promise.all([ v.ivBytes, v.saltBytes, - ].map((b) => common.randomBytesAsync(b))); + ].map((b) => randomBytesAsync(b))); timingsMs.preCompress = performance.now(); let compressedContents; @@ -342,6 +372,7 @@ class MysteryBox { const flagsBuffer = Buffer.alloc(v.flagsBytes); flagsBuffer.writeUInt8(flags, 0); + stats.flagsRaw = flags; stats.flags = this._prettyFlags(flags); // Authenticate all this data @@ -359,7 +390,7 @@ class MysteryBox { const result = Buffer.concat([versionBuffer, flagsBuffer, iv, salt, tag, encrypted, final]).toString('base64url'); timingsMs.end = timingsMs.postCrypt = performance.now(); - this.logger.debug(_scope, 'statistics', { ...stats, ...MysteryBox._timingsLog(timingsMs) }); + this.emit('statistics', { ...stats, ...MysteryBox._timingsLog(timingsMs), ...packageInfo }); return result; } @@ -371,18 +402,10 @@ class MysteryBox { * @returns {Object} */ async unpack(box) { - const _scope = _fileScope('unpack'); - const timingsMs = { - start: performance.now(), - preCompress: 0, - postCompress: 0, - preCrypt: 0, - postCrypt: 0, - end: 0, - }; + const { stats, timingsMs } = MysteryBox._newStats('unpack'); if (!box) { - throw new RangeError('nothing to unpack'); + throw new MysteryBoxError('nothing to unpack'); } const raw = Buffer.from(box, 'base64url'); @@ -390,23 +413,25 @@ class MysteryBox { const { version, versionBytes } = MysteryBox._versionDecode(raw); if (!(version in this.versionParameters)) { - throw new RangeError('unsupported version'); + throw new MysteryBoxError(`unsupported version (${version})`); } // eslint-disable-next-line security/detect-object-injection const v = this.versionParameters[version]; if (v.versionBytes !== versionBytes) { - throw new Error('internal inconsistency, mismatched version byte length'); + throw new MysteryBoxError('internal inconsistency, mismatched version byte length'); } offset += v.versionBytes; + stats.version = version; const minBytes = v.versionBytes + v.flagsBytes + v.ivBytes + v.saltBytes + v.tagBytes; if (raw.length < minBytes) { - throw new RangeError('not enough to unpack'); + throw new MysteryBoxError('not enough to unpack'); } const flags = raw.subarray(offset, offset + v.flagsBytes).readUInt8(0); offset += v.flagsBytes; + stats.flagsRaw = flags; const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags); @@ -461,12 +486,17 @@ class MysteryBox { break; } timingsMs.end = timingsMs.postCompress = performance.now(); + stats.serializedBytes = payload.byteLength; + if (compression) { + stats.compressedBytes = decrypted.byteLength; + } if (!payloadIsBuffer) { payload = JSON.parse(payload.toString('utf8')); } - this.logger.debug(_scope, 'statistics', { version, flags: this._prettyFlags(flags), ...MysteryBox._timingsLog(timingsMs) }); + stats.flags = this._prettyFlags(flags); + this.emit('statistics', { ...stats, ...MysteryBox._timingsLog(timingsMs), ...packageInfo }); return payload; } @@ -504,7 +534,4 @@ class MysteryBox { } -// Expose for stubbing in tests -MysteryBox._test = { crypto }; - module.exports = MysteryBox; \ No newline at end of file diff --git a/test/lib/common.js b/test/lib/common.js deleted file mode 100644 index ad93ea1..0000000 --- a/test/lib/common.js +++ /dev/null @@ -1,39 +0,0 @@ -/* eslint-env mocha */ -'use strict'; - -const assert = require('assert'); -const common = require('../../lib/common'); - -describe('Common', function () { - - describe('fileScope', function () { - it('names a file path', function () { - const filename = 'lib/foo/bar.js'; - const result = common.fileScope(filename)('baz'); - assert.strictEqual(result, 'bar:baz'); - }); - it('names an index path', function () { - const filename = 'lib/foo/index.js'; - const result = common.fileScope(filename)('baz'); - assert.strictEqual(result, 'foo:baz'); - }); - }); // fileScope - - describe('ensureArray', function () { - it('returns empty array for no data', function () { - const result = common.ensureArray(); - assert.deepStrictEqual(result, []); - }); - it('returns same array passed in', function () { - const expected = [1, 2, 3, 'foo']; - const result = common.ensureArray(expected); - assert.deepStrictEqual(result, expected); - }); - it('returns array containing non-array data', function () { - const data = 'bar'; - const result = common.ensureArray(data); - assert.deepStrictEqual(result, [data]); - }); - }); // ensureArray - -}); // Common diff --git a/test/lib/errors.js b/test/lib/errors.js new file mode 100644 index 0000000..0a31620 --- /dev/null +++ b/test/lib/errors.js @@ -0,0 +1,11 @@ +'use strict'; + +const assert = require('assert'); +const { MysteryBoxError } = require('../../lib/errors'); + +describe('Errors', function () { + it('MysteryBoxError', function () { + const e = new MysteryBoxError(); + assert.strictEqual(e.name, 'MysteryBoxError'); + }); +}); // Errors \ No newline at end of file diff --git a/test/lib/mystery-box.js b/test/lib/mystery-box.js index 9f48ff6..510362b 100644 --- a/test/lib/mystery-box.js +++ b/test/lib/mystery-box.js @@ -5,7 +5,14 @@ const assert = require('assert'); const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require const MysteryBox = require('../../lib/mystery-box'); -const stubLogger = require('../stub-logger'); +const { MysteryBoxError } = require('../../lib/errors'); +const crypto = require('crypto'); + +function _verbose(mb) { + if (process.env.VERBOSE_TESTS) { + mb.on('statistics', (...args) => console.log(...args)); + } +} describe('MysteryBox', function () { let mb, options, object; @@ -13,7 +20,6 @@ describe('MysteryBox', function () { options = { encryptionSecret: 'this is not a very good secret', }; - mb = new MysteryBox(stubLogger, options); }); afterEach(function () { sinon.restore(); @@ -22,7 +28,7 @@ describe('MysteryBox', function () { describe('constructor', function () { it('needs a secret', async function () { options = {}; - assert.rejects(() => new MysteryBox(stubLogger, options)); + assert.rejects(() => new MysteryBox(options)); }); it('accepts multiple secrets', async function () { @@ -30,7 +36,8 @@ describe('MysteryBox', function () { options = { encryptionSecret: ['first poor secret', 'second poor secret'], }; - mb = new MysteryBox(stubLogger, options); + mb = new MysteryBox(options); + _verbose(mb); object = { foo: 'bar', baz: 'quux', @@ -42,23 +49,40 @@ describe('MysteryBox', function () { }); it('covers options', function () { - assert.rejects(() => new MysteryBox(stubLogger)); + assert.rejects(() => new MysteryBox()); }); it('covers bad flags', function () { options.defaultFlags = 300; - assert.rejects(() => new MysteryBox(stubLogger, options), RangeError); + assert.rejects(() => new MysteryBox(options), MysteryBoxError); }); it('covers missing ciphers', function () { - sinon.stub(MysteryBox._test.crypto, 'getCiphers').returns(['rot13']); - assert.rejects(() => new MysteryBox(stubLogger, options)); + sinon.stub(crypto, 'getCiphers').returns(['rot13']); + assert.rejects(() => new MysteryBox(options)); }); }); // constructor + describe('_ensureArray', function () { + it('returns empty array for no data', function () { + const result = MysteryBox._ensureArray(); + assert.deepStrictEqual(result, []); + }); + it('returns same array passed in', function () { + const expected = [1, 2, 3, 'foo']; + const result = MysteryBox._ensureArray(expected); + assert.deepStrictEqual(result, expected); + }); + it('returns array containing non-array data', function () { + const data = 'bar'; + const result = MysteryBox._ensureArray(data); + assert.deepStrictEqual(result, [data]); + }); + }); // _ensureArray + describe('_keyFromSecret', function () { it('covers invalid', async function () { - assert.rejects(() => MysteryBox._keyFromSecret('unknown deriver', 'secret', 'salt', 32), RangeError); + assert.rejects(() => MysteryBox._keyFromSecret('unknown deriver', 'secret', 'salt', 32), MysteryBoxError); }); }); // _keyFromSecret @@ -117,7 +141,7 @@ describe('MysteryBox', function () { _check(0xfe, 8, 0x00); }); it('covers unsupported', function () { - assert.throws(() => MysteryBox._versionHeaderDecode(0xff), RangeError); + assert.throws(() => MysteryBox._versionHeaderDecode(0xff), MysteryBoxError); }); }); // _versionHeaderDecode @@ -166,7 +190,7 @@ describe('MysteryBox', function () { }); it('covers too big', function () { const buffer = Buffer.from([0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); - assert.throws(() => MysteryBox._versionDecode(buffer), RangeError); + assert.throws(() => MysteryBox._versionDecode(buffer), MysteryBoxError); }); }); // _versionDecode @@ -220,7 +244,7 @@ describe('MysteryBox', function () { }); it('covers too large', function () { const version = 277076930199552; - assert.rejects(() => MysteryBox._versionEncode(version), RangeError); + assert.rejects(() => MysteryBox._versionEncode(version), MysteryBoxError); }); it('recipricates', function () { [ @@ -236,18 +260,19 @@ describe('MysteryBox', function () { describe('pack, unpack', function () { beforeEach(function () { - mb = new MysteryBox(stubLogger, options); + mb = new MysteryBox(options); + _verbose(mb); }); it('covers packing unsupported version', async function () { - assert.rejects(() => mb.pack({}, 0), RangeError); + assert.rejects(() => mb.pack({}, 0), MysteryBoxError); }); it('covers unpacking unsupported version', async function () { const badBuffer = Buffer.alloc(128); badBuffer.writeUInt8(0, 0); // No such thing as version 0 const badPayload = badBuffer.toString('base64url'); - assert.rejects(() => mb.unpack(badPayload), RangeError); + assert.rejects(() => mb.unpack(badPayload), MysteryBoxError); }); it('encrypts and decrypts default version', async function () { @@ -280,8 +305,10 @@ describe('MysteryBox', function () { it('decrypts secondary (older) secret', async function () { this.slow(500); - const oldmb = new MysteryBox(stubLogger, { encryptionSecret: 'old secret' }); - const newmb = new MysteryBox(stubLogger, { encryptionSecret: ['new secret', 'old secret'] }); + const oldmb = new MysteryBox({ encryptionSecret: 'old secret' }); + _verbose(oldmb); + const newmb = new MysteryBox({ encryptionSecret: ['new secret', 'old secret'] }); + _verbose(newmb); object = { foo: 'bar', baz: 'quux', @@ -294,8 +321,10 @@ describe('MysteryBox', function () { it('fails to decrypt invalid secret', async function () { this.slow(500); - const oldmb = new MysteryBox(stubLogger, { encryptionSecret: 'very old secret' }); - const newmb = new MysteryBox(stubLogger, { encryptionSecret: ['new secret', 'old secret'] }); + const oldmb = new MysteryBox({ encryptionSecret: 'very old secret' }); + _verbose(oldmb); + const newmb = new MysteryBox({ encryptionSecret: ['new secret', 'old secret'] }); + _verbose(newmb); object = { foo: 'bar', baz: 'quux', @@ -305,7 +334,7 @@ describe('MysteryBox', function () { assert.rejects(() => newmb.unpack(oldEncrypted)); }); - it('encrypts and decrypts all available versions +brotli', async function () { + it('encrypts and decrypts all available versions no compression', async function () { Object.keys(mb.versionParameters).map((v) => Number(v)).forEach(async (version) => { object = { foo: 'bar', @@ -318,6 +347,19 @@ describe('MysteryBox', function () { }); }); + it('encrypts and decrypts all available versions +brotli', async function () { + Object.keys(mb.versionParameters).map((v) => Number(v)).forEach(async (version) => { + object = { + foo: 'bar', + baz: 'quux', + flarp: 13, + }; + const encryptedResult = await mb.pack(object, version, mb.Flags.Brotli); + const decryptedResult = await mb.unpack(encryptedResult); + assert.deepStrictEqual(decryptedResult, object, `${version} results not symmetric`); + }); + }); + it('encrypts and decrypts all available versions +flate', async function () { Object.keys(mb.versionParameters).map((v) => Number(v)).forEach(async (version) => { object = { @@ -325,7 +367,7 @@ describe('MysteryBox', function () { baz: 'quux', flarp: 13, }; - const encryptedResult = await mb.pack(object, version, 0x01); + const encryptedResult = await mb.pack(object, version, mb.Flags.Flate); const decryptedResult = await mb.unpack(encryptedResult); assert.deepStrictEqual(decryptedResult, object, `${version} results not symmetric`); }); @@ -341,7 +383,7 @@ describe('MysteryBox', function () { object = { longProperty: 'x'.repeat(384 * 1024).split('').map(() => String.fromCharCode(rnd())).join(''), }; - const encryptedResult = await mb.pack(object, mb.bestVersion, 0x00); + const encryptedResult = await mb.pack(object, mb.bestVersion, mb.Flags.Brotli); const decryptedResult = await mb.unpack(encryptedResult); assert.deepStrictEqual(decryptedResult, object); }); @@ -356,28 +398,30 @@ describe('MysteryBox', function () { object = { longProperty: 'x'.repeat(384 * 1024).split('').map(() => String.fromCharCode(rnd())).join(''), }; - const encryptedResult = await mb.pack(object, mb.bestVersion, 0x01); + const encryptedResult = await mb.pack(object, mb.bestVersion, mb.Flags.Flate); const decryptedResult = await mb.unpack(encryptedResult); assert.deepStrictEqual(decryptedResult, object); }); it('handles undefined', async function () { - assert.rejects(() => mb.unpack(), RangeError); + assert.rejects(() => mb.unpack(), MysteryBoxError); }); it('handles incomplete', async function () { this.slow(500); const encryptedResult = await mb.pack({ foo: 'bar' }); - assert.rejects(() => mb.unpack(encryptedResult.slice(0, 6)), RangeError); + assert.rejects(() => mb.unpack(encryptedResult.slice(0, 6)), MysteryBoxError); }); it('covers internal error, incorrect version byte size, pack', async function () { + this.slow(500); const version = 1; sinon.stub(mb.versionParameters[version], 'versionBytes').value(10); assert.rejects(() => mb.pack({}, version), Error); }); it('covers internal error, incorrect version byte size, unpack', async function () { + this.slow(500); const version = 1; const encryptedResult = await mb.pack({}, version); sinon.stub(mb.versionParameters[version], 'versionBytes').value(10); diff --git a/test/stub-logger.js b/test/stub-logger.js deleted file mode 100644 index 5ac0b98..0000000 --- a/test/stub-logger.js +++ /dev/null @@ -1,18 +0,0 @@ -'use strict'; - -const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require - -const nop = () => { /* */ }; -const stubLogger = process.env.VERBOSE_TESTS ? console : { - debug: nop, - error: nop, - info: nop, -}; -stubLogger['_reset'] = () => { - sinon.spy(stubLogger, 'debug'); - sinon.spy(stubLogger, 'error'); - sinon.spy(stubLogger, 'info'); -}, - - -module.exports = stubLogger; \ No newline at end of file