From c671f2115e3ef9a2d19ad5a7554887bcd57a66d8 Mon Sep 17 00:00:00 2001 From: Daniel Wu Date: Fri, 12 Jun 2026 03:10:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20WinAuth=20Go=20=E7=A7=BB?= =?UTF-8?q?=E6=A4=8D=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将原 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 桩实现。 --- .gitignore | 9 + assets/assets.go | 47 ++ assets/icons/AmazonAWSIcon.png | Bin 0 -> 1560 bytes assets/icons/AmazonIcon.png | Bin 0 -> 1064 bytes assets/icons/AndroidIcon.png | Bin 0 -> 1245 bytes assets/icons/AppleColorIcon.png | Bin 0 -> 2158 bytes assets/icons/AppleIcon.png | Bin 0 -> 1705 bytes assets/icons/AppleWhiteIcon.png | Bin 0 -> 973 bytes assets/icons/ArcheAgeIcon.png | Bin 0 -> 4877 bytes assets/icons/ArenaNetIcon.png | Bin 0 -> 2178 bytes assets/icons/BattleNetAuthenticatorIcon.png | Bin 0 -> 4616 bytes assets/icons/BitBucketIcon.png | Bin 0 -> 1988 bytes assets/icons/BitcoinEuroIcon.png | Bin 0 -> 6074 bytes assets/icons/BitcoinGoldIcon.png | Bin 0 -> 5759 bytes assets/icons/BitcoinIcon.png | Bin 0 -> 2361 bytes assets/icons/BluePixel.png | Bin 0 -> 157 bytes assets/icons/ChromeIcon.png | Bin 0 -> 2957 bytes assets/icons/DefianceIcon.png | Bin 0 -> 1226 bytes assets/icons/DiabloIcon.png | Bin 0 -> 4193 bytes assets/icons/DigitalOceanIcon.png | Bin 0 -> 1976 bytes assets/icons/DogeIcon.png | Bin 0 -> 2504 bytes assets/icons/DreamhostIcon.png | Bin 0 -> 2036 bytes assets/icons/DropboxIcon.png | Bin 0 -> 1837 bytes assets/icons/DropboxWhiteIcon.png | Bin 0 -> 2465 bytes assets/icons/EA2Icon.png | Bin 0 -> 1428 bytes assets/icons/EA3Icon.png | Bin 0 -> 1100 bytes assets/icons/EAIcon.png | Bin 0 -> 783 bytes assets/icons/EvernoteIcon.png | Bin 0 -> 1125 bytes assets/icons/FacebookIcon.png | Bin 0 -> 731 bytes assets/icons/FirefallIcon.png | Bin 0 -> 1963 bytes assets/icons/FlickrIcon.png | Bin 0 -> 913 bytes assets/icons/GMailIcon.png | Bin 0 -> 1690 bytes assets/icons/GitHub2Icon.png | Bin 0 -> 1854 bytes assets/icons/GitHubIcon.png | Bin 0 -> 1352 bytes assets/icons/GitIcon.png | Bin 0 -> 1491 bytes assets/icons/GitLabFox2Icon.png | Bin 0 -> 7414 bytes assets/icons/GitLabIcon.png | Bin 0 -> 1745 bytes assets/icons/Gitlab2Icon.png | Bin 0 -> 4587 bytes assets/icons/GitlabFoxIon.png | Bin 0 -> 3015 bytes assets/icons/Google2Icon.png | Bin 0 -> 1174 bytes assets/icons/GoogleAuthenticator2Icon.png | Bin 0 -> 2620 bytes assets/icons/GoogleAuthenticatorIcon.png | Bin 0 -> 4937 bytes assets/icons/GoogleIcon.png | Bin 0 -> 1883 bytes assets/icons/IFTTT2Icon.png | Bin 0 -> 326 bytes assets/icons/IFTTT3Icon.png | Bin 0 -> 342 bytes assets/icons/IFTTTIcon.png | Bin 0 -> 175 bytes assets/icons/InstagramIcon.png | Bin 0 -> 1285 bytes assets/icons/ItchIcon.png | Bin 0 -> 3411 bytes assets/icons/KickStarterIcon.png | Bin 0 -> 881 bytes assets/icons/LastPassIcon.png | Bin 0 -> 627 bytes assets/icons/LinkedinIcon.png | Bin 0 -> 1008 bytes assets/icons/LitecoinIcon.png | Bin 0 -> 3529 bytes assets/icons/MacIcon.png | Bin 0 -> 4373 bytes assets/icons/MicrosoftAuthenticatorIcon.png | Bin 0 -> 2768 bytes assets/icons/NameIcon.png | Bin 0 -> 977 bytes assets/icons/OktaVerifyAuthenticatorIcon.png | Bin 0 -> 1039 bytes assets/icons/OptionsIcon.png | Bin 0 -> 861 bytes assets/icons/OriginIcon.png | Bin 0 -> 3393 bytes assets/icons/PayPalIcon.png | Bin 0 -> 1926 bytes assets/icons/RefreshIcon.png | Bin 0 -> 2021 bytes assets/icons/RefreshIconWithLock.png | Bin 0 -> 2417 bytes assets/icons/RiftIcon.png | Bin 0 -> 3871 bytes assets/icons/RuneScapeIcon.png | Bin 0 -> 2877 bytes assets/icons/SteamAuthenticatorIcon.png | Bin 0 -> 1742 bytes assets/icons/SteamIcon.png | Bin 0 -> 3896 bytes assets/icons/Swtor.png | Bin 0 -> 6370 bytes assets/icons/SwtorEmpire.png | Bin 0 -> 4603 bytes assets/icons/SwtorRepublic.png | Bin 0 -> 5686 bytes assets/icons/TeamviewerIcon.png | Bin 0 -> 1635 bytes assets/icons/TextIcon.png | Bin 0 -> 2361 bytes assets/icons/Tumblr2Icon.png | Bin 0 -> 1041 bytes assets/icons/Tumblr3Icon.png | Bin 0 -> 2619 bytes assets/icons/TumblrIcon.png | Bin 0 -> 1858 bytes assets/icons/TwitterIcon.png | Bin 0 -> 1246 bytes assets/icons/WarcraftIcon.png | Bin 0 -> 7048 bytes assets/icons/WildstarIcon.png | Bin 0 -> 2663 bytes assets/icons/WinAuth2Icon.png | Bin 0 -> 2007 bytes assets/icons/WinAuthIcon.png | Bin 0 -> 4303 bytes assets/icons/Windows7Icon.png | Bin 0 -> 4591 bytes assets/icons/Windows8Icon.png | Bin 0 -> 1207 bytes assets/icons/WindowsPhoneIcon.png | Bin 0 -> 1167 bytes assets/icons/WordpressIcon.png | Bin 0 -> 2722 bytes assets/icons/WordpressWhiteIcon.png | Bin 0 -> 3076 bytes assets/icons/YahooIcon.png | Bin 0 -> 1246 bytes assets/icons/battlenet.png | Bin 0 -> 28630 bytes assets/icons/eBayIcon.png | Bin 0 -> 1888 bytes assets/icons/padlock-closed.png | Bin 0 -> 295 bytes assets/icons/padlock-open.png | Bin 0 -> 318 bytes cmd/winauth/main.go | 93 +++ go.mod | 28 + go.sum | 59 ++ internal/authenticator/base.go | 84 +++ internal/authenticator/base32.go | 97 ++++ internal/authenticator/battlenet.go | 389 +++++++++++++ internal/authenticator/battlenet_restore.go | 180 ++++++ .../authenticator/battlenet_restore_test.go | 66 +++ internal/authenticator/google.go | 118 ++++ internal/authenticator/hotp.go | 42 ++ internal/authenticator/hotpauthenticator.go | 74 +++ internal/authenticator/secretdata.go | 61 ++ internal/authenticator/steam.go | 177 ++++++ internal/authenticator/steam_client.go | 366 ++++++++++++ internal/authenticator/steam_confirmations.go | 380 ++++++++++++ internal/authenticator/steam_enroll.go | 477 ++++++++++++++++ internal/authenticator/steam_http.go | 251 ++++++++ internal/authenticator/steam_poller.go | 184 ++++++ internal/authenticator/steam_session.go | 212 +++++++ internal/authenticator/types.go | 34 ++ internal/config/legacy_secretdata.go | 35 ++ internal/config/legacy_xml.go | 266 +++++++++ internal/config/legacy_xml_test.go | 154 +++++ internal/config/model.go | 29 + internal/config/path.go | 41 ++ internal/config/yaml.go | 94 +++ internal/crypto/crypto.go | 98 ++++ internal/crypto/dpapi.go | 20 + internal/crypto/dpapi_other.go | 10 + internal/crypto/dpapi_windows.go | 49 ++ internal/crypto/legacy_blowfish.go | 84 +++ internal/global/log.go | 10 + internal/hotkey/hotkey.go | 142 +++++ internal/hotkey/hotkey_test.go | 42 ++ internal/httpc/transport.go | 127 ++++ internal/i18n/i18n.go | 86 +++ internal/i18n/locales/de.toml | 369 ++++++++++++ internal/i18n/locales/en.toml | 371 ++++++++++++ internal/i18n/locales/zh-CN.toml | 369 ++++++++++++ internal/i18n/toml.go | 9 + internal/logging/console_other.go | 6 + internal/logging/console_windows.go | 50 ++ internal/logging/logging.go | 94 +++ internal/qr/qr.go | 108 ++++ internal/qr/qr_test.go | 50 ++ internal/ui/app.go | 540 ++++++++++++++++++ internal/ui/convert.go | 49 ++ internal/ui/dialog.go | 132 +++++ internal/ui/dialog_battlenet.go | 117 ++++ internal/ui/dialog_battlenet_restore.go | 146 +++++ internal/ui/dialog_google.go | 122 ++++ internal/ui/dialog_hotkey.go | 87 +++ internal/ui/dialog_hotp.go | 91 +++ internal/ui/dialog_import_legacy.go | 102 ++++ internal/ui/dialog_password.go | 69 +++ internal/ui/dialog_scan_qr.go | 183 ++++++ internal/ui/dialog_setpassword.go | 65 +++ internal/ui/dialog_steam.go | 440 ++++++++++++++ internal/ui/dialog_steam_trades.go | 280 +++++++++ internal/ui/entry_actions.go | 56 ++ internal/ui/entry_ring.go | 58 ++ internal/ui/hotkey_runtime.go | 133 +++++ internal/ui/progress_ring.go | 80 +++ internal/ui/settings_menu.go | 88 +++ internal/ui/store.go | 173 ++++++ internal/ui/toast.go | 84 +++ internal/ui/vendor_menu.go | 116 ++++ internal/win32/clipboard_image_other.go | 10 + internal/win32/clipboard_image_windows.go | 130 +++++ internal/win32/doc.go | 15 + internal/win32/hotkey.go | 26 + internal/win32/hotkey_other.go | 19 + internal/win32/hotkey_windows.go | 203 +++++++ internal/win32/input_other.go | 23 + internal/win32/input_windows.go | 214 +++++++ internal/win32/instance_other.go | 14 + internal/win32/instance_windows.go | 96 ++++ 165 files changed, 10102 insertions(+) create mode 100644 .gitignore create mode 100644 assets/assets.go create mode 100644 assets/icons/AmazonAWSIcon.png create mode 100644 assets/icons/AmazonIcon.png create mode 100644 assets/icons/AndroidIcon.png create mode 100644 assets/icons/AppleColorIcon.png create mode 100644 assets/icons/AppleIcon.png create mode 100644 assets/icons/AppleWhiteIcon.png create mode 100644 assets/icons/ArcheAgeIcon.png create mode 100644 assets/icons/ArenaNetIcon.png create mode 100644 assets/icons/BattleNetAuthenticatorIcon.png create mode 100644 assets/icons/BitBucketIcon.png create mode 100644 assets/icons/BitcoinEuroIcon.png create mode 100644 assets/icons/BitcoinGoldIcon.png create mode 100644 assets/icons/BitcoinIcon.png create mode 100644 assets/icons/BluePixel.png create mode 100644 assets/icons/ChromeIcon.png create mode 100644 assets/icons/DefianceIcon.png create mode 100644 assets/icons/DiabloIcon.png create mode 100644 assets/icons/DigitalOceanIcon.png create mode 100644 assets/icons/DogeIcon.png create mode 100644 assets/icons/DreamhostIcon.png create mode 100644 assets/icons/DropboxIcon.png create mode 100644 assets/icons/DropboxWhiteIcon.png create mode 100644 assets/icons/EA2Icon.png create mode 100644 assets/icons/EA3Icon.png create mode 100644 assets/icons/EAIcon.png create mode 100644 assets/icons/EvernoteIcon.png create mode 100644 assets/icons/FacebookIcon.png create mode 100644 assets/icons/FirefallIcon.png create mode 100644 assets/icons/FlickrIcon.png create mode 100644 assets/icons/GMailIcon.png create mode 100644 assets/icons/GitHub2Icon.png create mode 100644 assets/icons/GitHubIcon.png create mode 100644 assets/icons/GitIcon.png create mode 100644 assets/icons/GitLabFox2Icon.png create mode 100644 assets/icons/GitLabIcon.png create mode 100644 assets/icons/Gitlab2Icon.png create mode 100644 assets/icons/GitlabFoxIon.png create mode 100644 assets/icons/Google2Icon.png create mode 100644 assets/icons/GoogleAuthenticator2Icon.png create mode 100644 assets/icons/GoogleAuthenticatorIcon.png create mode 100644 assets/icons/GoogleIcon.png create mode 100644 assets/icons/IFTTT2Icon.png create mode 100644 assets/icons/IFTTT3Icon.png create mode 100644 assets/icons/IFTTTIcon.png create mode 100644 assets/icons/InstagramIcon.png create mode 100644 assets/icons/ItchIcon.png create mode 100644 assets/icons/KickStarterIcon.png create mode 100644 assets/icons/LastPassIcon.png create mode 100644 assets/icons/LinkedinIcon.png create mode 100644 assets/icons/LitecoinIcon.png create mode 100644 assets/icons/MacIcon.png create mode 100644 assets/icons/MicrosoftAuthenticatorIcon.png create mode 100644 assets/icons/NameIcon.png create mode 100644 assets/icons/OktaVerifyAuthenticatorIcon.png create mode 100644 assets/icons/OptionsIcon.png create mode 100644 assets/icons/OriginIcon.png create mode 100644 assets/icons/PayPalIcon.png create mode 100644 assets/icons/RefreshIcon.png create mode 100644 assets/icons/RefreshIconWithLock.png create mode 100644 assets/icons/RiftIcon.png create mode 100644 assets/icons/RuneScapeIcon.png create mode 100644 assets/icons/SteamAuthenticatorIcon.png create mode 100644 assets/icons/SteamIcon.png create mode 100644 assets/icons/Swtor.png create mode 100644 assets/icons/SwtorEmpire.png create mode 100644 assets/icons/SwtorRepublic.png create mode 100644 assets/icons/TeamviewerIcon.png create mode 100644 assets/icons/TextIcon.png create mode 100644 assets/icons/Tumblr2Icon.png create mode 100644 assets/icons/Tumblr3Icon.png create mode 100644 assets/icons/TumblrIcon.png create mode 100644 assets/icons/TwitterIcon.png create mode 100644 assets/icons/WarcraftIcon.png create mode 100644 assets/icons/WildstarIcon.png create mode 100644 assets/icons/WinAuth2Icon.png create mode 100644 assets/icons/WinAuthIcon.png create mode 100644 assets/icons/Windows7Icon.png create mode 100644 assets/icons/Windows8Icon.png create mode 100644 assets/icons/WindowsPhoneIcon.png create mode 100644 assets/icons/WordpressIcon.png create mode 100644 assets/icons/WordpressWhiteIcon.png create mode 100644 assets/icons/YahooIcon.png create mode 100644 assets/icons/battlenet.png create mode 100644 assets/icons/eBayIcon.png create mode 100644 assets/icons/padlock-closed.png create mode 100644 assets/icons/padlock-open.png create mode 100644 cmd/winauth/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/authenticator/base.go create mode 100644 internal/authenticator/base32.go create mode 100644 internal/authenticator/battlenet.go create mode 100644 internal/authenticator/battlenet_restore.go create mode 100644 internal/authenticator/battlenet_restore_test.go create mode 100644 internal/authenticator/google.go create mode 100644 internal/authenticator/hotp.go create mode 100644 internal/authenticator/hotpauthenticator.go create mode 100644 internal/authenticator/secretdata.go create mode 100644 internal/authenticator/steam.go create mode 100644 internal/authenticator/steam_client.go create mode 100644 internal/authenticator/steam_confirmations.go create mode 100644 internal/authenticator/steam_enroll.go create mode 100644 internal/authenticator/steam_http.go create mode 100644 internal/authenticator/steam_poller.go create mode 100644 internal/authenticator/steam_session.go create mode 100644 internal/authenticator/types.go create mode 100644 internal/config/legacy_secretdata.go create mode 100644 internal/config/legacy_xml.go create mode 100644 internal/config/legacy_xml_test.go create mode 100644 internal/config/model.go create mode 100644 internal/config/path.go create mode 100644 internal/config/yaml.go create mode 100644 internal/crypto/crypto.go create mode 100644 internal/crypto/dpapi.go create mode 100644 internal/crypto/dpapi_other.go create mode 100644 internal/crypto/dpapi_windows.go create mode 100644 internal/crypto/legacy_blowfish.go create mode 100644 internal/global/log.go create mode 100644 internal/hotkey/hotkey.go create mode 100644 internal/hotkey/hotkey_test.go create mode 100644 internal/httpc/transport.go create mode 100644 internal/i18n/i18n.go create mode 100644 internal/i18n/locales/de.toml create mode 100644 internal/i18n/locales/en.toml create mode 100644 internal/i18n/locales/zh-CN.toml create mode 100644 internal/i18n/toml.go create mode 100644 internal/logging/console_other.go create mode 100644 internal/logging/console_windows.go create mode 100644 internal/logging/logging.go create mode 100644 internal/qr/qr.go create mode 100644 internal/qr/qr_test.go create mode 100644 internal/ui/app.go create mode 100644 internal/ui/convert.go create mode 100644 internal/ui/dialog.go create mode 100644 internal/ui/dialog_battlenet.go create mode 100644 internal/ui/dialog_battlenet_restore.go create mode 100644 internal/ui/dialog_google.go create mode 100644 internal/ui/dialog_hotkey.go create mode 100644 internal/ui/dialog_hotp.go create mode 100644 internal/ui/dialog_import_legacy.go create mode 100644 internal/ui/dialog_password.go create mode 100644 internal/ui/dialog_scan_qr.go create mode 100644 internal/ui/dialog_setpassword.go create mode 100644 internal/ui/dialog_steam.go create mode 100644 internal/ui/dialog_steam_trades.go create mode 100644 internal/ui/entry_actions.go create mode 100644 internal/ui/entry_ring.go create mode 100644 internal/ui/hotkey_runtime.go create mode 100644 internal/ui/progress_ring.go create mode 100644 internal/ui/settings_menu.go create mode 100644 internal/ui/store.go create mode 100644 internal/ui/toast.go create mode 100644 internal/ui/vendor_menu.go create mode 100644 internal/win32/clipboard_image_other.go create mode 100644 internal/win32/clipboard_image_windows.go create mode 100644 internal/win32/doc.go create mode 100644 internal/win32/hotkey.go create mode 100644 internal/win32/hotkey_other.go create mode 100644 internal/win32/hotkey_windows.go create mode 100644 internal/win32/input_other.go create mode 100644 internal/win32/input_windows.go create mode 100644 internal/win32/instance_other.go create mode 100644 internal/win32/instance_windows.go 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 0000000000000000000000000000000000000000..ea9980a6566aeef7b259cb1f446ea1ea5fdefc30 GIT binary patch literal 1560 zcmeHG|4+8Do~2=N=!w_$PSwyk4(8uiu{6o~I&3A?LgJxDZ14 z3GvcYMn!+%#AaAybH2q0t2k9IAstWsW|)DqTbwK=r0cBfFD;i4Hc3gMWQ-5BETjq6 zv9Qj;UdOiAae(Fm?T8IrtarqE9yT~b!#p&)U?anPG`fLtJ{aBMf(Kmm!X{5>_C?bY zyySx>KQIZfMF5vqz~vxp4Z^lyXcJ<)5Uzy6RS}xQ!Mql)N5J*<&=CbUqOmI)I%A+S z7A<1DDM5=AEOB6wLw7uMC%~;Fyv=kn+*a6olxU^Ex(%!8}j6QN335@CshYKZYU}l zGYM+PZS-sU{@B2S;m^*!<~?J^%1~YSf`H=vYsO2QX5TGw?LKuayTBZqHPS8Y+Fo&W zp+|ewsH|jlj8ZAoEw122o#l;Oq~-Y*Rg~}ABkI*oAABbQbeVS|6^XfC^;DpAI{&A5 z>A9cgqa{6GHwUL4E^~Ekiab<#p~dPanTs->n-r!SbjuxTi+)T+)I{61jJSEadz7b( z^i_N>@8wjaM-qu)w@t;(%BTVX`7Y3ehvu|gdEdL~OxE+AttXn_aJ(8dv9x*z&z>gH zY7%L+ziRVG%}3F)t*xq;$2VHX4arkfYmLj7GW~R7(7Z>Zl+ekW-Ix}bVX9u~zO8dB zx73vRYEhrz^bC17bBrTmX&yXVw=C^(pq5+u#_T`5BG=bOI*l(@4d$jFbm(j7nfNj} fDELt>@SgtVKe4EWy_El$xnM#PWD04AL{s(~CO=`Q literal 0 HcmV?d00001 diff --git a/assets/icons/AmazonIcon.png b/assets/icons/AmazonIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..1ff92dc157486a365d5478edec2b443a28a912b4 GIT binary patch literal 1064 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 nZ3(}xPg17e`y9Y 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 literal 0 HcmV?d00001 diff --git a/assets/icons/ArenaNetIcon.png b/assets/icons/ArenaNetIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..a620a816ff13ca49f5bbaadbe63c55cef241091e GIT binary patch literal 2178 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 z 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 literal 0 HcmV?d00001 diff --git a/assets/icons/BitcoinIcon.png b/assets/icons/BitcoinIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..b3b5bb6481c24ccb81a14d2bf6055f9e0b9816ac GIT binary patch literal 2361 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 literal 0 HcmV?d00001 diff --git a/assets/icons/DiabloIcon.png b/assets/icons/DiabloIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..394e4fb24a05d77074e6b48dc4d9f1ddf93094c5 GIT binary patch literal 4193 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 KMNUMnLSTYqScu60 literal 0 HcmV?d00001 diff --git a/assets/icons/DogeIcon.png b/assets/icons/DogeIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..b6b8ee049690bc74d3d90007e6b2a0a7d2123028 GIT binary patch literal 2504 zcmd^9`9IT-AAdWlRV0;2^`%nj!WiXRHkK>PRkzJ5vz zB4?m`$46Xx%29rVID_OF|1D!s#s_lR<#1BR)tfmz^hggR4D{?O1f%wx^`WPIyC`WLr||Js?`!9jcGUQ(zNP` znp7gR;ge1sNuxeP^TT_)e_|2I;g+PhJH;R2qzJ35j}OQRdiglhJpBC<;sY|lrs&h7 zvUqrPk{%VSS4Tk5QjHoiVf8t7r5Vm;S*{HQeoaX_t$6Jw(#@7ko$k-teL2wnJZSqT zeR`T{Qx=>-(re3xHD{YOlMyZXce_6ue91HH&NpUcSTgc0J92Hhi!8c|?0U*9ddqB? z`EYi*8ME|$e}!#d`QyGyTV|yLi}HwF^JJ{nwuS7}Q{>oMgl?;F@2+t9Qib|j>)BNH znpxpGQ0vU1dW=#WhZ|joXGT3C~;*hVU>fQ9xPlO%i@m}ObwP#vug$1s(EhX8n<$dm%cQa zIX_vj{H=6(s&Q>Hd+l5P`drbH;7klpkGD)td=+q7c-+xB zE_-Q)u{hm5Gc~q2Gq^B2vNSieFgLOKy=P;-WtHEv@{PGZKeQ$o+gj%QSee*bomdj^ z)|YrcR;ISsr+;q#DTUc_aLHaz0JJ62YWM&90ISUd7xyAL=woE-3jjx2{y?bRJMZb9 zDT_wfpsl>!(Ed&@T>*2a=U!+fB*I$WPfbZ(2@Ez|hu;K%eR9SIFl!@I9PY5S)!%2@ zFJ5?KZnGmInkBvNiA~1XSO`n`>y9hD*0WS&oX2TVvk_}XiRA}QW~I3LtFZ^<`Lk~R zQdWmED$Di#RL(|QXe~E&%C$|{&~{x^Zm~3j{CoX&H+Q#xzoYH$Zr1rv!*{J*WH2lc zBkZW9^s2q2>|cs{tK>@0kgM8eQgMg23~Vll*BaqEd8MlN6;tl1B4iI)kmU<&uvz*h zi10J=7?nJ$j`^=TuXLwt+#Il2eC9V0s-rJB$AZU?EXkoc_%53wPriSi7viEH@j9tD zs%vUQ@|+h~DHSg^jkf*@^Fo2vM+z{{p0p^zPDSkVzl-b&F4*u9^U~vMMLfl0Oih!O z9<9Y|T)yn_Z}=wC%*ZHJX!CD=6!r_rZ9D8_akl4tMr zLT_(tXwo(B4oZ0d1Ys4BXl-csX1dZ{m&=i;DF|2{+0e052`I z2+0b gPoRZEi=Jti!+@2ZA_;83$pysDed;q!7OuIr(sP^72`LEq4J87+48KN6Sh zK~rAB10LHY#4$yAkgvR`%+v_V=}n{{#JBqAPHFkC??0c7yYl&$k8FGYI(0$x98m^i zbH{Eyn#<*EtiguWleKrFWZ%)<4-Ds`9Js^q z(qroA!nic333MbQ;(Ta)&VCW)(2+&XZSSeY+xqY2AH2TMPFvnN4)KNl#DRPh+6u;ScD; zEeBnrZW zXTZ4ADYq%1^7RehE7CQ*#`R9+(CLnPAy1s=RPlFx4`veXiT{!eVo-$CQV(_sapRe* zqwcHuX`p5~ra9+?4C>~XLbw*m2I5aW#ru@02!)ARt<{7i!=F{uf()s6P%p;MIBvpO zlF?3A`9;W3R@DXf;UZT7D7D);MImMjBy;}p(-krc@2O$$Ay~Rs9mE{o8dSOziKsM^ zk9kFNYAraHKyck3)vu>>X?+>ybkYpfQ7ScP5!Qo(B>T$vwAp)zN8nM{ zF4Ug0ma8Vpc6GaJ-moh?k&?{@-JX}&=d7S4{QJJk+4iDR+Z?ShC-+x7AbrK`&f`6} zK_}ussZdK*w9C$wP|UIA$M?%9qhi1M))J~N?Q(zn!2(`9EG|4+G&!5$ELcBo92BYE z4}6MJqJ0mqE-V!(_%8#oEi1aC0nZXAF=S8V4xv~tj4D#Om_C_^T}#Cc2?vnKPK0#~ Qc5hGtV+7Km815MQKM@#x<^TWy literal 0 HcmV?d00001 diff --git a/assets/icons/DreamhostIcon.png b/assets/icons/DreamhostIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..7b704996d05615187977eb5e2ddc06975de030b1 GIT binary patch literal 2036 zcmV}ZF?!uu~Lh$GHoe>VYG$)ApvD<$f9G)a5HQyOGdL`9Ek%=VCu}G zsM#M=W0rN(pxGR1mbkFF#RUFv0!!@JSON~ZwLojH(9&|--rjr9kN5SD-dicXzst`j zIk_k2+;h(JeV*_0>pe%%+zRivK}%}|@D*S)PzOkEEb)QUz#oBkXm3l>vIPM^OKUyw zXW$NQ4ml321^z2&X{`i~0-pm6q(I&Ha)I`?S&G``R$$Xjro09!sBNAufvu^7*GG9{ zz!;<`>hThAfX5}U;Ch)+2nYmh8g8pWdw#K(2Y_kCWg6c{N{MaT1OkDAw`^i5h2wa% z?S6y@zq%ZfE>adp1gjwG7=u)bIdkU{4u`RAy9g@_hKGmg?CeAcfiY(Moagx<;CFBQ zlYKwkLUZFhcK+;D)U*nGtqTT_SAk$CL}OzkN~w~lQe9n5S6A000O`H=KR(M)EWwV= zYnfSD!ImHI1xpni3^^@t+ct|9Eka5;1+M3I@+UogoQ_;TYs2IBuiz(7Zh)kVRe-@{ zz9?&GXux3dDv{j=Chvce{TJ!{Pd`Eke688_*gCdux*KmOmNzD{1JIh;vu6_w1`$)p zutEro&I3Y9j-BjA2oMNd&*#@W9%5-zJv!-?20&UCRaI3bTmDkz)N|V&K)W7xmaLRj!(wuMK8Z%7xhW*T^L2*0shLwf zgL_vl!AZCoy&y|XeT7OQkzizGBu}b>ve4v;EtSOAnmyn71`G~o)aj}ZErWxDc==+$ z*VI&1PBNTGd7-hchN|0ZGxkeY4S+EQ-}mY1=>f~i3!r{p^>~9fA-+7Sdvl9x) zVnU2HDPL}?r?MiHdwQ`gP;%03tt+8?+RfeF-9&nO5mFXZ!EH<7I4;{CS(|^B(mFMM zioyY8QzCs_N{Ld6fq?-ckq9HvC@58s@WAs7zv+aErPlD(BIF%`STN>oc@&(VOd3YtCo^@jz{aa*RX!olES#J;qb9f!M2K=iKQIdrZ8SJzDGRi7NmU8 zws`rCLmBCl6@Zdg4wnGRf;Rf%2-_Z8$KQVWB*qxZf_6cH4)E+tZ(&uIXY{z0m3LRF zzya}u!(ZEWuzuB&f@pK8A@}Y-!o{H&$}V7_5dgz+wm*CicP^ekRybk_&>AHrQi`k( z7h;U#~__jUe-Edx*yW$nmv=Jw%yBvcen7x&(HaP ztD$7UH3H!I*A8&%R5yeIIVjrFYAz2p?P;Fg@-?1);=b|Yk^U&JAN+uSd~k{nPj=IN zeh{M#2AB~pqp_}rl}+`0bM;b|Hq0AWq}a)BOn1YHt_X|2z6((i%9-sZ0Z0!KzUT9o zpFhC^cQrAZa0mvhf`dEhde}-8y-=H$(~*7}H~a_#IBp5+LTSEj-1!2pA3REVD1h&4 zj4>Ev(8i$k#C@GQ2N1Nal9czg=1k-Q%Qo*J>9~272T;^DKa(vgZQEks{=<})hgjLv zkXoO~y-h_v=|0b*Z#~2CXrkcOBqt|a*T=31@%>-C#{Ju0py%A+cy^fTf>*jwrSJ3m zH;=Gj{cf}|MJ;4zjDbwW$T%Li+&Y7ukKV)1RR8XJK9*7xYj|S}uBQpuilpOl-&4Qg z@9%z;vDdjG044-?+~G5|xA5{0HnZ}Mdcx(wlB_>6nq=>rhxzWFSFwTte636JYEb~v z)zKJ(zA!}H;`wY`)68AV7O?ya^O;#$p7YvRJjtPBpYqO;w?U))`@z)PI)xd9#@{J&aP4fuR_ud;4iWafWk)Lktd$ z;>HqSTL>kXVkj9wOX~?>;Z!?KB_#rIJRf`w>3&KGPzpQM%lKNO_0$sp-~vfSZLraUyoH@sU*^}kd{IyIsWEltaeDvh(=mib|`)F^A1N;wAT|AsI SBTU!;0000?H>w_rfDRN6wy)& zWp-zAsjx^_U}xsu{_*zb?97|@W}g4F-+yNE&b{aRz4Ok!_uO*_{x6{iXR-c7JT!mn zPsGuKv&!kJ0{77AuVFF=iB`RXPJOu?(y9LXZXr73&zQ_168(1u?JmAsK6dO1s$*NR zvU*a#i&$Ar(s?l`F#w6a7p%)_H|!x4{%j!qH+8cCHJA7lLf8%q=Qkffh-E(^DI`@k#u$zGEN_W z9;_ob(u2tT0Z1kzja5rX#{V+qX-D79i0lDASC&^R`!M!lHr1ylKb{(YiN58S%;5^e z12~A;Pa-`LBD(K8h`dF);sM0oO+*gSk!}Jz{=>Up+E2|#ayt>eS-$RRuvnXLS)`L7 zD_&{Of5b+b=&Cy|B>G!`EkI3Z1TGF^r5OOR+kpik`yU3{6v`X}G9fy?0Bq#!Cto7z z93bf&KsmR7{1Et0h&%?QZ#0BbpGT?PRQxN+UX(n7j-M!K-jJ#%B0mLLRv8a}N2#Ug z_7o3H+`a?u5#Q**GgWjy1xvX9862gl;Q5K3<@+A3^+5-V@e zS^tMH^%g~<|4zi-fz)@D%5Z&5)PcK3H>7Sl`n=*a9O(3|U^ijq5ls5tQq4Wy!1#)^ z35bk61p6XNJ&Jbz9g5&F=;O1oRU5IanabyV)+n+H4bH-3p98zR2;CX1w2_QI3t}r4 z@22ig0#_Gl@j9w{1^Tle=NqOEp#9`^sOWC63w==UN2z9X{Jmg`N1;>KlT$qicll`j z0ZNvjo%bg{oH~F+dNJ6Sfm;76B!k%P3`f?}T{rBji+d_t2do2P{<-)V#Y%Mjo^f{f zqhP!Tg#9NVecjLwTrrwzmkKR#8%O!IS(cn|9Sw-|nqe{w<( z)?r4rf~*XQXAhC;mBQowFJpNi6sO~mb6DHX`_6U%ZcH~L(gpU4aB29J&~h}!oSJql zkAa;LGQmp#SQ#3b%WEkxvJ0V@VxI6y`%!;WOBiuM8xA^Ck5E8ipO zyaSVW;^e$TsQTMRPfp&oozi%gQv(gevWuvWG@zVs;pYF9;3BXQHMD32i+b8@yAAj)B#P-3Iun z9G^$oMmYC-%+f zl>3mVdd1_-xs|(YE{0OC17Gl9-j6X4(KYX=mpuQq8aH3r0nEv>{E3XZpJ=uYQNID^ zdpnW=RBj3I4|1v(;Tw7Ob68sa=2`yk2U?ECID26aYQ_Srov0WKyHG<7%rLhh>aEh^ z0c;$j-o&WIM4ZJy&xA?#k+Tbl#GgYCT;@lbzX5!vrjFW~e}n7|BK*k*lFNcJfWk@>Hrqh$WoN*K zu<6-*Fc%L{7mqSgK(J&ZzN&@(C$Uc7im^SwSAvTSgb#ogzvplNmweheh;nYn+EqZ& z8_r{s6!&N9OuhAkvL{3*U_z+KgT>@~QBBYS8H`L;X|8 b|04bi*fUUO9kZOV00000NkvXXu0mjf>8*eP literal 0 HcmV?d00001 diff --git a/assets/icons/DropboxWhiteIcon.png b/assets/icons/DropboxWhiteIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..2e1850e032105ed727077d0c9b857e9166643de9 GIT binary patch literal 2465 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 literal 0 HcmV?d00001 diff --git a/assets/icons/EA2Icon.png b/assets/icons/EA2Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..913461a0d4f56ea962bb9f5fdbd826dae74f5691 GIT binary patch literal 1428 zcmV;F1#9|=P)e zKU&k1SS|nMcZm(yF7)9?`IRVbPRrzNeiu%fbrl=qWcf+_uD0jOp-bM|?9tvYUqqk0 zIemuC=@=fBx4qSKtY8^jBfl3PszJX{M7bOQC!4ui@^Q!jyJZ>u~gE-CY6eqq8p}QAUXSKNrVXfdQ zQ&;BdCS`0%c>Cvxyu5<1WmN|Jhu{=!5M`F8(2NPeI|==e={I0;)z)Y4FIRizCUIGV z@u!&3VDjRM2Fy1W2E3^O{o4ucphyP}6V9TZ`3(w>RS&5v-rgyKSH%;Zf%ob#&X-TI z_u+!9)j|($$et?+;-4bYZ>d4QPMpF{7wOEyb>yZcoI&z|?DaZK@QV6htxC)mxA7k%Vwkq|OH#*UBGVADE7~OI$8gi1!M&dZ2K&d@i+3gVG`q%G2WRk0Qjk z8;kK%qQ^edr~5gq5~*)GiFU5U+49OyXyI;RjlAvc6_*cljqByjW)iozDr}AXN=ypx zG#4(mM|hj!6;sQ~SFlQ4E~E+CZc&b$jfmYXf>i3~_h)0y+LhK)d|DWwaw1!=wf%99 z$ZgW}AoVt-Hf%E&-JQ5kTtwG8eD4(hKYS&!**)Sb5++x<=w2==YNytaF)GS0FN=!B zoS;|KIn$^;(>5k*L&wjR(zVbdDh^vUSL96{!hlE|o2ks@(2NO9hXwM#+bqFxs-`w3 iOzH>u-))9xI`IGdqcPx61>f!f0000Op)tsv-Xe2|SOnm948L`5ZnXo8F;nU^MxcDty1o$5<> z-@bQxx;sDoG<~bislU4Fo;p?413H5d+=h*~7FS~k7bN8in8r`|76@&_DJp)G(EDDCPr0bTbXuo3b3Tu= zxF%2hIl3EditwEHK{FKC})9#`N*`e3JG!}te=OEmmsUSfX=8=~hAlUoQgFef)v?=kKh$c z@5}3WEGoZ4NL}8Ko_&Bj3$*bAAtdGv)oTTurg%HDKZey&*(%|O`!)0YQX#gtJB6m< zbWWvg?=Y7nXOmFP`XMU#7zwv|6CZtw!sa><`cu+x$l0TO{+- zz;!&WV%`DEd=~I9g82+MZ|HVA;%u3w*AaWwWjf!)?Z7pye*>`B68{;gi(GsK&LXcK zuGacM2&p#;VL+A+Hc^-{WB)0gYjcPKKPeIcFR(W*fy` zn$JV85cPID(0m6lW6^nyWIK>IJ!y$?wVt9$*CF}ad~iaI`LYCfYtfko4rpY*o#NQ^+A?G6=j>*PVEtwi>IDLa!_D&`;uO6=DoNA&V-j`|ZHm zf@gY>g_;~kQgel(3Nc1I=DARvX5?HkwZ@VE?Fv2>VlD0N0?g-JaA*$X0d2_dT)oWC z+jxQGMWCrW19X&tA>d#Q6BIE-cnUUY1+H8A-_)p^NF)-8L?ZD|^A}}bwf5P*$oc>P N002ovPDHLkV1h$HR+az& literal 0 HcmV?d00001 diff --git a/assets/icons/EvernoteIcon.png b/assets/icons/EvernoteIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..990e9cb058e32eb1d4c6ce668d05a344c4c78c02 GIT binary patch literal 1125 zcmV-r1e*JaP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02XvbSaefwW^{L9 za%BK;VQFr3E^cLXAT%y8JLvUk000BnNkljxEqMY-9Rk<7lDcuN0qjBD}L)nBoK{8d?ELu*9a}uqZ!ml z8B8otqdxm?{{g#qYI7%qBEdRMCN(8dCJ^YOsc3w;w`$}(_TLGnl_3~VHIiPbK3g_? zB7fi@CBkI9f!a*P(aorS>Dv=cCN_@)FvSyen%A@{+xu2yL5boRB`aoJf!a(ZAPokF z;4ot0VDgZq2@A``2V)^+L{chHx$@Ms zO9c-GD{(^4+*8Vkq*S2U)lsa$6-rwRWkfP&puGP5+@B2sEJVtSWXwRYBmdq<{sLeb zP+laZUKtcAticsl&je*eQYsM4#2Q@T^vzR7B&7oB99>vLE?^Z3o2mdPCX^9LsX$+6 zYp{kaXzU%Zhm|N&sV5x>uTw@Or2;kTZ(t2sC>=hIbly(n;HE7^q9MUTOsPPih&5y( z81}&lX~{P?>Fmc%+sN(TM|la93Iucqm$3#{NYqzGAj5JG)?gtJ_Tpc00Lmg|B#;pZ zsy)_VVbN>Czv2Lt0m?`qBT$7Z4QogOjwm`uC;r0%Tn#7}l8ivWDc3MoiVdfK;kDr= z{=20qvIA8p(<~kX7K;Imy&XmlT3*FdkKu?->QiI~LWGM%_iPQ+#b9_H3lo4H z3k|zJl=5Qide=;)%GL^x)*tfV5d^f3PC-`8cmoN9Ed(w)Et7e@^UvWDvf$P$tj)Q7 zd#PcF1%t`vH60GJjUID7HH>7!fuvb9bfk9lVx2bYq3Sehd8WI9gKVRD;*Kz0Om2aS z6{+w-g(DPP#85k&tEh$bSba{IB__8(KbO8>!!7(UHwaxGV!MZWB2hPhd;-B50w-h% z8xmYn>oi$HuPb0fU4WTYwa~sJ`2>RZh5W*i4eN9r?Vub6xSL9rB;GweNZe=@lg(=MgWYlNzT+5z)xI%=Yr+!nQCeFodc z#!G!RHA_rxf!>VY79XEUXzd-;lpV<}5U^f*S?l-=j|%Qj;b;g#zz*aKty$cLj_Gpg ruU#O|KwP*Rh{fGNEdFDG`jUPDt^ERbI{{uc00000NkvXXu0mjf(3RcK literal 0 HcmV?d00001 diff --git a/assets/icons/FacebookIcon.png b/assets/icons/FacebookIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..d2fe5b70a6d1b5d517337d0098349838380e2069 GIT binary patch literal 731 zcmV<10wn#3P)wo`K;%!%zjF7*Jps!T{s}h9T_%hN13( zfsO%gPF4mLS$>@IcOHLa`1<`fJ`en(yQB5hL>YpdRT%VuG!M`N_&`bDA%=^$UJs5K z06Dfa!IZ(;K#FKRAot;OBVF@AX@Ut!j)(ey0iPS`76YJUV{IT!vOa<_K(9RDZbfc& z1MNFS+ii+&> zmmdsguRK9(D*Pl|6aOE~HSvGK(u3Lq|B1*rgS<8RpRg=htF4~fbHWI#|Uv;nBMpJ9NP8GxNbdJ?+8bY*Yd2cRbs;WHt$!o;LMGCT>h}?IQ)A}*VsXbPCb_gQ0?<7LUN$znQbVFb0O0G-t^VYi0e~qG9myaW z=cZ|FNPAGF!)WbNSUU}x)KZ@1UM(P2MmzYEyK`2YwE z-n9>5<>YR;&&$>sW(z{ux*oOP4{-v+oem;FZqU64F?NsuCx|x~0(U)(aDrkT-GlZa zj=8v_o#A*8@>4j>9qNICA$&YxP)|=d${PXm@^|+QMEGF5{IFgaJjU18-`D>r+WV+K z8Xa&P6LdTf=Z_D>?tw>`qFCl=>b@fcCnV7k8R?7&1^YySykicbPlElZt{55^$8y6b z0=RS+de*aFd+ z^lT2}6qgytW3xC(96tAqFgZ!U<#JQg(gl36P{vIETx!rZT&pTAu2z?* zuYOrJICQC{{fn;N=I)*wLqppBY3;q~^6#E#zF$z!{&eYk!!^Tv-Q;hzFXkJbztAr{ zZ(mvJU0xlZp1r@kVmy9XdWW;oCxC=TrGNN)2S9(f2X0)6MHZQr3jnrbA2GR~*~T{x zK@t*ELeET*oa5#QfM{;ISmHw^F(5LG57q~b4%;N)jGyEm6k-U29KqW<1$CYaw*E*{ z!WhG|!m3H+hsC}UJ?v#Xo<~1Gon6EDKe!Nn7toaMIMTgy^*xoDE3bYX*#4oj|Me4n zuJ`8kU(3QTm@vj>D=QtONg+uoDceK1)M1UbvvYMNzwyKQ>tmB%kh`68vkL(hEeF_bhdh&#CzAbcw^| z@p#G4l9MG*rShF2td;2?)1WhKZ5f!l0Z9+$axf^VUOxEr{Qzff#Z;6yF)<++Z!K;e zSo+EpzyD^^S}V*Qwb%i>uTS%s)N|&6zQa)@#Gn72;cdpbX;0+*ZWtXO=k4@)Z#16U z{CV`Ev3}>PQ+Omqp%+)dXvI^;tx^Jo@@|-7GUGZ5Pg=LWa<1^@T;NP!cfQzTU?77T zu==R1t0+ajedK*oGfGc0pRc+m*~`3(s} zL7S6rvrhlmIJs5_-na*PG{{VSZ!6$3f)*uBN3zb`UebGo^?&zjByCKe=QqRsi`IXo zarDr}Na5=#y+LxTbdl4(P(&DHf)mEaNruF@orNHS#;Yq{?}=@er+X zUxH>#=GRZ)kcSh>mDO6U(ZVIMOeQTnTt%W#Xf=%$)e#I`1xr^^t&B0?kq1E8jvbxQ z7U+3v8>#-~f=;om_+r7Z1zXT5oqSzys76Mco4L{aXwn;g+ing`C_)Lnrn;WIeJj5+ z(yLN7kElyj&E2Yzg8yCb`9?Y+I9pO7sJdu31DM+FOy=EgHPd6U3NtCTb$jdf@WUXt zVEADm*3>*wWLFdLZ0>Pt+|s-Du^*^9P_Y_Ucffo=fpH5lt1=0&fvm5a)hcbX((hWF h#M+yFggm?c;RFze5_CTjZHkPZ0VpIYv75kA{U727&Q$;a literal 0 HcmV?d00001 diff --git a/assets/icons/FlickrIcon.png b/assets/icons/FlickrIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..313e007fc0ed385c7d8578dbe60696ceab95371a GIT binary patch literal 913 zcmV;C18)3@P))6NYP zT{l6TVuc+PM3^wa!RTefgNNM*+hDiBwu_#(<6vX^3wBa3r6=_udQ)~#i!jl(Ru4nl z)U|2SHu3u+nPpA$3#pCd!Iz}uz0dc(@8`Ym_oXcbCqz+{DRLQw?v*`?qMm3UDD6g; zlj2QE-nG$r#rH_~eq6Z(+jMXf&n>9d2|UWti^r zf;AN%S$#MA4>Y)3Tpxg!LLLJi10Dnam;t*6$XKmbgJ$MP7Yhu-sMk(o*-9Zvl0Xzi z(6gyltKj$hArJ_36w5LLf*`1?#aCrWX9UO<>c`IpV?LO?!UCs=*x3ThvX-zYz6Qbc zH|WV#j-NFKLm*C`1(8!MVOe4TR|_ZHdG!<4pYduCCxr{rFng;PmhNysUNqDdG{Rzc zK0_sRqT{gtB!~h0}lP*5`4!dv4ZS^oH3@%r!!^XXS-8|N?dKX_q za5vi)3xf)g;OJZ#jyA3v)&z~9Mx$P@!`joLq4UjxdfP8cs+}HIXf0#te6yflaC=`D zOB<%!XrWS9Q^$@V6KL1MW89Pr)?t+woS(!_TaAMsk`9=&)0R3o>U=L0k1_DKu$6eE zEtV+*Lw$^+N%idHp|B$?BRkU~PF{@w$J*=pP#^7Dw$bD{P&mK6)hGI@ozo&lXI3Zr zZB4pxyH7W0bVgdeW(%vfFk%?ADFZm>#-cEDoT=6aT?~wVy0Ntt($6LGMgXBJ}>DrwUPj7e#Cr-DwRqP z$@PKNK#D>GQg4qS+tiK5NjI8IL?Fuc*sIQ>1isW4ptFD46geIyIe#7?YOPi%p66`^ z;Js3MFZCV+9s?c&{}lsX3V9583;^u8IRN$RuEWDa*Zxwk)xb3T{_laC1pS>>0~i<> nz%&}ih}^RB=r8*HOMn3YIwoMqqPf4100000NkvXXu0mjfDk!2L literal 0 HcmV?d00001 diff --git a/assets/icons/GMailIcon.png b/assets/icons/GMailIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..87cfb48ae878ebe94bd5636fa4bee3df638b745a GIT binary patch literal 1690 zcmV;L24(q)P)RAm&$|KFWE%bn@8h3RyZlcDoRUe|0nHIZSDA2zFwZMz1HeU zNvJ@@i+?}idwV0Rc6I*Idzq1;?d|9NTi)VgiKl>E?`b=I&FvWjioAs0z<&@zxfQQe z8t2Y)0qASb;Duj*t_~mmW;Up{6hV@kuee(~WJT@1;?>9r6y(>Tv1t@2C6C!2f*n2f zqGiM8p@h{9@0D~}Zt#Efp`%a(Uy*UrC?o_1Gr z|GFy^eVf*&QIF?rg3_V3G*@{yuFH%iUb5LbZ@j*y-0I>_ovZ|?5L-G8@ZTP-@|EwJMS0$$<=Hi7?)AFoUrh50!q8|w+C@wl z8UWq6#+VC>_sgS@9>(od=xG+1RtE9tS=LnwkouIr67Y%#yCu zUPr)hQQF#ET0z5O#x^v~d@4IEhzZ|ji6(G=mA(+`*72yRIgMk-NeM-?D8EeY_S=ar7V`7m*H*6PJvzOU#%N$#^!;0( zGmj@*wxrYf3o7H=k)d}NFS&ko&b+L!AZT8rkFJaOlq6?ZA;e0ao?C)#ThGB_>4@eg zWoEY3zqZ=zHd~8SRnhG7@siwW%a$<%yUP_!o?O$@)G`0LYjq1sZ2?|PPv(}rD`G7O z>{zxe11?u3zWDM<$z)WTUtZ381+f@P^2dqfC-5%>d3hzuuAPldEuJiYAduSEfE1BW zT)zzLilRc`1u#ZqzH>yLF+1A<5=B6~5JbMIlAeGlN}!a&XflEj0zv?X-G-u~0vL^k zWN(+e1XNXpD2mYQS+K0`F}s=LFsjH2yDjR6Wv@s|0)i+(mK7Kb1`rbcs4xtJ+}tdT zbml?NCRTp^QUj{0A{^$SXIU5w@lTf4loX6Ab|NkHvH2undYynMN{}QO9A^Tqa1`VZgHG4`|1sffl!Aii1sL>(K5g7hdRi*X9EYaccMu4M5?-H>0!fk(4)fql zrlg4P#$eE+aAW~;a~zsA6bS}^9LtGPicmNVfWXXgy_6Xndxj08ik#rMNV8JJ#1sY; zby`6sNkT9b0%tNoukWwM{Q#56h~mN#aM*3}yp1M?5bTO(4F*F{DMgCKlJKkT3xqJp z&UT>CnGd5uqxn%BWwH*P_?}R_2_W44TvQaj&ldp4aoPd;8H>e?;-V3-4IR?c#8cn^ zJ0w`n0zlT`6Q8!ey66ZL*+oGNqPN|gn(0XVO6Ui4Iu^r+Px# literal 0 HcmV?d00001 diff --git a/assets/icons/GitHub2Icon.png b/assets/icons/GitHub2Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..7e5e13871e91d1a50414745f35090b3bfb112e96 GIT binary patch literal 1854 zcmV-E2f_G>P)`f9LMnj$0>gHr}0?-L>NeL)03DqNIrm zLQNH-RwSi7KoTKDKoqrnJfH#(2#Kn^643`*P$8`X6(1r65jCMz(<&f}`jr%^Wd#yx z;<~%*8E3I0JFesP&Kw>bC;r%-nYB0K|9J2Jobx|3b3e|#BeWLAvaF2>)d4Z)CRJSr zAR-g0I_|?ep69&_1g#1vt98)pINMZpZ$NODAlrd85hl~X>x!PjB%dvp%WtkG{)!Sv zr_){SN%K`v?pL^VH3Ae0A{?#-{KE6Rsb=k)N?>4MpksP+@*#w80~?wR7J-T&hq^ax zc(hO`RO1@OP9SgFI~AS){m{zv#m2t{d9diZe~m*<#Np&^`(Z^71L@Uii-jKG;M%UP z&dEztrvMEjhrWSqHhY5v{_lZ3Efm-co>h}LP%f9>Z$vPSI#Q|BI#K^5|3C2n_lobo zkV>W2H6qkaU~q7-y|bh38I+G*KW#A~e7v*0?O1<*f8FW1?ilDunx|3jyS~brLcc#- zyLobI>W@o1mL-sLG7q5gz50Ii8AMJp_vUgf7Wo>&A5<9;;g+SHB5dhi+f|;pG_`2q zTQrD!Gno$rM&1TG>jVgDisO0SC_tZOZSqCF0s6HD{5W_Y+ z(||vnP(D2DdFSdTkhSgS1ihz$@xMy0w`|pQ9A~4^;4xq;a9YvRBAh3XaZTtr0KTL| zbiKmOf^1XRCh&%u#5c?3^6*lhIomo9^f%CPqU3semnG2aWbO`Bo^N0T@KVY3J{i*A zGUROQFTkCR`c>Io_PiHi!7QZ8_appHw?thGC=wb=SGJG29OJ^z^Jyt^8lBT@fP^CZqYZejQmI^OfyPjb zjg3tLVPkPl59|kwaUAD1<~C5Fev_RuUnXK>)gox3z2i&3cmyw#%cZv%qyElFm%@*S zhAv)gfw)*09v&VCeKe97n7a&^k3{i|ufJ=FvL=vF`E?{ob&H8APz27uH#+)G%ap~$ zmDTp$aF%Q~KyNrni>(sXG?ehzN}mBjr$$94TdAojh)#rd3u_Hl8H$35~6A zqXA|kyhKg0*9`)h2+E8Bq82Bcc*TFKkyNlEIG0T1K!L7YCbPX&s^Y?N(svN~d27)i z5;;YRF^y`3>jo2;yJOJK8ASgP#ZuL;WV6|*!QL|D^Z8DMFGlf1<%}WVl}L|5PJI7c zEm0Q(TC0AC$n^;jc~u4n2iq_Hw^AWuJpigA!F|P%3z0j;)gWhCyAVzQ9br6yiS7+O zX;UZ^swl@|_-sR$iYzY#dy9Bd|IgM9K+eP2qd!F}CghNY=OaMOwC|;;CdwaVtC@LJZgKFun(*E2IS&D@TjQdaa^GEtkCjj%`YxP$kpAF-+t?BF> z8XCGBL)84`VbrDeQAxHPEEbEQiAJt|#bR-~(`(fNM5gsVH z-u@`@(8Tgxa^0gOlD7&xMV&3f%*;&eaS;STy#xXgo|I(U2a6-#&xv`AxuSn19LKrE zXz*R&0CU$%lO@+nQD>H@hpo`&+SVA*g>n&*r%Vw1bR`F9tNL4I)B5$@)irIq@Htx? s9et}A+iW)dQGC1C(%Ew;MX8!2kdN07*qoM6N<$f`}S<7XSbN literal 0 HcmV?d00001 diff --git a/assets/icons/GitHubIcon.png b/assets/icons/GitHubIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..ab038f9ad99aba74f1e121bfa67edc59d207f51f GIT binary patch literal 1352 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 literal 0 HcmV?d00001 diff --git a/assets/icons/GitLabIcon.png b/assets/icons/GitLabIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..bb274f2d7c6c006c44c215f0fcc7fc253103e70c GIT binary patch literal 1745 zcmV;?1}^!DP)y5n*RT7)LsCI0}FSQSD)ut~(!fDcQY3n3zXhoA$>L0+JGva_aQ3(!Q5J}pe znhU)Y5J@G(0i*%~C-pXMQ)4TyYDAs7ioL&iIQ-Vmu6KSfYi|*Pue8$c%rombP@kmuU=`jTov^9TpY=Vft z`{obC^zZrI?EK~_CSzf0XrHr%c^@xzGSPSO^6bWT?+!>caiwKd_1M;ECL^+i84?!N z80Y2W4M0T_r6L(=Z7UF;d}>hqoSLHj=W}BR)KN+C<6OYL{`an2-vq}*d~$fAHy~OB zY?6(2ZglU(xw-nbrX6rjo3g+L7^%vOo1>YGK;3at$OBZxG|9Iq07;S0y3N-iqZ}Dd zJT?sy`gCfv-&*-(W4i~{Ac}VHxm3B+8cRPG4v+3|$PFOt)~i)z|G@9Be74rmumjd| zf^6~tLDd8=v`*CzCuW*#@&FPT8tPL+0FrRhy)(73sA(&bbd4O>SNr-aqy2hMJ|_Eu zw}$)SZl1R;Sjp|&gUD3FmFv6<6mt%7gbeJt5X)$N+j_oJJ*FQU!@qL-F5X=*zg_yG z+7jW7C~a4I_PhK8x-l{mOml%Zpl-D_kZA^h%tu_K^R1#(ZeflBFbFEu>9r;Upl=QD z1o;*PV2FIj7$|mu&pcX*nIL?}!x`2oC6X?iGz=Mu* z3k&ctF}D~t68j>oOK3wD=mmD>C9#D7x^s@*NzADcGXSE$?Egj-gg4o!eg|9zmec1} z2RG&{+F~3c&^r=gvUXq_@Cd%#xN1$Y*F=SI8sE`1#OERPb`nbvz@({2mgG@P=L|%xHefW3Re5X#t zA%k)`)8js1dvdq2O1Ld^mUV=kib<1ZH?TL?RF{nvdkpkars*K5#lEyXe>F`lULiSa zkzTUc69S-$xbq|5SM(>Wl?or0i8GUjtE<9|k1XUk4!t7|YdwZSoN+o+hgTJT6cj5z zlwCCCr#>A9c!IulDtaJq8VS{@hpt9R58O>Zb0keZFc z63yfT;!B!`i3qcim?x9Srv?BZ->ZCR?z*Q`iMCgqUKk?Hyb(@Yo8jbD{$+oUq(F#B7*uHw-;Z&zk|BhgELou zorhQM+kD~F)!$gVI*I6e4IO)FC~`pvg_37{2aV*n(_G<|i`Frjmprtv1M5C~HKPj5 z0{3H^r8~mXt;V$}_d3cuYAP%OWO zL6LaR$EeF~&AGi5Z2CbMo~rygt{M6!03~35+!utzWNQdI>VEf8PyQWi!pY5&hb`t+{0(^&?y3)%Jr}F|1bUxJ^aEdcx#zE00000NkvXXu0mjf3+zEb literal 0 HcmV?d00001 diff --git a/assets/icons/Gitlab2Icon.png b/assets/icons/Gitlab2Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..03684511b5536d1b5d473f3ee677ee7aaee793d5 GIT binary patch literal 4587 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 literal 0 HcmV?d00001 diff --git a/assets/icons/GoogleAuthenticator2Icon.png b/assets/icons/GoogleAuthenticator2Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..311a79b1fe3853b490ab88772c4b5749e822c5df GIT binary patch literal 2620 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 Hu0mjfkt0&@ literal 0 HcmV?d00001 diff --git a/assets/icons/GoogleIcon.png b/assets/icons/GoogleIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..c6826ee0900ec05139f3e449168bbd19b75b8b4d GIT binary patch literal 1883 zcmV-h2c-CkP)KIYyGEtWeO*Vtk4Xci+%QlxRWI;0>%!LM=kTil!+Cw9Vvz%_m01+6M0E3mb z^aeer=e+OZA6zV@=bZOFwCwxWd*0vo`90s~{4VeBd457a;7mzLmOqr3g*YV!nd!g; zfdXJi4_X)KS&$<@gDCZ}s6RaBz=5vY@hzDeD#*oo#fwyR6(Y-koD90@RCK4MY|aZu zb_%H14m<~-6%|%v%c+&9t_3E0wNKIWVG&*)F(>klRN}a`@)SVhg5n}|coVqavp)A| za74a4s;0i)4cS!yT2WzLY&!XK71jdQZOL2r-8SDc)cg?sl#lMzfR>x&DEMq7TX?lTe(urNp3C#53zk~LghSTx$PtvcYIewXDA zR~*B`Blk2!Qjz+c&u25IFF2~iGHt!&$7 zCnZ)?SQlDOl#0r80*iZ#$H>xxJ@qwi9pAJIwSI5x_}G8*KOpr#)Emb|oG0>Y8vgFV z27tx|GiIwTn-L0uE`((xYYzU+OCLAwLbe^+ffw;Ao9t}8FB9-(ba=vjK z{}Bw&-h{1jP>&LZZtJ0o9OUAcuH>p(1{x;#_h=wy=k1xtrOg+NCL-MdzmlfJ((3rjRpZZO@GWQtPPpXe0Dd3@ zWThh;@16Oy{A%Cr|!x#fNXX6q7N^UToU{@>DvWKOrNp=L2[^<]*
([^<]*)
[^<]*
([^<]*)
[^<]*`) + 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 +}

As#OpgI4`?nh|E2ah3iqaGN-bPFjRuF?dw`bfO~>q9o@g&+U-!sc%);Gwzr1<( z=kxnZaBnz{u5-gvbT?cJK#GYAybZ83Hip%0vpHwq)u-WEJ3IKVj!}`VTl>GrJ9mBi z;~)!MP-7(c3-ri|~FYf#m;HOG_|$^@%+#2_s#k+98O-E^oqH^=$I?Y8?MMI z@9nM3ZGs*FWIuT2lTYo?A-4|q#R{fWHJ6^*RQ-j!J*(}7vh{6CM06J}Z+TSo&FjxM z?fo08Cyo8n<#8ulG?=n#-4j8v&vYZ-Ir!*~U9J^h!`we?D2OJa@A)q+`m(Qkj7y)g zJN*2hMK^x+Zm!(^rB|w>W1jUY1|=*;f7_ZH-F|%Wu2?E+>BhGnj9HC8u|r2@tJP`5 z2@q=f7XFTsbY2`BGd;X6=if;K04Uy4zENSd=O6&kc7b|FBqFNkR7_MH4I0Y~maefY z9beN#wL1G;WP!H%1e;eP+V|2D<=3?@AVyFQuPp7*roHcb@xiuV8{S7E*=^Fa_* z5O5p^f~Qhib`P$>4t-44g=a8{UWZ?i$nZqYEn95T^Uu`HEAst*sRdHrkPHMQF_^5z zr|@-6y#DH(U_Vg%{Ls_4a8q?O<=rJi117kOfxVUN8)^}1DJRgjxY>v)t@-hw!+p_G zZupIVJ{BGQwaME=bsBg2s!0^b-xx=;7S}yDCFwPjyL3{K8C5Hu1z8US`b%RfSkc0k zz~!kW`=0*h(NsK7`S6UY^6BF68x=}#PhJW^PIlbaIrESIT;2R6QknL~KBI)bcuVAw)lF`VZmS|u{O%1M;{{vu% VafrJ=A2I*{002ovPDHLkV1mu?j>P}~ literal 0 HcmV?d00001 diff --git a/assets/icons/IFTTT2Icon.png b/assets/icons/IFTTT2Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..107bfbd14b5f7e2e7d3cc5b6ba825927bb67b027 GIT binary patch literal 326 zcmV-M0lEH(P)OG#3KeC0t(f_#Qr zLc*(ngoJki*AhVleC$r%NE=gJTf;|}DE04NQBDfR9Z$BfK0%y+pL1p@tN}Uuj>CHT zd|V?rmX1CHDiE5B?PlCfPj>v|!kU9o4gac<8fz8k6PyQ!ZOwHFsleGotTUhjilQ8z YH)nBvd3EV&`~Uy|07*qoM6N<$f&jFHr2qf` literal 0 HcmV?d00001 diff --git a/assets/icons/IFTTT3Icon.png b/assets/icons/IFTTT3Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c3ef073d383cfb9bf64f63a9a1c0a8d937d8459f GIT binary patch literal 342 zcmV-c0jd6pP) z%L&6U39#JvSd^RQ#dk_5R~f)86Vafky(N^~co=w35P z2D<0`pg=cr8XzL_jKH$hP7(MyuMQFTYdU8LtWFL`2yBi&CkX7$a0dt+UPQ(b$XR3= zf!u{w5hz(~5rNVKYY3c4vV_3dL@Nl?BwIkBwtzMQ97Qw{;3}kr0B11`1h@-&|Hob; z4j5yPL`2e`Afm@44G7XZaec=z1-W;p(Be2rB0{U4kV1r(jV*x)t)~+=qB(#Yp#tDU zr~$YTssIjzI-nL|0#Ji612~H?1vrB+2Pj3D1e74m0&)?i0XYcsfFpzpz#hU4U<=_2 ou!e94Xz@_c3~~9yb%7 literal 0 HcmV?d00001 diff --git a/assets/icons/IFTTTIcon.png b/assets/icons/IFTTTIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..0a4fd34aebe0f4bc7a2a72b7c4e1376f41e270f0 GIT binary patch literal 175 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtGEW!Bkc@k8FCXM>P~d5Ocx{JZ z&x0F1492$nax9lCl#l$VKYnSFN$y#oS_X#fid8}K88`2qX`9ivV8e{pJ3jB}d0%_> z%$>Kdg%>nl{iZQv)v?c-CT5-E$Z9zhZ?`;h%Rc}FK;NkfDvTM0W0;* WOSKXNVxIj4aXnrAT-G@yGywn@eMemY literal 0 HcmV?d00001 diff --git a/assets/icons/InstagramIcon.png b/assets/icons/InstagramIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..cfc21c9c42a4778ca699720b38bf93848bd4797e GIT binary patch literal 1285 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 Hu0mjf3@xsQ literal 0 HcmV?d00001 diff --git a/assets/icons/LastPassIcon.png b/assets/icons/LastPassIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..fcbed3f670b8af9421fcd411db906497d1241805 GIT binary patch literal 627 zcmV-(0*w8MP)R z7-zxonCQ7-IS&cYahlCy@u;8Xo5WlO&<0e+u(w_(&104FvrK%Qn8t~?&s;f@S0{M`^SU9)ywy03zUyRhle@-`^?}4ior(L?z@D z)1&IlOs4NWK*0F~(PDUPWBS+;0CO>t43Ih(9`w^V4d7smW<2NrnC^cQGg7f3;+|OM zXEw1RP=rP)PSgZirTRBO{3_w=ybO;Sw%rEdON>NA^IC7oW!?kWiCLz0uIq2~ZNMAx zZvprk5qi6scPlYzUWrVIc%{^EYf+{dw1nP<;47l5ldYE5w}IKu+uyU@;3R?W0jLA0 zGI-e=8JoZG3_+B}u$xtIP-gH`01gOmz_@7C-5YW9n3hw~#PeXR0dN>tWzeaqdUJKp z9;bKJM&&3W2KKOlfaz}&3DU!8pflF_w0~#x2|$iqYY2iM2!b#GzW|XT!w|a^Cc^*# N002ovPDHLkV1f|4AaDQx literal 0 HcmV?d00001 diff --git a/assets/icons/LinkedinIcon.png b/assets/icons/LinkedinIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..4ada56fd6526366b0a35657518db271fdb09e9cb GIT binary patch literal 1008 zcmVq;km`^c`B9=Y-u#u+Sp(q$wlGm?Z&|Q<7ld@!9;Ks58wI`+K2U|rwpi6x8oQ* zuG2JlQO??}xZmf17GLG6CI&j2EqOLE2{ImJqYp$jGkLaXYHME#nx1xIl{)-&6|>>E zB>2Y@3((@LT-C%tDD(^cPeZn>hrwjpn|mQXok2R8#D`$=ucNv;9| z6}iFh7V*2m8!lK)%45+JVcx3MLIF~LB```vOyZjJsNz_JMXcdp0lDD#(ipLaJeyoZ zU@Zn`M>B5s94XioF%_!f0}Uxmfy6B1_`&ELeDh&?{Bdyu9gVfPa?&cnh>KfkT$&o0 ze0?pB5l7!QwAPPo}>>UG@n?e#_N-)J1F|RZMEoDfy z3K4afB2z_aT$(zu?6C1FF~z1zH5rhpkkY$RC}Ti&s#TW(856G(qwv0};tUM=moXXS zq_+vljAYc5U_|+{6v54xpZ1o4AL~h~KgA?~g=E1-_5(Ok^+-~P?feH5BmC(VReOsmL?ufW!?`4FEprG!+ eql^CE0t^7#79!j literal 0 HcmV?d00001 diff --git a/assets/icons/LitecoinIcon.png b/assets/icons/LitecoinIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..5765ce123446acb517d1f18980085836ffe8f510 GIT binary patch literal 3529 zcmV;)4L0(LP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02*{fSaefwW^{L9 za%BKeVQFr3E>1;MAa*k@H7+qQF!XYv000d*Nkl%3T76#zlPuD--7yJSG zTPOsBf3IP@85oAcf85I>Akx2mwC^&0WB7!5vdBP!pi9v=F+;>0Qv$&mH zii?VN)vnX&(|hfAz3W|TukMqEzj_QEI`m&Bo_OMrlTJFR>C{tCoi~2``1Z5TI;-pK zv(N56=bUqDhVuy%CUlJ(H?Dp3=+SK>MvNF^j=6O+|NJp**sxZ|n8M<1ovUVAM)`Q(%7_S8GE{aBePV z9(?e@gpBM+UyjHWYt@%MI!9*xge;X!*I$2qdiddo)22x1 z0uq3}`|i8+^2;x$#~ypEByw9@TbeUxPDxsy^uBoU;`Gu>FBQgPt_X!AAukb@@R_(; zCU6ogfA|1?+O%m0RFIE9{7(oIm^QvuWMBbwv`+RjK#if4|Io@4ffZ zTW`IUo__l2l2a-Zo1%gsME&^VkEyGxt5i89_lV^rVK2Dgf*!zvdEpJFEb!D*Po;$m z7Z!l8zWQqF?CdOT0(8qQx1?*Xxu%pypEuofQ<2f^*|Uq300xZYKKbO6GI#0HrD@HY zHAPAs@fl8t#2t6sktR-@*zsuzvme;)J$?69(cG3l$F*CH9_s?jfZ5Z%Z*Ole zQn7mi4|st0sp1&e4c6UH!&(f0QaHfAJm8?>;N04rKp;E{ zfxOBUvb7HT_d_$4Yi=(F?66o2>5zxgNDhDiWRQ-(fd`a+u^oR0a1L@9DbuGsj36M)5~DV zb=PPf@GrjjqLA{+E3XuZ0dB*KD99c6T9@_}4fi9AoT;F{zrRGoIXjnKc3H}0wWWa9 zlV^`Q03{EGf_=bTw-|P+4n!0T45~mJuL9S7V$=qu0LUW%G;kzFZW7d*q8<+TWP^0A z``d57r2_{J6jIrf=(!?wl;??@5JN7$_~M${&IkiM6*h~i?IJ@S7Ucv7A__j~cGap? zrEVhuFoi(kV$;IcU3Xp4ImdnHx)$3UPJ{#DgzuDU`w=oZ026XWCU>qi*c);v#wdS6 z$q*LZXz^&^s^t*i(6zxKeGl1~6Tksr4X=rL{YMVcQegR;zjOQ_(+O&Kshm4X1c1goI2W&FhY%F6HpwP3NkbK&S z{AgL2Qa5e-A`;l(6n%$AIS^6c0LcMBqnu&93Oo`Ia6{|B zSr03Kszj2M1F;*7swa1Si-`$W~)tcS0>VPP4fYyMINgTksg~-OP z$WZK@YFZ2(RC=3~ncF`5U~+;9LffB|0JvE>&B*ahCF zd2%O`*maKTSo3b&)o{icXSDMM1JOQX6;SmWOW0$`P{)-xGRn`ZI2@HMqBTO;-pU z!{%WJa8a}maO&opZ!T?y1f_RmkdLT}LJ$*e3g-+FQ$}MWlOyc0%{lifAo<~Do>rpC zdrihMNDd(nt%Pz0G?rX5oRb|O=3s_#Ib<)9Xq?CoAEk3--_Q@)K@IJTp$r?Y0mW|Y z#I)}_Q4vx0tuLo8x#SY9L{n+zvJ`6M0!&CqBpAR3T}D+9LgDwI4s;cGB%V)p49vxd z#Et<}3~PGu=K#{=pFH%A$-S|Ou}>MfgRbRgf+3}y&Uw(`M!KsyA-oL5tCetoq$Ckg zRmZTyS;@$j#FU^S7QGW&@f4@OQYKY85!3LkT)DCo3HiezQsY3#$9%ha*8lJAw8JZI z;Uyz-2q!Qj3IGEIEF&25W`gDexG=jXdL_Vtj^}4l3*a`V08XA9QRPUAe3WWWfze~h zF%b^=OrAVBjTkZF_Xiq>$Fsa2^hP-)36nTsA<5(uD7sexBp%ixtknpFGZBSwC?YY~ zDRb;a07qiSBW1A9K6`qgapT4fe57=Y8a1j_)lek>;zUG3=~Q)86wX|J`Q?QreM=1O zMGPzr9@Xy$58o@x6u>F=Br%E7enejNQxqB-8wWnrJG`pMQRvhCVT}{y6bVH0s*)&q ze((%!(4!H*&PY5%kAzX}u-mX<<8~X9f3Z%IG>0Hj)*Sq4&@_*@Z;>?K=`ObWG1gx9O2Jp(ZgbmGO9{A?w<^#Fr|FIu9 z9629~6MkHx)kv+UXSrgI!2zs`FxJ_3kGa|f z;9>dGM>a4r8|qOq?FY1N5zcVL;@Asm#o!!phI3%US%1^JXade2@RDAnuDa?fzf}4` z1Xqe>E6)6noAXkq+ET|w7T00000NkvXXu0mjf DGK8~t literal 0 HcmV?d00001 diff --git a/assets/icons/MacIcon.png b/assets/icons/MacIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..e1ab6a679a7363dd259c175a55b1f1ea4b5e2922 GIT binary patch literal 4373 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 A9Sb;=JpRgV` za|H0}PjS#Zk%mRIBCj$lz!z6bplw$MUCQA_doE$v_~{)FVD;|X52L5e!D~x5KuhNk zmj7UZRfk>h$%#O$G786sM$!1-XgKETUBgrKddYhvQ!@(~{&EywZq&xBug#&oQ@_W4 zbNY;zVS3q;cAip)`K+P}B-7xH%_zamp0?A=4IE`N_Jo6b;k^^-SVTRs!ZaUiD1z&) zO9|vIf!xK)!)}46DCc@#;&R_Dj<+zth-){ z4K`)iL@=BlpPMFOp}sX{qy>Lr;F*QRFYA1+GR{%wEki2UlX9E$n~!;r zrsdyL8mGa^DHEAmh~R`g1kii1M_2}&f|6nF)5B+#h2L7rMt(2j0qsZ*(@?Let<#k!SPfu94Vv z%C0NGf?bxfVau=MxlbH0daVn_ZSoQipSB}dfXw_pN*H69*AK)CqbhN_ac|Aij~2#X z+!Z1Mz$y9sm+lz#*$wnjpCkY_=xi77wUMa>BEtM-nvWw=3dA72Nf8es<2+bBBQoH8 zGX?emiLmmDCG!z5ap>XGqL!Yy?Ae9p7`E&hMy#?E4;{PS4db=F3B+F*;)rreOy3=j z8G9l`n(@rVfNYw_&kNfEMH-IV=!r2Dh>@!uF?@v$`i)cWR9~C-wlK#z9T=A@qRirR zZugGL65oEDgHuG*ZG7Wl=@AVx=Wv|24~CJIKXkkMxT~dmQ8wt^^LS=~8PpeBV)%#F zqBj_1%ona0x4{DwzVZ>~7-;g=0HvsxCT;N-(H>8|Hg>(c=)sYnI*C9GTV{nJADCmn zbZr4JXz*az`^LdFBu&WeVeJ~6Dx%F_-t7Go#V5q8o-w%W5&=`EP@J_1!pWlY6@zXVZpq^*78glP-+D5Y`7P{?d=im3l; z;e$hFUN~Ul()~X;-qt?3`R$DtD&P6~lH%RXmlf}BF;{48wNPkmyQ> z59m>NLvzU=1{d5E2Ta`*o$H@WT!nr(PdewK*mu@h@!c6G#U3LE#WyGI6+2JZD)f$C zuY6z6c(c;=f4DsN+;aw0@t&ya(4j;9l&w!t(a_L%K~1gK?{tU}BSw6vY<0A}Gv~~Bc>FJxr=I#B^N?TquUF#n P00000NkvXXu0mjf#!^n! literal 0 HcmV?d00001 diff --git a/assets/icons/MicrosoftAuthenticatorIcon.png b/assets/icons/MicrosoftAuthenticatorIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..c6b139cc5ca472c4dad4f6df397208f1f002c76f GIT binary patch literal 2768 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 literal 0 HcmV?d00001 diff --git a/assets/icons/OriginIcon.png b/assets/icons/OriginIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..51efcfdbbcd7c764297a7c51f6664e3934d14b26 GIT binary patch literal 3393 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 X!Bgoa>(cbL00000NkvXXu0mjfE;W!k literal 0 HcmV?d00001 diff --git a/assets/icons/PayPalIcon.png b/assets/icons/PayPalIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..ade324b7e4050a6cff2e5c6c9c5ba3f8a511fd28 GIT binary patch literal 1926 zcmV;12YL93P)8xqvCBm@T<1W%Pvp$d-MRDcjrP+25|s+I?e zNYjcnQmUx55^Wz)C4^d}inTm6xQbFrLn27iIwf)JEROA2?sCq@gJa9|!(hyfK-_YN_ z``B~CleRBVwg-*jt@WI*LxIK3oM%4z>ZuEt8*X~!$*T?k zDkbC7p$n1JeA=zSgbYEN@&wFT_+l1QNb$7-nN9ByP!u4}9OyMoasTZ}4|M~WIes34 zcW!F>d7;zE&ySu2W|5wO;E#8U)e~nP zy70w5V+COQ@Y4bS`B1;VkQFU{JhXEcTWiGQU{5!o4Mc>~f%!rDH|M&o031I#u5SM9 zp*C8U9FFk{q``{5ZR7uQR}rX&2B5hKP*Rj37|;7dRsb@-R{x!tNrPhLKh$11&Z00y zK=sB!J`e2Nz7V`Jlv}I-h!W)mroZ4mwNdC?w3dthT+IRz;H0e$-27os>jj!Z+Rg(1 zcr|pDy3sm-LoZq=a(>%VtJaWRJAu3hDL*F!TP<1W2_0zOY#($4q|SaP3Iqw2PJ>%E z14=C>R>6#QCI-3Hu4gc_juX9Hl$(_3w8AnH8exP<|U z6pX?7dYJ^Yp52}gX>0^G4gen-0B*Pr^n6h3N^Vo4ebEzp`u=TY0b{ro0uMlcoZI$nUaJkQs!&lU5RG1FTtPJ>wSpUV$Ve;iwb& zyZdgG%p2 z*EQG*DOPQy1R88_t>|eB1&$JaeyjqmM6R^P8kZWqy;=b>@7@j zst0k>1sju)Y`&tvb3q9pq2ydl;Uxl2V33Uo&NVplIu(u>jfu;9`z~DpfP-&JxGx>r zj2(5!$|fhy(i$bxOnVhrFVV#ef~6n>7|aa%S&kX%oj4% z0kF3d46RC53|-gq_hp;Gms5e*wUJ5zZOxDfZePgvt^fzNt)5yD0MurqRW0v2K+Lt_ zdpaU-EZ6vbu?5JDHQh%5#2g?Nt2xLbNhKIrIRKD;2=V|tNTn@#ah(uxw`z${U?GZN z1V23KE(d@`FKtdbcg$qW{ngSwwV_IP^# z3Y46En2m?udNikTU(Jpmkk+``5E!1w45x!-x4^7`KdzZf2>Cb)(}-F{+Vu_2Tc>l{ zWL6>uY*%2oadyxo)hLuAz{uyir?zw_4c-d?I-WYCZhwBPsq~NgQlZN_EI`EtM*!gY zT^)Q+07xYj|M2OqOhVypY6>+015mSkheSe2W_8@zje~D4+kgE32S-+1vL(H5!TpF literal 0 HcmV?d00001 diff --git a/assets/icons/RefreshIcon.png b/assets/icons/RefreshIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..1ba04d4db6b9ab70d63a530ced437bc2c64f707d GIT binary patch literal 2021 zcmVPx#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02*{fSaefwW^{L9 za%BKeVQFr3E>1;MAa*k@H7+qQF!XYv000M6NklgoHe+^_~!=e9J82G3E3xTl@oVzu5TQk;?aT|gPN zPAE6cV7$X6CNB199Rin%l&|PPvOzL>TuNCf3K(mF)G9mF+Wm$}fYVHD>|pf4MUBDb zSK;yCj*H0S^v;uTeEq+5gDsF*>-3qY;2IMhI}9#Eg0|J|r_3X#sCZ0{)RnfCHaFZ) z`ZqbYfsR%8Vo}G~BCiOqbUJ1w?uZGEc#A>AZ=Ho=rBWHk#BUv#VQ$*zVIO-93jnyF zd+wW~Y!gt%0)PGePr{?bq_d7UkbuV{vFzoP<#AmBExD-*pnU~0UU zUS)#{({#a|J9kd7;jqw~=Z@zzx@<}eYBEHfmL62dwA%cAvjFGyme~@l87pL@P;s7U zJfo%;+*sAw)Xl-Q3h~(ugMRXq3caDX%-QU(;W+y{EHE>jD0nt%OGV=~xf(#=7GeSX#Rwb{ua&Zu1Z< z0DQt&QmGq(gY#VWNq815zl@^1#LUP1aUCCanQ}LD>S0G z%L|N68kiyzc@2K46n1fE%F0PPAEk(dQ>ew*hfq4Mi`;EfMk z9eD%rLXseLgh&8phGNDRRpdR%-&DF%K5B?O(KIfJgX5QHK}T+gO|XMWUd_>*Zy}Di0L$abEL~ zUoH=KjYA@1-Em+iTo~jue%&$?Xj|RxF@S@NH9X2q)5$`mg@m`G6}%n2-4mMx7O(56 zmG%`W1L#EH@r}F}GflxsRCJCPAQVWHQRDPtK91;a90{fYu4xRe6{#yFQ9-+B4o}8O z#vU1ruONjc2MeSW8@S-7RrUJtOkQQ|TS8`LW*lCUuIDrkax(c8zP=tl(?_o6!L=XR zl*oY=AJ(>wbgoxK{ud447ohJp^^S!!4Ezt-w1|Q7KW}g@SI~!DF5X0*r(;9ybJ@$O zdY!KHFq^hH;8{PDRX^Y(fgxMVEoug}-U_)*E?<-Be zOjPB@sY2cJ$qD*a%I_*KGsrpYIitqmGxRUVGc~q{5e%}NMhr5}W72`fj|V9ve3trO zL^MvjK}n*pV{FHg1cw)sa1M>|ij@*8ASS7C@jTw^MD*RMWf*qG^I|MvOq9NOWk{{; z;Q~xjnavW)pd*!DZHK0T6~jEhTfgLO3m8=UsV6Gwp2Rw_4Fj0*ls>hsqYm>FTuw|( z+|QPYK9Z7>j`qyKFWS}-OM0~lNb8w~j36&#{6JR2pr_8UXsoi%{cN3q&zOjJ3xE81|H!~H zC~}+KinQupMS6`>fj2A#He!m0kCKisw)Y5N&z}DP(-<8dXTCP@00000NkvXXu0mjf DNUXl9 literal 0 HcmV?d00001 diff --git a/assets/icons/RefreshIconWithLock.png b/assets/icons/RefreshIconWithLock.png new file mode 100644 index 0000000000000000000000000000000000000000..322bf64857c17b72cbb7d58f0596732e0fc558d6 GIT binary patch literal 2417 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 h2NL(YIB*z}e*+(CsJS6C8bQ=Hw literal 0 HcmV?d00001 diff --git a/assets/icons/RuneScapeIcon.png b/assets/icons/RuneScapeIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..ec48a446555f5102d19114323f58b032a550cca2 GIT binary patch literal 2877 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 NKAmQ5+(`9c{}1)8dfxy5 literal 0 HcmV?d00001 diff --git a/assets/icons/SteamAuthenticatorIcon.png b/assets/icons/SteamAuthenticatorIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..d8ecb4c408cca607fa5b2389daaa07d756278518 GIT binary patch literal 1742 zcmV;<1~K`GP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02p*dSaefwW^{L9 za%BK;VQFr3E^cLXAT%y8E;VI^GGzb&20uweK~!i%)tP@tR#zCuYs6rGjWL?9mfBhn zYvhk1I(4&^K|feUq+ytr%1Uu5Qj}?$p-cNoNnz3t(WZr(X^NB}!$dR$$q19g)HMGZ z+dmuoV?Ulf-^0C!``-85*W1jz9r*alz0Y&b^F8-D&pG#f7ZA}qjh(hIARxeZ$&w{s zG2hNLzP`TSxLmH!SFKw0zBayP^C|#A`}z6($c!;G5B$M2F|k@m9?eR?LY{>m*ztF* zqj`tpY4!2(`9zCPHkE+)*tkN~i31q-_xJxyi&HcY0UUgh!qG8sd?U-3FaJ;r*IfYL z{F06SHaj_xN-bm$0j&ySC-N7shffi+6tH;lVi(`|$7JYa3K6pu!1Xs?VdPjiTYg5& zQUG7bQyIQZmM&dNTxFE^%y7I@h*=8Yi=}3xx537Z8|m`p%hcD`N0XD2G&MCvPo6xX zn>TM#Qc{u-rSjw8f8)lRHdZ_}nt(%uf3VWfi|(DCEPm3Bs=qoZkjd|di$+qO+y_bMPFB7&Nln&{fKYa;g! z9XdocH8oUMS0@DEabLW6k?!8TD`rXen~9W^6d4FA&9Y_7ly+v$pFc1C%FD~eHLn8B zojWIHIW8_vSt+`^yJ`Ra{dD8T4Z3jQg4e>>@;6BZUG{T@AfB(7Ns*t~hO2nvt8 zt*uQuS}`&*Lc4eG_88O*(gmB7?sw+Q8Ks?ywQJW(zpkz>am`Wy76}Ak7wqipl#brO zBEu8&b__tuAvJX$^sTZ}aq!?l=~rD{Ev{J#h>D650#J3Zh;>IhASh$4)>*%Py;$)3 z_U)rruU<)e6a%xZ8Gd{J8<1c({e?GXzE;hv=c5FZ~e21Fpq$;ov8{(aerT~Mix963Ul zE?p9R0SLiUFxK^F&z@0wdb$W!A0OcS)85|h)@NE;n!N8-062X3uox8e9t#@vAA=b? zw8P`akHynO$-Q^)9u*W6i0X!2*l0IAJWMSuEn=s~$<%lcoIQJ1#s+2-fGs;QF;VOT z5YpV-EItPZ2R%BUiJ_q(5jd(9#9&e8<>k@y=g*b)Mqa*rDQu0gff)tpAeV8h*t2Jk z0RL~@x|Mo*dXxit11XV^kU&?jUZt|KGLfE0E6l#x76&;L5ga8{aoHAN1r}#XNr{*t z^WfgVQ^Z8z6Xz+Mvv8n0aNq##-Md!^ME>hVz=~N5&{?-`o!DGd!Ky$R#muA9K?J}$ z3k?lb_HV@-697;_ii?ZgYP>gca&p932zvtx#Q%B6c%uRUcHsvP9*CobDrg;u#4eBe zj>W9%%MNc`0Kie9wzih8T)ASoHc%>2jgUGxWvcqI!<-1v4}v%sVaqqZI%4K=IK=w} z1YW;>-S$@~D&|B0ICkuq*s!o!ZQs6~GBY!Upw!e);`PFBF@NW&>VJQEeaqDN|ZnDso>xjLjB~_ZP%$6o6l3 zl*SHa2r(N|D9U8ubbex@qyTP6 z#wW~=*rA*i|7qxCUf>w|n6I_a+?u5Ty>SO0v-9^%71PP|&NVz{H`B!b7r9)nFf9~i kn^Az%n-G9M?=H09KOa>AjiuWV?EnA(07*qoM6N<$f-{FJ@&Et; literal 0 HcmV?d00001 diff --git a/assets/icons/SteamIcon.png b/assets/icons/SteamIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..0d6d4b1ac7347618b406710a7a85698882645a4f GIT binary patch literal 3896 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 literal 0 HcmV?d00001 diff --git a/assets/icons/Swtor.png b/assets/icons/Swtor.png new file mode 100644 index 0000000000000000000000000000000000000000..d31781e6697aeea0dcebb04dbd87c69ab978e31e GIT binary patch literal 6370 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 literal 0 HcmV?d00001 diff --git a/assets/icons/SwtorEmpire.png b/assets/icons/SwtorEmpire.png new file mode 100644 index 0000000000000000000000000000000000000000..8c30df717fcd3395144fdd6473f6ec7a4f8d4d5c GIT binary patch literal 4603 zcmVPx#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02p*dSaefwW^{L9 za%BK;VQFr3E^cLXAT%y8E;VI^GGzb&5o}3BK~!i%)tY&1T-SBREm7R}*@qm?zHx@+ z3^^Rm4EJp`q$Ej5x8K)CPn^C|sn;Uv@Sm zX%WOg6LblR7DfUi4P2zD8%sLN-RPgYh|&beH!6V=t#lYYPde!O0f&+5Rk%=i9Zubh&S5_fTN z@pM5!!MBV?P6DE?HSwvUKTE<*t&GlOv_2rGga^ z8cBmzu3Y(kXJ_YCkH-_nxY_C9|M#ve2oFHu8+yI|21SHIA%XX0*|KG#(P#uABUM#Z zVzb%A;czH8oz8jecy6&+1mh?M@$*;-uGR;MMB)WFz=nC2rla(7gP7gx;iCHI2k=uT3V)vXTowK=0P|d7E~Z{;{6U7u@CQ$LD(R^vkyWag5VTFt`soz zh+@csRbiMpk>H$zE9CWh>5yxXQkqtDZ&xt}ezT^gMi@Hu5<{jKG0$YB*hnOD8%cO@ z6rY)dH#cAfED%9)r4ZT&!3V%YAO>>4ZHA~Tg70vJ-EOz?oOk5ei_nvomi6f_Zc1}@ zyIrXq^oAuPp2jfQ77A|xV_1P1cyEujf|1ifF4uw*6(r`TmEQeYf~A_|PRUSVMPPNIFg0vaV3NYYe$a{u zXe8@66FZ2|se$->2pI>A`)N=Ku{O{K7Vgek@BoZEfzQl>7Cg_zT{VKvQz8M65eYZ( zouQ1@yIy55;GdeCo0W2DLk>_B6Sf^gcKa8eK4DdWR875O3 z#LfVQ%qGwTShFJ#zXjr3VI()Nh2oi%=m3tFUWFX#>grPR zj70;lMKaQrYbi?9CHC|j+=0!D+dA`rtwJ0ja}c=^!}6I>nf4;IM$qSs=H$v41|Ns# zM=?Km*iOXl!g$B7>oX zFrR?#`4e3hJpX1}TbnWgcV8}*R;N?4zXleMbBBfaBCO0dc%c9Z8^)mAStkHngeiP? zE!YDXI!6G-9|yC5#%usjfK%`|MSl)=<_bkK)V!I;&tkkDK=BHgiv@;J!Xkq_V0$s2 z2YJ}(e#RJMi_1-mRfRdgFmfUO_M$@!vSo(&Z40Umaf4xqp#e-rCN4cp!K(PrfyV&F z)6=XztodpgB|Cr-NZi%9tS7DAYiDe-&0{lqCqs^3dZeK#REEw#f5o5 zC!-Vo0d&uwrM1|sev5;I689w~rIG`&Tm^|bn@#%GXnnsXPJdR<2g_TCRFPfMJ zd~)cd6So^I{_aAGF!m7eVonMt)I$UKOGl=Z{sg+`4JbjM^Diu!j5EdAUFkhz+uP`<|aD@w%5qjZ$kIH0a@raukptyTFQfrY5)hO0eb-( zEcWy}F=09A1P=jL9mYPxXF1pi?iM=S7kU6o=y@1$0bB$eyBh4leAPe?UWD*|h^E)s zC>9ptgojO($6I)xI-iE_`7*i$;d7#|uTK%}_IU0vfE$$`6Bk0jelQ7GGFf$K01qv0 zpsoxzD}IjN=ZYi%SC8)*0gbtY@wj1K#ynTRWz1O*jBw%?5hequI`q0y8R2z?IC~)i z&D*G3QO^risE5Cl@q>y17-Zf6c!?({mPs1GdQSnB$O=OFxf%ozg}5dRYLa1Az6dYd~ht0Uu|r5y&`fMj1KAAB5BIA=Q&seCJhwjpDn^bc(0NN*IAWOxOwmlejZ+ zQ-I+_QFmJbt`tjYy3b#xG-#@DC44X@3k6rZxS&u5;Q1F3y01a}6PUkafq4EruXYJ& zC%z7yAO9;hmTL&L_9izp{*ufkRda#DSU!gUOD1!GJwFpt70x!A#+3mkFvUNGJL`K3 z*q9N|@TB+%JbxNyI zgk*W#BGWsA^3+V7^fo&b@%{b%(uy+*fA~dZ{0B%grxpJ3Ybo{x{zCB~Yp@9(;wg=u z%LNB__sTDS^r*~iZib4K2IdscQN{qoy)pOv?+jmi1rLsFifE8o60 zDewOLjJ)@c=j1zIACq7FU{=0&{h%Bij>xb7eon4`VVivG)lvEJw~xsy&uo&GSXg>{ zdzHjx2;)%oJ80?!_w+Zl9EZbk25rSHo`7hG!|9UagEsln9+$lRR7jFRjl3}7k%5p# zUO!$fFHL*o@pTsY>$5d-?TKpn+O$`;w3uW^uUWn{<&kT%0XaHQA>Vi`ATR89$%$c$ zd~-G^&+c`}EBn3j)d{b>ddM#g;W`-_8d5e@%>N-)eH41;`<{Eb3LyUTj6tq~!C;gC z&d8dQVkyYW#pw(nKS+6Tp;Q`6#OKt?>g7&(ozTfxw^?>| zSIWjjrR?aakcr+3*_A|+cU4JWy;*u{4bmOZOT>lmA-Rw34$8p?12?wbA9^mM@s3A;HBcuY*Se(&3e=)}B zc{gZJ`3+YeO*thBVNslqFAlq85Fv1Ug+rpnC9=YT$3rgVy{C6oOQID03m%`^=#*Uz z1{r~8j}19wV^AkCvWd&BCWO?F0I67zUvqSYo%xW}F-hwO;kWfG4gRaWV8RmmZ&WX$c6 zM|pQSC5#G~aG8Grea?*5JHm)B&?^2x&01@(INVd`{MS`Y-VYLypd_2TvH_x!^-ftC zamYZ_EkHd9}n*SNDWsGExVHM7ZD-OJrIx;NUZHP?HCn(y4-q&H2{|#?BQ$uSPl-~dV002ovPDHLkV1mF}oBjX* literal 0 HcmV?d00001 diff --git a/assets/icons/SwtorRepublic.png b/assets/icons/SwtorRepublic.png new file mode 100644 index 0000000000000000000000000000000000000000..d2eef002c9c8fda390f78fb3a774eb6b8221b2b8 GIT binary patch literal 5686 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 Nu{O6iD>ubP{R3+rL=+)l;ukR(hA-INu6{{L6+ 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 literal 0 HcmV?d00001 diff --git a/assets/icons/TwitterIcon.png b/assets/icons/TwitterIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..9689fe2e3b846d42041ff205b8173d680171f23c GIT binary patch literal 1246 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 literal 0 HcmV?d00001 diff --git a/assets/icons/Windows7Icon.png b/assets/icons/Windows7Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4caad7cbe5921e87c8b26aaed0d12fe6b15d40fc GIT binary patch literal 4591 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 literal 0 HcmV?d00001 diff --git a/assets/icons/WordpressIcon.png b/assets/icons/WordpressIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..3235448adc176d5b9b115ed734e7c7123a29ba77 GIT binary patch literal 2722 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 literal 0 HcmV?d00001 diff --git a/assets/icons/WordpressWhiteIcon.png b/assets/icons/WordpressWhiteIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..1a556fac23a07608711ee7500f7d0e2eb8e9a9da GIT binary patch literal 3076 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 IM6N<$f-bc(HUIzs literal 0 HcmV?d00001 diff --git a/assets/icons/battlenet.png b/assets/icons/battlenet.png new file mode 100644 index 0000000000000000000000000000000000000000..bb42e382604c7b4d6d66637d5230137d1ed47f90 GIT binary patch literal 28630 zcmV)-K!?AHP)%0zx4nC>ZX%564{%K_j{%K|6{X>*FPoo;usQ!(V8NPqCFdE@N!rV#N$!suiUd?E+}1MrdGHvRwH9r>U0j5r376O@OLY7o<=pQQT-b!GkpJO0lkPQ5d#~b zhk|{;3=2)Wo4BQUg(E8yA${};OA9h15lMOZ`Pr$MdD+~GRdXXV23^w!0=@NsS6^qN zudUYG)8K53NxQ3b?PZ#t8hfcn#bgv=NeL9l0rJPTP%xe|NcL}o-3HbkgK@(Lj+b8J+j`kjQ2-alFZ zL9bo9aKt;M#nx4)sE+BYW0J~}h@CSIaWhV{v?Hd&%gl;~3- zQz~pqhK;F+sXSs%gpIMVE)md`dbBo+tk|X5xxVU!2ivzU2(Fx7-rZPQU)R*qHK})G zQP1%F+O`Ra>W)x2ToDb_Mf82;rYaY!x7^~l$R(V@h+k1!EH4Xd%0s$TI{!on+r~rs zct{&B(WD?eWQ+%OiLe&xSsv8Iz1s4izA^-+ttwSiMO4j6HRf_r`ue+8xzKPjg#_Fx+c2~-SX1Z5TPx-}hCnun1hV1;xu)=RoC$kuM4g-JTaO}_{19x2;U$!7NG}1X|!?tbrKY!CxXSP3j?3(*t z+jj4v#k(G9o4z60Gd|nbMthit1 z(zA-K!iZDm)Y76Jani4H>*+odvo4|xS(!d9tD!_Pri$H@l#Xj)_SF=%#W_>zSU0Z= zi@9_PnS?n*zqc~y=^ra1kw_w8i_w|S|1mhBgldjDtx7Emg1z*Y^y9W)R*X!euIolv#Z$-HQONN zSyddfnjzyC+4Ow1l%bN+O$L_1z%=LtJ{zZ`m~YlIYshSu}k5oN4gyr_6c!M+uq$TX0u%b913|AnZS-8h?nuT_x9!CtUv~vg=LsB3Kvg zMdsX#OuP}PoP^MLziz!1oZOs3gh0%E9Mz~U2p=|ooYJ%*=1{VfyxawoY-KK%N6VT# z#!=Yz3FWLE9S9oIDjs= z4;kK$PS}M`+JlVSf%qCQP+qaON(=#xOv~bl^QrtiD!YIx zRjL(Rm)2aruBoT1yLDvqz{*{dHt*d2@WEY=y|wP%CztHJW!m!X{j+ZCnYbyEYI6qa zO(hlDl8__b?uoT{;`M=4lg^zmIAS_mNNLbVyqcheX;jb{G#QPfV6p^!zJ|>=()o6l zz{uh11$>i$qi0eTMLZcps%CPuOooOhkc-4Tn~|L;mReQJwj{ef%9=l9OO)s^FONV$ z3BUZP?A0rCp8f!33djiiNF);Q6@cvQY_V7b*9bD1NG6lOi%rM*n+VuLX9$si8SFrE zY@5mWUq~oIq9D0N2ss;EQIb@bz49S+&j}2y+g?WI--VPkAwmsmC?V9%M2D_JyH+D= z50>O=1db|Dm9H6ns9;oADl<@iyMD<#BNFo{Ieqo+adoPxW2oJg60b>OQuAx#!9_Fs zw`?50Xx`+;`p$)O=UumP;Si7Kl^Wxq;n-%)j~8r7JeI zFPu5JdgkzgS$&DdDr-rlIgr%4%Jj}iOU=QChk`y`U+MRM}7ivp2E;L1rA z6VP!x(5lHOQ-lTnqOuDRwHI4G7}cn*P-bxbHeu6zLYgBNS#s&LkqG_*8D$x?1smsw zYJAl3t#Y@Dt=6$+nqah~YHXGC%MT*sdlVIsNO`gAfj!3OpQtfulw~E}=N}4nhIs~! zI942d>K;u;jO8;5EfV3{X{87El|Q;WwS0_l?I3mOIBs8pwrJ4$!HM~I-?w#m=9YmO zn~+1cFW6DaoZTqgIwU!NX6)(}L(Wi*)?F^sSc67ooxAAsGm+aj>6-lX zu-C}fcvYsb+FEX~RQjF9m^SyT52tKi6e*VGR+#ddB8BrhM33(1e)+jAZC%|`RZt)g ziF7WdR%2C*k{;@Ct87ApwYN)^n^j04k~1A}iJ+s17*$~dT?GHGw&yeF={E^a4BJby z$>e)M7%0=v29GD;+&BqC;Hfb0*cdZ__k z|0RTyC}c+&aprBv!bgzFd(h zpZ}rjbTO;I;;-&#NOlr<_kp=l}@K-FeUx- z$DW&So7pmXZR^m2va&II?`e5@|BzTKG8m09v*3=+hA%%3)z?oA)Q(Xaq71G!V&{GG zPS=K2?)sp*p+u?B+LYRm))G}40vdy--dp(XC-dgcYmlhadQ-928*EOhhLfyabF|-n zF@NIJHiNxEX^X4%#SWt)?qPISN&HsX+U2TRw=p|AD}A}aJAnxEX#z^hCXt0qW?oJX zO3Eb=Nf3IK%ABX)D#!pg+?~v1npVdB1XhwzG6jslU{a~n{M=l!LeA}MP37g1sYSVL z7A-H2lATS=q7bskXch_0CZSnGgpxKODd~q`1^bs^D}li!k6DJUeipkuHStDt%(|>` zo3d^=f`N7WOGw?s%jgMLJEFoyY+ZylEkMM^Uzp(M6rk2JRIJCka8#qZLYYDKYXw}u zeomT0%O|@{`OOJ#$SumH6cLEoC^$UWucE*OAZBM}xoxsJV?=dfT~$mNcX8ZSj=`FY zCMS3+#s(^<^>kH#dT!#BK@VS-)LJ|b-dy&|{?a|y8OA2~l~s*&vGe*J=8;B;OeP`c za_VE^zkXdl)FBE~mWkv(8edZ+a0=~7snMlZ*k61^diK*$*Muc4Lvw7dwnraodusm_ z7T=Vcr!Oet@7UOU`czAK)v&jwTcQe*86uB9|NP0G)vIi+W!fbJVW-2cbSCBc(n6*t ziy>=?=KtyZyqU9VvYEmnj)}@J$i%7&uV+H7@~vkYKKSrDZ@9+nX;JFKZd*yBgj-!K zjQeEmN!g?(B`v=IbKJ3CW8U;Dgr=#QokPmWB`K9+K99km=E!A&vE2;}T8@xMQ^^G!R$+b~ zIX{<_pF_;eBIHsCxnvY9n}h&o0k6Q;i+Ifx2NlRvLw1@@aSR4$UKWqyy zS7Ii(tI+~RCS+8j`k}Dl_Zvijp+%1P)Z-dN+h&S3Pm{H#_*Sh*A!n3@r4?aLeL|8f zVOE7D{k4X%Ev$%@Ra+_wdzfmK+8gdoR7{H3k9aG`RW&cZbx+TkqvNc0v%7p~%9OdE zo-5tHTpjR7lI=4B9{&^fDEsR@N=uB%m))?%_y7HOM@2~GiTVeJD`rfOC(2^WH`Y!W zDtDL0HmxoGf8Q^iGIwfy|D3wkCHtOef918A_4UJ%ik^7A|G*<-X7%YxD<*rBL;lEs z)KVF7NWVNYbJOP5M8G()UKx#8t);E*(oTi0xPYf^i57kJ;pA;QdbReLNE4(BRJly0 zh;Q+lcsH&I{PnxV?LDooaG%~$D^~@a7D+%~)KyN6I3$b4D=NbBM2K5iDy)u*%A;b7 zjjq$OBX0JrG2)%`luHN2^$~?k$jt-7D@DD4rX>2%YcCxnwDFH2AT&`bS^1?RZV4Wi9=1A1;Kw^Ap%}Antda8bM`{2^4GZugPLFc0ReuKTLwqf|_n>DW= z8j%=m8f)3MwZ+SZnH{~;_)5Fq;<<0P?*IJvgjmwDblJe9iFL!X#_za((j5<1ZJg8G zFg#`Dmcjq`{qDixu0-FIhR(%LJ~4Ra*vxe^S|7b_%vYZ-3poW=ZAkC0GzJ@ekyg1o zRpym{eP+qpt=-93bk#_#sU~14t}ZPfueFvIada(l`d8=rH*MYMfG4DL z^hUL2%TnL}{Lk9vmblqdYj9WS9hGV&Gv$H(c$bMxL|{5kNgK@Wb8|@f zc@%0rE)OHj%rjtrp)lYN+H;qF7WFkfkERih!sopzf&^X|?5{stM(-Gb>u>rkZCp_b=|5 zxN-OH!E^7GIXubXDZwR^)aOsF?j4`71QXBQVH@ZR`r|!ZWiT1_ZJVwA@9(ZV>tlCt<5SyFg`QM&f zw{&^Z9;h+6D(%4zx=IxfsCCeV&w3sS z+&?Q9$)_PqAtEy&jxwZj2$`@IS@|fs<5j}7uaQ?kD&plU6&x|Y$gJU*RUDO|NH3!Y9XyMSY1cAi zUZF-wzORLI1dT+wY z8Cr|ED&94ryl(Q7PxLe$NxYVLgkJPT& zQc)bJUNxzBxHsjF4pw#c>YSy8LRG}X`10(M<*OrRYq{R*^8_mk<>mr`)@_x2^YO|h zOS|mhN}I3Q5pFg*x}q+gL6++>h{8UhPRwu_xL%`B#w^f_7(Sy|FQqAjbUCxgr{gyy z#1Rj*%qw-O*fj|*mq8^E$jI*m)=Yx5t^_L>fDfZ8nNW~TqUDo0^lTwJPa&l05M z7jR0qEFT={4|UhuR!mRaw`=mmZs(*if%OYoCw7L)+>(HS=T@;S5~_-qkFm9o!pI>} zvq|~bk}Cng`UAFJ@#t%&mcPGHC=grNK(g}CJStkmMwudlNSiaTig4W<$bx$ieE=2d z2@(U@SDC%}Sq!WzA4P0YB%h9E<)GL~>yt3e}t|1w`DE_q8s77^#GT-{E5`5dT2qcPKU(i#}YmO<6GFELlFJy+xWif zzpeZKeA9Hz3|T5wUDr77+?mQ(_qw{P4Dr;ENFOQdn3hbH4#o}D4(3~rl|K9AO?92) zoMn9~f0a<@7D~J~-CcjXHh6CfqmIhN*k3ZaCaX9G$lTO3@{Id;r-8)%t zEZ4Zh!DJI%97v>!Uwp86)q;xhnkm+Jlgrm@^_S}{wT$Kxet)}CT$s~dB`DT2dfHVvp!dAbT^5?mte zrhnRCl1>3m09Z+AA%#fGA;FAZ#>oTd+coSGhdAz6)RY(DbYFO0_*2gJice* zg7N+p(@SsM(7SY`s;x{{8&t#{0-u&?78l8xx%@&3E009WAr_>;noZ6)t>7AyuvbL> z1K0{o%qv2{yA&H?dXCx>sHX-kZb2j6gv4O>>|2PtkD_ayM%osk&GQMJ%g7V9=k9zB z1M9K}5L1xoN{}M0Bzuggu%H|N9J?p0FX+GPj{`dGp|^nlVf2#w_jWU)4Ni z?O@Nsr*H6dmB}^MxWrPvY+-nAhuGrssGKQ(vb%iDqMEu?SBat_D7P!=tH%lt?5@6V zd+#mFC+?V?ynkcqP(o}M7gFg`dwi&|rGE94re|-ec=6W0YnN0;Du!fA*Qznp_m529 zvuBMx)GIawY7?Oq!?lm!*0S%Wwwva4+`l|}=dzmnw$)v~qf+gziH6jhha%7Jay@fv z@W$2s8|Sueo;|o_YI4RHWkn zD}I`;MwtYE)8D9yBQ+nWN+!@K1Xd1FO3gCx^Sx?j!Yyu!X$Bf?BVFzXc1~G7Ejp>w zK7V}V?kzo&dz_0V6yLG6cgx(`>7A~@YEx545w&ogGMbK^C!uC@^U3sVVo??mb6P1V z;1x>A;1ydC_z`953bjoexQh5acg%kEEw4N(BG4id6ZZZTt`gzM5T+Pmioj5gj8Z#+ zu;W!^=|hMkg_=uIUp=X9Va}%X4+K^`g7~V??xpC`M+i$FCM>#-IPH4Um?fmt7@|E& z6lu`BBJB1aN<^{?P>vEQWWhe!b^@`Gk*@!b2tYuk zQu)6|91bTZC+A{Mkw~PXq9PuTmpS~if@p<>g}J%8kVz)!Dg-rTGMSkVS0OH!3#G7F ztcxG>@_ym5UuaHNR@Sd71w^OQiA3x@43Hf5y zn=fS#b~s9!gRx$Bq{9(!4OR??%7>y=LnSp6DjTMy>c^+r=C_P2Z5i5_Y@ZQppVZhf zH_`ci12I_>)RotiFG7s&7d}<=ENn=J6?8!0xjf#7TeA z*iwFXls;6>X^99_yrP^uy4Y0Ws~RXPYclBA3SM@JicuF=hvN-WOO;N?8BX!ues1Nm z)$^5lvqPsfi3+VUu3f=*tBOL#qNtgX^k^#^`>o!DRn3>u^X(FD#Kfueaob~3pM~Rc znL^3hijE15eXHwx7RBpEs#~YUt0y@9%~nsH(Oai?Rp{&qrL|ORPdNO|Hea*WULn?p z#410Vqsh&o(F^m7y<%LLyK z$`uBIaEO$Cei#y`AN0Zu1XLxbshSH&C810T%FQB(a!AU2vXPeU6BH!%?7lMfq$cb9 ze&2#Y?}J+hdh4wL3$Ha{xMo&az`|`y>31xsy4(Cvfq1Gh|X_yv4fs&Ce zE~IetNX#4}hF1y!@QQ;Kd&CudWEf3p%SMqL6v;)AJQT?X10uqgA3<^uBr9EiVrESi z9~M_H?6EP-Eza1bbR!VR&KPm?^T?71upa_IhvZU;rqZl&>(CpIVL!WaB@wL$iFG4g zOORCm90-wwM5t#xR-HI`^7QG`4?p~HYilcw zMuYyrGrvr5F{`YseDu*r@qC~wR4P?|etuI^(}NE_c$Gbg>yA6_fNKA$RQvbupFVv$ z{syC87B*SUq7aA@0ktA1avRukE=xqu4Qe>OH6pD3^kX*7jWqU24BJzi3I|M zNDgyigFOOM{?g`I(e}B?&N=nHiyHe@L>h)g5~EJS)+%HM8P6i+8WlpLUda{7-O+Zi z-r==_)7B^3rh1cOT8EZ2jjU-LGrKLSsj|^ay&k8#O{yyvOAI2JRiM(*g&GP|EmYWi ziLoW+Bd(I(ynI1-mD#{A$fC+@;a+D+uSj80Nt9tTZ}9~0+Bwa3r=KdbuoNY9zCkW3 zkV%SoGN~ukt}Sj7sN8(1fyvVr@eNe5h9T9-O#x4|IbJuuvSnt^$mXt*bxmWJ)pabY zZkkutFf-mbHPJjXT01$}Fx409^OW`!CkDb5<3knW9HADAzfNr~6G-fAj+{qlSq!XV z8*NNdtmYIcS@{+jH|C`kTV;7!WbChVq2vq^FA+{v>_==Dz?yx*U@(ILq<~fhC{l0i_$@SUThD2;&N#vq|A5-Ok&`R9?4ut@z8Uky!>i8b+|;jq{*;yjAP@Z;(247 zH_WMO4ol?h>?*flL%&VM&MvcY7j%2qjSH_Fa4#S0S=eJAsZ`fFc*QcRjhkm+WUC4( zvOJPFn<%0X#1vFWMtLNZLqORCgoPqd4X6*)>QXDXKvif0GzZ!R4g9?Y7a666yz*gW z%|1kFgVBc4_^41vPWBLXzK(3%kN7IFTUC@Cq=1g_6^JE_B*!8HYmr6wA)B5@cD#aa ze~Gy6Nz(LP#;T@`ZAEkdJR=z<54HT#K^cM(O}G}$i~I4>}; z|NQ4a|M#!a`Sa(yy1KHnvkeBrQ%^nh{rBHz4qSzxtbP0TEnT|w%{SltumAe5Oz2Mw z;xAjathBTg@cs4IUuQypR`0+6{*on2UU}t}zy1~b_{k@q3=9nX3|Mi}9Y22juXuv1 z7F;C22Zu3WGMSDXIRYX7^FROdm%sew)TvWY0i)3fUT~(2O9VCP?d|>a(@*hCUU=aJ zo6Tl6o1rJKrb8~(TW`Gu7q)fl*1;LM%F09auDRwK;5a^de_lkCNXyTQm*{Ldo?b?^ zXxSPSPa)y+2EYI z!Zk3@H@Lt9UF1#lG*_73f3^OTx4YkcXZpEglis+yx!Nt_iIq*gO9y9d?jGLQHoU%b z^5(iR3wtN68Y8hYM+p;uyG}JDaY&fgsR4tRP@IKB?Fa)rkL5~ zEh%f9Slv0lX3Xr$&N+$38G%@rH`e5dw}dMD%iCvF_snk}zpQQilHREs8hRJEja^pN zJhP^IcBF2&s&jt*m}Qmi^F!5>OY5eVHO(w*niZ*?9IhEIsUGKwbSMm^T%k$86LUBM zqmDh)Z1h_NIwM0s&y5DyA}Y0_)TEZel!ZVbUitJDHe_*+6%4*fmw^>H1xPJKkRlk! z09FLRis)(7o%1HY^56}(EEsy?`qg)ATGitB=(7pVJd&@F9HeEXczK-~dY_IyR!{G@ zv-|AqMgwDIleJDy^|L75X2!NAS%^WYkrhsl$%g&XUMqWyjn!#k)$8cx8d^|NXl3LW z3MuMbk}{hpClf>@G^CK+vSz{4yI1eswrJO~xhh(boIt>8m987q5alpKi*9}aaiuVZ(uGI?lWYwUH$R7LeGw_^!2)q%9ZW>BauB8f+*q`13^INb zviuQb`zy$SKyz}nK9~1auUw!peI@nJ9tkgPJo{WI`z>7#K5wG`kX#}CLMnA z=+UF#u|hrle*Z1E+yeN6K*;l(Z}B>R59B>}?i?N&7x-l}X3RKv@E}&;U%vnFqmRIW z{TZ;r9K5fu@52v2#54T*n{UpX$&~&xf|_sGumN0M7y)*>1Db%9mZ{QTph|b$eK&Z@ zFjWU|XG%&7YSPm)1|r~*!E*<&hQnc?Bpw3&0F}uU^V5O~fHMwh<#PGjwQCO_eghAO z%763CH+WXiIUjulopTC51Uh!?I6xn!8u-|~QrMS0J*6ZND7jf#C4Q-1UYK;rD}0=` zQel0B7x8eSUbfZf;496}(6B&hT{~bp{y^xnca|Tyd+H1GtE0jqxsYmeMasM80;F7O zUeGKQ@&t9wQ^L(NA~x~y+pXtb>U-z$O;5~;Evr-snVd+~z_=;f$IjV2X5yytGq$yl zThujS-Qe`CU6a?>4J~W0>TQb)2Er=8(WBP81Ch?ko|&}+i|fWLZRlB4HD+O|eUVz{ z=xR58{mty}P7l1XPIz#Y!zQ7!6c$@)ZE?6;XRn~i%{-acVXI8G4o6bmH6^x`DZe^o z^@J;%`w55MV=kOZX*7q!J>R(aUw>a83)tBfux~oR1jHp~!BDlew~p!4@U0q7vRKBYWfMs-b^d`ccQuVv+*bwv6eCo63$&sm2ophB z2m;1I5iWumFTEXG7W(YS{ZbN9l|{Z|)ufMKdBBmM>!A?b6heqr@Wi%7gFZtdn^2iU zh~*HQb@JzKSaZ{?uIFyv(rFU*Nb)vC=qVcI=2=6x&+p$cvE|Y2Ypd9_2!#+JqpsZS zhc_)8ANME-h?aopQACR(QUc+&#S`w|I8jC>3JK`889g7o{HUrRpMxL*1QDid#YRv@ zy4v9MU36OUr3}C;X1}BNislxOu_vxxMk)rdmjM>C5pp(3pdc=L0D-`!=|BEL03ar~ zu?!yO!?w&s=HH22a|pTNDDmbKl-+M;FT5kGW{4y=p>!TmkSYeIvuc&OeU+<>hkh(2!#1B03uzPKYjekCtJ5|t*fiU=S5+TAuYPr=@9m|wYBjq= zm#x#4m9b>!%3hg%^j{NoO8BeZBe)PEg_%+7om$)r_nn>j-uNhFe%ao3UkS{gL=puzS z>h-pV&|s$*u&*u~MRevQiHP_tEHhR$44+r7APU|H+6=ry))3{4790& z6)IhT$yXQ9XoZDzvyNBhQO)mH&hFucjPztNOTdv;M>!ff3%(d|Dl?}6rVE!)6<`EB z1(<>Xq0-DkB?ux%5haSK(|`lanNh@wA`@c4aV4Aw!fx0CSTG zA3n3a)xfLBCV+X!gd67$Ke~Rf-m3k-?>$i|EF2N!ZgS;U7m@E+G`xLs_43ZjV~_1B z;W0v4gcyb3B%wzi-rnt2`iN+lh(?GA6yeUz{`BP=tMvjif*27*MIry`-4|yyMS-ev z_?R}>;bT_<%!kF{>2@4-Wdo4v|6p^hk}rJJpSa9Pkr{;=bwN61-PHnXUr%lDDZl{ufF#B z=bwKG23)ABs)jEI{5b(kZSDMv5*5E69f?;IRFbU4t#K!djC9F$BgNP zXn5&BgRHE}y`f9vUncm1V-KyOr%fWfhql@el0~%88dl( z`^e(P;g!`Lb2NI5-L8E2?s2zG;F(qV3QMSAXl?7%4Sln(kGG7k3M)T7G4rqAF5S1j z`I=(Qsk`l8zT!Nx*ziD$+ah4)p3fUTG@SrcFtI4_7q80s(^g!jOGnJP6s{vmgzN*FKsrF z(PA>{Afm7DncZa*78B7jGNF=0ND|Qmz38(Sw$v#YzH|lTD0=3xYnHY9p|%PH(IAKx zLDUEWmjQ5H!OMoWTy$M8;uW{wODKUaC(YQ6{oO#Z4xg%Hue_=qMz{G?BPfD z&YCrg&S2p6dHwancye&JSFKvL=jNMFo_gopC+9yt|0#H&&mVYU>eQ)9r4q`5nzVOx zynQlL++&YF0WM@X96t2Y%XkRHKX~ZS-beRhb@*A^zGDZpG}DX=g09=Q?@2r>V03G1 zD}@4s^m0M9e^eM)p-TuvgCwsuBKO(!bulrUn(dGm^w$O&h0Pq&(9mP*7tPN=-oRa^&G^Jz2c-+gWRL-P%CYHl#u)-!2CQ{U2Xa!{sH&6!#K z#pk=fduRKJrP|Fs0=tB2aE6;EF6^GN%~D+X;EvGu@0P!CeZ%!7?lpdUU#0To+wNn# zjE{_QHTXDs5ob!f?9krEzkWUUyOX`o&v(x$x9NBcr?uD@A6L;ayQ_bW$>g(W_?;>C z^{bp8zPIe-*Ospvrw;~t`lfD3)lV`RY%U$MKgzvssps1d7o0ym`{3HjLj&@yA1wSrqJ|WpTS~h3yo}! z)L>BBrPQ(IA~rSOtK?Ufs@+yab4-|v%_6c;?4~+)al;o?fj$6MaP+`Y1uy}gfUU|j zvlMA}6$ooV5eJGmQN)iT0TdYum=CTjaS{k6C^~4@J-w>Jm!B0RqD~@e&ZZpQ)s>V| z&16(hMlD5oFK_Q*5Q%*@?uQRe3bXU73nT?F*0wO!YCnE~Z46p8cbAKpC_j0aFmZ z{^^KU3Ptm2NLepB_byW3Dk4vYs+?%&Qf%#Y!?VZ@M+tjQkoLTtz44ixwwWZZ^hXz1 zK>*hE>o zmw^@F^7s?`@ZydfJ=WFz3t)x0IYh(bU+9FhAAIn0_WlQFw_UTH!{K7}v07o)pHBWU zxbQVKHNd->vu5po?!bHRV}1L<*^k~jauhlNChXAX9UUDFQY5BEaC87jcE~zHW^%=QM)tZS7 z;^BUiLr3pP>E$wpQ0x#Z+(Nb6;H!$(PKq{9@Wuuf&e8t)UBkN%dyXy8Ju+R~<1f<5 zXex7f?ug~Y&Bp7K#%akysZ1hMn~pwW`22O_{uRa}v+d(c*=!+OCf5!Rm7G4Y?ECL_ z9Ne7Rzfj?p^X;Ye^_^37)=(&_e(lh}_a85O@x~c4sQTf_T`T8n>l9g)=ggeG> zuz0HW-E98yMcv&kmiwcI8ohuoviW_&T}#{%Ewj|j*}EtFr%xvQC6-NkGa3e{y@9#)UBo$8Wmfu+Fh-VTRPk~cX@B_ab4fxxw$8BLs$5Y z9?$+6dZ$oiDrvU(TGWP!Ruh&eJzSZICs2m$x`>5VTP<8NXzZ;Q8rA&4TA7?j!JJm` z;nI&@!8rp1wgP>CK^Og2m{W_=gvAXUSbZ8^UKA+-wi3`4^}eH?5jkx^H7Z+jrAc?BU0XvtlW$2dJ9__*mHt% z-77i$%Sl4zkFpg8`P#MX-gy_GcNPpr`!mn(hso)c2E+c;JMTiAGw*%y;=x1lvNCW* zT`o5`vv^tvhhco===s zbg?JFWd`r{^5U+veNSG0{cfEezQ8}|D79JxJ&b2{^w`@?P0ha&tk{d0#cWnxR9b3d zbe4&0eX3$R-=<|p!)BREr?b}Ef-TNimp9qzPxhwTCzrO2uSl7{J8%BvCGV@V)vvEr z_u6v;MjDeNc<{E+;hSC0G&^S|^E^h5S|B|4mhHQDT+c069NQ}G53-8g>gVsP`s%&b zuTM;P|J}+bXXy`3Q?$!aI=!p9eU4UZZ%FD7Z}J`9<9&5w*}2CwZ?4xJSgr*EN*JuM z3I4sye4oG2@$UZGrxpYsuC?6VVj2u6w#?GLcZYJ7!LmANToH6{t5&|eOn+OcYg<&k zE$Z4(rrXo%uuI?y(HTq*+JntzZ>`!Im#K^Sd_`5hIsbnNCbb!AxYR?+*KB^Eg= zo0N?buoZ4N5ja@EaMu;^3f?Pz+lrOGF9dUKFkq_+MNDar6|6WfyOf#hF355eWLfek zGH@~wjH)ISwGap{A~8ZHm1mLav&pF}vY$+PbbQF0OW9dkSWF>_$izerX=V=5oJaEI zkTyD~Q{=hTS>%Q+awUaanMF!uk&DSB7m4U55y2yN6VPG;8Uf&lMxmp|~rLA?`RX~=_J*kfQq-=ky$J^a6Ht5U}b$R=^aNA5*dACd~ ztv2#+9&0=>S+~+-x<;=VsuCW1&UN+){_E4VZ;h{>8nA0kfzsw7F`IKuqxQ&p!<}X7 zZ(o!j-(`9G2BX!;*XrFWDR*kMe$SBdsTu0~8a20iEMs(Bxzfd8OBW6pn$-#=vrt=* z_3#?)dpB}#^*ZLcHAWUkCt&q@^jfjV6l!+|JMI1!lc!E;DU<4hT!~(z0g4ukYj)R_ zX{HZ{$2FQmRyvzXCy??81PaVr;Y3`z_z3%O3IRa*>16@t1+e1x+<{q&v`-2%Zg5W3 z=`~H@DZZmX5NUd=y|CN~+hQW2j7;>A2=o1tE**cXC)Z9U^2x+_HgRb#QIgi(lD7oOaEdO9N4N!1Xl%uKcSUVfL0A5>z+ZbeH~m^1t?{x1_@Pdx>r#V|ipt6d9()MmK+NNBpPW2-YGGla z)9HlSD1?9km^N?OIx;eG!pO*FHfi$Yojb2To-PFp^0C=$nHr=8)T^tjd*#*F@d$hO zJ_g8u?^fpU4+Y@>GH}Z9%D~hc=Vs>c1+YGNp|-Cdegj-fuh$E$_)&wIcwgUGn9Jj( zgHs8Vce_28#Bl*rb8>PiOn#2qmZNpzmL-q@UN5206G+&fU^81R&prPFp8WX(FTh3U zV&!3nMx!-0Hr{dPUEo~faW1uc@4Me*G8GpWKl#+tcnFYp;-pC)&reqPqCvuo_rxv= z+77VBvw~|?Lu2Ew0xQf$Q6eduLh5QVEE#b+RQZ0Jo|=_i>bDymb(WI0P}MkBv@2NI z=TCNd%R8z%=QRz^m8#v(JQV!yoaM-k>i2dFm)0|DyqwY?=g%L94z3S8Gt+f#r>r~5 z?WpE|`<~~k!>*INMbE7eq#Rj%t-GdP{mF}tM~78kzH5EwN#DUq>K)Bun~ECyw&i$2pFSvls5>=f4UZ*49eTA=u2*Xc zZeAdHWu19TsQC5Ab)Ot?9$BxLR>^fnIz3gLjC@X;gM0oa!vnp7JB{8tg~A_f2vxPH zRB?&HO{MdMd3k%cE8n|Kd`F3ShE=C^l-lEsDy>7UcDUl>O5y{)$XHiNr^Z&PG(|-! z4^Qs08w`3O)hH9&wTz}9d)-X0PKv$Kgn;A|Q0!5Yi&O=>=(hqOz-a{}fx8DzA55r$ zUt9oLdVK&;3s3`wsxF}_a1agwN5HWHqCqOaq6Nj)Mgt^*H=Dev*0n}YDJ2u71VSR4 zxTb)lf^ZV?HWz)7kOE)@bD*e=fI>pZ2QLV=V30W!0bVnNVC+ucSOfnQa1ZE&sscLU z3kuZ+fP%r!rG`@#ng$Joi^uOIN;YCELwnYe%7@5ZiwHxT5vd8IB!ZHpdeZb8ki`!m zTKC@`lY$)@7pWLTmOV_^_#CBYF_A6A0%49XHKAix6BHSr7aKHuB{E?fvgHu6<5gtm zVPw}E#Ob>TLb%@iKmgX2D_6aBv$ub`8=&|DyCr$#7 z)!}r)Av_QquBxgkpilM{0tACo2~%=B=6&}+a3xr4YwKQm`Bgl^!;kF!d9Z>v2ktW- z8B%q0c3uWnm0As@;1S@m0rT{IPd=TQLI2R6*!R@-?bmkq^gxCX^Z1EV;N?BL|M^Sf zKe6x0YqswYOQg95Pu}G1l;sbTVXm;^k(~C~#3F1d0O|vtJ6_!W=ME$i$;&Daod8W< zyksfx7b_(l6Aaw>yYIOd_dTz@Zs(DsaP>KL^!N#IX@9E1m)iBaZvf(F%D5oN3SfD})kR{NcXq$#$i4DA>Q%4p)%@w0`@jt8voqCRW4_j1 z;;HPM)ulQ26#e<7#&_2mx?;SdLh<|&?dONQCwKa8Nf_^_*7;@Z!w;E1f7krtRKt;B zwTV}x;O8lL1zJJAfKx!HW=*PfTdO_mlDZum&3`##duxsP-WfurNUJjY3JaN09qrsb`bTDnpDporo7h~L zj;HYl{pG=mE)g|fL(O~gTDSnH_LiF#I#dchZLu0PWq<-xIq&x zx~MSy#~xz6@FR%dL-1wh%8}X`XxA#D(oR%6hy&}<@*(W?0^qY2P|3|R(B+RJu`cYj zSXdAdWsA{ddlfZV^(}M`&As(xIgnl%0>%O+nT^gKR#C{lV8A zuc0d+CD-|d z7}(7%tuQ5rXpsD#d+xJZt(jwK5sAcTGz##BqY&r8haOS>5?CL2@L{~%LodAy6Zy-) zDi8{H?z$cjdE_XR`15xB&O7ghhP1Y}LAh@pIhJnY58}Xu=dQc&#qQVT73Rd+D9av1 zuYDc8?r_%N8WIirfdJG8E=cKw$KeW;__2O~HuCxW_V$i@?!6x_V0cTx_S}36_?TH) zS;fV{+itrZEnnF9f;2wtNY}2#jD$NXV&uu z%9tA$MZP*y_5Oq9cUBm$O(;9FPNx>- z70qbje*BE`nX#(AsL%CsXTxS)E7(9NtjrG;rp%-U+#@iM3 z^{Q__R=sye;M5jbv4ZKfarDC6MknWsr&Rk_NM3EVEvi%0#yHI>wqH%>D@{T!BPh*& z{s!H-$J8%%SQdmNCR=%;V@9H7W?AF3Kx!ydF%m2r@`StHp-#2AQmQVNsDfgNp*E&p zI?1s%g%fe2+)hwaWi*T^w=)t)IA>iEMi>=@W0=B>|IIuuB6kEi+ zHvSj{_Hu!and62WW<;7r=@KLMt+@OFzDf+fNMlUn_Dp z;;u&eHWDi*qWLs5JC_uwBlNFFETz~VxFnKEjyPfAy~vmq*wd^e3L?=XoePjn&k?S9 zi88X8BsXQKN+cm_7do_sSX_@@{G}FD67sB@iY$K|S-T(E_yV}D=<-KUd-SsE!$fju zX#6d=-G2L>cin!+RoN}K-nMAbV*IH9z1{$R*8LAW1O_1U`vce@y+UL%Igw1=dDq?P zm=A8)xarD=gg6||%$eA2bf6(bQ!0NBtV*R4oHbw&An(N&4*{(%1uF#o~% z+wFH=R{zTkA}n8yz4`#IF?DtIJ9q8AECLwBS+R1pKmZqhgqTmwDQ(W0wv#gV*6jL; z!~*P{08pQ8*X+3cb}TaZzh1BJM=KA3xw*N4K=H~|tM@+o*ug_DKlAK!lP6E1)8VU# zU4Fvh@a8SsezDFMZTwe&9KKAMb-}`P0j3 zLas!l5FdLX`1&&Kl44m)jq3ArmUr&dzdl{p9~4yw8I4iy`bqY$j=JBuRdN1$<>5!G zzx%x5%Qx%q?lJAD^f#IKVwsi0m%sL)>EpwWqw^det+JXVG^NBMRlD^zuOpCD>bz6> z1b=$N_`(eBiwljPpC0_LFJ>ROwq{3#eo3jz%;z~`Eg~UzcC+*^rvuMVQ`Rdvb`$N~ z$&#<0)4sh{bIan`v$sa~UF&S~(Z4)mIK12V#`3^l&J6watEvC@<1r^5h?}JnDUY5s z=D&Kk<^0R8qmvygD%56sbw&5Aiq3iEZF57_6Qea#{Ly|#ahomBthJU)R6dm{B9Ryp zVU1Z;s1%4wY`prEPAZ_WnOrhC7hhw&NF23W1;fD#rLa-R5WQFp*a65w1gH!SR-B$-xUUV>#=O@Hhc2Zm-n!pIYzfjZ2OZc- zbfmDC{=pZ$h(#>xLpzrvVgo{?5I9m|?+T=UE%u13DTqwmhFtR!;o4V$s$``#{b(wd z2x0Kimbt|7TL>HpwhjOW6fR_-o@#VtC$e}ivV0%1`e}6Sv*`HEhybonKM;%~E|&`) z6O7NRG=)MzrBVs$pJ@V@VJd2~V}kiCjRsx%BZ9~*7RzF>LNtgBRK4Qg5r)&h2?Hw(TLMbPe(*p* zB!Z&$6m#>kkV;Aoxm{%*nb~gEI;!>7q&Cpxh>QtU4Y}goo_M<}))}v#7^@uM3Zw@g zcYgbh4OdcS>IzRu6bMIuY&cJMNDUnGa2soPDC^%;TkZt=BCbmVI%`^ZtGEPxjjX=hMl5 zIy3m#bm!ge)=oQL#+OAay9G+y+mD*hzhQZMjq{^TDz}jvDeLl8jEmL}C7PyL-HG7= z(f7waFRpj|>77ac=ZjTOZmPMl#6ZT(T++F{*C zFPD9K$a8S3{^;$Y$Jcr8-fSAK_qI1+3ICgDjclI_SvbHIRI8qY%EY!Wl3rsDU~TI*LnF08+_49T+*0Fqg09q zyG%3tI3+F`g@XU7X-t3{7y&Q_F46}#_!i<|g)bRw@r6tLrWSA$NQckOVGAKJAi;3( z!45F-0&Oue3NR-Zl@id0oP|Cj8Y2;Ub4a&q@`5B{ghW`!&mE#u@b~~tycEa>N`bU^ z5to7$46hQ7&Wmm-7+e@&i`N_s4q<^mY!}$yE$BT*3K`gjcCSGtMvRXj=t?@51Z=IJ zg~+UEZXrQtLIJ5&%RC?z-z90C8he z^Oa!z*H?P@{63fID+QMdpgiCkE}R#^eW~!$e;|Ym!*%QJ@4Pn$p8ZFy~v z`P0+3lPfI`PcnY_N%W%|IS*}Ce|6UP&6(PFZsY&iP}=C)Ca{%7l&`=vAK;Y`CG)z8Dt?3$y}k0Z+kje&Nm_P!AXf^aP&b{wWxQ;BF=u%;JHDKuHJ(I09th zBRIK26urq$Goz@JKxoS$U8~M>5D9h^oyyGaC?G)`hzuFx#lf(IJRyXIppX@03x_Ti zhex{1R~~{lCx1y1-kHF^0LQBJ!=Rp3khAT(6O75aodsMo#^5-+V*4ep>t03l*nt)O_=p?#%1%4^~+^gQCw)2ETYjefA;6w;y}{biCr^wZbQs zDP77OE?0BIR?CNv_@A5ZJo(17@89Zqa#Q$?hr%besZX!exp{OJGk^AE-Pa$OKf2rc z${gXty^4balDF2%PcKm&=yU>sOnf; z)xOXgP7b&5zk1()bf@{FhjgEv@AYJ9hri&}r!8N;n|gnnd~>%#sEZdTdVFPrj!>u7+o-XnB*uWkP%Kvmgc6%jVJj@; ztAy-vt-(};@6}ViM&tM{VbIDa5y%)=(@O!F^K^+I2!er1;2*(Y#`y&XA91$g>;r=x zARx{=a4PZFwEzbJqi_hq(_jTMq8M;dWHOg*BA{Lpp)Q-W#ZY7-65K>WPae5CmjJ1N z)VLpt7Yh53Ef5^i!uC?Ig5hAr=$U3E)EP!5zK^pN>VH|=usFZN8Y?hSq(}Ojhr(wcmk3zYsgBdW%BK z$bSFL&{wY(zq3}kq(RPMQAZkCe?DFM+2PpxD|IvK1;#*2tYKELW^5oesFbkU-9;}w zWBuxM#o22VI|if@Q#?{RSW-D0ObvQM?FM_5##&)^RH^l)T0=3FBQ2sc!hUVJmsJxe zjJw%Y6@tou)Tk~b!psZ%OSnG{R$Ra!VADnK52qEd6=PPKU$6sc!`Ye!Yi8{g44a9k z-;0X-uy6>V2_9&DHY!6=If_Qege7{afq+O3SJz3D+~;Fa8LOn zSfNmeiC5-g8(;?-gx4Ile~gG^#GXW^-h#|~0GV|Mk{m!d66|MJ1q|$|RlOIfpN!7G z6S?-a^iQYW&c5MD_JVtefhI(3Mwk-pj|Bi&)lOu}PU7mv$)xW#a5d9Hh*D92lm6H|oi7mpSsFzo&=P&5vx0ecnHknwY6v(U+jaO-k zSc@Cor5(OlH^3!OF;dbnD3d7GY6M)fjsL~F#cyBleRQUKd7arVSBs_6H(#=R{&LCdOL({SbM4B) zS(Ef%o%MWlm-X~^Ws{ZT(~9n!uRM8==Iq`6k8hCll}kB%&ie=bpPlr*ztiy1W~Ik2 zscD{6S~IAYN<=)C-WKg?6nt^U|KT3T``0<$IsmC`-`?1l=VIrc3!HgKrxdbQOj3UF z#hAlW?Hg-cW|2f|_2n1xAH2!-r(?cT+oX@pl1o?xlN*@doGkwMm9i7_b(0c&U%08h zYe_@joVI~wN(sBgmjB{D0BgxFlr0wVFHQryjieTe2FqJs2IDk~A4Ak7B^!$%tdRT2L7<0zcnc%iTlfnbmu zo*_g7!?T4DAp50Y#f9Vb4-+LDjrF2H)uoRiEA}BvA3-MXLi$!B18b0(H(~F+2BhA2 zoN)6A@{LDx7T=XSaXV>nGotojjwj#}pb5U}wCjmm4^kQ@6N=!9GOAJiYT+2YQW#j_ zADJZOQ>|KtSR!W?=66-lw@%Tt#bpt{h9}~%xLUQb)EjHG`rAE`u5k6RFEu{WI;1wo zZrh{&?%l-6?V&Swrq0}}zhk^KY|rcNHviX|6csMs%nACBPut(W&Hv73e%!=j2|e>W z<>#JIpSa6*dJU~JD5o)~C!chE_PY7_X6^Ci3a_54bi|lEUSC2Ow{tjL?Zh_mpFW7Z zyUlxasll#RiaE?z?lb@Co#^L}`p)b&y2XNdggFFfY{_MOVp z0M;R`*iary4Ekf^g5^WbK#R>;r80y$B69&l!sCj44t3nG9BLGLy7CWRe5r@N(N;D;6 zy<6|BwuYJl)nii~6YRmRSrhH&P87d?XY>2lc|N^azNJmr=Hr^Jg6~h2ef(_j?1Oc$ zEz=!aq3ZJ%D#fhrYdxPmuRpn6+!vtpSd4hxgwpDP;eOelUv{0@6L@dEYDR*(bH3#K z%ewckkDgd#Y;dq>48hbfin9l#ryqzO8sW_5*WmAcnwi|e#3E|c98 zWIr}Zc;99>gI#$23hDXxEoXLBytP7XQj3KW!=ty`zdGqY|8VW0En*{sd(RE7Q@7MU zK4MV`HD#T1qRCc|S}?tt_tgpCSFd!RU8`DBFPU9W{qk+w$1k+LGRZU{qOds2tGegM zTc??9@k$4EpfvCKedaGtRlTuLx@VkRC^Q9P1A)|lyR6IXs#L230)eTph_BYDoff`T z!S1T!ZkQ-u+0SVx)w>-^wJcx6&BgjYjZ|Ej6LE!bp8k|@u;Lyi?vuh~9~g=Q5qO9D zp0I-(jz}?zd8Ul?jjZ}yA|wO@s&doo1~^zDIpmBn{Q_PoD3%+ZCqNuu5WuO8$N%X@ z{1F1jvwQ$q^(3qJuM#t4Qo7H}j@ejKn+iI^%<1jY zau25>WZ*F9`E(ImtWp|-QcKd{s_{m9WSZjjGp(oZvOT{#_V5zRvEBB?-Td~5Tqmc$ zdz)w964f))ov+Ocy|%(JKgsGXmh8XA^!!4{dpj-58u>Ao#O5ons2dsT(I0!rvTw8Q z)s3asPEc%^WZ1vcapz$1OG|898pQ!4J5nO};;8z)`-=|@26nacV;bg73*7q`h+kc9 z`e>8wz7ga266(QK(hs+rK6yve7Gl4##q!t!?NgIlUtE-km_#CpdFD9XCvSK@dbI8M zX5W@p+cWEJ_x5TIE~<>0)x|a4Dy_FMB)nsu@`Jmbch9!Iw9a<#3ghe+-iMESo?YZV zzMygE7zgfRZQFSDzAt_dI zIVC=t%ffe>{~ zBmL+)&AZoIo|vb3Xq$dklW#$tVt!IPFKJs)ZkSmYw~16PqIt^6BA#S*M`KOXMb1 zpdHR)tbU-w~9JrA9lpHd`QHV1X+ez<(;jDz6 zLKKDgfJI=d3dPn9fRA)=Leu+T7r_eoBM`HTit;paiPuq-@QdYqDx)Zm&tR$4T&r2*(r}wn zHVKbAGN63%-p1>fS_QOx293vIin)A=P^=J(R3e4RXpKshcB9BDRcaJUy+&n|%Z*B{ zLu(7_4Pk@cr`J1_T9>1=tEzv#HCC;1xC|zbQsx&Sy0EO?scjURlvH%jT|9DV1 z%*Ye!1ze?2Y?O!$5~0OtsyBPu%-%YoqL{&uvG@ueSILLV3_~sE2rAV zjYMahTwG1Emy+ZrB9)Dj$k-#UEsKyDcOsK^BmEnZi93;Hk0RH+O4#)#W!u55?!^QK z?+5K0)u?`x@X`BAA`poL4yRBkqRGW9g@_d|R#pYcv0}Q{%WO<=YN8USMLXOY+&s%X zu96-wi4+>W&ll91Lppc8(c5MX^mro!-ta&mG0q{W2vcu@Q^pyylt*Po5fdUaHe#Yq{fs~7^7-S z)ab6T25N2oq{~|=ReKuz@)y=PkF77htyQ|c(HK?G>RyZ*dubLK$Pl%LH^2Ww_BYm!tPMg2U;%e8J zYt`lonFbgc)mh6;j%t&$Oro;GWrHJDvxPD?U&s~8_(HXa%hy}2TA8@1ls|ufv2|Xu z%fQh-sAxm3)Ro#LCGc6Hp>C{iA?Ol{rtpUib_h7`3SSIP>F1o;Yv`gatg9YUiNeIC7 z(&wTI=t##h?B`O;pG4L^hipECZheWk;t_J!0!nE+v2r5zsQ>5+Z$i$J# zasBZLL#d(uioTxG-tJIuR|&-LY7Mm1yOX7=nsR+ZwV}Ss*w*B1YxYhU8}6;N_}p%h z)Y4y}eRGXtL(1Y;$g~=5RkdZPD?GWcVaiC|&_J@cCp4xj+TI*)Zw>VJ6p!gD9vmB+ zJW@R}R6TwmF@7L2G!Puv+p3xy^}a)C#hiy(Ns(*VuF)P^wglP zD(rPuqZ-xUr_AuZ8X?#t>jWZ+OwKLHE=mO%Yo_G4dKr)0BpcJ9i+Q9Q<{1)RzJ!-A zW9F9y_z5p7WMrjW!dkzi+{KStxN$o-pru8Oyre@?tY-L?1xdRwW@f}~^a{5isAF2? zc_Am?ZDd&$MLr!nWRo}*MNT<8Y!%t%`2iih%%QLfnPvekYL(k1w2+2T;SiNsm?>TJ%0*Qz@RRjVKt;>`-PrrvQg&;0`2evp^aRY{efs2ZNyy*F2{Jj9y zpQ+Z*9QxN#PCh}bBN_rkjg!C@qdECl?}OmF#u5>NEvVFt5TN0s8r5%CX82x(@QG^r zFSZZ?2Zi}rJynJ4dwF9@I3*szm{#4)HrY@M&!*xUL`5-|tSY98mxz*KLDVlSD-lGC z*=0d-%%_a`q!AB05fmhYqEtv)9g|kYL={nKWmr*PDz7XSl>7PBCDK$#R8}m7Bq0ws z78F(|H04oQJSdKM`LSYdDk3Nk%Tgh3MTB1!mtIsQc-uMWO9?RuDUql^te3g z;TFflMp1iIQW561Rtg(RxwT<#D#T2MB;{qY(hxV~lZHIPvaqPWQWFm;Lq2iHElh^g zrNx3osU#5=NB!J}guF7ONR`ch0 zM)jMN8NR<(1e8R~lkvDi^^C<6SOGI#FD^(JxE)mzn?~d^2>oV(O~=(pX*MlS#-&-c z+>lpfR&yMBaj{!uHLw(-B9~QSP_va1nnllZ+eCUL+hqby$!%JiUPkkn1qu#T$uD#o zIZ}SUQN?wdB`Qgwgq`QM@VqvzT1+!(*WU#^#POj@SLE|m+ZP-ll(s8ccxN`~LT_t~Tt4J+Ujc`Q7OTA+Xm8w3gw z-EU$STV+;-y45mo;P(Ua6ZW-tF0#&i8 z$_4u|82yKdl7p(fs4j>-4?C(+{Z?g$@2?m5S_L$Qh%adjGuKb0UbmXPWqzc$Ng1%) zv@&T&bI58?Y7_#qUZPWq10KCXLYMIAQUQ}i&tfw2>GUEh4co$JI-`(I%cB+*=I3SS z=TQpsDb%7|8Z8$BXw<@j{A_Ab0i9aJpwVeXg@t*!w89*EQBF~QUS3{yK|x+oQ9g&m zFtt9`=AQAHm=!JO-i6A$-2nx&2CKnXsQ>l4WY7P`p zSWrNt7SL(1g|f1!1-bO1qN0KvT2Vn!L0*17IX{nF2&GW-=!_x;gU)2qpyo7MA&Ws} z({p+30-2B@6;jQ5QP5)%^JpH2PQ>N6)VaJ4jn$~Hh-zo`diJbwTr-C;sg0{uG5CA| ziG;mV{A!drPrs9XDmy6W;=(Ji6+YrIFK%(LUJUsU7K-Lk3EBWE&|>$#Mm4J6q|EUB zm4e}gA~^(NHYtlDX7d9EU3;a_VGw)lZmCd9CKB>xkHU2+2fj ziixdgWirCRad8ezO1}{H$|*5~KsX7eq%d1ezic>TxF9!dN)3BNLP1`hT_v|@srRq( z$NU_diYw&PNhAsu0edzPo77?%LRMIwm*0X1U6M2QpeMeE3#&HZ1&*f$pRw9Q;n=%V zNhBhLOv-|ml1Zd2ayG3n7uueeU*xccHBwbqojGP^YbD}>!U7^Wo0$H4R#zjutbc&; z=0Jhz1;-yK-ArtY@BfDj`+gL$QI#7h;*35aFsk3C%<%n90#Kr8Hi1MaDq?9>Dz($8AuElt^!+XZ2|_SD;&!<%2K{7=w0{05eh{ce&d<*AJ47Qj+>o87lyFGc zQ%o0=Ud-WdK|ie@jqqbh(h)Q5MX~m#_tNYJg68CztX7Fi!WK%2WH^*fB;-K6t5oJZ z{qe%6n%=n*B@m7W{^wIQBRHRhEQBM+{>00uM)m8J8NPpqFo+O|xw*MqE;keO&r_Oi z1bPv<)WcASipWIlujl@Ag?A2*$Ag_r(63SEJpJ*ySkjLci3k3#0V@dlo=C|&Ffgi7 z{Yv4Z_wN@@w9JQpo-WjZ#H8gCNwEKex%@vxc$L!K^IQ5SbDsWz`YEvDHmXsL>X(%n zzW-?g-|#=AX_@o%FQ8GXj%rj_tIY8IPt!l&cmBPUIZyur8U^d9Ms>By4Bt`xt15Gz tMm4HY{TnGWd`I=Ks?2%%-y;P1{{c=y7)O&~?ePEr002ovPDHLkV1mk;SUCUy literal 0 HcmV?d00001 diff --git a/assets/icons/eBayIcon.png b/assets/icons/eBayIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..1186ef293fd290e70d6aaa5d10ea523024cf9162 GIT binary patch literal 1888 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 J);T3K0RRf1bEyCT literal 0 HcmV?d00001 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".*?