feat: split client tooling and add OAuth server login

This commit is contained in:
2026-09-30 17:32:14 +08:00
parent 7cb5e36c2a
commit 2d054f6b72
230 changed files with 9875 additions and 853 deletions
+401
View File
@@ -0,0 +1,401 @@
// Package account owns the local-account representation used by LoginUser.
// It deliberately stores decoded protobuf data, never a captured HTTP reply
// or a captured session key.
package account
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"bd2server/internal/server/cryptox"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
)
func StateVersion() string { return versionconfig.State() }
var (
ErrInvalidSeed = errors.New("account: invalid LoginUser seed")
)
// LoginSeed is the versioned, decoded representation of a LoginUser response.
// UserInfo is UserDBInfo protobuf field 1 with its field 3 (user_key)
// deliberately absent. ResponseFields contains the remaining top-level
// protobuf fields (such as client-notification state), never an HTTP envelope.
type LoginSeed struct {
Version string
PacketCode int
UserInfo []byte
ResponseFields []byte
currencies CurrencyProvider
purchaseCounts PurchaseCountProvider
presetSlots PresetSlotProvider
inventorySlots InventorySlotProvider
firstGacha *bool
}
func (s *LoginSeed) SetFirstGacha(value bool) { s.firstGacha = &value }
// CurrencyProvider supplies the authoritative mutable UserDBInfo wallet.
type CurrencyProvider interface {
Currencies() (gold, freeJewelry, jewelry, mileage uint64)
}
type HopePowderProvider interface {
HopePowderBalance() uint64
}
type CatalystProvider interface {
CatalystBalance() uint64
}
type EquipmentMileageProvider interface {
EquipmentMileageBalances() (mileage, exchangeGage uint64)
}
// PurchaseCountProvider supplies the current PurchaseCountDBInfo messages for
// UserDBInfo field 26. Implementations must derive them from authoritative
// account state rather than the immutable login seed.
type PurchaseCountProvider interface {
PurchaseCountDBInfos() [][]byte
}
// PresetSlotProvider supplies the authoritative number of ordinary party
// preset slots for UserDBInfo field 28. The deck domain owns both purchased
// slot state and the preset records stored in those slots.
type PresetSlotProvider interface {
PresetSlotCount() uint64
}
// InventorySlotProvider owns the four mutable UserDBInfo capacity fields.
// Development overrides are applied by the provider as a login-time view;
// the immutable account seed is never rewritten.
type InventorySlotProvider interface {
UserInventorySlots() (items, storage, equipment, equipmentStorage uint64, err error)
}
func (s *LoginSeed) AttachCurrencies(provider CurrencyProvider) error {
if provider == nil {
return errors.New("account: nil currency provider")
}
s.currencies = provider
return nil
}
func (s *LoginSeed) AttachPurchaseCounts(provider PurchaseCountProvider) error {
if provider == nil {
return errors.New("account: nil purchase count provider")
}
s.purchaseCounts = provider
return nil
}
func (s *LoginSeed) AttachPresetSlots(provider PresetSlotProvider) error {
if provider == nil {
return errors.New("account: nil preset slot provider")
}
s.presetSlots = provider
return nil
}
func (s *LoginSeed) AttachInventorySlots(provider InventorySlotProvider) error {
if provider == nil {
return errors.New("account: nil inventory slot provider")
}
s.inventorySlots = provider
return nil
}
func (s *LoginSeed) SeedInventorySlots() (items, storage, equipment, equipmentStorage uint64, err error) {
if err = s.Validate(); err != nil {
return 0, 0, 0, 0, err
}
values := []*uint64{&items, &storage, &equipment, &equipmentStorage}
for i, field := range []int{5, 6, 10, 15} {
value, _, readErr := wire.Varint(s.UserInfo, field)
if readErr != nil {
return 0, 0, 0, 0, readErr
}
*values[i] = value
}
return items, storage, equipment, equipmentStorage, nil
}
// SeedCurrencies returns the immutable starting balances embedded in the
// versioned UserDBInfo template. Mutable balances live in player.Wallet.
func (s *LoginSeed) SeedCurrencies() (gold, freeJewelry, jewelry, mileage uint64, err error) {
if err = s.Validate(); err != nil {
return 0, 0, 0, 0, err
}
read := func(field int) (uint64, error) {
value, found, readErr := wire.Varint(s.UserInfo, field)
if readErr != nil {
return 0, readErr
}
if !found {
return 0, nil
}
return value, nil
}
if gold, err = read(7); err != nil {
return 0, 0, 0, 0, err
}
if freeJewelry, err = read(8); err != nil {
return 0, 0, 0, 0, err
}
if jewelry, err = read(9); err != nil {
return 0, 0, 0, 0, err
}
if mileage, err = read(23); err != nil {
return 0, 0, 0, 0, err
}
return gold, freeJewelry, jewelry, mileage, nil
}
func (s *LoginSeed) SeedHopePowder() (uint64, error) {
if err := s.Validate(); err != nil {
return 0, err
}
value, found, err := wire.Varint(s.UserInfo, 24)
if err != nil || !found {
return value, err
}
return value, nil
}
func (s *LoginSeed) SeedCatalyst() (uint64, error) {
if err := s.Validate(); err != nil {
return 0, err
}
value, _, err := wire.Varint(s.UserInfo, 11)
return value, err
}
func (s *LoginSeed) SeedEquipmentMileage() (mileage, exchangeGage uint64, err error) {
if err = s.Validate(); err != nil {
return 0, 0, err
}
if mileage, _, err = wire.Varint(s.UserInfo, 67); err != nil {
return 0, 0, err
}
if exchangeGage, _, err = wire.Varint(s.UserInfo, 68); err != nil {
return 0, 0, err
}
return mileage, exchangeGage, nil
}
type diskSeed struct {
Version string `json:"version"`
PacketCode int `json:"packet_code"`
UserInfoBase64 string `json:"user_info_base64"`
ResponseFieldsBase64 string `json:"response_fields_base64,omitempty"`
}
// Load reads one versioned seed file. Relative paths are intentionally left to
// the caller; production code can therefore choose an explicit local asset.
func Load(path string) (*LoginSeed, error) {
data, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return nil, fmt.Errorf("account: read seed: %w", err)
}
var disk diskSeed
if err := json.Unmarshal(data, &disk); err != nil {
return nil, fmt.Errorf("account: decode seed JSON: %w", err)
}
user, err := base64.StdEncoding.DecodeString(disk.UserInfoBase64)
if err != nil {
return nil, fmt.Errorf("account: decode user_info_base64: %w", err)
}
other, err := base64.StdEncoding.DecodeString(disk.ResponseFieldsBase64)
if err != nil {
return nil, fmt.Errorf("account: decode response_fields_base64: %w", err)
}
seed := &LoginSeed{Version: disk.Version, PacketCode: disk.PacketCode, UserInfo: user, ResponseFields: other}
if err := seed.Validate(); err != nil {
return nil, err
}
return seed, nil
}
// Write stores a seed in the portable JSON form used by the one-shot importer.
func (s *LoginSeed) Write(path string) error {
if err := s.Validate(); err != nil {
return err
}
data, err := json.MarshalIndent(diskSeed{
Version: s.Version, PacketCode: s.PacketCode,
UserInfoBase64: base64.StdEncoding.EncodeToString(s.UserInfo),
ResponseFieldsBase64: base64.StdEncoding.EncodeToString(s.ResponseFields),
}, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
if err := os.WriteFile(filepath.Clean(path), data, 0o644); err != nil {
return fmt.Errorf("account: write seed: %w", err)
}
return nil
}
// Validate verifies the minimum protocol contract and makes sure a captured
// user_key cannot accidentally be committed to a local seed.
func (s *LoginSeed) Validate() error {
if s == nil || s.Version == "" || s.PacketCode <= 0 || len(s.UserInfo) == 0 {
return ErrInvalidSeed
}
if _, found, err := wire.Bytes(s.UserInfo, 3); err != nil {
return fmt.Errorf("%w: malformed UserInfo: %v", ErrInvalidSeed, err)
} else if found {
return fmt.Errorf("%w: UserInfo contains user_key", ErrInvalidSeed)
}
if err := wire.Walk(s.ResponseFields, func(field wire.Field) error {
if field.Number == 1 {
return fmt.Errorf("%w: response fields contain UserInfo", ErrInvalidSeed)
}
return nil
}); err != nil {
return err
}
return nil
}
// Encode makes a fresh encrypted LoginUser HTTP envelope. sessionKey belongs
// to the new local session; no value from a capture is used at runtime.
func (s *LoginSeed) Encode(sessionKey string, now time.Time) ([]byte, error) {
if err := s.Validate(); err != nil {
return nil, err
}
proto, err := s.Login(nil, []byte(sessionKey))
if err != nil {
return nil, err
}
data, err := cryptox.EncryptBase64Payload(proto, cryptox.Key())
if err != nil {
return nil, err
}
envelope := struct {
ErrorType int `json:"errorType"`
PacketCode int `json:"packetCode"`
Length int `json:"length"`
Data string `json:"data"`
ServerNowTime int64 `json:"serverNowTime"`
}{
ErrorType: 0, PacketCode: s.PacketCode,
Length: base64.StdEncoding.EncodedLen(len(proto)), Data: data,
ServerNowTime: now.UnixMilli(),
}
return json.Marshal(envelope)
}
// Login constructs the decoded LoginUser protobuf for one fresh local
// session. request is the already-decrypted LoginUser protobuf. The reply
// is deliberately protobuf-only so the session/transport layer owns its
// packet-code and HTTP envelope policy.
func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) {
if err := s.Validate(); err != nil {
return nil, err
}
if len(request) != 0 {
if _, found, err := wire.Varint(request, 1); err != nil || !found {
return nil, fmt.Errorf("account: LoginUser request has no sequence: %w", err)
}
}
if len(sessionKey) != cryptox.AESKeySize {
return nil, fmt.Errorf("account: session key: %w", cryptox.ErrInvalidKey)
}
if _, err := cryptox.SessionKey(string(sessionKey)); err != nil {
return nil, fmt.Errorf("account: session key: %w", err)
}
user := append([]byte(nil), s.UserInfo...)
if s.currencies != nil {
gold, freeJewelry, jewelry, mileage := s.currencies.Currencies()
var err error
if user, _, err = wire.ReplaceVarint(user, 7, gold); err != nil {
return nil, fmt.Errorf("account: replace gold: %w", err)
}
if user, _, err = wire.ReplaceVarint(user, 8, freeJewelry); err != nil {
return nil, fmt.Errorf("account: replace free jewelry: %w", err)
}
if user, _, err = wire.ReplaceVarint(user, 9, jewelry); err != nil {
return nil, fmt.Errorf("account: replace jewelry: %w", err)
}
if user, _, err = wire.ReplaceVarint(user, 23, mileage); err != nil {
return nil, fmt.Errorf("account: replace mileage: %w", err)
}
if provider, ok := s.currencies.(CatalystProvider); ok {
if user, _, err = wire.ReplaceVarint(user, 11, provider.CatalystBalance()); err != nil {
return nil, fmt.Errorf("account: replace catalyst: %w", err)
}
}
if provider, ok := s.currencies.(HopePowderProvider); ok {
if user, _, err = wire.ReplaceVarint(user, 24, provider.HopePowderBalance()); err != nil {
return nil, fmt.Errorf("account: replace hope powder: %w", err)
}
}
if provider, ok := s.currencies.(EquipmentMileageProvider); ok {
equipMileage, exchangeGage := provider.EquipmentMileageBalances()
if user, _, err = wire.ReplaceVarint(user, 67, equipMileage); err != nil {
return nil, fmt.Errorf("account: replace equipment mileage: %w", err)
}
if user, _, err = wire.ReplaceVarint(user, 68, exchangeGage); err != nil {
return nil, fmt.Errorf("account: replace equipment mileage exchange gauge: %w", err)
}
}
}
if s.firstGacha != nil {
value := uint64(0)
if *s.firstGacha {
value = 1
}
var err error
if user, _, err = wire.ReplaceVarint(user, 27, value); err != nil {
return nil, fmt.Errorf("account: replace first gacha: %w", err)
}
}
if s.purchaseCounts != nil {
var err error
if user, err = replaceRepeatedBytes(user, 26, s.purchaseCounts.PurchaseCountDBInfos()); err != nil {
return nil, fmt.Errorf("account: replace purchase counts: %w", err)
}
}
if s.presetSlots != nil {
var err error
if user, _, err = wire.ReplaceVarint(user, 28, s.presetSlots.PresetSlotCount()); err != nil {
return nil, fmt.Errorf("account: replace preset slots: %w", err)
}
}
if s.inventorySlots != nil {
items, storage, equipment, equipmentStorage, err := s.inventorySlots.UserInventorySlots()
if err != nil {
return nil, fmt.Errorf("account: inventory slots: %w", err)
}
for field, value := range map[int]uint64{5: items, 6: storage, 10: equipment, 15: equipmentStorage} {
if user, _, err = wire.ReplaceVarint(user, field, value); err != nil {
return nil, fmt.Errorf("account: replace inventory slot field %d: %w", field, err)
}
}
}
user = wire.AppendBytes(user, 3, sessionKey)
return append(wire.AppendBytes(nil, 1, user), s.ResponseFields...), nil
}
func replaceRepeatedBytes(data []byte, number int, values [][]byte) ([]byte, error) {
result := make([]byte, 0, len(data))
if err := wire.Walk(data, func(field wire.Field) error {
if field.Number != number {
result = append(result, data[field.Start:field.End]...)
}
return nil
}); err != nil {
return nil, err
}
for _, value := range values {
result = wire.AppendBytes(result, number, value)
}
return result, nil
}
+267
View File
@@ -0,0 +1,267 @@
package account
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"bd2server/internal/server/cryptox"
"bd2server/internal/server/wire"
)
func TestEncodeUsesFreshLocalKey(t *testing.T) {
user := wire.AppendVarint(nil, 1, 42)
user = wire.AppendString(user, 2, "Guest_42")
user = wire.AppendVarint(user, 5, 100)
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: user}
const local = "0123456789abcdef0123456789abcdef"
body, err := seed.Encode(local, time.UnixMilli(1234))
if err != nil {
t.Fatal(err)
}
var envelope struct {
PacketCode int `json:"packetCode"`
Length int `json:"length"`
Data string `json:"data"`
ServerNowTime int64 `json:"serverNowTime"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatal(err)
}
if envelope.PacketCode != 11 || envelope.ServerNowTime != 1234 {
t.Fatalf("envelope=%+v", envelope)
}
proto, err := cryptox.DecryptBase64Payload(envelope.Data, cryptox.Key())
if err != nil {
t.Fatal(err)
}
responseUser, found, err := wire.Bytes(proto, 1)
if err != nil || !found {
t.Fatalf("response UserInfo: found=%v err=%v", found, err)
}
gotKey, found, err := wire.Bytes(responseUser, 3)
if err != nil || !found || string(gotKey) != local {
t.Fatalf("local key=%q found=%v err=%v", gotKey, found, err)
}
}
func TestLoginValidatesEncryptedRequest(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
if _, err := seed.Login([]byte("not protobuf"), []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("Login accepted invalid protobuf request")
}
}
func TestLoadRejectsSeedWithUserKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.json")
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: wire.AppendString(nil, 3, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}
if err := seed.Write(path); err == nil {
t.Fatal("Write accepted user_key")
}
if err := os.WriteFile(path, []byte(`{"version":"2.35.10","packet_code":11,"user_info_base64":"GgF4"}`), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("Load accepted user_key")
}
}
func TestCheckedInSeedBuildsLoginWithoutCapture(t *testing.T) {
seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "login_user.json"))
if err != nil {
t.Fatal(err)
}
proto, err := seed.Login(wire.AppendVarint(nil, 1, 4), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(proto, 1)
if err != nil || !found {
t.Fatalf("missing UserInfo: found=%v err=%v", found, err)
}
key, found, err := wire.Bytes(user, 3)
if err != nil || !found || string(key) != "0123456789abcdef0123456789abcdef" {
t.Fatalf("unexpected generated key: %q found=%v err=%v", key, found, err)
}
}
type loginCurrencyFixture struct{}
func (loginCurrencyFixture) Currencies() (uint64, uint64, uint64, uint64) {
return 1, 2, 3, 4
}
func (loginCurrencyFixture) EquipmentMileageBalances() (uint64, uint64) { return 17, 845 }
func TestLoginRestoresEquipmentMileageFromCurrencyProvider(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
if err := seed.AttachCurrencies(loginCurrencyFixture{}); err != nil {
t.Fatal(err)
}
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing login user: %v", err)
}
if mileage, found, err := wire.Varint(user, 67); err != nil || !found || mileage != 17 {
t.Fatalf("equipment mileage=%d found=%v err=%v", mileage, found, err)
}
if gauge, found, err := wire.Varint(user, 68); err != nil || !found || gauge != 845 {
t.Fatalf("equipment mileage gauge=%d found=%v err=%v", gauge, found, err)
}
}
type loginPurchaseCountFixture struct {
infos [][]byte
}
func (f *loginPurchaseCountFixture) PurchaseCountDBInfos() [][]byte { return f.infos }
func TestLoginReplacesSeedPurchaseCountsFromProvider(t *testing.T) {
stale := wire.AppendVarint(nil, 1, 999)
userTemplate := wire.AppendVarint(nil, 1, 1)
userTemplate = wire.AppendBytes(userTemplate, 26, stale)
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: userTemplate}
current := wire.AppendVarint(nil, 1, 1100001)
current = wire.AppendVarint(current, 2, 9100033)
current = wire.AppendVarint(current, 4, 1)
provider := &loginPurchaseCountFixture{infos: [][]byte{current}}
if err := seed.AttachPurchaseCounts(provider); err != nil {
t.Fatal(err)
}
login := func() []byte {
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing login user: found=%v err=%v", found, err)
}
return user
}
counts := byteFields(login(), 26)
if len(counts) != 1 || string(counts[0]) != string(current) {
t.Fatalf("purchase counts=%x want=%x", counts, current)
}
// The provider is consulted on every LoginUser response. An empty current
// state must also remove any stale count captured in the seed template.
provider.infos = nil
if counts = byteFields(login(), 26); len(counts) != 0 {
t.Fatalf("empty current state retained purchase counts: %x", counts)
}
}
func TestAttachPurchaseCountsRejectsNil(t *testing.T) {
seed := &LoginSeed{}
if err := seed.AttachPurchaseCounts(nil); err == nil {
t.Fatal("AttachPurchaseCounts accepted nil provider")
}
}
type loginPresetSlotFixture struct{ count uint64 }
func (f *loginPresetSlotFixture) PresetSlotCount() uint64 { return f.count }
func TestLoginReplacesSeedPresetSlotFromProvider(t *testing.T) {
userTemplate := wire.AppendVarint(nil, 1, 1)
userTemplate = wire.AppendVarint(userTemplate, 28, 6)
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: userTemplate}
provider := &loginPresetSlotFixture{count: 9}
if err := seed.AttachPresetSlots(provider); err != nil {
t.Fatal(err)
}
login := func() []byte {
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing login user: found=%v err=%v", found, err)
}
return user
}
if count, found, err := wire.Varint(login(), 28); err != nil || !found || count != 9 {
t.Fatalf("preset slots=%d found=%v err=%v", count, found, err)
}
provider.count = 12
if count, found, err := wire.Varint(login(), 28); err != nil || !found || count != 12 {
t.Fatalf("updated preset slots=%d found=%v err=%v", count, found, err)
}
}
func TestAttachPresetSlotsRejectsNil(t *testing.T) {
seed := &LoginSeed{}
if err := seed.AttachPresetSlots(nil); err == nil {
t.Fatal("AttachPresetSlots accepted nil provider")
}
}
type loginInventorySlotFixture struct {
items, storage, equipment, equipmentStorage uint64
err error
}
func (f *loginInventorySlotFixture) UserInventorySlots() (uint64, uint64, uint64, uint64, error) {
return f.items, f.storage, f.equipment, f.equipmentStorage, f.err
}
func TestLoginReplacesAllInventorySlotFieldsFromProvider(t *testing.T) {
user := wire.AppendVarint(nil, 1, 1)
for field, value := range map[int]uint64{5: 100, 6: 100, 10: 500, 15: 100} {
user = wire.AppendVarint(user, field, value)
}
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
provider := &loginInventorySlotFixture{items: 500, storage: 100, equipment: 2000, equipmentStorage: 100}
if err := seed.AttachInventorySlots(provider); err != nil {
t.Fatal(err)
}
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
result, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing UserInfo: %v", err)
}
for field, want := range map[int]uint64{5: 500, 6: 100, 10: 2000, 15: 100} {
if got, found, err := wire.Varint(result, field); err != nil || !found || got != want {
t.Fatalf("field %d=%d found=%t err=%v want=%d", field, got, found, err, want)
}
}
}
func TestSeedInventorySlotsReadsUserInfoFields(t *testing.T) {
user := wire.AppendVarint(nil, 1, 1)
for field, value := range map[int]uint64{5: 100, 6: 101, 10: 500, 15: 102} {
user = wire.AppendVarint(user, field, value)
}
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
items, storage, equipment, equipmentStorage, err := seed.SeedInventorySlots()
if err != nil || items != 100 || storage != 101 || equipment != 500 || equipmentStorage != 102 {
t.Fatalf("slots=%d/%d/%d/%d err=%v", items, storage, equipment, equipmentStorage, err)
}
}
func byteFields(data []byte, number int) [][]byte {
var result [][]byte
_ = wire.Walk(data, func(field wire.Field) error {
if field.Number == number && field.Type == 2 {
result = append(result, append([]byte(nil), field.Value...))
}
return nil
})
return result
}