adding ssh host signing
This commit is contained in:
@@ -82,5 +82,15 @@ services:
|
||||
BROKER_ADMIN_CERT: "/pki/broker/server.crt"
|
||||
BROKER_ADMIN_KEY: "/pki/broker/server.key"
|
||||
BROKER_ADMIN_CLIENT_CA: "/pki/broker/admin-ca.crt"
|
||||
# HOST plane (SSH host-cert issuance, F-02/P-17). mTLS on the boot LAN:
|
||||
# FLEET-signed server cert + the fleet CA (root+intermediate) as the client
|
||||
# CA that verifies each host's TPM identity. Certs issued by
|
||||
# step-ca/ssh-ca-bootstrap.sh (run it once after bootstrap.sh). 0.0.0.0 so
|
||||
# booted hosts can reach it — firewall to the boot LAN.
|
||||
BROKER_HOST_ADDR: "0.0.0.0:9444"
|
||||
BROKER_HOST_CERT: "/pki/broker/host-server.crt"
|
||||
BROKER_HOST_KEY: "/pki/broker/host-server.key"
|
||||
BROKER_HOST_CLIENT_CA: "/pki/broker/fleet-client-ca.crt"
|
||||
STEP_SSH_CERT_NOT_AFTER: "720h"
|
||||
volumes:
|
||||
- ./pki:/pki:ro
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
#!/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 <name> --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 '<contents of pki/ssh_host_ca.pub>')"
|
||||
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."
|
||||
@@ -10,7 +10,7 @@ Authoritative design: [`../../docs/tpm-enrolment-design.md`](../../docs/tpm-enro
|
||||
This is **v1** (fingerprint-only OOB assurance; inline empty-NV vehicle on the host
|
||||
side). TPM attestation and the host-side dracut branch are the next phases.
|
||||
|
||||
## Two listeners, by design
|
||||
## Three listeners, by design
|
||||
|
||||
```
|
||||
PUBLIC (plain HTTP, behind the serving nginx /enrol/) UNAUTHENTICATED
|
||||
@@ -23,10 +23,28 @@ ADMIN (mTLS, management network / loopback ONLY) admin client cert
|
||||
GET /pending JSON queue
|
||||
POST /approve/{id} OOB-approve -> step ca sign -> store cert
|
||||
POST /deny/{id} reject
|
||||
|
||||
HOST (mTLS, boot LAN) FLEET host client cert
|
||||
POST /ssh/hostcert {publicKey, principals, keyID} -> {cert}
|
||||
GET /healthz
|
||||
```
|
||||
|
||||
The public side is deliberately tiny and stdlib-only — it is the only thing
|
||||
reachable by un-enrolled devices. The admin side never touches the boot LAN.
|
||||
reachable by un-enrolled devices. The admin side never touches the boot LAN. The
|
||||
**host plane** (F-02/P-17) is the automatic, self-authenticating counterpart of
|
||||
enrolment: an already-enrolled host proves itself with its **TPM identity** as the
|
||||
mTLS client cert (verified to the fleet CA) and the broker has step-ca sign an SSH
|
||||
**host** certificate — no human approval, no CA secret on the host. This keeps
|
||||
step-ca behind the single front door for SSH host certs too. The host plane is
|
||||
**disabled unless `BROKER_HOST_ADDR` is set**. Enable the SSH CA on step-ca +
|
||||
issue its certs with `serving/dot1x-bench/step-ca/ssh-ca-bootstrap.sh`; the client
|
||||
is `files/ssh-hostcert` (docs/ssh-host-certificate-design.md).
|
||||
|
||||
> **Client CA differs by plane:** the ADMIN plane uses a *dedicated admin CA*
|
||||
> (`admin-pki.sh`) — deliberately **not** the fleet root. The HOST plane uses the
|
||||
> **fleet CA** (`BROKER_HOST_CLIENT_CA` = root+intermediate), because host clients
|
||||
> present fleet-signed TPM identities. Its *server* cert must also be fleet-signed
|
||||
> so the booted host verifies it against `radius-ca.pem`.
|
||||
|
||||
## How identity is gated
|
||||
|
||||
@@ -84,6 +102,11 @@ authenticate to the admin plane and approve enrolments (privilege escalation).
|
||||
| `HOSTNQN_BASE` | `nqn.2026-06.org.example.fleet:host-` | hostnqn = base + cert-serial |
|
||||
| `BROKER_ADMIN_CERT` / `_KEY` / `_CLIENT_CA` | — | admin mTLS (required unless insecure) |
|
||||
| `BROKER_ADMIN_INSECURE` | `false` | **dev only**: plaintext admin, no client-cert auth |
|
||||
| `BROKER_HOST_ADDR` | — (disabled) | SSH host-cert plane listener; unset ⇒ off |
|
||||
| `BROKER_HOST_CERT` / `_KEY` | — | host-plane server cert/key (**fleet-signed**) |
|
||||
| `BROKER_HOST_CLIENT_CA` | `STEP_CA_ROOT` | verifies host TPM identities (**fleet** root+intermediate) |
|
||||
| `BROKER_HOST_INSECURE` | `false` | **dev only**: plaintext host plane, no client-cert auth |
|
||||
| `STEP_SSH_CERT_NOT_AFTER` | `720h` | issued SSH host-cert validity |
|
||||
|
||||
Swap `wordlist.txt` for the 2048-word BIP39 list (11 bits/word) for more entropy
|
||||
per word; it just needs to be a power-of-two number of lines.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// host.go — the mTLS HOST plane: enrolled hosts get their per-boot SSH host key
|
||||
// signed by the fleet SSH CA.
|
||||
//
|
||||
// This is the THIRD listener, distinct from the two in main.go:
|
||||
//
|
||||
// HOST (mTLS, boot-LAN reachable) -- fleet-CA host client cert:
|
||||
// POST /ssh/hostcert {publicKey, principals, keyID} -> {cert}
|
||||
//
|
||||
// It is the automatic, self-authenticating counterpart of enrolment: a host
|
||||
// proves it is enrolled by presenting its TPM identity as the mTLS client cert
|
||||
// (verified to the fleet CA), and the broker has step-ca sign an SSH HOST cert —
|
||||
// no human approval, no CA secret on the host. Admins then trust every fleet host
|
||||
// via one `@cert-authority` line instead of per-boot TOFU (F-02 / POA&M P-17).
|
||||
// Keep step-ca itself off the boot LAN; the broker is the single front door.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type sshHostCertRequest struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
Principals []string `json:"principals"`
|
||||
KeyID string `json:"keyID"`
|
||||
}
|
||||
|
||||
type sshHostCertResponse struct {
|
||||
Cert string `json:"cert,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (b *broker) serveHost() {
|
||||
if b.cfg.hostAddr == "" {
|
||||
log.Printf("host plane disabled (set BROKER_HOST_ADDR + BROKER_HOST_CERT/KEY to enable SSH host-cert issuance)")
|
||||
return
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /ssh/hostcert", b.handleSSHHostCert)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: b.cfg.hostAddr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 40 * time.Second, // signing blocks on step-ca
|
||||
IdleTimeout: 30 * time.Second,
|
||||
MaxHeaderBytes: 16 << 10,
|
||||
}
|
||||
|
||||
if b.cfg.hostInsecure {
|
||||
log.Printf("WARNING: BROKER_HOST_INSECURE=true — SSH host plane is PLAINTEXT with NO client-cert auth. DEV ONLY.")
|
||||
log.Fatalf("host listener: %v", srv.ListenAndServe())
|
||||
return
|
||||
}
|
||||
if b.cfg.hostCert == "" || b.cfg.hostKey == "" {
|
||||
log.Fatalf("host mTLS requires BROKER_HOST_CERT and BROKER_HOST_KEY (or BROKER_HOST_INSECURE=true for dev)")
|
||||
}
|
||||
clientCAPath := b.cfg.hostClientCA
|
||||
if clientCAPath == "" {
|
||||
clientCAPath = b.cfg.caRoot // default: the fleet root that signs host identities
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(b.cfg.hostCert, b.cfg.hostKey)
|
||||
if err != nil {
|
||||
log.Fatalf("host server cert: %v", err)
|
||||
}
|
||||
caPEM, err := os.ReadFile(clientCAPath)
|
||||
if err != nil {
|
||||
log.Fatalf("host client CA: %v", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
log.Fatalf("host client CA %s contains no certificates", clientCAPath)
|
||||
}
|
||||
srv.TLSConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, // must be an enrolled fleet host
|
||||
ClientCAs: pool,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
log.Printf("host plane up: %s (mTLS, fleet-CA host client certs) ssh-signer=%s", b.cfg.hostAddr, b.sshSigner.Kind())
|
||||
log.Fatalf("host listener: %v", srv.ListenAndServeTLS("", ""))
|
||||
}
|
||||
|
||||
func (b *broker) handleSSHHostCert(w http.ResponseWriter, r *http.Request) {
|
||||
// RequireAndVerifyClientCert already guaranteed the peer chains to the fleet
|
||||
// CA; the presented identity is who we're issuing for.
|
||||
peerCN := hostPeerCN(r)
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
var req sshHostCertRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeSSHErr(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.KeyID == "" {
|
||||
req.KeyID = peerCN
|
||||
}
|
||||
if len(req.Principals) == 0 {
|
||||
writeSSHErr(w, http.StatusBadRequest, "no principals requested")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := ctxWithTimeout(35 * time.Second)
|
||||
defer cancel()
|
||||
certLine, err := b.sshSigner.SignHost(ctx, req.PublicKey, req.KeyID, req.Principals)
|
||||
if err != nil {
|
||||
log.Printf("audit ssh-hostcert-FAILED cn=%q principals=%v err=%v", peerCN, req.Principals, err)
|
||||
writeSSHErr(w, http.StatusBadGateway, "signing failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("audit SSH-HOSTCERT cn=%q keyid=%q principals=%v", peerCN, req.KeyID, req.Principals)
|
||||
writeJSON(w, http.StatusOK, sshHostCertResponse{Cert: certLine})
|
||||
}
|
||||
|
||||
func hostPeerCN(r *http.Request) string {
|
||||
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
|
||||
if cn := r.TLS.PeerCertificates[0].Subject.CommonName; cn != "" {
|
||||
return cn
|
||||
}
|
||||
return "unknown-host"
|
||||
}
|
||||
return "dev-insecure"
|
||||
}
|
||||
|
||||
func writeSSHErr(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, sshHostCertResponse{Error: msg})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestBroker() *broker {
|
||||
return &broker{cfg: loadConfig(), sshSigner: mockSSHSigner{}}
|
||||
}
|
||||
|
||||
func TestHandleSSHHostCert_OK(t *testing.T) {
|
||||
b := newTestBroker()
|
||||
body, _ := json.Marshal(sshHostCertRequest{
|
||||
PublicKey: "ecdsa-sha2-nistp256 AAAAB3Nz root@fleet",
|
||||
Principals: []string{"host01", "host01.fleet.lab"},
|
||||
KeyID: "host01.fleet.lab",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/ssh/hostcert", strings.NewReader(string(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.handleSSHHostCert(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp sshHostCertResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(resp.Cert, "ecdsa-sha2-nistp256-cert-v01@openssh.com ") {
|
||||
t.Errorf("cert line = %q", resp.Cert)
|
||||
}
|
||||
if !strings.Contains(resp.Cert, "host01.fleet.lab") {
|
||||
t.Errorf("cert should carry keyID/principals: %q", resp.Cert)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSSHHostCert_NoPrincipals(t *testing.T) {
|
||||
b := newTestBroker()
|
||||
body, _ := json.Marshal(sshHostCertRequest{PublicKey: "ecdsa-sha2-nistp256 AAAA x", KeyID: "h"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/ssh/hostcert", strings.NewReader(string(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.handleSSHHostCert(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 for missing principals, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSSHHostCert_BadJSON(t *testing.T) {
|
||||
b := newTestBroker()
|
||||
req := httptest.NewRequest(http.MethodPost, "/ssh/hostcert", strings.NewReader("{not json"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
b.handleSSHHostCert(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 for bad JSON, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockSSHSignerRejectsEmptyKey(t *testing.T) {
|
||||
if _, err := (mockSSHSigner{}).SignHost(context.Background(), "", "h", []string{"h"}); err == nil {
|
||||
t.Error("expected error on malformed public key")
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,13 @@ type config struct {
|
||||
adminKey string
|
||||
adminClientCA string
|
||||
adminInsecure bool
|
||||
// host plane (SSH host-cert issuance) mTLS
|
||||
hostAddr string
|
||||
hostCert string
|
||||
hostKey string
|
||||
hostClientCA string
|
||||
hostInsecure bool
|
||||
sshNotAfter string
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
@@ -87,14 +94,21 @@ func loadConfig() config {
|
||||
adminKey: env("BROKER_ADMIN_KEY", ""),
|
||||
adminClientCA: env("BROKER_ADMIN_CLIENT_CA", ""),
|
||||
adminInsecure: envBool("BROKER_ADMIN_INSECURE", false),
|
||||
hostAddr: env("BROKER_HOST_ADDR", ""), // empty => host plane disabled
|
||||
hostCert: env("BROKER_HOST_CERT", ""),
|
||||
hostKey: env("BROKER_HOST_KEY", ""),
|
||||
hostClientCA: env("BROKER_HOST_CLIENT_CA", ""), // default: caRoot (fleet root)
|
||||
hostInsecure: envBool("BROKER_HOST_INSECURE", false),
|
||||
sshNotAfter: env("STEP_SSH_CERT_NOT_AFTER", "720h"),
|
||||
}
|
||||
}
|
||||
|
||||
type broker struct {
|
||||
cfg config
|
||||
store *Store
|
||||
signer Signer
|
||||
rl *rateLimiter
|
||||
cfg config
|
||||
store *Store
|
||||
signer Signer
|
||||
sshSigner SSHSigner
|
||||
rl *rateLimiter
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -123,7 +137,20 @@ func main() {
|
||||
log.Fatalf("unknown BROKER_SIGNER %q (want step|mock)", cfg.signerKind)
|
||||
}
|
||||
|
||||
b := &broker{cfg: cfg, store: store, signer: signer, rl: newRateLimiter(cfg.ratePerMin)}
|
||||
// SSH host-cert signer (host plane) — same step|mock selection as the X.509
|
||||
// enrolment signer, so a mock broker exercises both without a running step-ca.
|
||||
var sshSigner SSHSigner
|
||||
switch cfg.signerKind {
|
||||
case "step":
|
||||
sshSigner = &stepSSHSigner{
|
||||
bin: cfg.stepBin, caURL: cfg.caURL, root: cfg.caRoot,
|
||||
provisioner: cfg.provisioner, passwordFile: cfg.passwordFile, notAfter: cfg.sshNotAfter,
|
||||
}
|
||||
case "mock":
|
||||
sshSigner = mockSSHSigner{}
|
||||
}
|
||||
|
||||
b := &broker{cfg: cfg, store: store, signer: signer, sshSigner: sshSigner, rl: newRateLimiter(cfg.ratePerMin)}
|
||||
|
||||
// background reaper: expire timed-out requests + evict old decided ones.
|
||||
go func() {
|
||||
@@ -138,11 +165,12 @@ func main() {
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); b.servePublic() }()
|
||||
go func() { defer wg.Done(); b.serveAdmin() }()
|
||||
log.Printf("enrol-broker up: public=%s admin=%s signer=%s words=%d ttl=%s",
|
||||
cfg.publicAddr, cfg.adminAddr, signer.Kind(), len(words), cfg.ttl)
|
||||
go func() { defer wg.Done(); b.serveHost() }() // SSH host-cert plane (no-op if BROKER_HOST_ADDR unset)
|
||||
log.Printf("enrol-broker up: public=%s admin=%s host=%s signer=%s words=%d ttl=%s",
|
||||
cfg.publicAddr, cfg.adminAddr, cfg.hostAddr, signer.Kind(), len(words), cfg.ttl)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// sshsigner.go — issuing SSH HOST certificates for already-enrolled hosts.
|
||||
//
|
||||
// This is the SSH counterpart of signer.go. Unlike enrolment (unauthenticated,
|
||||
// human-approved), an SSH host cert is issued AUTOMATICALLY to a host that
|
||||
// authenticates on the host plane with its fleet-CA mTLS identity (host.go) —
|
||||
// the cert IS the authorisation. The broker holds the step-ca provisioner
|
||||
// credential and drives `step ssh certificate`, so step-ca stays behind the
|
||||
// single front door and the host never holds a CA secret. Closes F-02 / P-17.
|
||||
//
|
||||
// BENCH-VERIFY: the exact `step ssh certificate` sign flags vary across step
|
||||
// versions (and the CA must have its SSH host CA enabled — see
|
||||
// serving/dot1x-bench/step-ca/ssh-ca-bootstrap.sh). Confirm against your
|
||||
// `step version`. The mock path returns a synthetic (non-cryptographic) cert line
|
||||
// so the host-plane HTTP flow is testable without a running step-ca.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSHSigner signs an OpenSSH host public key, returning the certificate as a
|
||||
// single authorized-keys/HostCertificate line.
|
||||
type SSHSigner interface {
|
||||
SignHost(ctx context.Context, pubKeyLine, keyID string, principals []string) (certLine string, err error)
|
||||
Kind() string
|
||||
}
|
||||
|
||||
// --- production: step-ca SSH host CA via the `step` CLI ---------------------
|
||||
|
||||
type stepSSHSigner struct {
|
||||
bin string
|
||||
caURL string
|
||||
root string
|
||||
provisioner string
|
||||
passwordFile string
|
||||
notAfter string
|
||||
}
|
||||
|
||||
func (s *stepSSHSigner) Kind() string { return "step" }
|
||||
|
||||
func (s *stepSSHSigner) SignHost(ctx context.Context, pubKeyLine, keyID string, principals []string) (string, error) {
|
||||
if strings.TrimSpace(pubKeyLine) == "" {
|
||||
return "", fmt.Errorf("empty public key")
|
||||
}
|
||||
if keyID == "" {
|
||||
return "", fmt.Errorf("empty key id")
|
||||
}
|
||||
if len(principals) == 0 {
|
||||
return "", fmt.Errorf("no principals requested")
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "sshhost-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
// `step ssh certificate --sign <id> <key.pub>` writes <key>-cert.pub next to
|
||||
// the input; give the input a .pub name so the derived cert path is stable.
|
||||
pubPath := filepath.Join(dir, "hostkey.pub")
|
||||
certPath := filepath.Join(dir, "hostkey-cert.pub")
|
||||
if err := os.WriteFile(pubPath, []byte(strings.TrimRight(pubKeyLine, "\n")+"\n"), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"ssh", "certificate",
|
||||
"--host", // HOST certificate (not a user cert)
|
||||
"--sign", // sign the EXISTING public key (don't generate one)
|
||||
"--no-password", "--insecure", // non-interactive; key file is already on disk
|
||||
"--provisioner", s.provisioner,
|
||||
"--provisioner-password-file", s.passwordFile,
|
||||
"--ca-url", s.caURL,
|
||||
"--root", s.root,
|
||||
}
|
||||
for _, p := range principals {
|
||||
args = append(args, "--principal", p)
|
||||
}
|
||||
if s.notAfter != "" {
|
||||
args = append(args, "--not-after", s.notAfter)
|
||||
}
|
||||
args = append(args, keyID, pubPath)
|
||||
|
||||
if _, err := runStep(ctx, s.bin, args...); err != nil {
|
||||
return "", fmt.Errorf("step ssh certificate: %w", err)
|
||||
}
|
||||
cert, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read signed cert: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(cert)), nil
|
||||
}
|
||||
|
||||
// runStep executes `step ...`, returning stdout (stderr folded into the error).
|
||||
// We never log argv (it can carry a token) or the password-file contents.
|
||||
func runStep(ctx context.Context, bin string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// --- dev only: synthetic cert line -----------------------------------------
|
||||
|
||||
// mockSSHSigner returns a deterministic, NON-cryptographic cert line so the
|
||||
// host-plane HTTP path is testable without a running step-ca. LAB/TEST ONLY.
|
||||
type mockSSHSigner struct{}
|
||||
|
||||
func (mockSSHSigner) Kind() string { return "mock" }
|
||||
|
||||
func (mockSSHSigner) SignHost(_ context.Context, pubKeyLine, keyID string, principals []string) (string, error) {
|
||||
fields := strings.Fields(pubKeyLine)
|
||||
if len(fields) < 2 {
|
||||
return "", fmt.Errorf("malformed public key")
|
||||
}
|
||||
// Not a real certificate — just an identifiable placeholder blob.
|
||||
return fmt.Sprintf("ecdsa-sha2-nistp256-cert-v01@openssh.com MOCK-%s-%s %s",
|
||||
keyID, strings.Join(principals, ","), fields[len(fields)-1]), nil
|
||||
}
|
||||
@@ -287,9 +287,9 @@ func fingerprint(pubDER, nonce []byte, words []string, n int) (string, error) {
|
||||
stream := digestStream(pubDER, nonce, (n*bpw+7)/8)
|
||||
out := make([]string, 0, n)
|
||||
bitpos := 0
|
||||
for i := 0; i < n; i++ {
|
||||
for range n {
|
||||
idx := 0
|
||||
for k := 0; k < bpw; k++ {
|
||||
for range bpw {
|
||||
byteIdx := bitpos / 8
|
||||
bit := 7 - uint(bitpos%8)
|
||||
b := (stream[byteIdx] >> bit) & 1
|
||||
|
||||
Reference in New Issue
Block a user