TLS 之上还差一道门——以及两个把我骗了的验证脚本
TLS 建立的是私密信道,但它不回答「对面是谁」。灵犀的 Mac 端曾经在 TCP 一通就处理所有帧——不配对也能写它的剪贴板。这篇讲握手门的设计:未认证的连接只许发 hello、五秒不发就断、一个字节都不回;拒绝的爆炸半径怎么控制;挂断为什么需要一条专门的消息;以及验证脚本自己先后踩的两个坑——每个都让测试结果和真相完全相反。
灵犀 Lynsi 的手机端和 Mac 端之间是一条自研帧协议,跑在 TLS 1.3 上,证书由手机在配对时固定(上一篇讲了这张证书怎么来的)。这一篇讲 TLS 之上的那道门——为什么需要它,它怎么设计,以及我怎么验证它真的关着。
起点是一个真实存在过的洞
早期版本的 Mac 接收端,TCP 一建立就开始处理帧。也就是说,一个从未配对的对端可以完全跳过配对,直接往 Mac 的剪贴板写内容、往 Mac 的下载目录推文件。
这个洞的成因很典型:开发时两端都是自己的设备,「连上」和「配对」在心智上是同一件事,代码就把它们写成了同一件事。而它们必须是两件事——**TLS 建立的是私密信道,它保证没有第三方偷听,但完全不回答坐在信道另一头的是谁。**证书固定是手机在验 Mac 的身份;反方向,Mac 对连进来的对端一无所知。
门的三条规则
于是在 TLS 之上加一道握手门。一条刚被接受的连接处于「未认证」状态,此时:
- Mac 只读
hello帧。任何其他类型的帧到达,连接立刻被断掉——不是忽略这一帧,是断连接。发得出非 hello 帧的对端就是跳过了配对的对端,没有然后。 - 一个字节都不回,包括
hello.ack。未认证阶段的 Mac 是全哑的:不确认、不报错、不解释。任何回应都是给探测者的信息(「这个端口上有个什么东西,它嫌我的帧不对」),零输出面意味着零信息泄漏。 - 五秒内不发 hello 就断。对知道自己该发什么的软件来说五秒绰绰有余;对占着连接不说话的陌生人来说,这是资源上限。
两个实现细节值得记:
- 计时器在 accept 时就启动,而不是等连接 ready。卡在 TLS 握手里的连接永远到不了 ready,如果计时器等 ready 才装,这种半开连接会永久占据名额。
- 连接数有上限,而每个被持有的连接都在门后。上限约束的是「一个未认证的陌生人最多能占用什么」:一个解码器加一个 socket,五秒后回收。正常设计只用两条连接(主会话 + 分享面板一条),触到上限本身就说明有半开连接在被各自的握手计时器清理中。
hello 里的 pairToken 有三条验证路径,按证明力排序:匹配 Mac 为这台设备铸造过的专属凭证(常规路径,二维码轮换也不影响它);等于当前二维码上的 token(首次接入,随后当场铸造专属凭证);或者对端来自 loopback 且出示了字面量 usb-link——这是 USB 数据线路径,而且它不是自动放行的,Mac 上要弹出「允许」由人批准。
USB 这条路曾经宽得多:只看「对端地址是 loopback」就放行。这在两个方向上都超出了本意——本机的任何进程都符合这个条件;一台凭证早已被吊销的手机插上线也能重新进来。所以数据线路径必须报上名来(哨兵值)再加人工批准,而已配对的手机不管走网络还是走线,出示的都是真凭证。
拒绝的爆炸半径是一
一个 hello 被拒(token 错、数据线批准被点了拒绝),断掉的只有发它的那条连接。这条规则看起来平淡,但它守住的东西不平淡:陌生人发一个错误的 hello,不能把手机正在用的会话打下线。否则「朝那台 Mac 扔垃圾 hello」就成了一个免费的拒绝服务按钮。
同样的隔离也用在分享面板上:鸿蒙的分享扩展跑在独立进程里,够不到主应用的 socket,所以它自己开一条短命连接(purpose: share),发完文件就挂断。Mac 把它和主会话并排服务——它不抢主会话的位置,不改变界面上的连接状态,来去都不惊动站着的链路。在这条通道存在之前,接收端只持有一条连接,于是「分享」恰恰在主应用显示「已连接」的时候必然失败。
挂断需要一条专门的消息
还有一条容易被忽略的语义:socket 关闭本身不携带意图。Mac 崩溃了、合盖睡了、Wi-Fi 抖了、用户主动点了「断开」——在手机看来都是同一个信号:连接断了。而手机的重连策略(指数退避,1 秒起翻倍、封顶 15 秒、12 次后放弃)对前三种情况的正确反应就是自动重连。
于是用户在 Mac 上点「断开手机连接」,几秒之内就会被手机自己的退避重连默默撤销。修法是加一条 bye 消息,在 Mac 主动关 socket 之前发出,带 reason:user(用户断开,配对还在,别再重连了)或 unpaired(配对 token 已轮换,重连只会被拒)。bye 是尽力而为的:永远不发给未认证的对端,手机漏收了也只是退回老行为(重连、被门拒掉),不会更糟。
验证脚本两次骗了我
门造好了,怎么证明它关着?靠一个从外部扮演敌意对端的 Python 脚本(verify-handshake-gate.py):不配对直接发 clipboard.offer、直接推文件、发错 token 的 hello、发没有 token 的 hello、只连不说话——逐条断言连接被丢弃。带上真 token 跑,还会验证正常路径和「分享连接与主会话共存」。
有意思的是这个脚本自己先后错了两次,两次的症状都是测试结果与真相完全相反:
第一次:所有用例齐刷刷 FAIL,而接收端其实完美。早期脚本用明文 TCP 连接。TLS 服务端对明文的第一批字节的回应是一条 fatal alert——而 alert 在明文 socket 看来就是数据。脚本读到「服务端回了东西」,推断敌意对端被服务了,全部用例判 FAIL。实际上接收端的行为无可指摘,是探针说的语言不对。修法:脚本必须说真实的传输语言(TLS,故意不验证书——固定证书是手机的职责,脚本测的是 TLS 之上的门,苛求一条设计上就不存在的信任链只会让测试跑不起来)。
**第二次:所有用例齐刷刷 PASS,而真实客户端全被拒。**早期脚本的帧用了标准 UUID 和整秒时间戳——而鸿蒙客户端产生的 id 是 毫秒时间戳-十六进制随机数,时间戳带毫秒。脚本的「敌意帧」通过了,真手机的帧却全军覆没,因为帧的编码函数和真实客户端有出入。修法:脚本的 encode_frame 逐字段对齐鸿蒙端 Protocol.ets 的 makeEnvelope(),注释里写明这段历史,防止后人「顺手改标准」。
这两个坑合起来是一条完整的教训:**扮演攻击者的测试代码,先得像一个真实的客户端。**说错语言,你测的是自己的探针;帧格式有出入,你放行的是一个不存在的客户端。两种偏差都让绿色的测试结果毫无意义——而且它们不会自己暴露,因为测试「跑通了」。
学到什么
- **认证门的输出面要为零。**未认证阶段不回一个字节,连拒绝都不解释——任何回应都是给扫描者的免费信息。
- **拒绝的爆炸半径要为一。**错误凭证只断自己那条连接;能被陌生人的垃圾帧打掉的会话,等于把拒绝服务做成了公开 API。
- 意图要显式传达。「断开」和「断线」在 socket 层长得一模一样,自动重连会忠实地撤销用户的主动断开——区分它们的唯一办法是在协议里给意图留一条消息。
- **对抗性测试的第一要求是保真。**验证脚本要说真实的传输语言、发和真实客户端逐字节一致的帧,否则它验证的只是它自己的想象。
留言