从零实现TOTP:打造兼容Google Authenticator的动态验证码生成器
发布时间:2026/10/18 11:59:57 锦皓数字建站

在写自动化脚本、做多账户统一管理或者单纯想弄明白手机里那个每30秒跳一次的数字到底怎么算出来的自己照着 TOTP 协议实现一个兼容 Google Authenticator 的认证工具、生成6位验证码是非常值得动手的练手项目。标题拆开看就三件事TOTP 算法、Google Authenticator 兼容、6位动态验证码。这篇文章不扒官方源码而是从协议原理讲起带着写一个能直接跑起来的命令行工具并记录我调试过程中的各种坑。1. 项目拆解这标题到底想解决什么问题1.1 标题的三个关键词拆开就清楚了先聊标题里的“TOTP”。TOTP 全称 Time-based One-Time Password翻译过来就是“基于时间的一次性密码”。它的核心逻辑是客户端和服务器事先约定一个共享密钥再根据当前时间算出一个数字这个数字每隔一段时间就变化。你手机上的 Google Authenticator 用的就是这个 RFC 6238 标准的算法每次生成6位数字30秒一换。再看“Google Authenticator 认证工具”。很多人以为 Google Authenticator 是个专属 App其实它的兼容性非常广任何实现了 TOTP 算法的工具都能生成相同的6位验证码。我用自己写的脚本生成的同时手机上的官方 App 也在走同一个算法两边应该显示一样的数字。所以标题真正要做的事情是“兼容它的算法”而不是仿冒它的界面。最后是“获取6位验证码”。这6位数字不是凭空来的而是由复杂运算得出来的。它看起来随机实际上是确定性的只要种子密钥、当前时间、时间步长和算法参数一致任何人都能算出同一个结果。1.2 为什么这工具值得自己写一遍市面上有现成的 Python 库比如 pyotp一行就能生成验证码。但直接用库你永远不知道背后发生了什么。我自己写一遍至少有三个收获第一彻底搞懂两因素认证的原理。当你理解了共享密钥 时间窗口 HMAC-SHA1 之后再看到任何扫码登录界面脑子里就会有完整的画面。第二能在自动化环境里跑验证码。比如你想做一个小脚本定期登录某个需要用双因素认证的服务但不想每次手动掏手机。这时候自己实现的工具可以集成到脚本里配上参数就能输出验证码。第三排查问题时不用看天书。遇到验证码对不上、时间不同步、Base32 解码报错如果你没读过算法细节根本不知道从哪下手。自己写过一遍问题定位快得多。1.3 可行性评估和时间成本这个项目不需要复杂的依赖我用最基础的 Python 标准库就能完成HMAC、hashlib、struct、base64、time 这些模块都是自带的。如果你只是想验证算法十几分钟就能写个原型想做到支持扫码、多账户、容错窗口这些功能半天时间也足够了。我当时从零到跑通第一版大概花了二十分钟。期间踩了 Base32 padding、时间戳取整、动态截断偏移量这几个坑后面我会详细展开。2. TOTP 核心原理6位数字是怎么算出来的2.1 共享密钥与时间计数器TOTP 的输入有三个共享密钥、时间计数器、参数配置。共享密钥就是大家在扫码时看到的那个一串字符它可能是JBSWY3DPEHPK3PXP这种样子。这个密钥通常是 Base32 编码的因为 Base32 只用了大写字母 A-Z 和数字 2-7方便手工输入也能避免把 0 和 O、1 和 I 搞混。时间计数器则是一个不断递增的整数。TOTP 不是直接用当前时间的秒数而是把时间戳除以时间步长后再向下取整。标准步长是30秒所以计数器就是当前UNIX时间戳 // 30。比如说当前时间戳是1750000000除以30得到58333333这个整数就是计数器值。30秒之内不管你怎么算计数器都一样验证码不变一过30秒整点计数器加1验证码就变了。这里有个关键点服务器和客户端之间即使时间有差异只要差异在容差范围内验证码还能对上。所以“基于时间”既是优点也是风险点后面排查部分会讲到。2.2 HMAC-SHA1 和动态截断有了密钥和计数器接下来要用 HMAC-SHA1 计算一个20字节的摘要。HMAC 是“密钥化哈希运算”简单理解就是带着密钥去算哈希。TOTP 标准的默认哈希算法是 SHA1这也是 Google Authenticator 默认使用的。光有20字节的摘要还不够我们只需要6位数字所以要从摘要里截取一段。截取方式叫“动态截断”步骤如下取摘要结果的最后一个字节只看它的低4位得到偏移量 offset范围是 0 到 15。从摘要的第 offset 字节开始连续取4个字节32位。把这32位整数最高位去掉也就是跟0x7fffffff做与运算避免得到负数。然后用这个整数对10^6取模得到0到999999之间的数。不足6位左侧补0就成了我们看到的6位验证码。为什么用动态截断因为直接取摘要的一部分不够随机。最后一位字节的低4位作为偏移相当于在摘要里挑一个随机位置不同时间算出来的验证码分布更均匀也更容易满足安全要求。2.3 手把手推演一个6位验证码我用一个具体例子把流程走一遍。假设种子密钥是JBSWY3DPEHPK3PXP这个密钥在官方示例里经常出现它对应的字节序列其实是 ASCII 字符 “Hello!” 的 Base32 编码。第一步把密钥 Base32 解码成字节串。第二步假设当前计数器值为2000000000把计数器转成8字节的大端整数也就是用struct.pack(Q, counter)得到二进制数据。第三步用密钥的原始字节和这个二进制数据计算 HMAC-SHA1得到20字节摘要。第四步取最后一个字节0xa7的低4位0x07也就是偏移量7。第五步从摘要第7字节开始取4字节0x9d 0x2f 0x3a 0x1b组成0x9d2f3a1b去掉最高位后对1000000取模得到983234之类的结果。最后补零。每一步都是确定性的所以只要算法一致我的脚本算出来的数字和 Google Authenticator 显示的一定一样。如果不一样说明某个环节有偏差通常是时间窗口或者密钥。3. 动手实现一个可用的 TOTP 命令行工具3.1 环境准备与基础约定我选择 Python 3 来实现因为标准库丰富跨平台也方便。不需要额外安装任何第三方库。写之前约定参数密钥使用 Base32 编码的字符串时间步长默认30秒验证码长度默认6位哈希算法默认 SHA1。这些都是在 TOTP 标准里最常见的配置也决定了我们能和主流认证 App 互通。3.2 Base32 解码函数密钥是 Base32 字符串但 Python 标准库的base64.b32decode要求字符串长度必须是8的倍数并且要有正确的 padding。实际使用中很多二维码里的密钥并没有 padding比如JBSWY3DPEHPK3PXP长度是16已经满足8的倍数但有些密钥长度不是8的倍数就需要手动补上等号。普通用户输入的密钥还可能带空格、小写字母或者混入分隔符比如jbsWY3DP EHPK3PXP。所以解码前要先清理字符串去掉空格和连接符转成大写然后补 padding。处理后用base64.b32decode解码得到字节串。import base64 import re def base32_decode(secret): # 清理去掉空白字符和连字符 cleaned re.sub(r[\s-], , secret).upper() # 补齐 padding密钥长度必须是8的倍数 padding * ((8 - len(cleaned) % 8) % 8) return base64.b32decode(cleaned padding)这段代码看似简单却解决了我踩过的第一个大坑。最开始我直接调用b32decode而不补 padding当密钥长度不是8的倍数时会抛binascii.Error。补 padding 的公式是(8 - len % 8) % 8当长度正好是8的倍数时结果为0不会多加等号。3.3 核心 TOTP 计算函数核心函数就是根据当前时间戳生成验证码。逻辑如下import hmac import hashlib import struct import time def get_totp_token(secret, timestampNone, step30, digits6): if timestamp is None: timestamp int(time.time()) counter timestamp // step key base32_decode(secret) msg struct.pack(Q, counter) digest hmac.new(key, msg, hashlib.sha1).digest() offset digest[-1] 0x0F binary struct.unpack(I, digest[offset:offset 4])[0] 0x7FFFFFFF token binary % (10 ** digits) return str(token).zfill(digits)拆开解释一下timestamp // step是整数除法直接算出当前时间落在哪个30秒窗口。这里建议用int(time.time())而不是直接用浮点数避免除法的精度问题。struct.pack(Q, counter)把计数器编码为8字节大端整数Q表示无符号64位整数。TOTP 标准里要求计数器是64位的大端表示。hmac.new(key, msg, hashlib.sha1)用来计算 HMAC-SHA1 摘要。digest()返回20字节原始哈希值。动态截断部分digest[-1] 0x0F取最后一个字节的低4位得到0到15的偏移量。然后从摘要的对应位置切4字节struct.unpack(I, ...)解析为32位无符号整数。和0x7FFFFFFF做与运算去掉符号位这一步是 RFC 4226 规定的防止最高位导致结果变成负数。最后取模10 ** digits得到1000000取余就是0到999999。用zfill(digits)补足前导零确保输出一定是6位字符串。3.4 otpauth:// URI 与二维码支持要生成一个能被 Google Authenticator 扫码添加的账户密钥必须以 otpauth URI 格式呈现。标准的 URI 格式如下otpauth://totp/Issuer:Account?secretJBSWY3DPEHPK3PXPissuerIssueralgorithmSHA1digits6period30Issuer:Account用来给用户标识账户secret是 Base32 密钥issuer参数是服务方名称。有些旧版 App 只认secret但加上这些参数更能保证兼容。如果你要生成二维码可以引入第三方库qrcode或者调用命令行工具生成。我这里避免引入重量级依赖只演示 URI 的构造函数二维码部分知道原理即可。真正扫码时可以用任何二维码生成器把这个 URI 转成图片。from urllib.parse import quote def build_otpauth_uri(secret, account, issuerMyTool, algorithmSHA1, digits6, period30): label f{issuer}:{account} params ( fsecret{secret} fissuer{quote(issuer)} falgorithm{algorithm} fdigits{digits} fperiod{period} ) return fotpauth://totp/{quote(label)}?{params}这里quote做 URL 编码很重要。账号里如果有空格、斜杠、中文字符不编码会导致扫码失败。我见过有人把账号写成了John DoeURI 里直接带空格结果 App 识别不出来。3.5 命令行封装把前面三个函数整合成一个可直接运行的脚本再从命令行接收参数。我支持两种用法直接传入密钥输出当前的6位验证码。传一个包含多个账户密钥的文件批量输出所有验证码。第一种用法代码if __name__ __main__: import argparse parser argparse.ArgumentParser(descriptionTOTP Token Generator) parser.add_argument(-s, --secret, requiredTrue, helpBase32 encoded secret key) parser.add_argument(-t, --timestamp, typeint, helpunix timestamp (default now)) parser.add_argument(-d, --digits, typeint, default6, helptoken digits (default 6)) parser.add_argument(--step, typeint, default30, helptime step seconds (default 30)) args parser.parse_args() print(get_totp_token(args.secret, args.timestamp, args.step, args.digits))这样在终端里执行python totp.py --secret JBSWY3DPEHPK3PXP输出一行6位数字比如483912。你可以和手机上的 Google Authenticator 对一下正常情况下数字完全一致。批量场景下我会用一个配置文件每行格式为账户名 密钥脚本遍历所有行输出验证码。这在管理多个测试账户时非常方便。4. 实测与兼容性验证4.1 用官方 App 扫码对比第一个测试方法是用上面生成的 otpauth URI 转成二维码再拿 Google Authenticator 扫码添加。添加成功之后App 上每30秒跳一次数字。我同时在终端运行脚本把两个数字并排对比。我第一次测试就发现脚本输出的数字和 App 不一样差了很大一段。后来意识到是我的脚本里time.time()返回的是浮点数在转换成整数时和 App 内部处理不一致。准确做法应该是先取整再除步长我改成int(time.time() // step)注意这里的time.time()本身就是浮点但先地板除再取整效果不同。其实更稳妥的是int(time.time()) // step。改正之后两个数字终于能对上了。这里值得多说一句兼容性验证不要只在某一个时刻看最好观察两个连续时间窗口。因为如果时间边界处理有偏差可能在某个30秒的中间显示正确但在整点切换时差出6位。我习惯在 App 倒计时剩5秒时连续跑三次脚本确保每次输出的数字都和 App 同步。4.2 时钟同步与容差设置TOTP 对时间差特别敏感。我的电脑如果时钟和手机差了十几秒验证码就可能对不上。解决办法有两个第一让服务器端或验证方允许一定的时间偏移。很多服务端会允许前后各一个时间步长的容错窗口也就是30秒的差值。我的脚本也加了-t参数可以传入一个向后偏移的时间戳用于模拟时钟偏差场景。第二本机启动 NTP 时间同步。在 Linux 上可以用systemctl enable --now systemd-timesyncdWindows 上开启自动同步。实际工作中我发现很多验证码错误并不是算法问题而是测试者的虚拟机时钟狂奔了刷新一下时间就好。4.3 读取已有设备中的种子并迁移另一个常见的需求是你已经有一个 Google Authenticator 账户但想把它迁移到自己的工具里。官方 App 通常提供“导出账户”功能导出时会让你输入一个 PIN然后生成一组二维码。这些二维码内容就是 otpauth URI里面包含了种子密钥。拿到 URI 后提取其中的secret参数填进我的脚本就能继续生成验证码。这里有个安全提醒导出过程和保存密钥的过程一定要在可信环境里操作密钥一旦泄露任何拿到它的人都能冒充你完成两因素认证。5. 常见问题排查速查表5.1 验证码总是差几位问题出在哪表现是同一个时间点脚本输出的数字和 App 不一样或者验证总是失败。可能的嫌疑排序时间不同步先执行date看一下系统时间再和手机时间对比。密钥没清理干净比如复制的时候带了空格或者换行。Base32 解码时 padding 算错了。计数器取整方式不对确认是int(timestamp) // step而不是timestamp // step时把浮点数保留在小数位。动态截断偏移量写错比如取了第四个字节而不是低四位。我踩过最隐蔽的坑是在 Windows 上使用time.time()得到的浮点数精度是足够的但 Python 在struct.pack(Q, counter)里如果传入的是浮点数即使值看起来是整数某些版本里也会报错。所以一定要先int()转换。5.2 Base32 解码报错binascii.Error: Non-base32 digit found是比较常见的错误。这通常是因为密钥里有数字1、0或者小写l它们不是合法的 Base32 字符。Base32 字符集只包含A-Z和2-7你应该让用户在上层就先校验。还有一种是Incorrect padding错误。我的基类解码函数补了 padding但如果你直接复制网上的算法可能忽略了对长度不是8倍数的处理。记住公式padding * ((8 - len(cleaned) % 8) % 8)。5.3 多个账户怎么管理当账户多起来每次手动输入密钥就很痛苦。我的做法是维护一个文本文件每行存账户名:密钥。批量输出时读取文件对每一行调用get_totp_token然后格式化成表格打印。要注意的是不要在密码管理器之外明文存储密钥文件。至少要对文件设置权限避免其他用户读走。某些场景下也可以把密钥放到环境变量里脚本启动时读取这样不落盘。5.4 安全注意事项这些底线不能破第一种子密钥等于两因素认证的“第二因素”本身。谁拿到密钥谁就能生成验证码所以密钥必须像密码一样严格保管。不要在 GitHub 示例代码里放真实密钥不要写进日志不要随意截图。第二验证码本身也很敏感。它只有30秒有效期但在这30秒内就相当于你的登录凭证。脚本输出验证码后如果终端日志被记录验证码会随之泄漏。我一般在脚本里不打印完整密钥只打印验证码。第三传输和存储要加密。如果你做了一个 Web 服务来调用这个工具务必用 HTTPS防止中间人截获 otpauth URI 或验证码。第四时间步长不要轻易改短。改短到10秒意味着验证码刷新太快容易出现时间窗口错过也会降低安全性。除非有特殊业务需求30秒是标准选择。6. 最后分享一点我的实操习惯项目本身并不复杂但我在完成之后养成了一个习惯不再手动复制密钥到在线生成器而是把种子密钥放在本地的加密凭证库里需要时用脚本生成验证码然后把输出复制到目标网站。这样既省去了掏手机的步骤又确保了密钥不经过第三方服务。如果你后续想扩展可以做两个方向。一是支持 Steam 风格的字母验证码原来 Steam 的令牌不是纯数字但底层原理非常相似二是把脚本封装成 Web API 或者手机端的小程序方便多设备使用。不过无论怎么扩展核心还是 TOTP 的那五步解码密钥、算计数器、算 HMAC、动态截断、取模补零。把这几步吃透以后遇到任何两因素认证工具你都能一眼看穿它的本质。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。