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.
260 lines
9.4 KiB
Go
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)
|
|
}
|