otpauth-migration:// 里面装了什么:Google Authenticator 导出码的结构逐字段拆解

· 约 5 分钟 📲 Authenticator 迁移

在 Google Authenticator 里点「转移账号 → 导出账号」,会得到一张二维码。用普通扫码 App 扫它,你只会看到这样一串东西:

otpauth-migration://offline?data=CjEKCkhlbGxvId6tvu8SGEV4YW1wbGU6YWxpY2VAZ29vZ2xlLmNvbRoHRXhhbXBsZTAC...

它不是链接,不是密钥,也不是加密的。它是四层包装,剥开就是一份明文的账号清单。

四层包装

内容
1otpauth-migration://offline?data= 后面跟一段 URL 编码文本
2URL 解码后是 Base64
3Base64 解码后是一条 protobuf 消息
4protobuf 里是账号数组,每个账号含密钥、名称、算法、位数、类型

扫码 App 只认得第 1 层,所以它只能把整串原样吐给你。要看见里面的账号,必须一直剥到第 4 层——这就是 Authenticator 迁移 做的事,整个过程在浏览器本地完成。

顺带说明:这四层里没有任何一层是加密。Base64 和 protobuf 都是编码,不是保护。拿到这张码就等于拿到了里面所有账号的长期密钥。

MigrationPayload 的完整结构

这份 schema 不是哪个标准定的,是 GA 客户端自己定的,字段号一旦定下就不能改(改了旧版本导出的码就解不开)。等价的 .proto 是:

message MigrationPayload {
  repeated OtpParameters otp_parameters = 1;
  int32 version     = 2;
  int32 batch_size  = 3;
  int32 batch_index = 4;
  int32 batch_id    = 5;

  enum Algorithm {
    ALGORITHM_UNSPECIFIED = 0;
    ALGORITHM_SHA1        = 1;
    ALGORITHM_SHA256      = 2;
    ALGORITHM_SHA512      = 3;
    ALGORITHM_MD5         = 4;
  }
  enum DigitCount {
    DIGIT_COUNT_UNSPECIFIED = 0;
    DIGIT_COUNT_SIX         = 1;
    DIGIT_COUNT_EIGHT       = 2;
  }
  enum OtpType {
    OTP_TYPE_UNSPECIFIED = 0;
    OTP_TYPE_HOTP        = 1;
    OTP_TYPE_TOTP        = 2;
  }

  message OtpParameters {
    bytes      secret    = 1;
    string     name      = 2;
    string     issuer    = 3;
    Algorithm  algorithm = 4;
    DigitCount digits    = 5;
    OtpType    type      = 6;
    int64      counter   = 7;
  }
}

三个枚举,三个坑

这段结构里最容易写错的地方全在枚举上,因为枚举值和它表示的数字没有关系

digits = 1 表示 6 位

DIGIT_COUNT_SIX   = 1
DIGIT_COUNT_EIGHT = 2

不是「digits 字段等于 6」。手写解析器时把 digits 直接当位数用,会得到「1 位验证码」这种荒唐结果,或者更糟——默默按 6 位算,八位的账号全部出错码。

type = 1 是 HOTP,2 才是 TOTP

顺序和直觉相反。TOTP 是绝大多数账号用的类型,却排在 2。判错的表现是 TOTP 账号被当成 HOTP,于是计数器取 0、验证码永远停在同一个值上。

0 是 UNSPECIFIED,不是错误

三个枚举都有一个 0 值。它不代表解析失败,代表「用默认值」

字段UNSPECIFIED 时应落到
algorithmSHA1
digits6
typeTOTP

把 0 当成异常抛出来,会让一批完全正常的账号解析失败。

secret 是原始字节,不是 Base32

这一条最容易踩:

bytes secret = 1;

bytes原始二进制。而所有验证器 App 和 otpauth:// 链接里用的密钥都是 Base32 文本。所以拿到 payload 之后还要自己做一次编码:

Base32(RFC 4648 字母表 A–Z + 2–7) → 去掉尾部的 = 填充

去填充是因为 otpauth:// 的 secret 参数惯例不带 =。带着也不算错,多数验证器都认,但重新生成的链接和原始链接就对不上字面了。

验证自己有没有编码错:拿解出来的 Base32 密钥算一个当前验证码,和手机上的 GA 比一比。对得上就是对的,这比逐字节比对快得多。

payload 里没有的东西

同样重要的是它不携带什么

  • period(周期) —— GA 固定 30 秒,不支持自定义,所以这个字段压根不存在。重建 otpauth:// 时只能按 30 填
  • issuer 的规范形式 —— name 字段常常是 GitHub:me@example.com 这种把发行方拼在标签前面的写法,而 issuer 又是一个独立字段,两者可能重复、可能冲突、也可能只有一个有值
  • 图标、备注、排序 —— 纯客户端的东西,不进 payload

推论很直接:用非默认周期的账号,不要指望通过 GA 的导出码搬家。信息在导出那一步就已经丢了,导回去会算出错的码。这类账号要么留着原始 otpauth:// 链接,要么用服务方给的恢复码重新绑定。密钥备份的几条路和各自的安全性排序,见 2FA 验证器怎么迁移;单条 otpauth:// URI 的参数逐个说明见 otpauth:// 二维码里到底存了什么

账号多会拆片

一张二维码能装的字节有上限,账号多时 GA 会自动拆成好几张,靠顶层三个字段标序:

字段含义
batch_size这次导出一共几张
batch_index这是第几张(从 0 开始)
batch_id同一次导出的多张,这个值相同

batch_id 是防串的关键。分两次导出会得到两组码,如果只按 index 拼装,把两次的码混在一起会得到一份缺东少西又不报错的结果。正确做法是先按 batch_id 分组,组内再按 index 排序,并检查 0 到 batch_size - 1 是否齐全。

protobuf 会静默跳过未知字段

这是解析这类数据时最需要防的一件事:protobuf 遇到 schema 里没有的字段号不会报错,直接跳过

好处是向前兼容,坏处是——哪天 GA 改了字段编号,解析器不会有任何提示,只会安静地少解出一些东西。用户看到的是「怎么少了两个账号」,而程序认为一切正常。

稳妥的做法是自己扫一遍顶层的 wire format,把出现过但 schema 不认识的字段号收集起来,据此提示「结果可能不完整」。这不需要解析值,只要按 wire type 跳过长度即可:

wire type含义跳过方式
0varint逐字节读到最高位为 0
164 位跳 8 字节
2长度前缀先读 varint 长度,再跳这么多
532 位跳 4 字节

「解析出来是空的」和「解析出来少了一半」是两种完全不同的故障,前者用户一眼看得出,后者要等到某个账号登不上才发现。

用 protoc 手工解一份

不想用工具,命令行也能走完全程:

# 1. 从二维码得到 URI,取出 data 参数并 URL 解码
# 2. Base64 解码成二进制
echo "$DATA" | base64 -d > payload.bin

# 3. 不写 .proto 也能看结构
protoc --decode_raw < payload.bin

--decode_raw 会按字段号和 wire type 把结构打印出来,对照上面的表就能读。注意 secret 那一项打出来是转义过的字节串,还要自己 Base32 编码一遍才能给验证器用。

最后:这张码的安全性质

动态码几十秒就失效,密钥是长期凭证。拿到导出码等于永久绕过这些账号的两步验证,而且它既没加密也没有效期。

  • 不要在公共电脑或别人的设备上解析
  • 不要把解析结果截图外发、存进相册或聊天记录
  • 迁移完先在新设备上逐个核对验证码,确认无误再删旧设备上的账号
  • 用完就关掉页面——Authenticator 迁移 的结果只在内存里,刷新即清空,不写 localStorage

❓ 常见问题

我用别的工具解出来的 Base32 密钥,和这里显示的不一样?

先比对去掉填充和统一大小写之后的部分三处常见差异:(1) 填充 —— RFC 4648 的 Base32 会用 = 补齐到 8 的倍数,而 otpauth:// 链接里的密钥习惯不带填充,两种写法解出来的字节完全相同;(2) 大小写 —— Base32 字母表是大写 A–Z 加数字 2–7,小写只是显示风格,验证器都认;(3) 空格分组 —— 有些工具每 4 位加一个空格方便手抄。真正不同的情况只有一种:解码后的字节不一样,那说明有一方解析错了。验证办法:拿两边的密钥各算一个当前验证码,能对上就是同一个密钥。

为什么 payload 里找不到 period(30 秒)这个字段?

因为 Google Authenticator 不支持自定义周期,它把这个值写死了后果:(1) 解析出来的账号一律按 30 秒周期重建 otpauth:// 链接,这对绝大多数服务是对的;(2) 如果某个服务用的是 60 秒周期,你先把它加进 GA 再导出,这个信息在导出环节就已经丢了——导回去会算出错误的验证码。同理丢失的还有 issuer 的规范形式:payload 里的 name 常常是 GitHub:me@example.com 这种把发行方拼在前面的写法,和独立的 issuer 字段可能重复或冲突。结论:非默认参数的账号不要指望用 GA 的导出码搬家,直接留原始的 otpauth:// 链接或恢复码。

算法是 MD5 的账号为什么算不出验证码?

因为 MD5 不在 RFC 4226 / 6238 的范围里背景:GA 的 proto 里 Algorithm 枚举确实留了 ALGORITHM_MD5 = 4,但 HOTP 规范定义的是 HMAC-SHA1,TOTP 扩展到 SHA-256 和 SHA-512,MD5 从来不是合法取值实际影响:(1) 主流验证器都不支持,导进去也不出码;(2) 这类条目多半来自某些非标准实现或测试数据。能做的:把 Base32 密钥保留下来,回到原服务重新绑定一次两步验证,让它签发一个标准参数的新密钥。其他非默认参数(8 位、SHA-256、HOTP)是规范内的,解析和重建都正常,只是部分验证器 App 自己不支持。

解出来的一堆账号,能再打包回一张二维码吗?

可以,这正是反向操作用途:(1) 从密码管理器或手抄的密钥批量迁回 Authenticator;(2) 把散落在多处的 otpauth:// 链接合并成一张码,新手机扫一次就全进去了;(3) 账号多时按 batch_size 拆成多张。做法:把一条条 otpauth:// 链接或 Base32 密钥交给 Authenticator 批量导入,它按同一套 protobuf 结构编码出 otpauth-migration:// 的码。注意:生成的码和导出码一样是明文密钥的容器,扫完就该销毁,不要存进相册或聊天记录。

📲 打开 Authenticator 迁移 解析 Google Authenticator「导出账号」二维码 · 换手机批量取回密钥 · Base32 与 otpauth 链接 · 逐个出码可再扫 · 本地解析不上传

🔗 相关阅读

全部教程 →
2FA 验证码总是对不上?TOTP 时间步、SHA 算法与 Base32 密钥三类坑排查
自己实现两步验证、或换手机想恢复 Google Authenticator 时,最常见的崩溃是"算出来的 6 位码和服务端就是对不上"。这篇讲清 TOTP 是怎么用密钥和时间算出动态码的,再按设备时间、算法/位数/周期、Base32 密钥格式三类高频原因逐一排查,并说明备份密钥如何手动出码与跨设备恢复。
手机丢了 / 换新机,2FA 验证器怎么迁移:TOTP 密钥备份的 5 条路和 3 个坑
换手机时 Google Authenticator 里的验证码不会自动跟过去——它存的是本地密钥,不是账号。这篇讲清 TOTP 密钥到底是什么、导出二维码里藏了什么、5 种备份方式的安全性排序,以及手机已经丢了、恢复码也没留时还能怎么抢救。
把散落各处的 2FA 密钥收拢到一起,顺便评估迁移码能不能当备份
密钥分散在密码管理器、旧手机、几张截图和一叠恢复码里,哪个账号在哪儿自己都说不清。这篇给出五种来源各自的取法、收拢进一个验证器的完整流程、迁移二维码作为备份介质的三个优点和三个致命缺点,以及哪些账号根本不该只靠 TOTP。
otpauth:// 二维码里到底存了什么:secret、algorithm、digits、period 逐参数详解
扫 2FA 二维码时你交出去的是一串 otpauth:// URI。这篇逐个拆解它的 7 个参数——Base32 密钥怎么校验、issuer 为什么会重复、SHA-256 为什么很多验证器不认、counter 型 HOTP 和 TOTP 差在哪,以及手工拼 URI 时最容易写错的三处。