feat(ui): 导出/备份 — otpauth URI 列表 + 加密备份导出/恢复

- qr/export.go: EntryToOtpAuth + OtpAuth.URI,将 config.Entry 反向
  转换为标准 otpauth:// URI(Battle.Net/Steam 标记为 partial)
- dialog_export.go: Tab 切换对话框,otpauth URI 列表可复制 +
  加密备份文件导出(独立密码,0o600 权限)
- dialog_restore_backup.go: 从 .winauth.bak 恢复,密码验证后合并
- settings_menu: 新增 Export / Restore Backup 菜单项
- i18n: en/zh-CN/de 三语翻译
This commit is contained in:
2026-06-12 10:44:51 +08:00
parent 133091a32e
commit fd4d756823
8 changed files with 722 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
package qr
import (
"encoding/hex"
"fmt"
"net/url"
"strconv"
"strings"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
)
// EntryToOtpAuth converts a config.Entry into an OtpAuth struct suitable
// for URI export. It parses the SecretRaw field directly instead of
// constructing a full authenticator, so the qr package stays free of
// network/Win32 dependencies. For Battle.Net and Steam the partial flag
// is set because those vendors carry extra fields (serial, device ID,
// session data) that cannot be expressed in the standard otpauth://
// format — only the raw HMAC secret and basic TOTP params are exported.
func EntryToOtpAuth(e config.Entry) (oa *OtpAuth, partial bool, err error) {
if e.SecretRaw == "" {
return nil, false, fmt.Errorf("qr: entry %q has no secret data", e.Name)
}
head, _, _ := strings.Cut(e.SecretRaw, "|")
parts := strings.Split(head, "\t")
if len(parts) == 0 || parts[0] == "" {
return nil, false, fmt.Errorf("qr: entry %q has empty secret", e.Name)
}
secretBytes, err := hex.DecodeString(parts[0])
if err != nil {
return nil, false, fmt.Errorf("qr: entry %q: bad secret hex: %w", e.Name, err)
}
oa = &OtpAuth{
SecretBase32: authenticator.Base32Encode(secretBytes),
Digits: authenticator.DefaultCodeDigits,
Algorithm: "SHA1",
Period: authenticator.DefaultPeriod,
}
if len(parts) > 1 {
if d, e := strconv.Atoi(parts[1]); e == nil && d > 0 {
oa.Digits = d
}
}
if len(parts) > 2 {
oa.Algorithm = strings.ToUpper(parts[2])
}
if len(parts) > 3 {
if p, e := strconv.Atoi(parts[3]); e == nil && p > 0 {
oa.Period = p
}
}
switch e.Vendor {
case "hotp":
oa.Type = "hotp"
// Counter is stored after the first "|" in SecretRaw.
if idx := strings.Index(e.SecretRaw, "|"); idx >= 0 {
if c, e := strconv.ParseUint(strings.TrimSpace(e.SecretRaw[idx+1:]), 10, 64); e == nil {
oa.Counter = c
}
}
default:
oa.Type = "totp"
}
issuer := vendorToIssuer(e.Vendor)
oa.Issuer = issuer
if e.Name != "" {
if issuer != "" {
oa.Label = issuer + ":" + e.Name
} else {
oa.Label = e.Name
}
}
if e.Vendor == "battlenet" || e.Vendor == "steam" {
partial = true
}
return oa, partial, nil
}
// URI renders the OtpAuth as an otpauth:// URI string per the
// Key-Uri-Format spec used by Google Authenticator et al.
func (oa *OtpAuth) URI() string {
var buf strings.Builder
buf.WriteString("otpauth://")
buf.WriteString(oa.Type)
buf.WriteByte('/')
if oa.Label != "" {
buf.WriteString(url.PathEscape(oa.Label))
}
buf.WriteString("?secret=")
buf.WriteString(oa.SecretBase32)
if oa.Issuer != "" {
buf.WriteString("&issuer=")
buf.WriteString(url.QueryEscape(oa.Issuer))
}
if oa.Algorithm != "" && oa.Algorithm != "SHA1" {
buf.WriteString("&algorithm=")
buf.WriteString(oa.Algorithm)
}
if oa.Digits > 0 && oa.Digits != 6 {
buf.WriteString("&digits=")
buf.WriteString(strconv.Itoa(oa.Digits))
}
if oa.Period > 0 && oa.Period != 30 {
buf.WriteString("&period=")
buf.WriteString(strconv.Itoa(oa.Period))
}
if oa.Type == "hotp" && oa.Counter > 0 {
buf.WriteString("&counter=")
buf.WriteString(strconv.FormatUint(oa.Counter, 10))
}
return buf.String()
}
// vendorToIssuer maps internal vendor strings to standard issuer names
// for otpauth:// URIs.
func vendorToIssuer(vendor string) string {
switch vendor {
case "google":
return "Google"
case "microsoft":
return "Microsoft"
case "okta":
return "Okta"
case "hotp":
return ""
case "battlenet":
return "Battle.Net"
case "steam":
return "Steam"
default:
return ""
}
}