手机与桌面之间的端到端加密
FutureOS Mobile 让你的手机驱动桌面上的会话:读流式回复、发提示、批准请求、传文件。工具是在桌面上执行的,而不是手机上——所以命令、会话事件和文件内容都要经过一个 NATS 中继在两台设备之间传输。
那个中继在公网上。设计必须从一个不太舒服的前提出发:中继是恶意的。
威胁模型
一个恶意的中继可以丢弃、延迟、重排、重放、改路由、伪造消息。它绝不能做到的是:获知应用层明文,或者制造出一条被任一端点接受的命令、回复、文件块或在线状态事件。
所以 Mobile 和 Desktop 在发布到 NATS 之前就相互认证并加密每一条应用记录。中继永远只能看到密文和 subject。
要说清楚这套机制不保护什么:被攻陷的端点、被盗或未锁定的凭据库、泄露的未使用邀请、流量时序与大小、NATS subject 元数据,或可用性。如果攻击者能阻断投递,云端吊销也不是即时的终止信号——但本地的停用/
配对:一个短寿命的持有者邀请
配对是两台设备学会互相信任的方式,也是唯一一次有秘密在明文中穿过的时刻。
桌面端在本地生成一个 X25519 身份和一个独立的随机 32 字节邀请 PSK。私钥和 PSK 都不会发给平台 API;平台自己的 claim nonce 是另一个值,那个确实会发给平台。邀请本身——无论你扫二维码还是粘贴文本,都是同一份——携带 v=2、平台码、desktopId、NATS desktopKey、X25519 secureKey 和本地 secret。
把整个邀请当作一个短寿命的持有者凭据:五分钟内有效、只能用一次。别分享它,也别发它的截图。

图 1:桌面端为配对手机弹出的面板,含倒计时。二维码编码的是邀请——单次使用、默认五分钟有效,这张演示截图就是按这个 TTL 截的。它不是真实邀请。握手完成后桌面端会删除 PSK,邀请随之作废。
手机生成自己的 X25519 身份,把这捆数据存进 Expo SecureStore(WHEN_);桌面端把配对身份存在一个 owner 限定、原子写入的凭据文件里。两者都不是硬件密钥库级别的保证——边界在端点,而不是某颗芯片。
握手:Noise,两次
首次配对用 Noise_。手机在发出最后一条握手消息前,会用二维码/
之后的每次握手都用 Noise_,且双方都要求已绑定的对端身份。所以即便丢失了首次配对的确认,也能用同一个手机身份通过 IK 恢复——而绝不接受服务器下发的替换 key。每次握手都用全新的临时密钥,流量密钥和计数器从不落盘。
有几个细节很容易出错,正是它们让握手保持诚实:
- 原始握手输入有上限(裸数据 16 KiB、每条 Noise 消息 8 KiB),候选连接数量有界且 30 秒过期,只有通过认证的 Noise 输出才能产生候选流量通道。
- 候选连接不是握手成功就立刻生效。桌面端返回一个加密的
handshake-confirm;只有当手机装好并刷新它的订阅、发出加密的secure_ready之后,桌面端才把这个候选提交为当前通道。失败的候选绝不会停用旧通道,已停止的访问纪元也装不进迟到的候选,未提交的候选则不能跑普通命令或上传文件块。
记录层

图 2:上——配对与握手时序(首次配对用 XXpsk0,每次重连用 IK,中间是候选就绪交接);下——每条应用消息都被包裹进去的二进制记录。
握手之后的一切都作为二进制记录传输,用 ChaCha20-Poly1305 加密,密钥来自 Noise Split 得到的两个方向密钥。Rust 用 snow 和 chacha20poly1305;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 解码和业务去重。压缩(启用时)发生在加密之前。
所有命令/
如何校验
vectors.json 由 Rust/snow 和 Mobile 双方针对确定性的公开测试密钥做验证,覆盖 XXpsk0、IK、握手哈希、记录字节、AAD 和回复关联——所以两个独立实现产生字节一致的输出。
测试套件还覆盖了逐字节篡改、错误的 PSK/-D warnings、1167 个桌面库测试及其二进制与集成测试组、967 个桌面 Vitest 测试、900 个移动端 Jest 测试——在仓库的验证快照里。
这里要说清这些测试证明了什么。较早的业务和生命周期 fixture 把各自的认证传输层 mock 掉了,所以「fixture 全绿」本身不构成端到端加密的证据;能构成证据的是加密向量,以及真正穿过真实命令循环的那些路径。另外,进程内的 Rust 测试中继按设计就是明文跑的。
没被测到的是设备侧。性能和耗电必须在真机 Android/iOS 上量过,才能谈任何手机延迟的说法——我们手上的是 Node 和桌面测试的结果,不是设备保证。而且这一切都不是一次独立的密码学审计或渗透测试证明。
小结
中继不必被信任,因为它永远看不到任何能据以行动的东西。配对是一次性、五分钟、单用的持有者邀请;身份由 Noise 握手绑定,且绝不从服务器重新接受;每条应用记录都端到端认证,subject 和回复上下文都折进了认证范围;恢复路径拒绝信任未签名的发现。
协议与威胁边界在 FutureOS 仓库的
REMOTE_E2EE.md
里有完整规定;共享的加密 crate 是 remote-crypto。