/**
* @file MFKDF TOTP Factor Setup
* @copyright Multifactor 2022 All Rights Reserved
*
* @description
* Setup an TOTP factor for multi-factor key derivation
*
* @author Vivek Nair (https://nair.me) <[email protected]>
*/
const defaults = require('../../defaults')
const crypto = require('crypto')
const xor = require('buffer-xor')
const speakeasy = require('speakeasy')
const random = require('random-number-csprng')
function mod (n, m) {
return ((n % m) + m) % m
}
/**
* Setup an MFKDF TOTP factor
*
* @example
* // setup key with totp factor
* const setup = await mfkdf.setup.key([
* await mfkdf.setup.factors.totp({
* secret: Buffer.from('hello world'),
* time: 1650430806597
* })
* ], {size: 8})
*
* // derive key with totp factor
* const derive = await mfkdf.derive.key(setup.policy, {
* totp: mfkdf.derive.factors.totp(528258, { time: 1650430943604 })
* })
*
* setup.key.toString('hex') // -> 01d0c7236adf2516
* derive.key.toString('hex') // -> 01d0c7236adf2516
*
* @param {Object} [options] - Configuration options
* @param {string} [options.id='totp'] - Unique identifier for this factor
* @param {string} [options.hash='sha1'] - Hash algorithm to use; sha512, sha256, or sha1
* @param {number} [options.digits=6] - Number of digits to use
* @param {Buffer} [options.secret] - TOTP secret to use; randomly generated by default
* @param {Buffer} [options.issuer='MFKDF'] - OTPAuth issuer string
* @param {Buffer} [options.label='mfkdf.com'] - OTPAuth label string
* @param {number} [options.time] - Current time for TOTP; defaults to Date.now()
* @param {number} [options.window=87600] - Maximum window between logins, in number of steps (1 month by default)
* @param {number} [options.step=30] - TOTP step size
* @returns {MFKDFFactor} MFKDF factor information
* @author Vivek Nair (https://nair.me) <[email protected]>
* @since 0.13.0
* @async
* @memberof setup.factors
*/
async function totp (options) {
options = Object.assign(Object.assign({}, defaults.totp), options)
if (typeof options.id !== 'string') throw new TypeError('id must be a string')
if (options.id.length === 0) throw new RangeError('id cannot be empty')
if (!Number.isInteger(options.digits)) throw new TypeError('digits must be an interger')
if (options.digits < 6) throw new RangeError('digits must be at least 6')
if (options.digits > 8) throw new RangeError('digits must be at most 8')
if (!Number.isInteger(options.step)) throw new TypeError('step must be an interger')
if (options.step < 0) throw new RangeError('step must be positive')
if (!Number.isInteger(options.window)) throw new TypeError('window must be an interger')
if (options.window < 0) throw new RangeError('window must be positive')
if (!['sha1', 'sha256', 'sha512'].includes(options.hash)) throw new RangeError('unrecognized hash function')
if (!Buffer.isBuffer(options.secret) && typeof options.secret !== 'undefined') throw new TypeError('secret must be a buffer')
if (typeof options.time === 'undefined') options.time = Date.now()
if (!Number.isInteger(options.time)) throw new TypeError('time must be an integer')
if (options.time <= 0) throw new RangeError('time must be positive')
const target = await random(0, (10 ** options.digits) - 1)
const buffer = Buffer.allocUnsafe(4)
buffer.writeUInt32BE(target, 0)
return {
type: 'totp',
id: options.id,
data: buffer,
entropy: Math.log2(10 ** options.digits),
params: async ({ key }) => {
if (typeof options.secret === 'undefined') options.secret = crypto.randomBytes(Buffer.byteLength(key))
const time = options.time
const offsets = Buffer.allocUnsafe(4 * options.window)
for (let i = 0; i < options.window; i++) {
const counter = Math.floor(time / (options.step * 1000)) + i
const code = parseInt(speakeasy.totp({
secret: options.secret.toString('hex'),
encoding: 'hex',
step: options.step,
counter,
algorithm: options.hash,
digits: options.digits
}))
const offset = mod(target - code, 10 ** options.digits)
offsets.writeUInt32BE(offset, 4 * i)
}
return {
start: time,
hash: options.hash,
digits: options.digits,
step: options.step,
window: options.window,
pad: xor(options.secret, key.slice(0, Buffer.byteLength(options.secret))).toString('base64'),
offsets: offsets.toString('base64')
}
},
output: async () => {
return {
scheme: 'otpauth',
type: 'totp',
label: options.label,
secret: options.secret,
issuer: options.issuer,
algorithm: options.hash,
digits: options.digits,
period: options.step,
uri: speakeasy.otpauthURL({
secret: options.secret.toString('hex'),
encoding: 'hex',
label: options.label,
type: 'totp',
issuer: options.issuer,
algorithm: options.hash,
digits: options.digits,
period: options.step
})
}
}
}
}
module.exports.totp = totp