WebSocket 握手与帧编解码(含掩码)
本节目标
- 理解 WebSocket 握手为什么"借道" HTTP
- 手写握手响应(Sec-WebSocket-Accept 计算)
- 掌握 WebSocket 帧的编解码(含客户端掩码)
握手:客户端发一个带 Sec-WebSocket-Key 的 HTTP 升级请求,服务器用固定魔术串算出 Sec-WebSocket-Accept 回 101。之后协议从 HTTP 切换为 WS 帧。
// 运行环境:Node.js 14+
// 保存为 aj-l5.js,执行:node aj-l5.js(演示握手与帧编码,不连真实浏览器)
const crypto = require('crypto')
// 1) 计算握手响应值:key + 魔法串 -> SHA1 -> base64
function acceptKey(key) {
return crypto.createHash('sha1')
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
.digest('base64')
}
console.log('accept =', acceptKey('dGhlIHNhbXBsZSBub25jZQ==')) // s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
// 2) 编码一帧(客户端→服务器需掩码,演示掩码逻辑)
function encodeFrame(payload, mask = true) {
const len = Buffer.byteLength(payload)
const buf = Buffer.alloc(2 + len)
buf[0] = 0x81 // FIN=1, opcode=0x1(text)
buf[1] = len | (mask ? 0x80 : 0) // 最高位表示是否掩码
payload = Buffer.from(payload)
if (mask) {
const maskingKey = crypto.randomBytes(4)
for (let i = 0; i < len; i++) buf[2 + i] = payload[i] ^ maskingKey[i % 4] // 逐字节异或
// 真实实现需把 maskingKey 也写进帧;此处仅展示异或算法
} else {
payload.copy(buf, 2)
}
return buf
}
console.log('帧字节:', encodeFrame('hi').toString('hex')) // 如 81 82 xx xx(含掩码位)解码(服务器收客户端帧):读首字节得 opcode/FIN,若掩码位=1,用后 4 字节 maskingKey 对负载逐字节异或还原。浏览器→服务器必须掩码,服务器→浏览器禁止掩码(防缓存污染攻击)。
名词解释
- WebSocket:全双工长连接协议,建立在 TCP 之上,握手借 HTTP 升级,之后双方可随时推送数据,适合聊天/实时游戏/行情推送。
- Sec-WebSocket-Accept:服务器对客户端
Key拼接魔法串做 SHA1+base64 的结果,是握手合法性的校验值。 - 掩码(Mask):客户端发送帧时对所有负载字节用 4 字节随机密钥异或加密,防止中间代理缓存污染。这是 WS 协议的强制安全机制。
课后练习
- 为什么 WebSocket 握手要"借道" HTTP 而不是另起端口?
- 答案:复用 80/443 端口可穿透绝大多数防火墙与代理,且第一个请求形如 HTTP 便于基础设施识别;升级成功后才切到 WS 帧,平滑兼容现有 Web 设施。
- 掩码为什么只要求"客户端→服务器"单向?
- 答案:攻击场景是恶意网页借助浏览器向缓存代理投递伪造响应;强制客户端掩码后,代理无法缓存未解码内容。服务器→浏览器无此风险,故不加掩码以减少开销。
总结
WebSocket 是"HTTP 之后还有另一片天"的最好证明。它先用一次 HTTP 握手"骗取"通行许可,再悄悄切换协议,从此告别"客户端轮询"的低效。手写握手会让你明白那个看似神秘的 Sec-WebSocket-Accept 不过是一次固定的 SHA1+base64;手写帧编解码则揭示掩码机制——一个用 4 字节随机密钥逐字节异或的小把戏,却是防御缓存污染的关键。我特别想点出:协议设计里"为什么这样规定"往往比"怎么实现"更有价值。WS 的掩码单向要求、FIN/opcode 分帧,都是为了在复杂网络环境里既高效又安全。理解这些,你看 socket.io、看游戏同步协议,都会本能地问"它的消息边界和加密是怎么做的"——这正是一个系统级工程师的直觉。