Files
winauth-go/internal/authenticator/steam_enroll.go
T
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

478 lines
15 KiB
Go

package authenticator
import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// EnrollState carries the cross-call state of a Steam enrollment. The
// caller (UI) creates one, fills in Username/Password (and optionally
// CaptchaText / EmailAuthText / ActivationCode on retries), and calls
// SteamAuthenticator.Enroll repeatedly until Success == true or Error
// is set to a fatal message.
type EnrollState struct {
// Inputs supplied by the caller across multiple calls.
Username string
Password string
CaptchaID string
CaptchaURL string
CaptchaText string
EmailDomain string
EmailAuthText string
ActivationCode string
// Session state carried across calls. The cookie jar is internal to
// the http client; we cache it here so retries reuse it.
jar http.CookieJar
SteamID string
OAuthToken string
// Flags the caller inspects after each Enroll call to decide which
// extra input is needed next.
RequiresLogin bool
RequiresCaptcha bool
Requires2FA bool
RequiresEmailAuth bool
RequiresActivation bool
// Outputs populated once enrollment succeeds. RevocationCode MUST
// be displayed to the user — losing it locks them out of the
// authenticator removal flow.
RevocationCode string
SecretKey string // hex form, for convenience; raw is on the authenticator
Success bool
// Error is the last user-facing message. Cleared at the start of
// every Enroll call.
Error string
}
// enrollActivateRetries matches the C# ENROLL_ACTIVATE_RETRIES constant.
const enrollActivateRetries = 30
// invalidActivationCode is the response.status value Steam returns when
// the SMS code the user typed is wrong. Mirrors the C# constant.
const invalidActivationCode = 89
// rsaKeyResponse models the /mobilelogin/getrsakey response.
type rsaKeyResponse struct {
Success bool `json:"success"`
PublicKeyMod string `json:"publickey_mod"`
PublicKeyExp string `json:"publickey_exp"`
Timestamp string `json:"timestamp"`
}
// Enroll drives one step of the Steam mobile-authenticator enrollment
// state machine. Call it repeatedly with the same EnrollState until it
// returns (true, nil) or sets state.Error.
//
// Return value:
// - (true, nil) → enrollment fully complete; this authenticator now
// holds the new secret/serial/deviceid/steamdata.
// - (false, nil) → caller must inspect state.Requires* and supply the
// missing input (captcha text, email code, 2FA
// activation code) then call Enroll again.
// - (false, err) → unrecoverable transport / parse error.
//
// SECURITY: state.Password is wiped from the struct after a successful
// RSA-encrypted login round, so a subsequent retry (e.g. for activation
// code) does not keep the cleartext password resident.
func (s *SteamAuthenticator) Enroll(ctx context.Context, state *EnrollState) (bool, error) {
const fn = "internal.authenticator.SteamAuthenticator.Enroll"
logger := global.Log.WithField("func", fn)
state.Error = ""
if state.jar == nil {
jar, err := newSteamCookieJar()
if err != nil {
return false, err
}
state.jar = jar
}
client := steamHTTPClient(state.jar)
if state.OAuthToken == "" {
// One-time GET so the jar gets the sessionid cookie. Mirrors
// the C# "if cookies.Count == 0" branch.
if !steamJarHasSessionID(state.jar) {
headers := http.Header{"X-Requested-With": []string{"com.valvesoftware.android.steam.community"}}
_, err := steamRequest(ctx, client, http.MethodGet,
steamCommunityBase+"/mobilelogin?oauth_client_id="+steamOAuthClientID+
"&oauth_scope="+url.QueryEscape(steamOAuthScope), nil, headers)
if err != nil {
return false, fmt.Errorf("mobilelogin GET: %w", err)
}
}
state.Username = stripNonASCII(state.Username)
state.Password = stripNonASCII(state.Password)
rsaResp, err := steamRequest(ctx, client, http.MethodPost,
steamCommunityBase+"/mobilelogin/getrsakey",
url.Values{"username": {state.Username}}, nil)
if err != nil {
return false, fmt.Errorf("getrsakey: %w", err)
}
var rsaJSON rsaKeyResponse
if err := json.Unmarshal([]byte(rsaResp), &rsaJSON); err != nil {
return false, fmt.Errorf("getrsakey parse: %w", err)
}
if !rsaJSON.Success {
return false, errors.New("steam: cannot fetch RSA key for user")
}
encPw, err := steamRSAEncryptPassword(state.Password, rsaJSON.PublicKeyMod, rsaJSON.PublicKeyExp)
if err != nil {
return false, fmt.Errorf("rsa encrypt: %w", err)
}
captchaID := state.CaptchaID
if captchaID == "" {
captchaID = "-1"
}
captchaText := state.CaptchaText
if captchaText == "" {
captchaText = "enter above characters"
}
emailSteamID := ""
if state.EmailAuthText != "" {
emailSteamID = state.SteamID
}
loginForm := url.Values{
"password": {base64.StdEncoding.EncodeToString(encPw)},
"username": {state.Username},
"twofactorcode": {""},
"emailauth": {state.EmailAuthText},
"loginfriendlyname": {"#login_emailauth_friendlyname_mobile"},
"captchagid": {captchaID},
"captcha_text": {captchaText},
"emailsteamid": {emailSteamID},
"rsatimestamp": {rsaJSON.Timestamp},
"remember_login": {"false"},
"oauth_client_id": {steamOAuthClientID},
"oauth_scope": {steamOAuthScope},
"donotache": {strconv.FormatInt(time.Now().UnixMilli(), 10)},
}
loginResp, err := steamRequest(ctx, client, http.MethodPost,
steamCommunityBase+"/mobilelogin/dologin/", loginForm, nil)
if err != nil {
return false, fmt.Errorf("dologin: %w", err)
}
// Wipe the plaintext password from memory now that it has been
// RSA-encrypted and sent.
state.Password = ""
var login map[string]any
if err := json.Unmarshal([]byte(loginResp), &login); err != nil {
return false, fmt.Errorf("dologin parse: %w", err)
}
if v, ok := login["emailsteamid"].(string); ok {
state.SteamID = v
}
if b, _ := login["captcha_needed"].(bool); b {
state.RequiresCaptcha = true
if id, ok := login["captcha_gid"].(string); ok {
state.CaptchaID = id
state.CaptchaURL = steamCommunityBase + "/public/captcha.php?gid=" + id
}
} else {
state.RequiresCaptcha = false
state.CaptchaID = ""
state.CaptchaURL = ""
state.CaptchaText = ""
}
if b, _ := login["emailauth_needed"].(bool); b {
if d, ok := login["emaildomain"].(string); ok && d != "" {
state.EmailDomain = d
}
state.RequiresEmailAuth = true
} else {
state.EmailDomain = ""
state.RequiresEmailAuth = false
}
if b, _ := login["requires_twofactor"].(bool); b {
state.Requires2FA = true
} else {
state.Requires2FA = false
}
complete, _ := login["login_complete"].(bool)
oauthStr, _ := login["oauth"].(string)
if !complete || oauthStr == "" {
if oauthStr == "" {
state.Error = "Invalid response from Steam (No OAuth token)"
}
if msg, ok := login["message"].(string); ok && msg != "" {
state.Error = msg
}
return false, nil
}
// oauth is a JSON-stringified inner object.
var oauth struct {
OAuthToken string `json:"oauth_token"`
SteamID string `json:"steamid"`
}
if err := json.Unmarshal([]byte(oauthStr), &oauth); err != nil {
return false, fmt.Errorf("oauth parse: %w", err)
}
state.OAuthToken = oauth.OAuthToken
if oauth.SteamID != "" {
state.SteamID = oauth.SteamID
}
}
// Logon to WebAPI (needed for the ITwoFactorService calls below).
if _, err := steamRequest(ctx, client, http.MethodPost,
steamWebAPIBase+"/ISteamWebUserPresenceOAuth/Logon/v0001",
url.Values{"access_token": {state.OAuthToken}}, nil); err != nil {
return false, fmt.Errorf("ISteamWebUserPresenceOAuth/Logon: %w", err)
}
sessionID := steamJarSessionID(state.jar)
if !state.RequiresActivation {
// Phone check. No phone → cannot enroll.
phoneResp, err := steamRequest(ctx, client, http.MethodPost,
steamCommunityBase+"/steamguard/phoneajax",
url.Values{"op": {"has_phone"}, "arg": {"null"}, "sessionid": {sessionID}}, nil)
if err != nil {
return false, fmt.Errorf("phoneajax: %w", err)
}
var phoneJSON struct {
HasPhone bool `json:"has_phone"`
}
if err := json.Unmarshal([]byte(phoneResp), &phoneJSON); err != nil {
return false, fmt.Errorf("phoneajax parse: %w", err)
}
if !phoneJSON.HasPhone {
state.OAuthToken = ""
state.RequiresLogin = true
state.jar = nil
state.Error = "Your Steam account must have a SMS-capable phone number attached. Go into Account Details of the Steam client or Steam website and click Add a Phone Number."
return false, nil
}
deviceID := buildRandomDeviceID()
addResp, err := steamRequest(ctx, client, http.MethodPost,
steamWebAPIBase+"/ITwoFactorService/AddAuthenticator/v0001",
url.Values{
"access_token": {state.OAuthToken},
"steamid": {state.SteamID},
"authenticator_type": {"1"},
"device_identifier": {deviceID},
"sms_phone_id": {"1"},
}, nil)
if err != nil {
return false, fmt.Errorf("AddAuthenticator: %w", err)
}
// The C# branch on response.status == 84 = "SMS send failed".
var addJSON struct {
Response struct {
Status int `json:"status"`
SharedSecret string `json:"shared_secret"`
SerialNumber string `json:"serial_number"`
RevocationCode string `json:"revocation_code"`
ServerTime json.Number `json:"server_time"`
Raw json.RawMessage `json:"-"`
} `json:"response"`
}
if err := json.Unmarshal([]byte(addResp), &addJSON); err != nil {
return false, fmt.Errorf("AddAuthenticator parse: %w", err)
}
if addJSON.Response.Status == 84 {
state.OAuthToken = ""
state.RequiresLogin = true
state.jar = nil
state.Error = "Unable to send SMS. Check your phone is registered on your Steam account."
return false, nil
}
if addJSON.Response.SharedSecret == "" {
state.OAuthToken = ""
state.RequiresLogin = true
state.jar = nil
state.Error = "Invalid response from Steam"
return false, nil
}
secretRaw, err := base64.StdEncoding.DecodeString(addJSON.Response.SharedSecret)
if err != nil {
return false, fmt.Errorf("shared_secret decode: %w", err)
}
s.SecretKey = secretRaw
s.Serial = addJSON.Response.SerialNumber
s.DeviceID = deviceID
state.RevocationCode = addJSON.Response.RevocationCode
// Re-parse the response.* sub-object so we can preserve every
// field Steam returned and just inject steamid / steamguard_scheme
// if missing, matching the C# behaviour.
var envelope map[string]json.RawMessage
_ = json.Unmarshal([]byte(addResp), &envelope)
var steamDataMap map[string]any
if raw, ok := envelope["response"]; ok {
_ = json.Unmarshal(raw, &steamDataMap)
}
if steamDataMap == nil {
steamDataMap = map[string]any{}
}
if _, ok := steamDataMap["steamid"]; !ok {
steamDataMap["steamid"] = state.SteamID
}
if _, ok := steamDataMap["steamguard_scheme"]; !ok {
steamDataMap["steamguard_scheme"] = "2"
}
steamDataBytes, _ := json.Marshal(steamDataMap)
s.SteamData = string(steamDataBytes)
if addJSON.Response.ServerTime != "" {
if sec, err := strconv.ParseInt(string(addJSON.Response.ServerTime), 10, 64); err == nil {
s.ServerTimeDiff = sec*1000 - NowMillis()
s.LastServerTime = NowMillis()
}
}
state.RequiresActivation = true
logger.Info("authenticator added; awaiting SMS activation code")
return false, nil
}
// Activation: try up to ENROLL_ACTIVATE_RETRIES times. Between
// retries we slide ServerTimeDiff forward by one TOTP period to
// align with whichever step Steam is expecting.
retries := 0
for state.RequiresActivation && retries < enrollActivateRetries {
form := url.Values{
"access_token": {state.OAuthToken},
"steamid": {state.SteamID},
"activation_code": {state.ActivationCode},
"authenticator_code": {s.steamCode()},
"authenticator_time": {strconv.FormatInt(s.ServerTime()/1000, 10)},
}
finResp, err := steamRequest(ctx, client, http.MethodPost,
steamWebAPIBase+"/ITwoFactorService/FinalizeAddAuthenticator/v0001", form, nil)
if err != nil {
return false, fmt.Errorf("FinalizeAddAuthenticator: %w", err)
}
var fin struct {
Response struct {
Status int `json:"status"`
Success bool `json:"success"`
WantMore bool `json:"want_more"`
ServerTime json.Number `json:"server_time"`
} `json:"response"`
}
if err := json.Unmarshal([]byte(finResp), &fin); err != nil {
return false, fmt.Errorf("FinalizeAddAuthenticator parse: %w", err)
}
if fin.Response.Status == invalidActivationCode {
state.Error = "Invalid activation code"
return false, nil
}
if fin.Response.ServerTime != "" {
if sec, err := strconv.ParseInt(string(fin.Response.ServerTime), 10, 64); err == nil {
s.ServerTimeDiff = sec*1000 - NowMillis()
s.LastServerTime = NowMillis()
}
}
if fin.Response.Success {
if fin.Response.WantMore {
s.advanceServerTime()
retries++
continue
}
state.RequiresActivation = false
break
}
s.advanceServerTime()
retries++
}
if state.RequiresActivation {
state.Error = "There was a problem activating. There might be an issue with the Steam servers. Please try again later."
return false, nil
}
state.Success = true
state.SecretKey = strings.ToUpper(hex.EncodeToString(s.SecretKey))
// Best-effort confirmation email; failures are non-fatal because
// the authenticator is already activated.
_, _ = steamRequest(ctx, client, http.MethodPost,
steamWebAPIBase+"/ITwoFactorService/SendEmail/v0001",
url.Values{
"access_token": {state.OAuthToken},
"steamid": {state.SteamID},
"email_type": {"2"},
}, nil)
logger.WithField("serial", s.Serial).Info("steam authenticator enrolled")
return true, nil
}
// advanceServerTime nudges ServerTimeDiff forward by one TOTP period so
// the next FinalizeAddAuthenticator call submits a code for the next
// step. Matches the C# loop.
func (s *SteamAuthenticator) advanceServerTime() {
period := s.Period
if period <= 0 {
period = DefaultPeriod
}
s.ServerTimeDiff += int64(period) * 1000
}
// steamRSAEncryptPassword RSA-encrypts the password using the hex
// modulus / exponent returned by getrsakey. The original WinAuth uses
// RSACryptoServiceProvider.Encrypt(_, false) which is PKCS#1 v1.5.
func steamRSAEncryptPassword(password, hexMod, hexExp string) ([]byte, error) {
modBytes, err := hex.DecodeString(hexMod)
if err != nil {
return nil, fmt.Errorf("modulus hex: %w", err)
}
expBytes, err := hex.DecodeString(hexExp)
if err != nil {
return nil, fmt.Errorf("exponent hex: %w", err)
}
n := new(big.Int).SetBytes(modBytes)
e := new(big.Int).SetBytes(expBytes)
pub := &rsa.PublicKey{N: n, E: int(e.Int64())}
// PKCS#1 v1.5 is required by the Steam mobile-login endpoint;
// OAEP would be rejected. The deprecation warning is acknowledged.
return rsa.EncryptPKCS1v15(nil, pub, []byte(password))
}
// steamJarSessionID returns the "sessionid" cookie set by Steam on the
// community domain, or "" if it has not been issued yet.
func steamJarSessionID(jar http.CookieJar) string {
u, _ := url.Parse(steamCommunityBase + "/")
for _, c := range jar.Cookies(u) {
if c.Name == "sessionid" {
return c.Value
}
}
return ""
}
func steamJarHasSessionID(jar http.CookieJar) bool {
return steamJarSessionID(jar) != ""
}