adding ssh host signing

This commit is contained in:
Audiobuzz
2026-07-11 16:20:21 +09:30
parent b5d6c790c4
commit 2e318fba00
24 changed files with 1626 additions and 90 deletions
+25 -2
View File
@@ -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.
+135
View File
@@ -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})
}
+72
View File
@@ -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")
}
}
+36 -8
View File
@@ -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()
}
+130
View File
@@ -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
}
+2 -2
View File
@@ -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