mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:51:30 +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:
@@ -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)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user