feat: Phase 1 WinAuth Go 移植完整实现

将原 C#/.NET WinAuth 移植为 Go + Gio GUI,覆盖 Phase 1 全部功能。

核心模块:
- internal/authenticator: TOTP (Google/Microsoft/Okta) + HOTP + BattleNet + Steam,含
  enroll/sync/code 生成、Steam 交易确认轮询
- internal/config: YAML 配置 + 老版 WinAuth XML 导入(DPAPI + Password + Blowfish/PBKDF2 解密链)
- internal/crypto: 现代加密 (WAGO1) + DPAPI 跨平台封装 + 老版 Blowfish ECB
- internal/win32: 单实例 Mutex 锁 + 全局热键管理器 (RegisterHotKey + PeekMessage 泵) +
  SendInput Unicode 注入 + 剪贴板文本/CF_DIB 图像读写 + AttachThreadInput 焦点切换
- internal/hotkey: "Ctrl+Alt+G" 风格快捷键字符串解析/格式化
- internal/qr: gozxing 二维码解码 + otpauth:// URI 解析
- internal/i18n: en/zh-CN/de 三语 TOML

UI 模块 (Gio):
- 主窗口:圆环倒计时进度条、复制按钮 + Toast 反馈、空列表占位、行分隔线
- 添加流程:vendor 菜单 + 各 vendor 独立对话框 + 二维码扫描入口(文件 / 剪贴板)
- 设置:密码加密、老版 XML 导入、每条目热键配置
- Steam:注册向导(含 captcha/email/SMS 多步)+ 交易确认窗

构建:Windows 主目标,非 Windows 平台所有 Win32 功能走 build-tag 桩实现。
This commit is contained in:
2026-06-12 03:10:37 +08:00
commit c671f2115e
165 changed files with 10102 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
package authenticator
import (
"sync"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Authenticator is the interface implemented by every concrete OTP type.
// It deliberately mirrors the surface of the C# Authenticator base class
// rather than introducing a wider/cleaner Go interface, so the port can
// stay close to the original.
type Authenticator interface {
// Name returns a short identifier for logging ("google", "battlenet", ...).
Name() string
// CurrentCode returns the OTP that should be shown to the user right now.
CurrentCode() (string, error)
// SecretData returns the serialized form of the secret + parameters,
// matching the field stored inside the original XML config under
// <secretdata>.
SecretData() string
// SetSecretData reverses SecretData, populating the receiver.
SetSecretData(value string) error
// Sync re-aligns the server time offset using whatever network call the
// concrete authenticator supports.
Sync() error
}
// Base holds fields shared by every concrete authenticator. Embed it in
// your subtype to inherit the field set and common helpers.
type Base struct {
SecretKey []byte
CodeDigits int
HMACType HMACType
Period int
ServerTimeDiff int64 // ms
LastServerTime int64 // ms (Unix-millis here, not .NET ticks)
mu sync.Mutex
}
// NewBase returns a Base seeded with the project-wide defaults.
func NewBase() Base {
return Base{
CodeDigits: DefaultCodeDigits,
HMACType: HMACSHA1,
Period: DefaultPeriod,
}
}
// NowMillis is the milliseconds since the Unix epoch, equivalent to the
// CurrentTime helper in the original C#.
func NowMillis() int64 { return time.Now().UnixMilli() }
// ServerTime returns the server's notion of "now" in milliseconds.
func (b *Base) ServerTime() int64 { return NowMillis() + b.ServerTimeDiff }
// CodeInterval returns the TOTP step number for the current server time.
func (b *Base) CodeInterval() uint64 {
if b.Period <= 0 {
b.Period = DefaultPeriod
}
return uint64(b.ServerTime() / int64(b.Period*1000))
}
// CalculateTOTP runs the TOTP algorithm using the receiver's fields.
func (b *Base) CalculateTOTP() string {
const fn = "internal.authenticator.Base.CalculateTOTP"
digits := b.CodeDigits
if digits == 0 {
digits = DefaultCodeDigits
}
code := hotpCode(b.SecretKey, b.CodeInterval(), digits, b.HMACType)
global.Log.WithField("func", fn).
WithField("interval", b.CodeInterval()).
WithField("digits", digits).
Trace("computed TOTP")
return code
}
+97
View File
@@ -0,0 +1,97 @@
package authenticator
import (
"fmt"
"regexp"
"strings"
)
// base32Alphabet is the RFC 4648 / 3548 base32 alphabet (no padding).
const base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
var (
base32EncodeTable [32]byte
base32DecodeTable [256]int8
base32CleanRE = regexp.MustCompile(`[\s-]+`)
base32PadRE = regexp.MustCompile(`=+$`)
)
func init() {
for i, c := range base32Alphabet {
base32EncodeTable[i] = byte(c)
}
for i := range base32DecodeTable {
base32DecodeTable[i] = -1
}
for i, c := range base32Alphabet {
base32DecodeTable[c] = int8(i)
}
}
// Base32Decode decodes a base32 string into bytes. Whitespace and dashes
// are stripped; trailing '=' padding is ignored; input is uppercased before
// decoding. This mirrors the lenient behavior of the original C# Base32 class.
func Base32Decode(encoded string) ([]byte, error) {
encoded = base32CleanRE.ReplaceAllString(encoded, "")
encoded = base32PadRE.ReplaceAllString(encoded, "")
encoded = strings.ToUpper(encoded)
if encoded == "" {
return []byte{}, nil
}
const shift = 5
const mask = 0x1F
outLen := len(encoded) * shift / 8
out := make([]byte, outLen)
var buffer int
var bitsLeft int
var next int
for _, c := range encoded {
if c >= 256 || base32DecodeTable[c] < 0 {
return nil, fmt.Errorf("base32: illegal character %q", c)
}
buffer <<= shift
buffer |= int(base32DecodeTable[c]) & mask
bitsLeft += shift
if bitsLeft >= 8 {
out[next] = byte(buffer >> (bitsLeft - 8))
next++
bitsLeft -= 8
}
}
return out, nil
}
// Base32Encode encodes raw bytes as base32 with no padding.
func Base32Encode(data []byte) string {
if len(data) == 0 {
return ""
}
const shift = 5
const mask = 0x1F
var sb strings.Builder
buffer := int(data[0])
next := 1
bitsLeft := 8
for bitsLeft > 0 || next < len(data) {
if bitsLeft < shift {
if next < len(data) {
buffer <<= 8
buffer |= int(data[next]) & 0xFF
next++
bitsLeft += 8
} else {
pad := shift - bitsLeft
buffer <<= pad
bitsLeft += pad
}
}
index := mask & (buffer >> (bitsLeft - shift))
bitsLeft -= shift
sb.WriteByte(base32EncodeTable[index])
}
return sb.String()
}
+389
View File
@@ -0,0 +1,389 @@
package authenticator
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"math/big"
mrand "math/rand"
"net/http"
"strings"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/httpc"
)
// Battle.Net mobile-service URLs by region. Matches the original C# table.
var battlenetURLs = map[string]string{
"US": "http://mobile-service.blizzard.com",
"EU": "http://mobile-service.blizzard.com",
"KR": "http://mobile-service.blizzard.com",
"CN": "http://mobile-service.battlenet.com.cn",
}
const (
bnetEnrollPath = "/enrollment/enroll2.htm"
bnetSyncPath = "/enrollment/time.htm"
bnetRestorePath = "/enrollment/initiatePaperRestore.htm"
bnetRestoreValidatePath = "/enrollment/validatePaperRestore.htm"
bnetCodeDigits = 8
bnetModelSize = 16
bnetModelChars = " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01234567890"
bnetEnrollRespSz = 45
bnetSyncRespSz = 8
bnetEnrollModulus = "955e4bd989f3917d2f15544a7e0504eb9d7bb66b6f8a2fe470e453c779200e5e" +
"3ad2e43a02d06c4adbd8d328f1a426b83658e88bfd949b2af4eaf30054673a14" +
"19a250fa4cc1278d12855b5b25818d162c6e6ee2ab4a350d401d78f6ddb99711" +
"e72626b48bd8b5b0b7f3acf9ea3c9e0005fee59e19136cdb7c83f2ab8b0a2a99"
bnetEnrollExponent = "0101"
)
// BattleNetAuthenticator is the Go port of the C# BattleNetAuthenticator.
// It supports Enroll, Sync, Restore (paper restore), and the customary
// 8-digit TOTP code generation.
type BattleNetAuthenticator struct {
Base
Serial string
RestoreCodeVerified bool
}
// NewBattleNetAuthenticator returns an empty 8-digit Battle.Net authenticator.
func NewBattleNetAuthenticator() *BattleNetAuthenticator {
b := &BattleNetAuthenticator{Base: NewBase()}
b.CodeDigits = bnetCodeDigits
return b
}
// Name returns the short logger tag for this type.
func (b *BattleNetAuthenticator) Name() string { return "battlenet" }
// Region returns the two-letter region prefix derived from the serial.
func (b *BattleNetAuthenticator) Region() string {
if len(b.Serial) >= 2 {
return strings.ToUpper(b.Serial[:2])
}
return ""
}
// CurrentCode returns the live 8-digit Battle.Net authenticator code.
func (b *BattleNetAuthenticator) CurrentCode() (string, error) {
if b.SecretKey == nil {
return "", fmt.Errorf("battlenet: no secret loaded")
}
return b.CalculateTOTP(), nil
}
// SecretData / SetSecretData persist both the secret and the serial number,
// matching the C# format "<basesecret>|<utf8-hex-serial>".
func (b *BattleNetAuthenticator) SecretData() string {
return b.EncodeSecretData() + "|" + strings.ToUpper(hex.EncodeToString([]byte(b.Serial)))
}
func (b *BattleNetAuthenticator) SetSecretData(value string) error {
if value == "" {
b.SecretKey = nil
b.Serial = ""
return nil
}
parts := strings.Split(value, "|")
if len(parts) == 1 {
// legacy WinAuth2 form: 40 hex chars secret then UTF8-hex serial
if len(value) < 40 {
return fmt.Errorf("battlenet: secret data too short")
}
raw, err := hex.DecodeString(value[:40])
if err != nil {
return fmt.Errorf("battlenet: bad secret hex: %w", err)
}
b.SecretKey = raw
serialBytes, err := hex.DecodeString(value[40:])
if err == nil {
b.Serial = string(serialBytes)
}
return nil
}
if err := b.DecodeSecretData(parts[0]); err != nil {
return err
}
serialIdx := 1
if len(parts) == 3 {
// alpha 3.0.6 form: secret|script|serial
serialIdx = 2
}
if len(parts) > serialIdx {
raw, err := hex.DecodeString(parts[serialIdx])
if err == nil {
b.Serial = string(raw)
}
}
return nil
}
// Enroll registers a brand-new Battle.Net authenticator with the Blizzard
// mobile service. Country is auto-detected via Wikimedia GEO IP; pass an
// explicit two-letter override to skip the lookup.
func (b *BattleNetAuthenticator) Enroll(ctx context.Context, countryOverride string) error {
const fn = "internal.authenticator.BattleNetAuthenticator.Enroll"
logger := global.Log.WithField("func", fn)
country := strings.ToUpper(strings.TrimSpace(countryOverride))
region := "US"
if country == "" {
c, r := lookupRegion(ctx)
country, region = c, r
} else {
region = regionForCountry(country)
}
logger.WithField("country", country).WithField("region", region).Debug("region determined")
otp := mustOneTimePad(20)
payload := make([]byte, 38)
copy(payload[0:20], otp)
copy(payload[20:22], []byte(country))
copy(payload[22:38], []byte(randomModel()))
encrypted, err := rsaEncryptRaw(payload)
if err != nil {
return fmt.Errorf("battlenet: rsa encrypt failed: %w", err)
}
respBytes, err := bnetPostBinary(ctx, mobileURL(region)+bnetEnrollPath, encrypted)
if err != nil {
return err
}
if len(respBytes) != bnetEnrollRespSz {
return fmt.Errorf("battlenet: enroll response size %d, want %d", len(respBytes), bnetEnrollRespSz)
}
serverMs := int64(binary.BigEndian.Uint64(respBytes[0:8]))
b.ServerTimeDiff = serverMs - NowMillis()
secret := make([]byte, 20)
copy(secret, respBytes[25:45])
for i := range secret {
secret[i] ^= otp[i]
}
b.SecretKey = secret
b.Serial = string(respBytes[8:25])
logger.WithField("serial", b.Serial).Info("enrolled with Battle.Net mobile service")
return nil
}
// Sync re-aligns ServerTimeDiff against the mobile service for our region.
func (b *BattleNetAuthenticator) Sync() error {
const fn = "internal.authenticator.BattleNetAuthenticator.Sync"
logger := global.Log.WithField("func", fn).WithField("region", b.Region())
if b.SecretKey == nil {
logger.Debug("skip sync: no secret loaded")
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
url := mobileURL(b.Region()) + bnetSyncPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := httpc.New().Do(req)
if err != nil {
logger.WithError(err).Warn("sync failed; using local clock")
return nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if len(body) != bnetSyncRespSz {
return fmt.Errorf("battlenet: sync response size %d, want %d", len(body), bnetSyncRespSz)
}
serverMs := int64(binary.BigEndian.Uint64(body))
b.ServerTimeDiff = serverMs - NowMillis()
b.LastServerTime = NowMillis()
logger.WithField("offset_ms", b.ServerTimeDiff).Debug("clock synced")
return nil
}
// RestoreCode computes the 10-char Battle.Net restore code derived from
// the SHA1 of (serial || secretKey).
func (b *BattleNetAuthenticator) RestoreCode() string {
if b.Serial == "" || b.SecretKey == nil {
return ""
}
serial := strings.ReplaceAll(strings.ToUpper(b.Serial), "-", "")
hash := sha1.Sum(append([]byte(serial), b.SecretKey...))
out := make([]byte, 10)
for i := 0; i < 10; i++ {
out[i] = restoreByteToChar(hash[len(hash)-10+i])
}
return string(out)
}
// helpers ------------------------------------------------------------------
func mobileURL(region string) string {
region = strings.ToUpper(region)
if len(region) > 2 {
region = region[:2]
}
if u, ok := battlenetURLs[region]; ok {
return u
}
return battlenetURLs["US"]
}
func regionForCountry(country string) string {
switch country {
case "CN":
return "CN"
case "KR", "KP", "TW", "HK", "MO":
return "KR"
}
euCountries := []string{
"AL", "AD", "AM", "AT", "AZ", "BY", "BE", "BA", "BG", "HR",
"CY", "CZ", "DK", "EE", "FI", "FR", "GE", "DE", "GR", "HU",
"IS", "IE", "IT", "KV", "XK", "LV", "LI", "LT", "LU", "MK",
"MT", "MD", "MC", "ME", "NL", "NO", "PL", "PT", "RO", "RU",
"SM", "RS", "SK", "ES", "SE", "CH", "TR", "UA", "UK", "GB",
"VA",
}
for _, c := range euCountries {
if c == country {
return "EU"
}
}
return "US"
}
func lookupRegion(ctx context.Context) (country, region string) {
const fn = "internal.authenticator.lookupRegion"
logger := global.Log.WithField("func", fn)
geoCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(geoCtx, http.MethodGet, "http://geoiplookup.wikimedia.org", nil)
resp, err := httpc.New().Do(req)
if err != nil {
logger.WithError(err).Debug("geoip lookup failed; defaulting to US")
return "US", "US"
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// Cheap regex-free parse for "country":"XX"
const key = `"country":"`
idx := strings.Index(string(body), key)
if idx < 0 {
return "US", "US"
}
rest := string(body)[idx+len(key):]
end := strings.Index(rest, `"`)
if end < 0 {
return "US", "US"
}
country = strings.ToUpper(rest[:end])
return country, regionForCountry(country)
}
func bnetPostBinary(ctx context.Context, url string, body []byte) ([]byte, error) {
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := httpc.New().Do(req)
if err != nil {
return nil, fmt.Errorf("battlenet: contact mobile service: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("battlenet: server returned %d %s", resp.StatusCode, resp.Status)
}
return io.ReadAll(resp.Body)
}
func rsaEncryptRaw(data []byte) ([]byte, error) {
n, ok := new(big.Int).SetString(bnetEnrollModulus, 16)
if !ok {
return nil, fmt.Errorf("invalid RSA modulus")
}
e, ok := new(big.Int).SetString(bnetEnrollExponent, 16)
if !ok {
return nil, fmt.Errorf("invalid RSA exponent")
}
pub := &rsa.PublicKey{N: n, E: int(e.Int64())}
// The original C# uses BouncyCastle's RsaEngine.ProcessBlock without
// padding (raw RSA / "no padding"). We emulate that by padding the input
// to modulus length with leading zeros and using big.Int exponentiation.
keySize := (pub.N.BitLen() + 7) / 8
if len(data) > keySize {
return nil, fmt.Errorf("data too large for raw RSA")
}
m := new(big.Int).SetBytes(data)
c := new(big.Int).Exp(m, big.NewInt(int64(pub.E)), pub.N)
out := make([]byte, keySize)
cBytes := c.Bytes()
copy(out[keySize-len(cBytes):], cBytes)
return out, nil
}
func mustOneTimePad(n int) []byte {
out := make([]byte, n)
if _, err := rand.Read(out); err != nil {
panic(err)
}
return out
}
func randomModel() string {
var seedBytes [8]byte
_, _ = rand.Read(seedBytes[:])
r := mrand.New(mrand.NewSource(int64(binary.LittleEndian.Uint64(seedBytes[:]))))
out := make([]byte, bnetModelSize)
for i := range out {
out[i] = bnetModelChars[r.Intn(len(bnetModelChars))]
}
return string(out)
}
func restoreByteToChar(b byte) byte {
// Mirror C# ConvertRestoreCodeByteToChar — but we only need the inverse
// for code *display*. The original algorithm maps each byte to an alpha
// or numeric, skipping I, L, O, S to avoid ambiguity. Below is a direct
// translation of the C# code path that lives at the bottom of
// BattleNetAuthenticator.cs (ConvertRestoreCodeByteToChar).
v := int(b) & 0x1F // 5 bits
switch {
case v < 10:
return byte('0' + v)
}
c := v - 10 + 'A'
if c >= 'I' {
c++
}
if c >= 'L' {
c++
}
if c >= 'O' {
c++
}
if c >= 'S' {
c++
}
return byte(c)
}
+180
View File
@@ -0,0 +1,180 @@
package authenticator
import (
"context"
"crypto/hmac"
"crypto/sha1"
"fmt"
"strings"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Battle.Net paper-restore protocol constants. The server responds with
// a fixed 32-byte challenge to the initiate POST, and a fixed 20-byte
// secret to the validate POST. Mismatching sizes are treated as fatal
// since the binary protocol has no error envelope.
const (
bnetRestoreChallengeSize = 32
bnetRestoreSecretSize = 20
bnetSerialDigits = 14 // CC-NNNN-NNNN-NNNN after stripping dashes
bnetRestoreCodeLen = 10
)
// Restore recovers an existing Battle.Net authenticator's secret key
// using the 10-character paper restore code the user wrote down when
// they first enrolled.
//
// The wire protocol mirrors the original WinAuth implementation:
//
// 1. POST <serial-ascii-bytes> → /enrollment/initiatePaperRestore.htm
// ← 32-byte challenge
// 2. HMAC-SHA1(key = restoreCode-decoded-10-bytes,
// data = serial-bytes || challenge) → 20-byte signature
// 3. POST <serial || signature> → /enrollment/validatePaperRestore.htm
// ← 20-byte secret (the new SecretKey)
//
// SECURITY: restoreCode grants full account control if leaked. We do not
// log it, never persist it, and wipe the derived 10-byte key buffer
// before returning. The caller's restoreCode string is the caller's
// responsibility to manage.
func (b *BattleNetAuthenticator) Restore(ctx context.Context, serial, restoreCode string) error {
const fn = "internal.authenticator.BattleNetAuthenticator.Restore"
logger := global.Log.WithField("func", fn)
cleanSerial := normalizeBnetSerial(serial)
if len(cleanSerial) < bnetSerialDigits {
return fmt.Errorf("battlenet: serial must contain %d digits after the region prefix", bnetSerialDigits)
}
region := cleanSerial[:2]
if _, ok := battlenetURLs[region]; !ok {
return fmt.Errorf("battlenet: unknown region %q in serial", region)
}
logger.WithField("region", region).Debug("starting paper restore")
cleanCode := normalizeBnetRestoreCode(restoreCode)
if len(cleanCode) != bnetRestoreCodeLen {
return fmt.Errorf("battlenet: restore code must be %d characters", bnetRestoreCodeLen)
}
codeKey, err := decodeRestoreCode(cleanCode)
if err != nil {
return err
}
// Zero the derived key on return so it does not linger in stack/heap
// after the HMAC call has consumed it.
defer func() {
for i := range codeKey {
codeKey[i] = 0
}
}()
serialBytes := []byte(cleanSerial)
challenge, err := bnetPostBinary(ctx, mobileURL(region)+bnetRestorePath, serialBytes)
if err != nil {
return fmt.Errorf("battlenet: initiate restore: %w", err)
}
if len(challenge) != bnetRestoreChallengeSize {
return fmt.Errorf("battlenet: restore challenge size %d, want %d",
len(challenge), bnetRestoreChallengeSize)
}
mac := hmac.New(sha1.New, codeKey)
_, _ = mac.Write(serialBytes)
_, _ = mac.Write(challenge)
signature := mac.Sum(nil)
// POST body is serial-ascii || HMAC signature.
validateBody := make([]byte, 0, len(serialBytes)+len(signature))
validateBody = append(validateBody, serialBytes...)
validateBody = append(validateBody, signature...)
secret, err := bnetPostBinary(ctx, mobileURL(region)+bnetRestoreValidatePath, validateBody)
if err != nil {
return fmt.Errorf("battlenet: validate restore: %w", err)
}
if len(secret) != bnetRestoreSecretSize {
return fmt.Errorf("battlenet: restore secret size %d, want %d",
len(secret), bnetRestoreSecretSize)
}
b.SecretKey = secret
b.Serial = cleanSerial
b.RestoreCodeVerified = true
logger.WithField("serial", b.Serial).Info("paper restore succeeded")
return nil
}
// normalizeBnetSerial strips spaces and dashes, upper-cases, and returns
// the canonical form ("CCNNNNNNNNNNNN", 14 ASCII bytes when valid).
func normalizeBnetSerial(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "-", "")
s = strings.ReplaceAll(s, " ", "")
return s
}
// normalizeBnetRestoreCode strips formatting whitespace / dashes and
// upper-cases. The actual character-set validation happens in
// decodeRestoreCode.
func normalizeBnetRestoreCode(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "-", "")
s = strings.ReplaceAll(s, " ", "")
return s
}
// decodeRestoreCode is the inverse of restoreByteToChar applied 10
// times in a row: each character maps back to one byte (low 5 bits
// populated). The 10-byte buffer is what the protocol uses as the
// HMAC-SHA1 key for the validate step.
func decodeRestoreCode(code string) ([]byte, error) {
if len(code) != bnetRestoreCodeLen {
return nil, fmt.Errorf("battlenet: restore code must be %d characters", bnetRestoreCodeLen)
}
out := make([]byte, bnetRestoreCodeLen)
for i := 0; i < bnetRestoreCodeLen; i++ {
v, ok := restoreCharToByte(code[i])
if !ok {
return nil, fmt.Errorf("battlenet: invalid character %q in restore code", code[i])
}
out[i] = v
}
return out, nil
}
// restoreCharToByte is the inverse of restoreByteToChar. The encoding
// uses a 5-bit value: 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)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
package authenticator
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/httpc"
)
// GoogleAuthenticator implements the time-sync flavor of TOTP that Google,
// Microsoft and Okta all share. The only difference between vendors in the
// original C# port is the URL used to learn the server clock.
type GoogleAuthenticator struct {
Base
timeSyncURL string
}
// NewGoogleAuthenticator returns a Google-flavored TOTP authenticator.
func NewGoogleAuthenticator() *GoogleAuthenticator {
return newTOTP("https://www.google.com")
}
// NewMicrosoftAuthenticator is an alias kept for parity with the original
// C# class hierarchy.
func NewMicrosoftAuthenticator() *GoogleAuthenticator {
return newTOTP("https://www.microsoft.com")
}
// NewOktaVerifyAuthenticator returns a TOTP that syncs against okta.com.
func NewOktaVerifyAuthenticator() *GoogleAuthenticator {
return newTOTP("https://www.okta.com")
}
func newTOTP(syncURL string) *GoogleAuthenticator {
g := &GoogleAuthenticator{Base: NewBase(), timeSyncURL: syncURL}
return g
}
// Name returns a short identifier used in logs.
func (g *GoogleAuthenticator) Name() string {
switch g.timeSyncURL {
case "https://www.microsoft.com":
return "microsoft"
case "https://www.okta.com":
return "okta"
default:
return "google"
}
}
// Enroll loads a base32-encoded shared secret and then performs an initial
// clock sync against the vendor's HTTP endpoint.
func (g *GoogleAuthenticator) Enroll(b32 string) error {
const fn = "internal.authenticator.GoogleAuthenticator.Enroll"
raw, err := Base32Decode(b32)
if err != nil {
return err
}
g.SecretKey = raw
global.Log.WithField("func", fn).WithField("len", len(raw)).Debug("enrolled secret")
return g.Sync()
}
// CurrentCode returns the live TOTP for the receiver.
func (g *GoogleAuthenticator) CurrentCode() (string, error) {
if g.SecretKey == nil {
return "", fmt.Errorf("authenticator: no secret loaded")
}
return g.CalculateTOTP(), nil
}
// SecretData / SetSecretData delegate to the embedded Base.
func (g *GoogleAuthenticator) SecretData() string { return g.EncodeSecretData() }
func (g *GoogleAuthenticator) SetSecretData(value string) error { return g.DecodeSecretData(value) }
// Sync issues a HEAD request against the configured vendor URL and reads
// the response's Date header to derive ServerTimeDiff. Errors are swallowed
// in the same way as the original C# implementation — repeated failures
// should not block code generation, the local clock is the fallback.
func (g *GoogleAuthenticator) Sync() error {
const fn = "internal.authenticator.GoogleAuthenticator.Sync"
logger := global.Log.WithField("func", fn).WithField("url", g.timeSyncURL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodHead, g.timeSyncURL, nil)
if err != nil {
logger.WithError(err).Warn("build request failed")
return err
}
resp, err := httpc.New().Do(req)
if err != nil {
logger.WithError(err).Warn("sync request failed; using local clock")
return nil
}
defer resp.Body.Close()
dateStr := strings.TrimSpace(resp.Header.Get("Date"))
if dateStr == "" {
logger.Warn("response missing Date header")
return nil
}
t, err := http.ParseTime(dateStr)
if err != nil {
logger.WithError(err).Warn("invalid Date header")
return nil
}
serverMs := t.UnixMilli()
g.ServerTimeDiff = serverMs - NowMillis()
g.LastServerTime = NowMillis()
logger.WithField("offset_ms", g.ServerTimeDiff).Debug("clock synced")
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package authenticator
import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/binary"
"fmt"
"hash"
)
// hmacFor returns a fresh HMAC keyed with secret for the given hash type.
func hmacFor(h HMACType, secret []byte) hash.Hash {
switch h {
case HMACSHA256:
return hmac.New(sha256.New, secret)
case HMACSHA512:
return hmac.New(sha512.New, secret)
default:
return hmac.New(sha1.New, secret)
}
}
// hotpCode computes the RFC 4226 HOTP code for the given (secret, counter)
// pair, with the supplied number of decimal digits and HMAC algorithm.
func hotpCode(secret []byte, counter uint64, digits int, h HMACType) string {
mac := hmacFor(h, secret)
var counterBytes [8]byte
binary.BigEndian.PutUint64(counterBytes[:], counter)
_, _ = mac.Write(counterBytes[:])
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0F
truncated := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7FFFFFFF
mod := uint32(1)
for i := 0; i < digits; i++ {
mod *= 10
}
return fmt.Sprintf("%0*d", digits, truncated%mod)
}
@@ -0,0 +1,74 @@
package authenticator
import (
"fmt"
"strconv"
"strings"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// HOTPAuthenticator implements RFC 4226 counter-based HOTP.
type HOTPAuthenticator struct {
Base
Counter uint64
}
// NewHOTPAuthenticator returns a fresh HOTP authenticator with the project
// defaults (6 digits, SHA1).
func NewHOTPAuthenticator() *HOTPAuthenticator {
return &HOTPAuthenticator{Base: NewBase()}
}
// Name returns the short logger tag for this type.
func (h *HOTPAuthenticator) Name() string { return "hotp" }
// Enroll loads the secret from a base32 string and optionally seeds the
// counter.
func (h *HOTPAuthenticator) Enroll(b32 string, counter uint64) error {
const fn = "internal.authenticator.HOTPAuthenticator.Enroll"
raw, err := Base32Decode(b32)
if err != nil {
return err
}
h.SecretKey = raw
h.Counter = counter
global.Log.WithField("func", fn).WithField("counter", counter).Debug("enrolled HOTP")
return nil
}
// CurrentCode increments the internal counter and returns the resulting code.
func (h *HOTPAuthenticator) CurrentCode() (string, error) {
if h.SecretKey == nil {
return "", fmt.Errorf("authenticator: no secret loaded")
}
h.Counter++
digits := h.CodeDigits
if digits == 0 {
digits = DefaultCodeDigits
}
return hotpCode(h.SecretKey, h.Counter, digits, h.HMACType), nil
}
// Sync is a no-op for HOTP — there is no server clock to align against.
func (h *HOTPAuthenticator) Sync() error { return nil }
// SecretData appends "|<counter>" to the base secret data string, matching
// the C# HOTPAuthenticator.SecretData getter.
func (h *HOTPAuthenticator) SecretData() string {
return h.EncodeSecretData() + "|" + strconv.FormatUint(h.Counter, 10)
}
// SetSecretData parses the "<base>|<counter>" form.
func (h *HOTPAuthenticator) SetSecretData(value string) error {
if err := h.DecodeSecretData(value); err != nil {
return err
}
if idx := strings.Index(value, "|"); idx >= 0 {
c, err := strconv.ParseUint(strings.TrimSpace(value[idx+1:]), 10, 64)
if err == nil {
h.Counter = c
}
}
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package authenticator
import (
"encoding/hex"
"fmt"
"strconv"
"strings"
)
// EncodeSecretData encodes the "<key>\t<digits>\t<hmac>\t<period>" string
// that the original WinAuth config uses inside <secretdata>.
func (b *Base) EncodeSecretData() string {
return fmt.Sprintf(
"%s\t%d\t%s\t%d",
strings.ToUpper(hex.EncodeToString(b.SecretKey)),
b.CodeDigits, b.HMACType.String(), b.Period,
)
}
// DecodeSecretData parses the value produced by EncodeSecretData (and
// optionally a "|"-suffixed payload for HOTP subclasses).
func (b *Base) DecodeSecretData(value string) error {
if value == "" {
b.SecretKey = nil
return nil
}
head := strings.SplitN(value, "|", 2)[0]
parts := strings.Split(head, "\t")
if len(parts) == 0 {
return fmt.Errorf("authenticator: empty secret data")
}
raw, err := hex.DecodeString(parts[0])
if err != nil {
return fmt.Errorf("authenticator: bad secret hex: %w", err)
}
b.SecretKey = raw
if len(parts) > 1 {
if d, err := strconv.Atoi(parts[1]); err == nil {
b.CodeDigits = d
}
}
if len(parts) > 2 {
switch strings.ToUpper(parts[2]) {
case "SHA256":
b.HMACType = HMACSHA256
case "SHA512":
b.HMACType = HMACSHA512
default:
b.HMACType = HMACSHA1
}
}
if len(parts) > 3 {
if p, err := strconv.Atoi(parts[3]); err == nil && p > 0 {
b.Period = p
}
}
return nil
}
+177
View File
@@ -0,0 +1,177 @@
package authenticator
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/httpc"
)
// steamChars is the alphanumeric alphabet that Steam Guard maps the
// truncated HMAC into. It deliberately omits visually similar characters.
var steamChars = []byte{
'2', '3', '4', '5', '6', '7', '8', '9', 'B', 'C',
'D', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q',
'R', 'T', 'V', 'W', 'X', 'Y',
}
const (
steamCodeDigits = 5
steamQueryTime = "https://api.steampowered.com:443/ITwoFactorService/QueryTime/v0001"
)
// SteamAuthenticator implements Steam Guard's variant of TOTP. Full
// enrollment / login / session handling will be added in a later phase;
// this file covers code generation, time sync, and persistence — enough
// for an already-enrolled authenticator imported from the original WinAuth
// config to keep working.
type SteamAuthenticator struct {
Base
Serial string
DeviceID string
SteamData string // JSON blob from FinalizeAddAuthenticator
SessionData string // optional cookie/session JSON
}
// NewSteamAuthenticator returns a fresh 5-character Steam Guard authenticator.
func NewSteamAuthenticator() *SteamAuthenticator {
s := &SteamAuthenticator{Base: NewBase()}
s.CodeDigits = steamCodeDigits
return s
}
// Name returns the short logger tag for this type.
func (s *SteamAuthenticator) Name() string { return "steam" }
// CurrentCode returns the current 5-char Steam Guard code.
func (s *SteamAuthenticator) CurrentCode() (string, error) {
if s.SecretKey == nil {
return "", fmt.Errorf("steam: no secret loaded")
}
return s.steamCode(), nil
}
// steamCode mirrors the C# CalculateCode override, mapping a 4-byte
// truncation into the Steam alphabet.
func (s *SteamAuthenticator) steamCode() string {
mac := hmac.New(sha1.New, s.SecretKey)
var counter [8]byte
binary.BigEndian.PutUint64(counter[:], s.CodeInterval())
_, _ = mac.Write(counter[:])
sum := mac.Sum(nil)
start := sum[len(sum)-1] & 0x0F
full := binary.BigEndian.Uint32(sum[start:start+4]) & 0x7FFFFFFF
out := make([]byte, steamCodeDigits)
for i := 0; i < steamCodeDigits; i++ {
out[i] = steamChars[full%uint32(len(steamChars))]
full /= uint32(len(steamChars))
}
return string(out)
}
// Sync hits the Steam ITwoFactorService/QueryTime endpoint to recompute
// the local-vs-server clock offset.
func (s *SteamAuthenticator) Sync() error {
const fn = "internal.authenticator.SteamAuthenticator.Sync"
logger := global.Log.WithField("func", fn)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, steamQueryTime,
strings.NewReader("steamid=0"))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpc.New().Do(req)
if err != nil {
logger.WithError(err).Warn("query time failed; using local clock")
return nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var parsed struct {
Response struct {
ServerTime json.Number `json:"server_time"`
} `json:"response"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
logger.WithError(err).Warn("query time: invalid JSON")
return nil
}
serverSec, err := strconv.ParseInt(string(parsed.Response.ServerTime), 10, 64)
if err != nil {
logger.WithError(err).Warn("query time: bad server_time")
return nil
}
s.ServerTimeDiff = serverSec*1000 - NowMillis()
s.LastServerTime = NowMillis()
logger.WithField("offset_ms", s.ServerTimeDiff).Debug("clock synced")
return nil
}
// SecretData encodes the Steam-specific payload as "<base>|<serialhex>|<deviceidhex>|<steamdatahex>|<sessionhex>".
func (s *SteamAuthenticator) SecretData() string {
enc := func(v string) string { return strings.ToUpper(hex.EncodeToString([]byte(v))) }
return s.EncodeSecretData() + "|" +
enc(s.Serial) + "|" +
enc(s.DeviceID) + "|" +
enc(s.SteamData) + "|" +
enc(s.SessionData)
}
// SetSecretData reverses SecretData.
func (s *SteamAuthenticator) SetSecretData(value string) error {
if value == "" {
s.SecretKey = nil
s.Serial = ""
s.DeviceID = ""
s.SteamData = ""
s.SessionData = ""
return nil
}
parts := strings.Split(value, "|")
if err := s.DecodeSecretData(parts[0]); err != nil {
return err
}
dec := func(s string) string {
raw, _ := hex.DecodeString(s)
return string(raw)
}
if len(parts) > 1 {
s.Serial = dec(parts[1])
}
if len(parts) > 2 {
s.DeviceID = dec(parts[2])
}
if len(parts) > 3 {
s.SteamData = dec(parts[3])
if s.SteamData != "" && !strings.HasPrefix(s.SteamData, "{") {
// legacy WinAuth stored only the revocation_code; wrap to JSON
s.SteamData = `{"revocation_code":"` + s.SteamData + `"}`
}
}
if len(parts) > 4 {
s.SessionData = dec(parts[4])
}
return nil
}
+366
View File
@@ -0,0 +1,366 @@
package authenticator
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"sync"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Steam WebAPI endpoints used by the client (login / cookie refresh /
// logoff). Kept here rather than in steam_http.go because they are
// SteamClient-specific.
const (
steamAPIGetWGToken = "/IMobileAuthService/GetWGToken/v0001"
steamAPILogon = "/ISteamWebUserPresenceOAuth/Logon/v0001"
steamAPILogoff = "/ISteamWebUserPresenceOAuth/Logoff/v0001"
)
// SteamClient is the Go port of the C# SteamClient inner class. It owns
// a SteamSession, an attached SteamAuthenticator (for live TOTP codes),
// and an HTTP client with persistent cookie jar.
//
// SECURITY: holds OAuth token, login cookies, and a reference to the
// authenticator's shared secret via Authenticator.SteamData. Never log
// the Session directly.
type SteamClient struct {
Authenticator *SteamAuthenticator
Session *SteamSession
// Login state flags inspected by the UI after Login returns. They
// mirror the C# public fields exactly so the wizard logic stays
// recognisable.
InvalidLogin bool
RequiresCaptcha bool
CaptchaID string
CaptchaURL string
RequiresEmailAuth bool
EmailDomain string
Requires2FA bool
Error string
mu sync.Mutex
jar http.CookieJar
client *http.Client
// confirmationsHTML / confirmationsQuery hold the most recent
// GetConfirmations response so GetConfirmationDetails can wrap the
// per-trade detail HTML in the same outer markup. Recomputed every
// poll; never persisted.
confirmationsHTML string
confirmationsQuery string
// Poller hooks. Set by the UI before StartConfirmationPoller.
// Called from the poller goroutine — implementations must not
// block the UI thread or attempt to drive the Gio frame loop
// directly; marshal back via window.Invalidate or a channel.
OnConfirmation ConfirmationCallback
OnConfirmationError ConfirmationErrorCallback
// ConfirmationPollerRetries controls how many consecutive failed
// poll cycles trigger OnConfirmationError. Zero falls back to
// defaultConfirmationPollerRetries (3).
ConfirmationPollerRetries int
// poller is the handle to the currently running background
// goroutine, or nil if none is active.
poller *pollerHandle
}
// NewSteamClient builds a client around an existing authenticator. If
// sessionJSON is non-empty it is parsed via ParseSteamSession; the
// resulting cookies are loaded into the internal jar so subsequent
// requests carry the login state.
func NewSteamClient(auth *SteamAuthenticator, sessionJSON string) (*SteamClient, error) {
sess := ParseSteamSession(sessionJSON)
jar, err := newSteamCookieJar()
if err != nil {
return nil, err
}
// Replay any cookies the session already had into the live jar.
if len(sess.Cookies) > 0 {
u, _ := url.Parse(steamCommunityBase + "/")
cs := make([]*http.Cookie, 0, len(sess.Cookies))
for name, value := range sess.Cookies {
cs = append(cs, &http.Cookie{Name: name, Value: value})
}
jar.SetCookies(u, cs)
}
return &SteamClient{
Authenticator: auth,
Session: sess,
jar: jar,
client: steamHTTPClient(jar),
}, nil
}
// IsLoggedIn reports whether the session carries an OAuth token. Note
// this does NOT round-trip to Steam — the token might have been
// invalidated server-side.
func (c *SteamClient) IsLoggedIn() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.Session != nil && c.Session.OAuthToken != ""
}
// Clear resets the per-login flags and wipes the session. The jar is
// recreated so leftover Steam cookies do not bleed into the next login
// attempt.
func (c *SteamClient) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.clearLocked()
}
func (c *SteamClient) clearLocked() {
c.InvalidLogin = false
c.RequiresCaptcha = false
c.CaptchaID = ""
c.CaptchaURL = ""
c.RequiresEmailAuth = false
c.EmailDomain = ""
c.Requires2FA = false
c.Error = ""
if c.Session != nil {
c.Session.Clear()
}
if jar, err := newSteamCookieJar(); err == nil {
c.jar = jar
c.client = steamHTTPClient(jar)
}
}
// Login authenticates against Steam using credentials plus the live
// TOTP code from the bound authenticator. Returns true on success.
// On a non-fatal failure (captcha / email / 2FA needed, bad password)
// it returns (false, nil) with the corresponding Requires* flag and
// Error set. A non-nil error means the call could not even complete
// the transport-level handshake.
func (c *SteamClient) Login(
ctx context.Context, username, password, captchaID, captchaText string,
) (bool, error) {
const fn = "internal.authenticator.SteamClient.Login"
logger := global.Log.WithField("func", fn)
c.mu.Lock()
defer c.mu.Unlock()
c.Error = ""
if c.Session.OAuthToken != "" {
return true, nil
}
if !steamJarHasSessionID(c.jar) {
headers := http.Header{"X-Requested-With": []string{"com.valvesoftware.android.steam.community"}}
if _, err := steamRequest(ctx, c.client, http.MethodGet,
steamCommunityBase+"/mobilelogin?oauth_client_id="+steamOAuthClientID+
"&oauth_scope="+url.QueryEscape(steamOAuthScope), nil, headers); err != nil {
return false, fmt.Errorf("mobilelogin GET: %w", err)
}
}
username = stripNonASCII(username)
password = stripNonASCII(password)
rsaResp, err := steamRequest(ctx, c.client, http.MethodPost,
steamCommunityBase+"/mobilelogin/getrsakey",
url.Values{"username": {username}}, nil)
if err != nil {
return false, fmt.Errorf("getrsakey: %w", err)
}
var rsaJSON rsaKeyResponse
if err := json.Unmarshal([]byte(rsaResp), &rsaJSON); err != nil {
return false, fmt.Errorf("getrsakey parse: %w", err)
}
if !rsaJSON.Success {
c.InvalidLogin = true
c.Error = "Unknown username"
return false, nil
}
encPw, err := steamRSAEncryptPassword(password, rsaJSON.PublicKeyMod, rsaJSON.PublicKeyExp)
if err != nil {
return false, fmt.Errorf("rsa encrypt: %w", err)
}
// Live TOTP — the key difference from Enroll, which sends "".
twoFactor, err := c.Authenticator.CurrentCode()
if err != nil {
// No secret yet: send empty, Steam will reply requires_twofactor.
twoFactor = ""
}
if captchaID == "" {
captchaID = "-1"
}
if captchaText == "" {
captchaText = "enter above characters"
}
form := url.Values{
"password": {base64.StdEncoding.EncodeToString(encPw)},
"username": {username},
"twofactorcode": {twoFactor},
"loginfriendlyname": {"#login_emailauth_friendlyname_mobile"},
"captchagid": {captchaID},
"captcha_text": {captchaText},
"rsatimestamp": {rsaJSON.Timestamp},
"remember_login": {"false"},
"oauth_client_id": {steamOAuthClientID},
"oauth_scope": {steamOAuthScope},
"donotache": {strconv.FormatInt(time.Now().UnixMilli(), 10)},
}
// Wipe the local plaintext password copy now that it is RSA-encrypted.
password = ""
_ = password
loginResp, err := steamRequest(ctx, c.client, http.MethodPost,
steamCommunityBase+"/mobilelogin/dologin/", form, nil)
if err != nil {
return false, fmt.Errorf("dologin: %w", err)
}
var login map[string]any
if err := json.Unmarshal([]byte(loginResp), &login); err != nil {
return false, fmt.Errorf("dologin parse: %w", err)
}
if v, ok := login["emailsteamid"].(string); ok {
c.Session.SteamId = v
}
c.InvalidLogin = false
c.RequiresCaptcha = false
c.CaptchaID = ""
c.CaptchaURL = ""
c.RequiresEmailAuth = false
c.EmailDomain = ""
c.Requires2FA = false
complete, _ := login["login_complete"].(bool)
oauthStr, _ := login["oauth"].(string)
if !complete || oauthStr == "" {
c.InvalidLogin = true
if b, _ := login["captcha_needed"].(bool); b {
c.RequiresCaptcha = true
if id, ok := login["captcha_gid"].(string); ok {
c.CaptchaID = id
c.CaptchaURL = steamCommunityBase + "/public/captcha.php?gid=" + id
}
}
if b, _ := login["emailauth_needed"].(bool); b {
if d, ok := login["emaildomain"].(string); ok && d != "" {
c.EmailDomain = d
}
c.RequiresEmailAuth = true
}
if b, _ := login["requires_twofactor"].(bool); b {
c.Requires2FA = true
}
if msg, ok := login["message"].(string); ok && msg != "" {
c.Error = msg
}
return false, nil
}
var oauth struct {
OAuthToken string `json:"oauth_token"`
SteamID string `json:"steamid"`
}
if err := json.Unmarshal([]byte(oauthStr), &oauth); err != nil {
return false, fmt.Errorf("oauth parse: %w", err)
}
c.Session.OAuthToken = oauth.OAuthToken
if oauth.SteamID != "" {
c.Session.SteamId = oauth.SteamID
}
c.syncCookiesFromJarLocked()
logger.WithField("steamid", c.Session.SteamId).Info("steam login ok")
return true, nil
}
// Refresh exchanges the stored OAuth token for fresh steamLogin /
// steamLoginSecure cookies via IMobileAuthService/GetWGToken. Returns
// true on success; false (with no error) if the response is missing
// expected fields, mirroring the C# best-effort behaviour.
func (c *SteamClient) Refresh(ctx context.Context) (bool, error) {
const fn = "internal.authenticator.SteamClient.Refresh"
logger := global.Log.WithField("func", fn)
c.mu.Lock()
defer c.mu.Unlock()
if c.Session == nil || c.Session.OAuthToken == "" {
return false, errors.New("steam: refresh without OAuth token")
}
resp, err := steamRequest(ctx, c.client, http.MethodPost,
steamWebAPIBase+steamAPIGetWGToken,
url.Values{"access_token": {c.Session.OAuthToken}}, nil)
if err != nil {
return false, fmt.Errorf("GetWGToken: %w", err)
}
var parsed struct {
Response struct {
Token string `json:"token"`
TokenSecure string `json:"token_secure"`
} `json:"response"`
}
if err := json.Unmarshal([]byte(resp), &parsed); err != nil {
logger.WithError(err).Warn("GetWGToken parse failed")
return false, nil
}
if parsed.Response.Token == "" || parsed.Response.TokenSecure == "" {
return false, nil
}
u, _ := url.Parse(steamCommunityBase + "/")
c.jar.SetCookies(u, []*http.Cookie{
{Name: "steamLogin", Value: c.Session.SteamId + "||" + parsed.Response.Token},
{Name: "steamLoginSecure", Value: c.Session.SteamId + "||" + parsed.Response.TokenSecure},
})
c.syncCookiesFromJarLocked()
logger.Debug("session cookies refreshed")
return true, nil
}
// Logout best-effort revokes the UMQ session (if one was opened) and
// then clears the local state. Network errors are swallowed — the
// local state must still end up cleared regardless of server reply.
func (c *SteamClient) Logout(ctx context.Context) {
c.mu.Lock()
defer c.mu.Unlock()
if c.Session != nil && c.Session.OAuthToken != "" && c.Session.UmqId != "" {
_, _ = steamRequest(ctx, c.client, http.MethodPost,
steamWebAPIBase+steamAPILogoff,
url.Values{
"access_token": {c.Session.OAuthToken},
"umqid": {c.Session.UmqId},
}, nil)
}
c.clearLocked()
}
// syncCookiesFromJarLocked copies the community-domain cookies out of
// the live jar into Session.Cookies. Must be called with c.mu held.
func (c *SteamClient) syncCookiesFromJarLocked() {
if c.Session == nil || c.jar == nil {
return
}
u, _ := url.Parse(steamCommunityBase + "/")
c.Session.Cookies = map[string]string{}
for _, ck := range c.jar.Cookies(u) {
c.Session.Cookies[ck.Name] = ck.Value
}
}
@@ -0,0 +1,380 @@
package authenticator
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Steam mobileconf endpoints. Trade confirmations live on the
// community domain, not the WebAPI.
const (
steamMobileConfList = "/mobileconf/conf"
steamMobileConfDetails = "/mobileconf/details/"
steamMobileConfAjaxOp = "/mobileconf/ajaxop"
)
// Regexes ported verbatim from the C# SteamClient. The Steam mobile
// confirmations page is plain HTML and Valve has not provided a JSON
// alternative, so we have to scrape. Keep flags ((?is) = case-insensitive,
// dot matches newline) aligned with the C# Singleline|IgnoreCase combo.
var (
steamRegexTrades = regexp.MustCompile(`(?is)"mobileconf_list_entry"(.*?)>(.*?)"mobileconf_list_entry_sep"`)
steamRegexTradeConfID = regexp.MustCompile(`(?is)data-confid\s*=\s*"([^"]+)"`)
steamRegexTradeKey = regexp.MustCompile(`(?is)data-key\s*=\s*"([^"]+)"`)
steamRegexTradePlayer = regexp.MustCompile(`(?is)"mobileconf_list_entry_icon"(.*?)src="([^"]+)"`)
steamRegexTradeDetails = regexp.MustCompile(`(?is)"mobileconf_list_entry_description".*?<div>([^<]*)</div>[^<]*<div>([^<]*)</div>[^<]*<div>([^<]*)</div>[^<]*</div>`)
steamRegexConfDetails = regexp.MustCompile(`(?is)(.*<body[^>]*>\s*<div\s+class="[^"]+">).*(</div>.*?</body>\s*</html>)`)
)
// ErrSteamRequestInvalid is returned by ConfirmTrade / GetConfirmation*
// when Steam answers with a body that doesn't even parse as the expected
// success envelope. Lets the UI surface "try again" vs a hard failure.
var ErrSteamRequestInvalid = errors.New("steam: invalid response")
// confirmationsHTML / confirmationsQuery hold the last GetConfirmations
// result so GetConfirmationDetails can wrap individual detail HTML in
// the same outer body markup the user already trusts. They are NOT
// persisted — recomputed every poll.
//
// Storing them as private fields on SteamClient keeps the method
// signatures matching the C# code.
// GetConfirmations fetches the user's current pending trade / market
// confirmations. The bound authenticator MUST already have SteamData
// holding identity_secret, otherwise the request will be rejected.
//
// On success the returned slice describes each pending confirmation. As
// a side effect, if Session.Confirmations is non-nil its Ids set is
// updated and each returned Confirmation has IsNew populated.
func (c *SteamClient) GetConfirmations(ctx context.Context) ([]Confirmation, error) {
const fn = "internal.authenticator.SteamClient.GetConfirmations"
logger := global.Log.WithField("func", fn)
c.mu.Lock()
auth := c.Authenticator
c.mu.Unlock()
if auth == nil {
return nil, errors.New("steam: GetConfirmations without authenticator")
}
identitySecret, err := extractIdentitySecret(auth.SteamData)
if err != nil {
return nil, err
}
if identitySecret == "" {
return nil, errors.New("steam: identity_secret missing from SteamData")
}
c.mu.Lock()
serverTime := (NowMillis() + auth.ServerTimeDiff) / 1000
deviceID := auth.DeviceID
steamID := ""
if c.Session != nil {
steamID = c.Session.SteamId
}
c.mu.Unlock()
timehash, err := steamCreateTimeHash(serverTime, "conf", identitySecret)
if err != nil {
return nil, err
}
form := url.Values{
"p": {deviceID},
"a": {steamID},
"k": {timehash},
"t": {strconv.FormatInt(serverTime, 10)},
"m": {"android"},
"tag": {"conf"},
}
c.mu.Lock()
client := c.client
c.mu.Unlock()
html, err := steamRequest(ctx, client, http.MethodGet,
steamCommunityBase+steamMobileConfList, form, nil)
if err != nil {
return nil, fmt.Errorf("mobileconf/conf: %w", err)
}
c.mu.Lock()
c.confirmationsHTML = html
c.confirmationsQuery = form.Encode()
c.mu.Unlock()
trades := parseConfirmationsHTML(html)
// Maintain the poller's id-set: mark which trades are new and prune
// ids that are no longer pending. Mirrors the C# locked block.
c.mu.Lock()
if c.Session != nil && c.Session.Confirmations != nil {
if c.Session.Confirmations.Ids == nil {
c.Session.Confirmations.Ids = []string{}
}
known := make(map[string]bool, len(c.Session.Confirmations.Ids))
for _, id := range c.Session.Confirmations.Ids {
known[id] = true
}
seen := make(map[string]bool, len(trades))
for i := range trades {
seen[trades[i].Id] = true
if !known[trades[i].Id] {
trades[i].IsNew = true
c.Session.Confirmations.Ids = append(c.Session.Confirmations.Ids, trades[i].Id)
known[trades[i].Id] = true
}
}
// Drop ids that are no longer pending.
kept := c.Session.Confirmations.Ids[:0]
for _, id := range c.Session.Confirmations.Ids {
if seen[id] {
kept = append(kept, id)
}
}
c.Session.Confirmations.Ids = kept
}
c.mu.Unlock()
logger.WithField("count", len(trades)).Debug("fetched confirmations")
return trades, nil
}
// GetConfirmationDetails returns the HTML fragment Steam serves for the
// inner details of a single confirmation, wrapped in the outer markup
// captured by the most recent GetConfirmations call.
func (c *SteamClient) GetConfirmationDetails(ctx context.Context, trade Confirmation) (string, error) {
c.mu.Lock()
client := c.client
confHTML := c.confirmationsHTML
confQuery := c.confirmationsQuery
c.mu.Unlock()
if client == nil {
return "", errors.New("steam: client not initialised")
}
detailURL := steamCommunityBase + steamMobileConfDetails + url.PathEscape(trade.Id)
if confQuery != "" {
detailURL += "?" + confQuery
}
resp, err := steamRequest(ctx, client, http.MethodGet, detailURL, nil, nil)
if err != nil {
return "", fmt.Errorf("mobileconf/details: %w", err)
}
if !strings.Contains(resp, "success") {
return "", fmt.Errorf("%w: %s", ErrSteamRequestInvalid, resp)
}
var detail struct {
Success bool `json:"success"`
HTML string `json:"html"`
}
if err := json.Unmarshal([]byte(resp), &detail); err != nil {
return "", fmt.Errorf("mobileconf/details parse: %w", err)
}
if !detail.Success {
return fallbackDetailsHTML(), nil
}
if m := steamRegexConfDetails.FindStringSubmatch(confHTML); len(m) >= 3 {
return m[1] + detail.HTML + m[2], nil
}
return fallbackDetailsHTML(), nil
}
// ConfirmTrade accepts or rejects a single pending confirmation. Returns
// (true, nil) on success. (false, nil) means Steam answered with
// success=false; a non-nil error is a transport or parse failure.
func (c *SteamClient) ConfirmTrade(ctx context.Context, id, key string, accept bool) (bool, error) {
const fn = "internal.authenticator.SteamClient.ConfirmTrade"
logger := global.Log.WithField("func", fn)
c.mu.Lock()
auth := c.Authenticator
hasToken := c.Session != nil && c.Session.OAuthToken != ""
steamID := ""
if c.Session != nil {
steamID = c.Session.SteamId
}
client := c.client
c.mu.Unlock()
if !hasToken {
return false, nil
}
if auth == nil {
return false, errors.New("steam: ConfirmTrade without authenticator")
}
identitySecret, err := extractIdentitySecret(auth.SteamData)
if err != nil {
return false, err
}
if identitySecret == "" {
return false, errors.New("steam: identity_secret missing from SteamData")
}
serverTime := (NowMillis() + auth.ServerTimeDiff) / 1000
timehash, err := steamCreateTimeHash(serverTime, "conf", identitySecret)
if err != nil {
return false, err
}
op := "cancel"
if accept {
op = "allow"
}
form := url.Values{
"op": {op},
"p": {auth.DeviceID},
"a": {steamID},
"k": {timehash},
"t": {strconv.FormatInt(serverTime, 10)},
"m": {"android"},
"tag": {"conf"},
"cid": {id},
"ck": {key},
}
resp, err := steamRequest(ctx, client, http.MethodGet,
steamCommunityBase+steamMobileConfAjaxOp, form, nil)
if err != nil {
c.mu.Lock()
c.Error = err.Error()
c.mu.Unlock()
return false, err
}
if resp == "" {
c.mu.Lock()
c.Error = "Blank response"
c.mu.Unlock()
return false, nil
}
var parsed struct {
Success bool `json:"success"`
}
if err := json.Unmarshal([]byte(resp), &parsed); err != nil {
c.mu.Lock()
c.Error = "Failed"
c.mu.Unlock()
return false, nil
}
if !parsed.Success {
c.mu.Lock()
c.Error = "Failed"
c.mu.Unlock()
return false, nil
}
// Drop the id from the poller set so the next poll does not see it
// as still pending.
c.mu.Lock()
if c.Session != nil && c.Session.Confirmations != nil {
kept := c.Session.Confirmations.Ids[:0]
for _, x := range c.Session.Confirmations.Ids {
if x != id {
kept = append(kept, x)
}
}
c.Session.Confirmations.Ids = kept
}
c.mu.Unlock()
logger.WithField("op", op).WithField("id", id).Info("trade confirmation submitted")
return true, nil
}
// parseConfirmationsHTML extracts every <div class="mobileconf_list_entry">
// from the mobileconf/conf response. Empty result is a valid outcome
// (the user simply has no pending trades).
func parseConfirmationsHTML(html string) []Confirmation {
var trades []Confirmation
for _, m := range steamRegexTrades.FindAllStringSubmatch(html, -1) {
if len(m) < 3 {
continue
}
head, body := m[1], m[2]
var conf Confirmation
if cm := steamRegexTradeConfID.FindStringSubmatch(head); len(cm) >= 2 {
conf.Id = cm[1]
}
if km := steamRegexTradeKey.FindStringSubmatch(head); len(km) >= 2 {
conf.Key = km[1]
}
if pm := steamRegexTradePlayer.FindStringSubmatch(body); len(pm) >= 3 {
if strings.Contains(pm[1], "offline") {
conf.Offline = true
}
conf.Image = pm[2]
}
if dm := steamRegexTradeDetails.FindStringSubmatch(body); len(dm) >= 4 {
conf.Details = dm[1]
conf.Traded = dm[2]
conf.When = dm[3]
}
trades = append(trades, conf)
}
return trades
}
// extractIdentitySecret pulls the identity_secret string out of the
// authenticator's SteamData JSON envelope. Returns "" without error if
// SteamData is empty so callers can give the user a friendlier message.
func extractIdentitySecret(steamData string) (string, error) {
if strings.TrimSpace(steamData) == "" {
return "", nil
}
var parsed map[string]any
if err := json.Unmarshal([]byte(steamData), &parsed); err != nil {
return "", fmt.Errorf("steam: SteamData parse: %w", err)
}
if v, ok := parsed["identity_secret"].(string); ok {
return v, nil
}
return "", nil
}
// steamCreateTimeHash is the HMAC-SHA1 signature scheme Steam's mobile
// app uses to authenticate confirmation requests. The buffer is the
// 8-byte big-endian server time followed by up to 32 bytes of the tag
// string (UTF-8). The key is the base64-decoded identity_secret.
func steamCreateTimeHash(serverTime int64, tag, identitySecret string) (string, error) {
key, err := base64.StdEncoding.DecodeString(identitySecret)
if err != nil {
return "", fmt.Errorf("steam: identity_secret base64: %w", err)
}
tagLen := len(tag)
if tagLen > 32 {
tagLen = 32
}
buf := make([]byte, 8+tagLen)
binary.BigEndian.PutUint64(buf[:8], uint64(serverTime))
if tagLen > 0 {
copy(buf[8:], tag[:tagLen])
}
mac := hmac.New(sha1.New, key)
_, _ = mac.Write(buf)
return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
}
// fallbackDetailsHTML is the placeholder body the UI shows when Steam
// cannot or will not produce real details HTML. Identical text to the
// C# port to keep i18n / screenshots stable.
func fallbackDetailsHTML() string {
return "<html><head></head><body><p>Cannot load trade confirmation details</p></body></html>"
}
+477
View File
@@ -0,0 +1,477 @@
package authenticator
import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// EnrollState carries the cross-call state of a Steam enrollment. The
// caller (UI) creates one, fills in Username/Password (and optionally
// CaptchaText / EmailAuthText / ActivationCode on retries), and calls
// SteamAuthenticator.Enroll repeatedly until Success == true or Error
// is set to a fatal message.
type EnrollState struct {
// Inputs supplied by the caller across multiple calls.
Username string
Password string
CaptchaID string
CaptchaURL string
CaptchaText string
EmailDomain string
EmailAuthText string
ActivationCode string
// Session state carried across calls. The cookie jar is internal to
// the http client; we cache it here so retries reuse it.
jar http.CookieJar
SteamID string
OAuthToken string
// Flags the caller inspects after each Enroll call to decide which
// extra input is needed next.
RequiresLogin bool
RequiresCaptcha bool
Requires2FA bool
RequiresEmailAuth bool
RequiresActivation bool
// Outputs populated once enrollment succeeds. RevocationCode MUST
// be displayed to the user — losing it locks them out of the
// authenticator removal flow.
RevocationCode string
SecretKey string // hex form, for convenience; raw is on the authenticator
Success bool
// Error is the last user-facing message. Cleared at the start of
// every Enroll call.
Error string
}
// enrollActivateRetries matches the C# ENROLL_ACTIVATE_RETRIES constant.
const enrollActivateRetries = 30
// invalidActivationCode is the response.status value Steam returns when
// the SMS code the user typed is wrong. Mirrors the C# constant.
const invalidActivationCode = 89
// rsaKeyResponse models the /mobilelogin/getrsakey response.
type rsaKeyResponse struct {
Success bool `json:"success"`
PublicKeyMod string `json:"publickey_mod"`
PublicKeyExp string `json:"publickey_exp"`
Timestamp string `json:"timestamp"`
}
// Enroll drives one step of the Steam mobile-authenticator enrollment
// state machine. Call it repeatedly with the same EnrollState until it
// returns (true, nil) or sets state.Error.
//
// Return value:
// - (true, nil) → enrollment fully complete; this authenticator now
// holds the new secret/serial/deviceid/steamdata.
// - (false, nil) → caller must inspect state.Requires* and supply the
// missing input (captcha text, email code, 2FA
// activation code) then call Enroll again.
// - (false, err) → unrecoverable transport / parse error.
//
// SECURITY: state.Password is wiped from the struct after a successful
// RSA-encrypted login round, so a subsequent retry (e.g. for activation
// code) does not keep the cleartext password resident.
func (s *SteamAuthenticator) Enroll(ctx context.Context, state *EnrollState) (bool, error) {
const fn = "internal.authenticator.SteamAuthenticator.Enroll"
logger := global.Log.WithField("func", fn)
state.Error = ""
if state.jar == nil {
jar, err := newSteamCookieJar()
if err != nil {
return false, err
}
state.jar = jar
}
client := steamHTTPClient(state.jar)
if state.OAuthToken == "" {
// One-time GET so the jar gets the sessionid cookie. Mirrors
// the C# "if cookies.Count == 0" branch.
if !steamJarHasSessionID(state.jar) {
headers := http.Header{"X-Requested-With": []string{"com.valvesoftware.android.steam.community"}}
_, err := steamRequest(ctx, client, http.MethodGet,
steamCommunityBase+"/mobilelogin?oauth_client_id="+steamOAuthClientID+
"&oauth_scope="+url.QueryEscape(steamOAuthScope), nil, headers)
if err != nil {
return false, fmt.Errorf("mobilelogin GET: %w", err)
}
}
state.Username = stripNonASCII(state.Username)
state.Password = stripNonASCII(state.Password)
rsaResp, err := steamRequest(ctx, client, http.MethodPost,
steamCommunityBase+"/mobilelogin/getrsakey",
url.Values{"username": {state.Username}}, nil)
if err != nil {
return false, fmt.Errorf("getrsakey: %w", err)
}
var rsaJSON rsaKeyResponse
if err := json.Unmarshal([]byte(rsaResp), &rsaJSON); err != nil {
return false, fmt.Errorf("getrsakey parse: %w", err)
}
if !rsaJSON.Success {
return false, errors.New("steam: cannot fetch RSA key for user")
}
encPw, err := steamRSAEncryptPassword(state.Password, rsaJSON.PublicKeyMod, rsaJSON.PublicKeyExp)
if err != nil {
return false, fmt.Errorf("rsa encrypt: %w", err)
}
captchaID := state.CaptchaID
if captchaID == "" {
captchaID = "-1"
}
captchaText := state.CaptchaText
if captchaText == "" {
captchaText = "enter above characters"
}
emailSteamID := ""
if state.EmailAuthText != "" {
emailSteamID = state.SteamID
}
loginForm := url.Values{
"password": {base64.StdEncoding.EncodeToString(encPw)},
"username": {state.Username},
"twofactorcode": {""},
"emailauth": {state.EmailAuthText},
"loginfriendlyname": {"#login_emailauth_friendlyname_mobile"},
"captchagid": {captchaID},
"captcha_text": {captchaText},
"emailsteamid": {emailSteamID},
"rsatimestamp": {rsaJSON.Timestamp},
"remember_login": {"false"},
"oauth_client_id": {steamOAuthClientID},
"oauth_scope": {steamOAuthScope},
"donotache": {strconv.FormatInt(time.Now().UnixMilli(), 10)},
}
loginResp, err := steamRequest(ctx, client, http.MethodPost,
steamCommunityBase+"/mobilelogin/dologin/", loginForm, nil)
if err != nil {
return false, fmt.Errorf("dologin: %w", err)
}
// Wipe the plaintext password from memory now that it has been
// RSA-encrypted and sent.
state.Password = ""
var login map[string]any
if err := json.Unmarshal([]byte(loginResp), &login); err != nil {
return false, fmt.Errorf("dologin parse: %w", err)
}
if v, ok := login["emailsteamid"].(string); ok {
state.SteamID = v
}
if b, _ := login["captcha_needed"].(bool); b {
state.RequiresCaptcha = true
if id, ok := login["captcha_gid"].(string); ok {
state.CaptchaID = id
state.CaptchaURL = steamCommunityBase + "/public/captcha.php?gid=" + id
}
} else {
state.RequiresCaptcha = false
state.CaptchaID = ""
state.CaptchaURL = ""
state.CaptchaText = ""
}
if b, _ := login["emailauth_needed"].(bool); b {
if d, ok := login["emaildomain"].(string); ok && d != "" {
state.EmailDomain = d
}
state.RequiresEmailAuth = true
} else {
state.EmailDomain = ""
state.RequiresEmailAuth = false
}
if b, _ := login["requires_twofactor"].(bool); b {
state.Requires2FA = true
} else {
state.Requires2FA = false
}
complete, _ := login["login_complete"].(bool)
oauthStr, _ := login["oauth"].(string)
if !complete || oauthStr == "" {
if oauthStr == "" {
state.Error = "Invalid response from Steam (No OAuth token)"
}
if msg, ok := login["message"].(string); ok && msg != "" {
state.Error = msg
}
return false, nil
}
// oauth is a JSON-stringified inner object.
var oauth struct {
OAuthToken string `json:"oauth_token"`
SteamID string `json:"steamid"`
}
if err := json.Unmarshal([]byte(oauthStr), &oauth); err != nil {
return false, fmt.Errorf("oauth parse: %w", err)
}
state.OAuthToken = oauth.OAuthToken
if oauth.SteamID != "" {
state.SteamID = oauth.SteamID
}
}
// Logon to WebAPI (needed for the ITwoFactorService calls below).
if _, err := steamRequest(ctx, client, http.MethodPost,
steamWebAPIBase+"/ISteamWebUserPresenceOAuth/Logon/v0001",
url.Values{"access_token": {state.OAuthToken}}, nil); err != nil {
return false, fmt.Errorf("ISteamWebUserPresenceOAuth/Logon: %w", err)
}
sessionID := steamJarSessionID(state.jar)
if !state.RequiresActivation {
// Phone check. No phone → cannot enroll.
phoneResp, err := steamRequest(ctx, client, http.MethodPost,
steamCommunityBase+"/steamguard/phoneajax",
url.Values{"op": {"has_phone"}, "arg": {"null"}, "sessionid": {sessionID}}, nil)
if err != nil {
return false, fmt.Errorf("phoneajax: %w", err)
}
var phoneJSON struct {
HasPhone bool `json:"has_phone"`
}
if err := json.Unmarshal([]byte(phoneResp), &phoneJSON); err != nil {
return false, fmt.Errorf("phoneajax parse: %w", err)
}
if !phoneJSON.HasPhone {
state.OAuthToken = ""
state.RequiresLogin = true
state.jar = nil
state.Error = "Your Steam account must have a SMS-capable phone number attached. Go into Account Details of the Steam client or Steam website and click Add a Phone Number."
return false, nil
}
deviceID := buildRandomDeviceID()
addResp, err := steamRequest(ctx, client, http.MethodPost,
steamWebAPIBase+"/ITwoFactorService/AddAuthenticator/v0001",
url.Values{
"access_token": {state.OAuthToken},
"steamid": {state.SteamID},
"authenticator_type": {"1"},
"device_identifier": {deviceID},
"sms_phone_id": {"1"},
}, nil)
if err != nil {
return false, fmt.Errorf("AddAuthenticator: %w", err)
}
// The C# branch on response.status == 84 = "SMS send failed".
var addJSON struct {
Response struct {
Status int `json:"status"`
SharedSecret string `json:"shared_secret"`
SerialNumber string `json:"serial_number"`
RevocationCode string `json:"revocation_code"`
ServerTime json.Number `json:"server_time"`
Raw json.RawMessage `json:"-"`
} `json:"response"`
}
if err := json.Unmarshal([]byte(addResp), &addJSON); err != nil {
return false, fmt.Errorf("AddAuthenticator parse: %w", err)
}
if addJSON.Response.Status == 84 {
state.OAuthToken = ""
state.RequiresLogin = true
state.jar = nil
state.Error = "Unable to send SMS. Check your phone is registered on your Steam account."
return false, nil
}
if addJSON.Response.SharedSecret == "" {
state.OAuthToken = ""
state.RequiresLogin = true
state.jar = nil
state.Error = "Invalid response from Steam"
return false, nil
}
secretRaw, err := base64.StdEncoding.DecodeString(addJSON.Response.SharedSecret)
if err != nil {
return false, fmt.Errorf("shared_secret decode: %w", err)
}
s.SecretKey = secretRaw
s.Serial = addJSON.Response.SerialNumber
s.DeviceID = deviceID
state.RevocationCode = addJSON.Response.RevocationCode
// Re-parse the response.* sub-object so we can preserve every
// field Steam returned and just inject steamid / steamguard_scheme
// if missing, matching the C# behaviour.
var envelope map[string]json.RawMessage
_ = json.Unmarshal([]byte(addResp), &envelope)
var steamDataMap map[string]any
if raw, ok := envelope["response"]; ok {
_ = json.Unmarshal(raw, &steamDataMap)
}
if steamDataMap == nil {
steamDataMap = map[string]any{}
}
if _, ok := steamDataMap["steamid"]; !ok {
steamDataMap["steamid"] = state.SteamID
}
if _, ok := steamDataMap["steamguard_scheme"]; !ok {
steamDataMap["steamguard_scheme"] = "2"
}
steamDataBytes, _ := json.Marshal(steamDataMap)
s.SteamData = string(steamDataBytes)
if addJSON.Response.ServerTime != "" {
if sec, err := strconv.ParseInt(string(addJSON.Response.ServerTime), 10, 64); err == nil {
s.ServerTimeDiff = sec*1000 - NowMillis()
s.LastServerTime = NowMillis()
}
}
state.RequiresActivation = true
logger.Info("authenticator added; awaiting SMS activation code")
return false, nil
}
// Activation: try up to ENROLL_ACTIVATE_RETRIES times. Between
// retries we slide ServerTimeDiff forward by one TOTP period to
// align with whichever step Steam is expecting.
retries := 0
for state.RequiresActivation && retries < enrollActivateRetries {
form := url.Values{
"access_token": {state.OAuthToken},
"steamid": {state.SteamID},
"activation_code": {state.ActivationCode},
"authenticator_code": {s.steamCode()},
"authenticator_time": {strconv.FormatInt(s.ServerTime()/1000, 10)},
}
finResp, err := steamRequest(ctx, client, http.MethodPost,
steamWebAPIBase+"/ITwoFactorService/FinalizeAddAuthenticator/v0001", form, nil)
if err != nil {
return false, fmt.Errorf("FinalizeAddAuthenticator: %w", err)
}
var fin struct {
Response struct {
Status int `json:"status"`
Success bool `json:"success"`
WantMore bool `json:"want_more"`
ServerTime json.Number `json:"server_time"`
} `json:"response"`
}
if err := json.Unmarshal([]byte(finResp), &fin); err != nil {
return false, fmt.Errorf("FinalizeAddAuthenticator parse: %w", err)
}
if fin.Response.Status == invalidActivationCode {
state.Error = "Invalid activation code"
return false, nil
}
if fin.Response.ServerTime != "" {
if sec, err := strconv.ParseInt(string(fin.Response.ServerTime), 10, 64); err == nil {
s.ServerTimeDiff = sec*1000 - NowMillis()
s.LastServerTime = NowMillis()
}
}
if fin.Response.Success {
if fin.Response.WantMore {
s.advanceServerTime()
retries++
continue
}
state.RequiresActivation = false
break
}
s.advanceServerTime()
retries++
}
if state.RequiresActivation {
state.Error = "There was a problem activating. There might be an issue with the Steam servers. Please try again later."
return false, nil
}
state.Success = true
state.SecretKey = strings.ToUpper(hex.EncodeToString(s.SecretKey))
// Best-effort confirmation email; failures are non-fatal because
// the authenticator is already activated.
_, _ = steamRequest(ctx, client, http.MethodPost,
steamWebAPIBase+"/ITwoFactorService/SendEmail/v0001",
url.Values{
"access_token": {state.OAuthToken},
"steamid": {state.SteamID},
"email_type": {"2"},
}, nil)
logger.WithField("serial", s.Serial).Info("steam authenticator enrolled")
return true, nil
}
// advanceServerTime nudges ServerTimeDiff forward by one TOTP period so
// the next FinalizeAddAuthenticator call submits a code for the next
// step. Matches the C# loop.
func (s *SteamAuthenticator) advanceServerTime() {
period := s.Period
if period <= 0 {
period = DefaultPeriod
}
s.ServerTimeDiff += int64(period) * 1000
}
// steamRSAEncryptPassword RSA-encrypts the password using the hex
// modulus / exponent returned by getrsakey. The original WinAuth uses
// RSACryptoServiceProvider.Encrypt(_, false) which is PKCS#1 v1.5.
func steamRSAEncryptPassword(password, hexMod, hexExp string) ([]byte, error) {
modBytes, err := hex.DecodeString(hexMod)
if err != nil {
return nil, fmt.Errorf("modulus hex: %w", err)
}
expBytes, err := hex.DecodeString(hexExp)
if err != nil {
return nil, fmt.Errorf("exponent hex: %w", err)
}
n := new(big.Int).SetBytes(modBytes)
e := new(big.Int).SetBytes(expBytes)
pub := &rsa.PublicKey{N: n, E: int(e.Int64())}
// PKCS#1 v1.5 is required by the Steam mobile-login endpoint;
// OAEP would be rejected. The deprecation warning is acknowledged.
return rsa.EncryptPKCS1v15(nil, pub, []byte(password))
}
// steamJarSessionID returns the "sessionid" cookie set by Steam on the
// community domain, or "" if it has not been issued yet.
func steamJarSessionID(jar http.CookieJar) string {
u, _ := url.Parse(steamCommunityBase + "/")
for _, c := range jar.Cookies(u) {
if c.Name == "sessionid" {
return c.Value
}
}
return ""
}
func steamJarHasSessionID(jar http.CookieJar) bool {
return steamJarSessionID(jar) != ""
}
+251
View File
@@ -0,0 +1,251 @@
package authenticator
import (
"compress/gzip"
"context"
"crypto/rand"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Steam base URLs. Kept as vars (not consts) so tests can override.
var (
steamCommunityBase = "https://steamcommunity.com"
steamWebAPIBase = "https://api.steampowered.com"
)
// steamMobileUserAgent is the exact UA the original WinAuth uses, chosen
// so that Steam's mobile login endpoint accepts the request as coming
// from the official Android app. Changing this without testing tends to
// trigger captcha or outright rejection.
const steamMobileUserAgent = "Mozilla/5.0 (Linux; U; Android 4.1.1; en-us; Google Nexus 4 - 4.1.1 - API 16 - 768x1280 Build/JRO03S) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30"
// steamOAuthClientID / steamOAuthScope are the constants the mobile app
// sends to /mobilelogin. Do not log them — they are not secret, but
// keeping the redact list short reduces accidents.
const (
steamOAuthClientID = "DE45CD61"
steamOAuthScope = "read_profile write_profile read_client write_client"
)
// steamFormFieldsToRedact lists the form keys that must never appear in
// logs. The HTTP layer prints request body at debug only after filtering
// these out.
var steamFormFieldsToRedact = map[string]struct{}{
"password": {},
"access_token": {},
"oauth_token": {},
"twofactorcode": {},
"emailauth": {},
"shared_secret": {},
"identity_secret": {},
"revocation_code": {},
"authenticator_code": {},
"activation_code": {},
}
// newSteamCookieJar returns a cookie jar pre-loaded with the constant
// cookies the original mobile login flow needs before its first request.
func newSteamCookieJar() (http.CookieJar, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
u, _ := url.Parse(steamCommunityBase + "/")
jar.SetCookies(u, []*http.Cookie{
{Name: "mobileClientVersion", Value: "3067969+%282.1.3%29"},
{Name: "mobileClient", Value: "android"},
{Name: "steamid", Value: ""},
{Name: "steamLogin", Value: ""},
{Name: "Steam_Language", Value: "english"},
{Name: "dob", Value: ""},
})
return jar, nil
}
// steamHTTPClient returns a fresh http.Client that uses the given jar
// and a 30s timeout. We intentionally do NOT reuse internal/httpc here:
// httpc dumps full request bodies at Trace level, which would leak the
// password / OAuth token form fields used by the Steam endpoints. This
// client instead logs only method / URL / status at debug level.
func steamHTTPClient(jar http.CookieJar) *http.Client {
return &http.Client{
Jar: jar,
Timeout: 30 * time.Second,
}
}
// steamRequest performs a Steam mobile / WebAPI request. method is GET or
// POST. For GET the form values are appended to the URL; for POST they
// are sent as application/x-www-form-urlencoded.
//
// The returned body is the full response payload as a string. headers
// are added before the call. extraCookies are sent as the explicit
// Cookie header on top of whatever the jar already supplies — most
// callers can pass nil.
//
// IMPORTANT: passwords/tokens MUST be in `form` and never in the URL.
// The debug log only records method/URL/status; form keys listed in
// steamFormFieldsToRedact are filtered out of the (debug-level) body
// dump.
func steamRequest(
ctx context.Context, client *http.Client,
method, rawURL string, form url.Values, headers http.Header,
) (string, error) {
const fn = "internal.authenticator.steamRequest"
logger := global.Log.WithField("func", fn).
WithField("method", method).
WithField("url", rawURL)
method = strings.ToUpper(method)
body := ""
if form != nil {
body = form.Encode()
}
finalURL := rawURL
var reqBody io.Reader
if method == http.MethodGet {
if body != "" {
if strings.Contains(finalURL, "?") {
finalURL += "&" + body
} else {
finalURL += "?" + body
}
}
} else {
reqBody = strings.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, finalURL, reqBody)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", steamMobileUserAgent)
req.Header.Set("Accept", "text/javascript, text/html, application/xml, text/xml, */*")
req.Header.Set("Accept-Encoding", "gzip, deflate")
req.Header.Set("Referer", steamCommunityBase)
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
}
for k, vs := range headers {
for _, v := range vs {
req.Header.Add(k, v)
}
}
if global.Log != nil {
logger.WithField("form", redactFormForLog(form)).Debug("steam request")
}
resp, err := client.Do(req)
if err != nil {
logger.WithError(err).Warn("steam request failed")
return "", err
}
defer resp.Body.Close()
logger = logger.WithField("status", resp.StatusCode)
var reader io.Reader = resp.Body
if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") {
gz, gerr := gzip.NewReader(resp.Body)
if gerr != nil {
return "", gerr
}
defer gz.Close()
reader = gz
}
raw, err := io.ReadAll(reader)
if err != nil {
return "", err
}
if resp.StatusCode == http.StatusForbidden {
logger.Warn("steam request unauthorised")
return string(raw), errSteamUnauthorised
}
if resp.StatusCode != http.StatusOK {
logger.Warn("steam request non-200")
return string(raw), fmt.Errorf("steam: %d %s", resp.StatusCode, resp.Status)
}
logger.Debug("steam response ok")
return string(raw), nil
}
// errSteamUnauthorised maps the C# UnauthorisedRequestException — a 403
// from /steamguard or related endpoints typically means Family View has
// disabled community content.
var errSteamUnauthorised = fmt.Errorf("steam: unauthorised (403)")
// redactFormForLog produces a copy of form with sensitive values
// replaced by "<redacted>". Returns "" for nil/empty input.
func redactFormForLog(form url.Values) string {
if len(form) == 0 {
return ""
}
parts := make([]string, 0, len(form))
for k, vs := range form {
if _, sensitive := steamFormFieldsToRedact[k]; sensitive {
parts = append(parts, k+"=<redacted>")
continue
}
for _, v := range vs {
parts = append(parts, k+"="+v)
}
}
return strings.Join(parts, "&")
}
// buildRandomDeviceID returns "android:" followed by a freshly generated
// UUID v4. Matches the C# SteamAuthenticator.BuildRandomId helper.
func buildRandomDeviceID() string {
var u [16]byte
if _, err := rand.Read(u[:]); err != nil {
// crypto/rand failing is fatal; fall back to a time-derived
// value so the caller does not crash, but log it loudly.
now := time.Now().UnixNano()
for i := 0; i < 16; i++ {
u[i] = byte(now >> (i % 8 * 8))
}
}
u[6] = (u[6] & 0x0F) | 0x40 // version 4
u[8] = (u[8] & 0x3F) | 0x80 // variant RFC 4122
hexb := func(b byte) (byte, byte) {
const hexd = "0123456789abcdef"
return hexd[b>>4], hexd[b&0x0F]
}
out := make([]byte, 0, 8+36)
out = append(out, "android:"...)
for i, b := range u {
if i == 4 || i == 6 || i == 8 || i == 10 {
out = append(out, '-')
}
hi, lo := hexb(b)
out = append(out, hi, lo)
}
return string(out)
}
// stripNonASCII removes any non-ASCII code points from s. Steam's
// login endpoint silently drops these from username/password, so the
// client side must do the same to keep RSA-encrypted bytes consistent.
func stripNonASCII(s string) string {
b := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c < 0x80 {
b = append(b, c)
}
}
return string(b)
}
+184
View File
@@ -0,0 +1,184 @@
package authenticator
import (
"context"
"math/rand"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// confirmationEventDelay is the base sleep between firing successive
// OnConfirmation callbacks. Matches the C# CONFIRMATION_EVENT_DELAY:
// the per-event sleep is uniformly randomised to 100%-150% of this
// value, throttling the UI when several new trades arrive at once.
const confirmationEventDelay = 1000 * time.Millisecond
// defaultConfirmationPollerRetries is the number of consecutive failed
// poll cycles before OnConfirmationError fires. Mirrors the C# default.
const defaultConfirmationPollerRetries = 3
// ConfirmationCallback receives one notification per newly observed
// pending confirmation. action tells the UI whether the user wanted a
// passive notification, an interactive prompt, or silent auto-accept.
type ConfirmationCallback func(conf Confirmation, action PollerAction)
// ConfirmationErrorCallback is fired once per failure burst (every
// ConfirmationPollerRetries consecutive failures), letting the UI
// surface "Steam unreachable" once instead of on every cycle.
type ConfirmationErrorCallback func(message string, action PollerAction, err error)
// pollerHandle tracks a running background poller so it can be stopped
// cleanly. Kept private; SteamClient exposes Start/Stop wrappers.
type pollerHandle struct {
cancel context.CancelFunc
done chan struct{}
}
// StartConfirmationPoller starts (or restarts) the background goroutine
// that periodically calls GetConfirmations and fires OnConfirmation /
// OnConfirmationError. Passing a nil or zero-Duration poller stops any
// running poller and returns.
//
// It is safe to call StartConfirmationPoller repeatedly — the previous
// poller is stopped (and its goroutine joined) before the new one is
// started.
func (c *SteamClient) StartConfirmationPoller(poller *ConfirmationPoller) {
c.StopConfirmationPoller()
if poller == nil || poller.Duration <= 0 {
return
}
c.mu.Lock()
if c.Session == nil {
c.mu.Unlock()
return
}
c.Session.Confirmations = poller
if c.ConfirmationPollerRetries <= 0 {
c.ConfirmationPollerRetries = defaultConfirmationPollerRetries
}
retries := c.ConfirmationPollerRetries
c.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
h := &pollerHandle{cancel: cancel, done: make(chan struct{})}
c.mu.Lock()
c.poller = h
c.mu.Unlock()
go c.runPollerLoop(ctx, h, retries)
}
// StopConfirmationPoller cancels the running poller (if any) and waits
// for its goroutine to exit before returning. Also clears
// Session.Confirmations so a restored session does not auto-restart.
func (c *SteamClient) StopConfirmationPoller() {
c.mu.Lock()
h := c.poller
c.poller = nil
if c.Session != nil {
c.Session.Confirmations = nil
}
c.mu.Unlock()
if h == nil {
return
}
h.cancel()
<-h.done
}
// runPollerLoop is the goroutine body. It owns no locks across network
// calls. Snapshots of the poller config / retry budget are taken once
// per iteration to avoid races with concurrent Stop / Start callers.
func (c *SteamClient) runPollerLoop(ctx context.Context, h *pollerHandle, maxRetries int) {
const fn = "internal.authenticator.SteamClient.runPollerLoop"
logger := global.Log.WithField("func", fn)
defer close(h.done)
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
retryCount := 0
for ctx.Err() == nil {
c.mu.Lock()
poller := c.Session.Confirmations
onConf := c.OnConfirmation
onErr := c.OnConfirmationError
c.mu.Unlock()
if poller == nil {
logger.Debug("poller cleared; exiting loop")
return
}
action := poller.Action
confs, err := c.GetConfirmations(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
retryCount++
logger.WithError(err).WithField("retry", retryCount).Warn("poll failed")
if retryCount >= maxRetries {
if onErr != nil {
onErr("Failed to read confirmations", action, err)
}
} else {
// Best-effort cookie refresh — matches the C# fallback.
_, _ = c.Refresh(ctx)
}
} else {
retryCount = 0
if onConf != nil {
for i := range confs {
if !confs[i].IsNew {
continue
}
if ctx.Err() != nil {
return
}
start := time.Now()
onConf(confs[i], action)
// Jitter the inter-event delay 100%-150% to keep
// the UI from being slammed during a burst.
delay := confirmationEventDelay + time.Duration(rng.Int63n(int64(confirmationEventDelay/2)))
elapsed := time.Since(start)
if delay > elapsed {
if !sleepWithCancel(ctx, delay-elapsed) {
return
}
}
}
}
}
// Re-read duration in case the user changed it mid-flight.
c.mu.Lock()
var wait time.Duration
if c.Session != nil && c.Session.Confirmations != nil {
wait = time.Duration(c.Session.Confirmations.Duration) * time.Minute
}
c.mu.Unlock()
if wait <= 0 {
return
}
if !sleepWithCancel(ctx, wait) {
return
}
}
}
// sleepWithCancel sleeps for d, or returns early if ctx is cancelled.
// Returns true if the full duration elapsed, false on cancellation.
func sleepWithCancel(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return true
case <-ctx.Done():
return false
}
}
+212
View File
@@ -0,0 +1,212 @@
package authenticator
import (
"encoding/json"
"net/url"
"regexp"
"sort"
"strings"
)
// PollerAction mirrors the C# WinAuthenticator.SteamClient.PollerAction
// enum. The integer values are persisted in SteamSession JSON, so they
// MUST NOT be renumbered.
type PollerAction int
const (
PollerActionNone PollerAction = 0
PollerActionNotify PollerAction = 1
PollerActionAutoConfirm PollerAction = 2
PollerActionSilentAutoConfirm PollerAction = 3
)
// ConfirmationPoller is the background-poll configuration persisted
// inside a SteamSession. A Duration of 0 means the poller is disabled
// and the whole object serializes as the literal "null".
type ConfirmationPoller struct {
Duration int `json:"duration"`
Action PollerAction `json:"action"`
Ids []string `json:"ids,omitempty"`
}
// ToJSON returns the on-disk representation. Matches the C#
// ConfirmationPoller.ToString output exactly so old WinAuth session
// blobs round-trip.
func (p *ConfirmationPoller) ToJSON() string {
if p == nil || p.Duration == 0 {
return "null"
}
b, _ := json.Marshal(p)
return string(b)
}
// ParseConfirmationPoller restores a poller from its JSON form. Returns
// nil for an empty / "null" / zero-duration payload, matching the C#
// FromJSON contract.
func ParseConfirmationPoller(s string) *ConfirmationPoller {
s = strings.TrimSpace(s)
if s == "" || s == "null" {
return nil
}
var p ConfirmationPoller
if err := json.Unmarshal([]byte(s), &p); err != nil {
return nil
}
if p.Duration == 0 {
return nil
}
return &p
}
// Confirmation is a single in-flight trade/market confirmation as
// returned by /mobileconf/conf. Runtime-only; not persisted.
type Confirmation struct {
Id string
Key string
Offline bool
IsNew bool
Image string
Details string
Traded string
When string
}
// SteamSession is the persistent half of a Steam mobile authenticator's
// runtime state: the Steam ID, OAuth token, cookie jar, and the poller
// config. UmqId / MessageId are intentionally NOT persisted (matches the
// C# implementation, which comments them out of ToString).
//
// SECURITY: this struct holds OAuth tokens and login cookies. Callers
// must never log it, embed it in error messages, or write it anywhere
// other than the encrypted secretdata blob.
type SteamSession struct {
SteamId string
OAuthToken string
Cookies map[string]string
UmqId string // runtime only, not serialized
MessageId int // runtime only, not serialized
Confirmations *ConfirmationPoller
}
// NewSteamSession returns an empty session.
func NewSteamSession() *SteamSession {
return &SteamSession{Cookies: map[string]string{}}
}
// ParseSteamSession decodes the JSON form written by ToJSON / by the
// old C# client. An empty or invalid input yields an empty session
// rather than an error, matching the C# constructor's behavior.
func ParseSteamSession(s string) *SteamSession {
sess := NewSteamSession()
s = strings.TrimSpace(s)
if s == "" {
return sess
}
var raw struct {
SteamId string `json:"steamid"`
Cookies string `json:"cookies"`
OAuthToken string `json:"oauthtoken"`
Confs json.RawMessage `json:"confs"`
}
if err := json.Unmarshal([]byte(s), &raw); err != nil {
return sess
}
sess.SteamId = raw.SteamId
sess.OAuthToken = raw.OAuthToken
sess.Cookies = parseCookieHeader(raw.Cookies)
if len(raw.Confs) > 0 && string(raw.Confs) != "null" {
sess.Confirmations = ParseConfirmationPoller(string(raw.Confs))
}
return sess
}
// ToJSON serializes the session in the exact format the C# WinAuth
// client writes — cookies as a single "name=value; name=value" header
// string, confs as either "null" or a poller object, and no umqid /
// messageid keys.
func (s *SteamSession) ToJSON() string {
type out struct {
SteamId string `json:"steamid"`
Cookies string `json:"cookies"`
OAuthToken string `json:"oauthtoken"`
Confs json.RawMessage `json:"confs"`
}
o := out{
SteamId: s.SteamId,
Cookies: formatCookieHeader(s.Cookies),
OAuthToken: s.OAuthToken,
Confs: json.RawMessage(s.Confirmations.ToJSON()),
}
b, _ := json.Marshal(o)
return string(b)
}
// SessionFromEnrollState builds a SteamSession from a completed
// enrollment, snapshotting the community-domain cookies out of the
// EnrollState's internal jar. Used by the UI after Enroll returns
// Success so the persisted SessionData carries a usable cookie set.
func SessionFromEnrollState(state *EnrollState) *SteamSession {
sess := NewSteamSession()
if state == nil {
return sess
}
sess.SteamId = state.SteamID
sess.OAuthToken = state.OAuthToken
if state.jar != nil {
u, _ := url.Parse(steamCommunityBase + "/")
for _, c := range state.jar.Cookies(u) {
sess.Cookies[c.Name] = c.Value
}
}
return sess
}
// Clear wipes mutable session state, keeping the SteamId. Matches the
// C# SteamSession.Clear behavior used by Logout.
func (s *SteamSession) Clear() {
s.OAuthToken = ""
s.UmqId = ""
s.MessageId = 0
s.Cookies = map[string]string{}
s.Confirmations = nil
}
var cookieHeaderRe = regexp.MustCompile(`([^=;]+)=([^;]*);?`)
// parseCookieHeader splits "name1=value1; name2=value2" into a map.
// Whitespace around names/values is trimmed. Empty input returns an
// empty (non-nil) map so callers can immediately .Set into it.
func parseCookieHeader(h string) map[string]string {
out := map[string]string{}
for _, m := range cookieHeaderRe.FindAllStringSubmatch(h, -1) {
name := strings.TrimSpace(m[1])
if name == "" {
continue
}
out[name] = strings.TrimSpace(m[2])
}
return out
}
// formatCookieHeader is the inverse. Keys are emitted in sorted order
// so the JSON output is stable across saves (helps diffs and tests).
func formatCookieHeader(cookies map[string]string) string {
if len(cookies) == 0 {
return ""
}
names := make([]string, 0, len(cookies))
for n := range cookies {
names = append(names, n)
}
sort.Strings(names)
var b strings.Builder
for i, n := range names {
if i > 0 {
b.WriteString("; ")
}
b.WriteString(n)
b.WriteByte('=')
b.WriteString(cookies[n])
}
return b.String()
}
+34
View File
@@ -0,0 +1,34 @@
// Package authenticator implements RFC 4226 (HOTP) and RFC 6238 (TOTP)
// authenticator algorithms, plus vendor-specific subclasses (Google,
// Battle.Net, Microsoft, Okta, Steam, YubiKey-backed).
//
// This is a Go port of the C# Authenticator/HOTPAuthenticator classes from
// the original WinAuth project.
package authenticator
// HMACType selects the hash function used to derive the OTP. Matches the
// HMACTypes enum from the original C# source.
type HMACType int
const (
HMACSHA1 HMACType = iota
HMACSHA256
HMACSHA512
)
func (h HMACType) String() string {
switch h {
case HMACSHA256:
return "SHA256"
case HMACSHA512:
return "SHA512"
default:
return "SHA1"
}
}
// Common defaults that mirror the original C# constants.
const (
DefaultCodeDigits = 6
DefaultPeriod = 30
)
+35
View File
@@ -0,0 +1,35 @@
package config
import (
"errors"
"strings"
)
// normalizeLegacySecretData hands the decrypted WinAuth SecretData string
// off to the modern Entry.SecretRaw slot. The modern authenticator
// SetSecretData methods already accept the exact C# WinAuth on-disk form
// (tab-separated head with pipe-separated trailers), so the conversion
// is mostly a sanity check.
//
// vendor is the value detectLegacyVendor produced and is used only to
// catch obviously empty/malformed payloads early; the per-vendor parse
// happens later in authenticator.SetSecretData.
func normalizeLegacySecretData(vendor, secret string) (string, error) {
secret = strings.TrimSpace(secret)
if secret == "" {
return "", errors.New("legacy entry: empty secret payload")
}
switch vendor {
case "battlenet":
// Either "<hex>\t...|<serialhex>" (modern C# form) or the
// legacy WinAuth2 short form "<40 hex secret><utf8-hex serial>".
// Both are accepted by BattleNetAuthenticator.SetSecretData.
return secret, nil
case "steam":
// "<hex>\t...|serial|device|steamdata|session" — SetSecretData
// also tolerates fewer fields.
return secret, nil
default:
return secret, nil
}
}
+266
View File
@@ -0,0 +1,266 @@
package config
import (
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"os"
"strings"
"git.wxccs.org/iceking2nd/winauth-go/internal/crypto"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// LegacyAuthenticator is the partial WinAuth XML element we care about.
// Fields not relevant to migration are ignored.
type LegacyAuthenticator struct {
XMLName xml.Name `xml:"WinAuthAuthenticator"`
Type string `xml:"type,attr"`
Name string `xml:"name"`
AuthData struct {
Encrypted string `xml:"encrypted,attr"`
SecretData string `xml:"secretdata"`
ServerTimeDiff string `xml:"servertimediff"`
} `xml:"authenticatordata"`
}
// legacyRoot matches the outer <WinAuth ...><authenticator>...</authenticator>
// container the original WinAuthHelper.SaveAuthenticator writes.
type legacyRoot struct {
XMLName xml.Name `xml:"WinAuth"`
Authenticators []LegacyAuthenticator `xml:"authenticator>WinAuthAuthenticator"`
}
// LegacyPasswordType encodes the per-entry encryption layering of an old
// WinAuth config. Multiple bits may be set: the original app supported
// chained encryption like "yum" (password → user-DPAPI → machine-DPAPI).
type LegacyPasswordType int
const (
LegacyPasswordNone LegacyPasswordType = 0
LegacyPasswordExplicit LegacyPasswordType = 1 << iota // 'y' — PBKDF2-SHA1 + Blowfish
LegacyPasswordUser // 'u' — User-scope DPAPI
LegacyPasswordMachine // 'm' — Machine-scope DPAPI
)
// ErrLegacyPasswordRequired is returned by LoadLegacyXML when at least
// one entry has the 'y' bit set but the caller did not supply a
// passphrase. The caller is expected to prompt the user and retry.
var ErrLegacyPasswordRequired = errors.New("legacy XML: password required")
// ErrLegacyPasswordWrong is returned when the supplied passphrase
// successfully unprotects DPAPI layers but the resulting Blowfish output
// fails to look like sensible UTF-8 secret data — almost always a wrong
// password since the legacy format has no MAC.
var ErrLegacyPasswordWrong = errors.New("legacy XML: wrong password")
// parseLegacyEncryptionFlags maps the encrypted-attribute string ("y",
// "ymu", "um", ...) to a flag bitmask. Unknown letters are ignored.
func parseLegacyEncryptionFlags(s string) LegacyPasswordType {
var f LegacyPasswordType
for _, c := range strings.ToLower(strings.TrimSpace(s)) {
switch c {
case 'y':
f |= LegacyPasswordExplicit
case 'u':
f |= LegacyPasswordUser
case 'm':
f |= LegacyPasswordMachine
}
}
return f
}
// LegacyXMLNeedsPassword reports whether any entry in the file uses the
// 'y' password layer, so the UI knows to prompt before calling
// LoadLegacyXML with the user-supplied password.
func LegacyXMLNeedsPassword(path string) (bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
return false, err
}
var root legacyRoot
if err := xml.Unmarshal(raw, &root); err != nil {
return false, fmt.Errorf("legacy XML: %w", err)
}
for _, a := range root.Authenticators {
if parseLegacyEncryptionFlags(a.AuthData.Encrypted)&LegacyPasswordExplicit != 0 {
return true, nil
}
}
return false, nil
}
// LoadLegacyXML reads an old WinAuth XML config (the one stored at
// %APPDATA%\WinAuth\winauth.xml). password may be nil if the file is
// fully unencrypted, but it must be supplied if any entry carries the
// 'y' password bit, otherwise ErrLegacyPasswordRequired is returned.
//
// Entries that cannot be decrypted (e.g. DPAPI blob produced by a
// different Windows user / on a different machine) are logged and
// skipped — partial migration is better than aborting.
func LoadLegacyXML(path string, password []byte) (*Config, error) {
const fn = "internal.config.LoadLegacyXML"
logger := global.Log.WithField("func", fn).WithField("path", path)
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var root legacyRoot
if err := xml.Unmarshal(raw, &root); err != nil {
return nil, fmt.Errorf("legacy XML: %w", err)
}
// Pre-flight: refuse early so the UI can switch to password prompt
// without us having partially decrypted some unprotected entries.
for _, a := range root.Authenticators {
flags := parseLegacyEncryptionFlags(a.AuthData.Encrypted)
if flags&LegacyPasswordExplicit != 0 && len(password) == 0 {
return nil, ErrLegacyPasswordRequired
}
}
cfg := &Config{Version: 1}
for _, a := range root.Authenticators {
entryLogger := logger.WithField("name", a.Name)
flags := parseLegacyEncryptionFlags(a.AuthData.Encrypted)
plaintext, err := decryptLegacySecretData(a.AuthData.SecretData, flags, password)
if err != nil {
entryLogger.WithError(err).Warn("skip entry: decrypt failed")
continue
}
entry, err := convertLegacyEntry(a, plaintext)
if err != nil {
entryLogger.WithError(err).Warn("skip entry: conversion failed")
continue
}
cfg.Entries = append(cfg.Entries, entry)
}
logger.WithField("entries", len(cfg.Entries)).Debug("legacy config imported")
return cfg, nil
}
// decryptLegacySecretData reverses the layered encryption WinAuth wrote.
// Each layer expects a hex string and produces a hex string (the final
// layer's hex decodes to UTF-8 secret data). Order of removal is the
// reverse of the original encryption order: User-DPAPI, then
// Machine-DPAPI, then explicit Password.
func decryptLegacySecretData(data string, flags LegacyPasswordType, password []byte) (string, error) {
data = strings.TrimSpace(data)
if data == "" || flags == LegacyPasswordNone {
return data, nil
}
if flags&LegacyPasswordUser != 0 {
blob, err := hex.DecodeString(data)
if err != nil {
return "", fmt.Errorf("user-DPAPI: hex decode: %w", err)
}
out, err := crypto.Unprotect(blob, nil, crypto.DPAPIScopeCurrentUser)
if err != nil {
return "", fmt.Errorf("user-DPAPI: %w", err)
}
data = hex.EncodeToString(out)
}
if flags&LegacyPasswordMachine != 0 {
blob, err := hex.DecodeString(data)
if err != nil {
return "", fmt.Errorf("machine-DPAPI: hex decode: %w", err)
}
out, err := crypto.Unprotect(blob, nil, crypto.DPAPIScopeLocalMachine)
if err != nil {
return "", fmt.Errorf("machine-DPAPI: %w", err)
}
data = hex.EncodeToString(out)
}
if flags&LegacyPasswordExplicit != 0 {
// First 16 hex chars = 8-byte salt; rest is Blowfish ciphertext.
const saltHexLen = 16
if len(data) < saltHexLen {
return "", errors.New("explicit: payload shorter than salt")
}
saltHex, bodyHex := data[:saltHexLen], data[saltHexLen:]
salt, err := hex.DecodeString(saltHex)
if err != nil {
return "", fmt.Errorf("explicit: salt hex decode: %w", err)
}
key := crypto.DerivePBKDF2SHA1(password, salt)
defer func() {
for i := range key {
key[i] = 0
}
}()
plain, err := crypto.LegacyDecryptBlowfish(bodyHex, key)
if err != nil {
return "", fmt.Errorf("explicit: %w", err)
}
// The Blowfish output should itself be a hex string representing
// the next inner layer (or the UTF-8 secret data). A wrong
// password almost always lands here producing garbage bytes;
// check that the result is printable ASCII to reject it.
if !looksLikeLegacyPlaintext(plain) {
return "", ErrLegacyPasswordWrong
}
data = string(plain)
}
return data, nil
}
// looksLikeLegacyPlaintext returns true if buf reads as a printable
// ASCII string of the kind WinAuth writes for SecretData (tab-separated
// hex, pipe-separated serial, or JSON). Non-printable bytes nearly
// always indicate a bad password since legacy Blowfish has no MAC.
func looksLikeLegacyPlaintext(buf []byte) bool {
if len(buf) == 0 {
return false
}
for _, b := range buf {
if b == '\t' || b == '\n' || b == '\r' || (b >= 0x20 && b < 0x7f) {
continue
}
return false
}
return true
}
// convertLegacyEntry takes one parsed authenticator block and its
// decrypted secret-data payload and turns it into a modern Entry.
// Vendor-specific quirks (Battle.Net serial trailer, Steam JSON, ...)
// live in the per-vendor helpers in legacy_secretdata.go.
func convertLegacyEntry(a LegacyAuthenticator, secret string) (Entry, error) {
vendor := detectLegacyVendor(a.Type)
raw, err := normalizeLegacySecretData(vendor, secret)
if err != nil {
return Entry{}, err
}
return Entry{
Name: a.Name,
Vendor: vendor,
SecretRaw: raw,
}, nil
}
func detectLegacyVendor(typeAttr string) string {
t := strings.ToLower(typeAttr)
switch {
case strings.Contains(t, "battlenet"):
return "battlenet"
case strings.Contains(t, "steam"):
return "steam"
case strings.Contains(t, "microsoft"):
return "microsoft"
case strings.Contains(t, "oktaverify"):
return "okta"
case strings.Contains(t, "hotp"):
return "hotp"
default:
return "google"
}
}
+154
View File
@@ -0,0 +1,154 @@
package config
import (
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"git.wxccs.org/iceking2nd/winauth-go/internal/crypto"
)
// TestParseLegacyEncryptionFlags covers the encrypted-attribute parsing
// for the three letters WinAuth actually wrote, in every legal order
// plus the empty / unknown-letter cases.
func TestParseLegacyEncryptionFlags(t *testing.T) {
cases := map[string]LegacyPasswordType{
"": LegacyPasswordNone,
"y": LegacyPasswordExplicit,
"u": LegacyPasswordUser,
"m": LegacyPasswordMachine,
"yum": LegacyPasswordExplicit | LegacyPasswordUser | LegacyPasswordMachine,
"YMU": LegacyPasswordExplicit | LegacyPasswordUser | LegacyPasswordMachine,
" yu ": LegacyPasswordExplicit | LegacyPasswordUser,
"abc": LegacyPasswordNone,
}
for in, want := range cases {
if got := parseLegacyEncryptionFlags(in); got != want {
t.Errorf("parseLegacyEncryptionFlags(%q) = %v, want %v", in, got, want)
}
}
}
// TestDecryptLegacyExplicitRoundTrip encrypts a known plaintext using
// the helpers and round-trips it through decryptLegacySecretData to
// catch any salt-layout / PBKDF2 / Blowfish drift.
func TestDecryptLegacyExplicitRoundTrip(t *testing.T) {
plaintext := "ABCDEF1234\t6\tSHA1\t30"
password := []byte("hunter2")
salt := []byte{1, 2, 3, 4, 5, 6, 7, 8}
key := crypto.DerivePBKDF2SHA1(password, salt)
body, err := crypto.LegacyEncryptBlowfish([]byte(plaintext), key)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
payload := hex.EncodeToString(salt) + body
got, err := decryptLegacySecretData(payload, LegacyPasswordExplicit, password)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if got != plaintext {
t.Errorf("round trip mismatch: got %q want %q", got, plaintext)
}
}
// TestDecryptLegacyExplicitWrongPassword verifies the printable-ASCII
// heuristic flags a bad passphrase rather than returning garbage to the
// caller. Two random passwords almost certainly produce non-printable
// Blowfish output of length 8 or more.
func TestDecryptLegacyExplicitWrongPassword(t *testing.T) {
plaintext := "ABCDEF1234\t6\tSHA1\t30"
salt := []byte{9, 9, 9, 9, 9, 9, 9, 9}
key := crypto.DerivePBKDF2SHA1([]byte("correct"), salt)
body, err := crypto.LegacyEncryptBlowfish([]byte(plaintext), key)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
payload := hex.EncodeToString(salt) + body
_, err = decryptLegacySecretData(payload, LegacyPasswordExplicit, []byte("wrong"))
if err == nil {
t.Fatal("expected wrong-password rejection, got nil")
}
}
// TestLooksLikeLegacyPlaintext covers the ASCII-printable heuristic.
func TestLooksLikeLegacyPlaintext(t *testing.T) {
if !looksLikeLegacyPlaintext([]byte("ABC\t123|x")) {
t.Error("printable string should pass")
}
if looksLikeLegacyPlaintext([]byte{0x00, 0x01, 0x02}) {
t.Error("control bytes should fail")
}
if looksLikeLegacyPlaintext(nil) {
t.Error("empty buffer should fail")
}
// High bit / extended ASCII should also fail — WinAuth never wrote
// non-ASCII into <secretdata>.
if looksLikeLegacyPlaintext([]byte{0xff, 'A'}) {
t.Error("high bit should fail")
}
}
// TestLoadLegacyXMLPlaintext walks the full XML → Entry path with an
// unencrypted Google entry and an encrypted entry the caller skipped
// the password for; the encrypted one should be skipped and the
// plaintext one returned.
func TestLoadLegacyXMLPlaintext(t *testing.T) {
const sample = `<?xml version="1.0"?>
<WinAuth version="3.5">
<authenticator>
<WinAuthAuthenticator type="GoogleAuthenticator">
<name>plain</name>
<authenticatordata encrypted="">
<secretdata>ABCDEF1234 6 SHA1 30</secretdata>
<servertimediff>0</servertimediff>
</authenticatordata>
</WinAuthAuthenticator>
</authenticator>
</WinAuth>`
path := writeTempXML(t, sample)
cfg, err := LoadLegacyXML(path, nil)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cfg.Entries) != 1 {
t.Fatalf("entries=%d, want 1", len(cfg.Entries))
}
if cfg.Entries[0].Vendor != "google" || cfg.Entries[0].Name != "plain" {
t.Errorf("entry mismatch: %+v", cfg.Entries[0])
}
}
func TestLoadLegacyXMLPasswordRequired(t *testing.T) {
const sample = `<?xml version="1.0"?>
<WinAuth>
<authenticator>
<WinAuthAuthenticator type="GoogleAuthenticator">
<name>locked</name>
<authenticatordata encrypted="y">
<secretdata>0102030405060708abcdef</secretdata>
</authenticatordata>
</WinAuthAuthenticator>
</authenticator>
</WinAuth>`
path := writeTempXML(t, sample)
if _, err := LoadLegacyXML(path, nil); err == nil ||
!strings.Contains(err.Error(), "password required") {
t.Fatalf("want password-required error, got %v", err)
}
}
func writeTempXML(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "winauth.xml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
return path
}
+29
View File
@@ -0,0 +1,29 @@
// Package config defines the persistent shape of a winauth-go config and
// provides loaders for both the new YAML format and the legacy WinAuth
// XML format produced by the original C# application.
package config
// Entry is a serialized authenticator inside the config file. Vendor
// determines how Data is interpreted by the authenticator package's
// SetSecretData method.
type Entry struct {
Name string `yaml:"name" json:"name"`
Vendor string `yaml:"vendor" json:"vendor"` // google|microsoft|okta|hotp|battlenet|steam
IconName string `yaml:"icon" json:"icon,omitempty"`
SecretRaw string `yaml:"secret" json:"secret"` // value returned by Authenticator.SecretData()
// Hotkey is a human-readable global hotkey like "Ctrl+Alt+G". Empty
// means no hotkey. Parsed by internal/hotkey.Parse — invalid strings
// log a warning at registration time and are otherwise ignored.
Hotkey string `yaml:"hotkey,omitempty" json:"hotkey,omitempty"`
}
// Config is the top-level file shape. Entries are stored unencrypted by
// default; if Encrypted is true, EncryptedBlob holds a WAGO1 base64 ciphertext
// produced by internal/crypto.EncryptModern and Entries is empty on disk.
type Config struct {
Version int `yaml:"version" json:"version"`
Language string `yaml:"language,omitempty" json:"language,omitempty"`
Encrypted bool `yaml:"encrypted" json:"encrypted"`
EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"`
Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"`
}
+41
View File
@@ -0,0 +1,41 @@
package config
import (
"os"
"path/filepath"
"runtime"
)
// DefaultPath returns the default location for the YAML config file. On
// Windows we follow the same convention as the legacy WinAuth and use
// %APPDATA%\winauth-go\config.yaml. On other platforms we honor
// $XDG_CONFIG_HOME (falling back to ~/.config) and use the standard
// winauth-go subdirectory.
func DefaultPath() string {
if p := envConfigPath(); p != "" {
return p
}
if runtime.GOOS == "windows" {
if dir := os.Getenv("APPDATA"); dir != "" {
return filepath.Join(dir, "winauth-go", "config.yaml")
}
}
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
return filepath.Join(dir, "winauth-go", "config.yaml")
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".config", "winauth-go", "config.yaml")
}
return "winauth.yaml"
}
func envConfigPath() string {
return os.Getenv("WINAUTH_GO_CONFIG")
}
// EnsureDir creates the parent directory of path with 0o700 permissions
// (best effort). Returns nil if creation succeeds or the directory already
// exists.
func EnsureDir(path string) error {
return os.MkdirAll(filepath.Dir(path), 0o700)
}
+94
View File
@@ -0,0 +1,94 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"gopkg.in/yaml.v3"
"git.wxccs.org/iceking2nd/winauth-go/internal/crypto"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Sentinel errors returned by LoadYAML for the encrypted-config password
// path. Callers use errors.Is to distinguish them from generic I/O / parse
// failures.
var (
ErrPasswordRequired = errors.New("config: passphrase required")
ErrPasswordWrong = errors.New("config: wrong passphrase")
)
// SaveYAML writes the receiver as YAML to path. If passphrase is non-empty
// and cfg.Encrypted is true, the entries slice is serialized to JSON,
// encrypted, and stored as EncryptedBlob — entries are NOT written in
// plaintext in that case.
func SaveYAML(cfg *Config, path string, passphrase []byte) error {
const fn = "internal.config.SaveYAML"
logger := global.Log.WithField("func", fn).WithField("path", path)
out := *cfg
if cfg.Encrypted && len(passphrase) > 0 {
raw, err := json.Marshal(cfg.Entries)
if err != nil {
return err
}
blob, err := crypto.EncryptModern(raw, passphrase)
if err != nil {
return err
}
out.EncryptedBlob = blob
out.Entries = nil
}
data, err := yaml.Marshal(&out)
if err != nil {
return err
}
if err := EnsureDir(path); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
logger.Debug("config saved")
return nil
}
// LoadYAML reads the YAML config at path. If the file is encrypted,
// passphrase is required and the EncryptedBlob is decrypted into Entries.
func LoadYAML(path string, passphrase []byte) (*Config, error) {
const fn = "internal.config.LoadYAML"
logger := global.Log.WithField("func", fn).WithField("path", path)
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("config: parse YAML: %w", err)
}
if cfg.Encrypted && cfg.EncryptedBlob != "" {
if len(passphrase) == 0 {
return &cfg, ErrPasswordRequired
}
raw, err := crypto.DecryptModern(cfg.EncryptedBlob, passphrase)
if err != nil {
return &cfg, ErrPasswordWrong
}
if err := json.Unmarshal(raw, &cfg.Entries); err != nil {
return nil, fmt.Errorf("config: decode entries: %w", err)
}
}
logger.WithField("entries", len(cfg.Entries)).Debug("config loaded")
return &cfg, nil
}
+98
View File
@@ -0,0 +1,98 @@
// Package crypto implements the password-based and DPAPI-based encryption
// layers that the original WinAuth used to protect its config XML, plus a
// modern AES-GCM scheme used by the new YAML/JSON config format.
//
// The legacy reader is provided for one-way migration only — new files are
// always written in the modern format.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"golang.org/x/crypto/pbkdf2"
)
const (
saltLength = 8
legacyIterations = 2000
legacyKeySize = 32 // 256 bits
)
// EncryptModern encrypts plaintext with a passphrase using PBKDF2-SHA256 +
// AES-256-GCM. The wire format is:
//
// "WAGO1" || base64( salt(16) || nonce(12) || ciphertext+tag )
//
// All-in-one base64 makes the result safe to embed in YAML/JSON.
func EncryptModern(plaintext, passphrase []byte) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := pbkdf2.Key(passphrase, salt, 100_000, 32, sha256.New)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ct := gcm.Seal(nil, nonce, plaintext, nil)
buf := make([]byte, 0, len(salt)+len(nonce)+len(ct))
buf = append(buf, salt...)
buf = append(buf, nonce...)
buf = append(buf, ct...)
return "WAGO1" + base64.StdEncoding.EncodeToString(buf), nil
}
// DecryptModern is the inverse of EncryptModern.
func DecryptModern(encoded string, passphrase []byte) ([]byte, error) {
const prefix = "WAGO1"
if len(encoded) < len(prefix) || encoded[:len(prefix)] != prefix {
return nil, errors.New("crypto: not a WAGO1 payload")
}
raw, err := base64.StdEncoding.DecodeString(encoded[len(prefix):])
if err != nil {
return nil, fmt.Errorf("crypto: base64 decode: %w", err)
}
if len(raw) < 16+12+16 {
return nil, errors.New("crypto: payload too short")
}
salt, nonce, ct := raw[:16], raw[16:28], raw[28:]
key := pbkdf2.Key(passphrase, salt, 100_000, 32, sha256.New)
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
pt, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return nil, fmt.Errorf("crypto: decrypt failed: %w", err)
}
return pt, nil
}
// DerivePBKDF2SHA1 reproduces the C# Rfc2898DeriveBytes(password, salt,
// 2000) used by the legacy WinAuth Encrypt/Decrypt sequence. The output
// length matches PBKDF2_KEYSIZE / 8 from the original (32 bytes).
func DerivePBKDF2SHA1(password, salt []byte) []byte {
return pbkdf2.Key(password, salt, legacyIterations, legacyKeySize, sha1.New)
}
+20
View File
@@ -0,0 +1,20 @@
package crypto
import "errors"
// ErrDPAPIUnsupported is returned by Unprotect on non-Windows platforms,
// where DPAPI does not exist. Callers migrating WinAuth XML that used
// DPAPI encryption must run the migration on Windows.
var ErrDPAPIUnsupported = errors.New("crypto: DPAPI is only available on Windows")
// DPAPIScope picks which key the OS uses to derive the decryption key.
type DPAPIScope int
const (
// DPAPIScopeCurrentUser uses the per-user master key. The XML must be
// decrypted on the same Windows user account that encrypted it.
DPAPIScopeCurrentUser DPAPIScope = iota
// DPAPIScopeLocalMachine uses the per-machine master key. Any user
// on the same machine can decrypt the payload.
DPAPIScopeLocalMachine
)
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
package crypto
// Unprotect always returns ErrDPAPIUnsupported on non-Windows platforms.
// Importing legacy WinAuth XML that uses DPAPI encryption requires
// Windows; the password-only ("y") leg still works cross-platform.
func Unprotect(blob, entropy []byte, scope DPAPIScope) ([]byte, error) {
return nil, ErrDPAPIUnsupported
}
+49
View File
@@ -0,0 +1,49 @@
//go:build windows
package crypto
import (
"fmt"
"unsafe"
"golang.org/x/sys/windows"
)
// Unprotect calls Windows CryptUnprotectData on the given blob. entropy
// is optional secondary entropy that must match what was passed to the
// matching CryptProtectData call; pass nil if none was used. scope
// selects between the current-user and local-machine master keys.
//
// The original WinAuth wrote both User and Machine DPAPI blobs without
// extra entropy, so passing entropy=nil is what the legacy migration
// needs in practice.
func Unprotect(blob, entropy []byte, scope DPAPIScope) ([]byte, error) {
var in windows.DataBlob
in.Size = uint32(len(blob))
if len(blob) > 0 {
in.Data = &blob[0]
}
var entIn *windows.DataBlob
if len(entropy) > 0 {
entIn = &windows.DataBlob{Size: uint32(len(entropy)), Data: &entropy[0]}
}
var flags uint32
if scope == DPAPIScopeLocalMachine {
flags |= 0x4 // CRYPTPROTECT_LOCAL_MACHINE
}
var out windows.DataBlob
if err := windows.CryptUnprotectData(&in, nil, entIn, 0, nil, flags, &out); err != nil {
return nil, fmt.Errorf("dpapi: unprotect: %w", err)
}
defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data)))
if out.Size == 0 {
return []byte{}, nil
}
result := make([]byte, out.Size)
copy(result, unsafe.Slice(out.Data, out.Size))
return result, nil
}
+84
View File
@@ -0,0 +1,84 @@
package crypto
import (
"crypto/cipher"
"encoding/hex"
"errors"
"fmt"
"golang.org/x/crypto/blowfish"
)
// LegacyDecryptBlowfish decrypts the hex-encoded payload produced by the
// original WinAuth Authenticator.Decrypt(string, byte[]) method, which uses
// CBC-less Blowfish with ISO10126-2 padding via BouncyCastle's
// PaddedBufferedBlockCipher.
//
// The BouncyCastle "PaddedBufferedBlockCipher" with no IV is effectively
// ECB; the original WinAuth code chose Blowfish in that mode and relied on
// ISO10126-2 to round the payload to the block size.
func LegacyDecryptBlowfish(hexCiphertext string, key []byte) ([]byte, error) {
ct, err := hex.DecodeString(hexCiphertext)
if err != nil {
return nil, fmt.Errorf("legacy blowfish: hex decode: %w", err)
}
cph, err := blowfish.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("legacy blowfish: %w", err)
}
bs := cph.BlockSize()
if len(ct)%bs != 0 {
return nil, errors.New("legacy blowfish: ciphertext not a multiple of block size")
}
out := make([]byte, len(ct))
for i := 0; i < len(ct); i += bs {
cph.Decrypt(out[i:i+bs], ct[i:i+bs])
}
return stripISO10126(out, bs)
}
// LegacyEncryptBlowfish is provided for symmetry / round-trip tests; the
// new format never writes Blowfish.
func LegacyEncryptBlowfish(plaintext, key []byte) (string, error) {
cph, err := blowfish.NewCipher(key)
if err != nil {
return "", err
}
padded, err := padISO10126(plaintext, cph.BlockSize())
if err != nil {
return "", err
}
out := make([]byte, len(padded))
for i := 0; i < len(padded); i += cph.BlockSize() {
cph.Encrypt(out[i:i+cph.BlockSize()], padded[i:i+cph.BlockSize()])
}
return hex.EncodeToString(out), nil
}
// stripISO10126 removes ISO 10126-2 padding: last byte = pad length;
// preceding bytes are arbitrary.
func stripISO10126(buf []byte, blockSize int) ([]byte, error) {
if len(buf) == 0 {
return nil, errors.New("iso10126: empty buffer")
}
padLen := int(buf[len(buf)-1])
if padLen <= 0 || padLen > blockSize {
return nil, errors.New("iso10126: invalid padding length")
}
return buf[:len(buf)-padLen], nil
}
func padISO10126(buf []byte, blockSize int) ([]byte, error) {
padLen := blockSize - len(buf)%blockSize
out := make([]byte, len(buf)+padLen)
copy(out, buf)
// fill with deterministic-but-non-zero bytes so tests are reproducible
// (real WinAuth uses a CSPRNG; the actual content is ignored on decrypt).
for i := len(buf); i < len(out)-1; i++ {
out[i] = byte(i)
}
out[len(out)-1] = byte(padLen)
// silence linter for unused cipher variable if any
_ = cipher.NewCBCEncrypter
return out, nil
}
+10
View File
@@ -0,0 +1,10 @@
package global
import "github.com/sirupsen/logrus"
// Log is the project-wide logger instance. All packages should obtain a
// per-function logger by attaching a "func" field that follows the
// dotted-path convention defined in CLAUDE.local.md, e.g.:
//
// global.Log.WithField("func", "internal.foo.Bar.Baz").Info("...")
var Log = logrus.New()
+142
View File
@@ -0,0 +1,142 @@
// Package hotkey converts between human-readable hotkey strings
// ("Ctrl+Alt+G") and the win32.Hotkey struct the registration syscall
// expects. The mapping is intentionally limited to the keys WinAuth
// users actually configured: letters, digits, F1-F12.
package hotkey
import (
"errors"
"fmt"
"strings"
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
)
// ErrEmpty is returned by Parse when the input is empty or whitespace.
// It is *not* a syntax error — callers typically treat it as "no
// hotkey configured" rather than a validation failure.
var ErrEmpty = errors.New("hotkey: empty")
// Parse turns "Ctrl+Alt+G" / "ctrl + shift + f5" into a win32.Hotkey.
// Whitespace and case are ignored. Modifier order is irrelevant.
func Parse(s string) (win32.Hotkey, error) {
s = strings.TrimSpace(s)
if s == "" {
return win32.Hotkey{}, ErrEmpty
}
parts := strings.Split(s, "+")
if len(parts) == 0 {
return win32.Hotkey{}, fmt.Errorf("hotkey: malformed %q", s)
}
var mods uint32
var key string
for _, p := range parts {
token := strings.ToLower(strings.TrimSpace(p))
switch token {
case "ctrl", "control":
mods |= win32.ModCtrl
case "alt":
mods |= win32.ModAlt
case "shift":
mods |= win32.ModShift
case "win", "super":
mods |= win32.ModWin
case "":
// tolerate trailing "+"
default:
if key != "" {
return win32.Hotkey{}, fmt.Errorf("hotkey: more than one base key in %q", s)
}
key = token
}
}
if key == "" {
return win32.Hotkey{}, fmt.Errorf("hotkey: no base key in %q", s)
}
if mods == 0 {
return win32.Hotkey{}, fmt.Errorf("hotkey: %q has no modifier (would conflict with normal typing)", s)
}
vk, ok := vkFromName(key)
if !ok {
return win32.Hotkey{}, fmt.Errorf("hotkey: unsupported key %q", key)
}
return win32.Hotkey{Mods: mods | win32.ModNoRepeat, VK: vk}, nil
}
// Format canonicalises h back into a "Ctrl+Alt+G" style string. The
// modifier order is fixed (Ctrl, Alt, Shift, Win) so two equivalent
// hotkeys render identically.
func Format(h win32.Hotkey) string {
if h.VK == 0 {
return ""
}
parts := make([]string, 0, 4)
if h.Mods&win32.ModCtrl != 0 {
parts = append(parts, "Ctrl")
}
if h.Mods&win32.ModAlt != 0 {
parts = append(parts, "Alt")
}
if h.Mods&win32.ModShift != 0 {
parts = append(parts, "Shift")
}
if h.Mods&win32.ModWin != 0 {
parts = append(parts, "Win")
}
parts = append(parts, nameFromVK(h.VK))
return strings.Join(parts, "+")
}
// vkFromName maps the lowercase key name to a Win32 virtual-key code.
// Returns false for anything it doesn't know.
func vkFromName(name string) (uint32, bool) {
if len(name) == 1 {
c := name[0]
switch {
case c >= 'a' && c <= 'z':
return uint32(c - 'a' + 'A'), true
case c >= '0' && c <= '9':
return uint32(c), true
}
}
if strings.HasPrefix(name, "f") {
// F1=0x70, F12=0x7B
var n int
if _, err := fmt.Sscanf(name, "f%d", &n); err == nil && n >= 1 && n <= 12 {
return uint32(0x70 + n - 1), true
}
}
switch name {
case "space":
return 0x20, true
case "enter", "return":
return 0x0D, true
case "tab":
return 0x09, true
}
return 0, false
}
// nameFromVK is the inverse of vkFromName for the cases Parse accepts.
// Unknown codes render as their hex value so the UI still shows
// something.
func nameFromVK(vk uint32) string {
switch {
case vk >= 'A' && vk <= 'Z':
return string(rune(vk))
case vk >= '0' && vk <= '9':
return string(rune(vk))
case vk >= 0x70 && vk <= 0x7B:
return fmt.Sprintf("F%d", vk-0x70+1)
}
switch vk {
case 0x20:
return "Space"
case 0x0D:
return "Enter"
case 0x09:
return "Tab"
}
return fmt.Sprintf("0x%X", vk)
}
+42
View File
@@ -0,0 +1,42 @@
package hotkey
import "testing"
func TestParseAndFormat(t *testing.T) {
cases := []struct {
in string
want string
}{
{"Ctrl+Alt+G", "Ctrl+Alt+G"},
{" alt + shift + f5", "Alt+Shift+F5"},
{"ctrl+1", "Ctrl+1"},
{"win+space", "Win+Space"},
}
for _, c := range cases {
got, err := Parse(c.in)
if err != nil {
t.Fatalf("Parse(%q): %v", c.in, err)
}
if Format(got) != c.want {
t.Errorf("Parse+Format(%q) = %q, want %q", c.in, Format(got), c.want)
}
}
}
func TestParseRejectsMissingModifier(t *testing.T) {
if _, err := Parse("G"); err == nil {
t.Error("expected error for missing modifier")
}
}
func TestParseRejectsUnknownKey(t *testing.T) {
if _, err := Parse("Ctrl+banana"); err == nil {
t.Error("expected error for unknown base key")
}
}
func TestParseEmpty(t *testing.T) {
if _, err := Parse(" "); err != ErrEmpty {
t.Errorf("expected ErrEmpty, got %v", err)
}
}
+127
View File
@@ -0,0 +1,127 @@
package httpc
import (
"bytes"
"io"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/sirupsen/logrus"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Sensitive header names that must be redacted from Trace logs. Comparison
// is case-insensitive. The body of responses is logged as-is at Trace level
// — callers should not put bearer tokens in URL query strings if they want
// to keep them out of logs.
var sensitiveHeaders = map[string]struct{}{
"authorization": {},
"cookie": {},
"set-cookie": {},
"proxy-authorization": {},
"x-api-key": {},
"x-auth-token": {},
}
// New returns an *http.Client that logs full request/response payloads at
// Trace level. At lower levels it logs only a single Debug line with method,
// URL, status code and duration.
//
// Sensitive headers (Authorization, Cookie, Set-Cookie, ...) are redacted
// to "<redacted>" before being written to the log, per the project rule
// "never write secrets or tokens to logs".
func New() *http.Client {
return &http.Client{
Transport: &traceTransport{base: http.DefaultTransport},
Timeout: 30 * time.Second,
}
}
type traceTransport struct {
base http.RoundTripper
}
func (t *traceTransport) RoundTrip(req *http.Request) (*http.Response, error) {
const fn = "internal.httpc.traceTransport.RoundTrip"
logger := global.Log.WithField("func", fn).WithField("method", req.Method).WithField("url", req.URL.String())
trace := global.Log.GetLevel() >= logrus.TraceLevel
if trace {
dump, err := dumpRequest(req)
if err != nil {
logger.WithError(err).Trace("failed to dump request")
} else {
logger.WithField("payload", "request").Trace("\n" + dump)
}
}
start := time.Now()
resp, err := t.base.RoundTrip(req)
dur := time.Since(start)
if err != nil {
logger.WithError(err).WithField("duration_ms", dur.Milliseconds()).Debug("http error")
return nil, err
}
logger.WithField("status", resp.StatusCode).WithField("duration_ms", dur.Milliseconds()).Debug("http ok")
if trace {
dump, derr := dumpResponse(resp)
if derr != nil {
logger.WithError(derr).Trace("failed to dump response")
} else {
logger.WithField("payload", "response").Trace("\n" + dump)
}
}
return resp, nil
}
// dumpRequest produces a textual dump of req with sensitive headers redacted.
func dumpRequest(req *http.Request) (string, error) {
clone := req.Clone(req.Context())
clone.Header = redactHeaders(req.Header)
raw, err := httputil.DumpRequestOut(clone, true)
if err != nil {
return "", err
}
return string(raw), nil
}
func dumpResponse(resp *http.Response) (string, error) {
if resp.Body != nil {
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
_ = resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(body))
}
origHeader := resp.Header
resp.Header = redactHeaders(origHeader)
defer func() { resp.Header = origHeader }()
raw, err := httputil.DumpResponse(resp, true)
if err != nil {
return "", err
}
return string(raw), nil
}
func redactHeaders(h http.Header) http.Header {
out := make(http.Header, len(h))
for k, v := range h {
if _, ok := sensitiveHeaders[strings.ToLower(k)]; ok {
out[k] = []string{"<redacted>"}
continue
}
out[k] = append([]string(nil), v...)
}
return out
}
+86
View File
@@ -0,0 +1,86 @@
package i18n
import (
"embed"
"fmt"
"strings"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
"gopkg.in/yaml.v3"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
//go:embed locales/*.toml
var localeFS embed.FS
var (
bundle *i18n.Bundle
localizer *i18n.Localizer
)
// Init loads bundled locales and selects a localizer based on the requested
// language tag. Pass an empty string to use the system default (currently
// English).
func Init(lang string) error {
const fn = "internal.i18n.Init"
bundle = i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("toml", tomlUnmarshal)
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
entries, err := localeFS.ReadDir("locales")
if err != nil {
return fmt.Errorf("read locales: %w", err)
}
for _, e := range entries {
if e.IsDir() {
continue
}
path := "locales/" + e.Name()
if _, err := bundle.LoadMessageFileFS(localeFS, path); err != nil {
global.Log.WithField("func", fn).WithError(err).
WithField("file", path).Warn("failed to load locale")
}
}
if strings.TrimSpace(lang) == "" {
lang = "en"
}
localizer = i18n.NewLocalizer(bundle, lang, "en")
global.Log.WithField("func", fn).WithField("lang", lang).Debug("i18n initialized")
return nil
}
// T translates the given message id using the active localizer. If
// translation fails (missing key or i18n not initialized), it returns the
// id unchanged so the UI still renders something readable.
func T(id string, args ...any) string {
if localizer == nil {
return id
}
cfg := &i18n.LocalizeConfig{MessageID: id}
if len(args) > 0 {
// args are formatted directly via fmt.Sprintf if the message
// id contains %s/%d-style verbs.
out, err := localizer.Localize(cfg)
if err != nil {
return fmt.Sprintf(id, args...)
}
return fmt.Sprintf(out, args...)
}
out, err := localizer.Localize(cfg)
if err != nil {
return id
}
return out
}
// SetLanguage switches the active language at runtime.
func SetLanguage(lang string) {
const fn = "internal.i18n.SetLanguage"
localizer = i18n.NewLocalizer(bundle, lang, "en")
global.Log.WithField("func", fn).WithField("lang", lang).Debug("language changed")
}
+369
View File
@@ -0,0 +1,369 @@
# German locale file for winauth-go.
[app_title]
other = "WinAuth"
[menu_file]
other = "Datei"
[menu_help]
other = "Hilfe"
[menu_add_authenticator]
other = "Authentifikator hinzufügen"
[menu_choose_vendor]
other = "Authentifikator-Typ auswählen"
[btn_ok]
other = "OK"
[btn_cancel]
other = "Abbrechen"
[btn_add]
other = "Hinzufügen"
[btn_remove]
other = "Entfernen"
[btn_close]
other = "Schließen"
[btn_enroll]
other = "Registrieren"
[vendor_google]
other = "Google"
[vendor_microsoft]
other = "Microsoft"
[vendor_okta]
other = "Okta Verify"
[vendor_hotp]
other = "HOTP (zählerbasiert)"
[vendor_battlenet]
other = "Battle.Net"
[vendor_steam]
other = "Steam"
[vendor_battlenet_restore]
other = "Battle.Net (mit Wiederherstellungscode wiederherstellen)"
[dialog_add_google_title]
other = "Google Authenticator hinzufügen"
[dialog_add_microsoft_title]
other = "Microsoft Authenticator hinzufügen"
[dialog_add_okta_title]
other = "Okta Verify hinzufügen"
[dialog_add_hotp_title]
other = "HOTP-Authentifikator hinzufügen"
[dialog_add_battlenet_title]
other = "Battle.Net-Authentifikator hinzufügen"
[dialog_add_steam_title]
other = "Steam-Authentifikator hinzufügen"
[label_secret_key]
other = "Geheimschlüssel"
[label_name]
other = "Name"
[label_counter]
other = "Zähler"
[label_serial]
other = "Seriennummer"
[label_device_id]
other = "Geräte-ID"
[label_steam_data]
other = "Steam-Daten (JSON, optional)"
[label_region]
other = "Region"
[label_show_secret]
other = "Geheimnis anzeigen"
[region_auto]
other = "Automatisch erkennen"
[msg_empty_secret]
other = "Bitte einen Geheimschlüssel eingeben."
[msg_invalid_secret]
other = "Der Geheimschlüssel ist kein gültiger Base32-Wert."
[msg_empty_serial]
other = "Bitte die Seriennummer eingeben."
[msg_invalid_counter]
other = "Der Zähler muss eine nicht-negative ganze Zahl sein."
[msg_enrolling]
other = "Verbinde mit dem Server..."
[msg_enroll_failed]
other = "Registrierung fehlgeschlagen: %s"
[menu_settings]
other = "Einstellungen"
[menu_set_password]
other = "Passwort festlegen..."
[menu_about]
other = "Über"
[dialog_password_title]
other = "Passwort eingeben"
[dialog_set_password_title]
other = "Passwort festlegen"
[label_password]
other = "Passwort"
[label_password_confirm]
other = "Passwort bestätigen"
[hint_password_empty_disables]
other = "Beide Felder leer lassen, um die Verschlüsselung zu deaktivieren."
[msg_password_required]
other = "Die Konfiguration ist verschlüsselt. Bitte das Passwort eingeben."
[msg_password_wrong]
other = "Falsches Passwort."
[msg_password_mismatch]
other = "Die beiden Passwörter stimmen nicht überein."
[msg_save_failed]
other = "Speichern der Konfiguration fehlgeschlagen: %s"
[msg_load_failed]
other = "Laden der Konfiguration fehlgeschlagen: %s"
# --- Steam-Registrierungsassistent ---
[steam_step_credentials]
other = "Bitte geben Sie Ihre Steam-Zugangsdaten ein. Das Passwort wird mit Steams öffentlichem RSA-Schlüssel verschlüsselt gesendet und nicht lokal gespeichert."
[steam_step_captcha]
other = "Steam verlangt ein Captcha. Bitte geben Sie die Zeichen aus dem Bild unten ein."
[steam_step_email]
other = "Steam hat einen Bestätigungscode an %s gesendet. Bitte unten eingeben."
[steam_step_activation]
other = "Steam hat einen SMS-Code an Ihr Telefon gesendet. Geben Sie ihn unten ein, um die Registrierung abzuschließen."
[steam_step_done]
other = "Registrierung abgeschlossen. Notieren Sie den Widerrufscode JETZT — ohne ihn können Sie den Authentifikator nicht mehr aus Ihrem Steam-Konto entfernen."
[label_username]
other = "Benutzername"
[label_captcha_text]
other = "Captcha"
[label_email_code]
other = "E-Mail-Code"
[label_activation_code]
other = "Aktivierungscode (SMS)"
[label_revocation_code]
other = "Widerrufscode"
[hint_captcha_loading]
other = "Captcha wird geladen..."
[hint_captcha_failed]
other = "Captcha konnte nicht geladen werden: %s"
[btn_login]
other = "Anmelden"
[btn_continue]
other = "Weiter"
[btn_activate]
other = "Aktivieren"
[btn_finish]
other = "Fertig"
[btn_trades]
other = "Bestätigungen"
[btn_refresh]
other = "Aktualisieren"
[btn_accept]
other = "Annehmen"
[btn_reject]
other = "Ablehnen"
[msg_empty_username]
other = "Bitte einen Benutzernamen eingeben."
[msg_empty_password]
other = "Bitte ein Passwort eingeben."
[msg_empty_captcha]
other = "Bitte den Captcha-Text eingeben."
[msg_empty_email_code]
other = "Bitte den Code aus Ihrer E-Mail eingeben."
[msg_empty_activation_code]
other = "Bitte den Aktivierungscode aus der SMS eingeben."
[msg_busy]
other = "Verarbeitung läuft — bitte warten..."
# --- Steam Bestätigungsfenster ---
[dialog_steam_trades_title]
other = "Steam-Bestätigungen"
[steam_trades_empty]
other = "Keine ausstehenden Bestätigungen."
[steam_trades_loading]
other = "Bestätigungen werden geladen..."
[steam_trades_session_missing]
other = "Dieser Steam-Authentifikator hat keine gespeicherte Login-Sitzung. Bitte fügen Sie ihn erneut über den Registrierungsassistenten hinzu, um Bestätigungen zu verwalten."
[steam_trades_error]
other = "Bestätigungen konnten nicht geladen werden: %s"
# --- Battle.Net Wiederherstellung mit Papier-Code ---
[dialog_restore_battlenet_title]
other = "Battle.Net-Authentifikator wiederherstellen"
[battlenet_restore_intro]
other = "Geben Sie die Seriennummer und den 10-stelligen Wiederherstellungscode ein, den Sie bei der Erstregistrierung notiert haben. Jeder, der beides besitzt, kann den vollen Kontozugriff wiederherstellen — bewahren Sie den Code geheim auf."
[label_restore_code]
other = "Wiederherstellungscode"
[label_show_restore_code]
other = "Wiederherstellungscode anzeigen"
[btn_restore]
other = "Wiederherstellen"
[msg_empty_restore_code]
other = "Bitte den 10-stelligen Wiederherstellungscode eingeben."
[msg_restoring]
other = "Wird von Battle.Net wiederhergestellt..."
[msg_restore_failed]
other = "Wiederherstellung fehlgeschlagen: %s"
# --- Import alter WinAuth-XML-Konfiguration ---
[menu_import_legacy]
other = "Alte WinAuth-XML importieren..."
[dialog_import_legacy_title]
other = "Alte WinAuth-Konfiguration importieren"
[import_legacy_intro]
other = "Wählen Sie die winauth.xml der ursprünglichen WinAuth (gewöhnlich unter %APPDATA%\\WinAuth). DPAPI-verschlüsselte Einträge lassen sich nur auf demselben Windows-Konto/Rechner entschlüsseln, mit dem sie verschlüsselt wurden. Importierte Einträge werden an die aktuelle Liste angehängt."
[label_import_path]
other = "Pfad zur winauth.xml"
[btn_import]
other = "Importieren"
[msg_empty_import_path]
other = "Bitte den Pfad zur winauth.xml angeben."
[msg_legacy_password_required]
other = "Diese Datei enthält passwortverschlüsselte Einträge — bitte das ursprüngliche WinAuth-Passwort eingeben."
[msg_legacy_password_wrong]
other = "Falsches Passwort für die alte WinAuth-Datei."
[msg_import_failed]
other = "Import fehlgeschlagen: %s"
# --- Hotkey-Konfiguration ---
[btn_hotkey]
other = "Hotkey..."
[btn_clear_hotkey]
other = "Hotkey löschen"
[dialog_hotkey_title]
other = "Hotkey bearbeiten"
[label_hotkey]
other = "Hotkey"
[hotkey_intro]
other = "Geben Sie eine globale Tastenkombination für diesen Eintrag ein, z. B. \"Strg+Alt+G\". Es ist mindestens ein Modifikator (Strg, Alt, Umschalt oder Win) erforderlich. Beim Drücken der Kombination wird das OTP in die Zwischenablage kopiert und in das Vordergrundfenster eingegeben."
# --- Sonstige UX ---
[btn_copy]
other = "Kopieren"
[msg_copied]
other = "Code in die Zwischenablage kopiert"
[msg_copy_failed]
other = "Kopieren in die Zwischenablage fehlgeschlagen"
[msg_empty_list]
other = "Noch keine Authentifikatoren. Klicken Sie auf \"Hinzufügen\", um einen zu registrieren."
# --- QR-Code Scan ---
[vendor_scan_qr]
other = "QR-Code scannen (otpauth://)"
[dialog_scan_qr_title]
other = "otpauth QR-Code scannen"
[qr_intro]
other = "Geben Sie den Pfad zu einer PNG/JPG-Datei mit dem QR-Code ein und klicken Sie auf \"Aus Datei\". Oder kopieren Sie das QR-Bild in die Zwischenablage (Win+Umschalt+S funktioniert) und klicken Sie auf \"Aus Zwischenablage\"."
[label_qr_path]
other = "Pfad zur Bilddatei"
[btn_qr_from_file]
other = "Aus Datei"
[btn_qr_from_clipboard]
other = "Aus Zwischenablage"
[msg_empty_qr_path]
other = "Bitte den Pfad zu einer Bilddatei eingeben oder \"Aus Zwischenablage\" verwenden."
[msg_qr_failed]
other = "QR-Scan fehlgeschlagen: %s"
[msg_clipboard_no_image]
other = "Zwischenablage enthält kein Bild"
+371
View File
@@ -0,0 +1,371 @@
# English locale file for winauth-go.
# Use {{.Name}} placeholders for go-i18n templating, or %s/%d style for
# direct fmt-based substitution via T(id, args...).
[app_title]
other = "WinAuth"
[menu_file]
other = "File"
[menu_help]
other = "Help"
[menu_add_authenticator]
other = "Add authenticator"
[menu_choose_vendor]
other = "Choose authenticator type"
[btn_ok]
other = "OK"
[btn_cancel]
other = "Cancel"
[btn_add]
other = "Add"
[btn_remove]
other = "Remove"
[btn_close]
other = "Close"
[btn_enroll]
other = "Enroll"
[vendor_google]
other = "Google"
[vendor_microsoft]
other = "Microsoft"
[vendor_okta]
other = "Okta Verify"
[vendor_hotp]
other = "HOTP (counter-based)"
[vendor_battlenet]
other = "Battle.Net"
[vendor_steam]
other = "Steam"
[vendor_battlenet_restore]
other = "Battle.Net (restore from paper code)"
[dialog_add_google_title]
other = "Add Google Authenticator"
[dialog_add_microsoft_title]
other = "Add Microsoft Authenticator"
[dialog_add_okta_title]
other = "Add Okta Verify"
[dialog_add_hotp_title]
other = "Add HOTP Authenticator"
[dialog_add_battlenet_title]
other = "Add Battle.Net Authenticator"
[dialog_add_steam_title]
other = "Add Steam Authenticator"
[label_secret_key]
other = "Secret key"
[label_name]
other = "Name"
[label_counter]
other = "Counter"
[label_serial]
other = "Serial number"
[label_device_id]
other = "Device ID"
[label_steam_data]
other = "Steam data (JSON, optional)"
[label_region]
other = "Region"
[label_show_secret]
other = "Show secret"
[region_auto]
other = "Auto detect"
[msg_empty_secret]
other = "Please enter a secret key."
[msg_invalid_secret]
other = "The secret key is not valid Base32."
[msg_empty_serial]
other = "Please enter the serial number."
[msg_invalid_counter]
other = "Counter must be a non-negative integer."
[msg_enrolling]
other = "Contacting server..."
[msg_enroll_failed]
other = "Enrollment failed: %s"
[menu_settings]
other = "Settings"
[menu_set_password]
other = "Set password..."
[menu_about]
other = "About"
[dialog_password_title]
other = "Enter password"
[dialog_set_password_title]
other = "Set password"
[label_password]
other = "Password"
[label_password_confirm]
other = "Confirm password"
[hint_password_empty_disables]
other = "Leave both fields empty to disable encryption."
[msg_password_required]
other = "The configuration is encrypted. Please enter the password."
[msg_password_wrong]
other = "Wrong password."
[msg_password_mismatch]
other = "The two passwords do not match."
[msg_save_failed]
other = "Saving the configuration failed: %s"
[msg_load_failed]
other = "Loading the configuration failed: %s"
# --- Steam enrollment wizard ---
[steam_step_credentials]
other = "Enter your Steam account credentials. The password is sent encrypted with Steam's public RSA key and is never stored locally."
[steam_step_captcha]
other = "Steam asked for a captcha. Open the image below and type the characters."
[steam_step_email]
other = "Steam sent a verification code to %s. Enter it below."
[steam_step_activation]
other = "Steam sent an SMS code to your phone. Enter it below to finish enrolling the authenticator."
[steam_step_done]
other = "Enrollment complete. Write the revocation code down NOW — without it you cannot remove this authenticator from your Steam account."
[label_username]
other = "Username"
[label_captcha_text]
other = "Captcha"
[label_email_code]
other = "Email code"
[label_activation_code]
other = "Activation code (SMS)"
[label_revocation_code]
other = "Revocation code"
[hint_captcha_loading]
other = "Loading captcha image..."
[hint_captcha_failed]
other = "Could not load captcha image: %s"
[btn_login]
other = "Sign in"
[btn_continue]
other = "Continue"
[btn_activate]
other = "Activate"
[btn_finish]
other = "Finish"
[btn_trades]
other = "Trades"
[btn_refresh]
other = "Refresh"
[btn_accept]
other = "Accept"
[btn_reject]
other = "Reject"
[msg_empty_username]
other = "Please enter a username."
[msg_empty_password]
other = "Please enter a password."
[msg_empty_captcha]
other = "Please enter the captcha text."
[msg_empty_email_code]
other = "Please enter the code from your email."
[msg_empty_activation_code]
other = "Please enter the activation code from the SMS."
[msg_busy]
other = "Working — please wait..."
# --- Steam trade confirmations window ---
[dialog_steam_trades_title]
other = "Steam confirmations"
[steam_trades_empty]
other = "No pending confirmations."
[steam_trades_loading]
other = "Loading confirmations..."
[steam_trades_session_missing]
other = "This Steam authenticator has no saved login session. Re-add it through the enrollment wizard before you can manage trade confirmations."
[steam_trades_error]
other = "Failed to load confirmations: %s"
# --- Battle.Net paper-code restore ---
[dialog_restore_battlenet_title]
other = "Restore Battle.Net Authenticator"
[battlenet_restore_intro]
other = "Enter the serial number and the 10-character restore code you wrote down when first enrolling the authenticator. Anyone holding both can recover full account access — keep the code secret."
[label_restore_code]
other = "Restore code"
[label_show_restore_code]
other = "Show restore code"
[btn_restore]
other = "Restore"
[msg_empty_restore_code]
other = "Please enter the 10-character restore code."
[msg_restoring]
other = "Restoring from Battle.Net..."
[msg_restore_failed]
other = "Restore failed: %s"
# --- Legacy WinAuth XML import ---
[menu_import_legacy]
other = "Import legacy WinAuth XML..."
[dialog_import_legacy_title]
other = "Import legacy WinAuth config"
[import_legacy_intro]
other = "Locate the winauth.xml from the original WinAuth (usually under %APPDATA%\\WinAuth). DPAPI-encrypted entries can only be decrypted on the same Windows account / machine that produced them. Imported entries are appended to your current list."
[label_import_path]
other = "Path to winauth.xml"
[btn_import]
other = "Import"
[msg_empty_import_path]
other = "Please enter the path to winauth.xml."
[msg_legacy_password_required]
other = "This file contains password-encrypted entries — enter the original WinAuth password."
[msg_legacy_password_wrong]
other = "Wrong password for the legacy WinAuth file."
[msg_import_failed]
other = "Import failed: %s"
# --- Hotkey configuration ---
[btn_hotkey]
other = "Hotkey..."
[btn_clear_hotkey]
other = "Clear hotkey"
[dialog_hotkey_title]
other = "Edit hotkey"
[label_hotkey]
other = "Hotkey"
[hotkey_intro]
other = "Type a global keyboard shortcut for this entry, e.g. \"Ctrl+Alt+G\". Requires at least one modifier (Ctrl, Alt, Shift or Win). Pressing the shortcut anywhere on the desktop copies the OTP to the clipboard and types it into the foreground window."
# --- Misc UX ---
[btn_copy]
other = "Copy"
[msg_copied]
other = "Code copied to clipboard"
[msg_copy_failed]
other = "Could not copy to clipboard"
[msg_empty_list]
other = "No authenticators yet. Click \"Add\" to enroll one."
# --- QR scan dialog ---
[vendor_scan_qr]
other = "Scan QR code (otpauth://)"
[dialog_scan_qr_title]
other = "Scan otpauth QR code"
[qr_intro]
other = "Either enter the path to a PNG/JPG containing the QR code, then click \"From file\". Or copy the QR image to the clipboard (Win+Shift+S works) and click \"From clipboard\"."
[label_qr_path]
other = "Path to image file"
[btn_qr_from_file]
other = "From file"
[btn_qr_from_clipboard]
other = "From clipboard"
[msg_empty_qr_path]
other = "Please enter a path to an image file, or use \"From clipboard\"."
[msg_qr_failed]
other = "QR scan failed: %s"
[msg_clipboard_no_image]
other = "Clipboard does not contain an image"
+369
View File
@@ -0,0 +1,369 @@
# Simplified Chinese locale file for winauth-go.
[app_title]
other = "WinAuth"
[menu_file]
other = "文件"
[menu_help]
other = "帮助"
[menu_add_authenticator]
other = "添加身份验证器"
[menu_choose_vendor]
other = "选择身份验证器类型"
[btn_ok]
other = "确定"
[btn_cancel]
other = "取消"
[btn_add]
other = "添加"
[btn_remove]
other = "删除"
[btn_close]
other = "关闭"
[btn_enroll]
other = "注册"
[vendor_google]
other = "Google"
[vendor_microsoft]
other = "Microsoft"
[vendor_okta]
other = "Okta Verify"
[vendor_hotp]
other = "HOTP(基于计数器)"
[vendor_battlenet]
other = "战网"
[vendor_steam]
other = "Steam"
[vendor_battlenet_restore]
other = "战网(纸质恢复码恢复)"
[dialog_add_google_title]
other = "添加 Google 身份验证器"
[dialog_add_microsoft_title]
other = "添加 Microsoft 身份验证器"
[dialog_add_okta_title]
other = "添加 Okta Verify"
[dialog_add_hotp_title]
other = "添加 HOTP 身份验证器"
[dialog_add_battlenet_title]
other = "添加战网身份验证器"
[dialog_add_steam_title]
other = "添加 Steam 身份验证器"
[label_secret_key]
other = "密钥"
[label_name]
other = "名称"
[label_counter]
other = "计数器"
[label_serial]
other = "序列号"
[label_device_id]
other = "设备 ID"
[label_steam_data]
other = "Steam 数据(JSON,可选)"
[label_region]
other = "区域"
[label_show_secret]
other = "显示密钥"
[region_auto]
other = "自动检测"
[msg_empty_secret]
other = "请输入密钥。"
[msg_invalid_secret]
other = "密钥不是有效的 Base32 字符串。"
[msg_empty_serial]
other = "请输入序列号。"
[msg_invalid_counter]
other = "计数器必须是非负整数。"
[msg_enrolling]
other = "正在连接服务器……"
[msg_enroll_failed]
other = "注册失败:%s"
[menu_settings]
other = "设置"
[menu_set_password]
other = "设置密码…"
[menu_about]
other = "关于"
[dialog_password_title]
other = "输入密码"
[dialog_set_password_title]
other = "设置密码"
[label_password]
other = "密码"
[label_password_confirm]
other = "确认密码"
[hint_password_empty_disables]
other = "两个字段均留空可关闭加密。"
[msg_password_required]
other = "配置已加密,请输入密码。"
[msg_password_wrong]
other = "密码错误。"
[msg_password_mismatch]
other = "两次输入的密码不一致。"
[msg_save_failed]
other = "保存配置失败:%s"
[msg_load_failed]
other = "加载配置失败:%s"
# --- Steam 注册向导 ---
[steam_step_credentials]
other = "请输入 Steam 账号凭证。密码会使用 Steam 公钥 RSA 加密后发送,不会在本地保存。"
[steam_step_captcha]
other = "Steam 需要图形验证码,请输入下面图片中的字符。"
[steam_step_email]
other = "Steam 已将验证码发送到 %s,请在下方输入。"
[steam_step_activation]
other = "Steam 已将短信验证码发送至您的手机,请在下方输入以完成注册。"
[steam_step_done]
other = "注册完成。请立即记录吊销码 —— 若丢失将无法再从 Steam 账号上移除该验证器。"
[label_username]
other = "用户名"
[label_captcha_text]
other = "图形验证码"
[label_email_code]
other = "邮件验证码"
[label_activation_code]
other = "激活码(短信)"
[label_revocation_code]
other = "吊销码"
[hint_captcha_loading]
other = "正在加载验证码图片……"
[hint_captcha_failed]
other = "无法加载验证码图片:%s"
[btn_login]
other = "登录"
[btn_continue]
other = "继续"
[btn_activate]
other = "激活"
[btn_finish]
other = "完成"
[btn_trades]
other = "交易确认"
[btn_refresh]
other = "刷新"
[btn_accept]
other = "接受"
[btn_reject]
other = "拒绝"
[msg_empty_username]
other = "请输入用户名。"
[msg_empty_password]
other = "请输入密码。"
[msg_empty_captcha]
other = "请输入图形验证码。"
[msg_empty_email_code]
other = "请输入邮件中的验证码。"
[msg_empty_activation_code]
other = "请输入短信中的激活码。"
[msg_busy]
other = "正在处理,请稍候……"
# --- Steam 交易确认窗 ---
[dialog_steam_trades_title]
other = "Steam 交易确认"
[steam_trades_empty]
other = "暂无待确认交易。"
[steam_trades_loading]
other = "正在加载交易……"
[steam_trades_session_missing]
other = "该 Steam 验证器没有可用的登录会话。请通过注册向导重新添加,才能管理交易确认。"
[steam_trades_error]
other = "加载交易失败:%s"
# --- 战网纸质恢复码 ---
[dialog_restore_battlenet_title]
other = "恢复战网身份验证器"
[battlenet_restore_intro]
other = "请输入您在初次注册验证器时记录的序列号与 10 字符恢复码。任何同时持有二者的人都能恢复完整账号控制权 —— 请妥善保管恢复码。"
[label_restore_code]
other = "恢复码"
[label_show_restore_code]
other = "显示恢复码"
[btn_restore]
other = "恢复"
[msg_empty_restore_code]
other = "请输入 10 字符恢复码。"
[msg_restoring]
other = "正在从战网服务器恢复……"
[msg_restore_failed]
other = "恢复失败:%s"
# --- 老 WinAuth XML 导入 ---
[menu_import_legacy]
other = "导入旧版 WinAuth XML..."
[dialog_import_legacy_title]
other = "导入旧版 WinAuth 配置"
[import_legacy_intro]
other = "请定位原 WinAuth 的 winauth.xml(通常位于 %APPDATA%\\WinAuth)。DPAPI 加密条目只能在加密时使用的 Windows 用户/电脑上解密。导入的条目会追加到当前列表后面。"
[label_import_path]
other = "winauth.xml 路径"
[btn_import]
other = "导入"
[msg_empty_import_path]
other = "请输入 winauth.xml 的路径。"
[msg_legacy_password_required]
other = "该文件包含密码加密的条目 —— 请输入原 WinAuth 的密码。"
[msg_legacy_password_wrong]
other = "旧版 WinAuth 文件密码错误。"
[msg_import_failed]
other = "导入失败:%s"
# --- 热键配置 ---
[btn_hotkey]
other = "热键..."
[btn_clear_hotkey]
other = "清除热键"
[dialog_hotkey_title]
other = "编辑热键"
[label_hotkey]
other = "热键"
[hotkey_intro]
other = "为该条目设置一个全局快捷键,例如 \"Ctrl+Alt+G\"。至少需要一个修饰键(Ctrl、Alt、Shift 或 Win)。在桌面任意位置按下该快捷键后,OTP 会被复制到剪贴板并直接输入到当前前台窗口。"
# --- 其他 UX ---
[btn_copy]
other = "复制"
[msg_copied]
other = "验证码已复制到剪贴板"
[msg_copy_failed]
other = "复制到剪贴板失败"
[msg_empty_list]
other = "还没有验证器,点击\"添加\"开始。"
# --- 二维码扫描 ---
[vendor_scan_qr]
other = "扫描二维码 (otpauth://)"
[dialog_scan_qr_title]
other = "扫描 otpauth 二维码"
[qr_intro]
other = "输入包含二维码的 PNG/JPG 文件路径后点击\"从文件\"。或者将二维码图片复制到剪贴板(Win+Shift+S 截图即可)后点击\"从剪贴板\"。"
[label_qr_path]
other = "图片文件路径"
[btn_qr_from_file]
other = "从文件"
[btn_qr_from_clipboard]
other = "从剪贴板"
[msg_empty_qr_path]
other = "请输入图片文件路径,或使用\"从剪贴板\"。"
[msg_qr_failed]
other = "二维码扫描失败:%s"
[msg_clipboard_no_image]
other = "剪贴板中没有图片"
+9
View File
@@ -0,0 +1,9 @@
package i18n
import "github.com/BurntSushi/toml"
// tomlUnmarshal is a thin adapter so the i18n bundle can speak TOML via
// BurntSushi/toml without leaking the dependency through the public API.
func tomlUnmarshal(data []byte, v any) error {
return toml.Unmarshal(data, v)
}
+6
View File
@@ -0,0 +1,6 @@
//go:build !windows
package logging
// AttachConsole is a no-op on non-Windows platforms.
func AttachConsole() {}
+50
View File
@@ -0,0 +1,50 @@
//go:build windows
package logging
import (
"syscall"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// AttachConsole attempts to attach the current process to the parent
// console (if any). When a GUI subsystem binary is launched from a shell,
// stdout/stderr are not visible by default; this allows --console to make
// them visible.
//
// This is a best-effort helper: any error is logged at Debug level and
// otherwise ignored. The process keeps running without a console.
func AttachConsole() {
const fn = "internal.logging.AttachConsole"
kernel32, err := syscall.LoadLibrary("kernel32.dll")
if err != nil {
global.Log.WithField("func", fn).WithError(err).Debug("LoadLibrary kernel32 failed")
return
}
defer syscall.FreeLibrary(kernel32)
attach, err := syscall.GetProcAddress(kernel32, "AttachConsole")
if err != nil {
global.Log.WithField("func", fn).WithError(err).Debug("GetProcAddress AttachConsole failed")
return
}
alloc, err := syscall.GetProcAddress(kernel32, "AllocConsole")
if err != nil {
global.Log.WithField("func", fn).WithError(err).Debug("GetProcAddress AllocConsole failed")
return
}
const attachParentProcess = ^uintptr(0) // -1
r1, _, _ := syscall.SyscallN(attach, attachParentProcess)
if r1 == 0 {
// no parent console — allocate a new one
r1, _, _ = syscall.SyscallN(alloc)
if r1 == 0 {
global.Log.WithField("func", fn).Debug("AllocConsole failed")
return
}
}
global.Log.WithField("func", fn).Debug("console attached")
}
+94
View File
@@ -0,0 +1,94 @@
package logging
import (
"fmt"
"io"
"os"
"strings"
"github.com/sirupsen/logrus"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Options controls logger initialization from CLI flags.
type Options struct {
// Level is one of: panic, fatal, error, warn, info, debug, trace.
// Numeric forms (0..6) are also accepted to mirror the legacy convention
// used in CLAUDE.local.md ("Trace(6)", "Debug(5)").
Level string
// File, if non-empty, enables file logging in addition to console output.
// The file is opened with append + create semantics.
File string
// Console forces console output to be visible. On Windows this is the
// signal used by the cobra entry point to allocate / show a console
// window for a GUI build. The logger itself always writes to stderr; this
// field is kept here so that the entry point can read it through the
// same Options struct.
Console bool
}
// Init configures the global.Log logger according to opts.
// Returns the opened file handle (or nil) so the caller can close it on exit.
func Init(opts Options) (io.Closer, error) {
const fn = "internal.logging.Init"
lvl, err := parseLevel(opts.Level)
if err != nil {
return nil, err
}
global.Log.SetLevel(lvl)
global.Log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006-01-02 15:04:05.000",
DisableQuote: false,
})
var closer io.Closer
if strings.TrimSpace(opts.File) != "" {
f, ferr := os.OpenFile(opts.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if ferr != nil {
return nil, fmt.Errorf("open log file %q: %w", opts.File, ferr)
}
global.Log.SetOutput(io.MultiWriter(os.Stderr, f))
closer = f
} else {
global.Log.SetOutput(os.Stderr)
}
global.Log.WithField("func", fn).
WithField("level", lvl.String()).
WithField("file", opts.File).
Debug("logger initialized")
return closer, nil
}
// parseLevel accepts either the textual logrus level names or the numeric
// 0..6 form used in CLAUDE.local.md.
func parseLevel(s string) (logrus.Level, error) {
s = strings.TrimSpace(strings.ToLower(s))
if s == "" {
return logrus.InfoLevel, nil
}
switch s {
case "0", "panic":
return logrus.PanicLevel, nil
case "1", "fatal":
return logrus.FatalLevel, nil
case "2", "error":
return logrus.ErrorLevel, nil
case "3", "warn", "warning":
return logrus.WarnLevel, nil
case "4", "info":
return logrus.InfoLevel, nil
case "5", "debug":
return logrus.DebugLevel, nil
case "6", "trace":
return logrus.TraceLevel, nil
}
return logrus.InfoLevel, fmt.Errorf("unknown log level %q", s)
}
+108
View File
@@ -0,0 +1,108 @@
// Package qr handles otpauth:// QR-code parsing. Image decoding is done
// with gozxing (pure Go, no CGO); URL parsing is hand-rolled per the
// otpauth:// spec used by Google Authenticator, Authy, and friends.
//
// https://github.com/google/google-authenticator/wiki/Key-Uri-Format
package qr
import (
"errors"
"fmt"
"image"
"net/url"
"strconv"
"strings"
"github.com/makiuchi-d/gozxing"
"github.com/makiuchi-d/gozxing/qrcode"
)
// ErrNoQR is returned when the image does not contain a decodable QR code.
var ErrNoQR = errors.New("qr: no QR code found in image")
// ErrNotOtpAuth is returned when the QR decodes successfully but does
// not contain an otpauth:// URI.
var ErrNotOtpAuth = errors.New("qr: decoded text is not an otpauth:// URI")
// DecodeImage runs the gozxing QR reader on img and returns the decoded
// text. Returns ErrNoQR if no QR pattern was found.
func DecodeImage(img image.Image) (string, error) {
bmp, err := gozxing.NewBinaryBitmapFromImage(img)
if err != nil {
return "", fmt.Errorf("qr: bitmap: %w", err)
}
reader := qrcode.NewQRCodeReader()
result, err := reader.Decode(bmp, nil)
if err != nil {
return "", ErrNoQR
}
return result.GetText(), nil
}
// OtpAuth carries the parsed pieces of an otpauth:// URI in the form
// the UI needs to create a config.Entry.
type OtpAuth struct {
// Type is "totp" or "hotp".
Type string
// Label is the human-readable display name (issuer + ":" + account).
Label string
// SecretBase32 is the Base32-encoded HMAC secret.
SecretBase32 string
// Issuer is the optional issuer string ("Google", "GitHub", ...).
Issuer string
// Algorithm is "SHA1", "SHA256", or "SHA512". Empty = unspecified.
Algorithm string
// Digits is the OTP length (typically 6). 0 = unspecified.
Digits int
// Period is the TOTP step in seconds (typically 30). 0 = unspecified.
Period int
// Counter is the HOTP initial counter.
Counter uint64
}
// ParseOtpAuth turns an otpauth:// URI into an OtpAuth struct. The
// secret is left base32-encoded; callers feed it straight into the
// authenticator's Enroll method.
func ParseOtpAuth(raw string) (*OtpAuth, error) {
const fn = "internal.qr.ParseOtpAuth"
raw = strings.TrimSpace(raw)
if !strings.HasPrefix(strings.ToLower(raw), "otpauth://") {
return nil, ErrNotOtpAuth
}
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("%s: parse URL: %w", fn, err)
}
typ := strings.ToLower(u.Host)
if typ != "totp" && typ != "hotp" {
return nil, fmt.Errorf("%s: unsupported otpauth type %q", fn, typ)
}
q := u.Query()
secret := strings.TrimSpace(q.Get("secret"))
if secret == "" {
return nil, fmt.Errorf("%s: missing secret", fn)
}
out := &OtpAuth{
Type: typ,
Label: strings.TrimPrefix(u.Path, "/"),
SecretBase32: secret,
Issuer: q.Get("issuer"),
Algorithm: strings.ToUpper(q.Get("algorithm")),
}
if v := q.Get("digits"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
out.Digits = n
}
}
if v := q.Get("period"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
out.Period = n
}
}
if v := q.Get("counter"); v != "" {
if n, err := strconv.ParseUint(v, 10, 64); err == nil {
out.Counter = n
}
}
return out, nil
}
+50
View File
@@ -0,0 +1,50 @@
package qr
import "testing"
func TestParseOtpAuth_TOTP(t *testing.T) {
uri := "otpauth://totp/Example:[email protected]?" +
"secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30"
got, err := ParseOtpAuth(uri)
if err != nil {
t.Fatalf("ParseOtpAuth: %v", err)
}
if got.Type != "totp" {
t.Errorf("Type = %q, want totp", got.Type)
}
if got.SecretBase32 != "JBSWY3DPEHPK3PXP" {
t.Errorf("SecretBase32 = %q", got.SecretBase32)
}
if got.Issuer != "Example" {
t.Errorf("Issuer = %q", got.Issuer)
}
if got.Label != "Example:[email protected]" {
t.Errorf("Label = %q", got.Label)
}
if got.Digits != 6 || got.Period != 30 {
t.Errorf("Digits/Period = %d/%d", got.Digits, got.Period)
}
}
func TestParseOtpAuth_HOTP(t *testing.T) {
uri := "otpauth://hotp/Account?secret=AAAAAA&counter=42"
got, err := ParseOtpAuth(uri)
if err != nil {
t.Fatalf("ParseOtpAuth: %v", err)
}
if got.Type != "hotp" || got.Counter != 42 {
t.Errorf("got %+v", got)
}
}
func TestParseOtpAuth_RejectsNonOtp(t *testing.T) {
if _, err := ParseOtpAuth("https://example.com/?secret=x"); err != ErrNotOtpAuth {
t.Errorf("want ErrNotOtpAuth, got %v", err)
}
}
func TestParseOtpAuth_MissingSecret(t *testing.T) {
if _, err := ParseOtpAuth("otpauth://totp/foo"); err == nil {
t.Errorf("expected error for missing secret")
}
}
+540
View File
@@ -0,0 +1,540 @@
// Package ui hosts the Gio-based desktop user interface.
package ui
import (
"errors"
"fmt"
"image/color"
"os"
"sync"
"time"
"gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
)
// Run starts the Gio event loop and blocks until the window is closed.
// configPath is the YAML path to load from / save to. An empty string
// uses config.DefaultPath().
func Run(configPath string) error {
const fn = "internal.ui.Run"
if configPath == "" {
configPath = config.DefaultPath()
}
global.Log.WithField("func", fn).WithField("config", configPath).Info("starting Gio UI")
go func() {
w := new(app.Window)
w.Option(
app.Title(i18n.T("app_title")),
app.Size(unit.Dp(560), unit.Dp(420)),
)
if err := loop(w, configPath); err != nil {
global.Log.WithField("func", fn).WithError(err).Error("ui loop failed")
os.Exit(1)
}
os.Exit(0)
}()
app.Main()
return nil
}
type entry struct {
Name string
Auth authenticator.Authenticator
Code string
// Hotkey is the user-configured global shortcut string ("Ctrl+Alt+G")
// or "" if none is set.
Hotkey string
// hotkeyID is the win32 manager's id for the currently-registered
// shortcut, or 0 if not registered.
hotkeyID int32
// Clickable backing the row; for HOTP entries clicking advances the
// counter and reveals the next code.
click widget.Clickable
// tradesBtn is wired only for Steam entries; clicking it opens the
// confirmations window.
tradesBtn widget.Clickable
// hotkeyBtn opens the per-entry hotkey editor.
hotkeyBtn widget.Clickable
// copyBtn copies the current code to the clipboard.
copyBtn widget.Clickable
}
type appState struct {
mu sync.Mutex
entries []*entry
addBtn widget.Clickable
removeBtn widget.Clickable
settingsBtn widget.Clickable
list widget.List
vendorMenu *vendorMenu
settingsMenu *settingsMenu
dialog Dialog
pwDialog *passwordDialog
setPwDialog *setPasswordDialog
importDialog *importLegacyDialog
hotkeyDialog *hotkeyDialog
hotkeyTarget *entry
tradesDialog *steamTradesDialog
store *store
saveErr string // surfaced in the top bar
hkMgr *win32.HotkeyManager
toast toast
}
// snapshotEntries returns a freshly serialized slice of config entries.
// Called from the store goroutine, so it must take appState.mu itself.
func (st *appState) snapshotEntries() []config.Entry {
st.mu.Lock()
defer st.mu.Unlock()
out := make([]config.Entry, 0, len(st.entries))
for _, en := range st.entries {
out = append(out, entryFromAuthenticator(en.Name, en.Auth, en.Hotkey))
}
return out
}
func loop(w *app.Window, configPath string) error {
const fn = "internal.ui.loop"
th := material.NewTheme()
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
state := &appState{}
state.list.Axis = layout.Vertical
state.store = newStore(configPath, state.snapshotEntries, func(err error) {
state.mu.Lock()
state.saveErr = fmt.Sprintf(i18n.T("msg_save_failed"), err.Error())
state.mu.Unlock()
w.Invalidate()
})
// First-load attempt: empty passphrase. If the file is encrypted we'll
// surface a password dialog on the first frame.
if cfg, err := state.store.Load(nil); err != nil {
switch {
case errors.Is(err, ErrPasswordRequired):
state.pwDialog = newPasswordDialog(i18n.T("msg_password_required"))
default:
global.Log.WithField("func", fn).WithError(err).Warn("config load failed; starting empty")
state.saveErr = fmt.Sprintf(i18n.T("msg_load_failed"), err.Error())
}
} else if cfg != nil {
state.absorbConfig(cfg)
}
// Spin up the global hotkey manager and register whatever the user
// already had configured. Failures are non-fatal (logged + the row
// just won't fire).
state.hkMgr = win32.NewHotkeyManager()
state.registerAllHotkeys()
go state.runHotkeyLoop(w)
// Tick once per second to refresh TOTP codes.
go func() {
t := time.NewTicker(time.Second)
defer t.Stop()
for range t.C {
w.Invalidate()
}
}()
var ops op.Ops
for {
switch e := w.Event().(type) {
case app.DestroyEvent:
global.Log.WithField("func", fn).Info("window closed")
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
drawFrame(gtx, th, state, w)
e.Frame(gtx.Ops)
}
}
}
// absorbConfig replaces the in-memory entries with the contents of cfg,
// best-effort: bad entries are logged and skipped.
func (st *appState) absorbConfig(cfg *config.Config) {
const fn = "internal.ui.appState.absorbConfig"
if cfg == nil {
return
}
st.mu.Lock()
defer st.mu.Unlock()
st.entries = st.entries[:0]
for _, e := range cfg.Entries {
a, err := buildAuthenticator(e)
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("skip entry")
continue
}
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey})
}
}
// mergeImportedConfig appends entries from cfg onto the live list
// without dropping anything the user already had. Bad entries are
// logged and skipped, same as absorbConfig.
func (st *appState) mergeImportedConfig(cfg *config.Config) {
const fn = "internal.ui.appState.mergeImportedConfig"
if cfg == nil {
return
}
st.mu.Lock()
defer st.mu.Unlock()
for _, e := range cfg.Entries {
a, err := buildAuthenticator(e)
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("skip imported entry")
continue
}
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey})
}
}
func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Window) layout.Dimensions {
if st.addBtn.Clicked(gtx) {
st.vendorMenu = newVendorMenu()
}
if st.removeBtn.Clicked(gtx) {
st.mu.Lock()
if len(st.entries) > 0 {
st.entries = st.entries[:len(st.entries)-1]
}
st.mu.Unlock()
st.store.Push()
}
if st.settingsBtn.Clicked(gtx) {
st.settingsMenu = newSettingsMenu()
}
// Refresh TOTP codes on every frame; HOTP entries advance on user click only.
st.mu.Lock()
var tradesTarget *entry
var hotkeyTarget *entry
var copyTarget *entry
for _, en := range st.entries {
if en.Auth.Name() == "steam" {
if en.tradesBtn.Clicked(gtx) {
tradesTarget = en
}
}
if en.hotkeyBtn.Clicked(gtx) {
hotkeyTarget = en
}
if en.copyBtn.Clicked(gtx) {
copyTarget = en
}
if en.Auth.Name() == "hotp" {
if en.click.Clicked(gtx) {
if code, err := en.Auth.CurrentCode(); err == nil {
en.Code = code
// Counter advanced — persist so a restart does not
// reuse the same counter value.
go st.store.Push()
}
}
continue
}
if code, err := en.Auth.CurrentCode(); err == nil {
en.Code = code
}
}
st.mu.Unlock()
if tradesTarget != nil {
st.openTradesDialog(tradesTarget, w)
}
if hotkeyTarget != nil {
st.hotkeyTarget = hotkeyTarget
st.hotkeyDialog = newHotkeyDialog(hotkeyTarget)
}
if copyTarget != nil {
st.copyCodeToClipboard(copyTarget, w)
}
// Password retry / first-decrypt loop.
if st.pwDialog != nil {
return st.pwDialog.Layout(gtx, th, func(pw []byte, ok bool) {
if !ok {
// User cancelled. Leave the entry list empty; do NOT
// trigger a save (we don't want to overwrite the
// encrypted file with an empty plaintext one).
st.pwDialog = nil
w.Invalidate()
return
}
cfg, err := st.store.Load(pw)
switch {
case errors.Is(err, ErrPasswordWrong):
st.pwDialog.SetError(i18n.T("msg_password_wrong"))
case err != nil:
st.pwDialog.SetError(fmt.Sprintf(i18n.T("msg_load_failed"), err.Error()))
default:
st.absorbConfig(cfg)
st.pwDialog = nil
}
w.Invalidate()
})
}
if st.setPwDialog != nil {
return st.setPwDialog.Layout(gtx, th, func(pw []byte, ok bool) {
if ok {
st.store.SetPassword(pw)
}
st.setPwDialog = nil
w.Invalidate()
})
}
if st.settingsMenu != nil {
if act, closed := st.settingsMenu.Pick(gtx); closed {
st.settingsMenu = nil
switch act {
case settingsActionSetPassword:
st.setPwDialog = newSetPasswordDialog()
case settingsActionImportLegacy:
st.importDialog = newImportLegacyDialog()
case settingsActionAbout:
// TODO: about dialog (next phase).
}
w.Invalidate()
} else {
return st.settingsMenu.Layout(gtx, th)
}
}
if st.importDialog != nil {
return st.importDialog.Layout(gtx, th, func(r importLegacyResult) {
if !r.cancel && r.cfg != nil {
st.mergeImportedConfig(r.cfg)
st.store.Push()
}
st.importDialog = nil
w.Invalidate()
})
}
if st.vendorMenu != nil {
if v, closed := st.vendorMenu.Pick(gtx); closed {
st.vendorMenu = nil
switch v {
case vendorGoogle:
st.dialog = newAddGoogleDialog()
case vendorMicrosoft:
st.dialog = newAddMicrosoftDialog()
case vendorOkta:
st.dialog = newAddOktaDialog()
case vendorHOTP:
st.dialog = newAddHOTPDialog()
case vendorBattleNet:
st.dialog = newAddBattleNetDialog(w.Invalidate)
case vendorBattleNetRestore:
st.dialog = newRestoreBattleNetDialog(w.Invalidate)
case vendorSteam:
st.dialog = newAddSteamDialog(w.Invalidate)
case vendorScanQR:
st.dialog = newScanQRDialog()
}
w.Invalidate()
} else {
return st.vendorMenu.Layout(gtx, th)
}
}
if st.dialog != nil {
return st.dialog.Layout(gtx, th, func(added authenticator.Authenticator, name string) {
if added != nil {
st.mu.Lock()
st.entries = append(st.entries, &entry{Name: name, Auth: added})
st.mu.Unlock()
st.store.Push()
}
st.dialog = nil
w.Invalidate()
})
}
if st.hotkeyDialog != nil {
return st.hotkeyDialog.Layout(gtx, th, func(r hotkeyResult) {
if !r.cancel && st.hotkeyTarget != nil {
if r.cleared {
st.applyHotkey(st.hotkeyTarget, "")
} else {
st.applyHotkey(st.hotkeyTarget, r.value)
}
st.store.Push()
}
st.hotkeyDialog = nil
st.hotkeyTarget = nil
w.Invalidate()
})
}
if st.tradesDialog != nil {
return st.tradesDialog.Layout(gtx, th)
}
dims := layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(material.H6(th, i18n.T("app_title")).Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{Size: gtx.Constraints.Min}
}),
layout.Rigid(material.Button(th, &st.addBtn, i18n.T("btn_add")).Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Left: unit.Dp(8)}.Layout(gtx,
material.Button(th, &st.removeBtn, i18n.T("btn_remove")).Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Left: unit.Dp(8)}.Layout(gtx,
material.Button(th, &st.settingsBtn, i18n.T("menu_settings")).Layout)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
st.mu.Lock()
msg := st.saveErr
st.mu.Unlock()
if msg == "" {
return layout.Dimensions{}
}
lbl := material.Body2(th, msg)
lbl.Color = color.NRGBA{R: 0xc0, A: 0xff}
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, lbl.Layout)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
st.mu.Lock()
count := len(st.entries)
st.mu.Unlock()
if count == 0 {
return drawEmptyPlaceholder(gtx, th)
}
return material.List(th, &st.list).Layout(gtx, count, func(gtx layout.Context, i int) layout.Dimensions {
st.mu.Lock()
en := st.entries[i]
st.mu.Unlock()
return entryRow(gtx, th, en)
})
}),
)
})
st.toast.draw(gtx, th)
return dims
}
func entryRow(gtx layout.Context, th *material.Theme, en *entry) layout.Dimensions {
return en.click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(6), Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Right: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return entryProgressRing(gtx, en)
})
}),
layout.Flexed(1, material.Body1(th, en.Name).Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
label := i18n.T("btn_hotkey")
if en.Hotkey != "" {
label = en.Hotkey
}
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
material.Button(th, &en.hotkeyBtn, label).Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if en.Auth.Name() != "steam" {
return layout.Dimensions{}
}
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
material.Button(th, &en.tradesBtn, i18n.T("btn_trades")).Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if en.Auth.Name() == "hotp" {
return layout.Dimensions{}
}
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
material.Button(th, &en.copyBtn, i18n.T("btn_copy")).Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.H6(th, en.Code)
lbl.Color = color.NRGBA{R: 0x10, G: 0x70, B: 0xff, A: 0xff}
return lbl.Layout(gtx)
}),
)
})
}),
layout.Rigid(drawDivider),
)
})
}
// openTradesDialog initialises the Steam confirmations modal for the
// given entry. If the authenticator has no usable session we surface a
// hint in the top bar instead of opening an empty window.
func (st *appState) openTradesDialog(en *entry, w *app.Window) {
const fn = "internal.ui.appState.openTradesDialog"
sauth, ok := en.Auth.(*authenticator.SteamAuthenticator)
if !ok {
return
}
if sauth.SessionData == "" {
st.mu.Lock()
st.saveErr = i18n.T("steam_trades_session_missing")
st.mu.Unlock()
return
}
dlg, err := newSteamTradesDialog(sauth, en.Name, w.Invalidate, func() {
st.tradesDialog = nil
// Persist any session-cookie rotation that happened while the
// dialog was open.
st.store.Push()
w.Invalidate()
})
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("open trades dialog failed")
st.mu.Lock()
st.saveErr = fmt.Sprintf(i18n.T("steam_trades_error"), err.Error())
st.mu.Unlock()
return
}
st.tradesDialog = dlg
w.Invalidate()
}
// fillBackground paints a rectangle that covers gtx with the given color.
// Useful for dialog backdrops without depending on material.Surface.
func fillBackground(gtx layout.Context, c color.NRGBA) {
defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop()
paint.ColorOp{Color: c}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
}
+49
View File
@@ -0,0 +1,49 @@
package ui
import (
"fmt"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
)
// entryFromAuthenticator builds a serializable config.Entry from an
// in-memory authenticator plus its display name. The vendor string is
// derived from the authenticator's Name() (which already returns
// "google" / "microsoft" / "okta" / "hotp" / "battlenet" / "steam").
func entryFromAuthenticator(name string, a authenticator.Authenticator, hotkey string) config.Entry {
return config.Entry{
Name: name,
Vendor: a.Name(),
SecretRaw: a.SecretData(),
Hotkey: hotkey,
}
}
// buildAuthenticator is the reverse of entryFromAuthenticator: it picks
// the right concrete type based on vendor, then asks it to parse the
// stored secret blob.
func buildAuthenticator(e config.Entry) (authenticator.Authenticator, error) {
const fn = "internal.ui.buildAuthenticator"
var a authenticator.Authenticator
switch e.Vendor {
case "google", "":
a = authenticator.NewGoogleAuthenticator()
case "microsoft":
a = authenticator.NewMicrosoftAuthenticator()
case "okta":
a = authenticator.NewOktaVerifyAuthenticator()
case "hotp":
a = authenticator.NewHOTPAuthenticator()
case "battlenet":
a = authenticator.NewBattleNetAuthenticator()
case "steam":
a = authenticator.NewSteamAuthenticator()
default:
return nil, fmt.Errorf("%s: unknown vendor %q", fn, e.Vendor)
}
if err := a.SetSecretData(e.SecretRaw); err != nil {
return nil, fmt.Errorf("%s: decode entry %q: %w", fn, e.Name, err)
}
return a, nil
}
+132
View File
@@ -0,0 +1,132 @@
package ui
import (
"image/color"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
)
// Dialog is the common contract implemented by every modal dialog.
// onDone is invoked with the newly built authenticator + display name on
// successful submission, or with (nil, "") on cancel.
type Dialog interface {
Layout(gtx layout.Context, th *material.Theme,
onDone func(authenticator.Authenticator, string)) layout.Dimensions
}
// modalCard renders the standard backdrop + centered card with the given
// title, body widget and an OK + Cancel button row. okLabel allows callers
// to override the primary button label (e.g. "Enroll" for Battle.Net).
//
// The function does not handle the click events itself; callers should
// query okBtn.Clicked / cancelBtn.Clicked before laying out so they can
// short-circuit the frame.
func modalCard(
gtx layout.Context,
th *material.Theme,
title string,
okLabel string,
cancelLabel string,
okBtn *widget.Clickable,
cancelBtn *widget.Clickable,
body layout.Widget,
) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60})
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(420)
return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
CornerRadius: unit.Dp(4),
Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, title).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(body),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(material.Button(th, cancelBtn, cancelLabel).Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Left: unit.Dp(8)}.Layout(gtx,
material.Button(th, okBtn, okLabel).Layout)
}),
)
}),
)
})
})
})
}
// modalCardCancel is a variant of modalCard with only a Cancel button.
// Useful for dialogs whose primary actions live inside the body itself
// (e.g. the QR scan dialog with separate "from file" / "from clipboard"
// buttons).
func modalCardCancel(
gtx layout.Context,
th *material.Theme,
title string,
cancelLabel string,
cancelBtn *widget.Clickable,
body layout.Widget,
) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60})
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(420)
return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
CornerRadius: unit.Dp(4),
Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, title).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(body),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(material.Button(th, cancelBtn, cancelLabel).Layout),
)
}),
)
})
})
})
}
// errorLabel returns a layout widget that renders msg in red, or nothing
// when msg is empty. Used by dialogs to display validation errors.
func errorLabel(th *material.Theme, msg string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
if msg == "" {
return layout.Dimensions{}
}
lbl := material.Body2(th, msg)
lbl.Color = color.NRGBA{R: 0xc0, A: 0xff}
return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, lbl.Layout)
}
}
// labeledEditor lays out a small body label above the editor. hint is the
// editor placeholder text.
func labeledEditor(th *material.Theme, label string, ed *widget.Editor, hint string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, label).Layout),
layout.Rigid(material.Editor(th, ed, hint).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
)
}
}
+117
View File
@@ -0,0 +1,117 @@
package ui
import (
"context"
"fmt"
"sync"
"time"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// addBattleNetDialog drives the Battle.Net mobile-service enrollment flow.
// Unlike Google/Microsoft/Okta the secret is server-issued, so the user
// only chooses a display name and an optional region override; on Enroll
// we kick off a background goroutine and disable the button while waiting.
type addBattleNetDialog struct {
nameEd widget.Editor
regionEd widget.Editor // empty = auto
okBtn widget.Clickable
cancelBtn widget.Clickable
errorMsg string
mu sync.Mutex
pending bool
result *authenticator.BattleNetAuthenticator
resultEr error
invalidate func() // set by Layout on first call
}
func newAddBattleNetDialog(invalidate func()) *addBattleNetDialog {
d := &addBattleNetDialog{invalidate: invalidate}
d.nameEd.SingleLine = true
d.regionEd.SingleLine = true
return d
}
func (d *addBattleNetDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(authenticator.Authenticator, string),
) layout.Dimensions {
const fn = "internal.ui.addBattleNetDialog.Layout"
d.mu.Lock()
pending := d.pending
finished := !pending && (d.result != nil || d.resultEr != nil)
res := d.result
resErr := d.resultEr
d.mu.Unlock()
if finished {
if resErr != nil {
d.errorMsg = fmt.Sprintf(i18n.T("msg_enroll_failed"), resErr.Error())
// clear the latched result so retry works
d.mu.Lock()
d.result, d.resultEr = nil, nil
d.mu.Unlock()
} else if res != nil {
name := d.nameEd.Text()
if name == "" {
name = i18n.T("vendor_battlenet")
}
onDone(res, name)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}
if d.okBtn.Clicked(gtx) && !pending {
d.mu.Lock()
d.pending = true
d.errorMsg = i18n.T("msg_enrolling")
d.mu.Unlock()
region := d.regionEd.Text()
go func(region string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
b := authenticator.NewBattleNetAuthenticator()
err := b.Enroll(ctx, region)
d.mu.Lock()
d.pending = false
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("battle.net enroll failed")
d.resultEr = err
} else {
d.result = b
}
d.mu.Unlock()
if d.invalidate != nil {
d.invalidate()
}
}(region)
}
if d.cancelBtn.Clicked(gtx) && !pending {
onDone(nil, "")
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(th, i18n.T("label_name"), &d.nameEd, i18n.T("vendor_battlenet"))),
layout.Rigid(labeledEditor(th, i18n.T("label_region"), &d.regionEd, i18n.T("region_auto"))),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
// disable buttons while pending by swallowing inputs (no Gio-native
// "disabled" flag on material.Button — we just gate via the pending check
// above before reacting to clicks)
return modalCard(gtx, th, i18n.T("dialog_add_battlenet_title"),
i18n.T("btn_enroll"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
+146
View File
@@ -0,0 +1,146 @@
package ui
import (
"context"
"fmt"
"strings"
"sync"
"time"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// restoreBattleNetDialog drives the paper-restore flow: the user types
// the serial they wrote down at enrollment time plus the 10-char
// restore code, and the backend asks Blizzard for the original secret.
//
// SECURITY: the restore code is functionally a root key — anybody
// holding it can recover the authenticator and authorize Battle.Net
// logins. The editor masks it by default and we never log it.
type restoreBattleNetDialog struct {
nameEd widget.Editor
serialEd widget.Editor
codeEd widget.Editor
showCode widget.Bool
okBtn widget.Clickable
cancelBtn widget.Clickable
errorMsg string
mu sync.Mutex
pending bool
result *authenticator.BattleNetAuthenticator
resultEr error
invalidate func()
}
func newRestoreBattleNetDialog(invalidate func()) *restoreBattleNetDialog {
d := &restoreBattleNetDialog{invalidate: invalidate}
d.nameEd.SingleLine = true
d.serialEd.SingleLine = true
d.codeEd.SingleLine = true
d.codeEd.Mask = '*'
return d
}
func (d *restoreBattleNetDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(authenticator.Authenticator, string),
) layout.Dimensions {
const fn = "internal.ui.restoreBattleNetDialog.Layout"
if d.showCode.Update(gtx) {
if d.showCode.Value {
d.codeEd.Mask = 0
} else {
d.codeEd.Mask = '*'
}
}
d.mu.Lock()
pending := d.pending
finished := !pending && (d.result != nil || d.resultEr != nil)
res := d.result
resErr := d.resultEr
d.mu.Unlock()
if finished {
if resErr != nil {
d.errorMsg = fmt.Sprintf(i18n.T("msg_restore_failed"), resErr.Error())
d.mu.Lock()
d.result, d.resultEr = nil, nil
d.mu.Unlock()
} else if res != nil {
name := strings.TrimSpace(d.nameEd.Text())
if name == "" {
name = i18n.T("vendor_battlenet")
}
// Wipe the code editor so a leftover value cannot be read
// off the screen if the parent reuses the dialog.
d.codeEd.SetText("")
onDone(res, name)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}
if d.okBtn.Clicked(gtx) && !pending {
serial := strings.TrimSpace(d.serialEd.Text())
code := d.codeEd.Text()
if serial == "" {
d.errorMsg = i18n.T("msg_empty_serial")
} else if strings.TrimSpace(code) == "" {
d.errorMsg = i18n.T("msg_empty_restore_code")
} else {
d.mu.Lock()
d.pending = true
d.errorMsg = i18n.T("msg_restoring")
d.mu.Unlock()
go func(serial, code string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
b := authenticator.NewBattleNetAuthenticator()
err := b.Restore(ctx, serial, code)
d.mu.Lock()
d.pending = false
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("battle.net restore failed")
d.resultEr = err
} else {
d.result = b
}
d.mu.Unlock()
if d.invalidate != nil {
d.invalidate()
}
}(serial, code)
}
}
if d.cancelBtn.Clicked(gtx) && !pending {
// Clear the code editor on cancel so the secret does not linger.
d.codeEd.SetText("")
onDone(nil, "")
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("battlenet_restore_intro")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_name"), &d.nameEd, i18n.T("vendor_battlenet"))),
layout.Rigid(labeledEditor(th, i18n.T("label_serial"), &d.serialEd, "US-1234-5678-9012")),
layout.Rigid(labeledEditor(th, i18n.T("label_restore_code"), &d.codeEd, "")),
layout.Rigid(material.CheckBox(th, &d.showCode, i18n.T("label_show_restore_code")).Layout),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
return modalCard(gtx, th, i18n.T("dialog_restore_battlenet_title"),
i18n.T("btn_restore"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
+122
View File
@@ -0,0 +1,122 @@
package ui
import (
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// addTOTPDialog is the shared "Add ..." form used by Google, Microsoft and
// Okta Verify — vendors that all use the same {Name, Base32-secret} input
// surface and only differ by display title, default name, and factory
// function.
type addTOTPDialog struct {
title string
defaultName string
factory func() authenticator.Authenticator
nameEd widget.Editor
secretEd widget.Editor
showSec widget.Bool
okBtn widget.Clickable
cancelBtn widget.Clickable
errorMsg string
}
func newAddGoogleDialog() *addTOTPDialog {
return newAddTOTPDialog(
i18n.T("dialog_add_google_title"),
i18n.T("vendor_google"),
func() authenticator.Authenticator { return authenticator.NewGoogleAuthenticator() },
)
}
func newAddMicrosoftDialog() *addTOTPDialog {
return newAddTOTPDialog(
i18n.T("dialog_add_microsoft_title"),
i18n.T("vendor_microsoft"),
func() authenticator.Authenticator { return authenticator.NewMicrosoftAuthenticator() },
)
}
func newAddOktaDialog() *addTOTPDialog {
return newAddTOTPDialog(
i18n.T("dialog_add_okta_title"),
i18n.T("vendor_okta"),
func() authenticator.Authenticator { return authenticator.NewOktaVerifyAuthenticator() },
)
}
func newAddTOTPDialog(title, defaultName string, factory func() authenticator.Authenticator) *addTOTPDialog {
d := &addTOTPDialog{title: title, defaultName: defaultName, factory: factory}
d.nameEd.SingleLine = true
d.secretEd.SingleLine = true
d.secretEd.Mask = '*'
return d
}
// enrollable is implemented by authenticators whose secret is provided as a
// raw Base32 string by the user (Google / Microsoft / Okta).
type enrollable interface {
authenticator.Authenticator
Enroll(secret string) error
}
func (d *addTOTPDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(authenticator.Authenticator, string),
) layout.Dimensions {
const fn = "internal.ui.addTOTPDialog.Layout"
if d.showSec.Update(gtx) {
if d.showSec.Value {
d.secretEd.Mask = 0
} else {
d.secretEd.Mask = '*'
}
}
if d.okBtn.Clicked(gtx) {
secret := d.secretEd.Text()
if secret == "" {
d.errorMsg = i18n.T("msg_empty_secret")
} else {
a := d.factory()
if en, ok := a.(enrollable); ok {
if err := en.Enroll(secret); err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("enroll failed")
d.errorMsg = i18n.T("msg_invalid_secret")
} else {
name := d.nameEd.Text()
if name == "" {
name = d.defaultName
}
onDone(a, name)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
} else {
d.errorMsg = i18n.T("msg_invalid_secret")
}
}
}
if d.cancelBtn.Clicked(gtx) {
onDone(nil, "")
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(th, i18n.T("label_name"), &d.nameEd, d.defaultName)),
layout.Rigid(labeledEditor(th, i18n.T("label_secret_key"), &d.secretEd, "ABCDEF...")),
layout.Rigid(material.CheckBox(th, &d.showSec, i18n.T("label_show_secret")).Layout),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
return modalCard(gtx, th, d.title,
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
+87
View File
@@ -0,0 +1,87 @@
package ui
import (
"errors"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/hotkey"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// hotkeyDialog edits the hotkey string of a single entry. It's a thin
// wrapper around an Editor: validation runs synchronously on submit and
// the caller is told the *parsed* value (still in canonical string
// form, "" for clear).
type hotkeyDialog struct {
target *entry
ed widget.Editor
clearBt widget.Clickable
okBt widget.Clickable
cancelBt widget.Clickable
errorMsg string
}
func newHotkeyDialog(target *entry) *hotkeyDialog {
d := &hotkeyDialog{target: target}
d.ed.SingleLine = true
d.ed.SetText(target.Hotkey)
return d
}
// hotkeyResult tells the caller what to do. cleared=true means remove
// any existing hotkey. value is the canonical string ("Ctrl+Alt+G") if
// set is true.
type hotkeyResult struct {
cancel bool
cleared bool
value string
}
func (d *hotkeyDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(hotkeyResult),
) layout.Dimensions {
if d.cancelBt.Clicked(gtx) {
onDone(hotkeyResult{cancel: true})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.clearBt.Clicked(gtx) {
onDone(hotkeyResult{cleared: true})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.okBt.Clicked(gtx) {
txt := d.ed.Text()
h, err := hotkey.Parse(txt)
switch {
case errors.Is(err, hotkey.ErrEmpty):
onDone(hotkeyResult{cleared: true})
return layout.Dimensions{Size: gtx.Constraints.Max}
case err != nil:
d.errorMsg = err.Error()
default:
onDone(hotkeyResult{value: hotkey.Format(h)})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("hotkey_intro")).Layout),
layout.Rigid(layout.Spacer{Height: 8}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_hotkey"), &d.ed, "Ctrl+Alt+G")),
layout.Rigid(errorLabel(th, d.errorMsg)),
layout.Rigid(layout.Spacer{Height: 8}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return material.Button(th, &d.clearBt, i18n.T("btn_clear_hotkey")).Layout(gtx)
}),
)
}
return modalCard(gtx, th, i18n.T("dialog_hotkey_title"),
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBt, &d.cancelBt, body)
}
+91
View File
@@ -0,0 +1,91 @@
package ui
import (
"strconv"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// addHOTPDialog asks for {Name, Base32 secret, starting counter}.
type addHOTPDialog struct {
nameEd widget.Editor
secretEd widget.Editor
counterEd widget.Editor
showSec widget.Bool
okBtn widget.Clickable
cancelBtn widget.Clickable
errorMsg string
}
func newAddHOTPDialog() *addHOTPDialog {
d := &addHOTPDialog{}
d.nameEd.SingleLine = true
d.secretEd.SingleLine = true
d.secretEd.Mask = '*'
d.counterEd.SingleLine = true
d.counterEd.SetText("0")
return d
}
func (d *addHOTPDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(authenticator.Authenticator, string),
) layout.Dimensions {
const fn = "internal.ui.addHOTPDialog.Layout"
if d.showSec.Update(gtx) {
if d.showSec.Value {
d.secretEd.Mask = 0
} else {
d.secretEd.Mask = '*'
}
}
if d.okBtn.Clicked(gtx) {
secret := d.secretEd.Text()
if secret == "" {
d.errorMsg = i18n.T("msg_empty_secret")
} else {
counter, err := strconv.ParseUint(d.counterEd.Text(), 10, 64)
if err != nil {
d.errorMsg = i18n.T("msg_invalid_counter")
} else {
h := authenticator.NewHOTPAuthenticator()
if err := h.Enroll(secret, counter); err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("enroll failed")
d.errorMsg = i18n.T("msg_invalid_secret")
} else {
name := d.nameEd.Text()
if name == "" {
name = i18n.T("vendor_hotp")
}
onDone(h, name)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}
}
}
if d.cancelBtn.Clicked(gtx) {
onDone(nil, "")
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(th, i18n.T("label_name"), &d.nameEd, i18n.T("vendor_hotp"))),
layout.Rigid(labeledEditor(th, i18n.T("label_secret_key"), &d.secretEd, "ABCDEF...")),
layout.Rigid(labeledEditor(th, i18n.T("label_counter"), &d.counterEd, "0")),
layout.Rigid(material.CheckBox(th, &d.showSec, i18n.T("label_show_secret")).Layout),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
return modalCard(gtx, th, i18n.T("dialog_add_hotp_title"),
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
+102
View File
@@ -0,0 +1,102 @@
package ui
import (
"errors"
"fmt"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// importLegacyDialog drives the "Import legacy WinAuth XML..." flow.
// It is intentionally minimal: a path field, an optional password field
// for entries the user encrypted in the original C# WinAuth, and Import
// + Cancel buttons. DPAPI-encrypted entries decrypt transparently on
// Windows; on other platforms they are skipped with a warning in the
// log.
type importLegacyDialog struct {
pathEd widget.Editor
pwEd widget.Editor
importBt widget.Clickable
cancelBt widget.Clickable
errorMsg string
}
func newImportLegacyDialog() *importLegacyDialog {
d := &importLegacyDialog{}
d.pathEd.SingleLine = true
d.pwEd.SingleLine = true
d.pwEd.Mask = '*'
return d
}
// importLegacyResult is what the dialog reports to its caller.
type importLegacyResult struct {
cfg *config.Config
cancel bool
}
// Layout returns dimensions and reports outcomes via onDone. onDone is
// called with cancel=true on Cancel and with a non-nil cfg on success;
// validation errors are kept inside the dialog so the user can retry.
func (d *importLegacyDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(importLegacyResult),
) layout.Dimensions {
if d.cancelBt.Clicked(gtx) {
d.wipe()
onDone(importLegacyResult{cancel: true})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.importBt.Clicked(gtx) {
path := d.pathEd.Text()
if path == "" {
d.errorMsg = i18n.T("msg_empty_import_path")
} else {
password := []byte(d.pwEd.Text())
cfg, err := config.LoadLegacyXML(path, password)
for i := range password {
password[i] = 0
}
switch {
case errors.Is(err, config.ErrLegacyPasswordRequired):
d.errorMsg = i18n.T("msg_legacy_password_required")
case errors.Is(err, config.ErrLegacyPasswordWrong):
d.errorMsg = i18n.T("msg_legacy_password_wrong")
case err != nil:
d.errorMsg = fmt.Sprintf(i18n.T("msg_import_failed"), err.Error())
global.Log.WithField("func", "internal.ui.importLegacyDialog.Layout").
WithError(err).Warn("legacy import failed")
default:
d.wipe()
onDone(importLegacyResult{cfg: cfg})
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("import_legacy_intro")).Layout),
layout.Rigid(layout.Spacer{Height: 8}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_import_path"), &d.pathEd, "C:\\Users\\...\\winauth.xml")),
layout.Rigid(labeledEditor(th, i18n.T("label_password"), &d.pwEd, "")),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
return modalCard(gtx, th, i18n.T("dialog_import_legacy_title"),
i18n.T("btn_import"), i18n.T("btn_cancel"),
&d.importBt, &d.cancelBt, body)
}
// wipe clears the password editor so the plaintext bytes are not kept
// after the dialog closes.
func (d *importLegacyDialog) wipe() {
d.pwEd.SetText("")
}
+69
View File
@@ -0,0 +1,69 @@
package ui
import (
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// passwordDialog asks the user for the password to decrypt an existing
// config. The dialog is shown at startup when the config file is found
// to be encrypted; on submit it calls onDone with the entered passphrase.
// Cancel returns an empty passphrase and signals abort.
type passwordDialog struct {
prompt string // optional explanatory line above the field
errorMsg string
pwEd widget.Editor
okBtn widget.Clickable
cancelBtn widget.Clickable
}
func newPasswordDialog(prompt string) *passwordDialog {
d := &passwordDialog{prompt: prompt}
d.pwEd.SingleLine = true
d.pwEd.Mask = '*'
return d
}
// SetError lets the caller surface a "wrong password" message after a
// failed Load attempt, so the same dialog can be reused for a retry loop.
func (d *passwordDialog) SetError(msg string) { d.errorMsg = msg }
// Layout takes onDone(password, ok). ok=true on submit; ok=false on cancel.
// On ok the password slice is freshly allocated (the editor's underlying
// buffer is not retained).
func (d *passwordDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(password []byte, ok bool),
) layout.Dimensions {
if d.okBtn.Clicked(gtx) {
pw := []byte(d.pwEd.Text())
onDone(pw, true)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.cancelBtn.Clicked(gtx) {
onDone(nil, false)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
children := []layout.FlexChild{}
if d.prompt != "" {
children = append(children,
layout.Rigid(material.Body2(th, d.prompt).Layout),
layout.Rigid(layout.Spacer{Height: 8}.Layout),
)
}
children = append(children,
layout.Rigid(labeledEditor(th, i18n.T("label_password"), &d.pwEd, "")),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
return modalCard(gtx, th, i18n.T("dialog_password_title"),
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
+183
View File
@@ -0,0 +1,183 @@
package ui
import (
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
"git.wxccs.org/iceking2nd/winauth-go/internal/qr"
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
)
// scanQRDialog drives the "scan otpauth:// QR code" flow. It offers two
// entry points: read a PNG/JPG file from disk, or grab whatever image
// is currently on the clipboard (Win+Shift+S screenshot landing zone).
// On success the dialog hands the caller a fully built authenticator
// and a default display name derived from the QR's issuer + label.
type scanQRDialog struct {
pathEd widget.Editor
fromFileBt widget.Clickable
clipBt widget.Clickable
cancelBt widget.Clickable
errorMsg string
}
func newScanQRDialog() *scanQRDialog {
d := &scanQRDialog{}
d.pathEd.SingleLine = true
return d
}
// Layout follows the same Dialog contract as the per-vendor add
// dialogs: onDone(nil, "") on cancel, onDone(auth, displayName) on
// successful scan + parse.
func (d *scanQRDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(authenticator.Authenticator, string),
) layout.Dimensions {
if d.cancelBt.Clicked(gtx) {
onDone(nil, "")
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.fromFileBt.Clicked(gtx) {
path := d.pathEd.Text()
if path == "" {
d.errorMsg = i18n.T("msg_empty_qr_path")
} else if auth, name, err := decodeFromFile(path); err != nil {
d.errorMsg = fmt.Sprintf(i18n.T("msg_qr_failed"), err.Error())
global.Log.WithField("func", "internal.ui.scanQRDialog.fromFile").
WithError(err).Warn("QR scan from file failed")
} else {
onDone(auth, name)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}
if d.clipBt.Clicked(gtx) {
if auth, name, err := decodeFromClipboard(); err != nil {
d.errorMsg = fmt.Sprintf(i18n.T("msg_qr_failed"), err.Error())
global.Log.WithField("func", "internal.ui.scanQRDialog.fromClipboard").
WithError(err).Warn("QR scan from clipboard failed")
} else {
onDone(auth, name)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("qr_intro")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_qr_path"), &d.pathEd, "C:\\...\\code.png")),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(material.Button(th, &d.fromFileBt, i18n.T("btn_qr_from_file")).Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(th, &d.clipBt, i18n.T("btn_qr_from_clipboard")).Layout),
)
}),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
// Use modalCard with only the cancel button (OK is a no-op here
// because the action buttons live inside the body).
return modalCardCancel(gtx, th, i18n.T("dialog_scan_qr_title"),
i18n.T("btn_cancel"), &d.cancelBt, body)
}
// decodeFromFile reads a PNG/JPG/GIF off disk, decodes any QR code in
// it, parses the otpauth URI, then builds the matching authenticator.
func decodeFromFile(path string) (authenticator.Authenticator, string, error) {
f, err := os.Open(path)
if err != nil {
return nil, "", err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil, "", fmt.Errorf("decode image: %w", err)
}
return decodeAndBuild(img)
}
// decodeFromClipboard pulls the current clipboard image (Snipping Tool
// landing zone) and runs the same pipeline.
func decodeFromClipboard() (authenticator.Authenticator, string, error) {
img, err := win32.GetClipboardImage()
if err != nil {
return nil, "", err
}
if img == nil {
return nil, "", fmt.Errorf("%s", i18n.T("msg_clipboard_no_image"))
}
return decodeAndBuild(img)
}
func decodeAndBuild(img image.Image) (authenticator.Authenticator, string, error) {
text, err := qr.DecodeImage(img)
if err != nil {
return nil, "", err
}
parsed, err := qr.ParseOtpAuth(text)
if err != nil {
return nil, "", err
}
auth, err := authenticatorFromOtpAuth(parsed)
if err != nil {
return nil, "", err
}
return auth, displayNameFromOtpAuth(parsed), nil
}
func authenticatorFromOtpAuth(p *qr.OtpAuth) (authenticator.Authenticator, error) {
switch p.Type {
case "totp":
a := authenticator.NewGoogleAuthenticator()
if err := a.Enroll(p.SecretBase32); err != nil {
return nil, err
}
if p.Digits > 0 {
a.CodeDigits = p.Digits
}
if p.Period > 0 {
a.Period = p.Period
}
return a, nil
case "hotp":
a := authenticator.NewHOTPAuthenticator()
if err := a.Enroll(p.SecretBase32, p.Counter); err != nil {
return nil, err
}
if p.Digits > 0 {
a.CodeDigits = p.Digits
}
return a, nil
default:
return nil, fmt.Errorf("unsupported otpauth type %q", p.Type)
}
}
func displayNameFromOtpAuth(p *qr.OtpAuth) string {
if p.Issuer != "" && p.Label != "" {
return p.Issuer + ": " + p.Label
}
if p.Label != "" {
return p.Label
}
if p.Issuer != "" {
return p.Issuer
}
return "QR"
}
+65
View File
@@ -0,0 +1,65 @@
package ui
import (
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// setPasswordDialog collects a new password and a confirmation. Leaving
// both fields empty disables encryption (passphrase = nil).
type setPasswordDialog struct {
pwEd widget.Editor
confirmEd widget.Editor
okBtn widget.Clickable
cancelBtn widget.Clickable
errorMsg string
}
func newSetPasswordDialog() *setPasswordDialog {
d := &setPasswordDialog{}
d.pwEd.SingleLine = true
d.pwEd.Mask = '*'
d.confirmEd.SingleLine = true
d.confirmEd.Mask = '*'
return d
}
// Layout takes onDone(password, ok). ok=true on submit; an empty
// password byte slice signals "disable encryption", a non-empty slice
// is the new passphrase.
func (d *setPasswordDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(password []byte, ok bool),
) layout.Dimensions {
if d.okBtn.Clicked(gtx) {
pw := d.pwEd.Text()
confirm := d.confirmEd.Text()
if pw != confirm {
d.errorMsg = i18n.T("msg_password_mismatch")
} else {
onDone([]byte(pw), true)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}
if d.cancelBtn.Clicked(gtx) {
onDone(nil, false)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(th, i18n.T("label_password"), &d.pwEd, "")),
layout.Rigid(labeledEditor(th, i18n.T("label_password_confirm"), &d.confirmEd, "")),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(material.Caption(th, i18n.T("hint_password_empty_disables")).Layout),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
return modalCard(gtx, th, i18n.T("dialog_set_password_title"),
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
+440
View File
@@ -0,0 +1,440 @@
package ui
import (
"bytes"
"context"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"strings"
"sync"
"time"
"gioui.org/layout"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// steamWizardStep enumerates the wizard pages. The state machine is
// driven by the EnrollState.Requires* flags returned by each Enroll
// call; we just map them onto a UI step.
type steamWizardStep int
const (
steamStepCredentials steamWizardStep = iota
steamStepCaptcha
steamStepEmail
steamStepActivation
steamStepDone
)
// addSteamDialog is the Steam mobile authenticator enrollment wizard.
// It owns a single long-lived EnrollState plus a SteamAuthenticator
// instance; each "OK"-style button kicks off a goroutine that runs one
// Enroll round and updates the UI when it finishes.
//
// SECURITY: state.Password is wiped by Enroll itself once the credentials
// have been RSA-encrypted and sent. Captcha / email / SMS codes are not
// secrets per se but we still avoid logging them anywhere.
type addSteamDialog struct {
nameEd widget.Editor
usernameEd widget.Editor
passwordEd widget.Editor
captchaEd widget.Editor
emailEd widget.Editor
activateEd widget.Editor
okBtn widget.Clickable
cancelBtn widget.Clickable
step steamWizardStep
errorMsg string
mu sync.Mutex
pending bool
authImpl *authenticator.SteamAuthenticator
state *authenticator.EnrollState
lastDone bool
lastErr error
finalName string
// Cached decoded captcha image, plus a marker so we re-fetch only
// when the URL actually changes between Enroll rounds.
captchaURLLoaded string
captchaImg image.Image
captchaImgErr error
captchaLoading bool
invalidate func()
}
func newAddSteamDialog(invalidate func()) *addSteamDialog {
d := &addSteamDialog{
step: steamStepCredentials,
authImpl: authenticator.NewSteamAuthenticator(),
state: &authenticator.EnrollState{},
invalidate: invalidate,
}
d.nameEd.SingleLine = true
d.usernameEd.SingleLine = true
d.passwordEd.SingleLine = true
d.passwordEd.Mask = '*'
d.captchaEd.SingleLine = true
d.emailEd.SingleLine = true
d.activateEd.SingleLine = true
return d
}
func (d *addSteamDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(authenticator.Authenticator, string),
) layout.Dimensions {
const fn = "internal.ui.addSteamDialog.Layout"
d.mu.Lock()
pending := d.pending
finished := !pending && d.lastDone
resErr := d.lastErr
stateErr := ""
if d.state != nil {
stateErr = d.state.Error
}
d.mu.Unlock()
// Network-error path (e.g. transport, parse): show and let user retry.
if finished && resErr != nil {
d.errorMsg = fmt.Sprintf(i18n.T("msg_enroll_failed"), resErr.Error())
d.mu.Lock()
d.lastDone = false
d.lastErr = nil
d.mu.Unlock()
}
// Successful Enroll round: advance the wizard based on which flags
// the state machine just set, or surface state.Error for soft fails.
if finished && resErr == nil {
d.mu.Lock()
d.lastDone = false
st := d.state
d.mu.Unlock()
if st.Success {
d.step = steamStepDone
d.errorMsg = ""
} else if stateErr != "" {
d.errorMsg = stateErr
// Stay on current step so the user can retry the same input.
} else {
d.errorMsg = ""
switch {
case st.RequiresCaptcha:
d.step = steamStepCaptcha
case st.RequiresEmailAuth:
d.step = steamStepEmail
case st.RequiresActivation:
d.step = steamStepActivation
}
}
}
// Captcha image fetch when we land on the captcha step with a new URL.
if d.step == steamStepCaptcha && d.state != nil && d.state.CaptchaURL != "" &&
d.state.CaptchaURL != d.captchaURLLoaded && !d.captchaLoading {
d.captchaLoading = true
d.captchaURLLoaded = d.state.CaptchaURL
urlCopy := d.state.CaptchaURL
go func(u string) {
img, err := fetchCaptchaImage(u)
d.mu.Lock()
d.captchaImg = img
d.captchaImgErr = err
d.captchaLoading = false
d.mu.Unlock()
if d.invalidate != nil {
d.invalidate()
}
}(urlCopy)
}
// Cancel: always allowed; abort whatever step we're on.
if d.cancelBtn.Clicked(gtx) {
// Scrub the password just in case the user cancels before Enroll
// had a chance to wipe it.
d.mu.Lock()
if d.state != nil {
d.state.Password = ""
}
d.mu.Unlock()
onDone(nil, "")
return layout.Dimensions{Size: gtx.Constraints.Max}
}
// OK button: action depends on current step.
if d.okBtn.Clicked(gtx) && !pending {
switch d.step {
case steamStepDone:
// Final hand-off: build SessionData JSON now (we couldn't
// earlier because we kept the jar live until success).
d.mu.Lock()
sess := authenticator.SessionFromEnrollState(d.state)
d.authImpl.SessionData = sess.ToJSON()
d.mu.Unlock()
name := d.finalName
if name == "" {
name = i18n.T("vendor_steam")
}
onDone(d.authImpl, name)
return layout.Dimensions{Size: gtx.Constraints.Max}
case steamStepCredentials:
username := strings.TrimSpace(d.usernameEd.Text())
password := d.passwordEd.Text()
if username == "" {
d.errorMsg = i18n.T("msg_empty_username")
break
}
if password == "" {
d.errorMsg = i18n.T("msg_empty_password")
break
}
name := strings.TrimSpace(d.nameEd.Text())
if name == "" {
name = i18n.T("vendor_steam")
}
d.finalName = name
d.errorMsg = ""
d.mu.Lock()
d.state.Username = username
d.state.Password = password
// Drop the editor's plaintext copy as soon as we've handed
// it to the state machine.
d.passwordEd.SetText("")
d.mu.Unlock()
d.runEnroll(fn)
case steamStepCaptcha:
txt := strings.TrimSpace(d.captchaEd.Text())
if txt == "" {
d.errorMsg = i18n.T("msg_empty_captcha")
break
}
d.errorMsg = ""
d.mu.Lock()
d.state.CaptchaText = txt
d.mu.Unlock()
d.captchaEd.SetText("")
d.runEnroll(fn)
case steamStepEmail:
txt := strings.TrimSpace(d.emailEd.Text())
if txt == "" {
d.errorMsg = i18n.T("msg_empty_email_code")
break
}
d.errorMsg = ""
d.mu.Lock()
d.state.EmailAuthText = txt
d.mu.Unlock()
d.emailEd.SetText("")
d.runEnroll(fn)
case steamStepActivation:
txt := strings.TrimSpace(d.activateEd.Text())
if txt == "" {
d.errorMsg = i18n.T("msg_empty_activation_code")
break
}
d.errorMsg = ""
d.mu.Lock()
d.state.ActivationCode = txt
d.mu.Unlock()
d.activateEd.SetText("")
d.runEnroll(fn)
}
}
body := d.layoutBody(th)
return modalCard(gtx, th, i18n.T("dialog_add_steam_title"),
d.okLabel(), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
// runEnroll fires off a background goroutine that performs exactly one
// Enroll round. The Layout function picks up the result on the next frame.
func (d *addSteamDialog) runEnroll(fn string) {
d.mu.Lock()
if d.pending {
d.mu.Unlock()
return
}
d.pending = true
d.errorMsg = i18n.T("msg_busy")
d.mu.Unlock()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
_, err := d.authImpl.Enroll(ctx, d.state)
d.mu.Lock()
d.pending = false
d.lastDone = true
d.lastErr = err
d.mu.Unlock()
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("steam enroll round failed")
}
if d.invalidate != nil {
d.invalidate()
}
}()
}
// okLabel chooses the primary-button text for the current step.
func (d *addSteamDialog) okLabel() string {
switch d.step {
case steamStepCredentials:
return i18n.T("btn_login")
case steamStepActivation:
return i18n.T("btn_activate")
case steamStepDone:
return i18n.T("btn_finish")
default:
return i18n.T("btn_continue")
}
}
// layoutBody renders the inputs specific to the current wizard step.
func (d *addSteamDialog) layoutBody(th *material.Theme) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
switch d.step {
case steamStepCredentials:
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("steam_step_credentials")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_name"), &d.nameEd, i18n.T("vendor_steam"))),
layout.Rigid(labeledEditor(th, i18n.T("label_username"), &d.usernameEd, "")),
layout.Rigid(labeledEditor(th, i18n.T("label_password"), &d.passwordEd, "")),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
case steamStepCaptcha:
d.mu.Lock()
img := d.captchaImg
loading := d.captchaLoading
imgErr := d.captchaImgErr
d.mu.Unlock()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("steam_step_captcha")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(captchaImageWidget(th, img, loading, imgErr)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_captcha_text"), &d.captchaEd, "")),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
case steamStepEmail:
domain := ""
d.mu.Lock()
if d.state != nil {
domain = d.state.EmailDomain
}
d.mu.Unlock()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, fmt.Sprintf(i18n.T("steam_step_email"), domain)).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_email_code"), &d.emailEd, "")),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
case steamStepActivation:
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("steam_step_activation")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditor(th, i18n.T("label_activation_code"), &d.activateEd, "")),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
case steamStepDone:
revocation := ""
d.mu.Lock()
if d.state != nil {
revocation = d.state.RevocationCode
}
d.mu.Unlock()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("steam_step_done")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(th, i18n.T("label_revocation_code")).Layout),
layout.Rigid(material.H6(th, revocation).Layout),
)
}),
layout.Rigid(errorLabel(th, d.errorMsg)),
)
}
return layout.Dimensions{}
}
}
// captchaImageWidget paints the captcha PNG/JPEG, or a placeholder line
// while it loads / on failure. We never log the captcha URL because it
// contains the captcha gid which is a Steam session token.
func captchaImageWidget(th *material.Theme, img image.Image, loading bool, imgErr error) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
if loading {
return material.Body2(th, i18n.T("hint_captcha_loading")).Layout(gtx)
}
if imgErr != nil {
return material.Body2(th, fmt.Sprintf(i18n.T("hint_captcha_failed"), imgErr.Error())).Layout(gtx)
}
if img == nil {
return layout.Dimensions{}
}
return widget.Image{
Src: paint.NewImageOp(img),
Fit: widget.Unscaled,
}.Layout(gtx)
}
}
// fetchCaptchaImage GETs the captcha URL and decodes it as PNG/JPEG/GIF.
// 10s timeout — Steam's captchas are tiny so anything longer is a hang.
func fetchCaptchaImage(rawURL string) (image.Image, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
// Steam's captcha endpoint accepts any UA but the rest of the flow
// uses the mobile UA, so be consistent.
req.Header.Set("User-Agent", "Mozilla/5.0 (Linux; Android 4.1.1) Mobile")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
// Cap at 256KB — captchas are tiny; anything bigger is suspicious.
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
if err != nil {
return nil, err
}
img, _, err := image.Decode(bytes.NewReader(body))
if err != nil {
return nil, err
}
return img, nil
}
+280
View File
@@ -0,0 +1,280 @@
package ui
import (
"context"
"fmt"
"sync"
"time"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// steamTradeRow holds per-row UI state for a single pending confirmation
// so accept/reject button clicks survive across frames.
type steamTradeRow struct {
conf authenticator.Confirmation
acceptBn widget.Clickable
rejectBn widget.Clickable
busy bool // an op is in flight for this id
status string // last per-row status message
}
// steamTradesDialog lists a Steam authenticator's pending trade /
// market confirmations and lets the user accept or reject each one.
//
// SECURITY: holds a live SteamClient with OAuth cookies. Closing the
// dialog drops the reference but does NOT log the user out — the same
// session will be reused next time the dialog is opened.
type steamTradesDialog struct {
client *authenticator.SteamClient
authImpl *authenticator.SteamAuthenticator
authName string
closeBtn widget.Clickable
refreshBn widget.Clickable
list widget.List
mu sync.Mutex
loading bool
loadErr error
rows []*steamTradeRow
loadedAt time.Time
invalidate func()
onClose func()
}
// newSteamTradesDialog constructs the dialog around an authenticator
// instance, reusing its persisted SessionData to skip a fresh login.
// Returns nil if the authenticator has no usable session.
func newSteamTradesDialog(
auth *authenticator.SteamAuthenticator, name string,
invalidate, onClose func(),
) (*steamTradesDialog, error) {
const fn = "internal.ui.newSteamTradesDialog"
client, err := authenticator.NewSteamClient(auth, auth.SessionData)
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("steam client init failed")
return nil, err
}
d := &steamTradesDialog{
client: client,
authImpl: auth,
authName: name,
invalidate: invalidate,
onClose: onClose,
}
d.list.Axis = layout.Vertical
// Kick off the first load right away.
d.refresh()
return d, nil
}
// Layout draws the modal. It does not implement the Dialog interface
// (no Add-style onDone signature) — the parent appState owns the close
// callback directly.
func (d *steamTradesDialog) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions {
if d.closeBtn.Clicked(gtx) && d.onClose != nil {
d.onClose()
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.refreshBn.Clicked(gtx) {
d.refresh()
}
// Handle per-row accept/reject clicks.
d.mu.Lock()
rowsSnapshot := d.rows
d.mu.Unlock()
for _, r := range rowsSnapshot {
if r.busy {
continue
}
if r.acceptBn.Clicked(gtx) {
d.runConfirm(r, true)
} else if r.rejectBn.Clicked(gtx) {
d.runConfirm(r, false)
}
}
body := func(gtx layout.Context) layout.Dimensions {
d.mu.Lock()
loading := d.loading
loadErr := d.loadErr
rows := d.rows
d.mu.Unlock()
if loadErr != nil {
return errorLabel(th, fmt.Sprintf(i18n.T("steam_trades_error"), loadErr.Error()))(gtx)
}
if loading && len(rows) == 0 {
return material.Body2(th, i18n.T("steam_trades_loading")).Layout(gtx)
}
if len(rows) == 0 {
return material.Body2(th, i18n.T("steam_trades_empty")).Layout(gtx)
}
// Cap list height so the modal does not exceed the window.
gtx.Constraints.Max.Y = gtx.Dp(360)
return material.List(th, &d.list).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return d.layoutRow(gtx, th, rows[i])
})
}
return modalCard(gtx, th, i18n.T("dialog_steam_trades_title"),
i18n.T("btn_refresh"), i18n.T("btn_close"),
&d.refreshBn, &d.closeBtn, body)
}
// layoutRow renders one pending confirmation. The image URL is shown
// rather than fetched: the trade list can be long and async image
// loading would add complexity not present in the original WinAuth UI.
func (d *steamTradesDialog) layoutRow(
gtx layout.Context, th *material.Theme, r *steamTradeRow,
) layout.Dimensions {
return layout.Inset{Top: unit.Dp(4), Bottom: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body1(th, r.conf.Details).Layout),
layout.Rigid(material.Body2(th, r.conf.Traded).Layout),
layout.Rigid(material.Caption(th, r.conf.When).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if r.status == "" {
return layout.Dimensions{}
}
return material.Body2(th, r.status).Layout(gtx)
}),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{Size: gtx.Constraints.Min}
}),
layout.Rigid(material.Button(th, &r.rejectBn, i18n.T("btn_reject")).Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Left: unit.Dp(8)}.Layout(gtx,
material.Button(th, &r.acceptBn, i18n.T("btn_accept")).Layout)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(thinDivider(th)),
)
})
}
// refresh kicks off (or restarts) a background GetConfirmations call.
func (d *steamTradesDialog) refresh() {
const fn = "internal.ui.steamTradesDialog.refresh"
d.mu.Lock()
if d.loading {
d.mu.Unlock()
return
}
d.loading = true
d.loadErr = nil
d.mu.Unlock()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
confs, err := d.client.GetConfirmations(ctx)
d.mu.Lock()
d.loading = false
d.loadedAt = time.Now()
if err != nil {
d.loadErr = err
global.Log.WithField("func", fn).WithError(err).Warn("get confirmations failed")
} else {
d.rows = mergeConfirmations(d.rows, confs)
}
d.mu.Unlock()
// Refresh persists rotated session cookies; push them to disk.
d.syncSessionToAuthenticator()
if d.invalidate != nil {
d.invalidate()
}
}()
}
// runConfirm fires the accept/reject HTTP call for a single trade.
func (d *steamTradesDialog) runConfirm(r *steamTradeRow, accept bool) {
const fn = "internal.ui.steamTradesDialog.runConfirm"
d.mu.Lock()
r.busy = true
r.status = i18n.T("msg_busy")
d.mu.Unlock()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ok, err := d.client.ConfirmTrade(ctx, r.conf.Id, r.conf.Key, accept)
d.mu.Lock()
r.busy = false
switch {
case err != nil:
r.status = fmt.Sprintf(i18n.T("steam_trades_error"), err.Error())
global.Log.WithField("func", fn).WithError(err).Warn("confirm trade failed")
case !ok:
r.status = i18n.T("steam_trades_error")
default:
// Drop the row from the visible list — Steam considers it done.
out := d.rows[:0]
for _, x := range d.rows {
if x.conf.Id != r.conf.Id {
out = append(out, x)
}
}
d.rows = out
}
d.mu.Unlock()
d.syncSessionToAuthenticator()
if d.invalidate != nil {
d.invalidate()
}
}()
}
// syncSessionToAuthenticator copies the latest Session JSON from the
// client back onto the authenticator's SessionData so the next save
// persists rotated cookies. Called after any network round-trip.
func (d *steamTradesDialog) syncSessionToAuthenticator() {
if d.client == nil || d.authImpl == nil || d.client.Session == nil {
return
}
d.authImpl.SessionData = d.client.Session.ToJSON()
}
// mergeConfirmations rebuilds the row slice from a fresh confirmations
// list, preserving the per-row Clickable state for ids that survived.
func mergeConfirmations(
old []*steamTradeRow, fresh []authenticator.Confirmation,
) []*steamTradeRow {
byID := make(map[string]*steamTradeRow, len(old))
for _, r := range old {
byID[r.conf.Id] = r
}
out := make([]*steamTradeRow, 0, len(fresh))
for _, c := range fresh {
if existing, ok := byID[c.Id]; ok {
existing.conf = c
out = append(out, existing)
} else {
out = append(out, &steamTradeRow{conf: c})
}
}
return out
}
// thinDivider returns a 1dp horizontal rule used between rows.
func thinDivider(_ *material.Theme) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
// material.Divider doesn't exist in this Gio version; draw a
// thin rectangle instead.
gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(1))
gtx.Constraints.Max.Y = gtx.Constraints.Min.Y
return layout.Dimensions{Size: gtx.Constraints.Min}
}
}
+56
View File
@@ -0,0 +1,56 @@
package ui
import (
"image"
"image/color"
"gioui.org/app"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
)
// copyCodeToClipboard pushes the current OTP onto the system clipboard
// and pops a brief toast confirming the action. Errors are surfaced via
// the same toast so the user actually sees them.
func (st *appState) copyCodeToClipboard(en *entry, w *app.Window) {
const fn = "internal.ui.appState.copyCodeToClipboard"
if en == nil || en.Code == "" {
return
}
if err := win32.SetClipboardText(en.Code); err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("clipboard copy failed")
st.toast.Show(i18n.T("msg_copy_failed"), w)
w.Invalidate()
return
}
st.toast.Show(i18n.T("msg_copied"), w)
w.Invalidate()
}
// drawEmptyPlaceholder paints the centered "no entries yet" hint shown
// when the entries list is empty.
func drawEmptyPlaceholder(gtx layout.Context, th *material.Theme) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Body1(th, i18n.T("msg_empty_list"))
lbl.Color = color.NRGBA{R: 0x80, G: 0x80, B: 0x80, A: 0xff}
return layout.UniformInset(unit.Dp(8)).Layout(gtx, lbl.Layout)
})
}
// drawDivider paints a 1px-tall light-gray line across the available
// horizontal space. Used between entries in the list.
func drawDivider(gtx layout.Context) layout.Dimensions {
h := gtx.Dp(unit.Dp(1))
size := image.Pt(gtx.Constraints.Max.X, h)
defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
paint.ColorOp{Color: color.NRGBA{R: 0xe0, G: 0xe0, B: 0xe0, A: 0xff}}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
return layout.Dimensions{Size: size}
}
+58
View File
@@ -0,0 +1,58 @@
package ui
import (
"image/color"
"time"
"gioui.org/layout"
"gioui.org/unit"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
)
// entryProgressRing draws the per-row TOTP countdown ring. For HOTP
// entries it returns a same-sized blank box so the rows still line up.
func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
const ringDp = 22
if en.Auth == nil || en.Auth.Name() == "hotp" {
return layout.Dimensions{Size: gtx.Constraints.Constrain(
layout.Spacer{Width: unit.Dp(ringDp), Height: unit.Dp(ringDp)}.Layout(gtx).Size,
)}
}
period := totpPeriod(en.Auth)
if period <= 0 {
period = authenticator.DefaultPeriod
}
now := time.Now().Unix()
elapsed := now % int64(period)
remaining := int64(period) - elapsed
progress := float32(remaining) / float32(period)
fg := color.NRGBA{R: 0x10, G: 0x70, B: 0xff, A: 0xff}
if remaining <= 5 {
fg = color.NRGBA{R: 0xd0, G: 0x30, B: 0x30, A: 0xff}
}
bg := color.NRGBA{R: 0xd8, G: 0xd8, B: 0xd8, A: 0xff}
return progressRing{
Size: unit.Dp(ringDp),
Stroke: unit.Dp(2.5),
Progress: progress,
Color: fg,
BgColor: bg,
}.Layout(gtx)
}
// totpPeriod extracts the configured period from any authenticator whose
// underlying Base we can reach. Returns 0 if the authenticator does not
// expose one (in which case callers fall back to the default).
func totpPeriod(a authenticator.Authenticator) int {
type periodGetter interface{ GetPeriod() int }
if pg, ok := a.(periodGetter); ok {
return pg.GetPeriod()
}
// All current TOTP-like authenticators (Google/Microsoft/Okta/Steam/
// BattleNet) embed authenticator.Base whose default period is 30s,
// matching the C# original. Hard-code that here.
return authenticator.DefaultPeriod
}
+133
View File
@@ -0,0 +1,133 @@
package ui
import (
"errors"
"gioui.org/app"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/hotkey"
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
)
// registerAllHotkeys walks every entry and registers its configured
// hotkey. Failure on a single row is logged and skipped so the rest
// keep working. Called once at startup; applyHotkey handles incremental
// updates.
func (st *appState) registerAllHotkeys() {
const fn = "internal.ui.appState.registerAllHotkeys"
st.mu.Lock()
defer st.mu.Unlock()
for _, en := range st.entries {
if en.Hotkey == "" {
continue
}
h, err := hotkey.Parse(en.Hotkey)
if err != nil {
global.Log.WithField("func", fn).WithField("entry", en.Name).
WithError(err).Warn("bad hotkey string; skipping")
continue
}
id, err := st.hkMgr.Register(h)
if err != nil {
global.Log.WithField("func", fn).WithField("entry", en.Name).
WithField("hotkey", en.Hotkey).WithError(err).
Warn("hotkey registration failed (already taken?)")
continue
}
en.hotkeyID = id
}
}
// applyHotkey updates target.Hotkey to value (empty string clears),
// unregistering the old binding and registering the new one. Errors are
// surfaced via st.saveErr so the user sees them.
func (st *appState) applyHotkey(target *entry, value string) {
const fn = "internal.ui.appState.applyHotkey"
st.mu.Lock()
defer st.mu.Unlock()
if target.hotkeyID != 0 {
if err := st.hkMgr.Unregister(target.hotkeyID); err != nil {
global.Log.WithField("func", fn).WithError(err).
Warn("unregister old hotkey failed")
}
target.hotkeyID = 0
}
target.Hotkey = value
if value == "" {
return
}
h, err := hotkey.Parse(value)
if err != nil {
st.saveErr = err.Error()
target.Hotkey = ""
return
}
id, err := st.hkMgr.Register(h)
if err != nil {
st.saveErr = err.Error()
target.Hotkey = ""
return
}
target.hotkeyID = id
}
// runHotkeyLoop drains the manager's Events channel and triggers the
// Auto-type flow for whichever entry owns the fired ID. Runs as a
// daemon goroutine until the events channel closes (Stop).
func (st *appState) runHotkeyLoop(w *app.Window) {
const fn = "internal.ui.appState.runHotkeyLoop"
for ev := range st.hkMgr.Events() {
target := st.findEntryByHotkeyID(ev.ID)
if target == nil {
continue
}
code, err := target.Auth.CurrentCode()
if err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("compute OTP failed")
continue
}
st.autoType(code)
w.Invalidate()
}
}
// findEntryByHotkeyID is a tiny lookup with the lock held just for the
// scan. Returns nil if no entry matches.
func (st *appState) findEntryByHotkeyID(id int32) *entry {
st.mu.Lock()
defer st.mu.Unlock()
for _, en := range st.entries {
if en.hotkeyID == id {
return en
}
}
return nil
}
// autoType pushes the OTP onto the clipboard and types it into the
// currently-foreground window. If GetForegroundWindow points at
// winauth-go itself (because the user was looking at it when the
// hotkey fired) we only copy — typing would inject into our own
// editor field which is almost never useful.
func (st *appState) autoType(code string) {
const fn = "internal.ui.appState.autoType"
if err := win32.SetClipboardText(code); err != nil {
if !errors.Is(err, win32.ErrUnsupported) {
global.Log.WithField("func", fn).WithError(err).Warn("clipboard write failed")
}
}
// We do not have a HWND for our own Gio window via the public API,
// so we cannot reliably detect "foreground is us." In practice the
// global hotkey almost always fires while another window is on top
// (that's the whole point), so we just inject blindly. The OTP is
// also on the clipboard as a safety net.
if err := win32.TypeUnicode(code); err != nil {
if !errors.Is(err, win32.ErrUnsupported) {
global.Log.WithField("func", fn).WithError(err).Warn("send input failed")
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package ui
import (
"image"
"image/color"
"math"
"gioui.org/f32"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
)
// progressRing renders a circular progress indicator. Progress is clamped
// to [0, 1]; 1.0 fills the full circle. Drawing approximates the arc with
// short line segments, which is plenty smooth at the small sizes used in
// list rows.
type progressRing struct {
Size unit.Dp
Stroke unit.Dp
Progress float32
Color color.NRGBA
BgColor color.NRGBA
}
func (r progressRing) Layout(gtx layout.Context) layout.Dimensions {
sizePx := gtx.Dp(r.Size)
if sizePx <= 0 {
return layout.Dimensions{}
}
strokePx := float32(gtx.Dp(r.Stroke))
if strokePx <= 0 {
strokePx = 2
}
center := f32.Pt(float32(sizePx)/2, float32(sizePx)/2)
radius := float32(sizePx)/2 - strokePx/2
if r.BgColor.A > 0 {
var bg clip.Path
bg.Begin(gtx.Ops)
buildArc(&bg, center, radius, -math.Pi/2, math.Pi*2)
paint.FillShape(gtx.Ops, r.BgColor,
clip.Stroke{Path: bg.End(), Width: strokePx}.Op())
}
if r.Progress > 0 {
sweep := float64(r.Progress) * math.Pi * 2
if sweep > math.Pi*2 {
sweep = math.Pi * 2
}
var fg clip.Path
fg.Begin(gtx.Ops)
buildArc(&fg, center, radius, -math.Pi/2, sweep)
paint.FillShape(gtx.Ops, r.Color,
clip.Stroke{Path: fg.End(), Width: strokePx}.Op())
}
return layout.Dimensions{Size: image.Pt(sizePx, sizePx)}
}
// buildArc emits a polyline approximation of an arc into p. startRad is
// the starting angle (radians, 0 = +x axis, clockwise), sweepRad is the
// signed angular extent.
func buildArc(p *clip.Path, center f32.Point, radius float32, startRad, sweepRad float64) {
const segs = 48
step := sweepRad / float64(segs)
start := f32.Pt(
center.X+radius*float32(math.Cos(startRad)),
center.Y+radius*float32(math.Sin(startRad)),
)
p.MoveTo(start)
for i := 1; i <= segs; i++ {
a := startRad + step*float64(i)
p.LineTo(f32.Pt(
center.X+radius*float32(math.Cos(a)),
center.Y+radius*float32(math.Sin(a)),
))
}
}
+88
View File
@@ -0,0 +1,88 @@
package ui
import (
"image/color"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// settingsAction is what the settings popup returns to the caller.
type settingsAction int
const (
settingsActionNone settingsAction = iota
settingsActionSetPassword
settingsActionImportLegacy
settingsActionAbout
)
// settingsMenu is the small popup that opens when the user clicks the
// gear button in the main window's top bar.
type settingsMenu struct {
setPwBtn widget.Clickable
importBtn widget.Clickable
aboutBtn widget.Clickable
cancelBtn widget.Clickable
}
func newSettingsMenu() *settingsMenu { return &settingsMenu{} }
// Pick returns the chosen action and whether the menu should close.
func (m *settingsMenu) Pick(gtx layout.Context) (settingsAction, bool) {
switch {
case m.setPwBtn.Clicked(gtx):
return settingsActionSetPassword, true
case m.importBtn.Clicked(gtx):
return settingsActionImportLegacy, true
case m.aboutBtn.Clicked(gtx):
return settingsActionAbout, true
case m.cancelBtn.Clicked(gtx):
return settingsActionNone, true
}
return settingsActionNone, false
}
func (m *settingsMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60})
row := func(btn *widget.Clickable, label string) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(4), Bottom: unit.Dp(4)}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return material.Button(th, btn, label).Layout(gtx)
})
})
}
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(280)
return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
CornerRadius: unit.Dp(4),
Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, i18n.T("menu_settings")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
row(&m.setPwBtn, i18n.T("menu_set_password")),
row(&m.importBtn, i18n.T("menu_import_legacy")),
row(&m.aboutBtn, i18n.T("menu_about")),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(material.Button(th, &m.cancelBtn, i18n.T("btn_cancel")).Layout),
)
}),
)
})
})
})
}
+173
View File
@@ -0,0 +1,173 @@
package ui
import (
"errors"
"os"
"sync"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// store wraps the on-disk YAML config plus the in-memory passphrase.
// All writes go through an async, coalescing worker: callers Push() and
// the worker debounces rapid bursts (e.g. HOTP code clicks) into a single
// disk write.
//
// passphrase is held in memory for the lifetime of the process. We do not
// attempt to zero it after use — Go's garbage collector may move strings
// around freely, so secure-erase is largely placebo and would only buy a
// false sense of security. We instead enforce that it is never logged.
type store struct {
path string
mu sync.Mutex
passphrase []byte
encrypted bool
// dirty signals a save is pending. The worker reads & resets it.
dirty bool
snapshotFn func() []config.Entry
pendingErr error
saveTrigger chan struct{}
// onError is called from the save goroutine when a write fails.
// The caller is responsible for marshalling back to the UI thread.
onError func(error)
}
// newStore initializes a store and starts the background save worker.
// snapshotFn is invoked whenever a save runs; it must return a freshly
// copied entries slice (the worker holds no UI locks). onError is called
// asynchronously from the worker goroutine on save failures.
func newStore(path string, snapshotFn func() []config.Entry, onError func(error)) *store {
s := &store{
path: path,
snapshotFn: snapshotFn,
onError: onError,
saveTrigger: make(chan struct{}, 1),
}
go s.run()
return s
}
// Load reads the YAML file at path. If the file does not exist, returns
// (nil, nil) — the caller should treat that as an empty config. If the
// file is encrypted, passphrase must be valid; otherwise ErrPasswordRequired
// or ErrPasswordWrong is returned.
//
// On success the store's passphrase + encrypted flag are updated.
func (s *store) Load(passphrase []byte) (*config.Config, error) {
const fn = "internal.ui.store.Load"
logger := global.Log.WithField("func", fn).WithField("path", s.path)
if _, err := os.Stat(s.path); errors.Is(err, os.ErrNotExist) {
logger.Debug("config file does not exist; starting empty")
s.mu.Lock()
s.passphrase = nil
s.encrypted = false
s.mu.Unlock()
return nil, nil
}
// First load: peek the header (no passphrase) to learn encrypted-ness.
cfg, err := config.LoadYAML(s.path, passphrase)
if err != nil {
switch {
case errors.Is(err, config.ErrPasswordRequired):
return cfg, ErrPasswordRequired
case errors.Is(err, config.ErrPasswordWrong):
return cfg, ErrPasswordWrong
default:
return nil, err
}
}
s.mu.Lock()
s.passphrase = passphrase
s.encrypted = cfg.Encrypted
s.mu.Unlock()
logger.WithField("entries", len(cfg.Entries)).Debug("config loaded into store")
return cfg, nil
}
// Sentinel errors returned by store.Load to signal the password UI path.
// We re-export config's sentinels here so the UI layer doesn't need to
// import internal/config directly.
var (
ErrPasswordRequired = config.ErrPasswordRequired
ErrPasswordWrong = config.ErrPasswordWrong
)
// SetPassword updates the in-memory passphrase. An empty value disables
// encryption on the next save. The change is queued for save immediately.
func (s *store) SetPassword(pw []byte) {
s.mu.Lock()
s.passphrase = pw
s.encrypted = len(pw) > 0
s.mu.Unlock()
s.Push()
}
// Encrypted reports whether the store will encrypt the next write.
func (s *store) Encrypted() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.encrypted
}
// Push schedules a save. Calls within ~300ms of each other coalesce into
// a single write.
func (s *store) Push() {
s.mu.Lock()
s.dirty = true
s.mu.Unlock()
select {
case s.saveTrigger <- struct{}{}:
default:
}
}
// LastError returns the most recent save error, if any.
func (s *store) LastError() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.pendingErr
}
func (s *store) run() {
const fn = "internal.ui.store.run"
for range s.saveTrigger {
// debounce: wait briefly to coalesce bursts
time.Sleep(300 * time.Millisecond)
s.mu.Lock()
if !s.dirty {
s.mu.Unlock()
continue
}
s.dirty = false
pw := append([]byte(nil), s.passphrase...)
enc := s.encrypted
s.mu.Unlock()
entries := s.snapshotFn()
cfg := &config.Config{
Version: 1,
Encrypted: enc,
Entries: entries,
}
if err := config.SaveYAML(cfg, s.path, pw); err != nil {
global.Log.WithField("func", fn).WithError(err).Error("save failed")
s.mu.Lock()
s.pendingErr = err
s.mu.Unlock()
if s.onError != nil {
s.onError(err)
}
continue
}
s.mu.Lock()
s.pendingErr = nil
s.mu.Unlock()
}
}
+84
View File
@@ -0,0 +1,84 @@
package ui
import (
"image"
"image/color"
"time"
"gioui.org/app"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget/material"
)
// toast is a transient top-of-window notification ("Copied", error
// messages, etc). It is non-modal: drawing it does not consume pointer
// input. Lifetime is governed by `until`; the next frame after the
// deadline simply skips drawing.
type toast struct {
msg string
until time.Time
}
const toastDuration = 1500 * time.Millisecond
// Show stores the message and schedules an Invalidate after the toast
// expires so the window redraws and removes it without waiting for the
// next user interaction.
func (t *toast) Show(msg string, w *app.Window) {
t.msg = msg
t.until = time.Now().Add(toastDuration)
go func(deadline time.Time) {
time.Sleep(time.Until(deadline) + 50*time.Millisecond)
w.Invalidate()
}(t.until)
}
// active reports whether the toast should be drawn this frame.
func (t *toast) active() bool {
return t.msg != "" && time.Now().Before(t.until)
}
// draw paints the toast as an overlay centered near the top of gtx.
// Call AFTER laying out the rest of the frame so it stacks on top.
func (t *toast) draw(gtx layout.Context, th *material.Theme) {
if !t.active() {
return
}
bg := color.NRGBA{R: 0x20, G: 0x20, B: 0x20, A: 0xe0}
fg := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}
macro := op.Record(gtx.Ops)
pad := layout.UniformInset(unit.Dp(10))
dims := pad.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Body2(th, t.msg)
lbl.Color = fg
return lbl.Layout(gtx)
})
call := macro.Stop()
// Center horizontally near the top.
x := (gtx.Constraints.Max.X - dims.Size.X) / 2
if x < 0 {
x = 0
}
y := gtx.Dp(unit.Dp(12))
stack := op.Offset(image.Point{X: x, Y: y}).Push(gtx.Ops)
rrect := clip.RRect{
Rect: image.Rectangle{Max: dims.Size},
SE: gtx.Dp(unit.Dp(6)),
SW: gtx.Dp(unit.Dp(6)),
NE: gtx.Dp(unit.Dp(6)),
NW: gtx.Dp(unit.Dp(6)),
}
bgArea := rrect.Push(gtx.Ops)
paint.ColorOp{Color: bg}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
bgArea.Pop()
call.Add(gtx.Ops)
stack.Pop()
}
+116
View File
@@ -0,0 +1,116 @@
package ui
import (
"image/color"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// vendor identifies which Add dialog should be opened next.
type vendor int
const (
vendorNone vendor = iota
vendorGoogle
vendorMicrosoft
vendorOkta
vendorHOTP
vendorBattleNet
vendorBattleNetRestore
vendorSteam
vendorScanQR
)
// vendorMenu is the little popup shown after clicking the Add button.
// It lets the user pick which kind of authenticator to enroll. Selecting
// any entry returns that vendor via Pick(); Cancel returns vendorNone and
// signals the menu should close.
type vendorMenu struct {
googleBtn widget.Clickable
microsoftBtn widget.Clickable
oktaBtn widget.Clickable
hotpBtn widget.Clickable
bnetBtn widget.Clickable
bnetRestoreBtn widget.Clickable
steamBtn widget.Clickable
scanQRBtn widget.Clickable
cancelBtn widget.Clickable
}
func newVendorMenu() *vendorMenu { return &vendorMenu{} }
// Pick returns the vendor selected this frame (vendorNone if no click) and
// whether the menu should close (true on any click, including Cancel).
func (m *vendorMenu) Pick(gtx layout.Context) (vendor, bool) {
switch {
case m.googleBtn.Clicked(gtx):
return vendorGoogle, true
case m.microsoftBtn.Clicked(gtx):
return vendorMicrosoft, true
case m.oktaBtn.Clicked(gtx):
return vendorOkta, true
case m.hotpBtn.Clicked(gtx):
return vendorHOTP, true
case m.bnetBtn.Clicked(gtx):
return vendorBattleNet, true
case m.bnetRestoreBtn.Clicked(gtx):
return vendorBattleNetRestore, true
case m.steamBtn.Clicked(gtx):
return vendorSteam, true
case m.scanQRBtn.Clicked(gtx):
return vendorScanQR, true
case m.cancelBtn.Clicked(gtx):
return vendorNone, true
}
return vendorNone, false
}
func (m *vendorMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0, G: 0, B: 0, A: 0x60})
row := func(btn *widget.Clickable, label string) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(4), Bottom: unit.Dp(4)}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return material.Button(th, btn, label).Layout(gtx)
})
})
}
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(320)
return widget.Border{
Color: color.NRGBA{R: 0x55, G: 0x55, B: 0x55, A: 0xff},
CornerRadius: unit.Dp(4),
Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
fillBackground(gtx, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, i18n.T("menu_choose_vendor")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
row(&m.googleBtn, i18n.T("vendor_google")),
row(&m.microsoftBtn, i18n.T("vendor_microsoft")),
row(&m.oktaBtn, i18n.T("vendor_okta")),
row(&m.hotpBtn, i18n.T("vendor_hotp")),
row(&m.bnetBtn, i18n.T("vendor_battlenet")),
row(&m.bnetRestoreBtn, i18n.T("vendor_battlenet_restore")),
row(&m.steamBtn, i18n.T("vendor_steam")),
row(&m.scanQRBtn, i18n.T("vendor_scan_qr")),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(material.Button(th, &m.cancelBtn, i18n.T("btn_cancel")).Layout),
)
}),
)
})
})
})
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
package win32
import "image"
// GetClipboardImage is a no-op on non-Windows builds.
func GetClipboardImage() (image.Image, error) {
return nil, ErrUnsupported
}
+130
View File
@@ -0,0 +1,130 @@
//go:build windows
package win32
import (
"fmt"
"image"
"image/color"
"unsafe"
)
// GetClipboardImage attempts to read an image from the Windows clipboard.
// It supports CF_DIB and CF_DIBV5 (the two formats produced by Snipping
// Tool / Win+Shift+S and most other screenshot tools). Returns nil, nil
// if the clipboard does not currently hold an image format we can read.
func GetClipboardImage() (image.Image, error) {
if err := openClipboardRetry(0); err != nil {
return nil, fmt.Errorf("win32: OpenClipboard: %w", err)
}
defer procCloseClipboard.Call()
// Try CF_DIBV5 (17) first, then CF_DIB (8).
if img, err := readDIBFromClipboard(17); err != nil {
return nil, err
} else if img != nil {
return img, nil
}
if img, err := readDIBFromClipboard(8); err != nil {
return nil, err
} else if img != nil {
return img, nil
}
return nil, nil
}
func readDIBFromClipboard(format uint32) (image.Image, error) {
r, _, _ := procGetClipboardData.Call(uintptr(format))
if r == 0 {
return nil, nil
}
src, _, _ := procGlobalLock.Call(r)
if src == 0 {
return nil, nil
}
defer procGlobalUnlock.Call(r)
size, _, _ := procGlobalSize.Call(r)
if size == 0 {
return nil, nil
}
data := unsafe.Slice((*byte)(unsafe.Pointer(src)), size)
img := decodeDIB(data)
if img == nil {
return nil, fmt.Errorf("win32: unsupported DIB format")
}
return img, nil
}
// decodeDIB decodes a BITMAPINFOHEADER-based DIB blob (no BMP file
// header) into an *image.RGBA. Only uncompressed 24-bit and 32-bit
// pixel formats are supported, which covers what the Windows snipping
// stack produces.
func decodeDIB(dib []byte) image.Image {
if len(dib) < 40 {
return nil
}
headerSize := int(readU32(dib, 0))
width := int(int32(readU32(dib, 4)))
height := int(int32(readU32(dib, 8)))
bitCount := int(readU16(dib, 14))
compression := int(readU32(dib, 16))
if width <= 0 || height == 0 {
return nil
}
if compression != 0 || (bitCount != 24 && bitCount != 32) {
return nil
}
absH := height
topDown := false
if absH < 0 {
absH = -absH
topDown = true
}
rowBytes := ((bitCount*width + 31) / 32) * 4
pixelOffset := headerSize
// BITMAPV4/V5 headers may include color masks immediately after, but
// since we only accept BI_RGB, headerSize already points past them.
if len(dib) < pixelOffset+rowBytes*absH {
return nil
}
img := image.NewRGBA(image.Rect(0, 0, width, absH))
for y := 0; y < absH; y++ {
var srcY int
if topDown {
srcY = y
} else {
srcY = absH - 1 - y
}
row := dib[pixelOffset+srcY*rowBytes:]
for x := 0; x < width; x++ {
off := x * (bitCount / 8)
b := row[off]
g := row[off+1]
r := row[off+2]
var a byte = 0xff
if bitCount == 32 {
// CF_DIB normally writes 0 in the alpha slot; treat 0
// as opaque to avoid an invisible bitmap.
ax := row[off+3]
if ax != 0 {
a = ax
}
}
img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: a})
}
}
return img
}
func readU16(b []byte, off int) uint16 {
return uint16(b[off]) | uint16(b[off+1])<<8
}
func readU32(b []byte, off int) uint32 {
return uint32(b[off]) | uint32(b[off+1])<<8 | uint32(b[off+2])<<16 | uint32(b[off+3])<<24
}
+15
View File
@@ -0,0 +1,15 @@
// Package win32 wraps the small set of Win32 APIs winauth-go needs for
// platform-specific UX: single-instance locking, global hotkeys, key
// injection, clipboard access, and foreground-window juggling.
//
// All exported functions are no-ops or return ErrUnsupported on
// non-Windows platforms. Callers should treat failure as informational
// (warn + continue) rather than fatal — none of this functionality is
// load-bearing for the core authenticator code.
package win32
import "errors"
// ErrUnsupported is returned by every entry point when the build is not
// Windows. Callers should test with errors.Is.
var ErrUnsupported = errors.New("win32: feature only available on Windows")
+26
View File
@@ -0,0 +1,26 @@
package win32
// Hotkey describes a single global key combination. Mods is the bitwise
// OR of ModCtrl/ModAlt/ModShift/ModWin; VK is a virtual-key code
// (0x41='A', ...).
type Hotkey struct {
Mods uint32
VK uint32
}
// Modifier bits passed to RegisterHotKey.
const (
ModAlt uint32 = 0x1
ModCtrl uint32 = 0x2
ModShift uint32 = 0x4
ModWin uint32 = 0x8
// ModNoRepeat suppresses auto-repeat events when the key is held.
// Supported on Windows 7+.
ModNoRepeat uint32 = 0x4000
)
// HotkeyEvent fires when a registered hotkey is pressed. ID matches the
// id returned by HotkeyManager.Register.
type HotkeyEvent struct {
ID int32
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
package win32
// HotkeyManager is the cross-platform stub. Register always errors;
// Events returns a never-firing channel.
type HotkeyManager struct {
events chan HotkeyEvent
}
func NewHotkeyManager() *HotkeyManager {
return &HotkeyManager{events: make(chan HotkeyEvent)}
}
func (m *HotkeyManager) Events() <-chan HotkeyEvent { return m.events }
func (m *HotkeyManager) Register(h Hotkey) (int32, error) { return 0, ErrUnsupported }
func (m *HotkeyManager) Unregister(id int32) error { return ErrUnsupported }
func (m *HotkeyManager) Stop() { close(m.events) }
+203
View File
@@ -0,0 +1,203 @@
//go:build windows
package win32
import (
"fmt"
"runtime"
"sync"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// HotkeyManager owns a dedicated OS thread that runs a GetMessageW
// pump. RegisterHotKey can only be called from the thread that will
// receive the messages, so all register/unregister/dispatch operations
// are serialized onto that goroutine via the cmd channel.
type HotkeyManager struct {
cmd chan hkCmd
events chan HotkeyEvent
mu sync.Mutex
nextID int32
stopped bool
}
type hkCmd struct {
kind hkCmdKind
id int32
hotkey Hotkey
reply chan error
}
type hkCmdKind int
const (
hkCmdRegister hkCmdKind = iota
hkCmdUnregister
hkCmdStop
)
// NewHotkeyManager starts the dedicated thread and returns a manager
// ready to accept Register calls. Events() yields presses; the channel
// is closed on Stop().
func NewHotkeyManager() *HotkeyManager {
m := &HotkeyManager{
cmd: make(chan hkCmd),
events: make(chan HotkeyEvent, 16),
nextID: 1,
}
started := make(chan struct{})
go m.run(started)
<-started
return m
}
// Events returns the read-only event channel.
func (m *HotkeyManager) Events() <-chan HotkeyEvent { return m.events }
// Register adds a global hotkey and returns its assigned id. Re-registering
// a combination that is already taken by another application returns an
// error; the caller should surface it to the user.
func (m *HotkeyManager) Register(h Hotkey) (int32, error) {
m.mu.Lock()
if m.stopped {
m.mu.Unlock()
return 0, fmt.Errorf("win32: hotkey manager stopped")
}
id := m.nextID
m.nextID++
m.mu.Unlock()
reply := make(chan error, 1)
m.cmd <- hkCmd{kind: hkCmdRegister, id: id, hotkey: h, reply: reply}
if err := <-reply; err != nil {
return 0, err
}
return id, nil
}
// Unregister removes a previously registered hotkey.
func (m *HotkeyManager) Unregister(id int32) error {
reply := make(chan error, 1)
m.cmd <- hkCmd{kind: hkCmdUnregister, id: id, reply: reply}
return <-reply
}
// Stop tears down the message pump and closes Events(). Subsequent
// Register calls fail.
func (m *HotkeyManager) Stop() {
m.mu.Lock()
if m.stopped {
m.mu.Unlock()
return
}
m.stopped = true
m.mu.Unlock()
reply := make(chan error, 1)
m.cmd <- hkCmd{kind: hkCmdStop, reply: reply}
<-reply
}
// run is the dedicated-thread loop. It owns the message queue that
// RegisterHotKey targets.
func (m *HotkeyManager) run(started chan struct{}) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// Force the message queue to exist before anybody tries to post to
// us. PeekMessage with PM_NOREMOVE is the canonical incantation.
var msg msgStruct
procPeekMessageW.Call(
uintptr(unsafe.Pointer(&msg)),
0, 0, 0, 0, // PM_NOREMOVE
)
close(started)
for {
// Non-blocking message pump: drain hotkey messages first, then
// service one cmd, then sleep briefly. A blocking GetMessage
// would freeze the cmd intake.
for {
r, _, _ := procPeekMessageW.Call(
uintptr(unsafe.Pointer(&msg)),
0, 0, 0, 1, // PM_REMOVE
)
if r == 0 {
break
}
if msg.message == wmHotKey {
select {
case m.events <- HotkeyEvent{ID: int32(msg.wParam)}:
default:
// Listener slow — drop to avoid stalling the pump.
}
}
}
select {
case c := <-m.cmd:
switch c.kind {
case hkCmdRegister:
err := registerHotKey(0, c.id, c.hotkey.Mods, c.hotkey.VK)
c.reply <- err
case hkCmdUnregister:
err := unregisterHotKey(0, c.id)
c.reply <- err
case hkCmdStop:
close(m.events)
c.reply <- nil
return
}
default:
// brief sleep so we don't busy-loop. 30ms is well under any
// human-perceptible hotkey latency.
windows.SleepEx(30, false)
}
}
}
// -----------------------------------------------------------------------------
// raw syscalls
var (
procRegisterHotKey = user32.NewProc("RegisterHotKey")
procUnregisterHotKey = user32.NewProc("UnregisterHotKey")
procPeekMessageW = user32.NewProc("PeekMessageW")
)
const wmHotKey uint32 = 0x0312
type msgStruct struct {
hwnd uintptr
message uint32
wParam uintptr
lParam uintptr
time uint32
pt struct{ x, y int32 }
}
func registerHotKey(hwnd uintptr, id int32, mods, vk uint32) error {
r, _, e := procRegisterHotKey.Call(
hwnd, uintptr(id), uintptr(mods), uintptr(vk),
)
if r == 0 {
if errno, ok := e.(syscall.Errno); ok && errno != 0 {
return fmt.Errorf("win32: RegisterHotKey: %w", errno)
}
return fmt.Errorf("win32: RegisterHotKey: unknown failure")
}
return nil
}
func unregisterHotKey(hwnd uintptr, id int32) error {
r, _, e := procUnregisterHotKey.Call(hwnd, uintptr(id))
if r == 0 {
if errno, ok := e.(syscall.Errno); ok && errno != 0 {
return fmt.Errorf("win32: UnregisterHotKey: %w", errno)
}
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
//go:build !windows
package win32
// SetClipboardText errors on non-Windows; callers should warn and fall
// back to displaying the code in the UI.
func SetClipboardText(s string) error { return ErrUnsupported }
// GetForegroundWindow returns 0 on non-Windows so Auto-type sites can
// detect the absence and skip.
func GetForegroundWindow() uintptr { return 0 }
// FocusWindow is a no-op on non-Windows.
func FocusWindow(hwnd uintptr) error { return ErrUnsupported }
// TypeUnicode is a no-op on non-Windows.
func TypeUnicode(s string) error { return ErrUnsupported }
// PressKey is a no-op on non-Windows.
func PressKey(vk uint16) error { return ErrUnsupported }
// VK_RETURN is exposed for cross-platform compile.
const VK_RETURN uint16 = 0x0D
+214
View File
@@ -0,0 +1,214 @@
//go:build windows
package win32
import (
"fmt"
"syscall"
"time"
"unicode/utf16"
"unsafe"
"golang.org/x/sys/windows"
)
// SetClipboardText copies s onto the Windows clipboard as CF_UNICODETEXT.
// OpenClipboard may transiently fail if another process holds the
// clipboard; we retry a few times before giving up.
func SetClipboardText(s string) error {
const cfUnicodeText = 13
utf := utf16.Encode([]rune(s + "\x00"))
size := len(utf) * 2
hMem, _, e := procGlobalAlloc.Call(0x0042 /*GMEM_MOVEABLE|GMEM_ZEROINIT*/, uintptr(size))
if hMem == 0 {
return fmt.Errorf("win32: GlobalAlloc: %w", e)
}
dst, _, _ := procGlobalLock.Call(hMem)
if dst == 0 {
procGlobalFree.Call(hMem)
return fmt.Errorf("win32: GlobalLock failed")
}
dstSlice := unsafe.Slice((*uint16)(unsafe.Pointer(dst)), len(utf))
copy(dstSlice, utf)
procGlobalUnlock.Call(hMem)
if err := openClipboardRetry(0); err != nil {
procGlobalFree.Call(hMem)
return err
}
procEmptyClipboard.Call()
r, _, ce := procSetClipboardData.Call(cfUnicodeText, hMem)
if r == 0 {
procCloseClipboard.Call()
procGlobalFree.Call(hMem)
return fmt.Errorf("win32: SetClipboardData: %w", ce)
}
// Ownership of hMem transfers to the system on success — do not free.
procCloseClipboard.Call()
return nil
}
func openClipboardRetry(hwnd uintptr) error {
var last error
for i := 0; i < 8; i++ {
r, _, e := procOpenClipboard.Call(hwnd)
if r != 0 {
return nil
}
last = e
time.Sleep(20 * time.Millisecond)
}
return fmt.Errorf("win32: OpenClipboard: %w", last)
}
// GetForegroundWindow returns the HWND that currently has keyboard
// focus. Use this to remember the target window before the user clicks
// into winauth-go (which itself becomes foreground and would otherwise
// receive injected keystrokes).
func GetForegroundWindow() uintptr {
r, _, _ := procGetForegroundWindow.Call()
return r
}
// FocusWindow restores hwnd to the foreground. Hits the well-known
// SetForegroundWindow restriction (only the foreground process may
// hand focus to another); we work around it by attaching to the target
// thread's input queue briefly, the trick documented in MSDN's
// "AttachThreadInput" page.
func FocusWindow(hwnd uintptr) error {
if hwnd == 0 {
return fmt.Errorf("win32: FocusWindow: nil hwnd")
}
curTID, _, _ := procGetCurrentThreadId.Call()
targetTID, _, _ := procGetWindowThreadProcessId.Call(hwnd, 0)
if targetTID == 0 {
return fmt.Errorf("win32: GetWindowThreadProcessId failed")
}
if curTID != targetTID {
procAttachThreadInput.Call(curTID, targetTID, 1)
defer procAttachThreadInput.Call(curTID, targetTID, 0)
}
procSetForegroundWindowProc.Call(hwnd)
procShowWindowProc.Call(hwnd, 9 /*SW_RESTORE*/)
procBringWindowToTop.Call(hwnd)
return nil
}
// TypeUnicode injects s as Unicode characters using SendInput KEYEVENTF_UNICODE.
// Special characters in s pass through transparently; no translation
// of newlines / tabs happens. If you need an Enter at the end, pass
// "\n" and let the caller append it explicitly.
func TypeUnicode(s string) error {
if s == "" {
return nil
}
utf := utf16.Encode([]rune(s))
// Each rune becomes 2 inputs (keydown + keyup).
inputs := make([]inputUnion, 0, len(utf)*2)
for _, u := range utf {
inputs = append(inputs,
makeUnicodeInput(u, false),
makeUnicodeInput(u, true),
)
}
r, _, e := procSendInput.Call(
uintptr(len(inputs)),
uintptr(unsafe.Pointer(&inputs[0])),
unsafe.Sizeof(inputs[0]),
)
if int(r) != len(inputs) {
if errno, ok := e.(syscall.Errno); ok && errno != 0 {
return fmt.Errorf("win32: SendInput: sent %d/%d: %w", r, len(inputs), errno)
}
return fmt.Errorf("win32: SendInput: sent %d/%d", r, len(inputs))
}
return nil
}
// PressKey injects a single virtual-key down+up pair (e.g. VK_RETURN).
func PressKey(vk uint16) error {
inputs := [2]inputUnion{
makeVKInput(vk, false),
makeVKInput(vk, true),
}
r, _, e := procSendInput.Call(
uintptr(len(inputs)),
uintptr(unsafe.Pointer(&inputs[0])),
unsafe.Sizeof(inputs[0]),
)
if int(r) != len(inputs) {
if errno, ok := e.(syscall.Errno); ok && errno != 0 {
return fmt.Errorf("win32: SendInput (vk): %w", errno)
}
return fmt.Errorf("win32: SendInput (vk): short send")
}
return nil
}
// VK_RETURN is exposed for callers that want to press Enter after Auto-type.
const VK_RETURN uint16 = 0x0D
// -----------------------------------------------------------------------------
// raw syscalls + structs
var (
procOpenClipboard = user32.NewProc("OpenClipboard")
procCloseClipboard = user32.NewProc("CloseClipboard")
procEmptyClipboard = user32.NewProc("EmptyClipboard")
procSetClipboardData = user32.NewProc("SetClipboardData")
procGetClipboardData = user32.NewProc("GetClipboardData")
procGetForegroundWindow = user32.NewProc("GetForegroundWindow")
procSetForegroundWindowProc = user32.NewProc("SetForegroundWindow")
procShowWindowProc = user32.NewProc("ShowWindow")
procBringWindowToTop = user32.NewProc("BringWindowToTop")
procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId")
procAttachThreadInput = user32.NewProc("AttachThreadInput")
procSendInput = user32.NewProc("SendInput")
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procGlobalAlloc = kernel32.NewProc("GlobalAlloc")
procGlobalFree = kernel32.NewProc("GlobalFree")
procGlobalLock = kernel32.NewProc("GlobalLock")
procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
procGlobalSize = kernel32.NewProc("GlobalSize")
procGetCurrentThreadId = kernel32.NewProc("GetCurrentThreadId")
)
// inputUnion is the Win32 INPUT structure, sized for KEYBDINPUT (the
// largest variant on x64 is MOUSEINPUT but KEYBDINPUT padded to 40
// works because INPUT_KEYBOARD never reads the extra trailing bytes).
type inputUnion struct {
typ uint32
_pad uint32 // alignment on 64-bit
wVk uint16
wScan uint16
dwFlags uint32
time uint32
dwExtra uintptr
// 8 bytes of MOUSEINPUT-sized padding so the layout is large enough
// for the union on amd64.
_padTail [8]byte
}
const (
inputKeyboard uint32 = 1
keyeventfKeyUp uint32 = 0x0002
keyeventfUnicode uint32 = 0x0004
)
func makeUnicodeInput(r uint16, up bool) inputUnion {
flags := keyeventfUnicode
if up {
flags |= keyeventfKeyUp
}
return inputUnion{typ: inputKeyboard, wScan: r, dwFlags: flags}
}
func makeVKInput(vk uint16, up bool) inputUnion {
var flags uint32
if up {
flags = keyeventfKeyUp
}
return inputUnion{typ: inputKeyboard, wVk: vk, dwFlags: flags}
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !windows
package win32
// AcquireInstanceLock always reports "first instance" on non-Windows
// platforms — there is no convenient cross-platform equivalent and
// running two copies is not catastrophic for the YAML store (last
// writer wins).
func AcquireInstanceLock(name string) (release func(), alreadyRunning bool, err error) {
return func() {}, false, nil
}
// ActivateOtherInstance is a no-op on non-Windows platforms.
func ActivateOtherInstance(windowTitle string) error { return ErrUnsupported }
+96
View File
@@ -0,0 +1,96 @@
//go:build windows
package win32
import (
"fmt"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
// AcquireInstanceLock takes a named mutex so a second launch of
// winauth-go can detect the first one. Returns a Release closer to be
// deferred at program exit. If another instance is already running it
// returns alreadyRunning=true and a zero Release; the caller may then
// call ActivateOtherInstance(windowTitle) to bring the existing window
// forward and exit.
func AcquireInstanceLock(name string) (release func(), alreadyRunning bool, err error) {
wname, err := syscall.UTF16PtrFromString(name)
if err != nil {
return nil, false, fmt.Errorf("win32: utf16: %w", err)
}
h, err := windows.CreateMutex(nil, false, wname)
if h == 0 {
return nil, false, fmt.Errorf("win32: CreateMutex: %w", err)
}
// CreateMutex returns the existing handle when the name is taken; in
// that case GetLastError reports ERROR_ALREADY_EXISTS. err is set
// regardless of whether the handle is valid, so check the error
// number rather than err != nil.
if errno, ok := err.(syscall.Errno); ok && errno == windows.ERROR_ALREADY_EXISTS {
windows.CloseHandle(h)
return nil, true, nil
}
return func() { windows.CloseHandle(h) }, false, nil
}
// ActivateOtherInstance walks the top-level windows looking for one
// whose title matches windowTitle, then restores + foregrounds it. The
// match is exact. Used after AcquireInstanceLock reports a duplicate
// launch.
func ActivateOtherInstance(windowTitle string) error {
wtitle, err := syscall.UTF16PtrFromString(windowTitle)
if err != nil {
return err
}
hwnd, _ := findWindow(nil, wtitle)
if hwnd == 0 {
// The mutex says somebody is running, but the window isn't up
// yet — give it a brief moment then retry once.
time.Sleep(200 * time.Millisecond)
hwnd, _ = findWindow(nil, wtitle)
}
if hwnd == 0 {
return fmt.Errorf("win32: could not locate existing window %q", windowTitle)
}
const (
SW_RESTORE = 9
)
showWindow(hwnd, SW_RESTORE)
setForegroundWindow(hwnd)
return nil
}
// -----------------------------------------------------------------------------
// raw syscall stubs — the windows package does not expose these directly.
var (
user32 = windows.NewLazySystemDLL("user32.dll")
procFindWindowW = user32.NewProc("FindWindowW")
procShowWindow = user32.NewProc("ShowWindow")
procSetForegroundWindow = user32.NewProc("SetForegroundWindow")
)
func findWindow(class, title *uint16) (windows.HWND, error) {
r, _, e := procFindWindowW.Call(
uintptr(unsafe.Pointer(class)),
uintptr(unsafe.Pointer(title)),
)
if r == 0 {
return 0, e
}
return windows.HWND(r), nil
}
func showWindow(hwnd windows.HWND, cmdShow int32) bool {
r, _, _ := procShowWindow.Call(uintptr(hwnd), uintptr(cmdShow))
return r != 0
}
func setForegroundWindow(hwnd windows.HWND) bool {
r, _, _ := procSetForegroundWindow.Call(uintptr(hwnd))
return r != 0
}