From 959826680e087bdf691487be58c1e1f13750279a Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Mon, 15 Jun 2026 22:22:22 +0400 Subject: [PATCH] feat: support pass commands (#1473) ## What? Adds support for the `pass` commands ## Why? Closes #684 --------- Signed-off-by: drew --- config/config.go | 33 ++++++++++++++++-- config/config_test.go | 65 +++++++++++++++++++++++++++++++++++ docs/docs/Configuration.md | 4 +++ docs/docs/Features/PassCmd.md | 59 +++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 docs/docs/Features/PassCmd.md diff --git a/config/config.go b/config/config.go index 411f48bddefc931cd27576d1c4c4e5fd6767abc6..9636c38dbbaccf3b891f4e3d9889ba605baa9ef4 100644 --- a/config/config.go +++ b/config/config.go @@ -1,12 +1,14 @@ package config import ( + "context" "crypto/tls" "encoding/json" "errors" "fmt" "log" "os" + "os/exec" "path/filepath" "strings" "sync" @@ -95,6 +97,9 @@ type Account struct { // OAuth2 settings AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2" + // PassCmd is a shell command whose stdout is used as the password (e.g. "pass show email/user"). + // When set, the keyring is bypassed and the command is evaluated at startup. + PassCmd string `json:"pass_cmd,omitempty"` // Multi-protocol settings Protocol string `json:"protocol,omitempty"` // "imap" (default), "jmap", or "pop3" @@ -440,6 +445,7 @@ type secureDiskAccount struct { PGPPIN string `json:"pgp_pin,omitempty"` PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"` AuthMethod string `json:"auth_method,omitempty"` + PassCmd string `json:"pass_cmd,omitempty"` Protocol string `json:"protocol,omitempty"` JMAPEndpoint string `json:"jmap_endpoint,omitempty"` POP3Server string `json:"pop3_server,omitempty"` @@ -476,7 +482,7 @@ func SaveConfig(config *Config) error { // any hint to the user. Log the error as a warning so the misconfiguration // (no keyring backend, locked keyring, etc.) is at least visible. See #616. for _, acc := range config.Accounts { - if acc.Password != "" { + if acc.Password != "" && acc.PassCmd == "" { if err := keyring.Set(keyringServiceName, acc.Email, acc.Password); err != nil { log.Printf("matcha: failed to store password for %s in keyring: %v", acc.Email, err) } @@ -516,11 +522,15 @@ func SaveConfig(config *Config) error { PluginSettings: config.PluginSettings, } for _, acc := range config.Accounts { + var securePassword string + if acc.PassCmd == "" { + securePassword = acc.Password + } sdc.Accounts = append(sdc.Accounts, secureDiskAccount{ ID: acc.ID, Name: acc.Name, Email: acc.Email, - Password: acc.Password, + Password: securePassword, ServiceProvider: acc.ServiceProvider, FetchEmail: acc.FetchEmail, SendAsEmail: acc.SendAsEmail, @@ -538,6 +548,7 @@ func SaveConfig(config *Config) error { PGPPIN: acc.PGPPIN, PGPSignByDefault: acc.PGPSignByDefault, AuthMethod: acc.AuthMethod, + PassCmd: acc.PassCmd, Protocol: acc.Protocol, JMAPEndpoint: acc.JMAPEndpoint, POP3Server: acc.POP3Server, @@ -601,6 +612,7 @@ func LoadConfig() (*Config, error) { PGPPIN string `json:"pgp_pin,omitempty"` PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"` AuthMethod string `json:"auth_method,omitempty"` + PassCmd string `json:"pass_cmd,omitempty"` Protocol string `json:"protocol,omitempty"` JMAPEndpoint string `json:"jmap_endpoint,omitempty"` POP3Server string `json:"pop3_server,omitempty"` @@ -692,6 +704,7 @@ func LoadConfig() (*Config, error) { PGPKeySource: rawAcc.PGPKeySource, PGPSignByDefault: rawAcc.PGPSignByDefault, AuthMethod: rawAcc.AuthMethod, + PassCmd: rawAcc.PassCmd, Protocol: rawAcc.Protocol, JMAPEndpoint: rawAcc.JMAPEndpoint, POP3Server: rawAcc.POP3Server, @@ -707,6 +720,13 @@ func LoadConfig() (*Config, error) { } switch { + case rawAcc.PassCmd != "": + // Evaluate the external command and use its stdout as the password. + if pwd, err := resolvePassCmd(rawAcc.PassCmd); err != nil { + log.Printf("matcha: pass_cmd for %s failed: %v", acc.Email, err) + } else { + acc.Password = pwd + } case secureMode: // In secure mode, passwords and PINs are stored in the encrypted config JSON acc.Password = rawAcc.Password @@ -746,6 +766,15 @@ func LoadConfig() (*Config, error) { return &config, nil } +// resolvePassCmd runs cmd via the shell and returns its trimmed stdout as the password. +func resolvePassCmd(cmd string) (string, error) { + out, err := exec.CommandContext(context.Background(), "sh", "-c", cmd).Output() + if err != nil { + return "", err + } + return strings.TrimRight(string(out), "\r\n"), nil +} + // legacyConfigFormat represents the old single-account configuration format. type legacyConfigFormat struct { ServiceProvider string `json:"service_provider"` diff --git a/config/config_test.go b/config/config_test.go index d60b2c84e9b853c69ab63c59fee948423c214731..aaeab88f58dc1f0ce2e3a3fce0707d831b3a7049 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "os" "path/filepath" "reflect" @@ -613,3 +614,67 @@ func TestConfigGetDateFormatCustom(t *testing.T) { t.Fatalf("GetDateFormat() = %q, want %q", got, want) } } + +// TestPassCmd verifies that pass_cmd is persisted to JSON, that the password is resolved +// from the command at load time, and that no password is written to the keyring. +func TestPassCmd(t *testing.T) { + keyring.MockInit() + t.Setenv("HOME", t.TempDir()) + + cfg := &Config{ + Accounts: []Account{ + { + ID: "pass-id-1", + Name: "PassCmd User", + Email: "pass@example.com", + PassCmd: "echo supersecret", + ServiceProvider: "custom", + SC: &SessionCache{}, + }, + }, + } + + if err := SaveConfig(cfg); err != nil { + t.Fatalf("SaveConfig() failed: %v", err) + } + + // The JSON on disk must contain pass_cmd and must NOT contain a password field. + cfgPath, err := configFile() + if err != nil { + t.Fatalf("configFile() failed: %v", err) + } + raw, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("ReadFile() failed: %v", err) + } + var disk map[string]interface{} + if err := json.Unmarshal(raw, &disk); err != nil { + t.Fatalf("Unmarshal() failed: %v", err) + } + accounts := disk["accounts"].([]interface{}) + diskAcc := accounts[0].(map[string]interface{}) + if diskAcc["pass_cmd"] != "echo supersecret" { + t.Errorf("expected pass_cmd in JSON, got %v", diskAcc["pass_cmd"]) + } + if _, ok := diskAcc["password"]; ok { + t.Error("password must not appear in JSON when pass_cmd is set") + } + + // Keyring must not have been written for this account. + if _, err := keyring.Get(keyringServiceName, "pass@example.com"); err == nil { + t.Error("keyring entry must not be created when pass_cmd is set") + } + + // On reload, Password must be populated by running the command. + loaded, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() failed: %v", err) + } + acc := loaded.Accounts[0] + if acc.PassCmd != "echo supersecret" { + t.Errorf("PassCmd not preserved: got %q", acc.PassCmd) + } + if acc.Password != "supersecret" { + t.Errorf("Password not resolved from pass_cmd: got %q", acc.Password) + } +} diff --git a/docs/docs/Configuration.md b/docs/docs/Configuration.md index e34986b3d76d0c28040b4fce981510fd4a891b3b..d141c63f78f73a589156fbf0a635462198203a3a 100644 --- a/docs/docs/Configuration.md +++ b/docs/docs/Configuration.md @@ -102,3 +102,7 @@ Cache files are automatically refreshed from the server on each app launch and m All data files can optionally be encrypted with a password. See [Encryption](/docs/Features/Encryption) for details. When encryption is enabled, account passwords are stored inside the encrypted `config.json` instead of the OS keyring. + +## Password Command + +Instead of storing a password in the OS keyring, you can set `pass_cmd` on an account to have matcha fetch the password from an external command (e.g. `pass`, `gopass`, or an age script). See [Password Command](/docs/Features/PassCmd) for details. diff --git a/docs/docs/Features/PassCmd.md b/docs/docs/Features/PassCmd.md new file mode 100644 index 0000000000000000000000000000000000000000..cb89e7706ddbcb89a32ff6cc6d967a80e2e32ed3 --- /dev/null +++ b/docs/docs/Features/PassCmd.md @@ -0,0 +1,59 @@ +# Password Command (`pass_cmd`) + +Matcha can fetch your account password from an external command rather than the OS keyring. This lets you integrate any CLI-based password manager — [pass](https://www.passwordstore.org/), [gopass](https://github.com/gopasspw/gopass), [age](https://github.com/FiloSottile/age) scripts, or any tool that prints a password to stdout. + +This is the same pattern used by [isync (`PassCmd`)](https://isync.sourceforge.io/mbsync.html) and [msmtp (`passwordeval`)](https://marlam.de/msmtp/msmtp.html). + +## Configuration + +Add `pass_cmd` to an account in `~/.config/matcha/config.json`: + +```json +{ + "accounts": [ + { + "id": "unique-id-1", + "name": "John Doe", + "email": "john@example.com", + "service_provider": "custom", + "imap_server": "imap.example.com", + "smtp_server": "smtp.example.com", + "pass_cmd": "pass show email/john@example.com" + } + ] +} +``` + +Matcha runs the command via `sh -c` at startup and uses its stdout (trailing newlines stripped) as the password. The password is never written to `config.json` or the OS keyring. + +## Examples + +### pass / gopass + +```json +"pass_cmd": "pass show email/john@example.com" +``` + +```json +"pass_cmd": "gopass show -o email/john@example.com" +``` + +### age-encrypted file + +```json +"pass_cmd": "age --decrypt -i ~/.age/key.txt ~/.secrets/mail.age" +``` + +### Custom script + +```json +"pass_cmd": "/home/john/.local/bin/get-mail-password.sh" +``` + +The command can be anything that exits `0` and prints the password to stdout. + +## Notes + +- **Priority**: `pass_cmd` takes precedence over both the OS keyring and any password stored in a secure (encrypted) config. If `pass_cmd` is set, no other source is consulted. +- **Errors**: If the command exits non-zero or cannot be found, matcha logs the error and continues with an empty password, which will cause authentication to fail. Check the command works in a shell before adding it to your config. +- **Encryption compatibility**: `pass_cmd` works alongside [Encryption](/docs/Features/Encryption). The command is stored in the encrypted config, and the resolved password is never written to disk.