From 7c22d047a32293cdb669c03074494fb0ba8d1b5b Mon Sep 17 00:00:00 2001 From: sspaeti Date: Wed, 6 May 2026 10:07:05 +0200 Subject: [PATCH 1/2] WIP: add OS keyring support for passwords + OAuth2 tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-implementation of PR #5 onto current main. Differences vs the original PR: - Resolves the "keyring" sentinel inside config.Load() rather than at IMAP construction time. This fixes the [[senders]] gap: SMTP send paths and sender aliases that reference an account now see the resolved password automatically without needing their own keyring lookup. - OAuth2 token storage uses keyring with file fallback for headless/SSH. TokenSource keeps its existing func() (string, error) signature; no caller-side API changes. Foundations only — :set-password / :migrate-to-keyring cmdline commands and the password_prompt UI integration are intentionally deferred. Users on this branch must seed the keyring externally (e.g. secret-tool, kwallet, or a one-off Go helper) before launching neomd. Co-Authored-By: Jesus Rodriguez Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 + cmd/neomd/main.go | 1 + docs/content/docs/configuration/_index.md | 22 +++ go.mod | 3 + go.sum | 13 ++ internal/config/config.go | 40 ++++++ internal/config/config_test.go | 110 +++++++++++++++ internal/keyring/keyring.go | 89 ++++++++++++ internal/keyring/keyring_test.go | 156 ++++++++++++++++++++++ internal/keyring/mock.go | 81 +++++++++++ internal/oauth2/oauth2.go | 84 +++++++++--- internal/ui/password_prompt.go | 154 +++++++++++++++++++++ 12 files changed, 739 insertions(+), 16 deletions(-) create mode 100644 internal/keyring/keyring.go create mode 100644 internal/keyring/keyring_test.go create mode 100644 internal/keyring/mock.go create mode 100644 internal/ui/password_prompt.go diff --git a/CLAUDE.md b/CLAUDE.md index b8af05f..91611b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,8 @@ Folder operations prefer RFC 6851 MOVE; `u` undo uses UIDPLUS destination UIDs c **Config** (`internal/config/`) — TOML at `~/.config/neomd/config.toml`, auto-created with placeholders. Supports multiple `[[accounts]]` and SMTP-only `[[senders]]` aliases (cycled with `ctrl+f` in compose/pre-send). OAuth2 authentication supported via `oauth2_client_id`, `oauth2_client_secret`, `oauth2_issuer_url`, `oauth2_scopes` fields. `-config PATH` flag overrides location. +**Keyring credentials** (`internal/keyring/`) — accounts may set `password = "keyring"` to fetch the password from the OS keyring (zalando/go-keyring; macOS Keychain, Secret Service on Linux, Credential Manager on Windows). Resolution happens in `config.Load()` so all consumers (IMAP at boot, SMTP at send, `[[senders]]` aliases) see the resolved value. If the keyring entry is missing or the service is unavailable, the `"keyring"` sentinel is preserved and a warning is logged. Service name: `neomd`; key format: `account//password`. + **Documentation** — Hugo site in `docs/` served at https://neomd.ssp.sh/. README.md is synced to `docs/content/overview.md` via `scripts/sync-readme-to-docs.sh`. Keybindings are auto-generated from `internal/ui/keys.go` via `cmd/docs/main.go` — never hand-edit the markdown tables. **Package structure:** diff --git a/cmd/neomd/main.go b/cmd/neomd/main.go index 9e7c321..e7dee7d 100644 --- a/cmd/neomd/main.go +++ b/cmd/neomd/main.go @@ -102,6 +102,7 @@ func main() { Scopes: acc.OAuth2Scopes, RedirectPort: acc.OAuth2RedirectPort, TokenFile: tokenFile, + AccountName: acc.Name, // enables keyring storage; TokenFile remains as headless fallback }) if err != nil { fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2: %v\n", acc.Name, err) diff --git a/docs/content/docs/configuration/_index.md b/docs/content/docs/configuration/_index.md index f638609..f0eec69 100644 --- a/docs/content/docs/configuration/_index.md +++ b/docs/content/docs/configuration/_index.md @@ -7,6 +7,28 @@ sidebar: On first run, neomd creates `~/.config/neomd/config.toml` with placeholders. +## Storing passwords in the OS keyring + +Set `password = "keyring"` to fetch the password from your OS keyring (macOS Keychain, GNOME Keyring / KDE Wallet via Secret Service on Linux, Windows Credential Manager) at startup. The lookup uses the `[[accounts]].name` as the account identifier under service `neomd`. + +```toml +[[accounts]] +name = "Personal" +password = "keyring" # resolved at startup; see below for setup +# ...rest of the account +``` + +**Setup before first launch (Linux example using `secret-tool`):** + +```sh +secret-tool store --label "neomd Personal" service neomd account/Personal/password +# enter password when prompted +``` + +If the keyring entry is missing or the keyring service is unavailable, neomd prints a warning and the literal sentinel `"keyring"` is used as the password — IMAP/SMTP authentication will then fail with a clear error. `[[senders]]` aliases that reference an account inherit the resolved keyring password automatically. + +OAuth2 tokens are still persisted to `~/.config/neomd/tokens/.json` (mode `0600`). Keyring storage for OAuth2 tokens is on the roadmap. + ## Full example ```toml diff --git a/go.mod b/go.mod index a53bf18..498a0f8 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/sspaeti/goldmark-obsidian-callout-for-neomd v0.1.1 github.com/yuin/goldmark v1.7.8 + github.com/zalando/go-keyring v0.2.8 golang.org/x/oauth2 v0.35.0 ) @@ -32,8 +33,10 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 37c94e8..d031145 100644 --- a/go.sum +++ b/go.sum @@ -44,7 +44,10 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= @@ -58,6 +61,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -103,8 +108,12 @@ github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NF github.com/sspaeti/goldmark-obsidian-callout-for-neomd v0.1.1 h1:obWsWMJZGW4w9Qj1/WKLDweR+olq23r24UPS86XTbQU= github.com/sspaeti/goldmark-obsidian-callout-for-neomd v0.1.1/go.mod h1:p/gHio8liSHKd6Zcig2LFSU8Ym1yV9L+Dt1eYP9F6+I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -113,6 +122,8 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= @@ -182,3 +193,5 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 4f8f3da..66c513a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/BurntSushi/toml" + "github.com/sspaeti/neomd/internal/keyring" ) // SenderConfig holds a named "From" alias used only for sending. @@ -52,6 +53,19 @@ func (a AccountConfig) IsOAuth2() bool { return strings.EqualFold(a.AuthType, "oauth2") } +// keyringSentinel is the literal value users put in `password = "keyring"` +// to signal "fetch from OS keyring at startup." +const keyringSentinel = "keyring" + +// UseKeyring reports whether this account stores its password in the OS +// keyring. After config.Load() resolves the keyring entry, Password holds +// the actual password and this method returns false. The sentinel only +// remains if keyring lookup failed (no entry yet, or service unavailable), +// in which case downstream code can prompt the user via :set-password. +func (a AccountConfig) UseKeyring() bool { + return a.Password == keyringSentinel +} + // ScreenerConfig points to the allowlist/blocklist files. type ScreenerConfig struct { ScreenedIn string `toml:"screened_in"` @@ -462,10 +476,12 @@ func Load(path string) (*Config, error) { cfg.Accounts[i].Password = expandEnv(cfg.Accounts[i].Password) cfg.Accounts[i].User = expandEnv(cfg.Accounts[i].User) cfg.Accounts[i].TLSCertFile = expandPath(expandEnv(cfg.Accounts[i].TLSCertFile)) + cfg.Accounts[i].Password = resolveKeyringPassword(cfg.Accounts[i].Name, cfg.Accounts[i].Password) } cfg.Account.Password = expandEnv(cfg.Account.Password) cfg.Account.User = expandEnv(cfg.Account.User) cfg.Account.TLSCertFile = expandPath(expandEnv(cfg.Account.TLSCertFile)) + cfg.Account.Password = resolveKeyringPassword(cfg.Account.Name, cfg.Account.Password) cfg.Listmonk.APIToken = expandEnv(cfg.Listmonk.APIToken) @@ -607,6 +623,30 @@ func writeDefault(path string, cfg *Config) error { return toml.NewEncoder(f).Encode(cfg) } +// resolveKeyringPassword turns the "keyring" sentinel into the actual password +// stored in the OS keyring under the given account name. Anything else is +// returned unchanged. If the keyring lookup fails (no entry yet, or service +// unavailable) the sentinel is preserved so downstream code can detect it and +// prompt the user; a one-line warning is written to stderr. +// +// This step runs in Load() so every consumer (IMAP at boot, SMTP at send, +// senders aliasing this account) sees the resolved value. +func resolveKeyringPassword(accountName, password string) string { + if password != keyringSentinel || accountName == "" { + return password + } + resolved, err := keyring.GetPassword(accountName) + if err == nil { + return resolved + } + if err == keyring.ErrNotFound { + fmt.Fprintf(os.Stderr, "neomd: account %q: keyring entry not set — run :set-password %s\n", accountName, accountName) + } else { + fmt.Fprintf(os.Stderr, "neomd: account %q: keyring unavailable: %v\n", accountName, err) + } + return password // leave sentinel for downstream +} + // expandEnv resolves a value that is entirely a single env var reference // ($VAR or ${VAR}). If the value contains other text or multiple $ signs // it is returned as-is, so passwords containing $ are never mangled. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8da6144..8ec5b42 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,6 +5,9 @@ import ( "path/filepath" "strings" "testing" + + zalandokeyring "github.com/zalando/go-keyring" + "github.com/sspaeti/neomd/internal/keyring" ) func TestExpandEnv(t *testing.T) { @@ -368,3 +371,110 @@ func TestValidate_NegativeUIValues(t *testing.T) { t.Error("expected error for negative inbox_count") } } + +func TestUseKeyring(t *testing.T) { + tests := []struct { + password string + want bool + }{ + {"keyring", true}, + {"actual-password", false}, + {"", false}, + {"$ENV_VAR", false}, + {"keyring-like", false}, + } + for _, tt := range tests { + acc := AccountConfig{Password: tt.password} + if got := acc.UseKeyring(); got != tt.want { + t.Errorf("UseKeyring(%q) = %v, want %v", tt.password, got, tt.want) + } + } +} + +func TestResolveKeyringPassword(t *testing.T) { + // Use the in-memory mock provided by zalando/go-keyring so tests don't + // touch the real OS keyring. + zalandokeyring.MockInit() + + t.Run("resolved when entry exists", func(t *testing.T) { + const acct = "TestAcctResolved" + if err := keyring.SetPassword(acct, "real-secret"); err != nil { + t.Fatalf("SetPassword: %v", err) + } + got := resolveKeyringPassword(acct, "keyring") + if got != "real-secret" { + t.Errorf("got %q, want resolved password", got) + } + _ = keyring.DeletePassword(acct) + }) + + t.Run("sentinel preserved when entry missing", func(t *testing.T) { + got := resolveKeyringPassword("MissingAcct", "keyring") + if got != "keyring" { + t.Errorf("got %q, want sentinel preserved", got) + } + }) + + t.Run("non-sentinel passthrough", func(t *testing.T) { + got := resolveKeyringPassword("any", "literal-password") + if got != "literal-password" { + t.Errorf("got %q, want passthrough", got) + } + }) + + t.Run("empty account name passthrough", func(t *testing.T) { + // Anonymous accounts (empty Name) should not trigger a keyring lookup. + got := resolveKeyringPassword("", "keyring") + if got != "keyring" { + t.Errorf("got %q, want passthrough for empty account", got) + } + }) +} + +// TestLoad_KeyringResolvesAcrossSenders verifies the senders-gap fix: +// PR #5 resolved keyring at IMAP construction time, leaving SMTP send paths +// reading the literal "keyring" sentinel. By moving resolution into Load(), +// every consumer (IMAP, SMTP, [[senders]] aliases) sees the resolved value. +func TestLoad_KeyringResolvesAcrossSenders(t *testing.T) { + zalandokeyring.MockInit() + const acctName = "Personal" + const realPw = "the-real-password" + if err := keyring.SetPassword(acctName, realPw); err != nil { + t.Fatalf("SetPassword: %v", err) + } + defer keyring.DeletePassword(acctName) + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + cfgBody := ` +[[accounts]] +name = "Personal" +imap = "imap.example.com:993" +smtp = "smtp.example.com:587" +user = "me@example.com" +password = "keyring" +from = "Me " + +[[senders]] +name = "Alias" +from = "Alias " +account = "Personal" +` + if err := os.WriteFile(cfgPath, []byte(cfgBody), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.Accounts) != 1 { + t.Fatalf("expected 1 account, got %d", len(cfg.Accounts)) + } + if got := cfg.Accounts[0].Password; got != realPw { + t.Errorf("Accounts[0].Password = %q, want resolved %q (the senders-gap fix)", got, realPw) + } + if cfg.Accounts[0].UseKeyring() { + t.Error("UseKeyring() should be false after successful resolution") + } +} diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go new file mode 100644 index 0000000..6438d2c --- /dev/null +++ b/internal/keyring/keyring.go @@ -0,0 +1,89 @@ +// Package keyring provides secure storage for passwords and OAuth2 tokens +// using the OS keyring (macOS Keychain, Linux Secret Service, Windows Credential Manager). +package keyring + +import ( + "encoding/json" + "fmt" + + "github.com/zalando/go-keyring" + "golang.org/x/oauth2" +) + +const serviceName = "neomd" + +// passwordKey returns the keyring key for an account password. +func passwordKey(accountName string) string { + return fmt.Sprintf("account/%s/password", accountName) +} + +// oauth2Key returns the keyring key for an OAuth2 token. +func oauth2Key(accountName string) string { + return fmt.Sprintf("account/%s/oauth2", accountName) +} + +// SetPassword stores a password in the OS keyring. +func SetPassword(accountName, password string) error { + return keyring.Set(serviceName, passwordKey(accountName), password) +} + +// GetPassword retrieves a password from the OS keyring. +// Returns ErrNotFound if no password exists for this account. +func GetPassword(accountName string) (string, error) { + password, err := keyring.Get(serviceName, passwordKey(accountName)) + if err == keyring.ErrNotFound { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("keyring get password: %w", err) + } + return password, nil +} + +// DeletePassword removes a password from the OS keyring. +func DeletePassword(accountName string) error { + err := keyring.Delete(serviceName, passwordKey(accountName)) + if err == keyring.ErrNotFound { + return nil // Already deleted is fine + } + return err +} + +// SetOAuth2Token stores an OAuth2 token in the OS keyring as JSON. +func SetOAuth2Token(accountName string, token *oauth2.Token) error { + data, err := json.Marshal(token) + if err != nil { + return fmt.Errorf("marshal oauth2 token: %w", err) + } + return keyring.Set(serviceName, oauth2Key(accountName), string(data)) +} + +// GetOAuth2Token retrieves an OAuth2 token from the OS keyring. +// Returns ErrNotFound if no token exists for this account. +func GetOAuth2Token(accountName string) (*oauth2.Token, error) { + data, err := keyring.Get(serviceName, oauth2Key(accountName)) + if err == keyring.ErrNotFound { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("keyring get oauth2 token: %w", err) + } + + var token oauth2.Token + if err := json.Unmarshal([]byte(data), &token); err != nil { + return nil, fmt.Errorf("unmarshal oauth2 token: %w", err) + } + return &token, nil +} + +// DeleteOAuth2Token removes an OAuth2 token from the OS keyring. +func DeleteOAuth2Token(accountName string) error { + err := keyring.Delete(serviceName, oauth2Key(accountName)) + if err == keyring.ErrNotFound { + return nil // Already deleted is fine + } + return err +} + +// ErrNotFound is returned when a keyring entry doesn't exist. +var ErrNotFound = fmt.Errorf("keyring: not found") diff --git a/internal/keyring/keyring_test.go b/internal/keyring/keyring_test.go new file mode 100644 index 0000000..fce8418 --- /dev/null +++ b/internal/keyring/keyring_test.go @@ -0,0 +1,156 @@ +package keyring + +import ( + "encoding/json" + "testing" + "time" + + "golang.org/x/oauth2" +) + +func TestPasswordRoundTrip(t *testing.T) { + // Use mock backend for tests + mock := NewMockBackend() + + account := "test@example.com" + password := "secret123" + + // Test Set and Get + key := passwordKey(account) + err := mock.Set(serviceName, key, password) + if err != nil { + t.Fatalf("Set failed: %v", err) + } + + got, err := mock.Get(serviceName, key) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if got != password { + t.Errorf("got %q, want %q", got, password) + } +} + +func TestGetNotFound(t *testing.T) { + mock := NewMockBackend() + + _, err := mock.Get(serviceName, passwordKey("nonexistent")) + if err != ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestDelete(t *testing.T) { + mock := NewMockBackend() + account := "test@example.com" + key := passwordKey(account) + + // Set then delete + mock.Set(serviceName, key, "password") + err := mock.Delete(serviceName, key) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + // Verify it's gone + _, err = mock.Get(serviceName, key) + if err != ErrNotFound { + t.Errorf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestOAuth2TokenRoundTrip(t *testing.T) { + mock := NewMockBackend() + account := "oauth@example.com" + + token := &oauth2.Token{ + AccessToken: "access_token_123", + RefreshToken: "refresh_token_456", + Expiry: time.Now().Add(time.Hour), + } + + // Marshal and store + data, _ := json.Marshal(token) + key := oauth2Key(account) + err := mock.Set(serviceName, key, string(data)) + if err != nil { + t.Fatalf("Set failed: %v", err) + } + + // Retrieve and unmarshal + got, err := mock.Get(serviceName, key) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + var gotToken oauth2.Token + if err := json.Unmarshal([]byte(got), &gotToken); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if gotToken.AccessToken != token.AccessToken { + t.Errorf("AccessToken: got %q, want %q", gotToken.AccessToken, token.AccessToken) + } + if gotToken.RefreshToken != token.RefreshToken { + t.Errorf("RefreshToken: got %q, want %q", gotToken.RefreshToken, token.RefreshToken) + } +} + +func TestKeyFormats(t *testing.T) { + tests := []struct { + account string + wantPass string + wantOauth string + }{ + { + account: "Personal", + wantPass: "account/Personal/password", + wantOauth: "account/Personal/oauth2", + }, + { + account: "me@example.com", + wantPass: "account/me@example.com/password", + wantOauth: "account/me@example.com/oauth2", + }, + { + account: "user/name/with/slashes", + wantPass: "account/user/name/with/slashes/password", + wantOauth: "account/user/name/with/slashes/oauth2", + }, + } + + for _, tt := range tests { + t.Run(tt.account, func(t *testing.T) { + gotPass := passwordKey(tt.account) + if gotPass != tt.wantPass { + t.Errorf("passwordKey: got %q, want %q", gotPass, tt.wantPass) + } + + gotOauth := oauth2Key(tt.account) + if gotOauth != tt.wantOauth { + t.Errorf("oauth2Key: got %q, want %q", gotOauth, tt.wantOauth) + } + }) + } +} + +func TestClear(t *testing.T) { + mock := NewMockBackend() + + // Add some data + mock.Set(serviceName, "key1", "value1") + mock.Set(serviceName, "key2", "value2") + + // Clear all + mock.Clear() + + // Verify all gone + _, err := mock.Get(serviceName, "key1") + if err != ErrNotFound { + t.Errorf("key1: expected ErrNotFound after clear, got %v", err) + } + _, err = mock.Get(serviceName, "key2") + if err != ErrNotFound { + t.Errorf("key2: expected ErrNotFound after clear, got %v", err) + } +} diff --git a/internal/keyring/mock.go b/internal/keyring/mock.go new file mode 100644 index 0000000..b779cde --- /dev/null +++ b/internal/keyring/mock.go @@ -0,0 +1,81 @@ +// Package keyring provides secure storage for passwords and OAuth2 tokens. +// This file contains mock implementations for testing. +package keyring + +import ( + "encoding/json" + "sync" + + "golang.org/x/oauth2" +) + +// MockBackend is a memory-based mock keyring for testing. +// Use this in tests by setting the environment variable NEOMD_TEST_KEYRING_MOCK=1. +type MockBackend struct { + mu sync.RWMutex + store map[string]string +} + +// NewMockBackend creates a new mock keyring backend. +func NewMockBackend() *MockBackend { + return &MockBackend{ + store: make(map[string]string), + } +} + +// Set stores a value in the mock keyring. +func (m *MockBackend) Set(service, key, value string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.store[service+"/"+key] = value + return nil +} + +// Get retrieves a value from the mock keyring. +func (m *MockBackend) Get(service, key string) (string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + value, ok := m.store[service+"/"+key] + if !ok { + return "", ErrNotFound + } + return value, nil +} + +// Delete removes a value from the mock keyring. +func (m *MockBackend) Delete(service, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.store, service+"/"+key) + return nil +} + +// Clear removes all entries from the mock keyring. +func (m *MockBackend) Clear() { + m.mu.Lock() + defer m.mu.Unlock() + m.store = make(map[string]string) +} + +// MockProvider is the global mock backend instance used in tests. +// Tests can access this to set up pre-existing credentials. +var MockProvider = NewMockBackend() + +// IsMockEnabled returns true if the mock backend should be used. +// This is controlled by the NEOMD_TEST_KEYRING_MOCK environment variable. +// In a real implementation, this would check os.Getenv. +func IsMockEnabled() bool { + // This will be checked by the main implementation + return false +} + +// SetMockPassword is a test helper to pre-populate a password. +func SetMockPassword(accountName, password string) { + MockProvider.Set(serviceName, passwordKey(accountName), password) +} + +// SetMockOAuth2Token is a test helper to pre-populate an OAuth2 token. +func SetMockOAuth2Token(accountName string, token *oauth2.Token) { + data, _ := json.Marshal(token) + MockProvider.Set(serviceName, oauth2Key(accountName), string(data)) +} diff --git a/internal/oauth2/oauth2.go b/internal/oauth2/oauth2.go index 40457ec..d0bd72d 100644 --- a/internal/oauth2/oauth2.go +++ b/internal/oauth2/oauth2.go @@ -24,6 +24,7 @@ import ( "time" "github.com/emersion/go-sasl" + "github.com/sspaeti/neomd/internal/keyring" "golang.org/x/oauth2" ) @@ -41,7 +42,12 @@ type Config struct { TokenURL string // manual override (skips discovery) Scopes []string RedirectPort int // local callback port; defaults to 8085 - TokenFile string // path to persist the token JSON + TokenFile string // path to persist the token JSON (used as fallback when keyring is unavailable) + + // AccountName, when non-empty, enables keyring storage for the OAuth2 + // token under key `account//oauth2`. The token file remains as a + // fallback for headless/SSH systems where no keyring is available. + AccountName string DiscoveryTimeout time.Duration // Timeout for the discovery OIDC HTTP request. Defaults to 10s AuthFlowTimeout time.Duration // Timeout for the AuthFlow to be completed. Defaults to 5m @@ -140,7 +146,9 @@ func TokenSource(ctx context.Context, cfg Config) (func() (string, error), error Scopes: cfg.Scopes, } - tok, err := loadToken(cfg.TokenFile) + storage := newTokenStorage(cfg.AccountName, cfg.TokenFile) + + tok, err := storage.Load() if err != nil { flowCtx, flowCancel := context.WithTimeout(ctx, cfg.authFlowTimeout()) defer flowCancel() @@ -149,7 +157,7 @@ func TokenSource(ctx context.Context, cfg Config) (func() (string, error), error if err != nil { return nil, fmt.Errorf("oauth2 auth flow: %w", err) } - if err := saveToken(cfg.TokenFile, tok); err != nil { + if err := storage.Save(tok); err != nil { return nil, fmt.Errorf("save oauth2 token: %w", err) } } @@ -161,7 +169,7 @@ func TokenSource(ctx context.Context, cfg Config) (func() (string, error), error if err != nil { return "", err } - _ = saveToken(cfg.TokenFile, t) + _ = storage.Save(t) return t.AccessToken, nil }, nil } @@ -251,18 +259,6 @@ func openBrowser(url string) { _ = exec.Command(cmd, args...).Start() } -func saveToken(path string, tok *oauth2.Token) error { - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return err - } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) - if err != nil { - return err - } - defer f.Close() - return json.NewEncoder(f).Encode(tok) -} - // XOAuth2Client returns a sasl.Client that implements the XOAUTH2 mechanism. // Both Google and Microsoft Exchange Online support XOAUTH2; it is more // broadly compatible than the RFC 7628 OAUTHBEARER mechanism. @@ -286,6 +282,50 @@ func (c *xoauth2Client) Next(_ []byte) ([]byte, error) { return []byte{}, nil } +// tokenStorage persists OAuth2 tokens with a keyring-first / file-fallback policy. +// Flow: +// - Save: try keyring (if AccountName set); if it fails (or no name), write to file. +// - Load: try keyring first; on ErrNotFound or any keyring failure, fall back to file. +// The TokenFile path remains useful for headless/SSH systems where the keyring +// service is unavailable. Both paths use mode 0600. +type tokenStorage struct { + account string + path string +} + +func newTokenStorage(account, path string) *tokenStorage { + return &tokenStorage{account: account, path: path} +} + +func (s *tokenStorage) Load() (*oauth2.Token, error) { + if s.account != "" { + tok, err := keyring.GetOAuth2Token(s.account) + if err == nil { + return tok, nil + } + // Any keyring error (including ErrNotFound) — try file fallback. + } + if s.path == "" { + return nil, fmt.Errorf("oauth2: no token storage available (no account name and no token file path)") + } + return loadToken(s.path) +} + +func (s *tokenStorage) Save(tok *oauth2.Token) error { + if s.account != "" { + if err := keyring.SetOAuth2Token(s.account, tok); err == nil { + return nil + } + // Keyring failed — fall back to file. + } + if s.path == "" { + return fmt.Errorf("oauth2: no token storage available") + } + return saveToken(s.path, tok) +} + +// loadToken / saveToken expose the file-storage path so existing tests +// (and the tokenStorage fallback) share one implementation. func loadToken(path string) (*oauth2.Token, error) { data, err := os.ReadFile(path) if err != nil { @@ -297,3 +337,15 @@ func loadToken(path string) (*oauth2.Token, error) { } return &tok, nil } + +func saveToken(path string, tok *oauth2.Token) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer f.Close() + return json.NewEncoder(f).Encode(tok) +} diff --git a/internal/ui/password_prompt.go b/internal/ui/password_prompt.go new file mode 100644 index 0000000..d935fee --- /dev/null +++ b/internal/ui/password_prompt.go @@ -0,0 +1,154 @@ +package ui + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// passwordPromptModel holds state for the password prompt view. +type passwordPromptModel struct { + input textinput.Model + account string // account name requesting password + promptType promptType + errMsg string // error message to display (e.g., "authentication failed") +} + +// promptType indicates why we're asking for a password. +type promptType int + +const ( + promptNewAccount promptType = iota // new account setup + promptAuthFailed // auth failed, need to re-enter + promptManualSet // user ran :set-password command +) + +// newPasswordPromptModel creates a new password prompt with masked input. +func newPasswordPromptModel() passwordPromptModel { + ti := textinput.New() + ti.Placeholder = "Enter password" + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '•' + ti.Focus() + ti.CharLimit = 256 + ti.Width = 50 + ti.Prompt = "" + + return passwordPromptModel{input: ti} +} + +// reset clears the input and error message. +func (p *passwordPromptModel) reset() { + p.input.Reset() + p.errMsg = "" + p.input.Focus() +} + +// setPrompt configures the prompt for a specific account and type. +func (p *passwordPromptModel) setPrompt(account string, pt promptType, errMsg string) { + p.account = account + p.promptType = pt + p.errMsg = errMsg + p.reset() +} + +// passwordSubmittedMsg is sent when the user submits a password. +type passwordSubmittedMsg struct { + account string + password string +} + +// passwordCancelledMsg is sent when the user cancels the prompt. +type passwordCancelledMsg struct{} + +// Update handles input for the password prompt. +func (p passwordPromptModel) Update(msg tea.Msg) (passwordPromptModel, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + if p.input.Value() != "" { + return p, func() tea.Msg { + return passwordSubmittedMsg{ + account: p.account, + password: p.input.Value(), + } + } + } + case tea.KeyEsc, tea.KeyCtrlC: + return p, func() tea.Msg { + return passwordCancelledMsg{} + } + } + } + + p.input, cmd = p.input.Update(msg) + return p, cmd +} + +// View renders the password prompt. +func (p passwordPromptModel) View(width, height int) string { + // Center the dialog + dialogWidth := 60 + + var title string + switch p.promptType { + case promptNewAccount: + title = fmt.Sprintf("🔐 Set password for %s", p.account) + case promptAuthFailed: + title = fmt.Sprintf("🔐 Authentication failed for %s", p.account) + case promptManualSet: + title = fmt.Sprintf("🔐 Update password for %s", p.account) + } + + var hint string + switch p.promptType { + case promptNewAccount: + hint = "This account uses the OS keyring for secure password storage.\nEnter your password to continue." + case promptAuthFailed: + hint = "Your password may be incorrect or have expired.\nEnter the correct password to continue." + case promptManualSet: + hint = "Enter the new password to store in the OS keyring." + } + + // Build content + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7aa2f7")).Bold(true) + content := titleStyle.Render(title) + "\n\n" + content += lipgloss.NewStyle().Foreground(lipgloss.Color("#7aa2f7")).Render(hint) + "\n\n" + + if p.errMsg != "" { + content += styleError.Render("Error: "+p.errMsg) + "\n\n" + } + + // Input field with border + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#565f89")). + Padding(0, 1). + Width(dialogWidth - 4) + + content += inputStyle.Render(p.input.View()) + "\n\n" + + // Help text + help := "[Enter] Confirm [Esc] Cancel" + content += lipgloss.NewStyle().Foreground(lipgloss.Color("#565f89")).Render(help) + + // Dialog box + dialogStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7aa2f7")). + Padding(1, 2). + Width(dialogWidth) + + dialog := dialogStyle.Render(content) + + // Center on screen + return lipgloss.Place(width, height, + lipgloss.Center, lipgloss.Center, + dialog, + ) +} From 155ec85c39189d399c64735dd54a474a908b940e Mon Sep 17 00:00:00 2001 From: sspaeti Date: Wed, 6 May 2026 12:17:45 +0200 Subject: [PATCH 2/2] documentation and fix --- docs/content/docs/configuration/_index.md | 74 ++++++++++++++++------- internal/ui/password_prompt.go | 5 +- 2 files changed, 56 insertions(+), 23 deletions(-) diff --git a/docs/content/docs/configuration/_index.md b/docs/content/docs/configuration/_index.md index f0eec69..16a82e3 100644 --- a/docs/content/docs/configuration/_index.md +++ b/docs/content/docs/configuration/_index.md @@ -7,27 +7,6 @@ sidebar: On first run, neomd creates `~/.config/neomd/config.toml` with placeholders. -## Storing passwords in the OS keyring - -Set `password = "keyring"` to fetch the password from your OS keyring (macOS Keychain, GNOME Keyring / KDE Wallet via Secret Service on Linux, Windows Credential Manager) at startup. The lookup uses the `[[accounts]].name` as the account identifier under service `neomd`. - -```toml -[[accounts]] -name = "Personal" -password = "keyring" # resolved at startup; see below for setup -# ...rest of the account -``` - -**Setup before first launch (Linux example using `secret-tool`):** - -```sh -secret-tool store --label "neomd Personal" service neomd account/Personal/password -# enter password when prompted -``` - -If the keyring entry is missing or the keyring service is unavailable, neomd prints a warning and the literal sentinel `"keyring"` is used as the password — IMAP/SMTP authentication will then fail with a clear error. `[[senders]]` aliases that reference an account inherit the resolved keyring password automatically. - -OAuth2 tokens are still persisted to `~/.config/neomd/tokens/.json` (mode `0600`). Keyring storage for OAuth2 tokens is on the roadmap. ## Full example @@ -119,7 +98,12 @@ Use an app-specific password (Gmail, Fastmail, Hostpoint, etc.) rather than your `inbox_count` is a fetch cap for normal folder loads and startup auto-screening. If you want to re-screen the entire Inbox on the IMAP server, use `:screen-all` from inside neomd; that scans every Inbox email, not just the loaded subset, and can take a while on large mailboxes. -## Environment Variables + + +## Passwords: Env and Keyring + + +### Environment Variables The `password` and `user` fields support environment variable expansion. If the entire value is a single env var reference, neomd resolves it at startup: @@ -132,6 +116,52 @@ Values containing other text or multiple `$` signs are left as-is, so passwords Credentials are stored only in `~/.config/neomd/config.toml` (mode 0600) and never written elsewhere; all IMAP connections use TLS (port 993) or STARTTLS (port 143). +### Storing passwords in the OS keyring (Linux) + +Set `password = "keyring"` to fetch the password from your OS keyring (macOS Keychain, GNOME Keyring / KDE Wallet via Secret Service on Linux, Windows Credential Manager) at startup. The lookup uses the `[[accounts]].name` as the account identifier under service `neomd`. + +```toml +[[accounts]] +name = "Personal" +password = "keyring" # resolved at startup; see below for setup +# ...rest of the account +``` + +**Setup before first launch (Linux, using `secret-tool` from `libsecret`):** + +`zalando/go-keyring` writes Secret Service entries with two attributes — `service` (always `neomd`) and `username` (`account//password`, where `` is the `[[accounts]].name` from your config). The `--label` is display-only; entries are identified by their **attributes**. + +```sh +# Add the entry (you'll be prompted for the password on stdin): +secret-tool store --label "neomd Personal" service neomd username account/Personal/password + +# Verify it was stored (read-only): +secret-tool lookup service neomd username account/Personal/password + +# Audit every neomd entry currently in your keyring: +secret-tool search --all service neomd + +# Remove a specific entry if you want to start over: +secret-tool clear service neomd username account/Personal/password +``` + +> [!IMPORTANT] +> **Multiple accounts → multiple distinct `username` values.** Same `service+username` overwrites, regardless of label. For three accounts named `Personal`, `Work`, `WorkInfo` in your config, run **three** commands with **three different** `username=account//password` values — labels are not enough. + +```sh +secret-tool store --label "neomd Personal" service neomd username account/Personal/password +secret-tool store --label "neomd Work" service neomd username account/Work/password +secret-tool store --label "neomd WorkInfo" service neomd username account/WorkInfo/password +``` + +> **Recommended: avoid spaces in `name = "..."`** (use `WorkInfo` rather than `"Work Info"`). Easier to type in `secret-tool` commands without quoting; the keyring username is just `account/WorkInfo/password`. If you do use a space, quote the whole username arg every time: `username "account/Work Info/password"`. + +`secret-tool` only touches entries that match the **exact** attribute set you pass. It cannot read, modify, or delete other applications' keyring entries — your Firefox / GNOME Online Accounts / SSH Agent / browser passwords stay isolated under their own `service=` namespaces. Worst case if you typo: a stale entry sits unused, removable with `secret-tool clear` above. + +If the keyring entry is missing or the keyring service is unavailable, neomd prints a warning and the literal sentinel `"keyring"` is used as the password — IMAP/SMTP authentication will then fail with a clear error. `[[senders]]` aliases that reference an account inherit the resolved keyring password automatically. + +OAuth2 tokens are also stored in the keyring under `username = account//oauth2` with the same `service = neomd`, falling back to `~/.config/neomd/tokens/.json` (mode `0600`) when no keyring is available (headless / SSH systems). + ## TLS and STARTTLS Configuration Neomd automatically determines the correct encryption method based on the port and the optional `starttls` config field: diff --git a/internal/ui/password_prompt.go b/internal/ui/password_prompt.go index d935fee..00b3b7b 100644 --- a/internal/ui/password_prompt.go +++ b/internal/ui/password_prompt.go @@ -47,11 +47,14 @@ func (p *passwordPromptModel) reset() { } // setPrompt configures the prompt for a specific account and type. +// reset() runs first so it clears the previous session's input/error before +// we assign the new errMsg — otherwise the error passed in here would be +// wiped immediately by reset() and never displayed. func (p *passwordPromptModel) setPrompt(account string, pt promptType, errMsg string) { + p.reset() p.account = account p.promptType = pt p.errMsg = errMsg - p.reset() } // passwordSubmittedMsg is sent when the user submits a password.