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,380 @@
|
||||
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>"
|
||||
}
|
||||
Reference in New Issue
Block a user