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
+10
View File
@@ -82,5 +82,15 @@ services:
BROKER_ADMIN_CERT: "/pki/broker/server.crt"
BROKER_ADMIN_KEY: "/pki/broker/server.key"
BROKER_ADMIN_CLIENT_CA: "/pki/broker/admin-ca.crt"
# HOST plane (SSH host-cert issuance, F-02/P-17). mTLS on the boot LAN:
# FLEET-signed server cert + the fleet CA (root+intermediate) as the client
# CA that verifies each host's TPM identity. Certs issued by
# step-ca/ssh-ca-bootstrap.sh (run it once after bootstrap.sh). 0.0.0.0 so
# booted hosts can reach it — firewall to the boot LAN.
BROKER_HOST_ADDR: "0.0.0.0:9444"
BROKER_HOST_CERT: "/pki/broker/host-server.crt"
BROKER_HOST_KEY: "/pki/broker/host-server.key"
BROKER_HOST_CLIENT_CA: "/pki/broker/fleet-client-ca.crt"
STEP_SSH_CERT_NOT_AFTER: "720h"
volumes:
- ./pki:/pki:ro
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# ssh-ca-bootstrap.sh — enable the fleet SSH host CA so booted hosts can get their
# per-boot SSH HOST key signed VIA THE ENROL-BROKER (files/ssh-hostcert →
# fleet-ssh-hostcert → broker host plane → step-ca), killing per-boot TOFU
# (F-02 / POA&M P-17). Run ONCE after bootstrap.sh, from serving/dot1x-bench/.
# Pairs with docs/ssh-host-certificate-design.md.
#
# 1. enable step-ca's SSH CA (generate host+user CA keys; add the ssh block)
# 2. enable SSH host-cert issuance on the existing 'enrol' JWK provisioner
# (the broker already authenticates to step-ca with it)
# 3. issue the broker HOST-PLANE server cert (fleet-signed; the booted host
# verifies it against radius-ca.pem) + a root+intermediate client-CA bundle
# (verifies the host's TPM identity cert)
# 4. export the SSH HOST CA public key (-> the admin @cert-authority line)
#
# The signing path is BROKER-mediated: step-ca stays behind the single front door;
# hosts never reach it directly. `step` runs INSIDE the CA container. Flags vary
# across step / step-ca versions — this is a BENCH-VERIFY scaffold. Reads .env.
set -euo pipefail
cd "$(dirname "$0")/.."
[ -f .env ] && . ./.env
CA_DNS=${CA_DNS:-ca.fleet.lab}
CA_PORT=${CA_PORT:-8443}
CA_CONNECT=${CA_CONNECT:-localhost}
CA_URL="https://${CA_CONNECT}:${CA_PORT}"
PKI=pki
PROVISIONER=${PROVISIONER:-enrol}
# The DNS name booted hosts dial for the broker host plane (must be in the server
# cert SAN and resolve via the fleet dnsmasq). Reuses boot.fleet.lab by default.
HOST_PLANE_CN=${HOST_PLANE_CN:-boot.fleet.lab}
PW="$PKI/stepca/password"
[ -e "$PW" ] || { [ -e "$PKI/stepca/secrets/password" ] && PW="$PKI/stepca/secrets/password"; }
[ -r "$PW" ] || { echo "ERROR: CA password $PW missing — run bootstrap.sh first." >&2; exit 1; }
CONTAINER_PW="/home/step/${PW#"$PKI/stepca/"}"
step() { docker compose exec -T step-ca step "$@"; }
incca() { docker compose exec -T step-ca "$@"; }
CA_JSON="$PKI/stepca/config/ca.json"
[ -f "$CA_JSON" ] || { echo "ERROR: $CA_JSON not found — is step-ca up?" >&2; exit 1; }
restart_ca() {
docker compose restart step-ca >/dev/null
for _ in $(seq 1 50); do curl -fsS -k "$CA_URL/health" >/dev/null 2>&1 && break; sleep 0.2; done
}
# --- 1. SSH CA keys + ca.json ssh block -------------------------------------
# step-ca signs SSH certs with dedicated SSH CA keys (separate from the X.509
# root). `step ca init --ssh` creates them at init; to add them to an already-
# initialised CA we generate two EC keypairs and reference them in ca.json.ssh.
# BENCH-VERIFY: key format/password expectations vary by version.
echo "==> generating SSH host/user CA keys (if absent)"
for kind in host user; do
if ! incca test -f "/home/step/secrets/ssh_${kind}_ca_key"; then
incca step crypto keypair \
"/home/step/secrets/ssh_${kind}_ca_key.pub" \
"/home/step/secrets/ssh_${kind}_ca_key" \
--kty EC --curve P-256 --no-password --insecure --force
fi
done
echo "==> patching ca.json ssh block (idempotent)"
if python3 - "$CA_JSON" <<'PY'
import json,sys
p=sys.argv[1]
c=json.load(open(p))
want={"hostKey":"/home/step/secrets/ssh_host_ca_key",
"userKey":"/home/step/secrets/ssh_user_ca_key"}
if c.get("ssh")==want: sys.exit(1) # already set — no restart needed
c["ssh"]=want
json.dump(c,open(p,"w"),indent=2)
PY
then
echo " ssh block added; restarting step-ca"; restart_ca
else
echo " ssh block already present"
fi
# --- 2. enable SSH host certs on the 'enrol' provisioner ---------------------
# The broker authenticates to step-ca with this JWK provisioner (signer.go). It
# must be allowed to issue SSH HOST certs. BENCH-VERIFY the exact subcommand/flag
# on your step version (some use `provisioner update <name> --ssh`).
echo "==> enabling SSH on provisioner '${PROVISIONER}' (BENCH-VERIFY the flag)"
incca step ca provisioner update "${PROVISIONER}" --ssh 2>/dev/null \
&& restart_ca \
|| echo " NOTE: could not auto-enable SSH on '${PROVISIONER}' — enable it by hand (step ca provisioner update ${PROVISIONER} --ssh) and restart step-ca."
# --- 3. broker host-plane server cert + fleet client-CA bundle --------------
# The host plane is mTLS: its SERVER cert must be FLEET-signed (the booted host
# verifies it against radius-ca.pem), and its CLIENT CA must verify the host's
# TPM identity leaf (root + intermediate).
echo "==> issuing broker host-plane server cert (CN ${HOST_PLANE_CN})"
incca mkdir -p /export/broker
step ca certificate "${HOST_PLANE_CN}" \
/export/broker/host-server.crt /export/broker/host-server.key \
--ca-url "$CA_URL" --root /export/root_ca.crt \
--provisioner "${PROVISIONER}" --provisioner-password-file "$CONTAINER_PW" \
--not-after 2160h --force
echo "==> building fleet client-CA bundle (root + intermediate) -> $PKI/broker/fleet-client-ca.crt"
mkdir -p "$PKI/broker"
cat "$PKI/root_ca.crt" > "$PKI/broker/fleet-client-ca.crt"
[ -f "$PKI/intermediate_ca.crt" ] && cat "$PKI/intermediate_ca.crt" >> "$PKI/broker/fleet-client-ca.crt"
# --- 4. export the SSH host CA public key (admin trust anchor) ---------------
echo "==> exporting SSH host CA public key -> $PKI/ssh_host_ca.pub"
incca cp /home/step/secrets/ssh_host_ca_key.pub /export/ssh_host_ca.pub
echo
echo "Admin rollout — add ONE line to each admin's ~/.ssh/known_hosts (or a system"
echo "known_hosts), so every fleet host is trusted via the CA instead of TOFU:"
echo
echo " @cert-authority *.${CA_DNS#ca.} $(cat "$PKI/ssh_host_ca.pub" 2>/dev/null || echo '<contents of pki/ssh_host_ca.pub>')"
echo
echo "Then enable the broker host plane (dot1x-bench compose already wires"
echo "BROKER_HOST_ADDR + the certs issued above) and rebuild the secure-path image."
echo "On a booted+enrolled host, ssh-hostcert.timer POSTs its host key to the broker"
echo "and installs /etc/ssh/ssh_host_ecdsa_key-cert.pub."
+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