05fe28f72c872ac80b0604af4d15bbe8f52e069f
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');
11 const _fileScope
= common
.fileScope(__filename
);
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
);
20 * Only you will know what's inside your...
22 * |\/ | | | __| __| _ \ __| | __ \ _ \ \ /
23 * | | | |\__ \ | __/ | | | | | ( |` <
24 * _| _|\__, |____/\__|\___|_| \__, |____/ \___/ _/\_\
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.
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
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.
54 BufferPayload: (1<<7), // Payload is a buffer, not an object
56 const compressionFlagsMask
= (availableFlags
.Flate
| availableFlags
.Brotli
);
57 const compressionFlagsShift
= 0;
58 const payloadFlagsMask
= (availableFlags
.BufferPayload
);
59 const payloadFlagsShift
= 7;
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
69 constructor(logger
, options
= {}) {
71 this.options
= options
;
73 this.secrets
= common
.ensureArray(options
.encryptionSecret
);
74 if (!this.secrets
.length
) {
75 throw new Error('missing encryption secret');
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
93 this.bestVersion
= Number(Object
.keys(this.versionParameters
).sort().pop());
94 if (Number
.isNaN(this.bestVersion
)) {
95 throw new Error('no supported versions available');
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');
107 * Parse the bits out of the flags.
109 static _decodeFlags(flags
) {
111 compression: (flags
& compressionFlagsMask
) >> compressionFlagsShift
,
112 payloadIsBuffer: Boolean((flags
& payloadFlagsMask
) >> payloadFlagsShift
),
120 static async
_keyFromSecret(deriver
, secret
, salt
, keyBytes
) {
123 const hash
= crypto
.createHash('shake256', { outputLength: keyBytes
});
126 return hash
.digest();
130 const hash
= crypto
.createHash('blake2b512');
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
);
141 return scryptAsync(secret
, salt
, keyBytes
);
144 throw new RangeError('unsupported key deriver');
150 * Put contents into a mysterious box.
151 * @param {Object|Buffer} contents
154 async
pack(contents
, version
= this.bestVersion
, flags
= this.defaultFlags
) {
155 const _scope
= _fileScope('pack');
157 start: performance
.now(),
165 if (!(version
in this.versionParameters
)) {
166 throw new RangeError(`MysteryBox format version ${version} not supported`);
168 // eslint-disable-next-line security/detect-object-injection
169 const v
= this.versionParameters
[version
];
171 const { compression
} = MysteryBox
._decodeFlags(flags
);
173 if (Buffer
.isBuffer(contents
)) {
174 flags
|= this.Flags
.BufferPayload
;
176 contents
= JSON
.stringify(contents
);
179 const versionBuffer
= Buffer
.alloc(v
.versionBytes
);
180 versionBuffer
.writeUInt8(v
.version
, 0);
182 const [iv
, salt
] = await Promise
.all([
185 ].map((b
) => common
.randomBytesAsync(b
)));
187 timingsMs
.preCompress
= performance
.now();
188 let compressedContents
;
189 switch (compression
) {
190 case 0: // No compression requested
191 compressedContents
= contents
;
193 case this.Flags
.Brotli:
194 compressedContents
= await
brotliCompressAsync(contents
);
196 case this.Flags
.Flate:
197 compressedContents
= await
deflateRawAsync(contents
);
200 timingsMs
.postCompress
= timingsMs
.preCrypt
= performance
.now();
203 if (compressedContents
.length
>= contents
.length
) {
204 flags
= flags
& ~compressionFlagsMask
;
207 payload
= compressedContents
;
209 const flagsBuffer
= Buffer
.alloc(v
.flagsBytes
);
210 flagsBuffer
.writeUInt8(flags
, 0);
212 // Authenticate all this data
213 const aadBuffer
= Buffer
.concat([versionBuffer
, flagsBuffer
, iv
, salt
]);
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();
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();
228 this.logger
.debug(_scope
, 'statistics', { version
, flags: this._prettyFlags(flags
), serialized: contents
.length
, compressed: payload
.length
, encoded: result
.length
, ...MysteryBox
._timingsLog(timingsMs
) });
235 * Take contents out of a mysterious box.
236 * @param {String} box - Base64URL encoded payload
240 const _scope
= _fileScope('unpack');
242 start: performance
.now(),
251 throw new RangeError('nothing to unpack');
254 const raw
= Buffer
.from(base64URLToBase64(box
), 'base64');
257 const version
= raw
.subarray(offset
, 1).readUInt8(0);
258 if (!(version
in this.versionParameters
)) {
259 throw new RangeError('unsupported version');
261 // eslint-disable-next-line security/detect-object-injection
262 const v
= this.versionParameters
[version
];
263 offset
+= v
.versionBytes
;
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');
270 const flags
= raw
.subarray(offset
, offset
+ v
.flagsBytes
).readUInt8(0);
271 offset
+= v
.flagsBytes
;
273 const { compression
, payloadIsBuffer
} = MysteryBox
._decodeFlags(flags
);
275 const iv
= raw
.subarray(offset
, offset
+ v
.ivBytes
);
278 const salt
= raw
.subarray(offset
, offset
+ v
.saltBytes
);
279 offset
+= v
.saltBytes
;
281 const aad
= raw
.subarray(0, offset
); // Everything up to here
283 const tag
= raw
.subarray(offset
, offset
+ v
.tagBytes
);
284 offset
+= v
.tagBytes
;
286 const encrypted
= raw
.subarray(offset
);
288 timingsMs
.preCrypt
= performance
.now();
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
);
300 decrypted
= Buffer
.concat([decipher
.update(encrypted
), decipher
.final()]);
313 timingsMs
.preCompress
= timingsMs
.postCrypt
= performance
.now();
314 switch (compression
) {
315 case 0: // No compression
318 case this.Flags
.Brotli:
319 payload
= await
brotliDecompressAsync(decrypted
);
321 case this.Flags
.Flate:
322 payload
= await
inflateRawAsync(decrypted
);
325 timingsMs
.end
= timingsMs
.postCompress
= performance
.now();
327 if (!payloadIsBuffer
) {
328 payload
= JSON
.parse(payload
.toString('utf8'));
331 this.logger
.debug(_scope
, 'statistics', { version
, flags: this._prettyFlags(flags
), ...MysteryBox
._timingsLog(timingsMs
) });
338 * Pretty-print flag values
339 * @param {Number} flags
342 _prettyFlags(flags
) {
343 const flagNames
= Object
.entries(this.Flags
).reduce((acc
, cur
) => {
344 const [flagName
, flagValue
] = cur
;
345 if ((flags
& flagValue
) === flagValue
) {
350 return `0x${flags.toString(16).padStart(2, '0')} [${flagNames.join(',')}]`;
355 * Everyone loves numbers.
356 * @param {Object} timingsMs
359 static _timingsLog({ start
, preCompress
, postCompress
, preCrypt
, postCrypt
, end
}) {
361 totalMs: end
- start
,
362 compressMs: postCompress
- preCompress
,
363 cryptMs: postCrypt
- preCrypt
,
369 // Expose for stubbing in tests
370 MysteryBox
._test
= { crypto
};
372 module
.exports
= MysteryBox
;