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 桩实现。
478 lines
15 KiB
Go
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) != ""
|
|
}
|