const inflateRawAsync = promisify(zlib.inflateRaw);
const scryptAsync = promisify(crypto.scrypt);
const randomBytesAsync = promisify(crypto.randomBytes);
+const hkdfAsync = promisify(crypto.hkdf);
/**
* Only you will know what's inside your...
* - payload, encrypted version of possibly compressed and serialized object
*
* Older versions exposed the payload state flags, later versions encrypt them.
+ * All bytes up to the encrypted content are verified as AAD.
*/
/**
* @typedef {string|Buffer} Secret
* @typedef {object} MysteryBoxOptions
- * @property {Secret|Secret[]} encryptionSecret if an array, will always encrypt with first secret, will attempt to decrypt with all in ordere
+ * @property {Secret|Secret[]} encryptionSecret if an array, will always encrypt with first secret, will attempt to decrypt with all in order
* @property {number=} defaultFlags default flags to use when packing boxes
+ * @property {string|Buffer=} hkdfSalt domain-specific salt for HKDF key derivation
* @event MysteryBox#statistics
* @type {object}
* @property {...PackageInfo} package name and version
* @property {...Stats} details of operation
* @property {...TimingsPhases} timings of operation
+ * @property {any=} extra extra data
*/
class MysteryBox extends EventEmitter {
+ static statisticsEvent = 'statistics';
+ secrets;
+ versionParameters;
+ bestVersion;
+ Flags;
+ defaultFlags;
+ hkdfSalt;
+
/**
* @param {MysteryBoxOptions} options options
* @param {any[]} args passed on to EventEmitter constructor
*/
constructor(options = {}, ...args) {
super(...args);
- this.secrets = MysteryBox._ensureArray(options.encryptionSecret);
+ this.secrets = MysteryBox._ensureArray(options.encryptionSecret)
+ .map((secret) => crypto.createSecretKey(secret));
if (!this.secrets.length) {
throw new MysteryBoxError('missing encryption secret');
}
const availableHashes = crypto.getHashes();
// Add legacy scrypt to available hashes for filtering key derivations
availableHashes.push(allVersions.KD.SCRYPT);
+ /**
+ * @type {Record<number, import('./version-parameters').VersionParameters>}
+ */
this.versionParameters = Object.entries(allVersions).reduce((acc, [v, p]) => {
const validCipher = availableCiphers.includes(p.algorithm);
const validKeyDeriver = availableHashes.includes(p.keyDeriver);
if (this.defaultFlags < 0 || this.defaultFlags > 255) {
throw new MysteryBoxError('Invalid default flag value');
}
+
+ this.hkdfSalt = options.hkdfSalt ?? Buffer.from('squeep-mystery-box');
}
}
+ /**
+ * @typedef {object} FlagsDecoded
+ * @property {number} compression compression type
+ * @property {boolean} payloadIsBuffer true if payload is a buffer
+ */
/**
* Parse the bits out of the flags.
* @param {number} flags flags
- * @returns {object} decoded flags
+ * @returns {FlagsDecoded} decoded flags
*/
static _decodeFlags(flags) {
return {
/**
* Generate key data.
- * @param {string} deriver key deriver enum
- * @param {Buffer} secret secret
+ * @param {string} deriver key deriver enum string
+ * @param {crypto.KeyObject} secret secret
* @param {Buffer} salt salt
* @param {number} keyBytes bytes in key
- * @returns {Promise<Buffer>} key
+ * @param {boolean} useHKDF use HKDF for derivation
+ * @returns {Promise<crypto.KeyObject>} key
*/
- static async _keyFromSecret(deriver, secret, salt, keyBytes) {
+ async _keyFromSecret(deriver, secret, salt, keyBytes, useHKDF) {
+ let keyBuffer;
switch (deriver) {
case allVersions.KD.SHAKE256: {
const hash = crypto.createHash(allVersions.KD.SHAKE256, { outputLength: keyBytes });
hash.update(salt);
- hash.update(secret);
- return hash.digest();
+ hash.update(secret.export());
+ keyBuffer = hash.digest();
+ break;
}
case allVersions.KD.BLAKE2B512: {
- const hash = crypto.createHash(allVersions.KD.BLAKE2B512);
- hash.update(salt);
- hash.update(secret);
- const digest = hash.digest();
- // should assert that keyBytes <= 64
- // but not concerned about that for now
- // until we have a new algorithm with bigger key size
- return digest.subarray(0, keyBytes);
+ if (useHKDF) {
+ keyBuffer = await hkdfAsync(allVersions.KD.BLAKE2B512, secret.export(), this.hkdfSalt, salt, keyBytes);
+ } else {
+ const hash = crypto.createHash(allVersions.KD.BLAKE2B512);
+ hash.update(salt);
+ hash.update(secret.export());
+ const digest = hash.digest();
+ // should assert that keyBytes <= 64
+ // but not concerned about that for now
+ // until we have a new algorithm with bigger key size
+ keyBuffer = digest.subarray(0, keyBytes);
+ }
+ break;
}
case allVersions.KD.SCRYPT:
- return scryptAsync(secret, salt, keyBytes);
+ keyBuffer = await scryptAsync(secret.export(), salt, keyBytes);
+ break;
default:
throw new MysteryBoxError('unsupported key deriver');
}
+
+ return crypto.createSecretKey(keyBuffer);
}
+ /**
+ * @typedef {object} VersionHeaderBits
+ * @property {number} headerValue value
+ * @property {number} headerMask mask
+ */
/**
* Return bits and bit mask for given number of encoded bytes.
* @param {number} numBytes number of bytes
- * @returns {object} bits value and mask
+ * @returns {VersionHeaderBits} bits value and mask
*/
static _versionHeaderBits(numBytes) {
// Round up to 8 bits in result, just to be proper.
}
+ /**
+ * @typedef {object} VersionDecoded
+ * @property {number} version version number
+ * @property {number} versionBytes number of bytes used to encode version
+ */
/**
* Decode leading bytes of buffer as version identifier.
* In the first byte, the position of the first unset bit indicates how
* Returns decoded version and number of bytes used to decode.
* Only supports up to 6-byte numbers, and of those, only up to 4398046511103.
* @param {Buffer} buf N.B. will be mogrified
- * @returns {object} decoded version and version byte length
+ * @returns {VersionDecoded} decoded version and version byte length
*/
static _versionDecode(buf) {
const headerByte = buf.readUInt8(0);
}
+ /**
+ * @typedef {object} VersionEncoded
+ * @property {Buffer} buffer encoded version
+ * @property {number} versionBytes number of bytes used to encode version
+ */
/**
* Encode a version identifier into a buffer of a variable number of bytes.
* @param {number} version version number to encode
- * @returns {object} encoded version buffer and number of bytes
+ * @returns {VersionEncoded} encoded version buffer and number of bytes
*/
static _versionEncode(version) {
let versionBytes = 0;
* @param {object|Buffer} contents object or buffer
* @param {number=} version version
* @param {number=} flags flags
+ * @param {any=} statsExtra extra field added to stats
* @returns {Promise<string>} encoded contents
* @fires MysteryBox#statistics
*/
- async pack(contents, version = this.bestVersion, flags = this.defaultFlags) {
+ async pack(contents, version, flags, statsExtra) {
const { stats, timingsMs } = MysteryBox._newStats('pack');
+ version = version ?? this.bestVersion;
+ flags = flags ?? this.defaultFlags;
+
if (!(version in this.versionParameters)) {
throw new MysteryBoxError(`MysteryBox format version ${version} not supported`);
}
// eslint-disable-next-line security/detect-object-injection
const v = this.versionParameters[version];
- stats.version = version;
+ stats.version = v.version;
+ // version specific details
const visibleFlags = (version <= 9);
+ const useHKDF = (version >= 19);
const { compression, payloadIsBuffer } = MysteryBox._decodeFlags(flags);
if (Buffer.isBuffer(contents)) {
// Ensure payloadIsBuffer flag is set when contents are indeed a Buffer
flags |= this.Flags.BufferPayload;
+ } else if (payloadIsBuffer) {
+ // Flag is set, but contents are not a Buffer? Try to coerce contents.
+ contents = Buffer.from(contents);
} else {
- if (payloadIsBuffer) {
- // Flag is set, but contents are not a Buffer? Try to coerce contents.
- contents = Buffer.from(contents);
- } else {
- // Otherwise attempt to serialize the object
- contents = JSON.stringify(contents);
- }
+ // Otherwise attempt to serialize the object
+ contents = JSON.stringify(contents);
}
+
stats.serializedBytes = Buffer.byteLength(contents);
const { buffer: versionBuffer, versionBytes } = MysteryBox._versionEncode(v.version);
// Always encrypt with first secret
const secret = this.secrets[0];
- const key = await MysteryBox._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes);
+ const key = await this._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes, useHKDF);
const cipher = crypto.createCipheriv(v.algorithm, key, iv, v.algOptions);
let result;
}
timingsMs.end = timingsMs.postCrypt = performance.now();
- this.emit('statistics', { ...stats, ...MysteryBox._timingsLog(timingsMs), ...packageInfo });
+ this.emit('statistics', {
+ ...stats,
+ ...MysteryBox._timingsLog(timingsMs),
+ ...packageInfo,
+ ...(statsExtra && { extra: statsExtra }),
+ });
return result;
}
/**
* Take contents out of a mysterious box.
- * @param {string} box - Base64URL encoded payload
+ * @param {string} box Base64URL encoded payload
+ * @param {any=} statsExtra extra fieild added to stats
* @returns {Promise<object|Buffer>} decoded contents
* @fires MysteryBox#statistics
*/
- async unpack(box) {
+ async unpack(box, statsExtra) {
const { stats, timingsMs } = MysteryBox._newStats('unpack');
if (!box) {
stats.version = version;
const visibleFlags = (version <= 9);
+ const useHKDF = (version >= 19);
const minBytes = v.versionBytes + (visibleFlags ? v.flagsBytes : 0) + v.ivBytes + v.saltBytes + v.tagBytes;
if (raw.length < minBytes) {
let decrypted;
let err;
let success = false;
- for await (const secret of this.secrets) {
- const key = await MysteryBox._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes);
+ for (const secret of this.secrets) {
+ const key = await this._keyFromSecret(v.keyDeriver, secret, salt, v.keyBytes, useHKDF);
const decipher = crypto.createDecipheriv(v.algorithm, key, iv, v.algOptions);
decipher.setAAD(aad);
decipher.setAuthTag(tag);
}
stats.flags = this._prettyFlags(flags);
- this.emit('statistics', { ...stats, ...MysteryBox._timingsLog(timingsMs), ...packageInfo });
+ this.emit('statistics', {
+ ...stats,
+ ...MysteryBox._timingsLog(timingsMs),
+ ...packageInfo,
+ ...(statsExtra && { extra: statsExtra }),
+ });
return payload;
}
AES_256_GCM: 'aes-256-gcm',
CHACHA20_POLY1305: 'chacha20-poly1305',
- XCHACHA20_POLY1305: 'xchacha20-poly1305',
+ XCHACHA20_POLY1305: 'xchacha20-poly1305', // Not currently available, but we optimistically allocate for a future where it is
};
const KD = {
BLAKE2B512: 'blake2b512',
};
+/**
+ * @typedef {object} VersionParameters
+ * @property {number} version version number
+ * @property {string} algorithm encryption algorithm, from ALG
+ * @property {object} algOptions algorithm specific options
+ * @property {number=} algOptions.authTagLength length of authentication tag
+ * @property {number} versionBytes number of bytes used for version
+ * @property {number} flagsBytes number of bytes used for flags
+ * @property {number} ivBytes number of bytes used for IV
+ * @property {number} saltBytes number of bytes used for salt
+ * @property {number} tagBytes number of bytes used for tag
+ * @property {string} keyDeriver key derivation function, from KD
+ * @property {object} keyDeriverOptions options for key derivation function
+ * @property {number} keyBytes number of bytes used for key
+ */
+
+/**
+ * @typedef {Record<number, VersionParameters>} Versions
+ * @property {Record<string, string>} ALG encryption algorithms
+ * @property {Record<string, string>} KD key derivation functions
+ */
/**
* Supported packings/cipher types.
* To be useful, any cipher included here must be Authenticated Encryption with Additional Data (AEAD).
* More preferable versions are numbered higher.
+ * @type {Versions}
*/
const allVersions = {
__proto__: null,
},
3: {
version: 3,
- algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+ algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer this, allocating for hopeful future use
algOptions: {
authTagLength: 16,
},
keyDeriver: KD.SCRYPT,
keyBytes: 32,
},
+
4: {
version: 4,
algorithm: ALG.AES_256_GCM,
},
6: {
version: 6,
- algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+ algorithm: ALG.XCHACHA20_POLY1305,
algOptions: {
authTagLength: 16,
},
keyDeriver: KD.SHAKE256,
keyBytes: 32,
},
+
7: {
version: 7,
algorithm: ALG.AES_256_GCM,
},
9: {
version: 9,
- algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+ algorithm: ALG.XCHACHA20_POLY1305,
algOptions: {
authTagLength: 16,
},
},
12: {
version: 12,
- algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+ algorithm: ALG.XCHACHA20_POLY1305,
algOptions: {
authTagLength: 16,
},
keyDeriver: KD.SCRYPT,
keyBytes: 32,
},
+
13: {
version: 13,
algorithm: ALG.AES_256_GCM,
},
15: {
version: 15,
- algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+ algorithm: ALG.XCHACHA20_POLY1305,
algOptions: {
authTagLength: 16,
},
keyDeriver: KD.SHAKE256,
keyBytes: 32,
},
+
16: {
version: 16,
algorithm: ALG.AES_256_GCM,
},
18: {
version: 18,
- algorithm: ALG.XCHACHA20_POLY1305, // Not yet available, but would prefer even more...
+ algorithm: ALG.XCHACHA20_POLY1305,
+ algOptions: {
+ authTagLength: 16,
+ },
+ versionBytes: 1,
+ flagsBytes: 1,
+ ivBytes: 24,
+ saltBytes: 16,
+ tagBytes: 16,
+ keyDeriver: KD.BLAKE2B512,
+ keyBytes: 32,
+ },
+
+ // Prior to version 19, blake2b512 used plain hash digest.
+ // Versions 19 and later use HKDF for blake2b512 key derivation.
+ 19: {
+ version: 19,
+ algorithm: ALG.AES_256_GCM,
+ algOptions: {},
+ versionBytes: 1,
+ flagsBytes: 1,
+ ivBytes: 12,
+ saltBytes: 16,
+ tagBytes: 16,
+ keyDeriver: KD.BLAKE2B512,
+ keyBytes: 32,
+ },
+ 20: {
+ version: 20,
+ algorithm: ALG.CHACHA20_POLY1305,
+ algOptions: {
+ authTagLength: 16,
+ },
+ versionBytes: 1,
+ flagsBytes: 1,
+ ivBytes: 12,
+ saltBytes: 16,
+ tagBytes: 16,
+ keyDeriver: KD.BLAKE2B512,
+ keyBytes: 32,
+ },
+ 21: {
+ version: 21,
+ algorithm: ALG.XCHACHA20_POLY1305,
algOptions: {
authTagLength: 16,
},
keyDeriver: KD.BLAKE2B512,
keyBytes: 32,
},
+
};
Object.defineProperties(allVersions, {
}
describe('MysteryBox', function () {
- let mb, options, object;
+ let mb, options, object, statsStub;
beforeEach(function () {
options = {
encryptionSecret: 'this is not a very good secret',
};
+ statsStub = sinon.stub();
});
afterEach(function () {
sinon.restore();
encryptionSecret: ['first poor secret', 'second poor secret'],
};
mb = new MysteryBox(options);
+ mb.on('statistics', statsStub);
_verbose(mb);
object = {
foo: 'bar',
baz: 'quux',
flarp: 13,
};
- const encryptedResult = await mb.pack(object);
- const decryptedResult = await mb.unpack(encryptedResult);
+ const encryptedResult = await mb.pack(object, undefined, undefined, 'extra data');
+ const decryptedResult = await mb.unpack(encryptedResult, 'extra data');
+ assert.strictEqual(statsStub.callCount, 2);
assert.deepStrictEqual(decryptedResult, object);
});
describe('_keyFromSecret', function () {
it('covers invalid', async function () {
- assert.rejects(() => MysteryBox._keyFromSecret('unknown deriver', 'secret', 'salt', 32), MysteryBoxError);
+ assert.rejects(() => mb._keyFromSecret('unknown deriver', 'secret', 'salt', 32), MysteryBoxError);
});
}); // _keyFromSecret
it('encrypts and decrypts all available versions no compression', async function () {
this.slow(Object.keys(mb.versionParameters).length * 200);
const availableVersions = Object.keys(mb.versionParameters).map((v) => Number(v));
- for await (const version of availableVersions) {
+ for (const version of availableVersions) {
object = {
foo: 'bar',
baz: 'quux',
it('encrypts and decrypts all available versions +brotli', async function () {
this.slow(Object.keys(mb.versionParameters).length * 200);
const availableVersions = Object.keys(mb.versionParameters).map((v) => Number(v));
- for await (const version of availableVersions) {
+ for (const version of availableVersions) {
object = {
foo: 'bar',
baz: 'quux',
it('encrypts and decrypts all available versions +flate', async function () {
this.slow(Object.keys(mb.versionParameters).length * 200);
const availableVersions = Object.keys(mb.versionParameters).map((v) => Number(v));
- for await (const version of availableVersions) {
+ for (const version of availableVersions) {
object = {
foo: 'bar',
baz: 'quux',
}); // pack, unpack
+ describe('sanity check', function () {
+ const encryptionSecret = 'jived fox nymph grabs quick waltz';
+ const data = 'Jackdaws love my big sphinx of quartz.';
+ const testVectors = {
+ '1': 'AQDrdybNo2AZM1YWGhWWMDp5V4ukBz_rm9PCSreBu3GkpLuegLWHZ4YKW2ObxA6QQowDixNdGcTzJLxFxyl5sojeZwM3FcXQlje920CqhVkaj5hs_xA',
+ '2': 'AgBRg3sswTRkYAP67KfbTvt14V2smjqT9E5i7exkiy3C8qv6LzXWahoPBKW1fOYTbT9n4nIwRjCmpZMiswWfiES2VqQHd8JPuQAvkfgGy2xoE2PmoxM',
+ '4': 'BADNJyfzTimCZm84ltW7Iipta4cBOPk3SnGaUmUD9sXralAFlYG_iSmZrEbqE7Ywc1XI5-CFn-yTsCd9-gNYisainPr1IBRErpyM_T5LE5tTxqGVmy8',
+ '5': 'BQBh2VkCGJflQcGCDDrKNmb2g8NszYawXYNKRoeyyPAXah0YV_h_2SPzQ11YswwGVR3zlbhlg-8x-bhbe_mDDbYwZs_PRq3YZ6OV2TOhR1y7XBODdDE',
+ '7': 'BwBKSO5LwBGUjPeD0m3tYe-L9U_4rwDAe6Tj6g02G9_2CnZ3dqRozDWi0ITD4YiDz3x1VQHMV7r6Zl_7SnBg-4D1UMt0dZyxGfL4XqQlglNrJazyFBQ',
+ '8': 'CACkXxst6JGfec1pdBW1N1z9DG9bbITMJ0ZO4u1nmfKuEwwyxewutucajVwirUThI4ZYjJMGfudnqwZAIs6FzoJ8XQna-IJw3bwORNXRliq4Lj3ZEM4',
+ '10': 'Ck8zfSTdZIpi7pucKuV4dt97zDz-blVk4IC3ou8dmvIHukVJwCepBPeByBalR2krxqDLHIfI86JJBauHIbkn6-20hGhV69DMaD11OahewlfqSEgdbjA',
+ '11': 'C0F3TPLnpGjtfA0XbDZ735Q9X5vw2P6Stcn44peZnBk8qzdxldGLTR5QBuRVOk7HWc8oWEdO9yc4wpULmfCvw5NeaA2dwAeZ-cgWnESvu5RSVUv3Ajs',
+ '13': 'DUxzFf148hjckC7uAcmOgRrGLV8N5Zku5afpggewFsSPDiKRzhE_rQoPTtjE6b42QgSag0RDu5wA-r9RobNg6CiCHeEaW_6krdjTkqNlxv_KO9rhUs8',
+ '14': 'Dparydjh1muUkxEDyywlAV2YIbaxLoJB2KZ8BqVubmPFQ4G9qtydpAcdD3eSzFHrU_npRN8UrLpLW13Bp56Pj70hbsRhw15ncxId104ZfiHZbmdWm2A',
+ '16': 'EN96MLAnjIdggkaBLUpdzLq2hArVtiLkkl8ih8UTrp-xGQlOQndnPMPUzlAbKGFKn2Rya0C7NmbFTKGWP5ZIwkt6ayT97aaZrp_K2XYSK4KvZnHp_CU',
+ '17': 'EUuC_-KVGtWqnV51NwOTPj-uRWoEMM_-YlqG7F9zblx2W3qFrKgEMzRpYNhyh_zqbCvuDR9CV9-b1Rj4OUPD1OtrHwcVEEpuiUcPVa2XA2ulupbPHdo',
+ '19': 'E_et0lNMOpI5k1FAI2LX85C5WbisN9cONnL99g-yDPm2IYGNZjQIygWxJ4gTQcxQGrfnce_JQjlY5nHs-OgJb2nunyqD2T9S46wghHU_9aBZ3p-U6pg',
+ '20': 'FFBZIC71_4Hn94rS-oJhOXA6y-yRFnW4ROtviSDYXLanaFEDrHthNFeglju0XnBEQLHB4kXXMsQYcwOn4Av88cfgEV7mQiLr5IAWavM0CQbkHsPYCk8',
+ };
+ it('decrypts existing, known good, boxes', async function () {
+ this.slow(Object.keys(testVectors).length * 200);
+ mb = new MysteryBox({ encryptionSecret });
+ for (const box of Object.values(testVectors)) {
+ const result = await mb.unpack(box);
+ assert.strictEqual(result, data);
+ }
+ });
+ }); // sanity check
+
}); // MysteryBox
\ No newline at end of file