一段可以随便拍照的配对码——因为口令不在里面
Shellby 复盘:新设备接入跨栈同步要手输一堆敏感配置。做成一段口令加密的配对码、渲成二维码扫一下就好。关键设计是口令不进码、只当解密钥,于是配对码被拍照截图也无用;外加鸿蒙二维码白屏与桌面选图解码两个跨端坑。
Shellby 的跨栈同步让 iPhone、Android、Windows、Linux、鸿蒙用同一个第三方存储账号互相同步。新设备接入时要填一堆敏感配置:WebDAV 地址密码,或者 S3 的 endpoint / region / bucket / access key / secret。在手机上戳这些字符串是灾难。
于是做了配对码:已配好的一端把整份同步配置编码成一段口令加密的配对码,渲染成二维码;新设备扫一下(或粘贴),再输入同一个口令,就解出配置套用。Apple(Swift) 和 Flutter 双栈同格式互认。这篇讲它的密码学设计——尤其一个让配对码可以随便拍照的决定——和两个跨端的坑。
配对码里装了什么
格式是一个双栈冻结的契约:
shellby-sync-v1: + base64url( salt(16) | nonce(12) | 密文 | mac(16) )
密钥用 Argon2id 从口令派生(交互式轻量参数 memory=19 MiB / iterations=2 / parallelism=1,解密控制在几百毫秒,兼顾安全与体感),加密用 ChaCha20-Poly1305(IETF,12 字节 nonce)。载荷是一份 JSON:
{ "backend": "s3", "includeCredentials": true,
"s3Endpoint": "...", "s3Region": "...", "s3Bucket": "...",
"s3AccessKey": "...", "s3Secret": "...", "webdavPassword": "..." }
两项最敏感的凭据(s3Secret、webdavPassword)只在非空时才写入——「仅配置」模式或用户没填,就整个省略,另一端解出来是 nil。
关键设计:口令不进码
整套设计里最重要的一条:口令(passphrase)不进配对码,它只是解密钥。
为什么这很关键?因为这个口令本来就有另一个身份——它是加密同步档案的端到端口令。用户的同步数据在第三方存储上是密文,这个口令是唯一能解开它的钥匙,本就必须在所有设备上一致。配对码复用了它当解密钥。
这一步复用带来一个漂亮的性质:配对码被拍照、截图、甚至贴到群里,都没用。因为码里只有密文,没有口令;而口令要用户在新设备上手动输入。这就天然完成了「知道口令的人才能导入」这一层鉴权——不需要额外的签名、token 或时效,一个「知识因子」全包了。导入时代码显式把用户输入的口令也写进新设备的安全存储(注释:「新设备须存同一口令才能读写档案」),配对和「获得档案访问权」一步到位。
// 交互式配对用轻量 Argon2id,解密在几百毫秒内。口令不入载荷。
static Argon2id _kdf() =>
Argon2id(parallelism: 1, memory: 19 * 1024, iterations: 2, hashLength: 32);
final salt = _random(_saltLen);
final key = await _kdf().deriveKey(
secretKey: SecretKey(utf8.encode(passphrase)), nonce: salt);
final box = await _aead.encrypt(plain, secretKey: key, nonce: _random(_nonceLen));
return '$_prefix${base64Url.encode([...salt, ...box.nonce, ...box.cipherText, ...box.mac.bytes])}';
坑一:鸿蒙上二维码白屏,修了三次
二维码在 iOS/Android 上没事,鸿蒙(HarmonyOS)上一渲染,整个对话框白屏。这个坑修了三次才干净:
- 加文本码兜底:最初只有
qr_flutter的QrImageView,渲染失败对话框白屏。第一版给它加errorStateBuilder,并始终显示一个可复制的文本码,二维码挂了还能复制文本配对; - 发现兜底接不住:
errorStateBuilder只接编码错误,接不住鸿蒙渲染期抛的 render 异常——子树照样被带崩。于是用Platform.operatingSystem == 'ohos'判断,在鸿蒙上干脆隐藏二维码 widget、只留文本码,复制粘贴一样配对; - 换成自绘:最终彻底弃用
qr_flutter,改自绘——用qr包算出模块矩阵,CustomPainter逐格Canvas.drawRect。纯 Canvas 在鸿蒙上稳定,_isOhos那段隐藏逻辑也删了,全平台含鸿蒙都能正常显示二维码。
自绘还顺手做了两个小取舍:用最低纠错级 QrErrorCorrectLevel.L 换最大容量(配对码是较长的加密 base64,近距离扫码不需要高纠错,优先避免超容量生成失败);每格 drawRect 的宽高取 cell + 0.5,让相邻格子叠掉抗锯齿留的白缝——否则细白线会拉低识别率。
坑二:没有相机的设备靠「选图解码」
桌面(Windows/Linux/macOS)和鸿蒙很多设备没有相机,扫不了码。扫码路径于是按平台分流:
iOS / Android → mobile_scanner 相机实时扫
其它平台 → file_picker 选一张图 → image 包解像素 → zxing2 纯 Dart 解码
mobile_scanner 没有鸿蒙实现,但它不在鸿蒙的编译路径里(鸿蒙不走相机分支),所以鸿蒙构建照样通过。选了图却没解出二维码时,抛一个明确的 QrScanNotFoundException(提示「未识别到二维码」),和用户主动取消返回 null 的静默区分开——失败要说清是「没找到」还是「你取消了」。
双栈互认怎么保证
Apple 端是同一套格式的独立实现(ChaChaPoly + Argon2Swift,同参数):
let key = try deriveKey(passphrase: passphrase, salt: salt) // Argon2id 同参数
let sealed = try ChaChaPoly.seal(plain, using: SymmetricKey(data: key),
nonce: try ChaChaPoly.Nonce(data: nonce))
return prefix + base64urlEncode(salt + nonce + sealed.ciphertext + sealed.tag)
问题是:怎么确保 Swift 生成的码 Dart 一定解得开、反之亦然?答案不是让两栈互相跑一遍,而是引入第三方独立实现的共享向量:Tests/Fixtures/sync-pairing.json 里的配对码由一个 Python 独立实现(argon2-cffi + cryptography,固定 salt / nonce)生成,Swift 和 Dart 各自解码都必须得到逐字段一致的载荷。
为什么要第三方生成、而不是拿一栈的输出喂另一栈?因为两栈用同一套错误逻辑互测,会「错得一模一样」而互相通过。引一个独立实现当裁判,才能把这种共谋错误挡在外面。(有个细节:随机 nonce 本就让密文不逐字节相同,所以契约只约束字段名和类型一致,不约束 JSON 字段顺序。)
复盘
- 把「知识因子」复用成鉴权,能省掉一整套机制。口令既是档案的 E2E 钥、又是配对码的解密钥,于是配对码不含任何秘密、拍照截图都无用,「知道口令才能导入」这层鉴权不需要额外的签名或时效——一个用户脑子里的口令全包了;
- 敏感字段只在非空时才进载荷。「仅配置」模式下不导出凭据,是把「传不传密码」的选择权交给用户,而不是默认全带;
- 跨端渲染差异要有降级链,别赌一个组件在所有平台都行。鸿蒙二维码从「加文本兜底 → 隐藏 widget → 自绘」修了三次,教训是
errorStateBuilder这类「错误回调」接不住渲染期异常,真正稳的是不依赖那个有问题的组件; - 没有相机就选图解码,让能力缺失的平台走另一条路而不是没得用;失败还要区分「没找到」和「你取消了」;
- 双栈互认要靠独立的第三方向量,不能让两栈互测。同一套逻辑互相验证会「错得一样」而假通过,引一个独立实现当裁判才拦得住共谋错误。
留言