#!/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 on provisioner '${PROVISIONER}' (BENCH-VERIFY the flag)" incca step ca provisioner update "${PROVISIONER}" --ssh 2>/dev/null \ && restart_ca \ || echo " NOTE: could not auto-enable SSH on '${PROVISIONER}' — enable it by hand (step ca provisioner update ${PROVISIONER} --ssh) and restart step-ca." # --- 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) --------------- echo "==> exporting SSH host CA public key -> $PKI/ssh_host_ca.pub" incca cp /home/step/secrets/ssh_host_ca_key.pub /export/ssh_host_ca.pub 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."