support providing multiple secrets, always encrypt with first, attempt decryption...
[squeep-mystery-box] / lib / mystery-box.js
index be12245538fd966172e88b5f7266dd0a4d949a45..cb85ebe698453a70f48f6af68bdad8d7cdeb9b08 100644 (file)
@@ -3,6 +3,7 @@
 const crypto = require('crypto');
 const zlib = require('zlib');
 const { promisify } = require('util');
+const { base64ToBase64URL, base64URLToBase64 } = require('@squeep/base64url');
 const common = require('./common');
 const allVersions = require('./version-parameters');
 const { performance } = require('perf_hooks');
@@ -62,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
@@ -175,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);
@@ -183,7 +186,7 @@ class MysteryBox {
     const tag = cipher.getAuthTag();
 
     const merged = Buffer.concat([versionBuffer, flagsBuffer, iv, salt, tag, encrypted, final]).toString('base64');
-    const result = common.base64ToBase64URL(merged);
+    const result = base64ToBase64URL(merged);
     timingsMs.end = timingsMs.postCrypt = performance.now();
 
     this.logger.debug(_scope, 'statistics', { version, flags: this._prettyFlags(flags), serialized: contents.length, compressed: payload.length, encoded: result.length, ...MysteryBox._timingsLog(timingsMs) });
@@ -208,7 +211,11 @@ class MysteryBox {
       end: 0,
     };
 
-    const raw = Buffer.from(common.base64URLToBase64(box), 'base64');
+    if (!box) {
+      throw new RangeError('nothing to unpack');
+    }
+
+    const raw = Buffer.from(base64URLToBase64(box), 'base64');
     let offset = 0;
 
     const version = raw.slice(offset, 1).readUInt8(0);
@@ -219,6 +226,11 @@ class MysteryBox {
     const v = this.versionParameters[version];
     offset += v.versionBytes;
 
+    const minBytes = v.versionBytes + v.flagsBytes + v.ivBytes + v.saltBytes + v.tagBytes;
+    if (raw.length < minBytes) {
+      throw new RangeError('not enough to unpack');
+    }
+
     const flags = raw.slice(offset, offset + v.flagsBytes).readUInt8(0);
     offset += v.flagsBytes;
 
@@ -238,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();