'use strict';
-const { EventEmitter } = require('events');
-const crypto = require('crypto');
-const zlib = require('zlib');
-const { promisify } = require('util');
-const common = require('./common');
+const { EventEmitter } = require('node:events');
+const crypto = require('node:crypto');
+const zlib = require('node:zlib');
+const { promisify } = require('node:util');
const { MysteryBoxError } = require('./errors');
const allVersions = require('./version-parameters');
-const { performance } = require('perf_hooks');
+const { performance } = require('node:perf_hooks');
const { name: packageName, version: packageVersion } = require('../package.json');
+/**
+ * @typedef {object} PackageInfo
+ * @property {string} packageName from package.json
+ * @property {string} packageVersion from package.json
+ */
const packageInfo = {
packageName,
packageVersion,
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...
*
* The result is a Base64URL-encoded byte-array consisting of:
* - version, indicating format and encryption algorithm
- * - flags, indicating state of the contents
* - iv/nonce, randomness for the algorithm
* - salt, applied to a secret to derive a key
* - tag, additional room for encryption to validate the previous fields
+ * - flags, encrypted, indicating state of the payload (id est compression used, encoding type)
* - payload, encrypted version of possibly compressed and serialized object
*
+ * Older versions exposed the payload state flags, later versions encrypt them.
*/
const payloadFlagsMask = (availableFlags.BufferPayload);
const payloadFlagsShift = 7;
+/**
+ * @typedef {string|Buffer} Secret
+ * @typedef {object} MysteryBoxOptions
+ * @property {Secret|Secret[]} encryptionSecret if an array, will always encrypt with first secret, will attempt to decrypt with all in ordere
+ * @property {number=} defaultFlags default flags to use when packing boxes
+ * @event MysteryBox#statistics
+ * @type {object}
+ * @property {...PackageInfo} package name and version
+ * @property {...Stats} details of operation
+ * @property {...TimingsPhases} timings of operation
+ */
class MysteryBox extends EventEmitter {
/**
- * @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
+ * @param {MysteryBoxOptions} options options
+ * @param {any[]} args passed on to EventEmitter constructor
*/
constructor(options = {}, ...args) {
super(...args);
- this.secrets = common.ensureArray(options.encryptionSecret);
+ this.secrets = MysteryBox._ensureArray(options.encryptionSecret);
if (!this.secrets.length) {
throw new MysteryBoxError('missing encryption secret');
}
}, {});
// Default to highest
- this.bestVersion = Number(Object.keys(this.versionParameters).sort().pop());
+ this.bestVersion = Number(Object.keys(this.versionParameters).sort((a, b) => a - b).pop());
if (Number.isNaN(this.bestVersion)) {
throw new MysteryBoxError('no supported versions available');
}
this.Flags = availableFlags;
- this.defaultFlags = 'defaultFlags' in options ? options.defaultFlags : availableFlags.Flate;
+ this.defaultFlags = options.defaultFlags ?? availableFlags.Flate;
if (this.defaultFlags < 0 || this.defaultFlags > 255) {
throw new MysteryBoxError('Invalid default flag value');
}
}
+ /**
+ * Return an array containing x if x is something and not an array
+ * @param {*} x thing to ensure is an array
+ * @returns {any[]} thing array or thing in an array
+ */
+ static _ensureArray(x) {
+ if (x === undefined) {
+ return [];
+ }
+ if (!Array.isArray(x)) {
+ return Array(x);
+ }
+ return x;
+ }
+
+
/**
* Parse the bits out of the flags.
+ * @param {number} flags flags
+ * @returns {object} decoded flags
*/
static _decodeFlags(flags) {
return {
/**
* Generate key data.
+ * @param {string} deriver key deriver enum
+ * @param {Buffer} secret secret
+ * @param {Buffer} salt salt
+ * @param {number} keyBytes bytes in key
+ * @returns {Promise<Buffer>} key
*/
static async _keyFromSecret(deriver, secret, salt, keyBytes) {
switch (deriver) {
/**
* Return bits and bit mask for given number of encoded bytes.
- * @param {Number} numBytes
- * @returns {Object}
+ * @param {number} numBytes number of bytes
+ * @returns {object} bits value and mask
*/
static _versionHeaderBits(numBytes) {
// Round up to 8 bits in result, just to be proper.
* Number of packed bytes is indicated by location of first leading 0 bit.
* Support for numbers larger than 127 is of dubious practicality, but here
* it is anyhow.
- * @param {Number} firstByte
- * @returns {Object}
+ * @param {number} firstByte first byte contains encoded byte length
+ * @returns {object} number of bytes and masked first byte
*/
static _versionHeaderDecode(firstByte) {
for (let numBytes = 1; numBytes <= 8; numBytes++) {
* many total bytes comprise the version, in big-endian encoding.
* Returns decoded version and number of bytes used to decode.
* Only supports up to 6-byte numbers, and of those, only up to 4398046511103.
- * @param {Buffer} buf - N.B. will be mogrified
- * @returns {Object}
+ * @param {Buffer} buf N.B. will be mogrified
+ * @returns {object} decoded version and version byte length
*/
static _versionDecode(buf) {
const headerByte = buf.readUInt8(0);
/**
* Encode a version identifier into a buffer of a variable number of bytes.
- * @param {Number} version
- * @returns {Object}
+ * @param {number} version version number to encode
+ * @returns {object} encoded version buffer and number of bytes
*/
static _versionEncode(version) {
let versionBytes = 0;
}
+ /**
+ * @typedef {object} Stats tracking details of operation
+ * @property {string} method name of operation
+ * @property {number} version box version
+ * @property {string} flags decoded flags
+ * @property {number} flagsRaw numeric flags
+ * @property {number} serializedBytes size of payload
+ * @property {number} compressedBytes size of compressed payload
+ * @typedef {object} Timings tracking for pack and unpack operations
+ * @property {number} start timestamp in ms, start of all operations
+ * @property {number} preCompress timestamp in ms, start of compression checkpoint
+ * @property {number} postCompress timestamp in ms, end of compression checkpoint
+ * @property {number} preCrypt timestamp in ms, start of encryption checkpoint
+ * @property {number} postCrypt timestamp in ms, end of encryption checkpoint
+ * @property {number} end timestamp in ms, end of all operations
+ * @typedef {object} NewStats
+ * @property {Stats} stats stats
+ * @property {Timings} timingsMs timings
+ */
/**
* Stats tracked when packing/unpacking boxes.
- * @returns {Object}
+ * @param {string} method method name generating stats
+ * @returns {NewStats} stats and timings
*/
static _newStats(method) {
return {
/**
* Put contents into a mysterious box.
- * @param {Object|Buffer} contents
- * @param {Number=} version
- * @param {Number=} flags
- * @returns {String}
+ * @param {object|Buffer} contents object or buffer
+ * @param {number=} version version
+ * @param {number=} flags flags
+ * @returns {Promise<string>} encoded contents
+ * @fires MysteryBox#statistics
*/
async pack(contents, version = this.bestVersion, flags = this.defaultFlags) {
const { stats, timingsMs } = MysteryBox._newStats('pack');
const v = this.versionParameters[version];
stats.version = version;
+ const visibleFlags = (version <= 9);
+
const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags);
if (Buffer.isBuffer(contents)) {
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;
break;
}
stats.compressedBytes = Buffer.byteLength(compressedContents);
- // const compressionRatio = stats.compressedBytes / stats.serializedBytes;
timingsMs.postCompress = timingsMs.preCrypt = performance.now();
let payload;
stats.flagsRaw = flags;
stats.flags = this._prettyFlags(flags);
- // Authenticate all this data
- const aadBuffer = Buffer.concat([versionBuffer, flagsBuffer, iv, salt]);
-
// Always encrypt with first secret
const secret = this.secrets[0];
const key = await MysteryBox._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes);
const cipher = crypto.createCipheriv(v.algorithm, key, iv, v.algOptions);
+
+ let result;
+
+ // Authenticate all this data
+ let aadBuffer;
+ if (visibleFlags) {
+ aadBuffer = Buffer.concat([versionBuffer, flagsBuffer, iv, salt]);
+ } else {
+ aadBuffer = Buffer.concat([versionBuffer, iv, salt]);
+ }
cipher.setAAD(aadBuffer);
+
+ let encryptedFlags;
+ if (!visibleFlags) {
+ encryptedFlags = cipher.update(flagsBuffer);
+ }
+
const encrypted = cipher.update(payload);
const final = cipher.final();
const tag = cipher.getAuthTag();
- const result = Buffer.concat([versionBuffer, flagsBuffer, iv, salt, tag, encrypted, final]).toString('base64url');
+ if (visibleFlags) {
+ result = Buffer.concat([versionBuffer, flagsBuffer, iv, salt, tag, encrypted, final]).toString('base64url');
+ } else {
+ result = Buffer.concat([versionBuffer, iv, salt, tag, encryptedFlags, encrypted, final]).toString('base64url');
+ }
timingsMs.end = timingsMs.postCrypt = performance.now();
this.emit('statistics', { ...stats, ...MysteryBox._timingsLog(timingsMs), ...packageInfo });
/**
* Take contents out of a mysterious box.
- * @param {String} box - Base64URL encoded payload
- * @returns {Object}
+ * @param {string} box - Base64URL encoded payload
+ * @returns {Promise<object|Buffer>} decoded contents
+ * @fires MysteryBox#statistics
*/
async unpack(box) {
const { stats, timingsMs } = MysteryBox._newStats('unpack');
offset += v.versionBytes;
stats.version = version;
- const minBytes = v.versionBytes + v.flagsBytes + v.ivBytes + v.saltBytes + v.tagBytes;
+ const visibleFlags = (version <= 9);
+
+ const minBytes = v.versionBytes + (visibleFlags ? v.flagsBytes : 0) + v.ivBytes + v.saltBytes + v.tagBytes;
if (raw.length < minBytes) {
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);
+ let flags;
+ if (visibleFlags) {
+ flags = raw.subarray(offset, offset + v.flagsBytes).readUInt8(0);
+ offset += v.flagsBytes;
+ stats.flagsRaw = flags;
+ }
const iv = raw.subarray(offset, offset + v.ivBytes);
offset += v.ivBytes;
}
}
if (!success) {
- throw err;
+ throw new MysteryBoxError('failed to decipher', { cause: err });
+ }
+
+ if (!visibleFlags) {
+ flags = decrypted.subarray(0, v.flagsBytes).readUInt8(0);
+ stats.flagsRaw = flags;
+ decrypted = decrypted.subarray(v.flagsBytes);
}
+ const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags);
+
let payload;
timingsMs.preCompress = timingsMs.postCrypt = performance.now();
switch (compression) {
/**
* Pretty-print flag values
- * @param {Number} flags
- * @returns {String}
+ * @param {number} flags flags
+ * @returns {string} pretty flags
*/
_prettyFlags(flags) {
const flagNames = Object.entries(this.Flags).reduce((acc, cur) => {
}
+ /**
+ * @typedef {object} TimingsPhases
+ * @property {number} totalMs total time taken
+ * @property {number} compressMs time taken by compression phase
+ * @property {number} cryptMs time taken by encryption phase
+ */
/**
* Everyone loves numbers.
- * @param {Object} timingsMs
- * @returns
+ * @param {Timings} timingsMs raw timings
+ * @returns {TimingsPhases} computed timings
*/
static _timingsLog({ start, preCompress, postCompress, preCrypt, postCrypt, end }) {
return {
}
-module.exports = MysteryBox;
\ No newline at end of file
+module.exports = MysteryBox;