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