一条连接上的挑战和令牌 左边是被划掉的 ws:// 和强调色的 wss://,下面是 connect.challenge 里的 nonce;右边一台笔记本通过一根连线接到服务器,连线前半段是透明的虚线管,里面的 token 字样一眼可见,后半段套着一把锁。 ws:// wss:// connect.challenge nonce d57bef68… sig zrIaDJPZ… {"auth": "token"…} tok_demo…

在 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 规定,客户端发往服务端的每一帧都必须做掩码1:随机挑 4 个字节,和载荷逐字节异或。那掩码算不算一种加密?

原因:掩码键就写在帧头里

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

ws:// 连接在线路上的样子 左边是 HTTP 升级请求,URL 里的 token 参数和 Cookie 都是明文;右边是一帧客户端帧,帧头 81 d3 之后紧跟 4 字节掩码键 78 a7 22 9e,再后面是掩码后的载荷。用这 4 个字节循环异或,就还原出带 token 的 connect 请求。 线路上 · ws://gw.lan:18789 HTTP 升级请求 只在开头发一次 GET /?token=tok_demo_123 HTTP/1.1 Upgrade: websocket Cookie: session=7c1e… Sec-WebSocket-Key: dGhl… 客户端帧 每条消息一帧 81 d3 文本帧,有掩码,长 83 78 a7 22 9e 掩码键 03 85 56 e7 08 c2 00 a4 … 掩码后的载荷 逐字节异或 78 a7 22 9e 之后 {"type":"req","method":"connect", "params":{"auth":{"token":"tok_demo_123"}}} 虚线框不在线路上,是任何人都能算出来的结果: 掩码键和载荷一起发出,异或一遍就还原。 URL、Cookie、头部和之后的每一帧, 在 ws:// 上都是明文。
图 1 · 帧掩码不是加密:掩码键就在帧头里,路上的人和服务端一样能还原载荷

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

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

二、令牌放在哪里

浏览器的限制

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

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

共同点:都是不记名令牌

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

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

能不能干脆不把秘密发出去,只证明“我知道它”?

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

第一次尝试:发秘密的哈希

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

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

解决:每个连接一道新题

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

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

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

服务端用自己保存的口令做同样的计算,比较结果10。图 2 是用口令 demo-pass 实际算出的两条连接。

obs-websocket 的挑战-应答 OBS 在 Hello 里发出 salt 和 challenge,客户端用口令、salt、challenge 算出 authentication 字符串放进 Identify;下一条连接 salt 不变、challenge 换了,算出的 authentication 完全不同,上一条的应答无法重用。 连接 1 客户端 OBS · 端口 4455 Hello(op 0) salt: 7/lxUzB5okOcXs… challenge: 8cBw+ZuwaQtncwvh… 客户端本地算:secret = b64(sha256("demo-pass" + salt));auth = b64(sha256(secret + challenge)) Identify(op 1) authentication: P4DVdeLOA8QPdb9h… 连接 2 Hello salt: 7/lxUzB5okOcXs… challenge: XSDgMXNCGayZRxsA… Identify authentication: xYIL6RhHMzuB0T+t… salt 不变,challenge 换了,应答就完全不同;把连接 1 的应答拿来交,服务端算出的不是它。
图 2 · 挑战-应答:口令不上线路,每个连接的应答只对这一道题有效

它没有防住的事

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

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

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

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

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

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

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

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

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

OpenClaw 网关的设备签名握手 网关在 connect.challenge 里发出 nonce 和 ts;客户端的 connect 请求里带 deviceId、公钥、signedAt、同一个 nonce 和 64 字节签名,另有明文的 auth.token;被签名的 v3 载荷把设备、角色、权限范围、时间、令牌和 nonce 拼在一起;网关依次检查 deviceId 是否等于公钥的 SHA-256、signedAt 是否在两分钟内、nonce 是否等于本连接的 nonce、签名是否有效。 客户端 · connect 请求 网关 · connect-auth.ts 被签名的 v3 载荷 event connect.challenge nonce d57bef68-95bd-4c68-… ts 1793019487750 依次检查 任一失败即 1008 断开 sha256(publicKey) == id |now − signedAt| ≤ 120 s nonce == 本连接的 nonce Ed25519 验签 v3 载荷 params.device 私钥不离开设备 id 3faf77339ceab958… publicKey 53x2_wKpjVK1xL04… signedAt 1793019487750 nonce d57bef68-95bd-4c68-… signature zrIaDJPZa4hjFxYM… 64 字节 params.auth 明文,见第五节 token tok_demo_123 v3|3faf7733…|cli|operator|operator|operator.read|1793019487750| tok_demo_123|d57bef68-95bd-4c68-b55b-8989377ab652|linux|
图 3 · OpenClaw 把本连接的 nonce 签进载荷,服务端只存公钥,就能确认“是这台设备、在这条连接上、刚刚签的”

每一项检查防的是什么

服务端的检查顺序写在 connect-auth.ts 里14,每一项都对应一种冒充方式:

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

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

签名必须覆盖 v3 载荷。 载荷不只有 nonce,还有角色、权限范围(scopes)、客户端类型和令牌15。这样窃听者也不能保留签名、只改一下请求的权限,因为改了任何一个字段,签名就对不上了。

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

我们照着这四项检查写了一个玩具校验器2,用同一把私钥跑了五种情况:

连接 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 里的签名12。文档的说法是,网关鉴权先于设备配对执行。

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

那令牌被偷了会怎样

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

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

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

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

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

那么,只要有了设备签名,明文 ws 是不是就够了?

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

现象:签名只发生在第一帧

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

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

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

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

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

明文 ws:// 上,不同鉴权方式挡得住什么
鉴权方式被动窃听后能重放泄露的东西可离线猜口令主动中间人改后续消息
不记名令牌(HA、Jupyter、VS Code Server)能不适用,令牌即凭据能
发口令的哈希能能能
共享秘密挑战-应答(obs-websocket)不能能,弱口令有风险能
公钥签名 + nonce(OpenClaw 设备身份)不能不能能
换成 wss://,并校验证书
以上任何一种看不到看不到不能

OpenClaw 的共享令牌本身仍属于第一行,靠配对审批兜底。

图 4 · 挑战-应答把前两列变绿,最后一列只有 TLS 能改

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

七、wss 和 TLS 做了什么

TLS 补上的三件事

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

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

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

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

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

OpenClaw 怎么逼用户用上它

OpenClaw 的客户端在发起连接前就检查地址19:wss:// 一律放行;ws:// 只放行回环地址、私网 IP、.local 和 Tailscale 的 .ts.net。连别的地址会直接报错,不会把令牌发出去:

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 Serve16,这两种方式都在 WebSocket 外面先套了一层加密和认证。设备令牌的自动重试也只在“可信端点”上做,也就是回环地址,或者配了指纹的 wss://。

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

八、同类软件的做法对照

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

WebSocket 鉴权做法对照(2026-10 源码)
软件凭据放在哪凭据形态防重放对明文的态度
OpenClaw 网关第一帧 connect共享 / 设备令牌 + Ed25519 设备签名每连接 nonce,签名有效 2 分钟非私网地址拒绝 ws://,支持证书钉扎
obs-websocket 5第一帧 IdentifySHA-256 挑战-应答每连接 challenge只有 ws://,靠本机或内网
Home Assistant第一帧 authJWT 访问令牌,30 分钟;可发长期令牌无,靠过期文档建议远程访问用 HTTPS
Jupyter ServerURL 参数、Authorization 头、Cookie随机令牌,登录后换 Cookie无默认只听本机;检查 Origin
VS Code Server第一条控制消息 authconnection token;sign 步骤校验官方客户端无靠 SSH 隧道或本机
code-serverCookie口令的 argon2 哈希无,改口令前一直有效建议 HTTPS;检查 Origin
Kubernetes APIAuthorization 头或子协议不记名令牌无,靠令牌有效期只走 HTTPS

“防重放”一列指应用层机制。走 TLS 时,窃听者本来就拿不到可以重放的东西。

图 5 · 只有 OpenClaw 和 obs-websocket 在应用层做了挑战-应答,其余都把保密交给传输层

为什么大多数软件不在应用层做挑战-应答?从上面的分析可以推出原因:它只能防住被动窃听,主动中间人和后续消息还是得靠 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。

参考资料

Footnotes

  1. RFC 6455: The WebSocket ProtocolI. Fette, A. Melnikov · IETF · 2011rfc-editor.org ↗第 5.3 节规定客户端帧必须带 4 字节掩码键,第 10.3 节说明掩码是为了防止针对中间代理的缓存投毒,不是为了保密。https://www.rfc-editor.org/rfc/rfc6455 ↩ ↩2

  2. 本文的调研笔记与实验脚本项目共享目录 websocket-auth-research/:experiment/mask.py 构造并还原一帧客户端帧,verify.py 是照 OpenClaw 校验逻辑写的玩具校验器,obs_values.py 按 obs-websocket 的公式算图 2 里的值。 ↩ ↩2

  3. Jupyter Server · Security in the Jupyter ServerRead the Docsjupyter-server.readthedocs.io ↗令牌可以放在 URL 参数 ?token=、Authorization: token ... 头里,登录后换成 cookie;WebSocket 的 Origin 检查见 jupyter_server/base/websocket.py 的 check_origin。https://jupyter-server.readthedocs.io/en/latest/operators/security.html ↩

  4. Cross-Site WebSocket Hijacking (CSWSH)Christian Schneider · 2013christian-schneider.net ↗https://christian-schneider.net/CrossSiteWebSocketHijacking.html ↩

  5. Kubernetes · Authenticating: Putting a bearer token in a requestkubernetes.iokubernetes.io ↗浏览器发不了 Authorization 头时,可以把令牌编进子协议 base64url.bearer.authorization.k8s.io.<token>。https://kubernetes.io/docs/reference/access-authn-authz/authentication/ ↩

  6. Home Assistant · WebSocket APIHome Assistant Developer Docsdevelopers.home-assistant.io ↗连上后服务端发 auth_required,客户端回 {"type": "auth", "access_token": ...}。https://developers.home-assistant.io/docs/api/websocket/ ↩

  7. home-assistant/core · websocket_api/auth.pyGitHubgithub.com ↗访问令牌是 HS256 签名的 JWT,ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30)(homeassistant/auth/const.py)。https://github.com/home-assistant/core/blob/dev/homeassistant/components/websocket_api/auth.py ↩

  8. coder/code-server · src/node/util.tsGitHubgithub.com ↗登录后 cookie 的值是口令的 argon2 哈希(routes/login.ts),之后用 safeCompare 比较;WebSocket 路由前有 ensureOrigin。https://github.com/coder/code-server/blob/main/src/node/util.ts ↩

  9. obs-websocket 5.x protocol · Creating an authentication stringGitHubgithub.com ↗https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md ↩

  10. obs-websocket/src/utils/Crypto.cppGitHubgithub.com ↗GenerateSecret 和 CheckAuthenticationString;salt 在 WebSocketServer::Start() 时生成一次,challenge 每个连接生成一次。https://github.com/obsproject/obs-websocket/blob/master/src/utils/Crypto.cpp ↩

  11. microsoft/vscode · remoteExtensionHostAgentServer.tsGitHubgithub.com ↗握手先收 auth 消息校验 connection token,再发 sign 请求;签名和校验由闭源模块 vsda 完成,web 客户端直接把 connection token 当作 signedData。https://github.com/microsoft/vscode/blob/main/src/vs/server/node/remoteExtensionHostAgentServer.ts ↩

  12. OpenClaw · Gateway protocol authGitHubgithub.com ↗共享令牌、设备令牌、设备身份、v2/v3 签名载荷,以及旧客户端迁移时的 DEVICE_AUTH_* 错误码。https://github.com/openclaw/openclaw/blob/main/docs/gateway/protocol/auth.md ↩ ↩2 ↩3

  13. OpenClaw · Gateway protocol handshakeGitHubgithub.com ↗connect.challenge 事件和 connect 请求的完整字段。https://github.com/openclaw/openclaw/blob/main/docs/gateway/protocol/handshake.md ↩

  14. openclaw/src/gateway/server/ws-connection/connect-auth.tsGitHubgithub.com ↗服务端校验设备签名的代码:DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000,依次检查 deviceId、signedAt、nonce 和签名。本文读的是 2026-10-26 的 0ef5ddd。https://github.com/openclaw/openclaw/blob/main/src/gateway/server/ws-connection/connect-auth.ts ↩

  15. openclaw/packages/gateway-client/src/device-auth.tsGitHubgithub.com ↗buildDeviceAuthPayloadV3:签名载荷用 | 拼接 v3、deviceId、clientId、clientMode、role、scopes、signedAtMs、token、nonce、platform、deviceFamily。https://github.com/openclaw/openclaw/blob/main/packages/gateway-client/src/device-auth.ts ↩

  16. OpenClaw · Network exposureGitHubgithub.com ↗默认 gateway.bind: loopback,推荐 SSH 隧道或 Tailscale Serve;远程配对需要审批,直连回环自动批准。https://github.com/openclaw/openclaw/blob/main/docs/gateway/security/network-exposure.md ↩ ↩2

  17. OpenClaw · Control UI: Connect and pair(Insecure HTTP 一节)GitHubgithub.com ↗原文:签名密钥不离开浏览器,是明文传输唯一泄露不了的凭据;共享令牌则任何路径上的观察者都能读到。主动攻击者还可以改页面。https://github.com/openclaw/openclaw/blob/main/docs/web/control-ui/connect-and-pair.md ↩ ↩2

  18. RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3E. Rescorla · IETF · 2018rfc-editor.org ↗https://www.rfc-editor.org/rfc/rfc8446 ↩

  19. openclaw/packages/gateway-client/src/websocket-transport.tsGitHubgithub.com ↗isSecureWebSocketUrl:ws:// 只放行回环、私网 IP、.local 和 .ts.net,其余报 SECURITY ERROR;tlsFingerprint 只能配 wss://。https://github.com/openclaw/openclaw/blob/main/packages/gateway-client/src/websocket-transport.ts ↩