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:
@@ -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 ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user