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

381 lines
11 KiB
Go

package authenticator
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Steam mobileconf endpoints. Trade confirmations live on the
// community domain, not the WebAPI.
const (
steamMobileConfList = "/mobileconf/conf"
steamMobileConfDetails = "/mobileconf/details/"
steamMobileConfAjaxOp = "/mobileconf/ajaxop"
)
// Regexes ported verbatim from the C# SteamClient. The Steam mobile
// confirmations page is plain HTML and Valve has not provided a JSON
// alternative, so we have to scrape. Keep flags ((?is) = case-insensitive,
// dot matches newline) aligned with the C# Singleline|IgnoreCase combo.
var (
steamRegexTrades = regexp.MustCompile(`(?is)"mobileconf_list_entry"(.*?)>(.*?)"mobileconf_list_entry_sep"`)
steamRegexTradeConfID = regexp.MustCompile(`(?is)data-confid\s*=\s*"([^"]+)"`)
steamRegexTradeKey = regexp.MustCompile(`(?is)data-key\s*=\s*"([^"]+)"`)
steamRegexTradePlayer = regexp.MustCompile(`(?is)"mobileconf_list_entry_icon"(.*?)src="([^"]+)"`)
steamRegexTradeDetails = regexp.MustCompile(`(?is)"mobileconf_list_entry_description".*?<div>([^<]*)</div>[^<]*<div>([^<]*)</div>[^<]*<div>([^<]*)</div>[^<]*</div>`)
steamRegexConfDetails = regexp.MustCompile(`(?is)(.*<body[^>]*>\s*<div\s+class="[^"]+">).*(</div>.*?</body>\s*</html>)`)
)
// ErrSteamRequestInvalid is returned by ConfirmTrade / GetConfirmation*
// when Steam answers with a body that doesn't even parse as the expected
// success envelope. Lets the UI surface "try again" vs a hard failure.
var ErrSteamRequestInvalid = errors.New("steam: invalid response")
// confirmationsHTML / confirmationsQuery hold the last GetConfirmations
// result so GetConfirmationDetails can wrap individual detail HTML in
// the same outer body markup the user already trusts. They are NOT
// persisted — recomputed every poll.
//
// Storing them as private fields on SteamClient keeps the method
// signatures matching the C# code.
// GetConfirmations fetches the user's current pending trade / market
// confirmations. The bound authenticator MUST already have SteamData
// holding identity_secret, otherwise the request will be rejected.
//
// On success the returned slice describes each pending confirmation. As
// a side effect, if Session.Confirmations is non-nil its Ids set is
// updated and each returned Confirmation has IsNew populated.
func (c *SteamClient) GetConfirmations(ctx context.Context) ([]Confirmation, error) {
const fn = "internal.authenticator.SteamClient.GetConfirmations"
logger := global.Log.WithField("func", fn)
c.mu.Lock()
auth := c.Authenticator
c.mu.Unlock()
if auth == nil {
return nil, errors.New("steam: GetConfirmations without authenticator")
}
identitySecret, err := extractIdentitySecret(auth.SteamData)
if err != nil {
return nil, err
}
if identitySecret == "" {
return nil, errors.New("steam: identity_secret missing from SteamData")
}
c.mu.Lock()
serverTime := (NowMillis() + auth.ServerTimeDiff) / 1000
deviceID := auth.DeviceID
steamID := ""
if c.Session != nil {
steamID = c.Session.SteamId
}
c.mu.Unlock()
timehash, err := steamCreateTimeHash(serverTime, "conf", identitySecret)
if err != nil {
return nil, err
}
form := url.Values{
"p": {deviceID},
"a": {steamID},
"k": {timehash},
"t": {strconv.FormatInt(serverTime, 10)},
"m": {"android"},
"tag": {"conf"},
}
c.mu.Lock()
client := c.client
c.mu.Unlock()
html, err := steamRequest(ctx, client, http.MethodGet,
steamCommunityBase+steamMobileConfList, form, nil)
if err != nil {
return nil, fmt.Errorf("mobileconf/conf: %w", err)
}
c.mu.Lock()
c.confirmationsHTML = html
c.confirmationsQuery = form.Encode()
c.mu.Unlock()
trades := parseConfirmationsHTML(html)
// Maintain the poller's id-set: mark which trades are new and prune
// ids that are no longer pending. Mirrors the C# locked block.
c.mu.Lock()
if c.Session != nil && c.Session.Confirmations != nil {
if c.Session.Confirmations.Ids == nil {
c.Session.Confirmations.Ids = []string{}
}
known := make(map[string]bool, len(c.Session.Confirmations.Ids))
for _, id := range c.Session.Confirmations.Ids {
known[id] = true
}
seen := make(map[string]bool, len(trades))
for i := range trades {
seen[trades[i].Id] = true
if !known[trades[i].Id] {
trades[i].IsNew = true
c.Session.Confirmations.Ids = append(c.Session.Confirmations.Ids, trades[i].Id)
known[trades[i].Id] = true
}
}
// Drop ids that are no longer pending.
kept := c.Session.Confirmations.Ids[:0]
for _, id := range c.Session.Confirmations.Ids {
if seen[id] {
kept = append(kept, id)
}
}
c.Session.Confirmations.Ids = kept
}
c.mu.Unlock()
logger.WithField("count", len(trades)).Debug("fetched confirmations")
return trades, nil
}
// GetConfirmationDetails returns the HTML fragment Steam serves for the
// inner details of a single confirmation, wrapped in the outer markup
// captured by the most recent GetConfirmations call.
func (c *SteamClient) GetConfirmationDetails(ctx context.Context, trade Confirmation) (string, error) {
c.mu.Lock()
client := c.client
confHTML := c.confirmationsHTML
confQuery := c.confirmationsQuery
c.mu.Unlock()
if client == nil {
return "", errors.New("steam: client not initialised")
}
detailURL := steamCommunityBase + steamMobileConfDetails + url.PathEscape(trade.Id)
if confQuery != "" {
detailURL += "?" + confQuery
}
resp, err := steamRequest(ctx, client, http.MethodGet, detailURL, nil, nil)
if err != nil {
return "", fmt.Errorf("mobileconf/details: %w", err)
}
if !strings.Contains(resp, "success") {
return "", fmt.Errorf("%w: %s", ErrSteamRequestInvalid, resp)
}
var detail struct {
Success bool `json:"success"`
HTML string `json:"html"`
}
if err := json.Unmarshal([]byte(resp), &detail); err != nil {
return "", fmt.Errorf("mobileconf/details parse: %w", err)
}
if !detail.Success {
return fallbackDetailsHTML(), nil
}
if m := steamRegexConfDetails.FindStringSubmatch(confHTML); len(m) >= 3 {
return m[1] + detail.HTML + m[2], nil
}
return fallbackDetailsHTML(), nil
}
// ConfirmTrade accepts or rejects a single pending confirmation. Returns
// (true, nil) on success. (false, nil) means Steam answered with
// success=false; a non-nil error is a transport or parse failure.
func (c *SteamClient) ConfirmTrade(ctx context.Context, id, key string, accept bool) (bool, error) {
const fn = "internal.authenticator.SteamClient.ConfirmTrade"
logger := global.Log.WithField("func", fn)
c.mu.Lock()
auth := c.Authenticator
hasToken := c.Session != nil && c.Session.OAuthToken != ""
steamID := ""
if c.Session != nil {
steamID = c.Session.SteamId
}
client := c.client
c.mu.Unlock()
if !hasToken {
return false, nil
}
if auth == nil {
return false, errors.New("steam: ConfirmTrade without authenticator")
}
identitySecret, err := extractIdentitySecret(auth.SteamData)
if err != nil {
return false, err
}
if identitySecret == "" {
return false, errors.New("steam: identity_secret missing from SteamData")
}
serverTime := (NowMillis() + auth.ServerTimeDiff) / 1000
timehash, err := steamCreateTimeHash(serverTime, "conf", identitySecret)
if err != nil {
return false, err
}
op := "cancel"
if accept {
op = "allow"
}
form := url.Values{
"op": {op},
"p": {auth.DeviceID},
"a": {steamID},
"k": {timehash},
"t": {strconv.FormatInt(serverTime, 10)},
"m": {"android"},
"tag": {"conf"},
"cid": {id},
"ck": {key},
}
resp, err := steamRequest(ctx, client, http.MethodGet,
steamCommunityBase+steamMobileConfAjaxOp, form, nil)
if err != nil {
c.mu.Lock()
c.Error = err.Error()
c.mu.Unlock()
return false, err
}
if resp == "" {
c.mu.Lock()
c.Error = "Blank response"
c.mu.Unlock()
return false, nil
}
var parsed struct {
Success bool `json:"success"`
}
if err := json.Unmarshal([]byte(resp), &parsed); err != nil {
c.mu.Lock()
c.Error = "Failed"
c.mu.Unlock()
return false, nil
}
if !parsed.Success {
c.mu.Lock()
c.Error = "Failed"
c.mu.Unlock()
return false, nil
}
// Drop the id from the poller set so the next poll does not see it
// as still pending.
c.mu.Lock()
if c.Session != nil && c.Session.Confirmations != nil {
kept := c.Session.Confirmations.Ids[:0]
for _, x := range c.Session.Confirmations.Ids {
if x != id {
kept = append(kept, x)
}
}
c.Session.Confirmations.Ids = kept
}
c.mu.Unlock()
logger.WithField("op", op).WithField("id", id).Info("trade confirmation submitted")
return true, nil
}
// parseConfirmationsHTML extracts every <div class="mobileconf_list_entry">
// from the mobileconf/conf response. Empty result is a valid outcome
// (the user simply has no pending trades).
func parseConfirmationsHTML(html string) []Confirmation {
var trades []Confirmation
for _, m := range steamRegexTrades.FindAllStringSubmatch(html, -1) {
if len(m) < 3 {
continue
}
head, body := m[1], m[2]
var conf Confirmation
if cm := steamRegexTradeConfID.FindStringSubmatch(head); len(cm) >= 2 {
conf.Id = cm[1]
}
if km := steamRegexTradeKey.FindStringSubmatch(head); len(km) >= 2 {
conf.Key = km[1]
}
if pm := steamRegexTradePlayer.FindStringSubmatch(body); len(pm) >= 3 {
if strings.Contains(pm[1], "offline") {
conf.Offline = true
}
conf.Image = pm[2]
}
if dm := steamRegexTradeDetails.FindStringSubmatch(body); len(dm) >= 4 {
conf.Details = dm[1]
conf.Traded = dm[2]
conf.When = dm[3]
}
trades = append(trades, conf)
}
return trades
}
// extractIdentitySecret pulls the identity_secret string out of the
// authenticator's SteamData JSON envelope. Returns "" without error if
// SteamData is empty so callers can give the user a friendlier message.
func extractIdentitySecret(steamData string) (string, error) {
if strings.TrimSpace(steamData) == "" {
return "", nil
}
var parsed map[string]any
if err := json.Unmarshal([]byte(steamData), &parsed); err != nil {
return "", fmt.Errorf("steam: SteamData parse: %w", err)
}
if v, ok := parsed["identity_secret"].(string); ok {
return v, nil
}
return "", nil
}
// steamCreateTimeHash is the HMAC-SHA1 signature scheme Steam's mobile
// app uses to authenticate confirmation requests. The buffer is the
// 8-byte big-endian server time followed by up to 32 bytes of the tag
// string (UTF-8). The key is the base64-decoded identity_secret.
func steamCreateTimeHash(serverTime int64, tag, identitySecret string) (string, error) {
key, err := base64.StdEncoding.DecodeString(identitySecret)
if err != nil {
return "", fmt.Errorf("steam: identity_secret base64: %w", err)
}
tagLen := len(tag)
if tagLen > 32 {
tagLen = 32
}
buf := make([]byte, 8+tagLen)
binary.BigEndian.PutUint64(buf[:8], uint64(serverTime))
if tagLen > 0 {
copy(buf[8:], tag[:tagLen])
}
mac := hmac.New(sha1.New, key)
_, _ = mac.Write(buf)
return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
}
// fallbackDetailsHTML is the placeholder body the UI shows when Steam
// cannot or will not produce real details HTML. Identical text to the
// C# port to keep i18n / screenshots stable.
func fallbackDetailsHTML() string {
return "<html><head></head><body><p>Cannot load trade confirmation details</p></body></html>"
}