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:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: 0–9 → '0'–'9', 10–25 → '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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>"
|
||||
}
|
||||
@@ -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) != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user