use KeyObject instead of buffers for all sensitive data, use HKDF for new blake2b512...
authorJustin Wind <justin.wind+git@gmail.com>
Sat, 26 Jul 2025 20:55:20 +0000 (13:55 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Sat, 26 Jul 2025 20:55:20 +0000 (13:55 -0700)
CHANGELOG.md
README.md
lib/mystery-box.js
lib/version-parameters.js
test/lib/mystery-box.js

index 430fdddce403057368a07533d21d6f89162bfe27..ce000757746328f1375a4b61a64bdc157f46d242 100644 (file)
@@ -2,6 +2,14 @@
 
 Releases and notable changes to this project are documented here.
 
+## [v2.2.0] - TBD
+
+- support including additional data in events
+- include simple secret generator convenience script
+- internal: add hkdf as preferred key deriver
+- internal: use KeyObject instead of plain Buffer for keys and secrets
+- updated devDependencies
+
 ## [v2.1.0] - 2024-11-26
 
 - Added new preferred versions which store payload flags encrypted rather than tagged, to avoid leaking any payload metadata.
index 3104107c4a03c760651e685fa72cdc23e28cdf95..41c2bf9c8e0e6b75a6ecf97d23d129e5262893be 100644 (file)
--- a/README.md
+++ b/README.md
@@ -6,8 +6,17 @@ In our case, this results in a Base64URL encoded string containing a bespoke pac
 
 ## API
 
-- `async pack(contents, version, flags)`
-- `async unpack(box)`
+### `async pack(contents, version, flags, statsExtra)`
+
+  - `contents`: a JSON-serializable object or Buffer
+  - `version`: optional, override the best-available version
+  - `flags`: optional, override the default flags of enabling Flate compression
+  - `statsExtra`: optional, additional data included in the statistics event for this call
+
+### `async unpack(box, statsExtra)`
+
+  - `box`: base64url encoded string
+  - `statsExtra`: optional, additional data included in the statistics event for this call
 
 ## Example
 
index 3bf9b84a0afa3c8beedb44eeb40f1f94bc840e6b..cb2aa4e6170add39c3efb126d2b3a349322bcb02 100644 (file)
@@ -25,6 +25,7 @@ const deflateRawAsync = promisify(zlib.deflateRaw);
 const inflateRawAsync = promisify(zlib.inflateRaw);
 const scryptAsync = promisify(crypto.scrypt);
 const randomBytesAsync = promisify(crypto.randomBytes);
+const hkdfAsync = promisify(crypto.hkdf);
 
 /**
  * Only you will know what's inside your...
@@ -48,6 +49,7 @@ const randomBytesAsync = promisify(crypto.randomBytes);
  * - payload, encrypted version of possibly compressed and serialized object
  *
  * Older versions exposed the payload state flags, later versions encrypt them.
+ * All bytes up to the encrypted content are verified as AAD.
  */
 
 
@@ -72,23 +74,34 @@ 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 {Secret|Secret[]} encryptionSecret if an array, will always encrypt with first secret, will attempt to decrypt with all in order
  * @property {number=} defaultFlags default flags to use when packing boxes
+ * @property {string|Buffer=} hkdfSalt domain-specific salt for HKDF key derivation
  * @event MysteryBox#statistics
  * @type {object}
  * @property {...PackageInfo} package name and version
  * @property {...Stats} details of operation
  * @property {...TimingsPhases} timings of operation
+ * @property {any=} extra extra data
  */
 
 class MysteryBox extends EventEmitter {
+  static statisticsEvent = 'statistics';
+  secrets;
+  versionParameters;
+  bestVersion;
+  Flags;
+  defaultFlags;
+  hkdfSalt;
+
   /**
    * @param {MysteryBoxOptions} options options
    * @param {any[]} args passed on to EventEmitter constructor
    */
   constructor(options = {}, ...args) {
     super(...args);
-    this.secrets = MysteryBox._ensureArray(options.encryptionSecret);
+    this.secrets = MysteryBox._ensureArray(options.encryptionSecret)
+      .map((secret) => crypto.createSecretKey(secret));
     if (!this.secrets.length) {
       throw new MysteryBoxError('missing encryption secret');
     }
@@ -98,6 +111,9 @@ class MysteryBox extends EventEmitter {
     const availableHashes = crypto.getHashes();
     // Add legacy scrypt to available hashes for filtering key derivations
     availableHashes.push(allVersions.KD.SCRYPT);
+    /**
+     * @type {Record<number, import('./version-parameters').VersionParameters>}
+     */
     this.versionParameters = Object.entries(allVersions).reduce((acc, [v, p]) => {
       const validCipher = availableCiphers.includes(p.algorithm);
       const validKeyDeriver = availableHashes.includes(p.keyDeriver);
@@ -118,6 +134,8 @@ class MysteryBox extends EventEmitter {
     if (this.defaultFlags < 0 || this.defaultFlags > 255) {
       throw new MysteryBoxError('Invalid default flag value');
     }
+
+    this.hkdfSalt = options.hkdfSalt ?? Buffer.from('squeep-mystery-box');
   }
 
 
@@ -137,10 +155,15 @@ class MysteryBox extends EventEmitter {
   }
 
 
+  /**
+   * @typedef {object} FlagsDecoded
+   * @property {number} compression compression type
+   * @property {boolean} payloadIsBuffer true if payload is a buffer
+   */
   /**
    * Parse the bits out of the flags.
    * @param {number} flags flags
-   * @returns {object} decoded flags
+   * @returns {FlagsDecoded} decoded flags
    */
   static _decodeFlags(flags) {
     return {
@@ -152,45 +175,61 @@ class MysteryBox extends EventEmitter {
 
   /**
    * Generate key data.
-   * @param {string} deriver key deriver enum
-   * @param {Buffer} secret secret
+   * @param {string} deriver key deriver enum string
+   * @param {crypto.KeyObject} secret secret
    * @param {Buffer} salt salt
    * @param {number} keyBytes bytes in key
-   * @returns {Promise<Buffer>} key
+   * @param {boolean} useHKDF use HKDF for derivation
+   * @returns {Promise<crypto.KeyObject>} key
    */
-  static async _keyFromSecret(deriver, secret, salt, keyBytes) {
+  async _keyFromSecret(deriver, secret, salt, keyBytes, useHKDF) {
+    let keyBuffer;
     switch (deriver) {
       case allVersions.KD.SHAKE256: {
         const hash = crypto.createHash(allVersions.KD.SHAKE256, { outputLength: keyBytes });
         hash.update(salt);
-        hash.update(secret);
-        return hash.digest();
+        hash.update(secret.export());
+        keyBuffer = hash.digest();
+        break;
       }
 
       case allVersions.KD.BLAKE2B512: {
-        const hash = crypto.createHash(allVersions.KD.BLAKE2B512);
-        hash.update(salt);
-        hash.update(secret);
-        const digest = hash.digest();
-        // should assert that keyBytes <= 64
-        // but not concerned about that for now
-        // until we have a new algorithm with bigger key size
-        return digest.subarray(0, keyBytes);
+        if (useHKDF) {
+          keyBuffer = await hkdfAsync(allVersions.KD.BLAKE2B512, secret.export(), this.hkdfSalt, salt, keyBytes);
+        } else {
+          const hash = crypto.createHash(allVersions.KD.BLAKE2B512);
+          hash.update(salt);
+          hash.update(secret.export());
+          const digest = hash.digest();
+          // should assert that keyBytes <= 64
+          // but not concerned about that for now
+          // until we have a new algorithm with bigger key size
+          keyBuffer = digest.subarray(0, keyBytes);
+        }
+        break;
       }
 
       case allVersions.KD.SCRYPT:
-        return scryptAsync(secret, salt, keyBytes);
+        keyBuffer = await scryptAsync(secret.export(), salt, keyBytes);
+        break;
 
       default:
         throw new MysteryBoxError('unsupported key deriver');
     }
+
+    return crypto.createSecretKey(keyBuffer);
   }
 
 
+  /**
+   * @typedef {object} VersionHeaderBits
+   * @property {number} headerValue value
+   * @property {number} headerMask mask
+   */
   /**
    * Return bits and bit mask for given number of encoded bytes.
    * @param {number} numBytes number of bytes
-   * @returns {object} bits value and mask
+   * @returns {VersionHeaderBits} bits value and mask
    */
   static _versionHeaderBits(numBytes) {
     // Round up to 8 bits in result, just to be proper.
@@ -231,6 +270,11 @@ class MysteryBox extends EventEmitter {
   }
 
 
+  /**
+   * @typedef {object} VersionDecoded
+   * @property {number} version version number
+   * @property {number} versionBytes number of bytes used to encode version
+   */
   /**
    * Decode leading bytes of buffer as version identifier.
    * In the first byte, the position of the first unset bit indicates how
@@ -238,7 +282,7 @@ class MysteryBox extends EventEmitter {
    * 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} decoded version and version byte length
+   * @returns {VersionDecoded} decoded version and version byte length
    */
   static _versionDecode(buf) {
     const headerByte = buf.readUInt8(0);
@@ -263,10 +307,15 @@ class MysteryBox extends EventEmitter {
   }
 
 
+  /**
+   * @typedef {object} VersionEncoded
+   * @property {Buffer} buffer encoded version
+   * @property {number} versionBytes number of bytes used to encode version
+   */
   /**
    * Encode a version identifier into a buffer of a variable number of bytes.
    * @param {number} version version number to encode
-   * @returns {object} encoded version buffer and number of bytes
+   * @returns {VersionEncoded} encoded version buffer and number of bytes
    */
   static _versionEncode(version) {
     let versionBytes = 0;
@@ -350,35 +399,40 @@ class MysteryBox extends EventEmitter {
    * @param {object|Buffer} contents object or buffer
    * @param {number=} version version
    * @param {number=} flags flags
+   * @param {any=} statsExtra extra field added to stats
    * @returns {Promise<string>} encoded contents
    * @fires MysteryBox#statistics
    */
-  async pack(contents, version = this.bestVersion, flags = this.defaultFlags) {
+  async pack(contents, version, flags, statsExtra) {
     const { stats, timingsMs } = MysteryBox._newStats('pack');
 
+    version = version ?? this.bestVersion;
+    flags = flags ?? this.defaultFlags;
+
     if (!(version in this.versionParameters)) {
       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;
+    stats.version = v.version;
 
+    // version specific details
     const visibleFlags = (version <= 9);
+    const useHKDF = (version >= 19);
 
     const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags);
 
     if (Buffer.isBuffer(contents)) {
       // Ensure payloadIsBuffer flag is set when contents are indeed a Buffer
       flags |= this.Flags.BufferPayload;
+    } else if (payloadIsBuffer) {
+      // Flag is set, but contents are not a Buffer? Try to coerce contents.
+      contents = Buffer.from(contents);
     } else {
-      if (payloadIsBuffer) {
-        // Flag is set, but contents are not a Buffer? Try to coerce contents.
-        contents = Buffer.from(contents);
-      } else {
-        // Otherwise attempt to serialize the object
-        contents = JSON.stringify(contents);
-      }
+      // Otherwise attempt to serialize the object
+      contents = JSON.stringify(contents);
     }
+
     stats.serializedBytes = Buffer.byteLength(contents);
 
     const { buffer: versionBuffer, versionBytes } = MysteryBox._versionEncode(v.version);
@@ -424,7 +478,7 @@ class MysteryBox extends EventEmitter {
 
     // Always encrypt with first secret
     const secret = this.secrets[0];
-    const key = await MysteryBox._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes);
+    const key = await this._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes, useHKDF);
     const cipher = crypto.createCipheriv(v.algorithm, key, iv, v.algOptions);
 
     let result;
@@ -454,7 +508,12 @@ class MysteryBox extends EventEmitter {
     }
     timingsMs.end = timingsMs.postCrypt = performance.now();
 
-    this.emit('statistics', { ...stats, ...MysteryBox._timingsLog(timingsMs), ...packageInfo });
+    this.emit('statistics', {
+      ...stats,
+      ...MysteryBox._timingsLog(timingsMs),
+      ...packageInfo,
+      ...(statsExtra && { extra: statsExtra }),
+    });
 
     return result;
   }
@@ -462,11 +521,12 @@ class MysteryBox extends EventEmitter {
 
   /**
    * Take contents out of a mysterious box.
-   * @param {string} box - Base64URL encoded payload
+   * @param {string} box Base64URL encoded payload
+   * @param {any=} statsExtra extra fieild added to stats
    * @returns {Promise<object|Buffer>} decoded contents
    * @fires MysteryBox#statistics
    */
-  async unpack(box) {
+  async unpack(box, statsExtra) {
     const { stats, timingsMs } = MysteryBox._newStats('unpack');
 
     if (!box) {
@@ -490,6 +550,7 @@ class MysteryBox extends EventEmitter {
     stats.version = version;
 
     const visibleFlags = (version <= 9);
+    const useHKDF = (version >= 19);
 
     const minBytes = v.versionBytes + (visibleFlags ? v.flagsBytes : 0) + v.ivBytes + v.saltBytes + v.tagBytes;
     if (raw.length < minBytes) {
@@ -521,8 +582,8 @@ class MysteryBox extends EventEmitter {
     let decrypted;
     let err;
     let success = false;
-    for await (const secret of this.secrets) {
-      const key = await MysteryBox._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes);
+    for (const secret of this.secrets) {
+      const key = await this._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes, useHKDF);
       const decipher = crypto.createDecipheriv(v.algorithm, key, iv, v.algOptions);
       decipher.setAAD(aad);
       decipher.setAuthTag(tag);
@@ -572,7 +633,12 @@ class MysteryBox extends EventEmitter {
     }
 
     stats.flags = this._prettyFlags(flags);
-    this.emit('statistics', { ...stats, ...MysteryBox._timingsLog(timingsMs), ...packageInfo });
+    this.emit('statistics', {
+      ...stats,
+      ...MysteryBox._timingsLog(timingsMs),
+      ...packageInfo,
+      ...(statsExtra && { extra: statsExtra }),
+    });
 
     return payload;
   }
index bfbb116bb887e9272532faea374671e0edbefe53..3d67e6e91b2b4abd671eb91e5fd867f035130c4c 100644 (file)
@@ -5,7 +5,7 @@ const ALG = {
 
   AES_256_GCM: 'aes-256-gcm',
   CHACHA20_POLY1305: 'chacha20-poly1305',
-  XCHACHA20_POLY1305: 'xchacha20-poly1305',
+  XCHACHA20_POLY1305: 'xchacha20-poly1305', // Not currently available, but we optimistically allocate for a future where it is
 };
 
 const KD = {
@@ -16,10 +16,32 @@ const KD = {
   BLAKE2B512: 'blake2b512',
 };
 
+/**
+ * @typedef {object} VersionParameters
+ * @property {number} version version number
+ * @property {string} algorithm encryption algorithm, from ALG
+ * @property {object} algOptions algorithm specific options
+ * @property {number=} algOptions.authTagLength length of authentication tag
+ * @property {number} versionBytes number of bytes used for version
+ * @property {number} flagsBytes number of bytes used for flags
+ * @property {number} ivBytes number of bytes used for IV
+ * @property {number} saltBytes number of bytes used for salt
+ * @property {number} tagBytes number of bytes used for tag
+ * @property {string} keyDeriver key derivation function, from KD
+ * @property {object} keyDeriverOptions options for key derivation function
+ * @property {number} keyBytes number of bytes used for key
+ */
+
+/**
+ * @typedef {Record<number, VersionParameters>} Versions
+ * @property {Record<string, string>} ALG encryption algorithms
+ * @property {Record<string, string>} KD key derivation functions
+ */
 /**
  * Supported packings/cipher types.
  * To be useful, any cipher included here must be Authenticated Encryption with Additional Data (AEAD).
  * More preferable versions are numbered higher.
+ * @type {Versions}
  */
 const allVersions = {
   __proto__: null,
@@ -54,7 +76,7 @@ const allVersions = {
   },
   3: {
     version: 3,
-    algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+    algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer this, allocating for hopeful future use
     algOptions: {
       authTagLength: 16,
     },
@@ -66,6 +88,7 @@ const allVersions = {
     keyDeriver: KD.SCRYPT,
     keyBytes: 32,
   },
+
   4: {
     version: 4,
     algorithm: ALG.AES_256_GCM,
@@ -94,7 +117,7 @@ const allVersions = {
   },
   6: {
     version: 6,
-    algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+    algorithm: ALG.XCHACHA20_POLY1305,
     algOptions: {
       authTagLength: 16,
     },
@@ -106,6 +129,7 @@ const allVersions = {
     keyDeriver: KD.SHAKE256,
     keyBytes: 32,
   },
+
   7: {
     version: 7,
     algorithm: ALG.AES_256_GCM,
@@ -134,7 +158,7 @@ const allVersions = {
   },
   9: {
     version: 9,
-    algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+    algorithm: ALG.XCHACHA20_POLY1305,
     algOptions: {
       authTagLength: 16,
     },
@@ -177,7 +201,7 @@ const allVersions = {
   },
   12: {
     version: 12,
-    algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+    algorithm: ALG.XCHACHA20_POLY1305,
     algOptions: {
       authTagLength: 16,
     },
@@ -189,6 +213,7 @@ const allVersions = {
     keyDeriver: KD.SCRYPT,
     keyBytes: 32,
   },
+
   13: {
     version: 13,
     algorithm: ALG.AES_256_GCM,
@@ -217,7 +242,7 @@ const allVersions = {
   },
   15: {
     version: 15,
-    algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+    algorithm: ALG.XCHACHA20_POLY1305,
     algOptions: {
       authTagLength: 16,
     },
@@ -229,6 +254,7 @@ const allVersions = {
     keyDeriver: KD.SHAKE256,
     keyBytes: 32,
   },
+
   16: {
     version: 16,
     algorithm: ALG.AES_256_GCM,
@@ -257,7 +283,50 @@ const allVersions = {
   },
   18: {
     version: 18,
-    algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+    algorithm: ALG.XCHACHA20_POLY1305,
+    algOptions: {
+      authTagLength: 16,
+    },
+    versionBytes: 1,
+    flagsBytes: 1,
+    ivBytes: 24,
+    saltBytes: 16,
+    tagBytes: 16,
+    keyDeriver: KD.BLAKE2B512,
+    keyBytes: 32,
+  },
+
+  // Prior to version 19, blake2b512 used plain hash digest.
+  // Versions 19 and later use HKDF for blake2b512 key derivation.
+  19: {
+    version: 19,
+    algorithm: ALG.AES_256_GCM,
+    algOptions: {},
+    versionBytes: 1,
+    flagsBytes: 1,
+    ivBytes: 12,
+    saltBytes: 16,
+    tagBytes: 16,
+    keyDeriver: KD.BLAKE2B512,
+    keyBytes: 32,
+  },
+  20: {
+    version: 20,
+    algorithm: ALG.CHACHA20_POLY1305,
+    algOptions: {
+      authTagLength: 16,
+    },
+    versionBytes: 1,
+    flagsBytes: 1,
+    ivBytes: 12,
+    saltBytes: 16,
+    tagBytes: 16,
+    keyDeriver: KD.BLAKE2B512,
+    keyBytes: 32,
+  },
+  21: {
+    version: 21,
+    algorithm: ALG.XCHACHA20_POLY1305,
     algOptions: {
       authTagLength: 16,
     },
@@ -269,6 +338,7 @@ const allVersions = {
     keyDeriver: KD.BLAKE2B512,
     keyBytes: 32,
   },
+
 };
 
 Object.defineProperties(allVersions, {
index c0b5d9ab02e53e108332ba5d8cb029b1ce5f4563..865cec67f12519e28d172178f92cd23d990aacf1 100644 (file)
@@ -13,11 +13,12 @@ function _verbose(mb) {
 }
 
 describe('MysteryBox', function () {
-  let mb, options, object;
+  let mb, options, object, statsStub;
   beforeEach(function () {
     options = {
       encryptionSecret: 'this is not a very good secret',
     };
+    statsStub = sinon.stub();
   });
   afterEach(function () {
     sinon.restore();
@@ -35,14 +36,16 @@ describe('MysteryBox', function () {
         encryptionSecret: ['first poor secret', 'second poor secret'],
       };
       mb = new MysteryBox(options);
+      mb.on('statistics', statsStub);
       _verbose(mb);
       object = {
         foo: 'bar',
         baz: 'quux',
         flarp: 13,
       };
-      const encryptedResult = await mb.pack(object);
-      const decryptedResult = await mb.unpack(encryptedResult);
+      const encryptedResult = await mb.pack(object, undefined, undefined, 'extra data');
+      const decryptedResult = await mb.unpack(encryptedResult, 'extra data');
+      assert.strictEqual(statsStub.callCount, 2);
       assert.deepStrictEqual(decryptedResult, object);
     });
 
@@ -80,7 +83,7 @@ describe('MysteryBox', function () {
 
   describe('_keyFromSecret', function () {
     it('covers invalid', async function () {
-      assert.rejects(() => MysteryBox._keyFromSecret('unknown deriver', 'secret', 'salt', 32), MysteryBoxError);
+      assert.rejects(() => mb._keyFromSecret('unknown deriver', 'secret', 'salt', 32), MysteryBoxError);
     });
   }); // _keyFromSecret
 
@@ -338,7 +341,7 @@ describe('MysteryBox', function () {
     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) {
+      for (const version of availableVersions) {
         object = {
           foo: 'bar',
           baz: 'quux',
@@ -353,7 +356,7 @@ describe('MysteryBox', function () {
     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) {
+      for (const version of availableVersions) {
         object = {
           foo: 'bar',
           baz: 'quux',
@@ -368,7 +371,7 @@ describe('MysteryBox', function () {
     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) {
+      for (const version of availableVersions) {
         object = {
           foo: 'bar',
           baz: 'quux',
@@ -437,4 +440,33 @@ describe('MysteryBox', function () {
 
   }); // pack, unpack
 
+  describe('sanity check', function () {
+    const encryptionSecret = 'jived fox nymph grabs quick waltz';
+    const data = 'Jackdaws love my big sphinx of quartz.';
+    const testVectors = {
+      '1': 'AQDrdybNo2AZM1YWGhWWMDp5V4ukBz_rm9PCSreBu3GkpLuegLWHZ4YKW2ObxA6QQowDixNdGcTzJLxFxyl5sojeZwM3FcXQlje920CqhVkaj5hs_xA',
+      '2': 'AgBRg3sswTRkYAP67KfbTvt14V2smjqT9E5i7exkiy3C8qv6LzXWahoPBKW1fOYTbT9n4nIwRjCmpZMiswWfiES2VqQHd8JPuQAvkfgGy2xoE2PmoxM',
+      '4': 'BADNJyfzTimCZm84ltW7Iipta4cBOPk3SnGaUmUD9sXralAFlYG_iSmZrEbqE7Ywc1XI5-CFn-yTsCd9-gNYisainPr1IBRErpyM_T5LE5tTxqGVmy8',
+      '5': 'BQBh2VkCGJflQcGCDDrKNmb2g8NszYawXYNKRoeyyPAXah0YV_h_2SPzQ11YswwGVR3zlbhlg-8x-bhbe_mDDbYwZs_PRq3YZ6OV2TOhR1y7XBODdDE',
+      '7': 'BwBKSO5LwBGUjPeD0m3tYe-L9U_4rwDAe6Tj6g02G9_2CnZ3dqRozDWi0ITD4YiDz3x1VQHMV7r6Zl_7SnBg-4D1UMt0dZyxGfL4XqQlglNrJazyFBQ',
+      '8': 'CACkXxst6JGfec1pdBW1N1z9DG9bbITMJ0ZO4u1nmfKuEwwyxewutucajVwirUThI4ZYjJMGfudnqwZAIs6FzoJ8XQna-IJw3bwORNXRliq4Lj3ZEM4',
+      '10': 'Ck8zfSTdZIpi7pucKuV4dt97zDz-blVk4IC3ou8dmvIHukVJwCepBPeByBalR2krxqDLHIfI86JJBauHIbkn6-20hGhV69DMaD11OahewlfqSEgdbjA',
+      '11': 'C0F3TPLnpGjtfA0XbDZ735Q9X5vw2P6Stcn44peZnBk8qzdxldGLTR5QBuRVOk7HWc8oWEdO9yc4wpULmfCvw5NeaA2dwAeZ-cgWnESvu5RSVUv3Ajs',
+      '13': 'DUxzFf148hjckC7uAcmOgRrGLV8N5Zku5afpggewFsSPDiKRzhE_rQoPTtjE6b42QgSag0RDu5wA-r9RobNg6CiCHeEaW_6krdjTkqNlxv_KO9rhUs8',
+      '14': 'Dparydjh1muUkxEDyywlAV2YIbaxLoJB2KZ8BqVubmPFQ4G9qtydpAcdD3eSzFHrU_npRN8UrLpLW13Bp56Pj70hbsRhw15ncxId104ZfiHZbmdWm2A',
+      '16': 'EN96MLAnjIdggkaBLUpdzLq2hArVtiLkkl8ih8UTrp-xGQlOQndnPMPUzlAbKGFKn2Rya0C7NmbFTKGWP5ZIwkt6ayT97aaZrp_K2XYSK4KvZnHp_CU',
+      '17': 'EUuC_-KVGtWqnV51NwOTPj-uRWoEMM_-YlqG7F9zblx2W3qFrKgEMzRpYNhyh_zqbCvuDR9CV9-b1Rj4OUPD1OtrHwcVEEpuiUcPVa2XA2ulupbPHdo',
+      '19': 'E_et0lNMOpI5k1FAI2LX85C5WbisN9cONnL99g-yDPm2IYGNZjQIygWxJ4gTQcxQGrfnce_JQjlY5nHs-OgJb2nunyqD2T9S46wghHU_9aBZ3p-U6pg',
+      '20': 'FFBZIC71_4Hn94rS-oJhOXA6y-yRFnW4ROtviSDYXLanaFEDrHthNFeglju0XnBEQLHB4kXXMsQYcwOn4Av88cfgEV7mQiLr5IAWavM0CQbkHsPYCk8',
+    };
+    it('decrypts existing, known good, boxes', async function () {
+      this.slow(Object.keys(testVectors).length * 200);
+      mb = new MysteryBox({ encryptionSecret });
+      for (const box of Object.values(testVectors)) {
+        const result = await mb.unpack(box);
+        assert.strictEqual(result, data);
+      }
+    });
+  }); // sanity check
+
 }); // MysteryBox
\ No newline at end of file