support new key derivations, add new preferred versions using such
authorJustin Wind <justin.wind+git@gmail.com>
Sat, 17 Sep 2022 21:39:36 +0000 (14:39 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Sun, 18 Sep 2022 21:56:23 +0000 (14:56 -0700)
The purposefully time-consuming scrypt has proven divergent from the
use-cases for this module.
Scrypt is still available but no longer the default, preferring blake2b512,
with shake256 also available.

README.md
lib/mystery-box.js
lib/version-parameters.js
test/lib/mystery-box.js

index e070bb24d4f31dc58f743b93fb4bdfc3f2e161c4..b7ad9d4fd0ac5dbff6aa89260295c221fc23113d 100644 (file)
--- a/README.md
+++ b/README.md
@@ -6,5 +6,30 @@ In our case, this results in a Base64URL encoded string containing a bespoke pac
 
 ## API
 
-- pack(contents, version, flags)
-- unpack(box)
+- `async pack(contents, version, flags)`
+- `async unpack(box)`
+
+## Example
+
+```js
+const { MysteryBox } = require('@squeep/mystery-box');
+const assert = require('assert');
+
+const mb = new MysteryBox(console, {
+  encryptionSecret: 'very secret',
+});
+
+(async () => {
+  const data = { important: 'to keep secret' };
+  const encrypted = await mb.pack(data);
+  const decrypted = await mb.unpack(encrypted);
+  assert.deepStrictEqual(decrypted, data);
+})()
+  .then(() => console.log('data retrieved!'));
+```
+
+## Details
+
+This relies on AEAD ciphers, such as `aes-256-gcm` and `chacha20-poly1305`, to encrypt the payload and authenticate the additional metadata (version identifier, flags indicating payload details, the iv of the cipher, and the salt used to create the key) needed to decrypt the payload.
+
+For each box, a new key is generated using the stored secret and a securely-random salt by way of a mechanism such as an XOF such as `shake256`, a hash such as `blake2b512`, or a more time-consuming multi-round hash such as `scrypt`.  This key is used to encrypt and authenticate the data and metadata, which is then encoded as a base64url string.
index 2fb68a583e2c9b2408e37f7d17dcde212bb9a481..05fe28f72c872ac80b0604af4d15bbe8f52e069f 100644 (file)
@@ -77,8 +77,13 @@ class MysteryBox {
 
     // Filter any unavailable algorithms
     const availableCiphers = crypto.getCiphers();
+    const availableHashes = crypto.getHashes();
+    // Add legacy scrypt to available hashes for filtering key derivations
+    availableHashes.push('scrypt');
     this.versionParameters = Object.entries(allVersions).reduce((acc, [v, p]) => {
-      if (availableCiphers.includes(p.algorithm)) {
+      const validCipher = availableCiphers.includes(p.algorithm);
+      const validKeyDeriver = availableHashes.includes(p.keyDeriver);
+      if (validCipher && validKeyDeriver) {
         acc[v] = p; // eslint-disable-line security/detect-object-injection
       }
       return acc;
@@ -109,6 +114,38 @@ class MysteryBox {
   }
 
 
+  /**
+   * Generate key data.
+   */
+  static async _keyFromSecret(deriver, secret, salt, keyBytes) {
+    switch (deriver) {
+      case 'shake256': {
+        const hash = crypto.createHash('shake256', { outputLength: keyBytes });
+        hash.update(salt);
+        hash.update(secret);
+        return hash.digest();
+      }
+
+      case 'blake2b512': {
+        const hash = crypto.createHash('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);
+      }
+
+      case 'scrypt':
+        return scryptAsync(secret, salt, keyBytes);
+
+      default:
+        throw new RangeError('unsupported key deriver');
+    }
+  }
+
+
   /**
    * Put contents into a mysterious box.
    * @param {Object|Buffer} contents
@@ -177,7 +214,7 @@ class MysteryBox {
 
     // Always encrypt with first secret
     const secret = this.secrets[0];
-    const key = await scryptAsync(secret, salt, v.keyBytes);
+    const key = await MysteryBox._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes);
     const cipher = crypto.createCipheriv(v.algorithm, key, iv, v.algOptions);
     cipher.setAAD(aadBuffer);
     const encrypted = cipher.update(payload);
@@ -254,7 +291,7 @@ class MysteryBox {
     let err;
     let success = false;
     for await (const secret of this.secrets) {
-      const key = await scryptAsync(secret, salt, v.keyBytes);
+      const key = await MysteryBox._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes);
       const decipher = crypto.createDecipheriv(v.algorithm, key, iv, v.algOptions);
       decipher.setAAD(aad);
       decipher.setAuthTag(tag);
index 80a09356c28c3a3bb7cc1afbeb2b570d68e133a2..9af7d49d61eee45b8ee4922abc7f6f66ab30100f 100644 (file)
@@ -15,6 +15,7 @@ const allVersions = {
     ivBytes: 12,
     saltBytes: 16,
     tagBytes: 16,
+    keyDeriver: 'scrypt',
     keyBytes: 32,
   },
   2: {
@@ -28,11 +29,12 @@ const allVersions = {
     ivBytes: 12,
     saltBytes: 16,
     tagBytes: 16,
+    keyDeriver: 'scrypt',
     keyBytes: 32,
   },
   3: {
     version: 3,
-    algorithm: 'xchacha20-poly1305', // Not yet available...
+    algorithm: 'xchacha20-poly1305', // Not yet available, but would prefer even more...
     algOptions: {
       authTagLength: 16,
     },
@@ -41,6 +43,87 @@ const allVersions = {
     ivBytes: 24,
     saltBytes: 16,
     tagBytes: 16,
+    keyDeriver: 'scrypt',
+    keyBytes: 32,
+  },
+  4: {
+    version: 4,
+    algorithm: 'aes-256-gcm',
+    algOptions: {},
+    versionBytes: 1,
+    flagsBytes: 1,
+    ivBytes: 12,
+    saltBytes: 16,
+    tagBytes: 16,
+    keyDeriver: 'shake256',
+    keyBytes: 32,
+  },
+  5: {
+    version: 5,
+    algorithm: 'chacha20-poly1305',
+    algOptions: {
+      authTagLength: 16,
+    },
+    versionBytes: 1,
+    flagsBytes: 1,
+    ivBytes: 12,
+    saltBytes: 16,
+    tagBytes: 16,
+    keyDeriver: 'shake256',
+    keyBytes: 32,
+  },
+  6: {
+    version: 6,
+    algorithm: 'xchacha20-poly1305', // Not yet available, but would prefer even more...
+    algOptions: {
+      authTagLength: 16,
+    },
+    versionBytes: 1,
+    flagsBytes: 1,
+    ivBytes: 24,
+    saltBytes: 16,
+    tagBytes: 16,
+    keyDeriver: 'shake256',
+    keyBytes: 32,
+  },
+  7: {
+    version: 7,
+    algorithm: 'aes-256-gcm',
+    algOptions: {},
+    versionBytes: 1,
+    flagsBytes: 1,
+    ivBytes: 12,
+    saltBytes: 16,
+    tagBytes: 16,
+    keyDeriver: 'blake2b512',
+    keyBytes: 32,
+  },
+  8: {
+    version: 8,
+    algorithm: 'chacha20-poly1305',
+    algOptions: {
+      authTagLength: 16,
+    },
+    versionBytes: 1,
+    flagsBytes: 1,
+    ivBytes: 12,
+    saltBytes: 16,
+    tagBytes: 16,
+    keyDeriver: 'blake2b512',
+    keyBytes: 32,
+  },
+  9: {
+    version: 9,
+    algorithm: 'xchacha20-poly1305', // Not yet available, but would prefer even more...
+    algOptions: {
+      authTagLength: 16,
+    },
+    versionBytes: 1,
+    flagsBytes: 1,
+    ivBytes: 24,
+    saltBytes: 16,
+    tagBytes: 16,
+    keyDeriver: 'blake2b512',
     keyBytes: 32,
   },
 };
index 802d8b3da8e1bb03e46475ed904c306535ec1336..a48bad847e18a795ac3b1b9692e053d44f4efb42 100644 (file)
@@ -44,7 +44,6 @@ describe('MysteryBox', function () {
       const encryptedResult = await mb.pack(object);
       const decryptedResult = await mb.unpack(encryptedResult);
       assert.deepStrictEqual(decryptedResult, object);
-
     });
 
     it('covers options', function () {
@@ -77,6 +76,16 @@ describe('MysteryBox', function () {
     });
   }); // constructor
 
+  describe('_keyFromSecret', function () {
+    it('covers invalid', async function () {
+      try {
+        await MysteryBox._keyFromSecret('unknown deriver', 'secret', 'salt', 32);
+      } catch (e) {
+        assert(e instanceof RangeError);
+      }
+    });
+  }); // _keyFromSecret
+
   describe('pack, unpack', function () {
     beforeEach(function () {
       mb = new MysteryBox(stubLogger, options);