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 动态码生成逻辑

流程是这样的:

  1. 获取当前 Unix 时间戳
  2. 计算时间窗口编号 T = floor((timestamp - T0) / X)
  3. 将 T 转为 8 字节大端序字节串
  4. 用 HMAC-SHA1 对密钥和 T 做运算
  5. 从 HMAC 结果中提取 4 字节动态二进制码
  6. 对 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 生命周期串起来了:

TOTP 动态码生成与验证流程 客户端(边缘网关) 获取时间戳 计算 T = ts // 30 HMAC-SHA1 截断 → 6位码 发送动态码 服务器(认证中心) 获取服务器时间 计算 T-1, T, T+1 生成预期码 比对 → 通过/拒绝 关键点 • 客户端和服务器必须使用相同的密钥和步长 • 服务器通过时间窗口容忍 ±1 步长的时钟偏差 预共享密钥(Base32 编码) 客户端 服务器

4.6 避坑指南

最后分享几个我实战中踩过的坑:

  • 时间同步:边缘网关一定要配 NTP。我有个项目设备在偏远地区,NTP 请求经常超时,后来我加了本地 RTC 备份和手动校时接口。
  • 密钥存储:千万别硬编码在代码里。用硬件安全模块(HSM)或者至少用加密配置文件。我曾经见过把密钥写在配置文件里然后上传到 Git 的……
  • 重放攻击:TOTP 本身不防重放。服务器端验证通过后,要记录这个时间窗口的码,防止 30 秒内重复使用。
  • 性能考虑:如果网关上有大量设备需要同时验证,HMAC-SHA1 计算量不小。可以考虑预计算未来几个时间窗口的码,缓存起来。

总结一下:TOTP 的核心就是「时间窗口 + HMAC + 截断」。理解了这个三元组,你就能自己实现一套动态认证系统。代码我已经给了,剩下的就是根据你的场景调整步长和窗口大小。