3 const { EventEmitter
} = require('events');
4 const crypto
= require('crypto');
5 const zlib
= require('zlib');
6 const { promisify
} = require('util');
7 const { MysteryBoxError
} = require('./errors');
8 const allVersions
= require('./version-parameters');
9 const { performance
} = require('perf_hooks');
10 const { name: packageName
, version: packageVersion
} = require('../package.json');
17 const brotliCompressAsync
= promisify(zlib
.brotliCompress
);
18 const brotliDecompressAsync
= promisify(zlib
.brotliDecompress
);
19 const deflateRawAsync
= promisify(zlib
.deflateRaw
);
20 const inflateRawAsync
= promisify(zlib
.inflateRaw
);
21 const scryptAsync
= promisify(crypto
.scrypt
);
22 const randomBytesAsync
= promisify(crypto
.randomBytes
);
25 * Only you will know what's inside your...
27 * |\/ | | | __| __| _ \ __| | __ \ _ \ \ /
28 * | | | |\__ \ | __/ | | | | | ( |` <
29 * _| _|\__, |____/\__|\___|_| \__, |____/ \___/ _/\_\
32 * Our very own way of converting a buffer or serializable object
33 * to and from an opaque and web-safe representation, which we can
34 * let anyone see without disclosing the contents, nor allowing it
35 * to be modified without detection.
37 * The result is a Base64URL-encoded byte-array consisting of:
38 * - version, indicating format and encryption algorithm
39 * - flags, indicating state of the contents
40 * - iv/nonce, randomness for the algorithm
41 * - salt, applied to a secret to derive a key
42 * - tag, additional room for encryption to validate the previous fields
43 * - payload, encrypted version of possibly compressed and serialized object
48 const availableFlags
= {
49 Brotli: (1<<0), // Slightly better compression, but slow
50 Flate: (1<<1), // Slightly worse compression, but much faster especially with larger payloads. Prefer this.
51 FutureCompression: (1<<0)|(1<<1), // Something else, like zstd maybe.
59 BufferPayload: (1<<7), // Payload is a buffer, not an object
61 const compressionFlagsMask
= (availableFlags
.Flate
| availableFlags
.Brotli
);
62 const compressionFlagsShift
= 0;
63 const payloadFlagsMask
= (availableFlags
.BufferPayload
);
64 const payloadFlagsShift
= 7;
66 class MysteryBox
extends EventEmitter
{
68 * @param {object} options options
69 * @param {string|string[]} options.encryptionSecret if an array, will always encrypt with first secret, will attempt to decrypt with all; useful for rolling secrets
70 * @param {number=} options.defaultFlags default flags to use when packing boxes
71 * @param {any[]} args passed on to EventEmitter constructor
73 constructor(options
= {}, ...args
) {
75 this.secrets
= MysteryBox
._ensureArray(options
.encryptionSecret
);
76 if (!this.secrets
.length
) {
77 throw new MysteryBoxError('missing encryption secret');
80 // Filter any unavailable algorithms
81 const availableCiphers
= crypto
.getCiphers();
82 const availableHashes
= crypto
.getHashes();
83 // Add legacy scrypt to available hashes for filtering key derivations
84 availableHashes
.push(allVersions
.KD
.SCRYPT
);
85 this.versionParameters
= Object
.entries(allVersions
).reduce((acc
, [v
, p
]) => {
86 const validCipher
= availableCiphers
.includes(p
.algorithm
);
87 const validKeyDeriver
= availableHashes
.includes(p
.keyDeriver
);
88 if (validCipher
&& validKeyDeriver
) {
89 acc
[v
] = p
; // eslint-disable-line security/detect-object-injection
95 this.bestVersion
= Number(Object
.keys(this.versionParameters
).sort().pop());
96 if (Number
.isNaN(this.bestVersion
)) {
97 throw new MysteryBoxError('no supported versions available');
100 this.Flags
= availableFlags
;
101 this.defaultFlags
= 'defaultFlags' in options
? options
.defaultFlags : availableFlags
.Flate
;
102 if (this.defaultFlags
< 0 || this.defaultFlags
> 255) {
103 throw new MysteryBoxError('Invalid default flag value');
109 * Return an array containing x if x is something and not an array
110 * @param {*} x thing to ensure is an array
111 * @returns {any[]} thing array or thing in an array
113 static _ensureArray(x
) {
114 if (x
=== undefined) {
117 if (!Array
.isArray(x
)) {
125 * Parse the bits out of the flags.
126 * @param {number} flags flags
127 * @returns {object} decoded flags
129 static _decodeFlags(flags
) {
131 compression: (flags
& compressionFlagsMask
) >> compressionFlagsShift
,
132 payloadIsBuffer: Boolean((flags
& payloadFlagsMask
) >> payloadFlagsShift
),
139 * @param {string} deriver key deriver enum
140 * @param {Buffer} secret secret
141 * @param {Buffer} salt salt
142 * @param {number} keyBytes bytes in key
143 * @returns {Promise<Buffer>} key
145 static async
_keyFromSecret(deriver
, secret
, salt
, keyBytes
) {
147 case allVersions
.KD
.SHAKE256: {
148 const hash
= crypto
.createHash(allVersions
.KD
.SHAKE256
, { outputLength: keyBytes
});
151 return hash
.digest();
154 case allVersions
.KD
.BLAKE2B512: {
155 const hash
= crypto
.createHash(allVersions
.KD
.BLAKE2B512
);
158 const digest
= hash
.digest();
159 // should assert that keyBytes <= 64
160 // but not concerned about that for now
161 // until we have a new algorithm with bigger key size
162 return digest
.subarray(0, keyBytes
);
165 case allVersions
.KD
.SCRYPT:
166 return scryptAsync(secret
, salt
, keyBytes
);
169 throw new MysteryBoxError('unsupported key deriver');
175 * Return bits and bit mask for given number of encoded bytes.
176 * @param {number} numBytes number of bytes
177 * @returns {object} bits value and mask
179 static _versionHeaderBits(numBytes
) {
180 // Round up to 8 bits in result, just to be proper.
181 const resultBits
= (((numBytes
+ 7) >> 3) << 3) >>> 0;
183 headerValue: ((0xff << (resultBits
- numBytes
+ 1)) & 0xff) >>> 0,
184 headerMask: ((0xff << (resultBits
- numBytes
)) & 0xff) >>> 0,
190 * Parse a byte into the total number of bytes in the packed number,
191 * returning that and the new value for the first byte, to update in the
192 * buffer before parsing as an unsigned integer.
193 * Number of packed bytes is indicated by location of first leading 0 bit.
194 * Support for numbers larger than 127 is of dubious practicality, but here
196 * @param {number} firstByte first byte contains encoded byte length
197 * @returns {object} number of bytes and masked first byte
199 static _versionHeaderDecode(firstByte
) {
200 for (let numBytes
= 1; numBytes
<= 8; numBytes
++) {
204 } = MysteryBox
._versionHeaderBits(numBytes
);
205 if (((firstByte
& headerMask
) >>> 0) === headerValue
) {
206 const restMask
= (~headerMask
& 0xff) >>> 0;
209 firstByte: (firstByte
& restMask
) >>> 0,
213 // Nine bytes would be an extravagence.
214 throw new MysteryBoxError(`unsupported version header (0x${firstByte.toString(16)})`);
219 * Decode leading bytes of buffer as version identifier.
220 * In the first byte, the position of the first unset bit indicates how
221 * many total bytes comprise the version, in big-endian encoding.
222 * Returns decoded version and number of bytes used to decode.
223 * Only supports up to 6-byte numbers, and of those, only up to 4398046511103.
224 * @param {Buffer} buf N.B. will be mogrified
225 * @returns {object} decoded version and version byte length
227 static _versionDecode(buf
) {
228 const headerByte
= buf
.readUInt8(0);
229 const { numBytes: versionBytes
, firstByte
} = MysteryBox
._versionHeaderDecode(headerByte
);
230 if (versionBytes
=== 1) {
237 if (versionBytes
> 6) {
238 throw new MysteryBoxError(`unsupported version (${versionBytes} bytes)`);
241 // Otherwise, update the masked first byte and parse the rest of the buffer.
244 version: buf
.readUIntBE(0, versionBytes
),
251 * Encode a version identifier into a buffer of a variable number of bytes.
252 * @param {number} version version number to encode
253 * @returns {object} encoded version buffer and number of bytes
255 static _versionEncode(version
) {
256 let versionBytes
= 0;
258 if (version
<= 0x7f) { // 0-127
260 } else if (version
<= 0x3fff) { // 128-16383
262 } else if (version
<= 0x1fffff) { // 16384-2097151
264 } else if (version
<= 0x0fffffff) { // 2097152-268435455
266 } else if (version
<= 0x07ffffffff) { // 268435456-34359738367
268 } else if (version
<= 0x03ffffffffff) { // 34359738368-4398046511103
271 throw new MysteryBoxError(`version too large to encode (${version})`);
274 const buffer
= Buffer
.alloc(versionBytes
);
275 buffer
.writeUIntBE(version
, 0, versionBytes
);
276 const headerByte
= ((0xff << (8 - versionBytes
+ 1)) & 0xff) >>> 0;
277 buffer
[0] = (buffer
[0] | headerByte
) >>> 0;
287 * Stats tracked when packing/unpacking boxes.
288 * @param {string} method method name generating stats
289 * @returns {object} stats and timings
291 static _newStats(method
) {
298 serializedBytes: undefined,
299 compressedBytes: undefined,
302 start: performance
.now(),
314 * Put contents into a mysterious box.
315 * @param {object|Buffer} contents object or buffer
316 * @param {number=} version version
317 * @param {number=} flags flags
318 * @returns {Promise<string>} encoded contents
320 async
pack(contents
, version
= this.bestVersion
, flags
= this.defaultFlags
) {
321 const { stats
, timingsMs
} = MysteryBox
._newStats('pack');
323 if (!(version
in this.versionParameters
)) {
324 throw new MysteryBoxError(`MysteryBox format version ${version} not supported`);
326 // eslint-disable-next-line security/detect-object-injection
327 const v
= this.versionParameters
[version
];
328 stats
.version
= version
;
330 const { compression
, payloadIsBuffer
} = MysteryBox
._decodeFlags(flags
);
332 if (Buffer
.isBuffer(contents
)) {
333 // Ensure payloadIsBuffer flag is set when contents are indeed a Buffer
334 flags
|= this.Flags
.BufferPayload
;
336 if (payloadIsBuffer
) {
337 // Flag is set, but contents are not a Buffer? Try to coerce contents.
338 contents
= Buffer
.from(contents
);
340 // Otherwise attempt to serialize the object
341 contents
= JSON
.stringify(contents
);
344 stats
.serializedBytes
= Buffer
.byteLength(contents
);
346 const { buffer: versionBuffer
, versionBytes
} = MysteryBox
._versionEncode(v
.version
);
347 if (versionBytes
!== v
.versionBytes
) {
348 throw new MysteryBoxError('internal inconsistency, mismatched version byte length');
351 const [iv
, salt
] = await Promise
.all([
354 ].map((b
) => randomBytesAsync(b
)));
356 timingsMs
.preCompress
= performance
.now();
357 let compressedContents
;
358 switch (compression
) {
359 case 0: // No compression requested
360 compressedContents
= contents
;
362 case this.Flags
.Brotli:
363 compressedContents
= await
brotliCompressAsync(contents
);
365 case this.Flags
.Flate:
366 compressedContents
= await
deflateRawAsync(contents
);
369 stats
.compressedBytes
= Buffer
.byteLength(compressedContents
);
370 timingsMs
.postCompress
= timingsMs
.preCrypt
= performance
.now();
373 if (stats
.compressedBytes
>= stats
.serializedBytes
) {
374 // If compression is not beneficial enough, or detrimental, do not use
375 flags
= flags
& ~compressionFlagsMask
;
377 stats
.compressedBytes
= undefined;
379 payload
= compressedContents
;
382 const flagsBuffer
= Buffer
.alloc(v
.flagsBytes
);
383 flagsBuffer
.writeUInt8(flags
, 0);
384 stats
.flagsRaw
= flags
;
385 stats
.flags
= this._prettyFlags(flags
);
387 // Authenticate all this data
388 const aadBuffer
= Buffer
.concat([versionBuffer
, flagsBuffer
, iv
, salt
]);
390 // Always encrypt with first secret
391 const secret
= this.secrets
[0];
392 const key
= await MysteryBox
._keyFromSecret(v
.keyDeriver
, secret
, salt
, v
.keyBytes
);
393 const cipher
= crypto
.createCipheriv(v
.algorithm
, key
, iv
, v
.algOptions
);
394 cipher
.setAAD(aadBuffer
);
395 const encrypted
= cipher
.update(payload
);
396 const final
= cipher
.final();
397 const tag
= cipher
.getAuthTag();
399 const result
= Buffer
.concat([versionBuffer
, flagsBuffer
, iv
, salt
, tag
, encrypted
, final
]).toString('base64url');
400 timingsMs
.end
= timingsMs
.postCrypt
= performance
.now();
402 this.emit('statistics', { ...stats
, ...MysteryBox
._timingsLog(timingsMs
), ...packageInfo
});
409 * Take contents out of a mysterious box.
410 * @param {string} box - Base64URL encoded payload
411 * @returns {Promise<object|Buffer>} decoded contents
414 const { stats
, timingsMs
} = MysteryBox
._newStats('unpack');
417 throw new MysteryBoxError('nothing to unpack');
420 const raw
= Buffer
.from(box
, 'base64url');
423 const { version
, versionBytes
} = MysteryBox
._versionDecode(raw
);
424 if (!(version
in this.versionParameters
)) {
425 throw new MysteryBoxError(`unsupported version (${version})`);
427 // eslint-disable-next-line security/detect-object-injection
428 const v
= this.versionParameters
[version
];
430 if (v
.versionBytes
!== versionBytes
) {
431 throw new MysteryBoxError('internal inconsistency, mismatched version byte length');
433 offset
+= v
.versionBytes
;
434 stats
.version
= version
;
436 const minBytes
= v
.versionBytes
+ v
.flagsBytes
+ v
.ivBytes
+ v
.saltBytes
+ v
.tagBytes
;
437 if (raw
.length
< minBytes
) {
438 throw new MysteryBoxError('not enough to unpack');
441 const flags
= raw
.subarray(offset
, offset
+ v
.flagsBytes
).readUInt8(0);
442 offset
+= v
.flagsBytes
;
443 stats
.flagsRaw
= flags
;
445 const { compression
, payloadIsBuffer
} = MysteryBox
._decodeFlags(flags
);
447 const iv
= raw
.subarray(offset
, offset
+ v
.ivBytes
);
450 const salt
= raw
.subarray(offset
, offset
+ v
.saltBytes
);
451 offset
+= v
.saltBytes
;
453 const aad
= raw
.subarray(0, offset
); // Everything up to here
455 const tag
= raw
.subarray(offset
, offset
+ v
.tagBytes
);
456 offset
+= v
.tagBytes
;
458 const encrypted
= raw
.subarray(offset
);
460 timingsMs
.preCrypt
= performance
.now();
465 for await (const secret
of this.secrets
) {
466 const key
= await MysteryBox
._keyFromSecret(v
.keyDeriver
, secret
, salt
, v
.keyBytes
);
467 const decipher
= crypto
.createDecipheriv(v
.algorithm
, key
, iv
, v
.algOptions
);
468 decipher
.setAAD(aad
);
469 decipher
.setAuthTag(tag
);
472 decrypted
= Buffer
.concat([decipher
.update(encrypted
), decipher
.final()]);
485 timingsMs
.preCompress
= timingsMs
.postCrypt
= performance
.now();
486 switch (compression
) {
487 case 0: // No compression
490 case this.Flags
.Brotli:
491 payload
= await
brotliDecompressAsync(decrypted
);
493 case this.Flags
.Flate:
494 payload
= await
inflateRawAsync(decrypted
);
497 timingsMs
.end
= timingsMs
.postCompress
= performance
.now();
498 stats
.serializedBytes
= payload
.byteLength
;
500 stats
.compressedBytes
= decrypted
.byteLength
;
503 if (!payloadIsBuffer
) {
504 payload
= JSON
.parse(payload
.toString('utf8'));
507 stats
.flags
= this._prettyFlags(flags
);
508 this.emit('statistics', { ...stats
, ...MysteryBox
._timingsLog(timingsMs
), ...packageInfo
});
515 * Pretty-print flag values
516 * @param {number} flags flags
517 * @returns {string} pretty flags
519 _prettyFlags(flags
) {
520 const flagNames
= Object
.entries(this.Flags
).reduce((acc
, cur
) => {
521 const [flagName
, flagValue
] = cur
;
522 if ((flags
& flagValue
) === flagValue
) {
527 return `0x${flags.toString(16).padStart(2, '0')} [${flagNames.join(',')}]`;
532 * Everyone loves numbers.
533 * @param {object} timingsMs raw timings
534 * @param {number} timingsMs.start start
535 * @param {number} timingsMs.preCompress checkpoint
536 * @param {number} timingsMs.postCompress checkpoint
537 * @param {number} timingsMs.preCrypt checkpoint
538 * @param {number} timingsMs.postCrypt checkpoint
539 * @param {number} timingsMs.end checkpoint
540 * @returns {object} computed timings
542 static _timingsLog({ start
, preCompress
, postCompress
, preCrypt
, postCrypt
, end
}) {
544 totalMs: end
- start
,
545 compressMs: postCompress
- preCompress
,
546 cryptMs: postCrypt
- preCrypt
,
552 module
.exports
= MysteryBox
;