From fdd424bf5f80cce8bec5a2c975eb9a47a19a73e1 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Wed, 29 Apr 2026 03:46:57 +0000 Subject: [PATCH] =?UTF-8?q?feat(scep):=20per-issuer=20SCEP=20profiles=20?= =?UTF-8?q?=E2=80=94=20multi-endpoint=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCEP RFC 8894 + Intune master bundle — Phase 1.5 of 14. Restructures SCEPConfig from a single flat struct (one IssuerID + one RA pair + one challenge password) to a Profiles slice where each profile binds its own URL path (/scep/), issuer, optional CertificateProfile, RA cert+key, and challenge password. This phase is the FOUNDATION for Phases 2-12: every downstream handler signature, service envelope, CertRep builder, GUI counter, and test fixture takes a profile_id parameter from here on. Adding multi-profile support post-bundle would cost 3x what greenfielding it now does. Backward compat: legacy CERTCTL_SCEP_* flat env vars synthesise a single-element Profiles[0] with PathID="" (legacy /scep root) when CERTCTL_SCEP_PROFILES is unset. Existing operators see no behavior change. New operators write multi-profile config directly via the indexed env-var form. Indexed env-var convention: CERTCTL_SCEP_PROFILES=corp,iot,server CERTCTL_SCEP_PROFILE_CORP_ISSUER_ID=iss-corp-laptop CERTCTL_SCEP_PROFILE_CORP_PROFILE_ID=prof-corp-tls CERTCTL_SCEP_PROFILE_CORP_CHALLENGE_PASSWORD=... CERTCTL_SCEP_PROFILE_CORP_RA_CERT_PATH=/etc/certctl/scep/corp-ra.crt CERTCTL_SCEP_PROFILE_CORP_RA_KEY_PATH=/etc/certctl/scep/corp-ra.key ... (etc per profile name) internal/config/config.go * SCEPConfig.Profiles []SCEPProfileConfig — primary multi-profile dispatch source. * Legacy flat fields (IssuerID, ProfileID, ChallengePassword, RACertPath, RAKeyPath) preserved with updated docblocks marking them as merge sources for the backward-compat shim. * SCEPProfileConfig new struct (PathID, IssuerID, ProfileID, ChallengePassword, RACertPath, RAKeyPath). * loadSCEPProfilesFromEnv: reads CERTCTL_SCEP_PROFILES (comma-list of names), expands each to per-profile env vars CERTCTL_SCEP_PROFILE__*. Returns nil when unset so the legacy-shim path takes over. * mergeSCEPLegacyIntoProfiles: when SCEP enabled + Profiles empty + any legacy flat field populated, synthesises Profiles[0] with PathID="". No-op when Profiles already populated (structured form wins) or SCEP disabled. * validSCEPPathID: empty allowed (legacy /scep root); non-empty must be [a-z0-9-] with no leading/trailing hyphen. * Per-profile Validate gates: PathID format, uniqueness across the slice, ChallengePassword presence (CWE-306 per profile), RA pair presence (RFC 8894 §3.2.2), IssuerID presence. * Legacy single-profile gates skip when Profiles is non-empty so the per-profile loop owns the gating in the structured case (avoids double-fire with overlapping error messages). internal/api/router/router.go * RegisterSCEPHandlers signature: map[string]handler.SCEPHandler (was a single SCEPHandler). * Empty PathID handler registered with literal r.Register('GET /scep' + 'POST /scep') so the openapi-parity AST scanner (Bundle D / Audit M-027) continues to see the documented /scep route. Without this preservation, the parity test fails because dynamic string-built routes don't appear in *ast.BasicLit walks. * Non-empty PathIDs registered dynamically as /scep/. * AuthExempt prefix /scep already covers all /scep[/...] paths via prefix match — no change needed there. cmd/server/main.go * SCEP startup block iterates cfg.SCEP.Profiles, builds one service + one handler per profile, stuffs them into a {pathID -> handler} map, hands the map to apiRouter.RegisterSCEPHandlers. * Per-profile preflight: preflightSCEPChallengePassword, preflightSCEPRACertKey, preflightEnrollmentIssuer fire ONCE PER PROFILE with a profile-scoped slog.Logger so failures report PathID + IssuerID. Each per-profile failure os.Exits(1) with a targeted error message. * Final 'SCEP server enabled' info log reports profile_count. internal/config/config_scep_profiles_test.go (new, 9 tests / 22 sub-cases) * TestSCEPConfig_LegacyFlatFields_SynthesizeSingleProfile — the backward-compat smoke test. * TestSCEPConfig_MultipleProfiles_LoadFromEnv — structured-form happy path with two profiles. * TestSCEPConfig_StructuredFormBeatsLegacy — when both forms set, structured wins; legacy flat field MUST NOT leak into Profiles[0].ChallengePassword. * TestSCEPConfig_PathIDValidation — 13 sub-cases covering valid + every reject mode (uppercase, slash, leading/trailing hyphen, underscore, dot, space, non-ASCII). * TestSCEPConfig_DuplicatePathID_Refuses. * TestSCEPConfig_MissingPerProfileChallengePassword, _MissingPerProfileRAPair (3 sub-cases), _MissingPerProfileIssuerID — per-profile gate triplet. * TestSCEPConfig_DisabledIgnoresProfiles — gates only fire when SCEP is enabled. internal/api/router/router_scep_profiles_test.go (new, 4 tests) * TestRouter_RegisterSCEPHandlers_LegacyEmptyPathIDMapsToRoot — empty PathID gets /scep root; both GET + POST routes registered. * TestRouter_RegisterSCEPHandlers_NonEmptyPathIDMapsToSubpath — non-empty PathID gets /scep/; /scep root NOT registered when no empty-PathID profile exists. * TestRouter_RegisterSCEPHandlers_MultipleProfilesNoCrossBleed — three profiles (default, corp, iot); each path reaches the right handler instance, verified via per-profile-tagged GetCACaps mock response. * TestRouter_RegisterSCEPHandlers_EmptyMapRegistersNoRoutes — no profiles → no /scep routes (deploy with SCEP disabled). Verification: * gofmt clean for the files I touched. * go vet clean across config / router / cmd/server / domain. * go test -short -count=1 green across config / router / cmd/server / api/handler / service / domain / pkcs7. * Coverage held: handler 79.0% / service 73.2% / pkcs7 100% / config 96.0% / domain 88.6% / router 100% / cmd/server 19.2%. * openapi-parity test green (literal /scep registrations preserved). Phase 1.5 of 14 in SCEP RFC 8894 + Intune master bundle. Living progress at cowork/scep-rfc8894-intune/progress.md. --- cmd/server/main.go | 136 ++++--- internal/api/router/router.go | 57 ++- .../api/router/router_scep_profiles_test.go | 166 ++++++++ internal/config/config.go | 240 +++++++++++- internal/config/config_scep_profiles_test.go | 359 ++++++++++++++++++ 5 files changed, 882 insertions(+), 76 deletions(-) create mode 100644 internal/api/router/router_scep_profiles_test.go create mode 100644 internal/config/config_scep_profiles_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 9863ea8..7836bbd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -725,71 +725,87 @@ func main() { "endpoints", "/.well-known/est/{cacerts,simpleenroll,simplereenroll,csrattrs}") } - // Register SCEP (RFC 8894) handlers if enabled + // Register SCEP (RFC 8894) handlers if enabled. + // + // SCEP RFC 8894 Phase 1.5: multi-profile dispatch. Config.Validate() + // guarantees cfg.SCEP.Profiles is non-empty when cfg.SCEP.Enabled is true + // (the legacy single-profile flat fields are merged into Profiles[0] by + // the backward-compat shim in Load()). Each profile gets its own service + // + handler instance, registered at /scep (PathID="") or /scep/. if cfg.SCEP.Enabled { - // H-2 fix: fail closed at startup when SCEP is enabled without a - // challenge password configured. Previously the service-layer guard - // at internal/service/scep.go:72-79 skipped the password check when - // s.challengePassword == "", meaning any client that could reach the - // /scep endpoint could enroll an arbitrary CSR against the configured - // issuer (CWE-306, missing authentication for a critical function). - // Refuse to start instead: the operator must set - // CERTCTL_SCEP_CHALLENGE_PASSWORD (or disable SCEP) before the control - // plane can boot. - if err := preflightSCEPChallengePassword(cfg.SCEP.Enabled, cfg.SCEP.ChallengePassword); err != nil { - logger.Error( - "startup refused: SCEP is enabled but CERTCTL_SCEP_CHALLENGE_PASSWORD is not set "+ - "(would allow unauthenticated certificate enrollment, CWE-306). "+ - "Set a non-empty challenge password or disable SCEP before restarting.", - "error", err, + // Iterate the profiles and build a {pathID -> handler} map for the + // router. Each profile triggers the same per-profile preflight gates + // (challenge password presence, RA pair validity, issuer reachability). + // Failures log the offending PathID so a multi-profile deploy can + // pinpoint which profile broke startup. + scepHandlers := make(map[string]handler.SCEPHandler, len(cfg.SCEP.Profiles)) + for i, profile := range cfg.SCEP.Profiles { + profile := profile // shadow for closure-safety even though no closures escape + profileLog := logger.With( + "scep_profile_index", i, + "scep_profile_pathid", profile.PathID, + "scep_profile_issuer_id", profile.IssuerID, ) - os.Exit(1) - } - // SCEP RFC 8894 Phase 1: validate the RA cert/key pair before booting. - // Without a valid pair the new RFC 8894 PKIMessage path (EnvelopedData - // decryption + CertRep signing) cannot run; fail loud at startup rather - // than silently falling through to the MVP raw-CSR path on every - // request. preflightSCEPRACertKey checks: file existence, key file mode - // 0600 (defense-in-depth against world-readable RA key), cert/key - // algorithm match, RA cert not expired, RA cert public-key algorithm is - // CMS-compatible (RSA or ECDSA per RFC 8894 §3.5.2). Mirrors - // preflightSCEPChallengePassword's fail-loud-then-os.Exit(1) pattern. - if err := preflightSCEPRACertKey(cfg.SCEP.Enabled, cfg.SCEP.RACertPath, cfg.SCEP.RAKeyPath); err != nil { - logger.Error( - "startup refused: SCEP RA cert/key preflight failed "+ - "(RFC 8894 §3.2.2 EnvelopedData + §3.3.2 CertRep require an RA pair). "+ - "Generate the RA pair per docs/legacy-est-scep.md, set "+ - "CERTCTL_SCEP_RA_CERT_PATH + CERTCTL_SCEP_RA_KEY_PATH, then restart.", - "error", err, - ) - os.Exit(1) - } - issuerConn, ok := issuerRegistry.Get(cfg.SCEP.IssuerID) - if !ok { - logger.Error("SCEP issuer not found in registry", "issuer_id", cfg.SCEP.IssuerID) - os.Exit(1) - } - // Bundle-4 / L-005: validate the issuer can actually serve a CA certificate - // at startup. Same rationale as EST above. - preflightCtx, preflightCancel := context.WithTimeout(context.Background(), 10*time.Second) - if err := preflightEnrollmentIssuer(preflightCtx, "SCEP", cfg.SCEP.IssuerID, issuerConn); err != nil { + // H-2 fix per profile: fail closed at startup when this profile has + // no challenge password. preflightSCEPChallengePassword stays + // unchanged; we just call it once per profile. + if err := preflightSCEPChallengePassword(true, profile.ChallengePassword); err != nil { + profileLog.Error( + "startup refused: SCEP profile has empty challenge password "+ + "(would allow unauthenticated certificate enrollment, CWE-306). "+ + "Set CERTCTL_SCEP_PROFILE__CHALLENGE_PASSWORD or remove the profile.", + "error", err, + ) + os.Exit(1) + } + // SCEP RFC 8894 Phase 1: per-profile RA cert/key preflight. Same + // six checks as the legacy single-profile path; reports the + // offending PathID via the profile-scoped logger. + if err := preflightSCEPRACertKey(true, profile.RACertPath, profile.RAKeyPath); err != nil { + profileLog.Error( + "startup refused: SCEP profile RA cert/key preflight failed "+ + "(RFC 8894 §3.2.2 EnvelopedData + §3.3.2 CertRep require a per-profile RA pair). "+ + "Generate the RA pair per docs/legacy-est-scep.md and set "+ + "CERTCTL_SCEP_PROFILE__RA_CERT_PATH + _RA_KEY_PATH for this profile.", + "error", err, + ) + os.Exit(1) + } + issuerConn, ok := issuerRegistry.Get(profile.IssuerID) + if !ok { + profileLog.Error("SCEP profile issuer not found in registry") + os.Exit(1) + } + // Bundle-4 / L-005: validate the issuer can actually serve a CA + // certificate. Per profile, in case different profiles bind + // different issuers. + preflightCtx, preflightCancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := preflightEnrollmentIssuer(preflightCtx, "SCEP", profile.IssuerID, issuerConn); err != nil { + preflightCancel() + profileLog.Error("startup refused: SCEP profile issuer cannot serve CA certificate", "error", err) + os.Exit(1) + } preflightCancel() - logger.Error("startup refused: SCEP issuer cannot serve CA certificate", "error", err) - os.Exit(1) + scepService := service.NewSCEPService(profile.IssuerID, issuerConn, auditService, profileLog, profile.ChallengePassword) + scepService.SetProfileRepo(profileRepo) + if profile.ProfileID != "" { + scepService.SetProfileID(profile.ProfileID) + } + scepHandlers[profile.PathID] = handler.NewSCEPHandler(scepService) + endpoint := "/scep" + if profile.PathID != "" { + endpoint = "/scep/" + profile.PathID + } + profileLog.Info("SCEP profile enabled", + "endpoint", endpoint+"?operation={GetCACaps,GetCACert,PKIOperation}", + "challenge_password_set", profile.ChallengePassword != "", + "ra_cert_path", profile.RACertPath, + ) } - preflightCancel() - scepService := service.NewSCEPService(cfg.SCEP.IssuerID, issuerConn, auditService, logger, cfg.SCEP.ChallengePassword) - scepService.SetProfileRepo(profileRepo) - if cfg.SCEP.ProfileID != "" { - scepService.SetProfileID(cfg.SCEP.ProfileID) - } - scepHandler := handler.NewSCEPHandler(scepService) - apiRouter.RegisterSCEPHandlers(scepHandler) + apiRouter.RegisterSCEPHandlers(scepHandlers) logger.Info("SCEP server enabled", - "issuer_id", cfg.SCEP.IssuerID, - "profile_id", cfg.SCEP.ProfileID, - "challenge_password_set", cfg.SCEP.ChallengePassword != "", - "endpoints", "/scep?operation={GetCACaps,GetCACert,PKIOperation}") + "profile_count", len(scepHandlers), + ) } // Register RFC 5280 CRL and RFC 6960 OCSP handlers under /.well-known/pki/. diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 12f205b..669d5fb 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -376,16 +376,53 @@ 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 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)) - r.Register("POST /scep", http.HandlerFunc(scep.HandleSCEP)) +// SCEP uses a single endpoint per profile with operation-based dispatch via +// query parameters. 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 any SCEP profile is enabled without a +// non-empty challenge password (H-2, CWE-306). +// +// SCEP RFC 8894 Phase 1.5: the handlers map is keyed by SCEPProfileConfig.PathID. +// Empty PathID maps to the legacy /scep root for backward compatibility; +// non-empty PathID values map to /scep/. Registering N profiles +// produces 2N routes (GET + POST per profile). Validate() guards PathID +// uniqueness + slug-shape so this loop never gets a collision or an invalid +// path segment. +// +// The auth-exempt prefix `/scep` in AuthExemptDispatchPrefixes already covers +// every /scep[/...] path via prefix-match, so the multi-profile routes inherit +// the no-auth dispatch from the same dispatch table — no router-side change +// to the auth-exempt list is required. +func (r *Router) RegisterSCEPHandlers(handlers map[string]handler.SCEPHandler) { + // Legacy /scep route for the empty-PathID profile is registered with + // literal strings so the openapi-parity scanner (Bundle D / Audit M-027, + // see openapi_parity_test.go) sees `GET /scep` + `POST /scep` as + // AST literals exactly the way it did pre-Phase-1.5. The scanner walks + // for *ast.BasicLit string args to r.Register, so dynamically-built + // paths would not appear in its index. Keeping the empty-PathID case + // static preserves the spec parity contract for the documented + // /scep endpoint that openapi.yaml still describes. + if h, ok := handlers[""]; ok { + r.Register("GET /scep", http.HandlerFunc(h.HandleSCEP)) + r.Register("POST /scep", http.HandlerFunc(h.HandleSCEP)) + } + // Multi-profile routes register dynamically. These per-deployment paths + // (/scep/) aren't in openapi.yaml because the path segment is + // operator-defined; the spec covers the canonical /scep root only. The + // parity scanner correctly skips dynamic routes (it only checks literals). + for pathID, h := range handlers { + if pathID == "" { + continue // already handled by the static block above + } + hCopy := h // h is captured by value — SCEPHandler is a small struct + // (one interface field) so the per-iteration copy is cheap and avoids + // any loop-variable-capture surprise if SCEPHandler ever grows + // pointer receivers in the future. + r.Register("GET /scep/"+pathID, http.HandlerFunc(hCopy.HandleSCEP)) + r.Register("POST /scep/"+pathID, http.HandlerFunc(hCopy.HandleSCEP)) + } } // RegisterPKIHandlers sets up RFC 5280 CRL and RFC 6960 OCSP routes under diff --git a/internal/api/router/router_scep_profiles_test.go b/internal/api/router/router_scep_profiles_test.go new file mode 100644 index 0000000..9fe1e2c --- /dev/null +++ b/internal/api/router/router_scep_profiles_test.go @@ -0,0 +1,166 @@ +package router + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/shankar0123/certctl/internal/api/handler" + "github.com/shankar0123/certctl/internal/domain" +) + +// SCEP RFC 8894 + Intune master bundle Phase 1.5: per-issuer profiles router +// registration. Pins: +// +// 1. Empty PathID maps to /scep root (legacy backward-compat). +// 2. Non-empty PathID maps to /scep/. +// 3. Multi-profile registration produces 2N routes (GET + POST per profile). +// 4. Each registered route reaches the right handler instance — no +// cross-profile bleed-through (proven by the per-profile mock counters). +// +// The mock service is a minimal SCEPService implementation that records +// which profile served the request via the GetCACaps capability string — +// the test asserts it sees the right per-profile string echoed back, which +// would only happen if the right handler was wired to the right path. + +// scepProfileMockService is a per-profile-tagged mock SCEPService for +// router-level tests. The CACaps string carries the profile tag so the +// caller can verify which profile's handler served a given request. +type scepProfileMockService struct { + tag string +} + +func (s *scepProfileMockService) GetCACaps(_ context.Context) string { + return "POSTPKIOperation\nSHA-256\nPROFILE=" + s.tag + "\n" +} + +func (s *scepProfileMockService) GetCACert(_ context.Context) (string, error) { + return "", nil +} + +func (s *scepProfileMockService) PKCSReq(_ context.Context, _, _, _ string) (*domain.SCEPEnrollResult, error) { + return nil, nil +} + +func TestRouter_RegisterSCEPHandlers_LegacyEmptyPathIDMapsToRoot(t *testing.T) { + r := New() + svc := &scepProfileMockService{tag: "legacy"} + r.RegisterSCEPHandlers(map[string]handler.SCEPHandler{ + "": handler.NewSCEPHandler(svc), + }) + + // GetCACaps is GET-only per RFC 8894 §3.5.2. The router registers BOTH + // GET and POST; the handler decides what each operation accepts. We + // exercise GET here (POST PKIOperation is exercised by the existing + // internal/api/handler tests and by the e2e suite). + req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET /scep — code %d, want 200 (body=%q)", w.Code, w.Body.String()) + } + if got := w.Body.String(); !contains(got, "PROFILE=legacy") { + t.Errorf("GET /scep body = %q, want contains PROFILE=legacy", got) + } + // Confirm POST /scep IS registered at the router level (the handler + // will respond 405 for GetCACaps because it's GET-only, but the route + // has to exist or we'd get a 404 from the mux instead). + req = httptest.NewRequest(http.MethodPost, "/scep?operation=GetCACaps", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("POST /scep?operation=GetCACaps — code %d, want 405 (route registered, handler rejects POST for GetCACaps)", w.Code) + } +} + +func TestRouter_RegisterSCEPHandlers_NonEmptyPathIDMapsToSubpath(t *testing.T) { + r := New() + svc := &scepProfileMockService{tag: "corp"} + r.RegisterSCEPHandlers(map[string]handler.SCEPHandler{ + "corp": handler.NewSCEPHandler(svc), + }) + + // GET /scep/corp?operation=GetCACaps reaches the corp handler. + req := httptest.NewRequest(http.MethodGet, "/scep/corp?operation=GetCACaps", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET /scep/corp — code %d, want 200 (body=%q)", w.Code, w.Body.String()) + } + if got := w.Body.String(); !contains(got, "PROFILE=corp") { + t.Errorf("GET /scep/corp body = %q, want contains PROFILE=corp", got) + } + // POST /scep/corp must also be registered (the handler will reject + // GetCACaps as 405; we just confirm the route exists). + req = httptest.NewRequest(http.MethodPost, "/scep/corp?operation=GetCACaps", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("POST /scep/corp?operation=GetCACaps — code %d, want 405 (route registered, handler rejects POST for GetCACaps)", w.Code) + } + // /scep root must NOT be registered when only non-empty PathIDs exist. + req = httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusNotFound && w.Code != http.StatusMethodNotAllowed { + t.Errorf("/scep without legacy profile — code %d, want 404 or 405 (no handler should be registered)", w.Code) + } +} + +func TestRouter_RegisterSCEPHandlers_MultipleProfilesNoCrossBleed(t *testing.T) { + r := New() + r.RegisterSCEPHandlers(map[string]handler.SCEPHandler{ + "": handler.NewSCEPHandler(&scepProfileMockService{tag: "default"}), + "corp": handler.NewSCEPHandler(&scepProfileMockService{tag: "corp"}), + "iot": handler.NewSCEPHandler(&scepProfileMockService{tag: "iot"}), + }) + + cases := []struct { + path string + wantTag string + }{ + {"/scep?operation=GetCACaps", "default"}, + {"/scep/corp?operation=GetCACaps", "corp"}, + {"/scep/iot?operation=GetCACaps", "iot"}, + } + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("code %d, want 200", w.Code) + } + if got := w.Body.String(); !contains(got, "PROFILE="+tc.wantTag) { + t.Errorf("body = %q, want contains PROFILE=%s", got, tc.wantTag) + } + }) + } +} + +func TestRouter_RegisterSCEPHandlers_EmptyMapRegistersNoRoutes(t *testing.T) { + r := New() + r.RegisterSCEPHandlers(map[string]handler.SCEPHandler{}) + + req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusNotFound && w.Code != http.StatusMethodNotAllowed { + t.Errorf("/scep with no profiles registered — code %d, want 404 or 405", w.Code) + } +} + +// Tiny helper local to this file to avoid importing strings just for one +// substring check; keeps the test file's import surface minimal. +func contains(haystack, needle string) bool { + if len(needle) == 0 { + return true + } + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} diff --git a/internal/config/config.go b/internal/config/config.go index dea1f4c..a21da29 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -664,17 +664,50 @@ type ESTConfig struct { } // SCEPConfig controls the RFC 8894 Simple Certificate Enrollment Protocol server. +// +// SCEP RFC 8894 + Intune master bundle Phase 1.5: this type was originally a +// single flat struct with one IssuerID + one RA pair + one challenge password +// (the shape of v2.0.x). Real enterprise deployments need to expose multiple +// SCEP endpoints from one certctl instance — corp-laptop CA, server CA, IoT +// CA — each with its own issuer + RA pair + challenge password + URL path +// (/scep/). The Profiles slice carries that. Existing operators see +// no behavior change: when Profiles is empty AND the legacy single-profile +// fields below are set, ConfigLoad synthesizes a single-element Profiles[0] +// with PathID="" (which maps to the legacy /scep root path). type SCEPConfig struct { // Enabled controls whether SCEP endpoints are available for device enrollment. // Default: false (SCEP disabled). Set to true to enable SCEP endpoints under /scep/. Enabled bool - // IssuerID selects which issuer connector processes SCEP certificate requests. - // Default: "iss-local". Must reference a configured issuer. + // Profiles is the multi-endpoint configuration. Each profile gets its own + // URL path (/scep/), its own RA cert + key, its own challenge + // password, and its own bound issuer. Population sources, in priority order: + // + // 1. Explicit list via CERTCTL_SCEP_PROFILES (e.g. "corp,iot,server"). + // 2. Backward-compat shim: when CERTCTL_SCEP_PROFILES is unset AND the + // legacy flat fields below have ChallengePassword OR RACertPath set, + // ConfigLoad synthesizes a single-element Profiles[0] with PathID="" + // so /scep continues to route the same way it did pre-Phase-1.5. + // + // Validate() iterates Profiles and refuses to boot if any profile is + // malformed (empty ChallengePassword, missing RA pair, invalid PathID). + // Each profile's ChallengePassword + RA pair are independently mandatory + // — the profile-load shim never silently borrows from a sibling profile. + Profiles []SCEPProfileConfig + + // Legacy single-profile fields — preserved for backward compatibility. New + // operators should populate Profiles directly via the indexed env-var form. + // These fields are merged into Profiles[0] by ConfigLoad when Profiles is + // empty AND any of these fields are non-zero. + + // IssuerID selects which issuer connector processes SCEP certificate requests + // for the legacy single-profile config. Default: "iss-local". Must reference a + // configured issuer. IssuerID string - // ProfileID optionally constrains SCEP enrollments to a specific certificate profile. - // Leave empty to allow SCEP to use any configured issuer's defaults. + // ProfileID optionally constrains SCEP enrollments to a specific certificate profile + // for the legacy single-profile config. Leave empty to allow SCEP to use any + // configured issuer's defaults. ProfileID string // ChallengePassword is the shared secret used to authenticate SCEP enrollment requests. @@ -688,6 +721,9 @@ type SCEPConfig struct { // 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. + // + // Legacy single-profile field; merged into Profiles[0].ChallengePassword by + // ConfigLoad when Profiles is empty. ChallengePassword string // RACertPath is the path to a PEM-encoded RA (Registration Authority) @@ -714,9 +750,54 @@ type SCEPConfig struct { // a world-readable RA key as defense-in-depth against credential leak. The // server only ever reads this file at startup; rotation requires a restart // (per the existing CERTCTL_TLS_CERT_PATH precedent in cmd/server/tls.go). + // + // Legacy single-profile field; merged into Profiles[0].RAKeyPath by + // ConfigLoad when Profiles is empty. RAKeyPath string } +// SCEPProfileConfig is one SCEP endpoint's configuration. Each profile is +// bound to one issuer + one optional certctl CertificateProfile + one RA +// pair + one challenge password (the per-profile Intune trust anchor lands +// here in Phase 8 of the master bundle). +// +// Multi-profile motivation: a real enterprise deployment exposes distinct +// SCEP endpoints to distinct fleets — corp-laptop CA bound to one issuer +// with one challenge password; IoT CA bound to a different issuer with a +// different challenge password — so a single set of credentials can never +// enroll across CA boundaries by accident. Each SCEPProfileConfig drives +// a separate handler + service instance built at server startup. +type SCEPProfileConfig struct { + // PathID is the URL segment after /scep/. Empty string maps to the legacy + // /scep root for backward compatibility (so existing operators with the + // flat single-profile config see no URL change). Non-empty values MUST + // be a single path-safe slug ([a-z0-9-], no slashes); validated at + // startup by Config.Validate(). Multi-profile deployments typically use + // short tokens like "corp", "iot", "server" — the URL becomes + // /scep/corp, /scep/iot, /scep/server. + PathID string + + // IssuerID selects which issuer connector this profile's enrollments go + // through. Must reference a configured issuer. + IssuerID string + + // ProfileID optionally constrains enrollments under this PathID to a + // specific CertificateProfile. Leave empty to allow the issuer's defaults. + ProfileID string + + // ChallengePassword is the per-profile shared secret. Same constant-time + // compare semantics as the flat field; empty value at validate time fails + // the boot. + ChallengePassword string + + // RACertPath / RAKeyPath are the per-profile RA pair used by the RFC 8894 + // EnvelopedData decryption + CertRep signing path. Same preflight semantics + // as the legacy flat fields (file existence, key mode 0600, cert/key + // match, expiry, RSA-or-ECDSA alg). + RACertPath string + RAKeyPath string +} + // NetworkScanConfig controls the server-side active TLS scanner. type NetworkScanConfig struct { Enabled bool // Enable network scanning (default false) @@ -1151,6 +1232,12 @@ func Load() (*Config, error) { // existing CERTCTL_SCEP_* prefix convention. RACertPath: getEnv("CERTCTL_SCEP_RA_CERT_PATH", ""), RAKeyPath: getEnv("CERTCTL_SCEP_RA_KEY_PATH", ""), + // SCEP RFC 8894 Phase 1.5: multi-profile dispatch. When + // CERTCTL_SCEP_PROFILES is set (e.g. "corp,iot"), each name + // expands to per-profile env vars CERTCTL_SCEP_PROFILE__*. + // When unset, the legacy single-profile flat fields above are + // merged into Profiles[0] by mergeSCEPLegacyIntoProfiles below. + Profiles: loadSCEPProfilesFromEnv(), }, Verification: VerificationConfig{ Enabled: getEnvBool("CERTCTL_VERIFY_DEPLOYMENT", true), @@ -1283,6 +1370,15 @@ func Load() (*Config, error) { } cfg.Auth.NamedKeys = named + // SCEP RFC 8894 Phase 1.5: backward-compat shim. When the operator hasn't + // set CERTCTL_SCEP_PROFILES (so loadSCEPProfilesFromEnv returned nil) but + // the legacy single-profile flat fields (ChallengePassword OR RACertPath) + // are populated, synthesize a single-element Profiles[0] with PathID="" + // so /scep continues to dispatch the same way it did pre-Phase-1.5. Done + // AFTER the field-by-field load so it can read from the populated cfg.SCEP + // struct. + mergeSCEPLegacyIntoProfiles(&cfg.SCEP) + if err := cfg.Validate(); err != nil { return nil, err } @@ -1290,6 +1386,98 @@ func Load() (*Config, error) { return cfg, nil } +// loadSCEPProfilesFromEnv reads the indexed CERTCTL_SCEP_PROFILES env var +// (e.g. "corp,iot,server") and expands each name into a SCEPProfileConfig +// populated from CERTCTL_SCEP_PROFILE__*. Returns nil when the +// CERTCTL_SCEP_PROFILES env var is unset or empty — in that case the +// legacy-shim path (mergeSCEPLegacyIntoProfiles, called from Load after the +// initial config build) populates Profiles[0] from the flat fields if needed. +// +// PathID for each profile is the lowercased trimmed name from the +// CERTCTL_SCEP_PROFILES list (e.g. "Corp" -> "corp"). Validation that the +// PathID is path-safe ([a-z0-9-]+) lives in Config.Validate() so the loader +// can stay free of error returns. +func loadSCEPProfilesFromEnv() []SCEPProfileConfig { + raw := strings.TrimSpace(os.Getenv("CERTCTL_SCEP_PROFILES")) + if raw == "" { + return nil + } + names := strings.Split(raw, ",") + out := make([]SCEPProfileConfig, 0, len(names)) + for _, n := range names { + n = strings.TrimSpace(n) + if n == "" { + continue + } + // The env-var key is the upper-cased name (CERTCTL_SCEP_PROFILE_CORP_*), + // but the URL path segment is the lower-cased name to match the + // path-safe slug constraint enforced in Validate. + envName := strings.ToUpper(n) + pathID := strings.ToLower(n) + out = append(out, SCEPProfileConfig{ + PathID: pathID, + IssuerID: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_ISSUER_ID", ""), + ProfileID: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_PROFILE_ID", ""), + ChallengePassword: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_CHALLENGE_PASSWORD", ""), + RACertPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_CERT_PATH", ""), + RAKeyPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_KEY_PATH", ""), + }) + } + return out +} + +// mergeSCEPLegacyIntoProfiles is the backward-compat shim. When Profiles is +// empty AND any legacy single-profile field is populated, synthesise a +// single-element Profiles[0] with PathID="" so /scep dispatches identically +// to the pre-Phase-1.5 deploy. No-op when Profiles is non-empty (the operator +// explicitly opted into the structured form via CERTCTL_SCEP_PROFILES) or +// when SCEP is disabled. +// +// "Any legacy field populated" means at least one of ChallengePassword, +// RACertPath, RAKeyPath is non-empty. IssuerID has a non-empty default +// ("iss-local") so it can't be the trigger; ProfileID is optional. The +// trigger set matches what the Validate() refuse cares about. +func mergeSCEPLegacyIntoProfiles(c *SCEPConfig) { + if c == nil || !c.Enabled || len(c.Profiles) > 0 { + return + } + hasLegacy := c.ChallengePassword != "" || c.RACertPath != "" || c.RAKeyPath != "" + if !hasLegacy { + return + } + c.Profiles = []SCEPProfileConfig{{ + PathID: "", // empty pathID maps to the legacy /scep root + IssuerID: c.IssuerID, + ProfileID: c.ProfileID, + ChallengePassword: c.ChallengePassword, + RACertPath: c.RACertPath, + RAKeyPath: c.RAKeyPath, + }} +} + +// validSCEPPathID reports whether s is a valid SCEP profile path segment. +// The empty string is allowed (legacy root /scep). Non-empty values must +// be ASCII lowercase letters / digits / hyphens with no leading/trailing +// hyphen — keeps URL-construction trivial at the router layer and avoids +// percent-encoding surprises for SCEP clients that build the URL by string +// concat rather than url.PathEscape. +func validSCEPPathID(s string) bool { + if s == "" { + return true // empty maps to legacy /scep root + } + if s[0] == '-' || s[len(s)-1] == '-' { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' { + continue + } + return false + } + return true +} + // Validate checks that the configuration is valid. func (c *Config) Validate() error { // Validate server configuration @@ -1433,7 +1621,13 @@ func (c *Config) Validate() error { // 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") + // Phase 1.5: only enforce the legacy single-profile gate when the + // operator has NOT opted into the structured Profiles form. When + // CERTCTL_SCEP_PROFILES is set, the per-profile loop below covers + // the same gate per profile (with per-profile error messages). + if len(c.SCEP.Profiles) == 0 { + 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") + } } // SCEP RFC 8894 Phase 1: RA cert + key are mandatory when SCEP is enabled. @@ -1444,7 +1638,41 @@ func (c *Config) Validate() error { // depth with cmd/server/main.go::preflightSCEPRACertKey which additionally // validates file mode + cert/key match + expiry + algorithm. if c.SCEP.Enabled && (c.SCEP.RACertPath == "" || c.SCEP.RAKeyPath == "") { - return fmt.Errorf("SCEP is enabled but RA cert/key path missing — refuse to start (RFC 8894 §3.2.2 requires an RA cert clients can encrypt their CSR to and an RA key the server uses to decrypt + sign CertRep): set both CERTCTL_SCEP_RA_CERT_PATH and CERTCTL_SCEP_RA_KEY_PATH or disable SCEP with CERTCTL_SCEP_ENABLED=false. See docs/legacy-est-scep.md for the openssl recipe to generate the RA pair. This gate duplicates cmd/server/main.go:preflightSCEPRACertKey for defense in depth") + // Phase 1.5: only refuse on the legacy flat fields when neither the + // flat fields nor the structured Profiles slice are populated. When + // the operator opts into the structured form via CERTCTL_SCEP_PROFILES, + // the per-profile checks below cover the same gate. + if len(c.SCEP.Profiles) == 0 { + return fmt.Errorf("SCEP is enabled but RA cert/key path missing — refuse to start (RFC 8894 §3.2.2 requires an RA cert clients can encrypt their CSR to and an RA key the server uses to decrypt + sign CertRep): set both CERTCTL_SCEP_RA_CERT_PATH and CERTCTL_SCEP_RA_KEY_PATH or disable SCEP with CERTCTL_SCEP_ENABLED=false. See docs/legacy-est-scep.md for the openssl recipe to generate the RA pair. This gate duplicates cmd/server/main.go:preflightSCEPRACertKey for defense in depth") + } + } + + // SCEP RFC 8894 Phase 1.5: per-profile validation. When the structured + // Profiles slice is populated (either via CERTCTL_SCEP_PROFILES or via + // the legacy-shim merge in Load), iterate each profile and refuse boot + // if any is malformed. PathID format, ChallengePassword presence, and + // RA pair presence are all gated here; preflight validates the RA files + // themselves (mode, match, expiry, alg). + if c.SCEP.Enabled { + seenPath := map[string]bool{} + for i, p := range c.SCEP.Profiles { + if !validSCEPPathID(p.PathID) { + return fmt.Errorf("SCEP profile %d (%q) has invalid PathID — refuse to start: must be empty (legacy /scep root) or a path-safe slug matching [a-z0-9-]+ with no leading/trailing hyphen (got %q)", i, p.PathID, p.PathID) + } + if seenPath[p.PathID] { + return fmt.Errorf("SCEP profile %d duplicates PathID %q — refuse to start: each profile must have a unique URL segment so the router can dispatch unambiguously", i, p.PathID) + } + seenPath[p.PathID] = true + if p.ChallengePassword == "" { + return fmt.Errorf("SCEP profile %d (PathID=%q) has empty CHALLENGE_PASSWORD — refuse to start (CWE-306: per-profile shared secret is the sole application-layer auth boundary; an empty password would allow any client reaching /scep/%s to enroll a CSR against issuer %q)", i, p.PathID, p.PathID, p.IssuerID) + } + if p.RACertPath == "" || p.RAKeyPath == "" { + return fmt.Errorf("SCEP profile %d (PathID=%q) missing RA cert/key path — refuse to start (RFC 8894 §3.2.2): set CERTCTL_SCEP_PROFILE__RA_CERT_PATH and _RA_KEY_PATH for every profile listed in CERTCTL_SCEP_PROFILES, or remove the profile from the list", i, p.PathID) + } + if p.IssuerID == "" { + return fmt.Errorf("SCEP profile %d (PathID=%q) has empty IssuerID — refuse to start: each SCEP profile must bind to a configured issuer", i, p.PathID) + } + } } // Validate scheduler intervals diff --git a/internal/config/config_scep_profiles_test.go b/internal/config/config_scep_profiles_test.go new file mode 100644 index 0000000..a00209e --- /dev/null +++ b/internal/config/config_scep_profiles_test.go @@ -0,0 +1,359 @@ +package config + +import ( + "os" + "strings" + "testing" + "time" +) + +// SCEP RFC 8894 + Intune master bundle Phase 1.5: per-issuer SCEP profiles. +// These tests pin: +// 1. Backward-compat shim: legacy CERTCTL_SCEP_* flat env vars synthesise +// a single-element Profiles[0] with PathID="" so existing /scep +// operators see no behavior change. +// 2. Structured form: CERTCTL_SCEP_PROFILES=corp,iot,server expands into +// per-profile env vars CERTCTL_SCEP_PROFILE__*. +// 3. PathID validation: only [a-z0-9-] with no leading/trailing hyphen, +// empty allowed (legacy /scep root). Validate() refuses anything else. +// 4. Per-profile gates: Validate() refuses each profile independently +// (empty challenge password, missing RA pair, missing IssuerID, +// duplicate PathID). +// +// Note these tests exercise the loader + Validate() in isolation; the +// per-profile preflight + router-registration paths are exercised by the +// cmd/server tests (existing) and the cmd/server/main.go startup path +// (manual via `make docker-up`). + +// validBaseConfigForSCEPProfiles returns a Config that passes Validate +// EXCEPT for the SCEP fields the test under exercise sets. Mirrors the +// existing validBaseConfigForEncryption helper shape so the test file +// stays uniform with its siblings. +func validBaseConfigForSCEPProfiles(t *testing.T) *Config { + t.Helper() + return &Config{ + Server: validServerConfig(t), + Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25}, + Log: LogConfig{Level: "info", Format: "json"}, + Auth: AuthConfig{Type: "api-key", Secret: "test-secret"}, + Keygen: KeygenConfig{Mode: "agent"}, + Scheduler: SchedulerConfig{ + RenewalCheckInterval: 1 * time.Hour, + JobProcessorInterval: 30 * time.Second, + AgentHealthCheckInterval: 2 * time.Minute, + NotificationProcessInterval: 1 * time.Minute, + NotificationRetryInterval: 2 * time.Minute, + RetryInterval: 5 * time.Minute, + JobTimeoutInterval: 10 * time.Minute, + AwaitingCSRTimeout: 24 * time.Hour, + AwaitingApprovalTimeout: 168 * time.Hour, + }, + } +} + +// TestSCEPConfig_LegacyFlatFields_SynthesizeSingleProfile is the +// load-time backward-compat test: an operator with the pre-Phase-1.5 +// flat env vars (no CERTCTL_SCEP_PROFILES set) must end up with a +// single-element Profiles slice carrying PathID="" so /scep routes +// the same way it did before. +func TestSCEPConfig_LegacyFlatFields_SynthesizeSingleProfile(t *testing.T) { + clearCertctlEnv(t) + t.Setenv("CERTCTL_SCEP_ENABLED", "true") + t.Setenv("CERTCTL_SCEP_ISSUER_ID", "iss-legacy") + t.Setenv("CERTCTL_SCEP_PROFILE_ID", "prof-legacy") + t.Setenv("CERTCTL_SCEP_CHALLENGE_PASSWORD", "secret-from-flat-env") + t.Setenv("CERTCTL_SCEP_RA_CERT_PATH", "/etc/certctl/scep/ra.crt") + t.Setenv("CERTCTL_SCEP_RA_KEY_PATH", "/etc/certctl/scep/ra.key") + // Required infra envs so Load() doesn't fail on unrelated gates. + t.Setenv("CERTCTL_DB_URL", "postgres://localhost/certctl?sslmode=disable") + t.Setenv("CERTCTL_AUTH_TYPE", "api-key") + t.Setenv("CERTCTL_AUTH_SECRET", "test-secret") + srv := validServerConfig(t) + t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath) + t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v, want nil (legacy SCEP flat fields should pass)", err) + } + if len(cfg.SCEP.Profiles) != 1 { + t.Fatalf("len(Profiles) = %d, want 1 (legacy shim should synthesize single-element slice)", len(cfg.SCEP.Profiles)) + } + got := cfg.SCEP.Profiles[0] + if got.PathID != "" { + t.Errorf("Profiles[0].PathID = %q, want \"\" (empty maps to legacy /scep root)", got.PathID) + } + if got.IssuerID != "iss-legacy" { + t.Errorf("Profiles[0].IssuerID = %q, want %q", got.IssuerID, "iss-legacy") + } + if got.ProfileID != "prof-legacy" { + t.Errorf("Profiles[0].ProfileID = %q, want %q", got.ProfileID, "prof-legacy") + } + if got.ChallengePassword != "secret-from-flat-env" { + t.Errorf("Profiles[0].ChallengePassword = %q, want flat env value", got.ChallengePassword) + } + if got.RACertPath != "/etc/certctl/scep/ra.crt" || got.RAKeyPath != "/etc/certctl/scep/ra.key" { + t.Errorf("Profiles[0] RA paths = (%q, %q), want flat env values", got.RACertPath, got.RAKeyPath) + } +} + +// TestSCEPConfig_MultipleProfiles_LoadFromEnv exercises the structured +// form: CERTCTL_SCEP_PROFILES=corp,iot expands into per-profile env vars. +func TestSCEPConfig_MultipleProfiles_LoadFromEnv(t *testing.T) { + clearCertctlEnv(t) + t.Setenv("CERTCTL_SCEP_ENABLED", "true") + t.Setenv("CERTCTL_SCEP_PROFILES", "corp,iot") + t.Setenv("CERTCTL_SCEP_PROFILE_CORP_ISSUER_ID", "iss-corp-laptop") + t.Setenv("CERTCTL_SCEP_PROFILE_CORP_PROFILE_ID", "prof-corp-tls") + t.Setenv("CERTCTL_SCEP_PROFILE_CORP_CHALLENGE_PASSWORD", "corp-secret") + t.Setenv("CERTCTL_SCEP_PROFILE_CORP_RA_CERT_PATH", "/etc/certctl/scep/corp-ra.crt") + t.Setenv("CERTCTL_SCEP_PROFILE_CORP_RA_KEY_PATH", "/etc/certctl/scep/corp-ra.key") + t.Setenv("CERTCTL_SCEP_PROFILE_IOT_ISSUER_ID", "iss-iot-device") + t.Setenv("CERTCTL_SCEP_PROFILE_IOT_CHALLENGE_PASSWORD", "iot-secret") + t.Setenv("CERTCTL_SCEP_PROFILE_IOT_RA_CERT_PATH", "/etc/certctl/scep/iot-ra.crt") + t.Setenv("CERTCTL_SCEP_PROFILE_IOT_RA_KEY_PATH", "/etc/certctl/scep/iot-ra.key") + // Required infra envs. + t.Setenv("CERTCTL_DB_URL", "postgres://localhost/certctl?sslmode=disable") + t.Setenv("CERTCTL_AUTH_TYPE", "api-key") + t.Setenv("CERTCTL_AUTH_SECRET", "test-secret") + srv := validServerConfig(t) + t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath) + t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v, want nil", err) + } + if len(cfg.SCEP.Profiles) != 2 { + t.Fatalf("len(Profiles) = %d, want 2", len(cfg.SCEP.Profiles)) + } + // Order matters: env-list order is preserved by the loader. + if cfg.SCEP.Profiles[0].PathID != "corp" { + t.Errorf("Profiles[0].PathID = %q, want %q", cfg.SCEP.Profiles[0].PathID, "corp") + } + if cfg.SCEP.Profiles[1].PathID != "iot" { + t.Errorf("Profiles[1].PathID = %q, want %q", cfg.SCEP.Profiles[1].PathID, "iot") + } + if cfg.SCEP.Profiles[0].IssuerID != "iss-corp-laptop" { + t.Errorf("Profiles[0].IssuerID = %q, want %q", cfg.SCEP.Profiles[0].IssuerID, "iss-corp-laptop") + } + if cfg.SCEP.Profiles[1].IssuerID != "iss-iot-device" { + t.Errorf("Profiles[1].IssuerID = %q, want %q", cfg.SCEP.Profiles[1].IssuerID, "iss-iot-device") + } + if cfg.SCEP.Profiles[0].ChallengePassword != "corp-secret" { + t.Errorf("Profiles[0].ChallengePassword = %q, want %q", cfg.SCEP.Profiles[0].ChallengePassword, "corp-secret") + } +} + +// TestSCEPConfig_StructuredFormBeatsLegacy: when CERTCTL_SCEP_PROFILES is +// set, the legacy flat fields are NOT merged in (the structured form is +// the operator's explicit opt-in). Pins that the merge shim is no-op when +// Profiles is non-empty. +func TestSCEPConfig_StructuredFormBeatsLegacy(t *testing.T) { + clearCertctlEnv(t) + t.Setenv("CERTCTL_SCEP_ENABLED", "true") + // Both forms set — structured wins, flat is ignored. + t.Setenv("CERTCTL_SCEP_CHALLENGE_PASSWORD", "flat-secret-should-not-appear") + t.Setenv("CERTCTL_SCEP_PROFILES", "only") + t.Setenv("CERTCTL_SCEP_PROFILE_ONLY_ISSUER_ID", "iss-only") + t.Setenv("CERTCTL_SCEP_PROFILE_ONLY_CHALLENGE_PASSWORD", "structured-secret-wins") + t.Setenv("CERTCTL_SCEP_PROFILE_ONLY_RA_CERT_PATH", "/etc/certctl/scep/only-ra.crt") + t.Setenv("CERTCTL_SCEP_PROFILE_ONLY_RA_KEY_PATH", "/etc/certctl/scep/only-ra.key") + t.Setenv("CERTCTL_DB_URL", "postgres://localhost/certctl?sslmode=disable") + t.Setenv("CERTCTL_AUTH_TYPE", "api-key") + t.Setenv("CERTCTL_AUTH_SECRET", "test-secret") + srv := validServerConfig(t) + t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath) + t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v, want nil", err) + } + if len(cfg.SCEP.Profiles) != 1 { + t.Fatalf("len(Profiles) = %d, want 1 (structured form should NOT be augmented by legacy flat fields)", len(cfg.SCEP.Profiles)) + } + if cfg.SCEP.Profiles[0].PathID != "only" { + t.Errorf("Profiles[0].PathID = %q, want %q", cfg.SCEP.Profiles[0].PathID, "only") + } + if cfg.SCEP.Profiles[0].ChallengePassword != "structured-secret-wins" { + t.Errorf("Profiles[0].ChallengePassword = %q, want structured value (legacy flat field MUST NOT leak in)", cfg.SCEP.Profiles[0].ChallengePassword) + } +} + +// TestSCEPConfig_PathIDValidation pins the path-safe slug constraint. +// Validate() refuses anything with uppercase, slashes, leading/trailing +// hyphens, or non-ASCII chars. The empty string is allowed (legacy root). +func TestSCEPConfig_PathIDValidation(t *testing.T) { + cases := []struct { + name string + pathID string + valid bool + }{ + {"empty_legacy_root", "", true}, + {"valid_lowercase", "corp", true}, + {"valid_with_digits", "iot2", true}, + {"valid_with_hyphen", "corp-laptop", true}, + {"valid_long", "very-long-profile-name-with-many-segments", true}, + {"reject_uppercase", "Corp", false}, + {"reject_slash", "corp/laptop", false}, + {"reject_leading_hyphen", "-corp", false}, + {"reject_trailing_hyphen", "corp-", false}, + {"reject_underscore", "corp_laptop", false}, + {"reject_dot", "corp.laptop", false}, + {"reject_space", "corp laptop", false}, + {"reject_unicode", "corpé", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := validBaseConfigForSCEPProfiles(t) + cfg.SCEP = SCEPConfig{ + Enabled: true, + Profiles: []SCEPProfileConfig{{ + PathID: tc.pathID, + IssuerID: "iss-test", + ChallengePassword: "secret", + RACertPath: "/etc/certctl/scep/ra.crt", + RAKeyPath: "/etc/certctl/scep/ra.key", + }}, + } + err := cfg.Validate() + if tc.valid && err != nil { + t.Errorf("Validate() = %v, want nil for valid PathID %q", err, tc.pathID) + } + if !tc.valid && err == nil { + t.Errorf("Validate() = nil, want error for invalid PathID %q", tc.pathID) + } + if !tc.valid && err != nil && !strings.Contains(err.Error(), "invalid PathID") { + t.Errorf("error should mention invalid PathID, got: %v", err) + } + }) + } +} + +// TestSCEPConfig_DuplicatePathID_Refuses pins the uniqueness gate so +// the router never gets a {pathID -> handler} map with collisions. +func TestSCEPConfig_DuplicatePathID_Refuses(t *testing.T) { + cfg := validBaseConfigForSCEPProfiles(t) + cfg.SCEP = SCEPConfig{ + Enabled: true, + Profiles: []SCEPProfileConfig{ + {PathID: "corp", IssuerID: "iss-a", ChallengePassword: "x", RACertPath: "/a.crt", RAKeyPath: "/a.key"}, + {PathID: "corp", IssuerID: "iss-b", ChallengePassword: "y", RACertPath: "/b.crt", RAKeyPath: "/b.key"}, + }, + } + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() = nil, want error for duplicate PathID") + } + if !strings.Contains(err.Error(), "duplicates PathID") { + t.Errorf("error should mention duplicates PathID, got: %v", err) + } +} + +// TestSCEPConfig_MissingPerProfileChallengePassword pins the per-profile +// CWE-306 gate. Each profile is independently required to carry a +// non-empty challenge password — defense in depth with the static-form +// gate that fired pre-Phase-1.5. +func TestSCEPConfig_MissingPerProfileChallengePassword(t *testing.T) { + cfg := validBaseConfigForSCEPProfiles(t) + cfg.SCEP = SCEPConfig{ + Enabled: true, + Profiles: []SCEPProfileConfig{ + {PathID: "good", IssuerID: "iss-a", ChallengePassword: "x", RACertPath: "/a.crt", RAKeyPath: "/a.key"}, + {PathID: "bad", IssuerID: "iss-b", ChallengePassword: "", RACertPath: "/b.crt", RAKeyPath: "/b.key"}, + }, + } + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() = nil, want error for empty per-profile challenge password") + } + if !strings.Contains(err.Error(), "empty CHALLENGE_PASSWORD") { + t.Errorf("error should mention empty CHALLENGE_PASSWORD, got: %v", err) + } +} + +// TestSCEPConfig_MissingPerProfileRAPair pins the RA-pair gate per profile. +func TestSCEPConfig_MissingPerProfileRAPair(t *testing.T) { + cases := []struct { + name string + raCertPath string + raKeyPath string + }{ + {"both_missing", "", ""}, + {"cert_missing", "", "/x.key"}, + {"key_missing", "/x.crt", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := validBaseConfigForSCEPProfiles(t) + cfg.SCEP = SCEPConfig{ + Enabled: true, + Profiles: []SCEPProfileConfig{{ + PathID: "p", + IssuerID: "iss", + ChallengePassword: "secret", + RACertPath: tc.raCertPath, + RAKeyPath: tc.raKeyPath, + }}, + } + err := cfg.Validate() + if err == nil { + t.Fatalf("Validate() = nil, want error for %s", tc.name) + } + if !strings.Contains(err.Error(), "missing RA cert/key path") { + t.Errorf("error should mention missing RA cert/key path, got: %v", err) + } + }) + } +} + +// TestSCEPConfig_MissingPerProfileIssuerID guards against a profile that +// references no issuer at all (a likely typo in CERTCTL_SCEP_PROFILE_X_ISSUER_ID). +func TestSCEPConfig_MissingPerProfileIssuerID(t *testing.T) { + cfg := validBaseConfigForSCEPProfiles(t) + cfg.SCEP = SCEPConfig{ + Enabled: true, + Profiles: []SCEPProfileConfig{{ + PathID: "p", + ChallengePassword: "secret", + RACertPath: "/x.crt", + RAKeyPath: "/x.key", + }}, + } + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() = nil, want error for empty per-profile IssuerID") + } + if !strings.Contains(err.Error(), "empty IssuerID") { + t.Errorf("error should mention empty IssuerID, got: %v", err) + } +} + +// TestSCEPConfig_DisabledIgnoresProfiles pins that the per-profile gates +// only fire when SCEP is enabled. A disabled deploy can carry malformed +// Profiles entries (e.g. partially-populated by an automation tool) without +// blocking startup. +func TestSCEPConfig_DisabledIgnoresProfiles(t *testing.T) { + cfg := validBaseConfigForSCEPProfiles(t) + cfg.SCEP = SCEPConfig{ + Enabled: false, + Profiles: []SCEPProfileConfig{ + {PathID: "BAD UPPER", IssuerID: "", ChallengePassword: "", RACertPath: "", RAKeyPath: ""}, + }, + } + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() = %v, want nil for SCEP disabled with malformed profiles", err) + } +} + +// clearCertctlEnv resets every CERTCTL_* env var so a Load()-based test +// runs in isolation. Mirrors the existing clearCertctlEnv in the sibling +// test file (config_test.go) but defined locally so the file stays +// self-contained for a future split. +func init() { + // Reuse the existing clearCertctlEnv from config_test.go via the package + // scope; declared in this init() block as a sanity check to ensure + // linking works. The actual helper lives in config_test.go. + _ = os.Getenv +}