mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 07:08:51 +00:00
feat(scep): mTLS sibling route /scep-mtls/<pathID> (opt-in)
SCEP RFC 8894 + Intune master bundle — Phase 6.5 of 14 (opt-in,
enterprise-procurement-checkbox).
Closes the procurement-team objection that 'shared password
authentication' is a checkbox-fail regardless of how strong the
password is. The clean answer: a sibling route that adds client-cert
auth at the handler layer AND keeps the challenge password (defense in
depth, not replacement). Devices present a bootstrap cert from a
trusted CA (e.g. a manufacturing-time cert), then SCEP-enroll for
their long-lived cert. Same model Apple's MDM and Cisco's BRSKI use.
internal/config/config.go
* SCEPProfileConfig gains MTLSEnabled bool + MTLSClientCATrustBundlePath
string. Indexed env-var loader reads
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED +
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH.
* Validate() refuses MTLSEnabled=true with empty bundle path —
structural defense in depth ahead of the file-content preflight.
cmd/server/main.go
* preflightSCEPMTLSTrustBundle: file existence + PEM parse + ≥1
CERTIFICATE block + non-expired check. Returns the parsed
*x509.CertPool ready to inject into the per-profile SCEPHandler.
Failures os.Exit(1) with the offending PathID in the structured log.
* SCEP startup loop walks each profile; when MTLSEnabled, runs
preflight, builds the per-profile pool, contributes the bundle's
certs to the union pool that backs the TLS-layer
VerifyClientCertIfGiven, clones the SCEPHandler with
SetMTLSTrustPool, and registers the parallel sibling route via
apiRouter.RegisterSCEPMTLSHandlers.
* Union pool published to outer scope as scepMTLSUnionPoolForTLS;
passed to buildServerTLSConfigWithMTLS so the listener serves both
/scep[/<pathID>] (no client cert) and /scep-mtls/<pathID>
(cert required at handler layer) on the same socket.
* Final-handler dispatch gains /scep-mtls + /scep-mtls/* prefix
routing through the no-auth chain (auth boundary is the client
cert + challenge password, NOT a Bearer token).
cmd/server/tls.go
* New buildServerTLSConfigWithMTLS that wraps buildServerTLSConfig
+ sets ClientCAs + ClientAuth=VerifyClientCertIfGiven when a
non-nil pool is passed. nil pool = identical TLS shape to the
pre-Phase-6.5 builder (no behavior change for deploys without
mTLS profiles).
* Critical: VerifyClientCertIfGiven (NOT RequireAndVerifyClientCert)
so a client that doesn't present a cert can still hit the standard
/scep route. The per-profile gate at the handler layer enforces
'cert required' on /scep-mtls/<pathID>.
internal/api/handler/scep.go
* SCEPHandler gains mtlsTrustPool *x509.CertPool field +
SetMTLSTrustPool method. Per-profile pool injected by
cmd/server/main.go after preflight.
* HandleSCEPMTLS wrapper: gates on r.TLS.PeerCertificates non-empty
+ per-profile cert.Verify against THIS profile's pool. Returns
HTTP 401 for missing/untrusted cert (mTLS failure is auth, not
authorization). Returns HTTP 500 if mtlsTrustPool is nil (deploy
bug — the route shouldn't have been registered). On success
delegates to HandleSCEP — defense in depth: mTLS is additive,
NOT replacement; the standard SCEP code path including the
challenge-password gate still executes.
* Per-profile re-verification via cert.Verify(...) is critical:
the TLS layer verified against the UNION pool, so a cert that
chains to profile A's bundle would pass TLS even when targeting
profile B. The handler-layer gate prevents cross-profile
bleed-through.
internal/api/router/router.go
* AuthExemptDispatchPrefixes gains '/scep-mtls' (auth boundary is
client cert + challenge password, NOT Bearer token).
* RegisterSCEPMTLSHandlers parallel to RegisterSCEPHandlers:
empty PathID maps to /scep-mtls root; non-empty maps to
/scep-mtls/<pathID>. Each handler in the map MUST have had
SetMTLSTrustPool called.
internal/api/router/openapi_parity_test.go
* SpecParityExceptions allowlists 'GET /scep-mtls' + 'POST
/scep-mtls' since the wire format is identical to /scep —
documenting both routes separately would duplicate every
operation row with no information gain. Documented alternative
in docs/legacy-est-scep.md.
internal/api/handler/scep_mtls_test.go (new, ~210 LoC)
* 6 tests + 2 helpers covering the auth contract:
1. RejectsMissingClientCert — request with r.TLS=nil → 401
2. RejectsUntrustedClientCert — cert chains to a different
CA → 401 (per-profile re-verification works)
3. AcceptsTrustedClientCert — cert chains to THIS profile's
pool → 200 (delegates to HandleSCEP)
4. StillRoutesThroughHandleSCEP — pin Content-Type + body
come from HandleSCEP delegate (defense in depth pin)
5. NoTrustPool_Returns500 — handler with SetMTLSTrustPool
never called → 500 (deploy-bug surface)
6. StandardRoute_StillNoMTLS — pin /scep keeps working
without a client cert even when mTLS pool is set
* genSelfSignedECDSACA + signECDSAClientCert helpers materialise
real cert chains (trusted-bootstrap-ca + trusted-device,
untrusted-attacker-ca + untrusted-device) so the Verify path
exercises real x509 chain validation, not mocks.
docs/features.md
* SCEP env-vars table extended with the two new MTLS env vars
(CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED,
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH).
Closes the G-3 'env var defined in Go but never documented' gate.
docs/legacy-est-scep.md
* New 'mTLS sibling route (Phase 6.5, opt-in)' section covering
opt-in env vars, TLS server config (union pool +
VerifyClientCertIfGiven), handler-layer per-profile gate,
full auth chain on /scep-mtls/<pathID>, operator migration
workflow from challenge-password-only to challenge+mTLS.
cowork/CLAUDE.md::Active Focus
* 'HALF 1 COMPLETE' updated from '(Phases 0-5 of 14 SHIPPED)' to
'(Phases 0-6 + Phase 6.5 of 14 SHIPPED)'.
Verification:
* gofmt + go vet + staticcheck clean across api/handler /
api/router / config / cmd/server.
* go test -short -count=1 green across api/handler (with the new
scep_mtls_test.go) / api/router / service / config / pkcs7 /
cmd/server / connector/issuer/local.
* G-3 docs-drift CI guard local check: empty in both directions
after the new MTLS env vars landed in features.md.
* The constitutional test ('can an operator flip the bit and
observe the behavior change end-to-end?') is YES: setting
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED=true plus the trust
bundle path produces a working /scep-mtls/<pathID> endpoint
that accepts trusted client certs + rejects untrusted ones,
with no further code changes required.
Phase 6.5 of 14 in SCEP RFC 8894 + Intune master bundle.
Half 1 (Phases 0-6 + 6.5) is now FEATURE-COMPLETE for the
ChromeOS / general-MDM use case. Half 2 (Phases 7-12) adds the
Microsoft Intune dynamic-challenge layer.
This commit is contained in:
@@ -36,7 +36,21 @@ import (
|
||||
// At Bundle D close time, this list is empty. Future entries should be
|
||||
// rare — the OpenAPI spec is the source of truth for the public API
|
||||
// surface.
|
||||
var SpecParityExceptions = map[string]string{}
|
||||
var SpecParityExceptions = map[string]string{
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 6.5: the /scep-mtls
|
||||
// sibling route is opt-in (gated on per-profile MTLSEnabled). It rides
|
||||
// the same SCEP-PKIOperation contract as /scep but with an additional
|
||||
// client-cert auth layer at the handler. The OpenAPI spec covers the
|
||||
// canonical /scep endpoint; documenting /scep-mtls separately would
|
||||
// duplicate every operation row with no information gain — the
|
||||
// PKIMessage wire format, query params, and response shapes are
|
||||
// identical. The route lives in router.go as literal r.Register calls
|
||||
// for the openapi-parity scanner's benefit; it stays out of openapi.yaml
|
||||
// by exception. See docs/legacy-est-scep.md::mTLS-sibling-route for the
|
||||
// operator-facing description.
|
||||
"GET /scep-mtls": "Phase 6.5 mTLS sibling route — same wire format as /scep with cert-required gate; documented in docs/legacy-est-scep.md",
|
||||
"POST /scep-mtls": "Phase 6.5 mTLS sibling route — same wire format as /scep with cert-required gate; documented in docs/legacy-est-scep.md",
|
||||
}
|
||||
|
||||
func TestRouter_OpenAPIParity(t *testing.T) {
|
||||
routes, err := scanRouterRoutes("router.go")
|
||||
|
||||
@@ -84,6 +84,7 @@ var AuthExemptDispatchPrefixes = []string{
|
||||
"/.well-known/pki", // RFC 5280 CRL + RFC 6960 OCSP — relying-party-unauth
|
||||
"/.well-known/est", // RFC 7030 EST — auth via mTLS or CSR-embedded creds
|
||||
"/scep", // RFC 8894 SCEP — auth via challengePassword in CSR
|
||||
"/scep-mtls", // SCEP + mTLS sibling route (Phase 6.5) — auth is client cert + challengePassword
|
||||
}
|
||||
|
||||
// HandlerRegistry groups all API handler dependencies for router registration.
|
||||
@@ -425,6 +426,42 @@ func (r *Router) RegisterSCEPHandlers(handlers map[string]handler.SCEPHandler) {
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterSCEPMTLSHandlers sets up the sibling `/scep-mtls/<PathID>` routes
|
||||
// for SCEP profiles that opted into mTLS via
|
||||
// `CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED=true`.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 6.5: enterprise procurement
|
||||
// teams routinely reject 'shared password authentication' as a checkbox-
|
||||
// fail regardless of how strong the password is. This sibling route adds
|
||||
// client-cert auth at the handler layer AND keeps the challenge password
|
||||
// (defense in depth, not replacement). Devices present a bootstrap cert
|
||||
// from a trusted CA, then SCEP-enroll for their long-lived cert. Same
|
||||
// model Apple's MDM and Cisco's BRSKI use.
|
||||
//
|
||||
// Path conventions mirror the standard SCEP route: empty PathID maps to
|
||||
// `/scep-mtls` root (single-profile mTLS deploy); non-empty PathIDs map
|
||||
// to `/scep-mtls/<pathID>`. The /scep-mtls prefix is in
|
||||
// AuthExemptDispatchPrefixes — the auth boundary is the client cert
|
||||
// (verified at the TLS layer + per-profile re-verified at the handler
|
||||
// layer) plus the challenge password, NOT a Bearer token.
|
||||
//
|
||||
// Each handler in the map MUST have had SetMTLSTrustPool called so the
|
||||
// per-profile cert verification has a trust anchor.
|
||||
func (r *Router) RegisterSCEPMTLSHandlers(handlers map[string]handler.SCEPHandler) {
|
||||
if h, ok := handlers[""]; ok {
|
||||
r.Register("GET /scep-mtls", http.HandlerFunc(h.HandleSCEPMTLS))
|
||||
r.Register("POST /scep-mtls", http.HandlerFunc(h.HandleSCEPMTLS))
|
||||
}
|
||||
for pathID, h := range handlers {
|
||||
if pathID == "" {
|
||||
continue
|
||||
}
|
||||
hCopy := h
|
||||
r.Register("GET /scep-mtls/"+pathID, http.HandlerFunc(hCopy.HandleSCEPMTLS))
|
||||
r.Register("POST /scep-mtls/"+pathID, http.HandlerFunc(hCopy.HandleSCEPMTLS))
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterPKIHandlers sets up RFC 5280 CRL and RFC 6960 OCSP routes under
|
||||
// /.well-known/pki/. These endpoints are intentionally unauthenticated so
|
||||
// relying parties (browsers, OpenSSL, OCSP stapling sidecars, mTLS clients)
|
||||
|
||||
Reference in New Issue
Block a user