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 桩实现。
This commit is contained in:
2026-06-12 03:10:37 +08:00
commit c671f2115e
165 changed files with 10102 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
package authenticator
import (
"sync"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Authenticator is the interface implemented by every concrete OTP type.
// It deliberately mirrors the surface of the C# Authenticator base class
// rather than introducing a wider/cleaner Go interface, so the port can
// stay close to the original.
type Authenticator interface {
// Name returns a short identifier for logging ("google", "battlenet", ...).
Name() string
// CurrentCode returns the OTP that should be shown to the user right now.
CurrentCode() (string, error)
// SecretData returns the serialized form of the secret + parameters,
// matching the field stored inside the original XML config under
// <secretdata>.
SecretData() string
// SetSecretData reverses SecretData, populating the receiver.
SetSecretData(value string) error
// Sync re-aligns the server time offset using whatever network call the
// concrete authenticator supports.
Sync() error
}
// Base holds fields shared by every concrete authenticator. Embed it in
// your subtype to inherit the field set and common helpers.
type Base struct {
SecretKey []byte
CodeDigits int
HMACType HMACType
Period int
ServerTimeDiff int64 // ms
LastServerTime int64 // ms (Unix-millis here, not .NET ticks)
mu sync.Mutex
}
// NewBase returns a Base seeded with the project-wide defaults.
func NewBase() Base {
return Base{
CodeDigits: DefaultCodeDigits,
HMACType: HMACSHA1,
Period: DefaultPeriod,
}
}
// NowMillis is the milliseconds since the Unix epoch, equivalent to the
// CurrentTime helper in the original C#.
func NowMillis() int64 { return time.Now().UnixMilli() }
// ServerTime returns the server's notion of "now" in milliseconds.
func (b *Base) ServerTime() int64 { return NowMillis() + b.ServerTimeDiff }
// CodeInterval returns the TOTP step number for the current server time.
func (b *Base) CodeInterval() uint64 {
if b.Period <= 0 {
b.Period = DefaultPeriod
}
return uint64(b.ServerTime() / int64(b.Period*1000))
}
// CalculateTOTP runs the TOTP algorithm using the receiver's fields.
func (b *Base) CalculateTOTP() string {
const fn = "internal.authenticator.Base.CalculateTOTP"
digits := b.CodeDigits
if digits == 0 {
digits = DefaultCodeDigits
}
code := hotpCode(b.SecretKey, b.CodeInterval(), digits, b.HMACType)
global.Log.WithField("func", fn).
WithField("interval", b.CodeInterval()).
WithField("digits", digits).
Trace("computed TOTP")
return code
}
+97
View File
@@ -0,0 +1,97 @@
package authenticator
import (
"fmt"
"regexp"
"strings"
)
// base32Alphabet is the RFC 4648 / 3548 base32 alphabet (no padding).
const base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
var (
base32EncodeTable [32]byte
base32DecodeTable [256]int8
base32CleanRE = regexp.MustCompile(`[\s-]+`)
base32PadRE = regexp.MustCompile(`=+$`)
)
func init() {
for i, c := range base32Alphabet {
base32EncodeTable[i] = byte(c)
}
for i := range base32DecodeTable {
base32DecodeTable[i] = -1
}
for i, c := range base32Alphabet {
base32DecodeTable[c] = int8(i)
}
}
// Base32Decode decodes a base32 string into bytes. Whitespace and dashes
// are stripped; trailing '=' padding is ignored; input is uppercased before
// decoding. This mirrors the lenient behavior of the original C# Base32 class.
func Base32Decode(encoded string) ([]byte, error) {
encoded = base32CleanRE.ReplaceAllString(encoded, "")
encoded = base32PadRE.ReplaceAllString(encoded, "")
encoded = strings.ToUpper(encoded)
if encoded == "" {
return []byte{}, nil
}
const shift = 5
const mask = 0x1F
outLen := len(encoded) * shift / 8
out := make([]byte, outLen)
var buffer int
var bitsLeft int
var next int
for _, c := range encoded {
if c >= 256 || base32DecodeTable[c] < 0 {
return nil, fmt.Errorf("base32: illegal character %q", c)
}
buffer <<= shift
buffer |= int(base32DecodeTable[c]) & mask
bitsLeft += shift
if bitsLeft >= 8 {
out[next] = byte(buffer >> (bitsLeft - 8))
next++
bitsLeft -= 8
}
}
return out, nil
}
// Base32Encode encodes raw bytes as base32 with no padding.
func Base32Encode(data []byte) string {
if len(data) == 0 {
return ""
}
const shift = 5
const mask = 0x1F
var sb strings.Builder
buffer := int(data[0])
next := 1
bitsLeft := 8
for bitsLeft > 0 || next < len(data) {
if bitsLeft < shift {
if next < len(data) {
buffer <<= 8
buffer |= int(data[next]) & 0xFF
next++
bitsLeft += 8
} else {
pad := shift - bitsLeft
buffer <<= pad
bitsLeft += pad
}
}
index := mask & (buffer >> (bitsLeft - shift))
bitsLeft -= shift
sb.WriteByte(base32EncodeTable[index])
}
return sb.String()
}
+389
View File
@@ -0,0 +1,389 @@
package authenticator
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"math/big"
mrand "math/rand"
"net/http"
"strings"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/httpc"
)
// Battle.Net mobile-service URLs by region. Matches the original C# table.
var battlenetURLs = map[string]string{
"US": "http://mobile-service.blizzard.com",
"EU": "http://mobile-service.blizzard.com",
"KR": "http://mobile-service.blizzard.com",
"CN": "http://mobile-service.battlenet.com.cn",
}
const (
bnetEnrollPath = "/enrollment/enroll2.htm"
bnetSyncPath = "/enrollment/time.htm"
bnetRestorePath = "/enrollment/initiatePaperRestore.htm"
bnetRestoreValidatePath = "/enrollment/validatePaperRestore.htm"
bnetCodeDigits = 8
bnetModelSize = 16
bnetModelChars = " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01234567890"
bnetEnrollRespSz = 45
bnetSyncRespSz = 8
bnetEnrollModulus = "955e4bd989f3917d2f15544a7e0504eb9d7bb66b6f8a2fe470e453c779200e5e" +
"3ad2e43a02d06c4adbd8d328f1a426b83658e88bfd949b2af4eaf30054673a14" +
"19a250fa4cc1278d12855b5b25818d162c6e6ee2ab4a350d401d78f6ddb99711" +
"e72626b48bd8b5b0b7f3acf9ea3c9e0005fee59e19136cdb7c83f2ab8b0a2a99"
bnetEnrollExponent = "0101"
)
// BattleNetAuthenticator is the Go port of the C# BattleNetAuthenticator.
// It supports Enroll, Sync, Restore (paper restore), and the customary
// 8-digit TOTP code generation.
type BattleNetAuthenticator struct {
Base
Serial string
RestoreCodeVerified bool
}
// NewBattleNetAuthenticator returns an empty 8-digit Battle.Net authenticator.
func NewBattleNetAuthenticator() *BattleNetAuthenticator {
b := &BattleNetAuthenticator{Base: NewBase()}
b.CodeDigits = bnetCodeDigits
return b
}
// Name returns the short logger tag for this type.
func (b *BattleNetAuthenticator) Name() string { return "battlenet" }
// Region returns the two-letter region prefix derived from the serial.
func (b *BattleNetAuthenticator) Region() string {
if len(b.Serial) >= 2 {
return strings.ToUpper(b.Serial[:2])
}
return ""
}
// CurrentCode returns the live 8-digit Battle.Net authenticator code.
func (b *BattleNetAuthenticator) CurrentCode() (string, error) {
if b.SecretKey == nil {
return "", fmt.Errorf("battlenet: no secret loaded")
}
return b.CalculateTOTP(), nil
}
// SecretData / SetSecretData persist both the secret and the serial number,
// matching the C# format "<basesecret>|<utf8-hex-serial>".
func (b *BattleNetAuthenticator) SecretData() string {
return b.EncodeSecretData() + "|" + strings.ToUpper(hex.EncodeToString([]byte(b.Serial)))
}
func (b *BattleNetAuthenticator) SetSecretData(value string) error {
if value == "" {
b.SecretKey = nil
b.Serial = ""
return nil
}
parts := strings.Split(value, "|")
if len(parts) == 1 {
// legacy WinAuth2 form: 40 hex chars secret then UTF8-hex serial
if len(value) < 40 {
return fmt.Errorf("battlenet: secret data too short")
}
raw, err := hex.DecodeString(value[:40])
if err != nil {
return fmt.Errorf("battlenet: bad secret hex: %w", err)
}
b.SecretKey = raw
serialBytes, err := hex.DecodeString(value[40:])
if err == nil {
b.Serial = string(serialBytes)
}
return nil
}
if err := b.DecodeSecretData(parts[0]); err != nil {
return err
}
serialIdx := 1
if len(parts) == 3 {
// alpha 3.0.6 form: secret|script|serial
serialIdx = 2
}
if len(parts) > serialIdx {
raw, err := hex.DecodeString(parts[serialIdx])
if err == nil {
b.Serial = string(raw)
}
}
return nil
}
// Enroll registers a brand-new Battle.Net authenticator with the Blizzard
// mobile service. Country is auto-detected via Wikimedia GEO IP; pass an
// explicit two-letter override to skip the lookup.
func (b *BattleNetAuthenticator) Enroll(ctx context.Context, countryOverride string) error {
const fn = "internal.authenticator.BattleNetAuthenticator.Enroll"
logger := global.Log.WithField("func", fn)
country := strings.ToUpper(strings.TrimSpace(countryOverride))
region := "US"
if country == "" {
c, r := lookupRegion(ctx)
country, region = c, r
} else {
region = regionForCountry(country)
}
logger.WithField("country", country).WithField("region", region).Debug("region determined")
otp := mustOneTimePad(20)
payload := make([]byte, 38)
copy(payload[0:20], otp)
copy(payload[20:22], []byte(country))
copy(payload[22:38], []byte(randomModel()))
encrypted, err := rsaEncryptRaw(payload)
if err != nil {
return fmt.Errorf("battlenet: rsa encrypt failed: %w", err)
}
respBytes, err := bnetPostBinary(ctx, mobileURL(region)+bnetEnrollPath, encrypted)
if err != nil {
return err
}
if len(respBytes) != bnetEnrollRespSz {
return fmt.Errorf("battlenet: enroll response size %d, want %d", len(respBytes), bnetEnrollRespSz)
}
serverMs := int64(binary.BigEndian.Uint64(respBytes[0:8]))
b.ServerTimeDiff = serverMs - NowMillis()
secret := make([]byte, 20)
copy(secret, respBytes[25:45])
for i := range secret {
secret[i] ^= otp[i]
}
b.SecretKey = secret
b.Serial = string(respBytes[8:25])
logger.WithField("serial", b.Serial).Info("enrolled with Battle.Net mobile service")
return nil
}
// Sync re-aligns ServerTimeDiff against the mobile service for our region.
func (b *BattleNetAuthenticator) Sync() error {
const fn = "internal.authenticator.BattleNetAuthenticator.Sync"
logger := global.Log.WithField("func", fn).WithField("region", b.Region())
if b.SecretKey == nil {
logger.Debug("skip sync: no secret loaded")
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
url := mobileURL(b.Region()) + bnetSyncPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := httpc.New().Do(req)
if err != nil {
logger.WithError(err).Warn("sync failed; using local clock")
return nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if len(body) != bnetSyncRespSz {
return fmt.Errorf("battlenet: sync response size %d, want %d", len(body), bnetSyncRespSz)
}
serverMs := int64(binary.BigEndian.Uint64(body))
b.ServerTimeDiff = serverMs - NowMillis()
b.LastServerTime = NowMillis()
logger.WithField("offset_ms", b.ServerTimeDiff).Debug("clock synced")
return nil
}
// RestoreCode computes the 10-char Battle.Net restore code derived from
// the SHA1 of (serial || secretKey).
func (b *BattleNetAuthenticator) RestoreCode() string {
if b.Serial == "" || b.SecretKey == nil {
return ""
}
serial := strings.ReplaceAll(strings.ToUpper(b.Serial), "-", "")
hash := sha1.Sum(append([]byte(serial), b.SecretKey...))
out := make([]byte, 10)
for i := 0; i < 10; i++ {
out[i] = restoreByteToChar(hash[len(hash)-10+i])
}
return string(out)
}
// helpers ------------------------------------------------------------------
func mobileURL(region string) string {
region = strings.ToUpper(region)
if len(region) > 2 {
region = region[:2]
}
if u, ok := battlenetURLs[region]; ok {
return u
}
return battlenetURLs["US"]
}
func regionForCountry(country string) string {
switch country {
case "CN":
return "CN"
case "KR", "KP", "TW", "HK", "MO":
return "KR"
}
euCountries := []string{
"AL", "AD", "AM", "AT", "AZ", "BY", "BE", "BA", "BG", "HR",
"CY", "CZ", "DK", "EE", "FI", "FR", "GE", "DE", "GR", "HU",
"IS", "IE", "IT", "KV", "XK", "LV", "LI", "LT", "LU", "MK",
"MT", "MD", "MC", "ME", "NL", "NO", "PL", "PT", "RO", "RU",
"SM", "RS", "SK", "ES", "SE", "CH", "TR", "UA", "UK", "GB",
"VA",
}
for _, c := range euCountries {
if c == country {
return "EU"
}
}
return "US"
}
func lookupRegion(ctx context.Context) (country, region string) {
const fn = "internal.authenticator.lookupRegion"
logger := global.Log.WithField("func", fn)
geoCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(geoCtx, http.MethodGet, "http://geoiplookup.wikimedia.org", nil)
resp, err := httpc.New().Do(req)
if err != nil {
logger.WithError(err).Debug("geoip lookup failed; defaulting to US")
return "US", "US"
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// Cheap regex-free parse for "country":"XX"
const key = `"country":"`
idx := strings.Index(string(body), key)
if idx < 0 {
return "US", "US"
}
rest := string(body)[idx+len(key):]
end := strings.Index(rest, `"`)
if end < 0 {
return "US", "US"
}
country = strings.ToUpper(rest[:end])
return country, regionForCountry(country)
}
func bnetPostBinary(ctx context.Context, url string, body []byte) ([]byte, error) {
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := httpc.New().Do(req)
if err != nil {
return nil, fmt.Errorf("battlenet: contact mobile service: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("battlenet: server returned %d %s", resp.StatusCode, resp.Status)
}
return io.ReadAll(resp.Body)
}
func rsaEncryptRaw(data []byte) ([]byte, error) {
n, ok := new(big.Int).SetString(bnetEnrollModulus, 16)
if !ok {
return nil, fmt.Errorf("invalid RSA modulus")
}
e, ok := new(big.Int).SetString(bnetEnrollExponent, 16)
if !ok {
return nil, fmt.Errorf("invalid RSA exponent")
}
pub := &rsa.PublicKey{N: n, E: int(e.Int64())}
// The original C# uses BouncyCastle's RsaEngine.ProcessBlock without
// padding (raw RSA / "no padding"). We emulate that by padding the input
// to modulus length with leading zeros and using big.Int exponentiation.
keySize := (pub.N.BitLen() + 7) / 8
if len(data) > keySize {
return nil, fmt.Errorf("data too large for raw RSA")
}
m := new(big.Int).SetBytes(data)
c := new(big.Int).Exp(m, big.NewInt(int64(pub.E)), pub.N)
out := make([]byte, keySize)
cBytes := c.Bytes()
copy(out[keySize-len(cBytes):], cBytes)
return out, nil
}
func mustOneTimePad(n int) []byte {
out := make([]byte, n)
if _, err := rand.Read(out); err != nil {
panic(err)
}
return out
}
func randomModel() string {
var seedBytes [8]byte
_, _ = rand.Read(seedBytes[:])
r := mrand.New(mrand.NewSource(int64(binary.LittleEndian.Uint64(seedBytes[:]))))
out := make([]byte, bnetModelSize)
for i := range out {
out[i] = bnetModelChars[r.Intn(len(bnetModelChars))]
}
return string(out)
}
func restoreByteToChar(b byte) byte {
// Mirror C# ConvertRestoreCodeByteToChar — but we only need the inverse
// for code *display*. The original algorithm maps each byte to an alpha
// or numeric, skipping I, L, O, S to avoid ambiguity. Below is a direct
// translation of the C# code path that lives at the bottom of
// BattleNetAuthenticator.cs (ConvertRestoreCodeByteToChar).
v := int(b) & 0x1F // 5 bits
switch {
case v < 10:
return byte('0' + v)
}
c := v - 10 + 'A'
if c >= 'I' {
c++
}
if c >= 'L' {
c++
}
if c >= 'O' {
c++
}
if c >= 'S' {
c++
}
return byte(c)
}
+180
View File
@@ -0,0 +1,180 @@
package authenticator
import (
"context"
"crypto/hmac"
"crypto/sha1"
"fmt"
"strings"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Battle.Net paper-restore protocol constants. The server responds with
// a fixed 32-byte challenge to the initiate POST, and a fixed 20-byte
// secret to the validate POST. Mismatching sizes are treated as fatal
// since the binary protocol has no error envelope.
const (
bnetRestoreChallengeSize = 32
bnetRestoreSecretSize = 20
bnetSerialDigits = 14 // CC-NNNN-NNNN-NNNN after stripping dashes
bnetRestoreCodeLen = 10
)
// Restore recovers an existing Battle.Net authenticator's secret key
// using the 10-character paper restore code the user wrote down when
// they first enrolled.
//
// The wire protocol mirrors the original WinAuth implementation:
//
// 1. POST <serial-ascii-bytes> → /enrollment/initiatePaperRestore.htm
// ← 32-byte challenge
// 2. HMAC-SHA1(key = restoreCode-decoded-10-bytes,
// data = serial-bytes || challenge) → 20-byte signature
// 3. POST <serial || signature> → /enrollment/validatePaperRestore.htm
// ← 20-byte secret (the new SecretKey)
//
// SECURITY: restoreCode grants full account control if leaked. We do not
// log it, never persist it, and wipe the derived 10-byte key buffer
// before returning. The caller's restoreCode string is the caller's
// responsibility to manage.
func (b *BattleNetAuthenticator) Restore(ctx context.Context, serial, restoreCode string) error {
const fn = "internal.authenticator.BattleNetAuthenticator.Restore"
logger := global.Log.WithField("func", fn)
cleanSerial := normalizeBnetSerial(serial)
if len(cleanSerial) < bnetSerialDigits {
return fmt.Errorf("battlenet: serial must contain %d digits after the region prefix", bnetSerialDigits)
}
region := cleanSerial[:2]
if _, ok := battlenetURLs[region]; !ok {
return fmt.Errorf("battlenet: unknown region %q in serial", region)
}
logger.WithField("region", region).Debug("starting paper restore")
cleanCode := normalizeBnetRestoreCode(restoreCode)
if len(cleanCode) != bnetRestoreCodeLen {
return fmt.Errorf("battlenet: restore code must be %d characters", bnetRestoreCodeLen)
}
codeKey, err := decodeRestoreCode(cleanCode)
if err != nil {
return err
}
// Zero the derived key on return so it does not linger in stack/heap
// after the HMAC call has consumed it.
defer func() {
for i := range codeKey {
codeKey[i] = 0
}
}()
serialBytes := []byte(cleanSerial)
challenge, err := bnetPostBinary(ctx, mobileURL(region)+bnetRestorePath, serialBytes)
if err != nil {
return fmt.Errorf("battlenet: initiate restore: %w", err)
}
if len(challenge) != bnetRestoreChallengeSize {
return fmt.Errorf("battlenet: restore challenge size %d, want %d",
len(challenge), bnetRestoreChallengeSize)
}
mac := hmac.New(sha1.New, codeKey)
_, _ = mac.Write(serialBytes)
_, _ = mac.Write(challenge)
signature := mac.Sum(nil)
// POST body is serial-ascii || HMAC signature.
validateBody := make([]byte, 0, len(serialBytes)+len(signature))
validateBody = append(validateBody, serialBytes...)
validateBody = append(validateBody, signature...)
secret, err := bnetPostBinary(ctx, mobileURL(region)+bnetRestoreValidatePath, validateBody)
if err != nil {
return fmt.Errorf("battlenet: validate restore: %w", err)
}
if len(secret) != bnetRestoreSecretSize {
return fmt.Errorf("battlenet: restore secret size %d, want %d",
len(secret), bnetRestoreSecretSize)
}
b.SecretKey = secret
b.Serial = cleanSerial
b.RestoreCodeVerified = true
logger.WithField("serial", b.Serial).Info("paper restore succeeded")
return nil
}
// normalizeBnetSerial strips spaces and dashes, upper-cases, and returns
// the canonical form ("CCNNNNNNNNNNNN", 14 ASCII bytes when valid).
func normalizeBnetSerial(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "-", "")
s = strings.ReplaceAll(s, " ", "")
return s
}
// normalizeBnetRestoreCode strips formatting whitespace / dashes and
// upper-cases. The actual character-set validation happens in
// decodeRestoreCode.
func normalizeBnetRestoreCode(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "-", "")
s = strings.ReplaceAll(s, " ", "")
return s
}
// decodeRestoreCode is the inverse of restoreByteToChar applied 10
// times in a row: each character maps back to one byte (low 5 bits
// populated). The 10-byte buffer is what the protocol uses as the
// HMAC-SHA1 key for the validate step.
func decodeRestoreCode(code string) ([]byte, error) {
if len(code) != bnetRestoreCodeLen {
return nil, fmt.Errorf("battlenet: restore code must be %d characters", bnetRestoreCodeLen)
}
out := make([]byte, bnetRestoreCodeLen)
for i := 0; i < bnetRestoreCodeLen; i++ {
v, ok := restoreCharToByte(code[i])
if !ok {
return nil, fmt.Errorf("battlenet: invalid character %q in restore code", code[i])
}
out[i] = v
}
return out, nil
}
// restoreCharToByte is the inverse of restoreByteToChar. The encoding
// uses a 5-bit value: 09 → '0''9', 1025 → 'A'..'Z' but skipping
// I, L, O, S. We undo the skips to recover the original 5-bit value.
func restoreCharToByte(c byte) (byte, bool) {
switch {
case c >= '0' && c <= '9':
return c - '0', true
case c >= 'A' && c <= 'Z':
// I, L, O, S are deliberately absent from the encoding alphabet
// (visually similar to 1 / 1 / 0 / 5). Accepting them would map
// to the wrong 5-bit value and silently corrupt the HMAC key.
if c == 'I' || c == 'L' || c == 'O' || c == 'S' {
return 0, false
}
v := int(c)
if v >= 'T' {
v--
}
if v >= 'P' {
v--
}
if v >= 'M' {
v--
}
if v >= 'J' {
v--
}
v = v - 'A' + 10
if v < 10 || v > 31 {
return 0, false
}
return byte(v), true
}
return 0, false
}
@@ -0,0 +1,66 @@
package authenticator
import (
"testing"
)
// TestRestoreCodeRoundTrip verifies that restoreCharToByte exactly
// inverts restoreByteToChar across every 5-bit value the encoding
// produces. A regression here would silently corrupt the HMAC key the
// Restore flow sends to Blizzard, so the round-trip is the cheapest
// possible safety net.
func TestRestoreCodeRoundTrip(t *testing.T) {
for v := 0; v < 32; v++ {
c := restoreByteToChar(byte(v))
got, ok := restoreCharToByte(c)
if !ok {
t.Fatalf("v=%d encoded as %q but failed to decode", v, c)
}
if int(got) != v {
t.Fatalf("v=%d → %q → %d (want %d)", v, c, got, v)
}
}
}
// TestRestoreCharToByteRejectsAmbiguous confirms that the four letters
// deliberately omitted from the Battle.Net restore alphabet (I, L, O,
// S) are rejected on decode. A user typing "1" instead of "I" should
// land on the "1" branch; "I" should be a hard error rather than a
// silent misdecode.
func TestRestoreCharToByteRejectsAmbiguous(t *testing.T) {
for _, c := range []byte{'I', 'L', 'O', 'S'} {
if _, ok := restoreCharToByte(c); ok {
t.Errorf("char %q must not decode", c)
}
}
}
// TestDecodeRestoreCodeLength sanity-checks the length validator.
func TestDecodeRestoreCodeLength(t *testing.T) {
if _, err := decodeRestoreCode("ABCDEFGHI"); err == nil {
t.Error("expected error for 9-char input")
}
if _, err := decodeRestoreCode("ABCDEFGHIJK"); err == nil {
t.Error("expected error for 11-char input")
}
// 10 valid characters
if _, err := decodeRestoreCode("ABCDEFGHJK"); err != nil {
t.Errorf("unexpected error for 10-char input: %v", err)
}
}
// TestNormalizeBnetSerial verifies the user-friendly formats (dashes
// and spaces, mixed case) all collapse to the protocol-required form.
func TestNormalizeBnetSerial(t *testing.T) {
cases := map[string]string{
"us-1234-5678-9012": "US123456789012",
"US-1234-5678-9012": "US123456789012",
" US 1234 5678 9012": "US123456789012",
"us123456789012": "US123456789012",
}
for in, want := range cases {
if got := normalizeBnetSerial(in); got != want {
t.Errorf("normalizeBnetSerial(%q) = %q, want %q", in, got, want)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
package authenticator
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/httpc"
)
// GoogleAuthenticator implements the time-sync flavor of TOTP that Google,
// Microsoft and Okta all share. The only difference between vendors in the
// original C# port is the URL used to learn the server clock.
type GoogleAuthenticator struct {
Base
timeSyncURL string
}
// NewGoogleAuthenticator returns a Google-flavored TOTP authenticator.
func NewGoogleAuthenticator() *GoogleAuthenticator {
return newTOTP("https://www.google.com")
}
// NewMicrosoftAuthenticator is an alias kept for parity with the original
// C# class hierarchy.
func NewMicrosoftAuthenticator() *GoogleAuthenticator {
return newTOTP("https://www.microsoft.com")
}
// NewOktaVerifyAuthenticator returns a TOTP that syncs against okta.com.
func NewOktaVerifyAuthenticator() *GoogleAuthenticator {
return newTOTP("https://www.okta.com")
}
func newTOTP(syncURL string) *GoogleAuthenticator {
g := &GoogleAuthenticator{Base: NewBase(), timeSyncURL: syncURL}
return g
}
// Name returns a short identifier used in logs.
func (g *GoogleAuthenticator) Name() string {
switch g.timeSyncURL {
case "https://www.microsoft.com":
return "microsoft"
case "https://www.okta.com":
return "okta"
default:
return "google"
}
}
// Enroll loads a base32-encoded shared secret and then performs an initial
// clock sync against the vendor's HTTP endpoint.
func (g *GoogleAuthenticator) Enroll(b32 string) error {
const fn = "internal.authenticator.GoogleAuthenticator.Enroll"
raw, err := Base32Decode(b32)
if err != nil {
return err
}
g.SecretKey = raw
global.Log.WithField("func", fn).WithField("len", len(raw)).Debug("enrolled secret")
return g.Sync()
}
// CurrentCode returns the live TOTP for the receiver.
func (g *GoogleAuthenticator) CurrentCode() (string, error) {
if g.SecretKey == nil {
return "", fmt.Errorf("authenticator: no secret loaded")
}
return g.CalculateTOTP(), nil
}
// SecretData / SetSecretData delegate to the embedded Base.
func (g *GoogleAuthenticator) SecretData() string { return g.EncodeSecretData() }
func (g *GoogleAuthenticator) SetSecretData(value string) error { return g.DecodeSecretData(value) }
// Sync issues a HEAD request against the configured vendor URL and reads
// the response's Date header to derive ServerTimeDiff. Errors are swallowed
// in the same way as the original C# implementation — repeated failures
// should not block code generation, the local clock is the fallback.
func (g *GoogleAuthenticator) Sync() error {
const fn = "internal.authenticator.GoogleAuthenticator.Sync"
logger := global.Log.WithField("func", fn).WithField("url", g.timeSyncURL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodHead, g.timeSyncURL, nil)
if err != nil {
logger.WithError(err).Warn("build request failed")
return err
}
resp, err := httpc.New().Do(req)
if err != nil {
logger.WithError(err).Warn("sync request failed; using local clock")
return nil
}
defer resp.Body.Close()
dateStr := strings.TrimSpace(resp.Header.Get("Date"))
if dateStr == "" {
logger.Warn("response missing Date header")
return nil
}
t, err := http.ParseTime(dateStr)
if err != nil {
logger.WithError(err).Warn("invalid Date header")
return nil
}
serverMs := t.UnixMilli()
g.ServerTimeDiff = serverMs - NowMillis()
g.LastServerTime = NowMillis()
logger.WithField("offset_ms", g.ServerTimeDiff).Debug("clock synced")
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package authenticator
import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/binary"
"fmt"
"hash"
)
// hmacFor returns a fresh HMAC keyed with secret for the given hash type.
func hmacFor(h HMACType, secret []byte) hash.Hash {
switch h {
case HMACSHA256:
return hmac.New(sha256.New, secret)
case HMACSHA512:
return hmac.New(sha512.New, secret)
default:
return hmac.New(sha1.New, secret)
}
}
// hotpCode computes the RFC 4226 HOTP code for the given (secret, counter)
// pair, with the supplied number of decimal digits and HMAC algorithm.
func hotpCode(secret []byte, counter uint64, digits int, h HMACType) string {
mac := hmacFor(h, secret)
var counterBytes [8]byte
binary.BigEndian.PutUint64(counterBytes[:], counter)
_, _ = mac.Write(counterBytes[:])
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0F
truncated := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7FFFFFFF
mod := uint32(1)
for i := 0; i < digits; i++ {
mod *= 10
}
return fmt.Sprintf("%0*d", digits, truncated%mod)
}
@@ -0,0 +1,74 @@
package authenticator
import (
"fmt"
"strconv"
"strings"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// HOTPAuthenticator implements RFC 4226 counter-based HOTP.
type HOTPAuthenticator struct {
Base
Counter uint64
}
// NewHOTPAuthenticator returns a fresh HOTP authenticator with the project
// defaults (6 digits, SHA1).
func NewHOTPAuthenticator() *HOTPAuthenticator {
return &HOTPAuthenticator{Base: NewBase()}
}
// Name returns the short logger tag for this type.
func (h *HOTPAuthenticator) Name() string { return "hotp" }
// Enroll loads the secret from a base32 string and optionally seeds the
// counter.
func (h *HOTPAuthenticator) Enroll(b32 string, counter uint64) error {
const fn = "internal.authenticator.HOTPAuthenticator.Enroll"
raw, err := Base32Decode(b32)
if err != nil {
return err
}
h.SecretKey = raw
h.Counter = counter
global.Log.WithField("func", fn).WithField("counter", counter).Debug("enrolled HOTP")
return nil
}
// CurrentCode increments the internal counter and returns the resulting code.
func (h *HOTPAuthenticator) CurrentCode() (string, error) {
if h.SecretKey == nil {
return "", fmt.Errorf("authenticator: no secret loaded")
}
h.Counter++
digits := h.CodeDigits
if digits == 0 {
digits = DefaultCodeDigits
}
return hotpCode(h.SecretKey, h.Counter, digits, h.HMACType), nil
}
// Sync is a no-op for HOTP — there is no server clock to align against.
func (h *HOTPAuthenticator) Sync() error { return nil }
// SecretData appends "|<counter>" to the base secret data string, matching
// the C# HOTPAuthenticator.SecretData getter.
func (h *HOTPAuthenticator) SecretData() string {
return h.EncodeSecretData() + "|" + strconv.FormatUint(h.Counter, 10)
}
// SetSecretData parses the "<base>|<counter>" form.
func (h *HOTPAuthenticator) SetSecretData(value string) error {
if err := h.DecodeSecretData(value); err != nil {
return err
}
if idx := strings.Index(value, "|"); idx >= 0 {
c, err := strconv.ParseUint(strings.TrimSpace(value[idx+1:]), 10, 64)
if err == nil {
h.Counter = c
}
}
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package authenticator
import (
"encoding/hex"
"fmt"
"strconv"
"strings"
)
// EncodeSecretData encodes the "<key>\t<digits>\t<hmac>\t<period>" string
// that the original WinAuth config uses inside <secretdata>.
func (b *Base) EncodeSecretData() string {
return fmt.Sprintf(
"%s\t%d\t%s\t%d",
strings.ToUpper(hex.EncodeToString(b.SecretKey)),
b.CodeDigits, b.HMACType.String(), b.Period,
)
}
// DecodeSecretData parses the value produced by EncodeSecretData (and
// optionally a "|"-suffixed payload for HOTP subclasses).
func (b *Base) DecodeSecretData(value string) error {
if value == "" {
b.SecretKey = nil
return nil
}
head := strings.SplitN(value, "|", 2)[0]
parts := strings.Split(head, "\t")
if len(parts) == 0 {
return fmt.Errorf("authenticator: empty secret data")
}
raw, err := hex.DecodeString(parts[0])
if err != nil {
return fmt.Errorf("authenticator: bad secret hex: %w", err)
}
b.SecretKey = raw
if len(parts) > 1 {
if d, err := strconv.Atoi(parts[1]); err == nil {
b.CodeDigits = d
}
}
if len(parts) > 2 {
switch strings.ToUpper(parts[2]) {
case "SHA256":
b.HMACType = HMACSHA256
case "SHA512":
b.HMACType = HMACSHA512
default:
b.HMACType = HMACSHA1
}
}
if len(parts) > 3 {
if p, err := strconv.Atoi(parts[3]); err == nil && p > 0 {
b.Period = p
}
}
return nil
}
+177
View File
@@ -0,0 +1,177 @@
package authenticator
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/httpc"
)
// steamChars is the alphanumeric alphabet that Steam Guard maps the
// truncated HMAC into. It deliberately omits visually similar characters.
var steamChars = []byte{
'2', '3', '4', '5', '6', '7', '8', '9', 'B', 'C',
'D', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q',
'R', 'T', 'V', 'W', 'X', 'Y',
}
const (
steamCodeDigits = 5
steamQueryTime = "https://api.steampowered.com:443/ITwoFactorService/QueryTime/v0001"
)
// SteamAuthenticator implements Steam Guard's variant of TOTP. Full
// enrollment / login / session handling will be added in a later phase;
// this file covers code generation, time sync, and persistence — enough
// for an already-enrolled authenticator imported from the original WinAuth
// config to keep working.
type SteamAuthenticator struct {
Base
Serial string
DeviceID string
SteamData string // JSON blob from FinalizeAddAuthenticator
SessionData string // optional cookie/session JSON
}
// NewSteamAuthenticator returns a fresh 5-character Steam Guard authenticator.
func NewSteamAuthenticator() *SteamAuthenticator {
s := &SteamAuthenticator{Base: NewBase()}
s.CodeDigits = steamCodeDigits
return s
}
// Name returns the short logger tag for this type.
func (s *SteamAuthenticator) Name() string { return "steam" }
// CurrentCode returns the current 5-char Steam Guard code.
func (s *SteamAuthenticator) CurrentCode() (string, error) {
if s.SecretKey == nil {
return "", fmt.Errorf("steam: no secret loaded")
}
return s.steamCode(), nil
}
// steamCode mirrors the C# CalculateCode override, mapping a 4-byte
// truncation into the Steam alphabet.
func (s *SteamAuthenticator) steamCode() string {
mac := hmac.New(sha1.New, s.SecretKey)
var counter [8]byte
binary.BigEndian.PutUint64(counter[:], s.CodeInterval())
_, _ = mac.Write(counter[:])
sum := mac.Sum(nil)
start := sum[len(sum)-1] & 0x0F
full := binary.BigEndian.Uint32(sum[start:start+4]) & 0x7FFFFFFF
out := make([]byte, steamCodeDigits)
for i := 0; i < steamCodeDigits; i++ {
out[i] = steamChars[full%uint32(len(steamChars))]
full /= uint32(len(steamChars))
}
return string(out)
}
// Sync hits the Steam ITwoFactorService/QueryTime endpoint to recompute
// the local-vs-server clock offset.
func (s *SteamAuthenticator) Sync() error {
const fn = "internal.authenticator.SteamAuthenticator.Sync"
logger := global.Log.WithField("func", fn)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, steamQueryTime,
strings.NewReader("steamid=0"))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpc.New().Do(req)
if err != nil {
logger.WithError(err).Warn("query time failed; using local clock")
return nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var parsed struct {
Response struct {
ServerTime json.Number `json:"server_time"`
} `json:"response"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
logger.WithError(err).Warn("query time: invalid JSON")
return nil
}
serverSec, err := strconv.ParseInt(string(parsed.Response.ServerTime), 10, 64)
if err != nil {
logger.WithError(err).Warn("query time: bad server_time")
return nil
}
s.ServerTimeDiff = serverSec*1000 - NowMillis()
s.LastServerTime = NowMillis()
logger.WithField("offset_ms", s.ServerTimeDiff).Debug("clock synced")
return nil
}
// SecretData encodes the Steam-specific payload as "<base>|<serialhex>|<deviceidhex>|<steamdatahex>|<sessionhex>".
func (s *SteamAuthenticator) SecretData() string {
enc := func(v string) string { return strings.ToUpper(hex.EncodeToString([]byte(v))) }
return s.EncodeSecretData() + "|" +
enc(s.Serial) + "|" +
enc(s.DeviceID) + "|" +
enc(s.SteamData) + "|" +
enc(s.SessionData)
}
// SetSecretData reverses SecretData.
func (s *SteamAuthenticator) SetSecretData(value string) error {
if value == "" {
s.SecretKey = nil
s.Serial = ""
s.DeviceID = ""
s.SteamData = ""
s.SessionData = ""
return nil
}
parts := strings.Split(value, "|")
if err := s.DecodeSecretData(parts[0]); err != nil {
return err
}
dec := func(s string) string {
raw, _ := hex.DecodeString(s)
return string(raw)
}
if len(parts) > 1 {
s.Serial = dec(parts[1])
}
if len(parts) > 2 {
s.DeviceID = dec(parts[2])
}
if len(parts) > 3 {
s.SteamData = dec(parts[3])
if s.SteamData != "" && !strings.HasPrefix(s.SteamData, "{") {
// legacy WinAuth stored only the revocation_code; wrap to JSON
s.SteamData = `{"revocation_code":"` + s.SteamData + `"}`
}
}
if len(parts) > 4 {
s.SessionData = dec(parts[4])
}
return nil
}
+366
View File
@@ -0,0 +1,366 @@
package authenticator
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"sync"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Steam WebAPI endpoints used by the client (login / cookie refresh /
// logoff). Kept here rather than in steam_http.go because they are
// SteamClient-specific.
const (
steamAPIGetWGToken = "/IMobileAuthService/GetWGToken/v0001"
steamAPILogon = "/ISteamWebUserPresenceOAuth/Logon/v0001"
steamAPILogoff = "/ISteamWebUserPresenceOAuth/Logoff/v0001"
)
// SteamClient is the Go port of the C# SteamClient inner class. It owns
// a SteamSession, an attached SteamAuthenticator (for live TOTP codes),
// and an HTTP client with persistent cookie jar.
//
// SECURITY: holds OAuth token, login cookies, and a reference to the
// authenticator's shared secret via Authenticator.SteamData. Never log
// the Session directly.
type SteamClient struct {
Authenticator *SteamAuthenticator
Session *SteamSession
// Login state flags inspected by the UI after Login returns. They
// mirror the C# public fields exactly so the wizard logic stays
// recognisable.
InvalidLogin bool
RequiresCaptcha bool
CaptchaID string
CaptchaURL string
RequiresEmailAuth bool
EmailDomain string
Requires2FA bool
Error string
mu sync.Mutex
jar http.CookieJar
client *http.Client
// confirmationsHTML / confirmationsQuery hold the most recent
// GetConfirmations response so GetConfirmationDetails can wrap the
// per-trade detail HTML in the same outer markup. Recomputed every
// poll; never persisted.
confirmationsHTML string
confirmationsQuery string
// Poller hooks. Set by the UI before StartConfirmationPoller.
// Called from the poller goroutine — implementations must not
// block the UI thread or attempt to drive the Gio frame loop
// directly; marshal back via window.Invalidate or a channel.
OnConfirmation ConfirmationCallback
OnConfirmationError ConfirmationErrorCallback
// ConfirmationPollerRetries controls how many consecutive failed
// poll cycles trigger OnConfirmationError. Zero falls back to
// defaultConfirmationPollerRetries (3).
ConfirmationPollerRetries int
// poller is the handle to the currently running background
// goroutine, or nil if none is active.
poller *pollerHandle
}
// NewSteamClient builds a client around an existing authenticator. If
// sessionJSON is non-empty it is parsed via ParseSteamSession; the
// resulting cookies are loaded into the internal jar so subsequent
// requests carry the login state.
func NewSteamClient(auth *SteamAuthenticator, sessionJSON string) (*SteamClient, error) {
sess := ParseSteamSession(sessionJSON)
jar, err := newSteamCookieJar()
if err != nil {
return nil, err
}
// Replay any cookies the session already had into the live jar.
if len(sess.Cookies) > 0 {
u, _ := url.Parse(steamCommunityBase + "/")
cs := make([]*http.Cookie, 0, len(sess.Cookies))
for name, value := range sess.Cookies {
cs = append(cs, &http.Cookie{Name: name, Value: value})
}
jar.SetCookies(u, cs)
}
return &SteamClient{
Authenticator: auth,
Session: sess,
jar: jar,
client: steamHTTPClient(jar),
}, nil
}
// IsLoggedIn reports whether the session carries an OAuth token. Note
// this does NOT round-trip to Steam — the token might have been
// invalidated server-side.
func (c *SteamClient) IsLoggedIn() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.Session != nil && c.Session.OAuthToken != ""
}
// Clear resets the per-login flags and wipes the session. The jar is
// recreated so leftover Steam cookies do not bleed into the next login
// attempt.
func (c *SteamClient) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.clearLocked()
}
func (c *SteamClient) clearLocked() {
c.InvalidLogin = false
c.RequiresCaptcha = false
c.CaptchaID = ""
c.CaptchaURL = ""
c.RequiresEmailAuth = false
c.EmailDomain = ""
c.Requires2FA = false
c.Error = ""
if c.Session != nil {
c.Session.Clear()
}
if jar, err := newSteamCookieJar(); err == nil {
c.jar = jar
c.client = steamHTTPClient(jar)
}
}
// Login authenticates against Steam using credentials plus the live
// TOTP code from the bound authenticator. Returns true on success.
// On a non-fatal failure (captcha / email / 2FA needed, bad password)
// it returns (false, nil) with the corresponding Requires* flag and
// Error set. A non-nil error means the call could not even complete
// the transport-level handshake.
func (c *SteamClient) Login(
ctx context.Context, username, password, captchaID, captchaText string,
) (bool, error) {
const fn = "internal.authenticator.SteamClient.Login"
logger := global.Log.WithField("func", fn)
c.mu.Lock()
defer c.mu.Unlock()
c.Error = ""
if c.Session.OAuthToken != "" {
return true, nil
}
if !steamJarHasSessionID(c.jar) {
headers := http.Header{"X-Requested-With": []string{"com.valvesoftware.android.steam.community"}}
if _, err := steamRequest(ctx, c.client, http.MethodGet,
steamCommunityBase+"/mobilelogin?oauth_client_id="+steamOAuthClientID+
"&oauth_scope="+url.QueryEscape(steamOAuthScope), nil, headers); err != nil {
return false, fmt.Errorf("mobilelogin GET: %w", err)
}
}
username = stripNonASCII(username)
password = stripNonASCII(password)
rsaResp, err := steamRequest(ctx, c.client, http.MethodPost,
steamCommunityBase+"/mobilelogin/getrsakey",
url.Values{"username": {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 {
c.InvalidLogin = true
c.Error = "Unknown username"
return false, nil
}
encPw, err := steamRSAEncryptPassword(password, rsaJSON.PublicKeyMod, rsaJSON.PublicKeyExp)
if err != nil {
return false, fmt.Errorf("rsa encrypt: %w", err)
}
// Live TOTP — the key difference from Enroll, which sends "".
twoFactor, err := c.Authenticator.CurrentCode()
if err != nil {
// No secret yet: send empty, Steam will reply requires_twofactor.
twoFactor = ""
}
if captchaID == "" {
captchaID = "-1"
}
if captchaText == "" {
captchaText = "enter above characters"
}
form := url.Values{
"password": {base64.StdEncoding.EncodeToString(encPw)},
"username": {username},
"twofactorcode": {twoFactor},
"loginfriendlyname": {"#login_emailauth_friendlyname_mobile"},
"captchagid": {captchaID},
"captcha_text": {captchaText},
"rsatimestamp": {rsaJSON.Timestamp},
"remember_login": {"false"},
"oauth_client_id": {steamOAuthClientID},
"oauth_scope": {steamOAuthScope},
"donotache": {strconv.FormatInt(time.Now().UnixMilli(), 10)},
}
// Wipe the local plaintext password copy now that it is RSA-encrypted.
password = ""
_ = password
loginResp, err := steamRequest(ctx, c.client, http.MethodPost,
steamCommunityBase+"/mobilelogin/dologin/", form, nil)
if err != nil {
return false, fmt.Errorf("dologin: %w", err)
}
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 {
c.Session.SteamId = v
}
c.InvalidLogin = false
c.RequiresCaptcha = false
c.CaptchaID = ""
c.CaptchaURL = ""
c.RequiresEmailAuth = false
c.EmailDomain = ""
c.Requires2FA = false
complete, _ := login["login_complete"].(bool)
oauthStr, _ := login["oauth"].(string)
if !complete || oauthStr == "" {
c.InvalidLogin = true
if b, _ := login["captcha_needed"].(bool); b {
c.RequiresCaptcha = true
if id, ok := login["captcha_gid"].(string); ok {
c.CaptchaID = id
c.CaptchaURL = steamCommunityBase + "/public/captcha.php?gid=" + id
}
}
if b, _ := login["emailauth_needed"].(bool); b {
if d, ok := login["emaildomain"].(string); ok && d != "" {
c.EmailDomain = d
}
c.RequiresEmailAuth = true
}
if b, _ := login["requires_twofactor"].(bool); b {
c.Requires2FA = true
}
if msg, ok := login["message"].(string); ok && msg != "" {
c.Error = msg
}
return false, nil
}
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)
}
c.Session.OAuthToken = oauth.OAuthToken
if oauth.SteamID != "" {
c.Session.SteamId = oauth.SteamID
}
c.syncCookiesFromJarLocked()
logger.WithField("steamid", c.Session.SteamId).Info("steam login ok")
return true, nil
}
// Refresh exchanges the stored OAuth token for fresh steamLogin /
// steamLoginSecure cookies via IMobileAuthService/GetWGToken. Returns
// true on success; false (with no error) if the response is missing
// expected fields, mirroring the C# best-effort behaviour.
func (c *SteamClient) Refresh(ctx context.Context) (bool, error) {
const fn = "internal.authenticator.SteamClient.Refresh"
logger := global.Log.WithField("func", fn)
c.mu.Lock()
defer c.mu.Unlock()
if c.Session == nil || c.Session.OAuthToken == "" {
return false, errors.New("steam: refresh without OAuth token")
}
resp, err := steamRequest(ctx, c.client, http.MethodPost,
steamWebAPIBase+steamAPIGetWGToken,
url.Values{"access_token": {c.Session.OAuthToken}}, nil)
if err != nil {
return false, fmt.Errorf("GetWGToken: %w", err)
}
var parsed struct {
Response struct {
Token string `json:"token"`
TokenSecure string `json:"token_secure"`
} `json:"response"`
}
if err := json.Unmarshal([]byte(resp), &parsed); err != nil {
logger.WithError(err).Warn("GetWGToken parse failed")
return false, nil
}
if parsed.Response.Token == "" || parsed.Response.TokenSecure == "" {
return false, nil
}
u, _ := url.Parse(steamCommunityBase + "/")
c.jar.SetCookies(u, []*http.Cookie{
{Name: "steamLogin", Value: c.Session.SteamId + "||" + parsed.Response.Token},
{Name: "steamLoginSecure", Value: c.Session.SteamId + "||" + parsed.Response.TokenSecure},
})
c.syncCookiesFromJarLocked()
logger.Debug("session cookies refreshed")
return true, nil
}
// Logout best-effort revokes the UMQ session (if one was opened) and
// then clears the local state. Network errors are swallowed — the
// local state must still end up cleared regardless of server reply.
func (c *SteamClient) Logout(ctx context.Context) {
c.mu.Lock()
defer c.mu.Unlock()
if c.Session != nil && c.Session.OAuthToken != "" && c.Session.UmqId != "" {
_, _ = steamRequest(ctx, c.client, http.MethodPost,
steamWebAPIBase+steamAPILogoff,
url.Values{
"access_token": {c.Session.OAuthToken},
"umqid": {c.Session.UmqId},
}, nil)
}
c.clearLocked()
}
// syncCookiesFromJarLocked copies the community-domain cookies out of
// the live jar into Session.Cookies. Must be called with c.mu held.
func (c *SteamClient) syncCookiesFromJarLocked() {
if c.Session == nil || c.jar == nil {
return
}
u, _ := url.Parse(steamCommunityBase + "/")
c.Session.Cookies = map[string]string{}
for _, ck := range c.jar.Cookies(u) {
c.Session.Cookies[ck.Name] = ck.Value
}
}
@@ -0,0 +1,380 @@
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>"
}
+477
View File
@@ -0,0 +1,477 @@
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) != ""
}
+251
View File
@@ -0,0 +1,251 @@
package authenticator
import (
"compress/gzip"
"context"
"crypto/rand"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Steam base URLs. Kept as vars (not consts) so tests can override.
var (
steamCommunityBase = "https://steamcommunity.com"
steamWebAPIBase = "https://api.steampowered.com"
)
// steamMobileUserAgent is the exact UA the original WinAuth uses, chosen
// so that Steam's mobile login endpoint accepts the request as coming
// from the official Android app. Changing this without testing tends to
// trigger captcha or outright rejection.
const steamMobileUserAgent = "Mozilla/5.0 (Linux; U; Android 4.1.1; en-us; Google Nexus 4 - 4.1.1 - API 16 - 768x1280 Build/JRO03S) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30"
// steamOAuthClientID / steamOAuthScope are the constants the mobile app
// sends to /mobilelogin. Do not log them — they are not secret, but
// keeping the redact list short reduces accidents.
const (
steamOAuthClientID = "DE45CD61"
steamOAuthScope = "read_profile write_profile read_client write_client"
)
// steamFormFieldsToRedact lists the form keys that must never appear in
// logs. The HTTP layer prints request body at debug only after filtering
// these out.
var steamFormFieldsToRedact = map[string]struct{}{
"password": {},
"access_token": {},
"oauth_token": {},
"twofactorcode": {},
"emailauth": {},
"shared_secret": {},
"identity_secret": {},
"revocation_code": {},
"authenticator_code": {},
"activation_code": {},
}
// newSteamCookieJar returns a cookie jar pre-loaded with the constant
// cookies the original mobile login flow needs before its first request.
func newSteamCookieJar() (http.CookieJar, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
u, _ := url.Parse(steamCommunityBase + "/")
jar.SetCookies(u, []*http.Cookie{
{Name: "mobileClientVersion", Value: "3067969+%282.1.3%29"},
{Name: "mobileClient", Value: "android"},
{Name: "steamid", Value: ""},
{Name: "steamLogin", Value: ""},
{Name: "Steam_Language", Value: "english"},
{Name: "dob", Value: ""},
})
return jar, nil
}
// steamHTTPClient returns a fresh http.Client that uses the given jar
// and a 30s timeout. We intentionally do NOT reuse internal/httpc here:
// httpc dumps full request bodies at Trace level, which would leak the
// password / OAuth token form fields used by the Steam endpoints. This
// client instead logs only method / URL / status at debug level.
func steamHTTPClient(jar http.CookieJar) *http.Client {
return &http.Client{
Jar: jar,
Timeout: 30 * time.Second,
}
}
// steamRequest performs a Steam mobile / WebAPI request. method is GET or
// POST. For GET the form values are appended to the URL; for POST they
// are sent as application/x-www-form-urlencoded.
//
// The returned body is the full response payload as a string. headers
// are added before the call. extraCookies are sent as the explicit
// Cookie header on top of whatever the jar already supplies — most
// callers can pass nil.
//
// IMPORTANT: passwords/tokens MUST be in `form` and never in the URL.
// The debug log only records method/URL/status; form keys listed in
// steamFormFieldsToRedact are filtered out of the (debug-level) body
// dump.
func steamRequest(
ctx context.Context, client *http.Client,
method, rawURL string, form url.Values, headers http.Header,
) (string, error) {
const fn = "internal.authenticator.steamRequest"
logger := global.Log.WithField("func", fn).
WithField("method", method).
WithField("url", rawURL)
method = strings.ToUpper(method)
body := ""
if form != nil {
body = form.Encode()
}
finalURL := rawURL
var reqBody io.Reader
if method == http.MethodGet {
if body != "" {
if strings.Contains(finalURL, "?") {
finalURL += "&" + body
} else {
finalURL += "?" + body
}
}
} else {
reqBody = strings.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, finalURL, reqBody)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", steamMobileUserAgent)
req.Header.Set("Accept", "text/javascript, text/html, application/xml, text/xml, */*")
req.Header.Set("Accept-Encoding", "gzip, deflate")
req.Header.Set("Referer", steamCommunityBase)
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
}
for k, vs := range headers {
for _, v := range vs {
req.Header.Add(k, v)
}
}
if global.Log != nil {
logger.WithField("form", redactFormForLog(form)).Debug("steam request")
}
resp, err := client.Do(req)
if err != nil {
logger.WithError(err).Warn("steam request failed")
return "", err
}
defer resp.Body.Close()
logger = logger.WithField("status", resp.StatusCode)
var reader io.Reader = resp.Body
if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") {
gz, gerr := gzip.NewReader(resp.Body)
if gerr != nil {
return "", gerr
}
defer gz.Close()
reader = gz
}
raw, err := io.ReadAll(reader)
if err != nil {
return "", err
}
if resp.StatusCode == http.StatusForbidden {
logger.Warn("steam request unauthorised")
return string(raw), errSteamUnauthorised
}
if resp.StatusCode != http.StatusOK {
logger.Warn("steam request non-200")
return string(raw), fmt.Errorf("steam: %d %s", resp.StatusCode, resp.Status)
}
logger.Debug("steam response ok")
return string(raw), nil
}
// errSteamUnauthorised maps the C# UnauthorisedRequestException — a 403
// from /steamguard or related endpoints typically means Family View has
// disabled community content.
var errSteamUnauthorised = fmt.Errorf("steam: unauthorised (403)")
// redactFormForLog produces a copy of form with sensitive values
// replaced by "<redacted>". Returns "" for nil/empty input.
func redactFormForLog(form url.Values) string {
if len(form) == 0 {
return ""
}
parts := make([]string, 0, len(form))
for k, vs := range form {
if _, sensitive := steamFormFieldsToRedact[k]; sensitive {
parts = append(parts, k+"=<redacted>")
continue
}
for _, v := range vs {
parts = append(parts, k+"="+v)
}
}
return strings.Join(parts, "&")
}
// buildRandomDeviceID returns "android:" followed by a freshly generated
// UUID v4. Matches the C# SteamAuthenticator.BuildRandomId helper.
func buildRandomDeviceID() string {
var u [16]byte
if _, err := rand.Read(u[:]); err != nil {
// crypto/rand failing is fatal; fall back to a time-derived
// value so the caller does not crash, but log it loudly.
now := time.Now().UnixNano()
for i := 0; i < 16; i++ {
u[i] = byte(now >> (i % 8 * 8))
}
}
u[6] = (u[6] & 0x0F) | 0x40 // version 4
u[8] = (u[8] & 0x3F) | 0x80 // variant RFC 4122
hexb := func(b byte) (byte, byte) {
const hexd = "0123456789abcdef"
return hexd[b>>4], hexd[b&0x0F]
}
out := make([]byte, 0, 8+36)
out = append(out, "android:"...)
for i, b := range u {
if i == 4 || i == 6 || i == 8 || i == 10 {
out = append(out, '-')
}
hi, lo := hexb(b)
out = append(out, hi, lo)
}
return string(out)
}
// stripNonASCII removes any non-ASCII code points from s. Steam's
// login endpoint silently drops these from username/password, so the
// client side must do the same to keep RSA-encrypted bytes consistent.
func stripNonASCII(s string) string {
b := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c < 0x80 {
b = append(b, c)
}
}
return string(b)
}
+184
View File
@@ -0,0 +1,184 @@
package authenticator
import (
"context"
"math/rand"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// confirmationEventDelay is the base sleep between firing successive
// OnConfirmation callbacks. Matches the C# CONFIRMATION_EVENT_DELAY:
// the per-event sleep is uniformly randomised to 100%-150% of this
// value, throttling the UI when several new trades arrive at once.
const confirmationEventDelay = 1000 * time.Millisecond
// defaultConfirmationPollerRetries is the number of consecutive failed
// poll cycles before OnConfirmationError fires. Mirrors the C# default.
const defaultConfirmationPollerRetries = 3
// ConfirmationCallback receives one notification per newly observed
// pending confirmation. action tells the UI whether the user wanted a
// passive notification, an interactive prompt, or silent auto-accept.
type ConfirmationCallback func(conf Confirmation, action PollerAction)
// ConfirmationErrorCallback is fired once per failure burst (every
// ConfirmationPollerRetries consecutive failures), letting the UI
// surface "Steam unreachable" once instead of on every cycle.
type ConfirmationErrorCallback func(message string, action PollerAction, err error)
// pollerHandle tracks a running background poller so it can be stopped
// cleanly. Kept private; SteamClient exposes Start/Stop wrappers.
type pollerHandle struct {
cancel context.CancelFunc
done chan struct{}
}
// StartConfirmationPoller starts (or restarts) the background goroutine
// that periodically calls GetConfirmations and fires OnConfirmation /
// OnConfirmationError. Passing a nil or zero-Duration poller stops any
// running poller and returns.
//
// It is safe to call StartConfirmationPoller repeatedly — the previous
// poller is stopped (and its goroutine joined) before the new one is
// started.
func (c *SteamClient) StartConfirmationPoller(poller *ConfirmationPoller) {
c.StopConfirmationPoller()
if poller == nil || poller.Duration <= 0 {
return
}
c.mu.Lock()
if c.Session == nil {
c.mu.Unlock()
return
}
c.Session.Confirmations = poller
if c.ConfirmationPollerRetries <= 0 {
c.ConfirmationPollerRetries = defaultConfirmationPollerRetries
}
retries := c.ConfirmationPollerRetries
c.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
h := &pollerHandle{cancel: cancel, done: make(chan struct{})}
c.mu.Lock()
c.poller = h
c.mu.Unlock()
go c.runPollerLoop(ctx, h, retries)
}
// StopConfirmationPoller cancels the running poller (if any) and waits
// for its goroutine to exit before returning. Also clears
// Session.Confirmations so a restored session does not auto-restart.
func (c *SteamClient) StopConfirmationPoller() {
c.mu.Lock()
h := c.poller
c.poller = nil
if c.Session != nil {
c.Session.Confirmations = nil
}
c.mu.Unlock()
if h == nil {
return
}
h.cancel()
<-h.done
}
// runPollerLoop is the goroutine body. It owns no locks across network
// calls. Snapshots of the poller config / retry budget are taken once
// per iteration to avoid races with concurrent Stop / Start callers.
func (c *SteamClient) runPollerLoop(ctx context.Context, h *pollerHandle, maxRetries int) {
const fn = "internal.authenticator.SteamClient.runPollerLoop"
logger := global.Log.WithField("func", fn)
defer close(h.done)
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
retryCount := 0
for ctx.Err() == nil {
c.mu.Lock()
poller := c.Session.Confirmations
onConf := c.OnConfirmation
onErr := c.OnConfirmationError
c.mu.Unlock()
if poller == nil {
logger.Debug("poller cleared; exiting loop")
return
}
action := poller.Action
confs, err := c.GetConfirmations(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
retryCount++
logger.WithError(err).WithField("retry", retryCount).Warn("poll failed")
if retryCount >= maxRetries {
if onErr != nil {
onErr("Failed to read confirmations", action, err)
}
} else {
// Best-effort cookie refresh — matches the C# fallback.
_, _ = c.Refresh(ctx)
}
} else {
retryCount = 0
if onConf != nil {
for i := range confs {
if !confs[i].IsNew {
continue
}
if ctx.Err() != nil {
return
}
start := time.Now()
onConf(confs[i], action)
// Jitter the inter-event delay 100%-150% to keep
// the UI from being slammed during a burst.
delay := confirmationEventDelay + time.Duration(rng.Int63n(int64(confirmationEventDelay/2)))
elapsed := time.Since(start)
if delay > elapsed {
if !sleepWithCancel(ctx, delay-elapsed) {
return
}
}
}
}
}
// Re-read duration in case the user changed it mid-flight.
c.mu.Lock()
var wait time.Duration
if c.Session != nil && c.Session.Confirmations != nil {
wait = time.Duration(c.Session.Confirmations.Duration) * time.Minute
}
c.mu.Unlock()
if wait <= 0 {
return
}
if !sleepWithCancel(ctx, wait) {
return
}
}
}
// sleepWithCancel sleeps for d, or returns early if ctx is cancelled.
// Returns true if the full duration elapsed, false on cancellation.
func sleepWithCancel(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return true
case <-ctx.Done():
return false
}
}
+212
View File
@@ -0,0 +1,212 @@
package authenticator
import (
"encoding/json"
"net/url"
"regexp"
"sort"
"strings"
)
// PollerAction mirrors the C# WinAuthenticator.SteamClient.PollerAction
// enum. The integer values are persisted in SteamSession JSON, so they
// MUST NOT be renumbered.
type PollerAction int
const (
PollerActionNone PollerAction = 0
PollerActionNotify PollerAction = 1
PollerActionAutoConfirm PollerAction = 2
PollerActionSilentAutoConfirm PollerAction = 3
)
// ConfirmationPoller is the background-poll configuration persisted
// inside a SteamSession. A Duration of 0 means the poller is disabled
// and the whole object serializes as the literal "null".
type ConfirmationPoller struct {
Duration int `json:"duration"`
Action PollerAction `json:"action"`
Ids []string `json:"ids,omitempty"`
}
// ToJSON returns the on-disk representation. Matches the C#
// ConfirmationPoller.ToString output exactly so old WinAuth session
// blobs round-trip.
func (p *ConfirmationPoller) ToJSON() string {
if p == nil || p.Duration == 0 {
return "null"
}
b, _ := json.Marshal(p)
return string(b)
}
// ParseConfirmationPoller restores a poller from its JSON form. Returns
// nil for an empty / "null" / zero-duration payload, matching the C#
// FromJSON contract.
func ParseConfirmationPoller(s string) *ConfirmationPoller {
s = strings.TrimSpace(s)
if s == "" || s == "null" {
return nil
}
var p ConfirmationPoller
if err := json.Unmarshal([]byte(s), &p); err != nil {
return nil
}
if p.Duration == 0 {
return nil
}
return &p
}
// Confirmation is a single in-flight trade/market confirmation as
// returned by /mobileconf/conf. Runtime-only; not persisted.
type Confirmation struct {
Id string
Key string
Offline bool
IsNew bool
Image string
Details string
Traded string
When string
}
// SteamSession is the persistent half of a Steam mobile authenticator's
// runtime state: the Steam ID, OAuth token, cookie jar, and the poller
// config. UmqId / MessageId are intentionally NOT persisted (matches the
// C# implementation, which comments them out of ToString).
//
// SECURITY: this struct holds OAuth tokens and login cookies. Callers
// must never log it, embed it in error messages, or write it anywhere
// other than the encrypted secretdata blob.
type SteamSession struct {
SteamId string
OAuthToken string
Cookies map[string]string
UmqId string // runtime only, not serialized
MessageId int // runtime only, not serialized
Confirmations *ConfirmationPoller
}
// NewSteamSession returns an empty session.
func NewSteamSession() *SteamSession {
return &SteamSession{Cookies: map[string]string{}}
}
// ParseSteamSession decodes the JSON form written by ToJSON / by the
// old C# client. An empty or invalid input yields an empty session
// rather than an error, matching the C# constructor's behavior.
func ParseSteamSession(s string) *SteamSession {
sess := NewSteamSession()
s = strings.TrimSpace(s)
if s == "" {
return sess
}
var raw struct {
SteamId string `json:"steamid"`
Cookies string `json:"cookies"`
OAuthToken string `json:"oauthtoken"`
Confs json.RawMessage `json:"confs"`
}
if err := json.Unmarshal([]byte(s), &raw); err != nil {
return sess
}
sess.SteamId = raw.SteamId
sess.OAuthToken = raw.OAuthToken
sess.Cookies = parseCookieHeader(raw.Cookies)
if len(raw.Confs) > 0 && string(raw.Confs) != "null" {
sess.Confirmations = ParseConfirmationPoller(string(raw.Confs))
}
return sess
}
// ToJSON serializes the session in the exact format the C# WinAuth
// client writes — cookies as a single "name=value; name=value" header
// string, confs as either "null" or a poller object, and no umqid /
// messageid keys.
func (s *SteamSession) ToJSON() string {
type out struct {
SteamId string `json:"steamid"`
Cookies string `json:"cookies"`
OAuthToken string `json:"oauthtoken"`
Confs json.RawMessage `json:"confs"`
}
o := out{
SteamId: s.SteamId,
Cookies: formatCookieHeader(s.Cookies),
OAuthToken: s.OAuthToken,
Confs: json.RawMessage(s.Confirmations.ToJSON()),
}
b, _ := json.Marshal(o)
return string(b)
}
// SessionFromEnrollState builds a SteamSession from a completed
// enrollment, snapshotting the community-domain cookies out of the
// EnrollState's internal jar. Used by the UI after Enroll returns
// Success so the persisted SessionData carries a usable cookie set.
func SessionFromEnrollState(state *EnrollState) *SteamSession {
sess := NewSteamSession()
if state == nil {
return sess
}
sess.SteamId = state.SteamID
sess.OAuthToken = state.OAuthToken
if state.jar != nil {
u, _ := url.Parse(steamCommunityBase + "/")
for _, c := range state.jar.Cookies(u) {
sess.Cookies[c.Name] = c.Value
}
}
return sess
}
// Clear wipes mutable session state, keeping the SteamId. Matches the
// C# SteamSession.Clear behavior used by Logout.
func (s *SteamSession) Clear() {
s.OAuthToken = ""
s.UmqId = ""
s.MessageId = 0
s.Cookies = map[string]string{}
s.Confirmations = nil
}
var cookieHeaderRe = regexp.MustCompile(`([^=;]+)=([^;]*);?`)
// parseCookieHeader splits "name1=value1; name2=value2" into a map.
// Whitespace around names/values is trimmed. Empty input returns an
// empty (non-nil) map so callers can immediately .Set into it.
func parseCookieHeader(h string) map[string]string {
out := map[string]string{}
for _, m := range cookieHeaderRe.FindAllStringSubmatch(h, -1) {
name := strings.TrimSpace(m[1])
if name == "" {
continue
}
out[name] = strings.TrimSpace(m[2])
}
return out
}
// formatCookieHeader is the inverse. Keys are emitted in sorted order
// so the JSON output is stable across saves (helps diffs and tests).
func formatCookieHeader(cookies map[string]string) string {
if len(cookies) == 0 {
return ""
}
names := make([]string, 0, len(cookies))
for n := range cookies {
names = append(names, n)
}
sort.Strings(names)
var b strings.Builder
for i, n := range names {
if i > 0 {
b.WriteString("; ")
}
b.WriteString(n)
b.WriteByte('=')
b.WriteString(cookies[n])
}
return b.String()
}
+34
View File
@@ -0,0 +1,34 @@
// Package authenticator implements RFC 4226 (HOTP) and RFC 6238 (TOTP)
// authenticator algorithms, plus vendor-specific subclasses (Google,
// Battle.Net, Microsoft, Okta, Steam, YubiKey-backed).
//
// This is a Go port of the C# Authenticator/HOTPAuthenticator classes from
// the original WinAuth project.
package authenticator
// HMACType selects the hash function used to derive the OTP. Matches the
// HMACTypes enum from the original C# source.
type HMACType int
const (
HMACSHA1 HMACType = iota
HMACSHA256
HMACSHA512
)
func (h HMACType) String() string {
switch h {
case HMACSHA256:
return "SHA256"
case HMACSHA512:
return "SHA512"
default:
return "SHA1"
}
}
// Common defaults that mirror the original C# constants.
const (
DefaultCodeDigits = 6
DefaultPeriod = 30
)