--- /dev/null
+'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