// signer.go — turning an APPROVED CSR into a signed cert. // // Production path (stepSigner) shells out to the bundled `step` CLI exactly as // the bench's tpm-provision.sh does: mint a one-time provisioner token, then // `step ca sign` the CSR. The broker — not the host — holds the provisioner // password, so a host can never self-sign; signing happens ONLY after an admin // approves (the human OOB gate). // // Dev path (mockSigner) signs with an ephemeral in-process CA so the whole // request -> approve -> cert flow is testable without a running step-ca. It is // LAB-ONLY and announces itself loudly; never select it in production. package main import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "errors" "fmt" "log" "math/big" "os" "os/exec" "path/filepath" "strings" "time" ) // Signer issues a cert for an approved CSR. Returns the cert PEM and its serial // (lowercase hex, the value the hostnqn is derived from). type Signer interface { Sign(ctx context.Context, csrPEM, cn string) (certPEM, serial string, err error) Kind() string } // --- production: step-ca via the `step` CLI --------------------------------- type stepSigner struct { bin string caURL string root string provisioner string passwordFile string notAfter string } func (s *stepSigner) Kind() string { return "step" } func (s *stepSigner) Sign(ctx context.Context, csrPEM, cn string) (string, string, error) { if cn == "" { return "", "", fmt.Errorf("CSR has no CommonName; cannot mint a token subject") } dir, err := os.MkdirTemp("", "enrol-*") if err != nil { return "", "", err } defer os.RemoveAll(dir) csrPath := filepath.Join(dir, "req.csr") outPath := filepath.Join(dir, "out.pem") if err := os.WriteFile(csrPath, []byte(csrPEM), 0o600); err != nil { return "", "", err } // 1. one-time token (subject MUST equal the CSR CN for the JWK provisioner). // NOTE: token validity is the short-lived authorization window (a JWK // provisioner caps it at ~1h), NOT the issued cert's lifetime — do not put // s.notAfter here or a multi-day value fails "token validity too long". token, err := s.run(ctx, "ca", "token", cn, "--provisioner", s.provisioner, "--ca-url", s.caURL, "--root", s.root, "--provisioner-password-file", s.passwordFile, ) if err != nil { return "", "", fmt.Errorf("step ca token: %w", err) } token = strings.TrimSpace(token) // 2. sign the CSR with that token. The issued CERT's lifetime is set HERE // (must be <= the provisioner's maxTLSCertDuration claim, else step-ca // rejects it — raise that claim on the CA for multi-day fleet certs). signArgs := []string{ "ca", "sign", csrPath, outPath, "--token", token, "--ca-url", s.caURL, "--root", s.root, "--force", } if s.notAfter != "" { signArgs = append(signArgs, "--not-after", s.notAfter) } if _, err := s.run(ctx, signArgs...); err != nil { return "", "", fmt.Errorf("step ca sign: %w", err) } certPEM, err := os.ReadFile(outPath) if err != nil { return "", "", err } serial, err := serialFromPEM(certPEM) if err != nil { return "", "", err } return string(certPEM), serial, nil } // run executes `step ...`, returning stdout. The token is sensitive but is passed // as an argv element to the trusted local `step`; we never log argv or the // password-file contents. func (s *stepSigner) run(ctx context.Context, args ...string) (string, error) { cmd := exec.CommandContext(ctx, s.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: ephemeral in-process CA ------------------------------------- type mockSigner struct { caCert *x509.Certificate caKey *ecdsa.PrivateKey } func newMockSigner() (*mockSigner, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, err } tmpl := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "fleet-enrol-broker DEV mock CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, BasicConstraintsValid: true, IsCA: true, } der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) if err != nil { return nil, err } caCert, err := x509.ParseCertificate(der) if err != nil { return nil, err } log.Printf("WARNING: BROKER_SIGNER=mock — issuing certs from an EPHEMERAL in-process CA. LAB ONLY; these certs are NOT trusted by RADIUS/NVMe. Set BROKER_SIGNER=step for real enrolment.") return &mockSigner{caCert: caCert, caKey: key}, nil } func (m *mockSigner) Kind() string { return "mock" } func (m *mockSigner) Sign(_ context.Context, csrPEM, cn string) (string, string, error) { csr, err := parseCSR(csrPEM) if err != nil { return "", "", err } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return "", "", err } tmpl := &x509.Certificate{ SerialNumber: serial, Subject: csr.Subject, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(720 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, } der, err := x509.CreateCertificate(rand.Reader, tmpl, m.caCert, csr.PublicKey, m.caKey) if err != nil { return "", "", err } certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) return string(certPEM), strings.ToLower(fmt.Sprintf("%x", serial)), nil } // --- shared helpers -------------------------------------------------------- func parseCSR(csrPEM string) (*x509.CertificateRequest, error) { blk, _ := pem.Decode([]byte(csrPEM)) if blk == nil || blk.Type != "CERTIFICATE REQUEST" { return nil, fmt.Errorf("not a PEM CERTIFICATE REQUEST") } csr, err := x509.ParseCertificateRequest(blk.Bytes) if err != nil { return nil, err } if err := csr.CheckSignature(); err != nil { return nil, fmt.Errorf("CSR self-signature invalid: %w", err) } return csr, nil } func serialFromPEM(certPEM []byte) (string, error) { blk, _ := pem.Decode(certPEM) if blk == nil || blk.Type != "CERTIFICATE" { return "", fmt.Errorf("issued cert is not PEM") } cert, err := x509.ParseCertificate(blk.Bytes) if err != nil { return "", err } return strings.ToLower(fmt.Sprintf("%x", cert.SerialNumber)), nil }