数据截至 (上游 commit eb980a5c9eea)
端到端加密与配对握手
这章讲什么: Happy 让你用手机遥控终端里的 Claude Code,中间必然经过一台服务器。这章只回答一个问题——为什么那台服务器读不懂你和 AI 说了什么。涉及全部密钥的出生、派生、封装,以及手机和终端第一次见面的那次握手。
不涉及: 密文之后怎么在 WebSocket 上流动、RPC 怎么中继——那是 第 2 章。
1. 先问一个笨问题:服务器手里到底有什么
先建立直觉。你在终端敲 happy,它把一次会话的元数据和每一条消息发给服务器,手机再从服务器拉下来。
服务器为一个会话存的东西是这样的(packages/happy-server/sources/app/api/routes/sessionRoutes.ts:269-276,db.session.create):
| 字段 | 服务器看到的内容 | 是密文吗 |
|---|---|---|
tag | 一个随机 UUID | 明文,但不含信息 |
metadata | base64 字符串 | 密文 |
agentState | base64 字符串 | 密文 |
dataEncryptionKey | 一段字节 | 密文(封起来的会话密钥) |
seq / metadataVersion | 整数 | 明文 |
active / 各种时间戳 | 布尔、时间 | 明文 |
关键在最后一行的缺席:服务器没有任何一个字段能让它把 metadata 变回可读的 JSON。它连"钥匙"字段 dataEncryptionKey 都只是原样 Buffer 存进去、原样 base64 吐出来(sessionRoutes.ts:254、sessionRoutes.ts:302),从不尝试打开。
所以整套系统的三方分工是这样的:
┌───────────────┐ ┌───────────────┐
│ 手机 / 网页 │ ←── 密文 + 封好的钥匙 ──→ │ 服务器 │
│ 握着主密钥 │ │ 只当一间邮局 │
└───────┬───────┘ └───────┬───────┘
│ │
│ 扫码时只交出「内容公钥」 │ 密文 + 封好的钥匙
│ │
┌───────┴───────┐ │
│ 终端 CLI │ ←────────────────────────────────┘
│ 只拿到公钥 │
└───────────────┘
读法: 箭头是数据流,密钥的所有权是不对称的——手机有全套,终端只有一把公钥。这个不对称正是本章后半段的主角。
2. 一把 32 字节的主密钥,长出一整棵树
2.1 账号的出生
Happy 的账号没有邮箱、没有密码。点一下"创建账号",就是本地摇 32 个随机字节:
const secret = await getRandomBytesAsync(32); // packages/happy-app/sources/app/(app)/index.tsx:41
const token = await authGetToken(secret);
这 32 字节就是主密钥(masterSecret),是整个账号唯一的根。丢了它,你的所有历史会话就是一堆永远打不开的密文。
2.2 主密钥的第一重身份:签名
主密钥先被当作 Ed25519 的种子,用来向服务器证明"我是这个账号":
authChallenge(secret) 在 packages/happy-cli/src/api/encryption.ts:233-247 里做三件事——tweetnacl.sign.keyPair.fromSeed(secret) 生成签名密钥对、摇一个 32 字节随机 challenge、对它做 detached 签名。
服务器那边只验签,然后按公钥 upsert 一个账号(packages/happy-server/sources/app/api/routes/authRoutes.ts:22-33):
const isValid = tweetnacl.sign.detached.verify(challenge, signature, publicKey);
// ...
const user = await db.account.upsert({ where: { publicKey: publicKeyHex }, ... });
注意这里的巧妙:"注册"和"登录"是同一个动作。服务器只认公钥,不存任何可以反推主密钥的东西。
2.3 主密钥的第二重身份:一棵密钥树
签名之外,主密钥还要长出一堆用途各异的对称密钥。Happy 用的是一套 HMAC-SHA512 链码密钥树(结构上和 BIP-32 分层确定性钱包同族:一个 64 字节输出劈成"密钥 + 链码",链码继续往下派生子节点)。
只有两个函数(packages/happy-app/sources/encryption/deriveKey.ts):
deriveSecretKeyTreeRoot(seed, usage)(:8)—— 以usage + ' Master Seed'为 HMAC 的密钥、主密钥为数据,算一次 HMAC-SHA512,前 32 字节当 key,后 32 字节当 chainCode。deriveSecretKeyTreeChild(chainCode, index)(:16)—— 以上一级链码为 HMAC 密钥,数据是0x00 || index(那个0x00是分隔符,防止["ab","c"]和["a","bc"]撞到一起),同样劈成 key + chainCode。
deriveKey(master, usage, path)(:37)就是把这两步串起来走完整条路径,返回最后一级的 key。
原理演示(# 示意,非源码,抓核心想法):
// 一条路径 = 一次 root + N 次 child,每步都换掉链码
function deriveKey(master, usage, path) {
let [key, chain] = split(hmacSha512(utf8(usage + ' Master Seed'), master)); // 根
for (const index of path) {
[key, chain] = split(hmacSha512(chain, concat([0x00], utf8(index)))); // 每一级
}
return key; // 重点看:只有走完整条 path 才拿得到叶子密钥
}
树上现在挂着这些叶子(packages/happy-app/sources/sync/encryption/encryption.ts:14-30,Encryption.create):
| usage | path | 派生出什么 | 干什么用 |
|---|---|---|---|
Happy EnCoder | ['content'] | 32 字节种子 → box 密钥对 | 封/拆每个会话的数据密钥(本章主角) |
Happy Coder | ['analytics','id'] | 取 hex 前 16 位 | 埋点用的匿名 ID anonID |
Happy Blobs | ['master'] | 32 字节 | 老会话(无 dataKey)的附件加密密钥 |
Happy Blobs | ['session'] | 32 字节(从会话数据密钥派生,不是从主密钥) | 新会话的附件密钥,与消息密钥隔离 |
画成树:
masterSecret (32B,只存在于手机 / 网页端)
│
├─ 当 Ed25519 种子 ──────────────→ 账号身份公钥(服务器凭它认人)
│
├─ deriveKey(_, 'Happy EnCoder', ['content'])
│ └─ crypto_box_seed_keypair
│ ├─ 公钥 ──→ 扫码时交给终端
│ └─ 私钥 ──→ 死也不出手机
│
├─ deriveKey(_, 'Happy Coder', ['analytics','id']) ──→ anonID
│
└─ deriveKey(_, 'Happy Blobs', ['master']) ──→ 老会话附件密钥
那个 contentDataKey 字段名有点坑:它挂在 Encryption 上,但存的其实是公钥(encryption.ts:50,this.contentDataKey = contentKeyPair.publicKey)。第 4 节你会看到它被原样塞进二维码的答复里。
2.4 一个真实的坑:slice 不是 subarray
同一份 deriveKey.ts 在 CLI 和 App 各有一份。差别只有两行,但那两行是踩出来的:
| 位置 | 子节点返回 | 后果 |
|---|---|---|
packages/happy-cli/src/utils/deriveKey.ts:24-25 | I.subarray(0, 32) | Node 端没事 |
packages/happy-app/sources/encryption/deriveKey.ts:32-33 | I.slice(0, 32) | iOS 上必须这样 |
原因写在 App 端的注释里(deriveKey.ts:24-30):iOS 上的原生 libsodium TurboModule 校验 crypto_secretbox 密钥长度时,读的是底层 ArrayBuffer 的长度(.length(runtime)),不是视图长度。subarray 返回的是 64 字节父 buffer 上的视图,那个检查看到 64,直接报 "invalid key length"——尽管这 32 字节本身完全正确。slice 会复制出一块独占的 32 字节 buffer,检查才过。
同一个坑在附件加密里又出现一次,那边直接做了防御性拷贝(packages/happy-cli/src/api/encryption.ts:121-122,encryptBlob 里的 dataStandalone / keyStandalone)。
为什么两端不能对不上: packages/happy-cli/src/utils/deriveKey.appspec.ts:8-19 钉了一组硬编码测试向量(root key、chain code、child key 全部十六进制写死)。任何一端改了派生逻辑,这组向量立刻炸——两端派生结果必须逐字节一致,否则手机根本解不开终端写的东西。