服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
158 lines
6.6 KiB
Go
158 lines
6.6 KiB
Go
// S2C 回包二次加密:**等长流密码**(只加密 body,长度、头、checksum 布局全不变)。
|
||
//
|
||
// 设计见 `服务端回包二次加密_客户端DLL解密_设计文档_20260930.md`。要点:
|
||
//
|
||
// - 加密边界是**协议消息**,不是 TCP 写边界:合批(upperWriteBatch)与 Nagle 行为不受影响;
|
||
// - **等长**:客户端 `ProxyCipherDecodeFunction` 的契约是 `*outLength = bodyLength`,
|
||
// 带 tag 的 AEAD 会破坏它,所以这里不用 GCM;
|
||
// - **无跨包状态**:每包从 `(连接 nonce, msgID, seq)` 重新起流,丢包/重发/跳号都不会失步
|
||
// (有状态密钥流一旦错位就是"全部乱码 + 客户端卡住",是必须避免的故障形状);
|
||
// - 密钥按连接派生:HKDF-SHA256(ikm = 固化主密钥, salt = LANA 握手里那 16 字节随机 nonce),
|
||
// 因此**不需要任何额外往返**,也天然做到"每连接一把"。
|
||
//
|
||
// 计数器块(16 字节,AES-CTR 的初始计数块):
|
||
//
|
||
// [0:8] 连接 nonce 前 8 字节(每连接随机,来自 LANA challenge)
|
||
// [8:10] msgID(大端)—— 明文可读,用于降低"同 seq 复用密钥流"的概率
|
||
// [10:12] seq(大端)—— 明文可读
|
||
// [12:16] 0,包内块计数器从这里自增
|
||
//
|
||
// ⚠️ 已知边界:同一个连接里,`(msgID, seq)` 会在 65536 个包之后重复,
|
||
// 那时两包会用到同一段密钥流(CTR 复用)。这是为了"绝不失步"付出的代价:
|
||
// 加包序号纪元(epoch)能消除它,但服务端在写失败时也会 `upperSeq++`,
|
||
// 客户端观测不到那次自增 → 纪元错位 → **全部乱码**,比理论上的密钥流复用危险得多。
|
||
// 若要消除,必须先有一个两端都能可靠对齐的通道(协议字段或可靠的连接内信令)。
|
||
package protocol
|
||
|
||
import (
|
||
"crypto/aes"
|
||
"crypto/cipher"
|
||
"crypto/hkdf"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
const (
|
||
// S2CMasterKeySize 是固化主密钥长度(HKDF 的 ikm)。
|
||
S2CMasterKeySize = 32
|
||
// S2CCipherKeySize 是每连接派生出的 AES 密钥长度。
|
||
S2CCipherKeySize = 16
|
||
// S2CCipherCodecName 是回包 body 的 codec 标签,写进 game-upper-send-meta 便于排障。
|
||
S2CCipherCodecName = "s2c-ctr-v1"
|
||
// s2cKeyInfo 是 HKDF 的域分隔串。
|
||
s2cKeyInfo = "dnf:s2c:ctr:v1"
|
||
|
||
// DefaultS2CMasterKeyBase64 是**固化主密钥**(32 字节 base64)。
|
||
//
|
||
// 同一份常量必须同时存在于服务端与 90CN.dll 的 s2ccrypt.h 里——两边不一致 =
|
||
// 所有回包变成乱码。轮换步骤见设计文档:换这一行 + 改 DLL 常量 + 重新打包,两侧同时上线。
|
||
// 服务端也认 DNFBRIDGE_GAME_S2C_KEY_BASE64 覆盖(仅供本地联调/灰度)。
|
||
DefaultS2CMasterKeyBase64 = "ToXJkeLbeccbEN0a2fK2DjRhct6nx8KTYisGp49Levs="
|
||
)
|
||
|
||
var (
|
||
// ErrS2CMasterKeyInvalid 表示主密钥长度或编码不合法。
|
||
ErrS2CMasterKeyInvalid = errors.New("dnf protocol: s2c master key is invalid")
|
||
// ErrS2CNonceInvalid 表示连接 nonce 长度不合法。
|
||
ErrS2CNonceInvalid = errors.New("dnf protocol: s2c connection nonce is invalid")
|
||
)
|
||
|
||
// S2CCipher 是一条游戏连接上的 S2C body 加解密器(加密与解密是同一个变换)。
|
||
//
|
||
// 它是**无状态**的:Apply 只依赖入参与连接级的 block nonce,可以被并发调用。
|
||
type S2CCipher struct {
|
||
block cipher.Block
|
||
// nonce 是连接 nonce 的前 8 字节,来自 LANA challenge 的随机数。
|
||
nonce [8]byte
|
||
}
|
||
|
||
// ParseS2CMasterKey 解析 32 字节主密钥(base64 / hex / 原始字节)。
|
||
func ParseS2CMasterKey(data []byte) ([]byte, error) {
|
||
if len(data) == S2CMasterKeySize {
|
||
return append([]byte(nil), data...), nil
|
||
}
|
||
text := strings.TrimSpace(string(data))
|
||
if decoded, err := hex.DecodeString(text); err == nil && len(decoded) == S2CMasterKeySize {
|
||
return decoded, nil
|
||
}
|
||
for _, encoding := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
|
||
if decoded, err := encoding.DecodeString(text); err == nil && len(decoded) == S2CMasterKeySize {
|
||
return decoded, nil
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("%w: want %d bytes, got %d input bytes", ErrS2CMasterKeyInvalid, S2CMasterKeySize, len(data))
|
||
}
|
||
|
||
// DefaultS2CMasterKey 返回固化主密钥。
|
||
func DefaultS2CMasterKey() []byte {
|
||
key, err := ParseS2CMasterKey([]byte(DefaultS2CMasterKeyBase64))
|
||
if err != nil {
|
||
// 常量被改坏时这是构建期就能发现的错误;运行期只可能出现在有人手改常量之后。
|
||
panic("dnf protocol: baked S2C master key is invalid: " + err.Error())
|
||
}
|
||
return key
|
||
}
|
||
|
||
// NewS2CCipher 用主密钥与连接 nonce 派生一条连接的 S2C 加解密器。
|
||
//
|
||
// connectionNonce 必须**每连接不同**(服务端用 LANA challenge 里那 16 字节随机数),
|
||
// 否则不同连接会共用密钥流。
|
||
func NewS2CCipher(masterKey, connectionNonce []byte) (*S2CCipher, error) {
|
||
parsed, err := ParseS2CMasterKey(masterKey)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(connectionNonce) != 16 {
|
||
return nil, fmt.Errorf("%w: want 16 bytes, got %d", ErrS2CNonceInvalid, len(connectionNonce))
|
||
}
|
||
salt := append([]byte(nil), connectionNonce...)
|
||
// HKDF-Extract/Expand;salt 取连接 nonce,info 做域分隔。
|
||
derived, err := hkdf.Key(sha256.New, parsed, salt, s2cKeyInfo, S2CCipherKeySize)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("dnf protocol: derive s2c key: %w", err)
|
||
}
|
||
block, err := aes.NewCipher(derived)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("dnf protocol: new s2c cipher: %w", err)
|
||
}
|
||
cipher := &S2CCipher{block: block}
|
||
copy(cipher.nonce[:], connectionNonce[:8])
|
||
return cipher, nil
|
||
}
|
||
|
||
// Apply 就地变换 body(加密与解密用同一函数)。长度不变。
|
||
//
|
||
// msgID 与 seq 必须取**线上 13 字节头里**的值:客户端在两个端点都读得到它们,
|
||
// 因此不需要任何额外字段来对齐密钥流。
|
||
func (c *S2CCipher) Apply(body []byte, msgID uint16, seq uint16) {
|
||
if c == nil || c.block == nil || len(body) == 0 {
|
||
return
|
||
}
|
||
block := c.counterBlock(msgID, seq)
|
||
stream := cipher.NewCTR(c.block, block[:])
|
||
stream.XORKeyStream(body, body)
|
||
}
|
||
|
||
// counterBlock 组装该包的 AES-CTR 初始计数块。
|
||
func (c *S2CCipher) counterBlock(msgID uint16, seq uint16) [16]byte {
|
||
var block [16]byte
|
||
copy(block[0:8], c.nonce[:])
|
||
block[8] = byte(msgID >> 8)
|
||
block[9] = byte(msgID)
|
||
block[10] = byte(seq >> 8)
|
||
block[11] = byte(seq)
|
||
return block
|
||
}
|
||
|
||
// NoncePrefix 返回连接 nonce 前 8 字节(诊断用;客户端用同一段拼计数器块)。
|
||
func (c *S2CCipher) NoncePrefix() []byte {
|
||
if c == nil {
|
||
return nil
|
||
}
|
||
return append([]byte(nil), c.nonce[:]...)
|
||
}
|