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".*?
([^<]*)
[^<]*
([^<]*)
[^<]*
([^<]*)
[^<]*`) steamRegexConfDetails = regexp.MustCompile(`(?is)(.*]*>\s*).*(.*?\s*)`) ) // 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
// 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 "

Cannot load trade confirmation details

" }