扫一张 2FA 二维码只需要一秒,但那张图里编码的是一条完整的 URI,决定了之后每一次登录能不能过。当你需要手工迁移密钥、自建服务发二维码、或者排查「码就是对不上」时,都绕不开读懂这条 URI。
一条典型的完整 URI:
otpauth://totp/GitHub:zhangsan%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1&digits=6&period=30
结构总览
| 段 | 值 | 是否必需 | 默认值 |
|---|---|---|---|
| scheme | otpauth | 必需 | — |
| type | totp / hotp | 必需 | — |
| label | GitHub:zhangsan | 建议 | — |
secret | Base32 密钥 | 必需 | — |
issuer | 发行方名称 | 强烈建议 | — |
algorithm | SHA1 / SHA256 / SHA512 | 可选 | SHA1 |
digits | 6 / 7 / 8 | 可选 | 6 |
period | 秒数 | 可选(仅 totp) | 30 |
counter | 整数 | 仅 hotp 必需 | — |
真正必需的只有 secret。其余参数的默认值恰好覆盖了绝大多数真实服务,所以很多二维码里干脆不写——但这不代表你可以随便填,手工拼 URI 时写了和服务端不一致的值,码会永远对不上。
secret:唯一真正要备份的东西
secret 用 Base32 编码,字母表来自 RFC 4648:
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z 2 3 4 5 6 7
注意这 32 个字符里没有 0、1、8、9。这是刻意设计——排除掉和 O、I、B、g 视觉容易混淆的数字。
由此得到一条极其好用的自检规则:
密钥里只要出现 0、1、8、9 中任何一个,一定是抄错了。
纸质备份最高频的事故就是把 O 抄成 0、把 I 抄成 1。有了这条规则,不用登录冒险就能当场发现。
其他细节:
- 服务端展示时常每 4 位加一个空格(
JBSW Y3DP EHPK 3PXP),粘贴时多数实现会自动去空格,少数不会——保险起见自己去掉 - 末尾可能有
=填充,绝大多数验证器允许省略 - 规范上是大写,多数实现会自动转换,但手工填时统一用大写不吃亏
algorithm:写了也可能被忽略
RFC 6238 允许 SHA-1、SHA-256、SHA-512 三选一,默认 SHA-1。
问题在于生态:Google Authenticator 等主流实现长期硬编码 SHA-1,直接无视 URI 里的 algorithm 参数。后果是——服务端配了 SHA-256,验证器仍按 SHA-1 算,码永远对不上,而错误提示只会说「验证码错误」,排查起来非常折磨。
那 SHA-1 安全吗?在这个场景下不是短板:
- TOTP 用的是 HMAC-SHA-1,不是裸 SHA-1。SHA-1 已知的碰撞攻击威胁的是数字签名场景,对 HMAC 构造并不适用
- 码只有 6 位、30 秒失效,服务端还有失败次数限制——真实防线在限流上,不在哈希强度上
实务结论:自建服务就用默认 SHA-1。真要用 SHA-256,必须在绑定页面显著提示用户选择支持的验证器,否则用户流失在这一步。
排查「码永远对不上」时,algorithm 不匹配是仅次于时间不同步的第二大原因,完整排查顺序见2FA 验证码总是对不上的三类坑。
digits 和 period:改动是负收益
这两个参数最常被「安全加固」的名义改掉,但算一下就知道不划算。
digits 从 6 改到 8:
- 收益:暴力破解空间从 100 万扩到 1 亿
- 但真实防线是服务端的失败次数限制(通常 5 次)。在 5 次上限下,100 万和 1 亿的差别趋近于零
- 代价:部分验证器只显示前 6 位,直接不可用
period 从 30 改到 60:
- 收益:用户输入更从容
- 代价:一个码的有效窗口翻倍,被钓鱼骗走后的可利用时间也翻倍
- 同样存在验证器硬编码 30 秒忽略该参数的情况
真正提升安全性的做法是服务端限流、异常登录检测、以及给用户准备好恢复码,而不是把 6 位调成 8 位。除非有明确合规要求,三个可选参数全部保持默认。
totp 与 hotp
两者算法内核完全相同,差别只在计数器从哪来:
| TOTP | HOTP | |
|---|---|---|
| 规范 | RFC 6238 | RFC 4226 |
| counter | 当前 Unix 时间 ÷ period | 一个真实的递增整数 |
| 码何时变 | 每 period 秒自动 | 每用一次 |
| 会过期吗 | 会 | 不会,直到被用掉 |
| URI 必需参数 | secret | secret + counter |
| 典型故障 | 时间不同步 | 计数器失步 |
HOTP 的经典翻车:你在 App 上多点了几下生成码却没用,客户端 counter 跑到了服务端前面,之后全部对不上。服务端一般开一个前瞻窗口(往后试 10 个)容错,超出就得重新绑定。
现在还能遇到 HOTP 的地方:少数硬件令牌、老式银行动态口令牌、部分企业 VPN——共同点是设备没有电池驱动的时钟,用不了时间做计数器。新系统一律选 TOTP。
label 和 issuer 为什么都要写
看起来重复,但两个都得写,且必须一致。这是历史包袱:
- 早期规范只有 label,约定用
Issuer:Account的冒号写法 - 后来发现冒号在 URI 里要编码、解析容易出错,才补了独立的
issuer查询参数 - 为兼容新旧解析器,规范建议两处都写
写法要点:
@编码成%40,冒号可以写成%3A(多数实现也容忍原样的:)- 两处的发行方名称大小写要完全一致,否则验证器里会出现「GitHub」和「Github」两个看起来一样的条目
- label 的账号部分用邮箱或用户名——同一个服务有多个账号时,这是你唯一的区分依据
- 不要用中文,部分老验证器解析会乱码
手工拼 URI 前先本地验一遍
自己拼的 URI 别直接做成二维码发给用户,先用TOTP 工具本地跑一遍:粘入 secret,按 URI 里的参数逐项设置,看算出的码是否和服务端预期一致。
参数错和时间不同步的症状容易混淆,区分方法很简单:
时间不同步时,码会周期性地偶尔对上;参数不匹配时,码永远对不上。
验证通过后,再用二维码生成把完整 URI 编成图。生成的图片用完即删,别留在会自动同步到云端的目录——那张图是明文密钥,等同于把 2FA 交出去。
密钥的长期保管策略、换机迁移的正确顺序,见手机丢了 / 换新机 2FA 怎么迁移。