diff --git a/docs/pass-quick-reference.md b/docs/pass-quick-reference.md index 281f5ec..6a1ed18 100644 --- a/docs/pass-quick-reference.md +++ b/docs/pass-quick-reference.md @@ -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 `/.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 | @@ -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 `/.gpg-id` (or set `PASS_GPG_ID`); `insert`/`edit` refuse to guess **"No such file or directory"** - Check the path is correct (case-sensitive) diff --git a/pass/cmd/edit.go b/pass/cmd/edit.go index 2ac8101..6395f86 100644 --- a/pass/cmd/edit.go +++ b/pass/cmd/edit.go @@ -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 { @@ -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) } diff --git a/pass/cmd/gpgid_test.go b/pass/cmd/gpgid_test.go new file mode 100644 index 0000000..48a7086 --- /dev/null +++ b/pass/cmd/gpgid_test.go @@ -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) + } + }) +} diff --git a/pass/cmd/insert.go b/pass/cmd/insert.go index c4f6219..4df0b11 100644 --- a/pass/cmd/insert.go +++ b/pass/cmd/insert.go @@ -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 { @@ -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) } @@ -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: /.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 +} diff --git a/pass/docs/CHANGELOG.md b/pass/docs/CHANGELOG.md index 1950092..cf0f83f 100644 --- a/pass/docs/CHANGELOG.md +++ b/pass/docs/CHANGELOG.md @@ -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 `/.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 ` now fails with error `pass: : Already exists` if the file already exists - **List command**: `pass ls` now only lists secret files (`.gpg` files), not directories diff --git a/pass/pkg/gpg/gpg.go b/pass/pkg/gpg/gpg.go index 1310fec..fcd2fc9 100644 --- a/pass/pkg/gpg/gpg.go +++ b/pass/pkg/gpg/gpg.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" ) @@ -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) @@ -50,8 +52,107 @@ 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 /.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 /.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()) } @@ -59,10 +160,17 @@ func EncryptFile(srcPath, destPath string) error { // 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{ @@ -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 diff --git a/pass/pkg/gpg/gpgid_test.go b/pass/pkg/gpg/gpgid_test.go new file mode 100644 index 0000000..f52f77e --- /dev/null +++ b/pass/pkg/gpg/gpgid_test.go @@ -0,0 +1,248 @@ +package gpg + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/mandu/tools/pass/internal/testhelper" +) + +func writeGPGID(t *testing.T, dir, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, GPGIDFile), []byte(content), 0600); err != nil { + t.Fatal(err) + } +} + +// isolateGPG points GNUPGHOME at an empty temp dir so that no test can ever +// read the real user's keyring, and clears PASS_GPG_ID. +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 TestReadGPGID(t *testing.T) { + tests := []struct { + name string + content string + want []string + wantErr bool + }{ + {"single", "ABCD1234\n", []string{"ABCD1234"}, false}, + {"no trailing newline", "ABCD1234", []string{"ABCD1234"}, false}, + {"multiple", "AAAA\nBBBB\n", []string{"AAAA", "BBBB"}, false}, + {"comments and blanks", "# store key\n\n AAAA \n\n#BBBB\n", []string{"AAAA"}, false}, + {"crlf", "AAAA\r\nBBBB\r\n", []string{"AAAA", "BBBB"}, false}, + {"empty", "", nil, true}, + {"only comments", "# nothing\n\n", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + writeGPGID(t, dir, tt.content) + got, found, err := ReadGPGID(dir) + if !found { + t.Fatal("found = false, want true") + } + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr %v", err, tt.wantErr) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestReadGPGIDMissing(t *testing.T) { + got, found, err := ReadGPGID(t.TempDir()) + if found || err != nil || got != nil { + t.Errorf("got (%v, %v, %v), want (nil, false, nil)", got, found, err) + } +} + +func TestResolveRecipientsGPGIDBeatsEnv(t *testing.T) { + isolateGPG(t) + t.Setenv("PASS_GPG_ID", "ENVKEY") + dir := t.TempDir() + writeGPGID(t, dir, "# store\nSTOREKEY\n") + got, err := ResolveRecipients(dir) + if err != nil { + t.Fatal(err) + } + if want := []string{"STOREKEY"}; !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestResolveRecipientsEnvWhenNoGPGID(t *testing.T) { + isolateGPG(t) + t.Setenv("PASS_GPG_ID", "ENVKEY") + got, err := ResolveRecipients(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if want := []string{"ENVKEY"}; !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestResolveRecipientsBrokenGPGIDDoesNotFallThrough(t *testing.T) { + isolateGPG(t) + t.Setenv("PASS_GPG_ID", "ENVKEY") + dir := t.TempDir() + writeGPGID(t, dir, "\n# nothing\n") + if got, err := ResolveRecipients(dir); err == nil { + t.Errorf("expected error, got recipients %v", got) + } +} + +func TestResolveRecipientsNoKeys(t *testing.T) { + if err := CheckGPG(); err != nil { + t.Skip(err) + } + isolateGPG(t) // empty keyring + dir := t.TempDir() + _, err := ResolveRecipients(dir) + if err == nil { + t.Fatal("expected error with no keys") + } + for _, want := range []string{"no GPG secret key", ".gpg-id", "PASS_GPG_ID"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q should mention %q", err, want) + } + } +} + +func TestResolveRecipientsSoleKeyAndAmbiguity(t *testing.T) { + env, noPassKey, withPassKey, err := testhelper.SetupTestEnvWithGPGKeys() + if err != nil { + t.Skipf("Failed to set up test environment: %v", err) + } + defer env.Cleanup() + t.Setenv("GNUPGHOME", env.GNUPGHome) // throwaway keyring only + t.Setenv("PASS_GPG_ID", "") + store := t.TempDir() + + // Two secret keys, no .gpg-id, no PASS_GPG_ID: ambiguous. + _, err = ResolveRecipients(store) + if err == nil { + t.Fatal("expected ambiguity error with two secret keys") + } + for _, want := range []string{"multiple GPG secret keys", noPassKey, withPassKey} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q should mention %q", err, want) + } + } + + // .gpg-id or PASS_GPG_ID resolves the ambiguity. + writeGPGID(t, store, noPassKey+"\n") + if got, err := ResolveRecipients(store); err != nil || !reflect.DeepEqual(got, []string{noPassKey}) { + t.Errorf(".gpg-id: got (%v, %v)", got, err) + } + os.Remove(filepath.Join(store, GPGIDFile)) + t.Setenv("PASS_GPG_ID", withPassKey) + if got, err := ResolveRecipients(store); err != nil || !reflect.DeepEqual(got, []string{withPassKey}) { + t.Errorf("env: got (%v, %v)", got, err) + } + t.Setenv("PASS_GPG_ID", "") + + // Delete one secret key (in the throwaway keyring): the other is now the sole key. + // (batch mode requires the full fingerprint) + fprOut, err := exec.Command("gpg", "--batch", "--list-keys", "--with-colons", withPassKey).Output() + if err != nil { + t.Fatal(err) + } + var fpr string + for _, line := range strings.Split(string(fprOut), "\n") { + if strings.HasPrefix(line, "fpr:") { + fpr = strings.Split(line, ":")[9] + break + } + } + if fpr == "" { + t.Fatal("no fingerprint found in test keyring") + } + del := exec.Command("gpg", "--batch", "--yes", "--delete-secret-keys", fpr) + if out, err := del.CombinedOutput(); err != nil { + t.Fatalf("failed to delete secret key in test keyring: %v\n%s", err, out) + } + got, err := ResolveRecipients(store) + if err != nil { + t.Fatalf("sole key: %v", err) + } + if !reflect.DeepEqual(got, []string{noPassKey}) { + t.Errorf("sole key: got %v, want [%s]", got, noPassKey) + } +} + +// TestEncryptUsesGPGIDNotEnv verifies, with throwaway keys, that a secret is +// encrypted to the .gpg-id key even though PASS_GPG_ID names another key. +func TestEncryptUsesGPGIDNotEnv(t *testing.T) { + env, noPassKey, withPassKey, err := testhelper.SetupTestEnvWithGPGKeys() + if err != nil { + t.Skipf("Failed to set up test environment: %v", err) + } + defer env.Cleanup() + t.Setenv("GNUPGHOME", env.GNUPGHome) + t.Setenv("PASS_GPG_ID", noPassKey) + + store := t.TempDir() + writeGPGID(t, store, withPassKey+"\n") + recipients, err := ResolveRecipients(store) + if err != nil { + t.Fatal(err) + } + + src := filepath.Join(store, "plain.txt") + if err := os.WriteFile(src, []byte("secret"), 0600); err != nil { + t.Fatal(err) + } + dst := filepath.Join(store, "out.gpg") + opts := BatchGPGOptions("") + opts.Recipients = recipients + if err := EncryptFileWithOptions(src, dst, opts); err != nil { + t.Fatalf("encrypt: %v", err) + } + + // Only the passphrase-protected key is a recipient. + got, err := DecryptFileWithOptions(dst, BatchGPGOptions("test-passphrase-123")) + if err != nil { + t.Fatalf("decrypt with .gpg-id key failed: %v", err) + } + if got != "secret" { + t.Errorf("got %q, want %q", got, "secret") + } +} + +func TestEncryptAmbiguousKeyringFails(t *testing.T) { + env, _, _, err := testhelper.SetupTestEnvWithGPGKeys() + if err != nil { + t.Skipf("Failed to set up test environment: %v", err) + } + defer env.Cleanup() + t.Setenv("GNUPGHOME", env.GNUPGHome) + t.Setenv("PASS_GPG_ID", "") + + dir := t.TempDir() + src := filepath.Join(dir, "plain.txt") + if err := os.WriteFile(src, []byte("secret"), 0600); err != nil { + t.Fatal(err) + } + dst := filepath.Join(dir, "out.gpg") + if err := EncryptFileWithOptions(src, dst, BatchGPGOptions("")); err == nil { + t.Error("expected error: two secret keys and no recipient") + } + if _, err := os.Stat(dst); err == nil { + t.Error("no output file may be written") + } +} diff --git a/specs/011-gpg-id-recipient/spec.md b/specs/011-gpg-id-recipient/spec.md new file mode 100644 index 0000000..825d08f --- /dev/null +++ b/specs/011-gpg-id-recipient/spec.md @@ -0,0 +1,107 @@ +# `.gpg-id` Recipient Selection Specification + +## Overview + +`pass insert` and `pass edit` (which re-encrypts) must encrypt secrets to the +GPG key(s) listed in the `.gpg-id` file in the root of the password store. +The existing `PASS_GPG_ID` environment variable and default-key behaviour +remain as fallbacks for stores that have no `.gpg-id`, but the fallback to +"the default key" is made safe: it is only allowed when the choice is +unambiguous. + +## Status + +- **Status**: Implemented ✅ +- **Created**: 2026-08-10 +- **Branch**: `fix/011-gpg-id-recipient` + +## Background + +`pass-edit/spec.md` defines `PASS_GPG_ID` ("GPG recipient key ID", default +"Default key") and the implementation resolves the recipient as +`opts.Recipient`, then `PASS_GPG_ID`, then `--default-recipient-self`. The +specs never mention `.gpg-id`, which is how the reference `pass` binds a +store to its key(s), so `insert`/`edit` ignore it. + +## Problem Statement + +1. `.gpg-id` in the store root is ignored; secrets are encrypted to whatever + key `PASS_GPG_ID`/the default key selects, not the store's key. +2. With no `PASS_GPG_ID`, `--default-recipient-self` silently picks one of + possibly several secret keys, so the wrong key can be used. + +## Goals + +- If `/.gpg-id` exists, `insert` and `edit` encrypt to it. +- Otherwise the existing behaviour applies: `PASS_GPG_ID` if set. +- Otherwise use the user's GPG key, but only if there is exactly one usable + secret key. With none, or more than one, fail with a clear error. + +## Non-Goals + +- Per-subdirectory `.gpg-id` files: only the store root is read. +- Creating `.gpg-id` / initialising a store (use the reference `pass init`). +- Verifying the keys named in `.gpg-id` up front (gpg reports problems when encrypting). +- Re-encrypting existing secrets when `.gpg-id` changes. + +## Design + +### Recipient resolution (first match wins) + +1. `/.gpg-id`: one key ID, fingerprint or e-mail per line; blank lines + and `#` comments ignored; whitespace (incl. `\r`) trimmed. +2. `PASS_GPG_ID` environment variable. +3. The only usable secret key in the keyring (`gpg --list-secret-keys + --with-colons`; keys whose validity is revoked `r`, expired `e` or + disabled `d` are not counted). Its key ID is passed explicitly as + `--recipient`. + +A `.gpg-id` that exists but is unreadable or contains no recipient is an +error; resolution does **not** fall through to 2/3, since that would silently +encrypt to a different key than the store declares. + +`GPGOptions.Recipient` (explicit single recipient, used by tests) keeps its +current meaning and sits above `PASS_GPG_ID` but below an explicit +`GPGOptions.Recipients` (added here, carries the `.gpg-id` result). +`--default-recipient-self` is no longer used. + +### Errors + +| Condition | Message | +|---|---| +| `.gpg-id` unreadable / no recipients | `pass: invalid .gpg-id: ` | +| no `.gpg-id`, no `PASS_GPG_ID`, no secret key | `pass: no GPG secret key found; create /.gpg-id or set PASS_GPG_ID` | +| no `.gpg-id`, no `PASS_GPG_ID`, several secret keys | `pass: multiple GPG secret keys found (); cannot tell which to use. Create /.gpg-id or set PASS_GPG_ID` | + +`insert` resolves recipients before prompting for the secret; `edit` before +opening the editor. Neither prints anything to stdout on failure. + +### API + +`pkg/gpg`: + +```go +func ReadGPGID(storeDir string) (recipients []string, found bool, err error) +func ResolveRecipients(storeDir string) ([]string, error) // chain above +``` + +`GPGOptions.Recipients []string` is added. `EncryptFileWithOptions` uses, in +order: `Recipients`, `Recipient`, `PASS_GPG_ID`, the sole secret key; or +returns an error. + +`cmd`: `storeEncryptOptions(storeDir)` calls `ResolveRecipients` and returns +options for `insert` and `edit`. + +## Testing + +No test touches the user's keyring or password store. Tests use throwaway +keys in a temp `GNUPGHOME` (from `testhelper`) or no GPG at all. + +- `ReadGPGID`: missing, single, multiple, comments, blanks, CRLF, empty. +- `ResolveRecipients`: `.gpg-id` beats `PASS_GPG_ID`; env used when no + `.gpg-id`; broken `.gpg-id` errors even with env set; sole key used when it + is the only one (second key's secret deleted in the temp keyring); two keys + and no env/`.gpg-id` -> ambiguity error naming both; no keys -> error. +- Encryption goes to the `.gpg-id` key rather than the `PASS_GPG_ID` key + (decrypt with the former's passphrase). +- Existing tests that set `PASS_GPG_ID` keep passing unchanged. diff --git a/specs/011-gpg-id-recipient/tasks.md b/specs/011-gpg-id-recipient/tasks.md new file mode 100644 index 0000000..9846bba --- /dev/null +++ b/specs/011-gpg-id-recipient/tasks.md @@ -0,0 +1,9 @@ +# Tasks: `.gpg-id` recipient selection + +- [x] Spec (`spec.md`) +- [x] `gpg.ReadGPGID`, `GPGOptions.Recipients` +- [x] `gpg.ResolveRecipients` (.gpg-id > PASS_GPG_ID > sole secret key; ambiguity error) +- [x] `EncryptFileWithOptions` uses the chain; drop `--default-recipient-self` +- [x] `storeEncryptOptions` in `cmd`; use in `insert.go` and `edit.go` +- [x] Unit tests (no GPG) and throwaway-key tests (temp `GNUPGHOME`) +- [x] CHANGELOG, `docs/pass-quick-reference.md`, `specs/pass-edit/spec.md` env table diff --git a/specs/pass-edit/spec.md b/specs/pass-edit/spec.md index 45d1d84..b5ab3a0 100644 --- a/specs/pass-edit/spec.md +++ b/specs/pass-edit/spec.md @@ -479,7 +479,7 @@ func listPasswords(subpath string) error { |----------|-------------|---------| | `EDITOR` | Editor to use for editing passwords | `notepad` (Windows), `vi` (Unix) | | `PASSWORD_STORE_DIR` | Password store location | `~/.password-store` | -| `PASS_GPG_ID` | GPG recipient key ID | Default key | +| `PASS_GPG_ID` | GPG recipient key ID; used when `/.gpg-id` is absent (see `011-gpg-id-recipient`) | Only secret key, if exactly one | | `PASS_NO_COMMIT` | Skip git commits globally | false | ---