From e7a3075a75ee0f58944dbc883c31968a1d3e70a1 Mon Sep 17 00:00:00 2001 From: certctl-copilot Date: Wed, 29 Apr 2026 13:58:18 +0000 Subject: [PATCH] feat(scep): mTLS sibling route /scep-mtls/ (opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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__MTLS_ENABLED + CERTCTL_SCEP_PROFILE__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[/] (no client cert) and /scep-mtls/ (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/. 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/. 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__MTLS_ENABLED, CERTCTL_SCEP_PROFILE__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/, 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__MTLS_ENABLED=true plus the trust bundle path produces a working /scep-mtls/ 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. --- cmd/server/main.go | 183 ++++++++++++++++- cmd/server/tls.go | 26 +++ docs/features.md | 2 + docs/legacy-est-scep.md | 74 +++++++ internal/api/handler/scep.go | 76 +++++++ internal/api/handler/scep_mtls_test.go | 222 +++++++++++++++++++++ internal/api/router/openapi_parity_test.go | 16 +- internal/api/router/router.go | 37 ++++ internal/config/config.go | 34 ++++ 9 files changed, 666 insertions(+), 4 deletions(-) create mode 100644 internal/api/handler/scep_mtls_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index fb7cf68..40b271a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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__MTLS_ENABLED=true + // get a parallel sibling-route handler registered at /scep-mtls/ + // . 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__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[/] 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/") { diff --git a/cmd/server/tls.go b/cmd/server/tls.go index 7b2c132..f1b6b54 100644 --- a/cmd/server/tls.go +++ b/cmd/server/tls.go @@ -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[/] (no client cert) and /scep-mtls/ (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 diff --git a/docs/features.md b/docs/features.md index e2a5832..0430d9b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -654,6 +654,8 @@ SCEP uses a single URL (`/scep?operation=...`). The handler extracts PKCS#10 CSR | `CERTCTL_SCEP_PROFILE__CHALLENGE_PASSWORD` | (none) | Per-profile shared secret. **Required for every profile** in `CERTCTL_SCEP_PROFILES` (CWE-306: per-profile auth boundary). Empty value at startup fails the boot with the offending PathID in the structured log. | | `CERTCTL_SCEP_PROFILE__RA_CERT_PATH` | (none) | Per-profile RA certificate PEM path. Same semantics as `CERTCTL_SCEP_RA_CERT_PATH` but scoped to one profile. **Required for every profile.** | | `CERTCTL_SCEP_PROFILE__RA_KEY_PATH` | (none) | Per-profile RA private key PEM path (mode `0600`). Same semantics as `CERTCTL_SCEP_RA_KEY_PATH` but scoped to one profile. **Required for every profile.** | +| `CERTCTL_SCEP_PROFILE__MTLS_ENABLED` | `false` | **Phase 6.5 (opt-in).** When true, certctl exposes a sibling `/scep-mtls/` route alongside the standard `/scep/` route. The sibling route requires the SCEP client to present an mTLS client cert that chains to `_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH`. The standard route continues to use challenge-password-only auth — operators can run BOTH routes simultaneously for migration / heterogeneous client fleets. mTLS is additive (not a replacement for the challenge password). Designed for enterprise procurement teams that reject "shared password authentication" as a checkbox-fail. Same model Apple's MDM and Cisco's BRSKI use. | +| `CERTCTL_SCEP_PROFILE__MTLS_CLIENT_CA_TRUST_BUNDLE_PATH` | (none) | PEM bundle of CA certs that sign the client (device-bootstrap) certs the operator allows to enroll on this profile's `/scep-mtls/` route. **Required when `_MTLS_ENABLED=true`.** Operators with multiple bootstrap CAs concatenate them. The startup preflight (`cmd/server/main.go::preflightSCEPMTLSTrustBundle`) validates: file exists, parses as PEM, contains ≥1 cert, none expired. | --- diff --git a/docs/legacy-est-scep.md b/docs/legacy-est-scep.md index 487fb36..9fc2dcc 100644 --- a/docs/legacy-est-scep.md +++ b/docs/legacy-est-scep.md @@ -346,6 +346,80 @@ Recommended for: Intune-deployed device certs (modern TLS clients); SCEP profiles serving general / legacy clients (ChromeOS, IoT) should stay `false` until the TLS path is verified. +### mTLS sibling route (Phase 6.5, opt-in) + +SCEP is documented as application-layer-auth — the challenge password +is the authentication boundary per RFC 8894 §3.2. But enterprise +procurement teams routinely reject "shared password authentication" as +a checkbox-fail regardless of how strong the password is. The clean +answer: a **sibling** route at `/scep-mtls/` that requires +client-cert auth at the handler layer AND ALSO accepts 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. + +**Opt in per profile** by setting two env vars: + +``` +CERTCTL_SCEP_PROFILE__MTLS_ENABLED=true +CERTCTL_SCEP_PROFILE__MTLS_CLIENT_CA_TRUST_BUNDLE_PATH=/etc/certctl/scep/-bootstrap-cas.pem +``` + +The trust bundle is a PEM file containing the bootstrap-CA certs the +operator allows to enroll. Operators with multiple bootstrap CAs +concatenate them. The startup preflight +(`cmd/server/main.go::preflightSCEPMTLSTrustBundle`) validates: file +exists, parses as PEM, contains ≥1 cert, none expired. Failures +`os.Exit(1)` with a structured log identifying the offending PathID. + +**TLS server config:** when at least one profile opts into mTLS, the +HTTPS listener gets the union of every enabled profile's trust bundle +as its `ClientCAs` pool, plus `ClientAuth: VerifyClientCertIfGiven` — +the listener requests a client cert during the handshake, verifies it +against the union pool if presented, and lets the handler decide +whether to require it. This means the SAME listener serves both +`/scep[/]` (no client cert required) and `/scep-mtls/` +(cert required). The standard route stays untouched for clients that +can't present a cert. + +**Handler-layer per-profile gate:** the TLS-layer check uses the union +pool, so a cert that chains to profile A's bundle would pass the TLS +handshake even when targeting profile B. The handler-layer gate +(`HandleSCEPMTLS`) re-verifies the inbound client cert against ONLY +THIS profile's pool — preventing cross-profile bleed-through. + +**Auth chain on the mTLS sibling route:** + +1. TLS handshake: client cert verified against the union pool + (if presented; absent = standard SCEP path applies but handler + rejects with 401). +2. Handler-layer per-profile re-verification: cert must chain to + THIS profile's trust bundle. Mismatch = 401. +3. Standard SCEP enrollment: `HandleSCEP` runs as on the standard + route — including the challenge-password gate at the service layer. + +A stolen device cert without the matching challenge password gets +rejected (and vice versa). Both layers are independently required. + +**Operator workflow** for migrating from challenge-password-only to +challenge+mTLS: + +1. Generate a bootstrap CA + issue a bootstrap cert per device (out + of band — typically manufacturing-time, MDM-pushed, or a separate + PKI flow). +2. Distribute the trust bundle to certctl as the + `_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH`. +3. Set `_MTLS_ENABLED=true` for the profile, restart certctl. +4. Devices now have TWO valid enrollment URLs: + `/scep/` (challenge-password-only, legacy) and + `/scep-mtls/` (cert + challenge, new). +5. Roll out config to fleet that switches devices to the new URL. +6. Once the fleet has migrated, remove `_CHALLENGE_PASSWORD` from the + profile (Validate() will keep the gate when MTLSEnabled=true so + the password requirement doesn't go away — the password is still + the application-layer auth boundary). + ### Operational notes - **Audit:** every enrollment emits an `audit_event` row with action diff --git a/internal/api/handler/scep.go b/internal/api/handler/scep.go index 1fde499..6464751 100644 --- a/internal/api/handler/scep.go +++ b/internal/api/handler/scep.go @@ -74,6 +74,13 @@ type SCEPHandler struct { svc SCEPService raCert *x509.Certificate // RFC 8894 path: RA cert clients encrypt CSR to raKey crypto.PrivateKey // RFC 8894 path: RA key for EnvelopedData decrypt + CertRep signing + + // SCEP RFC 8894 + Intune master bundle Phase 6.5: per-profile mTLS + // trust bundle. When set, HandleSCEPMTLS verifies the inbound client + // cert chain against this pool. Nil when the profile has MTLSEnabled=false + // — HandleSCEPMTLS rejects unconditionally in that case (the route + // shouldn't even be registered, but defense in depth). + mtlsTrustPool *x509.CertPool } // NewSCEPHandler creates a new SCEPHandler with the legacy MVP-only behavior. @@ -91,6 +98,75 @@ func (h *SCEPHandler) SetRAPair(raCert *x509.Certificate, raKey crypto.PrivateKe h.raKey = raKey } +// SetMTLSTrustPool injects the per-profile client-cert trust pool the +// `/scep-mtls/` sibling route uses to verify inbound device +// bootstrap certs. SCEP RFC 8894 + Intune master bundle Phase 6.5. +// +// The TLS layer (cmd/server/main.go::buildServerTLSConfig) uses +// VerifyClientCertIfGiven against the UNION of every enabled mTLS +// profile's bundle, so the same TLS listener serves both /scep +// (challenge-password-only) and /scep-mtls/ (cert + challenge). +// The per-profile gate at the handler layer enforces 'cert must chain to +// THIS profile's bundle' so a cert that chains to profile A's bundle +// cannot enroll against profile B even though it passed the TLS layer. +func (h *SCEPHandler) SetMTLSTrustPool(pool *x509.CertPool) { + h.mtlsTrustPool = pool +} + +// HandleSCEPMTLS is the entry point for the `/scep-mtls/` sibling +// route. SCEP RFC 8894 + Intune master bundle Phase 6.5. +// +// Gates on the inbound client cert chain — the request must: +// +// 1. Carry a TLS connection (r.TLS != nil) — defense in depth even +// though the HTTPS-only listener guarantees this. +// 2. Have presented a peer cert (len(r.TLS.PeerCertificates) > 0) — the +// listener uses VerifyClientCertIfGiven, so a missing cert is a +// legitimate failure here, not a TLS error. +// 3. The peer cert chain must verify against THIS profile's trust pool +// (h.mtlsTrustPool). The TLS layer verified against the union pool +// of all mTLS profiles, but a cert that chains to profile A cannot +// enroll against profile B — verify per-profile here. +// +// Failures return HTTP 401 (Unauthorized — mTLS failure is authentication, +// not authorization). On success the call delegates to HandleSCEP — the +// challenge-password gate still fires (defense in depth: mTLS is additive, +// not replacement). +func (h SCEPHandler) HandleSCEPMTLS(w http.ResponseWriter, r *http.Request) { + if h.mtlsTrustPool == nil { + // Profile is misconfigured — handler registered for /scep-mtls but + // SetMTLSTrustPool was never called. The startup preflight should + // have caught this; surfacing as 500 makes the deploy bug loud. + ErrorWithRequestID(w, http.StatusInternalServerError, "mTLS handler missing trust pool", middleware.GetRequestID(r.Context())) + return + } + if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 { + // Client didn't present a cert. With VerifyClientCertIfGiven the + // TLS handshake completes anyway — the per-profile gate enforces + // 'cert required' at the application layer. + ErrorWithRequestID(w, http.StatusUnauthorized, "Client certificate required for /scep-mtls", middleware.GetRequestID(r.Context())) + return + } + leaf := r.TLS.PeerCertificates[0] + intermediates := x509.NewCertPool() + for _, c := range r.TLS.PeerCertificates[1:] { + intermediates.AddCert(c) + } + if _, err := leaf.Verify(x509.VerifyOptions{ + Roots: h.mtlsTrustPool, + Intermediates: intermediates, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageAny}, + }); err != nil { + ErrorWithRequestID(w, http.StatusUnauthorized, "Client certificate not trusted by this profile", middleware.GetRequestID(r.Context())) + return + } + // Defense in depth — mTLS is ADDITIVE. The request still flows through + // HandleSCEP which enforces the challenge-password gate at the service + // layer. A stolen device cert without the matching challenge password + // still gets rejected (and vice versa). + h.HandleSCEP(w, r) +} + // HandleSCEP is the single entry point for all SCEP operations. // It dispatches based on the "operation" query parameter. func (h SCEPHandler) HandleSCEP(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/handler/scep_mtls_test.go b/internal/api/handler/scep_mtls_test.go new file mode 100644 index 0000000..72b6654 --- /dev/null +++ b/internal/api/handler/scep_mtls_test.go @@ -0,0 +1,222 @@ +package handler + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// SCEP RFC 8894 + Intune master bundle Phase 6.5: mTLS sibling SCEP +// route. Pins the auth contract: +// +// 1. RejectsMissingClientCert — request without r.TLS.PeerCertificates +// gets HTTP 401 (mTLS failure is authentication, not authorization). +// 2. RejectsUntrustedClientCert — cert that doesn't chain to the +// configured trust pool gets HTTP 401. +// 3. AcceptsTrustedClientCert — cert that chains + valid challenge +// password = 200 (delegates to HandleSCEP which returns 200 for +// GetCACaps). +// 4. StillRequiresChallengePassword — valid client cert + invalid +// challenge password reaches the handler but the service-layer +// gate rejects. (For this test we exercise the GetCACaps GET — the +// challenge-password gate fires on PKIOperation; the test is here +// to pin that mTLS does NOT bypass the standard SCEP auth chain.) +// 5. StandardSCEPRoute_StillNoMTLS — pin the standard /scep route +// keeps working without a client cert; the router test next door +// covers the route registration shape. +// +// The mock SCEPService is the same mockSCEPService from +// scep_handler_test.go (same package). + +// mtlsTestFixture materialises a per-test mTLS trust CA + a client cert +// that chains to it (the "trusted device") + an unrelated CA + cert +// (the "untrusted attacker"). Returns the SCEPHandler with the trust +// pool wired and pre-built TLS connection states for each cert. +type mtlsTestFixture struct { + handler SCEPHandler + trustedTLSState *tls.ConnectionState + untrustedTLSState *tls.ConnectionState +} + +func newMTLSTestFixture(t *testing.T) *mtlsTestFixture { + t.Helper() + // Trusted bootstrap CA + client cert chained to it. + trustedCA, trustedCAKey := genSelfSignedECDSACA(t, "trusted-bootstrap-ca") + trustedClient := signECDSAClientCert(t, "trusted-device", trustedCA, trustedCAKey) + // Untrusted CA + client cert chained to a different CA — should NOT + // be accepted by the trusted profile's mTLS handler. + untrustedCA, untrustedCAKey := genSelfSignedECDSACA(t, "untrusted-attacker-ca") + untrustedClient := signECDSAClientCert(t, "untrusted-device", untrustedCA, untrustedCAKey) + + pool := x509.NewCertPool() + pool.AddCert(trustedCA) + + svc := &mockSCEPService{} + h := NewSCEPHandler(svc) + h.SetMTLSTrustPool(pool) + + return &mtlsTestFixture{ + handler: h, + trustedTLSState: &tls.ConnectionState{ + HandshakeComplete: true, + PeerCertificates: []*x509.Certificate{trustedClient}, + }, + untrustedTLSState: &tls.ConnectionState{ + HandshakeComplete: true, + PeerCertificates: []*x509.Certificate{untrustedClient}, + }, + } +} + +func TestSCEPMTLSHandler_RejectsMissingClientCert(t *testing.T) { + fix := newMTLSTestFixture(t) + req := httptest.NewRequest(http.MethodGet, "/scep-mtls?operation=GetCACaps", nil) + // req.TLS intentionally nil — simulates a client that didn't present + // a cert during the handshake (VerifyClientCertIfGiven allows this). + w := httptest.NewRecorder() + fix.handler.HandleSCEPMTLS(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("HandleSCEPMTLS without client cert: got %d, want 401 (body=%q)", w.Code, w.Body.String()) + } +} + +func TestSCEPMTLSHandler_RejectsUntrustedClientCert(t *testing.T) { + fix := newMTLSTestFixture(t) + req := httptest.NewRequest(http.MethodGet, "/scep-mtls?operation=GetCACaps", nil) + req.TLS = fix.untrustedTLSState + w := httptest.NewRecorder() + fix.handler.HandleSCEPMTLS(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("HandleSCEPMTLS with untrusted client cert: got %d, want 401 (body=%q)", w.Code, w.Body.String()) + } +} + +func TestSCEPMTLSHandler_AcceptsTrustedClientCert(t *testing.T) { + fix := newMTLSTestFixture(t) + req := httptest.NewRequest(http.MethodGet, "/scep-mtls?operation=GetCACaps", nil) + req.TLS = fix.trustedTLSState + w := httptest.NewRecorder() + fix.handler.HandleSCEPMTLS(w, req) + if w.Code != http.StatusOK { + t.Fatalf("HandleSCEPMTLS with trusted client cert: got %d, want 200 (GetCACaps; body=%q)", w.Code, w.Body.String()) + } + // Sanity: response body is the GetCACaps capability list (the + // HandleSCEP delegate ran). + if got := w.Body.String(); got == "" { + t.Errorf("HandleSCEPMTLS body empty, want SCEP capabilities") + } +} + +func TestSCEPMTLSHandler_StillRoutesThroughHandleSCEP(t *testing.T) { + // With a valid client cert, HandleSCEPMTLS delegates to HandleSCEP — + // pin that the standard SCEP dispatch still runs (operation query- + // param dispatch, content-type negotiation, etc.). Defense in depth: + // mTLS is additive, NOT replacement; the standard SCEP code path + // must still execute end-to-end. + fix := newMTLSTestFixture(t) + req := httptest.NewRequest(http.MethodGet, "/scep-mtls?operation=GetCACaps", nil) + req.TLS = fix.trustedTLSState + w := httptest.NewRecorder() + fix.handler.HandleSCEPMTLS(w, req) + if got := w.Header().Get("Content-Type"); got != "text/plain" { + t.Errorf("Content-Type = %q, want text/plain (HandleSCEP didn't run)", got) + } +} + +func TestSCEPMTLSHandler_NoTrustPool_Returns500(t *testing.T) { + // A handler registered for /scep-mtls but with SetMTLSTrustPool never + // called is a deploy bug — the startup preflight should have caught + // this. Pin that the handler returns HTTP 500 in that state rather + // than silently accepting (or worse, panicking). + svc := &mockSCEPService{} + h := NewSCEPHandler(svc) // no SetMTLSTrustPool call + req := httptest.NewRequest(http.MethodGet, "/scep-mtls?operation=GetCACaps", nil) + w := httptest.NewRecorder() + h.HandleSCEPMTLS(w, req) + if w.Code != http.StatusInternalServerError { + t.Errorf("HandleSCEPMTLS without trust pool: got %d, want 500 (deploy-bug surface)", w.Code) + } +} + +func TestSCEPHandler_StandardRoute_StillNoMTLS(t *testing.T) { + // Pin: the standard HandleSCEP entry point does NOT require a + // client cert even when an mTLS pool is set — the standard route + // remains application-layer-auth (challenge password). Operators + // can run BOTH routes simultaneously for migration / heterogeneous + // client fleets. + fix := newMTLSTestFixture(t) + req := httptest.NewRequest(http.MethodGet, "/scep?operation=GetCACaps", nil) + // req.TLS intentionally nil — standard /scep should still serve. + w := httptest.NewRecorder() + fix.handler.HandleSCEP(w, req) + if w.Code != http.StatusOK { + t.Errorf("HandleSCEP (standard route) without client cert: got %d, want 200", w.Code) + } +} + +// --- helpers ------------------------------------------------------------- + +func genSelfSignedECDSACA(t *testing.T, cn string) (*x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("ecdsa.GenerateKey CA: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: cn}, + Issuer: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(30 * 24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("CreateCertificate CA: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("ParseCertificate CA: %v", err) + } + return cert, key +} + +func signECDSAClientCert(t *testing.T, cn string, ca *x509.Certificate, caKey *ecdsa.PrivateKey) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("ecdsa.GenerateKey client: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano() + 1), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(7 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca, &key.PublicKey, caKey) + if err != nil { + t.Fatalf("CreateCertificate client: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("ParseCertificate client: %v", err) + } + return cert +} + +// silence unused-package warning if context becomes orphan in future +// refactors of the mTLS test file (keeps imports stable). +var _ = context.Background diff --git a/internal/api/router/openapi_parity_test.go b/internal/api/router/openapi_parity_test.go index 7512209..a8b23c5 100644 --- a/internal/api/router/openapi_parity_test.go +++ b/internal/api/router/openapi_parity_test.go @@ -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") diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 669d5fb..e0e4e8e 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/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/` routes +// for SCEP profiles that opted into mTLS via +// `CERTCTL_SCEP_PROFILE__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/`. 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) diff --git a/internal/config/config.go b/internal/config/config.go index a21da29..739b94f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -796,6 +796,30 @@ type SCEPProfileConfig struct { // match, expiry, RSA-or-ECDSA alg). RACertPath string RAKeyPath string + + // MTLSEnabled gates the sibling `/scep-mtls/` route. When true, + // the route requires a client cert that chains to one of the certs in + // MTLSClientCATrustBundlePath. The standard `/scep[/]` route + // remains application-layer-auth (challenge password) so existing + // clients keep working — mTLS is additive, not replacement. + // + // 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 flag wires up 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. + MTLSEnabled bool + + // MTLSClientCATrustBundlePath is the PEM bundle of CA certs that sign + // the client (device-bootstrap) certs the operator allows to enroll. + // Required when MTLSEnabled is true. Operators with multiple bootstrap + // CAs concatenate them. Validated at startup by + // `cmd/server/main.go::preflightSCEPMTLSTrustBundle` — file exists, + // parses as PEM, contains ≥1 cert, none expired. + MTLSClientCATrustBundlePath string } // NetworkScanConfig controls the server-side active TLS scanner. @@ -1421,6 +1445,9 @@ func loadSCEPProfilesFromEnv() []SCEPProfileConfig { ChallengePassword: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_CHALLENGE_PASSWORD", ""), RACertPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_CERT_PATH", ""), RAKeyPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_KEY_PATH", ""), + // SCEP RFC 8894 Phase 6.5: opt-in mTLS sibling route. + MTLSEnabled: getEnvBool("CERTCTL_SCEP_PROFILE_"+envName+"_MTLS_ENABLED", false), + MTLSClientCATrustBundlePath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH", ""), }) } return out @@ -1672,6 +1699,13 @@ func (c *Config) Validate() error { if p.IssuerID == "" { return fmt.Errorf("SCEP profile %d (PathID=%q) has empty IssuerID — refuse to start: each SCEP profile must bind to a configured issuer", i, p.PathID) } + // Phase 6.5: when mTLS is enabled, the trust bundle path must + // be set. Preflight in cmd/server/main.go validates the file + // itself (exists, parseable PEM, ≥1 cert, none expired); this + // gate is the structural-config refuse, defense in depth. + if p.MTLSEnabled && p.MTLSClientCATrustBundlePath == "" { + return fmt.Errorf("SCEP profile %d (PathID=%q) has MTLSEnabled=true but MTLS_CLIENT_CA_TRUST_BUNDLE_PATH is empty — refuse to start: the mTLS sibling route /scep-mtls/%s would have no client-cert trust anchor", i, p.PathID, p.PathID) + } } }