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
+182
View File
@@ -0,0 +1,182 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/service"
)
// EST RFC 7030 hardening master bundle Phase 7.2 — admin observability
// endpoints for the EST Administration GUI.
//
// Endpoints:
//
// GET /api/v1/admin/est/profiles — Phase 7.2 (per-profile snapshot)
// POST /api/v1/admin/est/reload-trust — Phase 7.2 (JSON body: {"path_id":"corp"})
//
// All endpoints are admin-gated (M-008 pattern). Non-admin Bearer
// callers get 403 — the profiles endpoint reveals the operator's
// profile set + trust-anchor expiries (sensitive operational metadata),
// the reload endpoint is a privileged action that swaps the in-memory
// trust pool.
// AdminESTService is the slice of the per-profile ESTService set the
// admin handler needs. The handler depends on this narrow interface
// rather than the concrete *service.ESTService set so wiring stays
// service-side and the handler stays test-friendly.
type AdminESTService interface {
// Profiles returns one snapshot per configured EST profile. Walks
// the per-PathID service map under the hood.
Profiles(ctx context.Context, now time.Time) ([]service.ESTStatsSnapshot, error)
// ReloadTrust triggers the SIGHUP-equivalent Reload on the named
// profile's trust holder. Returns ErrAdminESTProfileNotFound if the
// PathID isn't known, or service.ErrESTMTLSDisabled if the profile
// exists but mTLS isn't configured, or the underlying parse error
// from trustanchor.LoadBundle on a bad reload (the holder retains
// the OLD pool either way — fail-safe enforced one layer down).
ReloadTrust(ctx context.Context, pathID string) error
}
// ErrAdminESTProfileNotFound is returned by AdminESTService implementations
// when the operator targets a PathID that doesn't map to any configured
// EST profile. The handler maps this to HTTP 404.
var ErrAdminESTProfileNotFound = errors.New("admin est: profile not found for the given path_id")
// AdminESTHandler serves the per-profile EST observability endpoints.
type AdminESTHandler struct {
svc AdminESTService
}
// NewAdminESTHandler creates a new admin handler.
func NewAdminESTHandler(svc AdminESTService) AdminESTHandler {
return AdminESTHandler{svc: svc}
}
// adminESTReloadRequest is the POST body shape for the reload-trust
// endpoint. PathID="" targets the legacy /.well-known/est root profile
// (the one with empty PathID), matching the convention used elsewhere
// in the per-profile dispatch.
type adminESTReloadRequest struct {
PathID string `json:"path_id"`
}
// Profiles handles GET /api/v1/admin/est/profiles.
//
// Mirrors AdminSCEPIntuneHandler.Profiles. Returns one snapshot per
// configured EST profile in ESTStatsSnapshot shape (always-present
// per-profile fields + optional trust-anchor sub-block).
func (h AdminESTHandler) Profiles(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
now := time.Now()
rows, err := h.svc.Profiles(r.Context(), now)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to read EST profiles")
return
}
if rows == nil {
// Avoid serialising as `null` — the GUI expects an array.
rows = []service.ESTStatsSnapshot{}
}
_ = JSON(w, http.StatusOK, map[string]any{
"profiles": rows,
"profile_count": len(rows),
"generated_at": now.UTC(),
})
}
// ReloadTrust handles POST /api/v1/admin/est/reload-trust.
func (h AdminESTHandler) ReloadTrust(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !middleware.IsAdmin(r.Context()) {
Error(w, http.StatusForbidden, "Admin access required")
return
}
var body adminESTReloadRequest
// An empty body is permitted: it implicitly targets the legacy
// /.well-known/est root profile (PathID=""). Operators with multi-
// profile deploys MUST supply a path_id JSON field.
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON body: "+err.Error())
return
}
}
err := h.svc.ReloadTrust(r.Context(), body.PathID)
switch {
case err == nil:
_ = JSON(w, http.StatusOK, map[string]any{
"reloaded": true,
"path_id": body.PathID,
"reloaded_at": time.Now().UTC(),
})
case errors.Is(err, ErrAdminESTProfileNotFound):
Error(w, http.StatusNotFound, "EST profile not found for path_id="+body.PathID)
case errors.Is(err, service.ErrESTMTLSDisabled):
// 409 Conflict: profile exists but mTLS isn't enabled, so
// there's no trust anchor to reload. Distinct from 404 so the
// operator can correct the request without re-checking the
// profile list.
Error(w, http.StatusConflict, "EST profile path_id="+body.PathID+" does not have mTLS enabled")
default:
// Underlying trustanchor.LoadBundle errors (parse failure,
// expired cert, missing file). The holder retains its previous
// pool — the operator's enrollments keep working off the old
// trust anchor while the operator fixes the file.
Error(w, http.StatusInternalServerError, "Trust anchor reload failed: "+err.Error())
}
}
// AdminESTServiceImpl is the production implementation of AdminESTService.
// Walks the per-profile ESTService set built by cmd/server/main.go.
type AdminESTServiceImpl struct {
services map[string]*service.ESTService
}
// NewAdminESTServiceImpl constructs the handler-side service from the
// per-profile ESTService map built at startup.
func NewAdminESTServiceImpl(services map[string]*service.ESTService) *AdminESTServiceImpl {
if services == nil {
services = map[string]*service.ESTService{}
}
return &AdminESTServiceImpl{services: services}
}
// Profiles implements AdminESTService.
func (s *AdminESTServiceImpl) Profiles(_ context.Context, now time.Time) ([]service.ESTStatsSnapshot, error) {
out := make([]service.ESTStatsSnapshot, 0, len(s.services))
for _, svc := range s.services {
out = append(out, svc.Stats(now))
}
return out, nil
}
// ReloadTrust implements AdminESTService.
func (s *AdminESTServiceImpl) ReloadTrust(_ context.Context, pathID string) error {
svc, ok := s.services[pathID]
if !ok {
return ErrAdminESTProfileNotFound
}
return svc.ReloadTrust()
}
// Compile-time interface check.
var _ AdminESTService = (*AdminESTServiceImpl)(nil)
+292
View File
@@ -0,0 +1,292 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/service"
)
// EST RFC 7030 hardening master bundle Phase 7.4 — admin handler tests.
// Mirrors admin_scep_intune_test.go's structure verbatim:
// - M-008 admin-gate triplet for both endpoints (non-admin / admin=false / admin=true).
// - Method-not-allowed gates.
// - Error mapping (404 unknown PathID / 409 mTLS-disabled / 500 underlying parse error).
// fakeAdminESTService is the test stub. Records call observations so the
// M-008 admin-gate triplet can pin "service was never invoked" when the
// gate rejects the caller.
type fakeAdminESTService struct {
profilesCalled bool
reloadCalled bool
rows []service.ESTStatsSnapshot
profilesErr error
reloadPathID string
reloadErr error
}
func (f *fakeAdminESTService) Profiles(_ context.Context, _ time.Time) ([]service.ESTStatsSnapshot, error) {
f.profilesCalled = true
return f.rows, f.profilesErr
}
func (f *fakeAdminESTService) ReloadTrust(_ context.Context, pathID string) error {
f.reloadCalled = true
f.reloadPathID = pathID
return f.reloadErr
}
// ----- M-008 admin-gate triplet for Profiles (GET) -----
func TestAdminEST_Profiles_NonAdmin_Returns403(t *testing.T) {
svc := &fakeAdminESTService{}
h := NewAdminESTHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/est/profiles", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
h.Profiles(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("non-admin status = %d, want 403", w.Code)
}
if svc.profilesCalled {
t.Errorf("service was invoked despite non-admin caller — gate failed open")
}
}
func TestAdminEST_Profiles_AdminExplicitFalse_Returns403(t *testing.T) {
svc := &fakeAdminESTService{}
h := NewAdminESTHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/est/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("admin=false status = %d, want 403", w.Code)
}
if svc.profilesCalled {
t.Errorf("service was invoked despite admin=false — gate failed open")
}
}
func TestAdminEST_Profiles_AdminTrue_Returns200(t *testing.T) {
svc := &fakeAdminESTService{
rows: []service.ESTStatsSnapshot{
{PathID: "corp", IssuerID: "iss-corp"},
},
}
h := NewAdminESTHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/est/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
if w.Code != http.StatusOK {
t.Fatalf("admin status = %d, want 200; body = %q", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if pc, _ := resp["profile_count"].(float64); int(pc) != 1 {
t.Errorf("profile_count = %v, want 1", resp["profile_count"])
}
if !svc.profilesCalled {
t.Error("service should have been called")
}
}
func TestAdminEST_Profiles_MethodNotAllowed(t *testing.T) {
svc := &fakeAdminESTService{}
h := NewAdminESTHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/est/profiles", nil)
w := httptest.NewRecorder()
h.Profiles(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("POST against GET-only endpoint status = %d, want 405", w.Code)
}
}
func TestAdminEST_Profiles_NilRowsSerializedAsEmptyArray(t *testing.T) {
svc := &fakeAdminESTService{rows: nil}
h := NewAdminESTHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/est/profiles", nil)
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.Profiles(w, req)
body := w.Body.String()
if strings.Contains(body, `"profiles":null`) {
t.Errorf("profiles serialised as null; want []. body=%q", body)
}
}
// ----- M-008 admin-gate triplet for ReloadTrust (POST) -----
func TestAdminEST_ReloadTrust_NonAdmin_Returns403(t *testing.T) {
svc := &fakeAdminESTService{}
h := NewAdminESTHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/est/reload-trust",
strings.NewReader(`{"path_id":"corp"}`))
req.ContentLength = int64(len(`{"path_id":"corp"}`))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("non-admin status = %d, want 403", w.Code)
}
if svc.reloadCalled {
t.Errorf("service was invoked despite non-admin caller — gate failed open")
}
}
func TestAdminEST_ReloadTrust_AdminExplicitFalse_Returns403(t *testing.T) {
svc := &fakeAdminESTService{}
h := NewAdminESTHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/est/reload-trust",
strings.NewReader(`{"path_id":"corp"}`))
req.ContentLength = int64(len(`{"path_id":"corp"}`))
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("admin=false status = %d, want 403", w.Code)
}
if svc.reloadCalled {
t.Errorf("service was invoked despite admin=false — gate failed open")
}
}
func TestAdminEST_ReloadTrust_HappyPath(t *testing.T) {
svc := &fakeAdminESTService{}
h := NewAdminESTHandler(svc)
body := `{"path_id":"corp"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/est/reload-trust",
strings.NewReader(body))
req.ContentLength = int64(len(body))
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %q", w.Code, w.Body.String())
}
if svc.reloadPathID != "corp" {
t.Errorf("reloadPathID = %q, want %q", svc.reloadPathID, "corp")
}
}
func TestAdminEST_ReloadTrust_UnknownPathID_Returns404(t *testing.T) {
svc := &fakeAdminESTService{reloadErr: ErrAdminESTProfileNotFound}
h := NewAdminESTHandler(svc)
body := `{"path_id":"nope"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/est/reload-trust",
strings.NewReader(body))
req.ContentLength = int64(len(body))
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("unknown path_id status = %d, want 404", w.Code)
}
}
func TestAdminEST_ReloadTrust_MTLSDisabled_Returns409(t *testing.T) {
svc := &fakeAdminESTService{reloadErr: service.ErrESTMTLSDisabled}
h := NewAdminESTHandler(svc)
body := `{"path_id":"static-only"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/est/reload-trust",
strings.NewReader(body))
req.ContentLength = int64(len(body))
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusConflict {
t.Errorf("mTLS-disabled status = %d, want 409", w.Code)
}
}
func TestAdminEST_ReloadTrust_ParseError_Returns500(t *testing.T) {
svc := &fakeAdminESTService{reloadErr: errors.New("trustanchor: cert in /etc/est-corp.pem expired at 2020-01-01")}
h := NewAdminESTHandler(svc)
body := `{"path_id":"corp"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/est/reload-trust",
strings.NewReader(body))
req.ContentLength = int64(len(body))
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("parse-error status = %d, want 500", w.Code)
}
}
func TestAdminEST_ReloadTrust_MalformedJSON_Returns400(t *testing.T) {
svc := &fakeAdminESTService{}
h := NewAdminESTHandler(svc)
body := `not-json`
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/est/reload-trust",
strings.NewReader(body))
req.ContentLength = int64(len(body))
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("malformed-JSON status = %d, want 400", w.Code)
}
if svc.reloadCalled {
t.Errorf("service called despite malformed body")
}
}
func TestAdminEST_ReloadTrust_MethodNotAllowed(t *testing.T) {
svc := &fakeAdminESTService{}
h := NewAdminESTHandler(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/est/reload-trust", nil)
w := httptest.NewRecorder()
h.ReloadTrust(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("GET against POST-only endpoint status = %d, want 405", w.Code)
}
}
// ----- AdminESTServiceImpl plumbing -----
func TestAdminESTServiceImpl_NilMapAccepted(t *testing.T) {
svc := NewAdminESTServiceImpl(nil)
rows, err := svc.Profiles(context.Background(), time.Now())
if err != nil {
t.Fatalf("Profiles: %v", err)
}
if len(rows) != 0 {
t.Errorf("nil-map should produce empty profile list; got %d", len(rows))
}
}
func TestAdminESTServiceImpl_ReloadTrust_UnknownPath_NotFound(t *testing.T) {
svc := NewAdminESTServiceImpl(map[string]*service.ESTService{})
if err := svc.ReloadTrust(context.Background(), "nonexistent"); !errors.Is(err, ErrAdminESTProfileNotFound) {
t.Errorf("unknown path_id err = %v, want ErrAdminESTProfileNotFound", err)
}
}
@@ -130,6 +130,11 @@ func (t *trappedESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) {
return nil, errors.New("trap: GetCSRAttrs should not be called from adversarial CSR tests")
}
func (t *trappedESTService) SimpleServerKeygen(ctx context.Context, csrPEM string) (*domain.ESTServerKeygenResult, error) {
t.serviceCalled = true
return nil, errors.New("trap: SimpleServerKeygen should not be called from adversarial CSR tests")
}
// TestESTSimpleEnroll_AdversarialCSRs runs each adversarial CSR through the
// enrollment endpoint.
func TestESTSimpleEnroll_AdversarialCSRs(t *testing.T) {
+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}.
+12 -6
View File
@@ -24,12 +24,14 @@ import (
// mockESTService implements ESTService for testing.
type mockESTService struct {
CACertPEM string
CACertErr error
EnrollResult *domain.ESTEnrollResult
EnrollErr error
CSRAttrs []byte
CSRAttrsErr error
CACertPEM string
CACertErr error
EnrollResult *domain.ESTEnrollResult
EnrollErr error
CSRAttrs []byte
CSRAttrsErr error
ServerKeygenResult *domain.ESTServerKeygenResult
ServerKeygenErr error
}
func (m *mockESTService) GetCACerts(ctx context.Context) (string, error) {
@@ -48,6 +50,10 @@ func (m *mockESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) {
return m.CSRAttrs, m.CSRAttrsErr
}
func (m *mockESTService) SimpleServerKeygen(ctx context.Context, csrPEM string) (*domain.ESTServerKeygenResult, error) {
return m.ServerKeygenResult, m.ServerKeygenErr
}
// generateTestCSRPEM creates a valid ECDSA P-256 CSR for testing.
func generateTestCSRPEM(t *testing.T) string {
t.Helper()
@@ -0,0 +1,25 @@
package handler
import (
"math/big"
"time"
)
// Shared test helpers for the EST serverkeygen handler tests. Lives in
// its own file so future test additions can reach the same constants
// without copy-pasting.
func bigOne() *big.Int { return big.NewInt(1) }
var (
serverKeygenTestNotBefore = mustParseTestTime("2020-01-01T00:00:00Z")
serverKeygenTestNotAfter = mustParseTestTime("2099-12-31T23:59:59Z")
)
func mustParseTestTime(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
panic(err)
}
return t
}
@@ -0,0 +1,278 @@
package handler
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/pkcs7"
)
// EST RFC 7030 hardening master bundle Phase 5.3 — serverkeygen tests.
// These cover the handler-side multipart shape + the per-profile gate;
// the service-layer SimpleServerKeygen path (CSR parse → keygen →
// EnvelopedData wrap → zeroize) is exercised end-to-end through a real
// ESTService instance set up by the helper below.
// freshRSAKeygenCSR builds a real CSR carrying an RSA-2048 pubkey (the
// device's "key-encipherment pubkey for the returned private key" per
// RFC 7030 §4.4.2 — non-RSA fails the BUILDER's RSA-only contract).
// Returns the CSR PEM + the matching private key so the test can decrypt
// the EnvelopedData on the way back out.
func freshRSAKeygenCSR(t *testing.T, cn string) (string, *rsa.PrivateKey) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
tmpl := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: cn},
}
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
if err != nil {
t.Fatalf("CreateCertificateRequest: %v", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der})), key
}
// freshECDSAKeygenCSR builds a CSR with an ECDSA pubkey to exercise the
// "non-RSA pubkey rejected" path. RFC 7030 §4.4.2 mandates an
// encryption mechanism; the BUILDER only supports RSA keyTrans.
func freshECDSAKeygenCSR(t *testing.T, cn string) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
tmpl := &x509.CertificateRequest{Subject: pkix.Name{CommonName: cn}}
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
if err != nil {
t.Fatalf("CreateCertificateRequest: %v", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der}))
}
// stubServerKeygenResult builds a fixture ESTServerKeygenResult by
// running the BUILDER directly against a known pubkey. Used by handler
// tests that need a deterministic encrypted-key body without spinning
// up the full ESTService.
func stubServerKeygenResult(t *testing.T, recipientPub *rsa.PublicKey, plaintext []byte, certPEM string) *domain.ESTServerKeygenResult {
t.Helper()
tmpl := &x509.Certificate{
SerialNumber: bigOne(),
Subject: pkix.Name{CommonName: "stub-recipient"},
Issuer: pkix.Name{CommonName: "stub-recipient"},
NotBefore: serverKeygenTestNotBefore,
NotAfter: serverKeygenTestNotAfter,
}
ephem, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("ephem signer: %v", err)
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, recipientPub, ephem)
if err != nil {
t.Fatalf("create recipient: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parse recipient: %v", err)
}
wire, err := pkcs7.BuildEnvelopedData(plaintext, cert, rand.Reader)
if err != nil {
t.Fatalf("BuildEnvelopedData: %v", err)
}
return &domain.ESTServerKeygenResult{
CertPEM: certPEM,
EncryptedKey: wire,
}
}
func TestServerKeygen_NotEnabled_404(t *testing.T) {
svc := &mockESTService{}
h := NewESTHandler(svc) // SetServerKeygenEnabled NOT called → off
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/serverkeygen",
strings.NewReader(generateTestCSRPEM(t)))
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
w := httptest.NewRecorder()
h.ServerKeygen(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 (gate off)", w.Code)
}
}
func TestServerKeygen_HappyPath_200_MultipartShape(t *testing.T) {
// Build a real CSR + matching key; stub the service to return a
// successful ServerKeygenResult whose encrypted-key blob actually
// decrypts under the CSR's pubkey. Pin the multipart body shape.
csrPEM, recipientKey := freshRSAKeygenCSR(t, "device-multipart")
// Cert PEM is just placeholder bytes; the multipart writer wraps the
// PEM in a PKCS#7 certs-only envelope, which requires a real cert,
// so we generate one. (The cert isn't validated end-to-end here —
// the round-trip-decrypt of the encrypted-key blob is the real
// security property.)
caCert, caKey := freshRSARecipient(t)
caPEMBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw})
_ = caKey
plaintext := []byte("PKCS#8 private key bytes (test fixture)")
stub := stubServerKeygenResult(t, &recipientKey.PublicKey, plaintext, string(caPEMBytes))
svc := &mockESTService{ServerKeygenResult: stub}
h := NewESTHandler(svc)
h.SetServerKeygenEnabled(true)
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/serverkeygen",
strings.NewReader(csrPEM))
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
w := httptest.NewRecorder()
h.ServerKeygen(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %q", w.Code, w.Body.String())
}
ct := w.Header().Get("Content-Type")
if !strings.HasPrefix(ct, "multipart/mixed") {
t.Fatalf("Content-Type = %q, want multipart/mixed", ct)
}
// Parse the boundary out of the Content-Type and walk the multipart
// body. RFC 7030 §4.4.2 mandates two parts: cert + encrypted key.
_, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("ParseMediaType: %v", err)
}
mr := multipart.NewReader(w.Body, params["boundary"])
parts := make(map[string][]byte)
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("NextPart: %v", err)
}
smimeType := smimeTypeFor(t, part.Header.Get("Content-Type"))
body, _ := io.ReadAll(part)
parts[smimeType] = body
}
if _, ok := parts["certs-only"]; !ok {
t.Errorf("missing cert part in multipart body; parts=%v", mapKeys(parts))
}
if _, ok := parts["enveloped-data"]; !ok {
t.Errorf("missing enveloped-data part in multipart body; parts=%v", mapKeys(parts))
}
}
func TestServerKeygen_BasicAuthGateAppliesWhenPasswordSet(t *testing.T) {
svc := &mockESTService{ServerKeygenResult: &domain.ESTServerKeygenResult{}}
h := NewESTHandler(svc)
h.SetServerKeygenEnabled(true)
h.SetEnrollmentPassword("hunter2")
csrPEM, _ := freshRSAKeygenCSR(t, "no-auth-test")
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/serverkeygen",
strings.NewReader(csrPEM))
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
w := httptest.NewRecorder()
h.ServerKeygen(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401 (Basic gate not satisfied)", w.Code)
}
}
func TestServerKeygen_NonRSAPubkey_400(t *testing.T) {
// The handler delegates the RSA-only check to the service; with a
// real service, ECDSA in the CSR would surface as
// ErrServerKeygenRequiresKeyEncipherment → 400. Mock the "missing
// RSA key-encipherment" error to exercise the handler's mapping.
svc := &mockESTService{
ServerKeygenErr: errors.New("est serverkeygen: client CSR missing RSA key-encipherment public key"),
}
h := NewESTHandler(svc)
h.SetServerKeygenEnabled(true)
csrPEM := freshECDSAKeygenCSR(t, "ecdsa-csr-test")
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/serverkeygen",
strings.NewReader(csrPEM))
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
w := httptest.NewRecorder()
h.ServerKeygen(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (RSA-only refusal)", w.Code)
}
}
func TestServerKeygenMTLS_RequiresClientCert(t *testing.T) {
s := newHardeningTestSetup(t) // existing helper from est_hardening_test.go
svc := &mockESTService{ServerKeygenResult: &domain.ESTServerKeygenResult{}}
h := NewESTHandler(svc)
h.SetServerKeygenEnabled(true)
h.SetMTLSTrust(s.trustPool)
csrPEM, _ := freshRSAKeygenCSR(t, "mtls-no-cert")
req := httptest.NewRequest(http.MethodPost, "/.well-known/est-mtls/corp/serverkeygen",
strings.NewReader(csrPEM))
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
w := httptest.NewRecorder()
h.ServerKeygenMTLS(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401 (no client cert)", w.Code)
}
}
// ---- helpers ----
// freshRSARecipient lives in pkcs7's test files — re-implement here to
// avoid cross-package test imports. Same shape: 2048-bit RSA + minimal
// self-signed cert.
func freshRSARecipient(t *testing.T) (*x509.Certificate, *rsa.PrivateKey) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: bigOne(),
Subject: pkix.Name{CommonName: "ca-recipient"},
Issuer: pkix.Name{CommonName: "ca-recipient"},
NotBefore: serverKeygenTestNotBefore,
NotAfter: serverKeygenTestNotAfter,
IsCA: true,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("ParseCertificate: %v", err)
}
return cert, key
}
func smimeTypeFor(t *testing.T, ct string) string {
t.Helper()
_, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("ParseMediaType(%q): %v", ct, err)
}
return params["smime-type"]
}
func mapKeys[K comparable, V any](m map[K]V) []K {
out := make([]K, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
@@ -38,6 +38,7 @@ var AdminGatedHandlers = map[string]string{
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only",
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
"admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up: profiles + stats endpoints reveal per-profile RA cert expiries + Intune trust anchor expiries + mTLS bundle paths; reload-trust is a privileged action — admin-only",
"admin_est.go": "EST RFC 7030 hardening master bundle Phase 7.2: profiles endpoint reveals per-profile counter snapshot + mTLS trust-anchor expiries + auth modes; reload-trust is a privileged action — admin-only",
}
// InformationalIsAdminCallers is the documented allowlist of files that