把 TLS 证书装进二维码——没有 CA 会为局域网里的一台 Mac 作保
灵犀的手机端和 Mac 端走 TLS 1.3,但证书链在这个场景里根本不存在。解法是让配对二维码直接携带证书本体,手机从此只信这一张。这篇讲三个决定:选 P-256 而不是 RSA、证书在进程内生成而不是调 openssl(以及不这么做时沙盒里发生了什么)、还有二维码为什么可以随便拍照。
灵犀 Lynsi 是我正在做的 HarmonyOS 手机与 Mac 之间的本地接力工具:手机的相册、剪贴板、屏幕直接出现在 Mac 上,全程局域网点对点,不经任何服务器(目前 App Store 与 AppGallery 双端审核中)。既然剪贴板和相册原图都在这条链路上跑,传输层加密就不是可选项——TLS 1.3。
然后马上撞到一个所有「局域网直连」产品都会撞到的问题:证书谁来签?
公钥基础设施在这里帮不上忙
TLS 的信任模型建立在证书链上:服务器出示证书,CA 为它作保,客户端信 CA。但没有任何 CA 会为「家庭局域网里一台 IP 随 DHCP 轮换的 MacBook」签发证书——它没有域名,没有固定地址,没有可验证的身份。
所以走另一条路:自签名证书 + 固定(pinning)。Mac 自己给自己签一张证书;手机在配对时拿到这张证书,此后只信任它,别的一概不信。
反直觉的是,在这个场景里这比公共 CA 体系更强:公共 CA 模型下,全球几百家 CA 中任何一家被攻破,都能签出你的客户端会接受的证书;而固定单张已知证书之后,没有任何第三方能签出会被接受的证书。信任面从「所有 CA」收缩到「这一张」。
剩下的问题只有一个:手机怎么安全地拿到这张证书?答案是配对二维码——证书本体(DER)直接编码进二维码。屏幕到摄像头是一条天然的带外信道:能扫到这张码,就说明人站在这台 Mac 前面。密钥分发问题整个消失了。
P-256,因为二维码扫得动
证书进二维码,尺寸立刻变成硬约束。RSA 证书的 DER 轻松超过 1KB;EC P-256 的只要几百字节。反映到二维码上,这是「举起手机一瞬间扫上」和「密到屏幕上根本读不出来」的差别。签名算法用 ECDSA-SHA256,Apple 的 CryptoKit 和 Security 框架原生支持。
另有两个和时间有关的小决定:
- 生效时间倒填一小时(
notValidBefore: now - 3600)。手机时钟只要比 Mac 慢几秒,一张刚生成的证书就会被判「尚未生效」而拒绝——首次配对当场失败,而且没人能想到是时钟的锅。 - 有效期十年。每台配对过的手机都固定了这张证书,证书过期不是「例行轮换」,是所有手机同时掉线、全部重扫一遍的事件。既然过期的代价是全员重配,就把它推到产品生命周期之外。
沙盒里的明文事故
第一版实现是喊 openssl 干活:shell 出去生成 PKCS#12,再用 SecPKCS12Import 导入。它有两个代价,第二个几乎是致命的:
- 用户机器上得装着 Homebrew 版 openssl,而用户根本不知道有这依赖;
- App Sandbox 禁止执行容器外的二进制。于是 App Store 沙盒构建下,openssl 根本跑不起来——拿不到证书,应用静默降级成明文传输。
第二条值得多看一眼:没有崩溃,没有报错,功能全部正常,剪贴板照样同步,文件照样传——只是加密层整个不存在了。这是安全缺陷最糟糕的形态:它不是把功能弄坏了,是把承诺弄没了,而所有能引起注意的信号都来自「功能坏了」。
修法是把整条链路搬进进程内:
- X.509 证书用 swift-certificates 在内存里生成、DER 序列化,不碰任何外部工具;
SecIdentityCreate把内存里的证书和内存里的私钥直接配成SecIdentity——这一步是关键,它让子进程和钥匙串都变得不必要。钥匙串在沙盒里另有一层麻烦(访问依赖签名的 application-identifier),绕开它等于绕开一整类构建配置问题。
现在同一份代码在沙盒内外行为一致,没有环境依赖,也没有「环境不满足时静默降级」这条路可走。
证书必须活得比进程久
自签证书还有一条容易忽视的约束:重新生成等于吊销。每台手机固定的是那张旧证书,Mac 一旦换证书,所有已配对的手机会同时连不上,而且从任何一端看都像网络故障。
所以私钥(key.raw,POSIX 0600)和证书(certificate.der)落在 Application Support 里跨启动复用;只有首次运行才生成。从 openssl 旧版本迁移上来的安装,旧 PKCS#12 里的私钥已经没法在进程内打开,只能重新生成——这时日志里明确写一句「replacing pre-existing certificate: paired phones must scan again」,让它看起来是它本来的样子(一次性迁移代价),而不是一个连接 bug。
单元测试用的目录是参数传进去的,永远指向临时路径——一个跑测试的动作不应该有能力把用户所有手机全部解绑,这条约束值得用签名来强制,而不是靠小心。
二维码为什么可以随便拍
最后一个问题:二维码里除了证书还有一个配对 token,那被人拍下来怎么办?
答案是token 只在首次接入时有用。手机第一次凭它连上后,Mac 会为这台设备(按 deviceId)铸造一个专属凭证,在 hello.ack 里发回;此后这台手机出示的一直是自己的凭证,再也不用二维码上那个 token。于是:
- 拍下屏幕不构成持续访问权——洗出来的照片只在 token 未轮换的窗口内有用,而「重置配对码」随手一按就轮换了;
- 凭证按设备发,才有「断开这台设备」:吊销一台手机,不惊动其他的;
- 轮换二维码 token 则吊销所有手机、覆盖所有传输路径(包括 USB)——这就是「重置配对码」的实现。
(Mac 怎么验证这些凭证、未配对的连接会被怎样对待,是另一篇的主题。)
学到什么
**局域网直连产品的密钥分发,可以不解、直接绕。**屏幕到摄像头是一条现成的、物理上就近的带外信道,把信任锚(证书本体)沿它递过去,CA、域名、ACME 整套基础设施都不再需要——前提是把证书尺寸压进二维码的承载力,这是 P-256 被选中的全部理由。
沙盒不是部署细节,是威胁模型的一部分。「依赖一个外部二进制」在普通 app 里是打包问题,在沙盒里是静默失败:执行被拒,降级无声。任何「拿不到就退化」的路径,退化的那头如果是明文,这条路径就不该存在。
留言