mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 13:48:51 +00:00
fdd424bf5f
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.
167 lines
6.1 KiB
Go
167 lines
6.1 KiB
Go
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
|
|
}
|