mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 14:39:00 +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:
+180
-3
@@ -5,6 +5,7 @@ import (
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -726,6 +727,16 @@ func main() {
|
||||
"endpoints", "/.well-known/est/{cacerts,simpleenroll,simplereenroll,csrattrs}")
|
||||
}
|
||||
|
||||
// SCEP RFC 8894 Phase 6.5: union pool of every enabled mTLS profile's
|
||||
// trust bundle. Populated inside the SCEP startup block below; passed
|
||||
// to the TLS-config builder later so the listener accepts client certs
|
||||
// signed by ANY mTLS profile's CA. The handler-layer gate
|
||||
// (HandleSCEPMTLS) re-verifies per-profile, so a cert that chains to
|
||||
// profile A's bundle cannot enroll against profile B even though it
|
||||
// passes the TLS-layer union check. Stays nil when no profile opted in
|
||||
// (the TLS config builder treats nil as 'no mTLS').
|
||||
var scepMTLSUnionPoolForTLS *x509.CertPool
|
||||
|
||||
// Register SCEP (RFC 8894) handlers if enabled.
|
||||
//
|
||||
// SCEP RFC 8894 Phase 1.5: multi-profile dispatch. Config.Validate()
|
||||
@@ -739,7 +750,18 @@ func main() {
|
||||
// (challenge password presence, RA pair validity, issuer reachability).
|
||||
// Failures log the offending PathID so a multi-profile deploy can
|
||||
// pinpoint which profile broke startup.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 6.5: profiles that
|
||||
// opt into mTLS via CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED=true
|
||||
// get a parallel sibling-route handler registered at /scep-mtls/
|
||||
// <pathID>. The per-profile trust pool gates the inbound client
|
||||
// cert chain (verified at the TLS layer against the union pool +
|
||||
// re-verified at the handler layer against just THIS profile's
|
||||
// bundle to prevent cross-profile bleed-through).
|
||||
scepHandlers := make(map[string]handler.SCEPHandler, len(cfg.SCEP.Profiles))
|
||||
scepMTLSHandlers := make(map[string]handler.SCEPHandler)
|
||||
scepMTLSUnionPool := x509.NewCertPool()
|
||||
scepMTLSAnyEnabled := false
|
||||
for i, profile := range cfg.SCEP.Profiles {
|
||||
profile := profile // shadow for closure-safety even though no closures escape
|
||||
profileLog := logger.With(
|
||||
@@ -814,10 +836,83 @@ func main() {
|
||||
"challenge_password_set", profile.ChallengePassword != "",
|
||||
"ra_cert_path", profile.RACertPath,
|
||||
)
|
||||
|
||||
// SCEP RFC 8894 Phase 6.5: register the mTLS sibling route
|
||||
// when this profile opted in. Build a per-profile trust pool
|
||||
// from the bundle, share its certs into the union pool the
|
||||
// TLS layer uses, and clone the handler with the per-profile
|
||||
// pool injected so HandleSCEPMTLS can re-verify the inbound
|
||||
// client cert against just THIS profile's bundle.
|
||||
if profile.MTLSEnabled {
|
||||
perProfilePool, err := preflightSCEPMTLSTrustBundle(true, profile.MTLSClientCATrustBundlePath)
|
||||
if err != nil {
|
||||
profileLog.Error(
|
||||
"startup refused: SCEP profile MTLS trust bundle preflight failed "+
|
||||
"(Phase 6.5: required when MTLS_ENABLED=true). "+
|
||||
"Verify the bundle file exists at MTLS_CLIENT_CA_TRUST_BUNDLE_PATH, "+
|
||||
"is readable, parses as PEM, contains ≥1 CERTIFICATE block, "+
|
||||
"and none of the bundled certs are past NotAfter.",
|
||||
"error", err,
|
||||
)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Add this profile's certs to the union pool the TLS
|
||||
// layer uses for VerifyClientCertIfGiven. We re-walk the
|
||||
// bundle so the union pool gets exactly the same certs
|
||||
// as the per-profile pool (defensive against future
|
||||
// pool-mutation refactors).
|
||||
bundleBytes, _ := os.ReadFile(profile.MTLSClientCATrustBundlePath)
|
||||
rest := bundleBytes
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
|
||||
scepMTLSUnionPool.AddCert(cert)
|
||||
}
|
||||
}
|
||||
scepMTLSAnyEnabled = true
|
||||
|
||||
// Build the parallel sibling-route handler. Same SCEP
|
||||
// service + RA pair as the standard route — mTLS is
|
||||
// additive, not a replacement.
|
||||
mtlsHandler := handler.NewSCEPHandler(scepService)
|
||||
mtlsHandler.SetRAPair(raCert, raKey)
|
||||
mtlsHandler.SetMTLSTrustPool(perProfilePool)
|
||||
scepMTLSHandlers[profile.PathID] = mtlsHandler
|
||||
|
||||
mtlsEndpoint := "/scep-mtls"
|
||||
if profile.PathID != "" {
|
||||
mtlsEndpoint = "/scep-mtls/" + profile.PathID
|
||||
}
|
||||
profileLog.Info("SCEP mTLS sibling route enabled",
|
||||
"endpoint", mtlsEndpoint,
|
||||
"client_ca_trust_bundle", profile.MTLSClientCATrustBundlePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
apiRouter.RegisterSCEPHandlers(scepHandlers)
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 6.5: register the
|
||||
// /scep-mtls sibling routes when at least one profile opted in.
|
||||
// scepMTLSHandlers is non-empty only when scepMTLSAnyEnabled is
|
||||
// true (the per-profile branch only adds to the map when the
|
||||
// profile flag is set), but the explicit gate makes the
|
||||
// no-op-when-disabled case obvious in logs.
|
||||
if scepMTLSAnyEnabled {
|
||||
apiRouter.RegisterSCEPMTLSHandlers(scepMTLSHandlers)
|
||||
scepMTLSUnionPoolForTLS = scepMTLSUnionPool
|
||||
logger.Info("SCEP mTLS sibling route enabled (Phase 6.5)",
|
||||
"mtls_profile_count", len(scepMTLSHandlers),
|
||||
)
|
||||
}
|
||||
logger.Info("SCEP server enabled",
|
||||
"profile_count", len(scepHandlers),
|
||||
"mtls_profile_count", len(scepMTLSHandlers),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1055,9 +1150,17 @@ func main() {
|
||||
// Server configuration
|
||||
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: finalHandler,
|
||||
TLSConfig: buildServerTLSConfig(tlsCertHolder),
|
||||
Addr: addr,
|
||||
Handler: finalHandler,
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 6.5: when at least
|
||||
// one SCEP profile opted into mTLS, the listener carries the
|
||||
// union of every enabled profile's client-CA trust bundle and
|
||||
// negotiates VerifyClientCertIfGiven on the handshake. The
|
||||
// /scep route stays challenge-password-only; the /scep-mtls
|
||||
// sibling route gates additionally on the verified client cert.
|
||||
// nil pool = no profile opted in = identical TLS shape to the
|
||||
// pre-Phase-6.5 buildServerTLSConfig path.
|
||||
TLSConfig: buildServerTLSConfigWithMTLS(tlsCertHolder, scepMTLSUnionPoolForTLS),
|
||||
ReadTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: 120 * time.Second, // Must accommodate ACME issuance (order + challenge + finalize)
|
||||
@@ -1155,6 +1258,67 @@ func preflightSCEPChallengePassword(enabled bool, challengePassword string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// preflightSCEPMTLSTrustBundle validates a per-profile mTLS client-CA
|
||||
// trust bundle. SCEP RFC 8894 + Intune master bundle Phase 6.5.
|
||||
//
|
||||
// Mirrors preflightSCEPRACertKey's no-op-when-disabled pattern; otherwise
|
||||
// the checks are:
|
||||
//
|
||||
// 1. Path is non-empty (the Validate() refuse covers this too, but
|
||||
// preflight reports the specific failure with an actionable error
|
||||
// string + os.Exit(1) at the call site).
|
||||
// 2. File exists + readable.
|
||||
// 3. PEM-decodes to ≥1 CERTIFICATE block.
|
||||
// 4. None of the bundled certs is past NotAfter — an expired trust
|
||||
// anchor would silently reject every client cert at runtime.
|
||||
//
|
||||
// On success, returns the parsed *x509.CertPool ready to inject into the
|
||||
// per-profile SCEPHandler via SetMTLSTrustPool. Each bundled cert also
|
||||
// contributes to the union pool that backs the TLS-layer
|
||||
// VerifyClientCertIfGiven.
|
||||
func preflightSCEPMTLSTrustBundle(enabled bool, bundlePath string) (*x509.CertPool, error) {
|
||||
if !enabled {
|
||||
return nil, nil
|
||||
}
|
||||
if bundlePath == "" {
|
||||
return nil, fmt.Errorf("MTLS enabled but trust bundle path empty: " +
|
||||
"set CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH to a PEM file " +
|
||||
"containing the bootstrap-CA certs the operator allows to enroll")
|
||||
}
|
||||
body, err := os.ReadFile(bundlePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read MTLS trust bundle: %w (path=%s)", err, bundlePath)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
rest := body
|
||||
count := 0
|
||||
now := time.Now()
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse MTLS trust bundle cert: %w (path=%s)", err, bundlePath)
|
||||
}
|
||||
if now.After(cert.NotAfter) {
|
||||
return nil, fmt.Errorf("MTLS trust bundle cert expired at %s (subject=%q, path=%s) — replace before restart",
|
||||
cert.NotAfter.Format(time.RFC3339), cert.Subject.CommonName, bundlePath)
|
||||
}
|
||||
pool.AddCert(cert)
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, fmt.Errorf("MTLS trust bundle contained no CERTIFICATE PEM blocks (path=%s)", bundlePath)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// loadSCEPRAPair reads the RA cert PEM + key PEM and returns the parsed
|
||||
// x509.Certificate + crypto.PrivateKey ready for the SCEP handler's RFC
|
||||
// 8894 path. Called AFTER preflightSCEPRACertKey passed; failures here
|
||||
@@ -1390,10 +1554,23 @@ func buildFinalHandler(apiHandler, noAuthHandler http.Handler, webDir string, da
|
||||
// authenticate via the challengePassword attribute in the PKCS#10 CSR,
|
||||
// not via HTTP Bearer tokens. preflightSCEPChallengePassword refuses to
|
||||
// start the server if SCEP is enabled without a non-empty shared secret.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 6.5: the sibling
|
||||
// /scep-mtls[/<pathID>] route also rides the no-auth chain. Its
|
||||
// auth boundary is (a) client cert verified at the TLS layer +
|
||||
// re-verified per-profile at the handler layer, plus (b) the
|
||||
// challenge password — neither is a Bearer token. The /scepxyz
|
||||
// vs /scep-mtls disambiguation: 'xyz' starts with a letter so the
|
||||
// HasPrefix(path, "/scep/") gate doesn't match it; 'mtls' is its
|
||||
// own dedicated prefix gated below to avoid the same overlap.
|
||||
if path == "/scep" || strings.HasPrefix(path, "/scep/") {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if path == "/scep-mtls" || strings.HasPrefix(path, "/scep-mtls/") {
|
||||
noAuthHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticated API routes — full middleware stack including Auth.
|
||||
if strings.HasPrefix(path, "/api/v1/") {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -134,6 +135,31 @@ func buildServerTLSConfig(holder *certHolder) *tls.Config {
|
||||
}
|
||||
}
|
||||
|
||||
// buildServerTLSConfigWithMTLS extends buildServerTLSConfig with a client-cert
|
||||
// trust pool for the SCEP RFC 8894 + Intune master bundle Phase 6.5 mTLS
|
||||
// sibling route. SCEP profiles that opt into mTLS each contribute their
|
||||
// trust bundle to the union pool here; the same TLS listener serves both
|
||||
// /scep[/<pathID>] (no client cert) and /scep-mtls/<pathID> (cert required
|
||||
// at the handler layer).
|
||||
//
|
||||
// ClientAuth: VerifyClientCertIfGiven — request a cert during handshake; if
|
||||
// the client presents one, verify it against the union pool; if absent, the
|
||||
// request still reaches the handler and the per-route handler decides
|
||||
// whether to accept. Critical that we do NOT use RequireAndVerifyClientCert
|
||||
// here — that would break the standard /scep route (which is challenge-
|
||||
// password-only, no client cert expected).
|
||||
//
|
||||
// Pass clientCAs == nil to disable mTLS (no profile opted in). The function
|
||||
// then returns the same shape as buildServerTLSConfig.
|
||||
func buildServerTLSConfigWithMTLS(holder *certHolder, clientCAs *x509.CertPool) *tls.Config {
|
||||
cfg := buildServerTLSConfig(holder)
|
||||
if clientCAs != nil {
|
||||
cfg.ClientCAs = clientCAs
|
||||
cfg.ClientAuth = tls.VerifyClientCertIfGiven
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// preflightServerTLS is the fail-loud startup gate for HTTPS. Returns a
|
||||
// non-nil error when the TLS configuration is missing or the cert+key pair
|
||||
// cannot be parsed, so the caller refuses to start the control plane
|
||||
|
||||
Reference in New Issue
Block a user