/**
* @file Multi-factor Derived Key Setup
* @copyright Multifactor, Inc. 2022–2025
*
* @description
* Validate and setup a configuration for a multi-factor derived key
*
* @author Vivek Nair (https://nair.me) <[email protected]>
*/
const crypto = require('crypto')
const { v4: uuidv4 } = require('uuid')
const { hkdfSync } = require('crypto')
const share = require('../secrets/share').share
const { argon2id } = require('hash-wasm')
const MFKDFDerivedKey = require('../classes/MFKDFDerivedKey')
const { encrypt } = require('../crypt')
const { extract } = require('../integrity')
/**
* Validate and setup a configuration for a multi-factor derived key
*
* @example
* // setup 16 byte 2-of-3-factor multi-factor derived key with a password, HOTP code, and UUID recovery code
* const setup = await mfkdf.setup.key([
* await mfkdf.setup.factors.password('password'),
* await mfkdf.setup.factors.hotp({ secret: Buffer.from('abcdefghijklmnopqrst') }),
* await mfkdf.setup.factors.uuid({ id: 'recovery', uuid: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' })
* ], {threshold: 2})
*
* // derive key using 2 of the 3 factors
* const derive = await mfkdf.derive.key(setup.policy, {
* password: mfkdf.derive.factors.password('password'),
* hotp: mfkdf.derive.factors.hotp(241063)
* })
*
* setup.key.toString('hex') // -> 34d2…5771
* derive.key.toString('hex') // -> 34d2…5771
*
* @param {Array.<MFKDFFactor>} factors - Array of factors used to derive this key
* @param {Object} [options] - Configuration options
* @param {string} [options.id] - Unique identifier for this key; random UUIDv4 generated by default
* @param {number} [options.threshold] - Number of factors required to derive key; factors.length by default (all required)
* @param {Buffer} [options.salt] - Cryptographic salt; generated via secure PRG by default (recommended)
* @param {Buffer} [options.integrity=true] - Whether to sign the resulting key policy (recommended)
* @param {number} [options.time] - Additional rounds of argon2 time cost to add; 0 by default
* @param {number} [options.memory] - Additional argon2 memory cost to add (in KiB); 0 by default
* @returns {MFKDFDerivedKey} A multi-factor derived key object
* @author Vivek Nair (https://nair.me) <[email protected]>
* @since 0.8.0
* @async
* @memberOf setup
*/
async function key (factors, options) {
if (!Array.isArray(factors)) throw new TypeError('factors must be an array')
if (factors.length === 0) throw new RangeError('factors must not be empty')
options = Object.assign({}, options)
const policy = {
$schema: 'https://mfkdf.com/schema/v1.0.0/policy.json'
}
// id
if (options.id === undefined) options.id = uuidv4()
if (typeof options.id !== 'string') {
throw new TypeError('id must be a string')
}
if (options.id.length === 0) throw new RangeError('id must not be empty')
policy.$id = options.id
// threshold
if (options.threshold === undefined) options.threshold = factors.length
if (!Number.isInteger(options.threshold)) {
throw new TypeError('threshold must be an integer')
}
if (!(options.threshold > 0)) {
throw new RangeError('threshold must be positive')
}
if (!(options.threshold <= factors.length)) {
throw new RangeError('threshold cannot be greater than number of factors')
}
policy.threshold = options.threshold
// salt
if (options.salt === undefined) {
options.salt = crypto.randomBytes(32)
}
if (!Buffer.isBuffer(options.salt)) {
throw new TypeError('salt must be a buffer')
}
policy.salt = options.salt.toString('base64')
// time
if (options.time === undefined) {
options.time = 0
}
if (!Number.isInteger(options.time)) {
throw new TypeError('time must be an integer')
}
if (options.time < 0) {
throw new RangeError('time must be non-negative')
}
policy.time = options.time
// memory
if (options.memory === undefined) {
options.memory = 0
}
if (!Number.isInteger(options.memory)) {
throw new TypeError('memory must be an integer')
}
if (options.memory < 0) {
throw new RangeError('memory must be non-negative')
}
policy.memory = options.memory
// check factor correctness
for (const factor of factors) {
// type
if (typeof factor.type !== 'string') {
throw new TypeError('factor type must be a string')
}
if (factor.type.length === 0) {
throw new RangeError('factor type must not be empty')
}
// id
if (typeof factor.id !== 'string') {
throw new TypeError('factor id must be a string')
}
if (factor.id.length === 0) {
throw new RangeError('factor id must not be empty')
}
// data
if (!Buffer.isBuffer(factor.data)) {
throw new TypeError('factor data must be a buffer')
}
if (factor.data.length === 0) {
throw new RangeError('factor data must not be empty')
}
// params
if (typeof factor.params !== 'function') {
throw new TypeError('factor params must be a function')
}
}
// id uniqueness
const ids = factors.map((factor) => factor.id)
if (new Set(ids).size !== ids.length) {
throw new RangeError('factor ids must be unique')
}
// generate secret key material
const secret = crypto.randomBytes(32)
const key = crypto.randomBytes(32)
let kek
if (options.stack) {
kek = Buffer.from(
hkdfSync(
'sha256',
secret,
Buffer.from(policy.salt, 'base64'),
'mfkdf2:stack:' + policy.$id,
32
)
)
} else {
kek = Buffer.from(
await argon2id({
password: secret,
salt: Buffer.from(policy.salt, 'base64'),
hashLength: 32,
parallelism: 1,
iterations: 2 + Math.max(0, options.time),
memorySize: 19456 + Math.max(0, options.memory),
outputType: 'binary'
})
)
}
policy.key = encrypt(key, kek).toString('base64')
const shares = share(secret, policy.threshold, factors.length)
// process factors
policy.factors = []
const outputs = {}
const theoreticalEntropy = []
const realEntropy = []
for (const [index, factor] of factors.entries()) {
// stretch to key length via HKDF/SHA-512
const share = shares[index]
theoreticalEntropy.push(factor.data.byteLength * 8)
realEntropy.push(factor.entropy)
const salt = crypto.randomBytes(32)
const stretched = Buffer.from(
hkdfSync(
'sha256',
factor.data,
salt,
'mfkdf2:factor:pad:' + factor.id,
32
)
)
const pad = encrypt(share, stretched)
const paramsKey = Buffer.from(
hkdfSync('sha256', key, salt, 'mfkdf2:factor:params:' + factor.id, 32)
)
const params = await factor.params({ key: paramsKey })
outputs[factor.id] = await factor.output()
const secretKey = Buffer.from(
hkdfSync('sha256', key, salt, 'mfkdf2:factor:secret:' + factor.id, 32)
)
policy.factors.push({
id: factor.id,
type: factor.type,
pad: pad.toString('base64'),
secret: encrypt(stretched, secretKey).toString('base64'),
params,
salt: salt.toString('base64')
})
}
if (options.integrity !== false) {
const integrityData = await extract(policy)
const integrityKey = hkdfSync(
'sha256',
key,
Buffer.from(policy.salt, 'base64'),
'mfkdf2:integrity',
32
)
const hmac = crypto.createHmac('sha256', integrityKey)
hmac.update(integrityData)
policy.hmac = hmac.digest('base64')
}
const result = new MFKDFDerivedKey(policy, key, secret, shares, outputs)
theoreticalEntropy.sort((a, b) => a - b)
const theoretical = theoreticalEntropy
.slice(0, policy.threshold)
.reduce((a, b) => a + b, 0)
realEntropy.sort((a, b) => a - b)
const real = realEntropy
.slice(0, policy.threshold)
.reduce((a, b) => a + b, 0)
result.entropyBits = {
theoretical: Math.min(256, theoretical),
real: Math.min(256, real)
}
return result
}
module.exports.key = key