Files
winauth-go/internal/authenticator/battlenet.go
T
iceking2nd c671f2115e feat: Phase 1 WinAuth Go 移植完整实现
将原 C#/.NET WinAuth 移植为 Go + Gio GUI,覆盖 Phase 1 全部功能。

核心模块:
- internal/authenticator: TOTP (Google/Microsoft/Okta) + HOTP + BattleNet + Steam,含
  enroll/sync/code 生成、Steam 交易确认轮询
- internal/config: YAML 配置 + 老版 WinAuth XML 导入(DPAPI + Password + Blowfish/PBKDF2 解密链)
- internal/crypto: 现代加密 (WAGO1) + DPAPI 跨平台封装 + 老版 Blowfish ECB
- internal/win32: 单实例 Mutex 锁 + 全局热键管理器 (RegisterHotKey + PeekMessage 泵) +
  SendInput Unicode 注入 + 剪贴板文本/CF_DIB 图像读写 + AttachThreadInput 焦点切换
- internal/hotkey: "Ctrl+Alt+G" 风格快捷键字符串解析/格式化
- internal/qr: gozxing 二维码解码 + otpauth:// URI 解析
- internal/i18n: en/zh-CN/de 三语 TOML

UI 模块 (Gio):
- 主窗口:圆环倒计时进度条、复制按钮 + Toast 反馈、空列表占位、行分隔线
- 添加流程:vendor 菜单 + 各 vendor 独立对话框 + 二维码扫描入口(文件 / 剪贴板)
- 设置:密码加密、老版 XML 导入、每条目热键配置
- Steam:注册向导(含 captcha/email/SMS 多步)+ 交易确认窗

构建:Windows 主目标,非 Windows 平台所有 Win32 功能走 build-tag 桩实现。
2026-06-12 03:10:37 +08:00

390 lines
11 KiB
Go

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 "<basesecret>|<utf8-hex-serial>".
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)
}