package authenticator import ( "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/sha1" "encoding/binary" "encoding/hex" "fmt" "io" "math/big" mrand "math/rand" "net/http" "strings" "time" "git.wxccs.org/iceking2nd/winauth-go/internal/global" "git.wxccs.org/iceking2nd/winauth-go/internal/httpc" ) // Battle.Net mobile-service URLs by region. Matches the original C# table. var battlenetURLs = map[string]string{ "US": "http://mobile-service.blizzard.com", "EU": "http://mobile-service.blizzard.com", "KR": "http://mobile-service.blizzard.com", "CN": "http://mobile-service.battlenet.com.cn", } const ( bnetEnrollPath = "/enrollment/enroll2.htm" bnetSyncPath = "/enrollment/time.htm" bnetRestorePath = "/enrollment/initiatePaperRestore.htm" bnetRestoreValidatePath = "/enrollment/validatePaperRestore.htm" bnetCodeDigits = 8 bnetModelSize = 16 bnetModelChars = " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01234567890" bnetEnrollRespSz = 45 bnetSyncRespSz = 8 bnetEnrollModulus = "955e4bd989f3917d2f15544a7e0504eb9d7bb66b6f8a2fe470e453c779200e5e" + "3ad2e43a02d06c4adbd8d328f1a426b83658e88bfd949b2af4eaf30054673a14" + "19a250fa4cc1278d12855b5b25818d162c6e6ee2ab4a350d401d78f6ddb99711" + "e72626b48bd8b5b0b7f3acf9ea3c9e0005fee59e19136cdb7c83f2ab8b0a2a99" bnetEnrollExponent = "0101" ) // BattleNetAuthenticator is the Go port of the C# BattleNetAuthenticator. // It supports Enroll, Sync, Restore (paper restore), and the customary // 8-digit TOTP code generation. type BattleNetAuthenticator struct { Base Serial string RestoreCodeVerified bool } // NewBattleNetAuthenticator returns an empty 8-digit Battle.Net authenticator. func NewBattleNetAuthenticator() *BattleNetAuthenticator { b := &BattleNetAuthenticator{Base: NewBase()} b.CodeDigits = bnetCodeDigits return b } // Name returns the short logger tag for this type. func (b *BattleNetAuthenticator) Name() string { return "battlenet" } // Region returns the two-letter region prefix derived from the serial. func (b *BattleNetAuthenticator) Region() string { if len(b.Serial) >= 2 { return strings.ToUpper(b.Serial[:2]) } return "" } // CurrentCode returns the live 8-digit Battle.Net authenticator code. func (b *BattleNetAuthenticator) CurrentCode() (string, error) { if b.SecretKey == nil { return "", fmt.Errorf("battlenet: no secret loaded") } return b.CalculateTOTP(), nil } // SecretData / SetSecretData persist both the secret and the serial number, // matching the C# format "|". func (b *BattleNetAuthenticator) SecretData() string { return b.EncodeSecretData() + "|" + strings.ToUpper(hex.EncodeToString([]byte(b.Serial))) } func (b *BattleNetAuthenticator) SetSecretData(value string) error { if value == "" { b.SecretKey = nil b.Serial = "" return nil } parts := strings.Split(value, "|") if len(parts) == 1 { // legacy WinAuth2 form: 40 hex chars secret then UTF8-hex serial if len(value) < 40 { return fmt.Errorf("battlenet: secret data too short") } raw, err := hex.DecodeString(value[:40]) if err != nil { return fmt.Errorf("battlenet: bad secret hex: %w", err) } b.SecretKey = raw serialBytes, err := hex.DecodeString(value[40:]) if err == nil { b.Serial = string(serialBytes) } return nil } if err := b.DecodeSecretData(parts[0]); err != nil { return err } serialIdx := 1 if len(parts) == 3 { // alpha 3.0.6 form: secret|script|serial serialIdx = 2 } if len(parts) > serialIdx { raw, err := hex.DecodeString(parts[serialIdx]) if err == nil { b.Serial = string(raw) } } return nil } // Enroll registers a brand-new Battle.Net authenticator with the Blizzard // mobile service. Country is auto-detected via Wikimedia GEO IP; pass an // explicit two-letter override to skip the lookup. func (b *BattleNetAuthenticator) Enroll(ctx context.Context, countryOverride string) error { const fn = "internal.authenticator.BattleNetAuthenticator.Enroll" logger := global.Log.WithField("func", fn) country := strings.ToUpper(strings.TrimSpace(countryOverride)) region := "US" if country == "" { c, r := lookupRegion(ctx) country, region = c, r } else { region = regionForCountry(country) } logger.WithField("country", country).WithField("region", region).Debug("region determined") otp := mustOneTimePad(20) payload := make([]byte, 38) copy(payload[0:20], otp) copy(payload[20:22], []byte(country)) copy(payload[22:38], []byte(randomModel())) encrypted, err := rsaEncryptRaw(payload) if err != nil { return fmt.Errorf("battlenet: rsa encrypt failed: %w", err) } respBytes, err := bnetPostBinary(ctx, mobileURL(region)+bnetEnrollPath, encrypted) if err != nil { return err } if len(respBytes) != bnetEnrollRespSz { return fmt.Errorf("battlenet: enroll response size %d, want %d", len(respBytes), bnetEnrollRespSz) } serverMs := int64(binary.BigEndian.Uint64(respBytes[0:8])) b.ServerTimeDiff = serverMs - NowMillis() secret := make([]byte, 20) copy(secret, respBytes[25:45]) for i := range secret { secret[i] ^= otp[i] } b.SecretKey = secret b.Serial = string(respBytes[8:25]) logger.WithField("serial", b.Serial).Info("enrolled with Battle.Net mobile service") return nil } // Sync re-aligns ServerTimeDiff against the mobile service for our region. func (b *BattleNetAuthenticator) Sync() error { const fn = "internal.authenticator.BattleNetAuthenticator.Sync" logger := global.Log.WithField("func", fn).WithField("region", b.Region()) if b.SecretKey == nil { logger.Debug("skip sync: no secret loaded") return nil } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() url := mobileURL(b.Region()) + bnetSyncPath req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } resp, err := httpc.New().Do(req) if err != nil { logger.WithError(err).Warn("sync failed; using local clock") return nil } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return err } if len(body) != bnetSyncRespSz { return fmt.Errorf("battlenet: sync response size %d, want %d", len(body), bnetSyncRespSz) } serverMs := int64(binary.BigEndian.Uint64(body)) b.ServerTimeDiff = serverMs - NowMillis() b.LastServerTime = NowMillis() logger.WithField("offset_ms", b.ServerTimeDiff).Debug("clock synced") return nil } // RestoreCode computes the 10-char Battle.Net restore code derived from // the SHA1 of (serial || secretKey). func (b *BattleNetAuthenticator) RestoreCode() string { if b.Serial == "" || b.SecretKey == nil { return "" } serial := strings.ReplaceAll(strings.ToUpper(b.Serial), "-", "") hash := sha1.Sum(append([]byte(serial), b.SecretKey...)) out := make([]byte, 10) for i := 0; i < 10; i++ { out[i] = restoreByteToChar(hash[len(hash)-10+i]) } return string(out) } // helpers ------------------------------------------------------------------ func mobileURL(region string) string { region = strings.ToUpper(region) if len(region) > 2 { region = region[:2] } if u, ok := battlenetURLs[region]; ok { return u } return battlenetURLs["US"] } func regionForCountry(country string) string { switch country { case "CN": return "CN" case "KR", "KP", "TW", "HK", "MO": return "KR" } euCountries := []string{ "AL", "AD", "AM", "AT", "AZ", "BY", "BE", "BA", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "GE", "DE", "GR", "HU", "IS", "IE", "IT", "KV", "XK", "LV", "LI", "LT", "LU", "MK", "MT", "MD", "MC", "ME", "NL", "NO", "PL", "PT", "RO", "RU", "SM", "RS", "SK", "ES", "SE", "CH", "TR", "UA", "UK", "GB", "VA", } for _, c := range euCountries { if c == country { return "EU" } } return "US" } func lookupRegion(ctx context.Context) (country, region string) { const fn = "internal.authenticator.lookupRegion" logger := global.Log.WithField("func", fn) geoCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() req, _ := http.NewRequestWithContext(geoCtx, http.MethodGet, "http://geoiplookup.wikimedia.org", nil) resp, err := httpc.New().Do(req) if err != nil { logger.WithError(err).Debug("geoip lookup failed; defaulting to US") return "US", "US" } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) // Cheap regex-free parse for "country":"XX" const key = `"country":"` idx := strings.Index(string(body), key) if idx < 0 { return "US", "US" } rest := string(body)[idx+len(key):] end := strings.Index(rest, `"`) if end < 0 { return "US", "US" } country = strings.ToUpper(rest[:end]) return country, regionForCountry(country) } func bnetPostBinary(ctx context.Context, url string, body []byte) ([]byte, error) { reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/octet-stream") resp, err := httpc.New().Do(req) if err != nil { return nil, fmt.Errorf("battlenet: contact mobile service: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("battlenet: server returned %d %s", resp.StatusCode, resp.Status) } return io.ReadAll(resp.Body) } func rsaEncryptRaw(data []byte) ([]byte, error) { n, ok := new(big.Int).SetString(bnetEnrollModulus, 16) if !ok { return nil, fmt.Errorf("invalid RSA modulus") } e, ok := new(big.Int).SetString(bnetEnrollExponent, 16) if !ok { return nil, fmt.Errorf("invalid RSA exponent") } pub := &rsa.PublicKey{N: n, E: int(e.Int64())} // The original C# uses BouncyCastle's RsaEngine.ProcessBlock without // padding (raw RSA / "no padding"). We emulate that by padding the input // to modulus length with leading zeros and using big.Int exponentiation. keySize := (pub.N.BitLen() + 7) / 8 if len(data) > keySize { return nil, fmt.Errorf("data too large for raw RSA") } m := new(big.Int).SetBytes(data) c := new(big.Int).Exp(m, big.NewInt(int64(pub.E)), pub.N) out := make([]byte, keySize) cBytes := c.Bytes() copy(out[keySize-len(cBytes):], cBytes) return out, nil } func mustOneTimePad(n int) []byte { out := make([]byte, n) if _, err := rand.Read(out); err != nil { panic(err) } return out } func randomModel() string { var seedBytes [8]byte _, _ = rand.Read(seedBytes[:]) r := mrand.New(mrand.NewSource(int64(binary.LittleEndian.Uint64(seedBytes[:])))) out := make([]byte, bnetModelSize) for i := range out { out[i] = bnetModelChars[r.Intn(len(bnetModelChars))] } return string(out) } func restoreByteToChar(b byte) byte { // Mirror C# ConvertRestoreCodeByteToChar — but we only need the inverse // for code *display*. The original algorithm maps each byte to an alpha // or numeric, skipping I, L, O, S to avoid ambiguity. Below is a direct // translation of the C# code path that lives at the bottom of // BattleNetAuthenticator.cs (ConvertRestoreCodeByteToChar). v := int(b) & 0x1F // 5 bits switch { case v < 10: return byte('0' + v) } c := v - 10 + 'A' if c >= 'I' { c++ } if c >= 'L' { c++ } if c >= 'O' { c++ } if c >= 'S' { c++ } return byte(c) }