X-Git-Url: http://git.squeep.com/?p=squeep-mystery-box;a=blobdiff_plain;f=lib%2Fmystery-box.js;fp=lib%2Fmystery-box.js;h=a2ea8a82b05919ccac0c166f681cfe2a64b56fce;hp=abef55c474a619a273e68e932f793058d05ff853;hb=db1b282631d29b21ca028ce39d8bb84a232612c3;hpb=5ce60de5e64a735e575fb02cf8944d1b1d4f000c 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