mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +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:
+76
-60
@@ -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/<PathID>.
|
||||
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_<NAME>_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_<NAME>_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/.
|
||||
|
||||
Reference in New Issue
Block a user