TOTP 动态验证码:Google Authenticator 的生成与验证原理

This article is extracted from the chat log with AI. Please identify it with caution.

AI 参与说明(Agent:Codex):本文由 Codex 辅助撰写,并核对公开规范、执行代码示例与检查页面渲染。本次运行记录:模型 gpt-6-astra,reasoning effort ultra,执行入口 Codex Desktop,提供方 openai,运行记录中的 CLI 版本 0.153.4(不是桌面 App 版本)。资料整理于 2026-09-07;算法依据 RFC 6238 与 RFC 4226,具体服务的验证策略以其实现为准。

Google Authenticator 这类应用中随时间更新的验证码,通常使用 TOTP(Time-Based One-Time Password):手机和网站预先持有同一份密钥,在相同时间段内各自计算相同的数字,网站通过重新计算来验证用户输入。

生成时不需要服务器把验证码发给手机。Google 官方帮助也明确说明,Authenticator 没有网络或移动服务时仍可生成验证码。Google Authenticator 帮助

绑定:二维码把什么交给了手机#

在典型的绑定流程中,网站为该账户的认证器生成随机密钥 K,通过已认证的安全连接展示配置二维码。手机扫描后保存 K 及算法、位数、时间步长等参数;网站保留相应的验证材料。

Google 公开的 Key URI 格式形如下面这样。这里是说明字段的占位示意,不能直接用于绑定:

otpauth://totp/Example:alice%40example.com?secret=<Base32密钥>&issuer=Example&algorithm=SHA1&digits=6&period=30

secret 是经过 Base32 编码的共享密钥,issuer 与账户标签帮助人识别条目。Base32 是编码,不能提供加密保护。取得完整配置的人,可以在另一台设备上生成相同的验证码,因此绑定二维码和手动设置密钥都需要保密。它们不是某一次登录的六位验证码。Google Key URI Format

服务端应在验证一次正确的验证码后再完成绑定,这是确认双方配置一致的工程步骤。一个第三方网站使用 Google Authenticator,并不意味着它需要把每次验证码交给 Google 验证;在普通 TOTP 流程中,验证方是该网站或它委托的身份认证服务。

生成:把当前时间变成计数器#

TOTP 在 HOTP(HMAC-Based One-Time Password)的基础上,把递增的事件计数器替换为时间计数器。以 T0 = 0、30 秒一步为例:

T = floor(Unix 时间秒数 / 30)
TOTP(K, T) = HOTP(K, T)

Unix 时间对应同一时间基准,所以时区显示不同本身不会导致验证码不同;实际时钟误差才会。30 秒是 RFC 推荐的默认时间步长,双方必须使用一致配置。RFC 6238 §3–4

以 HMAC-SHA-1、六位输出为例,计算过程如下:

  1. T 编成 8 字节的大端整数。
  2. 使用密钥 K 计算 HMAC-SHA-1(K, T)。HMAC 是带密钥的消息认证码,结果由输入确定。
  3. 根据摘要末字节的低 4 位确定偏移量,取对应的 4 字节并清除最高位,得到非负的 31 位整数 N
  4. 计算 N % 1_000_000,转成十进制字符串,不足六位时在左侧补零。

第 3 步称为 Dynamic Truncation,之后再取模生成十进制验证码;它不是直接截取哈希字符串的最后六个字符。输出位数也属于双方约定的参数。RFC 4226 §5

相同密钥、相同时间步和相同参数,必然得到相同验证码。时间进入下一步后重新计算,但六位数字只有一百万种,不能保证历史上从不重复。不同账户即使某次碰巧显示相同数字,也不代表它们共享密钥。

验证:服务器重算,并检查是否已经使用#

网站收到账号和验证码后,先定位该账号绑定的密钥,再用服务器时间计算候选值。为容忍输入延迟和时钟偏差,可以检查一个有限窗口,例如当前步 T 以及 T−1T+1前后各一步只是策略示例,不是所有服务固定采用的规则;扩大窗口会同时增加可被接受的候选值和攻击机会。RFC 6238 §5.2–6

时间步内的数字保持不变,因此重新计算并比较相等,只能证明“值匹配”。要实现一次性使用,验证方还必须保证该有效 OTP 没有被成功消费过。NIST 要求同一有效 OTP 在有效期内只能被接受一次,并对短验证码实施失败尝试限速。NIST SP 800-63B-4 §3.1.4

一种工程实现是按认证器保存已接受的时间步,并将“检查未使用、标记为已使用”放入原子操作,避免两个并发请求同时通过。如果只保存最后一次成功时间步,就必须同时拒绝更早的时间步;多服务器部署时,消费状态也需要一致。若多个候选时间步碰巧产生相同数字,还应避免在该 OTP 有效期内通过另一个候选步重复接受它。

实际登录还要把验证结果绑定到正在进行的登录流程,完成密码等其他必要条件后才签发会话。验证码校验通过,不等于可以跳过这些条件。

一个可以复现的计算例子#

以下代码使用 Python 3 标准库,无第三方依赖;固定采用 HMAC-SHA-1、30 秒时间步、T0 = 0。保存为 totp_demo.py,执行 python3 totp_demo.py。它演示双方如何算出同一个值,不包含登录服务需要的密钥存储、限速或防重放状态。

import hmac


def totp(secret: bytes, unix_seconds: int, digits: int = 6) -> str:
    if unix_seconds < 0 or digits not in (6, 7, 8):
        raise ValueError("需要非负整数时间戳和 6、7 或 8 位输出")
    counter = unix_seconds // 30
    message = counter.to_bytes(8, "big")
    digest = hmac.digest(secret, message, "sha1")
    offset = digest[-1] & 0x0F
    number = int.from_bytes(digest[offset:offset + 4], "big") & 0x7FFFFFFF
    return f"{number % (10 ** digits):0{digits}d}"


# RFC 公开测试密钥,仅用于示例,不能用于真实账号。
secret = b"12345678901234567890"
phone_code = totp(secret, 59)
server_code = totp(secret, 59)

print(totp(secret, 59, digits=8))
print(phone_code)
print(hmac.compare_digest(phone_code, server_code))

预期输出:

94287082
287082
True

第一行对应 RFC 6238 Appendix B 的 SHA-1 八位测试向量;第二行是相同计算结果对六位模数取余后的值。本文示例已执行,并另行通过该附录的全部六组 SHA-1 测试向量,包括前导零和远期时间戳。测试中的原始字节密钥与二维码里的 Base32 文本表示不同,实际读取 secret 参数时须先解码。RFC 6238 Appendix B

安全性来自哪里,边界在哪里#

密码配合 TOTP 使用时,攻击者仅取得密码还不足以完成登录,仍需取得有效验证码或生成它的密钥。TOTP 本身也存在明确边界:

情况原因与影响
绑定密钥泄露攻击者可以持续生成验证码,仅等待当前验证码过期不能解决问题,需要撤销泄露的绑定并更换密钥。
只保存密钥的普通单向哈希普通 TOTP 验证方需要原密钥或等价生成能力来重算,不能照搬密码的哈希比对方式;应保护密钥存储与访问权限。
暴力尝试六位数字候选空间有限,必须限制尝试频率。近似均匀且接受三个不同候选值时,一次随机猜中的概率约为 3 / 1_000_000
实时钓鱼攻击者可以把用户输入的有效验证码立即转交真正的网站。短有效期和防重放无法阻止攻击者抢先使用。

密钥保护要求见 RFC 6238 §5.1,猜测概率分析见 RFC 4226 §6。NIST 明确将手工输入的 OTP 排除在 Phishing-resistant Authentication 之外,因为它没有把认证器输出绑定到特定认证会话。NIST SP 800-63B-4 §3.2.5

关联阅读#

本文共 2615 字,创建于 Sep 7, 2026

相关标签: ByAI