commit c671f2115e3ef9a2d19ad5a7554887bcd57a66d8 Author: Daniel Wu Date: Fri Jun 12 03:10:37 2026 +0800 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 桩实现。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..547d4e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.idea +.claude +*.local.md +*.log +*.exe +*.exe~ +/dist/ +/build/ +/vendor/ diff --git a/assets/assets.go b/assets/assets.go new file mode 100644 index 0000000..39c606b --- /dev/null +++ b/assets/assets.go @@ -0,0 +1,47 @@ +// Package assets exposes the embedded PNG icon set under assets/icons. +// Icons are exposed as raw bytes; the UI layer wraps them with Gio's +// image decoder on demand. +package assets + +import ( + "embed" + "fmt" + "io/fs" + + "git.wxccs.org/iceking2nd/winauth-go/internal/global" +) + +//go:embed icons/*.png +var iconsFS embed.FS + +// Icon returns the bytes of a named icon (e.g. "GoogleIcon.png"). If the +// requested icon is missing, a Debug log is emitted and (nil, error) is +// returned so the caller can fall back to a placeholder. +func Icon(name string) ([]byte, error) { + const fn = "assets.Icon" + data, err := iconsFS.ReadFile("icons/" + name) + if err != nil { + global.Log.WithField("func", fn).WithField("name", name). + Debug("icon not found") + return nil, fmt.Errorf("assets: icon %q: %w", name, err) + } + return data, nil +} + +// IconNames lists every embedded icon. Useful for the "choose an icon" +// dialog in the UI. +func IconNames() []string { + const fn = "assets.IconNames" + entries, err := fs.ReadDir(iconsFS, "icons") + if err != nil { + global.Log.WithField("func", fn).WithError(err).Warn("read embedded icons") + return nil + } + out := make([]string, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + out = append(out, e.Name()) + } + } + return out +} diff --git a/assets/icons/AmazonAWSIcon.png b/assets/icons/AmazonAWSIcon.png new file mode 100644 index 0000000..ea9980a Binary files /dev/null and b/assets/icons/AmazonAWSIcon.png differ diff --git a/assets/icons/AmazonIcon.png b/assets/icons/AmazonIcon.png new file mode 100644 index 0000000..1ff92dc Binary files /dev/null and b/assets/icons/AmazonIcon.png differ diff --git a/assets/icons/AndroidIcon.png b/assets/icons/AndroidIcon.png new file mode 100644 index 0000000..f87bbf8 Binary files /dev/null and b/assets/icons/AndroidIcon.png differ diff --git a/assets/icons/AppleColorIcon.png b/assets/icons/AppleColorIcon.png new file mode 100644 index 0000000..a80facc Binary files /dev/null and b/assets/icons/AppleColorIcon.png differ diff --git a/assets/icons/AppleIcon.png b/assets/icons/AppleIcon.png new file mode 100644 index 0000000..6d93298 Binary files /dev/null and b/assets/icons/AppleIcon.png differ diff --git a/assets/icons/AppleWhiteIcon.png b/assets/icons/AppleWhiteIcon.png new file mode 100644 index 0000000..e1a684c Binary files /dev/null and b/assets/icons/AppleWhiteIcon.png differ diff --git a/assets/icons/ArcheAgeIcon.png b/assets/icons/ArcheAgeIcon.png new file mode 100644 index 0000000..ce67dd2 Binary files /dev/null and b/assets/icons/ArcheAgeIcon.png differ diff --git a/assets/icons/ArenaNetIcon.png b/assets/icons/ArenaNetIcon.png new file mode 100644 index 0000000..a620a81 Binary files /dev/null and b/assets/icons/ArenaNetIcon.png differ diff --git a/assets/icons/BattleNetAuthenticatorIcon.png b/assets/icons/BattleNetAuthenticatorIcon.png new file mode 100644 index 0000000..88eb0e0 Binary files /dev/null and b/assets/icons/BattleNetAuthenticatorIcon.png differ diff --git a/assets/icons/BitBucketIcon.png b/assets/icons/BitBucketIcon.png new file mode 100644 index 0000000..c07d9fb Binary files /dev/null and b/assets/icons/BitBucketIcon.png differ diff --git a/assets/icons/BitcoinEuroIcon.png b/assets/icons/BitcoinEuroIcon.png new file mode 100644 index 0000000..bf8c59d Binary files /dev/null and b/assets/icons/BitcoinEuroIcon.png differ diff --git a/assets/icons/BitcoinGoldIcon.png b/assets/icons/BitcoinGoldIcon.png new file mode 100644 index 0000000..57d43b2 Binary files /dev/null and b/assets/icons/BitcoinGoldIcon.png differ diff --git a/assets/icons/BitcoinIcon.png b/assets/icons/BitcoinIcon.png new file mode 100644 index 0000000..b3b5bb6 Binary files /dev/null and b/assets/icons/BitcoinIcon.png differ diff --git a/assets/icons/BluePixel.png b/assets/icons/BluePixel.png new file mode 100644 index 0000000..211fc35 Binary files /dev/null and b/assets/icons/BluePixel.png differ diff --git a/assets/icons/ChromeIcon.png b/assets/icons/ChromeIcon.png new file mode 100644 index 0000000..9c9f6f7 Binary files /dev/null and b/assets/icons/ChromeIcon.png differ diff --git a/assets/icons/DefianceIcon.png b/assets/icons/DefianceIcon.png new file mode 100644 index 0000000..8c20bcb Binary files /dev/null and b/assets/icons/DefianceIcon.png differ diff --git a/assets/icons/DiabloIcon.png b/assets/icons/DiabloIcon.png new file mode 100644 index 0000000..394e4fb Binary files /dev/null and b/assets/icons/DiabloIcon.png differ diff --git a/assets/icons/DigitalOceanIcon.png b/assets/icons/DigitalOceanIcon.png new file mode 100644 index 0000000..7c29084 Binary files /dev/null and b/assets/icons/DigitalOceanIcon.png differ diff --git a/assets/icons/DogeIcon.png b/assets/icons/DogeIcon.png new file mode 100644 index 0000000..b6b8ee0 Binary files /dev/null and b/assets/icons/DogeIcon.png differ diff --git a/assets/icons/DreamhostIcon.png b/assets/icons/DreamhostIcon.png new file mode 100644 index 0000000..7b70499 Binary files /dev/null and b/assets/icons/DreamhostIcon.png differ diff --git a/assets/icons/DropboxIcon.png b/assets/icons/DropboxIcon.png new file mode 100644 index 0000000..fb5e431 Binary files /dev/null and b/assets/icons/DropboxIcon.png differ diff --git a/assets/icons/DropboxWhiteIcon.png b/assets/icons/DropboxWhiteIcon.png new file mode 100644 index 0000000..2e1850e Binary files /dev/null and b/assets/icons/DropboxWhiteIcon.png differ diff --git a/assets/icons/EA2Icon.png b/assets/icons/EA2Icon.png new file mode 100644 index 0000000..913461a Binary files /dev/null and b/assets/icons/EA2Icon.png differ diff --git a/assets/icons/EA3Icon.png b/assets/icons/EA3Icon.png new file mode 100644 index 0000000..f4b9ce6 Binary files /dev/null and b/assets/icons/EA3Icon.png differ diff --git a/assets/icons/EAIcon.png b/assets/icons/EAIcon.png new file mode 100644 index 0000000..0e8a6bb Binary files /dev/null and b/assets/icons/EAIcon.png differ diff --git a/assets/icons/EvernoteIcon.png b/assets/icons/EvernoteIcon.png new file mode 100644 index 0000000..990e9cb Binary files /dev/null and b/assets/icons/EvernoteIcon.png differ diff --git a/assets/icons/FacebookIcon.png b/assets/icons/FacebookIcon.png new file mode 100644 index 0000000..d2fe5b7 Binary files /dev/null and b/assets/icons/FacebookIcon.png differ diff --git a/assets/icons/FirefallIcon.png b/assets/icons/FirefallIcon.png new file mode 100644 index 0000000..893c703 Binary files /dev/null and b/assets/icons/FirefallIcon.png differ diff --git a/assets/icons/FlickrIcon.png b/assets/icons/FlickrIcon.png new file mode 100644 index 0000000..313e007 Binary files /dev/null and b/assets/icons/FlickrIcon.png differ diff --git a/assets/icons/GMailIcon.png b/assets/icons/GMailIcon.png new file mode 100644 index 0000000..87cfb48 Binary files /dev/null and b/assets/icons/GMailIcon.png differ diff --git a/assets/icons/GitHub2Icon.png b/assets/icons/GitHub2Icon.png new file mode 100644 index 0000000..7e5e138 Binary files /dev/null and b/assets/icons/GitHub2Icon.png differ diff --git a/assets/icons/GitHubIcon.png b/assets/icons/GitHubIcon.png new file mode 100644 index 0000000..ab038f9 Binary files /dev/null and b/assets/icons/GitHubIcon.png differ diff --git a/assets/icons/GitIcon.png b/assets/icons/GitIcon.png new file mode 100644 index 0000000..d17417b Binary files /dev/null and b/assets/icons/GitIcon.png differ diff --git a/assets/icons/GitLabFox2Icon.png b/assets/icons/GitLabFox2Icon.png new file mode 100644 index 0000000..58d723a Binary files /dev/null and b/assets/icons/GitLabFox2Icon.png differ diff --git a/assets/icons/GitLabIcon.png b/assets/icons/GitLabIcon.png new file mode 100644 index 0000000..bb274f2 Binary files /dev/null and b/assets/icons/GitLabIcon.png differ diff --git a/assets/icons/Gitlab2Icon.png b/assets/icons/Gitlab2Icon.png new file mode 100644 index 0000000..0368451 Binary files /dev/null and b/assets/icons/Gitlab2Icon.png differ diff --git a/assets/icons/GitlabFoxIon.png b/assets/icons/GitlabFoxIon.png new file mode 100644 index 0000000..57992b0 Binary files /dev/null and b/assets/icons/GitlabFoxIon.png differ diff --git a/assets/icons/Google2Icon.png b/assets/icons/Google2Icon.png new file mode 100644 index 0000000..14de27f Binary files /dev/null and b/assets/icons/Google2Icon.png differ diff --git a/assets/icons/GoogleAuthenticator2Icon.png b/assets/icons/GoogleAuthenticator2Icon.png new file mode 100644 index 0000000..311a79b Binary files /dev/null and b/assets/icons/GoogleAuthenticator2Icon.png differ diff --git a/assets/icons/GoogleAuthenticatorIcon.png b/assets/icons/GoogleAuthenticatorIcon.png new file mode 100644 index 0000000..ca5ed4f Binary files /dev/null and b/assets/icons/GoogleAuthenticatorIcon.png differ diff --git a/assets/icons/GoogleIcon.png b/assets/icons/GoogleIcon.png new file mode 100644 index 0000000..c6826ee Binary files /dev/null and b/assets/icons/GoogleIcon.png differ diff --git a/assets/icons/IFTTT2Icon.png b/assets/icons/IFTTT2Icon.png new file mode 100644 index 0000000..107bfbd Binary files /dev/null and b/assets/icons/IFTTT2Icon.png differ diff --git a/assets/icons/IFTTT3Icon.png b/assets/icons/IFTTT3Icon.png new file mode 100644 index 0000000..c3ef073 Binary files /dev/null and b/assets/icons/IFTTT3Icon.png differ diff --git a/assets/icons/IFTTTIcon.png b/assets/icons/IFTTTIcon.png new file mode 100644 index 0000000..0a4fd34 Binary files /dev/null and b/assets/icons/IFTTTIcon.png differ diff --git a/assets/icons/InstagramIcon.png b/assets/icons/InstagramIcon.png new file mode 100644 index 0000000..cfc21c9 Binary files /dev/null and b/assets/icons/InstagramIcon.png differ diff --git a/assets/icons/ItchIcon.png b/assets/icons/ItchIcon.png new file mode 100644 index 0000000..3ef71f0 Binary files /dev/null and b/assets/icons/ItchIcon.png differ diff --git a/assets/icons/KickStarterIcon.png b/assets/icons/KickStarterIcon.png new file mode 100644 index 0000000..29b9568 Binary files /dev/null and b/assets/icons/KickStarterIcon.png differ diff --git a/assets/icons/LastPassIcon.png b/assets/icons/LastPassIcon.png new file mode 100644 index 0000000..fcbed3f Binary files /dev/null and b/assets/icons/LastPassIcon.png differ diff --git a/assets/icons/LinkedinIcon.png b/assets/icons/LinkedinIcon.png new file mode 100644 index 0000000..4ada56f Binary files /dev/null and b/assets/icons/LinkedinIcon.png differ diff --git a/assets/icons/LitecoinIcon.png b/assets/icons/LitecoinIcon.png new file mode 100644 index 0000000..5765ce1 Binary files /dev/null and b/assets/icons/LitecoinIcon.png differ diff --git a/assets/icons/MacIcon.png b/assets/icons/MacIcon.png new file mode 100644 index 0000000..e1ab6a6 Binary files /dev/null and b/assets/icons/MacIcon.png differ diff --git a/assets/icons/MicrosoftAuthenticatorIcon.png b/assets/icons/MicrosoftAuthenticatorIcon.png new file mode 100644 index 0000000..c6b139c Binary files /dev/null and b/assets/icons/MicrosoftAuthenticatorIcon.png differ diff --git a/assets/icons/NameIcon.png b/assets/icons/NameIcon.png new file mode 100644 index 0000000..baafb15 Binary files /dev/null and b/assets/icons/NameIcon.png differ diff --git a/assets/icons/OktaVerifyAuthenticatorIcon.png b/assets/icons/OktaVerifyAuthenticatorIcon.png new file mode 100644 index 0000000..980cdeb Binary files /dev/null and b/assets/icons/OktaVerifyAuthenticatorIcon.png differ diff --git a/assets/icons/OptionsIcon.png b/assets/icons/OptionsIcon.png new file mode 100644 index 0000000..cb5866c Binary files /dev/null and b/assets/icons/OptionsIcon.png differ diff --git a/assets/icons/OriginIcon.png b/assets/icons/OriginIcon.png new file mode 100644 index 0000000..51efcfd Binary files /dev/null and b/assets/icons/OriginIcon.png differ diff --git a/assets/icons/PayPalIcon.png b/assets/icons/PayPalIcon.png new file mode 100644 index 0000000..ade324b Binary files /dev/null and b/assets/icons/PayPalIcon.png differ diff --git a/assets/icons/RefreshIcon.png b/assets/icons/RefreshIcon.png new file mode 100644 index 0000000..1ba04d4 Binary files /dev/null and b/assets/icons/RefreshIcon.png differ diff --git a/assets/icons/RefreshIconWithLock.png b/assets/icons/RefreshIconWithLock.png new file mode 100644 index 0000000..322bf64 Binary files /dev/null and b/assets/icons/RefreshIconWithLock.png differ diff --git a/assets/icons/RiftIcon.png b/assets/icons/RiftIcon.png new file mode 100644 index 0000000..3e51d67 Binary files /dev/null and b/assets/icons/RiftIcon.png differ diff --git a/assets/icons/RuneScapeIcon.png b/assets/icons/RuneScapeIcon.png new file mode 100644 index 0000000..ec48a44 Binary files /dev/null and b/assets/icons/RuneScapeIcon.png differ diff --git a/assets/icons/SteamAuthenticatorIcon.png b/assets/icons/SteamAuthenticatorIcon.png new file mode 100644 index 0000000..d8ecb4c Binary files /dev/null and b/assets/icons/SteamAuthenticatorIcon.png differ diff --git a/assets/icons/SteamIcon.png b/assets/icons/SteamIcon.png new file mode 100644 index 0000000..0d6d4b1 Binary files /dev/null and b/assets/icons/SteamIcon.png differ diff --git a/assets/icons/Swtor.png b/assets/icons/Swtor.png new file mode 100644 index 0000000..d31781e Binary files /dev/null and b/assets/icons/Swtor.png differ diff --git a/assets/icons/SwtorEmpire.png b/assets/icons/SwtorEmpire.png new file mode 100644 index 0000000..8c30df7 Binary files /dev/null and b/assets/icons/SwtorEmpire.png differ diff --git a/assets/icons/SwtorRepublic.png b/assets/icons/SwtorRepublic.png new file mode 100644 index 0000000..d2eef00 Binary files /dev/null and b/assets/icons/SwtorRepublic.png differ diff --git a/assets/icons/TeamviewerIcon.png b/assets/icons/TeamviewerIcon.png new file mode 100644 index 0000000..5b0aefe Binary files /dev/null and b/assets/icons/TeamviewerIcon.png differ diff --git a/assets/icons/TextIcon.png b/assets/icons/TextIcon.png new file mode 100644 index 0000000..211e24c Binary files /dev/null and b/assets/icons/TextIcon.png differ diff --git a/assets/icons/Tumblr2Icon.png b/assets/icons/Tumblr2Icon.png new file mode 100644 index 0000000..ab13991 Binary files /dev/null and b/assets/icons/Tumblr2Icon.png differ diff --git a/assets/icons/Tumblr3Icon.png b/assets/icons/Tumblr3Icon.png new file mode 100644 index 0000000..9b479f4 Binary files /dev/null and b/assets/icons/Tumblr3Icon.png differ diff --git a/assets/icons/TumblrIcon.png b/assets/icons/TumblrIcon.png new file mode 100644 index 0000000..bdff13d Binary files /dev/null and b/assets/icons/TumblrIcon.png differ diff --git a/assets/icons/TwitterIcon.png b/assets/icons/TwitterIcon.png new file mode 100644 index 0000000..9689fe2 Binary files /dev/null and b/assets/icons/TwitterIcon.png differ diff --git a/assets/icons/WarcraftIcon.png b/assets/icons/WarcraftIcon.png new file mode 100644 index 0000000..20495bc Binary files /dev/null and b/assets/icons/WarcraftIcon.png differ diff --git a/assets/icons/WildstarIcon.png b/assets/icons/WildstarIcon.png new file mode 100644 index 0000000..87210d3 Binary files /dev/null and b/assets/icons/WildstarIcon.png differ diff --git a/assets/icons/WinAuth2Icon.png b/assets/icons/WinAuth2Icon.png new file mode 100644 index 0000000..9e7ee6e Binary files /dev/null and b/assets/icons/WinAuth2Icon.png differ diff --git a/assets/icons/WinAuthIcon.png b/assets/icons/WinAuthIcon.png new file mode 100644 index 0000000..565c77d Binary files /dev/null and b/assets/icons/WinAuthIcon.png differ diff --git a/assets/icons/Windows7Icon.png b/assets/icons/Windows7Icon.png new file mode 100644 index 0000000..4caad7c Binary files /dev/null and b/assets/icons/Windows7Icon.png differ diff --git a/assets/icons/Windows8Icon.png b/assets/icons/Windows8Icon.png new file mode 100644 index 0000000..6aea209 Binary files /dev/null and b/assets/icons/Windows8Icon.png differ diff --git a/assets/icons/WindowsPhoneIcon.png b/assets/icons/WindowsPhoneIcon.png new file mode 100644 index 0000000..0cc2262 Binary files /dev/null and b/assets/icons/WindowsPhoneIcon.png differ diff --git a/assets/icons/WordpressIcon.png b/assets/icons/WordpressIcon.png new file mode 100644 index 0000000..3235448 Binary files /dev/null and b/assets/icons/WordpressIcon.png differ diff --git a/assets/icons/WordpressWhiteIcon.png b/assets/icons/WordpressWhiteIcon.png new file mode 100644 index 0000000..1a556fa Binary files /dev/null and b/assets/icons/WordpressWhiteIcon.png differ diff --git a/assets/icons/YahooIcon.png b/assets/icons/YahooIcon.png new file mode 100644 index 0000000..0149728 Binary files /dev/null and b/assets/icons/YahooIcon.png differ diff --git a/assets/icons/battlenet.png b/assets/icons/battlenet.png new file mode 100644 index 0000000..bb42e38 Binary files /dev/null and b/assets/icons/battlenet.png differ diff --git a/assets/icons/eBayIcon.png b/assets/icons/eBayIcon.png new file mode 100644 index 0000000..1186ef2 Binary files /dev/null and b/assets/icons/eBayIcon.png differ diff --git a/assets/icons/padlock-closed.png b/assets/icons/padlock-closed.png new file mode 100644 index 0000000..8106495 Binary files /dev/null and b/assets/icons/padlock-closed.png differ diff --git a/assets/icons/padlock-open.png b/assets/icons/padlock-open.png new file mode 100644 index 0000000..45798e4 Binary files /dev/null and b/assets/icons/padlock-open.png differ diff --git a/cmd/winauth/main.go b/cmd/winauth/main.go new file mode 100644 index 0000000..acc32ac --- /dev/null +++ b/cmd/winauth/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "git.wxccs.org/iceking2nd/winauth-go/internal/global" + "git.wxccs.org/iceking2nd/winauth-go/internal/i18n" + "git.wxccs.org/iceking2nd/winauth-go/internal/logging" + "git.wxccs.org/iceking2nd/winauth-go/internal/ui" + "git.wxccs.org/iceking2nd/winauth-go/internal/win32" +) + +const ( + // singleInstanceMutex is the name used by AcquireInstanceLock. The + // "Global\" prefix would make the lock cross-session; without it + // each Windows user sees their own copy, which is what we want + // (config.yaml is per-user anyway). + singleInstanceMutex = "winauth-go-single-instance-mutex" + // windowTitle is what AcquireInstanceLock's duplicate path tries to + // foreground. Must stay in sync with ui.Run's app.Title option. + windowTitle = "WinAuth" +) + +func main() { + var ( + console bool + logLevel string + logFile string + configPath string + ) + + root := &cobra.Command{ + Use: "winauth-go", + Short: "WinAuth (Go port) — TOTP/HOTP authenticator", + RunE: func(cmd *cobra.Command, args []string) error { + if console { + logging.AttachConsole() + } + + closer, err := logging.Init(logging.Options{ + Level: logLevel, + File: logFile, + Console: console, + }) + if err != nil { + return err + } + if closer != nil { + defer func() { _ = closer.Close() }() + } + + const fn = "cmd.winauth.main" + global.Log.WithField("func", fn).Info("winauth-go starting") + + release, alreadyRunning, err := win32.AcquireInstanceLock(singleInstanceMutex) + if err != nil { + global.Log.WithField("func", fn).WithError(err).Warn("instance lock failed; continuing without it") + } + if alreadyRunning { + global.Log.WithField("func", fn).Info("another instance already running; activating it") + if err := win32.ActivateOtherInstance(windowTitle); err != nil { + global.Log.WithField("func", fn).WithError(err).Warn("could not activate existing window") + } + return nil + } + if release != nil { + defer release() + } + + if err := i18n.Init(""); err != nil { + global.Log.WithField("func", fn).WithError(err).Warn("i18n init failed; falling back to keys") + } + + return ui.Run(configPath) + }, + } + + root.Flags().BoolVar(&console, "console", false, "Show console window (Windows GUI builds)") + root.Flags().StringVar(&logLevel, "log-level", "info", + "Log level: panic|fatal|error|warn|info|debug|trace (or 0..6)") + root.Flags().StringVar(&logFile, "log-file", "", + "If set, also write logs to this file in addition to the console") + root.Flags().StringVar(&configPath, "config", "", + "Path to the YAML config file (default: %APPDATA%\\winauth-go\\config.yaml or $XDG_CONFIG_HOME/winauth-go/config.yaml)") + + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2c3e313 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module git.wxccs.org/iceking2nd/winauth-go + +go 1.23 + +require ( + gioui.org v0.7.1 + github.com/BurntSushi/toml v1.4.0 + github.com/nicksnyder/go-i18n/v2 v2.4.0 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.8.1 + golang.org/x/crypto v0.28.0 + golang.org/x/text v0.19.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect + gioui.org/shader v1.0.8 // indirect + github.com/go-text/typesetting v0.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/makiuchi-d/gozxing v0.1.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect + golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect + golang.org/x/image v0.18.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e76a32d --- /dev/null +++ b/go.sum @@ -0,0 +1,59 @@ +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= +gioui.org v0.7.1 h1:l7OVj47n1z8acaszQ6Wlu+Rxme+HqF3q8b+Fs68+x3w= +gioui.org v0.7.1/go.mod h1:5Kw/q7R1BWc5MKStuTNvhCgSrRqbfHc9Dzfjs4IGgZo= +gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc= +gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= +gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-text/typesetting v0.1.1 h1:bGAesCuo85nXnEN5LmFMVGAGpGkCPtHrZLi//qD7EJo= +github.com/go-text/typesetting v0.1.1/go.mod h1:d22AnmeKq/on0HNv73UFriMKc4Ez6EqZAofLhAzpSzI= +github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04 h1:zBx+p/W2aQYtNuyZNcTfinWvXBQwYtDfme051PR/lAY= +github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I= +github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU= +github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM= +github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= +golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU= +golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/authenticator/base.go b/internal/authenticator/base.go new file mode 100644 index 0000000..9540c0a --- /dev/null +++ b/internal/authenticator/base.go @@ -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() 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 +} diff --git a/internal/authenticator/base32.go b/internal/authenticator/base32.go new file mode 100644 index 0000000..ede8d25 --- /dev/null +++ b/internal/authenticator/base32.go @@ -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() +} diff --git a/internal/authenticator/battlenet.go b/internal/authenticator/battlenet.go new file mode 100644 index 0000000..79e517b --- /dev/null +++ b/internal/authenticator/battlenet.go @@ -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 "|". +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) +} diff --git a/internal/authenticator/battlenet_restore.go b/internal/authenticator/battlenet_restore.go new file mode 100644 index 0000000..0bbebf5 --- /dev/null +++ b/internal/authenticator/battlenet_restore.go @@ -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 → /enrollment/initiatePaperRestore.htm +// ← 32-byte challenge +// 2. HMAC-SHA1(key = restoreCode-decoded-10-bytes, +// data = serial-bytes || challenge) → 20-byte signature +// 3. POST → /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 +} diff --git a/internal/authenticator/battlenet_restore_test.go b/internal/authenticator/battlenet_restore_test.go new file mode 100644 index 0000000..be9d627 --- /dev/null +++ b/internal/authenticator/battlenet_restore_test.go @@ -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) + } + } +} diff --git a/internal/authenticator/google.go b/internal/authenticator/google.go new file mode 100644 index 0000000..3d7b5b7 --- /dev/null +++ b/internal/authenticator/google.go @@ -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 +} diff --git a/internal/authenticator/hotp.go b/internal/authenticator/hotp.go new file mode 100644 index 0000000..70cbbab --- /dev/null +++ b/internal/authenticator/hotp.go @@ -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) +} diff --git a/internal/authenticator/hotpauthenticator.go b/internal/authenticator/hotpauthenticator.go new file mode 100644 index 0000000..fa7b8e4 --- /dev/null +++ b/internal/authenticator/hotpauthenticator.go @@ -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 "|" 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 "|" 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 +} diff --git a/internal/authenticator/secretdata.go b/internal/authenticator/secretdata.go new file mode 100644 index 0000000..fe40eea --- /dev/null +++ b/internal/authenticator/secretdata.go @@ -0,0 +1,61 @@ +package authenticator + +import ( + "encoding/hex" + "fmt" + "strconv" + "strings" +) + +// EncodeSecretData encodes the "\t\t\t" string +// that the original WinAuth config uses inside . +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 +} diff --git a/internal/authenticator/steam.go b/internal/authenticator/steam.go new file mode 100644 index 0000000..653b22f --- /dev/null +++ b/internal/authenticator/steam.go @@ -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 "||||". +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 +} diff --git a/internal/authenticator/steam_client.go b/internal/authenticator/steam_client.go new file mode 100644 index 0000000..4f8465b --- /dev/null +++ b/internal/authenticator/steam_client.go @@ -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 + } +} diff --git a/internal/authenticator/steam_confirmations.go b/internal/authenticator/steam_confirmations.go new file mode 100644 index 0000000..d742fa4 --- /dev/null +++ b/internal/authenticator/steam_confirmations.go @@ -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".*?
([^<]*)
[^<]*
([^<]*)
[^<]*
([^<]*)
[^<]*`) + steamRegexConfDetails = regexp.MustCompile(`(?is)(.*]*>\s*).*(.*?\s*)`) +) + +// 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
+// 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 "

Cannot load trade confirmation details

" +} diff --git a/internal/authenticator/steam_enroll.go b/internal/authenticator/steam_enroll.go new file mode 100644 index 0000000..6743463 --- /dev/null +++ b/internal/authenticator/steam_enroll.go @@ -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) != "" +} diff --git a/internal/authenticator/steam_http.go b/internal/authenticator/steam_http.go new file mode 100644 index 0000000..f75a0ab --- /dev/null +++ b/internal/authenticator/steam_http.go @@ -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 "". 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+"=") + 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) +} diff --git a/internal/authenticator/steam_poller.go b/internal/authenticator/steam_poller.go new file mode 100644 index 0000000..570d999 --- /dev/null +++ b/internal/authenticator/steam_poller.go @@ -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 + } +} diff --git a/internal/authenticator/steam_session.go b/internal/authenticator/steam_session.go new file mode 100644 index 0000000..8f023c9 --- /dev/null +++ b/internal/authenticator/steam_session.go @@ -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() +} diff --git a/internal/authenticator/types.go b/internal/authenticator/types.go new file mode 100644 index 0000000..665e540 --- /dev/null +++ b/internal/authenticator/types.go @@ -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 +) diff --git a/internal/config/legacy_secretdata.go b/internal/config/legacy_secretdata.go new file mode 100644 index 0000000..db97922 --- /dev/null +++ b/internal/config/legacy_secretdata.go @@ -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 "\t...|" (modern C# form) or the + // legacy WinAuth2 short form "<40 hex secret>". + // Both are accepted by BattleNetAuthenticator.SetSecretData. + return secret, nil + case "steam": + // "\t...|serial|device|steamdata|session" — SetSecretData + // also tolerates fewer fields. + return secret, nil + default: + return secret, nil + } +} diff --git a/internal/config/legacy_xml.go b/internal/config/legacy_xml.go new file mode 100644 index 0000000..0838795 --- /dev/null +++ b/internal/config/legacy_xml.go @@ -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 ... +// 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" + } +} diff --git a/internal/config/legacy_xml_test.go b/internal/config/legacy_xml_test.go new file mode 100644 index 0000000..542ae48 --- /dev/null +++ b/internal/config/legacy_xml_test.go @@ -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 . + 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 = ` + + + + plain + + ABCDEF1234 6 SHA1 30 + 0 + + + +` + + 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 = ` + + + + locked + + 0102030405060708abcdef + + + +` + 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 +} diff --git a/internal/config/model.go b/internal/config/model.go new file mode 100644 index 0000000..c033722 --- /dev/null +++ b/internal/config/model.go @@ -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"` +} diff --git a/internal/config/path.go b/internal/config/path.go new file mode 100644 index 0000000..8d77da6 --- /dev/null +++ b/internal/config/path.go @@ -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) +} diff --git a/internal/config/yaml.go b/internal/config/yaml.go new file mode 100644 index 0000000..78c3a5d --- /dev/null +++ b/internal/config/yaml.go @@ -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 +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..d8a728a --- /dev/null +++ b/internal/crypto/crypto.go @@ -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) +} diff --git a/internal/crypto/dpapi.go b/internal/crypto/dpapi.go new file mode 100644 index 0000000..9d286d8 --- /dev/null +++ b/internal/crypto/dpapi.go @@ -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 +) diff --git a/internal/crypto/dpapi_other.go b/internal/crypto/dpapi_other.go new file mode 100644 index 0000000..560846b --- /dev/null +++ b/internal/crypto/dpapi_other.go @@ -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 +} diff --git a/internal/crypto/dpapi_windows.go b/internal/crypto/dpapi_windows.go new file mode 100644 index 0000000..3b5000f --- /dev/null +++ b/internal/crypto/dpapi_windows.go @@ -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 +} diff --git a/internal/crypto/legacy_blowfish.go b/internal/crypto/legacy_blowfish.go new file mode 100644 index 0000000..508dfe4 --- /dev/null +++ b/internal/crypto/legacy_blowfish.go @@ -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 +} diff --git a/internal/global/log.go b/internal/global/log.go new file mode 100644 index 0000000..18a5d2d --- /dev/null +++ b/internal/global/log.go @@ -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() diff --git a/internal/hotkey/hotkey.go b/internal/hotkey/hotkey.go new file mode 100644 index 0000000..979cb00 --- /dev/null +++ b/internal/hotkey/hotkey.go @@ -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) +} diff --git a/internal/hotkey/hotkey_test.go b/internal/hotkey/hotkey_test.go new file mode 100644 index 0000000..26d8b32 --- /dev/null +++ b/internal/hotkey/hotkey_test.go @@ -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) + } +} diff --git a/internal/httpc/transport.go b/internal/httpc/transport.go new file mode 100644 index 0000000..1f52982 --- /dev/null +++ b/internal/httpc/transport.go @@ -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 "" 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{""} + continue + } + out[k] = append([]string(nil), v...) + } + return out +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..5734a4d --- /dev/null +++ b/internal/i18n/i18n.go @@ -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") +} diff --git a/internal/i18n/locales/de.toml b/internal/i18n/locales/de.toml new file mode 100644 index 0000000..a9530a9 --- /dev/null +++ b/internal/i18n/locales/de.toml @@ -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" diff --git a/internal/i18n/locales/en.toml b/internal/i18n/locales/en.toml new file mode 100644 index 0000000..aea6eaa --- /dev/null +++ b/internal/i18n/locales/en.toml @@ -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" diff --git a/internal/i18n/locales/zh-CN.toml b/internal/i18n/locales/zh-CN.toml new file mode 100644 index 0000000..923b515 --- /dev/null +++ b/internal/i18n/locales/zh-CN.toml @@ -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 = "剪贴板中没有图片" diff --git a/internal/i18n/toml.go b/internal/i18n/toml.go new file mode 100644 index 0000000..8199651 --- /dev/null +++ b/internal/i18n/toml.go @@ -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) +} diff --git a/internal/logging/console_other.go b/internal/logging/console_other.go new file mode 100644 index 0000000..1aa47cb --- /dev/null +++ b/internal/logging/console_other.go @@ -0,0 +1,6 @@ +//go:build !windows + +package logging + +// AttachConsole is a no-op on non-Windows platforms. +func AttachConsole() {} diff --git a/internal/logging/console_windows.go b/internal/logging/console_windows.go new file mode 100644 index 0000000..07f06e9 --- /dev/null +++ b/internal/logging/console_windows.go @@ -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") +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..124c6cd --- /dev/null +++ b/internal/logging/logging.go @@ -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) +} diff --git a/internal/qr/qr.go b/internal/qr/qr.go new file mode 100644 index 0000000..4229e7e --- /dev/null +++ b/internal/qr/qr.go @@ -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 +} diff --git a/internal/qr/qr_test.go b/internal/qr/qr_test.go new file mode 100644 index 0000000..42f9f0e --- /dev/null +++ b/internal/qr/qr_test.go @@ -0,0 +1,50 @@ +package qr + +import "testing" + +func TestParseOtpAuth_TOTP(t *testing.T) { + uri := "otpauth://totp/Example:alice@example.com?" + + "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:alice@example.com" { + 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") + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..5d2f6ef --- /dev/null +++ b/internal/ui/app.go @@ -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) +} diff --git a/internal/ui/convert.go b/internal/ui/convert.go new file mode 100644 index 0000000..9711ed5 --- /dev/null +++ b/internal/ui/convert.go @@ -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 +} diff --git a/internal/ui/dialog.go b/internal/ui/dialog.go new file mode 100644 index 0000000..b1a51c1 --- /dev/null +++ b/internal/ui/dialog.go @@ -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), + ) + } +} diff --git a/internal/ui/dialog_battlenet.go b/internal/ui/dialog_battlenet.go new file mode 100644 index 0000000..be511f8 --- /dev/null +++ b/internal/ui/dialog_battlenet.go @@ -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) +} diff --git a/internal/ui/dialog_battlenet_restore.go b/internal/ui/dialog_battlenet_restore.go new file mode 100644 index 0000000..23889a7 --- /dev/null +++ b/internal/ui/dialog_battlenet_restore.go @@ -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) +} diff --git a/internal/ui/dialog_google.go b/internal/ui/dialog_google.go new file mode 100644 index 0000000..4ce8ca5 --- /dev/null +++ b/internal/ui/dialog_google.go @@ -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) +} diff --git a/internal/ui/dialog_hotkey.go b/internal/ui/dialog_hotkey.go new file mode 100644 index 0000000..5ac9ff0 --- /dev/null +++ b/internal/ui/dialog_hotkey.go @@ -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) +} diff --git a/internal/ui/dialog_hotp.go b/internal/ui/dialog_hotp.go new file mode 100644 index 0000000..c96713b --- /dev/null +++ b/internal/ui/dialog_hotp.go @@ -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) +} diff --git a/internal/ui/dialog_import_legacy.go b/internal/ui/dialog_import_legacy.go new file mode 100644 index 0000000..50225aa --- /dev/null +++ b/internal/ui/dialog_import_legacy.go @@ -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("") +} diff --git a/internal/ui/dialog_password.go b/internal/ui/dialog_password.go new file mode 100644 index 0000000..6e2ccc3 --- /dev/null +++ b/internal/ui/dialog_password.go @@ -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) +} diff --git a/internal/ui/dialog_scan_qr.go b/internal/ui/dialog_scan_qr.go new file mode 100644 index 0000000..4a643e1 --- /dev/null +++ b/internal/ui/dialog_scan_qr.go @@ -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" +} diff --git a/internal/ui/dialog_setpassword.go b/internal/ui/dialog_setpassword.go new file mode 100644 index 0000000..73b7725 --- /dev/null +++ b/internal/ui/dialog_setpassword.go @@ -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) +} diff --git a/internal/ui/dialog_steam.go b/internal/ui/dialog_steam.go new file mode 100644 index 0000000..8dc7e04 --- /dev/null +++ b/internal/ui/dialog_steam.go @@ -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 +} diff --git a/internal/ui/dialog_steam_trades.go b/internal/ui/dialog_steam_trades.go new file mode 100644 index 0000000..95097e7 --- /dev/null +++ b/internal/ui/dialog_steam_trades.go @@ -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} + } +} diff --git a/internal/ui/entry_actions.go b/internal/ui/entry_actions.go new file mode 100644 index 0000000..dcc87d7 --- /dev/null +++ b/internal/ui/entry_actions.go @@ -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} +} diff --git a/internal/ui/entry_ring.go b/internal/ui/entry_ring.go new file mode 100644 index 0000000..f0ab4ce --- /dev/null +++ b/internal/ui/entry_ring.go @@ -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 +} diff --git a/internal/ui/hotkey_runtime.go b/internal/ui/hotkey_runtime.go new file mode 100644 index 0000000..b60f5f8 --- /dev/null +++ b/internal/ui/hotkey_runtime.go @@ -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") + } + } +} diff --git a/internal/ui/progress_ring.go b/internal/ui/progress_ring.go new file mode 100644 index 0000000..e4d05d8 --- /dev/null +++ b/internal/ui/progress_ring.go @@ -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)), + )) + } +} diff --git a/internal/ui/settings_menu.go b/internal/ui/settings_menu.go new file mode 100644 index 0000000..ea4b604 --- /dev/null +++ b/internal/ui/settings_menu.go @@ -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), + ) + }), + ) + }) + }) + }) +} diff --git a/internal/ui/store.go b/internal/ui/store.go new file mode 100644 index 0000000..8f33f86 --- /dev/null +++ b/internal/ui/store.go @@ -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() + } +} diff --git a/internal/ui/toast.go b/internal/ui/toast.go new file mode 100644 index 0000000..f700f9d --- /dev/null +++ b/internal/ui/toast.go @@ -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() +} diff --git a/internal/ui/vendor_menu.go b/internal/ui/vendor_menu.go new file mode 100644 index 0000000..5415be1 --- /dev/null +++ b/internal/ui/vendor_menu.go @@ -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), + ) + }), + ) + }) + }) + }) +} diff --git a/internal/win32/clipboard_image_other.go b/internal/win32/clipboard_image_other.go new file mode 100644 index 0000000..7c14710 --- /dev/null +++ b/internal/win32/clipboard_image_other.go @@ -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 +} diff --git a/internal/win32/clipboard_image_windows.go b/internal/win32/clipboard_image_windows.go new file mode 100644 index 0000000..79b5a98 --- /dev/null +++ b/internal/win32/clipboard_image_windows.go @@ -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 +} diff --git a/internal/win32/doc.go b/internal/win32/doc.go new file mode 100644 index 0000000..490b3e1 --- /dev/null +++ b/internal/win32/doc.go @@ -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") diff --git a/internal/win32/hotkey.go b/internal/win32/hotkey.go new file mode 100644 index 0000000..4eef03d --- /dev/null +++ b/internal/win32/hotkey.go @@ -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 +} diff --git a/internal/win32/hotkey_other.go b/internal/win32/hotkey_other.go new file mode 100644 index 0000000..3a80936 --- /dev/null +++ b/internal/win32/hotkey_other.go @@ -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) } diff --git a/internal/win32/hotkey_windows.go b/internal/win32/hotkey_windows.go new file mode 100644 index 0000000..7f0db14 --- /dev/null +++ b/internal/win32/hotkey_windows.go @@ -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 +} diff --git a/internal/win32/input_other.go b/internal/win32/input_other.go new file mode 100644 index 0000000..13c57d4 --- /dev/null +++ b/internal/win32/input_other.go @@ -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 diff --git a/internal/win32/input_windows.go b/internal/win32/input_windows.go new file mode 100644 index 0000000..6224aaf --- /dev/null +++ b/internal/win32/input_windows.go @@ -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} +} diff --git a/internal/win32/instance_other.go b/internal/win32/instance_other.go new file mode 100644 index 0000000..3639018 --- /dev/null +++ b/internal/win32/instance_other.go @@ -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 } diff --git a/internal/win32/instance_windows.go b/internal/win32/instance_windows.go new file mode 100644 index 0000000..3f0f356 --- /dev/null +++ b/internal/win32/instance_windows.go @@ -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 +}