在 Google Authenticator 里点「转移账号 → 导出账号」,会得到一张二维码。用普通扫码 App 扫它,你只会看到这样一串东西:
otpauth-migration://offline?data=CjEKCkhlbGxvId6tvu8SGEV4YW1wbGU6YWxpY2VAZ29vZ2xlLmNvbRoHRXhhbXBsZTAC...
它不是链接,不是密钥,也不是加密的。它是四层包装,剥开就是一份明文的账号清单。
四层包装
| 层 | 内容 |
|---|---|
| 1 | otpauth-migration://offline?data= 后面跟一段 URL 编码文本 |
| 2 | URL 解码后是 Base64 |
| 3 | Base64 解码后是一条 protobuf 消息 |
| 4 | protobuf 里是账号数组,每个账号含密钥、名称、算法、位数、类型 |
扫码 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 时应落到 |
|---|---|
| algorithm | SHA1 |
| digits | 6 |
| type | TOTP |
把 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 | 含义 | 跳过方式 |
|---|---|---|
| 0 | varint | 逐字节读到最高位为 0 |
| 1 | 64 位 | 跳 8 字节 |
| 2 | 长度前缀 | 先读 varint 长度,再跳这么多 |
| 5 | 32 位 | 跳 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