All files / src/setup/factors totp.js

100% Statements 58/58
100% Branches 34/34
100% Functions 4/4
100% Lines 50/50

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 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169                  1x 1x 1x 1x 1x     2452800x                                                                                 34x   34x 1x   33x 32x 1x   31x 30x 29x 1x   28x 27x 1x   26x 25x 1x   24x 12x   24x 1x   23x 1x   22x 22x 1x   21x   20x 20x 20x   20x   20x           20x 20x   20x 1752000x   1752000x                     1752000x   1752000x 700800x 700800x     1752000x     20x                     20x                                             1x  
/**
 * @file MFKDF TOTP Factor Setup
 * @copyright Multifactor, Inc. 2022–2025
 *
 * @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 speakeasy = require('speakeasy')
const { randomInt: random } = require('crypto')
const { encrypt } = require('../../crypt')
 
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('abcdefghijklmnopqrst'),
 *     time: 1650430806597
 *   })
 * ])
 *
 * // derive key with totp factor
 * const derive = await mfkdf.derive.key(setup.policy, {
 *   totp: mfkdf.derive.factors.totp(953265, { time: 1650430943604 })
 * })
 *
 * setup.key.toString('hex') // -> 01d0…2516
 * derive.key.toString('hex') // -> 01d0…2516
 *
 * @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
 * @param {Object} [options.oracle] - Timing oracle offsets to use; none by default
 * @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 (typeof options.secret === 'undefined') {
    options.secret = crypto.randomBytes(20)
  }
  if (!Buffer.isBuffer(options.secret)) {
    throw new TypeError('secret must be a buffer')
  }
  if (Buffer.byteLength(options.secret) !== 20) {
    throw new RangeError('secret must be 20 bytes')
  }
  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)
 
  const paddedSecret = Buffer.concat([options.secret, crypto.randomBytes(12)])
 
  return {
    type: 'totp',
    id: options.id,
    data: buffer,
    entropy: Math.log2(10 ** options.digits),
    params: async ({ 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: paddedSecret.subarray(0, 20).toString('hex'),
            encoding: 'hex',
            step: options.step,
            counter,
            algorithm: options.hash,
            digits: options.digits
          })
        )
 
        let offset = mod(target - code, 10 ** options.digits)
 
        if (options.oracle) {
          const time = counter * options.step * 1000
          offset = mod(offset + options.oracle[time], 10 ** options.digits)
        }
 
        offsets.writeUInt32BE(offset, 4 * i)
      }
 
      return {
        start: time,
        hash: options.hash,
        digits: options.digits,
        step: options.step,
        window: options.window,
        pad: encrypt(paddedSecret, key).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