
ZCode 桌面版有个「远程控制」功能：手机扫一下屏幕上的二维码，就能在手机浏览器里操控电脑上正在跑的 AI 任务。用的时候我忽然好奇三个问题：

1. 这个二维码里编码的是什么？有效期多久？
2. "扫一次之后手机就不用再扫了"，凭据存在哪、怎么复用的？
3. 手机和桌面隔着公网，认证握手具体是怎么做的？

于是花时间把 ZCode（Electron 应用，版本 3.7.6.4691）的代码逆向读了一遍，把整条链路挖了出来。这篇文章记录完整过程和结论——如果你也想逆向一个 Electron 应用，方法论部分应该也有参考价值。

## 先说结论（TL;DR）

- 二维码内容是一个 HTTPS URL：`https://zcode.z.ai/remote/v4?sid=...&hash=...&t=...&mid=...&name=...&app_version=...`
- `hash` 是配对密码的 SHA256——**拿到二维码等于拿到密码**，所以「刷新二维码」会把旧凭据作废
- 认证是双向对称的 HMAC 挑战应答：桌面端 role 为 `device`，手机端 role 为 `terminal`，proof 公式完全相同：`HMAC-SHA256(key=passHash, msg="${nonce}|${role}|${deviceSid}")`
- relay（云端中转）按 `device_sid` 把两端配对，配对后做透明桥接
- 凭据持久化在本地，重启、断线重连都复用同一个 sid+hash，**所以扫一次就长期有效**（客户端侧没有二维码 TTL）

## 逆向方法论

ZCode 是 Electron 应用，主进程和渲染进程的代码都打包在 `app.asar` 里：

```bash
# 提取 asar
npx @electron/asar extract /Applications/ZCode.app/Contents/Resources/app.asar /tmp/zcode-asar
```

提取出来是几百个文件，核心就三个：

| 文件 | 内容 |
|---|---|
| `out/main/index.js` | 主进程：relay 传输层、认证 provider、配对状态机 |
| `out/main/chunk-FQMTTDFW.js` | 二维码 URL 构建器、消息协议 schema |
| `out/renderer/assets/styles-CdEGpc2x.js` | 渲染进程：`WebRemoteControlDialog` 组件（渲染二维码的 UI） |

代码是压缩混淆过的（变量名全是 `Te`、`ZW`、`Hp` 这种），直接读不现实。几个实用的抓手：

1. **从 i18n 字符串切入**。UI 上的文案「生成二维码失败」「刷新二维码」在 `IntlProvider-*.js` 里都有 key（`webRemoteControl.qrAlt`、`webRemoteControl.refreshQr`...），grep 这些 key 就能定位到对应的 React 组件，再从组件的 IPC 调用追到主进程。
2. **grep 网络协议关键字**。`auth_init`、`device_register`、`pair_status`、`nonce` 这类字符串不会被打包器改名，是定位协议处理逻辑的锚点。
3. **顺着消息类型找状态机**。找到 `case"auth_challenge":` 这样的 switch 分支，前后读几百行，整个握手流程就出来了。
4. **手机端的代码不在 asar 里**，但手机 Web App 是从 `zcode.z.ai/remote/v4` 现场加载的——直接抓下来读，逻辑和桌面端对称，读起来反而更简单。

## 二维码是怎么生成的

渲染端非常朴素，就是 npm 包 `qrcode`（soldair/node-qrcode）：

```js
// WebRemoteControlDialog 组件
Qq.toDataURL(u.qrUrl, { margin: 1, width: 320 })
  .then(t => setQrImage(t))   // <img src={dataUrl} />
```

关键在 `qrUrl` 是主进程拼出来的（还原自 `buildWebRemoteControlExternalQrUrl`）：

```js
function buildQrUrl({ baseUrl, deviceSid, passHash, timestamp,
                      deviceMid, deviceName, appVersion }) {
  let p = new URL(baseUrl);                       // https://zcode.z.ai/remote/v4
  p.searchParams.set("sid",  deviceSid);          // relay 分配的设备会话 ID
  p.searchParams.set("hash", passHash);           // sha256(随机密码)，认证凭据
  p.searchParams.set("t",    String(timestamp));  // Date.now()
  p.searchParams.set("mid",  deviceMid);          // 稳定设备 ID
  p.searchParams.set("name", deviceName);         // 主机名
  p.searchParams.set("app_version", appVersion);
  return p.toString();
}
```

其中两个参数最重要：

- **`sid`（device_sid）**：桌面端向 relay 注册后由服务器下发的会话 ID，是手机和桌面配对的"房间号"
- **`hash`（pass_hash）**：`sha256(randomBytes(24).toString("base64url"))`，本地随机生成密码再哈希。relay 存一份、桌面留一份、**二维码里明文带一份给手机**

这个设计意味着：**二维码本身就是凭据的载体**。扫码 = 通过线下通道把 passHash 交给手机。所以二维码泄露 = 别人能连你电脑，这也解释了为什么 UI 里「刷新二维码」要弹确认框——刷新即作废旧 sid+hash，所有已配对的手机都要重扫。

## 有效期：客户端没有 TTL

这是我最意外的发现。逐个检查了代码里所有定时器：

| 定时器 | 时长 | 用途 |
|---|---|---|
| 启动看门狗 | 30 秒 | 30 秒内 relay 没进入可配对状态就报错放弃 |
| 心跳 `pair_status_query` | 10 秒 | 双端探活 |
| 心跳 ACK 超时 | 30 秒 | 超时强制重连 |
| 手机断开宽限 | 3 秒 | 短暂抖动不算断开 |
| 重连退避 | 1 秒 | 断线后重连基准 |

**没有任何一个定时器负责"二维码过期"**。URL 里的 `t` 参数只是生成时刻的时间戳，客户端代码里没有对它做新鲜度校验——如果服务端有校验策略，那也不在本地代码里。二维码生成一次后只要会话活着就有效，重新生成只有两条路：关掉重开远程控制面板，或手动点「刷新二维码」。

## 「认证一次」的持久化机制

首次配对成功后，桌面端把凭据写盘：

- `deviceSid` → `~/.zcode/v2/setting.json` 的 `webRemoteControlExternalRelayDevice` 字段
- `passHash` → 系统凭证库（key 为 `web-remote-control:external-relay:pass_hash`）

之后每次启动走 `persisted` 模式：直接复用同一个 sid+hash 做 `auth_init`，不再重新注册。断线重连、重启应用都一样，所以**手机扫一次就长期有效**。需要重扫只有三种情况：

1. 手动「刷新二维码」（旧凭据作废）
2. relay 拒绝持久化凭据（`AUTH_FAILED`）：桌面端自动清一次本地存储、重新注册拿新 sid
3. 本地凭据文件被删（两处存储缺一就被视为"半状态"，全部清除重来）

hash 没有自动轮换——正常重连永远用同一个 hash。

## 完整认证握手

这是整条链路最精彩的部分。核心设计：**手机和桌面共享同一个 `device_sid` 和 `passHash`，各自向 relay 证明"我知道这个房间号的密码"，relay 据此把两端桥接起来**。

两端用完全相同的挑战应答，唯一区别是 role 字符串：

```
proof = HMAC-SHA256(key=passHash, msg="${nonce}|${role}|${deviceSid}")
        role = "device"（桌面）/ "terminal"（手机）
```

时序图（从代码还原）：

```
  桌面 ZCode (role: device)          Relay (wss://zcode.z.ai/ws)     手机 (role: terminal)
  ────────────────────────          ─────────────────────           ──────────────────────

  ① 首次注册（仅一次；已有持久 sid 则跳过）
  ──WS connect ?mid=deviceMid──▶
     device_register_init{device_mid, pass_hash, meta}──▶
                                ◀──device_register_ack{device_sid}──
     持久化 {device_sid, pass_hash}

  ② 桌面认证（每次连接都走）
     auth_init{role:"device", device_sid}────────▶
                                ◀────auth_challenge{nonce}───
     proof=HMAC(passHash,"nonce|device|sid")
     auth_response{proof}───────────────────────▶
                                ◀────pair_status_ack{status:"waiting"}
     状态 = waiting_terminal        ← 此刻生成二维码，等手机扫码

                                     ③ 手机扫码，从 URL 解析 sid/hash
                                        ──WS connect（同一个 relay）──▶
                                        auth_init{role:"terminal", device_sid}──▶
                                ◀────auth_challenge{nonce}───────────
                                        proof=HMAC(passHash,"nonce|terminal|sid")
                                        auth_response{proof}──────────▶

  ④ relay 按 device_sid 配对两端
                                ◀──pair_status_ack{status:"matched"}─▶
  状态 = paired                    （两端进入 paired，flush 缓冲）

  ═════════════ data 帧双向桥接 ═════════════════════════════════

                                     ◀──bootstrap-request{requestId}────
  bootstrap-response{workspaces, tasks}──────────────────────────▶
                                     ◀──workspace-bridge-open{workspaceKey}
  workspace-bridge-ready─────────────────────────────────────────▶
        ◀═══ rpc-frame / rpc-frame-ack（分片·CRC32·base64）═══▶
```

几个细节值得展开：

**nonce 由 relay 生成**，客户端当不透明 token 用。同一轮 challenge 双端用同一个 nonce，靠 role 字符串区分各自的 proof——relay 存了 passHash，能独立重算并验证两个 role 的证明。

**手机端不注册**。它从二维码 URL 直接拿到 sid 和 hash，跳过 `device_register_init` 直接 `auth_init`。浏览器原生 WebSocket 加不了自定义 header，所以手机连接时不带 `X-Device-ID`（桌面端带这个 header），只有 URL 上可选的 `?mid=`。

**配对状态机**在 wire 上只有三个值：`waiting` / `matched` / `paired`。桌面端 UI 里的 "waiting_terminal" 是客户端本地状态。配对成功的第一个业务消息是手机发 `bootstrap-request` 拉工作区和任务列表，桌面回 `bootstrap-response`。

**实时任务 I/O 不走 JSON**。打开某个工作区后，流量切换到 `rpc-frame` 隧道：逻辑消息切片（fragmentIndex/fragmentCount）、每片带序列号和 CRC32 校验、base64 编码、有 ACK 重传机制（断线重连后 `replayUnacknowledged`）。relay 对这些帧只做透传。

## 安全性评价

这套设计的聪明之处和值得注意的地方：

✅ **passHash 不走网络明文传输**（唯一例外是二维码 URL 里那一次，走的是线下扫码通道）；网络上一律只交换 HMAC proof，抓包拿不到密码本身。

✅ 挑战应答带 nonce，proof 不可重放（每轮 challenge 换 nonce）。

⚠️ **二维码 = 密码本体**。截图发给别人、屏幕被投屏、扫码记录被云端保存（有些扫码 App 会把 URL 上传），任何一种都等于交出电脑控制权。用完随手「刷新二维码」是个好习惯。

⚠️ hash 是 sha256(密码) 而 relay 同时持有 hash 并能用它验证 HMAC——这意味着 relay 服务端有能力冒充任一端，这是纯中继架构的固有信任模型，不是实现缺陷，但值得知道。

## 复现

如果想自己验证，关键位置都在 `/Applications/ZCode.app/Contents/Resources/app.asar` 里：

- `out/main/index.js` — relay 传输类、认证 provider（`calculateProof`）、配对状态机
- `out/main/chunk-FQMTTDFW.js` — 二维码 URL 构建器、消息 schema（zod）
- `out/renderer/assets/styles-CdEGpc2x.js` — `WebRemoteControlDialog` 组件
- 手机端代码：浏览器打开 `https://zcode.z.ai/remote/v4` 后从 sources 里翻，`calculateProof` 用的是 WebCrypto 版 HMAC，和桌面端逐字节等价

另外两个环境变量可以改变默认端点（自建 relay 调试用）：`ZCODE_WEB_REMOTE_CONTROL_URL`（覆盖二维码 base URL）和 `ZCODE_WEB_REMOTE_CONTROL_RELAY_WS_URL`（覆盖 relay WebSocket 地址）。

## 总结

逆向下来，ZCode 远程控制的认证设计可以一句话概括：**桌面端向云端注册设备拿到"房间号"（sid），本地随机生成"房间密码"（passHash），二维码就是把房间号+密码通过线下扫码交给手机；之后两端各自用同一个密码对云端做 HMAC 挑战应答，云端按房间号把两端桥接起来**。

实现质量相当高：持久化凭据的半状态清理、AUTH_FAILED 的一次性兜底防死循环、心跳超时与 stale-waiting 恢复、RPC 帧的分片校验重传，这些边界情况都处理得很干净。作为对照学习 WebSocket 中继认证架构的样本，值得细读。

