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 "||||". 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 }