Files
90CNServer/pvf/internal/pvf/paged110.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

433 lines
14 KiB
Go

package pvf
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rsa"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
// The paged container layout is shared by 110US, 115US and unknown clients:
//
// PageSize 10 MiB pages, PageGuardSize 10,240 bytes, PageKeySize 32 bytes
//
// The first 10,240 bytes of every page are AES-256-CBC (IV = 0) encrypted with
// that page's 32-byte key; the rest of the page holds the ordinary 90US layout
// (48-byte header + file table + hash + name pool + GRPI + body), so once the
// guards are decrypted in place the archive parses exactly like the older
// formats.
//
// The per-page key table is not stored in the archive: it ships next to it as
// "sk.dat", RSA-1024 PKCS#1 v1.5 sealed (the private key lives in DFO.exe),
// and is additionally AES-256-CBC wrapped with a "metadata key" that is a
// 64-hex-character run found inside the executable. The embedded 110US RSA
// key is tried first; newer clients can supply PKCS#8 or PKCS#1 PEM literals.
//
// Every section uses the wide-string key derivation of the newer client with
// scheme-specific names:
//
// Header iNfO, GRPI Gidx, sTrA stAs, sTrW stWs, body mAIn
const (
paged110PageSize = 10485760
paged110PageGuardSize = 10240
paged110PageKeySize = 32
paged110MetadataAlign = 256
paged110KeyHeader = "iNfO"
paged110KeyGrpi = "Gidx"
paged110KeyBody = "mAIn"
paged110KeyStrA = "stAs"
paged110KeyStrW = "stWs"
// Name-pool size obfuscation constants of the Paged110 scheme.
paged110MaskStrA uint32 = 3886938090
paged110MaskStrW uint32 = 3101599660
// sealedPageKeyName / executableName are the sidecar files the client
// reads: the sealed page key table and the executable holding the key.
// The page-key table also has a built-in default for the bundled 110US
// client data.
sealedPageKeyName = "sk.dat"
executableName = "DFO.exe"
cnExecutableName = "DNF.exe"
)
// ErrPagedKeys indicates a paged archive whose page-key material is
// missing or does not unlock it.
var ErrPagedKeys = errors.New("pvf: 内置与同目录配套数据均无法解封归档;请将该 PVF 对应版本的 sk.dat 和 DNF.exe(或 DFO.exe)与 PVF 放在同一目录后重试")
// ErrPagedReadOnly reports that a paged container cannot be written back
// because its page keys are not in memory, so the decrypted page guards could
// not be re-encrypted.
var ErrPagedReadOnly = errors.New("pvf: paged archives cannot be written without their page keys")
// ErrPagedStructureLocked reports that a paged archive cannot take
// structural edits (adding or removing files) yet: its HASH section is carried
// over verbatim because that section's key is unknown, while a structural edit
// rebuilds the name pool and would leave the carried-over HASH pointing at stale
// name offsets. In-place edits of existing payloads are supported.
var ErrPagedStructureLocked = errors.New("pvf: paged archives only support in-place edits until the HASH key is known")
// Deprecated: use the client-neutral paged errors.
var (
ErrPaged110Keys = ErrPagedKeys
ErrPaged110ReadOnly = ErrPagedReadOnly
ErrPaged110StructureLocked = ErrPagedStructureLocked
)
// wideSeed is the newer client's seed derivation: four 16-bit key units
// (UTF-16 code units), Horner over base 0x393 with a 0x339E9711 multiplier.
func wideSeed(name string) uint32 {
var u [4]uint16
if len(name) < 4 {
return 0
}
for i := 0; i < 4; i++ {
u[i] = uint16(name[i])
}
return 0x339E9711*uint32(u[0]) +
0x393*(uint32(u[3])+0x393*(uint32(u[2])+0x393*uint32(u[1])))
}
// paged110Keys is the Paged110 section key set.
func paged110Keys() keySet {
return keySet{
header: sectionKey{wideSeed(paged110KeyHeader), magicMain},
grpi: sectionKey{wideSeed(paged110KeyGrpi), magicMain},
body: sectionKey{wideSeed(paged110KeyBody), magicMain},
strA: sectionKey{wideSeed(paged110KeyStrA), magicAlt},
strW: sectionKey{wideSeed(paged110KeyStrW), magicAlt},
maskA: paged110MaskStrA,
maskW: paged110MaskStrW,
}
}
func unsealPageKeyTableWithKey(sealed []byte, key *rsa.PrivateKey) ([]byte, error) {
blockSize := key.Size()
if len(sealed) == 0 || len(sealed)%blockSize != 0 {
return nil, ErrPagedKeys
}
out := make([]byte, 0, len(sealed))
for off := 0; off < len(sealed); off += blockSize {
block, err := rsa.DecryptPKCS1v15(nil, key, sealed[off:off+blockSize])
if err != nil {
return nil, ErrPagedKeys
}
out = append(out, block...)
}
return out, nil
}
// Client revisions can move or replace their PEM literals. Only RSA candidates
// that unseal the complete sidecar are passed on to the header validator.
func executablePageKeyTables(executable, sealed []byte) []pageKeyTable {
var tables []pageKeyTable
for len(executable) > 0 {
start := bytes.Index(executable, []byte("-----BEGIN "))
if start < 0 {
break
}
executable = executable[start:]
block, rest := pem.Decode(executable)
if block == nil {
executable = executable[len("-----BEGIN "):]
continue
}
executable = rest
var key *rsa.PrivateKey
switch block.Type {
case "PRIVATE KEY":
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err == nil {
key, _ = parsed.(*rsa.PrivateKey)
}
case "RSA PRIVATE KEY":
key, _ = x509.ParsePKCS1PrivateKey(block.Bytes)
}
if key == nil {
continue
}
if table, err := unsealPageKeyTableWithKey(sealed, key); err == nil {
tables = append(tables, pageKeyTable{data: table, profile: pagedClientProfile(&key.PublicKey)})
}
}
return tables
}
// unwrapPageKeyTable AES-256-CBC (IV = 0) decrypts the aligned prefix of the
// table in place; the trailing partial block is left untouched.
func unwrapPageKeyTable(table, metadataKey []byte) bool {
n := len(table) &^ (paged110MetadataAlign - 1)
if n == 0 || len(metadataKey) != 32 {
return false
}
block, err := aes.NewCipher(metadataKey)
if err != nil {
return false
}
cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(table[:n], table[:n])
return true
}
// decryptPageGuards AES-256-CBC (IV = 0) decrypts the first 10,240 bytes of
// every 10 MiB page in place, using 32 bytes of page key table per page.
func decryptPageGuards(data, pageKeys []byte) int {
pages := len(pageKeys) / paged110PageKeySize
done := 0
for i := 0; i < pages; i++ {
off := i * paged110PageSize
if off+paged110PageGuardSize > len(data) {
break
}
block, err := aes.NewCipher(pageKeys[i*paged110PageKeySize : (i+1)*paged110PageKeySize])
if err != nil {
break
}
cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)).
CryptBlocks(data[off:off+paged110PageGuardSize], data[off:off+paged110PageGuardSize])
done++
}
return done
}
// encryptPageGuards re-applies the page protection in place: the first 10,240
// bytes of every 10 MiB page are AES-256-CBC (IV = 0) encrypted with that page's
// key. It is the exact inverse of decryptPageGuards.
func encryptPageGuards(data, pageKeys []byte) int {
pages := len(pageKeys) / paged110PageKeySize
done := 0
for i := 0; i < pages; i++ {
off := i * paged110PageSize
if off+paged110PageGuardSize > len(data) {
break
}
block, err := aes.NewCipher(pageKeys[i*paged110PageKeySize : (i+1)*paged110PageKeySize])
if err != nil {
break
}
cipher.NewCBCEncrypter(block, make([]byte, aes.BlockSize)).
CryptBlocks(data[off:off+paged110PageGuardSize], data[off:off+paged110PageGuardSize])
done++
}
return done
}
// writePageGuarded writes logical (an archive whose page guards are decrypted)
// to w with the page protection re-applied, streaming page by page so a save
// never needs a second copy of the whole container in memory.
func writePageGuarded(w io.Writer, logical, pageKeys []byte) error {
if err := validatePagedCapacity(int64(len(logical)), pageKeys); err != nil {
return err
}
guard := make([]byte, paged110PageGuardSize)
for i := 0; ; i++ {
off := i * paged110PageSize
if off >= len(logical) {
return nil
}
end := off + paged110PageSize
if end > len(logical) {
end = len(logical)
}
head := off + paged110PageGuardSize
if head > len(logical) {
// A trailing partial guard is not encrypted; mirror
// decryptPageGuards, which stops there too.
if _, err := w.Write(logical[off:end]); err != nil {
return err
}
continue
}
copy(guard, logical[off:head])
block, err := aes.NewCipher(pageKeys[i*paged110PageKeySize : (i+1)*paged110PageKeySize])
if err != nil {
return err
}
cipher.NewCBCEncrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(guard, guard)
if _, err := w.Write(guard); err != nil {
return err
}
if _, err := w.Write(logical[head:end]); err != nil {
return err
}
}
}
// Validate before writing any bytes: the client cannot decrypt pages beyond
// its sealed table, and adding local keys would not extend that client table.
func validatePagedCapacity(size int64, pageKeys []byte) error {
capacity := int64(len(pageKeys)/paged110PageKeySize) * paged110PageSize
if size > capacity || len(pageKeys)%paged110PageKeySize != 0 {
return fmt.Errorf("%w(归档需要 %d 页,配套数据支持 %d 页)", ErrPagedCapacity,
(size+paged110PageSize-1)/paged110PageSize, len(pageKeys)/paged110PageKeySize)
}
return nil
}
// metadataKeyCandidates returns the AES keys that may wrap the page key table.
//
// The client takes every run of 64 hex characters found in the executable and
// decodes it to 32 bytes. Matching candidates are validated against the archive.
func metadataKeyCandidates(executable []byte) [][]byte {
var out [][]byte
seen := map[string]bool{}
add := func(b []byte) {
if len(b) != 32 || seen[string(b)] {
return
}
seen[string(b)] = true
out = append(out, b)
}
for i := 0; i+64 <= len(executable); {
j := i
for j < len(executable) && isHexDigit(executable[j]) {
j++
}
if j-i == 64 {
if b, err := hex.DecodeString(string(executable[i:j])); err == nil {
add(b)
}
}
if j == i {
i++
} else {
i = j
}
}
return out
}
func isHexDigit(c byte) bool {
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
}
// unlockPaged tries complete bundled clients first, then archive-local sidecars.
// Client identity is accepted only after the decrypted header and layout validate.
// Caller data is preserved unless owned; the bundled fast path does not read
// sibling files. An owned buffer is only modified after successful validation.
func unlockPaged(data []byte, dir string, owned bool) (detectedArchive, bool) {
if len(data) < paged110PageGuardSize {
return detectedArchive{}, false
}
keys := paged110Keys()
clients := bundledPagedClients()
for _, client := range clients {
if result, ok := openPagedTable(data, client.table, keys, client.metadata, client.profile, owned); ok {
return result, true
}
}
sealedCandidates := loadSealedPageKeyCandidates(dir)
var tables []pageKeyTable
for _, client := range clients {
for _, sealed := range sealedCandidates {
table, err := unsealPageKeyTableWithKey(sealed, client.key)
if err != nil {
continue
}
if result, ok := openPagedTable(data, table, keys, client.metadata, client.profile, owned); ok {
return result, true
}
tables = append(tables, pageKeyTable{data: table, profile: client.profile})
}
}
if dir == "" {
return detectedArchive{}, false
}
for _, name := range [...]string{executableName, cnExecutableName} {
exe, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
continue
}
clientTables := append([]pageKeyTable(nil), tables...)
for _, sealed := range sealedCandidates {
clientTables = append(clientTables, executablePageKeyTables(exe, sealed)...)
}
clientCandidates := pagedMetadataCandidates(exe)
for _, table := range clientTables {
for _, cand := range clientCandidates {
if result, ok := openPagedTable(data, table.data, keys, cand, table.profile, owned); ok {
return result, true
}
}
}
}
return detectedArchive{}, false
}
func openPagedTable(data, table []byte, keys keySet, metadata []byte, profile formatProfile, owned bool) (detectedArchive, bool) {
dec, pkeys, hdr, ok := decryptPagedWithOwnership(data, table, keys, metadata, owned)
if !ok {
return detectedArchive{}, false
}
return detectedArchive{data: dec, hdr: hdr, keys: keys, format: profile, pageKeys: pkeys}, true
}
// decryptPagedWith unwraps the page key table with cand and, if the header
// then validates, returns the archive with its page guards decrypted together
// with the unwrapped page key table needed to write it back.
func decryptPagedWith(data, table []byte, keys keySet, cand []byte) ([]byte, []byte, Header, bool) {
return decryptPagedWithOwnership(data, table, keys, cand, false)
}
func decryptPagedWithOwnership(data, table []byte, keys keySet, cand []byte, owned bool) ([]byte, []byte, Header, bool) {
pages := (len(data) + paged110PageSize - 1) / paged110PageSize
if len(data) < paged110PageGuardSize || len(table)%paged110PageKeySize != 0 ||
len(table)/paged110PageKeySize < pages {
return nil, nil, Header{}, false
}
unwrapped := make([]byte, len(table))
copy(unwrapped, table)
if !unwrapPageKeyTable(unwrapped, cand) {
return nil, nil, Header{}, false
}
block, err := aes.NewCipher(unwrapped[:paged110PageKeySize])
if err != nil {
return nil, nil, Header{}, false
}
var hdrRaw [headerSize]byte
cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(hdrRaw[:], data[:headerSize])
cryptSeed(keys.header.seed, magicMain, hdrRaw[:])
hdr := decodeHeader(hdrRaw)
if hdr.Signature != MagicSignature {
return nil, nil, Header{}, false
}
if hdr.FileCount <= 0 || hdr.BodySize <= 0 || hdr.GroupCount <= 0 ||
hdr.HashTableSize <= 0 || hdr.NameTableSize <= 0 {
return nil, nil, Header{}, false
}
declared := int64(headerSize) + int64(hdr.FileCount)*0x18 + int64(hdr.HashTableSize) +
int64(hdr.NameTableSize) + int64(hdr.GroupCount)*8 + int64(hdr.BodySize)
if declared != int64(len(data)) {
return nil, nil, Header{}, false
}
// Open owns its os.ReadFile buffer; Parse must preserve caller bytes.
// Only reuse the buffer after the complete header and key capacity validate.
buf := data
if !owned {
buf = bytes.Clone(data)
}
if decryptPageGuards(buf, unwrapped) != pages {
return nil, nil, Header{}, false
}
return buf, unwrapped, hdr, true
}
// hasSealedKeyFile reports whether dir contains the external sidecar key
// table. The embedded fallback is intentionally not included here so generic
// bad archives keep their existing error classification.
func hasSealedKeyFile(dir string) bool {
if dir == "" {
return false
}
st, err := os.Stat(filepath.Join(dir, sealedPageKeyName))
return err == nil && st.Size() > 0
}