Skip to content

Commit 4728bda

Browse files
authored
feat: secure passwords (#193) (#194)
* feat: OS keyring to store passwords * fix: add migration tool to the config
1 parent 464630f commit 4728bda

4 files changed

Lines changed: 100 additions & 10 deletions

File tree

config/config.go

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@ import (
66
"path/filepath"
77

88
"github.com/google/uuid"
9+
"github.com/zalando/go-keyring"
910
)
1011

12+
const keyringServiceName = "matcha-email-client"
13+
1114
// Account stores the configuration for a single email account.
1215
type Account struct {
1316
ID string `json:"id"`
1417
Name string `json:"name"`
1518
Email string `json:"email"`
16-
Password string `json:"password"`
19+
Password string `json:"-"` // "-" prevents the password from being saved to config.json
1720
ServiceProvider string `json:"service_provider"` // "gmail", "icloud", or "custom"
1821
// FetchEmail is the single email address for which messages should be fetched.
1922
// If empty, it will default to `Email` when accounts are added.
@@ -108,8 +111,17 @@ func configFile() (string, error) {
108111
return filepath.Join(dir, "config.json"), nil
109112
}
110113

111-
// SaveConfig saves the given configuration to the config file.
114+
// SaveConfig saves the given configuration to the config file and passwords to the keyring.
112115
func SaveConfig(config *Config) error {
116+
// Save passwords to the OS keyring before writing the JSON file
117+
for _, acc := range config.Accounts {
118+
if acc.Password != "" {
119+
// We ignore the error here because some environments (like headless CI)
120+
// might not have a keyring service, but we still want to save the rest of the config.
121+
_ = keyring.Set(keyringServiceName, acc.Email, acc.Password)
122+
}
123+
}
124+
113125
path, err := configFile()
114126
if err != nil {
115127
return err
@@ -124,7 +136,8 @@ func SaveConfig(config *Config) error {
124136
return os.WriteFile(path, data, 0600)
125137
}
126138

127-
// LoadConfig loads the configuration from the config file.
139+
// LoadConfig loads the configuration from the config file and passwords from the keyring.
140+
// It automatically migrates plain-text passwords to the OS keyring if they exist.
128141
func LoadConfig() (*Config, error) {
129142
path, err := configFile()
130143
if err != nil {
@@ -134,12 +147,31 @@ func LoadConfig() (*Config, error) {
134147
if err != nil {
135148
return nil, err
136149
}
150+
137151
var config Config
138-
if err := json.Unmarshal(data, &config); err != nil {
139-
// Try to load legacy single-account config
152+
var needsMigration bool
153+
154+
type rawAccount struct {
155+
ID string `json:"id"`
156+
Name string `json:"name"`
157+
Email string `json:"email"`
158+
Password string `json:"password,omitempty"`
159+
ServiceProvider string `json:"service_provider"`
160+
FetchEmail string `json:"fetch_email,omitempty"`
161+
IMAPServer string `json:"imap_server,omitempty"`
162+
IMAPPort int `json:"imap_port,omitempty"`
163+
SMTPServer string `json:"smtp_server,omitempty"`
164+
SMTPPort int `json:"smtp_port,omitempty"`
165+
}
166+
type diskConfig struct {
167+
Accounts []rawAccount `json:"accounts"`
168+
DisableImages bool `json:"disable_images,omitempty"`
169+
}
170+
171+
var raw diskConfig
172+
if err := json.Unmarshal(data, &raw); err != nil {
140173
var legacyConfig legacyConfigFormat
141174
if legacyErr := json.Unmarshal(data, &legacyConfig); legacyErr == nil && legacyConfig.Email != "" {
142-
// Convert legacy config to new format
143175
config = Config{
144176
Accounts: []Account{
145177
{
@@ -148,19 +180,54 @@ func LoadConfig() (*Config, error) {
148180
Email: legacyConfig.Email,
149181
Password: legacyConfig.Password,
150182
ServiceProvider: legacyConfig.ServiceProvider,
151-
// Default FetchEmail to the legacy Email value
152-
FetchEmail: legacyConfig.Email,
183+
FetchEmail: legacyConfig.Email,
153184
},
154185
},
155186
}
156-
// Save the migrated config
187+
// SaveConfig automatically pushes the password to the keyring and strips it from JSON
157188
if saveErr := SaveConfig(&config); saveErr != nil {
158189
return nil, saveErr
159190
}
160191
return &config, nil
161192
}
162193
return nil, err
163194
}
195+
196+
config.DisableImages = raw.DisableImages
197+
for _, rawAcc := range raw.Accounts {
198+
acc := Account{
199+
ID: rawAcc.ID,
200+
Name: rawAcc.Name,
201+
Email: rawAcc.Email,
202+
ServiceProvider: rawAcc.ServiceProvider,
203+
FetchEmail: rawAcc.FetchEmail,
204+
IMAPServer: rawAcc.IMAPServer,
205+
IMAPPort: rawAcc.IMAPPort,
206+
SMTPServer: rawAcc.SMTPServer,
207+
SMTPPort: rawAcc.SMTPPort,
208+
}
209+
210+
if rawAcc.Password != "" {
211+
// Found a plain-text password! Move it to the OS Keyring.
212+
_ = keyring.Set(keyringServiceName, rawAcc.Email, rawAcc.Password)
213+
acc.Password = rawAcc.Password
214+
needsMigration = true
215+
} else {
216+
// No plaintext password in JSON, fetch from Keyring as normal.
217+
if pwd, err := keyring.Get(keyringServiceName, acc.Email); err == nil {
218+
acc.Password = pwd
219+
}
220+
}
221+
222+
config.Accounts = append(config.Accounts, acc)
223+
}
224+
225+
if needsMigration {
226+
if saveErr := SaveConfig(&config); saveErr != nil {
227+
return nil, saveErr
228+
}
229+
}
230+
164231
return &config, nil
165232
}
166233

@@ -184,10 +251,13 @@ func (c *Config) AddAccount(account Account) {
184251
c.Accounts = append(c.Accounts, account)
185252
}
186253

187-
// RemoveAccount removes an account by its ID.
254+
// RemoveAccount removes an account by its ID and deletes its password from the keyring.
188255
func (c *Config) RemoveAccount(id string) bool {
189256
for i, acc := range c.Accounts {
190257
if acc.ID == id {
258+
// Delete password from OS Keyring when account is removed
259+
_ = keyring.Delete(keyringServiceName, acc.Email)
260+
191261
c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...)
192262
return true
193263
}

config/config_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@ package config
33
import (
44
"reflect"
55
"testing"
6+
7+
"github.com/zalando/go-keyring"
68
)
79

810
// TestSaveAndLoadConfig verifies that the config can be saved to and loaded from a file correctly.
911
func TestSaveAndLoadConfig(t *testing.T) {
12+
// Use an in-memory mock keyring so tests do not interact with the host OS keyring
13+
keyring.MockInit()
14+
1015
// Create a temporary directory for the test to avoid interfering with actual user config.
1116
tempDir := t.TempDir()
1217

@@ -107,6 +112,9 @@ func TestAccountGetSMTPServer(t *testing.T) {
107112

108113
// TestConfigAddRemoveAccount tests adding and removing accounts from config.
109114
func TestConfigAddRemoveAccount(t *testing.T) {
115+
// Use an in-memory mock keyring to test the deletion step cleanly
116+
keyring.MockInit()
117+
110118
cfg := &Config{}
111119

112120
// Add an account

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
)
1717

1818
require (
19+
al.essio.dev/pkg/shellescape v1.5.1 // indirect
1920
github.com/andybalholm/cascadia v1.3.3 // indirect
2021
github.com/atotto/clipboard v0.1.4 // indirect
2122
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -26,8 +27,10 @@ require (
2627
github.com/clipperhouse/displaywidth v0.9.0 // indirect
2728
github.com/clipperhouse/stringish v0.1.1 // indirect
2829
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
30+
github.com/danieljoos/wincred v1.2.2 // indirect
2931
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
3032
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
33+
github.com/godbus/dbus/v5 v5.1.0 // indirect
3134
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
3235
github.com/mattn/go-isatty v0.0.20 // indirect
3336
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -38,5 +41,6 @@ require (
3841
github.com/rivo/uniseg v0.4.7 // indirect
3942
github.com/sahilm/fuzzy v0.1.1 // indirect
4043
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
44+
github.com/zalando/go-keyring v0.2.6 // indirect
4145
golang.org/x/net v0.47.0 // indirect
4246
)

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
2+
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
13
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
24
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
35
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
@@ -32,6 +34,8 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
3234
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
3335
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
3436
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
37+
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
38+
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
3539
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
3640
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
3741
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
@@ -43,6 +47,8 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTe
4347
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
4448
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
4549
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
50+
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
51+
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
4652
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
4753
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
4854
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -71,6 +77,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
7177
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
7278
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
7379
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
80+
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
81+
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
7482
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
7583
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
7684
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=

0 commit comments

Comments
 (0)