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

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

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

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

构建:Windows 主目标,非 Windows 平台所有 Win32 功能走 build-tag 桩实现。
This commit is contained in:
2026-06-12 03:10:37 +08:00
commit c671f2115e
165 changed files with 10102 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.idea
.claude
*.local.md
*.log
*.exe
*.exe~
/dist/
/build/
/vendor/
+47
View File
@@ -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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

+93
View File
@@ -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)
}
}
+28
View File
@@ -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
)
+59
View File
@@ -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=
+84
View File
@@ -0,0 +1,84 @@
package authenticator
import (
"sync"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Authenticator is the interface implemented by every concrete OTP type.
// It deliberately mirrors the surface of the C# Authenticator base class
// rather than introducing a wider/cleaner Go interface, so the port can
// stay close to the original.
type Authenticator interface {
// Name returns a short identifier for logging ("google", "battlenet", ...).
Name() string
// CurrentCode returns the OTP that should be shown to the user right now.
CurrentCode() (string, error)
// SecretData returns the serialized form of the secret + parameters,
// matching the field stored inside the original XML config under
// <secretdata>.
SecretData() string
// SetSecretData reverses SecretData, populating the receiver.
SetSecretData(value string) error
// Sync re-aligns the server time offset using whatever network call the
// concrete authenticator supports.
Sync() error
}
// Base holds fields shared by every concrete authenticator. Embed it in
// your subtype to inherit the field set and common helpers.
type Base struct {
SecretKey []byte
CodeDigits int
HMACType HMACType
Period int
ServerTimeDiff int64 // ms
LastServerTime int64 // ms (Unix-millis here, not .NET ticks)
mu sync.Mutex
}
// NewBase returns a Base seeded with the project-wide defaults.
func NewBase() Base {
return Base{
CodeDigits: DefaultCodeDigits,
HMACType: HMACSHA1,
Period: DefaultPeriod,
}
}
// NowMillis is the milliseconds since the Unix epoch, equivalent to the
// CurrentTime helper in the original C#.
func NowMillis() int64 { return time.Now().UnixMilli() }
// ServerTime returns the server's notion of "now" in milliseconds.
func (b *Base) ServerTime() int64 { return NowMillis() + b.ServerTimeDiff }
// CodeInterval returns the TOTP step number for the current server time.
func (b *Base) CodeInterval() uint64 {
if b.Period <= 0 {
b.Period = DefaultPeriod
}
return uint64(b.ServerTime() / int64(b.Period*1000))
}
// CalculateTOTP runs the TOTP algorithm using the receiver's fields.
func (b *Base) CalculateTOTP() string {
const fn = "internal.authenticator.Base.CalculateTOTP"
digits := b.CodeDigits
if digits == 0 {
digits = DefaultCodeDigits
}
code := hotpCode(b.SecretKey, b.CodeInterval(), digits, b.HMACType)
global.Log.WithField("func", fn).
WithField("interval", b.CodeInterval()).
WithField("digits", digits).
Trace("computed TOTP")
return code
}
+97
View File
@@ -0,0 +1,97 @@
package authenticator
import (
"fmt"
"regexp"
"strings"
)
// base32Alphabet is the RFC 4648 / 3548 base32 alphabet (no padding).
const base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
var (
base32EncodeTable [32]byte
base32DecodeTable [256]int8
base32CleanRE = regexp.MustCompile(`[\s-]+`)
base32PadRE = regexp.MustCompile(`=+$`)
)
func init() {
for i, c := range base32Alphabet {
base32EncodeTable[i] = byte(c)
}
for i := range base32DecodeTable {
base32DecodeTable[i] = -1
}
for i, c := range base32Alphabet {
base32DecodeTable[c] = int8(i)
}
}
// Base32Decode decodes a base32 string into bytes. Whitespace and dashes
// are stripped; trailing '=' padding is ignored; input is uppercased before
// decoding. This mirrors the lenient behavior of the original C# Base32 class.
func Base32Decode(encoded string) ([]byte, error) {
encoded = base32CleanRE.ReplaceAllString(encoded, "")
encoded = base32PadRE.ReplaceAllString(encoded, "")
encoded = strings.ToUpper(encoded)
if encoded == "" {
return []byte{}, nil
}
const shift = 5
const mask = 0x1F
outLen := len(encoded) * shift / 8
out := make([]byte, outLen)
var buffer int
var bitsLeft int
var next int
for _, c := range encoded {
if c >= 256 || base32DecodeTable[c] < 0 {
return nil, fmt.Errorf("base32: illegal character %q", c)
}
buffer <<= shift
buffer |= int(base32DecodeTable[c]) & mask
bitsLeft += shift
if bitsLeft >= 8 {
out[next] = byte(buffer >> (bitsLeft - 8))
next++
bitsLeft -= 8
}
}
return out, nil
}
// Base32Encode encodes raw bytes as base32 with no padding.
func Base32Encode(data []byte) string {
if len(data) == 0 {
return ""
}
const shift = 5
const mask = 0x1F
var sb strings.Builder
buffer := int(data[0])
next := 1
bitsLeft := 8
for bitsLeft > 0 || next < len(data) {
if bitsLeft < shift {
if next < len(data) {
buffer <<= 8
buffer |= int(data[next]) & 0xFF
next++
bitsLeft += 8
} else {
pad := shift - bitsLeft
buffer <<= pad
bitsLeft += pad
}
}
index := mask & (buffer >> (bitsLeft - shift))
bitsLeft -= shift
sb.WriteByte(base32EncodeTable[index])
}
return sb.String()
}
+389
View File
@@ -0,0 +1,389 @@
package authenticator
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"math/big"
mrand "math/rand"
"net/http"
"strings"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/httpc"
)
// Battle.Net mobile-service URLs by region. Matches the original C# table.
var battlenetURLs = map[string]string{
"US": "http://mobile-service.blizzard.com",
"EU": "http://mobile-service.blizzard.com",
"KR": "http://mobile-service.blizzard.com",
"CN": "http://mobile-service.battlenet.com.cn",
}
const (
bnetEnrollPath = "/enrollment/enroll2.htm"
bnetSyncPath = "/enrollment/time.htm"
bnetRestorePath = "/enrollment/initiatePaperRestore.htm"
bnetRestoreValidatePath = "/enrollment/validatePaperRestore.htm"
bnetCodeDigits = 8
bnetModelSize = 16
bnetModelChars = " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01234567890"
bnetEnrollRespSz = 45
bnetSyncRespSz = 8
bnetEnrollModulus = "955e4bd989f3917d2f15544a7e0504eb9d7bb66b6f8a2fe470e453c779200e5e" +
"3ad2e43a02d06c4adbd8d328f1a426b83658e88bfd949b2af4eaf30054673a14" +
"19a250fa4cc1278d12855b5b25818d162c6e6ee2ab4a350d401d78f6ddb99711" +
"e72626b48bd8b5b0b7f3acf9ea3c9e0005fee59e19136cdb7c83f2ab8b0a2a99"
bnetEnrollExponent = "0101"
)
// BattleNetAuthenticator is the Go port of the C# BattleNetAuthenticator.
// It supports Enroll, Sync, Restore (paper restore), and the customary
// 8-digit TOTP code generation.
type BattleNetAuthenticator struct {
Base
Serial string
RestoreCodeVerified bool
}
// NewBattleNetAuthenticator returns an empty 8-digit Battle.Net authenticator.
func NewBattleNetAuthenticator() *BattleNetAuthenticator {
b := &BattleNetAuthenticator{Base: NewBase()}
b.CodeDigits = bnetCodeDigits
return b
}
// Name returns the short logger tag for this type.
func (b *BattleNetAuthenticator) Name() string { return "battlenet" }
// Region returns the two-letter region prefix derived from the serial.
func (b *BattleNetAuthenticator) Region() string {
if len(b.Serial) >= 2 {
return strings.ToUpper(b.Serial[:2])
}
return ""
}
// CurrentCode returns the live 8-digit Battle.Net authenticator code.
func (b *BattleNetAuthenticator) CurrentCode() (string, error) {
if b.SecretKey == nil {
return "", fmt.Errorf("battlenet: no secret loaded")
}
return b.CalculateTOTP(), nil
}
// SecretData / SetSecretData persist both the secret and the serial number,
// matching the C# format "<basesecret>|<utf8-hex-serial>".
func (b *BattleNetAuthenticator) SecretData() string {
return b.EncodeSecretData() + "|" + strings.ToUpper(hex.EncodeToString([]byte(b.Serial)))
}
func (b *BattleNetAuthenticator) SetSecretData(value string) error {
if value == "" {
b.SecretKey = nil
b.Serial = ""
return nil
}
parts := strings.Split(value, "|")
if len(parts) == 1 {
// legacy WinAuth2 form: 40 hex chars secret then UTF8-hex serial
if len(value) < 40 {
return fmt.Errorf("battlenet: secret data too short")
}
raw, err := hex.DecodeString(value[:40])
if err != nil {
return fmt.Errorf("battlenet: bad secret hex: %w", err)
}
b.SecretKey = raw
serialBytes, err := hex.DecodeString(value[40:])
if err == nil {
b.Serial = string(serialBytes)
}
return nil
}
if err := b.DecodeSecretData(parts[0]); err != nil {
return err
}
serialIdx := 1
if len(parts) == 3 {
// alpha 3.0.6 form: secret|script|serial
serialIdx = 2
}
if len(parts) > serialIdx {
raw, err := hex.DecodeString(parts[serialIdx])
if err == nil {
b.Serial = string(raw)
}
}
return nil
}
// Enroll registers a brand-new Battle.Net authenticator with the Blizzard
// mobile service. Country is auto-detected via Wikimedia GEO IP; pass an
// explicit two-letter override to skip the lookup.
func (b *BattleNetAuthenticator) Enroll(ctx context.Context, countryOverride string) error {
const fn = "internal.authenticator.BattleNetAuthenticator.Enroll"
logger := global.Log.WithField("func", fn)
country := strings.ToUpper(strings.TrimSpace(countryOverride))
region := "US"
if country == "" {
c, r := lookupRegion(ctx)
country, region = c, r
} else {
region = regionForCountry(country)
}
logger.WithField("country", country).WithField("region", region).Debug("region determined")
otp := mustOneTimePad(20)
payload := make([]byte, 38)
copy(payload[0:20], otp)
copy(payload[20:22], []byte(country))
copy(payload[22:38], []byte(randomModel()))
encrypted, err := rsaEncryptRaw(payload)
if err != nil {
return fmt.Errorf("battlenet: rsa encrypt failed: %w", err)
}
respBytes, err := bnetPostBinary(ctx, mobileURL(region)+bnetEnrollPath, encrypted)
if err != nil {
return err
}
if len(respBytes) != bnetEnrollRespSz {
return fmt.Errorf("battlenet: enroll response size %d, want %d", len(respBytes), bnetEnrollRespSz)
}
serverMs := int64(binary.BigEndian.Uint64(respBytes[0:8]))
b.ServerTimeDiff = serverMs - NowMillis()
secret := make([]byte, 20)
copy(secret, respBytes[25:45])
for i := range secret {
secret[i] ^= otp[i]
}
b.SecretKey = secret
b.Serial = string(respBytes[8:25])
logger.WithField("serial", b.Serial).Info("enrolled with Battle.Net mobile service")
return nil
}
// Sync re-aligns ServerTimeDiff against the mobile service for our region.
func (b *BattleNetAuthenticator) Sync() error {
const fn = "internal.authenticator.BattleNetAuthenticator.Sync"
logger := global.Log.WithField("func", fn).WithField("region", b.Region())
if b.SecretKey == nil {
logger.Debug("skip sync: no secret loaded")
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
url := mobileURL(b.Region()) + bnetSyncPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := httpc.New().Do(req)
if err != nil {
logger.WithError(err).Warn("sync failed; using local clock")
return nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if len(body) != bnetSyncRespSz {
return fmt.Errorf("battlenet: sync response size %d, want %d", len(body), bnetSyncRespSz)
}
serverMs := int64(binary.BigEndian.Uint64(body))
b.ServerTimeDiff = serverMs - NowMillis()
b.LastServerTime = NowMillis()
logger.WithField("offset_ms", b.ServerTimeDiff).Debug("clock synced")
return nil
}
// RestoreCode computes the 10-char Battle.Net restore code derived from
// the SHA1 of (serial || secretKey).
func (b *BattleNetAuthenticator) RestoreCode() string {
if b.Serial == "" || b.SecretKey == nil {
return ""
}
serial := strings.ReplaceAll(strings.ToUpper(b.Serial), "-", "")
hash := sha1.Sum(append([]byte(serial), b.SecretKey...))
out := make([]byte, 10)
for i := 0; i < 10; i++ {
out[i] = restoreByteToChar(hash[len(hash)-10+i])
}
return string(out)
}
// helpers ------------------------------------------------------------------
func mobileURL(region string) string {
region = strings.ToUpper(region)
if len(region) > 2 {
region = region[:2]
}
if u, ok := battlenetURLs[region]; ok {
return u
}
return battlenetURLs["US"]
}
func regionForCountry(country string) string {
switch country {
case "CN":
return "CN"
case "KR", "KP", "TW", "HK", "MO":
return "KR"
}
euCountries := []string{
"AL", "AD", "AM", "AT", "AZ", "BY", "BE", "BA", "BG", "HR",
"CY", "CZ", "DK", "EE", "FI", "FR", "GE", "DE", "GR", "HU",
"IS", "IE", "IT", "KV", "XK", "LV", "LI", "LT", "LU", "MK",
"MT", "MD", "MC", "ME", "NL", "NO", "PL", "PT", "RO", "RU",
"SM", "RS", "SK", "ES", "SE", "CH", "TR", "UA", "UK", "GB",
"VA",
}
for _, c := range euCountries {
if c == country {
return "EU"
}
}
return "US"
}
func lookupRegion(ctx context.Context) (country, region string) {
const fn = "internal.authenticator.lookupRegion"
logger := global.Log.WithField("func", fn)
geoCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(geoCtx, http.MethodGet, "http://geoiplookup.wikimedia.org", nil)
resp, err := httpc.New().Do(req)
if err != nil {
logger.WithError(err).Debug("geoip lookup failed; defaulting to US")
return "US", "US"
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// Cheap regex-free parse for "country":"XX"
const key = `"country":"`
idx := strings.Index(string(body), key)
if idx < 0 {
return "US", "US"
}
rest := string(body)[idx+len(key):]
end := strings.Index(rest, `"`)
if end < 0 {
return "US", "US"
}
country = strings.ToUpper(rest[:end])
return country, regionForCountry(country)
}
func bnetPostBinary(ctx context.Context, url string, body []byte) ([]byte, error) {
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := httpc.New().Do(req)
if err != nil {
return nil, fmt.Errorf("battlenet: contact mobile service: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("battlenet: server returned %d %s", resp.StatusCode, resp.Status)
}
return io.ReadAll(resp.Body)
}
func rsaEncryptRaw(data []byte) ([]byte, error) {
n, ok := new(big.Int).SetString(bnetEnrollModulus, 16)
if !ok {
return nil, fmt.Errorf("invalid RSA modulus")
}
e, ok := new(big.Int).SetString(bnetEnrollExponent, 16)
if !ok {
return nil, fmt.Errorf("invalid RSA exponent")
}
pub := &rsa.PublicKey{N: n, E: int(e.Int64())}
// The original C# uses BouncyCastle's RsaEngine.ProcessBlock without
// padding (raw RSA / "no padding"). We emulate that by padding the input
// to modulus length with leading zeros and using big.Int exponentiation.
keySize := (pub.N.BitLen() + 7) / 8
if len(data) > keySize {
return nil, fmt.Errorf("data too large for raw RSA")
}
m := new(big.Int).SetBytes(data)
c := new(big.Int).Exp(m, big.NewInt(int64(pub.E)), pub.N)
out := make([]byte, keySize)
cBytes := c.Bytes()
copy(out[keySize-len(cBytes):], cBytes)
return out, nil
}
func mustOneTimePad(n int) []byte {
out := make([]byte, n)
if _, err := rand.Read(out); err != nil {
panic(err)
}
return out
}
func randomModel() string {
var seedBytes [8]byte
_, _ = rand.Read(seedBytes[:])
r := mrand.New(mrand.NewSource(int64(binary.LittleEndian.Uint64(seedBytes[:]))))
out := make([]byte, bnetModelSize)
for i := range out {
out[i] = bnetModelChars[r.Intn(len(bnetModelChars))]
}
return string(out)
}
func restoreByteToChar(b byte) byte {
// Mirror C# ConvertRestoreCodeByteToChar — but we only need the inverse
// for code *display*. The original algorithm maps each byte to an alpha
// or numeric, skipping I, L, O, S to avoid ambiguity. Below is a direct
// translation of the C# code path that lives at the bottom of
// BattleNetAuthenticator.cs (ConvertRestoreCodeByteToChar).
v := int(b) & 0x1F // 5 bits
switch {
case v < 10:
return byte('0' + v)
}
c := v - 10 + 'A'
if c >= 'I' {
c++
}
if c >= 'L' {
c++
}
if c >= 'O' {
c++
}
if c >= 'S' {
c++
}
return byte(c)
}
+180
View File
@@ -0,0 +1,180 @@
package authenticator
import (
"context"
"crypto/hmac"
"crypto/sha1"
"fmt"
"strings"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// Battle.Net paper-restore protocol constants. The server responds with
// a fixed 32-byte challenge to the initiate POST, and a fixed 20-byte
// secret to the validate POST. Mismatching sizes are treated as fatal
// since the binary protocol has no error envelope.
const (
bnetRestoreChallengeSize = 32
bnetRestoreSecretSize = 20
bnetSerialDigits = 14 // CC-NNNN-NNNN-NNNN after stripping dashes
bnetRestoreCodeLen = 10
)
// Restore recovers an existing Battle.Net authenticator's secret key
// using the 10-character paper restore code the user wrote down when
// they first enrolled.
//
// The wire protocol mirrors the original WinAuth implementation:
//
// 1. POST <serial-ascii-bytes> → /enrollment/initiatePaperRestore.htm
// ← 32-byte challenge
// 2. HMAC-SHA1(key = restoreCode-decoded-10-bytes,
// data = serial-bytes || challenge) → 20-byte signature
// 3. POST <serial || signature> → /enrollment/validatePaperRestore.htm
// ← 20-byte secret (the new SecretKey)
//
// SECURITY: restoreCode grants full account control if leaked. We do not
// log it, never persist it, and wipe the derived 10-byte key buffer
// before returning. The caller's restoreCode string is the caller's
// responsibility to manage.
func (b *BattleNetAuthenticator) Restore(ctx context.Context, serial, restoreCode string) error {
const fn = "internal.authenticator.BattleNetAuthenticator.Restore"
logger := global.Log.WithField("func", fn)
cleanSerial := normalizeBnetSerial(serial)
if len(cleanSerial) < bnetSerialDigits {
return fmt.Errorf("battlenet: serial must contain %d digits after the region prefix", bnetSerialDigits)
}
region := cleanSerial[:2]
if _, ok := battlenetURLs[region]; !ok {
return fmt.Errorf("battlenet: unknown region %q in serial", region)
}
logger.WithField("region", region).Debug("starting paper restore")
cleanCode := normalizeBnetRestoreCode(restoreCode)
if len(cleanCode) != bnetRestoreCodeLen {
return fmt.Errorf("battlenet: restore code must be %d characters", bnetRestoreCodeLen)
}
codeKey, err := decodeRestoreCode(cleanCode)
if err != nil {
return err
}
// Zero the derived key on return so it does not linger in stack/heap
// after the HMAC call has consumed it.
defer func() {
for i := range codeKey {
codeKey[i] = 0
}
}()
serialBytes := []byte(cleanSerial)
challenge, err := bnetPostBinary(ctx, mobileURL(region)+bnetRestorePath, serialBytes)
if err != nil {
return fmt.Errorf("battlenet: initiate restore: %w", err)
}
if len(challenge) != bnetRestoreChallengeSize {
return fmt.Errorf("battlenet: restore challenge size %d, want %d",
len(challenge), bnetRestoreChallengeSize)
}
mac := hmac.New(sha1.New, codeKey)
_, _ = mac.Write(serialBytes)
_, _ = mac.Write(challenge)
signature := mac.Sum(nil)
// POST body is serial-ascii || HMAC signature.
validateBody := make([]byte, 0, len(serialBytes)+len(signature))
validateBody = append(validateBody, serialBytes...)
validateBody = append(validateBody, signature...)
secret, err := bnetPostBinary(ctx, mobileURL(region)+bnetRestoreValidatePath, validateBody)
if err != nil {
return fmt.Errorf("battlenet: validate restore: %w", err)
}
if len(secret) != bnetRestoreSecretSize {
return fmt.Errorf("battlenet: restore secret size %d, want %d",
len(secret), bnetRestoreSecretSize)
}
b.SecretKey = secret
b.Serial = cleanSerial
b.RestoreCodeVerified = true
logger.WithField("serial", b.Serial).Info("paper restore succeeded")
return nil
}
// normalizeBnetSerial strips spaces and dashes, upper-cases, and returns
// the canonical form ("CCNNNNNNNNNNNN", 14 ASCII bytes when valid).
func normalizeBnetSerial(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "-", "")
s = strings.ReplaceAll(s, " ", "")
return s
}
// normalizeBnetRestoreCode strips formatting whitespace / dashes and
// upper-cases. The actual character-set validation happens in
// decodeRestoreCode.
func normalizeBnetRestoreCode(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "-", "")
s = strings.ReplaceAll(s, " ", "")
return s
}
// decodeRestoreCode is the inverse of restoreByteToChar applied 10
// times in a row: each character maps back to one byte (low 5 bits
// populated). The 10-byte buffer is what the protocol uses as the
// HMAC-SHA1 key for the validate step.
func decodeRestoreCode(code string) ([]byte, error) {
if len(code) != bnetRestoreCodeLen {
return nil, fmt.Errorf("battlenet: restore code must be %d characters", bnetRestoreCodeLen)
}
out := make([]byte, bnetRestoreCodeLen)
for i := 0; i < bnetRestoreCodeLen; i++ {
v, ok := restoreCharToByte(code[i])
if !ok {
return nil, fmt.Errorf("battlenet: invalid character %q in restore code", code[i])
}
out[i] = v
}
return out, nil
}
// restoreCharToByte is the inverse of restoreByteToChar. The encoding
// uses a 5-bit value: 09 → '0''9', 1025 → 'A'..'Z' but skipping
// I, L, O, S. We undo the skips to recover the original 5-bit value.
func restoreCharToByte(c byte) (byte, bool) {
switch {
case c >= '0' && c <= '9':
return c - '0', true
case c >= 'A' && c <= 'Z':
// I, L, O, S are deliberately absent from the encoding alphabet
// (visually similar to 1 / 1 / 0 / 5). Accepting them would map
// to the wrong 5-bit value and silently corrupt the HMAC key.
if c == 'I' || c == 'L' || c == 'O' || c == 'S' {
return 0, false
}
v := int(c)
if v >= 'T' {
v--
}
if v >= 'P' {
v--
}
if v >= 'M' {
v--
}
if v >= 'J' {
v--
}
v = v - 'A' + 10
if v < 10 || v > 31 {
return 0, false
}
return byte(v), true
}
return 0, false
}
@@ -0,0 +1,66 @@
package authenticator
import (
"testing"
)
// TestRestoreCodeRoundTrip verifies that restoreCharToByte exactly
// inverts restoreByteToChar across every 5-bit value the encoding
// produces. A regression here would silently corrupt the HMAC key the
// Restore flow sends to Blizzard, so the round-trip is the cheapest
// possible safety net.
func TestRestoreCodeRoundTrip(t *testing.T) {
for v := 0; v < 32; v++ {
c := restoreByteToChar(byte(v))
got, ok := restoreCharToByte(c)
if !ok {
t.Fatalf("v=%d encoded as %q but failed to decode", v, c)
}
if int(got) != v {
t.Fatalf("v=%d → %q → %d (want %d)", v, c, got, v)
}
}
}
// TestRestoreCharToByteRejectsAmbiguous confirms that the four letters
// deliberately omitted from the Battle.Net restore alphabet (I, L, O,
// S) are rejected on decode. A user typing "1" instead of "I" should
// land on the "1" branch; "I" should be a hard error rather than a
// silent misdecode.
func TestRestoreCharToByteRejectsAmbiguous(t *testing.T) {
for _, c := range []byte{'I', 'L', 'O', 'S'} {
if _, ok := restoreCharToByte(c); ok {
t.Errorf("char %q must not decode", c)
}
}
}
// TestDecodeRestoreCodeLength sanity-checks the length validator.
func TestDecodeRestoreCodeLength(t *testing.T) {
if _, err := decodeRestoreCode("ABCDEFGHI"); err == nil {
t.Error("expected error for 9-char input")
}
if _, err := decodeRestoreCode("ABCDEFGHIJK"); err == nil {
t.Error("expected error for 11-char input")
}
// 10 valid characters
if _, err := decodeRestoreCode("ABCDEFGHJK"); err != nil {
t.Errorf("unexpected error for 10-char input: %v", err)
}
}
// TestNormalizeBnetSerial verifies the user-friendly formats (dashes
// and spaces, mixed case) all collapse to the protocol-required form.
func TestNormalizeBnetSerial(t *testing.T) {
cases := map[string]string{
"us-1234-5678-9012": "US123456789012",
"US-1234-5678-9012": "US123456789012",
" US 1234 5678 9012": "US123456789012",
"us123456789012": "US123456789012",
}
for in, want := range cases {
if got := normalizeBnetSerial(in); got != want {
t.Errorf("normalizeBnetSerial(%q) = %q, want %q", in, got, want)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
package authenticator
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/httpc"
)
// GoogleAuthenticator implements the time-sync flavor of TOTP that Google,
// Microsoft and Okta all share. The only difference between vendors in the
// original C# port is the URL used to learn the server clock.
type GoogleAuthenticator struct {
Base
timeSyncURL string
}
// NewGoogleAuthenticator returns a Google-flavored TOTP authenticator.
func NewGoogleAuthenticator() *GoogleAuthenticator {
return newTOTP("https://www.google.com")
}
// NewMicrosoftAuthenticator is an alias kept for parity with the original
// C# class hierarchy.
func NewMicrosoftAuthenticator() *GoogleAuthenticator {
return newTOTP("https://www.microsoft.com")
}
// NewOktaVerifyAuthenticator returns a TOTP that syncs against okta.com.
func NewOktaVerifyAuthenticator() *GoogleAuthenticator {
return newTOTP("https://www.okta.com")
}
func newTOTP(syncURL string) *GoogleAuthenticator {
g := &GoogleAuthenticator{Base: NewBase(), timeSyncURL: syncURL}
return g
}
// Name returns a short identifier used in logs.
func (g *GoogleAuthenticator) Name() string {
switch g.timeSyncURL {
case "https://www.microsoft.com":
return "microsoft"
case "https://www.okta.com":
return "okta"
default:
return "google"
}
}
// Enroll loads a base32-encoded shared secret and then performs an initial
// clock sync against the vendor's HTTP endpoint.
func (g *GoogleAuthenticator) Enroll(b32 string) error {
const fn = "internal.authenticator.GoogleAuthenticator.Enroll"
raw, err := Base32Decode(b32)
if err != nil {
return err
}
g.SecretKey = raw
global.Log.WithField("func", fn).WithField("len", len(raw)).Debug("enrolled secret")
return g.Sync()
}
// CurrentCode returns the live TOTP for the receiver.
func (g *GoogleAuthenticator) CurrentCode() (string, error) {
if g.SecretKey == nil {
return "", fmt.Errorf("authenticator: no secret loaded")
}
return g.CalculateTOTP(), nil
}
// SecretData / SetSecretData delegate to the embedded Base.
func (g *GoogleAuthenticator) SecretData() string { return g.EncodeSecretData() }
func (g *GoogleAuthenticator) SetSecretData(value string) error { return g.DecodeSecretData(value) }
// Sync issues a HEAD request against the configured vendor URL and reads
// the response's Date header to derive ServerTimeDiff. Errors are swallowed
// in the same way as the original C# implementation — repeated failures
// should not block code generation, the local clock is the fallback.
func (g *GoogleAuthenticator) Sync() error {
const fn = "internal.authenticator.GoogleAuthenticator.Sync"
logger := global.Log.WithField("func", fn).WithField("url", g.timeSyncURL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodHead, g.timeSyncURL, nil)
if err != nil {
logger.WithError(err).Warn("build request failed")
return err
}
resp, err := httpc.New().Do(req)
if err != nil {
logger.WithError(err).Warn("sync request failed; using local clock")
return nil
}
defer resp.Body.Close()
dateStr := strings.TrimSpace(resp.Header.Get("Date"))
if dateStr == "" {
logger.Warn("response missing Date header")
return nil
}
t, err := http.ParseTime(dateStr)
if err != nil {
logger.WithError(err).Warn("invalid Date header")
return nil
}
serverMs := t.UnixMilli()
g.ServerTimeDiff = serverMs - NowMillis()
g.LastServerTime = NowMillis()
logger.WithField("offset_ms", g.ServerTimeDiff).Debug("clock synced")
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package authenticator
import (
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/binary"
"fmt"
"hash"
)
// hmacFor returns a fresh HMAC keyed with secret for the given hash type.
func hmacFor(h HMACType, secret []byte) hash.Hash {
switch h {
case HMACSHA256:
return hmac.New(sha256.New, secret)
case HMACSHA512:
return hmac.New(sha512.New, secret)
default:
return hmac.New(sha1.New, secret)
}
}
// hotpCode computes the RFC 4226 HOTP code for the given (secret, counter)
// pair, with the supplied number of decimal digits and HMAC algorithm.
func hotpCode(secret []byte, counter uint64, digits int, h HMACType) string {
mac := hmacFor(h, secret)
var counterBytes [8]byte
binary.BigEndian.PutUint64(counterBytes[:], counter)
_, _ = mac.Write(counterBytes[:])
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0F
truncated := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7FFFFFFF
mod := uint32(1)
for i := 0; i < digits; i++ {
mod *= 10
}
return fmt.Sprintf("%0*d", digits, truncated%mod)
}
@@ -0,0 +1,74 @@
package authenticator
import (
"fmt"
"strconv"
"strings"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
)
// HOTPAuthenticator implements RFC 4226 counter-based HOTP.
type HOTPAuthenticator struct {
Base
Counter uint64
}
// NewHOTPAuthenticator returns a fresh HOTP authenticator with the project
// defaults (6 digits, SHA1).
func NewHOTPAuthenticator() *HOTPAuthenticator {
return &HOTPAuthenticator{Base: NewBase()}
}
// Name returns the short logger tag for this type.
func (h *HOTPAuthenticator) Name() string { return "hotp" }
// Enroll loads the secret from a base32 string and optionally seeds the
// counter.
func (h *HOTPAuthenticator) Enroll(b32 string, counter uint64) error {
const fn = "internal.authenticator.HOTPAuthenticator.Enroll"
raw, err := Base32Decode(b32)
if err != nil {
return err
}
h.SecretKey = raw
h.Counter = counter
global.Log.WithField("func", fn).WithField("counter", counter).Debug("enrolled HOTP")
return nil
}
// CurrentCode increments the internal counter and returns the resulting code.
func (h *HOTPAuthenticator) CurrentCode() (string, error) {
if h.SecretKey == nil {
return "", fmt.Errorf("authenticator: no secret loaded")
}
h.Counter++
digits := h.CodeDigits
if digits == 0 {
digits = DefaultCodeDigits
}
return hotpCode(h.SecretKey, h.Counter, digits, h.HMACType), nil
}
// Sync is a no-op for HOTP — there is no server clock to align against.
func (h *HOTPAuthenticator) Sync() error { return nil }
// SecretData appends "|<counter>" to the base secret data string, matching
// the C# HOTPAuthenticator.SecretData getter.
func (h *HOTPAuthenticator) SecretData() string {
return h.EncodeSecretData() + "|" + strconv.FormatUint(h.Counter, 10)
}
// SetSecretData parses the "<base>|<counter>" form.
func (h *HOTPAuthenticator) SetSecretData(value string) error {
if err := h.DecodeSecretData(value); err != nil {
return err
}
if idx := strings.Index(value, "|"); idx >= 0 {
c, err := strconv.ParseUint(strings.TrimSpace(value[idx+1:]), 10, 64)
if err == nil {
h.Counter = c
}
}
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package authenticator
import (
"encoding/hex"
"fmt"
"strconv"
"strings"
)
// EncodeSecretData encodes the "<key>\t<digits>\t<hmac>\t<period>" string
// that the original WinAuth config uses inside <secretdata>.
func (b *Base) EncodeSecretData() string {
return fmt.Sprintf(
"%s\t%d\t%s\t%d",
strings.ToUpper(hex.EncodeToString(b.SecretKey)),
b.CodeDigits, b.HMACType.String(), b.Period,
)
}
// DecodeSecretData parses the value produced by EncodeSecretData (and
// optionally a "|"-suffixed payload for HOTP subclasses).
func (b *Base) DecodeSecretData(value string) error {
if value == "" {
b.SecretKey = nil
return nil
}
head := strings.SplitN(value, "|", 2)[0]
parts := strings.Split(head, "\t")
if len(parts) == 0 {
return fmt.Errorf("authenticator: empty secret data")
}
raw, err := hex.DecodeString(parts[0])
if err != nil {
return fmt.Errorf("authenticator: bad secret hex: %w", err)
}
b.SecretKey = raw
if len(parts) > 1 {
if d, err := strconv.Atoi(parts[1]); err == nil {
b.CodeDigits = d
}
}
if len(parts) > 2 {
switch strings.ToUpper(parts[2]) {
case "SHA256":
b.HMACType = HMACSHA256
case "SHA512":
b.HMACType = HMACSHA512
default:
b.HMACType = HMACSHA1
}
}
if len(parts) > 3 {
if p, err := strconv.Atoi(parts[3]); err == nil && p > 0 {
b.Period = p
}
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More