adding ssh host signing
This commit is contained in:
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user