add jsdoc linting, address issues
[squeep-mystery-box] / lib / mystery-box.js
1 'use strict';
2
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');
11
12 const packageInfo = {
13 packageName,
14 packageVersion,
15 };
16
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);
23
24 /**
25 * Only you will know what's inside your...
26 * \ | | | __ )
27 * |\/ | | | __| __| _ \ __| | __ \ _ \ \ /
28 * | | | |\__ \ | __/ | | | | | ( |` <
29 * _| _|\__, |____/\__|\___|_| \__, |____/ \___/ _/\_\
30 * ____/ ____/
31 *
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.
36 *
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
44 *
45 */
46
47
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.
52
53 Unused2: (1<<2),
54 Unused3: (1<<3),
55 Unused4: (1<<4),
56 Unused5: (1<<5),
57 Unused6: (1<<6),
58
59 BufferPayload: (1<<7), // Payload is a buffer, not an object
60 };
61 const compressionFlagsMask = (availableFlags.Flate | availableFlags.Brotli);
62 const compressionFlagsShift = 0;
63 const payloadFlagsMask = (availableFlags.BufferPayload);
64 const payloadFlagsShift = 7;
65
66 class MysteryBox extends EventEmitter {
67 /**
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
72 */
73 constructor(options = {}, ...args) {
74 super(...args);
75 this.secrets = MysteryBox._ensureArray(options.encryptionSecret);
76 if (!this.secrets.length) {
77 throw new MysteryBoxError('missing encryption secret');
78 }
79
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
90 }
91 return acc;
92 }, {});
93
94 // Default to highest
95 this.bestVersion = Number(Object.keys(this.versionParameters).sort().pop());
96 if (Number.isNaN(this.bestVersion)) {
97 throw new MysteryBoxError('no supported versions available');
98 }
99
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');
104 }
105 }
106
107
108 /**
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
112 */
113 static _ensureArray(x) {
114 if (x === undefined) {
115 return [];
116 }
117 if (!Array.isArray(x)) {
118 return Array(x);
119 }
120 return x;
121 }
122
123
124 /**
125 * Parse the bits out of the flags.
126 * @param {number} flags flags
127 * @returns {object} decoded flags
128 */
129 static _decodeFlags(flags) {
130 return {
131 compression: (flags & compressionFlagsMask) >> compressionFlagsShift,
132 payloadIsBuffer: Boolean((flags & payloadFlagsMask) >> payloadFlagsShift),
133 };
134 }
135
136
137 /**
138 * Generate key data.
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
144 */
145 static async _keyFromSecret(deriver, secret, salt, keyBytes) {
146 switch (deriver) {
147 case allVersions.KD.SHAKE256: {
148 const hash = crypto.createHash(allVersions.KD.SHAKE256, { outputLength: keyBytes });
149 hash.update(salt);
150 hash.update(secret);
151 return hash.digest();
152 }
153
154 case allVersions.KD.BLAKE2B512: {
155 const hash = crypto.createHash(allVersions.KD.BLAKE2B512);
156 hash.update(salt);
157 hash.update(secret);
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);
163 }
164
165 case allVersions.KD.SCRYPT:
166 return scryptAsync(secret, salt, keyBytes);
167
168 default:
169 throw new MysteryBoxError('unsupported key deriver');
170 }
171 }
172
173
174 /**
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
178 */
179 static _versionHeaderBits(numBytes) {
180 // Round up to 8 bits in result, just to be proper.
181 const resultBits = (((numBytes + 7) >> 3) << 3) >>> 0;
182 return {
183 headerValue: ((0xff << (resultBits - numBytes + 1)) & 0xff) >>> 0,
184 headerMask: ((0xff << (resultBits - numBytes)) & 0xff) >>> 0,
185 };
186 }
187
188
189 /**
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
195 * it is anyhow.
196 * @param {number} firstByte first byte contains encoded byte length
197 * @returns {object} number of bytes and masked first byte
198 */
199 static _versionHeaderDecode(firstByte) {
200 for (let numBytes = 1; numBytes <= 8; numBytes++) {
201 const {
202 headerValue,
203 headerMask,
204 } = MysteryBox._versionHeaderBits(numBytes);
205 if (((firstByte & headerMask) >>> 0) === headerValue) {
206 const restMask = (~headerMask & 0xff) >>> 0;
207 return {
208 numBytes,
209 firstByte: (firstByte & restMask) >>> 0,
210 };
211 }
212 }
213 // Nine bytes would be an extravagence.
214 throw new MysteryBoxError(`unsupported version header (0x${firstByte.toString(16)})`);
215 }
216
217
218 /**
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
226 */
227 static _versionDecode(buf) {
228 const headerByte = buf.readUInt8(0);
229 const { numBytes: versionBytes, firstByte } = MysteryBox._versionHeaderDecode(headerByte);
230 if (versionBytes === 1) {
231 return {
232 version: firstByte,
233 versionBytes,
234 };
235 }
236
237 if (versionBytes > 6) {
238 throw new MysteryBoxError(`unsupported version (${versionBytes} bytes)`);
239 }
240
241 // Otherwise, update the masked first byte and parse the rest of the buffer.
242 buf[0] = firstByte;
243 return {
244 version: buf.readUIntBE(0, versionBytes),
245 versionBytes,
246 };
247 }
248
249
250 /**
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
254 */
255 static _versionEncode(version) {
256 let versionBytes = 0;
257
258 if (version <= 0x7f) { // 0-127
259 versionBytes = 1;
260 } else if (version <= 0x3fff) { // 128-16383
261 versionBytes = 2;
262 } else if (version <= 0x1fffff) { // 16384-2097151
263 versionBytes = 3;
264 } else if (version <= 0x0fffffff) { // 2097152-268435455
265 versionBytes = 4;
266 } else if (version <= 0x07ffffffff) { // 268435456-34359738367
267 versionBytes = 5;
268 } else if (version <= 0x03ffffffffff) { // 34359738368-4398046511103
269 versionBytes = 6;
270 } else {
271 throw new MysteryBoxError(`version too large to encode (${version})`);
272 }
273
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;
278
279 return {
280 buffer,
281 versionBytes,
282 };
283 }
284
285
286 /**
287 * Stats tracked when packing/unpacking boxes.
288 * @param {string} method method name generating stats
289 * @returns {object} stats and timings
290 */
291 static _newStats(method) {
292 return {
293 stats: {
294 method,
295 version: undefined,
296 flags: undefined,
297 flagsRaw: undefined,
298 serializedBytes: undefined,
299 compressedBytes: undefined,
300 },
301 timingsMs: {
302 start: performance.now(),
303 preCompress: 0,
304 postCompress: 0,
305 preCrypt: 0,
306 postCrypt: 0,
307 end: 0,
308 },
309 };
310 }
311
312
313 /**
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
319 */
320 async pack(contents, version = this.bestVersion, flags = this.defaultFlags) {
321 const { stats, timingsMs } = MysteryBox._newStats('pack');
322
323 if (!(version in this.versionParameters)) {
324 throw new MysteryBoxError(`MysteryBox format version ${version} not supported`);
325 }
326 // eslint-disable-next-line security/detect-object-injection
327 const v = this.versionParameters[version];
328 stats.version = version;
329
330 const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags);
331
332 if (Buffer.isBuffer(contents)) {
333 // Ensure payloadIsBuffer flag is set when contents are indeed a Buffer
334 flags |= this.Flags.BufferPayload;
335 } else {
336 if (payloadIsBuffer) {
337 // Flag is set, but contents are not a Buffer? Try to coerce contents.
338 contents = Buffer.from(contents);
339 } else {
340 // Otherwise attempt to serialize the object
341 contents = JSON.stringify(contents);
342 }
343 }
344 stats.serializedBytes = Buffer.byteLength(contents);
345
346 const { buffer: versionBuffer, versionBytes } = MysteryBox._versionEncode(v.version);
347 if (versionBytes !== v.versionBytes) {
348 throw new MysteryBoxError('internal inconsistency, mismatched version byte length');
349 }
350
351 const [iv, salt] = await Promise.all([
352 v.ivBytes,
353 v.saltBytes,
354 ].map((b) => randomBytesAsync(b)));
355
356 timingsMs.preCompress = performance.now();
357 let compressedContents;
358 switch (compression) {
359 case 0: // No compression requested
360 compressedContents = contents;
361 break;
362 case this.Flags.Brotli:
363 compressedContents = await brotliCompressAsync(contents);
364 break;
365 case this.Flags.Flate:
366 compressedContents = await deflateRawAsync(contents);
367 break;
368 }
369 stats.compressedBytes = Buffer.byteLength(compressedContents);
370 timingsMs.postCompress = timingsMs.preCrypt = performance.now();
371
372 let payload;
373 if (stats.compressedBytes >= stats.serializedBytes) {
374 // If compression is not beneficial enough, or detrimental, do not use
375 flags = flags & ~compressionFlagsMask;
376 payload = contents;
377 stats.compressedBytes = undefined;
378 } else {
379 payload = compressedContents;
380 }
381
382 const flagsBuffer = Buffer.alloc(v.flagsBytes);
383 flagsBuffer.writeUInt8(flags, 0);
384 stats.flagsRaw = flags;
385 stats.flags = this._prettyFlags(flags);
386
387 // Authenticate all this data
388 const aadBuffer = Buffer.concat([versionBuffer, flagsBuffer, iv, salt]);
389
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();
398
399 const result = Buffer.concat([versionBuffer, flagsBuffer, iv, salt, tag, encrypted, final]).toString('base64url');
400 timingsMs.end = timingsMs.postCrypt = performance.now();
401
402 this.emit('statistics', { ...stats, ...MysteryBox._timingsLog(timingsMs), ...packageInfo });
403
404 return result;
405 }
406
407
408 /**
409 * Take contents out of a mysterious box.
410 * @param {string} box - Base64URL encoded payload
411 * @returns {Promise<object|Buffer>} decoded contents
412 */
413 async unpack(box) {
414 const { stats, timingsMs } = MysteryBox._newStats('unpack');
415
416 if (!box) {
417 throw new MysteryBoxError('nothing to unpack');
418 }
419
420 const raw = Buffer.from(box, 'base64url');
421 let offset = 0;
422
423 const { version, versionBytes } = MysteryBox._versionDecode(raw);
424 if (!(version in this.versionParameters)) {
425 throw new MysteryBoxError(`unsupported version (${version})`);
426 }
427 // eslint-disable-next-line security/detect-object-injection
428 const v = this.versionParameters[version];
429
430 if (v.versionBytes !== versionBytes) {
431 throw new MysteryBoxError('internal inconsistency, mismatched version byte length');
432 }
433 offset += v.versionBytes;
434 stats.version = version;
435
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');
439 }
440
441 const flags = raw.subarray(offset, offset + v.flagsBytes).readUInt8(0);
442 offset += v.flagsBytes;
443 stats.flagsRaw = flags;
444
445 const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags);
446
447 const iv = raw.subarray(offset, offset + v.ivBytes);
448 offset += v.ivBytes;
449
450 const salt = raw.subarray(offset, offset + v.saltBytes);
451 offset += v.saltBytes;
452
453 const aad = raw.subarray(0, offset); // Everything up to here
454
455 const tag = raw.subarray(offset, offset + v.tagBytes);
456 offset += v.tagBytes;
457
458 const encrypted = raw.subarray(offset);
459
460 timingsMs.preCrypt = performance.now();
461
462 let decrypted;
463 let err;
464 let success = false;
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);
470
471 try {
472 decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
473 success = true;
474 break;
475 } catch (e) {
476 err = e;
477 continue;
478 }
479 }
480 if (!success) {
481 throw err;
482 }
483
484 let payload;
485 timingsMs.preCompress = timingsMs.postCrypt = performance.now();
486 switch (compression) {
487 case 0: // No compression
488 payload = decrypted;
489 break;
490 case this.Flags.Brotli:
491 payload = await brotliDecompressAsync(decrypted);
492 break;
493 case this.Flags.Flate:
494 payload = await inflateRawAsync(decrypted);
495 break;
496 }
497 timingsMs.end = timingsMs.postCompress = performance.now();
498 stats.serializedBytes = payload.byteLength;
499 if (compression) {
500 stats.compressedBytes = decrypted.byteLength;
501 }
502
503 if (!payloadIsBuffer) {
504 payload = JSON.parse(payload.toString('utf8'));
505 }
506
507 stats.flags = this._prettyFlags(flags);
508 this.emit('statistics', { ...stats, ...MysteryBox._timingsLog(timingsMs), ...packageInfo });
509
510 return payload;
511 }
512
513
514 /**
515 * Pretty-print flag values
516 * @param {number} flags flags
517 * @returns {string} pretty flags
518 */
519 _prettyFlags(flags) {
520 const flagNames = Object.entries(this.Flags).reduce((acc, cur) => {
521 const [flagName, flagValue] = cur;
522 if ((flags & flagValue) === flagValue) {
523 acc.push(flagName);
524 }
525 return acc;
526 }, []);
527 return `0x${flags.toString(16).padStart(2, '0')} [${flagNames.join(',')}]`;
528 }
529
530
531 /**
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
541 */
542 static _timingsLog({ start, preCompress, postCompress, preCrypt, postCrypt, end }) {
543 return {
544 totalMs: end - start,
545 compressMs: postCompress - preCompress,
546 cryptMs: postCrypt - preCrypt,
547 };
548 }
549
550 }
551
552 module.exports = MysteryBox;