diff --git a/cmd/server/finalhandler_test.go b/cmd/server/finalhandler_test.go new file mode 100644 index 0000000..cb94109 --- /dev/null +++ b/cmd/server/finalhandler_test.go @@ -0,0 +1,314 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestBuildFinalHandler_Dispatch is the M-001 regression harness for the outer +// HTTP dispatch layer. It pins which path prefixes ride the no-auth middleware +// chain (EST, SCEP, /.well-known/pki, health/ready, /api/v1/auth/info) versus +// the authenticated chain (/api/v1/*). +// +// The concern under test is ONLY the dispatch in buildFinalHandler — the +// handlers themselves are mocked as marker handlers that stamp "AUTH" or +// "NOAUTH" into the response body. Service-layer concerns (SCEP password +// validation, EST CSR validation, API auth enforcement) are covered by their +// respective test suites. +// +// Case (i) is the central guard: EST with NO client cert / NO Bearer token +// MUST reach the no-auth handler (pre-M-001 it was 401'd by the Auth +// middleware, blocking enrollment for every real-world EST client). +func TestBuildFinalHandler_Dispatch(t *testing.T) { + // Marker handlers — each stamps a unique body so tests can verify which + // chain the request traversed. + authHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Chain", "auth") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("AUTH")) + }) + noAuthHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Chain", "noauth") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("NOAUTH")) + }) + + // Dashboard directory with index.html + assets/ for SPA fallback and + // static-asset tests. Cleaned up by t.TempDir. + webDir := t.TempDir() + indexHTML := []byte("certctl dashboard") + if err := os.WriteFile(filepath.Join(webDir, "index.html"), indexHTML, 0o644); err != nil { + t.Fatalf("write index.html: %v", err) + } + assetsDir := filepath.Join(webDir, "assets") + if err := os.MkdirAll(assetsDir, 0o755); err != nil { + t.Fatalf("mkdir assets: %v", err) + } + assetJS := []byte("console.log('certctl');") + if err := os.WriteFile(filepath.Join(assetsDir, "app.js"), assetJS, 0o644); err != nil { + t.Fatalf("write app.js: %v", err) + } + + handler := buildFinalHandler(authHandler, noAuthHandler, webDir, true /* dashboardEnabled */) + + tests := []struct { + name string + method string + path string + wantBody string // "AUTH" | "NOAUTH" | "" (== substring match against response body) + wantBodyPrefix string + wantStatus int + description string + }{ + // ---- Case (i): M-001 central regression guard ---- + { + name: "est_cacerts_no_auth_reaches_noauth_handler", + method: http.MethodGet, + path: "/.well-known/est/cacerts", + wantBody: "NOAUTH", + wantStatus: http.StatusOK, + description: "EST clients cannot present Bearer tokens — must NOT be 401'd before reaching the handler (RFC 7030 §4.1.1)", + }, + { + name: "est_simpleenroll_no_auth_reaches_noauth_handler", + method: http.MethodPost, + path: "/.well-known/est/simpleenroll", + wantBody: "NOAUTH", + wantStatus: http.StatusOK, + description: "RFC 7030 §4.2 simpleenroll served from no-auth chain (option D)", + }, + { + name: "est_simplereenroll_no_auth_reaches_noauth_handler", + method: http.MethodPost, + path: "/.well-known/est/simplereenroll", + wantBody: "NOAUTH", + wantStatus: http.StatusOK, + description: "RFC 7030 §4.2.2 simplereenroll also on no-auth chain", + }, + { + name: "est_csrattrs_no_auth_reaches_noauth_handler", + method: http.MethodGet, + path: "/.well-known/est/csrattrs", + wantBody: "NOAUTH", + wantStatus: http.StatusOK, + description: "RFC 7030 §4.5 csrattrs also on no-auth chain", + }, + + // ---- Cases (ii) + (iii): SCEP dispatch ---- + // The actual challengePassword validation lives in the service layer + // (internal/service/scep.go). This test pins that ALL /scep* requests + // reach the no-auth chain — the service layer is then responsible for + // rejecting or accepting based on password contents. + { + name: "scep_exact_path_reaches_noauth_handler", + method: http.MethodGet, + path: "/scep", + wantBody: "NOAUTH", + wantStatus: http.StatusOK, + description: "SCEP clients authenticate via CSR challengePassword, not Bearer (RFC 8894 §3.2)", + }, + { + name: "scep_subpath_reaches_noauth_handler", + method: http.MethodPost, + path: "/scep/", + wantBody: "NOAUTH", + wantStatus: http.StatusOK, + description: "Trailing-slash variant must also ride no-auth chain", + }, + { + name: "scep_query_string_reaches_noauth_handler", + method: http.MethodGet, + path: "/scep?operation=GetCACaps", + wantBody: "NOAUTH", + wantStatus: http.StatusOK, + description: "Query string does not affect dispatch — operation dispatch is handler-internal", + }, + // Defensive: /scepxyz MUST NOT match the SCEP prefix (guards against + // over-broad matching that would leak non-SCEP paths into no-auth). + { + name: "scepxyz_does_not_match_scep_prefix", + method: http.MethodGet, + path: "/scepxyz", + wantStatus: http.StatusOK, + wantBody: "certctl dashboard", + description: "SPA fallback — /scepxyz must not be confused with /scep or /scep/", + }, + + // ---- Case (iv): RFC 5280 CRL + RFC 6960 OCSP ---- + { + name: "pki_crl_no_auth_reaches_noauth_handler", + method: http.MethodGet, + path: "/.well-known/pki/crl/abc123", + wantBody: "NOAUTH", + wantStatus: http.StatusOK, + description: "RFC 5280 CRL distribution point must be served without auth", + }, + { + name: "pki_ocsp_no_auth_reaches_noauth_handler", + method: http.MethodGet, + path: "/.well-known/pki/ocsp/abc123/serial", + wantBody: "NOAUTH", + wantStatus: http.StatusOK, + description: "RFC 6960 OCSP responder must be served without auth", + }, + + // ---- Case (v): Authenticated API routes ---- + { + name: "api_v1_certificates_goes_through_auth", + method: http.MethodGet, + path: "/api/v1/certificates", + wantBody: "AUTH", + wantStatus: http.StatusOK, + description: "Primary API surface must still require Bearer token", + }, + { + name: "api_v1_auth_check_goes_through_auth", + method: http.MethodGet, + path: "/api/v1/auth/check", + wantBody: "AUTH", + wantStatus: http.StatusOK, + description: "auth/check validates the caller's Bearer — auth chain required", + }, + { + name: "api_v1_jobs_goes_through_auth", + method: http.MethodGet, + path: "/api/v1/jobs", + wantBody: "AUTH", + wantStatus: http.StatusOK, + description: "Jobs API is part of the privileged surface", + }, + + // ---- Health probes bypass auth ---- + { + name: "health_bypasses_auth", + method: http.MethodGet, + path: "/health", + wantBody: "NOAUTH", + wantStatus: http.StatusOK, + description: "Docker/K8s health probes cannot carry Bearer tokens", + }, + { + name: "ready_bypasses_auth", + method: http.MethodGet, + path: "/ready", + wantBody: "NOAUTH", + wantStatus: http.StatusOK, + description: "Readiness probe also unauthenticated", + }, + { + name: "auth_info_bypasses_auth", + method: http.MethodGet, + path: "/api/v1/auth/info", + wantBody: "NOAUTH", + wantStatus: http.StatusOK, + description: "React app calls auth/info BEFORE login to discover auth mode", + }, + + // ---- Static assets served by file server ---- + { + name: "static_asset_served_by_file_server", + method: http.MethodGet, + path: "/assets/app.js", + wantStatus: http.StatusOK, + wantBody: "console.log('certctl');", + description: "Built Vite assets served directly without auth", + }, + + // ---- SPA fallback ---- + { + name: "spa_fallback_serves_index_html", + method: http.MethodGet, + path: "/", + wantStatus: http.StatusOK, + wantBody: "certctl dashboard", + description: "Root path serves SPA entry point", + }, + { + name: "spa_fallback_for_unknown_route", + method: http.MethodGet, + path: "/certificates", + wantStatus: http.StatusOK, + wantBody: "certctl dashboard", + description: "React Router routes fall through to index.html", + }, + { + name: "spa_fallback_deep_route", + method: http.MethodGet, + path: "/certificates/mc-api-prod/detail", + wantStatus: http.StatusOK, + wantBody: "certctl dashboard", + description: "Deep React Router routes also fall through to SPA", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != tc.wantStatus { + t.Errorf("status = %d, want %d (%s)", w.Code, tc.wantStatus, tc.description) + } + body := w.Body.String() + if tc.wantBody != "" && !strings.Contains(body, tc.wantBody) { + t.Errorf("body %q does not contain %q (%s)", body, tc.wantBody, tc.description) + } + if tc.wantBodyPrefix != "" && !strings.HasPrefix(body, tc.wantBodyPrefix) { + t.Errorf("body %q does not start with %q (%s)", body, tc.wantBodyPrefix, tc.description) + } + }) + } +} + +// TestBuildFinalHandler_NoDashboard pins the API-only (dashboard-absent) +// dispatch behavior. When web/dist/index.html is missing, everything that's +// not a no-auth bypass route falls through to the authenticated apiHandler +// (pre-M-001 behavior for headless deployments). EST/SCEP/PKI still ride the +// no-auth chain. +func TestBuildFinalHandler_NoDashboard(t *testing.T) { + authHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("AUTH")) + }) + noAuthHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("NOAUTH")) + }) + + handler := buildFinalHandler(authHandler, noAuthHandler, "/nonexistent", false /* dashboardEnabled */) + + tests := []struct { + name string + path string + wantBody string + }{ + {"est_still_no_auth", "/.well-known/est/cacerts", "NOAUTH"}, + {"scep_still_no_auth", "/scep", "NOAUTH"}, + {"pki_still_no_auth", "/.well-known/pki/crl/x", "NOAUTH"}, + {"health_still_no_auth", "/health", "NOAUTH"}, + {"api_still_auth", "/api/v1/certificates", "AUTH"}, + // The difference: non-API, non-special paths go through auth chain when + // there's no dashboard to serve (preserves legacy headless behavior). + {"unknown_path_falls_through_to_auth", "/", "AUTH"}, + {"unknown_deep_path_falls_through_to_auth", "/random/path", "AUTH"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + if got := w.Body.String(); !strings.Contains(got, tc.wantBody) { + t.Errorf("body = %q, want to contain %q", got, tc.wantBody) + } + }) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index b2a44cb..a13659f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -725,55 +725,14 @@ func main() { middleware.Recovery, ) + dashboardEnabled := false if _, err := os.Stat(webDir + "/index.html"); err == nil { - fileServer := http.FileServer(http.Dir(webDir)) - finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - // Health/ready and auth/info bypass auth middleware. - // Health/ready: Docker/K8s health probes don't carry Bearer tokens. - // auth/info: React app calls this before login to detect auth mode. - if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" { - noAuthHandler.ServeHTTP(w, r) - return - } - // RFC 5280 CRL and RFC 6960 OCSP live under /.well-known/pki/ and - // MUST be served unauthenticated — relying parties (browsers, - // OpenSSL, OCSP stapling sidecars, mTLS clients) cannot present - // certctl Bearer tokens. See router.RegisterPKIHandlers. - if len(path) >= 16 && path[:16] == "/.well-known/pki" { - noAuthHandler.ServeHTTP(w, r) - return - } - // All other API and EST routes go through the full middleware stack (with auth) - if (len(path) >= 8 && path[:8] == "/api/v1/") || - (len(path) >= 16 && path[:16] == "/.well-known/est") { - apiHandler.ServeHTTP(w, r) - return - } - // Try to serve static files (JS, CSS, assets) - if len(path) > 8 && path[:8] == "/assets/" { - fileServer.ServeHTTP(w, r) - return - } - // SPA fallback: serve index.html for all other routes - http.ServeFile(w, r, webDir+"/index.html") - }) + dashboardEnabled = true + } + finalHandler = buildFinalHandler(apiHandler, noAuthHandler, webDir, dashboardEnabled) + if dashboardEnabled { logger.Info("dashboard available at /", "web_dir", webDir) } else { - // No dashboard: route health/auth-info and /.well-known/pki without - // auth, everything else through full stack. - finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" { - noAuthHandler.ServeHTTP(w, r) - return - } - if len(path) >= 16 && path[:16] == "/.well-known/pki" { - noAuthHandler.ServeHTTP(w, r) - return - } - apiHandler.ServeHTTP(w, r) - }) logger.Info("dashboard directory not found, serving API only") } @@ -858,3 +817,95 @@ func preflightSCEPChallengePassword(enabled bool, challengePassword string) erro } return nil } + +// buildFinalHandler builds the outer HTTP dispatch handler that routes incoming +// requests to either the authenticated apiHandler chain or the unauthenticated +// noAuthHandler chain based on URL path prefix. Extracted from main() so the +// dispatch logic can be unit tested without booting the full server stack +// (see cmd/server/finalhandler_test.go). +// +// Dispatch rules (M-001, audit 2026-04-19, option D): +// +// - /health, /ready, /api/v1/auth/info → no-auth (probes + login detection) +// - /.well-known/pki/* → no-auth (RFC 5280 CRL, RFC 6960 OCSP) +// - /.well-known/est/* → no-auth (RFC 7030 §3.2.3) +// - /scep, /scep/* → no-auth (RFC 8894 §3.2, CSR challengePassword) +// - /api/v1/* → auth (Bearer token required) +// - /assets/* → static file server (dashboard only) +// - anything else → SPA index.html fallback (dashboard only) +// OR apiHandler (no dashboard) +// +// EST/SCEP clients (IoT devices, 802.1X supplicants, MDM endpoints, network +// appliances) cannot present certctl Bearer tokens, so those endpoints must be +// reachable without the Auth middleware. Authentication is instead enforced by +// CSR signature verification, profile policy gates, and for SCEP the +// challengePassword shared secret (fail-loud gated by preflightSCEPChallengePassword +// above). +// +// webDir must point to a directory containing index.html + assets/ when +// dashboardEnabled is true; it is ignored otherwise. +func buildFinalHandler(apiHandler, noAuthHandler http.Handler, webDir string, dashboardEnabled bool) http.Handler { + var fileServer http.Handler + if dashboardEnabled { + fileServer = http.FileServer(http.Dir(webDir)) + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + + // Health/ready and auth/info bypass auth middleware. + // Health/ready: Docker/K8s health probes don't carry Bearer tokens. + // auth/info: React app calls this before login to detect auth mode. + if path == "/health" || path == "/ready" || path == "/api/v1/auth/info" { + noAuthHandler.ServeHTTP(w, r) + return + } + + // RFC 5280 CRL and RFC 6960 OCSP live under /.well-known/pki/ and MUST + // be served unauthenticated — relying parties (browsers, OpenSSL, OCSP + // stapling sidecars, mTLS clients) cannot present certctl Bearer tokens. + if strings.HasPrefix(path, "/.well-known/pki") { + noAuthHandler.ServeHTTP(w, r) + return + } + + // RFC 7030 EST endpoints ride the no-auth middleware chain (M-001, + // option D, audit 2026-04-19). Trust boundary is CSR signature + profile + // policy, not HTTP Bearer. /.well-known/est/cacerts is explicitly + // anonymous per RFC 7030 §4.1.1. + if strings.HasPrefix(path, "/.well-known/est") { + noAuthHandler.ServeHTTP(w, r) + return + } + + // RFC 8894 SCEP rides the no-auth chain (M-001, option D). SCEP clients + // authenticate via the challengePassword attribute in the PKCS#10 CSR, + // not via HTTP Bearer tokens. preflightSCEPChallengePassword refuses to + // start the server if SCEP is enabled without a non-empty shared secret. + if path == "/scep" || strings.HasPrefix(path, "/scep/") { + noAuthHandler.ServeHTTP(w, r) + return + } + + // Authenticated API routes — full middleware stack including Auth. + if strings.HasPrefix(path, "/api/v1/") { + apiHandler.ServeHTTP(w, r) + return + } + + if !dashboardEnabled { + // No dashboard: everything non-special falls through to the + // authenticated handler (preserves pre-M-001 behavior for API-only + // deployments). + apiHandler.ServeHTTP(w, r) + return + } + + // Dashboard-present: serve static assets directly, SPA fallback for + // everything else. + if strings.HasPrefix(path, "/assets/") { + fileServer.ServeHTTP(w, r) + return + } + http.ServeFile(w, r, webDir+"/index.html") + }) +} diff --git a/docs/architecture.md b/docs/architecture.md index 5659167..f039354 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -723,6 +723,8 @@ type ESTService interface { **Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA returns its CA certificate PEM; Vault PKI fetches via `GET /v1/{mount}/ca/pem`; Google CAS fetches via API; AWS ACM PCA retrieves via `GetCertificateAuthorityCertificate`. ACME, step-ca, OpenSSL, DigiCert, and Sectigo connectors return errors (they don't expose a static CA chain — their chains are per-issuance). +**Authentication:** EST endpoints are served unauthenticated at the HTTP layer under `/.well-known/est/*` — no Bearer token required. Per RFC 7030 §3.2.3 EST authentication is deployment-specific, and per §4.1.1 `/cacerts` is explicitly anonymous. certctl enforces authentication via CSR signature verification inside `ESTService.SimpleEnroll`/`SimpleReEnroll` plus profile policy gates (allowed key algorithms, minimum key size, permitted SANs, permitted EKUs, MaxTTL). The HTTP dispatch is implemented in `cmd/server/main.go:buildFinalHandler`, which routes `/.well-known/est/*` through `noAuthHandler` (RequestID + structuredLogger + Recovery only). Operators who need stronger client identification should terminate mTLS at an upstream reverse proxy and pin the CSR's SAN to the client cert subject at the profile level. + **Audit:** Every EST enrollment is recorded in the audit trail with `protocol: "EST"`, the CN, SANs, issuer ID, serial number, and optional profile ID. ### SCEP Server (RFC 8894) @@ -749,7 +751,7 @@ Signed certificate returned as PKCS#7 certs-only **Wire format:** SCEP clients wrap CSRs in PKCS#7 SignedData envelopes. The handler parses the outer ASN.1 ContentInfo → SignedData → EncapsulatedContentInfo to extract the CSR bytes. Fallback paths handle base64-encoded PKCS#7 and raw CSR submissions (for simpler clients). Responses use PKCS#7 certs-only via the shared `internal/pkcs7` package (same as EST). Single certs are returned as raw DER for `GetCACert`, chains as PKCS#7. -**Authentication:** SCEP uses challenge passwords embedded in CSR attributes (OID 1.2.840.113549.1.9.7) rather than TLS client certificates. The server validates the challenge password against `CERTCTL_SCEP_CHALLENGE_PASSWORD`. When no challenge password is configured, any value is accepted. +**Authentication:** SCEP endpoints at `/scep` and `/scep/*` are served unauthenticated at the HTTP layer — no Bearer token required — per RFC 8894 §3.2, which defines authentication via the `challengePassword` attribute (OID 1.2.840.113549.1.9.7) embedded in the PKCS#10 CSR rather than an HTTP credential. The HTTP dispatch is implemented in `cmd/server/main.go:buildFinalHandler`, which routes `/scep` and `/scep/*` through `noAuthHandler` (RequestID + structuredLogger + Recovery only). The `challengePassword` is mandatory: `preflightSCEPChallengePassword` at startup refuses to boot the control plane when `CERTCTL_SCEP_ENABLED=true` is set without `CERTCTL_SCEP_CHALLENGE_PASSWORD`, closing CWE-306 (missing authentication for a critical function). `SCEPService.PKCSReq` enforces the same invariant defense-in-depth — an empty `s.challengePassword` rejects every enrollment — and the password comparison uses `crypto/subtle.ConstantTimeCompare` to prevent response-time side-channel leakage. The startup log line `SCEP server enabled` emits a `challenge_password_set` boolean for operator visibility. **Interface:** The `SCEPHandler` defines an `SCEPService` interface (dependency inversion): @@ -806,10 +808,11 @@ The control plane only handles public material: certificates, chains, and CSRs. ### Authentication -- **API clients → Server**: API key in `Authorization: Bearer` header, or `none` for demo mode +- **API clients → Server**: API key in `Authorization: Bearer` header, or `none` for demo mode. Applies to every path under `/api/v1/*`. - **Agent → Server**: API key registered at agent creation, included in all requests - **Server → Issuers**: ACME account key, or connector-specific credentials - **Agent → Targets**: API tokens, WinRM credentials (stored locally on agent or proxy agent — never on server). Credential scope is limited to the agent's network zone. +- **Standards-based enrollment and PKI distribution endpoints**: `/.well-known/est/*` (RFC 7030), `/scep` and `/scep/*` (RFC 8894), and `/.well-known/pki/crl/{issuer_id}` + `/.well-known/pki/ocsp/{issuer_id}/{serial}` (RFC 5280 §5 / RFC 6960 / RFC 8615) are served unauthenticated at the HTTP layer. These protocols carry their own authentication semantics — CSR signature + profile policy for EST (§3.2.3 says EST auth is deployment-specific; §4.1.1 makes `/cacerts` explicitly anonymous), `challengePassword` in CSR attributes for SCEP (§3.2), and relying-party accessibility for CRL/OCSP — and cannot present certctl Bearer tokens. The dispatch is implemented in `cmd/server/main.go:buildFinalHandler`, which routes these prefixes through `noAuthHandler` (RequestID + structuredLogger + Recovery only, no auth or rate-limit middleware). CWE-306 is closed for SCEP by `preflightSCEPChallengePassword`, which refuses to start the server when SCEP is enabled without `CERTCTL_SCEP_CHALLENGE_PASSWORD`. The 27-subtest regression harness `cmd/server/finalhandler_test.go` pins this dispatch surface (EST 4-endpoint, SCEP exact + trailing-slash + query-string, PKI CRL+OCSP, health probes, `/api/v1/*` authenticated, `/assets/*` file server, SPA fallback). ### Audit Trail diff --git a/docs/compliance-soc2.md b/docs/compliance-soc2.md index 04f76a8..1d56f5a 100644 --- a/docs/compliance-soc2.md +++ b/docs/compliance-soc2.md @@ -44,7 +44,8 @@ Each section includes: **certctl Implementation** (V2 — Community Edition): -- **API Key Authentication** — All API calls require a Bearer token (hashed with SHA-256, stored securely, validated with constant-time comparison) or are rejected with 401 Unauthorized. Environment: `CERTCTL_AUTH_TYPE` (default `api-key`; `none` requires explicit opt-in with log warning) +- **API Key Authentication** — All `/api/v1/*` calls require a Bearer token (hashed with SHA-256, stored securely, validated with constant-time comparison) or are rejected with 401 Unauthorized. Environment: `CERTCTL_AUTH_TYPE` (default `api-key`; `none` requires explicit opt-in with log warning) +- **Standards-based enrollment and PKI distribution endpoints** — EST (`/.well-known/est/*`, RFC 7030), SCEP (`/scep`, `/scep/*`, RFC 8894), and CRL/OCSP (`/.well-known/pki/crl/{issuer_id}`, `/.well-known/pki/ocsp/{issuer_id}/{serial}`, RFC 5280 §5 / RFC 6960 / RFC 8615) are served unauthenticated at the HTTP layer because these protocols cannot present certctl Bearer tokens. Authentication is enforced in-protocol: EST relies on CSR signature verification plus profile policy (RFC 7030 §3.2.3 says EST auth is deployment-specific; §4.1.1 makes `/cacerts` explicitly anonymous); SCEP requires a shared `challengePassword` in the PKCS#10 CSR attributes (OID 1.2.840.113549.1.9.7, RFC 8894 §3.2), validated with `crypto/subtle.ConstantTimeCompare`; CRL and OCSP are intentionally anonymous for relying-party accessibility. CWE-306 (missing authentication for a critical function) is closed for SCEP by `preflightSCEPChallengePassword` in `cmd/server/main.go`, which refuses to start the control plane when `CERTCTL_SCEP_ENABLED=true` is set without `CERTCTL_SCEP_CHALLENGE_PASSWORD`. The HTTP dispatch is implemented in `cmd/server/main.go:buildFinalHandler`, which routes these prefixes through `noAuthHandler` (RequestID + structuredLogger + Recovery only, no auth or rate-limit middleware) and is pinned by the 27-subtest regression harness at `cmd/server/finalhandler_test.go`. - **GUI Authentication** — Web dashboard includes login screen requiring API key entry. Failed auth redirects to login on 401. Auth context persists across page navigation. Logout clears session. - **Configurable CORS** — API restricts cross-origin requests via `CERTCTL_CORS_ORIGINS` allowlist or wildcard. Preflight caching prevents chatty browser auth flows. - **Token Bucket Rate Limiting** — Per-IP rate limiting (configurable via `CERTCTL_RATE_LIMIT_RPS` / `CERTCTL_RATE_LIMIT_BURST`) returns 429 Too Many Requests with Retry-After header. Prevents credential stuffing and brute-force attacks. @@ -58,6 +59,11 @@ Each section includes: - Auth info endpoint: `GET /api/v1/auth/info` (returns current auth mode, served without auth so GUI detects mode) - Rate limiting middleware: `internal/api/middleware/rate_limit.go` - CORS configuration: `cmd/server/main.go`, search for `CERTCTL_CORS_ORIGINS` +- Final handler dispatch (authenticated vs. unauthenticated routing): `cmd/server/main.go:buildFinalHandler` +- SCEP preflight gate (CWE-306 closure): `cmd/server/main.go:preflightSCEPChallengePassword` +- SCEP service-layer defense-in-depth (rejects enrollment on empty challenge password, `crypto/subtle.ConstantTimeCompare`): `internal/service/scep.go` +- Final handler dispatch regression harness (27 subtests): `cmd/server/finalhandler_test.go` +- OpenAPI spec `security: []` overrides on unauthenticated paths: `api/openapi.yaml` (EST `/cacerts`, `/simpleenroll`, `/simplereenroll`, `/csrattrs`; SCEP `/scep` GET+POST; PKI `/crl/{issuer_id}`, `/ocsp/{issuer_id}/{serial}`) **V3 Enhancement**: @@ -110,7 +116,7 @@ Each section includes: **certctl Implementation** (V2): -- **API Key Policy** — All API access requires an API key or explicit opt-out. Opt-out (`CERTCTL_AUTH_TYPE=none`) logs a warning: "WARNING: Auth disabled (CERTCTL_AUTH_TYPE=none) — this is insecure and only for development". Configuration choice is logged at startup. +- **API Key Policy** — All `/api/v1/*` access requires an API key or explicit opt-out. Opt-out (`CERTCTL_AUTH_TYPE=none`) logs a warning: "WARNING: Auth disabled (CERTCTL_AUTH_TYPE=none) — this is insecure and only for development". Configuration choice is logged at startup. The standards-based enrollment and PKI distribution endpoints (EST, SCEP, CRL, OCSP) are served unauthenticated at the HTTP layer per their respective RFCs; see CC6.1 for the full authentication contract and CWE-306 closure via `preflightSCEPChallengePassword`. - **Agent Authentication** — Agents authenticate to the server via API keys (same mechanism as users). Agent credentials are separate from user API keys. - **Private Key Policy** — Agent-side key generation is the default (`CERTCTL_KEYGEN_MODE=agent`). Server-side keygen (`CERTCTL_KEYGEN_MODE=server`) requires explicit configuration and logs a warning: "server-side key generation enabled (CERTCTL_KEYGEN_MODE=server) — private keys touch control plane, demo only". - **Password Policy** — Not applicable; certctl uses API keys exclusively. Password management is delegated to your organization's IAM system if you integrate OIDC/SSO (V3). diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 95646fc..d52e0b5 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -258,7 +258,19 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) { } // RegisterESTHandlers sets up EST (RFC 7030) routes under /.well-known/est/. -// EST endpoints use a separate middleware chain (no API key auth — EST uses TLS client certs). +// +// EST endpoints are intentionally unauthenticated at the HTTP layer. Per RFC 7030 +// §3.2.3, authentication and authorization for enrollment are deployment-specific; +// certctl relies on CSR signature verification, profile policy enforcement (allowed +// key types, max TTL, permitted EKUs), and the underlying issuer connector's own +// policy. Per RFC 7030 §4.1.1, /.well-known/est/cacerts is explicitly anonymous. +// +// cmd/server/main.go's finalHandler dispatches /.well-known/est/* to a dedicated +// no-auth middleware chain (RequestID, structuredLogger, Recovery only) so EST +// clients — IoT devices, 802.1X supplicants, MDM-enrolled laptops — never hit the +// Bearer-token auth middleware they cannot satisfy. See M-001 audit 2026-04-19 +// (option D): prior builds routed EST through the authenticated apiHandler chain, +// which reduced every enrollment to a 401 before the handler was reached. func (r *Router) RegisterESTHandlers(est handler.ESTHandler) { // EST endpoints per RFC 7030 Section 3.2.2 r.Register("GET /.well-known/est/cacerts", http.HandlerFunc(est.CACerts)) @@ -269,7 +281,11 @@ func (r *Router) RegisterESTHandlers(est handler.ESTHandler) { // RegisterSCEPHandlers sets up SCEP (RFC 8894) routes. // SCEP uses a single endpoint with operation-based dispatch via query parameters. -// Authentication is via challenge password in the CSR, not TLS client certs or API keys. +// Authentication is via the challengePassword attribute in the PKCS#10 CSR, not +// via HTTP Bearer tokens or TLS client certs. cmd/server/main.go's finalHandler +// routes /scep* through the no-auth middleware chain (M-001 audit 2026-04-19, +// option D), and Config.Validate() refuses to start the server if SCEP is enabled +// without a non-empty CERTCTL_SCEP_CHALLENGE_PASSWORD (H-2, CWE-306). func (r *Router) RegisterSCEPHandlers(scep handler.SCEPHandler) { // SCEP uses a single path; the handler dispatches on ?operation= query param r.Register("GET /scep", http.HandlerFunc(scep.HandleSCEP)) diff --git a/internal/config/config.go b/internal/config/config.go index cfa683f..5078712 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -651,11 +651,14 @@ type SCEPConfig struct { // ChallengePassword is the shared secret used to authenticate SCEP enrollment requests. // Clients include this in the PKCS#10 CSR challengePassword attribute. // - // REQUIRED when Enabled is true. If SCEP is enabled and this value is empty, - // cmd/server/main.go's preflightSCEPChallengePassword check will refuse to - // start the server (H-2, CWE-306): an empty shared secret allowed any client - // that could reach /scep to enroll a CSR against the configured issuer. The - // service-layer PKCSReq path also rejects this configuration defense-in-depth. + // REQUIRED when Enabled is true. Config.Validate() below refuses to start the + // server if SCEP is enabled and this value is empty (H-2, CWE-306): post-M-001 + // under option (D), the /scep endpoint rides the no-auth middleware chain per + // RFC 8894 §3.2, so the challenge password is the sole application-layer + // authentication boundary for SCEP enrollment. An empty shared secret would + // allow any client that can reach /scep to enroll a CSR against the configured + // issuer. The service-layer PKCSReq path also rejects this configuration + // defense-in-depth. ChallengePassword string } @@ -1109,6 +1112,19 @@ func (c *Config) Validate() error { return fmt.Errorf("invalid keygen mode: %s (must be 'agent' or 'server')", c.Keygen.Mode) } + // SCEP fail-loud startup gate (H-2, CWE-306). + // + // Post-M-001 option (D) routes /scep through the no-auth middleware chain per + // RFC 8894 §3.2 — SCEP clients authenticate via the challengePassword attribute + // in the PKCS#10 CSR, not via HTTP Bearer tokens or TLS client certs. That makes + // CERTCTL_SCEP_CHALLENGE_PASSWORD the sole application-layer authentication + // boundary for SCEP enrollment. Refuse to start if it is empty when SCEP is + // enabled: an empty shared secret would allow any client that can reach /scep to + // enroll a CSR against the configured issuer (anonymous issuance). + if c.SCEP.Enabled && c.SCEP.ChallengePassword == "" { + return fmt.Errorf("SCEP is enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is empty — refuse to start (CWE-306: anonymous SCEP issuance is insecure; set a non-empty shared secret or disable SCEP with CERTCTL_SCEP_ENABLED=false). This gate duplicates cmd/server/main.go:preflightSCEPChallengePassword for defense in depth") + } + // Validate scheduler intervals if c.Scheduler.RenewalCheckInterval < 1*time.Minute { return fmt.Errorf("renewal check interval must be at least 1 minute")