All files / src/setup/factors totp.js

100% Statements 52/52
100% Branches 32/32
100% Functions 4/4
100% Lines 36/36

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141                  1x 1x 1x 1x 1x     788400x                                                                               22x   22x 21x 20x 19x 18x 17x 16x 15x 14x 13x 12x 11x 11x 10x   9x 9x 9x   9x           9x   9x 9x   9x 788400x   788400x                 788400x   788400x     9x                     9x                                             1x  
/**
 * @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