initial commit
[squeep-mystery-box] / lib / mystery-box.js
diff --git a/lib/mystery-box.js b/lib/mystery-box.js
new file mode 100644 (file)
index 0000000..be12245
--- /dev/null
@@ -0,0 +1,308 @@
+'use strict';
+
+const crypto = require('crypto');
+const zlib = require('zlib');
+const { promisify } = require('util');
+const common = require('./common');
+const allVersions = require('./version-parameters');
+const { performance } = require('perf_hooks');
+
+const _fileScope = common.fileScope(__filename);
+
+const brotliCompressAsync = promisify(zlib.brotliCompress);
+const brotliDecompressAsync = promisify(zlib.brotliDecompress);
+const deflateRawAsync = promisify(zlib.deflateRaw);
+const inflateRawAsync = promisify(zlib.inflateRaw);
+const scryptAsync = promisify(crypto.scrypt);
+
+/**
+ * Only you will know what's inside your...
+ *    \  |            |               | __ )
+ *   |\/ | |   |  __| __|  _ \  __|   | __ \   _ \ \  /
+ *   |   | |   |\__ \ |    __/ |  |   | |   | (   |`  <
+ *  _|  _|\__, |____/\__|\___|_| \__, |____/ \___/ _/\_\
+ *        ____/                  ____/
+ *
+ * Our very own way of converting a buffer or serializable object
+ * to and from an opaque and web-safe representation, which we can
+ * let anyone see without disclosing the contents, nor allowing it
+ * to be modified without detection.
+ * 
+ * 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
+ * - payload, encrypted version of possibly compressed and serialized object
+ *
+ */
+
+
+const availableFlags = {
+  Brotli: (1<<0), // Slightly better compression, but slow
+  Flate: (1<<1), // Slightly worse compression, but much faster especially with larger payloads.  Prefer this.
+  FutureCompression: (1<<0)|(1<<1), // Something else, like zstd maybe.
+
+  Unused2: (1<<2),
+  Unused3: (1<<3),
+  Unused4: (1<<4),
+  Unused5: (1<<5),
+  Unused6: (1<<6),
+
+  BufferPayload: (1<<7), // Payload is a buffer, not an object
+};
+const compressionFlagsMask = (availableFlags.Flate | availableFlags.Brotli);
+const compressionFlagsShift = 0;
+const payloadFlagsMask = (availableFlags.BufferPayload);
+const payloadFlagsShift = 7;
+
+
+class MysteryBox {
+  /**
+   * @param {Console} logger
+   * @param {Object} options
+   * @param {String} options.encryptionSecret
+   * @param {Number=} options.defaultFlags
+   */
+  constructor(logger, options = {}) {
+    this.logger = logger;
+    this.options = options;
+
+    this.secret = options.encryptionSecret;
+    if (!this.secret) {
+      throw new Error('missing encryption secret');
+    }
+    // TODO: support secret rolling
+
+    // Filter any unavailable algorithms
+    const availableCiphers = crypto.getCiphers();
+    this.versionParameters = Object.entries(allVersions).reduce((acc, [v, p]) => {
+      if (availableCiphers.includes(p.algorithm)) {
+        acc[v] = p; // eslint-disable-line security/detect-object-injection
+      }
+      return acc;
+    }, {});
+
+    // Default to highest
+    this.bestVersion = Number(Object.keys(this.versionParameters).sort().pop());
+    if (Number.isNaN(this.bestVersion)) {
+      throw new Error('no supported versions available');
+    }
+
+    this.Flags = availableFlags;
+    this.defaultFlags = 'defaultFlags' in options ? options.defaultFlags : availableFlags.Flate;
+    if (this.defaultFlags < 0 || this.defaultFlags > 255) {
+      throw new RangeError('Invalid default flag value');
+    }
+  }
+
+
+  /**
+   * Parse the bits out of the flags.
+   */
+  static _decodeFlags(flags) {
+    return {
+      compression: (flags & compressionFlagsMask) >> compressionFlagsShift,
+      payloadIsBuffer: Boolean((flags & payloadFlagsMask) >> payloadFlagsShift),
+    };
+  }
+
+
+  /**
+   * Put contents into a mysterious box.
+   * @param {Object|Buffer} contents
+   * @returns {String}
+   */
+  async pack(contents, version = this.bestVersion, flags = this.defaultFlags) {
+    const _scope = _fileScope('pack');
+    const timingsMs = {
+      start: performance.now(),
+      preCompress: 0,
+      postCompress: 0,
+      preEncrypt: 0,
+      postEncrypt: 0,
+      end: 0,
+    };
+
+    if (!(version in this.versionParameters)) {
+      throw new RangeError(`MysteryBox format version ${version} not supported`);
+    }
+    // eslint-disable-next-line security/detect-object-injection
+    const v = this.versionParameters[version];
+
+    const { compression } = MysteryBox._decodeFlags(flags);
+
+    if (Buffer.isBuffer(contents)) {
+      flags |= this.Flags.BufferPayload;
+    } else {
+      contents = JSON.stringify(contents);
+    }
+
+    const versionBuffer = Buffer.alloc(v.versionBytes);
+    versionBuffer.writeUInt8(v.version, 0);
+
+    const [iv, salt] = await Promise.all([
+      v.ivBytes,
+      v.saltBytes,
+    ].map((b) => common.randomBytesAsync(b)));
+
+    timingsMs.preCompress = performance.now();
+    let compressedContents;
+    switch (compression) {
+      case 0: // No compression requested
+        compressedContents = contents;
+        break;
+      case this.Flags.Brotli:
+        compressedContents = await brotliCompressAsync(contents);
+        break;
+      case this.Flags.Flate:
+        compressedContents = await deflateRawAsync(contents);
+        break;
+    }
+    timingsMs.postCompress = timingsMs.preCrypt = performance.now();
+
+    let payload;
+    if (compressedContents.length >= contents.length) {
+      flags = flags & ~compressionFlagsMask;
+      payload = contents;
+    } else {
+      payload = compressedContents;
+    }
+    const flagsBuffer = Buffer.alloc(v.flagsBytes);
+    flagsBuffer.writeUInt8(flags, 0);
+
+    // Authenticate all this data
+    const aadBuffer = Buffer.concat([versionBuffer, flagsBuffer, iv, salt]);
+
+    const key = await scryptAsync(this.secret, salt, v.keyBytes);
+    const cipher = crypto.createCipheriv(v.algorithm, key, iv, v.algOptions);
+    cipher.setAAD(aadBuffer);
+    const encrypted = cipher.update(payload);
+    const final = cipher.final();
+    const tag = cipher.getAuthTag();
+
+    const merged = Buffer.concat([versionBuffer, flagsBuffer, iv, salt, tag, encrypted, final]).toString('base64');
+    const result = common.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) });
+
+    return result;
+  }
+
+
+  /**
+   * Take contents out of a mysterious box.
+   * @param {String} box - Base64URL encoded payload
+   * @returns {Object}
+   */
+  async unpack(box) {
+    const _scope = _fileScope('unpack');
+    const timingsMs = {
+      start: performance.now(),
+      preCompress: 0,
+      postCompress: 0,
+      preCrypt: 0,
+      postCrypt: 0,
+      end: 0,
+    };
+
+    const raw = Buffer.from(common.base64URLToBase64(box), 'base64');
+    let offset = 0;
+
+    const version = raw.slice(offset, 1).readUInt8(0);
+    if (!(version in this.versionParameters)) {
+      throw new RangeError('unsupported version');
+    }
+    // eslint-disable-next-line security/detect-object-injection
+    const v = this.versionParameters[version];
+    offset += v.versionBytes;
+
+    const flags = raw.slice(offset, offset + v.flagsBytes).readUInt8(0);
+    offset += v.flagsBytes;
+
+    const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags);
+
+    const iv = raw.slice(offset, offset + v.ivBytes);
+    offset += v.ivBytes;
+
+    const salt = raw.slice(offset, offset + v.saltBytes);
+    offset += v.saltBytes;
+
+    const aad = raw.slice(0, offset); // Everything up to here
+
+    const tag = raw.slice(offset, offset + v.tagBytes);
+    offset += v.tagBytes;
+
+    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 payload;
+    timingsMs.preCompress = timingsMs.postCrypt = performance.now();
+    switch (compression) {
+      case 0: // No compression
+        payload = decrypted;
+        break;
+      case this.Flags.Brotli:
+        payload = await brotliDecompressAsync(decrypted);
+        break;
+      case this.Flags.Flate:
+        payload = await inflateRawAsync(decrypted);
+        break;
+    }
+    timingsMs.end = timingsMs.postCompress = performance.now();
+
+    if (!payloadIsBuffer) {
+      payload = JSON.parse(payload.toString('utf8'));
+    }
+
+    this.logger.debug(_scope, 'statistics', { version, flags: this._prettyFlags(flags), ...MysteryBox._timingsLog(timingsMs) });
+
+    return payload;
+  }
+
+
+  /**
+   * Pretty-print flag values
+   * @param {Number} flags
+   * @returns {String}
+   */
+  _prettyFlags(flags) {
+    const flagNames = Object.entries(this.Flags).reduce((acc, cur) => {
+      const [flagName, flagValue] = cur;
+      if ((flags & flagValue) === flagValue) {
+        acc.push(flagName);
+      }
+      return acc;
+    }, []);
+    return `0x${flags.toString(16).padStart(2, '0')} [${flagNames.join(',')}]`;
+  }
+
+
+  /**
+   * Everyone loves numbers.
+   * @param {Object} timingsMs
+   * @returns 
+   */
+  static _timingsLog({ start, preCompress, postCompress, preCrypt, postCrypt, end }) {
+    return {
+      totalMs: end - start,
+      compressMs: postCompress - preCompress,
+      cryptMs: postCrypt - preCrypt,
+    };
+  }
+
+}
+
+// Expose for stubbing in tests
+MysteryBox._test = { crypto };
+
+module.exports = MysteryBox;
\ No newline at end of file