手机与桌面之间的端到端加密

FutureOS Mobile 让你的手机驱动桌面上的会话:读流式回复、发提示、批准请求、传文件。工具是在桌面上执行的,而不是手机上——所以命令、会话事件和文件内容都要经过一个 NATS 中继在两台设备之间传输。

那个中继在公网上。设计必须从一个不太舒服的前提出发:中继是恶意的。

威胁模型

一个恶意的中继可以丢弃、延迟、重排、重放、改路由、伪造消息。它绝不能做到的是:获知应用层明文,或者制造出一条被任一端点接受的命令、回复、文件块或在线状态事件。

所以 Mobile 和 Desktop 在发布到 NATS 之前就相互认证并加密每一条应用记录。中继永远只能看到密文和 subject。

要说清楚这套机制不保护什么:被攻陷的端点、被盗或未锁定的凭据库、泄露的未使用邀请、流量时序与大小、NATS subject 元数据,或可用性。如果攻击者能阻断投递,云端吊销也不是即时的终止信号——但本地的停用/解除配对是:它当场作废访问权限,并清掉流量密钥和候选连接。NATS JWT ACL 只是纵深防御,不是消息来源的证明。传输层一跳与端到端层也是分开的——生产环境 Desktop 要求经验证的 TLS,Mobile 要求 WSS。

配对:一个短寿命的持有者邀请

配对是两台设备学会互相信任的方式,也是唯一一次有秘密在明文中穿过的时刻。

桌面端在本地生成一个 X25519 身份和一个独立的随机 32 字节邀请 PSK。私钥和 PSK 都不会发给平台 API;平台自己的 claim nonce 是另一个值,那个确实会发给平台。邀请本身——无论你扫二维码还是粘贴文本,都是同一份——携带 v=2、平台码、desktopId、NATS desktopKey、X25519 secureKey 和本地 secret

把整个邀请当作一个短寿命的持有者凭据:五分钟内有效、只能用一次。别分享它,也别发它的截图。

从桌面端配对手机

图 1:桌面端为配对手机弹出的面板,含倒计时。二维码编码的是邀请——单次使用、默认五分钟有效,这张演示截图就是按这个 TTL 截的。它不是真实邀请。握手完成后桌面端会删除 PSK,邀请随之作废。

手机生成自己的 X25519 身份,把这捆数据存进 Expo SecureStore(WHEN_UNLOCKED_THIS_DEVICE_ONLY);桌面端把配对身份存在一个 owner 限定、原子写入的凭据文件里。两者都不是硬件密钥库级别的保证——边界在端点,而不是某颗芯片。

握手:Noise,两次

首次配对用 Noise_XXpsk0_25519_ChaChaPoly_BLAKE2b。手机在发出最后一条握手消息前,会用二维码/粘贴的 key 校验对端的静态 key。桌面端验证对方持有邀请 PSK、绑定对端静态 key、持久化这个绑定——然后把 PSK 从活动凭据中删除。此后即使有人复制了邀请也会被拒绝。这是「凭持有受信邀请」的认证,而不是对某个真人身份的核验。

之后的每次握手都用 Noise_IK_25519_ChaChaPoly_BLAKE2b,且双方都要求已绑定的对端身份。所以即便丢失了首次配对的确认,也能用同一个手机身份通过 IK 恢复——而绝不接受服务器下发的替换 key。每次握手都用全新的临时密钥,流量密钥和计数器从不落盘。

有几个细节很容易出错,正是它们让握手保持诚实:

  • 原始握手输入有上限(裸数据 16 KiB、每条 Noise 消息 8 KiB),候选连接数量有界且 30 秒过期,只有通过认证的 Noise 输出才能产生候选流量通道。
  • 候选连接不是握手成功就立刻生效。桌面端返回一个加密的 handshake-confirm;只有当手机装好并刷新它的订阅、发出加密的 secure_ready 之后,桌面端才把这个候选提交为当前通道。失败的候选绝不会停用旧通道,已停止的访问纪元也装不进迟到的候选,未提交的候选则不能跑普通命令或上传文件块。

记录层

配对握手与记录布局

图 2:上——配对与握手时序(首次配对用 XXpsk0,每次重连用 IK,中间是候选就绪交接);下——每条应用消息都被包裹进去的二进制记录。

握手之后的一切都作为二进制记录传输,用 ChaCha20-Poly1305 加密,密钥来自 Noise Split 得到的两个方向密钥。Rust 用 snowchacha20poly1305;Mobile 用 noise-handshake(纯 JS 的 sodium 后端)做握手、@noble/ciphers 做记录。独立的记录层之所以存在,是因为 NATS 消息可能超过 Noise 的 65,535 字节传输上限。

每条记录是:

偏移 字节 字段
0 4 ASCII FRE2
4 16 Noise 握手哈希的前 16 字节
20 8 小端序列号
28 变长 密文 + 16 字节 AEAD 标签

nonce 是四个零字节接上 8 字节序列号。每个方向有独立的密钥和单调计数器,并在序列号到 2³²−1 之前强制重新握手。线路上限 1 MiB,留给明文 1 MiB − 44;固定开销 44 字节,业务与文件流量不付 Base64 膨胀成本。(握手字段是短的 Base64url 字符串,走另一条路径。)

让它在恶意中继下依然成立的部分:

  • NATS subject 作为附加数据被认证。 替换 subject——把一条记录重放到另一个 subject 上——会认证失败。
  • 回复绑定到它的请求,而不是中继的收件箱。 回复上下文是 reply:<请求 subject>:<请求固定头部第 4..28 字节的十六进制>,所以换掉回复收件箱换不掉回复内容。
  • 有界的重放窗口。 接收方按通道/方向维护一个 4096 条记录的窗口,且认证先于窗口变更成功。反射的、subject 错误的、连接错误的、被篡改的、重复的、窗口外的记录统统被拒绝。
  • 先认证,再谈其它。 接收认证先于解压、JSON 解码和业务去重。压缩(启用时)发生在加密之前。

所有命令/回复、文件上传/下载、在线状态、目录、事件和断开路径都走这个边界。配对控制是唯一的裸传输例外。NATS subject 仍然可见——协议不声称元数据机密性。

如何校验

vectors.json 由 Rust/snow 和 Mobile 双方针对确定性的公开测试密钥做验证,覆盖 XXpsk0、IK、握手哈希、记录字节、AAD 和回复关联——所以两个独立实现产生字节一致的输出。

测试套件还覆盖了逐字节篡改、错误的 PSK/prologue/key、重放与反射、大记录、丢失确认、固定 key 重连、过期凭据刷新、持久化失败、邀请重用/过期、就绪交接、本地失效,以及一条穿过真实 Desktop NATS 命令循环的加密请求。验证快照——Rust clippy -D warnings、1167 个桌面库测试及其二进制与集成测试组、967 个桌面 Vitest 测试、900 个移动端 Jest 测试——在仓库的验证快照里。

这里要说清这些测试证明了什么。较早的业务和生命周期 fixture 把各自的认证传输层 mock 掉了,所以「fixture 全绿」本身不构成端到端加密的证据;能构成证据的是加密向量,以及真正穿过真实命令循环的那些路径。另外,进程内的 Rust 测试中继按设计就是明文跑的。

没被测到的是设备侧。性能和耗电必须在真机 Android/iOS 上量过,才能谈任何手机延迟的说法——我们手上的是 Node 和桌面测试的结果,不是设备保证。而且这一切都不是一次独立的密码学审计或渗透测试证明。

小结

中继不必被信任,因为它永远看不到任何能据以行动的东西。配对是一次性、五分钟、单用的持有者邀请;身份由 Noise 握手绑定,且绝不从服务器重新接受;每条应用记录都端到端认证,subject 和回复上下文都折进了认证范围;恢复路径拒绝信任未签名的发现。

协议与威胁边界在 FutureOS 仓库的 REMOTE_E2EE.md 里有完整规定;共享的加密 crate 是 remote-crypto