《游戏服务端编程实践》18.1 网络加密与签名

从传输安全与消息安全的边界讲起,比较 TLS、自签证书与双向认证的适用场景,说明自研加密协议为何通常不如 TLS,并用 HMAC、nonce 与时间窗实现可运行的验签代码,覆盖合法签名、内容篡改、nonce 重放与时间戳过期四类测试,最后列出上线前应观测的失败率、重放命中与缓存使用率指标。

一、传输安全和消息安全是两件事

传输安全解决的是“这条连接上的字节会不会被偷看、篡改或冒充”,消息安全解决的是“这一条业务消息是不是原始发送者发的、是不是刚刚发的”。前者是连接级、有状态的;后者是消息级、可以跨连接、跨网关、跨队列传递的。把两者混为一谈,通常会出现“已经上了 TLS,为什么还能重放购买请求”或者“每条消息都签名,为什么抓包仍能看到明文”这类问题。

层次典型手段保护对象能防什么防不了什么
传输层TLS 1.2/1.3整条连接的字节流窃听、连接内篡改、服务端冒充应用层重放、客户端本身被逆向
传输层 + 证书自签证书、证书固定连接对端身份中间人替换证书客户端里的固定密钥被提取
传输层 + 双向认证mTLS服务端也校验客户端证书未授权设备接入、证书吊销客户端进程被调试后冒用证书
消息层HMAC 签名单条消息的完整性与来源消息篡改、跨网关伪造窃听(签名不加密)
消息层签名 + nonce + 时间窗单条消息的新鲜性重放、过期请求复用密钥泄露后的伪造
消息层加密 + 签名消息机密性与完整性窃听与篡改的组合端点被攻破后的为所欲为

这张表最重要的结论是:TLS 不等于消息不可重放。攻击者不需要破解 TLS,只要在合法连接上原样重发一条“领取奖励”的请求,服务端如果只校验连接身份,就会再发一次奖励。传输层能保证“这条连接是可信的”,但不会替业务判断“这个请求是不是已经处理过”。

1.1 游戏客户端为什么经常想自研加密

常见的理由是“TLS 握手太慢”“移动网络下 TLS 不稳定”“我们需要更轻量的二进制协议”“加一层私有加密更安全”。前两条在今天的移动网络和 TLS 1.3 下大多可以用会话复用、连接预热和合理的超时策略解决;后两条则需要非常小心:二进制协议和 TLS 并不冲突,TLS 之上完全可以跑自定义帧格式;而“私有加密更安全”几乎总是错觉。

真正需要评估的是握手成本、证书管理、网络中间盒兼容性和客户端包体。把这些问题列清楚之后,大多数团队会发现:保留 TLS,把优化放在连接复用、首包合并和区域接入上,收益更确定,风险更小。

二、自研加密协议通常不如 TLS

TLS 的价值不只在“用了 AES”。它把密钥交换、身份认证、前向保密、算法协商、降级保护、记录层分帧、重放窗口和大量边界情况都处理过了,并且经过了几十年的公开分析与实现审计。自研协议哪怕算法选得对,也很容易在协议组合层面犯错。

常见自研做法表面效果真实问题
固定密钥 XOR 或异或混淆抓包看不到明文密钥在客户端里,提取后全部失效
AES-CBC 加密但无 MAC内容保密可被填充预言或位翻转攻击,完整性没有保障
AES-ECB 分块加密实现简单相同明文块产生相同密文,模式泄露
固定 IV 或计数器从 0 开始解密方便相同密钥流复用,密文可被推导
自定义握手 + 长期密钥看起来像 TLS没有前向保密,密钥泄露后历史流量全解
只校验 CRC32能发现传输错误CRC 不是MAC,攻击者可同时改内容和校验值

2.1 失败场景:自研“轻量加密”被离线还原

某项目为了“减少握手”,在客户端内置了一张固定密钥表,用 AES-CBC 加密登录后的业务包,消息尾部附一个 CRC32。上线后抓包看不到明文,团队认为安全达标。后来有人把客户端放进调试器,从内存里导出密钥,随后用脚本批量构造“购买成功”回包。由于协议没有消息序号、没有 nonce、没有服务端签名,服务端无法区分这是客户端真实发送的,还是攻击者重放的。

时间攻击动作服务端看到的现象
T+0从客户端内存提取固定 AES 密钥无异常
T+1h离线解密历史流量,学习包结构无异常
T+2h重放一条已完成的充值回调业务层把它当成新请求
T+1d批量重放并修改金额字段只触发少量风控规则
T+3d对账发现差额缺少消息级证据链,难以定位

正确的补救不是“换一个更复杂的加密算法”,而是承认客户端没有秘密:把密钥移到服务端,用 TLS 保护传输,用服务端签名的会话凭证标识玩家,用 nonce 和时间窗拒绝重放,并对资产变更做幂等与审计。加密可以继续做,但它只是提高逆向成本,不是信任边界。

2.2 什么时候可以用自签证书

自签证书并非不能用。私域分发、内部测试、无法走公共 CA 的封闭网络,都可以用自签证书配合证书固定(certificate pinning)。关键是把证书轮换和吊销流程设计好:证书过期或私钥泄露时,客户端能否通过热更拿到新证书,旧版本客户端是否会被直接拒绝,灰度期间如何同时信任新旧两张证书。没有这些流程,自签证书只是把“中间人风险”换成了“运维风险”。

双向认证适合“设备或渠道身份”的校验,例如发行渠道网关、内部服务间调用、需要按设备吊销访问权的场景。但它不适合被当成反作弊的终极手段:客户端证书的私钥同样存放在玩家设备上,只要攻击者能控制客户端进程,就能冒用证书。mTLS 能回答“这是不是我们签发的那个客户端实例”,不能回答“操作的人是不是在作弊”。

三、消息签名:HMAC、密钥分发与轮换

HMAC 用共享密钥和哈希函数为消息生成认证标签。它的安全性依赖两点:密钥只在可信方之间共享,消息编码是确定且无歧义的。游戏服务端通常用 HMAC-SHA256 给“客户端到网关”或“网关到游戏服”的关键消息签名,成本远低于非对称签名,适合高频消息。

3.1 先规范消息编码,再谈签名

签名之前必须把消息变成唯一的字节序列。字段顺序、整数端序、字符串长度前缀、可选字段是否存在,都要写进协议文档。否则同一个业务对象可能有两种合法编码,签名校验就会出现“客户端算得出、服务端算不出”的问题,或者更糟:攻击者利用编码歧义构造出语义不同、签名相同的消息。

推荐做法是使用带长度前缀的字段拼接,并在最前面加域分隔串。长度前缀保证字段边界清晰,域分隔串保证同一把密钥不会被其他协议复用。对于复杂结构,优先使用成熟的序列化格式并固定其确定性编码,而不是手写“用逗号拼接”。

3.2 密钥分发与轮换

阶段做法目的
初始分发通过配置中心或密钥管理服务下发,不写入代码仓库避免密钥随客户端或源码泄露
使用服务端按 KeyID 选择密钥,消息头携带 KeyID支持多版本并存
轮换新密钥先进入“可验证”状态,再切换签发新旧客户端都能通过
退役确认旧密钥签发的消息已全部过期后再删除避免在途消息突然失败
应急私钥泄露时立即吊销并强制升级缩短暴露窗口

轮换的关键是“验证端先支持新密钥,签发端后切换”。如果顺序反过来,新客户端签发的消息会被旧服务端拒绝。退役时间不能早于“旧密钥签发的消息最大有效期”,否则会误伤在途请求。KeyID 只用于选择密钥,不参与信任判断;攻击者可以随便填 KeyID,服务端仍然要用对应密钥验签。

四、nonce 与时间窗:把重放挡在业务之前

签名解决“消息没被改”,nonce 和时间窗解决“消息没被重复使用”。时间窗负责粗筛:时间戳偏离服务端时钟超过允许范围就拒绝;nonce 负责精筛:同一个 nonce 在有效期内只能成功一次。两者缺一不可,只做时间窗会让攻击者在一分钟窗口内反复重放,只做 nonce 则缓存会无限增长。

参数取值考虑过小的后果过大的后果
时间窗至少覆盖两端时钟偏差与最大 RTT正常请求被误杀重放窗口变长,缓存压力变大
nonce 长度足够随机,避免碰撞正常请求互相顶掉包体与缓存键变长
nonce 缓存按 KeyID + nonce 组合存储不同密钥的 nonce 互相干扰跨密钥重放识别变弱
缓存上限与在线连接数、消息速率匹配高峰期误判为缓存满内存占用上升
清理周期不高于时间窗过期项长期占用内存清理过频,CPU 开销上升

nonce 的检查必须放在验签之后。否则攻击者可以随便构造大量带新 nonce 的伪造消息,把缓存填满,让正常玩家的消息被拒绝。先验 MAC,确认消息确实来自持有密钥的一方,再写入 nonce 缓存,才能把缓存压力限制在可信流量上。

4.1 时间窗不是越大越安全

有些团队为了“减少玩家因为手机时间不准被踢”,把时间窗设成十分钟甚至更长。这会让重放攻击的可用窗口同步变大,也让 nonce 缓存必须保留更久。更合理的做法是:客户端不直接使用本地时间戳,而是先通过一次受 TLS 保护的校时接口拿到服务端时间偏移;签名里的时间戳使用“服务端时间 + 已校准偏移”。时间窗保持在几十秒量级,既容忍网络抖动,又不给重放留下太长的时间。

五、客户端密钥的边界:客户端里没有真正的秘密

无论密钥藏在 so 文件、资源包、白盒表还是硬件安全模块的模拟层里,只要解密逻辑最终要在玩家设备上执行,攻击者就有机会把它提取出来。差别只是成本:简单硬编码几分钟就能找到,白盒或代码混淆可能需要几天,但也仅此而已。

因此,服务端必须把客户端发来的一切都当成“意图”,而不是“事实”。客户端说“我移动到坐标 (x, y)”,服务端要校验速度、碰撞、地图边界和状态;客户端说“我造成了 100 点伤害”,服务端要根据攻击力、防御、暴击和技能冷却自己算;客户端说“我获得了道具”,服务端要以自己的掉落表为准。签名和加密能提高伪造门槛,但不能替代服务端权威。

5.1 会话凭证应该绑定什么

会话凭证至少应绑定玩家 ID、设备指纹(弱标识即可)、签发时间、过期时间和随机串,并由服务端签名。它不应该包含“客户端可信”这种无法验证的断言。网关校验凭证只证明“这张票是服务端签发的”,业务服仍需根据玩家状态、消息 nonce 和业务幂等键做二次判断。凭证过期后走刷新流程,刷新时重新校验设备与风险等级。

六、可运行实现:HMAC + nonce + 时间窗

下面的实现把签名、时间窗和 nonce 缓存放在同一个验签器里。编码使用域分隔串和带长度前缀的字段;验证顺序是结构检查、时间窗、MAC、nonce,确保伪造消息不会占用 nonce 缓存。SetClock 只用于测试或需要统一时钟的服务。

// file: ch18/sign/sign.go
// tier: L1
// verify: go test ./ch18/sign/

// Package sign 演示游戏消息的 HMAC 签名、时间窗与 nonce 防重放。
//
// 这里的签名用于“消息完整性 + 新鲜性”,不替代 TLS。密钥只保存在
// 服务端;客户端持有的任何密钥都只能视为公开信息。
package sign

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/binary"
	"errors"
	"hash"
	"sync"
	"time"
)

var (
	// ErrMalformed 表示消息缺少必要字段,无法进入验签。
	ErrMalformed = errors.New("sign: malformed message")
	// ErrUnknownKey 表示 KeyID 不在服务端密钥表中,通常意味着轮换未同步。
	ErrUnknownKey = errors.New("sign: unknown key id")
	// ErrExpired 表示时间戳落在允许窗口之外,可能过期或来自未来。
	ErrExpired = errors.New("sign: timestamp outside window")
	// ErrReplay 表示同一个 nonce 在有效期内被重复使用。
	ErrReplay = errors.New("sign: nonce already used")
	// ErrBadMAC 表示签名不匹配,消息内容或密钥不一致。
	ErrBadMAC = errors.New("sign: mac mismatch")
	// ErrNonceFull 表示 nonce 缓存已达上限且没有可回收的过期项。
	ErrNonceFull = errors.New("sign: nonce cache full")
)

const (
	// macDomain 做域分离,避免同一密钥在别的协议里被复用。
	macDomain = "gsp-sign-v1"
	// defaultMaxNonces 是单个 Verifier 允许缓存的 nonce 上限。
	defaultMaxNonces = 1 << 16
)

// Message 是一条待签名或待验证的业务消息。
//
// Timestamp 使用 Unix 秒。Nonce 由发送方生成,必须在时间窗内唯一;
// 服务端只要求唯一性,不解释它的具体格式。
type Message struct {
	KeyID     string
	Nonce     string
	Timestamp int64
	Payload   []byte
}

// Mac 计算消息的 HMAC-SHA256。
//
// 编码是确定性的:域分隔串、带长度前缀的 KeyID 与 Nonce、
// 大端序时间戳、带长度前缀的负载。长度前缀保证字段边界清晰,
// 不会出现“把两个字段拼接后恰好相同”的歧义。
func Mac(secret []byte, m Message) []byte {
	h := hmac.New(sha256.New, secret)
	h.Write([]byte(macDomain))
	writeField(h, []byte(m.KeyID))
	writeField(h, []byte(m.Nonce))
	var ts [8]byte
	binary.BigEndian.PutUint64(ts[:], uint64(m.Timestamp))
	h.Write(ts[:])
	writeField(h, m.Payload)
	return h.Sum(nil)
}

func writeField(h hash.Hash, b []byte) {
	var size [4]byte
	binary.BigEndian.PutUint32(size[:], uint32(len(b)))
	h.Write(size[:])
	h.Write(b)
}

// Verifier 持有一组可轮换的密钥和时间窗配置。
//
// keys 的键是 KeyID,值是对应密钥。Verify 只读取密钥表,
// 不会修改调用方传入的 map。
type Verifier struct {
	mu         sync.Mutex
	keys       map[string][]byte
	window     time.Duration
	maxNonces  int
	seen       map[string]time.Time // nonce key -> 可回收时间
	now        func() time.Time
	lastPurge  time.Time
	purgeEvery time.Duration
}

// NewVerifier 创建验签器并复制密钥表。
//
// window 是时间戳允许偏离服务端时钟的最大绝对值,小于等于 0 时
// 使用 30 秒。生产环境应根据时钟同步精度和消息生命周期取值,
// 不要为了“减少误杀”把窗口设成几分钟。
func NewVerifier(keys map[string][]byte, window time.Duration) *Verifier {
	if window <= 0 {
		window = 30 * time.Second
	}
	copied := make(map[string][]byte, len(keys))
	for id, secret := range keys {
		copied[id] = append([]byte(nil), secret...)
	}
	return &Verifier{
		keys:       copied,
		window:     window,
		maxNonces:  defaultMaxNonces,
		seen:       make(map[string]time.Time),
		now:        time.Now,
		purgeEvery: time.Second,
	}
}

// Verify 校验消息的时间窗、MAC 与 nonce 唯一性。
//
// 检查顺序是刻意的:先做廉价的结构与时间检查,再验 MAC,
// 最后才写入 nonce 缓存。这样伪造消息无法耗尽 nonce 缓存,
// 因为 MAC 不通过的消息不会占用缓存条目。
func (v *Verifier) Verify(m Message, mac []byte) error {
	if m.KeyID == "" || m.Nonce == "" || m.Timestamp <= 0 || len(mac) == 0 {
		return ErrMalformed
	}
	secret, ok := v.keys[m.KeyID]
	if !ok {
		return ErrUnknownKey
	}
	now := v.clock()
	ts := time.Unix(m.Timestamp, 0)
	if now.Sub(ts) > v.window || ts.Sub(now) > v.window {
		return ErrExpired
	}
	want := Mac(secret, m)
	if !hmac.Equal(want, mac) {
		return ErrBadMAC
	}

	nonceKey := m.KeyID + "\x00" + m.Nonce
	v.mu.Lock()
	defer v.mu.Unlock()
	v.purgeLocked(now)
	if _, ok := v.seen[nonceKey]; ok {
		return ErrReplay
	}
	if len(v.seen) >= v.maxNonces {
		return ErrNonceFull
	}
	expires := ts.Add(v.window)
	if expires.Before(now) {
		expires = now.Add(v.window)
	}
	v.seen[nonceKey] = expires
	return nil
}

// purgeLocked 回收已经过期的 nonce,避免缓存无限增长。
//
// 每个窗口最多全量扫描一次;两次清理之间只做 O(1) 判断。
func (v *Verifier) purgeLocked(now time.Time) {
	if now.Sub(v.lastPurge) < v.purgeEvery && len(v.seen) < v.maxNonces {
		return
	}
	for key, expires := range v.seen {
		if now.After(expires) {
			delete(v.seen, key)
		}
	}
	v.lastPurge = now
}

// SetClock 替换时钟,仅用于测试或需要统一注入时钟的服务。
func (v *Verifier) SetClock(now func() time.Time) {
	v.mu.Lock()
	defer v.mu.Unlock()
	if now != nil {
		v.now = now
	}
}

func (v *Verifier) clock() time.Time {
	v.mu.Lock()
	defer v.mu.Unlock()
	return v.now()
}

// Window 返回当前允许的时间偏差。
func (v *Verifier) Window() time.Duration {
	return v.window
}

测试覆盖六个边界:合法签名通过、篡改负载被拒绝、同一 nonce 重放被拒绝、过期时间戳被拒绝、未来时间戳被拒绝、未知 KeyID 被拒绝;另外验证了同一 nonce 在不同 KeyID 下互不影响。运行方式:

cd .local/checks/gs-practice-lab
gofmt -l ch18/sign
go vet ./ch18/sign/
go test ./ch18/sign/ -count=1

实际运行结果是 gofmt -l 无输出、go vet 通过、测试全部通过。测试里的时钟是注入的固定时间,不依赖机器当前时间,因此不会在 CI 上偶发失败。

6.1 为什么不用裸 SHA256(secret + message)

把密钥拼在消息前面再做一次 SHA256,看起来像 HMAC,但它存在长度扩展攻击的风险,而且不同拼接方式可能产生歧义。HMAC 的内部结构专门为此设计,标准库已经处理好块大小、密钥填充和常数时间比较。验签时使用 hmac.Equal 而不是 bytes.Equal,避免比较过程泄露前缀信息。

6.2 密钥轮换在代码里怎么落地

Verifier 的密钥表是“KeyID 到密钥”的映射,因此轮换只需要在配置里同时放入新旧两把密钥:新客户端用新 KeyID 签发,旧客户端继续用旧 KeyID,服务端都能验证。等旧密钥签发的消息全部过期后,再从配置中删除旧条目。生产环境应把密钥放在密钥管理服务里,通过短周期拉取或本地代理注入,而不是打进镜像或写在代码仓库。

七、上线前要观测什么

签名失败、时间窗失败、重放命中和 nonce 缓存满,应该分别计数,而不是混在一个“验签失败”里。分开之后才能判断问题性质:签名失败突然上升,可能是密钥轮换顺序错了;时间窗失败上升,可能是校时接口故障;重放命中的来源 IP 集中,可能是脚本在刷接口;缓存满则说明容量或清理策略需要调整。

指标维度典型用途
验签失败率按 KeyID、客户端版本发现轮换不同步或客户端被替换
时间窗拒绝数按地区、网络类型定位校时失败或时钟漂移
重放命中数按玩家、IP、nonce 前缀识别脚本与重放工具
nonce 缓存使用率按进程评估容量与清理周期
验签耗时 p99按消息类型防止签名成为高频路径瓶颈

小结

传输安全优先用 TLS,自研加密协议在绝大多数场景下收益低、风险高。消息安全要靠 HMAC 签名、nonce 和时间窗共同完成:签名保证内容与来源,nonce 保证一次性,时间窗限制攻击窗口。客户端里没有真正的秘密,服务端必须对位置、伤害、掉落和货币保持权威。下一篇 /game-server-in-action-18-2/ 会把视角落到单个封包:长度、序号、校验和、类型白名单,以及非法包为什么应该直接断开连接。

常见错误与边界

  • 把长期密钥硬编码在客户端,误以为混淆或白盒能提供真正的保密。
  • 只做对称加密不做完整性校验,攻击者可以位翻转密文而不被发现。
  • 用 CRC32 代替 MAC,攻击者能同时修改内容和校验值。
  • 时间窗设成几分钟甚至更长,重放窗口和 nonce 缓存同步膨胀。
  • 先写 nonce 缓存再验签,伪造流量可以耗尽缓存,挤掉正常请求。
  • 密钥轮换时先切换签发端、后升级验证端,导致新消息被旧服务端拒绝。
  • 只校验连接身份,不校验单条消息的新鲜性,TLS 连接内仍可重放。
  • 认为 mTLS 能防作弊;客户端证书私钥同样可能被提取和冒用。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist