use native base64url encoding, remove external dependency
[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('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 'shake256': {
122 const hash = crypto.createHash('shake256', { outputLength: keyBytes });
123 hash.update(salt);
124 hash.update(secret);
125 return hash.digest();
126 }
127
128 case 'blake2b512': {
129 const hash = crypto.createHash('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 'scrypt':
140 return scryptAsync(secret, salt, keyBytes);
141
142 default:
143 throw new RangeError('unsupported key deriver');
144 }
145 }
146
147
148 /**
149 * Put contents into a mysterious box.
150 * @param {Object|Buffer} contents
151 * @returns {String}
152 */
153 async pack(contents, version = this.bestVersion, flags = this.defaultFlags) {
154 const _scope = _fileScope('pack');
155 const timingsMs = {
156 start: performance.now(),
157 preCompress: 0,
158 postCompress: 0,
159 preEncrypt: 0,
160 postEncrypt: 0,
161 end: 0,
162 };
163
164 if (!(version in this.versionParameters)) {
165 throw new RangeError(`MysteryBox format version ${version} not supported`);
166 }
167 // eslint-disable-next-line security/detect-object-injection
168 const v = this.versionParameters[version];
169
170 const { compression } = MysteryBox._decodeFlags(flags);
171
172 if (Buffer.isBuffer(contents)) {
173 flags |= this.Flags.BufferPayload;
174 } else {
175 contents = JSON.stringify(contents);
176 }
177
178 const versionBuffer = Buffer.alloc(v.versionBytes);
179 versionBuffer.writeUInt8(v.version, 0);
180
181 const [iv, salt] = await Promise.all([
182 v.ivBytes,
183 v.saltBytes,
184 ].map((b) => common.randomBytesAsync(b)));
185
186 timingsMs.preCompress = performance.now();
187 let compressedContents;
188 switch (compression) {
189 case 0: // No compression requested
190 compressedContents = contents;
191 break;
192 case this.Flags.Brotli:
193 compressedContents = await brotliCompressAsync(contents);
194 break;
195 case this.Flags.Flate:
196 compressedContents = await deflateRawAsync(contents);
197 break;
198 }
199 timingsMs.postCompress = timingsMs.preCrypt = performance.now();
200
201 let payload;
202 if (compressedContents.length >= contents.length) {
203 flags = flags & ~compressionFlagsMask;
204 payload = contents;
205 } else {
206 payload = compressedContents;
207 }
208 const flagsBuffer = Buffer.alloc(v.flagsBytes);
209 flagsBuffer.writeUInt8(flags, 0);
210
211 // Authenticate all this data
212 const aadBuffer = Buffer.concat([versionBuffer, flagsBuffer, iv, salt]);
213
214 // Always encrypt with first secret
215 const secret = this.secrets[0];
216 const key = await MysteryBox._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes);
217 const cipher = crypto.createCipheriv(v.algorithm, key, iv, v.algOptions);
218 cipher.setAAD(aadBuffer);
219 const encrypted = cipher.update(payload);
220 const final = cipher.final();
221 const tag = cipher.getAuthTag();
222
223 const result = Buffer.concat([versionBuffer, flagsBuffer, iv, salt, tag, encrypted, final]).toString('base64url');
224 timingsMs.end = timingsMs.postCrypt = performance.now();
225
226 this.logger.debug(_scope, 'statistics', { version, flags: this._prettyFlags(flags), serialized: contents.length, compressed: payload.length, encoded: result.length, ...MysteryBox._timingsLog(timingsMs) });
227
228 return result;
229 }
230
231
232 /**
233 * Take contents out of a mysterious box.
234 * @param {String} box - Base64URL encoded payload
235 * @returns {Object}
236 */
237 async unpack(box) {
238 const _scope = _fileScope('unpack');
239 const timingsMs = {
240 start: performance.now(),
241 preCompress: 0,
242 postCompress: 0,
243 preCrypt: 0,
244 postCrypt: 0,
245 end: 0,
246 };
247
248 if (!box) {
249 throw new RangeError('nothing to unpack');
250 }
251
252 const raw = Buffer.from(box, 'base64url');
253 let offset = 0;
254
255 const version = raw.subarray(offset, 1).readUInt8(0);
256 if (!(version in this.versionParameters)) {
257 throw new RangeError('unsupported version');
258 }
259 // eslint-disable-next-line security/detect-object-injection
260 const v = this.versionParameters[version];
261 offset += v.versionBytes;
262
263 const minBytes = v.versionBytes + v.flagsBytes + v.ivBytes + v.saltBytes + v.tagBytes;
264 if (raw.length < minBytes) {
265 throw new RangeError('not enough to unpack');
266 }
267
268 const flags = raw.subarray(offset, offset + v.flagsBytes).readUInt8(0);
269 offset += v.flagsBytes;
270
271 const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags);
272
273 const iv = raw.subarray(offset, offset + v.ivBytes);
274 offset += v.ivBytes;
275
276 const salt = raw.subarray(offset, offset + v.saltBytes);
277 offset += v.saltBytes;
278
279 const aad = raw.subarray(0, offset); // Everything up to here
280
281 const tag = raw.subarray(offset, offset + v.tagBytes);
282 offset += v.tagBytes;
283
284 const encrypted = raw.subarray(offset);
285
286 timingsMs.preCrypt = performance.now();
287
288 let decrypted;
289 let err;
290 let success = false;
291 for await (const secret of this.secrets) {
292 const key = await MysteryBox._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes);
293 const decipher = crypto.createDecipheriv(v.algorithm, key, iv, v.algOptions);
294 decipher.setAAD(aad);
295 decipher.setAuthTag(tag);
296
297 try {
298 decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
299 success = true;
300 break;
301 } catch (e) {
302 err = e;
303 continue;
304 }
305 }
306 if (!success) {
307 throw err;
308 }
309
310 let payload;
311 timingsMs.preCompress = timingsMs.postCrypt = performance.now();
312 switch (compression) {
313 case 0: // No compression
314 payload = decrypted;
315 break;
316 case this.Flags.Brotli:
317 payload = await brotliDecompressAsync(decrypted);
318 break;
319 case this.Flags.Flate:
320 payload = await inflateRawAsync(decrypted);
321 break;
322 }
323 timingsMs.end = timingsMs.postCompress = performance.now();
324
325 if (!payloadIsBuffer) {
326 payload = JSON.parse(payload.toString('utf8'));
327 }
328
329 this.logger.debug(_scope, 'statistics', { version, flags: this._prettyFlags(flags), ...MysteryBox._timingsLog(timingsMs) });
330
331 return payload;
332 }
333
334
335 /**
336 * Pretty-print flag values
337 * @param {Number} flags
338 * @returns {String}
339 */
340 _prettyFlags(flags) {
341 const flagNames = Object.entries(this.Flags).reduce((acc, cur) => {
342 const [flagName, flagValue] = cur;
343 if ((flags & flagValue) === flagValue) {
344 acc.push(flagName);
345 }
346 return acc;
347 }, []);
348 return `0x${flags.toString(16).padStart(2, '0')} [${flagNames.join(',')}]`;
349 }
350
351
352 /**
353 * Everyone loves numbers.
354 * @param {Object} timingsMs
355 * @returns
356 */
357 static _timingsLog({ start, preCompress, postCompress, preCrypt, postCrypt, end }) {
358 return {
359 totalMs: end - start,
360 compressMs: postCompress - preCompress,
361 cryptMs: postCrypt - preCrypt,
362 };
363 }
364
365 }
366
367 // Expose for stubbing in tests
368 MysteryBox._test = { crypto };
369
370 module.exports = MysteryBox;