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) != "" }