c671f2115e
将原 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 桩实现。
390 lines
11 KiB
Go
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)
|
|
}
|