一、传输安全和消息安全是两件事
传输安全解决的是“这条连接上的字节会不会被偷看、篡改或冒充”,消息安全解决的是“这一条业务消息是不是原始发送者发的、是不是刚刚发的”。前者是连接级、有状态的;后者是消息级、可以跨连接、跨网关、跨队列传递的。把两者混为一谈,通常会出现“已经上了 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 能防作弊;客户端证书私钥同样可能被提取和冒用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。