Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/pass-quick-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7...
| Variable | Default | Description |
|----------|---------|-------------|
| `PASSWORD_STORE_DIR` | `%USERPROFILE%\.password-store` | Password store location |
| `PASS_GPG_ID` | Default GPG key | GPG recipient key ID |
| `PASS_GPG_ID` | Only secret key | GPG recipient key ID; used when `<store>/.gpg-id` is absent |
| `PASS_CLIPBOARD_TIMEOUT` | 45 | Clipboard clear timeout (seconds) |
| `PASS_CLIPBOARD_CLEAR` | true | Enable clipboard auto-clear |
| `PASS_GIT_NAME` | Git config | Git user.name override |
Expand Down Expand Up @@ -318,7 +318,7 @@ $ git push -u origin main
**"No secret key" or "gpg: decryption failed"**
- Ensure you have a GPG key pair: `gpg --list-secret-keys`
- Ensure the password was encrypted to your key
- Check `PASS_GPG_ID` if you use multiple keys
- With several secret keys, list the store's key in `<store>/.gpg-id` (or set `PASS_GPG_ID`); `insert`/`edit` refuse to guess

**"No such file or directory"**
- Check the path is correct (case-sensitive)
Expand Down
8 changes: 7 additions & 1 deletion pass/cmd/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ func editPassword(path string, noCommit bool) error {
return fmt.Errorf("pass: %s: Is a directory", path)
}

// Resolve encryption recipients (.gpg-id, PASS_GPG_ID, sole key)
encOpts, err := storeEncryptOptions(storeDir)
if err != nil {
return err
}

// Decrypt the file
password, err := gpg.DecryptFile(fullPath)
if err != nil {
Expand Down Expand Up @@ -172,7 +178,7 @@ func editPassword(path string, noCommit bool) error {
}

// Encrypt the modified content back to the original file
if err := gpg.EncryptFile(tempFile2.Name(), fullPath); err != nil {
if err := gpg.EncryptFileWithOptions(tempFile2.Name(), fullPath, encOpts); err != nil {
return fmt.Errorf("pass: GPG encryption failed: %v", err)
}

Expand Down
63 changes: 63 additions & 0 deletions pass/cmd/gpgid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package cmd

import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)

// These tests avoid invoking gpg: GNUPGHOME points at an empty temp dir.
func isolateGPG(t *testing.T) {
t.Helper()
home := t.TempDir()
if err := os.Chmod(home, 0700); err != nil {
t.Fatal(err)
}
t.Setenv("GNUPGHOME", home)
t.Setenv("PASS_GPG_ID", "")
}

func TestStoreEncryptOptions(t *testing.T) {
t.Run(".gpg-id is used and beats PASS_GPG_ID", func(t *testing.T) {
isolateGPG(t)
t.Setenv("PASS_GPG_ID", "ENVKEY")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".gpg-id"), []byte("# k\nAAAA\nBBBB\n"), 0600); err != nil {
t.Fatal(err)
}
opts, err := storeEncryptOptions(dir)
if err != nil {
t.Fatal(err)
}
if want := []string{"AAAA", "BBBB"}; !reflect.DeepEqual(opts.Recipients, want) {
t.Errorf("Recipients = %v, want %v", opts.Recipients, want)
}
})

t.Run("PASS_GPG_ID used when no .gpg-id", func(t *testing.T) {
isolateGPG(t)
t.Setenv("PASS_GPG_ID", "ENVKEY")
opts, err := storeEncryptOptions(t.TempDir())
if err != nil {
t.Fatal(err)
}
if want := []string{"ENVKEY"}; !reflect.DeepEqual(opts.Recipients, want) {
t.Errorf("Recipients = %v, want %v", opts.Recipients, want)
}
})

t.Run("empty .gpg-id is an error even with PASS_GPG_ID", func(t *testing.T) {
isolateGPG(t)
t.Setenv("PASS_GPG_ID", "ENVKEY")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".gpg-id"), []byte("\n"), 0600); err != nil {
t.Fatal(err)
}
_, err := storeEncryptOptions(dir)
if err == nil || !strings.Contains(err.Error(), "invalid .gpg-id") {
t.Errorf("err = %v, want invalid .gpg-id error", err)
}
})
}
21 changes: 20 additions & 1 deletion pass/cmd/insert.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ func insertPassword(path string) error {
return fmt.Errorf("pass: %s: Already exists", path)
}

// Resolve encryption recipients (.gpg-id, PASS_GPG_ID, sole key) before prompting
encOpts, err := storeEncryptOptions(storeDir)
if err != nil {
return err
}

// Get password from user
password, err := promptForPassword(path)
if err != nil {
Expand Down Expand Up @@ -102,7 +108,7 @@ func insertPassword(path string) error {
}

// Encrypt the temp file
if err := gpg.EncryptFile(tempFile.Name(), fullPath); err != nil {
if err := gpg.EncryptFileWithOptions(tempFile.Name(), fullPath, encOpts); err != nil {
return fmt.Errorf("pass: GPG encryption failed: %v", err)
}

Expand Down Expand Up @@ -183,3 +189,16 @@ func promptForPassword(path string) (string, error) {

return password1, nil
}

// storeEncryptOptions returns the GPG options for encrypting to the store's
// recipients: <store>/.gpg-id, else PASS_GPG_ID, else the sole secret key.
// Errors if the recipient cannot be determined unambiguously.
func storeEncryptOptions(storeDir string) (gpg.GPGOptions, error) {
opts := gpg.DefaultGPGOptions()
recipients, err := gpg.ResolveRecipients(storeDir)
if err != nil {
return opts, fmt.Errorf("pass: %v", err)
}
opts.Recipients = recipients
return opts, nil
}
1 change: 1 addition & 0 deletions pass/docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ All notable changes to the pass tool are documented in this file.
- **tmux clipboard integration**: When running inside tmux, `pass -c` (and `pass rm -c`/fuzzy clip mode) also loads the password into a dedicated tmux paste buffer (`pass`), pasteable with `prefix` + `]`. If tmux's `set-clipboard` option and the terminal support OSC 52, the password is also forwarded to the local terminal's clipboard, which works even over SSH without X11/Wayland forwarding

### Fixed
- **`.gpg-id` recipients**: `pass insert` and `pass edit` now encrypt to the key(s) in `<store>/.gpg-id` (store root only; one per line; blank lines and `#` comments ignored). Previously the file was ignored. Without a `.gpg-id`, `PASS_GPG_ID` applies as before; without that, the only usable secret key is used. If there are zero or several secret keys the command now fails with a clear error instead of letting gpg pick one (`--default-recipient-self` is no longer used). An empty/unreadable `.gpg-id` is an error rather than falling through to another key. See `specs/011-gpg-id-recipient/spec.md`
- **Linux/macOS clipboard support**: `pass -c` previously only worked on Windows (hard-coded `clip` command) and failed on every other platform with `exec: "clip": executable file not found in $PATH`. Clipboard copy/clear now uses the cross-platform `atotto/clipboard` library, supporting `xclip`/`xsel`/`wl-copy` on Linux and `pbcopy` on macOS, in addition to Windows
- **Insert overwrite prevention**: `pass insert <path>` now fails with error `pass: <path>: Already exists` if the file already exists
- **List command**: `pass ls` now only lists secret files (`.gpg` files), not directories
Expand Down
125 changes: 115 additions & 10 deletions pass/pkg/gpg/gpg.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)

Expand All @@ -24,6 +25,7 @@ type GPGOptions struct {
BatchMode bool
Passphrase string
Recipient string
Recipients []string // Resolved recipients (e.g. from the store's .gpg-id); takes precedence over Recipient
PinentryMode string // "loopback" to bypass pinentry
AllowPrompt bool // Allow interactive passphrase prompt (default: true)
RetryOnCancel bool // Retry with loopback on operation cancelled (default: true)
Expand All @@ -50,19 +52,125 @@ func BatchGPGOptions(passphrase string) GPGOptions {
}
}

// GPGIDFile is the name of the file in the store root listing the recipients.
const GPGIDFile = ".gpg-id"

// ReadGPGID reads the recipients from <storeDir>/.gpg-id (store root only).
// One recipient per line; blank lines and lines starting with '#' are ignored
// and whitespace is trimmed. found is false (with a nil error) if the file does
// not exist. An existing file without any recipient is an error.
func ReadGPGID(storeDir string) (recipients []string, found bool, err error) {
data, err := os.ReadFile(filepath.Join(storeDir, GPGIDFile))
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, true, err
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
recipients = append(recipients, line)
}
if len(recipients) == 0 {
return nil, true, fmt.Errorf("%s contains no recipients", GPGIDFile)
}
return recipients, true, nil
}

// ResolveRecipients determines whom secrets in storeDir are encrypted to:
// 1. the recipients in <storeDir>/.gpg-id,
// 2. otherwise the PASS_GPG_ID environment variable,
// 3. otherwise the only usable secret key in the keyring.
//
// A .gpg-id that exists but is unusable is an error (no fall-through, as that
// would encrypt to a key the store does not declare). Without .gpg-id and
// PASS_GPG_ID, zero or several secret keys are an error since the right key
// cannot be known.
func ResolveRecipients(storeDir string) ([]string, error) {
recipients, found, err := ReadGPGID(storeDir)
if err != nil {
return nil, fmt.Errorf("invalid %s: %v", GPGIDFile, err)
}
if found {
return recipients, nil
}
recipients, err = fallbackRecipients()
if err != nil {
return nil, fmt.Errorf("%v; create %s or set PASS_GPG_ID",
err, filepath.Join(storeDir, GPGIDFile))
}
return recipients, nil
}

// fallbackRecipients implements steps 2 and 3 of ResolveRecipients.
func fallbackRecipients() ([]string, error) {
if id := strings.TrimSpace(os.Getenv("PASS_GPG_ID")); id != "" {
return []string{id}, nil
}
keys, err := usableSecretKeys()
if err != nil {
return nil, err
}
switch len(keys) {
case 0:
return nil, fmt.Errorf("no GPG secret key found")
case 1:
return keys, nil
}
return nil, fmt.Errorf("multiple GPG secret keys found (%s); cannot tell which to use",
strings.Join(keys, ", "))
}

// usableSecretKeys returns the key IDs of secret keys that are not revoked,
// expired or disabled.
func usableSecretKeys() ([]string, error) {
cmd := exec.Command("gpg", "--batch", "--list-secret-keys", "--with-colons")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("failed to list GPG secret keys: %v: %s", err, strings.TrimSpace(stderr.String()))
}
var keys []string
for _, line := range strings.Split(stdout.String(), "\n") {
if !strings.HasPrefix(line, "sec:") {
continue
}
parts := strings.Split(line, ":")
if len(parts) < 5 {
continue
}
if parts[1] == "r" || parts[1] == "e" || parts[1] == "d" {
continue
}
keys = append(keys, parts[4])
}
return keys, nil
}

// EncryptFile encrypts a file using GPG and saves it to the destination path.
// Uses the default GPG key or the one specified in PASS_GPG_ID environment variable.
// Recipients: PASS_GPG_ID, otherwise the sole usable secret key (see EncryptFileWithOptions).
func EncryptFile(srcPath, destPath string) error {
return EncryptFileWithOptions(srcPath, destPath, DefaultGPGOptions())
}

// EncryptFileWithOptions encrypts a file using GPG with custom options.
// This allows for batch mode, specific recipients, and passphrase handling.
func EncryptFileWithOptions(srcPath, destPath string, opts GPGOptions) error {
// Get recipient from options, environment, or use default
recipient := opts.Recipient
if recipient == "" {
recipient = os.Getenv("PASS_GPG_ID")
// Recipients: explicit list, then single recipient, then PASS_GPG_ID,
// then the sole usable secret key. Never an arbitrary default key.
recipients := opts.Recipients
if len(recipients) == 0 && opts.Recipient != "" {
recipients = []string{opts.Recipient}
}
if len(recipients) == 0 {
var err error
if recipients, err = fallbackRecipients(); err != nil {
return err
}
}

args := []string{
Expand All @@ -81,11 +189,8 @@ func EncryptFileWithOptions(srcPath, destPath string, opts GPGOptions) error {
args = append(args, "--pinentry-mode", opts.PinentryMode)
}

if recipient != "" {
args = append(args, "--recipient", recipient)
} else {
// Use default key (no explicit recipient)
args = append(args, "--default-recipient-self")
for _, r := range recipients {
args = append(args, "--recipient", r)
}

// Add output file
Expand Down
Loading
Loading