Files
Audiobuzz 28d31e436c ssh-hostcert + enrol-broker: fix defects surfaced by the F-02 bench
F-02/P-17 SSH host-cert flow was proven end-to-end on comet-beacon-54
(2026-07-11); these fix the correctness/scoping/cleanup defects the bench
surfaced:

- ssh-hostcert: derive the FQDN principal <hostname>.<domain> from the DHCP
  search domain at runtime (resolv.conf), alongside the short hostname, so an
  admin `@cert-authority *.<domain>` line accepts by-FQDN logins instead of
  "name is not a listed principal". Never baked (invariant #1). + tests.
- ssh-ca-bootstrap.sh: export the SSH host CA public key in OpenSSH wire format
  (ssh-keygen -i -m PKCS8), not the PEM/PKCS8 that `step crypto keypair` writes
  -- a PEM @cert-authority anchor is unparseable and silently falls back to TOFU.
- ssh-ca-bootstrap.sh: fail loudly (and verify enableSSHCA=true) if the
  provisioner SSH-enable step fails, instead of swallowing it and shipping a CA
  that 502s every runtime sign.
- dot1x-bench: point the co-located broker at https://localhost:8443, not the
  fleet dnsmasq name ca.fleet.lab (unresolvable from the broker's host netns ->
  "no such host" at sign time). localhost is in the CA cert SAN.
- serving: remove the mock-signer enrol-broker copy -- two host-networked
  brokers collide on 9090/9443 and the mock issued untrusted certs. The real
  broker lives with step-ca (dot1x-bench); nginx /enrol/ still proxies to it on
  loopback.
- enrol-broker: retire the HTML admin console; the fleet-manager passkey SPA now
  drives the mTLS JSON API (/pending, /approve|deny). Removes a weaker,
  client-cert-only parallel human-approve path.

Design-doc bench note deferred: docs/ssh-host-certificate-design.md lives on the
secure-ntp branch (fbb8100), not here -- to be added there / on reconcile.
2026-07-11 09:44:33 +00:00

260 lines
9.4 KiB
Go

// Command fleet-ssh-hostcert obtains a fleet-CA-signed SSH *host* certificate for
// this host's per-boot SSH host key and installs it, so administrators trust the
// host via a single `@cert-authority` line instead of trust-on-first-use on a key
// that changes every boot (the ephemeral RAM overlay regenerates host keys each
// boot — see scripts/extract-artifacts.sh). Closes F-02 / POA&M P-17.
//
// Flow (Design A, broker-mediated — docs/ssh-host-certificate-design.md):
// 1. load the host's mTLS identity from the TPM (persistent key 0x81000100 +
// leaf cert in NV 0x01800100) — the same identity used for 802.1X, the NVMe
// hostnqn and agent mTLS. The private key never leaves the TPM.
// 2. read the freshly generated public host key (/etc/ssh/ssh_host_ecdsa_key.pub).
// 3. POST it to the enrol-broker's host plane over mTLS. The broker verifies the
// client cert to the fleet CA (proving an enrolled host) and has step-ca sign
// an SSH host certificate — the broker, not the host, holds the CA provisioner
// credential, keeping step-ca behind the single front door.
// 4. write the returned cert + a HostCertificate sshd drop-in, then reload sshd.
//
// FAIL-OPEN: any failure (no network, broker/CA down, host not enrolled) exits
// non-zero WITHOUT touching sshd — the host keeps serving its bare per-boot key
// (today's behaviour). It never installs a missing/broken HostCertificate that
// would stop sshd from starting. A systemd timer retries. Fleet-generic: no
// per-host data in the image (invariant #1).
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"github.com/google/go-tpm/tpm2"
)
func main() {
log.SetFlags(0)
log.SetPrefix("fleet-ssh-hostcert: ")
var (
tpmPath = flag.String("tpm", envOr("SSH_HOSTCERT_TPM", "/dev/tpmrm0"), "TPM resource-manager device")
handleStr = flag.String("handle", envOr("SSH_HOSTCERT_HANDLE", "0x81000100"), "persistent handle of the TPM identity key")
nvindexStr = flag.String("nvindex", envOr("SSH_HOSTCERT_NVINDEX", "0x01800100"), "NV index of the identity cert (DER)")
brokerURL = flag.String("broker-url", envOr("SSH_HOSTCERT_BROKER_URL", "https://boot.fleet.lab:9444/ssh/hostcert"), "enrol-broker host-plane SSH host-cert endpoint (mTLS)")
caFile = flag.String("cacert", envOr("SSH_HOSTCERT_CACERT", "/etc/dot1x/radius-ca.pem"), "fleet CA bundle (verifies the broker's server cert)")
hostKey = flag.String("hostkey", envOr("SSH_HOSTCERT_HOSTKEY", "/etc/ssh/ssh_host_ecdsa_key.pub"), "SSH host public key to certify")
outCert = flag.String("out", envOr("SSH_HOSTCERT_OUT", "/etc/ssh/ssh_host_ecdsa_key-cert.pub"), "path to write the host certificate")
dropIn = flag.String("dropin", envOr("SSH_HOSTCERT_DROPIN", "/etc/ssh/sshd_config.d/60-hostcert.conf"), "sshd drop-in that references the host certificate")
principals = flag.String("principals", envOr("SSH_HOSTCERT_PRINCIPALS", ""), "comma-separated cert principals (default: hostname)")
reload = flag.Bool("reload", envOr("SSH_HOSTCERT_RELOAD", "true") == "true", "reload sshd after installing the certificate")
)
flag.Parse()
handle, err := parseHandle(*handleStr)
if err != nil {
log.Fatalf("bad -handle: %v", err)
}
nvindex, err := parseHandle(*nvindexStr)
if err != nil {
log.Fatalf("bad -nvindex: %v", err)
}
// --- mTLS identity from the TPM ------------------------------------------
id, err := loadTPMIdentity(*tpmPath, handle, nvindex)
if err != nil {
log.Fatalf("TPM identity: %v", err)
}
defer id.Close()
// --- request inputs -------------------------------------------------------
pubLine, err := readHostPubLine(*hostKey)
if err != nil {
log.Fatalf("read host key %s: %v", *hostKey, err)
}
// Principals are the names admins connect by. Default to this host's
// (DHCP-assigned) hostname AND its FQDN <hostname>.<domain> derived from the
// DHCP search domain at runtime (never baked — invariant #1); any explicit
// -principals are added on top. Without the FQDN a `@cert-authority *.<domain>`
// line rejects the cert with "name is not a listed principal" on FQDN logins
// (bench-confirmed on comet-beacon-54, 2026-07-11).
princ := appendUnique(defaultPrincipals(), splitPrincipals(*principals)...)
if len(princ) == 0 {
princ = []string{"localhost"}
}
keyID := ""
if id.cert.Leaf != nil {
keyID = id.cert.Leaf.Subject.CommonName
}
if keyID == "" {
keyID = princ[0]
}
// --- ask the broker to sign (over mTLS) ----------------------------------
client, err := brokerClient(id, *brokerURL, *caFile)
if err != nil {
log.Fatalf("broker client: %v", err)
}
certLine, err := requestHostCert(client, *brokerURL, pubLine, princ, keyID)
if err != nil {
log.Fatalf("request SSH host cert: %v", err)
}
// --- install (only now do we touch sshd's config surface) -----------------
if err := writeFileAtomic(*outCert, []byte(strings.TrimRight(certLine, "\n")+"\n"), 0o644); err != nil {
log.Fatalf("write cert %s: %v", *outCert, err)
}
dropContent := fmt.Sprintf("# Managed by fleet-ssh-hostcert. Host cert signed by the fleet SSH CA.\nHostCertificate %s\n", *outCert)
if err := writeFileAtomic(*dropIn, []byte(dropContent), 0o644); err != nil {
log.Fatalf("write drop-in %s: %v", *dropIn, err)
}
log.Printf("installed SSH host certificate for principals %v (keyID %q)", princ, keyID)
if *reload {
if out, err := exec.Command("systemctl", "reload", "sshd.service").CombinedOutput(); err != nil {
// Non-fatal: the cert is on disk and takes effect on the next sshd (re)start.
log.Printf("warning: sshd reload failed (%v): %s", err, strings.TrimSpace(string(out)))
}
}
}
type hostCertRequest struct {
PublicKey string `json:"publicKey"`
Principals []string `json:"principals"`
KeyID string `json:"keyID"`
}
type hostCertResponse struct {
Cert string `json:"cert"`
Error string `json:"error,omitempty"`
}
// requestHostCert POSTs the host public key to the broker's mTLS host plane and
// returns the signed SSH host-certificate line (`ecdsa-sha2-…-cert-v01@… AAAA…`).
func requestHostCert(client *http.Client, url, pubLine string, principals []string, keyID string) (string, error) {
body, err := json.Marshal(hostCertRequest{PublicKey: pubLine, Principals: principals, KeyID: keyID})
if err != nil {
return "", err
}
resp, err := client.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("broker %s: %s", resp.Status, strings.TrimSpace(string(raw)))
}
var out hostCertResponse
if err := json.Unmarshal(raw, &out); err != nil || out.Cert == "" {
return "", fmt.Errorf("unexpected broker response: %s", strings.TrimSpace(string(raw)))
}
return out.Cert, nil
}
// readHostPubLine returns the trimmed single-line OpenSSH public key (type +
// base64 + optional comment) that the broker signs.
func readHostPubLine(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
line := strings.TrimSpace(string(b))
if len(strings.Fields(line)) < 2 {
return "", fmt.Errorf("malformed public key (want at least 2 fields)")
}
return line, nil
}
func splitPrincipals(s string) []string {
var out []string
for p := range strings.SplitSeq(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// defaultPrincipals returns the names admins connect to this host by: the
// (DHCP-assigned) hostname and, when a DHCP search domain is known, the FQDN
// <hostname>.<domain>. The FQDN is derived at runtime (never baked — invariant
// #1), mirroring how the NTS dispatcher derives ntp.<domain>: an admin's
// `@cert-authority *.<domain>` line only matches the host if the FQDN is a cert
// principal, so a short-name-only cert fails FQDN logins.
func defaultPrincipals() []string {
h, err := os.Hostname()
if err != nil || h == "" {
return nil
}
out := []string{h}
if !strings.Contains(h, ".") {
if dom := dhcpDomain(); dom != "" {
out = append(out, h+"."+dom)
}
}
return out
}
// resolvConf is the file dhcpDomain reads (a var so tests can point elsewhere).
var resolvConf = "/etc/resolv.conf"
// dhcpDomain returns the first DHCP-provided search/domain from resolv.conf
// (NetworkManager writes it from DHCP option 15/119). Empty if none is set.
func dhcpDomain() string {
b, err := os.ReadFile(resolvConf)
if err != nil {
return ""
}
for ln := range strings.SplitSeq(string(b), "\n") {
f := strings.Fields(ln)
if len(f) >= 2 && (f[0] == "search" || f[0] == "domain") {
return f[1]
}
}
return ""
}
// appendUnique appends to base each of extra not already present (order-preserving).
func appendUnique(base []string, extra ...string) []string {
seen := make(map[string]bool, len(base))
for _, s := range base {
seen[s] = true
}
for _, s := range extra {
if s != "" && !seen[s] {
seen[s] = true
base = append(base, s)
}
}
return base
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func parseHandle(s string) (tpm2.TPMHandle, error) {
v, err := strconv.ParseUint(strings.TrimPrefix(strings.ToLower(s), "0x"), 16, 32)
if err != nil {
return 0, err
}
return tpm2.TPMHandle(v), nil
}
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, mode); err != nil {
return err
}
return os.Rename(tmp, path)
}