基于时间戳的一次性密码算法 (Time-based One-Time Password, TOTP)
1. 一句话定位
基于时间戳的一次性密码算法(Time-based One-Time Password, TOTP) 是广义双因素认证(Two-Factor Authentication, 2FA)中最核心的软件令牌标准(RFC 6238)。它基于客户端与服务端共享的对称密钥(Secret Key)以及当前 Unix 时间戳,通过 HMAC 散列和动态截断算法,完全在离线环境下同步生成 6 位(或 8 位)动态数字口令,生命周期通常为 30 秒。
关联:
- 底层密码学依赖:密码哈希的盐与彩虹表 (Salt & Rainbow Table)
- 登录态与鉴权体系:Web 登录鉴权 (Session, Cookie, Bearer, JWT)
- 前序计数器算法:基于事件的一次性密码算法 (HMAC-based One-Time Password, HOTP)
2. 核心数学模型与计算流程
TOTP 本质上是 HOTP(RFC 4226) 的时间特化版本:HOTP 依赖物理按键递增的计数器 $C$,而 TOTP 将计数器 $C$ 替换为基于 Unix 时间步长的整除因子。
整个计算过程无需任何网络请求,分为五个严密的密码学步骤:
flowchart TD
A["共享密钥 (Base32 编码字符串)"] --> B["1. Base32 解码为原始字节流 K"]
C["Unix 毫秒时间戳 (当前系统时间)"] --> D["2. 时间步长计数: C = floor(T / 30)"]
D --> E["转换为 8 字节大端序字节数组"]
B & E --> F["3. HMAC-SHA1(K, C) 散列签名 (20 字节)"]
F --> G["4. 动态截断 (Dynamic Truncation, DT) 提取 4 字节整数"]
G --> H["5. 取模 10^6 补齐前导零 -> 输出 6 位动态口令"]步骤详解
步骤一:密钥解码(Base32 Decoding)
服务端在初次绑定时生成随机二进制密钥(建议 160-bit / 20 字节),并将其以 Base32(RFC 4648) 编码展示为用户可见的字符串或二维码(otpauth://totp/...):
- 字符集:
A-Z与2-7(去除了容易混淆的0/O、1/l/I,且不区分大小写,方便人肉输入); - 计算前先清洗空格与连字符,将其解码为字节数组 $K$。
步骤二:时间切片计数器(Time-step Counter)
读取当前纪元秒级时间戳 $T$(Epoch Seconds):
$$C = \left\lfloor \frac{T - T_0}{X} \right\rfloor$$
- 默认参数:起始时间 $T_0 = 0$,时间步长 $X = 30$ 秒。
- 将 64 位无符号整数 $C$ 写入固定 8 字节大端序(Big-Endian / Network Byte Order) 内存缓冲区。
步骤三:哈希消息认证码(HMAC Computation)
使用预设的单向哈希函数计算 HMAC 消息摘要:
$$\text{HS} = \text{HMAC-SHA1}(K, C)$$
- 默认算法为
HMAC-SHA1(输出定长 20 字节 / 160 bit),规范亦允许HMAC-SHA256或HMAC-SHA512。
步骤四:动态截断(Dynamic Truncation, DT)
这是 TOTP 算法最精妙之处。如何从 20 字节的哈希中公平、随机且确定性地取出 4 个字节?
- 取哈希摘要最后一个字节(第 19 字节)的低 4 位(Low 4 bits)作为偏移量(Offset):
$$\text{Offset} = \text{HS}[19] \ \& \ \text{0x0F}$$
($\text{0x0F} = 15$,因此 Offset 的取值范围严格落在 $[0, 15]$ 之间)。 - 从 $\text{HS}[\text{Offset}]$ 开始连续提取 4 个字节:
$$P = \text{HS}[\text{Offset} \dots \text{Offset}+3]$$ - 屏蔽最高有效位(MSB),防止有符号 32 位整型溢出:
$$\text{BinaryCode} = ((P[0] \ \& \ \text{0x7F}) \ll 24) \mid ((P[1] \ \& \ \text{0xFF}) \ll 16) \mid ((P[2] \ \& \ \text{0xFF}) \ll 8) \mid (P[3] \ \& \ \text{0xFF})$$
步骤五:取模与格式化(Modulo & Padding)
将无符号 31 位整数对 $10^{\text{digits}}$ 取模,生成最终用户界面口令:
$$\text{Code} = \text{BinaryCode} \pmod{10^6}$$
如果计算结果不足 6 位,必须使用前导零补齐(例如 725 $\rightarrow$ 000725)。
3. 核心工程实现范式
3.1 客户端(浏览器纯前端 Web Crypto API)
现代浏览器完全无需第三方库即可利用硬件加速完成运算:
async function generateTOTP(secretBase32, period = 30, digits = 6) {
// 1. Base32 解码
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const cleanSecret = secretBase32.toUpperCase().replace(/[\s-]/g, "").replace(/=+$/, "");
let bits = 0, val = 0;
const keyBytes = [];
for (let i = 0; i < cleanSecret.length; i++) {
val = (val << 5) | alphabet.indexOf(cleanSecret[i]);
bits += 5;
if (bits >= 8) {
keyBytes.push((val >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
// 2. 时间步长计数器 (8 字节大端序)
const counter = Math.floor((Date.now() / 1000) / period);
const counterBuffer = new ArrayBuffer(8);
const view = new DataView(counterBuffer);
view.setUint32(0, Math.floor(counter / 0x100000000));
view.setUint32(4, counter >>> 0);
// 3. Web Crypto API 执行 HMAC-SHA1
const cryptoKey = await crypto.subtle.importKey(
"raw",
new Uint8Array(keyBytes),
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", cryptoKey, counterBuffer);
const hash = new Uint8Array(sig);
// 4. 动态截断
const offset = hash[hash.length - 1] & 0x0f;
const binCode =
((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
// 5. 取模补零
const code = (binCode % (10 ** digits)).toString().padStart(digits, "0");
const remain = period - (Math.floor(Date.now() / 1000) % period);
return { code, remain };
}3.2 服务端核验容错(Python 原生实现)
import hmac
import hashlib
import time
import base64
import struct
def verify_totp(secret_base32: str, user_input_code: str, window: int = 1) -> bool:
"""
验证 TOTP,window=1 允许时钟前后漂移 +/- 30 秒(容忍 3 个时间窗口)
"""
# 补齐 Base32 填充字符 '='
missing_padding = len(secret_base32) % 8
if missing_padding:
secret_base32 += '=' * (8 - missing_padding)
key = base64.b32decode(secret_base32.upper(), casefold=True)
current_counter = int(time.time()) // 30
# 遍历容错窗口 [-1, 0, +1]
for step in range(-window, window + 1):
counter = current_counter + step
msg = struct.pack(">Q", counter) # 8 字节大端序整型
h = hmac.new(key, msg, hashlib.sha1).digest()
offset = h[19] & 0x0f
bin_code = struct.unpack(">I", h[offset:offset+4])[0] & 0x7fffffff
totp_code = str(bin_code % 1000000).zfill(6)
# 使用恒定时间比较防止时序攻击
if hmac.compare_digest(totp_code, user_input_code.strip()):
return True
return False4. 关键边界考量与安全陷阱(Pitfalls)
4.1 时钟漂移(Clock Drift / Skew)
- 现象:客户端手机时间比标准授时慢了 15 秒,而服务端开启了 NTP 对齐,导致用户输入验证码时服务端判定失败。
- 治理规范:服务端验证口令时,必须引入滑动窗口(Look-around Window)。标准做法是核验当前时间片 $C$ 以及 $C-1$ 与 $C+1$(即允许前后各 30 秒的物理误差),窗口过大会降低抗暴力枚举能力,绝不能盲目放宽到几分钟。
4.2 重放攻击(Replay Attack)
- 现象:在同一个 30 秒有效期内,拦截者截获到动态码并重新提交一次。
- 防御机制:动态码不仅是“一次性有效(One-Time)”,更必须是“一次性消费(Single-Use)”。服务端核验成功后,必须将对应的
(UserID, Counter)写入 Redis 设置 60~90 秒过期。若检测到相同计数器的再次提交,立刻拒绝。
4.3 字符串比对的时序攻击(Timing Attack)
- 陷阱:在代码中使用普通字符串判等
code == input_code。 - 后果:普通判等逐字符比对,遇第一个不匹配字符提前返回,攻击者可通过高精度网络延迟差推断每位字符。
- 解法:必须使用密码学恒定时间比较函数(如 Python 的
hmac.compare_digest或 Node.js 的crypto.timingSafeEqual)。
4.4 密钥静态存储泄露风险(Symmetric Vulnerability)
- 根本弱点:TOTP 是对称密钥体系,客户端拥有的 Secret 与服务端数据库保存的 Secret 完全一致。若服务端数据库遭遇拖库(SQL 注入/内部泄露),攻击者可批量计算出所有用户的离线 TOTP,使 2FA 形同虚设。
- 演进对策:现代化生产系统(如 Google/GitHub)正积极推广基于非对称密码学的 FIDO2 / WebAuthn / Passkeys,私钥永不出用户硬件安全芯片(Secure Enclave)。
5. 常见 2FA 认证体系全景对比
| 认证方案 | 核心协议 / 算法 | 对称/非对称 | 离线可用性 | 防钓鱼能力 (Anti-Phishing) | 典型代表 |
|---|---|---|---|---|---|
| TOTP | RFC 6238 (HMAC-SHA1) | 对称密钥 (Symmetric) | 支持完全离线 | 弱(用户仍可能在钓鱼假站手动输入) | Google Authenticator, 1Password, Bitwarden |
| HOTP | RFC 4226 (HMAC-SHA1) | 对称密钥 (Symmetric) | 支持完全离线 | 弱(易因误触产生计数器失步) | 早期银行按键式物理动态口令牌 |
| WebAuthn / Passkeys | FIDO2 / W3C (secp256r1/Ed25519) | 非对称公私钥 | 支持(需硬件握手) | 极强(天然绑定域名与信道来源,免疫钓鱼) | YubiKey, Touch ID, Windows Hello |
| Push 2FA | 双向 TLS / 长连接推送 | 专有业务通道 | 不支持(强依赖网络) | 中等(易受 MFA Fatigue 疲劳轰炸撞库) | Apple ID 弹窗, Microsoft Authenticator |
| SMS OTP | 服务端暂存 Redis + 电信网 | 随机短效串 | 不支持(依赖蜂窝网络) | 极弱(易受 SIM 换卡劫持、拦截木马) | 手机短信验证码 |
本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Web