'use strict';
-const { EventEmitter } = require('events');
-const crypto = require('crypto');
-const zlib = require('zlib');
-const { promisify } = require('util');
+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 { 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,
*
* 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.
*/
* @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 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 default flags to use when packing boxes
+ * @param {MysteryBoxOptions} options options
* @param {any[]} args passed on to EventEmitter constructor
*/
constructor(options = {}, ...args) {
* @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)) {
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 {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) {
const allVersions = {
__proto__: null,
+ // 0: {} There is no version zero
+
1: {
version: 1,
algorithm: ALG.AES_256_GCM,
keyDeriver: KD.BLAKE2B512,
keyBytes: 32,
},
+ // Prior to version 10, flags were part of tagged data.
+ // Version 10 and later, flags are part of encrypted data, to leak no hints of content.
+ // Configurations for versions 10 through 19 are duplicates of the legacy 1 through 9.
+ 10: {
+ version: 10,
+ algorithm: ALG.AES_256_GCM,
+ algOptions: {},
+ versionBytes: 1,
+ flagsBytes: 1,
+ ivBytes: 12,
+ saltBytes: 16,
+ tagBytes: 16,
+ keyDeriver: KD.SCRYPT,
+ keyBytes: 32,
+ },
+ 11: {
+ version: 11,
+ algorithm: ALG.CHACHA20_POLY1305, // Prefer this over NIST because we stan djb
+ algOptions: {
+ authTagLength: 16,
+ },
+ versionBytes: 1,
+ flagsBytes: 1,
+ ivBytes: 12,
+ saltBytes: 16,
+ tagBytes: 16,
+ keyDeriver: KD.SCRYPT,
+ keyBytes: 32,
+ },
+ 12: {
+ version: 12,
+ algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+ algOptions: {
+ authTagLength: 16,
+ },
+ versionBytes: 1,
+ flagsBytes: 1,
+ ivBytes: 24,
+ saltBytes: 16,
+ tagBytes: 16,
+ keyDeriver: KD.SCRYPT,
+ keyBytes: 32,
+ },
+ 13: {
+ version: 13,
+ algorithm: ALG.AES_256_GCM,
+ algOptions: {},
+ versionBytes: 1,
+ flagsBytes: 1,
+ ivBytes: 12,
+ saltBytes: 16,
+ tagBytes: 16,
+ keyDeriver: KD.SHAKE256,
+ keyBytes: 32,
+ },
+ 14: {
+ version: 14,
+ algorithm: ALG.CHACHA20_POLY1305,
+ algOptions: {
+ authTagLength: 16,
+ },
+ versionBytes: 1,
+ flagsBytes: 1,
+ ivBytes: 12,
+ saltBytes: 16,
+ tagBytes: 16,
+ keyDeriver: KD.SHAKE256,
+ keyBytes: 32,
+ },
+ 15: {
+ version: 15,
+ algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+ algOptions: {
+ authTagLength: 16,
+ },
+ versionBytes: 1,
+ flagsBytes: 1,
+ ivBytes: 24,
+ saltBytes: 16,
+ tagBytes: 16,
+ keyDeriver: KD.SHAKE256,
+ keyBytes: 32,
+ },
+ 16: {
+ version: 16,
+ algorithm: ALG.AES_256_GCM,
+ algOptions: {},
+ versionBytes: 1,
+ flagsBytes: 1,
+ ivBytes: 12,
+ saltBytes: 16,
+ tagBytes: 16,
+ keyDeriver: KD.BLAKE2B512,
+ keyBytes: 32,
+ },
+ 17: {
+ version: 17,
+ algorithm: ALG.CHACHA20_POLY1305,
+ algOptions: {
+ authTagLength: 16,
+ },
+ versionBytes: 1,
+ flagsBytes: 1,
+ ivBytes: 12,
+ saltBytes: 16,
+ tagBytes: 16,
+ keyDeriver: KD.BLAKE2B512,
+ keyBytes: 32,
+ },
+ 18: {
+ version: 18,
+ algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+ algOptions: {
+ authTagLength: 16,
+ },
+ versionBytes: 1,
+ flagsBytes: 1,
+ ivBytes: 24,
+ saltBytes: 16,
+ tagBytes: 16,
+ keyDeriver: KD.BLAKE2B512,
+ keyBytes: 32,
+ },
};
Object.defineProperties(allVersions, {
'use strict';
-const assert = require('assert');
+const assert = require('node:assert');
const sinon = require('sinon');
const MysteryBox = require('../../lib/mystery-box');
const { MysteryBoxError } = require('../../lib/errors');
-const crypto = require('crypto');
+const crypto = require('node:crypto');
function _verbose(mb) {
if (process.env.VERBOSE_TESTS) {
it('covers missing ciphers', function () {
sinon.stub(crypto, 'getCiphers').returns(['rot13']);
- assert.rejects(() => new MysteryBox(options));
+ assert.rejects(() => new MysteryBox(options), MysteryBoxError);
});
}); // constructor
});
it('encrypts and decrypts all available versions no compression', async function () {
+ this.slow(Object.keys(mb.versionParameters).length * 200);
const availableVersions = Object.keys(mb.versionParameters).map((v) => Number(v));
for await (const version of availableVersions) {
object = {
});
it('encrypts and decrypts all available versions +brotli', async function () {
+ this.slow(Object.keys(mb.versionParameters).length * 200);
const availableVersions = Object.keys(mb.versionParameters).map((v) => Number(v));
for await (const version of availableVersions) {
object = {
});
it('encrypts and decrypts all available versions +flate', async function () {
+ this.slow(Object.keys(mb.versionParameters).length * 200);
const availableVersions = Object.keys(mb.versionParameters).map((v) => Number(v));
for await (const version of availableVersions) {
object = {