Files
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

181 lines
5.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}