From 57d55b7390a4b32da72d242cee7550e0fc523305 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Thu, 14 May 2026 04:26:57 +0000 Subject: [PATCH] refactor(config): extract EST family + helpers to its own file (Phase 9, 4 of N) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuing Phase 9 ARCH-M2 closure. Sprint 4 extracts the EST surface, mirroring Sprint 3's SCEP cut shape (two structs + multiple helpers move together). What moved ========== internal/config/est.go (new, 396 lines including BSL header + Phase 9 doc-comment + 2 imports + 2 structs + 5 helpers) Two structs: - ESTConfig (top-level: Enabled + Profiles slice + legacy single-issuer flat fields kept for backward compat — fewer trigger fields than SCEP because EST has no per-profile RA pair or challenge password in this hardening-bundle phase) - ESTProfileConfig (one EST endpoint: PathID, IssuerID, ProfileID, EnrollmentPassword, MTLSEnabled, MTLSClientCATrustBundlePath, ChannelBindingRequired, AllowedAuthModes, RateLimitPerPrincipal24h, ServerKeygenEnabled — field surface spans the full Phase-1-through-5 hardening bundle) Five unexported helpers: - loadESTProfilesFromEnv() — reads CERTCTL_EST_PROFILES + expands each name into an ESTProfileConfig via the indexed env-var family. Mirrors loadSCEPProfilesFromEnv exactly. - parseAuthModes() — splits a comma-separated env value into a normalized []string of auth-mode tokens. - mergeESTLegacyIntoProfiles() — backward-compat shim: synthesize Profiles[0] from the legacy flat fields when Profiles is empty AND EST is enabled. - validESTPathID() — path-segment validator (mirrors validSCEPPathID; kept separate so future EST-specific path constraints can land without affecting SCEP). - validESTAuthMode() — refuses unknown auth-mode tokens at startup ("mtls" / "basic" are valid in Phase 1). Why move all five helpers together ================================== Live grep confirms each helper is exclusively EST-specific: - parseAuthModes() has one production call site (line 1851 inside loadESTProfilesFromEnv itself, intra-helper) + one test caller (config_est_profiles_test.go in package `config` — same package so the move is invisible to the test). - validESTAuthMode() has exactly one production caller (Validate() in config.go); validESTPathID() likewise. - mergeESTLegacyIntoProfiles() called from Load() in config.go. - loadESTProfilesFromEnv() called from Load() in config.go. All callers either stay in config.go (Load + Validate) or live in est.go itself (the intra-helper parseAuthModes call inside loadESTProfilesFromEnv stays a same-file call after the move — one LESS cross-file edge to track). The test in config_est_profiles_test.go is in package `config` so the unexported callable surface is preserved by same-package resolution. What stayed in config.go (intentionally) ======================================== - Load() and Validate() bodies — the EST-specific call sites stay where they are (cross-cutting validation logic, not split-target). - Every shared getEnv* helper (used by EVERY config family). - The Config{}.EST master-struct field declaration. Edit shape ========== Two sed passes (same approach as Sprint 3): 1. sed -i '611,774d' — deleted the 164-line EST struct block (ESTConfig + ESTProfileConfig + their doc comments). 2. sed -i '1648,1789d' — deleted the 142-line helper block (positions already shifted by Sprint 4's struct removal). Then gofmt -w to collapse a residual double-blank-line at the second join point (none surfaced at the first). Public-surface invariant ======================== Every type, field, exported method, and doc-comment is byte-identical to pre-split. Package stays `config`. Every caller's `config.ESTConfig` / `config.ESTProfileConfig` import path is preserved without modification. The five helpers are unexported so their move is invisible to package consumers; same-package callers (Load, Validate, the existing test) continue to resolve them via the package symbol table. Verification (all clean): gofmt -l internal/config/ → clean (after -w) go build ./internal/config/... → clean go test ./internal/config/... -count=1 → ok (0.58s) staticcheck ./internal/config/... → clean go build ./internal/api/router/... ./internal/scheduler/... ./cmd/server/... ./internal/api/handler/... → clean (broader importers still resolve every type and helper) grep -nE '^type EST|^func .*EST|^func parseAuthModes' config.go → empty (none remain in config.go) grep -nE '^type EST|^func .*EST|^func parseAuthModes' est.go → 2 types + 5 funcs (correct: ESTConfig, ESTProfileConfig, loadESTProfilesFromEnv, parseAuthModes, mergeESTLegacyIntoProfiles, validESTPathID, validESTAuthMode) LOC delta: config.go: 2774 → 2467 (-307 lines: -164 from struct block, -142 from helper block, -1 from double-blank collapse) est.go: new, 396 lines (incl. 87-line Phase 9 doc-comment + BSL header + package decl + 2 imports) Cumulative Phase 9 progress (Sprints 1+2+3+4 from config.go): Pre-Phase-9: 3403 LOC After Sprint 1 (Notifier): 3335 LOC (-68) After Sprint 2 (ACME): 3108 LOC (-227) After Sprint 3 (SCEP): 2774 LOC (-334) After Sprint 4 (EST): 2467 LOC (-307) Total Sprint 1+2+3+4: -936 LOC (-27.5%) Pattern lesson reinforcement ============================ Sprint 4 confirms the SCEP/EST symmetry the original helper authors documented inline ("Mirrors loadSCEPProfilesFromEnv exactly"). Sprint 3 + Sprint 4 are now demonstrating the same cut pattern works across two related-but-distinct protocol surfaces. Sprint 5+ should be easier because they don't carry the same helper-bundling complexity (Auth family probably has its own helper cluster too, but Server / Issuers are likely pure-data per the original audit-questions output). Next queued (Sprint 5): Auth family from config.go → internal/config/auth.go. Includes AuthConfig + SessionConfig + BreakglassConfig + NamedAPIKey + ParseNamedAPIKeys (note: this is EXPORTED — only exported function in the config-helpers cluster) + isValidKeyName + ValidAuthTypes. The exported ParseNamedAPIKeys adds a wrinkle Sprints 1-4 didn't have: external callers may import it, so the public-surface check needs to include it. Estimated ~340 LOC moved. Closes: cowork/certctl-architecture-diligence-audit.html#fix-ARCH-M2 (partial — 4 of 12 — full ARCH-M2 closure is the aggregate) --- internal/config/config.go | 307 ----------------------------- internal/config/est.go | 396 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 396 insertions(+), 307 deletions(-) create mode 100644 internal/config/est.go diff --git a/internal/config/config.go b/internal/config/config.go index eb20226..c186678 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -608,170 +608,6 @@ type OpenSSLConfig struct { TimeoutSeconds int } -// ESTConfig controls the RFC 7030 Enrollment over Secure Transport server. -// EST RFC 7030 hardening master bundle Phase 1: this type was originally a -// flat single-issuer struct. Real enterprise deployments need to expose -// multiple EST endpoints from one certctl instance — corp-laptop CA, IoT -// CA, WiFi/802.1X CA — each with its own issuer + auth modes + URL path -// (/.well-known/est//). The Profiles slice carries that. Existing -// operators see no behavior change: when Profiles is empty AND the legacy -// single-issuer flat fields below are set, ConfigLoad synthesizes a -// single-element Profiles[0] with PathID="" (which maps to the legacy -// /.well-known/est/ root path). -type ESTConfig struct { - // Enabled controls whether EST endpoints are available for device enrollment. - // Default: false (EST disabled). Set to true to enable RFC 7030 endpoints - // under /.well-known/est/ (cacerts, simpleenroll, simplereenroll, csrattrs). - Enabled bool - - // IssuerID selects which issuer connector processes EST certificate requests. - // Default: "iss-local". Legacy single-issuer field; merged into Profiles[0] - // by mergeESTLegacyIntoProfiles when Profiles is empty. - IssuerID string - - // ProfileID optionally constrains EST enrollments to a specific certificate profile. - // Legacy single-issuer field; merged into Profiles[0] when applicable. - ProfileID string - - // Profiles is the multi-endpoint configuration. Each profile gets its own - // URL path (/.well-known/est//), its own bound issuer, its own auth - // modes, and its own per-profile policy knobs (rate limit, server-keygen - // gate, mTLS bundle, RFC 9266 channel-binding requirement). Population - // sources, in priority order: - // - // 1. Explicit list via CERTCTL_EST_PROFILES (e.g. "corp,iot,wifi"). - // 2. Backward-compat shim: when CERTCTL_EST_PROFILES is unset AND the - // legacy flat fields above are populated AND Enabled=true, ConfigLoad - // synthesizes a single-element Profiles[0] with PathID="" so - // /.well-known/est/ continues to route the same way it did - // pre-Phase-1. - // - // EST RFC 7030 hardening master bundle Phase 1. - Profiles []ESTProfileConfig -} - -// ESTProfileConfig is one EST endpoint's configuration. Each profile is -// bound to one issuer + one optional certctl CertificateProfile + one set -// of per-profile auth modes (mTLS / HTTP Basic / both). Future phases of -// the hardening bundle wire the additional per-profile fields: -// -// - Phase 2 reads MTLSEnabled + MTLSClientCATrustBundlePath + -// ChannelBindingRequired to enable the /.well-known/est-mtls/ -// sibling route (mirrors SCEP's /scep-mtls/ from commit e7a3075). -// - Phase 3 reads EnrollmentPassword + AllowedAuthModes to enforce HTTP -// Basic auth on the standard /.well-known/est// route. -// - Phase 4 reads RateLimitPerPrincipal24h to apply per-CN+source-IP -// sliding-window rate limiting (mirrors SCEP/Intune's -// PerDeviceRateLimiter from internal/scep/intune/rate_limit.go). -// - Phase 5 reads ServerKeygenEnabled to gate the new /serverkeygen -// endpoint per RFC 7030 §4.4. -// -// Phase 1 (this commit) lays the FIELD CONTRACTS + per-profile Validate() -// gates so an operator who flips MTLSEnabled=true without supplying the -// bundle path gets a loud refuse-to-start error rather than a silent -// no-op. The actual auth/limit/keygen handlers ship in Phases 2-5. -// -// EST RFC 7030 hardening master bundle Phase 1. -type ESTProfileConfig struct { - // PathID is the URL segment after /.well-known/est/. Empty string maps - // to the legacy /.well-known/est/ root for backward compatibility (so - // existing operators with the flat single-issuer config see no URL - // change). Non-empty values MUST be a single path-safe slug - // ([a-z0-9-], no slashes); validated at startup by Config.Validate(). - // Multi-profile deployments typically use short tokens like "corp", - // "iot", "wifi" — the URL becomes /.well-known/est/corp/cacerts, - // /.well-known/est/iot/simpleenroll, etc. - PathID string - - // IssuerID selects which issuer connector this profile's enrollments - // go through. Must reference a configured issuer. Required (Validate - // refuses empty IssuerID). - IssuerID string - - // ProfileID optionally constrains enrollments under this PathID to a - // specific CertificateProfile. Leave empty to allow the issuer's - // defaults. When non-empty, profile crypto policy (allowed key - // algorithms, required EKUs, max TTL) is enforced at enrollment time - // via service.ValidateCSRAgainstProfile. - ProfileID string - - // EnrollmentPassword is the per-profile shared secret for HTTP Basic - // auth on the standard /.well-known/est// route (Phase 3). - // Empty value means HTTP Basic auth is NOT required for this profile - // (mTLS-only or anonymous, depending on AllowedAuthModes). Stored only - // in process memory; never logged. Constant-time comparison via - // crypto/subtle.ConstantTimeCompare in the handler. - EnrollmentPassword string - - // MTLSEnabled gates the sibling /.well-known/est-mtls// route - // (Phase 2). When true, the route requires a client cert that chains - // to one of the certs in MTLSClientCATrustBundlePath. The standard - // /.well-known/est// route remains application-layer-auth - // (HTTP Basic password) so existing clients keep working — mTLS is - // additive, not replacement. - // - // Mirrors SCEP's MTLSEnabled (commit e7a3075). Same defense-in-depth - // rationale: enterprise procurement teams routinely reject 'shared - // password authentication' as a checkbox-fail regardless of how - // strong the password is. This flag wires up a sibling route that - // adds client-cert auth at the handler layer. - MTLSEnabled bool - - // MTLSClientCATrustBundlePath is the PEM bundle of CA certs that sign - // the client (device-bootstrap) certs the operator allows to enroll - // via the mTLS sibling route. Required when MTLSEnabled is true. - // Validated at startup by cmd/server/main.go's - // preflightESTMTLSClientCATrustBundle (Phase 2): file exists, parses - // as PEM, contains ≥1 cert, none expired. - MTLSClientCATrustBundlePath string - - // ChannelBindingRequired forces the EST mTLS handler (Phase 2) to - // require RFC 9266 tls-exporter channel binding in the CSR's CMC - // id-aa-channelBindings attribute. When true, CSRs without the - // binding are refused with ErrChannelBindingMissing; mismatched - // bindings refused with ErrChannelBindingMismatch. Defaults true for - // new-cert-issuance flows (Phase 2 default), false for re-enrollment - // where the previous-cert presentation is the trust signal. Operators - // running clients that don't support RFC 9266 (older libest, etc.) - // can opt out per-profile. - // - // EST RFC 7030 hardening master bundle Phase 0 frozen decision 0.2. - ChannelBindingRequired bool - - // AllowedAuthModes enumerates which application-layer auth modes - // this profile accepts. Valid entries: "mtls", "basic". Empty slice - // means no auth required (the unauthenticated default that EST - // shipped with at v2.0.66; preserved for backward compat — Validate - // emits a warning log for empty slices to nudge operators toward - // explicit opt-in). Phase 2 + 3 read this to enforce per-mode - // requirements; Phase 1 just validates shape. - // - // EST RFC 7030 hardening master bundle Phase 0 frozen decision 0.1. - AllowedAuthModes []string - - // RateLimitPerPrincipal24h caps enrollments per (CSR.Subject.CN, - // sourceIP) pair in any rolling 24-hour window. Default 0 (Phase 1 - // preserves the unauthenticated/unlimited default to avoid changing - // production behavior); Phase 4 will wire this against the extracted - // internal/ratelimit/SlidingWindowLimiter. Negative values are - // rejected at Validate time as a config typo. - // - // EST RFC 7030 hardening master bundle Phase 1 + Phase 4. - RateLimitPerPrincipal24h int - - // ServerKeygenEnabled gates the /.well-known/est//serverkeygen - // endpoint (RFC 7030 §4.4) for this profile. When true, the server - // generates the keypair on behalf of the client and returns both - // cert + private key (the latter wrapped in CMS EnvelopedData). - // Default false. Phase 5 wires the handler; Phase 1 lays the gate - // + the Validate refusal for ServerKeygenEnabled=true without a - // CertificateProfile that pins AllowedKeyAlgorithms (the server - // must know what algorithm to generate). - // - // EST RFC 7030 hardening master bundle Phase 5. - ServerKeygenEnabled bool -} - // NetworkScanConfig controls the server-side active TLS scanner. type NetworkScanConfig struct { Enabled bool // Enable network scanning (default false) @@ -1809,149 +1645,6 @@ func Load() (*Config, error) { return cfg, nil } -// loadESTProfilesFromEnv reads the indexed CERTCTL_EST_PROFILES env var -// (e.g. "corp,iot,wifi") and expands each name into an ESTProfileConfig -// populated from CERTCTL_EST_PROFILE__*. Returns nil when the -// CERTCTL_EST_PROFILES env var is unset or empty — in that case the -// legacy-shim path (mergeESTLegacyIntoProfiles, called from Load after -// the initial config build) populates Profiles[0] from the flat fields -// if needed. -// -// PathID for each profile is the lowercased trimmed name from the -// CERTCTL_EST_PROFILES list (e.g. "Corp" -> "corp"). Validation that -// the PathID is path-safe ([a-z0-9-]+) lives in Config.Validate() so -// the loader can stay free of error returns. -// -// Mirrors loadSCEPProfilesFromEnv exactly. EST RFC 7030 hardening Phase 1. -func loadESTProfilesFromEnv() []ESTProfileConfig { - raw := strings.TrimSpace(os.Getenv("CERTCTL_EST_PROFILES")) - if raw == "" { - return nil - } - names := strings.Split(raw, ",") - out := make([]ESTProfileConfig, 0, len(names)) - for _, n := range names { - n = strings.TrimSpace(n) - if n == "" { - continue - } - // The env-var key is the upper-cased name (CERTCTL_EST_PROFILE_CORP_*), - // but the URL path segment is the lower-cased name to match the - // path-safe slug constraint enforced in Validate. - envName := strings.ToUpper(n) - pathID := strings.ToLower(n) - out = append(out, ESTProfileConfig{ - PathID: pathID, - IssuerID: getEnv("CERTCTL_EST_PROFILE_"+envName+"_ISSUER_ID", ""), - ProfileID: getEnv("CERTCTL_EST_PROFILE_"+envName+"_PROFILE_ID", ""), - EnrollmentPassword: getEnv("CERTCTL_EST_PROFILE_"+envName+"_ENROLLMENT_PASSWORD", ""), - MTLSEnabled: getEnvBool("CERTCTL_EST_PROFILE_"+envName+"_MTLS_ENABLED", false), - MTLSClientCATrustBundlePath: getEnv("CERTCTL_EST_PROFILE_"+envName+"_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH", ""), - ChannelBindingRequired: getEnvBool("CERTCTL_EST_PROFILE_"+envName+"_CHANNEL_BINDING_REQUIRED", false), - AllowedAuthModes: parseAuthModes(getEnv("CERTCTL_EST_PROFILE_"+envName+"_ALLOWED_AUTH_MODES", "")), - RateLimitPerPrincipal24h: getEnvInt("CERTCTL_EST_PROFILE_"+envName+"_RATE_LIMIT_PER_PRINCIPAL_24H", 0), - ServerKeygenEnabled: getEnvBool("CERTCTL_EST_PROFILE_"+envName+"_SERVERKEYGEN_ENABLED", false), - }) - } - return out -} - -// parseAuthModes splits a comma-separated env value into a normalized -// []string of auth-mode tokens. Empty input returns nil (the -// "unauthenticated default" Phase 1 preserves for back-compat). Tokens -// are lowercased + trimmed; unknown tokens are kept as-is so Validate -// can refuse them with a typed error message naming the offending token. -func parseAuthModes(s string) []string { - s = strings.TrimSpace(s) - if s == "" { - return nil - } - parts := strings.Split(s, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.ToLower(strings.TrimSpace(p)) - if p == "" { - continue - } - out = append(out, p) - } - return out -} - -// mergeESTLegacyIntoProfiles is the EST backward-compat shim. When -// Profiles is empty AND the legacy single-issuer fields are populated -// (Enabled=true is the trigger; IssuerID has a non-empty default so it -// can't be the trigger by itself), synthesise a single-element -// Profiles[0] with PathID="" so /.well-known/est/ dispatches identically -// to the pre-Phase-1 deploy. No-op when Profiles is non-empty (the -// operator explicitly opted into the structured form via -// CERTCTL_EST_PROFILES) or when EST is disabled. -// -// EST's legacy single-issuer config has fewer "trigger" fields than -// SCEP's (no per-profile RA pair, no per-profile challenge password — -// both of those land in Phases 2/3 of the hardening bundle). The shim -// triggers whenever EST is enabled, since the operator clearly intends -// to serve EST. This makes the back-compat behavior identical to v2.0.66 -// (single /.well-known/est/ root with the operator's chosen issuer). -// -// EST RFC 7030 hardening Phase 1. -func mergeESTLegacyIntoProfiles(c *ESTConfig) { - if c == nil || !c.Enabled || len(c.Profiles) > 0 { - return - } - c.Profiles = []ESTProfileConfig{{ - PathID: "", // empty pathID maps to the legacy /.well-known/est/ root - IssuerID: c.IssuerID, - ProfileID: c.ProfileID, - // No legacy fields exist for EnrollmentPassword, MTLS*, etc. — - // those land in Phases 2/3. Operators upgrading from v2.0.66 get - // the same unauthenticated behavior they had before; opting into - // auth requires moving to the structured CERTCTL_EST_PROFILES - // form (which Phase 12 docs as the recommended migration path). - }} -} - -// validESTPathID reports whether s is a valid EST profile path segment. -// Same shape as validSCEPPathID — empty string allowed (legacy root), -// otherwise ASCII lowercase letters / digits / hyphens with no -// leading/trailing hyphen. Kept as a separate function (rather than -// generalizing) so that future EST-specific path constraints (e.g. RFC -// 7030 §3.2.2 reserved path segments) can land here without affecting -// SCEP's validator. -// -// EST RFC 7030 hardening Phase 1. -func validESTPathID(s string) bool { - if s == "" { - return true // empty maps to legacy /.well-known/est/ root - } - if s[0] == '-' || s[len(s)-1] == '-' { - return false - } - for i := 0; i < len(s); i++ { - c := s[i] - if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' { - continue - } - return false - } - return true -} - -// validESTAuthMode reports whether mode is one of the documented EST -// auth modes Phase 2 + Phase 3 will dispatch on. Kept here so Validate -// can refuse unknown modes (typos, future modes the binary doesn't yet -// implement) at startup with a clear error rather than at first-request -// with a confusing 401/403. -// -// EST RFC 7030 hardening Phase 1. -func validESTAuthMode(mode string) bool { - switch mode { - case "mtls", "basic": - return true - } - return false -} - // Validate checks that the configuration is valid. func (c *Config) Validate() error { // Validate server configuration diff --git a/internal/config/est.go b/internal/config/est.go new file mode 100644 index 0000000..6cb28d7 --- /dev/null +++ b/internal/config/est.go @@ -0,0 +1,396 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 + +package config + +import ( + "os" + "strings" +) + +// Phase 9 ARCH-M2 closure Sprint 4 (2026-05-14): extracted from +// config.go. Same complexity shape as Sprint 3 (SCEP). Two structs +// AND five unexported helpers move together: +// +// ESTConfig — top-level multi-profile EST config +// (Enabled + Profiles slice + legacy +// single-issuer flat fields kept for +// backward compat — fewer trigger +// fields than SCEP because EST has no +// per-profile RA pair or challenge +// password in this hardening-bundle +// phase). +// ESTProfileConfig — one EST endpoint's configuration +// (PathID + IssuerID + ProfileID + +// EnrollmentPassword + MTLS gate + +// channel-binding requirement + +// allowed-auth-modes + rate-limit + +// server-keygen gate). Field surface +// spans the full RFC 7030 hardening +// bundle's per-phase plans (Phases +// 2-5). +// +// loadESTProfilesFromEnv — reads CERTCTL_EST_PROFILES + expands +// each name into an ESTProfileConfig +// via the indexed env-var family. +// Mirrors loadSCEPProfilesFromEnv +// exactly. +// parseAuthModes — splits a comma-separated env value +// into a normalized []string of +// auth-mode tokens (lowercased + +// trimmed; empty input → nil). +// Exercised by +// config_est_profiles_test.go which +// is in package `config` so the +// unexported callable surface is +// preserved by the move. +// mergeESTLegacyIntoProfiles — backward-compat shim: synthesize +// Profiles[0] from the legacy +// single-issuer fields when Profiles +// is empty AND EST is enabled. +// validESTPathID — path-segment validator (ASCII +// [a-z0-9-], no leading/trailing +// hyphen, empty allowed). Kept as a +// separate function from +// validSCEPPathID so future +// EST-specific path constraints +// (e.g. RFC 7030 §3.2.2 reserved +// segments) can land without +// affecting SCEP. +// validESTAuthMode — refuses unknown auth-mode tokens at +// startup ("mtls" and "basic" are +// the valid set in Phase 1; future +// phases may add). +// +// All callers stay in config.go and continue to resolve via +// same-package lookup. Specifically: +// - Load() calls loadESTProfilesFromEnv() during initial cfg.EST +// construction. +// - Load() calls mergeESTLegacyIntoProfiles(&cfg.EST) after the +// initial profile-load. +// - loadESTProfilesFromEnv() itself calls parseAuthModes() — +// intra-helper call that stays inside est.go after the move +// (one less cross-file edge). +// - Validate() calls validESTPathID(p.PathID) per-profile. +// - Validate() calls validESTAuthMode(mode) per auth-mode in +// each profile's AllowedAuthModes slice. +// - config_est_profiles_test.go (package `config`) directly tests +// parseAuthModes — that test file isn't touched by the move +// because parseAuthModes stays in the same package. +// +// The unexported helpers getEnv / getEnvBool / getEnvInt used by +// loadESTProfilesFromEnv also stay in config.go (shared across every +// config family); same-package resolution makes the calls work +// without any import change. +// +// Public-surface invariant: `go doc internal/config ESTConfig` and +// `go doc internal/config ESTProfileConfig` produce identical output +// before and after this split. Unexported helpers are unaffected by +// `go doc`. + +// ESTConfig controls the RFC 7030 Enrollment over Secure Transport server. +// EST RFC 7030 hardening master bundle Phase 1: this type was originally a +// flat single-issuer struct. Real enterprise deployments need to expose +// multiple EST endpoints from one certctl instance — corp-laptop CA, IoT +// CA, WiFi/802.1X CA — each with its own issuer + auth modes + URL path +// (/.well-known/est//). The Profiles slice carries that. Existing +// operators see no behavior change: when Profiles is empty AND the legacy +// single-issuer flat fields below are set, ConfigLoad synthesizes a +// single-element Profiles[0] with PathID="" (which maps to the legacy +// /.well-known/est/ root path). +type ESTConfig struct { + // Enabled controls whether EST endpoints are available for device enrollment. + // Default: false (EST disabled). Set to true to enable RFC 7030 endpoints + // under /.well-known/est/ (cacerts, simpleenroll, simplereenroll, csrattrs). + Enabled bool + + // IssuerID selects which issuer connector processes EST certificate requests. + // Default: "iss-local". Legacy single-issuer field; merged into Profiles[0] + // by mergeESTLegacyIntoProfiles when Profiles is empty. + IssuerID string + + // ProfileID optionally constrains EST enrollments to a specific certificate profile. + // Legacy single-issuer field; merged into Profiles[0] when applicable. + ProfileID string + + // Profiles is the multi-endpoint configuration. Each profile gets its own + // URL path (/.well-known/est//), its own bound issuer, its own auth + // modes, and its own per-profile policy knobs (rate limit, server-keygen + // gate, mTLS bundle, RFC 9266 channel-binding requirement). Population + // sources, in priority order: + // + // 1. Explicit list via CERTCTL_EST_PROFILES (e.g. "corp,iot,wifi"). + // 2. Backward-compat shim: when CERTCTL_EST_PROFILES is unset AND the + // legacy flat fields above are populated AND Enabled=true, ConfigLoad + // synthesizes a single-element Profiles[0] with PathID="" so + // /.well-known/est/ continues to route the same way it did + // pre-Phase-1. + // + // EST RFC 7030 hardening master bundle Phase 1. + Profiles []ESTProfileConfig +} + +// ESTProfileConfig is one EST endpoint's configuration. Each profile is +// bound to one issuer + one optional certctl CertificateProfile + one set +// of per-profile auth modes (mTLS / HTTP Basic / both). Future phases of +// the hardening bundle wire the additional per-profile fields: +// +// - Phase 2 reads MTLSEnabled + MTLSClientCATrustBundlePath + +// ChannelBindingRequired to enable the /.well-known/est-mtls/ +// sibling route (mirrors SCEP's /scep-mtls/ from commit e7a3075). +// - Phase 3 reads EnrollmentPassword + AllowedAuthModes to enforce HTTP +// Basic auth on the standard /.well-known/est// route. +// - Phase 4 reads RateLimitPerPrincipal24h to apply per-CN+source-IP +// sliding-window rate limiting (mirrors SCEP/Intune's +// PerDeviceRateLimiter from internal/scep/intune/rate_limit.go). +// - Phase 5 reads ServerKeygenEnabled to gate the new /serverkeygen +// endpoint per RFC 7030 §4.4. +// +// Phase 1 (this commit) lays the FIELD CONTRACTS + per-profile Validate() +// gates so an operator who flips MTLSEnabled=true without supplying the +// bundle path gets a loud refuse-to-start error rather than a silent +// no-op. The actual auth/limit/keygen handlers ship in Phases 2-5. +// +// EST RFC 7030 hardening master bundle Phase 1. +type ESTProfileConfig struct { + // PathID is the URL segment after /.well-known/est/. Empty string maps + // to the legacy /.well-known/est/ root for backward compatibility (so + // existing operators with the flat single-issuer config see no URL + // change). Non-empty values MUST be a single path-safe slug + // ([a-z0-9-], no slashes); validated at startup by Config.Validate(). + // Multi-profile deployments typically use short tokens like "corp", + // "iot", "wifi" — the URL becomes /.well-known/est/corp/cacerts, + // /.well-known/est/iot/simpleenroll, etc. + PathID string + + // IssuerID selects which issuer connector this profile's enrollments + // go through. Must reference a configured issuer. Required (Validate + // refuses empty IssuerID). + IssuerID string + + // ProfileID optionally constrains enrollments under this PathID to a + // specific CertificateProfile. Leave empty to allow the issuer's + // defaults. When non-empty, profile crypto policy (allowed key + // algorithms, required EKUs, max TTL) is enforced at enrollment time + // via service.ValidateCSRAgainstProfile. + ProfileID string + + // EnrollmentPassword is the per-profile shared secret for HTTP Basic + // auth on the standard /.well-known/est// route (Phase 3). + // Empty value means HTTP Basic auth is NOT required for this profile + // (mTLS-only or anonymous, depending on AllowedAuthModes). Stored only + // in process memory; never logged. Constant-time comparison via + // crypto/subtle.ConstantTimeCompare in the handler. + EnrollmentPassword string + + // MTLSEnabled gates the sibling /.well-known/est-mtls// route + // (Phase 2). When true, the route requires a client cert that chains + // to one of the certs in MTLSClientCATrustBundlePath. The standard + // /.well-known/est// route remains application-layer-auth + // (HTTP Basic password) so existing clients keep working — mTLS is + // additive, not replacement. + // + // Mirrors SCEP's MTLSEnabled (commit e7a3075). Same defense-in-depth + // rationale: enterprise procurement teams routinely reject 'shared + // password authentication' as a checkbox-fail regardless of how + // strong the password is. This flag wires up a sibling route that + // adds client-cert auth at the handler layer. + MTLSEnabled bool + + // MTLSClientCATrustBundlePath is the PEM bundle of CA certs that sign + // the client (device-bootstrap) certs the operator allows to enroll + // via the mTLS sibling route. Required when MTLSEnabled is true. + // Validated at startup by cmd/server/main.go's + // preflightESTMTLSClientCATrustBundle (Phase 2): file exists, parses + // as PEM, contains ≥1 cert, none expired. + MTLSClientCATrustBundlePath string + + // ChannelBindingRequired forces the EST mTLS handler (Phase 2) to + // require RFC 9266 tls-exporter channel binding in the CSR's CMC + // id-aa-channelBindings attribute. When true, CSRs without the + // binding are refused with ErrChannelBindingMissing; mismatched + // bindings refused with ErrChannelBindingMismatch. Defaults true for + // new-cert-issuance flows (Phase 2 default), false for re-enrollment + // where the previous-cert presentation is the trust signal. Operators + // running clients that don't support RFC 9266 (older libest, etc.) + // can opt out per-profile. + // + // EST RFC 7030 hardening master bundle Phase 0 frozen decision 0.2. + ChannelBindingRequired bool + + // AllowedAuthModes enumerates which application-layer auth modes + // this profile accepts. Valid entries: "mtls", "basic". Empty slice + // means no auth required (the unauthenticated default that EST + // shipped with at v2.0.66; preserved for backward compat — Validate + // emits a warning log for empty slices to nudge operators toward + // explicit opt-in). Phase 2 + 3 read this to enforce per-mode + // requirements; Phase 1 just validates shape. + // + // EST RFC 7030 hardening master bundle Phase 0 frozen decision 0.1. + AllowedAuthModes []string + + // RateLimitPerPrincipal24h caps enrollments per (CSR.Subject.CN, + // sourceIP) pair in any rolling 24-hour window. Default 0 (Phase 1 + // preserves the unauthenticated/unlimited default to avoid changing + // production behavior); Phase 4 will wire this against the extracted + // internal/ratelimit/SlidingWindowLimiter. Negative values are + // rejected at Validate time as a config typo. + // + // EST RFC 7030 hardening master bundle Phase 1 + Phase 4. + RateLimitPerPrincipal24h int + + // ServerKeygenEnabled gates the /.well-known/est//serverkeygen + // endpoint (RFC 7030 §4.4) for this profile. When true, the server + // generates the keypair on behalf of the client and returns both + // cert + private key (the latter wrapped in CMS EnvelopedData). + // Default false. Phase 5 wires the handler; Phase 1 lays the gate + // + the Validate refusal for ServerKeygenEnabled=true without a + // CertificateProfile that pins AllowedKeyAlgorithms (the server + // must know what algorithm to generate). + // + // EST RFC 7030 hardening master bundle Phase 5. + ServerKeygenEnabled bool +} + +// loadESTProfilesFromEnv reads the indexed CERTCTL_EST_PROFILES env var +// (e.g. "corp,iot,wifi") and expands each name into an ESTProfileConfig +// populated from CERTCTL_EST_PROFILE__*. Returns nil when the +// CERTCTL_EST_PROFILES env var is unset or empty — in that case the +// legacy-shim path (mergeESTLegacyIntoProfiles, called from Load after +// the initial config build) populates Profiles[0] from the flat fields +// if needed. +// +// PathID for each profile is the lowercased trimmed name from the +// CERTCTL_EST_PROFILES list (e.g. "Corp" -> "corp"). Validation that +// the PathID is path-safe ([a-z0-9-]+) lives in Config.Validate() so +// the loader can stay free of error returns. +// +// Mirrors loadSCEPProfilesFromEnv exactly. EST RFC 7030 hardening Phase 1. +func loadESTProfilesFromEnv() []ESTProfileConfig { + raw := strings.TrimSpace(os.Getenv("CERTCTL_EST_PROFILES")) + if raw == "" { + return nil + } + names := strings.Split(raw, ",") + out := make([]ESTProfileConfig, 0, len(names)) + for _, n := range names { + n = strings.TrimSpace(n) + if n == "" { + continue + } + // The env-var key is the upper-cased name (CERTCTL_EST_PROFILE_CORP_*), + // but the URL path segment is the lower-cased name to match the + // path-safe slug constraint enforced in Validate. + envName := strings.ToUpper(n) + pathID := strings.ToLower(n) + out = append(out, ESTProfileConfig{ + PathID: pathID, + IssuerID: getEnv("CERTCTL_EST_PROFILE_"+envName+"_ISSUER_ID", ""), + ProfileID: getEnv("CERTCTL_EST_PROFILE_"+envName+"_PROFILE_ID", ""), + EnrollmentPassword: getEnv("CERTCTL_EST_PROFILE_"+envName+"_ENROLLMENT_PASSWORD", ""), + MTLSEnabled: getEnvBool("CERTCTL_EST_PROFILE_"+envName+"_MTLS_ENABLED", false), + MTLSClientCATrustBundlePath: getEnv("CERTCTL_EST_PROFILE_"+envName+"_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH", ""), + ChannelBindingRequired: getEnvBool("CERTCTL_EST_PROFILE_"+envName+"_CHANNEL_BINDING_REQUIRED", false), + AllowedAuthModes: parseAuthModes(getEnv("CERTCTL_EST_PROFILE_"+envName+"_ALLOWED_AUTH_MODES", "")), + RateLimitPerPrincipal24h: getEnvInt("CERTCTL_EST_PROFILE_"+envName+"_RATE_LIMIT_PER_PRINCIPAL_24H", 0), + ServerKeygenEnabled: getEnvBool("CERTCTL_EST_PROFILE_"+envName+"_SERVERKEYGEN_ENABLED", false), + }) + } + return out +} + +// parseAuthModes splits a comma-separated env value into a normalized +// []string of auth-mode tokens. Empty input returns nil (the +// "unauthenticated default" Phase 1 preserves for back-compat). Tokens +// are lowercased + trimmed; unknown tokens are kept as-is so Validate +// can refuse them with a typed error message naming the offending token. +func parseAuthModes(s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.ToLower(strings.TrimSpace(p)) + if p == "" { + continue + } + out = append(out, p) + } + return out +} + +// mergeESTLegacyIntoProfiles is the EST backward-compat shim. When +// Profiles is empty AND the legacy single-issuer fields are populated +// (Enabled=true is the trigger; IssuerID has a non-empty default so it +// can't be the trigger by itself), synthesise a single-element +// Profiles[0] with PathID="" so /.well-known/est/ dispatches identically +// to the pre-Phase-1 deploy. No-op when Profiles is non-empty (the +// operator explicitly opted into the structured form via +// CERTCTL_EST_PROFILES) or when EST is disabled. +// +// EST's legacy single-issuer config has fewer "trigger" fields than +// SCEP's (no per-profile RA pair, no per-profile challenge password — +// both of those land in Phases 2/3 of the hardening bundle). The shim +// triggers whenever EST is enabled, since the operator clearly intends +// to serve EST. This makes the back-compat behavior identical to v2.0.66 +// (single /.well-known/est/ root with the operator's chosen issuer). +// +// EST RFC 7030 hardening Phase 1. +func mergeESTLegacyIntoProfiles(c *ESTConfig) { + if c == nil || !c.Enabled || len(c.Profiles) > 0 { + return + } + c.Profiles = []ESTProfileConfig{{ + PathID: "", // empty pathID maps to the legacy /.well-known/est/ root + IssuerID: c.IssuerID, + ProfileID: c.ProfileID, + // No legacy fields exist for EnrollmentPassword, MTLS*, etc. — + // those land in Phases 2/3. Operators upgrading from v2.0.66 get + // the same unauthenticated behavior they had before; opting into + // auth requires moving to the structured CERTCTL_EST_PROFILES + // form (which Phase 12 docs as the recommended migration path). + }} +} + +// validESTPathID reports whether s is a valid EST profile path segment. +// Same shape as validSCEPPathID — empty string allowed (legacy root), +// otherwise ASCII lowercase letters / digits / hyphens with no +// leading/trailing hyphen. Kept as a separate function (rather than +// generalizing) so that future EST-specific path constraints (e.g. RFC +// 7030 §3.2.2 reserved path segments) can land here without affecting +// SCEP's validator. +// +// EST RFC 7030 hardening Phase 1. +func validESTPathID(s string) bool { + if s == "" { + return true // empty maps to legacy /.well-known/est/ root + } + if s[0] == '-' || s[len(s)-1] == '-' { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' { + continue + } + return false + } + return true +} + +// validESTAuthMode reports whether mode is one of the documented EST +// auth modes Phase 2 + Phase 3 will dispatch on. Kept here so Validate +// can refuse unknown modes (typos, future modes the binary doesn't yet +// implement) at startup with a clear error rather than at first-request +// with a confusing 401/403. +// +// EST RFC 7030 hardening Phase 1. +func validESTAuthMode(mode string) bool { + switch mode { + case "mtls", "basic": + return true + } + return false +}