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 桩实现。
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
)
|
||||
|
||||
// Battle.Net paper-restore protocol constants. The server responds with
|
||||
// a fixed 32-byte challenge to the initiate POST, and a fixed 20-byte
|
||||
// secret to the validate POST. Mismatching sizes are treated as fatal
|
||||
// since the binary protocol has no error envelope.
|
||||
const (
|
||||
bnetRestoreChallengeSize = 32
|
||||
bnetRestoreSecretSize = 20
|
||||
bnetSerialDigits = 14 // CC-NNNN-NNNN-NNNN after stripping dashes
|
||||
bnetRestoreCodeLen = 10
|
||||
)
|
||||
|
||||
// Restore recovers an existing Battle.Net authenticator's secret key
|
||||
// using the 10-character paper restore code the user wrote down when
|
||||
// they first enrolled.
|
||||
//
|
||||
// The wire protocol mirrors the original WinAuth implementation:
|
||||
//
|
||||
// 1. POST <serial-ascii-bytes> → /enrollment/initiatePaperRestore.htm
|
||||
// ← 32-byte challenge
|
||||
// 2. HMAC-SHA1(key = restoreCode-decoded-10-bytes,
|
||||
// data = serial-bytes || challenge) → 20-byte signature
|
||||
// 3. POST <serial || signature> → /enrollment/validatePaperRestore.htm
|
||||
// ← 20-byte secret (the new SecretKey)
|
||||
//
|
||||
// SECURITY: restoreCode grants full account control if leaked. We do not
|
||||
// log it, never persist it, and wipe the derived 10-byte key buffer
|
||||
// before returning. The caller's restoreCode string is the caller's
|
||||
// responsibility to manage.
|
||||
func (b *BattleNetAuthenticator) Restore(ctx context.Context, serial, restoreCode string) error {
|
||||
const fn = "internal.authenticator.BattleNetAuthenticator.Restore"
|
||||
logger := global.Log.WithField("func", fn)
|
||||
|
||||
cleanSerial := normalizeBnetSerial(serial)
|
||||
if len(cleanSerial) < bnetSerialDigits {
|
||||
return fmt.Errorf("battlenet: serial must contain %d digits after the region prefix", bnetSerialDigits)
|
||||
}
|
||||
region := cleanSerial[:2]
|
||||
if _, ok := battlenetURLs[region]; !ok {
|
||||
return fmt.Errorf("battlenet: unknown region %q in serial", region)
|
||||
}
|
||||
logger.WithField("region", region).Debug("starting paper restore")
|
||||
|
||||
cleanCode := normalizeBnetRestoreCode(restoreCode)
|
||||
if len(cleanCode) != bnetRestoreCodeLen {
|
||||
return fmt.Errorf("battlenet: restore code must be %d characters", bnetRestoreCodeLen)
|
||||
}
|
||||
codeKey, err := decodeRestoreCode(cleanCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Zero the derived key on return so it does not linger in stack/heap
|
||||
// after the HMAC call has consumed it.
|
||||
defer func() {
|
||||
for i := range codeKey {
|
||||
codeKey[i] = 0
|
||||
}
|
||||
}()
|
||||
|
||||
serialBytes := []byte(cleanSerial)
|
||||
|
||||
challenge, err := bnetPostBinary(ctx, mobileURL(region)+bnetRestorePath, serialBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("battlenet: initiate restore: %w", err)
|
||||
}
|
||||
if len(challenge) != bnetRestoreChallengeSize {
|
||||
return fmt.Errorf("battlenet: restore challenge size %d, want %d",
|
||||
len(challenge), bnetRestoreChallengeSize)
|
||||
}
|
||||
|
||||
mac := hmac.New(sha1.New, codeKey)
|
||||
_, _ = mac.Write(serialBytes)
|
||||
_, _ = mac.Write(challenge)
|
||||
signature := mac.Sum(nil)
|
||||
|
||||
// POST body is serial-ascii || HMAC signature.
|
||||
validateBody := make([]byte, 0, len(serialBytes)+len(signature))
|
||||
validateBody = append(validateBody, serialBytes...)
|
||||
validateBody = append(validateBody, signature...)
|
||||
|
||||
secret, err := bnetPostBinary(ctx, mobileURL(region)+bnetRestoreValidatePath, validateBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("battlenet: validate restore: %w", err)
|
||||
}
|
||||
if len(secret) != bnetRestoreSecretSize {
|
||||
return fmt.Errorf("battlenet: restore secret size %d, want %d",
|
||||
len(secret), bnetRestoreSecretSize)
|
||||
}
|
||||
|
||||
b.SecretKey = secret
|
||||
b.Serial = cleanSerial
|
||||
b.RestoreCodeVerified = true
|
||||
logger.WithField("serial", b.Serial).Info("paper restore succeeded")
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeBnetSerial strips spaces and dashes, upper-cases, and returns
|
||||
// the canonical form ("CCNNNNNNNNNNNN", 14 ASCII bytes when valid).
|
||||
func normalizeBnetSerial(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, "-", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
return s
|
||||
}
|
||||
|
||||
// normalizeBnetRestoreCode strips formatting whitespace / dashes and
|
||||
// upper-cases. The actual character-set validation happens in
|
||||
// decodeRestoreCode.
|
||||
func normalizeBnetRestoreCode(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, "-", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
return s
|
||||
}
|
||||
|
||||
// decodeRestoreCode is the inverse of restoreByteToChar applied 10
|
||||
// times in a row: each character maps back to one byte (low 5 bits
|
||||
// populated). The 10-byte buffer is what the protocol uses as the
|
||||
// HMAC-SHA1 key for the validate step.
|
||||
func decodeRestoreCode(code string) ([]byte, error) {
|
||||
if len(code) != bnetRestoreCodeLen {
|
||||
return nil, fmt.Errorf("battlenet: restore code must be %d characters", bnetRestoreCodeLen)
|
||||
}
|
||||
out := make([]byte, bnetRestoreCodeLen)
|
||||
for i := 0; i < bnetRestoreCodeLen; i++ {
|
||||
v, ok := restoreCharToByte(code[i])
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("battlenet: invalid character %q in restore code", code[i])
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// restoreCharToByte is the inverse of restoreByteToChar. The encoding
|
||||
// uses a 5-bit value: 0–9 → '0'–'9', 10–25 → 'A'..'Z' but skipping
|
||||
// I, L, O, S. We undo the skips to recover the original 5-bit value.
|
||||
func restoreCharToByte(c byte) (byte, bool) {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c - '0', true
|
||||
case c >= 'A' && c <= 'Z':
|
||||
// I, L, O, S are deliberately absent from the encoding alphabet
|
||||
// (visually similar to 1 / 1 / 0 / 5). Accepting them would map
|
||||
// to the wrong 5-bit value and silently corrupt the HMAC key.
|
||||
if c == 'I' || c == 'L' || c == 'O' || c == 'S' {
|
||||
return 0, false
|
||||
}
|
||||
v := int(c)
|
||||
if v >= 'T' {
|
||||
v--
|
||||
}
|
||||
if v >= 'P' {
|
||||
v--
|
||||
}
|
||||
if v >= 'M' {
|
||||
v--
|
||||
}
|
||||
if v >= 'J' {
|
||||
v--
|
||||
}
|
||||
v = v - 'A' + 10
|
||||
if v < 10 || v > 31 {
|
||||
return 0, false
|
||||
}
|
||||
return byte(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
Reference in New Issue
Block a user