mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 06:18:59 +00:00
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:
@@ -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}.
|
||||
|
||||
Reference in New Issue
Block a user