#!/usr/bin/env bash # ssh-ca-bootstrap.sh — enable the fleet SSH host CA so booted hosts can get their # per-boot SSH HOST key signed VIA THE ENROL-BROKER (files/ssh-hostcert → # fleet-ssh-hostcert → broker host plane → step-ca), killing per-boot TOFU # (F-02 / POA&M P-17). Run ONCE after bootstrap.sh, from serving/dot1x-bench/. # Pairs with docs/ssh-host-certificate-design.md. # # 1. enable step-ca's SSH CA (generate host+user CA keys; add the ssh block) # 2. enable SSH host-cert issuance on the existing 'enrol' JWK provisioner # (the broker already authenticates to step-ca with it) # 3. issue the broker HOST-PLANE server cert (fleet-signed; the booted host # verifies it against radius-ca.pem) + a root+intermediate client-CA bundle # (verifies the host's TPM identity cert) # 4. export the SSH HOST CA public key (-> the admin @cert-authority line) # # The signing path is BROKER-mediated: step-ca stays behind the single front door; # hosts never reach it directly. `step` runs INSIDE the CA container. Flags vary # across step / step-ca versions — this is a BENCH-VERIFY scaffold. Reads .env. set -euo pipefail cd "$(dirname "$0")/.." [ -f .env ] && . ./.env CA_DNS=${CA_DNS:-ca.fleet.lab} CA_PORT=${CA_PORT:-8443} CA_CONNECT=${CA_CONNECT:-localhost} CA_URL="https://${CA_CONNECT}:${CA_PORT}" PKI=pki PROVISIONER=${PROVISIONER:-enrol} # The DNS name booted hosts dial for the broker host plane (must be in the server # cert SAN and resolve via the fleet dnsmasq). Reuses boot.fleet.lab by default. HOST_PLANE_CN=${HOST_PLANE_CN:-boot.fleet.lab} PW="$PKI/stepca/password" [ -e "$PW" ] || { [ -e "$PKI/stepca/secrets/password" ] && PW="$PKI/stepca/secrets/password"; } [ -r "$PW" ] || { echo "ERROR: CA password $PW missing — run bootstrap.sh first." >&2; exit 1; } CONTAINER_PW="/home/step/${PW#"$PKI/stepca/"}" step() { docker compose exec -T step-ca step "$@"; } incca() { docker compose exec -T step-ca "$@"; } CA_JSON="$PKI/stepca/config/ca.json" [ -f "$CA_JSON" ] || { echo "ERROR: $CA_JSON not found — is step-ca up?" >&2; exit 1; } restart_ca() { docker compose restart step-ca >/dev/null for _ in $(seq 1 50); do curl -fsS -k "$CA_URL/health" >/dev/null 2>&1 && break; sleep 0.2; done } # --- 1. SSH CA keys + ca.json ssh block ------------------------------------- # step-ca signs SSH certs with dedicated SSH CA keys (separate from the X.509 # root). `step ca init --ssh` creates them at init; to add them to an already- # initialised CA we generate two EC keypairs and reference them in ca.json.ssh. # BENCH-VERIFY: key format/password expectations vary by version. echo "==> generating SSH host/user CA keys (if absent)" for kind in host user; do if ! incca test -f "/home/step/secrets/ssh_${kind}_ca_key"; then incca step crypto keypair \ "/home/step/secrets/ssh_${kind}_ca_key.pub" \ "/home/step/secrets/ssh_${kind}_ca_key" \ --kty EC --curve P-256 --no-password --insecure --force fi done echo "==> patching ca.json ssh block (idempotent)" if python3 - "$CA_JSON" <<'PY' import json,sys p=sys.argv[1] c=json.load(open(p)) want={"hostKey":"/home/step/secrets/ssh_host_ca_key", "userKey":"/home/step/secrets/ssh_user_ca_key"} if c.get("ssh")==want: sys.exit(1) # already set — no restart needed c["ssh"]=want json.dump(c,open(p,"w"),indent=2) PY then echo " ssh block added; restarting step-ca"; restart_ca else echo " ssh block already present" fi # --- 2. enable SSH host certs on the 'enrol' provisioner --------------------- # The broker authenticates to step-ca with this JWK provisioner (signer.go). It # must be allowed to issue SSH HOST certs. BENCH-VERIFY the exact subcommand/flag # on your step version (some use `provisioner update --ssh`). echo "==> enabling SSH host-cert issuance on provisioner '${PROVISIONER}'" # FAIL LOUDLY: a swallowed failure here leaves enableSSHCA=false, and every # `step ssh certificate` at runtime dies with a 502 whose cause the operator # can't see (bench 2026-07-11). If your `step` uses a different flag, fix it and # re-run rather than shipping a half-configured CA. if ! incca step ca provisioner update "${PROVISIONER}" --ssh; then echo "ERROR: 'step ca provisioner update ${PROVISIONER} --ssh' failed. Your step" >&2 echo " version may use a different flag — check: step ca provisioner update --help" >&2 echo " Aborting so SSH host-cert signing isn't silently left disabled." >&2 exit 1 fi restart_ca # Verify it actually took — the CA only issues SSH host certs if enableSSHCA=true. if ! python3 - "$CA_JSON" "$PROVISIONER" <<'PY' import json,sys c=json.load(open(sys.argv[1])); name=sys.argv[2] for p in c["authority"]["provisioners"]: if p.get("name")==name and p.get("claims",{}).get("enableSSHCA") is True: sys.exit(0) sys.exit(1) PY then echo "ERROR: provisioner '${PROVISIONER}' still not SSH-enabled (enableSSHCA != true)" >&2 echo " after the update + restart. SSH host-cert signing would fail at runtime; aborting." >&2 exit 1 fi echo " SSH host-cert issuance enabled on '${PROVISIONER}' (enableSSHCA=true)." # --- 3. broker host-plane server cert + fleet client-CA bundle -------------- # The host plane is mTLS: its SERVER cert must be FLEET-signed (the booted host # verifies it against radius-ca.pem), and its CLIENT CA must verify the host's # TPM identity leaf (root + intermediate). echo "==> issuing broker host-plane server cert (CN ${HOST_PLANE_CN})" incca mkdir -p /export/broker step ca certificate "${HOST_PLANE_CN}" \ /export/broker/host-server.crt /export/broker/host-server.key \ --ca-url "$CA_URL" --root /export/root_ca.crt \ --provisioner "${PROVISIONER}" --provisioner-password-file "$CONTAINER_PW" \ --not-after 2160h --force echo "==> building fleet client-CA bundle (root + intermediate) -> $PKI/broker/fleet-client-ca.crt" mkdir -p "$PKI/broker" cat "$PKI/root_ca.crt" > "$PKI/broker/fleet-client-ca.crt" [ -f "$PKI/intermediate_ca.crt" ] && cat "$PKI/intermediate_ca.crt" >> "$PKI/broker/fleet-client-ca.crt" # --- 4. export the SSH host CA public key (admin trust anchor) --------------- # `step crypto keypair` writes the public key in PEM/PKCS8, but a known_hosts # `@cert-authority` line needs the single-line OpenSSH wire format # (`ecdsa-sha2-nistp256 AAAA…`). Emitting the PEM verbatim yields an unparseable # anchor → ssh silently falls back to TOFU (bench 2026-07-11). Convert it. echo "==> exporting SSH host CA public key (OpenSSH format) -> $PKI/ssh_host_ca.pub" incca cp /home/step/secrets/ssh_host_ca_key.pub /export/ssh_host_ca_key.pem command -v ssh-keygen >/dev/null 2>&1 || { echo "ERROR: ssh-keygen not found — cannot convert the CA key to OpenSSH format." >&2 echo " Install openssh-clients; the raw PKCS8 key is at $PKI/ssh_host_ca_key.pem." >&2 exit 1 } ssh-keygen -i -m PKCS8 -f "$PKI/ssh_host_ca_key.pem" > "$PKI/ssh_host_ca.pub" rm -f "$PKI/ssh_host_ca_key.pem" echo echo "Admin rollout — add ONE line to each admin's ~/.ssh/known_hosts (or a system" echo "known_hosts), so every fleet host is trusted via the CA instead of TOFU:" echo echo " @cert-authority *.${CA_DNS#ca.} $(cat "$PKI/ssh_host_ca.pub" 2>/dev/null || echo '')" echo echo "Then enable the broker host plane (dot1x-bench compose already wires" echo "BROKER_HOST_ADDR + the certs issued above) and rebuild the secure-path image." echo "On a booted+enrolled host, ssh-hostcert.timer POSTs its host key to the broker" echo "and installs /etc/ssh/ssh_host_ecdsa_key-cert.pub."