feat: Phase 1 WinAuth Go 移植完整实现
将原 C#/.NET WinAuth 移植为 Go + Gio GUI,覆盖 Phase 1 全部功能。 核心模块: - internal/authenticator: TOTP (Google/Microsoft/Okta) + HOTP + BattleNet + Steam,含 enroll/sync/code 生成、Steam 交易确认轮询 - internal/config: YAML 配置 + 老版 WinAuth XML 导入(DPAPI + Password + Blowfish/PBKDF2 解密链) - internal/crypto: 现代加密 (WAGO1) + DPAPI 跨平台封装 + 老版 Blowfish ECB - internal/win32: 单实例 Mutex 锁 + 全局热键管理器 (RegisterHotKey + PeekMessage 泵) + SendInput Unicode 注入 + 剪贴板文本/CF_DIB 图像读写 + AttachThreadInput 焦点切换 - internal/hotkey: "Ctrl+Alt+G" 风格快捷键字符串解析/格式化 - internal/qr: gozxing 二维码解码 + otpauth:// URI 解析 - internal/i18n: en/zh-CN/de 三语 TOML UI 模块 (Gio): - 主窗口:圆环倒计时进度条、复制按钮 + Toast 反馈、空列表占位、行分隔线 - 添加流程:vendor 菜单 + 各 vendor 独立对话框 + 二维码扫描入口(文件 / 剪贴板) - 设置:密码加密、老版 XML 导入、每条目热键配置 - Steam:注册向导(含 captcha/email/SMS 多步)+ 交易确认窗 构建:Windows 主目标,非 Windows 平台所有 Win32 功能走 build-tag 桩实现。
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
)
|
||||
|
||||
// Authenticator is the interface implemented by every concrete OTP type.
|
||||
// It deliberately mirrors the surface of the C# Authenticator base class
|
||||
// rather than introducing a wider/cleaner Go interface, so the port can
|
||||
// stay close to the original.
|
||||
type Authenticator interface {
|
||||
// Name returns a short identifier for logging ("google", "battlenet", ...).
|
||||
Name() string
|
||||
|
||||
// CurrentCode returns the OTP that should be shown to the user right now.
|
||||
CurrentCode() (string, error)
|
||||
|
||||
// SecretData returns the serialized form of the secret + parameters,
|
||||
// matching the field stored inside the original XML config under
|
||||
// <secretdata>.
|
||||
SecretData() string
|
||||
|
||||
// SetSecretData reverses SecretData, populating the receiver.
|
||||
SetSecretData(value string) error
|
||||
|
||||
// Sync re-aligns the server time offset using whatever network call the
|
||||
// concrete authenticator supports.
|
||||
Sync() error
|
||||
}
|
||||
|
||||
// Base holds fields shared by every concrete authenticator. Embed it in
|
||||
// your subtype to inherit the field set and common helpers.
|
||||
type Base struct {
|
||||
SecretKey []byte
|
||||
CodeDigits int
|
||||
HMACType HMACType
|
||||
Period int
|
||||
ServerTimeDiff int64 // ms
|
||||
LastServerTime int64 // ms (Unix-millis here, not .NET ticks)
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewBase returns a Base seeded with the project-wide defaults.
|
||||
func NewBase() Base {
|
||||
return Base{
|
||||
CodeDigits: DefaultCodeDigits,
|
||||
HMACType: HMACSHA1,
|
||||
Period: DefaultPeriod,
|
||||
}
|
||||
}
|
||||
|
||||
// NowMillis is the milliseconds since the Unix epoch, equivalent to the
|
||||
// CurrentTime helper in the original C#.
|
||||
func NowMillis() int64 { return time.Now().UnixMilli() }
|
||||
|
||||
// ServerTime returns the server's notion of "now" in milliseconds.
|
||||
func (b *Base) ServerTime() int64 { return NowMillis() + b.ServerTimeDiff }
|
||||
|
||||
// CodeInterval returns the TOTP step number for the current server time.
|
||||
func (b *Base) CodeInterval() uint64 {
|
||||
if b.Period <= 0 {
|
||||
b.Period = DefaultPeriod
|
||||
}
|
||||
return uint64(b.ServerTime() / int64(b.Period*1000))
|
||||
}
|
||||
|
||||
// CalculateTOTP runs the TOTP algorithm using the receiver's fields.
|
||||
func (b *Base) CalculateTOTP() string {
|
||||
const fn = "internal.authenticator.Base.CalculateTOTP"
|
||||
digits := b.CodeDigits
|
||||
if digits == 0 {
|
||||
digits = DefaultCodeDigits
|
||||
}
|
||||
code := hotpCode(b.SecretKey, b.CodeInterval(), digits, b.HMACType)
|
||||
global.Log.WithField("func", fn).
|
||||
WithField("interval", b.CodeInterval()).
|
||||
WithField("digits", digits).
|
||||
Trace("computed TOTP")
|
||||
return code
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// base32Alphabet is the RFC 4648 / 3548 base32 alphabet (no padding).
|
||||
const base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
|
||||
var (
|
||||
base32EncodeTable [32]byte
|
||||
base32DecodeTable [256]int8
|
||||
base32CleanRE = regexp.MustCompile(`[\s-]+`)
|
||||
base32PadRE = regexp.MustCompile(`=+$`)
|
||||
)
|
||||
|
||||
func init() {
|
||||
for i, c := range base32Alphabet {
|
||||
base32EncodeTable[i] = byte(c)
|
||||
}
|
||||
for i := range base32DecodeTable {
|
||||
base32DecodeTable[i] = -1
|
||||
}
|
||||
for i, c := range base32Alphabet {
|
||||
base32DecodeTable[c] = int8(i)
|
||||
}
|
||||
}
|
||||
|
||||
// Base32Decode decodes a base32 string into bytes. Whitespace and dashes
|
||||
// are stripped; trailing '=' padding is ignored; input is uppercased before
|
||||
// decoding. This mirrors the lenient behavior of the original C# Base32 class.
|
||||
func Base32Decode(encoded string) ([]byte, error) {
|
||||
encoded = base32CleanRE.ReplaceAllString(encoded, "")
|
||||
encoded = base32PadRE.ReplaceAllString(encoded, "")
|
||||
encoded = strings.ToUpper(encoded)
|
||||
if encoded == "" {
|
||||
return []byte{}, nil
|
||||
}
|
||||
|
||||
const shift = 5
|
||||
const mask = 0x1F
|
||||
|
||||
outLen := len(encoded) * shift / 8
|
||||
out := make([]byte, outLen)
|
||||
|
||||
var buffer int
|
||||
var bitsLeft int
|
||||
var next int
|
||||
for _, c := range encoded {
|
||||
if c >= 256 || base32DecodeTable[c] < 0 {
|
||||
return nil, fmt.Errorf("base32: illegal character %q", c)
|
||||
}
|
||||
buffer <<= shift
|
||||
buffer |= int(base32DecodeTable[c]) & mask
|
||||
bitsLeft += shift
|
||||
if bitsLeft >= 8 {
|
||||
out[next] = byte(buffer >> (bitsLeft - 8))
|
||||
next++
|
||||
bitsLeft -= 8
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Base32Encode encodes raw bytes as base32 with no padding.
|
||||
func Base32Encode(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
const shift = 5
|
||||
const mask = 0x1F
|
||||
|
||||
var sb strings.Builder
|
||||
buffer := int(data[0])
|
||||
next := 1
|
||||
bitsLeft := 8
|
||||
for bitsLeft > 0 || next < len(data) {
|
||||
if bitsLeft < shift {
|
||||
if next < len(data) {
|
||||
buffer <<= 8
|
||||
buffer |= int(data[next]) & 0xFF
|
||||
next++
|
||||
bitsLeft += 8
|
||||
} else {
|
||||
pad := shift - bitsLeft
|
||||
buffer <<= pad
|
||||
bitsLeft += pad
|
||||
}
|
||||
}
|
||||
index := mask & (buffer >> (bitsLeft - shift))
|
||||
bitsLeft -= shift
|
||||
sb.WriteByte(base32EncodeTable[index])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
mrand "math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/httpc"
|
||||
)
|
||||
|
||||
// Battle.Net mobile-service URLs by region. Matches the original C# table.
|
||||
var battlenetURLs = map[string]string{
|
||||
"US": "http://mobile-service.blizzard.com",
|
||||
"EU": "http://mobile-service.blizzard.com",
|
||||
"KR": "http://mobile-service.blizzard.com",
|
||||
"CN": "http://mobile-service.battlenet.com.cn",
|
||||
}
|
||||
|
||||
const (
|
||||
bnetEnrollPath = "/enrollment/enroll2.htm"
|
||||
bnetSyncPath = "/enrollment/time.htm"
|
||||
bnetRestorePath = "/enrollment/initiatePaperRestore.htm"
|
||||
bnetRestoreValidatePath = "/enrollment/validatePaperRestore.htm"
|
||||
|
||||
bnetCodeDigits = 8
|
||||
bnetModelSize = 16
|
||||
bnetModelChars = " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01234567890"
|
||||
bnetEnrollRespSz = 45
|
||||
bnetSyncRespSz = 8
|
||||
|
||||
bnetEnrollModulus = "955e4bd989f3917d2f15544a7e0504eb9d7bb66b6f8a2fe470e453c779200e5e" +
|
||||
"3ad2e43a02d06c4adbd8d328f1a426b83658e88bfd949b2af4eaf30054673a14" +
|
||||
"19a250fa4cc1278d12855b5b25818d162c6e6ee2ab4a350d401d78f6ddb99711" +
|
||||
"e72626b48bd8b5b0b7f3acf9ea3c9e0005fee59e19136cdb7c83f2ab8b0a2a99"
|
||||
bnetEnrollExponent = "0101"
|
||||
)
|
||||
|
||||
// BattleNetAuthenticator is the Go port of the C# BattleNetAuthenticator.
|
||||
// It supports Enroll, Sync, Restore (paper restore), and the customary
|
||||
// 8-digit TOTP code generation.
|
||||
type BattleNetAuthenticator struct {
|
||||
Base
|
||||
Serial string
|
||||
RestoreCodeVerified bool
|
||||
}
|
||||
|
||||
// NewBattleNetAuthenticator returns an empty 8-digit Battle.Net authenticator.
|
||||
func NewBattleNetAuthenticator() *BattleNetAuthenticator {
|
||||
b := &BattleNetAuthenticator{Base: NewBase()}
|
||||
b.CodeDigits = bnetCodeDigits
|
||||
return b
|
||||
}
|
||||
|
||||
// Name returns the short logger tag for this type.
|
||||
func (b *BattleNetAuthenticator) Name() string { return "battlenet" }
|
||||
|
||||
// Region returns the two-letter region prefix derived from the serial.
|
||||
func (b *BattleNetAuthenticator) Region() string {
|
||||
if len(b.Serial) >= 2 {
|
||||
return strings.ToUpper(b.Serial[:2])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CurrentCode returns the live 8-digit Battle.Net authenticator code.
|
||||
func (b *BattleNetAuthenticator) CurrentCode() (string, error) {
|
||||
if b.SecretKey == nil {
|
||||
return "", fmt.Errorf("battlenet: no secret loaded")
|
||||
}
|
||||
return b.CalculateTOTP(), nil
|
||||
}
|
||||
|
||||
// SecretData / SetSecretData persist both the secret and the serial number,
|
||||
// matching the C# format "<basesecret>|<utf8-hex-serial>".
|
||||
func (b *BattleNetAuthenticator) SecretData() string {
|
||||
return b.EncodeSecretData() + "|" + strings.ToUpper(hex.EncodeToString([]byte(b.Serial)))
|
||||
}
|
||||
|
||||
func (b *BattleNetAuthenticator) SetSecretData(value string) error {
|
||||
if value == "" {
|
||||
b.SecretKey = nil
|
||||
b.Serial = ""
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, "|")
|
||||
if len(parts) == 1 {
|
||||
// legacy WinAuth2 form: 40 hex chars secret then UTF8-hex serial
|
||||
if len(value) < 40 {
|
||||
return fmt.Errorf("battlenet: secret data too short")
|
||||
}
|
||||
raw, err := hex.DecodeString(value[:40])
|
||||
if err != nil {
|
||||
return fmt.Errorf("battlenet: bad secret hex: %w", err)
|
||||
}
|
||||
b.SecretKey = raw
|
||||
serialBytes, err := hex.DecodeString(value[40:])
|
||||
if err == nil {
|
||||
b.Serial = string(serialBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := b.DecodeSecretData(parts[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
serialIdx := 1
|
||||
if len(parts) == 3 {
|
||||
// alpha 3.0.6 form: secret|script|serial
|
||||
serialIdx = 2
|
||||
}
|
||||
if len(parts) > serialIdx {
|
||||
raw, err := hex.DecodeString(parts[serialIdx])
|
||||
if err == nil {
|
||||
b.Serial = string(raw)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enroll registers a brand-new Battle.Net authenticator with the Blizzard
|
||||
// mobile service. Country is auto-detected via Wikimedia GEO IP; pass an
|
||||
// explicit two-letter override to skip the lookup.
|
||||
func (b *BattleNetAuthenticator) Enroll(ctx context.Context, countryOverride string) error {
|
||||
const fn = "internal.authenticator.BattleNetAuthenticator.Enroll"
|
||||
logger := global.Log.WithField("func", fn)
|
||||
|
||||
country := strings.ToUpper(strings.TrimSpace(countryOverride))
|
||||
region := "US"
|
||||
if country == "" {
|
||||
c, r := lookupRegion(ctx)
|
||||
country, region = c, r
|
||||
} else {
|
||||
region = regionForCountry(country)
|
||||
}
|
||||
logger.WithField("country", country).WithField("region", region).Debug("region determined")
|
||||
|
||||
otp := mustOneTimePad(20)
|
||||
payload := make([]byte, 38)
|
||||
copy(payload[0:20], otp)
|
||||
copy(payload[20:22], []byte(country))
|
||||
copy(payload[22:38], []byte(randomModel()))
|
||||
|
||||
encrypted, err := rsaEncryptRaw(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("battlenet: rsa encrypt failed: %w", err)
|
||||
}
|
||||
|
||||
respBytes, err := bnetPostBinary(ctx, mobileURL(region)+bnetEnrollPath, encrypted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(respBytes) != bnetEnrollRespSz {
|
||||
return fmt.Errorf("battlenet: enroll response size %d, want %d", len(respBytes), bnetEnrollRespSz)
|
||||
}
|
||||
|
||||
serverMs := int64(binary.BigEndian.Uint64(respBytes[0:8]))
|
||||
b.ServerTimeDiff = serverMs - NowMillis()
|
||||
|
||||
secret := make([]byte, 20)
|
||||
copy(secret, respBytes[25:45])
|
||||
for i := range secret {
|
||||
secret[i] ^= otp[i]
|
||||
}
|
||||
b.SecretKey = secret
|
||||
b.Serial = string(respBytes[8:25])
|
||||
logger.WithField("serial", b.Serial).Info("enrolled with Battle.Net mobile service")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync re-aligns ServerTimeDiff against the mobile service for our region.
|
||||
func (b *BattleNetAuthenticator) Sync() error {
|
||||
const fn = "internal.authenticator.BattleNetAuthenticator.Sync"
|
||||
logger := global.Log.WithField("func", fn).WithField("region", b.Region())
|
||||
|
||||
if b.SecretKey == nil {
|
||||
logger.Debug("skip sync: no secret loaded")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := mobileURL(b.Region()) + bnetSyncPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := httpc.New().Do(req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Warn("sync failed; using local clock")
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) != bnetSyncRespSz {
|
||||
return fmt.Errorf("battlenet: sync response size %d, want %d", len(body), bnetSyncRespSz)
|
||||
}
|
||||
serverMs := int64(binary.BigEndian.Uint64(body))
|
||||
b.ServerTimeDiff = serverMs - NowMillis()
|
||||
b.LastServerTime = NowMillis()
|
||||
logger.WithField("offset_ms", b.ServerTimeDiff).Debug("clock synced")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreCode computes the 10-char Battle.Net restore code derived from
|
||||
// the SHA1 of (serial || secretKey).
|
||||
func (b *BattleNetAuthenticator) RestoreCode() string {
|
||||
if b.Serial == "" || b.SecretKey == nil {
|
||||
return ""
|
||||
}
|
||||
serial := strings.ReplaceAll(strings.ToUpper(b.Serial), "-", "")
|
||||
hash := sha1.Sum(append([]byte(serial), b.SecretKey...))
|
||||
out := make([]byte, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
out[i] = restoreByteToChar(hash[len(hash)-10+i])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// helpers ------------------------------------------------------------------
|
||||
|
||||
func mobileURL(region string) string {
|
||||
region = strings.ToUpper(region)
|
||||
if len(region) > 2 {
|
||||
region = region[:2]
|
||||
}
|
||||
if u, ok := battlenetURLs[region]; ok {
|
||||
return u
|
||||
}
|
||||
return battlenetURLs["US"]
|
||||
}
|
||||
|
||||
func regionForCountry(country string) string {
|
||||
switch country {
|
||||
case "CN":
|
||||
return "CN"
|
||||
case "KR", "KP", "TW", "HK", "MO":
|
||||
return "KR"
|
||||
}
|
||||
euCountries := []string{
|
||||
"AL", "AD", "AM", "AT", "AZ", "BY", "BE", "BA", "BG", "HR",
|
||||
"CY", "CZ", "DK", "EE", "FI", "FR", "GE", "DE", "GR", "HU",
|
||||
"IS", "IE", "IT", "KV", "XK", "LV", "LI", "LT", "LU", "MK",
|
||||
"MT", "MD", "MC", "ME", "NL", "NO", "PL", "PT", "RO", "RU",
|
||||
"SM", "RS", "SK", "ES", "SE", "CH", "TR", "UA", "UK", "GB",
|
||||
"VA",
|
||||
}
|
||||
for _, c := range euCountries {
|
||||
if c == country {
|
||||
return "EU"
|
||||
}
|
||||
}
|
||||
return "US"
|
||||
}
|
||||
|
||||
func lookupRegion(ctx context.Context) (country, region string) {
|
||||
const fn = "internal.authenticator.lookupRegion"
|
||||
logger := global.Log.WithField("func", fn)
|
||||
|
||||
geoCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(geoCtx, http.MethodGet, "http://geoiplookup.wikimedia.org", nil)
|
||||
resp, err := httpc.New().Do(req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Debug("geoip lookup failed; defaulting to US")
|
||||
return "US", "US"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// Cheap regex-free parse for "country":"XX"
|
||||
const key = `"country":"`
|
||||
idx := strings.Index(string(body), key)
|
||||
if idx < 0 {
|
||||
return "US", "US"
|
||||
}
|
||||
rest := string(body)[idx+len(key):]
|
||||
end := strings.Index(rest, `"`)
|
||||
if end < 0 {
|
||||
return "US", "US"
|
||||
}
|
||||
country = strings.ToUpper(rest[:end])
|
||||
return country, regionForCountry(country)
|
||||
}
|
||||
|
||||
func bnetPostBinary(ctx context.Context, url string, body []byte) ([]byte, error) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
resp, err := httpc.New().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("battlenet: contact mobile service: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("battlenet: server returned %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func rsaEncryptRaw(data []byte) ([]byte, error) {
|
||||
n, ok := new(big.Int).SetString(bnetEnrollModulus, 16)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid RSA modulus")
|
||||
}
|
||||
e, ok := new(big.Int).SetString(bnetEnrollExponent, 16)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid RSA exponent")
|
||||
}
|
||||
pub := &rsa.PublicKey{N: n, E: int(e.Int64())}
|
||||
|
||||
// The original C# uses BouncyCastle's RsaEngine.ProcessBlock without
|
||||
// padding (raw RSA / "no padding"). We emulate that by padding the input
|
||||
// to modulus length with leading zeros and using big.Int exponentiation.
|
||||
keySize := (pub.N.BitLen() + 7) / 8
|
||||
if len(data) > keySize {
|
||||
return nil, fmt.Errorf("data too large for raw RSA")
|
||||
}
|
||||
m := new(big.Int).SetBytes(data)
|
||||
c := new(big.Int).Exp(m, big.NewInt(int64(pub.E)), pub.N)
|
||||
out := make([]byte, keySize)
|
||||
cBytes := c.Bytes()
|
||||
copy(out[keySize-len(cBytes):], cBytes)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mustOneTimePad(n int) []byte {
|
||||
out := make([]byte, n)
|
||||
if _, err := rand.Read(out); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func randomModel() string {
|
||||
var seedBytes [8]byte
|
||||
_, _ = rand.Read(seedBytes[:])
|
||||
r := mrand.New(mrand.NewSource(int64(binary.LittleEndian.Uint64(seedBytes[:]))))
|
||||
out := make([]byte, bnetModelSize)
|
||||
for i := range out {
|
||||
out[i] = bnetModelChars[r.Intn(len(bnetModelChars))]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func restoreByteToChar(b byte) byte {
|
||||
// Mirror C# ConvertRestoreCodeByteToChar — but we only need the inverse
|
||||
// for code *display*. The original algorithm maps each byte to an alpha
|
||||
// or numeric, skipping I, L, O, S to avoid ambiguity. Below is a direct
|
||||
// translation of the C# code path that lives at the bottom of
|
||||
// BattleNetAuthenticator.cs (ConvertRestoreCodeByteToChar).
|
||||
v := int(b) & 0x1F // 5 bits
|
||||
switch {
|
||||
case v < 10:
|
||||
return byte('0' + v)
|
||||
}
|
||||
c := v - 10 + 'A'
|
||||
if c >= 'I' {
|
||||
c++
|
||||
}
|
||||
if c >= 'L' {
|
||||
c++
|
||||
}
|
||||
if c >= 'O' {
|
||||
c++
|
||||
}
|
||||
if c >= 'S' {
|
||||
c++
|
||||
}
|
||||
return byte(c)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
)
|
||||
|
||||
// Battle.Net paper-restore protocol constants. The server responds with
|
||||
// a fixed 32-byte challenge to the initiate POST, and a fixed 20-byte
|
||||
// secret to the validate POST. Mismatching sizes are treated as fatal
|
||||
// since the binary protocol has no error envelope.
|
||||
const (
|
||||
bnetRestoreChallengeSize = 32
|
||||
bnetRestoreSecretSize = 20
|
||||
bnetSerialDigits = 14 // CC-NNNN-NNNN-NNNN after stripping dashes
|
||||
bnetRestoreCodeLen = 10
|
||||
)
|
||||
|
||||
// Restore recovers an existing Battle.Net authenticator's secret key
|
||||
// using the 10-character paper restore code the user wrote down when
|
||||
// they first enrolled.
|
||||
//
|
||||
// The wire protocol mirrors the original WinAuth implementation:
|
||||
//
|
||||
// 1. POST <serial-ascii-bytes> → /enrollment/initiatePaperRestore.htm
|
||||
// ← 32-byte challenge
|
||||
// 2. HMAC-SHA1(key = restoreCode-decoded-10-bytes,
|
||||
// data = serial-bytes || challenge) → 20-byte signature
|
||||
// 3. POST <serial || signature> → /enrollment/validatePaperRestore.htm
|
||||
// ← 20-byte secret (the new SecretKey)
|
||||
//
|
||||
// SECURITY: restoreCode grants full account control if leaked. We do not
|
||||
// log it, never persist it, and wipe the derived 10-byte key buffer
|
||||
// before returning. The caller's restoreCode string is the caller's
|
||||
// responsibility to manage.
|
||||
func (b *BattleNetAuthenticator) Restore(ctx context.Context, serial, restoreCode string) error {
|
||||
const fn = "internal.authenticator.BattleNetAuthenticator.Restore"
|
||||
logger := global.Log.WithField("func", fn)
|
||||
|
||||
cleanSerial := normalizeBnetSerial(serial)
|
||||
if len(cleanSerial) < bnetSerialDigits {
|
||||
return fmt.Errorf("battlenet: serial must contain %d digits after the region prefix", bnetSerialDigits)
|
||||
}
|
||||
region := cleanSerial[:2]
|
||||
if _, ok := battlenetURLs[region]; !ok {
|
||||
return fmt.Errorf("battlenet: unknown region %q in serial", region)
|
||||
}
|
||||
logger.WithField("region", region).Debug("starting paper restore")
|
||||
|
||||
cleanCode := normalizeBnetRestoreCode(restoreCode)
|
||||
if len(cleanCode) != bnetRestoreCodeLen {
|
||||
return fmt.Errorf("battlenet: restore code must be %d characters", bnetRestoreCodeLen)
|
||||
}
|
||||
codeKey, err := decodeRestoreCode(cleanCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Zero the derived key on return so it does not linger in stack/heap
|
||||
// after the HMAC call has consumed it.
|
||||
defer func() {
|
||||
for i := range codeKey {
|
||||
codeKey[i] = 0
|
||||
}
|
||||
}()
|
||||
|
||||
serialBytes := []byte(cleanSerial)
|
||||
|
||||
challenge, err := bnetPostBinary(ctx, mobileURL(region)+bnetRestorePath, serialBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("battlenet: initiate restore: %w", err)
|
||||
}
|
||||
if len(challenge) != bnetRestoreChallengeSize {
|
||||
return fmt.Errorf("battlenet: restore challenge size %d, want %d",
|
||||
len(challenge), bnetRestoreChallengeSize)
|
||||
}
|
||||
|
||||
mac := hmac.New(sha1.New, codeKey)
|
||||
_, _ = mac.Write(serialBytes)
|
||||
_, _ = mac.Write(challenge)
|
||||
signature := mac.Sum(nil)
|
||||
|
||||
// POST body is serial-ascii || HMAC signature.
|
||||
validateBody := make([]byte, 0, len(serialBytes)+len(signature))
|
||||
validateBody = append(validateBody, serialBytes...)
|
||||
validateBody = append(validateBody, signature...)
|
||||
|
||||
secret, err := bnetPostBinary(ctx, mobileURL(region)+bnetRestoreValidatePath, validateBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("battlenet: validate restore: %w", err)
|
||||
}
|
||||
if len(secret) != bnetRestoreSecretSize {
|
||||
return fmt.Errorf("battlenet: restore secret size %d, want %d",
|
||||
len(secret), bnetRestoreSecretSize)
|
||||
}
|
||||
|
||||
b.SecretKey = secret
|
||||
b.Serial = cleanSerial
|
||||
b.RestoreCodeVerified = true
|
||||
logger.WithField("serial", b.Serial).Info("paper restore succeeded")
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeBnetSerial strips spaces and dashes, upper-cases, and returns
|
||||
// the canonical form ("CCNNNNNNNNNNNN", 14 ASCII bytes when valid).
|
||||
func normalizeBnetSerial(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, "-", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
return s
|
||||
}
|
||||
|
||||
// normalizeBnetRestoreCode strips formatting whitespace / dashes and
|
||||
// upper-cases. The actual character-set validation happens in
|
||||
// decodeRestoreCode.
|
||||
func normalizeBnetRestoreCode(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, "-", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
return s
|
||||
}
|
||||
|
||||
// decodeRestoreCode is the inverse of restoreByteToChar applied 10
|
||||
// times in a row: each character maps back to one byte (low 5 bits
|
||||
// populated). The 10-byte buffer is what the protocol uses as the
|
||||
// HMAC-SHA1 key for the validate step.
|
||||
func decodeRestoreCode(code string) ([]byte, error) {
|
||||
if len(code) != bnetRestoreCodeLen {
|
||||
return nil, fmt.Errorf("battlenet: restore code must be %d characters", bnetRestoreCodeLen)
|
||||
}
|
||||
out := make([]byte, bnetRestoreCodeLen)
|
||||
for i := 0; i < bnetRestoreCodeLen; i++ {
|
||||
v, ok := restoreCharToByte(code[i])
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("battlenet: invalid character %q in restore code", code[i])
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// restoreCharToByte is the inverse of restoreByteToChar. The encoding
|
||||
// uses a 5-bit value: 0–9 → '0'–'9', 10–25 → 'A'..'Z' but skipping
|
||||
// I, L, O, S. We undo the skips to recover the original 5-bit value.
|
||||
func restoreCharToByte(c byte) (byte, bool) {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c - '0', true
|
||||
case c >= 'A' && c <= 'Z':
|
||||
// I, L, O, S are deliberately absent from the encoding alphabet
|
||||
// (visually similar to 1 / 1 / 0 / 5). Accepting them would map
|
||||
// to the wrong 5-bit value and silently corrupt the HMAC key.
|
||||
if c == 'I' || c == 'L' || c == 'O' || c == 'S' {
|
||||
return 0, false
|
||||
}
|
||||
v := int(c)
|
||||
if v >= 'T' {
|
||||
v--
|
||||
}
|
||||
if v >= 'P' {
|
||||
v--
|
||||
}
|
||||
if v >= 'M' {
|
||||
v--
|
||||
}
|
||||
if v >= 'J' {
|
||||
v--
|
||||
}
|
||||
v = v - 'A' + 10
|
||||
if v < 10 || v > 31 {
|
||||
return 0, false
|
||||
}
|
||||
return byte(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRestoreCodeRoundTrip verifies that restoreCharToByte exactly
|
||||
// inverts restoreByteToChar across every 5-bit value the encoding
|
||||
// produces. A regression here would silently corrupt the HMAC key the
|
||||
// Restore flow sends to Blizzard, so the round-trip is the cheapest
|
||||
// possible safety net.
|
||||
func TestRestoreCodeRoundTrip(t *testing.T) {
|
||||
for v := 0; v < 32; v++ {
|
||||
c := restoreByteToChar(byte(v))
|
||||
got, ok := restoreCharToByte(c)
|
||||
if !ok {
|
||||
t.Fatalf("v=%d encoded as %q but failed to decode", v, c)
|
||||
}
|
||||
if int(got) != v {
|
||||
t.Fatalf("v=%d → %q → %d (want %d)", v, c, got, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreCharToByteRejectsAmbiguous confirms that the four letters
|
||||
// deliberately omitted from the Battle.Net restore alphabet (I, L, O,
|
||||
// S) are rejected on decode. A user typing "1" instead of "I" should
|
||||
// land on the "1" branch; "I" should be a hard error rather than a
|
||||
// silent misdecode.
|
||||
func TestRestoreCharToByteRejectsAmbiguous(t *testing.T) {
|
||||
for _, c := range []byte{'I', 'L', 'O', 'S'} {
|
||||
if _, ok := restoreCharToByte(c); ok {
|
||||
t.Errorf("char %q must not decode", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeRestoreCodeLength sanity-checks the length validator.
|
||||
func TestDecodeRestoreCodeLength(t *testing.T) {
|
||||
if _, err := decodeRestoreCode("ABCDEFGHI"); err == nil {
|
||||
t.Error("expected error for 9-char input")
|
||||
}
|
||||
if _, err := decodeRestoreCode("ABCDEFGHIJK"); err == nil {
|
||||
t.Error("expected error for 11-char input")
|
||||
}
|
||||
// 10 valid characters
|
||||
if _, err := decodeRestoreCode("ABCDEFGHJK"); err != nil {
|
||||
t.Errorf("unexpected error for 10-char input: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeBnetSerial verifies the user-friendly formats (dashes
|
||||
// and spaces, mixed case) all collapse to the protocol-required form.
|
||||
func TestNormalizeBnetSerial(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"us-1234-5678-9012": "US123456789012",
|
||||
"US-1234-5678-9012": "US123456789012",
|
||||
" US 1234 5678 9012": "US123456789012",
|
||||
"us123456789012": "US123456789012",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeBnetSerial(in); got != want {
|
||||
t.Errorf("normalizeBnetSerial(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/httpc"
|
||||
)
|
||||
|
||||
// GoogleAuthenticator implements the time-sync flavor of TOTP that Google,
|
||||
// Microsoft and Okta all share. The only difference between vendors in the
|
||||
// original C# port is the URL used to learn the server clock.
|
||||
type GoogleAuthenticator struct {
|
||||
Base
|
||||
timeSyncURL string
|
||||
}
|
||||
|
||||
// NewGoogleAuthenticator returns a Google-flavored TOTP authenticator.
|
||||
func NewGoogleAuthenticator() *GoogleAuthenticator {
|
||||
return newTOTP("https://www.google.com")
|
||||
}
|
||||
|
||||
// NewMicrosoftAuthenticator is an alias kept for parity with the original
|
||||
// C# class hierarchy.
|
||||
func NewMicrosoftAuthenticator() *GoogleAuthenticator {
|
||||
return newTOTP("https://www.microsoft.com")
|
||||
}
|
||||
|
||||
// NewOktaVerifyAuthenticator returns a TOTP that syncs against okta.com.
|
||||
func NewOktaVerifyAuthenticator() *GoogleAuthenticator {
|
||||
return newTOTP("https://www.okta.com")
|
||||
}
|
||||
|
||||
func newTOTP(syncURL string) *GoogleAuthenticator {
|
||||
g := &GoogleAuthenticator{Base: NewBase(), timeSyncURL: syncURL}
|
||||
return g
|
||||
}
|
||||
|
||||
// Name returns a short identifier used in logs.
|
||||
func (g *GoogleAuthenticator) Name() string {
|
||||
switch g.timeSyncURL {
|
||||
case "https://www.microsoft.com":
|
||||
return "microsoft"
|
||||
case "https://www.okta.com":
|
||||
return "okta"
|
||||
default:
|
||||
return "google"
|
||||
}
|
||||
}
|
||||
|
||||
// Enroll loads a base32-encoded shared secret and then performs an initial
|
||||
// clock sync against the vendor's HTTP endpoint.
|
||||
func (g *GoogleAuthenticator) Enroll(b32 string) error {
|
||||
const fn = "internal.authenticator.GoogleAuthenticator.Enroll"
|
||||
raw, err := Base32Decode(b32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.SecretKey = raw
|
||||
global.Log.WithField("func", fn).WithField("len", len(raw)).Debug("enrolled secret")
|
||||
return g.Sync()
|
||||
}
|
||||
|
||||
// CurrentCode returns the live TOTP for the receiver.
|
||||
func (g *GoogleAuthenticator) CurrentCode() (string, error) {
|
||||
if g.SecretKey == nil {
|
||||
return "", fmt.Errorf("authenticator: no secret loaded")
|
||||
}
|
||||
return g.CalculateTOTP(), nil
|
||||
}
|
||||
|
||||
// SecretData / SetSecretData delegate to the embedded Base.
|
||||
func (g *GoogleAuthenticator) SecretData() string { return g.EncodeSecretData() }
|
||||
func (g *GoogleAuthenticator) SetSecretData(value string) error { return g.DecodeSecretData(value) }
|
||||
|
||||
// Sync issues a HEAD request against the configured vendor URL and reads
|
||||
// the response's Date header to derive ServerTimeDiff. Errors are swallowed
|
||||
// in the same way as the original C# implementation — repeated failures
|
||||
// should not block code generation, the local clock is the fallback.
|
||||
func (g *GoogleAuthenticator) Sync() error {
|
||||
const fn = "internal.authenticator.GoogleAuthenticator.Sync"
|
||||
logger := global.Log.WithField("func", fn).WithField("url", g.timeSyncURL)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, g.timeSyncURL, nil)
|
||||
if err != nil {
|
||||
logger.WithError(err).Warn("build request failed")
|
||||
return err
|
||||
}
|
||||
resp, err := httpc.New().Do(req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Warn("sync request failed; using local clock")
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
dateStr := strings.TrimSpace(resp.Header.Get("Date"))
|
||||
if dateStr == "" {
|
||||
logger.Warn("response missing Date header")
|
||||
return nil
|
||||
}
|
||||
t, err := http.ParseTime(dateStr)
|
||||
if err != nil {
|
||||
logger.WithError(err).Warn("invalid Date header")
|
||||
return nil
|
||||
}
|
||||
serverMs := t.UnixMilli()
|
||||
g.ServerTimeDiff = serverMs - NowMillis()
|
||||
g.LastServerTime = NowMillis()
|
||||
logger.WithField("offset_ms", g.ServerTimeDiff).Debug("clock synced")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash"
|
||||
)
|
||||
|
||||
// hmacFor returns a fresh HMAC keyed with secret for the given hash type.
|
||||
func hmacFor(h HMACType, secret []byte) hash.Hash {
|
||||
switch h {
|
||||
case HMACSHA256:
|
||||
return hmac.New(sha256.New, secret)
|
||||
case HMACSHA512:
|
||||
return hmac.New(sha512.New, secret)
|
||||
default:
|
||||
return hmac.New(sha1.New, secret)
|
||||
}
|
||||
}
|
||||
|
||||
// hotpCode computes the RFC 4226 HOTP code for the given (secret, counter)
|
||||
// pair, with the supplied number of decimal digits and HMAC algorithm.
|
||||
func hotpCode(secret []byte, counter uint64, digits int, h HMACType) string {
|
||||
mac := hmacFor(h, secret)
|
||||
var counterBytes [8]byte
|
||||
binary.BigEndian.PutUint64(counterBytes[:], counter)
|
||||
_, _ = mac.Write(counterBytes[:])
|
||||
sum := mac.Sum(nil)
|
||||
|
||||
offset := sum[len(sum)-1] & 0x0F
|
||||
truncated := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7FFFFFFF
|
||||
|
||||
mod := uint32(1)
|
||||
for i := 0; i < digits; i++ {
|
||||
mod *= 10
|
||||
}
|
||||
return fmt.Sprintf("%0*d", digits, truncated%mod)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
)
|
||||
|
||||
// HOTPAuthenticator implements RFC 4226 counter-based HOTP.
|
||||
type HOTPAuthenticator struct {
|
||||
Base
|
||||
Counter uint64
|
||||
}
|
||||
|
||||
// NewHOTPAuthenticator returns a fresh HOTP authenticator with the project
|
||||
// defaults (6 digits, SHA1).
|
||||
func NewHOTPAuthenticator() *HOTPAuthenticator {
|
||||
return &HOTPAuthenticator{Base: NewBase()}
|
||||
}
|
||||
|
||||
// Name returns the short logger tag for this type.
|
||||
func (h *HOTPAuthenticator) Name() string { return "hotp" }
|
||||
|
||||
// Enroll loads the secret from a base32 string and optionally seeds the
|
||||
// counter.
|
||||
func (h *HOTPAuthenticator) Enroll(b32 string, counter uint64) error {
|
||||
const fn = "internal.authenticator.HOTPAuthenticator.Enroll"
|
||||
raw, err := Base32Decode(b32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.SecretKey = raw
|
||||
h.Counter = counter
|
||||
global.Log.WithField("func", fn).WithField("counter", counter).Debug("enrolled HOTP")
|
||||
return nil
|
||||
}
|
||||
|
||||
// CurrentCode increments the internal counter and returns the resulting code.
|
||||
func (h *HOTPAuthenticator) CurrentCode() (string, error) {
|
||||
if h.SecretKey == nil {
|
||||
return "", fmt.Errorf("authenticator: no secret loaded")
|
||||
}
|
||||
h.Counter++
|
||||
digits := h.CodeDigits
|
||||
if digits == 0 {
|
||||
digits = DefaultCodeDigits
|
||||
}
|
||||
return hotpCode(h.SecretKey, h.Counter, digits, h.HMACType), nil
|
||||
}
|
||||
|
||||
// Sync is a no-op for HOTP — there is no server clock to align against.
|
||||
func (h *HOTPAuthenticator) Sync() error { return nil }
|
||||
|
||||
// SecretData appends "|<counter>" to the base secret data string, matching
|
||||
// the C# HOTPAuthenticator.SecretData getter.
|
||||
func (h *HOTPAuthenticator) SecretData() string {
|
||||
return h.EncodeSecretData() + "|" + strconv.FormatUint(h.Counter, 10)
|
||||
}
|
||||
|
||||
// SetSecretData parses the "<base>|<counter>" form.
|
||||
func (h *HOTPAuthenticator) SetSecretData(value string) error {
|
||||
if err := h.DecodeSecretData(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if idx := strings.Index(value, "|"); idx >= 0 {
|
||||
c, err := strconv.ParseUint(strings.TrimSpace(value[idx+1:]), 10, 64)
|
||||
if err == nil {
|
||||
h.Counter = c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EncodeSecretData encodes the "<key>\t<digits>\t<hmac>\t<period>" string
|
||||
// that the original WinAuth config uses inside <secretdata>.
|
||||
func (b *Base) EncodeSecretData() string {
|
||||
return fmt.Sprintf(
|
||||
"%s\t%d\t%s\t%d",
|
||||
strings.ToUpper(hex.EncodeToString(b.SecretKey)),
|
||||
b.CodeDigits, b.HMACType.String(), b.Period,
|
||||
)
|
||||
}
|
||||
|
||||
// DecodeSecretData parses the value produced by EncodeSecretData (and
|
||||
// optionally a "|"-suffixed payload for HOTP subclasses).
|
||||
func (b *Base) DecodeSecretData(value string) error {
|
||||
if value == "" {
|
||||
b.SecretKey = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
head := strings.SplitN(value, "|", 2)[0]
|
||||
parts := strings.Split(head, "\t")
|
||||
if len(parts) == 0 {
|
||||
return fmt.Errorf("authenticator: empty secret data")
|
||||
}
|
||||
|
||||
raw, err := hex.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("authenticator: bad secret hex: %w", err)
|
||||
}
|
||||
b.SecretKey = raw
|
||||
|
||||
if len(parts) > 1 {
|
||||
if d, err := strconv.Atoi(parts[1]); err == nil {
|
||||
b.CodeDigits = d
|
||||
}
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
switch strings.ToUpper(parts[2]) {
|
||||
case "SHA256":
|
||||
b.HMACType = HMACSHA256
|
||||
case "SHA512":
|
||||
b.HMACType = HMACSHA512
|
||||
default:
|
||||
b.HMACType = HMACSHA1
|
||||
}
|
||||
}
|
||||
if len(parts) > 3 {
|
||||
if p, err := strconv.Atoi(parts[3]); err == nil && p > 0 {
|
||||
b.Period = p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/httpc"
|
||||
)
|
||||
|
||||
// steamChars is the alphanumeric alphabet that Steam Guard maps the
|
||||
// truncated HMAC into. It deliberately omits visually similar characters.
|
||||
var steamChars = []byte{
|
||||
'2', '3', '4', '5', '6', '7', '8', '9', 'B', 'C',
|
||||
'D', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q',
|
||||
'R', 'T', 'V', 'W', 'X', 'Y',
|
||||
}
|
||||
|
||||
const (
|
||||
steamCodeDigits = 5
|
||||
steamQueryTime = "https://api.steampowered.com:443/ITwoFactorService/QueryTime/v0001"
|
||||
)
|
||||
|
||||
// SteamAuthenticator implements Steam Guard's variant of TOTP. Full
|
||||
// enrollment / login / session handling will be added in a later phase;
|
||||
// this file covers code generation, time sync, and persistence — enough
|
||||
// for an already-enrolled authenticator imported from the original WinAuth
|
||||
// config to keep working.
|
||||
type SteamAuthenticator struct {
|
||||
Base
|
||||
Serial string
|
||||
DeviceID string
|
||||
SteamData string // JSON blob from FinalizeAddAuthenticator
|
||||
SessionData string // optional cookie/session JSON
|
||||
}
|
||||
|
||||
// NewSteamAuthenticator returns a fresh 5-character Steam Guard authenticator.
|
||||
func NewSteamAuthenticator() *SteamAuthenticator {
|
||||
s := &SteamAuthenticator{Base: NewBase()}
|
||||
s.CodeDigits = steamCodeDigits
|
||||
return s
|
||||
}
|
||||
|
||||
// Name returns the short logger tag for this type.
|
||||
func (s *SteamAuthenticator) Name() string { return "steam" }
|
||||
|
||||
// CurrentCode returns the current 5-char Steam Guard code.
|
||||
func (s *SteamAuthenticator) CurrentCode() (string, error) {
|
||||
if s.SecretKey == nil {
|
||||
return "", fmt.Errorf("steam: no secret loaded")
|
||||
}
|
||||
return s.steamCode(), nil
|
||||
}
|
||||
|
||||
// steamCode mirrors the C# CalculateCode override, mapping a 4-byte
|
||||
// truncation into the Steam alphabet.
|
||||
func (s *SteamAuthenticator) steamCode() string {
|
||||
mac := hmac.New(sha1.New, s.SecretKey)
|
||||
var counter [8]byte
|
||||
binary.BigEndian.PutUint64(counter[:], s.CodeInterval())
|
||||
_, _ = mac.Write(counter[:])
|
||||
sum := mac.Sum(nil)
|
||||
|
||||
start := sum[len(sum)-1] & 0x0F
|
||||
full := binary.BigEndian.Uint32(sum[start:start+4]) & 0x7FFFFFFF
|
||||
|
||||
out := make([]byte, steamCodeDigits)
|
||||
for i := 0; i < steamCodeDigits; i++ {
|
||||
out[i] = steamChars[full%uint32(len(steamChars))]
|
||||
full /= uint32(len(steamChars))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Sync hits the Steam ITwoFactorService/QueryTime endpoint to recompute
|
||||
// the local-vs-server clock offset.
|
||||
func (s *SteamAuthenticator) Sync() error {
|
||||
const fn = "internal.authenticator.SteamAuthenticator.Sync"
|
||||
logger := global.Log.WithField("func", fn)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, steamQueryTime,
|
||||
strings.NewReader("steamid=0"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpc.New().Do(req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Warn("query time failed; using local clock")
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Response struct {
|
||||
ServerTime json.Number `json:"server_time"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
logger.WithError(err).Warn("query time: invalid JSON")
|
||||
return nil
|
||||
}
|
||||
serverSec, err := strconv.ParseInt(string(parsed.Response.ServerTime), 10, 64)
|
||||
if err != nil {
|
||||
logger.WithError(err).Warn("query time: bad server_time")
|
||||
return nil
|
||||
}
|
||||
s.ServerTimeDiff = serverSec*1000 - NowMillis()
|
||||
s.LastServerTime = NowMillis()
|
||||
logger.WithField("offset_ms", s.ServerTimeDiff).Debug("clock synced")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SecretData encodes the Steam-specific payload as "<base>|<serialhex>|<deviceidhex>|<steamdatahex>|<sessionhex>".
|
||||
func (s *SteamAuthenticator) SecretData() string {
|
||||
enc := func(v string) string { return strings.ToUpper(hex.EncodeToString([]byte(v))) }
|
||||
return s.EncodeSecretData() + "|" +
|
||||
enc(s.Serial) + "|" +
|
||||
enc(s.DeviceID) + "|" +
|
||||
enc(s.SteamData) + "|" +
|
||||
enc(s.SessionData)
|
||||
}
|
||||
|
||||
// SetSecretData reverses SecretData.
|
||||
func (s *SteamAuthenticator) SetSecretData(value string) error {
|
||||
if value == "" {
|
||||
s.SecretKey = nil
|
||||
s.Serial = ""
|
||||
s.DeviceID = ""
|
||||
s.SteamData = ""
|
||||
s.SessionData = ""
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, "|")
|
||||
if err := s.DecodeSecretData(parts[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
dec := func(s string) string {
|
||||
raw, _ := hex.DecodeString(s)
|
||||
return string(raw)
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
s.Serial = dec(parts[1])
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
s.DeviceID = dec(parts[2])
|
||||
}
|
||||
if len(parts) > 3 {
|
||||
s.SteamData = dec(parts[3])
|
||||
if s.SteamData != "" && !strings.HasPrefix(s.SteamData, "{") {
|
||||
// legacy WinAuth stored only the revocation_code; wrap to JSON
|
||||
s.SteamData = `{"revocation_code":"` + s.SteamData + `"}`
|
||||
}
|
||||
}
|
||||
if len(parts) > 4 {
|
||||
s.SessionData = dec(parts[4])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
)
|
||||
|
||||
// Steam WebAPI endpoints used by the client (login / cookie refresh /
|
||||
// logoff). Kept here rather than in steam_http.go because they are
|
||||
// SteamClient-specific.
|
||||
const (
|
||||
steamAPIGetWGToken = "/IMobileAuthService/GetWGToken/v0001"
|
||||
steamAPILogon = "/ISteamWebUserPresenceOAuth/Logon/v0001"
|
||||
steamAPILogoff = "/ISteamWebUserPresenceOAuth/Logoff/v0001"
|
||||
)
|
||||
|
||||
// SteamClient is the Go port of the C# SteamClient inner class. It owns
|
||||
// a SteamSession, an attached SteamAuthenticator (for live TOTP codes),
|
||||
// and an HTTP client with persistent cookie jar.
|
||||
//
|
||||
// SECURITY: holds OAuth token, login cookies, and a reference to the
|
||||
// authenticator's shared secret via Authenticator.SteamData. Never log
|
||||
// the Session directly.
|
||||
type SteamClient struct {
|
||||
Authenticator *SteamAuthenticator
|
||||
Session *SteamSession
|
||||
|
||||
// Login state flags inspected by the UI after Login returns. They
|
||||
// mirror the C# public fields exactly so the wizard logic stays
|
||||
// recognisable.
|
||||
InvalidLogin bool
|
||||
RequiresCaptcha bool
|
||||
CaptchaID string
|
||||
CaptchaURL string
|
||||
RequiresEmailAuth bool
|
||||
EmailDomain string
|
||||
Requires2FA bool
|
||||
Error string
|
||||
|
||||
mu sync.Mutex
|
||||
jar http.CookieJar
|
||||
client *http.Client
|
||||
|
||||
// confirmationsHTML / confirmationsQuery hold the most recent
|
||||
// GetConfirmations response so GetConfirmationDetails can wrap the
|
||||
// per-trade detail HTML in the same outer markup. Recomputed every
|
||||
// poll; never persisted.
|
||||
confirmationsHTML string
|
||||
confirmationsQuery string
|
||||
|
||||
// Poller hooks. Set by the UI before StartConfirmationPoller.
|
||||
// Called from the poller goroutine — implementations must not
|
||||
// block the UI thread or attempt to drive the Gio frame loop
|
||||
// directly; marshal back via window.Invalidate or a channel.
|
||||
OnConfirmation ConfirmationCallback
|
||||
OnConfirmationError ConfirmationErrorCallback
|
||||
|
||||
// ConfirmationPollerRetries controls how many consecutive failed
|
||||
// poll cycles trigger OnConfirmationError. Zero falls back to
|
||||
// defaultConfirmationPollerRetries (3).
|
||||
ConfirmationPollerRetries int
|
||||
|
||||
// poller is the handle to the currently running background
|
||||
// goroutine, or nil if none is active.
|
||||
poller *pollerHandle
|
||||
}
|
||||
|
||||
// NewSteamClient builds a client around an existing authenticator. If
|
||||
// sessionJSON is non-empty it is parsed via ParseSteamSession; the
|
||||
// resulting cookies are loaded into the internal jar so subsequent
|
||||
// requests carry the login state.
|
||||
func NewSteamClient(auth *SteamAuthenticator, sessionJSON string) (*SteamClient, error) {
|
||||
sess := ParseSteamSession(sessionJSON)
|
||||
jar, err := newSteamCookieJar()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Replay any cookies the session already had into the live jar.
|
||||
if len(sess.Cookies) > 0 {
|
||||
u, _ := url.Parse(steamCommunityBase + "/")
|
||||
cs := make([]*http.Cookie, 0, len(sess.Cookies))
|
||||
for name, value := range sess.Cookies {
|
||||
cs = append(cs, &http.Cookie{Name: name, Value: value})
|
||||
}
|
||||
jar.SetCookies(u, cs)
|
||||
}
|
||||
return &SteamClient{
|
||||
Authenticator: auth,
|
||||
Session: sess,
|
||||
jar: jar,
|
||||
client: steamHTTPClient(jar),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsLoggedIn reports whether the session carries an OAuth token. Note
|
||||
// this does NOT round-trip to Steam — the token might have been
|
||||
// invalidated server-side.
|
||||
func (c *SteamClient) IsLoggedIn() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.Session != nil && c.Session.OAuthToken != ""
|
||||
}
|
||||
|
||||
// Clear resets the per-login flags and wipes the session. The jar is
|
||||
// recreated so leftover Steam cookies do not bleed into the next login
|
||||
// attempt.
|
||||
func (c *SteamClient) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.clearLocked()
|
||||
}
|
||||
|
||||
func (c *SteamClient) clearLocked() {
|
||||
c.InvalidLogin = false
|
||||
c.RequiresCaptcha = false
|
||||
c.CaptchaID = ""
|
||||
c.CaptchaURL = ""
|
||||
c.RequiresEmailAuth = false
|
||||
c.EmailDomain = ""
|
||||
c.Requires2FA = false
|
||||
c.Error = ""
|
||||
if c.Session != nil {
|
||||
c.Session.Clear()
|
||||
}
|
||||
if jar, err := newSteamCookieJar(); err == nil {
|
||||
c.jar = jar
|
||||
c.client = steamHTTPClient(jar)
|
||||
}
|
||||
}
|
||||
|
||||
// Login authenticates against Steam using credentials plus the live
|
||||
// TOTP code from the bound authenticator. Returns true on success.
|
||||
// On a non-fatal failure (captcha / email / 2FA needed, bad password)
|
||||
// it returns (false, nil) with the corresponding Requires* flag and
|
||||
// Error set. A non-nil error means the call could not even complete
|
||||
// the transport-level handshake.
|
||||
func (c *SteamClient) Login(
|
||||
ctx context.Context, username, password, captchaID, captchaText string,
|
||||
) (bool, error) {
|
||||
const fn = "internal.authenticator.SteamClient.Login"
|
||||
logger := global.Log.WithField("func", fn)
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.Error = ""
|
||||
|
||||
if c.Session.OAuthToken != "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if !steamJarHasSessionID(c.jar) {
|
||||
headers := http.Header{"X-Requested-With": []string{"com.valvesoftware.android.steam.community"}}
|
||||
if _, err := steamRequest(ctx, c.client, http.MethodGet,
|
||||
steamCommunityBase+"/mobilelogin?oauth_client_id="+steamOAuthClientID+
|
||||
"&oauth_scope="+url.QueryEscape(steamOAuthScope), nil, headers); err != nil {
|
||||
return false, fmt.Errorf("mobilelogin GET: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
username = stripNonASCII(username)
|
||||
password = stripNonASCII(password)
|
||||
|
||||
rsaResp, err := steamRequest(ctx, c.client, http.MethodPost,
|
||||
steamCommunityBase+"/mobilelogin/getrsakey",
|
||||
url.Values{"username": {username}}, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("getrsakey: %w", err)
|
||||
}
|
||||
var rsaJSON rsaKeyResponse
|
||||
if err := json.Unmarshal([]byte(rsaResp), &rsaJSON); err != nil {
|
||||
return false, fmt.Errorf("getrsakey parse: %w", err)
|
||||
}
|
||||
if !rsaJSON.Success {
|
||||
c.InvalidLogin = true
|
||||
c.Error = "Unknown username"
|
||||
return false, nil
|
||||
}
|
||||
|
||||
encPw, err := steamRSAEncryptPassword(password, rsaJSON.PublicKeyMod, rsaJSON.PublicKeyExp)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("rsa encrypt: %w", err)
|
||||
}
|
||||
|
||||
// Live TOTP — the key difference from Enroll, which sends "".
|
||||
twoFactor, err := c.Authenticator.CurrentCode()
|
||||
if err != nil {
|
||||
// No secret yet: send empty, Steam will reply requires_twofactor.
|
||||
twoFactor = ""
|
||||
}
|
||||
|
||||
if captchaID == "" {
|
||||
captchaID = "-1"
|
||||
}
|
||||
if captchaText == "" {
|
||||
captchaText = "enter above characters"
|
||||
}
|
||||
form := url.Values{
|
||||
"password": {base64.StdEncoding.EncodeToString(encPw)},
|
||||
"username": {username},
|
||||
"twofactorcode": {twoFactor},
|
||||
"loginfriendlyname": {"#login_emailauth_friendlyname_mobile"},
|
||||
"captchagid": {captchaID},
|
||||
"captcha_text": {captchaText},
|
||||
"rsatimestamp": {rsaJSON.Timestamp},
|
||||
"remember_login": {"false"},
|
||||
"oauth_client_id": {steamOAuthClientID},
|
||||
"oauth_scope": {steamOAuthScope},
|
||||
"donotache": {strconv.FormatInt(time.Now().UnixMilli(), 10)},
|
||||
}
|
||||
// Wipe the local plaintext password copy now that it is RSA-encrypted.
|
||||
password = ""
|
||||
_ = password
|
||||
|
||||
loginResp, err := steamRequest(ctx, c.client, http.MethodPost,
|
||||
steamCommunityBase+"/mobilelogin/dologin/", form, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("dologin: %w", err)
|
||||
}
|
||||
|
||||
var login map[string]any
|
||||
if err := json.Unmarshal([]byte(loginResp), &login); err != nil {
|
||||
return false, fmt.Errorf("dologin parse: %w", err)
|
||||
}
|
||||
|
||||
if v, ok := login["emailsteamid"].(string); ok {
|
||||
c.Session.SteamId = v
|
||||
}
|
||||
|
||||
c.InvalidLogin = false
|
||||
c.RequiresCaptcha = false
|
||||
c.CaptchaID = ""
|
||||
c.CaptchaURL = ""
|
||||
c.RequiresEmailAuth = false
|
||||
c.EmailDomain = ""
|
||||
c.Requires2FA = false
|
||||
|
||||
complete, _ := login["login_complete"].(bool)
|
||||
oauthStr, _ := login["oauth"].(string)
|
||||
if !complete || oauthStr == "" {
|
||||
c.InvalidLogin = true
|
||||
if b, _ := login["captcha_needed"].(bool); b {
|
||||
c.RequiresCaptcha = true
|
||||
if id, ok := login["captcha_gid"].(string); ok {
|
||||
c.CaptchaID = id
|
||||
c.CaptchaURL = steamCommunityBase + "/public/captcha.php?gid=" + id
|
||||
}
|
||||
}
|
||||
if b, _ := login["emailauth_needed"].(bool); b {
|
||||
if d, ok := login["emaildomain"].(string); ok && d != "" {
|
||||
c.EmailDomain = d
|
||||
}
|
||||
c.RequiresEmailAuth = true
|
||||
}
|
||||
if b, _ := login["requires_twofactor"].(bool); b {
|
||||
c.Requires2FA = true
|
||||
}
|
||||
if msg, ok := login["message"].(string); ok && msg != "" {
|
||||
c.Error = msg
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var oauth struct {
|
||||
OAuthToken string `json:"oauth_token"`
|
||||
SteamID string `json:"steamid"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(oauthStr), &oauth); err != nil {
|
||||
return false, fmt.Errorf("oauth parse: %w", err)
|
||||
}
|
||||
c.Session.OAuthToken = oauth.OAuthToken
|
||||
if oauth.SteamID != "" {
|
||||
c.Session.SteamId = oauth.SteamID
|
||||
}
|
||||
|
||||
c.syncCookiesFromJarLocked()
|
||||
logger.WithField("steamid", c.Session.SteamId).Info("steam login ok")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Refresh exchanges the stored OAuth token for fresh steamLogin /
|
||||
// steamLoginSecure cookies via IMobileAuthService/GetWGToken. Returns
|
||||
// true on success; false (with no error) if the response is missing
|
||||
// expected fields, mirroring the C# best-effort behaviour.
|
||||
func (c *SteamClient) Refresh(ctx context.Context) (bool, error) {
|
||||
const fn = "internal.authenticator.SteamClient.Refresh"
|
||||
logger := global.Log.WithField("func", fn)
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.Session == nil || c.Session.OAuthToken == "" {
|
||||
return false, errors.New("steam: refresh without OAuth token")
|
||||
}
|
||||
|
||||
resp, err := steamRequest(ctx, c.client, http.MethodPost,
|
||||
steamWebAPIBase+steamAPIGetWGToken,
|
||||
url.Values{"access_token": {c.Session.OAuthToken}}, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("GetWGToken: %w", err)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Response struct {
|
||||
Token string `json:"token"`
|
||||
TokenSecure string `json:"token_secure"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(resp), &parsed); err != nil {
|
||||
logger.WithError(err).Warn("GetWGToken parse failed")
|
||||
return false, nil
|
||||
}
|
||||
if parsed.Response.Token == "" || parsed.Response.TokenSecure == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
u, _ := url.Parse(steamCommunityBase + "/")
|
||||
c.jar.SetCookies(u, []*http.Cookie{
|
||||
{Name: "steamLogin", Value: c.Session.SteamId + "||" + parsed.Response.Token},
|
||||
{Name: "steamLoginSecure", Value: c.Session.SteamId + "||" + parsed.Response.TokenSecure},
|
||||
})
|
||||
c.syncCookiesFromJarLocked()
|
||||
logger.Debug("session cookies refreshed")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Logout best-effort revokes the UMQ session (if one was opened) and
|
||||
// then clears the local state. Network errors are swallowed — the
|
||||
// local state must still end up cleared regardless of server reply.
|
||||
func (c *SteamClient) Logout(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.Session != nil && c.Session.OAuthToken != "" && c.Session.UmqId != "" {
|
||||
_, _ = steamRequest(ctx, c.client, http.MethodPost,
|
||||
steamWebAPIBase+steamAPILogoff,
|
||||
url.Values{
|
||||
"access_token": {c.Session.OAuthToken},
|
||||
"umqid": {c.Session.UmqId},
|
||||
}, nil)
|
||||
}
|
||||
c.clearLocked()
|
||||
}
|
||||
|
||||
// syncCookiesFromJarLocked copies the community-domain cookies out of
|
||||
// the live jar into Session.Cookies. Must be called with c.mu held.
|
||||
func (c *SteamClient) syncCookiesFromJarLocked() {
|
||||
if c.Session == nil || c.jar == nil {
|
||||
return
|
||||
}
|
||||
u, _ := url.Parse(steamCommunityBase + "/")
|
||||
c.Session.Cookies = map[string]string{}
|
||||
for _, ck := range c.jar.Cookies(u) {
|
||||
c.Session.Cookies[ck.Name] = ck.Value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
)
|
||||
|
||||
// Steam mobileconf endpoints. Trade confirmations live on the
|
||||
// community domain, not the WebAPI.
|
||||
const (
|
||||
steamMobileConfList = "/mobileconf/conf"
|
||||
steamMobileConfDetails = "/mobileconf/details/"
|
||||
steamMobileConfAjaxOp = "/mobileconf/ajaxop"
|
||||
)
|
||||
|
||||
// Regexes ported verbatim from the C# SteamClient. The Steam mobile
|
||||
// confirmations page is plain HTML and Valve has not provided a JSON
|
||||
// alternative, so we have to scrape. Keep flags ((?is) = case-insensitive,
|
||||
// dot matches newline) aligned with the C# Singleline|IgnoreCase combo.
|
||||
var (
|
||||
steamRegexTrades = regexp.MustCompile(`(?is)"mobileconf_list_entry"(.*?)>(.*?)"mobileconf_list_entry_sep"`)
|
||||
steamRegexTradeConfID = regexp.MustCompile(`(?is)data-confid\s*=\s*"([^"]+)"`)
|
||||
steamRegexTradeKey = regexp.MustCompile(`(?is)data-key\s*=\s*"([^"]+)"`)
|
||||
steamRegexTradePlayer = regexp.MustCompile(`(?is)"mobileconf_list_entry_icon"(.*?)src="([^"]+)"`)
|
||||
steamRegexTradeDetails = regexp.MustCompile(`(?is)"mobileconf_list_entry_description".*?<div>([^<]*)</div>[^<]*<div>([^<]*)</div>[^<]*<div>([^<]*)</div>[^<]*</div>`)
|
||||
steamRegexConfDetails = regexp.MustCompile(`(?is)(.*<body[^>]*>\s*<div\s+class="[^"]+">).*(</div>.*?</body>\s*</html>)`)
|
||||
)
|
||||
|
||||
// ErrSteamRequestInvalid is returned by ConfirmTrade / GetConfirmation*
|
||||
// when Steam answers with a body that doesn't even parse as the expected
|
||||
// success envelope. Lets the UI surface "try again" vs a hard failure.
|
||||
var ErrSteamRequestInvalid = errors.New("steam: invalid response")
|
||||
|
||||
// confirmationsHTML / confirmationsQuery hold the last GetConfirmations
|
||||
// result so GetConfirmationDetails can wrap individual detail HTML in
|
||||
// the same outer body markup the user already trusts. They are NOT
|
||||
// persisted — recomputed every poll.
|
||||
//
|
||||
// Storing them as private fields on SteamClient keeps the method
|
||||
// signatures matching the C# code.
|
||||
|
||||
// GetConfirmations fetches the user's current pending trade / market
|
||||
// confirmations. The bound authenticator MUST already have SteamData
|
||||
// holding identity_secret, otherwise the request will be rejected.
|
||||
//
|
||||
// On success the returned slice describes each pending confirmation. As
|
||||
// a side effect, if Session.Confirmations is non-nil its Ids set is
|
||||
// updated and each returned Confirmation has IsNew populated.
|
||||
func (c *SteamClient) GetConfirmations(ctx context.Context) ([]Confirmation, error) {
|
||||
const fn = "internal.authenticator.SteamClient.GetConfirmations"
|
||||
logger := global.Log.WithField("func", fn)
|
||||
|
||||
c.mu.Lock()
|
||||
auth := c.Authenticator
|
||||
c.mu.Unlock()
|
||||
if auth == nil {
|
||||
return nil, errors.New("steam: GetConfirmations without authenticator")
|
||||
}
|
||||
|
||||
identitySecret, err := extractIdentitySecret(auth.SteamData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if identitySecret == "" {
|
||||
return nil, errors.New("steam: identity_secret missing from SteamData")
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
serverTime := (NowMillis() + auth.ServerTimeDiff) / 1000
|
||||
deviceID := auth.DeviceID
|
||||
steamID := ""
|
||||
if c.Session != nil {
|
||||
steamID = c.Session.SteamId
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
timehash, err := steamCreateTimeHash(serverTime, "conf", identitySecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
form := url.Values{
|
||||
"p": {deviceID},
|
||||
"a": {steamID},
|
||||
"k": {timehash},
|
||||
"t": {strconv.FormatInt(serverTime, 10)},
|
||||
"m": {"android"},
|
||||
"tag": {"conf"},
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
client := c.client
|
||||
c.mu.Unlock()
|
||||
|
||||
html, err := steamRequest(ctx, client, http.MethodGet,
|
||||
steamCommunityBase+steamMobileConfList, form, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mobileconf/conf: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.confirmationsHTML = html
|
||||
c.confirmationsQuery = form.Encode()
|
||||
c.mu.Unlock()
|
||||
|
||||
trades := parseConfirmationsHTML(html)
|
||||
|
||||
// Maintain the poller's id-set: mark which trades are new and prune
|
||||
// ids that are no longer pending. Mirrors the C# locked block.
|
||||
c.mu.Lock()
|
||||
if c.Session != nil && c.Session.Confirmations != nil {
|
||||
if c.Session.Confirmations.Ids == nil {
|
||||
c.Session.Confirmations.Ids = []string{}
|
||||
}
|
||||
known := make(map[string]bool, len(c.Session.Confirmations.Ids))
|
||||
for _, id := range c.Session.Confirmations.Ids {
|
||||
known[id] = true
|
||||
}
|
||||
seen := make(map[string]bool, len(trades))
|
||||
for i := range trades {
|
||||
seen[trades[i].Id] = true
|
||||
if !known[trades[i].Id] {
|
||||
trades[i].IsNew = true
|
||||
c.Session.Confirmations.Ids = append(c.Session.Confirmations.Ids, trades[i].Id)
|
||||
known[trades[i].Id] = true
|
||||
}
|
||||
}
|
||||
// Drop ids that are no longer pending.
|
||||
kept := c.Session.Confirmations.Ids[:0]
|
||||
for _, id := range c.Session.Confirmations.Ids {
|
||||
if seen[id] {
|
||||
kept = append(kept, id)
|
||||
}
|
||||
}
|
||||
c.Session.Confirmations.Ids = kept
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
logger.WithField("count", len(trades)).Debug("fetched confirmations")
|
||||
return trades, nil
|
||||
}
|
||||
|
||||
// GetConfirmationDetails returns the HTML fragment Steam serves for the
|
||||
// inner details of a single confirmation, wrapped in the outer markup
|
||||
// captured by the most recent GetConfirmations call.
|
||||
func (c *SteamClient) GetConfirmationDetails(ctx context.Context, trade Confirmation) (string, error) {
|
||||
c.mu.Lock()
|
||||
client := c.client
|
||||
confHTML := c.confirmationsHTML
|
||||
confQuery := c.confirmationsQuery
|
||||
c.mu.Unlock()
|
||||
|
||||
if client == nil {
|
||||
return "", errors.New("steam: client not initialised")
|
||||
}
|
||||
detailURL := steamCommunityBase + steamMobileConfDetails + url.PathEscape(trade.Id)
|
||||
if confQuery != "" {
|
||||
detailURL += "?" + confQuery
|
||||
}
|
||||
|
||||
resp, err := steamRequest(ctx, client, http.MethodGet, detailURL, nil, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mobileconf/details: %w", err)
|
||||
}
|
||||
if !strings.Contains(resp, "success") {
|
||||
return "", fmt.Errorf("%w: %s", ErrSteamRequestInvalid, resp)
|
||||
}
|
||||
|
||||
var detail struct {
|
||||
Success bool `json:"success"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(resp), &detail); err != nil {
|
||||
return "", fmt.Errorf("mobileconf/details parse: %w", err)
|
||||
}
|
||||
if !detail.Success {
|
||||
return fallbackDetailsHTML(), nil
|
||||
}
|
||||
|
||||
if m := steamRegexConfDetails.FindStringSubmatch(confHTML); len(m) >= 3 {
|
||||
return m[1] + detail.HTML + m[2], nil
|
||||
}
|
||||
return fallbackDetailsHTML(), nil
|
||||
}
|
||||
|
||||
// ConfirmTrade accepts or rejects a single pending confirmation. Returns
|
||||
// (true, nil) on success. (false, nil) means Steam answered with
|
||||
// success=false; a non-nil error is a transport or parse failure.
|
||||
func (c *SteamClient) ConfirmTrade(ctx context.Context, id, key string, accept bool) (bool, error) {
|
||||
const fn = "internal.authenticator.SteamClient.ConfirmTrade"
|
||||
logger := global.Log.WithField("func", fn)
|
||||
|
||||
c.mu.Lock()
|
||||
auth := c.Authenticator
|
||||
hasToken := c.Session != nil && c.Session.OAuthToken != ""
|
||||
steamID := ""
|
||||
if c.Session != nil {
|
||||
steamID = c.Session.SteamId
|
||||
}
|
||||
client := c.client
|
||||
c.mu.Unlock()
|
||||
|
||||
if !hasToken {
|
||||
return false, nil
|
||||
}
|
||||
if auth == nil {
|
||||
return false, errors.New("steam: ConfirmTrade without authenticator")
|
||||
}
|
||||
|
||||
identitySecret, err := extractIdentitySecret(auth.SteamData)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if identitySecret == "" {
|
||||
return false, errors.New("steam: identity_secret missing from SteamData")
|
||||
}
|
||||
|
||||
serverTime := (NowMillis() + auth.ServerTimeDiff) / 1000
|
||||
timehash, err := steamCreateTimeHash(serverTime, "conf", identitySecret)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
op := "cancel"
|
||||
if accept {
|
||||
op = "allow"
|
||||
}
|
||||
form := url.Values{
|
||||
"op": {op},
|
||||
"p": {auth.DeviceID},
|
||||
"a": {steamID},
|
||||
"k": {timehash},
|
||||
"t": {strconv.FormatInt(serverTime, 10)},
|
||||
"m": {"android"},
|
||||
"tag": {"conf"},
|
||||
"cid": {id},
|
||||
"ck": {key},
|
||||
}
|
||||
|
||||
resp, err := steamRequest(ctx, client, http.MethodGet,
|
||||
steamCommunityBase+steamMobileConfAjaxOp, form, nil)
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
c.Error = err.Error()
|
||||
c.mu.Unlock()
|
||||
return false, err
|
||||
}
|
||||
if resp == "" {
|
||||
c.mu.Lock()
|
||||
c.Error = "Blank response"
|
||||
c.mu.Unlock()
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(resp), &parsed); err != nil {
|
||||
c.mu.Lock()
|
||||
c.Error = "Failed"
|
||||
c.mu.Unlock()
|
||||
return false, nil
|
||||
}
|
||||
if !parsed.Success {
|
||||
c.mu.Lock()
|
||||
c.Error = "Failed"
|
||||
c.mu.Unlock()
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Drop the id from the poller set so the next poll does not see it
|
||||
// as still pending.
|
||||
c.mu.Lock()
|
||||
if c.Session != nil && c.Session.Confirmations != nil {
|
||||
kept := c.Session.Confirmations.Ids[:0]
|
||||
for _, x := range c.Session.Confirmations.Ids {
|
||||
if x != id {
|
||||
kept = append(kept, x)
|
||||
}
|
||||
}
|
||||
c.Session.Confirmations.Ids = kept
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
logger.WithField("op", op).WithField("id", id).Info("trade confirmation submitted")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// parseConfirmationsHTML extracts every <div class="mobileconf_list_entry">
|
||||
// from the mobileconf/conf response. Empty result is a valid outcome
|
||||
// (the user simply has no pending trades).
|
||||
func parseConfirmationsHTML(html string) []Confirmation {
|
||||
var trades []Confirmation
|
||||
for _, m := range steamRegexTrades.FindAllStringSubmatch(html, -1) {
|
||||
if len(m) < 3 {
|
||||
continue
|
||||
}
|
||||
head, body := m[1], m[2]
|
||||
var conf Confirmation
|
||||
if cm := steamRegexTradeConfID.FindStringSubmatch(head); len(cm) >= 2 {
|
||||
conf.Id = cm[1]
|
||||
}
|
||||
if km := steamRegexTradeKey.FindStringSubmatch(head); len(km) >= 2 {
|
||||
conf.Key = km[1]
|
||||
}
|
||||
if pm := steamRegexTradePlayer.FindStringSubmatch(body); len(pm) >= 3 {
|
||||
if strings.Contains(pm[1], "offline") {
|
||||
conf.Offline = true
|
||||
}
|
||||
conf.Image = pm[2]
|
||||
}
|
||||
if dm := steamRegexTradeDetails.FindStringSubmatch(body); len(dm) >= 4 {
|
||||
conf.Details = dm[1]
|
||||
conf.Traded = dm[2]
|
||||
conf.When = dm[3]
|
||||
}
|
||||
trades = append(trades, conf)
|
||||
}
|
||||
return trades
|
||||
}
|
||||
|
||||
// extractIdentitySecret pulls the identity_secret string out of the
|
||||
// authenticator's SteamData JSON envelope. Returns "" without error if
|
||||
// SteamData is empty so callers can give the user a friendlier message.
|
||||
func extractIdentitySecret(steamData string) (string, error) {
|
||||
if strings.TrimSpace(steamData) == "" {
|
||||
return "", nil
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(steamData), &parsed); err != nil {
|
||||
return "", fmt.Errorf("steam: SteamData parse: %w", err)
|
||||
}
|
||||
if v, ok := parsed["identity_secret"].(string); ok {
|
||||
return v, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// steamCreateTimeHash is the HMAC-SHA1 signature scheme Steam's mobile
|
||||
// app uses to authenticate confirmation requests. The buffer is the
|
||||
// 8-byte big-endian server time followed by up to 32 bytes of the tag
|
||||
// string (UTF-8). The key is the base64-decoded identity_secret.
|
||||
func steamCreateTimeHash(serverTime int64, tag, identitySecret string) (string, error) {
|
||||
key, err := base64.StdEncoding.DecodeString(identitySecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("steam: identity_secret base64: %w", err)
|
||||
}
|
||||
tagLen := len(tag)
|
||||
if tagLen > 32 {
|
||||
tagLen = 32
|
||||
}
|
||||
buf := make([]byte, 8+tagLen)
|
||||
binary.BigEndian.PutUint64(buf[:8], uint64(serverTime))
|
||||
if tagLen > 0 {
|
||||
copy(buf[8:], tag[:tagLen])
|
||||
}
|
||||
mac := hmac.New(sha1.New, key)
|
||||
_, _ = mac.Write(buf)
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// fallbackDetailsHTML is the placeholder body the UI shows when Steam
|
||||
// cannot or will not produce real details HTML. Identical text to the
|
||||
// C# port to keep i18n / screenshots stable.
|
||||
func fallbackDetailsHTML() string {
|
||||
return "<html><head></head><body><p>Cannot load trade confirmation details</p></body></html>"
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
)
|
||||
|
||||
// EnrollState carries the cross-call state of a Steam enrollment. The
|
||||
// caller (UI) creates one, fills in Username/Password (and optionally
|
||||
// CaptchaText / EmailAuthText / ActivationCode on retries), and calls
|
||||
// SteamAuthenticator.Enroll repeatedly until Success == true or Error
|
||||
// is set to a fatal message.
|
||||
type EnrollState struct {
|
||||
// Inputs supplied by the caller across multiple calls.
|
||||
Username string
|
||||
Password string
|
||||
CaptchaID string
|
||||
CaptchaURL string
|
||||
CaptchaText string
|
||||
EmailDomain string
|
||||
EmailAuthText string
|
||||
ActivationCode string
|
||||
|
||||
// Session state carried across calls. The cookie jar is internal to
|
||||
// the http client; we cache it here so retries reuse it.
|
||||
jar http.CookieJar
|
||||
SteamID string
|
||||
OAuthToken string
|
||||
|
||||
// Flags the caller inspects after each Enroll call to decide which
|
||||
// extra input is needed next.
|
||||
RequiresLogin bool
|
||||
RequiresCaptcha bool
|
||||
Requires2FA bool
|
||||
RequiresEmailAuth bool
|
||||
RequiresActivation bool
|
||||
|
||||
// Outputs populated once enrollment succeeds. RevocationCode MUST
|
||||
// be displayed to the user — losing it locks them out of the
|
||||
// authenticator removal flow.
|
||||
RevocationCode string
|
||||
SecretKey string // hex form, for convenience; raw is on the authenticator
|
||||
Success bool
|
||||
|
||||
// Error is the last user-facing message. Cleared at the start of
|
||||
// every Enroll call.
|
||||
Error string
|
||||
}
|
||||
|
||||
// enrollActivateRetries matches the C# ENROLL_ACTIVATE_RETRIES constant.
|
||||
const enrollActivateRetries = 30
|
||||
|
||||
// invalidActivationCode is the response.status value Steam returns when
|
||||
// the SMS code the user typed is wrong. Mirrors the C# constant.
|
||||
const invalidActivationCode = 89
|
||||
|
||||
// rsaKeyResponse models the /mobilelogin/getrsakey response.
|
||||
type rsaKeyResponse struct {
|
||||
Success bool `json:"success"`
|
||||
PublicKeyMod string `json:"publickey_mod"`
|
||||
PublicKeyExp string `json:"publickey_exp"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Enroll drives one step of the Steam mobile-authenticator enrollment
|
||||
// state machine. Call it repeatedly with the same EnrollState until it
|
||||
// returns (true, nil) or sets state.Error.
|
||||
//
|
||||
// Return value:
|
||||
// - (true, nil) → enrollment fully complete; this authenticator now
|
||||
// holds the new secret/serial/deviceid/steamdata.
|
||||
// - (false, nil) → caller must inspect state.Requires* and supply the
|
||||
// missing input (captcha text, email code, 2FA
|
||||
// activation code) then call Enroll again.
|
||||
// - (false, err) → unrecoverable transport / parse error.
|
||||
//
|
||||
// SECURITY: state.Password is wiped from the struct after a successful
|
||||
// RSA-encrypted login round, so a subsequent retry (e.g. for activation
|
||||
// code) does not keep the cleartext password resident.
|
||||
func (s *SteamAuthenticator) Enroll(ctx context.Context, state *EnrollState) (bool, error) {
|
||||
const fn = "internal.authenticator.SteamAuthenticator.Enroll"
|
||||
logger := global.Log.WithField("func", fn)
|
||||
|
||||
state.Error = ""
|
||||
|
||||
if state.jar == nil {
|
||||
jar, err := newSteamCookieJar()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
state.jar = jar
|
||||
}
|
||||
client := steamHTTPClient(state.jar)
|
||||
|
||||
if state.OAuthToken == "" {
|
||||
// One-time GET so the jar gets the sessionid cookie. Mirrors
|
||||
// the C# "if cookies.Count == 0" branch.
|
||||
if !steamJarHasSessionID(state.jar) {
|
||||
headers := http.Header{"X-Requested-With": []string{"com.valvesoftware.android.steam.community"}}
|
||||
_, err := steamRequest(ctx, client, http.MethodGet,
|
||||
steamCommunityBase+"/mobilelogin?oauth_client_id="+steamOAuthClientID+
|
||||
"&oauth_scope="+url.QueryEscape(steamOAuthScope), nil, headers)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("mobilelogin GET: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
state.Username = stripNonASCII(state.Username)
|
||||
state.Password = stripNonASCII(state.Password)
|
||||
|
||||
rsaResp, err := steamRequest(ctx, client, http.MethodPost,
|
||||
steamCommunityBase+"/mobilelogin/getrsakey",
|
||||
url.Values{"username": {state.Username}}, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("getrsakey: %w", err)
|
||||
}
|
||||
var rsaJSON rsaKeyResponse
|
||||
if err := json.Unmarshal([]byte(rsaResp), &rsaJSON); err != nil {
|
||||
return false, fmt.Errorf("getrsakey parse: %w", err)
|
||||
}
|
||||
if !rsaJSON.Success {
|
||||
return false, errors.New("steam: cannot fetch RSA key for user")
|
||||
}
|
||||
|
||||
encPw, err := steamRSAEncryptPassword(state.Password, rsaJSON.PublicKeyMod, rsaJSON.PublicKeyExp)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("rsa encrypt: %w", err)
|
||||
}
|
||||
|
||||
captchaID := state.CaptchaID
|
||||
if captchaID == "" {
|
||||
captchaID = "-1"
|
||||
}
|
||||
captchaText := state.CaptchaText
|
||||
if captchaText == "" {
|
||||
captchaText = "enter above characters"
|
||||
}
|
||||
emailSteamID := ""
|
||||
if state.EmailAuthText != "" {
|
||||
emailSteamID = state.SteamID
|
||||
}
|
||||
loginForm := url.Values{
|
||||
"password": {base64.StdEncoding.EncodeToString(encPw)},
|
||||
"username": {state.Username},
|
||||
"twofactorcode": {""},
|
||||
"emailauth": {state.EmailAuthText},
|
||||
"loginfriendlyname": {"#login_emailauth_friendlyname_mobile"},
|
||||
"captchagid": {captchaID},
|
||||
"captcha_text": {captchaText},
|
||||
"emailsteamid": {emailSteamID},
|
||||
"rsatimestamp": {rsaJSON.Timestamp},
|
||||
"remember_login": {"false"},
|
||||
"oauth_client_id": {steamOAuthClientID},
|
||||
"oauth_scope": {steamOAuthScope},
|
||||
"donotache": {strconv.FormatInt(time.Now().UnixMilli(), 10)},
|
||||
}
|
||||
loginResp, err := steamRequest(ctx, client, http.MethodPost,
|
||||
steamCommunityBase+"/mobilelogin/dologin/", loginForm, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("dologin: %w", err)
|
||||
}
|
||||
|
||||
// Wipe the plaintext password from memory now that it has been
|
||||
// RSA-encrypted and sent.
|
||||
state.Password = ""
|
||||
|
||||
var login map[string]any
|
||||
if err := json.Unmarshal([]byte(loginResp), &login); err != nil {
|
||||
return false, fmt.Errorf("dologin parse: %w", err)
|
||||
}
|
||||
|
||||
if v, ok := login["emailsteamid"].(string); ok {
|
||||
state.SteamID = v
|
||||
}
|
||||
|
||||
if b, _ := login["captcha_needed"].(bool); b {
|
||||
state.RequiresCaptcha = true
|
||||
if id, ok := login["captcha_gid"].(string); ok {
|
||||
state.CaptchaID = id
|
||||
state.CaptchaURL = steamCommunityBase + "/public/captcha.php?gid=" + id
|
||||
}
|
||||
} else {
|
||||
state.RequiresCaptcha = false
|
||||
state.CaptchaID = ""
|
||||
state.CaptchaURL = ""
|
||||
state.CaptchaText = ""
|
||||
}
|
||||
|
||||
if b, _ := login["emailauth_needed"].(bool); b {
|
||||
if d, ok := login["emaildomain"].(string); ok && d != "" {
|
||||
state.EmailDomain = d
|
||||
}
|
||||
state.RequiresEmailAuth = true
|
||||
} else {
|
||||
state.EmailDomain = ""
|
||||
state.RequiresEmailAuth = false
|
||||
}
|
||||
|
||||
if b, _ := login["requires_twofactor"].(bool); b {
|
||||
state.Requires2FA = true
|
||||
} else {
|
||||
state.Requires2FA = false
|
||||
}
|
||||
|
||||
complete, _ := login["login_complete"].(bool)
|
||||
oauthStr, _ := login["oauth"].(string)
|
||||
if !complete || oauthStr == "" {
|
||||
if oauthStr == "" {
|
||||
state.Error = "Invalid response from Steam (No OAuth token)"
|
||||
}
|
||||
if msg, ok := login["message"].(string); ok && msg != "" {
|
||||
state.Error = msg
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// oauth is a JSON-stringified inner object.
|
||||
var oauth struct {
|
||||
OAuthToken string `json:"oauth_token"`
|
||||
SteamID string `json:"steamid"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(oauthStr), &oauth); err != nil {
|
||||
return false, fmt.Errorf("oauth parse: %w", err)
|
||||
}
|
||||
state.OAuthToken = oauth.OAuthToken
|
||||
if oauth.SteamID != "" {
|
||||
state.SteamID = oauth.SteamID
|
||||
}
|
||||
}
|
||||
|
||||
// Logon to WebAPI (needed for the ITwoFactorService calls below).
|
||||
if _, err := steamRequest(ctx, client, http.MethodPost,
|
||||
steamWebAPIBase+"/ISteamWebUserPresenceOAuth/Logon/v0001",
|
||||
url.Values{"access_token": {state.OAuthToken}}, nil); err != nil {
|
||||
return false, fmt.Errorf("ISteamWebUserPresenceOAuth/Logon: %w", err)
|
||||
}
|
||||
|
||||
sessionID := steamJarSessionID(state.jar)
|
||||
|
||||
if !state.RequiresActivation {
|
||||
// Phone check. No phone → cannot enroll.
|
||||
phoneResp, err := steamRequest(ctx, client, http.MethodPost,
|
||||
steamCommunityBase+"/steamguard/phoneajax",
|
||||
url.Values{"op": {"has_phone"}, "arg": {"null"}, "sessionid": {sessionID}}, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("phoneajax: %w", err)
|
||||
}
|
||||
var phoneJSON struct {
|
||||
HasPhone bool `json:"has_phone"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(phoneResp), &phoneJSON); err != nil {
|
||||
return false, fmt.Errorf("phoneajax parse: %w", err)
|
||||
}
|
||||
if !phoneJSON.HasPhone {
|
||||
state.OAuthToken = ""
|
||||
state.RequiresLogin = true
|
||||
state.jar = nil
|
||||
state.Error = "Your Steam account must have a SMS-capable phone number attached. Go into Account Details of the Steam client or Steam website and click Add a Phone Number."
|
||||
return false, nil
|
||||
}
|
||||
|
||||
deviceID := buildRandomDeviceID()
|
||||
addResp, err := steamRequest(ctx, client, http.MethodPost,
|
||||
steamWebAPIBase+"/ITwoFactorService/AddAuthenticator/v0001",
|
||||
url.Values{
|
||||
"access_token": {state.OAuthToken},
|
||||
"steamid": {state.SteamID},
|
||||
"authenticator_type": {"1"},
|
||||
"device_identifier": {deviceID},
|
||||
"sms_phone_id": {"1"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("AddAuthenticator: %w", err)
|
||||
}
|
||||
|
||||
// The C# branch on response.status == 84 = "SMS send failed".
|
||||
var addJSON struct {
|
||||
Response struct {
|
||||
Status int `json:"status"`
|
||||
SharedSecret string `json:"shared_secret"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
RevocationCode string `json:"revocation_code"`
|
||||
ServerTime json.Number `json:"server_time"`
|
||||
Raw json.RawMessage `json:"-"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(addResp), &addJSON); err != nil {
|
||||
return false, fmt.Errorf("AddAuthenticator parse: %w", err)
|
||||
}
|
||||
if addJSON.Response.Status == 84 {
|
||||
state.OAuthToken = ""
|
||||
state.RequiresLogin = true
|
||||
state.jar = nil
|
||||
state.Error = "Unable to send SMS. Check your phone is registered on your Steam account."
|
||||
return false, nil
|
||||
}
|
||||
if addJSON.Response.SharedSecret == "" {
|
||||
state.OAuthToken = ""
|
||||
state.RequiresLogin = true
|
||||
state.jar = nil
|
||||
state.Error = "Invalid response from Steam"
|
||||
return false, nil
|
||||
}
|
||||
|
||||
secretRaw, err := base64.StdEncoding.DecodeString(addJSON.Response.SharedSecret)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("shared_secret decode: %w", err)
|
||||
}
|
||||
s.SecretKey = secretRaw
|
||||
s.Serial = addJSON.Response.SerialNumber
|
||||
s.DeviceID = deviceID
|
||||
state.RevocationCode = addJSON.Response.RevocationCode
|
||||
|
||||
// Re-parse the response.* sub-object so we can preserve every
|
||||
// field Steam returned and just inject steamid / steamguard_scheme
|
||||
// if missing, matching the C# behaviour.
|
||||
var envelope map[string]json.RawMessage
|
||||
_ = json.Unmarshal([]byte(addResp), &envelope)
|
||||
var steamDataMap map[string]any
|
||||
if raw, ok := envelope["response"]; ok {
|
||||
_ = json.Unmarshal(raw, &steamDataMap)
|
||||
}
|
||||
if steamDataMap == nil {
|
||||
steamDataMap = map[string]any{}
|
||||
}
|
||||
if _, ok := steamDataMap["steamid"]; !ok {
|
||||
steamDataMap["steamid"] = state.SteamID
|
||||
}
|
||||
if _, ok := steamDataMap["steamguard_scheme"]; !ok {
|
||||
steamDataMap["steamguard_scheme"] = "2"
|
||||
}
|
||||
steamDataBytes, _ := json.Marshal(steamDataMap)
|
||||
s.SteamData = string(steamDataBytes)
|
||||
|
||||
if addJSON.Response.ServerTime != "" {
|
||||
if sec, err := strconv.ParseInt(string(addJSON.Response.ServerTime), 10, 64); err == nil {
|
||||
s.ServerTimeDiff = sec*1000 - NowMillis()
|
||||
s.LastServerTime = NowMillis()
|
||||
}
|
||||
}
|
||||
|
||||
state.RequiresActivation = true
|
||||
logger.Info("authenticator added; awaiting SMS activation code")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Activation: try up to ENROLL_ACTIVATE_RETRIES times. Between
|
||||
// retries we slide ServerTimeDiff forward by one TOTP period to
|
||||
// align with whichever step Steam is expecting.
|
||||
retries := 0
|
||||
for state.RequiresActivation && retries < enrollActivateRetries {
|
||||
form := url.Values{
|
||||
"access_token": {state.OAuthToken},
|
||||
"steamid": {state.SteamID},
|
||||
"activation_code": {state.ActivationCode},
|
||||
"authenticator_code": {s.steamCode()},
|
||||
"authenticator_time": {strconv.FormatInt(s.ServerTime()/1000, 10)},
|
||||
}
|
||||
finResp, err := steamRequest(ctx, client, http.MethodPost,
|
||||
steamWebAPIBase+"/ITwoFactorService/FinalizeAddAuthenticator/v0001", form, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("FinalizeAddAuthenticator: %w", err)
|
||||
}
|
||||
var fin struct {
|
||||
Response struct {
|
||||
Status int `json:"status"`
|
||||
Success bool `json:"success"`
|
||||
WantMore bool `json:"want_more"`
|
||||
ServerTime json.Number `json:"server_time"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(finResp), &fin); err != nil {
|
||||
return false, fmt.Errorf("FinalizeAddAuthenticator parse: %w", err)
|
||||
}
|
||||
if fin.Response.Status == invalidActivationCode {
|
||||
state.Error = "Invalid activation code"
|
||||
return false, nil
|
||||
}
|
||||
if fin.Response.ServerTime != "" {
|
||||
if sec, err := strconv.ParseInt(string(fin.Response.ServerTime), 10, 64); err == nil {
|
||||
s.ServerTimeDiff = sec*1000 - NowMillis()
|
||||
s.LastServerTime = NowMillis()
|
||||
}
|
||||
}
|
||||
if fin.Response.Success {
|
||||
if fin.Response.WantMore {
|
||||
s.advanceServerTime()
|
||||
retries++
|
||||
continue
|
||||
}
|
||||
state.RequiresActivation = false
|
||||
break
|
||||
}
|
||||
s.advanceServerTime()
|
||||
retries++
|
||||
}
|
||||
if state.RequiresActivation {
|
||||
state.Error = "There was a problem activating. There might be an issue with the Steam servers. Please try again later."
|
||||
return false, nil
|
||||
}
|
||||
|
||||
state.Success = true
|
||||
state.SecretKey = strings.ToUpper(hex.EncodeToString(s.SecretKey))
|
||||
|
||||
// Best-effort confirmation email; failures are non-fatal because
|
||||
// the authenticator is already activated.
|
||||
_, _ = steamRequest(ctx, client, http.MethodPost,
|
||||
steamWebAPIBase+"/ITwoFactorService/SendEmail/v0001",
|
||||
url.Values{
|
||||
"access_token": {state.OAuthToken},
|
||||
"steamid": {state.SteamID},
|
||||
"email_type": {"2"},
|
||||
}, nil)
|
||||
|
||||
logger.WithField("serial", s.Serial).Info("steam authenticator enrolled")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// advanceServerTime nudges ServerTimeDiff forward by one TOTP period so
|
||||
// the next FinalizeAddAuthenticator call submits a code for the next
|
||||
// step. Matches the C# loop.
|
||||
func (s *SteamAuthenticator) advanceServerTime() {
|
||||
period := s.Period
|
||||
if period <= 0 {
|
||||
period = DefaultPeriod
|
||||
}
|
||||
s.ServerTimeDiff += int64(period) * 1000
|
||||
}
|
||||
|
||||
// steamRSAEncryptPassword RSA-encrypts the password using the hex
|
||||
// modulus / exponent returned by getrsakey. The original WinAuth uses
|
||||
// RSACryptoServiceProvider.Encrypt(_, false) which is PKCS#1 v1.5.
|
||||
func steamRSAEncryptPassword(password, hexMod, hexExp string) ([]byte, error) {
|
||||
modBytes, err := hex.DecodeString(hexMod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("modulus hex: %w", err)
|
||||
}
|
||||
expBytes, err := hex.DecodeString(hexExp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exponent hex: %w", err)
|
||||
}
|
||||
n := new(big.Int).SetBytes(modBytes)
|
||||
e := new(big.Int).SetBytes(expBytes)
|
||||
pub := &rsa.PublicKey{N: n, E: int(e.Int64())}
|
||||
// PKCS#1 v1.5 is required by the Steam mobile-login endpoint;
|
||||
// OAEP would be rejected. The deprecation warning is acknowledged.
|
||||
return rsa.EncryptPKCS1v15(nil, pub, []byte(password))
|
||||
}
|
||||
|
||||
// steamJarSessionID returns the "sessionid" cookie set by Steam on the
|
||||
// community domain, or "" if it has not been issued yet.
|
||||
func steamJarSessionID(jar http.CookieJar) string {
|
||||
u, _ := url.Parse(steamCommunityBase + "/")
|
||||
for _, c := range jar.Cookies(u) {
|
||||
if c.Name == "sessionid" {
|
||||
return c.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func steamJarHasSessionID(jar http.CookieJar) bool {
|
||||
return steamJarSessionID(jar) != ""
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
)
|
||||
|
||||
// Steam base URLs. Kept as vars (not consts) so tests can override.
|
||||
var (
|
||||
steamCommunityBase = "https://steamcommunity.com"
|
||||
steamWebAPIBase = "https://api.steampowered.com"
|
||||
)
|
||||
|
||||
// steamMobileUserAgent is the exact UA the original WinAuth uses, chosen
|
||||
// so that Steam's mobile login endpoint accepts the request as coming
|
||||
// from the official Android app. Changing this without testing tends to
|
||||
// trigger captcha or outright rejection.
|
||||
const steamMobileUserAgent = "Mozilla/5.0 (Linux; U; Android 4.1.1; en-us; Google Nexus 4 - 4.1.1 - API 16 - 768x1280 Build/JRO03S) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30"
|
||||
|
||||
// steamOAuthClientID / steamOAuthScope are the constants the mobile app
|
||||
// sends to /mobilelogin. Do not log them — they are not secret, but
|
||||
// keeping the redact list short reduces accidents.
|
||||
const (
|
||||
steamOAuthClientID = "DE45CD61"
|
||||
steamOAuthScope = "read_profile write_profile read_client write_client"
|
||||
)
|
||||
|
||||
// steamFormFieldsToRedact lists the form keys that must never appear in
|
||||
// logs. The HTTP layer prints request body at debug only after filtering
|
||||
// these out.
|
||||
var steamFormFieldsToRedact = map[string]struct{}{
|
||||
"password": {},
|
||||
"access_token": {},
|
||||
"oauth_token": {},
|
||||
"twofactorcode": {},
|
||||
"emailauth": {},
|
||||
"shared_secret": {},
|
||||
"identity_secret": {},
|
||||
"revocation_code": {},
|
||||
"authenticator_code": {},
|
||||
"activation_code": {},
|
||||
}
|
||||
|
||||
// newSteamCookieJar returns a cookie jar pre-loaded with the constant
|
||||
// cookies the original mobile login flow needs before its first request.
|
||||
func newSteamCookieJar() (http.CookieJar, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, _ := url.Parse(steamCommunityBase + "/")
|
||||
jar.SetCookies(u, []*http.Cookie{
|
||||
{Name: "mobileClientVersion", Value: "3067969+%282.1.3%29"},
|
||||
{Name: "mobileClient", Value: "android"},
|
||||
{Name: "steamid", Value: ""},
|
||||
{Name: "steamLogin", Value: ""},
|
||||
{Name: "Steam_Language", Value: "english"},
|
||||
{Name: "dob", Value: ""},
|
||||
})
|
||||
return jar, nil
|
||||
}
|
||||
|
||||
// steamHTTPClient returns a fresh http.Client that uses the given jar
|
||||
// and a 30s timeout. We intentionally do NOT reuse internal/httpc here:
|
||||
// httpc dumps full request bodies at Trace level, which would leak the
|
||||
// password / OAuth token form fields used by the Steam endpoints. This
|
||||
// client instead logs only method / URL / status at debug level.
|
||||
func steamHTTPClient(jar http.CookieJar) *http.Client {
|
||||
return &http.Client{
|
||||
Jar: jar,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// steamRequest performs a Steam mobile / WebAPI request. method is GET or
|
||||
// POST. For GET the form values are appended to the URL; for POST they
|
||||
// are sent as application/x-www-form-urlencoded.
|
||||
//
|
||||
// The returned body is the full response payload as a string. headers
|
||||
// are added before the call. extraCookies are sent as the explicit
|
||||
// Cookie header on top of whatever the jar already supplies — most
|
||||
// callers can pass nil.
|
||||
//
|
||||
// IMPORTANT: passwords/tokens MUST be in `form` and never in the URL.
|
||||
// The debug log only records method/URL/status; form keys listed in
|
||||
// steamFormFieldsToRedact are filtered out of the (debug-level) body
|
||||
// dump.
|
||||
func steamRequest(
|
||||
ctx context.Context, client *http.Client,
|
||||
method, rawURL string, form url.Values, headers http.Header,
|
||||
) (string, error) {
|
||||
const fn = "internal.authenticator.steamRequest"
|
||||
logger := global.Log.WithField("func", fn).
|
||||
WithField("method", method).
|
||||
WithField("url", rawURL)
|
||||
|
||||
method = strings.ToUpper(method)
|
||||
body := ""
|
||||
if form != nil {
|
||||
body = form.Encode()
|
||||
}
|
||||
|
||||
finalURL := rawURL
|
||||
var reqBody io.Reader
|
||||
if method == http.MethodGet {
|
||||
if body != "" {
|
||||
if strings.Contains(finalURL, "?") {
|
||||
finalURL += "&" + body
|
||||
} else {
|
||||
finalURL += "?" + body
|
||||
}
|
||||
}
|
||||
} else {
|
||||
reqBody = strings.NewReader(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, finalURL, reqBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", steamMobileUserAgent)
|
||||
req.Header.Set("Accept", "text/javascript, text/html, application/xml, text/xml, */*")
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
req.Header.Set("Referer", steamCommunityBase)
|
||||
if method == http.MethodPost {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
}
|
||||
for k, vs := range headers {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
if global.Log != nil {
|
||||
logger.WithField("form", redactFormForLog(form)).Debug("steam request")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.WithError(err).Warn("steam request failed")
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
logger = logger.WithField("status", resp.StatusCode)
|
||||
|
||||
var reader io.Reader = resp.Body
|
||||
if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") {
|
||||
gz, gerr := gzip.NewReader(resp.Body)
|
||||
if gerr != nil {
|
||||
return "", gerr
|
||||
}
|
||||
defer gz.Close()
|
||||
reader = gz
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
logger.Warn("steam request unauthorised")
|
||||
return string(raw), errSteamUnauthorised
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Warn("steam request non-200")
|
||||
return string(raw), fmt.Errorf("steam: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
logger.Debug("steam response ok")
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// errSteamUnauthorised maps the C# UnauthorisedRequestException — a 403
|
||||
// from /steamguard or related endpoints typically means Family View has
|
||||
// disabled community content.
|
||||
var errSteamUnauthorised = fmt.Errorf("steam: unauthorised (403)")
|
||||
|
||||
// redactFormForLog produces a copy of form with sensitive values
|
||||
// replaced by "<redacted>". Returns "" for nil/empty input.
|
||||
func redactFormForLog(form url.Values) string {
|
||||
if len(form) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(form))
|
||||
for k, vs := range form {
|
||||
if _, sensitive := steamFormFieldsToRedact[k]; sensitive {
|
||||
parts = append(parts, k+"=<redacted>")
|
||||
continue
|
||||
}
|
||||
for _, v := range vs {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// buildRandomDeviceID returns "android:" followed by a freshly generated
|
||||
// UUID v4. Matches the C# SteamAuthenticator.BuildRandomId helper.
|
||||
func buildRandomDeviceID() string {
|
||||
var u [16]byte
|
||||
if _, err := rand.Read(u[:]); err != nil {
|
||||
// crypto/rand failing is fatal; fall back to a time-derived
|
||||
// value so the caller does not crash, but log it loudly.
|
||||
now := time.Now().UnixNano()
|
||||
for i := 0; i < 16; i++ {
|
||||
u[i] = byte(now >> (i % 8 * 8))
|
||||
}
|
||||
}
|
||||
u[6] = (u[6] & 0x0F) | 0x40 // version 4
|
||||
u[8] = (u[8] & 0x3F) | 0x80 // variant RFC 4122
|
||||
hexb := func(b byte) (byte, byte) {
|
||||
const hexd = "0123456789abcdef"
|
||||
return hexd[b>>4], hexd[b&0x0F]
|
||||
}
|
||||
out := make([]byte, 0, 8+36)
|
||||
out = append(out, "android:"...)
|
||||
for i, b := range u {
|
||||
if i == 4 || i == 6 || i == 8 || i == 10 {
|
||||
out = append(out, '-')
|
||||
}
|
||||
hi, lo := hexb(b)
|
||||
out = append(out, hi, lo)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// stripNonASCII removes any non-ASCII code points from s. Steam's
|
||||
// login endpoint silently drops these from username/password, so the
|
||||
// client side must do the same to keep RSA-encrypted bytes consistent.
|
||||
func stripNonASCII(s string) string {
|
||||
b := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c < 0x80 {
|
||||
b = append(b, c)
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
)
|
||||
|
||||
// confirmationEventDelay is the base sleep between firing successive
|
||||
// OnConfirmation callbacks. Matches the C# CONFIRMATION_EVENT_DELAY:
|
||||
// the per-event sleep is uniformly randomised to 100%-150% of this
|
||||
// value, throttling the UI when several new trades arrive at once.
|
||||
const confirmationEventDelay = 1000 * time.Millisecond
|
||||
|
||||
// defaultConfirmationPollerRetries is the number of consecutive failed
|
||||
// poll cycles before OnConfirmationError fires. Mirrors the C# default.
|
||||
const defaultConfirmationPollerRetries = 3
|
||||
|
||||
// ConfirmationCallback receives one notification per newly observed
|
||||
// pending confirmation. action tells the UI whether the user wanted a
|
||||
// passive notification, an interactive prompt, or silent auto-accept.
|
||||
type ConfirmationCallback func(conf Confirmation, action PollerAction)
|
||||
|
||||
// ConfirmationErrorCallback is fired once per failure burst (every
|
||||
// ConfirmationPollerRetries consecutive failures), letting the UI
|
||||
// surface "Steam unreachable" once instead of on every cycle.
|
||||
type ConfirmationErrorCallback func(message string, action PollerAction, err error)
|
||||
|
||||
// pollerHandle tracks a running background poller so it can be stopped
|
||||
// cleanly. Kept private; SteamClient exposes Start/Stop wrappers.
|
||||
type pollerHandle struct {
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// StartConfirmationPoller starts (or restarts) the background goroutine
|
||||
// that periodically calls GetConfirmations and fires OnConfirmation /
|
||||
// OnConfirmationError. Passing a nil or zero-Duration poller stops any
|
||||
// running poller and returns.
|
||||
//
|
||||
// It is safe to call StartConfirmationPoller repeatedly — the previous
|
||||
// poller is stopped (and its goroutine joined) before the new one is
|
||||
// started.
|
||||
func (c *SteamClient) StartConfirmationPoller(poller *ConfirmationPoller) {
|
||||
c.StopConfirmationPoller()
|
||||
|
||||
if poller == nil || poller.Duration <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.Session == nil {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.Session.Confirmations = poller
|
||||
if c.ConfirmationPollerRetries <= 0 {
|
||||
c.ConfirmationPollerRetries = defaultConfirmationPollerRetries
|
||||
}
|
||||
retries := c.ConfirmationPollerRetries
|
||||
c.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h := &pollerHandle{cancel: cancel, done: make(chan struct{})}
|
||||
|
||||
c.mu.Lock()
|
||||
c.poller = h
|
||||
c.mu.Unlock()
|
||||
|
||||
go c.runPollerLoop(ctx, h, retries)
|
||||
}
|
||||
|
||||
// StopConfirmationPoller cancels the running poller (if any) and waits
|
||||
// for its goroutine to exit before returning. Also clears
|
||||
// Session.Confirmations so a restored session does not auto-restart.
|
||||
func (c *SteamClient) StopConfirmationPoller() {
|
||||
c.mu.Lock()
|
||||
h := c.poller
|
||||
c.poller = nil
|
||||
if c.Session != nil {
|
||||
c.Session.Confirmations = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.cancel()
|
||||
<-h.done
|
||||
}
|
||||
|
||||
// runPollerLoop is the goroutine body. It owns no locks across network
|
||||
// calls. Snapshots of the poller config / retry budget are taken once
|
||||
// per iteration to avoid races with concurrent Stop / Start callers.
|
||||
func (c *SteamClient) runPollerLoop(ctx context.Context, h *pollerHandle, maxRetries int) {
|
||||
const fn = "internal.authenticator.SteamClient.runPollerLoop"
|
||||
logger := global.Log.WithField("func", fn)
|
||||
defer close(h.done)
|
||||
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
retryCount := 0
|
||||
|
||||
for ctx.Err() == nil {
|
||||
c.mu.Lock()
|
||||
poller := c.Session.Confirmations
|
||||
onConf := c.OnConfirmation
|
||||
onErr := c.OnConfirmationError
|
||||
c.mu.Unlock()
|
||||
if poller == nil {
|
||||
logger.Debug("poller cleared; exiting loop")
|
||||
return
|
||||
}
|
||||
action := poller.Action
|
||||
|
||||
confs, err := c.GetConfirmations(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
retryCount++
|
||||
logger.WithError(err).WithField("retry", retryCount).Warn("poll failed")
|
||||
if retryCount >= maxRetries {
|
||||
if onErr != nil {
|
||||
onErr("Failed to read confirmations", action, err)
|
||||
}
|
||||
} else {
|
||||
// Best-effort cookie refresh — matches the C# fallback.
|
||||
_, _ = c.Refresh(ctx)
|
||||
}
|
||||
} else {
|
||||
retryCount = 0
|
||||
if onConf != nil {
|
||||
for i := range confs {
|
||||
if !confs[i].IsNew {
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
onConf(confs[i], action)
|
||||
// Jitter the inter-event delay 100%-150% to keep
|
||||
// the UI from being slammed during a burst.
|
||||
delay := confirmationEventDelay + time.Duration(rng.Int63n(int64(confirmationEventDelay/2)))
|
||||
elapsed := time.Since(start)
|
||||
if delay > elapsed {
|
||||
if !sleepWithCancel(ctx, delay-elapsed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read duration in case the user changed it mid-flight.
|
||||
c.mu.Lock()
|
||||
var wait time.Duration
|
||||
if c.Session != nil && c.Session.Confirmations != nil {
|
||||
wait = time.Duration(c.Session.Confirmations.Duration) * time.Minute
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if wait <= 0 {
|
||||
return
|
||||
}
|
||||
if !sleepWithCancel(ctx, wait) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sleepWithCancel sleeps for d, or returns early if ctx is cancelled.
|
||||
// Returns true if the full duration elapsed, false on cancellation.
|
||||
func sleepWithCancel(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package authenticator
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PollerAction mirrors the C# WinAuthenticator.SteamClient.PollerAction
|
||||
// enum. The integer values are persisted in SteamSession JSON, so they
|
||||
// MUST NOT be renumbered.
|
||||
type PollerAction int
|
||||
|
||||
const (
|
||||
PollerActionNone PollerAction = 0
|
||||
PollerActionNotify PollerAction = 1
|
||||
PollerActionAutoConfirm PollerAction = 2
|
||||
PollerActionSilentAutoConfirm PollerAction = 3
|
||||
)
|
||||
|
||||
// ConfirmationPoller is the background-poll configuration persisted
|
||||
// inside a SteamSession. A Duration of 0 means the poller is disabled
|
||||
// and the whole object serializes as the literal "null".
|
||||
type ConfirmationPoller struct {
|
||||
Duration int `json:"duration"`
|
||||
Action PollerAction `json:"action"`
|
||||
Ids []string `json:"ids,omitempty"`
|
||||
}
|
||||
|
||||
// ToJSON returns the on-disk representation. Matches the C#
|
||||
// ConfirmationPoller.ToString output exactly so old WinAuth session
|
||||
// blobs round-trip.
|
||||
func (p *ConfirmationPoller) ToJSON() string {
|
||||
if p == nil || p.Duration == 0 {
|
||||
return "null"
|
||||
}
|
||||
b, _ := json.Marshal(p)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ParseConfirmationPoller restores a poller from its JSON form. Returns
|
||||
// nil for an empty / "null" / zero-duration payload, matching the C#
|
||||
// FromJSON contract.
|
||||
func ParseConfirmationPoller(s string) *ConfirmationPoller {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || s == "null" {
|
||||
return nil
|
||||
}
|
||||
var p ConfirmationPoller
|
||||
if err := json.Unmarshal([]byte(s), &p); err != nil {
|
||||
return nil
|
||||
}
|
||||
if p.Duration == 0 {
|
||||
return nil
|
||||
}
|
||||
return &p
|
||||
}
|
||||
|
||||
// Confirmation is a single in-flight trade/market confirmation as
|
||||
// returned by /mobileconf/conf. Runtime-only; not persisted.
|
||||
type Confirmation struct {
|
||||
Id string
|
||||
Key string
|
||||
Offline bool
|
||||
IsNew bool
|
||||
Image string
|
||||
Details string
|
||||
Traded string
|
||||
When string
|
||||
}
|
||||
|
||||
// SteamSession is the persistent half of a Steam mobile authenticator's
|
||||
// runtime state: the Steam ID, OAuth token, cookie jar, and the poller
|
||||
// config. UmqId / MessageId are intentionally NOT persisted (matches the
|
||||
// C# implementation, which comments them out of ToString).
|
||||
//
|
||||
// SECURITY: this struct holds OAuth tokens and login cookies. Callers
|
||||
// must never log it, embed it in error messages, or write it anywhere
|
||||
// other than the encrypted secretdata blob.
|
||||
type SteamSession struct {
|
||||
SteamId string
|
||||
OAuthToken string
|
||||
Cookies map[string]string
|
||||
UmqId string // runtime only, not serialized
|
||||
MessageId int // runtime only, not serialized
|
||||
Confirmations *ConfirmationPoller
|
||||
}
|
||||
|
||||
// NewSteamSession returns an empty session.
|
||||
func NewSteamSession() *SteamSession {
|
||||
return &SteamSession{Cookies: map[string]string{}}
|
||||
}
|
||||
|
||||
// ParseSteamSession decodes the JSON form written by ToJSON / by the
|
||||
// old C# client. An empty or invalid input yields an empty session
|
||||
// rather than an error, matching the C# constructor's behavior.
|
||||
func ParseSteamSession(s string) *SteamSession {
|
||||
sess := NewSteamSession()
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return sess
|
||||
}
|
||||
var raw struct {
|
||||
SteamId string `json:"steamid"`
|
||||
Cookies string `json:"cookies"`
|
||||
OAuthToken string `json:"oauthtoken"`
|
||||
Confs json.RawMessage `json:"confs"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), &raw); err != nil {
|
||||
return sess
|
||||
}
|
||||
sess.SteamId = raw.SteamId
|
||||
sess.OAuthToken = raw.OAuthToken
|
||||
sess.Cookies = parseCookieHeader(raw.Cookies)
|
||||
if len(raw.Confs) > 0 && string(raw.Confs) != "null" {
|
||||
sess.Confirmations = ParseConfirmationPoller(string(raw.Confs))
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
// ToJSON serializes the session in the exact format the C# WinAuth
|
||||
// client writes — cookies as a single "name=value; name=value" header
|
||||
// string, confs as either "null" or a poller object, and no umqid /
|
||||
// messageid keys.
|
||||
func (s *SteamSession) ToJSON() string {
|
||||
type out struct {
|
||||
SteamId string `json:"steamid"`
|
||||
Cookies string `json:"cookies"`
|
||||
OAuthToken string `json:"oauthtoken"`
|
||||
Confs json.RawMessage `json:"confs"`
|
||||
}
|
||||
o := out{
|
||||
SteamId: s.SteamId,
|
||||
Cookies: formatCookieHeader(s.Cookies),
|
||||
OAuthToken: s.OAuthToken,
|
||||
Confs: json.RawMessage(s.Confirmations.ToJSON()),
|
||||
}
|
||||
b, _ := json.Marshal(o)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// SessionFromEnrollState builds a SteamSession from a completed
|
||||
// enrollment, snapshotting the community-domain cookies out of the
|
||||
// EnrollState's internal jar. Used by the UI after Enroll returns
|
||||
// Success so the persisted SessionData carries a usable cookie set.
|
||||
func SessionFromEnrollState(state *EnrollState) *SteamSession {
|
||||
sess := NewSteamSession()
|
||||
if state == nil {
|
||||
return sess
|
||||
}
|
||||
sess.SteamId = state.SteamID
|
||||
sess.OAuthToken = state.OAuthToken
|
||||
if state.jar != nil {
|
||||
u, _ := url.Parse(steamCommunityBase + "/")
|
||||
for _, c := range state.jar.Cookies(u) {
|
||||
sess.Cookies[c.Name] = c.Value
|
||||
}
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
// Clear wipes mutable session state, keeping the SteamId. Matches the
|
||||
// C# SteamSession.Clear behavior used by Logout.
|
||||
func (s *SteamSession) Clear() {
|
||||
s.OAuthToken = ""
|
||||
s.UmqId = ""
|
||||
s.MessageId = 0
|
||||
s.Cookies = map[string]string{}
|
||||
s.Confirmations = nil
|
||||
}
|
||||
|
||||
var cookieHeaderRe = regexp.MustCompile(`([^=;]+)=([^;]*);?`)
|
||||
|
||||
// parseCookieHeader splits "name1=value1; name2=value2" into a map.
|
||||
// Whitespace around names/values is trimmed. Empty input returns an
|
||||
// empty (non-nil) map so callers can immediately .Set into it.
|
||||
func parseCookieHeader(h string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, m := range cookieHeaderRe.FindAllStringSubmatch(h, -1) {
|
||||
name := strings.TrimSpace(m[1])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
out[name] = strings.TrimSpace(m[2])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatCookieHeader is the inverse. Keys are emitted in sorted order
|
||||
// so the JSON output is stable across saves (helps diffs and tests).
|
||||
func formatCookieHeader(cookies map[string]string) string {
|
||||
if len(cookies) == 0 {
|
||||
return ""
|
||||
}
|
||||
names := make([]string, 0, len(cookies))
|
||||
for n := range cookies {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var b strings.Builder
|
||||
for i, n := range names {
|
||||
if i > 0 {
|
||||
b.WriteString("; ")
|
||||
}
|
||||
b.WriteString(n)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(cookies[n])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package authenticator implements RFC 4226 (HOTP) and RFC 6238 (TOTP)
|
||||
// authenticator algorithms, plus vendor-specific subclasses (Google,
|
||||
// Battle.Net, Microsoft, Okta, Steam, YubiKey-backed).
|
||||
//
|
||||
// This is a Go port of the C# Authenticator/HOTPAuthenticator classes from
|
||||
// the original WinAuth project.
|
||||
package authenticator
|
||||
|
||||
// HMACType selects the hash function used to derive the OTP. Matches the
|
||||
// HMACTypes enum from the original C# source.
|
||||
type HMACType int
|
||||
|
||||
const (
|
||||
HMACSHA1 HMACType = iota
|
||||
HMACSHA256
|
||||
HMACSHA512
|
||||
)
|
||||
|
||||
func (h HMACType) String() string {
|
||||
switch h {
|
||||
case HMACSHA256:
|
||||
return "SHA256"
|
||||
case HMACSHA512:
|
||||
return "SHA512"
|
||||
default:
|
||||
return "SHA1"
|
||||
}
|
||||
}
|
||||
|
||||
// Common defaults that mirror the original C# constants.
|
||||
const (
|
||||
DefaultCodeDigits = 6
|
||||
DefaultPeriod = 30
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 = "剪贴板中没有图片"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package logging
|
||||
|
||||
// AttachConsole is a no-op on non-Windows platforms.
|
||||
func AttachConsole() {}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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("")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user