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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ keychain.iml
__pycache__/
*.pyc
.specstory/
.venv/
.ci-artifacts*/
dist/
build/
Expand Down
79 changes: 59 additions & 20 deletions man/embedded-docs.txt
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,13 @@ 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. ``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

Expand All @@ -83,7 +89,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
Expand Down Expand Up @@ -691,28 +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 (generic)
gpgs:KEYID warm only signing subkeys
gpge:KEYID warm only encryption subkeys
gpga:KEYID warm all 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.

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

Expand Down
100 changes: 80 additions & 20 deletions src/keychain/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -569,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:
Expand Down Expand Up @@ -602,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
Expand Down Expand Up @@ -655,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",
Expand All @@ -680,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")
Expand All @@ -697,6 +704,59 @@ 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 = self._gpg_env()
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 = self._run_gpg(
[
"--batch",
"--yes",
"--no-options",
"--trust-model",
"always",
"--encrypt",
"--recipient",
k,
"--output",
str(cipher),
str(plain),
],
env=run_env,
timeout=10,
)
dec = self._run_gpg(
[
"--yes",
"--no-autostart",
"--no-options",
"--use-agent",
"--decrypt",
"--output",
os.devnull,
str(cipher),
],
env=run_env,
timeout=30,
)
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."""
Expand Down
10 changes: 6 additions & 4 deletions src/keychain/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
missing_gpg = self.kstate.gpg.list_missing(gpg_e_keys, mode="--encrypt")
self.kstate.gpg.load(gpg_e_keys, mode="--encrypt")
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="--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")
if not self.kstate.gpg.load_decryption(gpg_a_keys):
raise KeychainError("Unable to add GPG encryption keys")

self.out.line()
return 0
Expand Down
Loading
Loading