做后端开发或者搞过账号安全的兄弟,对TOTP应该都不陌生。打开GitHub、Google Cloud、AWS 这些平台,开启两步验证时扫码添加的那个六位动态码,背后就是RFC 6238定义的TOTP算法。这篇文章我会从一个实际项目出发,把RFC 6238的算法要求、数学定义、代码落地一起捋清楚,顺带把otpauth://这类URI格式、密钥存储、验证窗口和防重放这些实操细节展开讲讲。内容定位偏工程实践,适合刚接触2FA的后端工程师、安全工程师,也适合想搞懂自己手机里那个验证器到底怎么工作的技术爱好者。
1. 为什么大家都在用TOTP:双因素认证的基石
1.1 从HOTP到TOTP:一次性密码的演进路线
TOTP严格来说不是从零发明的新算法,它是RFC 4226定义的HOTP算法在时间维度上的变体。HOTP的全称是HMAC-Based One-Time Password,核心思路是用HMAC算法对一个计数器做运算,生成一次性密码。第一次通信时双方约定一个共享密钥和计数器的初始值,之后每成功使用一次,计数器递增一次。这个方案的优点是实现简单,缺点是用户体验差——计数器必须严格同步,用户按一下生成,服务端记录的是另一个计数,两边差一步就验证失败,需要额外的同步机制来补救。
TOTP把"计数器"从递增整数换成了"随时间变化的整数",这就是RFC 6238做的事情。在2005年HOTP发布之后,Google等厂商在实践中发现,纯计数器方案对普通用户太不友好,于是推动设计了基于时间的一次性密码方案,最终在2011年形成了RFC 6238。现在你手机里的Google Authenticator、Microsoft Authenticator、Authy,用的都是这套标准。
1.2 RFC 6238到底规定了什么
RFC 6238的正式标题是"TOTP: Time-Based One-Time Password Algorithm",它做的事情可以拆成四块:
- 定义TOTP的完整计算流程,包括时间计数器怎么算、HMAC怎么用、动态截断怎么做。
- 规定参数的默认值和取值范围,比如时间步长默认30秒、输出默认6位十进制数字。
- 推荐使用HMAC-SHA-1作为默认哈希算法,同时允许实现方选择HMAC-SHA-256或HMAC-SHA-512。
- 明确安全注意事项,包括时间同步、重放攻击、暴力破解等场景下的约束。
搞清楚RFC 6238做的事情,你就能明白为什么各家平台的TOTP体验那么一致:打开验证器App,扫描二维码,每30秒出现一组新的6位数字。这套一致性正是标准化的价值——只要大家都按同一个公式算,任何客户端生成的码都能被任何服务端正确验证。
2. 手把手拆解RFC 6238核心算法
2.1 核心公式与参数定义
RFC 6238的算法定义可以用一条公式讲清楚:
TOTP = HOTP(K, T)
其中T是时间计数器,它的计算方式是:
T = (当前Unix时间戳 - T0) / X
这里的关键参数有四个:
- K:共享密钥,也就是二维码里那个Base32字符串解码后的原始字节。RFC 4226建议密钥长度至少128位(16字节),推荐160位(20字节),实际项目中多数服务端生成的是20字节。
- T0:起始时间戳,默认是0,也就是Unix epoch(1970年1月1日00:00:00 UTC)。一般不用改,除非你有特殊的历史对齐需求。
- X:时间步长,单位秒,默认是30。RFC没有强制所有实现必须用30,只建议不要设太小导致验证困难,也不要设太大导致窗口过长降低安全性。
- d:密码位数,默认是6。RFC 4226建议每个令牌至少6位。
这个公式描述的是"生成"过程。验证过程就是服务端也按同样的参数计算一遍,然后比较客户端提交的码和服务端算出来的码是否一致。
2.2 动态截断:把HMAC变成6位数字的关键一步
HMAC的输出是摘要,SHA-1是20字节、SHA-256是32字节,显然不能直接把一堆二进制字节显示给用户。RFC 4226规定了一套"动态截断"(Dynamic Truncation)流程,RFC 6238完全沿用了这一步。整个过程分四步:
- 取HMAC结果的最后一个字节,记为
offset(这个字节的低4位作为偏移量,所以offset范围是0到15)。 - 从摘要的第offset个字节开始,连续取4个字节。
- 将这4个字节按大端序解释为一个32位无符号整数,然后把最高位(符号位)清零,得到一个31位非负整数。
- 对这个31位整数取模10^d,d是密码位数,不足d位前面补0。
第3步为什么要清除符号位?因为不同语言对有符号整数的范围处理不一致,如果拿一个超过2^31-1的数直接做取模,在Java、C等强类型语言里会溢出或者变成负数,导致两边计算出完全不同的结果。清除符号位是一个"统一约定",保证所有语言实现出来的数学行为一致。
动态截断还有个容易被忽视的数学特征:取模10^6之后,6位数字并不是绝对均匀分布的。因为2^31 = 2147483648,它不是10^6的整数倍,所以生成某些数字的概率会比另一些略高一点。但这个偏差极小,对安全性影响可以忽略,业界也没有因此改变标准。
2.3 为什么默认选HMAC-SHA-1而不是SHA-256
你可能会有个疑问:SHA-1早就被证明存在碰撞攻击了,为什么TOTP还拿它当默认?这个问题的答案在于场景。SHA-1的碰撞攻击针对的是"两个不同内容有相同哈希值"的场景,比如数字签名里伪造证书。而TOTP里HMAC的密钥是保密的,攻击者根本拿不到密钥去构造碰撞。HMAC的安全性依赖的是伪随机函数性质,只要密钥不泄露,HMAC-SHA-1的输出对攻击者来说仍然无法预测。
RFC 6238实际上允许使用SHA-256和SHA-512,而且很多现代实现(比如pyotp)默认就支持。但需要注意兼容性:一些老版本的验证器App只实现了SHA-1分支,你如果生成一个algorithm=SHA256的otpauth链接,扫出来可能直接报错。所以我自己的经验是:如果是个人项目或内部系统,用SHA-256没问题;如果是面向公共用户的平台,保守起见继续用SHA-1,避免被老客户端坑到。安全界其实也认可这种做法,因为TOTP的安全边界在密钥保护,不在哈希算法的抗碰撞性。
3. 从算法到落地:Python实现与otpauth URI
3.1 从零写一个TOTP实现
讲了这么多理论,不如直接上代码。下面是一个不依赖第三方库的Python实现,完整覆盖RFC 6238的核心流程:
import hmac import hashlib import struct import time import base64 def hotp(key: bytes, counter: int, digits: int = 6, digestmod=hashlib.sha1) -> str: # counter 需要用 8 字节的大端序无符号整数表示 msg = struct.pack(">Q", counter) mac = hmac.new(key, msg, digestmod).digest() # 动态截断 offset = mac[-1] & 0x0F binary = int.from_bytes(mac[offset:offset + 4], byteorder="big") & 0x7FFFFFFF return str(binary % (10 ** digits)).zfill(digits) def totp(secret_b32: str, period: int = 30, digits: int = 6) -> str: key = base64.b32decode(secret_b32, casefold=True) counter = int(time.time() // period) return hotp(key, counter, digits) def verify(secret_b32: str, code: str, window: int = 1, period: int = 30) -> bool: key = base64.b32decode(secret_b32, casefold=True) current = int(time.time() // period) # 允许前后各 window 个时间步的容错 for counter in range(current - window, current + window + 1): expected = hotp(key, counter) if hmac.compare_digest(expected, code): return True return False如果你只是验证思路,这个代码就够了。生产环境我更推荐直接用pyotp这类成熟库,毕竟它处理了边界情况和兼容性问题,但读懂这段代码能帮你真正理解算法内部发生了什么,排查问题时会很有底气。
这里顺便解释一个新手常混淆的点:int(time.time() // period)得到的是当前时间步的计数器值,这个值每30秒加1,从1970年算起已经累计了超过5800万。HMAC的输入消息就是这个计数器,而不是时间本身。为什么不能直接拿时间戳做HMAC?因为时间戳是秒级连续的,直接做HMAC意味着每秒钟都换一个码,30秒内用户拿到的码会变来变去,无法形成"一个步长一个码"的稳定体验。用计数器整除,本质上是把连续时间离散化。
3.2 otpauth://完整格式解析
前面提到了otpauth://totp/github:flyeagleyuan这个格式,很多人只知道扫码添加,不理解这个URI里的每个字段是什么。完整的otpauth URI格式如下:
otpauth://totp/{label}?secret={secret}&issuer={issuer}&algorithm={algorithm}&digits={digits}&period={period}各参数含义如下:
- label:一般是
发行者:账号的格式,比如github:flyeagleyuan。冒号前面的部分用于在App里显示为账户名称。 - secret:Base32编码的共享密钥,这是唯一必需的参数。
- issuer:发行者名称,Google Authenticator会用它来分组显示。
- algorithm:可选,默认SHA1,可填SHA256或SHA512。
- digits:可选,默认6,可填8。
- period:可选,默认30。
一个完整的示例是:
otpauth://totp/github:flyeagleyuan?secret=JBSWY3DPEHPK3PXP&issuer=github&algorithm=SHA1&digits=6&period=30有个容易踩坑的细节:label里的冒号和issuer参数如果同时存在,官方推荐在展示时以issuer参数为准,label里的冒号前面部分只作为备选。不同App的处理逻辑略有差异,所以遇到"扫码后App显示的名称不对"这种问题,优先检查issuer参数是否传对了。
3.3 把TOTP接入业务系统的关键设计
生成TOTP只是一小半,验证和存储才是大头。一个完整的业务接入流程大概是:
- 用户请求开启两步验证,服务端随机生成20字节密钥,Base32编码后展示给用户。
- 服务端把密钥和用户信息绑定,但不要把原始密钥明文存到日志里。
- 用户用手机App扫码添加。
- 用户输入当前显示的验证码,服务端调用verify函数校验。
- 校验通过后,标记该用户已启用TOTP,同时要求用户保存恢复码。
- 后续登录时,用户除了密码还要提交验证码。
密钥存储这块我要多说一句,绝不能直接把Base32字符串存到普通字段里,因为它和密码一样是"第二凭证",泄露了等于两步验证形同虚设。生产环境建议加密存储,至少要做到数据库字段单独加解密,或者使用专门的安全存储服务。
4. 实战中避不开的问题与排查方法
4.1 时间窗口与容错设计
TOTP依赖客户端和服务端的时间同步,但网络延迟、设备时钟漂移都是客观存在的,所以服务端验证不能只比对当前时间步的码,必须允许一定的前后容错。上面代码里的window=1就表示允许当前步的前一步和后一步,也就是总共尝试3个计数器值。
这个window该怎么定?我的经验是:
- 默认window=1已经能覆盖绝大多数正常用户(手机时间走NTP同步,偏差通常在一两秒内)。
- window设得越大,暴力破解空间越大。6位数字密码(100万种组合)配合window=3,等于给了攻击者7次尝试机会,配合限流(比如每30秒最多试5次)才能保证安全。
- 有的系统支持用户手动"校准时间",但这对普通用户太复杂,服务端主动放宽到window=1通常就够了。
一个更容易被忽略的点:验证成功后,服务端应该记录当前使用的计数器值。如果用户把验证码发给别人,或者验证码被中间人截获,攻击者拿到后必须立刻使用,因为服务端一旦发现这个计数器已被使用过就会拒绝。实现上可以在数据库加一列last_used_counter,每次验证成功就更新它,下次如果提交的计数器值小于等于这个值,直接判定为无效。
4.2 Base32编码和密钥处理的坑
Base32是我见过新手踩坑最多的地方。RFC 4648定义的Base32只有大写字母A-Z和数字2-7,总共32个字符,不包含1、0、8、9这些容易混淆的字符。但有个细节:标准Base32编码会按8字符一组补齐=号作为padding,而otpauth URI里的secret通常是去掉padding的。
Python自带的base64.b32decode默认要求正确的padding,你直接拿一个没有=的字符串去解码,会报Incorrect padding错误。所以生产代码里需要这样处理:
def base32_decode_no_padding(s: str) -> bytes: s = s.upper().strip() padding = "=" * ((8 - len(s) % 8) % 8) return base64.b32decode(s + padding)另一个坑是密钥长度。有人图省事直接用用户名的ASCII字节做密钥,或者用一个很短的字符串当密钥,这在安全性上不过关,因为攻击者可以暴力枚举短密钥。RFC 4226建议至少128位(16字节),我在前文也提到了,这里再强调一次:生成密钥时就用os.urandom(20),别自己拼字符串。
还要注意Base32解码后密钥的字节数必须是整数,长度不是8的倍数时解码会报错,这其实是在帮你拦截配置错误。
4.3 重放攻击与并发场景的处理
TOTP本身无法防止重放攻击——同一个验证码在30秒窗口内是有效的,攻击者在你之前使用了这个码,服务端无法区分提交者是本人还是一次盗窃的凭证。解决办法就是我在4.1中提到的"记录最后使用计数器"。但如果你的服务是多实例部署,这部分逻辑必须放在共享存储里,并且要考虑并发。
比如用户同时从两个设备发起登录请求,两个请求都带了同一个验证码。如果两个实例同时读到last_used_counter=100,同时校验通过,然后各自把计数器更新为101,那就等于重放成功了。解决办法是让更新操作原子化,比如用数据库的行锁或者条件更新:
UPDATE user_totp SET last_used_counter = 101 WHERE user_id = ? AND last_used_counter < 101;如果影响行数为0,说明这个计数器已经被使用过了,直接拒绝请求。这种设计在分布式环境下也能保证正确性。
4.4 其他高频问题速查
我整理了一张问题对照表,基本覆盖了实际运维中能遇到的典型场景:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 用户反馈验证码总是无效 | 手机时间不准 | 先让用户校准系统时间,再重试 |
| 服务端时区配置影响验证 | 误把时间戳转成了本地时间做整除 | TOTP必须基于Unix时间戳计算,不要做时区转换 |
| Base32解码报Incorrect padding | 密钥URI里的secret省略了padding | 按上文方式补齐后再解码 |
| 扫码后App显示名称不对 | label和issuer参数不一致 | 统一使用issuer参数,label格式写发行者:账号 |
| 老版本App扫码后报"不支持" | 生成的URI用了SHA256或8位数字 | 兼容模式改回SHA1、digits=6 |
| 数据库被拖库,密钥泄露 | 密钥明文存储在业务表 | 改为加密存储,并强制用户重新绑定 |
5. 写在最后的个人体会
TOTP这套算法看起来就几行代码,但真正落地时涉及的工程问题远比公式本身多。我自己的体会是:读RFC原文很重要,但一定要结合实际踩坑的反馈来读。你会逐步发现,RFC里那些"建议"和"注意事项"几乎每一条都在实践中验证过,比如为什么默认不用SHA-256、为什么密钥至少要128位、为什么验证要留窗口。
最后再分享一个小技巧:调试TOTP时不要盯着真实时间等,直接用固定的测试参数。比如secret=JBSWY3DPEHPK3PXP是Base32编码的"Hello!",你把它和当前时间的计算值比对一下,能快速确认自己的实现是否正确。排查出问题后再换回真实密钥,效率会高很多。有条件的可以顺手建一个自动化测试,在CI里固定时间戳跑一遍生成和验证逻辑,后续改代码就不怕回归了。