mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:31:30 +00:00
EST RFC 7030 hardening master bundle Phases 5-7: end-to-end serverkeygen
+ profile-driven csrattrs + admin observability with per-status counters + reload-trust endpoint. Phase 5 — RFC 7030 §4.4 server-driven key generation: - internal/pkcs7/envelopeddata_builder.go is the inverse of the existing parser/decryptor: AES-256-CBC content cipher + RSA PKCS#1 v1.5 keyTrans + per-call random IV. Round-trip pinned in test (BuildEnvelopedData → ParseEnvelopedData → Decrypt returns the original plaintext byte-for-byte). - ESTService.SimpleServerKeygen runs the full §4.4 flow: parse client CSR → require RSA pubkey for keyTrans → resolve per-profile algorithm (RSA-2048 default; honors AllowedKeyAlgorithms) → in- memory keygen → re-build CSR with server pubkey → run existing issuer pipeline → marshal PKCS#8 → CMS-EnvelopedData wrap to a synthetic recipient cert wrapping the device's CSR-supplied pubkey → zeroize plaintext + PKCS#8 bytes → return CertPEM + ChainPEM + EncryptedKey. Typed sentinels ErrServerKeygenRequiresKey- Encipherment / ErrServerKeygenUnsupportedAlgorithm / ErrServerKeygenDisabled. - ESTHandler.ServerKeygen + ServerKeygenMTLS emit RFC 7030 §4.4.2 multipart/mixed with random per-response boundary; per-profile SetServerKeygenEnabled gate returns 404 when off (defense in depth even if the route was registered). - New routes POST /.well-known/est/[<PathID>/]serverkeygen + /.well-known/est-mtls/<PathID>/serverkeygen; openapi.yaml + openapi-parity guard updated. Phase 6 — Real csrattrs implementation: - New CertificateProfile.RequiredCSRAttributes []string + migration 000022_certificate_profiles_csrattrs.up.sql. The migration also lands the previously-unwired must_staple column (closes the 5.6 follow-up loop where the field shipped at the domain + service layer but the postgres scan/insert/update never persisted it). - domain.EKUStringToOID + AttributeStringToOID lookup tables: id-kp-* EKUs (RFC 5280 §4.2.1.12) + RFC 5280 DN attributes + RFC 2985 PKCS#10 attributes + Microsoft Intune device-serial OID. - ESTService.GetCSRAttrs replaces the v2.0.x nil/204 stub with a profile-derived SEQUENCE OF OID ASN.1 marshal. Unknown EKU / attribute strings dropped + warning-logged so a typo doesn't take down the entire endpoint. Phase 7 — Admin observability + counters + reload-trust: - internal/service/est_counters.go: estCounterTab (sync/atomic; 12 named labels) + ESTStatsSnapshot per-profile shape + ESTService.Stats(now) zero-allocation accessor + ReloadTrust() SIGHUP-equivalent + SetESTAdminMetadata setter. - Counter ticks wired into processEnrollment + SimpleServerKeygen at every success/failure leg. - internal/api/handler/admin_est.go mirrors AdminSCEPIntune verbatim: Profiles + ReloadTrust handlers + AdminESTServiceImpl. Both endpoints admin-gated (M-008 triplet pinned + admin_est.go added to AdminGatedHandlers). - New routes GET /api/v1/admin/est/profiles + POST /api/v1/admin/ est/reload-trust; openapi.yaml documented; openapi-parity guard reproduced clean. - cmd/server/main.go grows estServices map populated by the per- profile EST loop + handed to AdminEST. New MTLSTrust() + HasMTLSTrust() accessors on ESTHandler so main.go can pull the trust holder for the admin-metadata wire-up. - Per-profile counter isolation regression test (internal/service/est_profile_counter_isolation_test.go) proves a future shared-counter refactor would fail at compile-time pointer-identity check. Pre-commit verification (sandbox): gofmt clean, go vet clean (excluding repository/postgres which the sandbox can't build — disk-space testcontainers download), staticcheck clean across cms/trustanchor/api/handler/api/router/scep/intune/ratelimit/ service/pkcs7/domain/cmd/server, go test -short -count=1 green for every non-postgres package. G-3 docs-drift guard reproduced locally clean (Phases 5-7 added zero new env vars; Phase 1 already documented per-profile SERVER_KEYGEN_ENABLED). Spec preserved at cowork/est-rfc7030-hardening-prompt.md. Phases 8-13 (GUI ESTAdminPage / CLI+MCP / libest e2e / bulk revocation / docs/est.md / release prep) remain — post-2.1.0 work.
This commit is contained in:
@@ -672,6 +672,11 @@ func main() {
|
||||
// admin endpoint observes the populated state at request time.
|
||||
scepServices := map[string]*service.SCEPService{}
|
||||
|
||||
// EST RFC 7030 hardening master bundle Phase 7.2: same shape for
|
||||
// the EST admin endpoint. The EST startup loop populates this map
|
||||
// by PathID; the AdminEST handler reads it at request time.
|
||||
estServices := map[string]*service.ESTService{}
|
||||
|
||||
// Build the API router with all handlers
|
||||
apiRouter := router.New()
|
||||
apiRouter.RegisterHandlers(router.HandlerRegistry{
|
||||
@@ -722,6 +727,11 @@ func main() {
|
||||
AdminSCEPIntune: handler.NewAdminSCEPIntuneHandler(
|
||||
handler.NewAdminSCEPIntuneServiceImpl(scepServices),
|
||||
),
|
||||
// EST RFC 7030 hardening Phase 7.2: admin endpoint backing the
|
||||
// EST Administration GUI. Same shape as AdminSCEPIntune.
|
||||
AdminEST: handler.NewAdminESTHandler(
|
||||
handler.NewAdminESTServiceImpl(estServices),
|
||||
),
|
||||
})
|
||||
// Register EST (RFC 7030) handlers if enabled.
|
||||
//
|
||||
@@ -789,6 +799,11 @@ func main() {
|
||||
}
|
||||
estHandler := handler.NewESTHandler(estService)
|
||||
estHandler.SetLabelForLog(fmt.Sprintf("est (PathID=%q)", profile.PathID))
|
||||
// Phase 5: server-keygen endpoint per profile. The per-profile gate
|
||||
// stays off by default so existing v2.X.0 deploys see no behavior
|
||||
// change unless the operator explicitly opts in via
|
||||
// CERTCTL_EST_PROFILE_<NAME>_SERVER_KEYGEN_ENABLED=true.
|
||||
estHandler.SetServerKeygenEnabled(profile.ServerKeygenEnabled)
|
||||
|
||||
// Phase 3.1: HTTP Basic enrollment password. Only takes effect
|
||||
// on the standard /.well-known/est/<PathID>/ route — the mTLS
|
||||
@@ -856,6 +871,7 @@ func main() {
|
||||
mtlsHandler.SetLabelForLog(fmt.Sprintf("est-mtls (PathID=%q)", profile.PathID))
|
||||
mtlsHandler.SetMTLSTrust(holder)
|
||||
mtlsHandler.SetChannelBindingRequired(profile.ChannelBindingRequired)
|
||||
mtlsHandler.SetServerKeygenEnabled(profile.ServerKeygenEnabled)
|
||||
if profile.RateLimitPerPrincipal24h > 0 {
|
||||
perPrincipal := ratelimit.NewSlidingWindowLimiter(profile.RateLimitPerPrincipal24h, 24*time.Hour, 100_000)
|
||||
mtlsHandler.SetPerPrincipalRateLimiter(perPrincipal)
|
||||
@@ -884,6 +900,25 @@ func main() {
|
||||
}
|
||||
estHandlers[profile.PathID] = estHandler
|
||||
|
||||
// Phase 7.2: publish service into the shared estServices map +
|
||||
// wire the per-profile observability metadata so the AdminEST
|
||||
// handler can render the Profiles tab. This MUST happen after
|
||||
// every per-profile setter so Stats() snapshot reads stable
|
||||
// state.
|
||||
//
|
||||
// trustHolderForAdmin: the EST mTLS branch above declares a
|
||||
// local `holder` variable when MTLSEnabled=true. We rebuild
|
||||
// the lookup here so the metadata setter sees the same
|
||||
// holder. Non-mTLS profiles see nil — Stats() handles that.
|
||||
var trustHolderForAdmin *trustanchor.Holder
|
||||
if profile.MTLSEnabled && estMTLSHandlers[profile.PathID].HasMTLSTrust() {
|
||||
trustHolderForAdmin = estMTLSHandlers[profile.PathID].MTLSTrust()
|
||||
}
|
||||
estService.SetESTAdminMetadata(profile.PathID, profile.MTLSEnabled,
|
||||
profile.EnrollmentPassword != "", profile.ServerKeygenEnabled,
|
||||
trustHolderForAdmin)
|
||||
estServices[profile.PathID] = estService
|
||||
|
||||
endpoint := "/.well-known/est"
|
||||
if profile.PathID != "" {
|
||||
endpoint = "/.well-known/est/" + profile.PathID
|
||||
|
||||
Reference in New Issue
Block a user