mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:01:37 +00:00
a12a437664
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.
223 lines
8.4 KiB
Go
223 lines
8.4 KiB
Go
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
|