add jsdoc linting, address issues
[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 { base64ToBase64URL, base64URLToBase64 } = require('@squeep/base64url');
7 const common = require('./common');
8 const allVersions = require('./version-parameters');
9 const { performance } = require('perf_hooks');
10
11 const _fileScope = common.fileScope(__filename);
12
13 const brotliCompressAsync = promisify(zlib.brotliCompress);
14 const brotliDecompressAsync = promisify(zlib.brotliDecompress);
15 const deflateRawAsync = promisify(zlib.deflateRaw);
16 const inflateRawAsync = promisify(zlib.inflateRaw);
17 const scryptAsync = promisify(crypto.scrypt);
18
19 /**
20 * Only you will know what's inside your...
21 * \ | | | __ )
22 * |\/ | | | __| __| _ \ __| | __ \ _ \ \ /
23 * | | | |\__ \ | __/ | | | | | ( |` <
24 * _| _|\__, |____/\__|\___|_| \__, |____/ \___/ _/\_\
25 * ____/ ____/
26 *
27 * Our very own way of converting a buffer or serializable object
28 * to and from an opaque and web-safe representation, which we can
29 * let anyone see without disclosing the contents, nor allowing it
30 * to be modified without detection.
31 *
32 * The result is a Base64URL-encoded byte-array consisting of:
33 * - version, indicating format and encryption algorithm
34 * - flags, indicating state of the contents
35 * - iv/nonce, randomness for the algorithm
36 * - salt, applied to a secret to derive a key
37 * - tag, additional room for encryption to validate the previous fields
38 * - payload, encrypted version of possibly compressed and serialized object
39 *
40 */
41
42
43 const availableFlags = {
44 Brotli: (1<<0), // Slightly better compression, but slow
45 Flate: (1<<1), // Slightly worse compression, but much faster especially with larger payloads. Prefer this.
46 FutureCompression: (1<<0)|(1<<1), // Something else, like zstd maybe.
47
48 Unused2: (1<<2),
49 Unused3: (1<<3),
50 Unused4: (1<<4),
51 Unused5: (1<<5),
52 Unused6: (1<<6),
53
54 BufferPayload: (1<<7), // Payload is a buffer, not an object
55 };
56 const compressionFlagsMask = (availableFlags.Flate | availableFlags.Brotli);
57 const compressionFlagsShift = 0;
58 const payloadFlagsMask = (availableFlags.BufferPayload);
59 const payloadFlagsShift = 7;
60
61
62 class MysteryBox {
63 /**
64 * @param {Console} logger
65 * @param {Object} options
66 * @param {String|String[]} options.encryptionSecret - if an array, will always encrypt with first secret, will attempt to decrypt with all; useful for rolling secrets
67 * @param {Number=} options.defaultFlags
68 */
69 constructor(logger, options = {}) {
70 this.logger = logger;
71 this.options = options;
72
73 this.secrets = common.ensureArray(options.encryptionSecret);
74 if (!this.secrets.length) {
75 throw new Error('missing encryption secret');
76 }
77
78 // Filter any unavailable algorithms
79 const availableCiphers = crypto.getCiphers();
80 const availableHashes = crypto.getHashes();
81 // Add legacy scrypt to available hashes for filtering key derivations
82 availableHashes.push('scrypt');
83 this.versionParameters = Object.entries(allVersions).reduce((acc, [v, p]) => {
84 const validCipher = availableCiphers.includes(p.algorithm);
85 const validKeyDeriver = availableHashes.includes(p.keyDeriver);
86 if (validCipher && validKeyDeriver) {
87 acc[v] = p; // eslint-disable-line security/detect-object-injection
88 }
89 return acc;
90 }, {});
91
92 // Default to highest
93 this.bestVersion = Number(Object.keys(this.versionParameters).sort().pop());
94 if (Number.isNaN(this.bestVersion)) {
95 throw new Error('no supported versions available');
96 }
97
98 this.Flags = availableFlags;
99 this.defaultFlags = 'defaultFlags' in options ? options.defaultFlags : availableFlags.Flate;
100 if (this.defaultFlags < 0 || this.defaultFlags > 255) {
101 throw new RangeError('Invalid default flag value');
102 }
103 }
104
105
106 /**
107 * Parse the bits out of the flags.
108 */
109 static _decodeFlags(flags) {
110 return {
111 compression: (flags & compressionFlagsMask) >> compressionFlagsShift,
112 payloadIsBuffer: Boolean((flags & payloadFlagsMask) >> payloadFlagsShift),
113 };
114 }
115
116
117 /**
118 * Generate key data.
119 */
120 static async _keyFromSecret(deriver, secret, salt, keyBytes) {
121 switch (deriver) {
122 case 'shake256': {
123 const hash = crypto.createHash('shake256', { outputLength: keyBytes });
124 hash.update(salt);
125 hash.update(secret);
126 return hash.digest();
127 }
128
129 case 'blake2b512': {
130 const hash = crypto.createHash('blake2b512');
131 hash.update(salt);
132 hash.update(secret);
133 const digest = hash.digest();
134 // should assert that keyBytes <= 64
135 // but not concerned about that for now
136 // until we have a new algorithm with bigger key size
137 return digest.subarray(0, keyBytes);
138 }
139
140 case 'scrypt':
141 return scryptAsync(secret, salt, keyBytes);
142
143 default:
144 throw new RangeError('unsupported key deriver');
145 }
146 }
147
148
149 /**
150 * Put contents into a mysterious box.
151 * @param {Object|Buffer} contents
152 * @returns {String}
153 */
154 async pack(contents, version = this.bestVersion, flags = this.defaultFlags) {
155 const _scope = _fileScope('pack');
156 const timingsMs = {
157 start: performance.now(),
158 preCompress: 0,
159 postCompress: 0,
160 preEncrypt: 0,
161 postEncrypt: 0,
162 end: 0,
163 };
164
165 if (!(version in this.versionParameters)) {
166 throw new RangeError(`MysteryBox format version ${version} not supported`);
167 }
168 // eslint-disable-next-line security/detect-object-injection
169 const v = this.versionParameters[version];
170
171 const { compression } = MysteryBox._decodeFlags(flags);
172
173 if (Buffer.isBuffer(contents)) {
174 flags |= this.Flags.BufferPayload;
175 } else {
176 contents = JSON.stringify(contents);
177 }
178
179 const versionBuffer = Buffer.alloc(v.versionBytes);
180 versionBuffer.writeUInt8(v.version, 0);
181
182 const [iv, salt] = await Promise.all([
183 v.ivBytes,
184 v.saltBytes,
185 ].map((b) => common.randomBytesAsync(b)));
186
187 timingsMs.preCompress = performance.now();
188 let compressedContents;
189 switch (compression) {
190 case 0: // No compression requested
191 compressedContents = contents;
192 break;
193 case this.Flags.Brotli:
194 compressedContents = await brotliCompressAsync(contents);
195 break;
196 case this.Flags.Flate:
197 compressedContents = await deflateRawAsync(contents);
198 break;
199 }
200 timingsMs.postCompress = timingsMs.preCrypt = performance.now();
201
202 let payload;
203 if (compressedContents.length >= contents.length) {
204 flags = flags & ~compressionFlagsMask;
205 payload = contents;
206 } else {
207 payload = compressedContents;
208 }
209 const flagsBuffer = Buffer.alloc(v.flagsBytes);
210 flagsBuffer.writeUInt8(flags, 0);
211
212 // Authenticate all this data
213 const aadBuffer = Buffer.concat([versionBuffer, flagsBuffer, iv, salt]);
214
215 // Always encrypt with first secret
216 const secret = this.secrets[0];
217 const key = await MysteryBox._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes);
218 const cipher = crypto.createCipheriv(v.algorithm, key, iv, v.algOptions);
219 cipher.setAAD(aadBuffer);
220 const encrypted = cipher.update(payload);
221 const final = cipher.final();
222 const tag = cipher.getAuthTag();
223
224 const merged = Buffer.concat([versionBuffer, flagsBuffer, iv, salt, tag, encrypted, final]).toString('base64');
225 const result = base64ToBase64URL(merged);
226 timingsMs.end = timingsMs.postCrypt = performance.now();
227
228 this.logger.debug(_scope, 'statistics', { version, flags: this._prettyFlags(flags), serialized: contents.length, compressed: payload.length, encoded: result.length, ...MysteryBox._timingsLog(timingsMs) });
229
230 return result;
231 }
232
233
234 /**
235 * Take contents out of a mysterious box.
236 * @param {String} box - Base64URL encoded payload
237 * @returns {Object}
238 */
239 async unpack(box) {
240 const _scope = _fileScope('unpack');
241 const timingsMs = {
242 start: performance.now(),
243 preCompress: 0,
244 postCompress: 0,
245 preCrypt: 0,
246 postCrypt: 0,
247 end: 0,
248 };
249
250 if (!box) {
251 throw new RangeError('nothing to unpack');
252 }
253
254 const raw = Buffer.from(base64URLToBase64(box), 'base64');
255 let offset = 0;
256
257 const version = raw.subarray(offset, 1).readUInt8(0);
258 if (!(version in this.versionParameters)) {
259 throw new RangeError('unsupported version');
260 }
261 // eslint-disable-next-line security/detect-object-injection
262 const v = this.versionParameters[version];
263 offset += v.versionBytes;
264
265 const minBytes = v.versionBytes + v.flagsBytes + v.ivBytes + v.saltBytes + v.tagBytes;
266 if (raw.length < minBytes) {
267 throw new RangeError('not enough to unpack');
268 }
269
270 const flags = raw.subarray(offset, offset + v.flagsBytes).readUInt8(0);
271 offset += v.flagsBytes;
272
273 const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags);
274
275 const iv = raw.subarray(offset, offset + v.ivBytes);
276 offset += v.ivBytes;
277
278 const salt = raw.subarray(offset, offset + v.saltBytes);
279 offset += v.saltBytes;
280
281 const aad = raw.subarray(0, offset); // Everything up to here
282
283 const tag = raw.subarray(offset, offset + v.tagBytes);
284 offset += v.tagBytes;
285
286 const encrypted = raw.subarray(offset);
287
288 timingsMs.preCrypt = performance.now();
289
290 let decrypted;
291 let err;
292 let success = false;
293 for await (const secret of this.secrets) {
294 const key = await MysteryBox._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes);
295 const decipher = crypto.createDecipheriv(v.algorithm, key, iv, v.algOptions);
296 decipher.setAAD(aad);
297 decipher.setAuthTag(tag);
298
299 try {
300 decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
301 success = true;
302 break;
303 } catch (e) {
304 err = e;
305 continue;
306 }
307 }
308 if (!success) {
309 throw err;
310 }
311
312 let payload;
313 timingsMs.preCompress = timingsMs.postCrypt = performance.now();
314 switch (compression) {
315 case 0: // No compression
316 payload = decrypted;
317 break;
318 case this.Flags.Brotli:
319 payload = await brotliDecompressAsync(decrypted);
320 break;
321 case this.Flags.Flate:
322 payload = await inflateRawAsync(decrypted);
323 break;
324 }
325 timingsMs.end = timingsMs.postCompress = performance.now();
326
327 if (!payloadIsBuffer) {
328 payload = JSON.parse(payload.toString('utf8'));
329 }
330
331 this.logger.debug(_scope, 'statistics', { version, flags: this._prettyFlags(flags), ...MysteryBox._timingsLog(timingsMs) });
332
333 return payload;
334 }
335
336
337 /**
338 * Pretty-print flag values
339 * @param {Number} flags
340 * @returns {String}
341 */
342 _prettyFlags(flags) {
343 const flagNames = Object.entries(this.Flags).reduce((acc, cur) => {
344 const [flagName, flagValue] = cur;
345 if ((flags & flagValue) === flagValue) {
346 acc.push(flagName);
347 }
348 return acc;
349 }, []);
350 return `0x${flags.toString(16).padStart(2, '0')} [${flagNames.join(',')}]`;
351 }
352
353
354 /**
355 * Everyone loves numbers.
356 * @param {Object} timingsMs
357 * @returns
358 */
359 static _timingsLog({ start, preCompress, postCompress, preCrypt, postCrypt, end }) {
360 return {
361 totalMs: end - start,
362 compressMs: postCompress - preCompress,
363 cryptMs: postCrypt - preCrypt,
364 };
365 }
366
367 }
368
369 // Expose for stubbing in tests
370 MysteryBox._test = { crypto };
371
372 module.exports = MysteryBox;