setup/factors/totp.js

/**
 * @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