mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 12:08:56 +00:00
EST RFC 7030 hardening master bundle Phases 2-4: end-to-end mTLS sibling
route + RFC 9266 channel binding + HTTP Basic enrollment-password +
per-source-IP failed-auth limit + per-(CN, sourceIP) sliding-window cap.
Two new shared packages so EST + Intune share infrastructure:
- internal/cms/ — RFC 9266 tls-exporter extractor (ExtractTLSExporter
with stdlib-panic recovery for synthetic ConnectionStates) +
CSR-side channel-binding parser via raw TBSCertificationRequestInfo
walk (the stdlib's csr.Attributes can't represent the OCTET STRING
binding value), VerifyChannelBinding composite, EmbedChannel-
BindingAttribute fixture helper, typed sentinel errors for missing
/ mismatch / not-TLS-1.3 mapped to HTTP 400 / 409 / 426 in handler.
- internal/trustanchor/ — extracted from scep/intune/trust_anchor*.go
so the EST mTLS sibling route + Intune dispatcher share the same
SIGHUP-reloadable PEM bundle primitive. intune.TrustAnchorHolder
is now `= trustanchor.Holder` (type alias) + NewTrustAnchorHolder =
trustanchor.New (function alias) — every existing call site compiles
unchanged. Intune's LoadTrustAnchor is a thin wrapper over
trustanchor.LoadBundle. White-box tests moved to the new package.
- internal/ratelimit/ — extracted from scep/intune/rate_limit.go (this
was Phase 4.1, in the same bundle). intune.PerDeviceRateLimiter
is now a thin wrapper preserving the (subject, issuer)→key
composition; EST handler reaches for SlidingWindowLimiter directly.
ESTHandler grew six optional fields wired by per-profile setters
(SetMTLSTrust / SetChannelBindingRequired / SetEnrollmentPassword /
SetSourceIPRateLimiter / SetPerPrincipalRateLimiter / SetLabelForLog)
plus four new mTLS-route methods (CACertsMTLS / SimpleEnrollMTLS /
SimpleReEnrollMTLS / CSRAttrsMTLS); shared internal pipeline
handleEnrollOrReEnroll(reEnroll, viaMTLS) keeps the auth/binding/
rate-limit gates DRY. New router method RegisterESTMTLSHandlers
registers /.well-known/est-mtls/<PathID>/{cacerts,simpleenroll,
simplereenroll,csrattrs}; AuthExemptDispatchPrefixes extends the
no-auth chain to /.well-known/est-mtls.
cmd/server/main.go's EST loop wires per-profile mTLS holder +
channel-binding policy + per-principal limiter + (when EnrollmentPassword
non-empty) Basic + source-IP limiter; new preflightESTMTLSClientCATrust-
Bundle returns *trustanchor.Holder so SIGHUP rotates the EST mTLS
bundle live without restart. SCEP + EST mTLS profiles now share a
single union mtlsUnionPoolForTLS passed to buildServerTLSConfigWithMTLS
(replaces the protocol-specific scepMTLSUnionPoolForTLS); per-handler
re-verify enforces "cert must chain to THIS profile's bundle" so
cross-protocol bleed is blocked at the application layer even though
the TLS layer trusts certs from either pool's union.
Phase 3.3 source-IP failed-Basic limiter defaults: 10 attempts / 1h
/ 50k tracked IPs (no env var; tunable in a follow-up). Phase 4.2
per-principal limiter cap from CERTCTL_EST_PROFILE_<NAME>_RATE_
LIMIT_PER_PRINCIPAL_24H (existing field, Phase 1 shipped).
New tests:
- internal/cms/channelbinding_test.go: extractor + CSR-side parser +
composite + TLS-1.3 round-trip end-to-end + EmbedChannelBinding-
Attribute round-trip
- internal/trustanchor/holder_test.go: parseBundlePEM white-box +
LoadBundle + Holder Get/Pool/SetLabelForLog/Reload-happy/
Reload-keeps-old-on-failure/Reload-keeps-old-on-expired/
WatchSIGHUP-reloads-pool/WatchSIGHUP-stop-clean
- internal/api/handler/est_hardening_test.go: 16 named cases covering
mTLS no-trust-pool 500 + no-cert 401 + cross-profile cert 401 +
happy-path 200 + CACertsMTLS auth gate + CSRAttrsMTLS auth gate +
channel-binding required-absent-rejected + not-required-absent-
allowed + writeChannelBindingError mapping + Basic no-header 401
+ Basic wrong-password 401 + Basic correct-200 + Basic-no-password
no-gate + per-IP failed-attempt lockout 429 + per-principal
blocks-after-cap + different-principals-independent + no-limiter-
unbounded.
Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres which the sandbox can't build —
disk-space testcontainers download), staticcheck clean for
cms/trustanchor/api/handler/api/router/scep/intune/ratelimit/
cmd/server, go test -short -count=1 green for cms/trustanchor/
api/handler/api/router/scep/intune/ratelimit/service. G-3
docs-drift guard reproduced locally clean (Phase 1 already
documented every new env var; Phases 2-4 added zero new env vars).
This commit is contained in:
@@ -81,10 +81,11 @@ var AuthExemptRouterRoutes = []string{
|
||||
// TestDispatch_AuthExemptPrefixes regression test in cmd/server/main_test.go
|
||||
// pins this slice to buildFinalHandler's actual dispatch logic.
|
||||
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
|
||||
"/.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
|
||||
"/.well-known/est-mtls", // EST + mTLS sibling route (EST hardening Phase 2) — auth is client cert
|
||||
"/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.
|
||||
@@ -445,6 +446,44 @@ func (r *Router) RegisterESTHandlers(handlers map[string]handler.ESTHandler) {
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterESTMTLSHandlers sets up the sibling `/.well-known/est-mtls/<PathID>/`
|
||||
// routes for EST profiles that opted into mTLS via
|
||||
// `CERTCTL_EST_PROFILE_<NAME>_MTLS_ENABLED=true`.
|
||||
//
|
||||
// EST RFC 7030 hardening master bundle Phase 2.2 + 2.3: 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 (Phase 3)
|
||||
// HTTP Basic enrollment-password as a defense-in-depth fallback for the
|
||||
// non-mTLS profile. Devices present a bootstrap cert from a trusted CA,
|
||||
// then EST-enroll for their long-lived cert. Mirrors the SCEP mTLS
|
||||
// sibling pattern at RegisterSCEPMTLSHandlers below (commit 6b0d9e from
|
||||
// the SCEP Phase 6.5 work).
|
||||
//
|
||||
// Path conventions: every mTLS profile gets a non-empty PathID, so the
|
||||
// sibling routes are always /.well-known/est-mtls/<pathID>/. There is no
|
||||
// "empty PathID = legacy /.well-known/est-mtls" case — mTLS is opt-in
|
||||
// per profile, the legacy /.well-known/est root is always non-mTLS to
|
||||
// preserve backward compat with existing deploys.
|
||||
//
|
||||
// Each handler in the map MUST have had SetMTLSTrust called so the
|
||||
// per-profile cert verification has a trust anchor. cmd/server/main.go's
|
||||
// per-profile EST loop wires this in the same loop iteration that
|
||||
// registers the handler.
|
||||
func (r *Router) RegisterESTMTLSHandlers(handlers map[string]handler.ESTHandler) {
|
||||
for pathID, h := range handlers {
|
||||
if pathID == "" {
|
||||
continue // mTLS sibling route requires per-profile PathID
|
||||
}
|
||||
hCopy := h // h is captured by value — see RegisterESTHandlers above
|
||||
prefix := "/.well-known/est-mtls/" + pathID
|
||||
r.Register("GET "+prefix+"/cacerts", http.HandlerFunc(hCopy.CACertsMTLS))
|
||||
r.Register("POST "+prefix+"/simpleenroll", http.HandlerFunc(hCopy.SimpleEnrollMTLS))
|
||||
r.Register("POST "+prefix+"/simplereenroll", http.HandlerFunc(hCopy.SimpleReEnrollMTLS))
|
||||
r.Register("GET "+prefix+"/csrattrs", http.HandlerFunc(hCopy.CSRAttrsMTLS))
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterSCEPHandlers sets up SCEP (RFC 8894) routes.
|
||||
// SCEP uses a single endpoint per profile with operation-based dispatch via
|
||||
// query parameters. Authentication is via the challengePassword attribute in
|
||||
|
||||
Reference in New Issue
Block a user