support providing multiple secrets, always encrypt with first, attempt decryption...
authorJustin Wind <justin.wind+git@gmail.com>
Sat, 30 Jul 2022 21:12:04 +0000 (14:12 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Sat, 30 Jul 2022 21:12:04 +0000 (14:12 -0700)
lib/common.js
lib/mystery-box.js
test/lib/common.js
test/lib/mystery-box.js

index 4c7e6392fe1f7e6f28a9321680d56fbf933404cb..1d4223b1749d1cce37b729233f988098abc36b5a 100644 (file)
@@ -19,7 +19,23 @@ const fileScope = (filename) => {
 }
 
 
+/**
+ * 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
index 2343c5e164f79f9d04629a97c366a1fbd0f58595..cb85ebe698453a70f48f6af68bdad8d7cdeb9b08 100644 (file)
@@ -63,15 +63,15 @@ class MysteryBox {
   /**
    * @param {Console} logger
    * @param {Object} options
-   * @param {String} options.encryptionSecret
+   * @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.secret = options.encryptionSecret;
-    if (!this.secret) {
+    this.secrets = common.ensureArray(options.encryptionSecret);
+    if (!this.secrets.length) {
       throw new Error('missing encryption secret');
     }
     // TODO: support secret rolling
@@ -176,7 +176,9 @@ class MysteryBox {
     // Authenticate all this data
     const aadBuffer = Buffer.concat([versionBuffer, flagsBuffer, iv, salt]);
 
-    const key = await scryptAsync(this.secret, salt, v.keyBytes);
+    // Always encrypt with first secret
+    const secret = this.secrets[0];
+    const key = await scryptAsync(secret, salt, v.keyBytes);
     const cipher = crypto.createCipheriv(v.algorithm, key, iv, v.algOptions);
     cipher.setAAD(aadBuffer);
     const encrypted = cipher.update(payload);
@@ -248,12 +250,28 @@ class MysteryBox {
     const encrypted = raw.slice(offset);
 
     timingsMs.preCrypt = performance.now();
-    const key = await scryptAsync(this.secret, salt, v.keyBytes);
-    const decipher = crypto.createDecipheriv(v.algorithm, key, iv, v.algOptions);
-    decipher.setAAD(aad);
-    decipher.setAuthTag(tag);
 
-    const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
+    let decrypted;
+    let err;
+    let success = false;
+    for await (const secret of this.secrets) {
+      const key = await scryptAsync(secret, salt, v.keyBytes);
+      const decipher = crypto.createDecipheriv(v.algorithm, key, iv, v.algOptions);
+      decipher.setAAD(aad);
+      decipher.setAuthTag(tag);
+
+      try {
+        decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
+        success = true;
+        break;
+      } catch (e) {
+        err = e;
+        continue;
+      }
+    }
+    if (!success) {
+      throw err;
+    }
 
     let payload;
     timingsMs.preCompress = timingsMs.postCrypt = performance.now();
index 344e162366da391c15422e7588484b127d1ed9b1..ad93ea17304b126368f8ff486d87e466a2b91bdc 100644 (file)
@@ -19,4 +19,21 @@ describe('Common', function () {
     });
   }); // 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
index a6e69b91f25ae0553dee526649e9ae51e103ef14..802d8b3da8e1bb03e46475ed904c306535ec1336 100644 (file)
@@ -30,6 +30,23 @@ describe('MysteryBox', function () {
       }
     });
 
+    it('accepts multiple secrets', async function () {
+      this.slow(500);
+      options = {
+        encryptionSecret: ['first poor secret', 'second poor secret'],
+      };
+      mb = new MysteryBox(stubLogger, options);
+      object = {
+        foo: 'bar',
+        baz: 'quux',
+        flarp: 13,
+      };
+      const encryptedResult = await mb.pack(object);
+      const decryptedResult = await mb.unpack(encryptedResult);
+      assert.deepStrictEqual(decryptedResult, object);
+
+    });
+
     it('covers options', function () {
       try {
         mb = new MysteryBox(stubLogger);
@@ -106,6 +123,38 @@ describe('MysteryBox', function () {
       assert.deepStrictEqual(decryptedResult, object);
     });
 
+    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'] });
+      object = {
+        foo: 'bar',
+        baz: 'quux',
+        flarp: 13,
+      };
+      const oldEncrypted = await oldmb.pack(object);
+      const newDecrypted = await newmb.unpack(oldEncrypted);
+      assert.deepStrictEqual(newDecrypted, object);
+    });
+
+    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'] });
+      object = {
+        foo: 'bar',
+        baz: 'quux',
+        flarp: 13,
+      };
+      const oldEncrypted = await oldmb.pack(object);
+      try {
+        await newmb.unpack(oldEncrypted);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Error);
+      }
+    });
+
     it('encrypts and decrypts all available versions +brotli', async function () {
       Object.keys(mb.versionParameters).map((v) => Number(v)).forEach(async (version) => {
         object = {
@@ -172,6 +221,7 @@ describe('MysteryBox', function () {
     });
 
     it('handles incomplete', async function () {
+      this.slow(500);
       const encryptedResult = await mb.pack({ foo: 'bar' });
       try {
         await mb.unpack(encryptedResult.slice(0, 6));