8.3 通信加密与防篡改
8.1 划了权威边界,8.2 讲了客户端怎么校验服务端下发的数据。这一节处理最后一个客户端侧的安全话题:通信链路本身。
先说结论,免得读到一半产生幻觉:客户端侧的加密与防篡改,能提升链路的健壮性与隐私,但它不构成安全边界。原因只有一条——客户端在攻击者手里,写在客户端里的任何密钥、任何校验逻辑,攻击者都能读出来、改掉。所以这一节讲的是「客户端能做的正当配合」,而不是「怎么防住作弊者」。
8.3.1 传输层加密:wss 保护什么
《方块冲刺》用 WebSocket 做传输。ws:// 是明文,wss:// 是 TLS 上的 WebSocket。联机游戏一律用 wss://,ws:// 只在本地开发时用。
wss 保护三件事:
- 机密性:链路上的字节被加密,中间人看不到你的输入、位置、得分。
- 完整性:TLS 自带 MAC,中间人改一个字节,接收端会解密失败。
- 身份:客户端通过证书验证「我连的确实是服务端」,而不是一个冒充它的中间人。
注意第三条——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 同步表现与插值动画 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。