mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:21:30 +00:00
feat(scep): per-issuer SCEP profiles — multi-endpoint dispatch
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/<pathID>), 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_<NAME>_*. 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/<pathID>.
* 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/<pathID>; /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.
This commit is contained in:
@@ -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/<pathID>. 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/<pathID>) 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
|
||||
|
||||
@@ -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/<pathID>.
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user