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