Skip to content

Wave7t/sshkeyd

Repository files navigation

sshkeyd

Go Version License: MIT CI

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.


Features

  • 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.txt and rejected even if still chain-valid.
  • Atomic, race-free writes — process-wide mutex + temp-file fsync + rename; a crash never leaves a half-written authorized_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 under home_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 unitRestart=always, NoNewPrivileges, ProtectSystem, PrivateTmp; compatible with systemd 219.

Architecture

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).

Go project layout

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)

Quick start

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/sshkeyd

Configuration

config.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.

Deployment

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.

1. Build and ship the binary

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.toml

2. Generate the CA and certificates

Run 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.

3. Enable the service

On the target host (as root):

systemctl daemon-reload && systemctl enable --now sshkeyd

The 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.

CA generation (local; requires cfssl)

./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

OpenSSL fallback (for hosts without cfssl; tested on OpenSSL 1.0.2k, RSA 2048)

# 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>

Revoking a client certificate

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 sshkeyd

The revocation list is loaded once at startup (mtls.NewServerConfig -> LoadRevokedSerials); there is currently no SIGHUP / hot-reload mechanism. After editing the list you must restart, 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.

Rotation

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.

Client contract

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.

Security model

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_username injection / path traversal / cross-user writes are prevented by resolving the real home directory via getpwnam (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 ~/.ssh owned 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.

Contributing

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.

Changelog

See CHANGELOG.md for release history (Keep a Changelog format).

License

Released under the MIT License. Copyright (c) 2026 Teng Wan.

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages