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:
shankar0123
2026-04-29 23:15:35 +00:00
parent 8cc1153bd9
commit aa139ee0d9
17 changed files with 3273 additions and 728 deletions
+13 -92
View File
@@ -16,6 +16,13 @@ import (
"time"
)
// EST RFC 7030 hardening master bundle Phase 2.1: the white-box parser
// tests (TestParseTrustAnchorPEM_*) moved to internal/trustanchor/holder_test.go
// where parseBundlePEM now lives. The intune package retains a thin
// public-surface test of LoadTrustAnchor — the back-compat shim that
// existing intune callers use — so a future refactor that breaks the
// shim's wire-up to trustanchor.LoadBundle is caught here.
// pemEncodeCert is a small DRY helper for the PEM bundle fixtures.
func pemEncodeCert(t *testing.T, der []byte) []byte {
t.Helper()
@@ -24,7 +31,9 @@ func pemEncodeCert(t *testing.T, der []byte) []byte {
// freshConnectorCertDER returns a freshly-minted EC P-256 cert as raw DER
// + the matching key. Lifetime is parameterised so the same factory drives
// both the happy-path and expired-cert cases.
// both happy-path and expired-cert cases. Kept in this file (not deleted with
// the white-box tests) because trust_anchor_holder_test.go's freshHolderCert
// returns *x509.Certificate while LoadTrustAnchor tests need raw DER + key.
func freshConnectorCertDER(t *testing.T, notAfter time.Time) ([]byte, *ecdsa.PrivateKey) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -44,96 +53,6 @@ func freshConnectorCertDER(t *testing.T, notAfter time.Time) ([]byte, *ecdsa.Pri
return der, key
}
func TestParseTrustAnchorPEM_HappyPath_SingleCert(t *testing.T) {
der, _ := freshConnectorCertDER(t, time.Now().Add(365*24*time.Hour))
body := pemEncodeCert(t, der)
certs, err := parseTrustAnchorPEM(body, "test", time.Now())
if err != nil {
t.Fatalf("parseTrustAnchorPEM: %v", err)
}
if len(certs) != 1 {
t.Fatalf("len(certs) = %d, want 1", len(certs))
}
if certs[0].Subject.CommonName != "intune-connector-test" {
t.Errorf("Subject.CommonName = %q", certs[0].Subject.CommonName)
}
}
func TestParseTrustAnchorPEM_HappyPath_MultiCert(t *testing.T) {
d1, _ := freshConnectorCertDER(t, time.Now().Add(30*24*time.Hour))
d2, _ := freshConnectorCertDER(t, time.Now().Add(60*24*time.Hour))
body := append(pemEncodeCert(t, d1), pemEncodeCert(t, d2)...)
certs, err := parseTrustAnchorPEM(body, "test", time.Now())
if err != nil {
t.Fatalf("parseTrustAnchorPEM: %v", err)
}
if len(certs) != 2 {
t.Fatalf("len(certs) = %d, want 2", len(certs))
}
}
func TestParseTrustAnchorPEM_SkipsNonCertBlocks(t *testing.T) {
der, key := freshConnectorCertDER(t, time.Now().Add(30*24*time.Hour))
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("MarshalECPrivateKey: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
body := append(keyPEM, pemEncodeCert(t, der)...) // priv key first, cert second
certs, err := parseTrustAnchorPEM(body, "test", time.Now())
if err != nil {
t.Fatalf("parseTrustAnchorPEM should ignore non-CERTIFICATE blocks: %v", err)
}
if len(certs) != 1 {
t.Fatalf("len(certs) = %d, want 1 (priv key block must be skipped)", len(certs))
}
}
func TestParseTrustAnchorPEM_EmptyBundleRejected(t *testing.T) {
_, err := parseTrustAnchorPEM([]byte("nothing here"), "test", time.Now())
if err == nil || !strings.Contains(err.Error(), "no CERTIFICATE PEM blocks") {
t.Fatalf("expected 'no CERTIFICATE PEM blocks' error, got %v", err)
}
}
func TestParseTrustAnchorPEM_OnlyKeyBlocksRejected(t *testing.T) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
keyDER, _ := x509.MarshalECPrivateKey(key)
body := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
_, err := parseTrustAnchorPEM(body, "test", time.Now())
if err == nil {
t.Fatalf("expected error for bundle with no certs, got nil")
}
}
func TestParseTrustAnchorPEM_ExpiredCertRejected(t *testing.T) {
der, _ := freshConnectorCertDER(t, time.Now().Add(-1*time.Hour)) // already expired
body := pemEncodeCert(t, der)
_, err := parseTrustAnchorPEM(body, "expired-bundle", time.Now())
if err == nil || !strings.Contains(err.Error(), "expired") {
t.Fatalf("expected expiry error, got %v", err)
}
// Operator-actionable message must include the subject so the audit
// log says exactly which cert to rotate.
if !strings.Contains(err.Error(), "intune-connector-test") {
t.Errorf("error must include subject CN for operator action: %v", err)
}
}
func TestParseTrustAnchorPEM_MalformedCertRejected(t *testing.T) {
bad := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("not-a-real-asn1-cert")})
_, err := parseTrustAnchorPEM(bad, "test", time.Now())
if err == nil {
t.Fatalf("expected x509 parse error, got nil")
}
}
func TestLoadTrustAnchor_FromDisk(t *testing.T) {
der, _ := freshConnectorCertDER(t, time.Now().Add(30*24*time.Hour))
body := pemEncodeCert(t, der)
@@ -150,6 +69,9 @@ func TestLoadTrustAnchor_FromDisk(t *testing.T) {
if len(certs) != 1 {
t.Fatalf("len(certs) = %d, want 1", len(certs))
}
if certs[0].Subject.CommonName != "intune-connector-test" {
t.Errorf("Subject.CommonName = %q", certs[0].Subject.CommonName)
}
}
func TestLoadTrustAnchor_EmptyPath(t *testing.T) {
@@ -164,7 +86,6 @@ func TestLoadTrustAnchor_MissingFile(t *testing.T) {
if err == nil {
t.Fatalf("expected file-not-found error, got nil")
}
// Don't string-assert on the OS error — just make sure it's surfaced.
if errors.Is(err, nil) {
t.Fatalf("error must be non-nil")
}