mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 17:48:52 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user