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 对负载逐字节异或还原。浏览器→服务器必须掩码,服务器→浏览器禁止掩码(防缓存污染攻击)。

名词解释

课后练习

  1. 为什么 WebSocket 握手要"借道" HTTP 而不是另起端口?
    • 答案:复用 80/443 端口可穿透绝大多数防火墙与代理,且第一个请求形如 HTTP 便于基础设施识别;升级成功后才切到 WS 帧,平滑兼容现有 Web 设施。
  2. 掩码为什么只要求"客户端→服务器"单向?
    • 答案:攻击场景是恶意网页借助浏览器向缓存代理投递伪造响应;强制客户端掩码后,代理无法缓存未解码内容。服务器→浏览器无此风险,故不加掩码以减少开销。

总结

WebSocket 是"HTTP 之后还有另一片天"的最好证明。它先用一次 HTTP 握手"骗取"通行许可,再悄悄切换协议,从此告别"客户端轮询"的低效。手写握手会让你明白那个看似神秘的 Sec-WebSocket-Accept 不过是一次固定的 SHA1+base64;手写帧编解码则揭示掩码机制——一个用 4 字节随机密钥逐字节异或的小把戏,却是防御缓存污染的关键。我特别想点出:协议设计里"为什么这样规定"往往比"怎么实现"更有价值。WS 的掩码单向要求、FIN/opcode 分帧,都是为了在复杂网络环境里既高效又安全。理解这些,你看 socket.io、看游戏同步协议,都会本能地问"它的消息边界和加密是怎么做的"——这正是一个系统级工程师的直觉。