abef55c474a619a273e68e932f793058d05ff853
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');
10 const _fileScope
= common
.fileScope(__filename
);
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
);
19 * Only you will know what's inside your...
21 * |\/ | | | __| __| _ \ __| | __ \ _ \ \ /
22 * | | | |\__ \ | __/ | | | | | ( |` <
23 * _| _|\__, |____/\__|\___|_| \__, |____/ \___/ _/\_\
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.
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
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.
53 BufferPayload: (1<<7), // Payload is a buffer, not an object
55 const compressionFlagsMask
= (availableFlags
.Flate
| availableFlags
.Brotli
);
56 const compressionFlagsShift
= 0;
57 const payloadFlagsMask
= (availableFlags
.BufferPayload
);
58 const payloadFlagsShift
= 7;
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
68 constructor(logger
, options
= {}) {
70 this.options
= options
;
72 this.secrets
= common
.ensureArray(options
.encryptionSecret
);
73 if (!this.secrets
.length
) {
74 throw new Error('missing encryption secret');
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
92 this.bestVersion
= Number(Object
.keys(this.versionParameters
).sort().pop());
93 if (Number
.isNaN(this.bestVersion
)) {
94 throw new Error('no supported versions available');
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');
106 * Parse the bits out of the flags.
108 static _decodeFlags(flags
) {
110 compression: (flags
& compressionFlagsMask
) >> compressionFlagsShift
,
111 payloadIsBuffer: Boolean((flags
& payloadFlagsMask
) >> payloadFlagsShift
),
119 static async
_keyFromSecret(deriver
, secret
, salt
, keyBytes
) {
121 case allVersions
.KD
.SHAKE256: {
122 const hash
= crypto
.createHash(allVersions
.KD
.SHAKE256
, { outputLength: keyBytes
});
125 return hash
.digest();
128 case allVersions
.KD
.BLAKE2B512: {
129 const hash
= crypto
.createHash(allVersions
.KD
.BLAKE2B512
);
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
);
139 case allVersions
.KD
.SCRYPT:
140 return scryptAsync(secret
, salt
, keyBytes
);
143 throw new RangeError('unsupported key deriver');
149 * Return bits and bit mask for given number of encoded bytes.
150 * @param {Number} numBytes
153 static _versionHeaderBits(numBytes
) {
154 // Round up to 8 bits in result, just to be proper.
155 const resultBits
= (((numBytes
+ 7) >> 3) << 3) >>> 0;
157 headerValue: ((0xff << (resultBits
- numBytes
+ 1)) & 0xff) >>> 0,
158 headerMask: ((0xff << (resultBits
- numBytes
)) & 0xff) >>> 0,
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
170 * @param {Number} firstByte
173 static _versionHeaderDecode(firstByte
) {
174 for (let numBytes
= 1; numBytes
<= 8; numBytes
++) {
178 } = MysteryBox
._versionHeaderBits(numBytes
);
179 if (((firstByte
& headerMask
) >>> 0) === headerValue
) {
180 const restMask
= (~headerMask
& 0xff) >>> 0;
183 firstByte: (firstByte
& restMask
) >>> 0,
187 // Nine bytes would be an extravagence.
188 throw new RangeError(`unsupported version header (0x${firstByte.toString(16)})`);
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
201 static _versionDecode(buf
) {
202 const headerByte
= buf
.readUInt8(0);
203 const { numBytes: versionBytes
, firstByte
} = MysteryBox
._versionHeaderDecode(headerByte
);
204 if (versionBytes
=== 1) {
211 if (versionBytes
> 6) {
212 throw new RangeError(`unsupported version (${versionBytes} bytes)`);
215 // Otherwise, update the masked first byte and parse the rest of the buffer.
218 version: buf
.readUIntBE(0, versionBytes
),
225 * Encode a version identifier into a buffer of a variable number of bytes.
226 * @param {Number} version
229 static _versionEncode(version
) {
230 let versionBytes
= 0;
232 if (version
<= 0x7f) { // 0-127
234 } else if (version
<= 0x3fff) { // 128-16383
236 } else if (version
<= 0x1fffff) { // 16384-2097151
238 } else if (version
<= 0x0fffffff) { // 2097152-268435455
240 } else if (version
<= 0x07ffffffff) { // 268435456-34359738367
242 } else if (version
<= 0x03ffffffffff) { // 34359738368-4398046511103
245 throw new RangeError('version too large to encode');
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;
261 * Put contents into a mysterious box.
262 * @param {Object|Buffer} contents
263 * @param {Number=} version
264 * @param {Number=} flags
267 async
pack(contents
, version
= this.bestVersion
, flags
= this.defaultFlags
) {
268 const _scope
= _fileScope('pack');
270 start: performance
.now(),
280 serializedBytes: undefined, // original contents size in bytes
281 compressedBytes: undefined, // compressed contents size in bytes
284 if (!(version
in this.versionParameters
)) {
285 throw new RangeError(`MysteryBox format version ${version} not supported`);
287 // eslint-disable-next-line security/detect-object-injection
288 const v
= this.versionParameters
[version
];
290 const { compression
, payloadIsBuffer
} = MysteryBox
._decodeFlags(flags
);
292 if (Buffer
.isBuffer(contents
)) {
293 // Ensure payloadIsBuffer flag is set when contents are indeed a Buffer
294 flags
|= this.Flags
.BufferPayload
;
296 if (payloadIsBuffer
) {
297 // Flag is set, but contents are not a Buffer? Try to coerce contents.
298 contents
= Buffer
.from(contents
);
300 // Otherwise attempt to serialize the object
301 contents
= JSON
.stringify(contents
);
304 stats
.serializedBytes
= Buffer
.byteLength(contents
);
306 const { buffer: versionBuffer
, versionBytes
} = MysteryBox
._versionEncode(v
.version
);
307 if (versionBytes
!== v
.versionBytes
) {
308 throw new Error('internal inconsistency, mismatched version byte length');
311 const [iv
, salt
] = await Promise
.all([
314 ].map((b
) => common
.randomBytesAsync(b
)));
316 timingsMs
.preCompress
= performance
.now();
317 let compressedContents
;
318 switch (compression
) {
319 case 0: // No compression requested
320 compressedContents
= contents
;
322 case this.Flags
.Brotli:
323 compressedContents
= await
brotliCompressAsync(contents
);
325 case this.Flags
.Flate:
326 compressedContents
= await
deflateRawAsync(contents
);
329 stats
.compressedBytes
= Buffer
.byteLength(compressedContents
);
330 // const compressionRatio = stats.compressedBytes / stats.serializedBytes;
331 timingsMs
.postCompress
= timingsMs
.preCrypt
= performance
.now();
334 if (stats
.compressedBytes
>= stats
.serializedBytes
) {
335 // If compression is not beneficial enough, or detrimental, do not use
336 flags
= flags
& ~compressionFlagsMask
;
338 stats
.compressedBytes
= undefined;
340 payload
= compressedContents
;
343 const flagsBuffer
= Buffer
.alloc(v
.flagsBytes
);
344 flagsBuffer
.writeUInt8(flags
, 0);
345 stats
.flags
= this._prettyFlags(flags
);
347 // Authenticate all this data
348 const aadBuffer
= Buffer
.concat([versionBuffer
, flagsBuffer
, iv
, salt
]);
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();
359 const result
= Buffer
.concat([versionBuffer
, flagsBuffer
, iv
, salt
, tag
, encrypted
, final
]).toString('base64url');
360 timingsMs
.end
= timingsMs
.postCrypt
= performance
.now();
362 this.logger
.debug(_scope
, 'statistics', { ...stats
, ...MysteryBox
._timingsLog(timingsMs
) });
369 * Take contents out of a mysterious box.
370 * @param {String} box - Base64URL encoded payload
374 const _scope
= _fileScope('unpack');
376 start: performance
.now(),
385 throw new RangeError('nothing to unpack');
388 const raw
= Buffer
.from(box
, 'base64url');
391 const { version
, versionBytes
} = MysteryBox
._versionDecode(raw
);
392 if (!(version
in this.versionParameters
)) {
393 throw new RangeError('unsupported version');
395 // eslint-disable-next-line security/detect-object-injection
396 const v
= this.versionParameters
[version
];
398 if (v
.versionBytes
!== versionBytes
) {
399 throw new Error('internal inconsistency, mismatched version byte length');
401 offset
+= v
.versionBytes
;
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');
408 const flags
= raw
.subarray(offset
, offset
+ v
.flagsBytes
).readUInt8(0);
409 offset
+= v
.flagsBytes
;
411 const { compression
, payloadIsBuffer
} = MysteryBox
._decodeFlags(flags
);
413 const iv
= raw
.subarray(offset
, offset
+ v
.ivBytes
);
416 const salt
= raw
.subarray(offset
, offset
+ v
.saltBytes
);
417 offset
+= v
.saltBytes
;
419 const aad
= raw
.subarray(0, offset
); // Everything up to here
421 const tag
= raw
.subarray(offset
, offset
+ v
.tagBytes
);
422 offset
+= v
.tagBytes
;
424 const encrypted
= raw
.subarray(offset
);
426 timingsMs
.preCrypt
= performance
.now();
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
);
438 decrypted
= Buffer
.concat([decipher
.update(encrypted
), decipher
.final()]);
451 timingsMs
.preCompress
= timingsMs
.postCrypt
= performance
.now();
452 switch (compression
) {
453 case 0: // No compression
456 case this.Flags
.Brotli:
457 payload
= await
brotliDecompressAsync(decrypted
);
459 case this.Flags
.Flate:
460 payload
= await
inflateRawAsync(decrypted
);
463 timingsMs
.end
= timingsMs
.postCompress
= performance
.now();
465 if (!payloadIsBuffer
) {
466 payload
= JSON
.parse(payload
.toString('utf8'));
469 this.logger
.debug(_scope
, 'statistics', { version
, flags: this._prettyFlags(flags
), ...MysteryBox
._timingsLog(timingsMs
) });
476 * Pretty-print flag values
477 * @param {Number} flags
480 _prettyFlags(flags
) {
481 const flagNames
= Object
.entries(this.Flags
).reduce((acc
, cur
) => {
482 const [flagName
, flagValue
] = cur
;
483 if ((flags
& flagValue
) === flagValue
) {
488 return `0x${flags.toString(16).padStart(2, '0')} [${flagNames.join(',')}]`;
493 * Everyone loves numbers.
494 * @param {Object} timingsMs
497 static _timingsLog({ start
, preCompress
, postCompress
, preCrypt
, postCrypt
, end
}) {
499 totalMs: end
- start
,
500 compressMs: postCompress
- preCompress
,
501 cryptMs: postCrypt
- preCrypt
,
507 // Expose for stubbing in tests
508 MysteryBox
._test
= { crypto
};
510 module
.exports
= MysteryBox
;