sshkeyd is a small, privileged daemon that writes SSH authorized_keys on
behalf of an unprivileged web front-end. It exposes a minimal HTTP API over
mutual TLS so a partner service (e.g. an internal portal running in a
container without access to /home or root) can add, remove, and list a user's
authorized SSH keys safely, atomically, and with full audit.
It is intentionally single-purpose and stateless: it never calls a shell,
never connects to a database, and treats the authorized_keys file as the only
source of truth. Defense-in-depth is provided by re-validating both the public
key and the target ssh_username server-side, independently of whatever the
client already checked.
The full design (architecture, threat model, CA flow, systemd unit) lives in the design spec. This README summarizes it and focuses on build, deploy, and operation.
- mTLS with a private CA — only client certificates signed by the configured CA are accepted; everything else is rejected at the TLS layer.
- Serial-number revocation — revoked client certificates are listed by SN in
revoked-serials.txtand rejected even if still chain-valid. - Atomic, race-free writes — process-wide mutex + temp-file
fsync+rename; a crash never leaves a half-writtenauthorized_keys. - Dual validation of public key and ssh_username — key type whitelist,
options-prefix rejection (no
command=/from=injection), weak-algorithm rejection (DSS, RSA < 2048), caller-supplied fingerprint cross-check, and a safe username check (regex →getpwnam→ uid floor → not root → home underhome_root→ home owned by the target uid). - JSON-line audit log — one append-only line per operation (timestamp, op, ssh_username, client cert CN + serial, fingerprint, result); no full keys.
- Single static binary — cross-compiled, drop-in deploy; the target host needs no Go toolchain, no cfssl, and no CA private key.
- systemd unit —
Restart=always,NoNewPrivileges,ProtectSystem,PrivateTmp; compatible with systemd 219.
build host (Mac) target host (root, systemd)
───────────── ─────────────────────────────────────
cfssl / openssl /etc/sshkeyd/
private CA + server/client certs ca.pem (CA cert, trusted by daemon)
GOOS=linux GOARCH=amd64 go build server.crt / server.key (server cert, 0600 root)
-> single binary revoked-serials.txt (revoked SN blacklist)
│ /usr/local/bin/sshkeyd (binary)
├──scp──────────────────▶ /etc/sshkeyd/config.toml
│ /var/log/sshkeyd.log (audit JSON lines)
│ /etc/systemd/system/sshkeyd.service
│ │ listens on <host>:9222 (mTLS, not public)
│ ▼
client (e.g. portal container) ◀──mTLS── sshkeyd
The daemon owns only the server private key; the CA private key never leaves the build host, and the client private key lives with the client service (e.g. injected as a Docker secret).
sshkeyd/
cmd/sshkeyd/main.go # entrypoint: config -> mTLS server
internal/
config/ # config.toml loader (TOML tags)
mtls/ # server TLS config + revoked-SN enforcement
api/ # POST /v1/keys/{add,remove,list} handlers
sshkey/ # public-key parse + ssh_username validation + atomic store
audit/ # append-only JSON-line audit log
deploy/
sshkeyd.service # systemd unit (systemd 219 compatible)
config.example.toml
ca/ # local CA generation (cfssl, with openssl fallback)
Requirements: Go 1.26+ (build host only; the target needs no Go).
# Build (native)
go build ./...
# Test
go test ./...
# Cross-compile a Linux/amd64 binary for the target host
GOOS=linux GOARCH=amd64 go build -o sshkeyd ./cmd/sshkeydconfig.toml mirrors the Config struct in
internal/config/config.go. See
deploy/config.example.toml:
| Field | Default | Description |
|---|---|---|
listen |
— | Address to listen on. Use the host/container-gateway IP reachable by the client, port 9222; not public. |
ca_cert |
— | CA certificate PEM that signs client certs (the daemon's trust root). |
server_cert |
— | Server certificate PEM (CN=sshkeyd). |
server_key |
— | Server private key PEM (0600, root). |
revoked_serials |
— | Newline-separated decimal certificate serials to reject. Missing/empty = no revocations. |
home_root |
— | Root directory of user homes (e.g. /home). |
min_uid |
1000 | UID floor; a target user below this (or 0/root) is rejected. 0 falls back to 1000. |
audit_log |
— | Path to the append-only JSON-line audit log. |
sshkeyd runs as root on the target host (it must write into every user's
~/.ssh). Build on a host with Go, then ship the binary and config.
GOOS=linux GOARCH=amd64 go build -o sshkeyd ./cmd/sshkeyd
scp sshkeyd monitor@design:/usr/local/bin/sshkeyd
scp deploy/sshkeyd.service monitor@design:/tmp/ # root installs to /etc/systemd/system/
scp deploy/config.example.toml monitor@design:/etc/sshkeyd/config.tomlRun locally on the build host (see CA generation below). Upload
ca.pem and server.{prt,key} to /etc/sshkeyd/; inject the client cert into
the partner service. Never put ca-key.pem on the target host.
On the target host (as root):
systemctl daemon-reload && systemctl enable --now sshkeydThe listen address must be reachable by the client but not exposed to the
public internet. The example uses 172.17.0.1:9222 (the Docker bridge gateway),
which a containerized portal reaches via the host's docker0.
./deploy/ca/gen.sh ./ca-out
# Upload ca.pem + server.{pem,key} to design /etc/sshkeyd/
# client.{pem,key} go into the portal container (docker secret)
# ca-key.pem stays local — never upload it to the target host# CA (self-signed, 10 years)
openssl req -x509 -newkey rsa:2048 -keyout ca-key.pem -out ca.pem -days 3650 -nodes \
-subj "/CN=sshkeyd-ca"
# Server certificate (CSR -> CA-signed, 10 years, CN=sshkeyd)
openssl req -newkey rsa:2048 -keyout server.key -out server.csr -nodes -subj "/CN=sshkeyd"
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out server.crt -days 3650
# Client certificate (90 days, CN=homepage)
openssl req -newkey rsa:2048 -keyout client.key -out client.csr -nodes -subj "/CN=homepage"
openssl x509 -req -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out client.pem -days 90
# Read the client cert SN for revocation (decimal):
openssl x509 -in client.pem -noout -serial | cut -d= -f2 | tr '[:upper:]' '[:lower:]'
# The SN above is hex; revoked-serials.txt expects decimal. Convert with:
# printf '%d' 0x<HEX>Append the revoked certificate's SN (in decimal) to the target's
/etc/sshkeyd/revoked-serials.txt, then restart the service so the new
revocation list takes effect:
systemctl restart sshkeydThe revocation list is loaded once at startup (
mtls.NewServerConfig->LoadRevokedSerials); there is currently no SIGHUP / hot-reload mechanism. After editing the list you mustrestart, not just reload. A missing or empty file is treated as "no revocations" (the daemon can boot before the file exists). Lines starting with#and blank lines are ignored.
Client certificates expire after 90 days. Before expiry, re-sign locally
(./deploy/ca/gen.sh or the OpenSSL flow above) and push the new client cert to
the partner service (for a Dockerized portal: update the secret and restart the
container). The CA and server certificate do not change. To invalidate an old
client cert immediately after rotation, follow the revocation flow above.
HTTP/1.1 over mTLS, JSON bodies. Three endpoints:
POST /v1/keys/add—{ssh_username, key_type, pubkey_b64, fingerprint, label, request_id}POST /v1/keys/remove—{ssh_username, fingerprint}POST /v1/keys/list—{ssh_username}
Response shape: {ok, error_code, error_message, fingerprint} (list also
returns keys: [{type, comment, fingerprint}, ...]).
Error codes: INVALID_USERNAME, USER_NOT_FOUND, INVALID_PUBKEY,
KEY_EXISTS, KEY_NOT_FOUND, BAD_CERT, INTERNAL. The idempotent conditions
KEY_EXISTS (on add) and KEY_NOT_FOUND (on remove) are returned with
ok: true so a client retrying an already-applied operation sees success.
The design principle is default-deny, explicitly authorized, least privilege, fully audited, with independent server-side re-validation of everything the client claims. Highlights (see the design spec for the full threat table):
- Forged / unauthorized calls are stopped at the TLS layer: only client certs from the configured CA, not on the SN blacklist, and not expired, are accepted.
ssh_usernameinjection / path traversal / cross-user writes are prevented by resolving the real home directory viagetpwnam(never string concatenation) and enforcing a uid floor, non-root, home-under-home_root, and home-owned-by-uid.- Public-key options injection (
command=,from=) is rejected by requiring the first token to be a whitelisted key type. - Write races / crash half-files are eliminated by mutex + temp-file
fsync+rename. - Ownership / permission mistakes (e.g. a
~/.sshowned by the wrong user) cause a hard reject with an audit entry — sshkeyd never silently "fixes" them. - Privilege escalation / command injection is impossible: sshkeyd uses only
Go file APIs and never
execs a shell or builds a command line. - Private key exposure is minimized: the daemon holds only the server key; the CA key never leaves the build host; the client key lives with the client.
Contributions are welcome. See CONTRIBUTING.md for the workflow (fork → branch → test → PR), and CODE_OF_CONDUCT.md for the standards of behavior in the project community.
See CHANGELOG.md for release history (Keep a Changelog format).
Released under the MIT License. Copyright (c) 2026 Teng Wan.