跳到主要内容

数据截至 (上游 commit eb980a5c9eea)

端到端加密与配对握手

这章讲什么: Happy 让你用手机遥控终端里的 Claude Code,中间必然经过一台服务器。这章只回答一个问题——为什么那台服务器读不懂你和 AI 说了什么。涉及全部密钥的出生、派生、封装,以及手机和终端第一次见面的那次握手。

不涉及: 密文之后怎么在 WebSocket 上流动、RPC 怎么中继——那是 第 2 章


1. 先问一个笨问题:服务器手里到底有什么

先建立直觉。你在终端敲 happy,它把一次会话的元数据和每一条消息发给服务器,手机再从服务器拉下来。

服务器为一个会话存的东西是这样的(packages/happy-server/sources/app/api/routes/sessionRoutes.ts:269-276db.session.create):

字段服务器看到的内容是密文吗
tag一个随机 UUID明文,但不含信息
metadatabase64 字符串密文
agentStatebase64 字符串密文
dataEncryptionKey一段字节密文(封起来的会话密钥)
seq / metadataVersion整数明文
active / 各种时间戳布尔、时间明文

关键在最后一行的缺席:服务器没有任何一个字段能让它把 metadata 变回可读的 JSON。它连"钥匙"字段 dataEncryptionKey 都只是原样 Buffer 存进去、原样 base64 吐出来(sessionRoutes.ts:254sessionRoutes.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-30Encryption.create):

usagepath派生出什么干什么用
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:50this.contentDataKey = contentKeyPair.publicKey)。第 4 节你会看到它被原样塞进二维码的答复里。

2.4 一个真实的坑:slice 不是 subarray

同一份 deriveKey.ts 在 CLI 和 App 各有一份。差别只有两行,但那两行是踩出来的:

位置子节点返回后果
packages/happy-cli/src/utils/deriveKey.ts:24-25I.subarray(0, 32)Node 端没事
packages/happy-app/sources/encryption/deriveKey.ts:32-33I.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-122encryptBlob 里的 dataStandalone / keyStandalone)。

为什么两端不能对不上: packages/happy-cli/src/utils/deriveKey.appspec.ts:8-19 钉了一组硬编码测试向量(root key、chain code、child key 全部十六进制写死)。任何一端改了派生逻辑,这组向量立刻炸——两端派生结果必须逐字节一致,否则手机根本解不开终端写的东西。


3. 四种密码盒,各贴各的标签

Happy 里同时跑着四套密码学原语。别把它们混成"加密"一个词,它们各管一段:

密码盒算法谁能打开字节布局源码
legacy 对称盒NaCl secretbox(XSalsa20-Poly1305)任何拿到主密钥的人[nonce 24B][密文+tag]packages/happy-cli/src/api/encryption.ts:87 encryptLegacy
dataKey 对称盒AES-256-GCM拿到该会话数据密钥的人[版本 0x00][nonce 12B][密文][tag 16B]packages/happy-cli/src/api/encryption.ts:154 encryptWithDataKey
匿名公钥盒NaCl box(X25519 + XSalsa20-Poly1305)+ 一次性密钥对只有收件人私钥[临时公钥 32B][nonce 24B][密文+tag]packages/happy-cli/src/api/encryption.ts:62 libsodiumEncryptForPublicKey
附件盒NaCl secretbox,不走 JSON拿到 blob 密钥的人[nonce 24B][密文+tag]packages/happy-cli/src/api/encryption.ts:119 encryptBlob

术语提醒: 本章说的「盒」和「字节布局」,指的都是密文本身怎么排字节;第 4、6 章说的「线协议信封」(SessionEnvelope)是包在密文外面的业务事件格式。两者不在一个层次上,别当同一个词读。

三点值得单独说:

第一,前两种会先 JSON.stringify encryptLegacyencryptWithDataKey 收的都是 data: any,解密时再 JSON.parse。附件那一套(encryptBlob)刻意不做序列化,直接吃 Uint8Array——图片走 base64 会白胖 33%。

第二,"匿名公钥盒"是自己拼的,不是 libsodium 的 sealed_box libsodiumEncryptForPublicKeyencryption.ts:62-79)现摇一对临时密钥、把临时公钥明文贴在包头,收件人靠它反推共享密钥。语义上等价于 sealed box:发件人匿名、收件人只需自己的私钥。App 端的对应实现在 packages/happy-app/sources/encryption/libsodium.ts:8encryptBox)/ :22decryptBox),布局逐字节一致。

第三,版本字节是升级的伏笔。 AES 那条路径在包头钉了一个 0x00

// packages/happy-cli/src/api/encryption.ts:167-171
const bundle = new Uint8Array(12 + encrypted.length + 16 + 1);
bundle.set([0], 0); // 版本字节
bundle.set(nonce, 1); // 12 字节 GCM nonce
bundle.set(new Uint8Array(encrypted), 13);
bundle.set(new Uint8Array(authTag), 13 + encrypted.length);

解密端第一件事就是查这个字节,不是 0 直接返回 null(decryptWithDataKeyencryption.ts:186if (bundle[0] !== 0) return null)。App 端的 AES256Encryption 一模一样:加密时 output[0] = 0packages/happy-app/sources/sync/encryption/encryptor.ts:96-98),解密时 if (item[0] !== 0) return null:113)。

一层字节换来的是:以后换算法(比如 XChaCha20)时,老密文照样能识别、照样能解,不用给数据库做迁移。

诚实说明: App 端的 AES 实际调用第三方库 rn-encryptionencryptAsyncAESpackages/happy-app/sources/encryption/aes.ts:5-7),App 自己只负责在外面加那个版本字节。该库不在这份克隆里,所以"nonce 12 字节在前、tag 16 字节在后"这个布局,只能从 CLI 侧 encryptWithDataKey 的实现读出来;两端能互解说明布局一致。

另外 encryptor.ts:7-10 有一条上游自己写的告警:AES 实现目前只对"正常字符串"可靠,异常字符串可能破坏 UTF-8 编解码。


4. 配对握手:二维码里其实没有密钥

4.1 要解决的小问题

终端和手机素不相识,中间只有一台不可信的服务器。怎么让手机把一把密钥安全地递给终端,而服务器全程看不到?

思路: 反过来做。不是手机往外推密钥,而是终端先把自己的"收件地址"(一把一次性公钥)印成二维码,手机看到地址后把答复封进只有那把地址对应私钥才能拆的盒子里,扔给服务器转交。服务器摸不到私钥,转交的就是一团乱码。

4.2 走一遍

终端 CLI 服务器 手机 App
│ │ │
① 现摇 32B 随机数 │ │
tweetnacl.box.keyPair │ │
│ │ │
② POST /v1/auth/request ───────▶│ 建一条 terminalAuthRequest │
{publicKey, supportsV2:true} │ (按公钥 hex 做主键) │
│ │ │
③ 屏幕打印二维码 │ │
happy://terminal?<pubkey> │◀──── ④ 扫码,拿到 pubkey ────│
│ │ │
│ │◀─ ⑤ GET .../status 问 V2 ──│
│ │ │
│ │◀─ ⑥ POST /v1/auth/response │
│ │ box(答复, cli 公钥) │
│ │ │
⑦ 每秒轮询 /v1/auth/request ────▶│ │
◀── {state:'authorized', │ │
token, response} ───────│ │
│ │ │
⑧ 用①的私钥拆开 response │ │

读法: 从上往下是时间顺序;服务器那一列只负责存和转,它见到的 response 是密文。

逐条对到源码:

干什么源码
摇 32 字节做临时 box 密钥对packages/happy-cli/src/ui/auth.ts:28-29doAuth
登记认证请求,声明支持 V2packages/happy-cli/src/ui/auth.ts:37-40authRoutes.ts:71-75
happy://terminal?<base64url 公钥> 并画二维码packages/happy-cli/src/ui/auth.ts:102-103doMobileAuth
手机扫码,从 URL 尾巴取公钥packages/happy-app/sources/hooks/useConnectTerminal.ts:31-32
先问服务器这个终端支不支持 V2packages/happy-app/sources/auth/authApprove.ts:17-29
封好答复投递authApprove.ts:46-54authRoutes.ts:159-163
轮询同一个 /v1/auth/requestpackages/happy-cli/src/ui/auth.ts:160-170waitForAuthentication
用临时私钥拆盒packages/happy-cli/src/ui/auth.ts:234-246decryptWithEphemeralKey

4.3 三个细节

"发起"和"轮询"是同一个接口。 POST /v1/auth/request 在服务器端是一次 upsertauthRoutes.ts:71-75):没有就建,有了就原样返回。如果已经有人填了答复,它顺手签一个 token 一起给回来(authRoutes.ts:77-84)。所以 CLI 每秒重发一次同样的请求就是轮询,没有额外状态机。

Web 分支只换了展示方式。 选"浏览器登录"时走 doWebAuthpackages/happy-cli/src/ui/auth.ts:115-140),把同一把公钥拼进 web URL 后自动开浏览器。密码学部分和扫码完全一样,最后都汇到 waitForAuthentication。上游还留了个注释:因为有人在 devcontainer 里浏览器没弹出来(issue #19),所以现在总是把 URL 也打印出来。

服务器无法冒充手机。 它能篡改转发的 response,但拆不开——终端拿到乱七八糟的东西只会解密失败、打印 "Failed to decrypt response"(auth.ts:207)。它能做的只有拒绝服务。


5. V1 与 V2:同一个二维码,两种答复

这是整套设计的分水岭。手机扫码后,其实同时准备了两份答复packages/happy-app/sources/hooks/useConnectTerminal.ts:33-37):

const responseV1 = encryptBox(decodeBase64(auth.credentials!.secret, 'base64url'), publicKey);
let responseV2Bundle = new Uint8Array(sync.encryption.contentDataKey.length + 1);
responseV2Bundle[0] = 0;
responseV2Bundle.set(sync.encryption.contentDataKey, 1);
const responseV2 = encryptBox(responseV2Bundle, publicKey);

区别一句话说完:

V1(legacy)V2(dataKey)
盒子里装的主密钥本体,32 字节版本字节 0x00 + 内容公钥 32 字节
终端拿到后能解开这个账号的一切只能"封",不能"拆"
明文长度3233

服务器不参与选择,它只是照 supportsV2 标志二选一转发(packages/happy-app/sources/auth/authApprove.ts:48):

response: supportsV2 ? encodeBase64(answerV2) : encodeBase64(answerV1)

而终端靠长度和首字节自己认packages/happy-cli/src/ui/auth.ts:175-210):

拆开的明文

├─ length === 32 ────────────→ legacy:整段就是主密钥
│ writeCredentialsLegacy({secret, token})

└─ 否则看 decrypted[0]
├─ === 0 ───────────────→ dataKey:slice(1,33) 是账号内容公钥
│ 另外本地摇一把 machineKey = randomBytes(32)
│ writeCredentialsDataKey({publicKey, machineKey, token})
└─ ≠ 0 ────────────────→ 报错退出

落到磁盘的凭据结构因此有两种形态(packages/happy-cli/src/persistence.ts:222-229Credentials):

模式磁盘上有什么写入函数
legacysecret(主密钥)+ tokenpersistence.ts:262 writeCredentialsLegacy
dataKeypublicKey(账号内容公钥)+ machineKey(本机自摇)+ tokenpersistence.ts:272 writeCredentialsDataKey

readCredentialspersistence.ts:231-260)靠"文件里有没有 secret 字段"来判断加载哪种——老机器上的老文件继续按 legacy 跑,不用迁移。


6. 每会话一把随机数据密钥

6.1 它要解决的小问题

V2 模式下终端只有公钥,公钥不能解密。那终端怎么加密自己的会话?

答案:自己造钥匙。 每开一个会话,终端现摇 32 字节当对称密钥,正常用它 AES-GCM 加密全部内容;然后把这把钥匙用账号的内容公钥封成一个匿名盒子,当作会话的一个普通字段上传。手机拉到会话时,用自己的私钥把这把钥匙拆出来,就能解这个会话了。

每开一个会话:
随机 32B ──► 本会话所有 metadata / agentState / 消息都用它 AES-256-GCM

└─► libsodiumEncryptForPublicKey(随机32B, 账号内容公钥)
└─► [0x00][临时公钥 32B][nonce 24B][密文+tag]
└─► 作为 dataEncryptionKey 上传,服务器原样存

6.2 真实实现

packages/happy-cli/src/api/api.ts:40-56getOrCreateSession):

if (this.credential.encryption.type === 'dataKey') {
encryptionKey = getRandomBytes(32); // 现摇
encryptionVariant = 'dataKey';
let encryptedDataKey = libsodiumEncryptForPublicKey(encryptionKey, this.credential.encryption.publicKey);
dataEncryptionKey = new Uint8Array(encryptedDataKey.length + 1);
dataEncryptionKey.set([0], 0); // 版本字节
dataEncryptionKey.set(encryptedDataKey, 1);
}

然后 metadata、agentState 和这把封好的钥匙一起 POST 上去(api.ts:65-67)。legacy 分支则简单粗暴:encryptionKey = this.credential.encryption.secretdataEncryptionKey 保持 nullapi.ts:54-57)。

机器(machine)走同一套,但密钥不是每次现摇。 getOrCreateMachineapi.ts:155-162)直接用配对时本地生成、写进凭据文件的 machineKey——因为一台机器的元数据要跨多次启动保持可读。会话是一次性的,机器不是。守护进程怎么用它见 第 5 章

附件另起一层。 会话数据密钥不直接用来加密图片,而是再往下派生一级(packages/happy-cli/src/api/apiSession.ts:392-397getBlobKey):

const path = this.encryptionVariant === 'dataKey' ? ['session'] : ['master'];
this.blobKey = await deriveKey(this.encryptionKey, 'Happy Blobs', path);

App 端逻辑镜像一致(packages/happy-app/sources/sync/encryption/encryption.ts:94-97)。好处是消息密钥和附件密钥在密码学上隔离——泄一把不等于全泄。

6.3 一个值得知道的边角

服务器的 POST /v1/sessions 是"按 tag 查,有就返回旧的"(sessionRoutes.ts:234-261)。但 CLI 在 dataKey 模式下无条件先摇了新密钥,拿到旧会话后仍然拿这把新密钥去解旧的 metadataapi.ts:84-89),必然解出 null;代码里没有"改用服务器返回的 dataEncryptionKey"这条分支。

实际不出事,是因为 tag 每次启动都是新的 randomUUID()packages/happy-cli/src/claude/runClaude.ts:76),这条"命中旧会话"的路基本走不到。真要接管一个已存在的会话,走的是另一条路——由外部把密钥通过环境变量喂进来(runClaude.ts:151-174HAPPY_RECONNECT_ENCRYPTION_KEY / HAPPY_RECONNECT_ENCRYPTION_VARIANT)。喂的人是守护进程的 resume-in-place(packages/happy-cli/src/daemon/run.ts:749-756),细节见 第 5 章


7. 这次升级到底改变了什么

把 legacy 和 dataKey 并排放,能力差别一目了然:

能力legacy 终端dataKey 终端
解开自己创建的会话能(密钥是自己摇的,留在内存里)
解开别的终端创建的会话(同一把主密钥)不能(缺内容私钥)
解开手机上的历史会话不能
重新走 /v1/auth 换 token能(手里有 Ed25519 种子)不能
把账号搬到新设备不能
加密新内容能(公钥只用来封,够用)

"拆不开"的根据是什么? 拆封会话密钥的唯一入口是 Encryption.decryptEncryptionKeypackages/happy-app/sources/sync/encryption/encryption.ts:195-215),它用的是 this.contentKeyPair.privateKey。而那把私钥由 deriveKey(masterSecret, 'Happy EnCoder', ['content']) 派生(encryption.ts:17-20)——dataKey 终端的凭据文件里根本没有 masterSecretpersistence.ts:272-280),这条路直接断了。

"换不了 token"的根据是什么? /v1/auth 要求提交对 challenge 的 Ed25519 签名(authRoutes.ts:22),签名要主密钥当种子。CLI 里确实有 authGetTokenpackages/happy-cli/src/api/auth.ts:19),但在整个 CLI 源码里没有任何地方调用它——只有 App 端在用(app/(app)/index.tsx:42restore/manual.tsx:101)。dataKey 终端此后只能靠配对时拿到的那个 bearer token。

一句话总结这次升级: 一台被拿下的开发机,从"泄漏整个账号"降级成"泄漏这台机器上正在跑的那些会话"。终端从账号的共同持有人降级成一个只能往里写、写完就交出去的投递员。

对照组: 手机之间的配对("链接新设备")没有降级——packages/happy-app/sources/hooks/useConnectAccount.ts:32encryptBox(auth.credentials!.secret, publicKey),封的还是主密钥原文,走的是另一组接口 /v1/auth/account/requestauthRoutes.ts:169)。这是有意为之:新手机就该是账号的完整副本,终端不该是。


8. 手机端怎么把一堆盒子挨个打开

手机拉到一批会话时,要先解钥匙、再解内容,两步分开(packages/happy-app/sources/sync/sync.ts:985-999):

拉到 sessions[]

├─ 有 dataEncryptionKey ──► decryptEncryptionKey() ──► 32B 明文密钥
│ 失败 → console.error + continue(跳过这一条)
└─ 没有(老会话) ──► null


initializeSessions(Map<sessionId, key|null>)

└─ openEncryption(key)
├─ key 为 null ──► SecretBoxEncryption(主密钥那把)
└─ 否则 ──► AES256Encryption(key)

分派逻辑就三行(packages/happy-app/sources/sync/encryption/encryption.ts:57-62openEncryption):

async openEncryption(dataEncryptionKey: Uint8Array | null): Promise<Encryptor & Decryptor> {
if (!dataEncryptionKey) { return this.legacyEncryption; }
return new AES256Encryption(dataEncryptionKey);
}

关键在这个 null 上。 新老会话在同一个列表里共存,靠"有没有 dataEncryptionKey"这一个信号分流,不需要版本号、不需要数据迁移。老会话永远走 secretbox,新会话永远走 AES。

拿到 encryptor 之后,再包一层业务外壳:SessionEncryptionpackages/happy-app/sources/sync/encryption/sessionEncryption.ts:8)和 MachineEncryptionmachineEncryption.ts:6)。这两个类不碰密码学,只干三件事——按 id 记住自己该用哪把钥匙、按 version 号缓存解密结果(sessionEncryption.ts:147-168decryptMetadata)、把解出来的 JSON 用 zod schema 校一遍再交出去。

一条重要的约定:解密函数永不抛错

decryptEncryptionKey 的注释把理由写死了(encryption.ts:196-199):

调用方(fetchMachines / fetchSessions / artifacts)会一次遍历很多把钥匙,其中一把畸形或者根本不属于本账号,一旦抛异常就会让整次同步 reject,然后静默丢掉每一条数据

所以整条解密链上,失败一律降级成 nulldecryptWithDataKey 版本字节不对返回 null(packages/happy-cli/src/api/encryption.ts:186)、decryptLegacy 认证失败返回 null(:106-110)、AES256Encryption.decrypt 里每一项各自 try-catch(encryptor.ts:112-123)。UI 层看到的是"这一条打不开",而不是"整个列表空了"。

这条约定和 App 那句"永远不显示加载错误,只管重试"的原则是一体的。

顺带一个性能坑

AES256Encryption.decryptPromise.all 而不是顺序 for-await,注释说明了原因(encryptor.ts:105-110):顺序循环会把每次 AES 调用串行钉在 JS 线程上,一个 1000 条消息的会话就是 1000 次串行加解密,UI 什么都显示不出来。Promise.all 把它们丢进微任务队列,让原生 crypto 后端有机会交错执行。

对照 SecretBoxEncryptionencryptor.ts:27-43)——它是纯同步的 tweetnacl,所以老老实实用 for 循环,注释还特意写了"批处理,不用 Promise.all,更高效"。同一个接口,两种最优策略,取决于底下是同步还是异步实现。


9. 巧妙之处(值得偷走的设计)

把"钥匙"当成一个普通数据字段。 dataEncryptionKey 只是 session 表里一个 bytes 列,服务器像存 metadata 一样存它。密钥分发因此不需要任何专门的密钥服务器、不需要在线协商——加密和分发共用同一条 CRUD 通道(sessionRoutes.ts:274)。

能力最小化靠"只给公钥"实现,而不是靠权限系统。 与其在服务器上写一堆 ACL 规则去限制终端能读哪些会话,不如从密码学上让它读不了。服务器就算被攻破、就算规则写错,也改变不了这个事实(api.ts:50 只用得到 publicKey)。

每条链路上都钉一个版本字节。 会话密钥的封装盒(api.ts:52)、AES 密文(encryption.ts:168)、二维码答复(useConnectTerminal.ts:35)三处各有一个 0x00。成本一字节,换来一条不用数据迁移的升级通道。

用"字段在不在"代替版本号做兼容分流。 readCredentials 看有没有 secretpersistence.ts:238-246)、openEncryptiondataEncryptionKey 是不是 null(encryption.ts:58)。老数据什么都不用改,自动落到老路径上。

跨端加密必须有钉死的测试向量。 deriveKey.appspec.ts:8-19 那组十六进制常量,是"手机能解开终端写的东西"这件事的唯一自动化保证。App 和 CLI 用的甚至是两套不同的 HMAC 实现(Node createHmac vs 用 expo-crypto 手搓的 ipad/opad 循环,见 packages/happy-app/sources/encryption/hmac_sha512.ts),没有向量根本不敢动。


10. 边界与局限

诚实地列出这套设计不保护什么:

  • 元数据不加密。 会话何时创建、活跃到几点、有多少条消息、机器在不在线,服务器全知道。加密的只有内容。
  • 主密钥没有恢复手段。 32 字节随机数就是账号本身,丢了没有找回流程,历史密文就此报废。
  • legacy 账号不会自动升级。 一个已经存在的 legacy 会话永远是 legacy——openEncryption 只按 dataEncryptionKey 有无分流(encryption.ts:57-62),代码里没有任何重加密路径。升级只对新建的会话生效。
  • dataKey 终端的 token 是个软肋。 拿到那台机器的凭据文件,虽然解不开历史会话,但可以拿 token 冒充这台终端往服务器写东西。密码学挡不住这个,属于传输层认证的范畴。
  • 配对全靠"扫的是不是自己屏幕"。 服务器不知道谁扫了谁;如果你扫了别人屏幕上的二维码,就是把内容公钥(V2)或主密钥(V1)交给了对方。二维码里的公钥没有任何带外验证。
  • /v1/auth/request/status 在已授权时固定返回 supportsV2: falseauthRoutes.ts:119-121)。这条路径上的 V2 判断只在"pending"时有意义。同时 terminalAuthRequest 的 upsert 用的是 update: {}authRoutes.ts:71-75),意味着 supportsV2 只在首次创建时写入,后续轮询不会更新它。

11. 接下来读什么


12. 代码地图

主题文件路径符号名
密钥树派生(App,含 iOS slice 修复)packages/happy-app/sources/encryption/deriveKey.tsderiveSecretKeyTreeRoot / deriveSecretKeyTreeChild / deriveKey
密钥树派生(CLI)packages/happy-cli/src/utils/deriveKey.tsderiveKey
跨端派生测试向量packages/happy-cli/src/utils/deriveKey.appspec.tstestVectors
CLI 全部密码学原语packages/happy-cli/src/api/encryption.tsencryptLegacy / encryptWithDataKey / libsodiumEncryptForPublicKey / encryptBlob / authChallenge
App 密钥总管、内容密钥对、anonIDpackages/happy-app/sources/sync/encryption/encryption.tsEncryption.create / openEncryption / decryptEncryptionKey / encryptEncryptionKey
三种 encryptor 实现packages/happy-app/sources/sync/encryption/encryptor.tsSecretBoxEncryption / BoxEncryption / AES256Encryption
App 侧 libsodium 包装packages/happy-app/sources/encryption/libsodium.tsencryptBox / decryptBox / encryptSecretBox
会话 / 机器解密外壳packages/happy-app/sources/sync/encryption/sessionEncryption.tsmachineEncryption.tsSessionEncryption / MachineEncryption
CLI 配对流程与凭据分叉packages/happy-cli/src/ui/auth.tsdoAuth / waitForAuthentication / decryptWithEphemeralKey
App 扫码审批(V1/V2 双答复)packages/happy-app/sources/hooks/useConnectTerminal.tsprocessAuthUrl
提交审批答复packages/happy-app/sources/auth/authApprove.tsauthApprove
服务器认证接口packages/happy-server/sources/app/api/routes/authRoutes.tsauthRoutes
每会话 / 每机器数据密钥生成packages/happy-cli/src/api/api.tsgetOrCreateSession / getOrCreateMachine
凭据落盘的两种形态packages/happy-cli/src/persistence.tsCredentials / writeCredentialsLegacy / writeCredentialsDataKey
服务器只搬不看的会话存储packages/happy-server/sources/app/api/routes/sessionRoutes.tsPOST /v1/sessions handler
附件密钥派生packages/happy-cli/src/api/apiSession.tsgetBlobKey
已存在会话的密钥注入(resume-in-place)packages/happy-cli/src/claude/runClaude.tspackages/happy-cli/src/daemon/run.tsHAPPY_RECONNECT_* 分支 / resumeSession