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.
This commit is contained in:
@@ -77,9 +77,15 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("read host key %s: %v", *hostKey, err)
|
||||
}
|
||||
princ := splitPrincipals(*principals)
|
||||
// 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 = defaultPrincipals()
|
||||
princ = []string{"localhost"}
|
||||
}
|
||||
keyID := ""
|
||||
if id.cert.Leaf != nil {
|
||||
@@ -175,12 +181,58 @@ func splitPrincipals(s string) []string {
|
||||
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 []string{"localhost"}
|
||||
return nil
|
||||
}
|
||||
return []string{h}
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user