基于时间的一次性密码TOTP算法与实现

发布于 16 天前  70 次阅读


基于时间的一次性密码(TOTP)一般用于两步验证中,通过生成动态的6位或者8位的数字密码来增强安全性。这里将结合RFC 6238文档学习TOTP的算法原理并使用JavaScript进行实现。

TOTP的算法

根据RFC 6238,使用HMAC-SHA-1HMAC-SHA-256HMAC-SHA-512算法生成一次性密码。

核心步骤如下:

  1. 共享密钥:用户和服务器之间共享一个密钥,该密钥对外保密,是生成TOTP的基础。通常情况下,这个密钥会经过base32编码,然后去掉填充的=

  2. 时间步长:将当前时间戳按设定的时间步长(通常为30秒)分割。时间步长用于计算时间窗口索引。

  3. 计算时间窗口索引:使用公式T = floor((currentTime - T0) / X),其中currentTime是当前时间戳,T0是起始时间(通常为Unix纪元),X是时间步长。T表示时间窗口索引。

  4. 生成哈希值:使用HMAC-SHA算法,将密钥和时间窗口索引作为输入,生成一个哈希值。

  5. 截取动态密码:从哈希值中截取一部分作为一次性密码。

  6. 密码验证:服务器根据密钥和当前时间戳生成对应的TOTP,并与用户输入的密码进行对比。如果匹配,则验证通过。

JavaScript实现TOTP

以下是一个在JavaScript中实现TOTP的示例代码:

const crypto = require('crypto')

const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const base32Lookup = {}
for (let i = 0; i < base32Chars.length; i++) {
  base32Lookup[base32Chars[i]] = i
}

function base32Decode(secret) {
  let bits = []
  for (let i = 0; i < secret.length; i++) {
    const value = base32Lookup[secret[i].toUpperCase()]
    bits.push(value.toString(2).padStart(5, '0'))
  }
  bits = bits.join('')

  const bytes = []
  for (let i = 0; i + 8 <= bits.length; i += 8) {
    bytes.push(parseInt(bits.slice(i, i + 8), 2))
  }
  return Buffer.from(bytes)
}

function generateTOTP(secret, window = 0, digits = 6, algorithm = 'sha1', period = 30) {
  const key = base32Decode(secret)
  const epoch = Math.floor(Date.now() / 1000)
  const timeWindow = Math.floor(epoch / period) + window

  const timeBuffer = Buffer.alloc(8)
  timeBuffer.writeUInt32BE(0, 0)
  timeBuffer.writeUInt32BE(timeWindow, 4)

  const hmac = crypto.createHmac(algorithm, key)
  hmac.update(timeBuffer)
  const hmacResult = hmac.digest()

  const offset = hmacResult[hmacResult.length - 1] & 0x0f
  const binary =
    ((hmacResult[offset] & 0x7f) << 24) |
    ((hmacResult[offset + 1] & 0xff) << 16) |
    ((hmacResult[offset + 2] & 0xff) << 8) |
    (hmacResult[offset + 3] & 0xff)

  const otp = binary % Math.pow(10, digits)
  return otp.toString().padStart(digits, '0')
}

const secret = '36NFPP3U'
const totp = generateTOTP(secret)
console.log(`TOTP: ${totp}`)

代码示例说明

下面对代码实现的几个关键部分进行说明:

  1. base32Decode:这个函数用于将Base32编码的密钥解码为二进制数据,对于Base32的编解码规则这里不做赘述,有兴趣的请自行搜索。

36NFPP3U为例,经过解码后,获得的二进制数据是1101111110011010010101111011111101110100

  1. generateTOTP:这个函数接受五个参数:密钥(secret)、时间窗口偏移(window)、密码位数(digits)、哈希算法(algorithm)和时间步长(period)。

在计算时间窗口时,使用Math.floor(Date.now() / 1000)获取当前的时间戳(以秒为单位),Math.floor(epoch / period)将时间戳按设定的步长分割,计算出时间窗口索引。

再使用HMAC-SHA算法,结合密钥和时间窗口索引生成哈希值。再获取到哈希值的二进制数据,比如这里计算后的二进制数据是0111011111100000101010101101001100100110000010101111111110111101111010111010001101001101011110101001110110100110010010101110000011110001101010011110111111101100,一共160位。

显然这个长度太长了,我们需要截断,截断的算法十分精巧,首先我们将这串数据与0x0f相与,获取最后4位,结果是1100,这就是我们的起始偏移量,十进制为12

从刚才的160位二进制的第97位开始,与0x7f相与,将首位变为0,保证是正数,然后左移24位作为最高的一个字节,也就是00011101。然后依次取第11、12和13个字节,作为第2、3、4个字节,分别是101001100100101011100000,合并之后得到二进制数00011101101001100100101011100000,换算为10进制是497437408,一般我们取后6位,这样就得到了437408作为密码。