5dcf1b1fe4dbc8486c62de6e0cf3cbd3be96f2a6
[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 common = require('./common');
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
23 /**
24 * Only you will know what's inside your...
25 * \ | | | __ )
26 * |\/ | | | __| __| _ \ __| | __ \ _ \ \ /
27 * | | | |\__ \ | __/ | | | | | ( |` <
28 * _| _|\__, |____/\__|\___|_| \__, |____/ \___/ _/\_\
29 * ____/ ____/
30 *
31 * Our very own way of converting a buffer or serializable object
32 * to and from an opaque and web-safe representation, which we can
33 * let anyone see without disclosing the contents, nor allowing it
34 * to be modified without detection.
35 *
36 * The result is a Base64URL-encoded byte-array consisting of:
37 * - version, indicating format and encryption algorithm
38 * - flags, indicating state of the contents
39 * - iv/nonce, randomness for the algorithm
40 * - salt, applied to a secret to derive a key
41 * - tag, additional room for encryption to validate the previous fields
42 * - payload, encrypted version of possibly compressed and serialized object
43 *
44 */
45
46
47 const availableFlags = {
48 Brotli: (1<<0), // Slightly better compression, but slow
49 Flate: (1<<1), // Slightly worse compression, but much faster especially with larger payloads. Prefer this.
50 FutureCompression: (1<<0)|(1<<1), // Something else, like zstd maybe.
51
52 Unused2: (1<<2),
53 Unused3: (1<<3),
54 Unused4: (1<<4),
55 Unused5: (1<<5),
56 Unused6: (1<<6),
57
58 BufferPayload: (1<<7), // Payload is a buffer, not an object
59 };
60 const compressionFlagsMask = (availableFlags.Flate | availableFlags.Brotli);
61 const compressionFlagsShift = 0;
62 const payloadFlagsMask = (availableFlags.BufferPayload);
63 const payloadFlagsShift = 7;
64
65
66 class MysteryBox extends EventEmitter {
67 /**
68 * @param {Object} 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
71 */
72 constructor(options = {}, ...args) {
73 super(...args);
74 this.secrets = common.ensureArray(options.encryptionSecret);
75 if (!this.secrets.length) {
76 throw new Error('missing encryption secret');
77 }
78
79 // Filter any unavailable algorithms
80 const availableCiphers = crypto.getCiphers();
81 const availableHashes = crypto.getHashes();
82 // Add legacy scrypt to available hashes for filtering key derivations
83 availableHashes.push(allVersions.KD.SCRYPT);
84 this.versionParameters = Object.entries(allVersions).reduce((acc, [v, p]) => {
85 const validCipher = availableCiphers.includes(p.algorithm);
86 const validKeyDeriver = availableHashes.includes(p.keyDeriver);
87 if (validCipher && validKeyDeriver) {
88 acc[v] = p; // eslint-disable-line security/detect-object-injection
89 }
90 return acc;
91 }, {});
92
93 // Default to highest
94 this.bestVersion = Number(Object.keys(this.versionParameters).sort().pop());
95 if (Number.isNaN(this.bestVersion)) {
96 throw new Error('no supported versions available');
97 }
98
99 this.Flags = availableFlags;
100 this.defaultFlags = 'defaultFlags' in options ? options.defaultFlags : availableFlags.Flate;
101 if (this.defaultFlags < 0 || this.defaultFlags > 255) {
102 throw new RangeError('Invalid default flag value');
103 }
104 }
105
106
107 /**
108 * Parse the bits out of the flags.
109 */
110 static _decodeFlags(flags) {
111 return {
112 compression: (flags & compressionFlagsMask) >> compressionFlagsShift,
113 payloadIsBuffer: Boolean((flags & payloadFlagsMask) >> payloadFlagsShift),
114 };
115 }
116
117
118 /**
119 * Generate key data.
120 */
121 static async _keyFromSecret(deriver, secret, salt, keyBytes) {
122 switch (deriver) {
123 case allVersions.KD.SHAKE256: {
124 const hash = crypto.createHash(allVersions.KD.SHAKE256, { outputLength: keyBytes });
125 hash.update(salt);
126 hash.update(secret);
127 return hash.digest();
128 }
129
130 case allVersions.KD.BLAKE2B512: {
131 const hash = crypto.createHash(allVersions.KD.BLAKE2B512);
132 hash.update(salt);
133 hash.update(secret);
134 const digest = hash.digest();
135 // should assert that keyBytes <= 64
136 // but not concerned about that for now
137 // until we have a new algorithm with bigger key size
138 return digest.subarray(0, keyBytes);
139 }
140
141 case allVersions.KD.SCRYPT:
142 return scryptAsync(secret, salt, keyBytes);
143
144 default:
145 throw new RangeError('unsupported key deriver');
146 }
147 }
148
149
150 /**
151 * Return bits and bit mask for given number of encoded bytes.
152 * @param {Number} numBytes
153 * @returns {Object}
154 */
155 static _versionHeaderBits(numBytes) {
156 // Round up to 8 bits in result, just to be proper.
157 const resultBits = (((numBytes + 7) >> 3) << 3) >>> 0;
158 return {
159 headerValue: ((0xff << (resultBits - numBytes + 1)) & 0xff) >>> 0,
160 headerMask: ((0xff << (resultBits - numBytes)) & 0xff) >>> 0,
161 };
162 }
163
164
165 /**
166 * Parse a byte into the total number of bytes in the packed number,
167 * returning that and the new value for the first byte, to update in the
168 * buffer before parsing as an unsigned integer.
169 * Number of packed bytes is indicated by location of first leading 0 bit.
170 * Support for numbers larger than 127 is of dubious practicality, but here
171 * it is anyhow.
172 * @param {Number} firstByte
173 * @returns {Object}
174 */
175 static _versionHeaderDecode(firstByte) {
176 for (let numBytes = 1; numBytes <= 8; numBytes++) {
177 const {
178 headerValue,
179 headerMask,
180 } = MysteryBox._versionHeaderBits(numBytes);
181 if (((firstByte & headerMask) >>> 0) === headerValue) {
182 const restMask = (~headerMask & 0xff) >>> 0;
183 return {
184 numBytes,
185 firstByte: (firstByte & restMask) >>> 0,
186 };
187 }
188 }
189 // Nine bytes would be an extravagence.
190 throw new RangeError(`unsupported version header (0x${firstByte.toString(16)})`);
191 }
192
193
194 /**
195 * Decode leading bytes of buffer as version identifier.
196 * In the first byte, the position of the first unset bit indicates how
197 * many total bytes comprise the version, in big-endian encoding.
198 * Returns decoded version and number of bytes used to decode.
199 * Only supports up to 6-byte numbers, and of those, only up to 4398046511103.
200 * @param {Buffer} buf - N.B. will be mogrified
201 * @returns {Object}
202 */
203 static _versionDecode(buf) {
204 const headerByte = buf.readUInt8(0);
205 const { numBytes: versionBytes, firstByte } = MysteryBox._versionHeaderDecode(headerByte);
206 if (versionBytes === 1) {
207 return {
208 version: firstByte,
209 versionBytes,
210 };
211 }
212
213 if (versionBytes > 6) {
214 throw new RangeError(`unsupported version (${versionBytes} bytes)`);
215 }
216
217 // Otherwise, update the masked first byte and parse the rest of the buffer.
218 buf[0] = firstByte;
219 return {
220 version: buf.readUIntBE(0, versionBytes),
221 versionBytes,
222 };
223 }
224
225
226 /**
227 * Encode a version identifier into a buffer of a variable number of bytes.
228 * @param {Number} version
229 * @returns {Object}
230 */
231 static _versionEncode(version) {
232 let versionBytes = 0;
233
234 if (version <= 0x7f) { // 0-127
235 versionBytes = 1;
236 } else if (version <= 0x3fff) { // 128-16383
237 versionBytes = 2;
238 } else if (version <= 0x1fffff) { // 16384-2097151
239 versionBytes = 3;
240 } else if (version <= 0x0fffffff) { // 2097152-268435455
241 versionBytes = 4;
242 } else if (version <= 0x07ffffffff) { // 268435456-34359738367
243 versionBytes = 5;
244 } else if (version <= 0x03ffffffffff) { // 34359738368-4398046511103
245 versionBytes = 6;
246 } else {
247 throw new RangeError('version too large to encode');
248 }
249
250 const buffer = Buffer.alloc(versionBytes);
251 buffer.writeUIntBE(version, 0, versionBytes);
252 const headerByte = ((0xff << (8 - versionBytes + 1)) & 0xff) >>> 0;
253 buffer[0] = (buffer[0] | headerByte) >>> 0;
254
255 return {
256 buffer,
257 versionBytes,
258 };
259 }
260
261
262 /**
263 * Stats tracked when packing/unpacking boxes.
264 * @returns {Object}
265 */
266 static _newStats(method) {
267 return {
268 stats: {
269 method,
270 version: undefined,
271 flags: undefined,
272 flagsRaw: undefined,
273 serializedBytes: undefined,
274 compressedBytes: undefined,
275 },
276 timingsMs: {
277 start: performance.now(),
278 preCompress: 0,
279 postCompress: 0,
280 preCrypt: 0,
281 postCrypt: 0,
282 end: 0,
283 },
284 };
285 }
286
287
288 /**
289 * Put contents into a mysterious box.
290 * @param {Object|Buffer} contents
291 * @param {Number=} version
292 * @param {Number=} flags
293 * @returns {String}
294 */
295 async pack(contents, version = this.bestVersion, flags = this.defaultFlags) {
296 const { stats, timingsMs } = MysteryBox._newStats('pack');
297
298 if (!(version in this.versionParameters)) {
299 throw new RangeError(`MysteryBox format version ${version} not supported`);
300 }
301 // eslint-disable-next-line security/detect-object-injection
302 const v = this.versionParameters[version];
303 stats.version = version;
304
305 const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags);
306
307 if (Buffer.isBuffer(contents)) {
308 // Ensure payloadIsBuffer flag is set when contents are indeed a Buffer
309 flags |= this.Flags.BufferPayload;
310 } else {
311 if (payloadIsBuffer) {
312 // Flag is set, but contents are not a Buffer? Try to coerce contents.
313 contents = Buffer.from(contents);
314 } else {
315 // Otherwise attempt to serialize the object
316 contents = JSON.stringify(contents);
317 }
318 }
319 stats.serializedBytes = Buffer.byteLength(contents);
320
321 const { buffer: versionBuffer, versionBytes } = MysteryBox._versionEncode(v.version);
322 if (versionBytes !== v.versionBytes) {
323 throw new Error('internal inconsistency, mismatched version byte length');
324 }
325
326 const [iv, salt] = await Promise.all([
327 v.ivBytes,
328 v.saltBytes,
329 ].map((b) => common.randomBytesAsync(b)));
330
331 timingsMs.preCompress = performance.now();
332 let compressedContents;
333 switch (compression) {
334 case 0: // No compression requested
335 compressedContents = contents;
336 break;
337 case this.Flags.Brotli:
338 compressedContents = await brotliCompressAsync(contents);
339 break;
340 case this.Flags.Flate:
341 compressedContents = await deflateRawAsync(contents);
342 break;
343 }
344 stats.compressedBytes = Buffer.byteLength(compressedContents);
345 // const compressionRatio = stats.compressedBytes / stats.serializedBytes;
346 timingsMs.postCompress = timingsMs.preCrypt = performance.now();
347
348 let payload;
349 if (stats.compressedBytes >= stats.serializedBytes) {
350 // If compression is not beneficial enough, or detrimental, do not use
351 flags = flags & ~compressionFlagsMask;
352 payload = contents;
353 stats.compressedBytes = undefined;
354 } else {
355 payload = compressedContents;
356 }
357
358 const flagsBuffer = Buffer.alloc(v.flagsBytes);
359 flagsBuffer.writeUInt8(flags, 0);
360 stats.flagsRaw = flags;
361 stats.flags = this._prettyFlags(flags);
362
363 // Authenticate all this data
364 const aadBuffer = Buffer.concat([versionBuffer, flagsBuffer, iv, salt]);
365
366 // Always encrypt with first secret
367 const secret = this.secrets[0];
368 const key = await MysteryBox._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes);
369 const cipher = crypto.createCipheriv(v.algorithm, key, iv, v.algOptions);
370 cipher.setAAD(aadBuffer);
371 const encrypted = cipher.update(payload);
372 const final = cipher.final();
373 const tag = cipher.getAuthTag();
374
375 const result = Buffer.concat([versionBuffer, flagsBuffer, iv, salt, tag, encrypted, final]).toString('base64url');
376 timingsMs.end = timingsMs.postCrypt = performance.now();
377
378 this.emit('statistics', { ...stats, ...MysteryBox._timingsLog(timingsMs), ...packageInfo });
379
380 return result;
381 }
382
383
384 /**
385 * Take contents out of a mysterious box.
386 * @param {String} box - Base64URL encoded payload
387 * @returns {Object}
388 */
389 async unpack(box) {
390 const { stats, timingsMs } = MysteryBox._newStats('unpack');
391
392 if (!box) {
393 throw new RangeError('nothing to unpack');
394 }
395
396 const raw = Buffer.from(box, 'base64url');
397 let offset = 0;
398
399 const { version, versionBytes } = MysteryBox._versionDecode(raw);
400 if (!(version in this.versionParameters)) {
401 throw new RangeError('unsupported version');
402 }
403 // eslint-disable-next-line security/detect-object-injection
404 const v = this.versionParameters[version];
405
406 if (v.versionBytes !== versionBytes) {
407 throw new Error('internal inconsistency, mismatched version byte length');
408 }
409 offset += v.versionBytes;
410 stats.version = version;
411
412 const minBytes = v.versionBytes + v.flagsBytes + v.ivBytes + v.saltBytes + v.tagBytes;
413 if (raw.length < minBytes) {
414 throw new RangeError('not enough to unpack');
415 }
416
417 const flags = raw.subarray(offset, offset + v.flagsBytes).readUInt8(0);
418 offset += v.flagsBytes;
419 stats.flagsRaw = flags;
420
421 const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags);
422
423 const iv = raw.subarray(offset, offset + v.ivBytes);
424 offset += v.ivBytes;
425
426 const salt = raw.subarray(offset, offset + v.saltBytes);
427 offset += v.saltBytes;
428
429 const aad = raw.subarray(0, offset); // Everything up to here
430
431 const tag = raw.subarray(offset, offset + v.tagBytes);
432 offset += v.tagBytes;
433
434 const encrypted = raw.subarray(offset);
435
436 timingsMs.preCrypt = performance.now();
437
438 let decrypted;
439 let err;
440 let success = false;
441 for await (const secret of this.secrets) {
442 const key = await MysteryBox._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes);
443 const decipher = crypto.createDecipheriv(v.algorithm, key, iv, v.algOptions);
444 decipher.setAAD(aad);
445 decipher.setAuthTag(tag);
446
447 try {
448 decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
449 success = true;
450 break;
451 } catch (e) {
452 err = e;
453 continue;
454 }
455 }
456 if (!success) {
457 throw err;
458 }
459
460 let payload;
461 timingsMs.preCompress = timingsMs.postCrypt = performance.now();
462 switch (compression) {
463 case 0: // No compression
464 payload = decrypted;
465 break;
466 case this.Flags.Brotli:
467 payload = await brotliDecompressAsync(decrypted);
468 break;
469 case this.Flags.Flate:
470 payload = await inflateRawAsync(decrypted);
471 break;
472 }
473 timingsMs.end = timingsMs.postCompress = performance.now();
474 stats.serializedBytes = payload.byteLength;
475 if (compression) {
476 stats.compressedBytes = decrypted.byteLength;
477 }
478
479 if (!payloadIsBuffer) {
480 payload = JSON.parse(payload.toString('utf8'));
481 }
482
483 stats.flags = this._prettyFlags(flags);
484 this.emit('statistics', { ...stats, ...MysteryBox._timingsLog(timingsMs), ...packageInfo });
485
486 return payload;
487 }
488
489
490 /**
491 * Pretty-print flag values
492 * @param {Number} flags
493 * @returns {String}
494 */
495 _prettyFlags(flags) {
496 const flagNames = Object.entries(this.Flags).reduce((acc, cur) => {
497 const [flagName, flagValue] = cur;
498 if ((flags & flagValue) === flagValue) {
499 acc.push(flagName);
500 }
501 return acc;
502 }, []);
503 return `0x${flags.toString(16).padStart(2, '0')} [${flagNames.join(',')}]`;
504 }
505
506
507 /**
508 * Everyone loves numbers.
509 * @param {Object} timingsMs
510 * @returns
511 */
512 static _timingsLog({ start, preCompress, postCompress, preCrypt, postCrypt, end }) {
513 return {
514 totalMs: end - start,
515 compressMs: postCompress - preCompress,
516 cryptMs: postCrypt - preCrypt,
517 };
518 }
519
520 }
521
522 module.exports = MysteryBox;