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:
certctl-copilot
2026-04-29 13:58:18 +00:00
parent d8d1436736
commit e7a3075a75
9 changed files with 666 additions and 4 deletions
+180 -3
View File
@@ -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/") {
+26
View File
@@ -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