derive/factors/totp.js

/**
 * @file MFKDF TOTP Factor Derivation
 * @copyright Multifactor 2022 All Rights Reserved
 *
 * @description
 * Derive TOTP factor for multi-factor key derivation
 *
 * @author Vivek Nair (https://nair.me) <[email protected]>
 */
const xor = require('buffer-xor')
const speakeasy = require('speakeasy')

function mod (n, m) {
  return ((n % m) + m) % m
}

/**
 * Derive 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 {number} code - The TOTP code from which to derive an MFKDF factor
 * @param {Object} [options] - Additional options for deriving the TOTP factor
 * @param {number} [options.time] - Current time for TOTP; defaults to Date.now()
 * @returns {function(config:Object): Promise<MFKDFFactor>} Async function to generate MFKDF factor information
 * @author Vivek Nair (https://nair.me) <[email protected]>
 * @since 0.13.0
 * @memberof derive.factors
 */
function totp (code, options = {}) {
  if (!Number.isInteger(code)) throw new TypeError('code must be an integer')
  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')

  return async (params) => {
    const offsets = Buffer.from(params.offsets, 'base64')
    const startCounter = Math.floor(params.start / (params.step * 1000))
    const nowCounter = Math.floor(options.time / (params.step * 1000))

    const index = nowCounter - startCounter

    if (index >= params.window) throw new RangeError('TOTP window exceeded')

    const offset = offsets.readUInt32BE(4 * index)

    const target = mod(offset + code, 10 ** params.digits)
    const buffer = Buffer.allocUnsafe(4)
    buffer.writeUInt32BE(target, 0)

    return {
      type: 'totp',
      data: buffer,
      params: async ({ key }) => {
        const pad = Buffer.from(params.pad, 'base64')
        const secret = xor(pad, key.slice(0, Buffer.byteLength(pad)))

        const time = options.time
        const newOffsets = Buffer.allocUnsafe(4 * params.window)

        offsets.copy(newOffsets, 0, 4 * index)

        for (let i = params.window - index; i < params.window; i++) {
          const counter = Math.floor(time / (params.step * 1000)) + i

          const code = parseInt(speakeasy.totp({
            secret: secret.toString('hex'),
            encoding: 'hex',
            step: params.step,
            counter,
            algorithm: params.hash,
            digits: params.digits
          }))

          const offset = mod(target - code, 10 ** params.digits)

          newOffsets.writeUInt32BE(offset, 4 * i)
        }

        return {
          start: time,
          hash: params.hash,
          digits: params.digits,
          step: params.step,
          window: params.window,
          pad: params.pad,
          offsets: newOffsets.toString('base64')
        }
      },
      output: async () => {
        return { }
      }
    }
  }
}
module.exports.totp = totp