Show every answer the portal remembers, and give SSH keys their missing half
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -8,13 +8,21 @@ ssh-keygen to derive the PUBLIC key with an empty passphrase: it succeeds for an
|
||||
unencrypted key and fails for an encrypted one, and either way the only thing it
|
||||
can print is public material.
|
||||
|
||||
No passphrase passes through this tool at all. Adding an encrypted key to the
|
||||
agent lets ssh-add prompt through the system's own askpass, which is where that
|
||||
belongs -- a settings page collecting a passphrase and handing it on would be a
|
||||
worse place for it to live, and putting one in argv would publish it to every
|
||||
process on the machine.
|
||||
A passphrase enters this tool in exactly one place -- creating a key -- and it
|
||||
enters on standard input, which no other process can read. From there it is
|
||||
typed at ssh-keygen over a pseudo-terminal, the same way a person would type it,
|
||||
because the two obvious alternatives are both worse: a passphrase in argv is
|
||||
published to every process on the machine, and a passphrase in a temporary file
|
||||
is written to disk. It is never logged, never echoed back, and never included in
|
||||
an error message.
|
||||
|
||||
Adding an existing encrypted key to the agent is different: no passphrase is
|
||||
collected for that at all, because ssh-add prompts through the system's own
|
||||
askpass, which is where that belongs.
|
||||
|
||||
panama-ssh-keys snapshot
|
||||
panama-ssh-keys generate NAME COMMENT (passphrase on stdin)
|
||||
panama-ssh-keys fix-permissions NAME
|
||||
panama-ssh-keys agent-add PATH | agent-remove PATH
|
||||
panama-ssh-keys forget-host HOST
|
||||
"""
|
||||
@@ -23,9 +31,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SSH_DIR = Path.home() / ".ssh"
|
||||
@@ -34,6 +47,23 @@ KNOWN_HOSTS = SSH_DIR / "known_hosts"
|
||||
# A host as it may appear in known_hosts, including [host]:port forms.
|
||||
HOST = re.compile(r"^[A-Za-z0-9._:\[\]-]{1,253}$")
|
||||
|
||||
# A key file name, and nothing that could be a path. No slash is in the class,
|
||||
# so a name cannot describe another directory at all -- the resolve-and-compare
|
||||
# below is the second lock on the same door rather than the only one.
|
||||
KEY_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
|
||||
|
||||
# A key comment. Free text, but nothing that could break out of a terminal line
|
||||
# or be mistaken for one of ssh-keygen's own prompts.
|
||||
KEY_COMMENT = re.compile(r"^[^\x00-\x1f\x7f]{0,128}$")
|
||||
|
||||
# ssh-keygen's own floor. Checked here so the refusal arrives before a terminal
|
||||
# is opened, rather than as a re-prompt nobody is there to answer.
|
||||
MINIMUM_PASSPHRASE = 5
|
||||
|
||||
# Generating an ed25519 key takes milliseconds. The budget is this large only so
|
||||
# that a machine starved of entropy fails with a message rather than a hang.
|
||||
KEYGEN_TIMEOUT_SECONDS = 120.0
|
||||
|
||||
# gnome-keyring's agent, which is what runs on this desktop. Only used when the
|
||||
# environment has not already named one, so an ssh-agent started by hand wins.
|
||||
KEYRING_SOCKET = Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000")) / "keyring" / "ssh"
|
||||
@@ -253,6 +283,217 @@ def agent_remove(path: str) -> None:
|
||||
raise BoundaryError(detail[-1] if detail else "That key could not be removed.")
|
||||
|
||||
|
||||
def resolve_new_key(name: str) -> Path:
|
||||
"""Where a key by this name would go, or a refusal.
|
||||
|
||||
Refuses anything that already exists -- both halves, because a stray .pub
|
||||
beside no private key still means ssh-keygen would be asked to overwrite,
|
||||
and this tool does not overwrite keys. Losing a private key is not
|
||||
recoverable and a settings page is the wrong place to learn that.
|
||||
"""
|
||||
if not KEY_NAME.match(name or "") or name in (".", ".."):
|
||||
raise BoundaryError(
|
||||
"A key name can use letters, numbers, dots, dashes and underscores.")
|
||||
if name.endswith(".pub"):
|
||||
raise BoundaryError("Name the key itself, not its public half.")
|
||||
|
||||
try:
|
||||
SSH_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
directory = SSH_DIR.resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise BoundaryError("The SSH directory could not be opened.") from error
|
||||
if not directory.is_dir():
|
||||
raise BoundaryError("The SSH directory is not a directory.")
|
||||
|
||||
target = directory / name
|
||||
if target.parent.resolve(strict=True) != directory:
|
||||
raise BoundaryError("That key is not in the SSH directory.")
|
||||
|
||||
public = Path(str(target) + ".pub")
|
||||
for candidate in (target, public):
|
||||
if candidate.exists() or candidate.is_symlink():
|
||||
raise BoundaryError(f"{candidate.name} already exists, so nothing was written.")
|
||||
return target
|
||||
|
||||
|
||||
def terminal_environment() -> dict:
|
||||
"""The environment ssh-keygen must run in to ask its question at the terminal.
|
||||
|
||||
This desktop sets SSH_ASKPASS_REQUIRE=prefer, which makes ssh-keygen open a
|
||||
graphical passphrase dialog even when it has a perfectly good terminal in
|
||||
front of it -- so the first version of this hung, waiting for a prompt that
|
||||
had been drawn on somebody's screen instead. The terminal is supplied
|
||||
deliberately here, so the askpass route is switched off just as deliberately.
|
||||
|
||||
LC_ALL is pinned so the prompts read below are the ones OpenSSH ships.
|
||||
"""
|
||||
environment = dict(os.environ)
|
||||
environment["SSH_ASKPASS_REQUIRE"] = "never"
|
||||
environment["LC_ALL"] = "C"
|
||||
for name in ("SSH_ASKPASS", "DISPLAY", "WAYLAND_DISPLAY"):
|
||||
environment.pop(name, None)
|
||||
return environment
|
||||
|
||||
|
||||
def type_at_keygen(command: list[str], passphrase: str) -> None:
|
||||
"""Run ssh-keygen on a pseudo-terminal and answer its prompts.
|
||||
|
||||
ssh-keygen reads a passphrase through readpassphrase(), which opens
|
||||
/dev/tty: a pipe on standard input is not read at all, which is why this
|
||||
needs a terminal rather than a simpler subprocess call. The passphrase is
|
||||
written to the terminal's master side, exactly as typing it would, and
|
||||
ssh-keygen asks twice, so it is typed twice.
|
||||
|
||||
Nothing about the passphrase is kept. It is not written to disk, does not
|
||||
appear in the command, and is scrubbed out of anything reported back in case
|
||||
a future ssh-keygen ever echoes it.
|
||||
"""
|
||||
try:
|
||||
pid, master = pty.fork()
|
||||
except OSError as error:
|
||||
raise BoundaryError("A terminal could not be opened for ssh-keygen.") from error
|
||||
|
||||
if pid == 0:
|
||||
# The child. Nothing may return from here into the parent's code holding
|
||||
# the parent's file descriptors, so a failed exec exits outright.
|
||||
try:
|
||||
os.execvpe(command[0], command, terminal_environment())
|
||||
except OSError:
|
||||
pass
|
||||
os._exit(127)
|
||||
|
||||
typed = 0
|
||||
pending = ""
|
||||
transcript = ""
|
||||
problem = ""
|
||||
deadline = time.monotonic() + KEYGEN_TIMEOUT_SECONDS
|
||||
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
problem = "ssh-keygen did not finish."
|
||||
break
|
||||
try:
|
||||
ready, _, _ = select.select([master], [], [], min(remaining, 1.0))
|
||||
except OSError:
|
||||
break
|
||||
if not ready:
|
||||
continue
|
||||
try:
|
||||
chunk = os.read(master, 4096)
|
||||
except OSError:
|
||||
# EIO on Linux: the child closed the terminal, which is how a pty
|
||||
# reports end of output.
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
text = chunk.decode("utf-8", errors="replace")
|
||||
pending += text
|
||||
transcript += text
|
||||
lowered = pending.lower()
|
||||
|
||||
# Matched on ssh-keygen's whole prompt rather than one word of it: the
|
||||
# passphrase prompt quotes the key's path back, and "overwrite" is a
|
||||
# perfectly legal key name.
|
||||
if "overwrite (y/n)" in lowered:
|
||||
# Unreachable in practice -- an existing key is refused before this
|
||||
# runs -- but answering anything other than "no" here would destroy
|
||||
# a key, so it answers no and stops.
|
||||
os.write(master, b"n\n")
|
||||
problem = "That key already exists, so nothing was written."
|
||||
break
|
||||
if "passphrase is too short" in lowered:
|
||||
problem = (f"ssh-keygen wants a passphrase of at least "
|
||||
f"{MINIMUM_PASSPHRASE} characters.")
|
||||
break
|
||||
if "passphrases do not match" in lowered:
|
||||
problem = "Those passphrases did not match."
|
||||
break
|
||||
if typed < 2 and "passphrase" in lowered and pending.rstrip().endswith(":"):
|
||||
os.write(master, passphrase.encode("utf-8") + b"\n")
|
||||
typed += 1
|
||||
pending = ""
|
||||
|
||||
if problem:
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.close(master)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
_, status = os.waitpid(pid, 0)
|
||||
except OSError:
|
||||
status = 0
|
||||
|
||||
if problem:
|
||||
raise BoundaryError(problem)
|
||||
if not (os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0):
|
||||
detail = scrubbed(transcript, passphrase)
|
||||
last = [line.strip() for line in detail.splitlines() if line.strip()]
|
||||
raise BoundaryError(last[-1] if last else "ssh-keygen could not create that key.")
|
||||
|
||||
|
||||
def scrubbed(text: str, secret: str) -> str:
|
||||
return text.replace(secret, "********") if secret else text
|
||||
|
||||
|
||||
def generate(name: str, comment: str, passphrase: str, allow_empty: bool) -> None:
|
||||
"""Create an ed25519 key.
|
||||
|
||||
ed25519 and nothing else: it is the key everything current accepts, the
|
||||
choice between it and RSA is not one a settings page should make someone
|
||||
make, and offering a size field for a curve that has one size would be
|
||||
theatre.
|
||||
"""
|
||||
if not KEY_COMMENT.match(comment or ""):
|
||||
raise BoundaryError("A comment cannot contain control characters.")
|
||||
if "\n" in passphrase or "\r" in passphrase:
|
||||
raise BoundaryError("A passphrase cannot contain a line break.")
|
||||
if passphrase == "":
|
||||
if not allow_empty:
|
||||
raise BoundaryError(
|
||||
"A passphrase is required. A key with none is usable by anyone "
|
||||
"who reads the file.")
|
||||
elif len(passphrase) < MINIMUM_PASSPHRASE:
|
||||
raise BoundaryError(
|
||||
f"ssh-keygen wants a passphrase of at least {MINIMUM_PASSPHRASE} characters.")
|
||||
|
||||
if shutil.which("ssh-keygen") is None:
|
||||
raise BoundaryError("ssh-keygen is not installed.")
|
||||
|
||||
target = resolve_new_key(name)
|
||||
command = ["ssh-keygen", "-t", "ed25519", "-f", str(target)]
|
||||
if comment:
|
||||
command += ["-C", comment]
|
||||
type_at_keygen(command, passphrase)
|
||||
|
||||
if not target.is_file() or not Path(str(target) + ".pub").is_file():
|
||||
raise BoundaryError("ssh-keygen finished but the key is not there.")
|
||||
|
||||
|
||||
def fix_permissions(name: str) -> None:
|
||||
"""Make a private key readable only by its owner.
|
||||
|
||||
ssh refuses to use a key other people can read, and says so in a message
|
||||
most people meet for the first time at the worst moment. The path is
|
||||
resolved and compared against the SSH directory first, so a name that is a
|
||||
link to something elsewhere is refused rather than followed -- this changes
|
||||
a file's mode, and that is not a thing to do to a file you have not checked.
|
||||
"""
|
||||
if not KEY_NAME.match(name or "") or name in (".", ".."):
|
||||
raise BoundaryError("That is not a key name.")
|
||||
key = resolve_key(str(SSH_DIR / name))
|
||||
try:
|
||||
os.chmod(key, 0o600)
|
||||
except OSError as error:
|
||||
raise BoundaryError("That key's permissions could not be changed.") from error
|
||||
|
||||
|
||||
def forget_host(host: str) -> None:
|
||||
"""Drop a host's keys from known_hosts.
|
||||
|
||||
@@ -271,13 +512,42 @@ def forget_host(host: str) -> None:
|
||||
raise BoundaryError(detail[-1] if detail else "That host could not be removed.")
|
||||
|
||||
|
||||
def read_passphrase() -> str:
|
||||
"""The passphrase, from standard input, and only from there.
|
||||
|
||||
One trailing newline is dropped because the caller writes one to end the
|
||||
line; anything else is taken literally, including spaces, because a
|
||||
passphrase is allowed to end in one.
|
||||
"""
|
||||
try:
|
||||
raw = sys.stdin.buffer.read().decode("utf-8")
|
||||
except (OSError, UnicodeDecodeError) as error:
|
||||
raise BoundaryError("The passphrase could not be read.") from error
|
||||
if raw.endswith("\n"):
|
||||
raw = raw[:-1]
|
||||
if raw.endswith("\r"):
|
||||
raw = raw[:-1]
|
||||
return raw
|
||||
|
||||
|
||||
def main(arguments: list[str]) -> int:
|
||||
try:
|
||||
if arguments == ["snapshot"]:
|
||||
print(json.dumps(snapshot(), separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
if len(arguments) == 2 and arguments[0] == "agent-add":
|
||||
if arguments and arguments[0] == "generate":
|
||||
# The flag exists so a machine-shaped caller can ask for a key with
|
||||
# no passphrase deliberately. Panama's own page never passes it: it
|
||||
# requires a passphrase and validates that both fields match.
|
||||
allow_empty = "--no-passphrase" in arguments[1:]
|
||||
rest = [value for value in arguments[1:] if value != "--no-passphrase"]
|
||||
if len(rest) != 2:
|
||||
raise BoundaryError("Usage: panama-ssh-keys generate NAME COMMENT")
|
||||
generate(rest[0], rest[1], read_passphrase(), allow_empty)
|
||||
elif len(arguments) == 2 and arguments[0] == "fix-permissions":
|
||||
fix_permissions(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "agent-add":
|
||||
agent_add(arguments[1])
|
||||
elif len(arguments) == 2 and arguments[0] == "agent-remove":
|
||||
agent_remove(arguments[1])
|
||||
@@ -285,7 +555,8 @@ def main(arguments: list[str]) -> int:
|
||||
forget_host(arguments[1])
|
||||
else:
|
||||
raise BoundaryError(
|
||||
"Usage: panama-ssh-keys snapshot | agent-add PATH | agent-remove PATH | "
|
||||
"Usage: panama-ssh-keys snapshot | generate NAME COMMENT | "
|
||||
"fix-permissions NAME | agent-add PATH | agent-remove PATH | "
|
||||
"forget-host HOST")
|
||||
except BoundaryError as error:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user