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
- Signing mechanism: ssh-keygen -Y sign/verify (reuses existing tooling on every host, Debian and FreeBSD both; namespace-scoped signatures; allowed_signers file is structurally identical to authorized_keys).
- Multiple signer principals are fine — an 'ordinary' signer for now, a separate higher-trust principal for root-targeted deliveries, deferred until actually needed.
- Daemon privilege model: per-target-user systemd template unit (uucp-keyadd@.service, User=%i), not a single root daemon and not ACL grants to one shared service account. No privilege escalation anywhere; a bug in the daemon is bounded to exactly the one account that instance serves; extending to a new user (including root, eventually) is one systemctl enable --now uucp-keyadd@<user> — a visible, deliberate action, not a config toggle.
- The fixed uuxqt-invoked command (runs as the uucp user) does nothing but relay stdin to the correct per-user socket — all real logic (signature verification, validation, the authorized_keys write) lives in the per-user daemon, never in the UUCP-facing command itself.
- Target user is named inside the signed/validated envelope, not passed as a uux command-line argument — Taylor UUCP's commands allow-list is not known to validate argument values, only the program path.
- authorized_keys is append-only, never rewritten wholesale: backup to a timestamped file before every mutation, then write via temp-file + atomic rename (same pattern as PROGRAMMING_RULES/reversible-remote-test-cycles). Existing keys must keep working throughout.
- Security context: all UUCP legs except pomelo↔radagast are mutual-TLS (stunnel verify=2 against the shared CA); radagast is a local virtio-serial link with no network exposure, so its weaker auth factor (UUCP login/password only) is offset by having no network attack surface at all.
Phase 1 — signing infra
- 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.
- 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.
- 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)
- /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.
- 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).
- Enable the first instance: systemctl enable --now uucp-keyadd@john.service.
- 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>]
- 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).
- 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).
- Build the envelope {target_user, principal, pubkey, signature} as JSON.
- 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.
- Print a receipt: local private-key path, signer used, target host, timestamp.
Phase 4 — proof on pomelo/john
- Run generate_and_distribute_key john --host pomelo end to end, through the real uux/uuxqt/commands pipeline (not a shortcut), even though pomelo is both sender and target for this proof.
- Confirm: new line appended, existing lines in ~john/.ssh/authorized_keys untouched, a timestamped backup exists, a log entry was written.
- Confirm idempotency: resending the same key is a no-op, not a duplicate line.
- Confirm rejection: a deliberately corrupted or unsigned payload is refused and logged, not silently dropped.
- Only after this passes: extend to kelp, gravlax, radagast — out of scope for this plan, next phase.
Deferred (explicitly out of scope here)
- Creating the root-tier signer principal and any uucp-keyadd@root instance.
- Extending past pomelo/john to the other three hosts.
- Moving the signing private key off pomelo to a dedicated off-mesh machine.
- Generalising uucp-keyadd into uucp-exec for signed arbitrary execution (typed operation envelope, replay/sequence protection) — see the design discussion that preceded this plan.
Defaults chosen without asking (flagged here for review)
- Daemon and wrapper implemented in Python, matching the rest of this stack (gdata-server, popit3, ansible controller scripts).
- Socket path convention: /run/uucp-keyadd/<user>.sock.
- allowed_signers path: /etc/uucp-keyadd/allowed_signers.
- Local issued-key storage: ~/keys/issued/<target_user>/<date>-<host>/.