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:
2026-06-12 03:10:37 +08:00
commit c671f2115e
165 changed files with 10102 additions and 0 deletions
+180
View File
@@ -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: 09 → '0''9', 1025 → '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
}