fd4d756823
- 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 三语翻译
143 lines
3.6 KiB
Go
143 lines
3.6 KiB
Go
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 ""
|
|
}
|
|
}
|