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