HOTP 与 TOTP 详解以及 TypeScript 实战分析

Webotphotptotpotpauthhmacauthenticator
浏览数 - 232发布于 - 2025-05-29 - 10:37
鲲

5716

目标

我们接下来要实现一下 TouchGal 网站的 2FA 功能,所以需要一点 HOTP 和 TOTP 的基础,所以下面来详细解释一下这两个东东。

只是解释有一点过于苍白,我们还会结合 https://github.com/PlanetHoster/time2fa 这个库来实战解释一下这两个东东是怎么在实际应用中实现的。

首先需要明确,HOTP 和 TOTP 这两个东东都属于一次性密码 (OTP,即 One-Time Password) 算法。它们都是开放标准,广泛用于双因素认证 (2FA) 或多因素认证 (MFA)。

OTP 的核心思想是生成一个仅在短时间内或仅使用一次后就失效的密码。这大大增强了安全性,因为即使密码被截获,攻击者也无法在稍后使用它。

HOTP 和 TOTP 都是基于对称加密 HMAC (Hash-based Message Authentication Code) 的,用户的认证器(Authenticator)和服务器(Server)都需要一个共同的密钥来按照某种 Hash 算法(例如 SHA1,RFC 4226 推荐 SHA1,但现在更推荐使用 SHA256 或 SHA512)计算出一个 Hash 然后比较两者是否相同,以此来认证用户。

对称加密这里就不展开了,这不是本文的目的。

HOTP (HMAC-based One-Time Password)

HOTP 是基于事件(计数器)的 OTP 算法。核心原理是每次请求或使用密码时,一个叫计数器的东东会递增,从而生成一个新的密码。

下面的基础知识部分感谢我们热心的鸡米妮 (啊,这其实是 Gemini) 同学进行提供

基础知识

  1. 共享密钥 (Shared Secret Key, K):

    • 这是服务器和客户端(例如,用户的手机认证应用)之间共享的一个秘密字符串。
    • 这个密钥在初始设置时生成,并且必须保密。通常是160位 (20字节) 或更长。
    • 生成方式:通常是随机生成的。
    • 分发方式:通过安全通道(例如,扫描二维码,手动输入)从服务器传递给客户端。
  2. 计数器 (Counter, C):

    • 一个单调递增的整数值(通常是64位)。
    • 客户端和服务器都必须维护和同步这个计数器。
    • 每次生成 OTP 时,客户端会使用当前的计数器值。
    • 服务器在验证 OTP 后,会更新其存储的计数器值,以匹配客户端下次将使用的计数器。
  3. HMAC 函数:

    • HOTP 使用 HMAC 算法(例如 HMAC-SHA1, HMAC-SHA256, HMAC-SHA512)。
    • HMAC 函数以共享密钥 K 和计数器 C 作为输入,生成一个哈希值。 HS = HMAC(K, C) (HS 是一个较长的十六进制字符串)
  4. 动态截断 (Dynamic Truncation):

    • HMAC 的输出通常是一个较长的十六进制字符串(例如,SHA1 输出20字节)。

    • 因此,需要一个截断函数将这个长字符串转换为一个较短的、用户友好的数字码(通常是6位或8位)。

    • 截断步骤: a. 取 HS 的最后4位 (last nibble/half-byte)。这个值(0-15)作为偏移量 (offset)。 b. 从 HSoffset 字节开始,取4个字节。 c. 将这4个字节视为一个32位的大端整数。 d. 清除这个32位整数的最高位(将其与 0x7FFFFFFF 进行按位与操作),确保结果为正数。 e. 将结果对 10^D 取模 (D 是期望的数字位数,例如 10^6 表示6位数字)。 f. 如果结果位数不足D位,则在前面补零。

一个简单的示例

鸡米妮同学讲动态截断有点抽象,我们举个栗子吧。

我们假设 HMAC-SHA1(K, C) 产生 1f8698690e02ca16618550ef7f19da8e945b555a (20字节),这个码给用户让用户自己输的话会被产品打死的,用户无法方便地输入这么长的字符串。

所以我们需要从这一长串字母中拿出一部分来,方便用户输入的同时也能进行认证。所以我们可以按照下面的步骤进行截断

  1. 最后半字节是 0xa (十进制 10)。所以 offset = 10

  2. 从第10个字节开始取4字节 (索引从0开始): HS[10]...HS[13] => 50ef7f19 (十六进制)

  3. 转换为32位整数: 0x50ef7f19 => 1357872921 (十进制)

  4. 清除最高位 (已经是正数,此步无变化)

  5. 假设需要6位数字: 1357872921 % 1000000 => 872921

  6. OTP = 872921

使用 TypeScript 来实现上面的步骤大概是这样的

  const generatePasscode = (options: HotpCode, config: ValidTotpConfig) => {
    const secretBytes = Buffer.from(Decode32(options.secret));

    if (secretBytes.length !== config.secretSize) {
      throw new Error('INVALID_SECRET_ERR');
    }

    const buf = Buffer.alloc(8);
    buf.writeUInt32BE(options.counter, 4);
    const hmac = crypto.createHmac(config.algo, secretBytes);
    hmac.update(buf);
    const hmacResult = hmac.digest();

    // https://www.rfc-editor.org/rfc/rfc4226#section-5.4
    const offset = hmacResult[hmacResult.length - 1] & 0xf;
    const value =
      ((hmacResult[offset] & 0x7f) << 24) |
      ((hmacResult[offset + 1] & 0xff) << 16) |
      ((hmacResult[offset + 2] & 0xff) << 8) |
      (hmacResult[offset + 3] & 0xff);

    const mod = value % Math.pow(10, config.digits);

    return mod.toString().padStart(config.digits, "0");
  }

这个东东跑出来的结果就是一个 6 位的数字,这次应该熟悉了吧,很多地方的 2FA 都是输入 6 位数字进行 2FA (比如我们的大善人 Cloudflare),这个数字就是这么生成的。

上面的 RFC 4426 示意图可以看一下

   SHA-1 HMAC Bytes (Example)

   -------------------------------------------------------------
   | Byte Number                                               |
   -------------------------------------------------------------
   |00|01|02|03|04|05|06|07|08|09|10|11|12|13|14|15|16|17|18|19|
   -------------------------------------------------------------
   | Byte Value                                                |
   -------------------------------------------------------------
   |1f|86|98|69|0e|02|ca|16|61|85|50|ef|7f|19|da|8e|94|5b|55|5a|
   -------------------------------***********----------------++|

HOTP 工作流程

HOTP 很不靠谱,HOTP 的核心是基于一个计数器的(上面鸡米妮同学提到了),客户端和服务器都必须维护和同步这个计数器,如果用户在客户端生成了 OTP 但未使用,客户端计数器会增加,而服务器计数器不变,导致失步。

服务器通常会实现一个“前瞻窗口”(look-ahead window),检查未来几个计数器的 OTP,以缓解这个问题。但如果失步太大,就需要重新同步。

因此我们接下来实现 2FA 的时候肯定是用 TOTP,下面的工作流程由鸡米妮同学提供,仅供理论上的参考。

  1. 设置阶段:

    • 服务器生成一个唯一的共享密钥 K 和初始计数器值 C (通常为0或1)。
    • 服务器将 KC 安全地提供给用户的认证设备/应用。
  2. OTP 生成 (客户端):

    • 用户请求 OTP。
    • 客户端使用其存储的 K 和当前的 C,通过 HMAC 和截断函数生成 OTP。
    • 客户端显示 OTP,并将内部计数器 C 增1。
  3. OTP 验证 (服务器):

    • 用户将客户端生成的 OTP 输入到服务器。
    • 服务器使用其存储的 K 和期望的计数器值 C (以及可能的一个小窗口内的后续计数值,以处理同步问题) 来生成一个或多个 OTP。
    • 服务器将用户提交的 OTP 与自己生成的 OTP(s) 进行比较。
    • 如果匹配:
      • 认证成功。
      • 服务器将其存储的计数器 C 更新为匹配的那个计数器值(确保与客户端同步)。
    • 如果不匹配:认证失败。

TOTP (Time-based One-Time Password)

上面我们着重提到了 HOTP 的失步问题,而 TOTP 就是用来解决这个问题的。

TOTP 是 HOTP 的一个改进,它使用时间而不是计数器作为动态因素(Moving Factor)。这是目前最广泛使用的 OTP 算法(例如 Google Authenticator, Microsoft Authenticator 等应用都支持 TOTP)。

TOTP 基本上是 HOTP 的一个特例,它将 HOTP 中的计数器 C 替换为一个基于当前时间的离散值,用 TypeScript 来描述是这样的

const epoch = Math.floor(Date.now() / 1000);
const counter = Math.floor(epoch / (config.period || DEFAULT_TOTP_PERIOD));

这里我们就得到了一个基于时间的计数器,那么我们可以反过来看,刚才 HOTP 困扰我们的是什么,是计数器同步!

那么什么是永恒同步的呢?是时间!对了孩子们,到了这里我们的一切问题都解决喽~

基本知识

下面由鸡米妮同学为我们讲解 TOTP 的基本知识,这其实和 HOTP 没什么太大的不同(除了计数器)

  1. 共享密钥 (Shared Secret Key, K): 与 HOTP 中的 K 完全相同。
  2. 时间步长 (Time Step, X): 定义了 OTP 的有效期限,通常是30秒或60秒。RFC 6238 默认是30秒。例如,如果 X=30秒,则每30秒会生成一个新的 OTP。
  3. 初始时间 (T0): 一个参考时间戳,通常是 Unix 纪元时间 (1970年1月1日 00:00:00 UTC)。默认 T0 = 0
  4. 当前时间 (T): 当前的 Unix 时间戳 (从 T0 开始计数的秒数)。
  5. 计算时间计数器 (C_T): TOTP 的核心是将当前时间转换为一个离散的整数值,这个值在每个时间步长内保持不变。这个值就充当了 HOTP 中的计数器 CC_T = floor((Current Unix Time - T0) / X) (floor 表示向下取整)。
  6. HMAC 函数: 与 HOTP 相同,使用共享密钥 K 和计算出的时间计数器 C_THS = HMAC(K, C_T)
  7. 动态截断: 与 HOTP 的截断过程完全相同,将 HS 转换为6位或8位数字码。

工作流程

工作流程是下一篇文章给 TouchGal 对接 2FA 要讲的,这里仅由鸡米妮同学简单提一句

  1. 设置阶段:
    • 服务器生成一个唯一的共享密钥 K
    • 服务器将 K (以及可选的 XT0,如果不是默认值) 安全地提供给用户的认证设备/应用。
  2. OTP 生成 (客户端):
    • 客户端获取当前 Unix 时间 T
    • 使用 T, T0, X 计算出当前的时间计数器 C_T
    • 使用 KC_T,通过 HMAC 和截断函数生成 OTP。
    • 客户端显示 OTP。它会自动在下一个时间步长开始时刷新。
  3. OTP 验证 (服务器):
    • 用户将客户端显示的 OTP 输入到服务器。
    • 服务器获取当前 Unix 时间,并计算出其认为的当前时间计数器 C_T_server
    • 时间窗口容错: 由于客户端和服务器之间可能存在轻微的时钟漂移 (clock drift) 或网络延迟,服务器通常不仅会验证基于 C_T_server 生成的 OTP,还会验证基于前一个时间步长 (C_T_server - 1) 和后一个时间步长 (C_T_server + 1) 生成的 OTP。这提供了一个容错窗口(例如,如果 X=30秒,则容错窗口可以是 -30秒 到 +30秒)。
    • 服务器使用 K 和这些时间计数器值(例如 C_T_server-1, C_T_server, C_T_server+1)生成对应的 OTPs。
    • 服务器将用户提交的 OTP 与自己生成的这些 OTPs 进行比较。
    • 如果匹配:认证成功。
    • 如果不匹配:认证失败。

时间窗口容错

有时候网站 2FA 时,我们输入完验证码发现验证码立马变了,但是这个时候点击登录仍然可以正确进行 2FA,这就是时间窗口容错带来的好处(我所能体验到的最大的好处,因为眼睁睁看着验证码过期了又得重新输一个,就会让我很难受!),原理是这样的

  • 你输入的验证码属于刚刚过去的那个时间窗口
  • 服务器在验证时,会检查当前时间窗口以及前后(取决于 drift 设置)的一个或多个时间窗口内生成的验证码。
  • 由于你输入的验证码在服务器的容错窗口内,所以即使认证应用上已经显示了新的验证码,你的登录依然能够成功。

这大大提升了用户体验,避免了因为微小的时间差异或操作延迟导致的频繁登录失败。

使用 TypeScript 来实现大概是这样的

const generatePasscodes = (options: TotpCode, config: ValidTotpConfig) => {
  const epoch = Math.floor(Date.now() / 1000);
  const counter = Math.floor(epoch / (config.period || DEFAULT_TOTP_PERIOD));

  const counters = [counter]; // 1. 首先包含当前时间窗口的计数器
  if (options.drift && options.drift > 0) { // 2. 如果提供了 drift 值
    for (let i = 1; i <= options.drift; i++) {
      counters.push(counter + i); // 3. 添加未来时间窗口的计数器
      counters.push(counter - i); // 4. 添加过去时间窗口的计数器
    }
  }

  const codes: string[] = [];
  const hmac = new HmacBased();

  for (let i = 0; i < counters.length; i++) {
    codes.push(
      hmac.generatePasscode( // 5. 为所有这些计数器生成密码
        {
          secret: options.secret,
          counter: counters[i],
        },
        config
      )
    );
  }

  return codes;
}

然后在验证时,我们将生成的这些 codes 全部与用户提供的 passcode 进行比较,只要用户提供的 passcode 有一个在 codes 这个数组中,那么即可验证成功

如果想让用户有更多的容错时间,只需要增加 drift 的值即可,下面是一个 TypeScript 实现的验证函数,以此来验证用户是否通过认证

  const validate = (options: TotpValidateOptions, config?: TotpConfig) => {
    const validatedConfig = generateConfig(config);

    const passcode = options?.passcode.replace(/\s/g, "") || "";
    if (passcode.length !== validatedConfig.digits) {
      throw new Error("Invalid passcode");
    }

    const codes = this.generatePasscodes(options, validatedConfig);

    if (codes.includes(passcode)) {
      return true;
    }

    return false;
  }

总结

下面画一个图图来总结一下下 TOTP 究竟在做什么东东,叠词词恶心心

1.png这个绘图可以在 https://excalidraw.com/#json=ZGunAF4EiEuqVVGKj4m9u,8cc8E6XTpjbb9l3okwa4GQ 查看

通用安全注意事项

下面由我们今天的榜样鸡米妮同学总结一下安全事项

  1. 共享密钥的安全性: K 是整个系统的核心。它必须在生成、传输(例如通过 otpauth:// URI 中的 QR 码)和存储(服务器端和客户端)过程中都得到妥善保护。如果 K 泄露,则 OTP 机制失效。
  2. otpauth:// URI:
    • 这是一种标准化的 URI 格式,用于方便地将 OTP 参数(密钥、类型、发行者、账户名、算法、位数、周期/计数器)配置到认证应用中。
    • 示例: otpauth://totp/Example:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30
  3. 防暴力破解: 服务器端必须实现速率限制,以防止攻击者尝试大量猜测 OTP。通常在几次失败尝试后会锁定账户或增加延迟。
  4. 传输安全: 用户提交的 OTP(以及用户名、密码)应该通过 HTTPS 等安全通道传输。
  5. 备份和恢复: 为用户提供安全的备份码或恢复机制,以防他们丢失认证设备。

这里的备份和回复接下来会用到,otpauth:// URI 在实践中被我们做成一个二维码,这也是常规的方式(我不理解为什么一定要扫这个二维码,复制这个东东再粘贴进 Authenticator 不是很好吗,简直是现代互联网给用户惯的,怕用户看着这个 URI 觉得吓人,哎)

上面的代码实现摘自 time2fa 这个库,库的实现也比较干净利落,我们接下来的项目实现默认调用了这个包(嗯,我是调包侠,我懒得自己写,来打我鸭)

我们接下来的文章要讲解如何给网站对接 2FA 了,需要这些基础知识铺垫,嗯,就是这样!

重新编辑于 - 2025-05-30 - 08:56

#1

<p>
错误的,因为软件可以在用户不知情的情况下读取剪切板,输入法真的是可信任的吗?<br>
而且扫码可以直接填写完成账号和用户名,相比之下用二维码扫描安全方便的多的说。
</p>
并且暴力破解是几乎不可能的,因为TOTP每30秒刷新一次。输入错误3次之后就可以执行人机验证。<br>
<p>
顺 便 一 提,其实那个所谓的安全密钥本质上和TOTP差不多()<br>
其实HTTPS并非绝对可靠,因为历史上存在多次根证书滥用事件<https://en.wikipedia.org/wiki/Root_certificate#Incidents_of_root_certificate_misuse>
</p>
<p>
MFA这个东西超级奇怪的说...用户名、邮箱、密码、TOTP、甚至是安全密钥都可以被保存在密码管理器中...<br>
如果把这些东西全保存在密码管理器中,那么MFA的意义是什么呢...
</p>
<p>
所以其实在几个星期以前我的TOTP都是用7z上压缩密码独立保存的,最近才对无用账号大量注销与禁用MFA。就剩下3个被强制执行MFA的账号了。<br>
至少我的建议是鸡蛋放在不同的篮子中,尽量不要把MFA保存在密码管理器中。<br>
</p>
<p><del>编辑内容:markdown果然没有我直接写HTML用的方便()</del>。其实主要是纯文本用习惯了,搞不明白markdown要怎么搞才能只有一个换行(而不是两个)
</p>
```` html
2025-05-29 - 11:45 (已编辑于 2025-05-29 - 11:55)

评论

鲲

> 其实主要是纯文本用习惯了,搞不明白markdown要怎么搞才能只有一个换行(而不是两个) 这个弄不懂的话就不要折腾你上面说的那些东东了

Ashiroid
Ashiroid评论MarkForDeletion

这位兄弟,请问你是科班出来的吗?好像没学过markdown,但会敲html的样子? 在markdown里面,```{content}```的格式相当于<code>{content}</code>,并不会以代码原本的功能渲染或运行,这种格式本身被设计出来就是用于相反的目的——为了显示源代码。所以不需要加上那么一堆html tag,没用的,而且首尾是3个标点 (```)。在```同一行标注语言类型(如html)只是用来上个色,而且仅限于首部,放最后面是没用的 还有markdown是有<del>{content}</del>的,格式是~~{content}~~ 最后,安全性这一块,目前也只是相对安全而已。我认为倘若输入法不可信的话,2FA软件,手机系统及摄像机API,包括浏览器本身,等等,都会存在潜在的泄密风险。(以上环节不讨论某些国产流氓,理想情况应以完全开源的组件为参考) 在这种情况下可以将OTA和客户端identity绑定,并可以尝试在黏贴完毕后自动清除剪贴板,总之以现有技术而言这些尚处于可以接受的范围

kohaku