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 "" } }