Files
90CNServer/server/internal/modules/dnf/protocol/s2ccipher.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

158 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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[:]...)
}