package config import ( "encoding/hex" "encoding/xml" "errors" "fmt" "os" "strings" "git.wxccs.org/iceking2nd/winauth-go/internal/crypto" "git.wxccs.org/iceking2nd/winauth-go/internal/global" ) // LegacyAuthenticator is the partial WinAuth XML element we care about. // Fields not relevant to migration are ignored. type LegacyAuthenticator struct { XMLName xml.Name `xml:"WinAuthAuthenticator"` Type string `xml:"type,attr"` Name string `xml:"name"` AuthData struct { Encrypted string `xml:"encrypted,attr"` SecretData string `xml:"secretdata"` ServerTimeDiff string `xml:"servertimediff"` } `xml:"authenticatordata"` } // legacyRoot matches the outer ... // container the original WinAuthHelper.SaveAuthenticator writes. type legacyRoot struct { XMLName xml.Name `xml:"WinAuth"` Authenticators []LegacyAuthenticator `xml:"authenticator>WinAuthAuthenticator"` } // LegacyPasswordType encodes the per-entry encryption layering of an old // WinAuth config. Multiple bits may be set: the original app supported // chained encryption like "yum" (password → user-DPAPI → machine-DPAPI). type LegacyPasswordType int const ( LegacyPasswordNone LegacyPasswordType = 0 LegacyPasswordExplicit LegacyPasswordType = 1 << iota // 'y' — PBKDF2-SHA1 + Blowfish LegacyPasswordUser // 'u' — User-scope DPAPI LegacyPasswordMachine // 'm' — Machine-scope DPAPI ) // ErrLegacyPasswordRequired is returned by LoadLegacyXML when at least // one entry has the 'y' bit set but the caller did not supply a // passphrase. The caller is expected to prompt the user and retry. var ErrLegacyPasswordRequired = errors.New("legacy XML: password required") // ErrLegacyPasswordWrong is returned when the supplied passphrase // successfully unprotects DPAPI layers but the resulting Blowfish output // fails to look like sensible UTF-8 secret data — almost always a wrong // password since the legacy format has no MAC. var ErrLegacyPasswordWrong = errors.New("legacy XML: wrong password") // parseLegacyEncryptionFlags maps the encrypted-attribute string ("y", // "ymu", "um", ...) to a flag bitmask. Unknown letters are ignored. func parseLegacyEncryptionFlags(s string) LegacyPasswordType { var f LegacyPasswordType for _, c := range strings.ToLower(strings.TrimSpace(s)) { switch c { case 'y': f |= LegacyPasswordExplicit case 'u': f |= LegacyPasswordUser case 'm': f |= LegacyPasswordMachine } } return f } // LegacyXMLNeedsPassword reports whether any entry in the file uses the // 'y' password layer, so the UI knows to prompt before calling // LoadLegacyXML with the user-supplied password. func LegacyXMLNeedsPassword(path string) (bool, error) { raw, err := os.ReadFile(path) if err != nil { return false, err } var root legacyRoot if err := xml.Unmarshal(raw, &root); err != nil { return false, fmt.Errorf("legacy XML: %w", err) } for _, a := range root.Authenticators { if parseLegacyEncryptionFlags(a.AuthData.Encrypted)&LegacyPasswordExplicit != 0 { return true, nil } } return false, nil } // LoadLegacyXML reads an old WinAuth XML config (the one stored at // %APPDATA%\WinAuth\winauth.xml). password may be nil if the file is // fully unencrypted, but it must be supplied if any entry carries the // 'y' password bit, otherwise ErrLegacyPasswordRequired is returned. // // Entries that cannot be decrypted (e.g. DPAPI blob produced by a // different Windows user / on a different machine) are logged and // skipped — partial migration is better than aborting. func LoadLegacyXML(path string, password []byte) (*Config, error) { const fn = "internal.config.LoadLegacyXML" logger := global.Log.WithField("func", fn).WithField("path", path) raw, err := os.ReadFile(path) if err != nil { return nil, err } var root legacyRoot if err := xml.Unmarshal(raw, &root); err != nil { return nil, fmt.Errorf("legacy XML: %w", err) } // Pre-flight: refuse early so the UI can switch to password prompt // without us having partially decrypted some unprotected entries. for _, a := range root.Authenticators { flags := parseLegacyEncryptionFlags(a.AuthData.Encrypted) if flags&LegacyPasswordExplicit != 0 && len(password) == 0 { return nil, ErrLegacyPasswordRequired } } cfg := &Config{Version: 1} for _, a := range root.Authenticators { entryLogger := logger.WithField("name", a.Name) flags := parseLegacyEncryptionFlags(a.AuthData.Encrypted) plaintext, err := decryptLegacySecretData(a.AuthData.SecretData, flags, password) if err != nil { entryLogger.WithError(err).Warn("skip entry: decrypt failed") continue } entry, err := convertLegacyEntry(a, plaintext) if err != nil { entryLogger.WithError(err).Warn("skip entry: conversion failed") continue } cfg.Entries = append(cfg.Entries, entry) } logger.WithField("entries", len(cfg.Entries)).Debug("legacy config imported") return cfg, nil } // decryptLegacySecretData reverses the layered encryption WinAuth wrote. // Each layer expects a hex string and produces a hex string (the final // layer's hex decodes to UTF-8 secret data). Order of removal is the // reverse of the original encryption order: User-DPAPI, then // Machine-DPAPI, then explicit Password. func decryptLegacySecretData(data string, flags LegacyPasswordType, password []byte) (string, error) { data = strings.TrimSpace(data) if data == "" || flags == LegacyPasswordNone { return data, nil } if flags&LegacyPasswordUser != 0 { blob, err := hex.DecodeString(data) if err != nil { return "", fmt.Errorf("user-DPAPI: hex decode: %w", err) } out, err := crypto.Unprotect(blob, nil, crypto.DPAPIScopeCurrentUser) if err != nil { return "", fmt.Errorf("user-DPAPI: %w", err) } data = hex.EncodeToString(out) } if flags&LegacyPasswordMachine != 0 { blob, err := hex.DecodeString(data) if err != nil { return "", fmt.Errorf("machine-DPAPI: hex decode: %w", err) } out, err := crypto.Unprotect(blob, nil, crypto.DPAPIScopeLocalMachine) if err != nil { return "", fmt.Errorf("machine-DPAPI: %w", err) } data = hex.EncodeToString(out) } if flags&LegacyPasswordExplicit != 0 { // First 16 hex chars = 8-byte salt; rest is Blowfish ciphertext. const saltHexLen = 16 if len(data) < saltHexLen { return "", errors.New("explicit: payload shorter than salt") } saltHex, bodyHex := data[:saltHexLen], data[saltHexLen:] salt, err := hex.DecodeString(saltHex) if err != nil { return "", fmt.Errorf("explicit: salt hex decode: %w", err) } key := crypto.DerivePBKDF2SHA1(password, salt) defer func() { for i := range key { key[i] = 0 } }() plain, err := crypto.LegacyDecryptBlowfish(bodyHex, key) if err != nil { return "", fmt.Errorf("explicit: %w", err) } // The Blowfish output should itself be a hex string representing // the next inner layer (or the UTF-8 secret data). A wrong // password almost always lands here producing garbage bytes; // check that the result is printable ASCII to reject it. if !looksLikeLegacyPlaintext(plain) { return "", ErrLegacyPasswordWrong } data = string(plain) } return data, nil } // looksLikeLegacyPlaintext returns true if buf reads as a printable // ASCII string of the kind WinAuth writes for SecretData (tab-separated // hex, pipe-separated serial, or JSON). Non-printable bytes nearly // always indicate a bad password since legacy Blowfish has no MAC. func looksLikeLegacyPlaintext(buf []byte) bool { if len(buf) == 0 { return false } for _, b := range buf { if b == '\t' || b == '\n' || b == '\r' || (b >= 0x20 && b < 0x7f) { continue } return false } return true } // convertLegacyEntry takes one parsed authenticator block and its // decrypted secret-data payload and turns it into a modern Entry. // Vendor-specific quirks (Battle.Net serial trailer, Steam JSON, ...) // live in the per-vendor helpers in legacy_secretdata.go. func convertLegacyEntry(a LegacyAuthenticator, secret string) (Entry, error) { vendor := detectLegacyVendor(a.Type) raw, err := normalizeLegacySecretData(vendor, secret) if err != nil { return Entry{}, err } return Entry{ Name: a.Name, Vendor: vendor, SecretRaw: raw, }, nil } func detectLegacyVendor(typeAttr string) string { t := strings.ToLower(typeAttr) switch { case strings.Contains(t, "battlenet"): return "battlenet" case strings.Contains(t, "steam"): return "steam" case strings.Contains(t, "microsoft"): return "microsoft" case strings.Contains(t, "oktaverify"): return "okta" case strings.Contains(t, "hotp"): return "hotp" default: return "google" } }