4. TOTP 算法实现:从 RFC 6238 到实战代码
各位同学,今天我们来啃一块硬骨头——TOTP 算法。说实话,我第一次看 RFC 6238 的时候,也觉得这玩意儿挺抽象的。但后来在网关项目里被时间同步问题坑过几次之后,我才真正理解了它的精妙之处。
TOTP,全称 Time-based One-Time Password。说白了,就是基于时间戳生成的一次性密码。你手机上的 Google Authenticator、各种银行动态令牌,背后都是这套逻辑。
4.1 TOTP 标准(RFC 6238)核心思想
RFC 6238 其实是对 HOTP(RFC 4226)的扩展。HOTP 基于计数器,TOTP 把计数器换成了时间戳。就这么简单?嗯,但细节里藏着魔鬼。
核心公式长这样:
TOTP = HOTP(K, T)
其中 T = (当前Unix时间戳 - T0) / X
这里:
- K:预共享密钥,客户端和服务器都知道
- T0:起始时间,通常是 0(Unix 纪元)
- X:时间步长,默认 30 秒
关键理解:TOTP 不是对当前时间做哈希,而是对「时间窗口编号」做哈希。30 秒内,T 值不变,所以生成的密码也不变。30 秒后,T 值加 1,密码完全改变。
4.2 时间步长、密钥与动态码生成逻辑
咱们拆开来看这三个要素。
4.2.1 时间步长(Time Step)
默认是 30 秒。我见过有些项目改成 60 秒,甚至 10 秒。步长越短越安全,但对时间同步要求越高。我在一个工业网关项目里遇到过,设备时钟漂移严重,30 秒步长经常验证失败,后来我改成了 60 秒,配合 ±1 步长的窗口,问题就解决了。
| 步长 | 安全性 | 用户体验 | 适用场景 |
|---|---|---|---|
| 10 秒 | 高 | 差(频繁刷新) | 高安全环境 |
| 30 秒 | 中 | 好 | 通用场景 |
| 60 秒 | 低 | 极好 | 时钟漂移大的设备 |
4.2.2 密钥(Shared Secret)
密钥必须是双方都知道的。通常用 Base32 编码存储,方便人工输入。长度建议至少 16 字节(128 位)。
我曾经踩过的坑:密钥生成时用了随机数种子不够随机,导致多个设备生成了相同的密钥。后来我强制使用 os.urandom() 或 secrets 模块,再也没出过问题。
4.2.3 动态码生成逻辑
流程是这样的:
- 获取当前 Unix 时间戳
- 计算时间窗口编号 T = floor((timestamp - T0) / X)
- 将 T 转为 8 字节大端序字节串
- 用 HMAC-SHA1 对密钥和 T 做运算
- 从 HMAC 结果中提取 4 字节动态二进制码
- 对 10^6 取模,得到 6 位数字
4.3 HMAC-SHA1 计算过程
HMAC-SHA1 是 TOTP 的默认哈希算法。RFC 6238 也支持 SHA256 和 SHA512,但 SHA1 最通用。
计算过程分两步:
1. 密钥填充:
- 如果密钥长度 < 64 字节,在后面补 0x00 到 64 字节
- 如果密钥长度 > 64 字节,先对密钥做 SHA1 哈希,再补 0x00 到 64 字节
2. 两次哈希:
- ipad = 0x36 重复 64 次
- opad = 0x5C 重复 64 次
- 内部哈希 = SHA1((key ⊕ ipad) + message)
- 最终结果 = SHA1((key ⊕ opad) + 内部哈希)
嗯,这里要注意:message 就是时间窗口编号 T 的 8 字节大端序表示。
4.4 Python 实现:客户端与服务器端验证
直接上代码。这是我个人习惯的写法,清晰且可测试。
4.4.1 TOTP 客户端(生成动态码)
import hmac
import hashlib
import struct
import time
import base64
class TOTPClient:
def __init__(self, secret: str, step: int = 30, digits: int = 6):
# 密钥必须是 Base32 编码的字符串
self.secret = base64.b32decode(secret.upper())
self.step = step
self.digits = digits
def generate(self, timestamp: int = None) -> str:
if timestamp is None:
timestamp = int(time.time())
# 计算时间窗口编号
counter = timestamp // self.step
# 转为 8 字节大端序
counter_bytes = struct.pack('>Q', counter)
# HMAC-SHA1 计算
hmac_result = hmac.new(self.secret, counter_bytes, hashlib.sha1).digest()
# 动态截断
offset = hmac_result[-1] & 0x0F
binary_code = struct.unpack('>I', hmac_result[offset:offset+4])[0] & 0x7FFFFFFF
# 取模得到指定位数
code = binary_code % (10 ** self.digits)
return str(code).zfill(self.digits)
4.4.2 服务器端验证(带时间窗口)
class TOTPServer:
def __init__(self, secret: str, step: int = 30, digits: int = 6, window: int = 1):
self.client = TOTPClient(secret, step, digits)
self.window = window # 允许前后各几个步长的偏差
def verify(self, code: str, timestamp: int = None) -> bool:
if timestamp is None:
timestamp = int(time.time())
# 检查当前时间窗口及前后 window 个窗口
for offset in range(-self.window, self.window + 1):
check_time = timestamp + offset * self.client.step
if self.client.generate(check_time) == code:
return True
return False
实战建议:服务器端一定要加时间窗口。我见过太多因为设备时钟差几秒就验证失败的案例。±1 步长(共 90 秒窗口)通常够用。如果设备时钟漂移严重,可以放宽到 ±2。
4.5 完整流程示意图
下面这张图把整个 TOTP 生命周期串起来了:
4.6 避坑指南
最后分享几个我实战中踩过的坑:
- 时间同步:边缘网关一定要配 NTP。我有个项目设备在偏远地区,NTP 请求经常超时,后来我加了本地 RTC 备份和手动校时接口。
- 密钥存储:千万别硬编码在代码里。用硬件安全模块(HSM)或者至少用加密配置文件。我曾经见过把密钥写在配置文件里然后上传到 Git 的……
- 重放攻击:TOTP 本身不防重放。服务器端验证通过后,要记录这个时间窗口的码,防止 30 秒内重复使用。
- 性能考虑:如果网关上有大量设备需要同时验证,HMAC-SHA1 计算量不小。可以考虑预计算未来几个时间窗口的码,缓存起来。
总结一下:TOTP 的核心就是「时间窗口 + HMAC + 截断」。理解了这个三元组,你就能自己实现一套动态认证系统。代码我已经给了,剩下的就是根据你的场景调整步长和窗口大小。