《Cocos 游戏联机实战》8.3 通信加密与防篡改

客户端侧的加密配合与它的边界:wss 传输层加密在 H5、微信小游戏、原生三端的支持情况与证书要求,消息校验和与 HMAC 能防网络中间人篡改、却防不了本地改客户端,客户端该做与不该做的一整张职责清单,最后回到「真正的裁决在服务端」这条底线。

8.3 通信加密与防篡改

8.1 划了权威边界,8.2 讲了客户端怎么校验服务端下发的数据。这一节处理最后一个客户端侧的安全话题:通信链路本身。

先说结论,免得读到一半产生幻觉:客户端侧的加密与防篡改,能提升链路的健壮性与隐私,但它不构成安全边界。原因只有一条——客户端在攻击者手里,写在客户端里的任何密钥、任何校验逻辑,攻击者都能读出来、改掉。所以这一节讲的是「客户端能做的正当配合」,而不是「怎么防住作弊者」。

8.3.1 传输层加密:wss 保护什么

《方块冲刺》用 WebSocket 做传输。ws:// 是明文,wss:// 是 TLS 上的 WebSocket。联机游戏一律用 wss://,ws:// 只在本地开发时用。

wss 保护三件事:

  1. 机密性:链路上的字节被加密,中间人看不到你的输入、位置、得分。
  2. 完整性:TLS 自带 MAC,中间人改一个字节,接收端会解密失败。
  3. 身份:客户端通过证书验证「我连的确实是服务端」,而不是一个冒充它的中间人。

注意第三条——wss 验证的是服务端身份,不是客户端身份。客户端不需要、也不应该用「持有某个密钥」来证明自己是谁,因为那个密钥会出现在客户端里。

8.3.2 三端对 wss 的支持与证书要求

WebSocket 在 H5、微信小游戏、原生三端都可用,但要求不同,判平台用 sys.platform:

平台支持情况证书要求
H5(浏览器)原生 WebSocket页面是 HTTPS 时只能用 wss://,否则浏览器按混合内容拦截
微信小游戏平台提供 wx.connectSocket域名须在小游戏后台配白名单,必须 wss://,证书须被信任
原生(iOS/Android/MacOS)引擎内置 WebSocket必须 wss://;自签证书默认会被拒,除非在工程里显式信任

三个平台都不接受自签证书,这经常在联调时卡住人:本地起了一个带自签证书的 wss 服务,H5 打得开(浏览器让你点「继续访问」),小程序和原生却连不上。正确做法是联调也用受信任的证书,或者对原生端在开发构建里显式放宽——放宽只允许出现在开发构建,不能进发布包。

8.3.3 明确边界:wss 不保护已被攻破的客户端

这是本节最需要记住的一句话:wss 保护的是「传输中」的数据,不保护「已经被攻破的客户端」。

拆开说:

  • wss 保护的是两段链路(客户端↔服务端)上的字节。数据一旦到达客户端、被解密进内存,wss 就管不着了。
  • 攻击者如果在客户端本地(改内存、改脚本、挂调试器),他能看到明文、能改发送前的数据。TLS 在客户端内侧是不设防的,因为解密端点就是客户端自己。
  • 所以「客户端持有密钥」永远不能当作安全边界。你写在客户端里的任何密钥,攻击者都能从内存或代码里读出来。把密钥放进客户端,只是让攻击者多花五分钟。

一个具体的反例:有人想「我给每条消息用客户端里的密钥签个名,服务端验签,这样客户端就改不了消息了」。这是无效的——攻击者改消息的同时,会用同一个客户端里的密钥重新签名。签名拦得住「只改链路不改客户端」的中间人,拦不住「改了客户端」的攻击者。

8.3.4 消息完整性:校验和与 HMAC 能防什么

那签名到底还有没有用?有,但用途要说清。在传输层之外再加一层消息完整性标签(校验和或 HMAC),能防的是:

手段能防防不了
校验和(如 FNV-1a)传输/编码错误导致的字节损坏中间人(可重算校验和)、本地改客户端
HMAC(带密钥的哈希)网络中间人篡改(他算不出正确标签)本地改客户端(密钥在客户端里)
TLS(wss)中间人窃听与篡改本地改客户端

规律很清楚:越靠近链路的手段,防的是链路;越靠近客户端的手段,防的是本地。而客户端的密钥在本地,所以客户端的 HMAC 防不住本地攻击者。它的真实价值是:在服务端↔客户端之间多一层与 TLS 独立的完整性校验,能挡住「TLS 被降级或中间人代理」这类特定场景,也能在服务端侧帮助定位「这条消息是不是在链路上被动过」。

8.3.5 L1:消息校验和与签名

先实现两个纯函数:一个非加密校验和(发现损坏),一个带密钥的哈希(HMAC 风格)。都无 cc 依赖:

// net/protocol/integrity.ts —— 纯逻辑,无 cc 依赖

// FNV-1a 32 位:非加密校验和,用于发现传输/编码错误
export function fnv1a32(bytes: Uint8Array): number {
  let h = 0x811c9dc5;
  for (let i = 0; i < bytes.length; i++) {
    h ^= bytes[i];
    h = Math.imul(h, 0x01000193) >>> 0;
  }
  return h >>> 0;
}

// 带密钥的哈希(HMAC 风格的最小实现)——仅演示「内层哈希 + 外层再哈希」的结构。
// 生产请用平台原生 HMAC-SHA256,见 8.3.7。
export function keyedHash(key: Uint8Array, message: Uint8Array): number {
  const inner = new Uint8Array(key.length + message.length);
  inner.set(key, 0);
  inner.set(message, key.length);
  const innerHash = fnv1a32(inner);

  const outer = new Uint8Array(key.length + 4);
  outer.set(key, 0);
  outer[key.length] = (innerHash >>> 24) & 0xff;
  outer[key.length + 1] = (innerHash >>> 16) & 0xff;
  outer[key.length + 2] = (innerHash >>> 8) & 0xff;
  outer[key.length + 3] = innerHash & 0xff;
  return fnv1a32(outer);
}

export function sign(key: Uint8Array, message: Uint8Array): number {
  return keyedHash(key, message);
}

export function verify(key: Uint8Array, message: Uint8Array, tag: number): boolean {
  return sign(key, message) === tag;
}

再包一层信封,把「载荷 + 标签」绑在一起,收端验签失败就整条丢弃:

// net/protocol/integrity.ts(续)
export interface Envelope {
  readonly payload: Uint8Array;
  readonly tag: number;
}

export function seal(key: Uint8Array, payload: Uint8Array): Envelope {
  return { payload, tag: sign(key, payload) };
}

// 验签失败返回 null——调用方据此丢弃整条消息
export function open(key: Uint8Array, env: Envelope): Uint8Array | null {
  return verify(key, env.payload, env.tag) ? env.payload : null;
}

8.3.6 vitest:被篡改的消息验签失败

// integrity.test.ts
import { describe, expect, it } from 'vitest';
import { fnv1a32, open, seal, sign, verify } from './integrity';

const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
const KEY = enc('session-key-001');

describe('消息完整性', () => {
  it('同一消息同一密钥签名一致', () => {
    expect(sign(KEY, enc('input:right'))).toBe(sign(KEY, enc('input:right')));
  });

  it('密钥不同,签名不同', () => {
    expect(sign(KEY, enc('input:right'))).not.toBe(sign(enc('other'), enc('input:right')));
  });

  it('被篡改的消息验签失败', () => {
    const env = seal(KEY, enc('score:10'));
    const tampered = { payload: enc('score:999'), tag: env.tag };
    expect(verify(KEY, tampered.payload, tampered.tag)).toBe(false);
    expect(open(KEY, tampered)).toBeNull(); // 收端丢弃整条
  });

  it('未被篡改的消息验签通过', () => {
    expect(open(KEY, seal(KEY, enc('score:10')))).not.toBeNull();
  });

  it('校验和能发现单字节损坏', () => {
    expect(fnv1a32(enc('snapshot'))).not.toBe(fnv1a32(enc('snapshoU')));
  });
});

这组断言证明的正是 8.3.4 的结论:改载荷、不改标签,验签必失败。但请注意它的适用边界——攻击者若能改客户端,他就能同时改载荷和标签,这组测试拦不住他。这层完整性防的是链路,不是本地。

8.3.7 L2:按平台选择 wss 端点

wss 的端点选择要按平台走,用 cc 的 sys.platform 判断:

import { _decorator, Component, sys, warn } from 'cc';
const { ccclass } = _decorator;

@ccclass('WssEndpointPicker')
export class WssEndpointPicker extends Component {
  private isWechat(): boolean {
    return sys.platform === sys.Platform.WECHAT_GAME
      || sys.platform === sys.Platform.WECHAT_MINI_PROGRAM;
  }

  endpointFor(host: string): string {
    if (this.isWechat()) {
      // 微信小游戏:域名须在后台配白名单,且必须 wss
      return `wss://${host}/ws`;
    }
    if (sys.isNative) {
      // 原生:必须 wss,且证书须受信任(自签会被拒)
      return `wss://${host}/ws`;
    }
    // H5:页面是 https 时只能用 wss,否则浏览器按混合内容拦截
    return `${location.protocol === 'https:' ? 'wss' : 'ws'}://${host}/ws`;
  }

  onLoad(): void {
    if (sys.isBrowser && location.protocol === 'https:' && this.endpointFor('game.example.com').startsWith('ws://')) {
      warn('HTTPS 页面下必须使用 wss,否则浏览器会拦截连接');
    }
  }
}

生产环境里,签名应该用平台原生的 HMAC-SHA256:H5 用 crypto.subtle(异步,且需要安全上下文),小程序与原生走各自的原生加密接口。本节的纯 TS 实现是为了让逻辑可单测——真实的加密原语不要自己实现,用平台标准库。

8.3.8 客户端该做的与不该做的

把客户端侧的职责收成一张清单:

该做的:

  • 一律用 wss://,联调也不降级到 ws://(开发构建除外)。
  • 用平台原生 HMAC / TLS,不自己发明加密算法。
  • 不信任本地存档与本地计算结果——存档可以本地存,但关键结果(得分、胜负、进度)以服务端为准(第 10 章的对接清单会再列一次)。
  • 把「客户端不可信」写进代码审查清单,看到「客户端算好再上报」就拦下来。

不该做的:

  • 不硬编码敏感密钥。任何写进客户端的东西都不是秘密。客户端里的「密钥」只配用来做链路完整性,不能用来做身份或裁决。
  • 不把本地存档当权威。blockdash.save.v1 里的分数可以被改,服务端要有自己的记录。
  • 不指望客户端校验能防作弊。它防的是坏数据与网络噪声,不是攻击者。

这张清单的判据只有一条:这段代码能不能在「完全不信任本机」的前提下依然成立。任何依赖「客户端会老实执行」的逻辑,都不该被当作安全措施——它顶多是一条开发期纪律。

8.3.9 回到「真正的裁决在服务端」

这一章的三节,其实是同一句话的三个侧面:

  • 8.1:边界——客户端只发意图,位置、得分、胜负归服务端。
  • 8.2:校验——客户端校验服务端下发的数据,是为了可用性,不是安全。
  • 8.3:加密——客户端配合加密与完整性,防的是链路,防不了本地。

合起来就是那句底线:真正的裁决在服务端,客户端只能降低被篡改的面。客户端能做的所有正当防御——校验、签名、加密、不信任本地——都是「降低面」,没有一件是「解决问题」。这不是悲观,而是把安全责任放到唯一能承担它的地方:服务端。服务端怎么裁决,站内 game/server/ 与《游戏服务端编程实践》讲得很透,本卷只到「客户端怎么配合」为止。

至此,第 8 章的客户端侧安全边界讲完了。客户端该有的安全模块也清楚了:一个出站纪律(只发意图)、一个入站校验器(8.2 的快照校验器)、一层链路完整性(8.3 的信封)。接下来第 9 章换到体验侧——把同步数据变成画面:远端玩家怎么插值出平滑动画、延迟与掉线怎么告知玩家、延迟补偿与公平怎么权衡。安全是底线,表现才是玩家能感知到的联机质量。

阅读导航:上一节:8.2 关键数据校验 · 下一节:9.1 同步表现与插值动画 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画