UUCP SSH Key Distribution — Plan

Goal: distribute new SSH public keys to remote hosts' authorized_keys over the existing UUCP-over-TLS mesh, gated by ssh-keygen -Y sign/-Y verify rather than by which UUCP command name is on the allow-list. Generalises later to signed arbitrary execution (not in scope for this plan — see Deferred, below).

Decisions already made

Phase 1 — signing infra

  1. Generate the 'ordinary' signer keypair (passphrase-protected): ssh-keygen -t ed25519 -f ~/.ssh/uucp_signing_key -C "uucp-payload-signing". For this proof, generated on pomelo for convenience; should migrate to an off-mesh machine before any root-targeted use.
  2. Build allowed_signers: printf 'john-ordinary-signer %s\n' "$(cat ~/.ssh/uucp_signing_key.pub)" > /etc/uucp-keyadd/allowed_signers. One principal line per trusted signer; a root-tier principal is added here later, not now.
  3. Namespace for this operation type is fixed as uucp-keyadd — the daemon always verifies with -n uucp-keyadd regardless of what a sender claims, so a signature can't be repurposed for a different operation even if one existed.

Phase 2 — receiving side (pomelo, proof account: john)

  1. /usr/local/libexec/uucp-keyadd — fixed, trivial, invoked by uuxqt as the uucp user. Reads the envelope on stdin, extracts the target_user field, connects to /run/uucp-keyadd/<target_user>.sock, forwards the envelope, relays the daemon's ack/nak back to uuxqt's exit status. No parsing of key content happens here.
  2. uucp-keyadd@.service — systemd template, User=%i Group=%i, listens on /run/uucp-keyadd/%i.sock. Does: parse envelope (length-prefixed JSON) -> ssh-keygen -Y verify against /etc/uucp-keyadd/allowed_signers with -I <claimed principal> -n uucp-keyadd -> reject anything that isn't a single well-formed authorized_keys line -> dedupe against existing keys -> backup + atomic-append to the service account's own ~/.ssh/authorized_keys (correct automatically since User=%i makes $HOME resolve via getpwnam for that exact name, matching how sshd itself resolves AuthorizedKeysFile) -> log (timestamp, claimed principal, key fingerprint, result).
  3. Enable the first instance: systemctl enable --now uucp-keyadd@john.service.
  4. Add 'commands rmail uucp-keyadd' to the Defaults block in /etc/uucp/sys. CAUTION: pomelo's sys file is hand-maintained outside the Ansible role (see the documented gap in uucp/handover-next-actions — a bare uucp.yml run with no --limit would delete the hand-maintained radagast stanza), so this edit must be applied by hand on pomelo, never via a blind Ansible run.

Phase 3 — sending-side script

generate_and_distribute_key <target_user> [--signer <principal>] [--host <uucp-system>]

  1. ssh-keygen for a new distribution keypair, passphrase-protected, stored locally at ~/keys/issued/<target_user>/<date>-<host>/ (durable local record of what's been issued, to whom, when).
  2. ssh-keygen -Y sign -n uucp-keyadd with the chosen signer (default: the single 'ordinary' principal; the script must refuse to silently default to a root-tier signer once one exists — it must always be named explicitly via --signer).
  3. Build the envelope {target_user, principal, pubkey, signature} as JSON.
  4. uux - <host>!uucp-keyadd with the envelope piped to stdin. Default host: pomelo. One host per invocation for this plan; multi-host batch delivery is a later enhancement if wanted.
  5. Print a receipt: local private-key path, signer used, target host, timestamp.

Phase 4 — proof on pomelo/john

Deferred (explicitly out of scope here)

Defaults chosen without asking (flagged here for review)

created 2026-10-31  ·  tags uucp, ssh, security, automation, plan