diff --git a/go.mod b/go.mod index d4f7ac3..e339646 100644 --- a/go.mod +++ b/go.mod @@ -4,17 +4,17 @@ go 1.26 require ( github.com/BurntSushi/toml v1.5.0 + github.com/danieljoos/wincred v1.2.3 github.com/gorilla/websocket v1.5.3 github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade github.com/nicksnyder/go-i18n/v2 v2.5.1 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.34.1 ) -require golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - require ( fyne.io/fyne/v2 v2.7.4 fyne.io/systray v1.12.1 // indirect @@ -56,7 +56,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.46.0 // indirect + golang.org/x/sys v0.46.0 modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/libc v1.55.3 // indirect modernc.org/mathutil v1.6.0 // indirect diff --git a/go.sum b/go.sum index 833bd47..b4edb92 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ= fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -83,6 +85,8 @@ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiY github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= diff --git a/internal/keychain/keychain_other.go b/internal/keychain/keychain_other.go index 8e176c8..cb9ee5b 100644 --- a/internal/keychain/keychain_other.go +++ b/internal/keychain/keychain_other.go @@ -1,8 +1,11 @@ -//go:build !darwin +//go:build !darwin && !windows package keychain -// MemStore is an in-memory fallback used on non-darwin platforms. +// MemStore is an in-memory fallback used on non-darwin, non-windows +// platforms (currently Linux). Passwords do not persist across +// restarts; a platform-appropriate backend (e.g. Secret Service) +// should be added for production use. type MemStore struct { data map[string]string } diff --git a/internal/keychain/keychain_windows.go b/internal/keychain/keychain_windows.go new file mode 100644 index 0000000..492889b --- /dev/null +++ b/internal/keychain/keychain_windows.go @@ -0,0 +1,94 @@ +//go:build windows + +package keychain + +import ( + "errors" + "fmt" + + "github.com/danieljoos/wincred" +) + +// keyPrefixes distinguish password vs token entries in the credential +// store, mirroring the macOS implementation. +const ( + passwordAccountPrefix = "password:" + tokenAccountPrefix = "token:" +) + +// WinStore implements Store using the Windows Credential Manager. +type WinStore struct{} + +// New returns a Windows Credential Manager-backed Store. +func New() Store { + return WinStore{} +} + +// targetName builds the credential TargetName from the account prefix +// and profile name: "/". +func targetName(account string) string { + return ServiceName + "/" + account +} + +func (WinStore) setItem(account, secret string) error { + cred := wincred.NewGenericCredential(targetName(account)) + cred.CredentialBlob = []byte(secret) + if err := cred.Write(); err != nil { + return fmt.Errorf("wincred write %s: %w", account, err) + } + return nil +} + +func (WinStore) getItem(account string) (string, error) { + cred, err := wincred.GetGenericCredential(targetName(account)) + if err != nil { + if errors.Is(err, wincred.ErrElementNotFound) { + return "", ErrNotFound + } + return "", fmt.Errorf("wincred read %s: %w", account, err) + } + return string(cred.CredentialBlob), nil +} + +func (WinStore) deleteItem(account string) error { + cred, err := wincred.GetGenericCredential(targetName(account)) + if err != nil { + if errors.Is(err, wincred.ErrElementNotFound) { + return nil // already gone — treat as success + } + return fmt.Errorf("wincred read %s: %w", account, err) + } + if err := cred.Delete(); err != nil { + return fmt.Errorf("wincred delete %s: %w", account, err) + } + return nil +} + +func (s WinStore) SetPassword(profileName, password string) error { + return s.setItem(passwordAccountPrefix+profileName, password) +} + +func (s WinStore) GetPassword(profileName string) (string, error) { + return s.getItem(passwordAccountPrefix + profileName) +} + +func (s WinStore) DeletePassword(profileName string) error { + return s.deleteItem(passwordAccountPrefix + profileName) +} + +func (s WinStore) SetToken(profileName, token string) error { + return s.setItem(tokenAccountPrefix+profileName, token) +} + +func (s WinStore) GetToken(profileName string) (string, error) { + return s.getItem(tokenAccountPrefix + profileName) +} + +func (s WinStore) DeleteToken(profileName string) error { + return s.deleteItem(tokenAccountPrefix + profileName) +} + +func (s WinStore) DeleteAll(profileName string) error { + _ = s.DeletePassword(profileName) + return s.DeleteToken(profileName) +}