mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +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:
+178
-1
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1057,7 +1152,15 @@ func main() {
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: finalHandler,
|
||||
TLSConfig: buildServerTLSConfig(tlsCertHolder),
|
||||
// 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
|
||||
|
||||
@@ -654,6 +654,8 @@ SCEP uses a single URL (`/scep?operation=...`). The handler extracts PKCS#10 CSR
|
||||
| `CERTCTL_SCEP_PROFILE_<NAME>_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_<NAME>_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_<NAME>_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_<NAME>_MTLS_ENABLED` | `false` | **Phase 6.5 (opt-in).** When true, certctl exposes a sibling `/scep-mtls/<pathID>` route alongside the standard `/scep/<pathID>` 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_<NAME>_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/<pathID>` 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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/<pathID>` 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_<NAME>_MTLS_ENABLED=true
|
||||
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH=/etc/certctl/scep/<name>-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[/<pathID>]` (no client cert required) and `/scep-mtls/<pathID>`
|
||||
(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/<pathID>` (challenge-password-only, legacy) and
|
||||
`/scep-mtls/<pathID>` (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
|
||||
|
||||
@@ -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/<PathID>` 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/<PathID> (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/<PathID>` 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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -796,6 +796,30 @@ type SCEPProfileConfig struct {
|
||||
// match, expiry, RSA-or-ECDSA alg).
|
||||
RACertPath string
|
||||
RAKeyPath string
|
||||
|
||||
// MTLSEnabled gates the sibling `/scep-mtls/<PathID>` route. When true,
|
||||
// the route requires a client cert that chains to one of the certs in
|
||||
// MTLSClientCATrustBundlePath. The standard `/scep[/<PathID>]` 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user