﻿---
title: "WebSocket 连接怎么鉴权：明文 ws 下的令牌与挑战-应答"
description: "OpenClaw 网关的握手里，服务端先发来一个 nonce，客户端回的 connect 帧里却还躺着一个明文令牌。顺着这个现象往下追：ws:// 上到底什么是明文、帧掩码为什么不算加密，令牌通常放在哪，为什么发哈希不够、要用每次不同的挑战，obs-websocket 的 SHA-256 挑战-应答和 OpenClaw 的 Ed25519 设备签名各防住了什么，令牌为什么还要明文发，挑战-应答为什么护不住整条会话，最后看 wss 和 TLS 补上的是哪一块，并把 Home Assistant、Jupyter、VS Code Server、code-server、Kubernetes 的做法放在一起对照。"
pubDate: 2026-10-26
tags: ["WebSocket", "安全", "鉴权", "OpenClaw"]
hero: "./hero.svg"
clc: "TP309"
refs:
  - id: rfc6455
    title: "RFC 6455: The WebSocket Protocol"
    url: https://www.rfc-editor.org/rfc/rfc6455
    author: I. Fette, A. Melnikov
    site: IETF
    date: "2011"
    note: "第 5.3 节规定客户端帧必须带 4 字节掩码键，第 10.3 节说明掩码是为了防止针对中间代理的缓存投毒，不是为了保密。"
  - id: oc-handshake
    title: "OpenClaw · Gateway protocol handshake"
    url: https://github.com/openclaw/openclaw/blob/main/docs/gateway/protocol/handshake.md
    site: GitHub
    note: "`connect.challenge` 事件和 `connect` 请求的完整字段。"
  - id: oc-auth
    title: "OpenClaw · Gateway protocol auth"
    url: https://github.com/openclaw/openclaw/blob/main/docs/gateway/protocol/auth.md
    site: GitHub
    note: "共享令牌、设备令牌、设备身份、v2/v3 签名载荷，以及旧客户端迁移时的 `DEVICE_AUTH_*` 错误码。"
  - id: oc-connect-auth
    title: "openclaw/src/gateway/server/ws-connection/connect-auth.ts"
    url: https://github.com/openclaw/openclaw/blob/main/src/gateway/server/ws-connection/connect-auth.ts
    site: GitHub
    note: "服务端校验设备签名的代码：`DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000`，依次检查 deviceId、signedAt、nonce 和签名。本文读的是 2026-10-26 的 `0ef5ddd`。"
  - id: oc-device-auth
    title: "openclaw/packages/gateway-client/src/device-auth.ts"
    url: https://github.com/openclaw/openclaw/blob/main/packages/gateway-client/src/device-auth.ts
    site: GitHub
    note: "`buildDeviceAuthPayloadV3`：签名载荷用 `|` 拼接 v3、deviceId、clientId、clientMode、role、scopes、signedAtMs、token、nonce、platform、deviceFamily。"
  - id: oc-transport
    title: "openclaw/packages/gateway-client/src/websocket-transport.ts"
    url: https://github.com/openclaw/openclaw/blob/main/packages/gateway-client/src/websocket-transport.ts
    site: GitHub
    note: "`isSecureWebSocketUrl`：ws:// 只放行回环、私网 IP、`.local` 和 `.ts.net`，其余报 `SECURITY ERROR`；`tlsFingerprint` 只能配 wss://。"
  - id: oc-insecure-http
    title: "OpenClaw · Control UI: Connect and pair（Insecure HTTP 一节）"
    url: https://github.com/openclaw/openclaw/blob/main/docs/web/control-ui/connect-and-pair.md
    site: GitHub
    note: "原文：签名密钥不离开浏览器，是明文传输唯一泄露不了的凭据；共享令牌则任何路径上的观察者都能读到。主动攻击者还可以改页面。"
  - id: oc-exposure
    title: "OpenClaw · Network exposure"
    url: https://github.com/openclaw/openclaw/blob/main/docs/gateway/security/network-exposure.md
    site: GitHub
    note: "默认 `gateway.bind: loopback`，推荐 SSH 隧道或 Tailscale Serve；远程配对需要审批，直连回环自动批准。"
  - id: obs-protocol
    title: "obs-websocket 5.x protocol · Creating an authentication string"
    url: https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
    site: GitHub
  - id: obs-crypto
    title: "obs-websocket/src/utils/Crypto.cpp"
    url: https://github.com/obsproject/obs-websocket/blob/master/src/utils/Crypto.cpp
    site: GitHub
    note: "`GenerateSecret` 和 `CheckAuthenticationString`；salt 在 `WebSocketServer::Start()` 时生成一次，challenge 每个连接生成一次。"
  - id: ha-ws
    title: "Home Assistant · WebSocket API"
    url: https://developers.home-assistant.io/docs/api/websocket/
    site: Home Assistant Developer Docs
    note: "连上后服务端发 `auth_required`，客户端回 `{\"type\": \"auth\", \"access_token\": ...}`。"
  - id: ha-auth
    title: "home-assistant/core · websocket_api/auth.py"
    url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/websocket_api/auth.py
    site: GitHub
    note: "访问令牌是 HS256 签名的 JWT，`ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30)`（`homeassistant/auth/const.py`）。"
  - id: jupyter-security
    title: "Jupyter Server · Security in the Jupyter Server"
    url: https://jupyter-server.readthedocs.io/en/latest/operators/security.html
    site: Read the Docs
    note: "令牌可以放在 URL 参数 `?token=`、`Authorization: token ...` 头里，登录后换成 cookie；WebSocket 的 Origin 检查见 `jupyter_server/base/websocket.py` 的 `check_origin`。"
  - id: vscode-server
    title: "microsoft/vscode · remoteExtensionHostAgentServer.ts"
    url: https://github.com/microsoft/vscode/blob/main/src/vs/server/node/remoteExtensionHostAgentServer.ts
    site: GitHub
    note: "握手先收 `auth` 消息校验 connection token，再发 `sign` 请求；签名和校验由闭源模块 `vsda` 完成，web 客户端直接把 connection token 当作 `signedData`。"
  - id: code-server
    title: "coder/code-server · src/node/util.ts"
    url: https://github.com/coder/code-server/blob/main/src/node/util.ts
    site: GitHub
    note: "登录后 cookie 的值是口令的 argon2 哈希（`routes/login.ts`），之后用 `safeCompare` 比较；WebSocket 路由前有 `ensureOrigin`。"
  - id: k8s-ws
    title: "Kubernetes · Authenticating: Putting a bearer token in a request"
    url: https://kubernetes.io/docs/reference/access-authn-authz/authentication/
    site: kubernetes.io
    note: "浏览器发不了 Authorization 头时，可以把令牌编进子协议 `base64url.bearer.authorization.k8s.io.<token>`。"
  - id: cswsh
    title: "Cross-Site WebSocket Hijacking (CSWSH)"
    url: https://christian-schneider.net/CrossSiteWebSocketHijacking.html
    author: Christian Schneider
    date: "2013"
  - id: rfc8446
    title: "RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3"
    url: https://www.rfc-editor.org/rfc/rfc8446
    author: E. Rescorla
    site: IETF
    date: "2018"
  - id: notes
    title: "本文的调研笔记与实验脚本"
    note: "项目共享目录 `websocket-auth-research/`：`experiment/mask.py` 构造并还原一帧客户端帧，`verify.py` 是照 OpenClaw 校验逻辑写的玩具校验器，`obs_values.py` 按 obs-websocket 的公式算图 2 里的值。"
---

在 OpenClaw 网关上连一个客户端，WebSocket 一打开，服务端不等我们说话，先推过来一帧：

```json
{"type":"event","event":"connect.challenge",
 "payload":{"nonce":"d57bef68-95bd-4c68-b55b-8989377ab652","ts":1793019487750}}
```

客户端接着回一个 `connect` 请求。里面有一个 `device` 对象，带着公钥、64 字节的签名，还把刚才那个 nonce 原样抄了回去；而在它旁边，`auth.token` 字段里明明白白躺着网关令牌：

```json
"auth":   {"token":"tok_demo_123"},
"device": {"id":"3faf77339ceab958…","publicKey":"53x2_wKpjVK1xL04…",
           "signature":"zrIaDJPZa4hjFxYMlznC…","signedAt":1793019487750,
           "nonce":"d57bef68-95bd-4c68-b55b-8989377ab652"}
```

这就有点奇怪了。如果连接走的是 `ws://` 而不是 `wss://`，令牌不就被路上的人看见了吗？既然令牌都明文发了，那个 nonce 和签名又是在防什么？是不是有了 challenge，明文 ws 也就够了？

我们就从这两帧出发，先弄清明文 WebSocket 上到底暴露了什么，再看各家软件是怎么在这条连接上证明“我是谁”的。

## 一、明文 WebSocket 上暴露了什么

### 现象：帧里的内容看起来像乱码

WebSocket 连接以一个普通的 HTTP 请求开始：客户端发 `GET`，带上 `Upgrade: websocket`，服务端回 `101 Switching Protocols`，之后同一条 TCP 连接就改成按“帧”收发数据。如果用 `ws://`，这个 HTTP 请求和之后的每一帧都直接跑在 TCP 上，没有任何加密。

但如果真的去看客户端发出的字节，会发现载荷并不是明文 JSON，而是一串看不出规律的十六进制。这是因为 RFC 6455 规定，**客户端发往服务端的每一帧都必须做掩码**[^rfc6455]：随机挑 4 个字节，和载荷逐字节异或。那掩码算不算一种加密？

### 原因：掩码键就写在帧头里

不算。掩码键本身就放在帧头里，紧跟在长度字段后面，和载荷一起发出去。我们用一个小脚本构造了一帧[^notes]，图 1 是它在线路上的样子。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wsa-frame-title wsa-frame-desc">
<title id="wsa-frame-title">ws:// 连接在线路上的样子</title>
<desc id="wsa-frame-desc">左边是 HTTP 升级请求，URL 里的 token 参数和 Cookie 都是明文；右边是一帧客户端帧，帧头 81 d3 之后紧跟 4 字节掩码键 78 a7 22 9e，再后面是掩码后的载荷。用这 4 个字节循环异或，就还原出带 token 的 connect 请求。</desc>
<defs>
<marker id="wsa-frame-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="400" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="368" rx="8"/>
<text class="t-zone" x="32" y="44">线路上 · ws://gw.lan:18789</text>
<path class="dd-edge-acc" d="M592 176 V212" marker-end="url(#wsa-frame-arrow-acc)"/>
<rect class="dd-card" x="32" y="64" width="352" height="136" rx="6"/>
<path class="dd-card-head" d="M38 64 H378 A6 6 0 0 1 384 70 V96 H32 V70 A6 6 0 0 1 38 64 Z"/>
<text class="t-name" x="48" y="85">HTTP 升级请求</text>
<text class="t-note" x="368" y="85" text-anchor="end">只在开头发一次</text>
<text class="t-mono" x="48" y="113">GET /?token=tok_demo_123 HTTP/1.1</text>
<line class="dd-hair" x1="32" y1="122" x2="384" y2="122"/>
<text class="t-mono" x="48" y="139">Upgrade: websocket</text>
<line class="dd-hair" x1="32" y1="148" x2="384" y2="148"/>
<text class="t-mono" x="48" y="165">Cookie: session=7c1e…</text>
<line class="dd-hair" x1="32" y1="174" x2="384" y2="174"/>
<text class="t-mono" x="48" y="191">Sec-WebSocket-Key: dGhl…</text>
<rect class="dd-card" x="416" y="64" width="352" height="112" rx="6"/>
<path class="dd-card-head" d="M422 64 H762 A6 6 0 0 1 768 70 V96 H416 V70 A6 6 0 0 1 422 64 Z"/>
<text class="t-name" x="432" y="85">客户端帧</text>
<text class="t-note" x="752" y="85" text-anchor="end">每条消息一帧</text>
<text class="t-mono" x="432" y="113">81 d3</text>
<text class="t-note" x="752" y="113" text-anchor="end">文本帧，有掩码，长 83</text>
<line class="dd-hair" x1="416" y1="122" x2="768" y2="122"/>
<text class="t-mono t-acc" x="432" y="139">78 a7 22 9e</text>
<text class="t-note" x="752" y="139" text-anchor="end">掩码键</text>
<line class="dd-hair" x1="416" y1="148" x2="768" y2="148"/>
<text class="t-val" x="432" y="165">03 85 56 e7 08 c2 00 a4 …</text>
<text class="t-note" x="752" y="165" text-anchor="end">掩码后的载荷</text>
<rect class="dd-derived" x="416" y="216" width="352" height="76" rx="6"/>
<text class="t-note" x="432" y="238">逐字节异或 <tspan class="t-acc">78 a7 22 9e</tspan> 之后</text>
<text class="t-mono" x="432" y="260">{"type":"req","method":"connect",</text>
<text class="t-mono" x="432" y="280"> "params":{"auth":{"token":"tok_demo_123"}}}</text>
<text class="t-say" x="432" y="320">虚线框不在线路上，是任何人都能算出来的结果：</text>
<text class="t-say" x="432" y="340">掩码键和载荷一起发出，异或一遍就还原。</text>
<text class="t-say" x="48" y="232">URL、Cookie、头部和之后的每一帧，</text>
<text class="t-say" x="48" y="252">在 ws:// 上都是明文。</text>
</svg>
</div>
<figcaption>图 1 · 帧掩码不是加密：掩码键就在帧头里，路上的人和服务端一样能还原载荷</figcaption>
</figure>

掩码真正防的是另一件事。早期研究发现，网页里的脚本如果能让浏览器往一条连接上写出任意字节，就可能骗过路上那些不懂 WebSocket 的透明代理，让它们把攻击者伪造的内容当成某个 HTTP 响应缓存起来。每一帧换一个随机掩码，脚本就控制不了线路上的实际字节，这类缓存投毒就做不成了[^rfc6455]。它针对的是中间设备的误判，不是窃听者。

所以，**在 `ws://` 上，任何在路径上的人（同一个 Wi-Fi、路由器、运营商、公司代理）都能读到升级请求里的 URL、Cookie、头部，以及之后每一帧的内容。** 那鉴权用的令牌，通常就放在这几个地方之一。

## 二、令牌放在哪里

### 浏览器的限制

照理说，HTTP 上最常见的做法是把令牌放进 `Authorization` 头。但浏览器里的 `new WebSocket(url, protocols)` 只接受一个 URL 和一组子协议名，**不能自定义请求头**。于是各家软件只能在图 1 左边那张卡片和右边那一帧之间挑一个位置：

- **URL 参数**：Jupyter 启动时打印的链接就是 `?token=...`[^jupyter-security]。缺点是 URL 会进访问日志和浏览器历史。
- **Cookie**：Jupyter 登录后、code-server 都靠 Cookie。浏览器会自动带上它，这带来跨站 WebSocket 劫持的问题：别的网站上的脚本也能向你的服务发起 WebSocket，浏览器照样附上 Cookie，而 WebSocket 不受同源策略限制[^cswsh]。所以用 Cookie 的服务都要检查 `Origin` 头，Jupyter 的 `check_origin`、code-server 的 `ensureOrigin`、OpenClaw 的 `allowedOrigins` 都是这个用途。
- **子协议**：Kubernetes 允许把令牌编进 `Sec-WebSocket-Protocol`，写成 `base64url.bearer.authorization.k8s.io.<token>`[^k8s-ws]，借子协议这个唯一能设置的头。
- **第一帧消息**：Home Assistant 连上后先发 `auth_required`，客户端回 `{"type":"auth","access_token":"..."}`[^ha-ws]；OpenClaw、VS Code Server 也是这样。浏览器不会自动附带这种凭据，天然没有跨站劫持的问题。

### 共同点：都是不记名令牌

位置不同，性质是一样的：这些令牌都是**不记名令牌**（bearer token），谁拿着它，服务端就当谁是合法用户。服务端只做一次比较，“你交上来的字符串等不等于我存的那个”。在明文 ws 上，路上的人看到它，就能拿去自己连一次。

Home Assistant 的访问令牌是 30 分钟过期的 JWT[^ha-auth]，偷到之后能用的时间窗口有限；但它也支持以年计的长期令牌。code-server 更直接，Cookie 里存的就是口令的 argon2 哈希[^code-server]，在改口令之前一直有效。

**能不能干脆不把秘密发出去，只证明“我知道它”？**

## 三、挑战-应答：不发送秘密本身

### 第一次尝试：发秘密的哈希

最直觉的想法是不发口令，发 `sha256(口令)`。可是服务端要验证，只能拿同样的哈希来比，于是这个哈希值本身就成了新的口令：窃听者不需要知道原口令，把听到的哈希原样再发一遍就能登录。这就是**重放攻击**。

问题出在每次发送的内容都一样。要让听到的东西下次不能用，就得让每次要回答的问题不一样。

### 解决：每个连接一道新题

这就是**挑战-应答**（challenge-response）。服务端在每个连接开始时生成一个随机数（nonce，number used once，只用一次的数）发给客户端，客户端用秘密和这个随机数一起算出应答。下一条连接换一个随机数，上一条连接里听到的应答就作废了。

obs-websocket（OBS 直播软件的远程控制接口）的 5.x 协议是一个很干净的例子[^obs-protocol]。服务启动时生成一个 salt；每来一个连接，再生成一个 challenge，两者一起放进第一帧 `Hello` 里发给客户端。客户端按下面两步算出应答：

$$
\text{secret} = \text{base64}(\text{sha256}(\text{password} \,\|\, \text{salt})),\quad
\text{auth} = \text{base64}(\text{sha256}(\text{secret} \,\|\, \text{challenge}))
$$

服务端用自己保存的口令做同样的计算，比较结果[^obs-crypto]。图 2 是用口令 `demo-pass` 实际算出的两条连接。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 432" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wsa-obs-title wsa-obs-desc">
<title id="wsa-obs-title">obs-websocket 的挑战-应答</title>
<desc id="wsa-obs-desc">OBS 在 Hello 里发出 salt 和 challenge，客户端用口令、salt、challenge 算出 authentication 字符串放进 Identify；下一条连接 salt 不变、challenge 换了，算出的 authentication 完全不同，上一条的应答无法重用。</desc>
<defs>
<marker id="wsa-obs-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="432" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="232" rx="8"/>
<text class="t-zone" x="32" y="44">连接 1</text>
<text class="t-name" x="120" y="44">客户端</text>
<text class="t-name" x="680" y="44">OBS · 端口 4455</text>
<line class="dd-edge dd-dash" x1="144" y1="56" x2="144" y2="236"/>
<line class="dd-edge dd-dash" x1="704" y1="56" x2="704" y2="236"/>
<path class="dd-edge" d="M704 84 H152" marker-end="url(#wsa-obs-arrow)"/>
<rect class="dd-card" x="184" y="64" width="480" height="56" rx="6"/>
<text class="t-name" x="200" y="86">Hello（op 0）</text>
<text class="t-mono" x="320" y="86">salt: 7/lxUzB5okOcXs…</text>
<text class="t-mono" x="320" y="108">challenge: <tspan class="t-acc">8cBw+ZuwaQtncwvh…</tspan></text>
<rect class="dd-derived" x="32" y="132" width="736" height="36" rx="6"/>
<text class="t-note" x="48" y="155">客户端本地算：secret = b64(sha256("demo-pass" + salt))；auth = b64(sha256(secret + challenge))</text>
<path class="dd-edge" d="M144 200 H696" marker-end="url(#wsa-obs-arrow)"/>
<rect class="dd-card" x="184" y="180" width="480" height="40" rx="6"/>
<text class="t-name" x="200" y="205">Identify（op 1）</text>
<text class="t-mono" x="320" y="205">authentication: P4DVdeLOA8QPdb9h…</text>
<rect class="dd-zone" x="16" y="264" width="768" height="152" rx="8"/>
<text class="t-zone" x="32" y="292">连接 2</text>
<rect class="dd-card" x="184" y="276" width="480" height="56" rx="6"/>
<text class="t-name" x="200" y="298">Hello</text>
<text class="t-mono" x="320" y="298">salt: 7/lxUzB5okOcXs…</text>
<text class="t-mono" x="320" y="320">challenge: <tspan class="t-acc">XSDgMXNCGayZRxsA…</tspan></text>
<rect class="dd-card" x="184" y="344" width="480" height="40" rx="6"/>
<text class="t-name" x="200" y="369">Identify</text>
<text class="t-mono" x="320" y="369">authentication: xYIL6RhHMzuB0T+t…</text>
<text class="t-say" x="32" y="404">salt 不变，challenge 换了，应答就完全不同；把连接 1 的应答拿来交，服务端算出的不是它。</text>
</svg>
</div>
<figcaption>图 2 · 挑战-应答：口令不上线路，每个连接的应答只对这一道题有效</figcaption>
</figure>

### 它没有防住的事

挑战-应答解决了“听到就能重放”，但还留着两个口子。

**一是服务端保存的东西。** obs-websocket 在配置里存的是口令本身，`secret` 也是服务端启动时现算的。共享秘密的方案都这样：要验证对方算得对不对，服务端自己必须能算，也就必须持有和口令等价的东西。服务端的配置一旦泄露，攻击者照样能登录。

**二是弱口令。** 窃听者虽然拿不到口令，却拿到了 salt、challenge 和对应的应答，等于拿到了一道“题和答案”。如果口令是能被猜出来的短词，他可以在自己机器上一个个试，不用再碰服务器。口令足够长、足够随机时，这条路就走不通。

顺带一提，VS Code Server 的握手里也有一步叫 `sign`：客户端先在 `auth` 消息里交上 connection token，服务端再发一段数据让客户端签名[^vscode-server]。但这个签名由闭源模块 `vsda` 完成，网页版客户端直接把 connection token 当作“签名”交回去。从代码看，它是在确认连上来的是官方客户端，令牌本身仍然明文放在第一条消息里，防窃听靠的是 SSH 隧道或只在本机监听（这是根据源码的推断）。看起来像挑战，不一定是为了保护秘密。

**那有没有一种办法，让服务端连“和口令等价的东西”都不用存？**

## 四、用公钥签名代替共享秘密：OpenClaw 的设备身份

### 分析：把共享秘密换成一对密钥

办法是改用非对称签名。每个客户端设备第一次运行时生成一对 Ed25519 密钥，私钥留在本地（浏览器里也一样，不离开浏览器），只把公钥交给服务端。服务端出题，客户端用私钥签名，服务端用公钥验签。公钥是公开的，服务端存着它不怕泄露：拿到公钥的人签不出名。

OpenClaw 网关就是这么做的[^oc-auth]。开头那两帧就是这套机制的全部：服务端在连接打开后立即发出 `connect.challenge`，里面的 nonce 是一个随机 UUID，`ts` 是服务端当前时间[^oc-handshake]；客户端把 nonce 和一串连接参数拼成签名载荷，用私钥签名后放进 `connect.params.device`。图 3 把客户端交上去的字段、被签名的载荷和服务端的四项检查放在一起。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 456" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wsa-oc-title wsa-oc-desc">
<title id="wsa-oc-title">OpenClaw 网关的设备签名握手</title>
<desc id="wsa-oc-desc">网关在 connect.challenge 里发出 nonce 和 ts；客户端的 connect 请求里带 deviceId、公钥、signedAt、同一个 nonce 和 64 字节签名，另有明文的 auth.token；被签名的 v3 载荷把设备、角色、权限范围、时间、令牌和 nonce 拼在一起；网关依次检查 deviceId 是否等于公钥的 SHA-256、signedAt 是否在两分钟内、nonce 是否等于本连接的 nonce、签名是否有效。</desc>
<defs>
<marker id="wsa-oc-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="456" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="312" rx="8"/>
<rect class="dd-zone" x="408" y="16" width="376" height="312" rx="8"/>
<rect class="dd-zone" x="16" y="344" width="768" height="96" rx="8"/>
<text class="t-zone" x="32" y="44">客户端 · connect 请求</text>
<text class="t-zone" x="424" y="44">网关 · connect-auth.ts</text>
<text class="t-zone" x="32" y="372">被签名的 v3 载荷</text>
<path class="dd-edge-acc" d="M424 109 H400 V187 H384" marker-end="url(#wsa-oc-arrow-acc)"/>
<rect class="dd-card" x="424" y="64" width="344" height="84" rx="6"/>
<path class="dd-card-head" d="M430 64 H762 A6 6 0 0 1 768 70 V96 H424 V70 A6 6 0 0 1 430 64 Z"/>
<text class="t-name" x="440" y="85">event</text>
<text class="t-mono" x="752" y="85" text-anchor="end">connect.challenge</text>
<text class="t-mono" x="440" y="113">nonce</text>
<text class="t-mono t-acc" x="504" y="113">d57bef68-95bd-4c68-…</text>
<line class="dd-hair" x1="424" y1="122" x2="768" y2="122"/>
<text class="t-mono" x="440" y="139">ts</text>
<text class="t-val" x="504" y="139">1793019487750</text>
<rect class="dd-card" x="424" y="180" width="344" height="136" rx="6"/>
<path class="dd-card-head" d="M430 180 H762 A6 6 0 0 1 768 186 V212 H424 V186 A6 6 0 0 1 430 180 Z"/>
<text class="t-name" x="440" y="201">依次检查</text>
<text class="t-note" x="752" y="201" text-anchor="end">任一失败即 1008 断开</text>
<text class="t-mono" x="440" y="229">sha256(publicKey) == id</text>
<line class="dd-hair" x1="424" y1="238" x2="768" y2="238"/>
<text class="t-mono" x="440" y="255">|now − signedAt| ≤ 120 s</text>
<line class="dd-hair" x1="424" y1="264" x2="768" y2="264"/>
<text class="t-mono" x="440" y="281"><tspan class="t-acc">nonce</tspan> == 本连接的 <tspan class="t-acc">nonce</tspan></text>
<line class="dd-hair" x1="424" y1="290" x2="768" y2="290"/>
<text class="t-mono" x="440" y="307">Ed25519 验签 v3 载荷</text>
<rect class="dd-card" x="32" y="64" width="344" height="162" rx="6"/>
<path class="dd-card-head" d="M38 64 H370 A6 6 0 0 1 376 70 V96 H32 V70 A6 6 0 0 1 38 64 Z"/>
<text class="t-name" x="48" y="85">params.device</text>
<text class="t-note" x="360" y="85" text-anchor="end">私钥不离开设备</text>
<text class="t-mono" x="48" y="113">id</text>
<text class="t-val" x="136" y="113">3faf77339ceab958…</text>
<line class="dd-hair" x1="32" y1="122" x2="376" y2="122"/>
<text class="t-mono" x="48" y="139">publicKey</text>
<text class="t-val" x="136" y="139">53x2_wKpjVK1xL04…</text>
<line class="dd-hair" x1="32" y1="148" x2="376" y2="148"/>
<text class="t-mono" x="48" y="165">signedAt</text>
<text class="t-val" x="136" y="165">1793019487750</text>
<line class="dd-hair" x1="32" y1="174" x2="376" y2="174"/>
<text class="t-mono" x="48" y="191">nonce</text>
<text class="t-mono t-acc" x="136" y="191">d57bef68-95bd-4c68-…</text>
<line class="dd-hair" x1="32" y1="200" x2="376" y2="200"/>
<text class="t-mono" x="48" y="217">signature</text>
<text class="t-val" x="136" y="217">zrIaDJPZa4hjFxYM…</text>
<text class="t-note" x="360" y="217" text-anchor="end">64 字节</text>
<rect class="dd-card" x="32" y="248" width="344" height="58" rx="6"/>
<path class="dd-card-head" d="M38 248 H370 A6 6 0 0 1 376 254 V280 H32 V254 A6 6 0 0 1 38 248 Z"/>
<text class="t-name" x="48" y="269">params.auth</text>
<text class="t-note" x="360" y="269" text-anchor="end">明文，见第五节</text>
<text class="t-mono" x="48" y="297">token</text>
<text class="t-val" x="136" y="297">tok_demo_123</text>
<text class="t-mono" x="32" y="400">v3|3faf7733…|cli|operator|operator|operator.read|1793019487750|</text>
<text class="t-mono" x="32" y="420">tok_demo_123|<tspan class="t-acc">d57bef68-95bd-4c68-b55b-8989377ab652</tspan>|linux|</text>
</svg>
</div>
<figcaption>图 3 · OpenClaw 把本连接的 nonce 签进载荷，服务端只存公钥，就能确认“是这台设备、在这条连接上、刚刚签的”</figcaption>
</figure>

### 每一项检查防的是什么

服务端的检查顺序写在 `connect-auth.ts` 里[^oc-connect-auth]，每一项都对应一种冒充方式：

**deviceId 必须等于公钥的 SHA-256。** 设备 ID 不是客户端自己起的名字，而是从公钥算出来的。想冒充某台已配对的设备，就得拿出它的公钥，进而得拿出能和公钥对上的签名。

**nonce 必须等于这条连接的 nonce。** 这是防重放的核心。服务端为每个连接生成一个新 UUID，并记在这条连接上；上一条连接里签过的名，nonce 对不上，直接拒绝。

**签名必须覆盖 v3 载荷。** 载荷不只有 nonce，还有角色、权限范围（scopes）、客户端类型和令牌[^oc-device-auth]。这样窃听者也不能保留签名、只改一下请求的权限，因为改了任何一个字段，签名就对不上了。

**signedAt 必须在两分钟以内。** 有了 nonce，这一项看起来多余。它其实是上一代机制留下的：早期客户端不等 challenge，只签本地时间戳，靠时间窗口防重放，可在两分钟内重放仍然有效。现在的协议要求先等 `connect.challenge`，用里面的 `ts` 作为 `signedAt`，旧客户端会收到 `DEVICE_AUTH_NONCE_REQUIRED` 之类的迁移错误码[^oc-auth]。

我们照着这四项检查写了一个玩具校验器[^notes]，用同一把私钥跑了五种情况：

```text
连接 1，正常签名        : ok
连接 2，原样再交一次    : device-nonce-mismatch
连接 2，改 nonce 字段   : device-signature
连接 1，改 scopes       : device-signature
连接 1，3 分钟后才交    : device-signature-stale
```

把签名原样带到新连接，nonce 对不上；把 nonce 字段改成新连接的，签名又对不上，因为 nonce 在载荷里。这就是“签名绑定到连接”。

**既然设备签名这么严，为什么 `connect` 帧里还要明文带一个令牌？**

## 五、令牌为什么还要明文发

### 原因：两层鉴权各管一件事

OpenClaw 的鉴权分两层。第一层是**网关鉴权**，回答“你有没有资格跟这个网关说话”，凭的是共享令牌或口令，或者 Tailscale、可信代理之类的身份；第二层是**设备身份和配对**，回答“你是哪台设备、被批准过什么权限”，凭的是图 3 里的签名[^oc-auth]。文档的说法是，网关鉴权先于设备配对执行。

共享令牌是运维人员配进网关、再复制到各个客户端的一个字符串，客户端手里只有它的原文，没法对它做非对称签名，只能直接交上去。它也被拼进了 v3 载荷，但签名只能证明“这个令牌是和这次签名一起交的”，并不能把令牌藏起来。

### 那令牌被偷了会怎样

假设攻击者在明文 ws 上看到了共享令牌，他拿着它自己连一次：

**网关鉴权能过。** 共享令牌本来就是不记名的。

**设备这一关过不了。** 他没有原设备的私钥，只能自己生成一对，这就成了一台新设备。新设备从非回环地址连上来，需要管理员在网关上审批配对，连接会以 `1008 pairing required` 断开[^oc-exposure]。只有直连 `127.0.0.1` 的本机连接才自动批准。

**设备令牌更是偷了也没用。** 配对之后网关会签发一个设备令牌，以后连接用它代替共享令牌。它同样放在 `auth.token` 里明文发送，但网关按 deviceId 去查它，而 deviceId 要配上那台设备私钥的签名才认。

OpenClaw 的文档在讨论纯 HTTP 访问时把这一点说得很直白：签名密钥从不离开浏览器，是明文传输唯一泄露不了的凭据；共享令牌则不然，任何在路径上观察 HTTP 连接的人都能读到[^oc-insecure-http]。也就是说，设计者清楚令牌会在明文 ws 上暴露，设备签名和配对审批是在这种情况下兜底的第二道门。

**那么，只要有了设备签名，明文 ws 是不是就够了？**

## 六、挑战-应答护住的是秘密，不是会话

### 现象：签名只发生在第一帧

回头看图 3，签名只覆盖 `connect` 这一帧。握手完成之后，聊天消息、工具调用、执行命令的请求都是普通的 JSON 帧，既没有签名也没有加密。obs-websocket、Home Assistant 也一样：鉴权只在开头做一次，之后的连接被当成“已经验证过的”。

### 原因：被动窃听和主动中间人是两种对手

挑战-应答能防的是**被动窃听者**：他只能看，看到的应答下次用不了。可路径上的人不一定只看。路由器、恶意 Wi-Fi、被控制的代理可以做**主动中间人**：他不需要知道任何秘密，只需要把服务端的 challenge 原样转给真正的客户端，再把客户端的签名原样转回服务端。双方的握手都是真的，签名也是真的，鉴权顺利通过；之后他就坐在这条已认证的连接中间，读每一条消息，甚至改写或插入消息。

在浏览器场景里还有更直接的办法。Control UI 本身是通过 HTTP 下发的网页，主动中间人可以直接改掉页面里的脚本，在私钥签名之前就动手。OpenClaw 文档也提到了这一点：纯 HTTP 下，主动攻击者可以修改页面、拿走页面里的任何东西[^oc-insecure-http]。

把前面几种方案放在一起，能看清每一步到底防住了什么：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="wsa-threat-title">明文 ws:// 上，不同鉴权方式挡得住什么</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="wsa-threat-title">
<colgroup><col style="width:26%"><col><col><col></colgroup>
<thead>
<tr><th scope="col">鉴权方式</th><th scope="col">被动窃听后能重放</th><th scope="col">泄露的东西可离线猜口令</th><th scope="col">主动中间人改后续消息</th></tr>
</thead>
<tbody>
<tr><th scope="row">不记名令牌（HA、Jupyter、VS Code Server）</th><td class="bad">能</td><td>不适用，令牌即凭据</td><td class="bad">能</td></tr>
<tr><th scope="row">发口令的哈希</th><td class="bad">能</td><td class="warn">能</td><td class="bad">能</td></tr>
<tr><th scope="row">共享秘密挑战-应答（obs-websocket）</th><td class="good">不能</td><td class="warn">能，弱口令有风险</td><td class="bad">能</td></tr>
<tr><th scope="row">公钥签名 + nonce（OpenClaw 设备身份）</th><td class="good">不能</td><td class="good">不能</td><td class="bad">能</td></tr>
<tr class="dt-group"><th colspan="4" scope="rowgroup">换成 wss://，并校验证书</th></tr>
<tr><th scope="row">以上任何一种</th><td class="good">看不到</td><td class="good">看不到</td><td class="good">不能</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">OpenClaw 的共享令牌本身仍属于第一行，靠配对审批兜底。</p>
</div>
<figcaption>图 4 · 挑战-应答把前两列变绿，最后一列只有 TLS 能改</figcaption>
</figure>

最后一列在 ws:// 上无论怎么设计应用层鉴权都是红的，因为问题不在“怎么证明身份”，而在“证明之后，这条连接上的字节没有任何保护”。要把它变绿，就得给整条连接的每一个字节都加上加密和完整性校验，并且确认对面真的是那台服务器。

## 七、wss 和 TLS 做了什么

### TLS 补上的三件事

`wss://` 就是把 WebSocket 跑在 TLS 上[^rfc8446]。TLS 握手先于 HTTP 升级请求，所以它保护的范围是全部：URL、Cookie、头部、每一帧。它做了三件应用层鉴权做不到的事：

**加密**：路径上的人只能看到密文，令牌、应答、消息内容都看不到，图 4 的前两列直接无从谈起。

**完整性**：每条 TLS 记录都带认证标签，中间有人改一个字节，接收方就会发现并断开。

**认证服务器**：客户端校验服务器证书，确认对面是自己要连的那台机器。主动中间人拿不出能通过校验的证书，也就无法冒充服务器、转发 challenge。这一步是关键，跳过证书校验的 TLS 挡不住主动中间人。

TLS 只证明服务器是谁，客户端是谁仍然由应用层决定，所以令牌、挑战-应答、设备签名依然有用，只是不用再独自扛着窃听和篡改。

### OpenClaw 怎么逼用户用上它

OpenClaw 的客户端在发起连接前就检查地址[^oc-transport]：`wss://` 一律放行；`ws://` 只放行回环地址、私网 IP、`.local` 和 Tailscale 的 `.ts.net`。连别的地址会直接报错，不会把令牌发出去：

```text
SECURITY ERROR: Cannot connect to "gw.example.com" over plaintext ws://.
Both credentials and chat data would be exposed to network interception.
Use wss:// for remote URLs. Safe defaults: keep gateway.bind=loopback and
connect via SSH tunnel (ssh -N -L 18789:127.0.0.1:18789 user@gateway-host),
or use Tailscale Serve/Funnel.
```

注释写明这个检查放在读取本地设备令牌之前，免得凭据先被加载出来。确实需要在内网用明文时，要在客户端进程上设置 `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`，而且只认进程环境变量，不能写进 `openclaw.json`。

它还提供了两样东西，让“确认对面是谁”更牢靠。一是**证书指纹钉扎**：`gateway.remote.tlsFingerprint` 或 `--tls-fingerprint` 填上网关证书的 SHA-256 指纹，客户端只认这一张证书，自签名证书也能安全使用，`ws://` 地址配指纹会直接报错。二是**默认只监听回环**：`gateway.bind` 默认是 `loopback`，远程访问推荐走 SSH 隧道或 Tailscale Serve[^oc-exposure]，这两种方式都在 WebSocket 外面先套了一层加密和认证。设备令牌的自动重试也只在“可信端点”上做，也就是回环地址，或者配了指纹的 `wss://`。

回到开头的问题：OpenClaw 的 nonce 和签名防的是“令牌或签名被看到后被人拿去重放”“服务端泄露后被人冒充设备”；它不负责让明文 ws 变安全，这件事交给了 TLS、SSH 隧道和只在本机监听，并且在客户端直接拦住了不安全的组合。

## 八、同类软件的做法对照

把读过源码的几个软件放在一起，能看出两条路线：大多数用不记名令牌，把保密完全交给传输层；少数在应用层做挑战-应答，但也没有一个指望它替代 TLS。

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="wsa-compare-title">WebSocket 鉴权做法对照（2026-10 源码）</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="wsa-compare-title">
<colgroup><col style="width:20%"><col><col><col class="hl"><col></colgroup>
<thead>
<tr><th scope="col">软件</th><th scope="col">凭据放在哪</th><th scope="col">凭据形态</th><th scope="col" class="acc">防重放</th><th scope="col">对明文的态度</th></tr>
</thead>
<tbody>
<tr><th scope="row">OpenClaw 网关</th><td>第一帧 <span class="mono">connect</span></td><td>共享 / 设备令牌 + Ed25519 设备签名</td><td class="acc">每连接 nonce，签名有效 2 分钟</td><td>非私网地址拒绝 ws://，支持证书钉扎</td></tr>
<tr><th scope="row">obs-websocket 5</th><td>第一帧 <span class="mono">Identify</span></td><td>SHA-256 挑战-应答</td><td class="acc">每连接 challenge</td><td>只有 ws://，靠本机或内网</td></tr>
<tr><th scope="row">Home Assistant</th><td>第一帧 <span class="mono">auth</span></td><td>JWT 访问令牌，30 分钟；可发长期令牌</td><td>无，靠过期</td><td>文档建议远程访问用 HTTPS</td></tr>
<tr><th scope="row">Jupyter Server</th><td>URL 参数、Authorization 头、Cookie</td><td>随机令牌，登录后换 Cookie</td><td>无</td><td>默认只听本机；检查 Origin</td></tr>
<tr><th scope="row">VS Code Server</th><td>第一条控制消息 <span class="mono">auth</span></td><td>connection token；<span class="mono">sign</span> 步骤校验官方客户端</td><td>无</td><td>靠 SSH 隧道或本机</td></tr>
<tr><th scope="row">code-server</th><td>Cookie</td><td>口令的 argon2 哈希</td><td>无，改口令前一直有效</td><td>建议 HTTPS；检查 Origin</td></tr>
<tr><th scope="row">Kubernetes API</th><td>Authorization 头或子协议</td><td>不记名令牌</td><td>无，靠令牌有效期</td><td>只走 HTTPS</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">“防重放”一列指应用层机制。走 TLS 时，窃听者本来就拿不到可以重放的东西。</p>
</div>
<figcaption>图 5 · 只有 OpenClaw 和 obs-websocket 在应用层做了挑战-应答，其余都把保密交给传输层</figcaption>
</figure>

为什么大多数软件不在应用层做挑战-应答？从上面的分析可以推出原因：它只能防住被动窃听，主动中间人和后续消息还是得靠 TLS；而一旦用了 TLS，不记名令牌本身就已经安全了，应用层再做一遍收益不大。OpenClaw 做设备签名，主要不是为了替代 TLS，而是为了**设备级的身份和授权**：每台设备单独审批、单独吊销，服务端不存任何能冒充设备的秘密。obs-websocket 的场景则是局域网里的直播软件，用户基本不会为它配证书，挑战-应答至少保证口令不上线路。

## 小结

1. **明文 ws 上什么是可见的？** 升级请求的 URL、Cookie、头部和之后的每一帧都可见。客户端帧的掩码键就在帧头里，掩码是为了防代理缓存投毒，不是加密。
2. **令牌通常放在哪？** 浏览器不能给 WebSocket 加自定义头，所以放在 URL 参数、Cookie、子协议或第一帧消息里。用 Cookie 的要检查 Origin，防跨站 WebSocket 劫持。它们大多是不记名令牌，明文上看到就能拿去用。
3. **发哈希为什么不够？** 固定的哈希等于新的口令，照样能重放。要让每次的问题不一样，就是挑战-应答。
4. **共享秘密的挑战-应答（obs-websocket）防住了什么？** 口令不上线路，听到的应答下次无效；但服务端要存口令，弱口令可能被离线猜出。
5. **OpenClaw 的设备签名多做了什么？** 用 Ed25519 公钥代替共享秘密，服务端只存公钥；nonce 和权限范围都签进载荷，签名只对这条连接、这组权限有效。
6. **令牌为什么还要明文发？** 共享令牌负责网关这一层，没法签名，只能直接交；被偷后，攻击者仍要过新设备的配对审批，设备令牌没有私钥也用不了。
7. **挑战-应答护得住整条会话吗？** 护不住。签名只在第一帧，主动中间人可以转发握手、然后读改后续消息；纯 HTTP 下还能改页面。
8. **wss 补上了什么？** 加密、完整性和服务器认证，覆盖从升级请求开始的每一个字节。OpenClaw 在客户端直接拒绝向公网发 ws://，并提供证书钉扎、默认回环监听和 SSH 隧道等安全默认值。

所以开头那个问题的答案是：明文 ws 上令牌确实会被看到，challenge 能让“看到的东西拿去重放”这件事失效，但让整条连接不被偷看、不被篡改，只能靠 wss。

## 参考资料
