c671f2115e
将原 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 桩实现。
381 lines
11 KiB
Go
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>"
|
|
}
|