《MMO 服务器开发》16.3 封包加密与签名

加密解决「别人看不懂」,完整性解决「别人改不动」——两者缺一不可。本节给 aegis 的封包加一层 AES-256-GCM 的 Sealer:seq|nonce|密文+tag 的封套、以 seq 作附加认证数据、64 位滑动窗口抗重放,并讲清方向密钥为什么必须由握手密钥材料按标签派生(与 6.3 重连票据呼应)。

16.3 封包加密与签名

上一节把不该来的连接挡在了门外,但进来的连接依然要面对另一类威胁:包被偷看、被篡改、被重放。前 15 章的帧格式是明文的——谁在链路上抓包,谁就能读到玩家的聊天、坐标与背包;谁改了中间的字节,服务端也照收不误。

这一节给 aegis 的封包加一层保护。目标有三个,必须一起达成,缺一个都等于没做:

  • 机密性:链路上的人看不到明文内容。
  • 完整性:任何一个字节被改动,接收端必须拒绝,而不是「解出一个看起来对但其实是伪造的包」。
  • 抗重放:对手录下一段合法流量再原样重发,接收端必须识别出来并丢弃。

机密性、完整性、抗重放

实现上我们选 AES-256-GCM。GCM 是一种 AEAD(带关联数据的认证加密),一次调用同时给出密文和一个认证标签(tag)。这带来一个重要结论:不需要再叠一层 HMAC。常见误区是「AES 加密 + 独立 HMAC 签名」,但在 GCM 上这是多余的——tag 本身就是完整性证明,额外套 HMAC 只会多一次哈希、多一份密钥管理负担,却不增加任何安全性。HMAC-SHA256 在本节只出现在一个地方:密钥派生(见后文)。

抗重放不能靠 GCM 自己解决,得由协议补一个序号。封套布局是:

seq(4, BE) | nonce(12) | ciphertext + tag(16)

seq 明文随包传输,接收端拿它去查一个 64 位滑动窗口:高于窗口右沿就右移并接纳,落在窗口内且未见过就接纳,已见过或低于左沿就丢弃。nonce 由发送端每次随机生成——GCM 要求同一把密钥下 nonce 绝不重复,96 位随机值在单条连接的寿命里碰撞概率可忽略。

关键设计点:把 seq 作为附加认证数据(AAD)喂给 GCM。这样 seq 虽然明文传输,却也被 tag 保护着——对手改 seq 会让 tag 校验失败,于是滑动窗口看到的序号一定是发送端真正签过的那个,重放窗口无法被伪造序号顶飞。

Sealer 与滑动窗口的实现

下面这段是 L1,go test 可以直接跑:

// file: internal/guard/crypto.go
// tier: L1
// verify: go test ./internal/guard/ -run 'TestSeal|TestOpen|TestReplayWindow|TestDeriveKey'

package guard

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/binary"
	"errors"
	"io"
)

// 加密封套布局:seq(4, BE) | nonce(12) | ciphertext+tag。
// seq 明文随封套传输,接收端用它查重放窗口;nonce 由发送端随机生成,
// 每个 (key, nonce) 组合在 GCM 下只能出现一次,随机 96 位碰撞概率可忽略。
const (
	SeqLen   = 4
	NonceLen = 12
)

// 加解密错误。
var (
	ErrReplay    = errors.New("guard: sequence replayed or too old")
	ErrShortSeal = errors.New("guard: sealed envelope too short")
	ErrBadTag    = errors.New("guard: authentication tag mismatch")
)

// Sealer 用一把对称密钥封装/解封载荷。密钥方向单一:客户端→服务端与服务端→
// 客户端各用一把,避免同一 (key, nonce) 在两个方向复用。
type Sealer struct {
	aead  cipher.AEAD
	nonce func([]byte) error // 随机源,测试可注入以确定化
}

// NewSealer 用 32 字节密钥构造一个 AES-256-GCM Sealer。
func NewSealer(key []byte) (*Sealer, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	aead, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	return &Sealer{aead: aead, nonce: func(b []byte) error {
		_, err := io.ReadFull(rand.Reader, b)
		return err
	}}, nil
}

// Seal 把明文封成 seq | nonce | ciphertext+tag。seq 必须单调递增,
// 接收端的重放窗口依赖它。
func (s *Sealer) Seal(seq uint32, plaintext []byte) ([]byte, error) {
	nonce := make([]byte, NonceLen)
	if err := s.nonce(nonce); err != nil {
		return nil, err
	}
	out := make([]byte, SeqLen+NonceLen, SeqLen+NonceLen+len(plaintext)+s.aead.Overhead())
	binary.BigEndian.PutUint32(out[0:SeqLen], seq)
	copy(out[SeqLen:SeqLen+NonceLen], nonce)
	// 把 seq 作为附加认证数据(AAD):篡改 seq 会让 tag 校验失败,
	// 于是重放窗口看到的序号一定是发送端真正签过的序号。
	var aad [SeqLen]byte
	binary.BigEndian.PutUint32(aad[:], seq)
	return s.aead.Seal(out, nonce, plaintext, aad[:]), nil
}

// Open 校验并解开封套,同时用 w 做防重放检查。
func (s *Sealer) Open(w *ReplayWindow, sealed []byte) (uint32, []byte, error) {
	if len(sealed) < SeqLen+NonceLen+s.aead.Overhead() {
		return 0, nil, ErrShortSeal
	}
	seq := binary.BigEndian.Uint32(sealed[0:SeqLen])
	nonce := sealed[SeqLen : SeqLen+NonceLen]
	var aad [SeqLen]byte
	binary.BigEndian.PutUint32(aad[:], seq)
	plaintext, err := s.aead.Open(nil, nonce, sealed[SeqLen+NonceLen:], aad[:])
	if err != nil {
		return 0, nil, ErrBadTag
	}
	// 只有 tag 校验通过后才推进窗口:否则攻击者可用伪造的序号把窗口顶飞,
	// 让合法包被误判为「过旧」。
	if !w.Accept(seq) {
		return 0, nil, ErrReplay
	}
	return seq, plaintext, nil
}

// ReplayWindow 是一个 64 位滑动窗口,记录最近见过的序号。收到 seq 时:
// 高于窗口右沿 → 右移并置位;落在窗口内且未见过 → 置位;已见过或低于
// 左沿 → 拒绝。这是 IPsec/DTLS 里抗重放的标准做法。
type ReplayWindow struct {
	highest uint32
	bitmap  uint64
	init    bool
}

// Accept 判断 seq 是否新鲜,并把窗口推进到包含它。
func (w *ReplayWindow) Accept(seq uint32) bool {
	if !w.init {
		w.init = true
		w.highest = seq
		w.bitmap = 1
		return true
	}
	if seq > w.highest {
		if shift := seq - w.highest; shift >= 64 {
			w.bitmap = 1
		} else {
			w.bitmap = (w.bitmap << shift) | 1
		}
		w.highest = seq
		return true
	}
	diff := w.highest - seq
	if diff >= 64 {
		return false // 落在窗口左沿之外,过旧
	}
	mask := uint64(1) << diff
	if w.bitmap&mask != 0 {
		return false // 已经见过
	}
	w.bitmap |= mask
	return true
}

// DeriveKey 从握手密钥材料派生一把 32 字节方向密钥。
// 用 HMAC-SHA256 做一次带方向标签的派生——label 不同则密钥不同,
// 于是 c2s 与 s2c 各有一把独立密钥,(key, nonce) 不会跨方向复用。
func DeriveKey(secret []byte, label string) []byte {
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(label))
	return mac.Sum(nil) // 32 字节,正好一把 AES-256 密钥
}

两个顺序细节值得单拎出来。第一,Open 里先验 tag 再动窗口——如果先推进窗口、后校验 tag,对手只要发一堆乱造的 seq 就能把窗口右沿顶到很高,让真正的合法包全部「过旧」而被误丢,这是拿完整性换可用性的典型错误。第二,ReplayWindow 用 uint64 位图配一个 highest 序号,shift >= 64 时直接重置位图,避免 << 移位超过 64 位这种未定义行为。

密钥协商放在哪:与 6.3 重连票据呼应

密钥从哪来?本节的 Sealer 只负责「有密钥之后怎么用」,密钥本身由握手阶段产出。位置就在 6.3 讲过的重连票据里:登录成功后,服务端把一段共享密钥材料(secret)绑进票据下发给客户端,两端各持一份。之后每一条方向各派生一把独立密钥:

c2s := DeriveKey(secret, "c2s")   // 客户端 → 服务端
s2c := DeriveKey(secret, "s2c")   // 服务端 → 客户端

为什么必须分方向? GCM 的安全性建立在「同一把密钥下 nonce 不重复」之上。如果两个方向共用一把密钥,而两端的 nonce 又是各自随机生成的,那么两个方向偶然生成同一个 nonce 的概率不再是可忽略的——一旦撞上,攻击者就能通过异或两个密文恢复明文。给每个方向一把独立密钥,就把「跨方向 nonce 碰撞」这条路直接堵死。

这也解释了为什么 DeriveKey 用 HMAC 而不是「截断哈希」:label 作为消息、secret 作为密钥,改变 label 必然改变输出,方向标签与密钥材料被密码学地绑在一起。下面这段是 L1,验证篡改、重放与方向分离:

// snippet: internal/guard/crypto_test.go(节选)

func TestSealDetectsTamper(t *testing.T) {
	s := mustSealer(t, testKey(3))
	sealed, _ := s.Seal(7, []byte("gold 1000"))
	sealed[len(sealed)-1] ^= 0x01 // 翻一个密文字节
	var w ReplayWindow
	if _, _, err := s.Open(&w, sealed); err != ErrBadTag {
		t.Fatalf("tampered open = %v, want ErrBadTag", err)
	}
}

func TestOpenRejectsReplay(t *testing.T) {
	s := mustSealer(t, testKey(7))
	sealed, _ := s.Seal(42, []byte("hi"))
	var w ReplayWindow
	if _, _, err := s.Open(&w, sealed); err != nil {
		t.Fatalf("first open: %v", err)
	}
	if _, _, err := s.Open(&w, sealed); err != ErrReplay {
		t.Fatalf("replay = %v, want ErrReplay", err)
	}
}

实测结论

以下是本机(Go 1.26,macOS arm64)实测,同机相对量级,绝对数值不可移植:

指标实测值说明
一次「封装 + 解封」往返620 ns/op256 字节明文,-benchmem
吞吐约 413 MB/s单核,含 nonce 随机与窗口查重
内存565 B/op,5 allocs/op封套与明文各一次分配
篡改检测翻 1 位即返回 ErrBadTag完整性生效
重放检测同一封套二次解封返回 ErrReplay窗口生效

一个量级判断:620ns 的往返成本,对比 6.2 实测「写一次 socket 的 syscall 底噪约 80µs」,加密本身比一次系统调用便宜两个数量级。也就是说,只要把封套分配做进复用缓冲,加密在带宽与延迟上的开销几乎可以忽略——真正该担心的是「每包一次 rand.Read」,高频路径上应改成「每连接一个计数器派生 nonce」而不是每次向内核要随机数。

阅读导航:上一节:16.2 洪泛与连接耗尽防护 · 下一节:17.1 把前 16 章拼成 aegis 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标