RFC 6238 TOTP 原理解析

先打个招呼:
今天咱们聊的 TOTP(Time-based One-Time Password)就是为你量身定制的“一次性数字纸巾”——用完就扔,下一分钟再换新的。
本文目标:把 RFC 6238 里那些“天书”公式翻译成“人话”,再顺手用 60 行 Python 实现一遍,最后带你写个离线小管理器。整篇读完大约 10 分钟,建议配咖啡或肥宅快乐水。


目录(省流电梯)

  1. 故事从“一条二维码”说起
  2. RFC 6238 到底说了啥?
  3. 30 秒后会怎样?
  4. 来段 Python 热乎代码
  5. 常见疑问十连
  6. 把原理装进 GUI——顺带安利自己写的小玩具
  7. 进阶:为啥不用 SHA-256?
  8. 小结(再省流)

1. 故事从“一条二维码”说起

第一次开启二步验证,网站通常会弹出一张黑白二维码,下面写着:
“用 Google Authenticator、Microsoft Authenticator、Authy…… 扫我!”

你“咔嚓”一下,手机立刻每 30 秒蹦出 6 位数字。登录时输入,门就开了。
这条二维码里其实藏着一个 URI,长这样:

otpauth://totp/ACME:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME&algorithm=SHA1&digits=6&period=30

今天咱们就掰开揉碎,看看这串“魔法”背后到底靠啥运转。


2. RFC 6238 到底说了啥?

一句话版本:
“把共享密钥 + 当前时间戳拼一起,HMAC 一下,再动态截断,最后模 1 000 000,就是 6 位数字。”

展开就是四步,咱们一步步唠。

2.1 共享密钥(Secret)

服务器和用户手机事先商量好一串随机数,通常 160 位(20 字节),Base32 编码后 32 个字符,看起来就像 HXDMVJ...
注意:密钥只在二维码里出现一次,之后再也不在网络里传输,所以中间人抓包也偷不到。

2.2 时间切片(Time Counter)

把 Unix 时间戳除以 30 秒(默认周期),向下取整,得到一个整数 T
T = floor(current_timestamp / 30)
这样每 30 秒大家都能算出同一个 T,不同设备只要时钟没差几分钟,就算得出一样的数字。

2.3 HMAC 哈希

用商定的哈希算法(SHA-1 是默认,也允许 SHA-256/512)对 T 做 HMAC,密钥就是刚才的 Secret。
HMAC(K, T) 得到一串字节,长度跟算法有关,比如 SHA-1 是 20 字节。

2.4 动态截断(Dynamic Truncation)

为了把 20 字节压成 6 位数字,RFC 6238 设计了一个“奇葩”操作:

  1. 取最后一个字节的低 4 位,作为偏移量 offset(0–15)。
  2. offset 开始连续拿 4 字节,拼成 32 位无符号整数。
  3. 把最高位清零(避免符号干扰),然后模 1 000 000,得到 6 位数。
    如果不足 6 位,前面补 0,于是就有了“012345”这种样子。

官方伪代码如下(省流):

hmac_result = HMAC(K, T)               # 20 字节
offset = hmac_result[19] & 0x0F
bin_code = (hmac_result[offset] & 0x7F) << 24 |
           (hmac_result[offset+1] & 0xFF) << 16 |
           (hmac_result[offset+2] & 0xFF) << 8  |
           (hmac_result[offset+3] & 0xFF)
dyn_code = bin_code % 1000000
str_code = dyn_code.to_s.rjust(6, '0')

3. 30 秒后会怎样?

  • 客户端:定时器走到 00 秒或 30 秒,重新计算一次,数字刷新。
  • 服务器:接受验证码时,会额外多算前后两个周期(T-1、T、T+1),防止网络延迟或设备时钟跑偏。
    所以你的手机快/慢 30 秒,通常也能登录成功。

4. 来段 Python 热乎代码

先把原理翻译成能跑的代码,再去谈库函数封装。下面这段“0 依赖”版,只用到标准库,复制即运行。

import base64
import struct
import time
import hmac
import hashlib

def hotp(secret: bytes, counter: int, digits: int = 6,
         algorithm=hashlib.sha1) -> str:
    """RFC 4226 HOTP,为 TOTP 提供基础"""
    counter_bytes = struct.pack('>Q', counter)      # 8 字节 big-endian
    mac = hmac.new(secret, counter_bytes, algorithm).digest()
    offset = mac[-1] & 0x0F
    binary = struct.unpack('>I', mac[offset:offset+4])[0] & 0x7FFFFFFF
    code = binary % (10 ** digits)
    return str(code).zfill(digits)

def totp(secret_base32: str, digits: int = 6, period: int = 30,
         algorithm: str = 'SHA1') -> str:
    """RFC 6238 TOTP"""
    secret = base64.b32decode(secret_base32, casefold=True)
    counter = int(time.time()) // period
    algo = getattr(hashlib, algorithm.lower())
    return hotp(secret, counter, digits, algo)

# 跑一把
if __name__ == '__main__':
    print('当前验证码:', totp('HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ'))

把脚本丢服务器上跑,每 30 秒出来的数字跟你手机里 Authenticator 一模一样,不信你试。


5. 常见疑问十连

Q1: SHA-1 不是被“撞”过吗?安全吗?
A: 碰撞攻击≠HMAC 攻击。HMAC-SHA1 在当前(2024)仍被认为是安全的,实在不放心可以升级 SHA-256,只要客户端和服务器说好就行。

Q2: 密钥丢了怎么办?
A: 那就等于密码全丢。建议:

  • 网站端提供“换密钥”按钮;
  • 用户端打开云备份(iCloud / Google 备份)或抄下“备用码”放保险柜。

Q3: 时钟不同步会怎样?
A: 差一两分钟没事,服务器会前后各多试一个周期;差太多就跪。手机记得打开“自动对时”。

Q4: 能不能把周期改成 60 秒?
A: 可以,但得两边一起改。很多 App 默认只认 30 秒,改之前先确认客户端支持。

Q5: digits=8 行不行?
A: 行,同样要两端对齐。银行 U 盾常用 8 位,但大众 App 为了好输入普遍 6 位。

Q6: 为什么不用 RSA 签名?
A: TOTP 设计目标是“离线”+“轻量”。RSA 计算量大、密钥长,不适合 30 秒一刷的小玩具。

Q7: 会被暴力破解吗?
A: 6 位数字空间 10^6,概率百万分之一。服务器只要“三次错就冷却”,基本没戏。

Q8: 可以同时用多台手机吗?
A: 可以,只要把同一个密钥扫进多台设备,它们会同步输出同一串数字。

Q9: 二维码里的 issuer 有啥用?
A: 纯粹给你看,方便在 App 里分组管理。算法不会用 issuer 参与计算。

Q10: 想自己写个 App,需要授权费吗?
A: RFC 6238 是公开标准,免费实现,放心撸。


6. 把原理装进 GUI——顺带安利自己写的小玩具

如果你已经看完上面,想亲手撸一个“离线版 TOTP 管理器”。界面用 PyQt5,核心就是调了 pyotp.TOTP(secret),再配个 30 秒进度条,代码不到 300 行。自己本地跑,不连网、不上传,密钥纯本地 accounts.json,适合 paranoid 玩家。

核心片段如下,感受下“秒出验证码”:

import json
import os
import time
from datetime import datetime
import re
from urllib.parse import urlparse, parse_qs

import pyotp
import qrcode
from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QTimer, Qt, pyqtSlot
from PyQt5.QtGui import QFont, QPixmap, QIcon
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
                             QHBoxLayout, QLabel, QLineEdit, QPushButton,
                             QListWidget, QListWidgetItem, QMessageBox,
                             QInputDialog, QMenuBar, QStatusBar)

ACCOUNTS_FILE = 'accounts.json'
ICON_FILE = 'totp.png'          # 32×32 透明图标,可自己放一张


# ---------- 工具 ----------
def load_accounts():
    if not os.path.exists(ACCOUNTS_FILE):
        return {}
    with open(ACCOUNTS_FILE, 'r', encoding='utf-8') as f:
        raw = json.load(f)
    # 向下兼容:旧格式直接升级
    return {k: (v if isinstance(v, dict) else {"secret": v, "issuer": ""})
            for k, v in raw.items()}


def save_accounts(data: dict):
    with open(ACCOUNTS_FILE, 'w', encoding='utf-8') as f:
        json.dump(data, f, indent=2, ensure_ascii=False)


def parse_otpauth(url: str):
    """
    解析 otpauth:// 链接
    返回 (name, secret, issuer) 或抛出 ValueError
    """
    url = url.strip()
    if not url.startswith('otpauth://totp/'):
        raise ValueError('必须以 otpauth://totp/ 开头')

    parsed = urlparse(url)
    name = parsed.path[1:]                      # 去掉前导 /
    if not name:
        raise ValueError('缺少账户名')

    qs = parse_qs(parsed.query)
    secret = qs.get('secret', [None])[0]
    if not secret:
        raise ValueError('缺少 secret')
    issuer = qs.get('issuer', [''])[0]

    # Base32 校验
    try:
        pyotp.TOTP(secret.upper()).now()
    except Exception as e:
        raise ValueError(f'secret 无效: {e}')

    return name, secret.upper(), issuer


# ---------- 单条账户组件 ----------
class AccountWidget(QWidget):
    def __init__(self, name: str, secret: str, issuer: str = "", parent=None):
        super().__init__(parent)
        self.name = name
        self.secret = secret
        self.issuer = issuer
        self.totp = pyotp.TOTP(secret)

        self.lab_name = QLabel(name)
        self.lab_name.setFont(QFont('Arial', 12, QFont.Bold))
        self.lab_code = QLabel('------')
        self.lab_code.setFont(QFont('Consolas', 14))
        self.lab_code.setTextInteractionFlags(Qt.TextSelectableByMouse)
        self.btn_copy = QPushButton('复制')
        self.btn_copy.setFixedSize(60, 28)
        self.btn_copy.clicked.connect(self.copy_code)
        self.btn_del = QPushButton('删除')
        self.btn_del.setFixedSize(60, 28)
        self.btn_del.clicked.connect(self.delete_later)
        self.bar = QLabel()
        self.bar.setFixedHeight(4)
        self.bar.setStyleSheet("""background: qlineargradient(x1:0,y1:0,x2:1,y2:0,
                stop:0 #2d89ef,
                stop:1 #61b1ff);""")
        self.lab_issuer = QLabel(issuer or "")
        self.lab_issuer.setFont(QFont('Arial', 9))
        self.lab_issuer.setStyleSheet("color:gray;")

        lay = QVBoxLayout(self)
        lay.setContentsMargins(4, 4, 4, 4)
        h = QHBoxLayout()
        h.addWidget(self.lab_name)
        h.addStretch()
        h.addWidget(self.lab_code)
        h.addWidget(self.btn_copy)
        h.addWidget(self.btn_del)
        lay.addLayout(h)
        lay.addWidget(self.lab_issuer)
        lay.addWidget(self.bar)
        

        # 定时刷新
        self.refresh_timer = QTimer(self)
        self.refresh_timer.timeout.connect(self.refresh_code)
        self.refresh_timer.start(20)
        self.refresh_code()

    def refresh_code(self):
        # 浮点剩余秒
        rem = self.totp.interval - (time.time() % self.totp.interval)
        self.lab_code.setText(self.totp.now())
        # 平滑宽度
        width = int(rem / self.totp.interval * self.width())
        self.bar.setFixedWidth(width)

    def copy_code(self):
        QApplication.clipboard().setText(self.lab_code.text())

    def delete_later(self):
        self.setParent(None)
        self.deleteLater()


# ---------- 主窗体 ----------
class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('PyTOTP 认证器')
        self.resize(420, 320)
        self.setWindowIcon(QIcon(ICON_FILE) if os.path.exists(ICON_FILE) else QIcon())

        self.accounts = load_accounts()  # dict: name -> secret

        self.list_widget = QListWidget()
        self.list_widget.setSpacing(2)
        self.btn_add = QPushButton('添加账户')
        self.btn_add.clicked.connect(self.add_account)

        central = QWidget()
        lay = QVBoxLayout(central)
        lay.addWidget(self.list_widget)
        lay.addWidget(self.btn_add)
        self.setCentralWidget(central)

        self.build_menu()
        self.status = QStatusBar()
        self.setStatusBar(self.status)

        self.load_existing()

    def build_menu(self):
        men = self.menuBar()
        m = men.addMenu('文件')
        m.addAction('导出二维码', self.export_qr)
        m.addAction('保存二维码', self.save_qr_to_file)
        m.addAction('导入 otpauth 链接', self.import_otpauth)
        m.addSeparator()
        m.addAction('退出', self.close)

    def load_existing(self):
        for name, data in self.accounts.items():
            # data 可能是旧字符串,也可能是新字典
            if isinstance(data, dict):
                secret = data["secret"]
                issuer = data.get("issuer", "")
            else:                       # 旧格式兼容
                secret = data
                issuer = ""
            self._insert_account(name, secret, issuer)

    def _insert_account(self, name: str, secret: str, issuer: str = ""):
        item = QListWidgetItem()
        item.setSizeHint(QtCore.QSize(200, 65))
        widget = AccountWidget(name, secret, issuer)
        self.list_widget.addItem(item)
        self.list_widget.setItemWidget(item, widget)

    @pyqtSlot()
    def add_account(self):
        name, ok = QInputDialog.getText(self, '新增账户', '账户名称:')
        if not ok or not name.strip(): return
        name = name.strip()
        if name in self.accounts:
            QMessageBox.warning(self, '提示', '该账户已存在!')
            return

        secret, ok = QInputDialog.getText(self, '密钥',
                                          '请输入 Base32 密钥(留空则自动生成):')
        if not ok: return
        secret = secret.strip().upper() or pyotp.random_base32()

        issuer, ok = QInputDialog.getText(self, '发行方(可选)', '发行商:')
        if not ok: return
        issuer = issuer.strip()

        # 保存
        self.accounts[name] = {"secret": secret, "issuer": issuer}
        save_accounts(self.accounts)
        self._insert_account(name, secret, issuer)

    @pyqtSlot()
    def export_qr(self):
        from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QPushButton,
                                     QGraphicsView, QGraphicsScene)
        from PyQt5.QtCore import Qt

        sel = self.list_widget.currentRow()
        if sel < 0:
            QMessageBox.information(self, '提示', '请先选中一个账户')
            return

        item   = self.list_widget.item(sel)
        widget = self.list_widget.itemWidget(item)

        # 1. 生成二维码(box_size=6  256×256 左右)
        uri = pyotp.TOTP(widget.secret).provisioning_uri(
                name=widget.name, issuer_name=widget.issuer)
        qr = qrcode.QRCode(version=1,
                           error_correction=qrcode.constants.ERROR_CORRECT_L,
                           box_size=6,
                           border=6)
        qr.add_data(uri)
        qr.make(fit=True)

        # 2. PIL -> QPixmap
        qr_pil = qr.make_image(fill='black', back='white').convert('RGB')
        pixmap = QtGui.QPixmap.fromImage(
        QtGui.QImage(qr_pil.tobytes(),
                     qr_pil.width,
                     qr_pil.height,
                     qr_pil.width * 3,        # 关键:正确 stride
                     QtGui.QImage.Format_RGB888))

        # 3. 场景+视图(像素级,永不拉伸)
        scene = QGraphicsScene()
        scene.addPixmap(pixmap)
        view = QGraphicsView(scene)
        view.setStyleSheet("border:0;background:transparent;")
        view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        view.setFixedSize(pixmap.size() + QtCore.QSize(2, 2))  # 1 px 边距

        # 4. 对话框
        dlg = QDialog(self)
        dlg.setWindowTitle(f'二维码 - {widget.name}')
        dlg.setModal(True)
        dlg.setSizeGripEnabled(False)

        btn = QPushButton('关闭', dlg)
        btn.clicked.connect(dlg.accept)

        lay = QVBoxLayout(dlg)
        lay.setContentsMargins(8, 8, 8, 8)
        lay.addWidget(view, alignment=Qt.AlignCenter)
        lay.addWidget(btn, alignment=Qt.AlignCenter)

        dlg.adjustSize()
        dlg.exec_()

    @pyqtSlot()
    def save_qr_to_file(self):
        """把当前选中账户的二维码保存为 PNG 文件"""
        sel = self.list_widget.currentRow()
        if sel < 0:
            QMessageBox.information(self, '提示', '请先选中一个账户')
            return

        item   = self.list_widget.item(sel)
        widget = self.list_widget.itemWidget(item)   # AccountWidget

        # 1. 生成 otpauth 字符串
        uri = pyotp.TOTP(widget.secret).provisioning_uri(
                name=widget.name, issuer_name='PyTOTP')

        # 2. 生成高清二维码(正方形模块)
        qr = qrcode.QRCode(version=1,
                           error_correction=qrcode.constants.ERROR_CORRECT_L,
                           box_size=8,
                           border=4)
        qr.add_data(uri)
        qr.make(fit=True)
        qr_pil = qr.make_image(fill='black', back='white').convert('RGB')

        # 3. 保存文件
        file_name = f"二维码-{widget.name}.png"
        try:
            qr_pil.save(file_name)
            QMessageBox.information(self, '已保存',
                                    f'二维码已保存到:\n{os.path.abspath(file_name)}')
        except Exception as e:
            QMessageBox.critical(self, '错误', f'保存失败:\n{e}')

    @pyqtSlot()
    def import_otpauth(self):
        text, ok = QInputDialog.getText(
                self, '导入 otpauth 链接',
                '请粘贴完整的 otpauth://totp/... 链接:',
                text='')
        if not ok or not text.strip():
            return

        try:
            name, secret, issuer = parse_otpauth(text)
        except ValueError as e:
            QMessageBox.critical(self, '解析失败', str(e))
            return
        if name in self.accounts:
            QMessageBox.warning(self, '已存在', f'账户“{name}”已存在!')
            return
        # 保存
        self.accounts[name] = {"secret": secret, "issuer": issuer}
        save_accounts(self.accounts)
        self._insert_account(name, secret, issuer)
        QMessageBox.information(self, '成功',
                                f'已导入账户:{name}\n发行方:{issuer or "无"}')

# ---------- 启动 ----------
if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    w = MainWindow()
    w.show()
    sys.exit(app.exec_())

跑起来就是个大号数字窗,每秒刷新,截图发给朋友也泄露不了——毕竟 30 秒后就作废。


7. 进阶:为啥不用 SHA-256?

虽然默认是 SHA-1,但 RFC 6238 早就预留算法协商字段。
把 URI 改成:

otpauth://totp/ACME:x@x.com?secret=...&algorithm=SHA256&digits=6&period=30

两端都支持即可。实测 SHA-256 动态截断后分布更均匀,但计算量翻倍,老旧设备 30 秒一刷会掉电快。安全与性能自己权衡。


8. 小结(再省流)

  1. 共享密钥 + 时间片 → HMAC → 截断 → 模 100 万 → 6 位数字。
  2. 30 秒一换,服务器多验前后窗口,抗延迟。
  3. 密钥不二次传输,丢手机=丢密钥,记得有备用码。
  4. RFC 6238 是公开标准,看懂就能自己实现,不需要“魔法”。

好了,今天先唠到这儿。
下次如果有人问你:“为啥这 6 位数字不用联网还能一直变?”
就把这篇文章甩给 TA,然后深藏功与名。

(完)