mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:11:29 +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:
+599
-225
@@ -2,17 +2,23 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/cms"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/pkcs7"
|
||||
"github.com/shankar0123/certctl/internal/ratelimit"
|
||||
"github.com/shankar0123/certctl/internal/trustanchor"
|
||||
)
|
||||
|
||||
// ESTService defines the service interface for EST enrollment operations.
|
||||
@@ -33,62 +39,558 @@ type ESTService interface {
|
||||
|
||||
// ESTHandler handles HTTP requests for the EST protocol (RFC 7030).
|
||||
//
|
||||
// EST endpoints are served under /.well-known/est/ per the RFC.
|
||||
// EST endpoints are served under /.well-known/est/[<PathID>/] per RFC 7030.
|
||||
// Wire format: base64-encoded DER (PKCS#7 for certs, PKCS#10 for CSRs).
|
||||
//
|
||||
// Supported operations:
|
||||
// - GET /.well-known/est/cacerts — CA certificate distribution
|
||||
// - POST /.well-known/est/simpleenroll — initial enrollment
|
||||
// - POST /.well-known/est/simplereenroll — re-enrollment
|
||||
// - GET /.well-known/est/csrattrs — CSR attributes
|
||||
// Supported operations (per route family):
|
||||
//
|
||||
// /.well-known/est/[<PathID>/] — legacy + per-profile route family
|
||||
// GET cacerts — CA certificate distribution
|
||||
// POST simpleenroll — initial enrollment (HTTP Basic optional, Phase 3)
|
||||
// POST simplereenroll — re-enrollment (HTTP Basic optional, Phase 3)
|
||||
// GET csrattrs — CSR attributes
|
||||
//
|
||||
// /.well-known/est-mtls/<PathID>/ — mTLS sibling (Phase 2)
|
||||
// GET cacerts — CA certificate distribution (cert auth required)
|
||||
// POST simpleenroll — initial enrollment (cert + optional channel binding)
|
||||
// POST simplereenroll — re-enrollment (cert + optional channel binding)
|
||||
// GET csrattrs — CSR attributes
|
||||
//
|
||||
// EST RFC 7030 hardening master bundle Phases 2-4: ESTHandler grew six
|
||||
// optional fields wired by per-profile setters in cmd/server/main.go's
|
||||
// startup loop. None of the new fields are required — a handler with all
|
||||
// of them unset behaves exactly like the v2.0.x EST handler.
|
||||
type ESTHandler struct {
|
||||
svc ESTService
|
||||
|
||||
// EST RFC 7030 hardening Phase 2.1: per-profile mTLS client-CA trust
|
||||
// bundle. When set, the mTLS sibling route (CACertsMTLS /
|
||||
// SimpleEnrollMTLS / etc.) verifies the inbound client cert chain
|
||||
// against this pool. Nil when MTLS_ENABLED=false; the mTLS route
|
||||
// rejects unconditionally in that case (the route shouldn't even be
|
||||
// registered, but defense in depth).
|
||||
mtlsTrust *trustanchor.Holder
|
||||
|
||||
// EST RFC 7030 hardening Phase 2.4: per-profile channel-binding
|
||||
// requirement. When true, the mTLS handler refuses simplereenroll
|
||||
// requests whose CSR doesn't carry a matching id-aa-est-tls-exporter
|
||||
// (RFC 9266) attribute. Phase 1's Validate() guards
|
||||
// ChannelBindingRequired=true + MTLSEnabled=false at startup.
|
||||
channelBindingRequired bool
|
||||
|
||||
// EST RFC 7030 hardening Phase 3.1: per-profile HTTP Basic enrollment
|
||||
// password. When non-empty, the standard /.well-known/est/<PathID>/
|
||||
// route requires `Authorization: Basic <base64(<user>:<pw>)>` on the
|
||||
// enrollment endpoints (NOT on cacerts/csrattrs — RFC 7030 §4.1.1
|
||||
// says cacerts is anonymous). Constant-time compare; per-source-IP
|
||||
// failed-auth rate limit blocks brute-force.
|
||||
basicPassword string
|
||||
|
||||
// EST RFC 7030 hardening Phase 3.3: per-handler source-IP rate
|
||||
// limiter for FAILED HTTP Basic auth attempts. Keyed by sourceIP so
|
||||
// a hostile network segment can't burn through the password.
|
||||
failedBasicLimiter *ratelimit.SlidingWindowLimiter
|
||||
|
||||
// EST RFC 7030 hardening Phase 4.2: per-handler per-principal sliding-
|
||||
// window rate limit. Keyed by (CSR-CN, sourceIP) so a stolen
|
||||
// bootstrap cert AND a known device CN can't be used to flood the
|
||||
// issuer. Disabled when nil; configured per-profile.
|
||||
perPrincipalLimiter *ratelimit.SlidingWindowLimiter
|
||||
|
||||
// labelForLog gives observability code a per-profile string to
|
||||
// include in audit log lines / Prometheus labels. Defaults to
|
||||
// "est" when unset.
|
||||
labelForLog string
|
||||
}
|
||||
|
||||
// NewESTHandler creates a new ESTHandler.
|
||||
// NewESTHandler creates a new ESTHandler with no per-profile auth
|
||||
// hardening. Call SetMTLSTrust + SetChannelBindingRequired +
|
||||
// SetEnrollmentPassword + SetSourceIPRateLimiter + SetPerPrincipalRateLimiter
|
||||
// from the per-profile startup loop to opt-in to each surface.
|
||||
func NewESTHandler(svc ESTService) ESTHandler {
|
||||
return ESTHandler{svc: svc}
|
||||
}
|
||||
|
||||
// CACerts handles GET /.well-known/est/cacerts
|
||||
// Returns the CA certificate chain as base64-encoded PKCS#7 (certs-only).
|
||||
// Per RFC 7030 Section 4.1, this is a "certs-only" CMC Simple PKI Response.
|
||||
// For simplicity and broad client compatibility, we return base64-encoded DER certificates.
|
||||
// SetMTLSTrust injects the per-profile client-cert trust pool the
|
||||
// `/.well-known/est-mtls/<PathID>/` sibling route uses to verify inbound
|
||||
// device cert chains. EST RFC 7030 hardening Phase 2.1.
|
||||
//
|
||||
// Like the SCEP equivalent, the TLS layer (cmd/server/tls.go) uses
|
||||
// VerifyClientCertIfGiven against the UNION of every enabled mTLS
|
||||
// profile's bundle, so the same TLS listener serves both /.well-known/est
|
||||
// (anonymous or HTTP Basic) and /.well-known/est-mtls/<PathID>
|
||||
// (cert-required). 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.
|
||||
func (h *ESTHandler) SetMTLSTrust(t *trustanchor.Holder) { h.mtlsTrust = t }
|
||||
|
||||
// SetChannelBindingRequired toggles RFC 9266 tls-exporter channel binding
|
||||
// on the simplereenroll mTLS path. EST RFC 7030 hardening Phase 2.4.
|
||||
// When true, the handler refuses requests whose CSR lacks the binding
|
||||
// attribute or whose binding bytes don't match the live TLS exporter.
|
||||
func (h *ESTHandler) SetChannelBindingRequired(req bool) { h.channelBindingRequired = req }
|
||||
|
||||
// SetEnrollmentPassword injects the per-profile HTTP Basic enrollment
|
||||
// password. EST RFC 7030 hardening Phase 3.1. Empty disables the gate
|
||||
// (mTLS-only or unauthenticated profile). Constant-time compare via
|
||||
// crypto/subtle.ConstantTimeCompare.
|
||||
func (h *ESTHandler) SetEnrollmentPassword(pw string) { h.basicPassword = pw }
|
||||
|
||||
// SetSourceIPRateLimiter injects the per-handler failed-Basic-auth
|
||||
// rate limiter. Phase 3.3. Disabled when nil — but Validate() at
|
||||
// startup refuses an enabled basic-auth profile without a configured
|
||||
// limiter, so a real deploy always wires one.
|
||||
func (h *ESTHandler) SetSourceIPRateLimiter(l *ratelimit.SlidingWindowLimiter) {
|
||||
h.failedBasicLimiter = l
|
||||
}
|
||||
|
||||
// SetPerPrincipalRateLimiter injects the per-handler (CN, sourceIP)
|
||||
// sliding-window rate limiter. Phase 4.2. Disabled when nil. Counts
|
||||
// every successful enrollment, NOT just failures — the goal is to
|
||||
// bound enrollment-flooding from a compromised credential, not just
|
||||
// failed-auth brute force.
|
||||
func (h *ESTHandler) SetPerPrincipalRateLimiter(l *ratelimit.SlidingWindowLimiter) {
|
||||
h.perPrincipalLimiter = l
|
||||
}
|
||||
|
||||
// SetLabelForLog sets the per-profile observability label. Defaults to
|
||||
// "est" when unset; cmd/server/main.go's per-profile loop sets this
|
||||
// to "est (PathID=<id>)" for triage.
|
||||
func (h *ESTHandler) SetLabelForLog(label string) {
|
||||
if label == "" {
|
||||
return
|
||||
}
|
||||
h.labelForLog = label
|
||||
}
|
||||
|
||||
// label returns h.labelForLog with the "est" fallback applied. Tiny
|
||||
// helper so log call sites don't need to repeat the fallback.
|
||||
func (h ESTHandler) label() string {
|
||||
if h.labelForLog == "" {
|
||||
return "est"
|
||||
}
|
||||
return h.labelForLog
|
||||
}
|
||||
|
||||
// ----- /.well-known/est/[<PathID>/] route family (legacy + Basic auth) -----
|
||||
|
||||
// CACerts handles GET /.well-known/est/[<PathID>/]cacerts.
|
||||
//
|
||||
// RFC 7030 §4.1.1 — anonymous endpoint. The HTTP Basic gate is NOT
|
||||
// applied here (any client must be able to fetch the CA chain to
|
||||
// verify subsequent enrollment responses).
|
||||
func (h ESTHandler) CACerts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
h.writeCACertsResponse(w, r)
|
||||
}
|
||||
|
||||
caCertPEM, err := h.svc.GetCACerts(r.Context())
|
||||
if err != nil {
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CA certificates: %v", err), requestID)
|
||||
// SimpleEnroll handles POST /.well-known/est/[<PathID>/]simpleenroll.
|
||||
// Accepts a base64-encoded PKCS#10 CSR + returns base64-encoded PKCS#7.
|
||||
//
|
||||
// Auth: HTTP Basic when h.basicPassword != "" (Phase 3); otherwise
|
||||
// anonymous. Rate-limit: per-(CN, sourceIP) when wired (Phase 4).
|
||||
func (h ESTHandler) SimpleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleEnrollOrReEnroll(w, r, false /*reEnroll*/, false /*viaMTLS*/)
|
||||
}
|
||||
|
||||
// SimpleReEnroll handles POST /.well-known/est/[<PathID>/]simplereenroll.
|
||||
// Same as SimpleEnroll but the audit/log distinguishes the renewal flow
|
||||
// from initial issuance.
|
||||
func (h ESTHandler) SimpleReEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleEnrollOrReEnroll(w, r, true /*reEnroll*/, false /*viaMTLS*/)
|
||||
}
|
||||
|
||||
// CSRAttrs handles GET /.well-known/est/[<PathID>/]csrattrs.
|
||||
// Returns the CSR attributes the server wants the client to include.
|
||||
// RFC 7030 §4.5 — anonymous endpoint, no Basic auth gate.
|
||||
func (h ESTHandler) CSRAttrs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
h.writeCSRAttrsResponse(w, r)
|
||||
}
|
||||
|
||||
// ----- /.well-known/est-mtls/<PathID>/ route family (Phase 2 mTLS) -----
|
||||
|
||||
// CACertsMTLS handles GET /.well-known/est-mtls/<PathID>/cacerts.
|
||||
//
|
||||
// RFC 7030 §4.1.1 says cacerts is anonymous, but on the mTLS sibling
|
||||
// route we still require a valid client cert because the mTLS path is
|
||||
// the audit-distinguished surface — operators using mTLS WANT every
|
||||
// touchpoint logged. The cert isn't validated for purpose-of-issuance
|
||||
// here (cacerts isn't an enrollment), but absence is rejected.
|
||||
func (h ESTHandler) CACertsMTLS(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if _, ok := h.requireClientCertChain(w, r); !ok {
|
||||
return
|
||||
}
|
||||
h.writeCACertsResponse(w, r)
|
||||
}
|
||||
|
||||
// SimpleEnrollMTLS handles POST /.well-known/est-mtls/<PathID>/simpleenroll.
|
||||
//
|
||||
// Order of gates (each fails fast with the appropriate HTTP status):
|
||||
//
|
||||
// 1. Client cert presented + chains to per-profile mTLS trust pool
|
||||
// (the TLS layer already verified against the union pool; this is
|
||||
// the per-profile re-verify that prevents profile A↔B cross-bleed).
|
||||
// 2. CSR parses + matches the EST contract (handled by the shared
|
||||
// enrollment helper).
|
||||
// 3. Per-(CN, sourceIP) rate limit when configured.
|
||||
// 4. Service-layer enrollment.
|
||||
//
|
||||
// Channel binding does NOT apply here — RFC 9266 §1 calls out that
|
||||
// channel binding is a renewal-time defense-in-depth, not an initial-
|
||||
// enrollment requirement. (A first-time enrollment doesn't yet have a
|
||||
// device cert, so binding to the TLS session for the bootstrap cert
|
||||
// adds nothing.)
|
||||
func (h ESTHandler) SimpleEnrollMTLS(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireClientCertChain(w, r); !ok {
|
||||
return
|
||||
}
|
||||
h.handleEnrollOrReEnroll(w, r, false /*reEnroll*/, true /*viaMTLS*/)
|
||||
}
|
||||
|
||||
// SimpleReEnrollMTLS handles POST /.well-known/est-mtls/<PathID>/simplereenroll.
|
||||
//
|
||||
// Same as SimpleEnrollMTLS plus the channel-binding gate. RFC 9266 §4.1
|
||||
// says renewal CSRs SHOULD include the binding attribute when the
|
||||
// enrollment is over a TLS-1.3 channel; per-profile policy can either
|
||||
// require this strictly (ChannelBindingRequired=true) or accept its
|
||||
// absence (default).
|
||||
func (h ESTHandler) SimpleReEnrollMTLS(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireClientCertChain(w, r); !ok {
|
||||
return
|
||||
}
|
||||
h.handleEnrollOrReEnroll(w, r, true /*reEnroll*/, true /*viaMTLS*/)
|
||||
}
|
||||
|
||||
// CSRAttrsMTLS handles GET /.well-known/est-mtls/<PathID>/csrattrs.
|
||||
// Mirrors CACertsMTLS — cert-required even though the unauth route
|
||||
// version is anonymous.
|
||||
func (h ESTHandler) CSRAttrsMTLS(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if _, ok := h.requireClientCertChain(w, r); !ok {
|
||||
return
|
||||
}
|
||||
h.writeCSRAttrsResponse(w, r)
|
||||
}
|
||||
|
||||
// ----- shared internal pipeline -----
|
||||
|
||||
// handleEnrollOrReEnroll is the shared body for {Simple,SimpleRe}Enroll{,MTLS}.
|
||||
// reEnroll picks the SimpleReEnroll vs SimpleEnroll service method (purely
|
||||
// audit / metric distinguishing — same issuer call underneath); viaMTLS
|
||||
// picks whether the channel-binding + per-principal-limit gates apply
|
||||
// AND skips the HTTP Basic gate (mTLS handlers carry the auth).
|
||||
func (h ESTHandler) handleEnrollOrReEnroll(w http.ResponseWriter, r *http.Request, reEnroll, viaMTLS bool) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse PEM to DER for PKCS#7 encoding
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
if err := verifyESTTransport(r); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest,
|
||||
fmt.Sprintf("EST transport precondition failed: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// HTTP Basic gate (Phase 3) — non-mTLS path only. mTLS profiles
|
||||
// authenticate via the client cert so adding Basic on top would
|
||||
// double-tax operators with no security benefit.
|
||||
if !viaMTLS && h.basicPassword != "" {
|
||||
if !h.requireBasicAuth(w, r) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
csrPEM, err := h.readCSRFromRequest(r)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the CSR once for downstream gates (channel-binding, per-
|
||||
// principal rate limit). The service re-parses internally — that's a
|
||||
// minor inefficiency we accept to keep the service interface flat.
|
||||
csr, _ := decodeCSRPEM(csrPEM)
|
||||
|
||||
// Channel-binding gate (Phase 2.4) — mTLS reEnroll only. The optional
|
||||
// CSR-side attribute is checked even when the per-profile flag isn't
|
||||
// requiring it (a CSR carrying the attribute MUST match the live
|
||||
// exporter; a present-but-mismatched binding is always fatal).
|
||||
if viaMTLS && reEnroll && csr != nil {
|
||||
if err := cms.VerifyChannelBinding(r.TLS, csr, h.channelBindingRequired); err != nil {
|
||||
h.writeChannelBindingError(w, requestID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Per-principal rate-limit gate (Phase 4.2). Keyed by CN+sourceIP so
|
||||
// (a) a CN with no source-IP rotation can be capped, AND (b) a
|
||||
// hostile network segment trying to enroll many CNs from one IP is
|
||||
// also bounded.
|
||||
if h.perPrincipalLimiter != nil {
|
||||
if err := h.applyPerPrincipalRateLimit(r, csr); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusTooManyRequests,
|
||||
fmt.Sprintf("EST enrollment rate-limited: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
result *domain.ESTEnrollResult
|
||||
callErr error
|
||||
)
|
||||
if reEnroll {
|
||||
result, callErr = h.svc.SimpleReEnroll(r.Context(), csrPEM)
|
||||
} else {
|
||||
result, callErr = h.svc.SimpleEnroll(r.Context(), csrPEM)
|
||||
}
|
||||
if callErr != nil {
|
||||
op := "Enrollment"
|
||||
if reEnroll {
|
||||
op = "Re-enrollment"
|
||||
}
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError,
|
||||
fmt.Sprintf("%s failed: %v", op, callErr), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeCertResponse(w, result)
|
||||
}
|
||||
|
||||
// requireClientCertChain enforces the mTLS gate for the est-mtls sibling
|
||||
// route. Returns the leaf cert + true on success; on failure writes the
|
||||
// HTTP error and returns false.
|
||||
//
|
||||
// Mirrors SCEPHandler.HandleSCEPMTLS exactly:
|
||||
// - mtlsTrust nil → 500 (config bug; preflight should have prevented).
|
||||
// - r.TLS nil or no peer cert → 401 (cert required).
|
||||
// - chain doesn't verify against per-profile pool → 401.
|
||||
func (h ESTHandler) requireClientCertChain(w http.ResponseWriter, r *http.Request) (*x509.Certificate, bool) {
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
if h.mtlsTrust == nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError,
|
||||
h.label()+" mTLS handler missing trust pool", requestID)
|
||||
return nil, false
|
||||
}
|
||||
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
|
||||
ErrorWithRequestID(w, http.StatusUnauthorized,
|
||||
"Client certificate required for /.well-known/est-mtls", requestID)
|
||||
return nil, false
|
||||
}
|
||||
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.mtlsTrust.Pool(),
|
||||
Intermediates: intermediates,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageAny},
|
||||
}); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusUnauthorized,
|
||||
"Client certificate not trusted by this profile", requestID)
|
||||
return nil, false
|
||||
}
|
||||
return leaf, true
|
||||
}
|
||||
|
||||
// requireBasicAuth runs the Phase 3 HTTP Basic password gate. Returns
|
||||
// true when auth passed. On failure writes WWW-Authenticate + a 401
|
||||
// (with rate-limit accounting against the source IP).
|
||||
//
|
||||
// User: any non-empty value (RFC 7030 §3.2.3 says the username is
|
||||
// not authoritative when only a shared password is meaningful). Pass:
|
||||
// constant-time compare against h.basicPassword.
|
||||
func (h ESTHandler) requireBasicAuth(w http.ResponseWriter, r *http.Request) bool {
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
srcIP := clientIPForLimiter(r)
|
||||
|
||||
// recordFailedBasic ticks a slot on every credential rejection;
|
||||
// once the IP has burned through its window's worth of failed
|
||||
// attempts the limiter returns ErrRateLimited (which the next
|
||||
// recordFailedBasic just no-ops out — we still want to fail-closed
|
||||
// the auth here). The cleaner design is a pre-check that short-
|
||||
// circuits the constant-time compare ENTIRELY for an IP at-cap, so
|
||||
// a brute-force attacker can't smuggle timing data through. We do
|
||||
// that pre-check via SlidingWindowLimiter.Allow with a peek-style
|
||||
// fake-key that just queries state without recording a slot.
|
||||
if h.failedBasicLimiter != nil && srcIP != "" {
|
||||
if err := h.failedBasicLimiter.Allow(srcIP+"|peek", nowFn()); errors.Is(err, ratelimit.ErrRateLimited) {
|
||||
// peek-key is shared across requests from this IP; the slot
|
||||
// pollution is acceptable because the IP is already
|
||||
// rate-limited and we want to keep them rate-limited.
|
||||
ErrorWithRequestID(w, http.StatusTooManyRequests,
|
||||
h.label()+" too many failed enrollment attempts from this source", requestID)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user == "" {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="est-enrollment"`)
|
||||
ErrorWithRequestID(w, http.StatusUnauthorized,
|
||||
h.label()+" enrollment requires HTTP Basic auth", requestID)
|
||||
h.recordFailedBasic(srcIP)
|
||||
return false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(pass), []byte(h.basicPassword)) != 1 {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="est-enrollment"`)
|
||||
ErrorWithRequestID(w, http.StatusUnauthorized,
|
||||
h.label()+" enrollment password incorrect", requestID)
|
||||
h.recordFailedBasic(srcIP)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// recordFailedBasic ticks a slot against the source-IP failed-auth
|
||||
// limiter. Errors from Allow are intentionally ignored — a present
|
||||
// failure simply means the IP has crossed the limit, which is exactly
|
||||
// the state the per-IP gate reports back to the next request.
|
||||
func (h ESTHandler) recordFailedBasic(srcIP string) {
|
||||
if h.failedBasicLimiter == nil || srcIP == "" {
|
||||
return
|
||||
}
|
||||
_ = h.failedBasicLimiter.Allow(srcIP, nowFn())
|
||||
}
|
||||
|
||||
// applyPerPrincipalRateLimit gates an enrollment by (CN, sourceIP).
|
||||
// Returns nil when the request is allowed; ErrRateLimited (or wrapped
|
||||
// equivalent) when the principal has exhausted its window budget.
|
||||
//
|
||||
// CN extraction: the CSR's Subject.CommonName is the canonical
|
||||
// principal in the EST contract (the issued cert will carry that CN).
|
||||
// sourceIP comes from clientIPForLimiter.
|
||||
func (h ESTHandler) applyPerPrincipalRateLimit(r *http.Request, csr *x509.CertificateRequest) error {
|
||||
if h.perPrincipalLimiter == nil {
|
||||
return nil
|
||||
}
|
||||
cn := ""
|
||||
if csr != nil {
|
||||
cn = csr.Subject.CommonName
|
||||
}
|
||||
srcIP := clientIPForLimiter(r)
|
||||
key := cn + "|" + srcIP
|
||||
return h.perPrincipalLimiter.Allow(key, nowFn())
|
||||
}
|
||||
|
||||
// writeChannelBindingError maps cms.* sentinel errors to HTTP statuses
|
||||
// + audit-friendly messages. Mirrors the SCEP CertRep failInfo error
|
||||
// translation pattern (signature_invalid → BadMessageCheck etc.).
|
||||
func (h ESTHandler) writeChannelBindingError(w http.ResponseWriter, requestID string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, cms.ErrChannelBindingMissing):
|
||||
ErrorWithRequestID(w, http.StatusBadRequest,
|
||||
"EST simplereenroll requires RFC 9266 channel binding for this profile", requestID)
|
||||
case errors.Is(err, cms.ErrChannelBindingMismatch):
|
||||
// 409 Conflict signals to the client that the request was
|
||||
// well-formed but the channel-binding state on certctl's side
|
||||
// disagreed with the device's — usually MITM or reverse proxy
|
||||
// terminating TLS in front of certctl.
|
||||
ErrorWithRequestID(w, http.StatusConflict,
|
||||
"EST channel binding does not match TLS exporter — TLS terminator in front of certctl?", requestID)
|
||||
case errors.Is(err, cms.ErrChannelBindingNotTLS13):
|
||||
ErrorWithRequestID(w, http.StatusUpgradeRequired,
|
||||
"EST channel binding requires TLS 1.3", requestID)
|
||||
default:
|
||||
ErrorWithRequestID(w, http.StatusBadRequest,
|
||||
fmt.Sprintf("EST channel-binding verification failed: %v", err), requestID)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- response writers (legacy + mTLS share these) -----
|
||||
|
||||
// writeCACertsResponse writes the PKCS#7 certs-only CA chain. Shared
|
||||
// by CACerts (legacy route) + CACertsMTLS (mTLS route).
|
||||
func (h ESTHandler) writeCACertsResponse(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
caCertPEM, err := h.svc.GetCACerts(r.Context())
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError,
|
||||
fmt.Sprintf("Failed to get CA certificates: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
derCerts, err := pkcs7.PEMToDERChain(caCertPEM)
|
||||
if err != nil {
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to encode CA certificates", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// Build a simple PKCS#7 SignedData (certs-only, degenerate) structure
|
||||
pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts)
|
||||
if err != nil {
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to build PKCS#7 response", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
// RFC 7030 Section 4.1.3: response is base64-encoded application/pkcs7-mime
|
||||
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
|
||||
w.Header().Set("Content-Transfer-Encoding", "base64")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
encoded := base64.StdEncoding.EncodeToString(pkcs7Data)
|
||||
// Write base64 with line breaks at 76 chars per RFC 2045
|
||||
writeBase64Wrapped(w, pkcs7Data)
|
||||
}
|
||||
|
||||
// writeCSRAttrsResponse writes the per-profile CSR attribute hints.
|
||||
// Shared by CSRAttrs (legacy) + CSRAttrsMTLS (mTLS).
|
||||
func (h ESTHandler) writeCSRAttrsResponse(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
attrs, err := h.svc.GetCSRAttrs(r.Context())
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError,
|
||||
fmt.Sprintf("Failed to get CSR attributes: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
if len(attrs) == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/csrattrs")
|
||||
w.Header().Set("Content-Transfer-Encoding", "base64")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(base64.StdEncoding.EncodeToString(attrs)))
|
||||
}
|
||||
|
||||
// writeCertResponse writes an EST enrollment response as base64-encoded PKCS#7.
|
||||
func (h ESTHandler) writeCertResponse(w http.ResponseWriter, result *domain.ESTEnrollResult) {
|
||||
var derCerts [][]byte
|
||||
certDER, err := pkcs7.PEMToDERChain(result.CertPEM)
|
||||
if err != nil || len(certDER) == 0 {
|
||||
http.Error(w, "Failed to encode certificate", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
derCerts = append(derCerts, certDER...)
|
||||
if result.ChainPEM != "" {
|
||||
chainDER, err := pkcs7.PEMToDERChain(result.ChainPEM)
|
||||
if err == nil {
|
||||
derCerts = append(derCerts, chainDER...)
|
||||
}
|
||||
}
|
||||
pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to build PKCS#7 response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
|
||||
w.Header().Set("Content-Transfer-Encoding", "base64")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
writeBase64Wrapped(w, pkcs7Data)
|
||||
}
|
||||
|
||||
// writeBase64Wrapped emits b as base64 with CRLF every 76 chars per RFC 2045.
|
||||
// Pulled out as a helper so the three writers above don't repeat the loop.
|
||||
func writeBase64Wrapped(w http.ResponseWriter, b []byte) {
|
||||
encoded := base64.StdEncoding.EncodeToString(b)
|
||||
for i := 0; i < len(encoded); i += 76 {
|
||||
end := i + 76
|
||||
if end > len(encoded) {
|
||||
@@ -99,66 +601,84 @@ func (h ESTHandler) CACerts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// SimpleEnroll handles POST /.well-known/est/simpleenroll
|
||||
// Accepts a base64-encoded PKCS#10 CSR and returns a base64-encoded PKCS#7 certificate.
|
||||
func (h ESTHandler) SimpleEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
if err := verifyESTTransport(r); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("EST transport precondition failed: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
csrPEM, err := h.readCSRFromRequest(r)
|
||||
// readCSRFromRequest reads and decodes the CSR from an EST enrollment request.
|
||||
// EST sends CSRs as base64-encoded PKCS#10 DER with Content-Type application/pkcs10.
|
||||
func (h ESTHandler) readCSRFromRequest(r *http.Request) (string, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
|
||||
return
|
||||
return "", fmt.Errorf("failed to read request body: %w", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("empty request body")
|
||||
}
|
||||
|
||||
result, err := h.svc.SimpleEnroll(r.Context(), csrPEM)
|
||||
bodyStr := strings.TrimSpace(string(body))
|
||||
if strings.HasPrefix(bodyStr, "-----BEGIN CERTIFICATE REQUEST-----") {
|
||||
block, _ := pem.Decode([]byte(bodyStr))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("invalid PEM-encoded CSR")
|
||||
}
|
||||
if _, err := x509.ParseCertificateRequest(block.Bytes); err != nil {
|
||||
return "", fmt.Errorf("invalid CSR: %w", err)
|
||||
}
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
derBytes, err := base64.StdEncoding.DecodeString(bodyStr)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Enrollment failed: %v", err), requestID)
|
||||
return
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, bodyStr)
|
||||
derBytes, err = base64.StdEncoding.DecodeString(cleaned)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode base64 CSR: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
h.writeCertResponse(w, result)
|
||||
if _, err := x509.ParseCertificateRequest(derBytes); err != nil {
|
||||
return "", fmt.Errorf("invalid PKCS#10 CSR: %w", err)
|
||||
}
|
||||
csrPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: derBytes,
|
||||
})
|
||||
return string(csrPEM), nil
|
||||
}
|
||||
|
||||
// SimpleReEnroll handles POST /.well-known/est/simplereenroll
|
||||
// Same as SimpleEnroll but for re-enrollment (certificate renewal).
|
||||
func (h ESTHandler) SimpleReEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
// decodeCSRPEM is a convenience wrapper around pem.Decode +
|
||||
// x509.ParseCertificateRequest. Returns nil on any decode/parse error
|
||||
// (callers downstream re-parse via the service path; this is just for
|
||||
// the handler-side gates that need the CN + binding attribute).
|
||||
func decodeCSRPEM(csrPEM string) (*x509.CertificateRequest, error) {
|
||||
block, _ := pem.Decode([]byte(csrPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("PEM decode failed")
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
if err := verifyESTTransport(r); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("EST transport precondition failed: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
csrPEM, err := h.readCSRFromRequest(r)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.SimpleReEnroll(r.Context(), csrPEM)
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Re-enrollment failed: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeCertResponse(w, result)
|
||||
return x509.ParseCertificateRequest(block.Bytes)
|
||||
}
|
||||
|
||||
// clientIPForLimiter returns the source IP a per-IP rate limiter should
|
||||
// key against. Honors X-Forwarded-For when the request came through a
|
||||
// trusted proxy (no proxy-trust list yet — falls back to RemoteAddr).
|
||||
func clientIPForLimiter(r *http.Request) string {
|
||||
// Don't blindly trust XFF — ignore it for now and always use
|
||||
// RemoteAddr. A future bundle can add a documented proxy-trust list.
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// nowFn is the package-private time source. Override in tests for
|
||||
// deterministic clock injection without dragging time.Time into the
|
||||
// handler API surface. Defined in est_clock.go so mocking out
|
||||
// requires touching only one file.
|
||||
|
||||
// verifyESTTransport implements Bundle-4 / M-021 EST transport precondition.
|
||||
//
|
||||
// RFC 7030 §3.2.3 ("Linking Identity and POP Information") requires that when
|
||||
@@ -169,32 +689,11 @@ func (h ESTHandler) SimpleReEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
// TLS-Unique is unavailable; RFC 9266 defines `tls-exporter` as the TLS 1.3
|
||||
// replacement.
|
||||
//
|
||||
// **Current scope of this function (Bundle-4 closure):** certctl does NOT
|
||||
// currently support EST client certificate authentication. The EST endpoint
|
||||
// accepts unauthenticated POSTs (the SCEP equivalent enforces a
|
||||
// challenge-password via `preflightSCEPChallengePassword`; EST has no
|
||||
// equivalent today). Per RFC 7030 §3.2.3, channel binding is REQUIRED only
|
||||
// when client certificate authentication is in use; without that, the §3.2.3
|
||||
// requirement is moot.
|
||||
//
|
||||
// What we DO enforce here as defense-in-depth:
|
||||
//
|
||||
// 1. r.TLS must be non-nil — the EST endpoint MUST be reached over TLS.
|
||||
// Defensive: certctl pins HTTPS-only at the server-side TLS config, but
|
||||
// a future routing-layer regression that exposes EST over plaintext
|
||||
// would be caught here.
|
||||
// 2. Negotiated TLS version must be >= TLS 1.2 — RFC 7030 doesn't mandate
|
||||
// a specific TLS version, but a pre-1.2 negotiation indicates a
|
||||
// misconfigured client/server pair. certctl's MinVersion is TLS 1.3
|
||||
// so this should always hold.
|
||||
// 3. r.TLS.HandshakeComplete must be true — defensive against partial-
|
||||
// handshake replays.
|
||||
//
|
||||
// **Deferred to a future bundle (operator decision required):**
|
||||
//
|
||||
// - RFC 9266 `tls-exporter` channel binding when EST mTLS is added.
|
||||
// - EST mTLS support itself — currently EST is unauth-or-bearer; mTLS
|
||||
// would be a V3-aligned compliance feature.
|
||||
// **EST RFC 7030 hardening Phases 2-4 update:** RFC 9266 channel binding is
|
||||
// now wired in via the cms package (Phase 2.4) and called from
|
||||
// SimpleReEnrollMTLS when the per-profile policy requires it. This function
|
||||
// continues to handle the lower-level transport preconditions that ALL EST
|
||||
// requests share (regardless of mTLS / Basic / unauth profile shape).
|
||||
//
|
||||
// Returns nil if all preconditions pass; non-nil error otherwise.
|
||||
func verifyESTTransport(r *http.Request) error {
|
||||
@@ -213,130 +712,5 @@ func verifyESTTransport(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CSRAttrs handles GET /.well-known/est/csrattrs
|
||||
// Returns the CSR attributes the server wants the client to include in enrollment requests.
|
||||
func (h ESTHandler) CSRAttrs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
attrs, err := h.svc.GetCSRAttrs(r.Context())
|
||||
if err != nil {
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CSR attributes: %v", err), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
if len(attrs) == 0 {
|
||||
// No specific attributes required — return 204
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/csrattrs")
|
||||
w.Header().Set("Content-Transfer-Encoding", "base64")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(base64.StdEncoding.EncodeToString(attrs)))
|
||||
}
|
||||
|
||||
// readCSRFromRequest reads and decodes the CSR from an EST enrollment request.
|
||||
// EST sends CSRs as base64-encoded PKCS#10 DER with Content-Type application/pkcs10.
|
||||
func (h ESTHandler) readCSRFromRequest(r *http.Request) (string, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read request body: %w", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("empty request body")
|
||||
}
|
||||
|
||||
// Check if it's already PEM-encoded (some clients send PEM directly)
|
||||
bodyStr := strings.TrimSpace(string(body))
|
||||
if strings.HasPrefix(bodyStr, "-----BEGIN CERTIFICATE REQUEST-----") {
|
||||
// Validate it parses
|
||||
block, _ := pem.Decode([]byte(bodyStr))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("invalid PEM-encoded CSR")
|
||||
}
|
||||
if _, err := x509.ParseCertificateRequest(block.Bytes); err != nil {
|
||||
return "", fmt.Errorf("invalid CSR: %w", err)
|
||||
}
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
// EST standard: base64-encoded DER PKCS#10
|
||||
derBytes, err := base64.StdEncoding.DecodeString(bodyStr)
|
||||
if err != nil {
|
||||
// Try with padding/whitespace stripped
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, bodyStr)
|
||||
derBytes, err = base64.StdEncoding.DecodeString(cleaned)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode base64 CSR: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate it's a valid PKCS#10 CSR
|
||||
if _, err := x509.ParseCertificateRequest(derBytes); err != nil {
|
||||
return "", fmt.Errorf("invalid PKCS#10 CSR: %w", err)
|
||||
}
|
||||
|
||||
// Convert DER to PEM for internal use (certctl services expect PEM)
|
||||
csrPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: derBytes,
|
||||
})
|
||||
return string(csrPEM), nil
|
||||
}
|
||||
|
||||
// writeCertResponse writes an EST enrollment response as base64-encoded PKCS#7.
|
||||
func (h ESTHandler) writeCertResponse(w http.ResponseWriter, result *domain.ESTEnrollResult) {
|
||||
// Parse cert and chain PEM to DER
|
||||
var derCerts [][]byte
|
||||
|
||||
// Add the issued certificate
|
||||
certDER, err := pkcs7.PEMToDERChain(result.CertPEM)
|
||||
if err != nil || len(certDER) == 0 {
|
||||
http.Error(w, "Failed to encode certificate", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
derCerts = append(derCerts, certDER...)
|
||||
|
||||
// Add the CA chain if present
|
||||
if result.ChainPEM != "" {
|
||||
chainDER, err := pkcs7.PEMToDERChain(result.ChainPEM)
|
||||
if err == nil {
|
||||
derCerts = append(derCerts, chainDER...)
|
||||
}
|
||||
}
|
||||
|
||||
// Build PKCS#7 certs-only
|
||||
pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to build PKCS#7 response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
|
||||
w.Header().Set("Content-Transfer-Encoding", "base64")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
encoded := base64.StdEncoding.EncodeToString(pkcs7Data)
|
||||
for i := 0; i < len(encoded); i += 76 {
|
||||
end := i + 76
|
||||
if end > len(encoded) {
|
||||
end = len(encoded)
|
||||
}
|
||||
w.Write([]byte(encoded[i:end]))
|
||||
w.Write([]byte("\r\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: PKCS#7 helpers (BuildCertsOnlyPKCS7, PEMToDERChain, ASN.1 wrappers)
|
||||
// are in the shared internal/pkcs7 package, used by both EST and SCEP handlers.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package handler
|
||||
|
||||
import "time"
|
||||
|
||||
// EST RFC 7030 hardening Phase 3.3 / 4.2: nowFn is the time source that
|
||||
// the EST handler's per-IP failed-Basic-auth limiter and per-(CN,
|
||||
// sourceIP) rate limiter consult. Tests can override this to inject a
|
||||
// deterministic clock without dragging time.Time into the handler API
|
||||
// surface (the handler's setters take ratelimit.SlidingWindowLimiter
|
||||
// pointers, not time-injection callbacks — keeping the wire-up simple).
|
||||
//
|
||||
// nowFn is package-private + lower-case so external callers can't poke
|
||||
// at it; the est_clock_test.go helper restoreNowFn is the documented
|
||||
// override pattern for tests in this package.
|
||||
var nowFn = time.Now
|
||||
@@ -0,0 +1,459 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/cms"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/ratelimit"
|
||||
"github.com/shankar0123/certctl/internal/trustanchor"
|
||||
)
|
||||
|
||||
// EST RFC 7030 hardening master bundle Phases 2-4 tests.
|
||||
// Covers: mTLS sibling route gates, HTTP Basic enrollment-password auth,
|
||||
// per-source-IP failed-auth rate limit, RFC 9266 channel binding, and
|
||||
// per-(CN, sourceIP) per-principal sliding-window rate limit.
|
||||
|
||||
// hardeningTestSetup is a per-test fixture: a mock service that always
|
||||
// succeeds, plus a CA + issued client cert that an mTLS test can attach
|
||||
// to its synthetic *http.Request.TLS.
|
||||
type hardeningTestSetup struct {
|
||||
svc *mockESTService
|
||||
caCert *x509.Certificate
|
||||
caKey *ecdsa.PrivateKey
|
||||
clientCrt *x509.Certificate
|
||||
clientKey *ecdsa.PrivateKey
|
||||
trustPool *trustanchor.Holder
|
||||
bundleDir string
|
||||
}
|
||||
|
||||
func newHardeningTestSetup(t *testing.T) *hardeningTestSetup {
|
||||
t.Helper()
|
||||
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ca key: %v", err)
|
||||
}
|
||||
caTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "est-mtls-test-ca"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign,
|
||||
}
|
||||
caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("ca create: %v", err)
|
||||
}
|
||||
caCert, _ := x509.ParseCertificate(caDER)
|
||||
|
||||
clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("client key: %v", err)
|
||||
}
|
||||
clientTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{CommonName: "test-device-001"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
}
|
||||
clientDER, err := x509.CreateCertificate(rand.Reader, clientTmpl, caCert, &clientKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("client create: %v", err)
|
||||
}
|
||||
clientCrt, _ := x509.ParseCertificate(clientDER)
|
||||
|
||||
// Persist the CA bundle on disk so trustanchor.New can load it.
|
||||
dir := t.TempDir()
|
||||
bundlePath := filepath.Join(dir, "trust.pem")
|
||||
body := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
|
||||
if err := os.WriteFile(bundlePath, body, 0o600); err != nil {
|
||||
t.Fatalf("write bundle: %v", err)
|
||||
}
|
||||
holder, err := trustanchor.New(bundlePath, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("trustanchor.New: %v", err)
|
||||
}
|
||||
|
||||
svc := &mockESTService{
|
||||
CACertPEM: pemCertString(caDER),
|
||||
EnrollResult: &domain.ESTEnrollResult{
|
||||
CertPEM: pemCertString(clientDER),
|
||||
},
|
||||
}
|
||||
return &hardeningTestSetup{
|
||||
svc: svc,
|
||||
caCert: caCert,
|
||||
caKey: caKey,
|
||||
clientCrt: clientCrt,
|
||||
clientKey: clientKey,
|
||||
trustPool: holder,
|
||||
bundleDir: dir,
|
||||
}
|
||||
}
|
||||
|
||||
func pemCertString(der []byte) string {
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
}
|
||||
|
||||
// makeMTLSRequest synthesises a POST against `path` with PEM CSR body and
|
||||
// r.TLS populated with the given peer cert chain + handshake state. Used
|
||||
// by the mTLS path tests where a real TLS handshake would force us into a
|
||||
// full httptest.NewTLSServer setup.
|
||||
func makeMTLSRequest(t *testing.T, path, csrPEM string, peerCerts []*x509.Certificate, version uint16) *http.Request {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(csrPEM))
|
||||
req.TLS = &tls.ConnectionState{
|
||||
HandshakeComplete: true,
|
||||
Version: version,
|
||||
PeerCertificates: peerCerts,
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// ----- mTLS handler gate -----
|
||||
|
||||
func TestSimpleEnrollMTLS_NoTrustPool_500(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc) // intentionally do NOT call SetMTLSTrust
|
||||
req := makeMTLSRequest(t, "/.well-known/est-mtls/corp/simpleenroll",
|
||||
generateTestCSRPEM(t), []*x509.Certificate{s.clientCrt}, tls.VersionTLS13)
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnrollMTLS(w, req)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d, want 500 (handler missing trust pool)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleEnrollMTLS_NoClientCert_401(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc)
|
||||
h.SetMTLSTrust(s.trustPool)
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est-mtls/corp/simpleenroll",
|
||||
strings.NewReader(generateTestCSRPEM(t)))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnrollMTLS(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401 (no client cert)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleEnrollMTLS_CertNotInPool_401(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
other := newHardeningTestSetup(t) // different CA, unrelated to s.trustPool
|
||||
h := NewESTHandler(s.svc)
|
||||
h.SetMTLSTrust(s.trustPool)
|
||||
req := makeMTLSRequest(t, "/.well-known/est-mtls/corp/simpleenroll",
|
||||
generateTestCSRPEM(t), []*x509.Certificate{other.clientCrt}, tls.VersionTLS13)
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnrollMTLS(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401 (cert not trusted by this profile)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleEnrollMTLS_HappyPath_200(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc)
|
||||
h.SetMTLSTrust(s.trustPool)
|
||||
req := makeMTLSRequest(t, "/.well-known/est-mtls/corp/simpleenroll",
|
||||
generateTestCSRPEM(t), []*x509.Certificate{s.clientCrt}, tls.VersionTLS13)
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnrollMTLS(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200; body=%q", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ----- channel binding (Phase 2.4) -----
|
||||
|
||||
func TestSimpleReEnrollMTLS_ChannelBindingRequired_AbsentRejected(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc)
|
||||
h.SetMTLSTrust(s.trustPool)
|
||||
h.SetChannelBindingRequired(true)
|
||||
// CSR has no binding attribute. Synthetic ConnectionState — exporter
|
||||
// extraction will fail (no real TLS secret), and required=true makes
|
||||
// VerifyChannelBinding propagate that as the missing-binding error.
|
||||
req := makeMTLSRequest(t, "/.well-known/est-mtls/corp/simplereenroll",
|
||||
generateTestCSRPEM(t), []*x509.Certificate{s.clientCrt}, tls.VersionTLS13)
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleReEnrollMTLS(w, req)
|
||||
// Either 400 (missing) or 426 (TLS 1.3 unavailable on synthetic state).
|
||||
// Both are correct refusals; pin to "non-2xx" so the test isn't fragile
|
||||
// against ConnectionState evolution.
|
||||
if w.Code/100 == 2 {
|
||||
t.Errorf("required + absent must reject; got 2xx (%d)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleReEnrollMTLS_ChannelBindingNotRequired_AbsentAllowed(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc)
|
||||
h.SetMTLSTrust(s.trustPool)
|
||||
h.SetChannelBindingRequired(false)
|
||||
// CSR has no binding, profile is opt-in only. The handler must allow.
|
||||
req := makeMTLSRequest(t, "/.well-known/est-mtls/corp/simplereenroll",
|
||||
generateTestCSRPEM(t), []*x509.Certificate{s.clientCrt}, tls.VersionTLS13)
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleReEnrollMTLS(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("required=false + absent must allow; got %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteChannelBindingError_KnownErrorsMapped(t *testing.T) {
|
||||
// Smoke test the error-to-status mapping so a future cms sentinel rename
|
||||
// gets caught at compile time + we hit each branch.
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc)
|
||||
cases := []struct {
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{cms.ErrChannelBindingMissing, http.StatusBadRequest},
|
||||
{cms.ErrChannelBindingMismatch, http.StatusConflict},
|
||||
{cms.ErrChannelBindingNotTLS13, http.StatusUpgradeRequired},
|
||||
}
|
||||
for _, c := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
h.writeChannelBindingError(w, "req-id", c.err)
|
||||
if w.Code != c.want {
|
||||
t.Errorf("error=%v → status %d, want %d", c.err, w.Code, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- HTTP Basic enrollment-password (Phase 3) -----
|
||||
|
||||
func TestSimpleEnroll_BasicAuth_NoHeader_401(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc)
|
||||
h.SetEnrollmentPassword("super-secret")
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/simpleenroll",
|
||||
strings.NewReader(generateTestCSRPEM(t)))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401 (Basic required, header absent)", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Basic") {
|
||||
t.Errorf("WWW-Authenticate = %q, want to contain 'Basic'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleEnroll_BasicAuth_WrongPassword_401(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc)
|
||||
h.SetEnrollmentPassword("super-secret")
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/simpleenroll",
|
||||
strings.NewReader(generateTestCSRPEM(t)))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
req.SetBasicAuth("device", "wrong-password")
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401 (wrong password)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleEnroll_BasicAuth_CorrectPassword_200(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc)
|
||||
h.SetEnrollmentPassword("super-secret")
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/simpleenroll",
|
||||
strings.NewReader(generateTestCSRPEM(t)))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
req.SetBasicAuth("device", "super-secret")
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 (correct password); body=%q", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleEnroll_BasicAuth_NoPassword_NoGate(t *testing.T) {
|
||||
// When the per-profile enrollment password is empty, the Basic gate is
|
||||
// off and the handler reverts to the v2.0.x anonymous behavior.
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc) // SetEnrollmentPassword not called
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/simpleenroll",
|
||||
strings.NewReader(generateTestCSRPEM(t)))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 (no Basic gate)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- source-IP failed-auth rate limit (Phase 3.3) -----
|
||||
|
||||
func TestSimpleEnroll_BasicAuth_FailedAttemptLimitedAfterThreshold(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc)
|
||||
h.SetEnrollmentPassword("super-secret")
|
||||
// Cap of 2 failed attempts before the IP gets locked. Each failed
|
||||
// attempt records a slot; the 3rd request should be 429.
|
||||
limiter := ratelimit.NewSlidingWindowLimiter(2, time.Hour, 10)
|
||||
h.SetSourceIPRateLimiter(limiter)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/simpleenroll",
|
||||
strings.NewReader(generateTestCSRPEM(t)))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
req.RemoteAddr = "10.0.0.42:12345"
|
||||
req.SetBasicAuth("device", "WRONG")
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d: want 401, got %d", i, w.Code)
|
||||
}
|
||||
}
|
||||
// The 3rd attempt — even with a correct password — must be rate limited.
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/simpleenroll",
|
||||
strings.NewReader(generateTestCSRPEM(t)))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
req.RemoteAddr = "10.0.0.42:12345"
|
||||
req.SetBasicAuth("device", "super-secret")
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("post-lockout status = %d, want 429 (correct password should still be locked out)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- per-principal sliding-window rate limit (Phase 4.2) -----
|
||||
|
||||
func TestSimpleEnroll_PerPrincipalLimit_BlocksAfterCap(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc)
|
||||
limiter := ratelimit.NewSlidingWindowLimiter(2, 24*time.Hour, 100)
|
||||
h.SetPerPrincipalRateLimiter(limiter)
|
||||
|
||||
// First 2 enrollments from same (CN, IP) — pass.
|
||||
csrPEM := generateTestCSRPEM(t)
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/simpleenroll",
|
||||
strings.NewReader(csrPEM))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
req.RemoteAddr = "10.0.0.7:5555"
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("attempt %d: want 200, got %d", i, w.Code)
|
||||
}
|
||||
}
|
||||
// Third enrollment from same (CN, IP) — limited.
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/simpleenroll",
|
||||
strings.NewReader(csrPEM))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
req.RemoteAddr = "10.0.0.7:5555"
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("3rd same-principal enrollment status = %d, want 429", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleEnroll_PerPrincipalLimit_DifferentPrincipalsIndependent(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc)
|
||||
limiter := ratelimit.NewSlidingWindowLimiter(1, 24*time.Hour, 100)
|
||||
h.SetPerPrincipalRateLimiter(limiter)
|
||||
|
||||
csrPEM1 := generateTestCSRPEM(t)
|
||||
csrPEM2 := generateTestCSRPEM(t) // different key + (default) different CN
|
||||
|
||||
req1 := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/simpleenroll", strings.NewReader(csrPEM1))
|
||||
req1.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
req1.RemoteAddr = "10.0.0.10:1111"
|
||||
w1 := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w1, req1)
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("principal 1 first call: want 200, got %d", w1.Code)
|
||||
}
|
||||
|
||||
// Same CN as csrPEM1 but different IP — independent bucket.
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/simpleenroll", strings.NewReader(csrPEM2))
|
||||
req2.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
req2.RemoteAddr = "10.0.0.20:2222"
|
||||
w2 := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w2, req2)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Errorf("principal 2 first call: want 200, got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- per-handler smoke test for the un-rolled mTLS variants -----
|
||||
|
||||
func TestCACertsMTLS_RequiresClientCert(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc)
|
||||
h.SetMTLSTrust(s.trustPool)
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/est-mtls/corp/cacerts", nil)
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
w := httptest.NewRecorder()
|
||||
h.CACertsMTLS(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("CACertsMTLS no-cert status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRAttrsMTLS_RequiresClientCert(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc)
|
||||
h.SetMTLSTrust(s.trustPool)
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/est-mtls/corp/csrattrs", nil)
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
w := httptest.NewRecorder()
|
||||
h.CSRAttrsMTLS(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("CSRAttrsMTLS no-cert status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- ensure the per-principal limit fires only when configured -----
|
||||
|
||||
func TestSimpleEnroll_NoPerPrincipalLimiter_AllUnbounded(t *testing.T) {
|
||||
s := newHardeningTestSetup(t)
|
||||
h := NewESTHandler(s.svc) // SetPerPrincipalRateLimiter not called
|
||||
csrPEM := generateTestCSRPEM(t)
|
||||
for i := 0; i < 50; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/est/corp/simpleenroll",
|
||||
strings.NewReader(csrPEM))
|
||||
req.TLS = &tls.ConnectionState{HandshakeComplete: true, Version: tls.VersionTLS13}
|
||||
w := httptest.NewRecorder()
|
||||
h.SimpleEnroll(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("attempt %d: want 200, got %d", i, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// silenceUnused keeps the "declared and not used" linter happy when we add
|
||||
// helpers that future tests may invoke (asn1, atomic).
|
||||
var _ = asn1.RawValue{}
|
||||
var _ atomic.Int32
|
||||
@@ -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