EST RFC 7030 hardening master bundle Phases 5-7: end-to-end serverkeygen

+ profile-driven csrattrs + admin observability with per-status
counters + reload-trust endpoint.

Phase 5 — RFC 7030 §4.4 server-driven key generation:
- internal/pkcs7/envelopeddata_builder.go is the inverse of the
  existing parser/decryptor: AES-256-CBC content cipher + RSA PKCS#1
  v1.5 keyTrans + per-call random IV. Round-trip pinned in test
  (BuildEnvelopedData → ParseEnvelopedData → Decrypt returns the
  original plaintext byte-for-byte).
- ESTService.SimpleServerKeygen runs the full §4.4 flow: parse client
  CSR → require RSA pubkey for keyTrans → resolve per-profile
  algorithm (RSA-2048 default; honors AllowedKeyAlgorithms) → in-
  memory keygen → re-build CSR with server pubkey → run existing
  issuer pipeline → marshal PKCS#8 → CMS-EnvelopedData wrap to a
  synthetic recipient cert wrapping the device's CSR-supplied pubkey
  → zeroize plaintext + PKCS#8 bytes → return CertPEM + ChainPEM
  + EncryptedKey. Typed sentinels ErrServerKeygenRequiresKey-
  Encipherment / ErrServerKeygenUnsupportedAlgorithm /
  ErrServerKeygenDisabled.
- ESTHandler.ServerKeygen + ServerKeygenMTLS emit RFC 7030 §4.4.2
  multipart/mixed with random per-response boundary; per-profile
  SetServerKeygenEnabled gate returns 404 when off (defense in depth
  even if the route was registered).
- New routes POST /.well-known/est/[<PathID>/]serverkeygen +
  /.well-known/est-mtls/<PathID>/serverkeygen; openapi.yaml +
  openapi-parity guard updated.

Phase 6 — Real csrattrs implementation:
- New CertificateProfile.RequiredCSRAttributes []string + migration
  000022_certificate_profiles_csrattrs.up.sql. The migration also
  lands the previously-unwired must_staple column (closes the 5.6
  follow-up loop where the field shipped at the domain + service
  layer but the postgres scan/insert/update never persisted it).
- domain.EKUStringToOID + AttributeStringToOID lookup tables: id-kp-*
  EKUs (RFC 5280 §4.2.1.12) + RFC 5280 DN attributes + RFC 2985
  PKCS#10 attributes + Microsoft Intune device-serial OID.
- ESTService.GetCSRAttrs replaces the v2.0.x nil/204 stub with a
  profile-derived SEQUENCE OF OID ASN.1 marshal. Unknown EKU /
  attribute strings dropped + warning-logged so a typo doesn't take
  down the entire endpoint.

Phase 7 — Admin observability + counters + reload-trust:
- internal/service/est_counters.go: estCounterTab (sync/atomic; 12
  named labels) + ESTStatsSnapshot per-profile shape +
  ESTService.Stats(now) zero-allocation accessor + ReloadTrust()
  SIGHUP-equivalent + SetESTAdminMetadata setter.
- Counter ticks wired into processEnrollment + SimpleServerKeygen at
  every success/failure leg.
- internal/api/handler/admin_est.go mirrors AdminSCEPIntune verbatim:
  Profiles + ReloadTrust handlers + AdminESTServiceImpl. Both
  endpoints admin-gated (M-008 triplet pinned + admin_est.go added
  to AdminGatedHandlers).
- New routes GET /api/v1/admin/est/profiles + POST /api/v1/admin/
  est/reload-trust; openapi.yaml documented; openapi-parity guard
  reproduced clean.
- cmd/server/main.go grows estServices map populated by the per-
  profile EST loop + handed to AdminEST. New MTLSTrust() +
  HasMTLSTrust() accessors on ESTHandler so main.go can pull the
  trust holder for the admin-metadata wire-up.
- Per-profile counter isolation regression test
  (internal/service/est_profile_counter_isolation_test.go) proves
  a future shared-counter refactor would fail at compile-time
  pointer-identity check.

Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres which the sandbox can't build —
disk-space testcontainers download), staticcheck clean across
cms/trustanchor/api/handler/api/router/scep/intune/ratelimit/
service/pkcs7/domain/cmd/server, go test -short -count=1 green
for every non-postgres package. G-3 docs-drift guard reproduced
locally clean (Phases 5-7 added zero new env vars; Phase 1
already documented per-profile SERVER_KEYGEN_ENABLED).

Spec preserved at cowork/est-rfc7030-hardening-prompt.md. Phases
8-13 (GUI ESTAdminPage / CLI+MCP / libest e2e / bulk revocation /
docs/est.md / release prep) remain — post-2.1.0 work.
This commit is contained in:
shankar0123
2026-04-29 23:57:45 +00:00
parent 34518b2e66
commit 8bc9f4eed8
23 changed files with 2728 additions and 27 deletions
+177
View File
@@ -2,6 +2,7 @@ package handler
import (
"context"
"crypto/rand"
"crypto/subtle"
"crypto/x509"
"encoding/base64"
@@ -35,6 +36,13 @@ type ESTService interface {
// GetCSRAttrs returns the CSR attributes the server wants clients to include.
GetCSRAttrs(ctx context.Context) ([]byte, error)
// SimpleServerKeygen runs the RFC 7030 §4.4 server-driven key generation
// flow: server generates the keypair, issues a cert with the new pubkey,
// returns both cert + private key (the latter wrapped in CMS
// EnvelopedData to the client's CSR-supplied key-encipherment pubkey).
// EST RFC 7030 hardening master bundle Phase 5.
SimpleServerKeygen(ctx context.Context, csrPEM string) (*domain.ESTServerKeygenResult, error)
}
// ESTHandler handles HTTP requests for the EST protocol (RFC 7030).
@@ -101,6 +109,15 @@ type ESTHandler struct {
// include in audit log lines / Prometheus labels. Defaults to
// "est" when unset.
labelForLog string
// EST RFC 7030 hardening Phase 5: per-profile gate for the
// /serverkeygen endpoint (RFC 7030 §4.4). The endpoint is only
// routable when this flag is set; the standard /simpleenroll +
// /simplereenroll path is unaffected. Operators opt-in per
// profile to constrain the attack surface — server-driven keygen
// requires the server to hold plaintext private keys briefly,
// which is a meaningful trust delta from device-driven keygen.
serverKeygenEnabled bool
}
// NewESTHandler creates a new ESTHandler with no per-profile auth
@@ -124,6 +141,16 @@ func NewESTHandler(svc ESTService) ESTHandler {
// profile A's bundle cannot enroll against profile B.
func (h *ESTHandler) SetMTLSTrust(t *trustanchor.Holder) { h.mtlsTrust = t }
// MTLSTrust returns the per-profile mTLS trust holder (Phase 7.2 wire-up
// helper for cmd/server/main.go's admin-metadata setter). Nil when
// SetMTLSTrust was never called. Callers MUST treat the holder as
// read-only; the SIGHUP watcher inside the holder owns mutation.
func (h ESTHandler) MTLSTrust() *trustanchor.Holder { return h.mtlsTrust }
// HasMTLSTrust reports whether this handler instance has an mTLS trust
// pool wired up. Convenience wrapper around `h.MTLSTrust() != nil`.
func (h ESTHandler) HasMTLSTrust() bool { return h.mtlsTrust != nil }
// SetChannelBindingRequired toggles RFC 9266 tls-exporter channel binding
// on the simplereenroll mTLS path. EST RFC 7030 hardening Phase 2.4.
// When true, the handler refuses requests whose CSR lacks the binding
@@ -163,6 +190,15 @@ func (h *ESTHandler) SetLabelForLog(label string) {
h.labelForLog = label
}
// SetServerKeygenEnabled toggles the RFC 7030 §4.4 server-keygen endpoint
// for this handler instance. EST RFC 7030 hardening Phase 5. When false
// (default), ServerKeygen + ServerKeygenMTLS return 404 even if the
// route was registered — defense-in-depth against a router-level
// regression that exposes the endpoint without the per-profile gate.
func (h *ESTHandler) SetServerKeygenEnabled(enabled bool) {
h.serverKeygenEnabled = enabled
}
// label returns h.labelForLog with the "est" fallback applied. Tiny
// helper so log call sites don't need to repeat the fallback.
func (h ESTHandler) label() string {
@@ -286,6 +322,147 @@ func (h ESTHandler) CSRAttrsMTLS(w http.ResponseWriter, r *http.Request) {
h.writeCSRAttrsResponse(w, r)
}
// ----- /serverkeygen — RFC 7030 §4.4 (Phase 5) -----
// ServerKeygen handles POST /.well-known/est/[<PathID>/]serverkeygen.
// EST RFC 7030 hardening Phase 5. Identical auth + rate-limit pipeline
// as SimpleEnroll (HTTP Basic optional + per-principal limit optional);
// gated additionally by SetServerKeygenEnabled.
func (h ESTHandler) ServerKeygen(w http.ResponseWriter, r *http.Request) {
h.handleServerKeygen(w, r, false /*viaMTLS*/)
}
// ServerKeygenMTLS handles POST /.well-known/est-mtls/<PathID>/serverkeygen.
// Cert auth + serverkeygen pipeline.
func (h ESTHandler) ServerKeygenMTLS(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireClientCertChain(w, r); !ok {
return
}
h.handleServerKeygen(w, r, true /*viaMTLS*/)
}
// handleServerKeygen runs the shared pipeline for both /serverkeygen
// route variants. Mirrors handleEnrollOrReEnroll but emits the multipart
// response shape RFC 7030 §4.4.2 mandates.
func (h ESTHandler) handleServerKeygen(w http.ResponseWriter, r *http.Request, viaMTLS bool) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
requestID := middleware.GetRequestID(r.Context())
if !h.serverKeygenEnabled {
// Per-profile gate disabled — serve 404 even when the route is
// registered. Operator opted out at the profile level; the
// endpoint should appear non-existent to clients.
http.NotFound(w, r)
return
}
if err := verifyESTTransport(r); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest,
fmt.Sprintf("EST transport precondition failed: %v", err), requestID)
return
}
// HTTP Basic gate — non-mTLS path only (same logic as enroll).
if !viaMTLS && h.basicPassword != "" {
if !h.requireBasicAuth(w, r) {
return
}
}
csrPEM, err := h.readCSRFromRequest(r)
if err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
return
}
csr, _ := decodeCSRPEM(csrPEM)
// Per-principal limit applies to serverkeygen too — a compromised
// credential shouldn't be able to flood the server with key
// generation requests (each costs CPU + RNG entropy).
if h.perPrincipalLimiter != nil {
if err := h.applyPerPrincipalRateLimit(r, csr); err != nil {
ErrorWithRequestID(w, http.StatusTooManyRequests,
fmt.Sprintf("EST serverkeygen rate-limited: %v", err), requestID)
return
}
}
result, err := h.svc.SimpleServerKeygen(r.Context(), csrPEM)
if err != nil {
// Map known typed errors to actionable HTTP statuses; everything
// else falls back to 500 with an audit-log breadcrumb.
switch {
case strings.Contains(err.Error(), "missing RSA key-encipherment"):
ErrorWithRequestID(w, http.StatusBadRequest,
"EST serverkeygen requires an RSA key-encipherment public key in the CSR (RFC 7030 §4.4.2)",
requestID)
case strings.Contains(err.Error(), "unsupported keygen algorithm"):
ErrorWithRequestID(w, http.StatusBadRequest,
fmt.Sprintf("EST serverkeygen unsupported algorithm: %v", err), requestID)
case strings.Contains(err.Error(), "disabled for this profile"):
http.NotFound(w, r)
default:
ErrorWithRequestID(w, http.StatusInternalServerError,
fmt.Sprintf("EST serverkeygen failed: %v", err), requestID)
}
return
}
h.writeServerKeygenMultipart(w, result)
}
// writeServerKeygenMultipart emits the RFC 7030 §4.4.2 multipart body
// containing the cert (certs-only PKCS#7) + the EnvelopedData private
// key. Boundary is fixed-pattern + a per-response random suffix to
// satisfy MIME's "boundary must not appear in body" requirement
// (16 bytes of randomness gives a vanishingly small collision chance).
//
// Content-Type: multipart/mixed; boundary="..."
// First part: application/pkcs7-mime; smime-type=certs-only (base64-wrapped)
// Second part: application/pkcs7-mime; smime-type=enveloped-data (base64-wrapped)
func (h ESTHandler) writeServerKeygenMultipart(w http.ResponseWriter, result *domain.ESTServerKeygenResult) {
// Build cert part (certs-only PKCS#7 + base64-wrap).
certDERs, err := pkcs7.PEMToDERChain(result.CertPEM)
if err != nil || len(certDERs) == 0 {
http.Error(w, "Failed to encode certificate", http.StatusInternalServerError)
return
}
if result.ChainPEM != "" {
if chainDERs, err := pkcs7.PEMToDERChain(result.ChainPEM); err == nil {
certDERs = append(certDERs, chainDERs...)
}
}
certPart, err := pkcs7.BuildCertsOnlyPKCS7(certDERs)
if err != nil {
http.Error(w, "Failed to build PKCS#7 cert part", http.StatusInternalServerError)
return
}
boundary := newMultipartBoundary()
w.Header().Set("Content-Type", "multipart/mixed; boundary="+boundary)
w.WriteHeader(http.StatusOK)
bw := w
// First part: cert.
fmt.Fprintf(bw, "--%s\r\n", boundary)
bw.Write([]byte("Content-Type: application/pkcs7-mime; smime-type=certs-only\r\n"))
bw.Write([]byte("Content-Transfer-Encoding: base64\r\n\r\n"))
writeBase64Wrapped(bw, certPart)
// Second part: encrypted key (EnvelopedData).
fmt.Fprintf(bw, "--%s\r\n", boundary)
bw.Write([]byte("Content-Type: application/pkcs7-mime; smime-type=enveloped-data\r\n"))
bw.Write([]byte("Content-Transfer-Encoding: base64\r\n\r\n"))
writeBase64Wrapped(bw, result.EncryptedKey)
// Closing boundary.
fmt.Fprintf(bw, "--%s--\r\n", boundary)
}
// newMultipartBoundary returns a deterministic-prefix + random-suffix
// boundary string. The fixed prefix lets log filters spot serverkeygen
// responses; the random suffix prevents MIME-injection via a CSR whose
// signature happens to contain the boundary bytes.
func newMultipartBoundary() string {
var rnd [16]byte
_, _ = rand.Read(rnd[:])
return fmt.Sprintf("certctl-est-serverkeygen-%x", rnd[:])
}
// ----- shared internal pipeline -----
// handleEnrollOrReEnroll is the shared body for {Simple,SimpleRe}Enroll{,MTLS}.