Skip to content

Commit 9598266

Browse files
authored
feat: support pass commands (#1473)
## What? Adds support for the `pass` commands ## Why? Closes #684 --------- Signed-off-by: drew <me@andrinoff.com>
1 parent 1ae21f4 commit 9598266

4 files changed

Lines changed: 159 additions & 2 deletions

File tree

config/config.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package config
22

33
import (
4+
"context"
45
"crypto/tls"
56
"encoding/json"
67
"errors"
78
"fmt"
89
"log"
910
"os"
11+
"os/exec"
1012
"path/filepath"
1113
"strings"
1214
"sync"
@@ -95,6 +97,9 @@ type Account struct {
9597

9698
// OAuth2 settings
9799
AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2"
100+
// PassCmd is a shell command whose stdout is used as the password (e.g. "pass show email/user").
101+
// When set, the keyring is bypassed and the command is evaluated at startup.
102+
PassCmd string `json:"pass_cmd,omitempty"`
98103

99104
// Multi-protocol settings
100105
Protocol string `json:"protocol,omitempty"` // "imap" (default), "jmap", or "pop3"
@@ -440,6 +445,7 @@ type secureDiskAccount struct {
440445
PGPPIN string `json:"pgp_pin,omitempty"`
441446
PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"`
442447
AuthMethod string `json:"auth_method,omitempty"`
448+
PassCmd string `json:"pass_cmd,omitempty"`
443449
Protocol string `json:"protocol,omitempty"`
444450
JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
445451
POP3Server string `json:"pop3_server,omitempty"`
@@ -476,7 +482,7 @@ func SaveConfig(config *Config) error {
476482
// any hint to the user. Log the error as a warning so the misconfiguration
477483
// (no keyring backend, locked keyring, etc.) is at least visible. See #616.
478484
for _, acc := range config.Accounts {
479-
if acc.Password != "" {
485+
if acc.Password != "" && acc.PassCmd == "" {
480486
if err := keyring.Set(keyringServiceName, acc.Email, acc.Password); err != nil {
481487
log.Printf("matcha: failed to store password for %s in keyring: %v", acc.Email, err)
482488
}
@@ -516,11 +522,15 @@ func SaveConfig(config *Config) error {
516522
PluginSettings: config.PluginSettings,
517523
}
518524
for _, acc := range config.Accounts {
525+
var securePassword string
526+
if acc.PassCmd == "" {
527+
securePassword = acc.Password
528+
}
519529
sdc.Accounts = append(sdc.Accounts, secureDiskAccount{
520530
ID: acc.ID,
521531
Name: acc.Name,
522532
Email: acc.Email,
523-
Password: acc.Password,
533+
Password: securePassword,
524534
ServiceProvider: acc.ServiceProvider,
525535
FetchEmail: acc.FetchEmail,
526536
SendAsEmail: acc.SendAsEmail,
@@ -538,6 +548,7 @@ func SaveConfig(config *Config) error {
538548
PGPPIN: acc.PGPPIN,
539549
PGPSignByDefault: acc.PGPSignByDefault,
540550
AuthMethod: acc.AuthMethod,
551+
PassCmd: acc.PassCmd,
541552
Protocol: acc.Protocol,
542553
JMAPEndpoint: acc.JMAPEndpoint,
543554
POP3Server: acc.POP3Server,
@@ -601,6 +612,7 @@ func LoadConfig() (*Config, error) {
601612
PGPPIN string `json:"pgp_pin,omitempty"`
602613
PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"`
603614
AuthMethod string `json:"auth_method,omitempty"`
615+
PassCmd string `json:"pass_cmd,omitempty"`
604616
Protocol string `json:"protocol,omitempty"`
605617
JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
606618
POP3Server string `json:"pop3_server,omitempty"`
@@ -692,6 +704,7 @@ func LoadConfig() (*Config, error) {
692704
PGPKeySource: rawAcc.PGPKeySource,
693705
PGPSignByDefault: rawAcc.PGPSignByDefault,
694706
AuthMethod: rawAcc.AuthMethod,
707+
PassCmd: rawAcc.PassCmd,
695708
Protocol: rawAcc.Protocol,
696709
JMAPEndpoint: rawAcc.JMAPEndpoint,
697710
POP3Server: rawAcc.POP3Server,
@@ -707,6 +720,13 @@ func LoadConfig() (*Config, error) {
707720
}
708721

709722
switch {
723+
case rawAcc.PassCmd != "":
724+
// Evaluate the external command and use its stdout as the password.
725+
if pwd, err := resolvePassCmd(rawAcc.PassCmd); err != nil {
726+
log.Printf("matcha: pass_cmd for %s failed: %v", acc.Email, err)
727+
} else {
728+
acc.Password = pwd
729+
}
710730
case secureMode:
711731
// In secure mode, passwords and PINs are stored in the encrypted config JSON
712732
acc.Password = rawAcc.Password
@@ -746,6 +766,15 @@ func LoadConfig() (*Config, error) {
746766
return &config, nil
747767
}
748768

769+
// resolvePassCmd runs cmd via the shell and returns its trimmed stdout as the password.
770+
func resolvePassCmd(cmd string) (string, error) {
771+
out, err := exec.CommandContext(context.Background(), "sh", "-c", cmd).Output()
772+
if err != nil {
773+
return "", err
774+
}
775+
return strings.TrimRight(string(out), "\r\n"), nil
776+
}
777+
749778
// legacyConfigFormat represents the old single-account configuration format.
750779
type legacyConfigFormat struct {
751780
ServiceProvider string `json:"service_provider"`

config/config_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"encoding/json"
45
"os"
56
"path/filepath"
67
"reflect"
@@ -613,3 +614,67 @@ func TestConfigGetDateFormatCustom(t *testing.T) {
613614
t.Fatalf("GetDateFormat() = %q, want %q", got, want)
614615
}
615616
}
617+
618+
// TestPassCmd verifies that pass_cmd is persisted to JSON, that the password is resolved
619+
// from the command at load time, and that no password is written to the keyring.
620+
func TestPassCmd(t *testing.T) {
621+
keyring.MockInit()
622+
t.Setenv("HOME", t.TempDir())
623+
624+
cfg := &Config{
625+
Accounts: []Account{
626+
{
627+
ID: "pass-id-1",
628+
Name: "PassCmd User",
629+
Email: "pass@example.com",
630+
PassCmd: "echo supersecret",
631+
ServiceProvider: "custom",
632+
SC: &SessionCache{},
633+
},
634+
},
635+
}
636+
637+
if err := SaveConfig(cfg); err != nil {
638+
t.Fatalf("SaveConfig() failed: %v", err)
639+
}
640+
641+
// The JSON on disk must contain pass_cmd and must NOT contain a password field.
642+
cfgPath, err := configFile()
643+
if err != nil {
644+
t.Fatalf("configFile() failed: %v", err)
645+
}
646+
raw, err := os.ReadFile(cfgPath)
647+
if err != nil {
648+
t.Fatalf("ReadFile() failed: %v", err)
649+
}
650+
var disk map[string]interface{}
651+
if err := json.Unmarshal(raw, &disk); err != nil {
652+
t.Fatalf("Unmarshal() failed: %v", err)
653+
}
654+
accounts := disk["accounts"].([]interface{})
655+
diskAcc := accounts[0].(map[string]interface{})
656+
if diskAcc["pass_cmd"] != "echo supersecret" {
657+
t.Errorf("expected pass_cmd in JSON, got %v", diskAcc["pass_cmd"])
658+
}
659+
if _, ok := diskAcc["password"]; ok {
660+
t.Error("password must not appear in JSON when pass_cmd is set")
661+
}
662+
663+
// Keyring must not have been written for this account.
664+
if _, err := keyring.Get(keyringServiceName, "pass@example.com"); err == nil {
665+
t.Error("keyring entry must not be created when pass_cmd is set")
666+
}
667+
668+
// On reload, Password must be populated by running the command.
669+
loaded, err := LoadConfig()
670+
if err != nil {
671+
t.Fatalf("LoadConfig() failed: %v", err)
672+
}
673+
acc := loaded.Accounts[0]
674+
if acc.PassCmd != "echo supersecret" {
675+
t.Errorf("PassCmd not preserved: got %q", acc.PassCmd)
676+
}
677+
if acc.Password != "supersecret" {
678+
t.Errorf("Password not resolved from pass_cmd: got %q", acc.Password)
679+
}
680+
}

docs/docs/Configuration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,7 @@ Cache files are automatically refreshed from the server on each app launch and m
102102
All data files can optionally be encrypted with a password. See [Encryption](/docs/Features/Encryption) for details.
103103

104104
When encryption is enabled, account passwords are stored inside the encrypted `config.json` instead of the OS keyring.
105+
106+
## Password Command
107+
108+
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.

docs/docs/Features/PassCmd.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Password Command (`pass_cmd`)
2+
3+
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.
4+
5+
This is the same pattern used by [isync (`PassCmd`)](https://isync.sourceforge.io/mbsync.html) and [msmtp (`passwordeval`)](https://marlam.de/msmtp/msmtp.html).
6+
7+
## Configuration
8+
9+
Add `pass_cmd` to an account in `~/.config/matcha/config.json`:
10+
11+
```json
12+
{
13+
"accounts": [
14+
{
15+
"id": "unique-id-1",
16+
"name": "John Doe",
17+
"email": "john@example.com",
18+
"service_provider": "custom",
19+
"imap_server": "imap.example.com",
20+
"smtp_server": "smtp.example.com",
21+
"pass_cmd": "pass show email/john@example.com"
22+
}
23+
]
24+
}
25+
```
26+
27+
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.
28+
29+
## Examples
30+
31+
### pass / gopass
32+
33+
```json
34+
"pass_cmd": "pass show email/john@example.com"
35+
```
36+
37+
```json
38+
"pass_cmd": "gopass show -o email/john@example.com"
39+
```
40+
41+
### age-encrypted file
42+
43+
```json
44+
"pass_cmd": "age --decrypt -i ~/.age/key.txt ~/.secrets/mail.age"
45+
```
46+
47+
### Custom script
48+
49+
```json
50+
"pass_cmd": "/home/john/.local/bin/get-mail-password.sh"
51+
```
52+
53+
The command can be anything that exits `0` and prints the password to stdout.
54+
55+
## Notes
56+
57+
- **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.
58+
- **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.
59+
- **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.

0 commit comments

Comments
 (0)