cb85ebe698453a70f48f6af68bdad8d7cdeb9b08
[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 // TODO: support secret rolling
78
79 // Filter any unavailable algorithms
80 const availableCiphers = crypto.getCiphers();
81 this.versionParameters = Object.entries(allVersions).reduce((acc, [v, p]) => {
82 if (availableCiphers.includes(p.algorithm)) {
83 acc[v] = p; // eslint-disable-line security/detect-object-injection
84 }
85 return acc;
86 }, {});
87
88 // Default to highest
89 this.bestVersion = Number(Object.keys(this.versionParameters).sort().pop());
90 if (Number.isNaN(this.bestVersion)) {
91 throw new Error('no supported versions available');
92 }
93
94 this.Flags = availableFlags;
95 this.defaultFlags = 'defaultFlags' in options ? options.defaultFlags : availableFlags.Flate;
96 if (this.defaultFlags < 0 || this.defaultFlags > 255) {
97 throw new RangeError('Invalid default flag value');
98 }
99 }
100
101
102 /**
103 * Parse the bits out of the flags.
104 */
105 static _decodeFlags(flags) {
106 return {
107 compression: (flags & compressionFlagsMask) >> compressionFlagsShift,
108 payloadIsBuffer: Boolean((flags & payloadFlagsMask) >> payloadFlagsShift),
109 };
110 }
111
112
113 /**
114 * Put contents into a mysterious box.
115 * @param {Object|Buffer} contents
116 * @returns {String}
117 */
118 async pack(contents, version = this.bestVersion, flags = this.defaultFlags) {
119 const _scope = _fileScope('pack');
120 const timingsMs = {
121 start: performance.now(),
122 preCompress: 0,
123 postCompress: 0,
124 preEncrypt: 0,
125 postEncrypt: 0,
126 end: 0,
127 };
128
129 if (!(version in this.versionParameters)) {
130 throw new RangeError(`MysteryBox format version ${version} not supported`);
131 }
132 // eslint-disable-next-line security/detect-object-injection
133 const v = this.versionParameters[version];
134
135 const { compression } = MysteryBox._decodeFlags(flags);
136
137 if (Buffer.isBuffer(contents)) {
138 flags |= this.Flags.BufferPayload;
139 } else {
140 contents = JSON.stringify(contents);
141 }
142
143 const versionBuffer = Buffer.alloc(v.versionBytes);
144 versionBuffer.writeUInt8(v.version, 0);
145
146 const [iv, salt] = await Promise.all([
147 v.ivBytes,
148 v.saltBytes,
149 ].map((b) => common.randomBytesAsync(b)));
150
151 timingsMs.preCompress = performance.now();
152 let compressedContents;
153 switch (compression) {
154 case 0: // No compression requested
155 compressedContents = contents;
156 break;
157 case this.Flags.Brotli:
158 compressedContents = await brotliCompressAsync(contents);
159 break;
160 case this.Flags.Flate:
161 compressedContents = await deflateRawAsync(contents);
162 break;
163 }
164 timingsMs.postCompress = timingsMs.preCrypt = performance.now();
165
166 let payload;
167 if (compressedContents.length >= contents.length) {
168 flags = flags & ~compressionFlagsMask;
169 payload = contents;
170 } else {
171 payload = compressedContents;
172 }
173 const flagsBuffer = Buffer.alloc(v.flagsBytes);
174 flagsBuffer.writeUInt8(flags, 0);
175
176 // Authenticate all this data
177 const aadBuffer = Buffer.concat([versionBuffer, flagsBuffer, iv, salt]);
178
179 // Always encrypt with first secret
180 const secret = this.secrets[0];
181 const key = await scryptAsync(secret, salt, v.keyBytes);
182 const cipher = crypto.createCipheriv(v.algorithm, key, iv, v.algOptions);
183 cipher.setAAD(aadBuffer);
184 const encrypted = cipher.update(payload);
185 const final = cipher.final();
186 const tag = cipher.getAuthTag();
187
188 const merged = Buffer.concat([versionBuffer, flagsBuffer, iv, salt, tag, encrypted, final]).toString('base64');
189 const result = base64ToBase64URL(merged);
190 timingsMs.end = timingsMs.postCrypt = performance.now();
191
192 this.logger.debug(_scope, 'statistics', { version, flags: this._prettyFlags(flags), serialized: contents.length, compressed: payload.length, encoded: result.length, ...MysteryBox._timingsLog(timingsMs) });
193
194 return result;
195 }
196
197
198 /**
199 * Take contents out of a mysterious box.
200 * @param {String} box - Base64URL encoded payload
201 * @returns {Object}
202 */
203 async unpack(box) {
204 const _scope = _fileScope('unpack');
205 const timingsMs = {
206 start: performance.now(),
207 preCompress: 0,
208 postCompress: 0,
209 preCrypt: 0,
210 postCrypt: 0,
211 end: 0,
212 };
213
214 if (!box) {
215 throw new RangeError('nothing to unpack');
216 }
217
218 const raw = Buffer.from(base64URLToBase64(box), 'base64');
219 let offset = 0;
220
221 const version = raw.slice(offset, 1).readUInt8(0);
222 if (!(version in this.versionParameters)) {
223 throw new RangeError('unsupported version');
224 }
225 // eslint-disable-next-line security/detect-object-injection
226 const v = this.versionParameters[version];
227 offset += v.versionBytes;
228
229 const minBytes = v.versionBytes + v.flagsBytes + v.ivBytes + v.saltBytes + v.tagBytes;
230 if (raw.length < minBytes) {
231 throw new RangeError('not enough to unpack');
232 }
233
234 const flags = raw.slice(offset, offset + v.flagsBytes).readUInt8(0);
235 offset += v.flagsBytes;
236
237 const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags);
238
239 const iv = raw.slice(offset, offset + v.ivBytes);
240 offset += v.ivBytes;
241
242 const salt = raw.slice(offset, offset + v.saltBytes);
243 offset += v.saltBytes;
244
245 const aad = raw.slice(0, offset); // Everything up to here
246
247 const tag = raw.slice(offset, offset + v.tagBytes);
248 offset += v.tagBytes;
249
250 const encrypted = raw.slice(offset);
251
252 timingsMs.preCrypt = performance.now();
253
254 let decrypted;
255 let err;
256 let success = false;
257 for await (const secret of this.secrets) {
258 const key = await scryptAsync(secret, salt, v.keyBytes);
259 const decipher = crypto.createDecipheriv(v.algorithm, key, iv, v.algOptions);
260 decipher.setAAD(aad);
261 decipher.setAuthTag(tag);
262
263 try {
264 decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
265 success = true;
266 break;
267 } catch (e) {
268 err = e;
269 continue;
270 }
271 }
272 if (!success) {
273 throw err;
274 }
275
276 let payload;
277 timingsMs.preCompress = timingsMs.postCrypt = performance.now();
278 switch (compression) {
279 case 0: // No compression
280 payload = decrypted;
281 break;
282 case this.Flags.Brotli:
283 payload = await brotliDecompressAsync(decrypted);
284 break;
285 case this.Flags.Flate:
286 payload = await inflateRawAsync(decrypted);
287 break;
288 }
289 timingsMs.end = timingsMs.postCompress = performance.now();
290
291 if (!payloadIsBuffer) {
292 payload = JSON.parse(payload.toString('utf8'));
293 }
294
295 this.logger.debug(_scope, 'statistics', { version, flags: this._prettyFlags(flags), ...MysteryBox._timingsLog(timingsMs) });
296
297 return payload;
298 }
299
300
301 /**
302 * Pretty-print flag values
303 * @param {Number} flags
304 * @returns {String}
305 */
306 _prettyFlags(flags) {
307 const flagNames = Object.entries(this.Flags).reduce((acc, cur) => {
308 const [flagName, flagValue] = cur;
309 if ((flags & flagValue) === flagValue) {
310 acc.push(flagName);
311 }
312 return acc;
313 }, []);
314 return `0x${flags.toString(16).padStart(2, '0')} [${flagNames.join(',')}]`;
315 }
316
317
318 /**
319 * Everyone loves numbers.
320 * @param {Object} timingsMs
321 * @returns
322 */
323 static _timingsLog({ start, preCompress, postCompress, preCrypt, postCrypt, end }) {
324 return {
325 totalMs: end - start,
326 compressMs: postCompress - preCompress,
327 cryptMs: postCrypt - preCrypt,
328 };
329 }
330
331 }
332
333 // Expose for stubbing in tests
334 MysteryBox._test = { crypto };
335
336 module.exports = MysteryBox;