From 51565ed02205671ff0d0669b78fe8063f8937705 Mon Sep 17 00:00:00 2001 From: Daniel Robbins Date: Wed, 10 Jun 2026 17:04:42 -0400 Subject: [PATCH 1/5] Fix GPG encryption subkey warm-up Keychain 3 now gives encryption/decryption subkeys an explicit warm-up path instead of routing gpge: through a signing-style GPG probe. The new path creates a tiny temporary ciphertext for the requested key and decrypts it through gpg-agent, which exercises the secret encryption subkey used by pass and other decrypt-oriented workflows. Also update gpga: to warm both signing and encryption/decryption capability, document the GPG subkey prefixes in the embedded manual, and add a Linux-only e2e test with an isolated GNUPGHOME and fake pinentry. Fixes #62. Fixes #204. --- .gitignore | 1 + man/embedded-docs.txt | 23 +++-- src/keychain/agents.py | 69 +++++++++++++++ src/keychain/main.py | 8 +- tests/test_gpg_e2e.py | 190 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 282 insertions(+), 9 deletions(-) create mode 100644 tests/test_gpg_e2e.py diff --git a/.gitignore b/.gitignore index ca9b0c5..07be4a3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ keychain.iml __pycache__/ *.pyc .specstory/ +.venv/ .ci-artifacts*/ dist/ build/ diff --git a/man/embedded-docs.txt b/man/embedded-docs.txt index ddacdae..749ecba 100644 --- a/man/embedded-docs.txt +++ b/man/embedded-docs.txt @@ -67,7 +67,10 @@ although it is possible to specify an absolute or relative path to the private key file as well. Private key files can be symlinks to the actual key. In addition, for GPG keys specified, similar steps will be taken to ensure that -gpg-agent has the GPG key cached in memory and ready for use. +gpg-agent has the requested GPG capability cached in memory and ready for use. +Use ``gpgs:KEYID`` for signing-oriented workflows, ``gpge:KEYID`` for +decrypt-oriented workflows such as ``pass``, and ``gpga:KEYID`` when both +capabilities should be warmed. == @section ACTIONS @@ -83,7 +86,10 @@ gpg-agent has the GPG key cached in memory and ready for use. ``~/.keychain/`` that scripts can ``source`` to gain access to the agent. Bare key names are looked up under *~/.ssh/* and as GPG key IDs; absolute paths -are loaded directly. Symbolic links are supported Extended-key syntax -- ``sshk:PATH``, ``gpgk:KEYID``, ``host:HOSTNAME`` -- is automatically detected and can be used to explicitly specify the lookup method. +are loaded directly. Symbolic links are supported. Extended-key syntax -- +``sshk:PATH``, ``gpgk:KEYID``, ``gpgs:KEYID``, ``gpge:KEYID``, ``gpga:KEYID``, +``host:HOSTNAME`` -- is automatically detected and can be used to explicitly +specify the lookup method. When one or more KEY arguments are supplied and *none* of them resolve to a real SSH key or known GPG identity, ``add`` exits without starting or attaching @@ -696,10 +702,10 @@ workflows often prefer ``--ssh-spawn-gpg`` to collapse to one process. Positional key arguments accept six prefixes: sshk:PATH a literal SSH key file path - gpgk:KEYID a GnuPG key ID (generic) + gpgk:KEYID a GnuPG key ID (legacy generic signing-oriented warm-up) gpgs:KEYID warm only signing subkeys - gpge:KEYID warm only encryption subkeys - gpga:KEYID warm all subkeys + gpge:KEYID warm encryption/decryption subkeys + gpga:KEYID warm signing and encryption/decryption subkeys host:HOSTNAME every IdentityFile for HOSTNAME (resolved via ``ssh -G HOSTNAME``) @@ -707,6 +713,13 @@ Bare positional names without a prefix are auto-classified: ``~/.ssh/NAME`` if it exists → ``sshk:``; otherwise looked up via ``gpg --list-secret-keys`` → ``gpgk:``; otherwise reported as missing. +GPG subkey behavior matters. Many OpenPGP keys have separate signing and +encryption subkeys. A signing operation may happen to unlock later decryption +on some GnuPG versions or key layouts, but Keychain does not rely on that as a +portable guarantee. If your workflow decrypts data -- for example, +``pass show ...`` -- use ``gpge:KEYID``. If you want both signing and +decryption warmed, use ``gpga:KEYID``. + Prefixed and bare positionals can be mixed freely. The old ``--extended`` flag is accepted for 2.x compatibility and does not change parsing. diff --git a/src/keychain/agents.py b/src/keychain/agents.py index 65bce80..f3d8a81 100644 --- a/src/keychain/agents.py +++ b/src/keychain/agents.py @@ -11,6 +11,7 @@ import stat import subprocess import sys +import tempfile from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path @@ -697,6 +698,74 @@ def load(self, gpg_keys: list[str], mode: str = "--sign") -> bool: return False return True + def load_decryption(self, gpg_keys: list[str]) -> bool: + out = self.out + run_env = dict(self.k.env) + if bool(self.k.args.get_value("no_gui")) or not self.k.env.get("DISPLAY"): + run_env.pop("DISPLAY", None) + with tempfile.TemporaryDirectory(prefix="keychain-gpg-") as td: + plain = Path(td) / "plain" + cipher = Path(td) / "cipher.gpg" + plain.write_text("keychain\n", encoding="utf-8") + for k in filter(None, gpg_keys): + out.info(f"Adding gpg encryption key: {k}") + try: + enc = subprocess.run( + [ + self.k.gpg_prog, + "--batch", + "--yes", + "--no-options", + "--trust-model", + "always", + "--encrypt", + "--recipient", + k, + "--output", + str(cipher), + str(plain), + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=run_env, + timeout=10, + check=False, + ) + dec = subprocess.run( + [ + self.k.gpg_prog, + "--batch", + "--yes", + "--no-autostart", + "--no-options", + "--use-agent", + "--decrypt", + "--output", + os.devnull, + str(cipher), + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=run_env, + timeout=30, + check=False, + ) + except (FileNotFoundError, OSError): + out.warn(f"{self.k.gpg_prog} not found") + return False + except subprocess.TimeoutExpired: + out.warn(f"Error adding gpg encryption key: {k} timed out") + return False + if enc.returncode != 0 or dec.returncode != 0: + err = (enc.stdout + enc.stderr + dec.stdout + dec.stderr).strip() + out.warn(f"Error adding gpg encryption key (output: {err})") + return False + return True + def render_list_table(kstate, out: Output) -> int: """Render ``ssh-add -l`` as a TYPE/BITS/FINGERPRINT/COMMENT table.""" diff --git a/src/keychain/main.py b/src/keychain/main.py index 48a478b..ce37fda 100644 --- a/src/keychain/main.py +++ b/src/keychain/main.py @@ -343,11 +343,11 @@ def _do_add( missing_gpg = self.kstate.gpg.list_missing(gpg_s_keys, mode="--sign") self.kstate.gpg.load(missing_gpg, mode="--sign") if gpg_e_keys: - missing_gpg = self.kstate.gpg.list_missing(gpg_e_keys, mode="--encrypt") - self.kstate.gpg.load(gpg_e_keys, mode="--encrypt") + self.kstate.gpg.load_decryption(gpg_e_keys) if gpg_a_keys: - missing_gpg = self.kstate.gpg.list_missing(gpg_a_keys, mode="--armor") - self.kstate.gpg.load(gpg_a_keys, mode="--armor") + missing_gpg = self.kstate.gpg.list_missing(gpg_a_keys, mode="--sign") + self.kstate.gpg.load(missing_gpg, mode="--sign") + self.kstate.gpg.load_decryption(gpg_a_keys) self.out.line() return 0 diff --git a/tests/test_gpg_e2e.py b/tests/test_gpg_e2e.py new file mode 100644 index 0000000..9c971d6 --- /dev/null +++ b/tests/test_gpg_e2e.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import os +import re +import shlex +import shutil +import signal +import subprocess +import sys +from pathlib import Path + +import pytest + +from keychain.runtime import platform + +pytestmark = pytest.mark.skipif( + os.name == "nt" or not platform.detect().supported or not shutil.which("gpg") or not shutil.which("gpgconf"), + reason="GPG e2e coverage requires a POSIX host with gpg and gpgconf", +) + + +ROOT = Path(__file__).resolve().parents[1] + + +def _run(cmd: list[str], env: dict[str, str], *, input_: str | None = None, timeout: int = 30) -> subprocess.CompletedProcess: + return subprocess.run( + cmd, + input=input_, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + ) + + +def _gpg(env: dict[str, str], *args: str, timeout: int = 30) -> subprocess.CompletedProcess: + return _run(["gpg", *args], env, timeout=timeout) + + +def _assert_ok(result: subprocess.CompletedProcess) -> None: + assert result.returncode == 0, result.stdout + result.stderr + + +def _write_fake_pinentry(path: Path, passfile: Path, log: Path) -> None: + path.write_text( + f"""#!/bin/sh +passfile={shlex.quote(str(passfile))} +log={shlex.quote(str(log))} +printf "OK fake pinentry\\n" +while IFS= read -r line; do + printf "%s\\n" "$line" >> "$log" + case "$line" in + GETPIN*) + if [ -r "$passfile" ]; then + printf "D %s\\n" "$(cat "$passfile")" + printf "OK\\n" + else + printf "ERR 83886179 Operation cancelled\\n" + fi + ;; + BYE*) printf "OK\\n"; exit 0 ;; + *) printf "OK\\n" ;; + esac +done +""", + encoding="utf-8", + ) + path.chmod(0o700) + + +def _fingerprint(env: dict[str, str]) -> str: + result = _gpg(env, "--batch", "--with-colons", "--list-secret-keys") + _assert_ok(result) + for line in result.stdout.splitlines(): + fields = line.split(":") + if fields[0] == "fpr": + return fields[9] + raise AssertionError(f"no fingerprint in gpg output:\n{result.stdout}") + + +def _kill_keychain_ssh_agents(home: Path) -> None: + for pidfile in (home / ".keychain").glob("*-sh"): + match = re.search(r"SSH_AGENT_PID=([0-9]+)", pidfile.read_text(encoding="utf-8", errors="ignore")) + if match: + try: + os.kill(int(match.group(1)), signal.SIGTERM) + except OSError: + pass + + +@pytest.fixture +def gpg_home(tmp_path: Path): + home = tmp_path / "home" + gnupg = home / ".gnupg" + home.mkdir() + gnupg.mkdir(mode=0o700) + + passfile = tmp_path / "passphrase" + passfile.write_text("secret-pass", encoding="utf-8") + pinentry_log = tmp_path / "pinentry.log" + pinentry_log.write_text("", encoding="utf-8") + pinentry = tmp_path / "pinentry-test" + _write_fake_pinentry(pinentry, passfile, pinentry_log) + (gnupg / "gpg-agent.conf").write_text( + f"pinentry-program {pinentry}\n" + "allow-loopback-pinentry\n" + "default-cache-ttl 600\n" + "max-cache-ttl 600\n", + encoding="utf-8", + ) + + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "GNUPGHOME": str(gnupg), + "GPG_TTY": "", + "PYTHONPATH": str(ROOT / "src") + os.pathsep + env.get("PYTHONPATH", ""), + } + ) + env.pop("SSH_AUTH_SOCK", None) + env.pop("SSH_AGENT_PID", None) + + yield env, home, passfile, pinentry_log + + _run(["gpgconf", "--kill", "gpg-agent"], env, timeout=10) + _kill_keychain_ssh_agents(home) + + +def test_gpge_warms_encryption_subkey_for_decryption(gpg_home) -> None: + env, home, passfile, _pinentry_log = gpg_home + + _assert_ok( + _gpg( + env, + "--batch", + "--pinentry-mode", + "loopback", + "--passphrase-file", + str(passfile), + "--quick-generate-key", + "Keychain Test ", + "rsa2048", + "sign", + "0", + ) + ) + fingerprint = _fingerprint(env) + _assert_ok( + _gpg( + env, + "--batch", + "--pinentry-mode", + "loopback", + "--passphrase-file", + str(passfile), + "--quick-add-key", + fingerprint, + "rsa2048", + "encrypt", + "0", + ) + ) + + plain = home / "plain.txt" + cipher = home / "cipher.gpg" + out = home / "out.txt" + plain.write_text("plaintext\n", encoding="utf-8") + _assert_ok(_gpg(env, "--batch", "--yes", "--trust-model", "always", "--encrypt", "-r", fingerprint, "-o", str(cipher), str(plain))) + + _run(["gpgconf", "--kill", "gpg-agent"], env, timeout=10) + passfile.unlink() + failed = _gpg(env, "--batch", "--yes", "--decrypt", "-o", str(out), str(cipher), timeout=15) + assert failed.returncode != 0 + + passfile.write_text("secret-pass", encoding="utf-8") + _run(["gpgconf", "--kill", "gpg-agent"], env, timeout=10) + keychain = _run( + [sys.executable, "-m", "keychain", "--no-color", "--quiet", "add", f"gpge:{fingerprint}"], + env, + timeout=60, + ) + _assert_ok(keychain) + + passfile.unlink() + _assert_ok(_gpg(env, "--batch", "--yes", "--decrypt", "-o", str(out), str(cipher), timeout=15)) + assert out.read_text(encoding="utf-8") == "plaintext\n" From a243dc08c32458d2ccb3ce31fabe4cf57064de4a Mon Sep 17 00:00:00 2001 From: Daniel Robbins Date: Wed, 10 Jun 2026 18:22:34 -0400 Subject: [PATCH 2/5] Clean up GPG subprocess handling --- src/keychain/agents.py | 64 ++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/src/keychain/agents.py b/src/keychain/agents.py index f3d8a81..d10679c 100644 --- a/src/keychain/agents.py +++ b/src/keychain/agents.py @@ -570,6 +570,29 @@ def __init__(self, k, out: Output) -> None: self.k = k self.out = out + def _gpg_env(self, *, tty: bool = False) -> dict[str, str]: + env = dict(self.k.env) + if tty and (gpg_tty := get_tty()): + env["GPG_TTY"] = gpg_tty + if bool(self.k.args.get_value("no_gui")) or not self.k.env.get("DISPLAY"): + env.pop("DISPLAY", None) + return env + + def _run_gpg( + self, args: list[str], *, env: dict[str, str] | None = None, input_: str = "", timeout: int | None = None + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [self.k.gpg_prog, *args], + input=input_, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=self._gpg_env() if env is None else env, + timeout=timeout, + check=False, + ) + # ---- lifecycle --------------------------------------------------- def start(self, ssh_support: bool) -> SshAgentRef | None: @@ -656,23 +679,12 @@ def list_missing(self, gpg_keys: list[str], mode: str = "--sign") -> list[str]: def load(self, gpg_keys: list[str], mode: str = "--sign") -> bool: out = self.out - extra_env: dict[str, str] = {} - tty = get_tty() - if tty: - extra_env["GPG_TTY"] = tty - drop_display = bool(self.k.args.get_value("no_gui")) or not self.k.env.get("DISPLAY") + run_env = self._gpg_env(tty=True) for k in filter(None, gpg_keys): out.info(f"Adding gpg key: {k}") - # util.run() copies os.environ then layers `env` on top; an empty - # value here doesn't unset, so do the unset on our local copy. - run_env = dict(self.k.env) - run_env.update(extra_env) - if drop_display: - run_env.pop("DISPLAY", None) try: - r = subprocess.run( + r = self._run_gpg( [ - self.k.gpg_prog, "--no-autostart", "--no-options", "--use-agent", @@ -681,13 +693,7 @@ def load(self, gpg_keys: list[str], mode: str = "--sign") -> bool: k, "-o-", ], - input="", - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", env=run_env, - check=False, ) except (FileNotFoundError, OSError): out.warn(f"{self.k.gpg_prog} not found") @@ -700,9 +706,7 @@ def load(self, gpg_keys: list[str], mode: str = "--sign") -> bool: def load_decryption(self, gpg_keys: list[str]) -> bool: out = self.out - run_env = dict(self.k.env) - if bool(self.k.args.get_value("no_gui")) or not self.k.env.get("DISPLAY"): - run_env.pop("DISPLAY", None) + run_env = self._gpg_env() with tempfile.TemporaryDirectory(prefix="keychain-gpg-") as td: plain = Path(td) / "plain" cipher = Path(td) / "cipher.gpg" @@ -710,9 +714,8 @@ def load_decryption(self, gpg_keys: list[str]) -> bool: for k in filter(None, gpg_keys): out.info(f"Adding gpg encryption key: {k}") try: - enc = subprocess.run( + enc = self._run_gpg( [ - self.k.gpg_prog, "--batch", "--yes", "--no-options", @@ -725,17 +728,11 @@ def load_decryption(self, gpg_keys: list[str]) -> bool: str(cipher), str(plain), ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", env=run_env, timeout=10, - check=False, ) - dec = subprocess.run( + dec = self._run_gpg( [ - self.k.gpg_prog, "--batch", "--yes", "--no-autostart", @@ -746,13 +743,8 @@ def load_decryption(self, gpg_keys: list[str]) -> bool: os.devnull, str(cipher), ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", env=run_env, timeout=30, - check=False, ) except (FileNotFoundError, OSError): out.warn(f"{self.k.gpg_prog} not found") From 40b18bd64acd9124e5023e3802ced21455f8f6e3 Mon Sep 17 00:00:00 2001 From: Daniel Robbins Date: Wed, 10 Jun 2026 18:40:20 -0400 Subject: [PATCH 3/5] Fix GPG decrypt warm-up on modern agents --- src/keychain/agents.py | 3 +-- src/keychain/main.py | 6 ++++-- tests/test_gpg_e2e.py | 22 ++++++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/keychain/agents.py b/src/keychain/agents.py index d10679c..8108951 100644 --- a/src/keychain/agents.py +++ b/src/keychain/agents.py @@ -626,7 +626,7 @@ def start(self, ssh_support: bool) -> SshAgentRef | None: opts += shlex.split(self.k.env.get("KEYCHAIN_GPG_AGENT_ARGS", "")) out.info("Starting gpg-agent...") try: - r = run(["gpg-agent", "--sh"] + opts) + r = run(["gpg-agent", "--sh"] + opts, env=self.k.env) except (FileNotFoundError, OSError): return None return SshAgentRef.from_text(r.stdout) if r.returncode == 0 else None @@ -733,7 +733,6 @@ def load_decryption(self, gpg_keys: list[str]) -> bool: ) dec = self._run_gpg( [ - "--batch", "--yes", "--no-autostart", "--no-options", diff --git a/src/keychain/main.py b/src/keychain/main.py index ce37fda..2ed4112 100644 --- a/src/keychain/main.py +++ b/src/keychain/main.py @@ -343,11 +343,13 @@ def _do_add( missing_gpg = self.kstate.gpg.list_missing(gpg_s_keys, mode="--sign") self.kstate.gpg.load(missing_gpg, mode="--sign") if gpg_e_keys: - self.kstate.gpg.load_decryption(gpg_e_keys) + if not self.kstate.gpg.load_decryption(gpg_e_keys): + raise KeychainError("Unable to add GPG encryption keys") if gpg_a_keys: missing_gpg = self.kstate.gpg.list_missing(gpg_a_keys, mode="--sign") self.kstate.gpg.load(missing_gpg, mode="--sign") - self.kstate.gpg.load_decryption(gpg_a_keys) + if not self.kstate.gpg.load_decryption(gpg_a_keys): + raise KeychainError("Unable to add GPG encryption keys") self.out.line() return 0 diff --git a/tests/test_gpg_e2e.py b/tests/test_gpg_e2e.py index 9c971d6..7d8e662 100644 --- a/tests/test_gpg_e2e.py +++ b/tests/test_gpg_e2e.py @@ -71,6 +71,25 @@ def _write_fake_pinentry(path: Path, passfile: Path, log: Path) -> None: path.chmod(0o700) +def _write_gpg_wrapper(path: Path, passfile: Path) -> None: + path.write_text( + f"""#!/bin/sh +real_gpg={shlex.quote(shutil.which("gpg") or "gpg")} +passfile={shlex.quote(str(passfile))} +decrypt=0 +for arg do + [ "$arg" = "--decrypt" ] && decrypt=1 +done +if [ "$decrypt" = 1 ] && [ -r "$passfile" ]; then + exec "$real_gpg" --pinentry-mode loopback --passphrase-file "$passfile" "$@" +fi +exec "$real_gpg" "$@" +""", + encoding="utf-8", + ) + path.chmod(0o700) + + def _fingerprint(env: dict[str, str]) -> str: result = _gpg(env, "--batch", "--with-colons", "--list-secret-keys") _assert_ok(result) @@ -104,6 +123,8 @@ def gpg_home(tmp_path: Path): pinentry_log.write_text("", encoding="utf-8") pinentry = tmp_path / "pinentry-test" _write_fake_pinentry(pinentry, passfile, pinentry_log) + gpg_wrapper = tmp_path / "gpg-wrapper" + _write_gpg_wrapper(gpg_wrapper, passfile) (gnupg / "gpg-agent.conf").write_text( f"pinentry-program {pinentry}\n" "allow-loopback-pinentry\n" @@ -117,6 +138,7 @@ def gpg_home(tmp_path: Path): { "HOME": str(home), "GNUPGHOME": str(gnupg), + "GPG_BIN": str(gpg_wrapper), "GPG_TTY": "", "PYTHONPATH": str(ROOT / "src") + os.pathsep + env.get("PYTHONPATH", ""), } From cdb43a65e96805c6f5e53c89bf3297691fba3b9a Mon Sep 17 00:00:00 2001 From: Daniel Robbins Date: Wed, 10 Jun 2026 18:52:58 -0400 Subject: [PATCH 4/5] Document GPG warm-up guarantees --- man/embedded-docs.txt | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/man/embedded-docs.txt b/man/embedded-docs.txt index 749ecba..d6629c4 100644 --- a/man/embedded-docs.txt +++ b/man/embedded-docs.txt @@ -70,7 +70,10 @@ In addition, for GPG keys specified, similar steps will be taken to ensure that gpg-agent has the requested GPG capability cached in memory and ready for use. Use ``gpgs:KEYID`` for signing-oriented workflows, ``gpge:KEYID`` for decrypt-oriented workflows such as ``pass``, and ``gpga:KEYID`` when both -capabilities should be warmed. +capabilities should be warmed. ``gpge:`` is an explicit decryption-path +guarantee: keychain encrypts a tiny temporary payload to the requested key and +then decrypts it through ``gpg-agent``. If that check cannot be completed, +``add`` fails instead of reporting success. == @section ACTIONS @@ -713,12 +716,30 @@ Bare positional names without a prefix are auto-classified: ``~/.ssh/NAME`` if it exists → ``sshk:``; otherwise looked up via ``gpg --list-secret-keys`` → ``gpgk:``; otherwise reported as missing. -GPG subkey behavior matters. Many OpenPGP keys have separate signing and -encryption subkeys. A signing operation may happen to unlock later decryption -on some GnuPG versions or key layouts, but Keychain does not rely on that as a -portable guarantee. If your workflow decrypts data -- for example, -``pass show ...`` -- use ``gpge:KEYID``. If you want both signing and -decryption warmed, use ``gpga:KEYID``. +GPG subkey behavior matters. OpenPGP encryption capability may live on the +primary key or on a separate encryption subkey, and GnuPG's passphrase cache +behavior can vary by version, platform, and key layout. A signing operation may +happen to unlock later decryption on some systems, but Keychain does not rely +on that as a portable guarantee. + +The GPG prefixes describe the capability Keychain proves: + + gpgs:KEYID + gpgk:KEYID perform a signing-oriented warm-up using GnuPG's signing + path + + gpge:KEYID perform a decryption-oriented warm-up by encrypting a tiny + temporary payload to the requested key and then decrypting + it through ``gpg-agent`` + + gpga:KEYID perform both checks + +This means ``gpge:`` is not just a naming hint. It is a behavioral guarantee: +if Keychain cannot prove that ``gpg-agent`` can decrypt for the requested key, +``add`` fails instead of silently relying on incidental signing-cache behavior. +If your workflow decrypts data -- for example, ``pass show ...`` -- use +``gpge:KEYID``. If you want both signing and decryption warmed, use +``gpga:KEYID``. Prefixed and bare positionals can be mixed freely. The old ``--extended`` flag is accepted for 2.x compatibility and does not change parsing. From 6107c9f1f37d9976c7e9a4c3dd94e9ff5559a9a8 Mon Sep 17 00:00:00 2001 From: Daniel Robbins Date: Wed, 10 Jun 2026 19:56:56 -0400 Subject: [PATCH 5/5] Clarify extended key documentation --- man/embedded-docs.txt | 91 +++++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/man/embedded-docs.txt b/man/embedded-docs.txt index d6629c4..d8e59c3 100644 --- a/man/embedded-docs.txt +++ b/man/embedded-docs.txt @@ -700,53 +700,58 @@ per-user (mode 700). Older pidfiles are replaced atomically; no in-place edits. Modern gpg-agent can also serve as the SSH agent; users with mostly-GPG workflows often prefer ``--ssh-spawn-gpg`` to collapse to one process. -== @topic extkeys: Extended-key syntax - -Positional key arguments accept six prefixes: - - sshk:PATH a literal SSH key file path - gpgk:KEYID a GnuPG key ID (legacy generic signing-oriented warm-up) - gpgs:KEYID warm only signing subkeys - gpge:KEYID warm encryption/decryption subkeys - gpga:KEYID warm signing and encryption/decryption subkeys +== @topic extkeys: Extended key syntax + +SSH and GPG keys can be specified as positional arguments for the ``add`` +action. ``keychain add NAME`` will look for ``~/.ssh/NAME`` if +it exists and treat it as an SSH key; otherwise, it is looked up via ``gpg --list-secret-keys`` +and if found the GnuPG signing key will be loaded and warmed. This *may* also +warm an associated encryption key, but this is not guaranteed -- see below. + +In addition to explicit passing of keys by name or path, positional key +arguments may accept six extended key prefixes to avoid ambiguity and provide +more fine-grained control. The following prefixed formats can be mixed freely +with regular positional key names: + + sshk:PATH an SSH key file path + sshk:NAME an SSH key name under ``~/.ssh/`` + gpgk:KEYID a GnuPG key ID (legacy alias for ``gpgs:``) + gpgs:KEYID warm and prove signing capability using GnuPG's signing path + gpge:KEYID warm and prove encryption/decryption capability + gpga:KEYID warm and prove signing + encryption/decryption capability host:HOSTNAME every IdentityFile for HOSTNAME (resolved via ``ssh -G HOSTNAME``) -Bare positional names without a prefix are auto-classified: ``~/.ssh/NAME`` if -it exists → ``sshk:``; otherwise looked up via ``gpg --list-secret-keys`` → -``gpgk:``; otherwise reported as missing. - -GPG subkey behavior matters. OpenPGP encryption capability may live on the -primary key or on a separate encryption subkey, and GnuPG's passphrase cache -behavior can vary by version, platform, and key layout. A signing operation may -happen to unlock later decryption on some systems, but Keychain does not rely -on that as a portable guarantee. - -The GPG prefixes describe the capability Keychain proves: - - gpgs:KEYID - gpgk:KEYID perform a signing-oriented warm-up using GnuPG's signing - path - - gpge:KEYID perform a decryption-oriented warm-up by encrypting a tiny - temporary payload to the requested key and then decrypting - it through ``gpg-agent`` - - gpga:KEYID perform both checks - -This means ``gpge:`` is not just a naming hint. It is a behavioral guarantee: -if Keychain cannot prove that ``gpg-agent`` can decrypt for the requested key, -``add`` fails instead of silently relying on incidental signing-cache behavior. -If your workflow decrypts data -- for example, ``pass show ...`` -- use -``gpge:KEYID``. If you want both signing and decryption warmed, use -``gpga:KEYID``. - -Prefixed and bare positionals can be mixed freely. The old ``--extended`` flag -is accepted for 2.x compatibility and does not change parsing. - -``host:`` expansion is delegated to ``ssh -G`` so the full OpenSSH semantics +*Using the extended key syntax opens up several important features to help ensure +that your specified GnuPG signing or encryption key is actually loaded and ready for use.* +This is important because GnuPG's keys and subkeys can be tricky -- OpenPGP encryption +capability may live on the primary key or on a separate encryption subkey, and GnuPG's +passphrase cache behavior can vary by version, platform, and key layout. A signing +operation may happen to unlock later decryption on some systems, but Keychain does not rely +on that as a portable guarantee. + +Extended key syntax helps with this situation. For GPG keys, ``gpgk:KEYID`` is +equivalent to ``gpgs:KEYID``. Both perform a signing-oriented warm-up: Keychain asks +GnuPG to perform a signing operation with the requested key, which causes +``gpg-agent`` to cache the passphrase needed for signing. *This proves signing +capability, but it does not prove that the same key can decrypt data.* + +Using ``gpge:KEYID`` performs a similar explicit check to ensure functionality. When used, +*Keychain will encrypt a tiny temporary payload with the requested key and then decrypt it to +ensure that it is properly loaded and functional, and will fail if it does not +succeed.* If your workflow decrypts data -- for example, ``pass show ...`` -- use +``gpge:KEYID``. + +If your workflow needs both signing and decryption, use ``gpga:KEYID``. +*Keychain will first warm the signing path, then require the same explicit +decryption check performed by ``gpge:``.* If GnuPG cannot encrypt to and decrypt +with the requested key, ``gpga:KEYID`` fails rather than treating signing warm-up as +enough. + +``host:HOSTNAME`` can be used to refer to a specific hostname defined in your local +OpenSSH config. ``host:`` expansion is delegated to ``ssh -G`` so the full OpenSSH semantics (``Match``, ``Include``, ``%d``/``%u``/ ``%h`` substitution, quoted args) apply --- no parallel parser. +properly. == @topic pidfile: Pidfile location and contents