Two-layer structure: Sources (raw notes) + Wiki (compile output) Four domains: Dev (40), Venture (3), Homelab (23), Reference (0) Includes CLAUDE.md spec, index pages at all levels, compile log Co-Authored-By: Lovebug <lovebug@herbylab.dev>
6.5 KiB
6.5 KiB
| created | path | project | status | tags | type | updated | |||
|---|---|---|---|---|---|---|---|---|---|
| 2026-05-08 | Sources/Homelab | sshkm | active |
|
project-plan | 2026-05-08 |
sshkm — local SSH key manager
Goal
A small Python CLI that handles the day-to-day SSH key lifecycle (generate, register, deploy, list, remove) without the ceremony of a full CA. Solves the ~/.ssh/ graveyard problem today; designed to coexist with a future step-ca deployment rather than be replaced by it.
Design principles
- Capability/authorization split. Generation is local-only file I/O — safe for agents to drive. Deployment requires interactive auth (target server password / agent unlock) — human-only by design.
- Explicit state transitions. A key is
floating,bound, ordeployed. Every transition is its own command. No silent rebinds. - Plain text everything. Markdown ledger, plain
~/.ssh/configblocks, no SQLite, no daemon. Inspect withcat, edit at your own risk. - Coexists with future CA. This tool manages the long-lived keys that survive the cleanup; the CA project will replace human and host identities, leaving sshkm for the truly static service-key tier.
Architecture
- Typer + Rich for the CLI surface
ssh-keygen/ssh-copy-idas the heavy lifters (no paramiko, no shell-out reinvention)~/.ssh/managed/as sshkm's territory: keys, ledger, never touches anything outside~/.ssh/configgets# managed-by-sshkm: <label>blocks the tool can find and edit- Ledger is markdown table at
~/.ssh/managed/ledger.md, symlinkable into Obsidian
Key lifecycle
floating ──bind──▶ bound ──push──▶ deployed
▲ │
└────rebind──────┘ (--rebind flag, v1 errors out)
- floating — keypair exists, no target declared yet (LXC isn't built yet, etc.)
- bound — target host/user declared, ssh config block written, key not yet on target
- deployed — ssh-copy-id succeeded, connection test passed
Commands (v1)
sshkm generate -l <label> [-h <host> -u <user> -a <alias> -p <purpose>]
# No host/user → floating. With host/user → bound. Either way, key is created.
sshkm push <label> [-h <host> -u <user> -a <alias>]
# Floating: requires host/user, transitions to deployed.
# Bound: requires nothing, transitions to deployed.
# Deployed: errors out (use --rebind in v2).
sshkm list [--json]
# Shows label, status, alias, host, user, created, purpose.
sshkm show <label> [--json]
# Full detail including key paths and fingerprint.
sshkm remove <label> [--force]
# Local-only: removes config block, ledger row, key files.
# Warns that remote authorized_keys is untouched.
sshkm import <key-path> -l <label> [-h <host> -u <user> --move]
# Adopts existing on-disk keys. Marks as deployed (assumes already in use).
Agent boundary
generate,list,show,import— agent-friendly.--json+--yesflags everywhere.push,remove— human-only. Belt-and-suspenders enforcement:sys.stdin.isatty()check — refuses to run in non-interactive shellsSSHKM_AGENT=1env-var bypass for legitimate scripted use, paired with logging- Confirmation prompt by default,
--yesonly allowed when env-var set
Phases
Phase 1 — Core CLI
- 1.1 Project skeleton:
pyproject.toml, Typer + Rich,uv tool installtarget - 1.2
models.py—ManagedKeydataclass withStatusenum (floating,bound,deployed) - 1.3
paths.py,keygen.py,deploy.py— filesystem and subprocess wrappers - 1.4
config.py— render/append/remove blocks marked with# managed-by-sshkm: - 1.5
ledger.py— append/read/update rows in markdown table
Phase 2 — Commands
- 2.1
generate— handle both floating and bound modes - 2.2
push— state-aware: validates current status, accepts host/user only when floating - 2.3
list/show— Rich table for humans,--jsonfor machines - 2.4
remove— local cleanup with clear messaging about remote leftovers - 2.5
import— adopt existing keys, status defaults todeployed
Phase 3 — Agent boundary
- 3.1 TTY check + env-var override pattern for
pushandremove - 3.2
--jsonoutput on read commands and ongenerate(so agents can chain) - 3.3
--yesflag, gated on env-var - 3.4 Document the boundary in README — explicit "what agents can/can't do"
Phase 4 — Real-world burn-in
- 4.1 Install on tower via
uv tool install - 4.2 Use it for the next 3–5 real keys you'd otherwise create manually
- 4.3 Note friction points in a session note
- 4.4 Decide what (if anything) goes into v2
v2 candidates (not in v1)
rotate <label>— generate fresh key, push, retire old one (needs remote cleanup)--rebindflag on push — explicitly allow same key on multiple hosts--purge-remoteflag on remove — SSH in with the key and yank its own authorized_keys line- Passphrase support — only matters once you're moving keys into KeePassXC's agent
auditcommand — diff ledger against actual~/.ssh/managed/contents, find orphans
Non-goals
- Replacing
ssh-agent. KeePassXC handles that separately. - Replacing the future CA. This tool manages static keys; the CA will issue ephemeral certs.
- Managing remote
authorized_keysfiles as the source of truth. We touch the remote exactly once (push), never again. - Cross-machine sync. The ledger is per-machine. If you want a unified view, that's a future MCP-or-script job, not this tool's concern.
Success criteria
~/.ssh/on the tower has onlyconfig,known_hosts, andmanaged/after the cleanup phase- New key + new server flow takes one command (bound generate + push) instead of the manual dance
- Every key in
~/.ssh/managed/has a corresponding ledger row — no orphans - An agent driving Claude Code can prep a key for you without touching push
Interaction with the CA project
When step-ca lands, this tool's role contracts:
- Human keys → migrate to OIDC-issued certs, sshkm becomes irrelevant for them
- Host keys → migrate to host certs, sshkm never managed these anyway
- Service keys with no good cert story → stay in sshkm
So the CA project doesn't deprecate sshkm, it shrinks its surface to exactly the tier where short-lived certs don't fit. Good outcome either way.