hotp6.js

/**
  * @file MFCHF-HOTP6
  * @copyright Multifactor 2023
  * @license BSD-3-Clause-Clear
  *
  * @description
  * MFCHF with a password + 6-digit HOTP code
  *
  * @author Vivek Nair (https://nair.me) <[email protected]>
  */
const crypto = require('crypto')
const { argon2id } = require('hash-wasm')
const { hotp } = require('speakeasy')
const mod = (n, m) => ((n % m) + m) % m

/**
 * MFCHF-HOTP6
 * @namespace hotp6
 */

/**
 * Setup an MFCHF hash with a password and 6-digit HOTP factor
 *
 * @example
 * // Setup MFCHF-HOTP6 hash
 * const { hash, secret } = await mfchf.hotp6.setup('password123')
 *
 * // Verify MFCHF-HOTP6 hash
 * const otp = parseInt(hotp({ secret, counter: 1 }))
 * const result = await mfchf.hotp6.verify(hash, 'password123', otp)
 * result.valid.should.be.true
 *
 * @param {string} password - The password to use
 * @returns {MFCHFSetup} The resulting MFCHF hash and HOTP secret
 *
 * @author Vivek Nair (https://nair.me) <[email protected]>
 * @since 0.1.0
 * @memberof hotp6
 */
async function setup (password) {
  if (typeof password !== 'string') throw new TypeError('password must be a string')
  if (password.length === 0) throw new RangeError('password cannot be empty')

  const target = crypto.randomInt(10 ** 6)
  const hotpBuffer = Buffer.alloc(4)
  hotpBuffer.writeUInt32BE(target, 0)

  const passwordBuffer = Buffer.from(password, 'utf-8')
  const fullBuffer = Buffer.concat([hotpBuffer, passwordBuffer])

  let hash = 'mh6a2id'
  const salt = crypto.randomBytes(32)
  hash += ':' + salt.toString('base64')

  const key = Buffer.from(await argon2id({
    password: fullBuffer,
    salt,
    parallelism: 1,
    iterations: 2,
    memorySize: 32768,
    hashLength: 32,
    outputType: 'binary'
  }))
  const digest = crypto.createHash('sha256').update(key).digest('base64')
  hash += ':' + digest

  const secret = crypto.randomBytes(32)
  const code = parseInt(hotp({
    secret: secret.toString('hex'),
    counter: 1,
    digits: 6,
    encoding: 'hex',
    algorithm: 'sha1'
  }))

  const offset = mod(target - code, 10 ** 6)
  hash += ':1:' + offset.toString(10)

  const cipher = crypto.createCipheriv('aes-256-ecb', key, null)
  cipher.setAutoPadding(false)
  const blind = cipher.update(secret)
  hash += ':' + blind.toString('base64')

  return { hash, secret }
}

/**
 * Verify an MFCHF hash with a password and 6-digit HOTP code
 *
 * @example
 * // Setup MFCHF-HOTP6 hash
 * const { hash, secret } = await mfchf.hotp6.setup('password123')
 *
 * // Verify MFCHF-HOTP6 hash
 * const otp = parseInt(hotp({ secret, counter: 1 }))
 * const result = await mfchf.hotp6.verify(hash, 'password123', otp)
 * result.valid.should.be.true
 *
 * @param {string} hash - The MFCHF hash to verify
 * @param {string} password - The password to use
 * @param {number} code - The HOTP code to use
 * @returns {MFCHFVerify} The verification result
 *
 * @author Vivek Nair (https://nair.me) <[email protected]>
 * @since 0.1.0
 * @memberof hotp6
 */
async function verify (hash, password, code) {
  if (typeof hash !== 'string') throw new TypeError('hash must be a string')
  const parts = hash.split(':')
  const type = parts[0]
  if (type !== 'mh6a2id') throw new TypeError('hash type must be mh6a2id')
  if (typeof password !== 'string') throw new TypeError('password must be a string')
  if (password.length === 0) throw new RangeError('password cannot be empty')
  if (!Number.isInteger(code)) throw new TypeError('code must be an integer')

  let offset = parseInt(parts[4], 10)
  const target = mod(offset + code, 10 ** 6)
  const hotpBuffer = Buffer.alloc(4)
  hotpBuffer.writeUInt32BE(target, 0)

  const passwordBuffer = Buffer.from(password, 'utf-8')
  const fullBuffer = Buffer.concat([hotpBuffer, passwordBuffer])

  hash = 'mh6a2id'
  const salt = Buffer.from(parts[1], 'base64')
  hash += ':' + salt.toString('base64')
  const key = Buffer.from(await argon2id({
    password: fullBuffer,
    salt,
    parallelism: 1,
    iterations: 2,
    memorySize: 32768,
    hashLength: 32,
    outputType: 'binary'
  }))

  const digest = crypto.createHash('sha256').update(key).digest('base64')
  hash += ':' + digest
  if (digest !== parts[2]) return { valid: false }

  const blind = Buffer.from(parts[5], 'base64')

  const decipher = crypto.createDecipheriv('aes-256-ecb', key, null)
  decipher.setAutoPadding(false)
  const secret = decipher.update(blind)

  const counter = parseInt(parts[3], 10) + 1
  hash += ':' + counter.toString(10)

  code = parseInt(hotp({
    secret: secret.toString('hex'),
    counter,
    digits: 6,
    encoding: 'hex',
    algorithm: 'sha1'
  }))
  offset = mod(target - code, 10 ** 6)
  hash += ':' + offset.toString(10)
  hash += ':' + blind.toString('base64')

  return { valid: true, hash }
}

module.exports.hotp6 = { setup, verify }