揭秘6位验证码:从零构建 HOTP 和 TOTP
Behind the 6-digit code: Building HOTP and TOTP from scratch
Programming • 2025年4月10日
不久前,我开始在工作中负责授权和认证相关的工作。这让我对现代认证系统的工作原理有了很多了解。然而,我一直认为一次性密码登录是最神秘的。一个每次都会变化的六位数字代码,可以用来验证你的身份。服务器如何知道新生成的代码?它又是如何保证安全的?在这篇文章中,我将通过分享我从零开始的实现,来解释什么是 HOTP、TOTP 以及它们是如何工作的。
一个OTP登录代码示例
什么是 OTP?
一次性密码 (OTP) 是一种广泛使用的身份验证形式。当使用像 Google Authenticator 这样的“安全登录”应用,或者在“忘记密码”流程中收到发送到你的电子邮件或手机的临时代码时,你可能已经遇到过它们。
与传统密码不同,OTP 仅对一次使用或有限的时间窗口有效。这大大降低了密码重放攻击的风险,在这种攻击中,有人捕获用于登录的密码并试图重复使用它。
密码可以重复使用。一旦泄露,恶意行为者可以冒充用户并访问关键信息。
与传统的密码认证方法一样,用户和授权方(服务器)仍然需要就一个共同的密钥达成一致。在常规密码认证期间,此密钥直接传递给授权方。有很多方法可以安全地执行此过程,例如对密码进行哈希处理或通过加密网络发送它。然而,风险仍然存在,因为密码本身永远不会改变,只要我们使用设备来输入我们的密码,恶意行为者就可以在它到达网络之前观看并获取该信息。
因此,我们可以使用随时间变化的动态密钥,而不是使用恒定的密钥。举一个简单的例子,假设当两个人第一次见面时,他们将秘密隐藏的时钟同步到一个随机时间。
使用秘密时钟作为基本的 OTP 实现
同样在某些示例中,例如 Facebook 的密码恢复,此秘密时钟不会直接与用户共享,而是通过受信任的媒介(例如电子邮件)将服务器生成的一次性密码发送给用户。
显然,仅靠时钟本身并不安全,因为在本例中,Plankton 可能会根据实际时间预测秘密时钟的时间偏移。然而,为了这个例子,我想展示复制“密码”本身是不够的。让我们看看一些构建这个“秘密时钟”的策略,并确保仅通过知道某个时间点的单个代码无法预测时间。
有两种常见的 OTP 算法类型:
- HOTP (HMAC-based One-Time Password) – 基于每次请求 OTP 时递增的计数器。
- TOTP (Time-based One-Time Password) – 基于当前时间,通常使用 30 秒的间隔。
这些方法在 RFC 4226 (针对 HOTP) 和 RFC 6238 (针对 TOTP) 中进行了标准化,并在许多现代 2FA(双因素身份验证)实现中使用。
基于计数器的密码方法更容易理解。想象一下,两个人见面并生成了一系列完全随机的数字。他们都从计数 0 开始,因为在每次尝试中,用户都需要使用给定索引中的密钥与服务器通信。然而,这带来了一些问题:
- 客户端需要同步他们的计数器,如果存在偏差,他们可能会被暂时锁定。
- 恶意行为者可以通过网络钓鱼用户来收集即将到来的登录代码,并且这些代码可以长期使用。
因此,我们可以使用当前时间作为计数器,而不是存储计数器。这就是 TOTP 的工作原理。使用时间可以更轻松地进行同步,因为许多现代机器已经使用诸如 NTP 之类的技术来同步其时间,并且这可以防止恶意行为者收集代码,因为他们的代码仅在接下来的 30 秒左右有效,而不是针对一长串未来的登录尝试。
如何生成 TOTP?
两个人见面并决定一系列完全随机的数字的比喻部分是真实的。然而,拥有如此庞大的列表是不可行的,你可能需要数百万个密钥才能在合理的时间内支持 OTP。因此,我们应该使用密码学上安全的算法来生成基于密钥的值。重要的是,此算法不是随机的,因为用户和授权方都将拥有此密钥的副本,并且他们应该能够在给定相同时间的情况下生成相同的值。
我们首先介绍了 HOTP,因为 TOTP 的实际实现实际上是基于 HOTP 的。TOTP 不使用静态计数器,而是使用时间作为当前计数器。我们可以编写以下公式来查找任何给定时间的计数器:
\[ c(t) = \left\lfloor \frac{t - t_0}{X} \right\rfloor \]
这里 \(t_0\) 是开始时间,在大多数系统中,这是默认的 UNIX epoch 时间戳,1970 年 1 月 1 日。\(X\) 是你希望代码轮换的周期。例如,如果你希望登录代码每 30 秒更改一次,则 X 应为 30 秒。
如何_实际_生成 HOTP?
为了生成 HOTP,你需要决定三件事:
- 密钥
- 哈希函数
- 你将输出的位数
首先,我们需要从哈希我们的密钥开始。例如,如果我们选择 SHA-1
作为我们的哈希算法,那么我们的输出将只有 64 字节。如果密钥短于 64 字节,我们可以用零填充它。否则,假设 \(K\) 是我们的密钥,\(H\) 是我们的哈希算法,
\[ K_{pad} = H(K) \]
稍后,我们对文本与一些预定义的魔法常量 \(I_{pad}\) 和 \(O_{pad}\) 执行 XOR 运算。
\[ I_{pad} = [\texttt{0x36}, \dots] \newline O_{pad} = [\texttt{0x5c}, \dots] \]
这些数字最初是由 HMAC 设计者选择的,并且可以选择任何 \(I_{pad} \neq O_{pad}\) 的对。它们的长度也应为 64 字节,与我们的哈希算法的摘要长度相同。稍后,我们将定义著名的 \( \text{HMAC} \),基于哈希的消息身份验证代码函数。它输出一个使用给定密钥和消息计算的加密哈希。
\[ \text{HMAC}(K, M) = H(K_{pad} \oplus O_{pad} + H(K_{pad} \oplus I_{pad} + M)) \]
此加密哈希函数是安全的,因此即使他们知道 \( M \) 和生成的哈希,用户也无法推断出密钥 \( K_{pad} \)。
稍后,我们将定义一个新函数来生成一个 4 字节的结果。以下是来自原始 RFC 的 DT 的定义:
DT(String) // String = String[0]...String[19]
Let OffsetBits be the low-order 4 bits of String[19]
Offset = StToNum(OffsetBits) // 0 <= OffSet <= 15
Let P = String[OffSet]...String[OffSet+3]
Return the Last 31 bits of P
此函数允许我们通过选择由输入的最后 4 位数字表示的字节偏移来动态地将 20 字节输入缩小为 4 字节。DT 在不同计数器输入上的输出是均匀且独立分布的。
最后,我们可以将我们的 HOTP 函数定义为,
\[ \text{HOTP}(K,C) = \text{DT}(\text{HMAC}(K,C)) \bmod 10^{\text{digits}} \]
在这里,我们可以将我们的计数器 \( C \) 替换为 \( c(t) \) 以获得 TOTP 代码。
最后的评论
网上有很多关于 TOTP 和 HOTP 的资源,但是我很努力地寻找一个可以帮助我检查我的实现的网站,因为他们的密钥表示没有标准化。因此,我发布了自己的简短演示应用程序来展示。
OTP GeneratorTest and validate OTP workflows such as TOTP and HOTP.
我已将此应用程序发布在我的网站和 GitHub 上,该实现使用 Kotlin。
- 应用程序链接 https://otp.dogac.dev/
- GitHub 存储库链接:github.com/Dogacel/otp-server
总结: 我们已经了解了 HOTP 和 TOTP 的工作原理,探讨了它们如何从 HMAC 派生而来,并了解了服务器和客户端如何在不传输密码本身的情况下生成匹配的代码。
从事这个项目帮助我更深入地了解了 OTP 的工作原理。曾经感觉像魔法的东西现在感觉像优雅的设计。