From c461ef3339b32a46ec6f069b2e12c3c9ec705a80 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Thu, 14 May 2026 04:19:24 +0000 Subject: [PATCH] refactor(config): extract SCEP family + helpers to its own file (Phase 9, 3 of N) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuing Phase 9 ARCH-M2 closure. Sprints 1+2 extracted pure-data structs (NotifierConfig, then the ACME family). Sprint 3 is the first split that ALSO moves helper functions — the SCEP family has three structs AND three unexported package-internal helpers that move together. What moved ========== internal/config/scep.go (new, 402 lines including BSL header + Phase 9 doc-comment + the 3 imports + 3 structs + 3 helpers verbatim) Three structs: - SCEPConfig (top-level: Enabled + Profiles slice + legacy single-profile flat fields kept for backward compat) - SCEPProfileConfig (one endpoint binding: PathID, IssuerID, ProfileID, ChallengePassword, RA cert/key, MTLSEnabled + bundle path, per-profile Intune block) - SCEPIntuneProfileConfig (Enabled, ConnectorCertPath, Audience, ChallengeValidity, PerDeviceRateLimit24h, ClockSkewTolerance) Three unexported helpers: - loadSCEPProfilesFromEnv() — reads CERTCTL_SCEP_PROFILES + expands each name into a SCEPProfileConfig via the CERTCTL_SCEP_PROFILE__* indexed env-var family. - mergeSCEPLegacyIntoProfiles() — backward-compat shim: synthesize Profiles[0] from the legacy flat fields when Profiles is empty. - validSCEPPathID() — path-segment validator (ASCII [a-z0-9-], no leading/trailing hyphen, empty allowed). Why move the helpers along ========================== Each helper is exclusively SCEP-specific: live grep across the repo shows ZERO callers outside internal/config/config.go's Load() and Validate(). Both still live in config.go and continue to resolve the moved helpers via same-package lookup. Specifically: - Load() (still in config.go) calls loadSCEPProfilesFromEnv() during initial cfg.SCEP construction (call site at the original line ~1840, now closer to line ~1840 after Sprints 1+2 + 3 deletions). - Load() calls mergeSCEPLegacyIntoProfiles(&cfg.SCEP) after the initial profile-load. - Validate() calls validSCEPPathID(p.PathID) per-profile in the Profiles-iteration loop. The unexported helpers getEnv / getEnvBool / getEnvInt / getEnvDuration used by loadSCEPProfilesFromEnv stay in config.go (shared across every config family); same-package resolution makes the calls work. What stayed in config.go ======================== - All Load() + Validate() bodies — the SCEP-specific call sites stay where they are (cross-cutting validation logic, not split-target). - Every getEnv* helper. - The Config{}.SCEP master-struct field declaration. Edit shape ========== The edit was performed in two sed passes: 1. sed -i '775,1004d' — deleted the SCEP struct block (the three types + their doc-comments). 2. sed -i '1813,1916d' — deleted the SCEP helper-function block (the three helpers + their doc-comments). Then gofmt -w to collapse a residual double-blank-line at the first join point. The two-pass approach was necessary because the structs and helpers live in different regions of config.go (struct definitions in the top half, function bodies near the bottom). Public-surface invariant ======================== Every type, field, exported method, and doc-comment is byte-identical to pre-split. Package stays `config`. Every caller's `config.SCEPConfig` / `config.SCEPProfileConfig` / `config.SCEPIntuneProfileConfig` import path is preserved without modification. The three helpers are unexported so their move is invisible to package consumers; same-package callers in config.go 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.68s) staticcheck ./internal/config/... → clean go build ./internal/api/router/... ./internal/scheduler/... ./cmd/server/... → clean (broader importers still resolve every type) grep -nE '^type SCEP|^func .*SCEP' internal/config/config.go → empty (none remain in config.go) grep -nE '^type SCEP|^func .*SCEP' internal/config/scep.go → 3 types + 3 funcs (correct: SCEPConfig, SCEPProfileConfig, SCEPIntuneProfileConfig, loadSCEPProfilesFromEnv, mergeSCEPLegacyIntoProfiles, validSCEPPathID) LOC delta: config.go: 3108 → 2774 (-334 lines: -230 from struct block, -103 from helper block, -1 from double-blank collapse) scep.go: new, 402 lines (incl. 72-line Phase 9 doc-comment + BSL header + package decl + 3 imports) Cumulative Phase 9 progress (Sprints 1+2+3 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) Total Sprint 1+2+3: -629 LOC (-18.5%) Pattern lesson logged ===================== The "Do not assume line numbers" rule continues to pay off: every sprint of Phase 9 has touched line numbers from prior sprints (Sprint 1's 65-line removal shifted SCEPConfig from line 1083 to 1015 to its Sprint 3 starting position of 786). The Phase 9 prompt told us to re-derive every fact; the live-grep audit at the start of each sprint catches the drift. Next queued (Sprint 4): EST family from config.go → internal/config/est.go (~250-300 LOC including ESTConfig + ESTProfileConfig + loadESTProfilesFromEnv + mergeESTLegacyIntoProfiles + parseAuthModes + validESTPathID + validESTAuthMode). Same complexity shape as SCEP — three structs + multiple helpers + same Load()/Validate() callers that stay in config.go. Closes: cowork/certctl-architecture-diligence-audit.html#fix-ARCH-M2 (partial — 3 of 12 — full ARCH-M2 closure is the aggregate) --- internal/config/config.go | 334 ------------------------------- internal/config/scep.go | 402 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 402 insertions(+), 334 deletions(-) create mode 100644 internal/config/scep.go diff --git a/internal/config/config.go b/internal/config/config.go index 030e441..eb20226 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -772,236 +772,6 @@ type ESTProfileConfig struct { ServerKeygenEnabled bool } -// SCEPConfig controls the RFC 8894 Simple Certificate Enrollment Protocol server. -// -// SCEP RFC 8894 + Intune master bundle Phase 1.5: this type was originally a -// single flat struct with one IssuerID + one RA pair + one challenge password -// (the shape of v2.0.x). Real enterprise deployments need to expose multiple -// SCEP endpoints from one certctl instance — corp-laptop CA, server CA, IoT -// CA — each with its own issuer + RA pair + challenge password + URL path -// (/scep/). The Profiles slice carries that. Existing operators see -// no behavior change: when Profiles is empty AND the legacy single-profile -// fields below are set, ConfigLoad synthesizes a single-element Profiles[0] -// with PathID="" (which maps to the legacy /scep root path). -type SCEPConfig struct { - // Enabled controls whether SCEP endpoints are available for device enrollment. - // Default: false (SCEP disabled). Set to true to enable SCEP endpoints under /scep/. - Enabled bool - - // Profiles is the multi-endpoint configuration. Each profile gets its own - // URL path (/scep/), its own RA cert + key, its own challenge - // password, and its own bound issuer. Population sources, in priority order: - // - // 1. Explicit list via CERTCTL_SCEP_PROFILES (e.g. "corp,iot,server"). - // 2. Backward-compat shim: when CERTCTL_SCEP_PROFILES is unset AND the - // legacy flat fields below have ChallengePassword OR RACertPath set, - // ConfigLoad synthesizes a single-element Profiles[0] with PathID="" - // so /scep continues to route the same way it did pre-Phase-1.5. - // - // Validate() iterates Profiles and refuses to boot if any profile is - // malformed (empty ChallengePassword, missing RA pair, invalid PathID). - // Each profile's ChallengePassword + RA pair are independently mandatory - // — the profile-load shim never silently borrows from a sibling profile. - Profiles []SCEPProfileConfig - - // Legacy single-profile fields — preserved for backward compatibility. New - // operators should populate Profiles directly via the indexed env-var form. - // These fields are merged into Profiles[0] by ConfigLoad when Profiles is - // empty AND any of these fields are non-zero. - - // IssuerID selects which issuer connector processes SCEP certificate requests - // for the legacy single-profile config. Default: "iss-local". Must reference a - // configured issuer. - IssuerID string - - // ProfileID optionally constrains SCEP enrollments to a specific certificate profile - // for the legacy single-profile config. Leave empty to allow SCEP to use any - // configured issuer's defaults. - ProfileID string - - // ChallengePassword is the shared secret used to authenticate SCEP enrollment requests. - // Clients include this in the PKCS#10 CSR challengePassword attribute. - // - // REQUIRED when Enabled is true. Config.Validate() below refuses to start the - // server if SCEP is enabled and this value is empty (H-2, CWE-306): post-M-001 - // under option (D), the /scep endpoint rides the no-auth middleware chain per - // RFC 8894 §3.2, so the challenge password is the sole application-layer - // authentication boundary for SCEP enrollment. An empty shared secret would - // allow any client that can reach /scep to enroll a CSR against the configured - // issuer. The service-layer PKCSReq path also rejects this configuration - // defense-in-depth. - // - // Legacy single-profile field; merged into Profiles[0].ChallengePassword by - // ConfigLoad when Profiles is empty. - ChallengePassword string - - // RACertPath is the path to a PEM-encoded RA (Registration Authority) - // certificate used by the RFC 8894 SCEP path. SCEP clients encrypt their - // PKCS#10 CSR to this cert's public key (via the EnvelopedData wrapper, RFC - // 8894 §3.2.2). The certctl server uses RAKeyPath to decrypt inbound - // EnvelopedData and to sign outbound CertRep PKIMessage signerInfo (RFC - // 8894 §3.3.2). - // - // Required when Enabled is true; Config.Validate() refuses to start without - // it. Without an RA pair the new RFC 8894 path silently falls through to - // the MVP raw-CSR path on every request and the operator's intent is - // unclear — fail loud at startup instead. - // - // Generation: a self-signed RA cert with subject "CN=-RA" and - // the id-kp-emailProtection / id-kp-cmcRA EKU is sufficient. The RA cert - // SHOULD be the same cert returned by GetCACert (RFC 8894 §3.5.1) so - // clients encrypt to a key the server can decrypt with. See - // docs/legacy-est-scep.md for the openssl recipe. - RACertPath string - - // RAKeyPath is the path to the PEM-encoded private key matching RACertPath. - // File MUST be mode 0600 (owner read/write only); preflight refuses to load - // a world-readable RA key as defense-in-depth against credential leak. The - // server only ever reads this file at startup; rotation requires a restart - // (per the existing CERTCTL_TLS_CERT_PATH precedent in cmd/server/tls.go). - // - // Legacy single-profile field; merged into Profiles[0].RAKeyPath by - // ConfigLoad when Profiles is empty. - RAKeyPath string -} - -// SCEPProfileConfig is one SCEP endpoint's configuration. Each profile is -// bound to one issuer + one optional certctl CertificateProfile + one RA -// pair + one challenge password (the per-profile Intune trust anchor lands -// here in Phase 8 of the master bundle). -// -// Multi-profile motivation: a real enterprise deployment exposes distinct -// SCEP endpoints to distinct fleets — corp-laptop CA bound to one issuer -// with one challenge password; IoT CA bound to a different issuer with a -// different challenge password — so a single set of credentials can never -// enroll across CA boundaries by accident. Each SCEPProfileConfig drives -// a separate handler + service instance built at server startup. -type SCEPProfileConfig struct { - // PathID is the URL segment after /scep/. Empty string maps to the legacy - // /scep root for backward compatibility (so existing operators with the - // flat single-profile 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", "server" — the URL becomes - // /scep/corp, /scep/iot, /scep/server. - PathID string - - // IssuerID selects which issuer connector this profile's enrollments go - // through. Must reference a configured issuer. - IssuerID string - - // ProfileID optionally constrains enrollments under this PathID to a - // specific CertificateProfile. Leave empty to allow the issuer's defaults. - ProfileID string - - // ChallengePassword is the per-profile shared secret. Same constant-time - // compare semantics as the flat field; empty value at validate time fails - // the boot. - ChallengePassword string - - // RACertPath / RAKeyPath are the per-profile RA pair used by the RFC 8894 - // EnvelopedData decryption + CertRep signing path. Same preflight semantics - // as the legacy flat fields (file existence, key mode 0600, cert/key - // match, expiry, RSA-or-ECDSA alg). - RACertPath string - RAKeyPath string - - // MTLSEnabled gates the sibling `/scep-mtls/` route. When true, - // the route requires a client cert that chains to one of the certs in - // MTLSClientCATrustBundlePath. The standard `/scep[/]` route - // remains application-layer-auth (challenge password) so existing - // clients keep working — mTLS is additive, not replacement. - // - // SCEP RFC 8894 + Intune master bundle Phase 6.5: 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 AND keeps - // the challenge password (defense in depth, not replacement). Devices - // present a bootstrap cert from a trusted CA (e.g. a manufacturing-time - // cert), then SCEP-enroll for their long-lived cert. Same model Apple's - // MDM and Cisco's BRSKI use. - MTLSEnabled bool - - // MTLSClientCATrustBundlePath is the PEM bundle of CA certs that sign - // the client (device-bootstrap) certs the operator allows to enroll. - // Required when MTLSEnabled is true. Operators with multiple bootstrap - // CAs concatenate them. Validated at startup by - // `cmd/server/main.go::preflightSCEPMTLSTrustBundle` — file exists, - // parses as PEM, contains ≥1 cert, none expired. - MTLSClientCATrustBundlePath string - - // Intune is the per-profile Microsoft Intune Certificate Connector - // integration block. When Enabled is false (default), this profile only - // honors the static ChallengePassword; when true, requests with an - // Intune-shaped challenge password (length + dot-count heuristic) are - // routed to the Intune dynamic-challenge validator. - // - // SCEP RFC 8894 + Intune master bundle Phase 8.8: per-profile dispatch - // is what makes the heterogeneous-fleet story work — an operator - // running corp-laptops via Intune AND IoT devices via static challenge - // configures Intune-mode on the corp profile only; the IoT profile's - // PKCSReq path skips the Intune dispatcher entirely. - Intune SCEPIntuneProfileConfig -} - -// SCEPIntuneProfileConfig is the per-profile Microsoft Intune Certificate -// Connector integration sub-block on SCEPProfileConfig. -// -// SCEP RFC 8894 + Intune master bundle Phase 8.1. -// -// All fields here are populated from CERTCTL_SCEP_PROFILE__INTUNE_* -// env vars (e.g. CERTCTL_SCEP_PROFILE_CORP_INTUNE_ENABLED=true). Per-profile -// overrides means an operator with two Intune-backed profiles (corp + iot, -// say) can pin distinct Connectors + audiences + rate limits per fleet. -type SCEPIntuneProfileConfig struct { - // Enabled gates the Intune dynamic-challenge validation path. When - // false (default), this profile honors only the static ChallengePassword. - // When true, ConnectorCertPath becomes a required boot gate. - Enabled bool - - // ConnectorCertPath is the filesystem path to a PEM bundle of one or - // more Microsoft Intune Certificate Connector signing certs. Required - // when Enabled=true. Reloaded on SIGHUP via the per-profile - // TrustAnchorHolder wired in cmd/server/main.go. - ConnectorCertPath string - - // Audience is the expected "aud" claim value in the Intune challenge — - // typically the public SCEP endpoint URL the Connector is configured to - // call (e.g. "https://certctl.example.com/scep/corp"). Defaults to - // empty (audience check disabled) for proxy / load-balancer scenarios - // where the URL the Connector saw isn't the URL we see; operators - // who pin a public URL here gain defense-in-depth against challenge - // re-use across endpoints. - Audience string - - // ChallengeValidity caps the maximum age of an Intune challenge, on - // top of the challenge's own iat/exp claims. Default 60 minutes per - // Microsoft's published Connector defaults — operators may want a - // stricter cap to reduce the replay-window exposure on a stolen - // challenge. Zero means "use Connector's exp claim only" (no extra cap). - ChallengeValidity time.Duration - - // PerDeviceRateLimit24h caps the number of enrollments per - // (claim.Subject, claim.Issuer) pair in any rolling 24-hour window. - // Default 3 (covers legitimate first-cert + recovery + post-wipe - // re-enrollment, blocks bulk-enumeration from a compromised Connector - // signing key). Zero means "unlimited" (defense-in-depth disabled; - // not recommended for production). - PerDeviceRateLimit24h int - - // ClockSkewTolerance widens the iat/exp validation window by - // ±|tolerance| to absorb modest clock drift between the Microsoft - // Intune Certificate Connector and the certctl host. Default 60s - // per master prompt §15 ("known hazards"). Operators on tightly - // time-synced fleets can set this to zero to enforce strict - // iat/exp checks; operators on loosely synced fleets (e.g. field - // devices with no NTP) may raise to 5m. Validate() refuses any - // tolerance ≥ ChallengeValidity (which would make the per-profile - // validity cap meaningless). Source env var: - // CERTCTL_SCEP_PROFILE__INTUNE_CLOCK_SKEW_TOLERANCE. - ClockSkewTolerance time.Duration -} - // NetworkScanConfig controls the server-side active TLS scanner. type NetworkScanConfig struct { Enabled bool // Enable network scanning (default false) @@ -2039,110 +1809,6 @@ func Load() (*Config, error) { return cfg, nil } -// loadSCEPProfilesFromEnv reads the indexed CERTCTL_SCEP_PROFILES env var -// (e.g. "corp,iot,server") and expands each name into a SCEPProfileConfig -// populated from CERTCTL_SCEP_PROFILE__*. Returns nil when the -// CERTCTL_SCEP_PROFILES env var is unset or empty — in that case the -// legacy-shim path (mergeSCEPLegacyIntoProfiles, 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_SCEP_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. -func loadSCEPProfilesFromEnv() []SCEPProfileConfig { - raw := strings.TrimSpace(os.Getenv("CERTCTL_SCEP_PROFILES")) - if raw == "" { - return nil - } - names := strings.Split(raw, ",") - out := make([]SCEPProfileConfig, 0, len(names)) - for _, n := range names { - n = strings.TrimSpace(n) - if n == "" { - continue - } - // The env-var key is the upper-cased name (CERTCTL_SCEP_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, SCEPProfileConfig{ - PathID: pathID, - IssuerID: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_ISSUER_ID", ""), - ProfileID: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_PROFILE_ID", ""), - ChallengePassword: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_CHALLENGE_PASSWORD", ""), - RACertPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_CERT_PATH", ""), - RAKeyPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_KEY_PATH", ""), - // SCEP RFC 8894 Phase 6.5: opt-in mTLS sibling route. - MTLSEnabled: getEnvBool("CERTCTL_SCEP_PROFILE_"+envName+"_MTLS_ENABLED", false), - MTLSClientCATrustBundlePath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH", ""), - // SCEP RFC 8894 Phase 8.1: per-profile Intune Connector dispatch. - Intune: SCEPIntuneProfileConfig{ - Enabled: getEnvBool("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_ENABLED", false), - ConnectorCertPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_CONNECTOR_CERT_PATH", ""), - Audience: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_AUDIENCE", ""), - ChallengeValidity: getEnvDuration("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_CHALLENGE_VALIDITY", 60*time.Minute), - PerDeviceRateLimit24h: getEnvInt("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_PER_DEVICE_RATE_LIMIT_24H", 3), - ClockSkewTolerance: getEnvDuration("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_CLOCK_SKEW_TOLERANCE", 60*time.Second), - }, - }) - } - return out -} - -// mergeSCEPLegacyIntoProfiles is the backward-compat shim. When Profiles is -// empty AND any legacy single-profile field is populated, synthesise a -// single-element Profiles[0] with PathID="" so /scep dispatches identically -// to the pre-Phase-1.5 deploy. No-op when Profiles is non-empty (the operator -// explicitly opted into the structured form via CERTCTL_SCEP_PROFILES) or -// when SCEP is disabled. -// -// "Any legacy field populated" means at least one of ChallengePassword, -// RACertPath, RAKeyPath is non-empty. IssuerID has a non-empty default -// ("iss-local") so it can't be the trigger; ProfileID is optional. The -// trigger set matches what the Validate() refuse cares about. -func mergeSCEPLegacyIntoProfiles(c *SCEPConfig) { - if c == nil || !c.Enabled || len(c.Profiles) > 0 { - return - } - hasLegacy := c.ChallengePassword != "" || c.RACertPath != "" || c.RAKeyPath != "" - if !hasLegacy { - return - } - c.Profiles = []SCEPProfileConfig{{ - PathID: "", // empty pathID maps to the legacy /scep root - IssuerID: c.IssuerID, - ProfileID: c.ProfileID, - ChallengePassword: c.ChallengePassword, - RACertPath: c.RACertPath, - RAKeyPath: c.RAKeyPath, - }} -} - -// validSCEPPathID reports whether s is a valid SCEP profile path segment. -// The empty string is allowed (legacy root /scep). Non-empty values must -// be ASCII lowercase letters / digits / hyphens with no leading/trailing -// hyphen — keeps URL-construction trivial at the router layer and avoids -// percent-encoding surprises for SCEP clients that build the URL by string -// concat rather than url.PathEscape. -func validSCEPPathID(s string) bool { - if s == "" { - return true // empty maps to legacy /scep 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 -} - // 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 diff --git a/internal/config/scep.go b/internal/config/scep.go new file mode 100644 index 0000000..34e70c3 --- /dev/null +++ b/internal/config/scep.go @@ -0,0 +1,402 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 + +package config + +import ( + "os" + "strings" + "time" +) + +// Phase 9 ARCH-M2 closure Sprint 3 (2026-05-14): extracted from +// config.go. Larger and more complex than Sprints 1+2 because the +// SCEP surface has THREE structs AND three helper functions that +// move together: +// +// SCEPConfig — top-level multi-profile config +// (Enabled + Profiles slice + the +// legacy single-profile flat fields +// kept for backward compat). +// SCEPProfileConfig — one SCEP endpoint's binding +// (PathID + IssuerID + ProfileID + +// ChallengePassword + RA cert/key + +// mTLS sibling-route gate + per- +// profile Intune block). +// SCEPIntuneProfileConfig — per-profile Microsoft Intune +// Certificate Connector integration +// (Enabled, ConnectorCertPath, +// Audience, ChallengeValidity, +// PerDeviceRateLimit24h, +// ClockSkewTolerance). +// +// loadSCEPProfilesFromEnv — reads CERTCTL_SCEP_PROFILES + +// expands each name into a +// SCEPProfileConfig via the +// CERTCTL_SCEP_PROFILE__* +// indexed env-var family. +// mergeSCEPLegacyIntoProfiles — backward-compat shim: when +// Profiles is empty AND legacy flat +// fields are populated, synthesize +// Profiles[0] with PathID="" so +// /scep dispatches as it did +// pre-Phase-1.5. +// validSCEPPathID — path-segment validator (ASCII +// [a-z0-9-], no leading/trailing +// hyphen, empty allowed). Called +// from Config.Validate() in +// config.go. +// +// All callers stay in config.go and continue to resolve via +// same-package lookup. Specifically: +// - Load() calls loadSCEPProfilesFromEnv() during initial cfg.SCEP +// construction (currently config.go's Load body) +// - Load() calls mergeSCEPLegacyIntoProfiles(&cfg.SCEP) after the +// initial profile-load +// - Validate() calls validSCEPPathID(p.PathID) per-profile +// +// The unexported helpers getEnv / getEnvBool / getEnvInt / +// getEnvDuration used by loadSCEPProfilesFromEnv 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 SCEPConfig`, +// `go doc internal/config SCEPProfileConfig`, and +// `go doc internal/config SCEPIntuneProfileConfig` produce +// identical output before and after this split. Unexported helpers +// are unaffected by `go doc` (which only shows the exported +// surface). + +// SCEPConfig controls the RFC 8894 Simple Certificate Enrollment Protocol server. +// +// SCEP RFC 8894 + Intune master bundle Phase 1.5: this type was originally a +// single flat struct with one IssuerID + one RA pair + one challenge password +// (the shape of v2.0.x). Real enterprise deployments need to expose multiple +// SCEP endpoints from one certctl instance — corp-laptop CA, server CA, IoT +// CA — each with its own issuer + RA pair + challenge password + URL path +// (/scep/). The Profiles slice carries that. Existing operators see +// no behavior change: when Profiles is empty AND the legacy single-profile +// fields below are set, ConfigLoad synthesizes a single-element Profiles[0] +// with PathID="" (which maps to the legacy /scep root path). +type SCEPConfig struct { + // Enabled controls whether SCEP endpoints are available for device enrollment. + // Default: false (SCEP disabled). Set to true to enable SCEP endpoints under /scep/. + Enabled bool + + // Profiles is the multi-endpoint configuration. Each profile gets its own + // URL path (/scep/), its own RA cert + key, its own challenge + // password, and its own bound issuer. Population sources, in priority order: + // + // 1. Explicit list via CERTCTL_SCEP_PROFILES (e.g. "corp,iot,server"). + // 2. Backward-compat shim: when CERTCTL_SCEP_PROFILES is unset AND the + // legacy flat fields below have ChallengePassword OR RACertPath set, + // ConfigLoad synthesizes a single-element Profiles[0] with PathID="" + // so /scep continues to route the same way it did pre-Phase-1.5. + // + // Validate() iterates Profiles and refuses to boot if any profile is + // malformed (empty ChallengePassword, missing RA pair, invalid PathID). + // Each profile's ChallengePassword + RA pair are independently mandatory + // — the profile-load shim never silently borrows from a sibling profile. + Profiles []SCEPProfileConfig + + // Legacy single-profile fields — preserved for backward compatibility. New + // operators should populate Profiles directly via the indexed env-var form. + // These fields are merged into Profiles[0] by ConfigLoad when Profiles is + // empty AND any of these fields are non-zero. + + // IssuerID selects which issuer connector processes SCEP certificate requests + // for the legacy single-profile config. Default: "iss-local". Must reference a + // configured issuer. + IssuerID string + + // ProfileID optionally constrains SCEP enrollments to a specific certificate profile + // for the legacy single-profile config. Leave empty to allow SCEP to use any + // configured issuer's defaults. + ProfileID string + + // ChallengePassword is the shared secret used to authenticate SCEP enrollment requests. + // Clients include this in the PKCS#10 CSR challengePassword attribute. + // + // REQUIRED when Enabled is true. Config.Validate() below refuses to start the + // server if SCEP is enabled and this value is empty (H-2, CWE-306): post-M-001 + // under option (D), the /scep endpoint rides the no-auth middleware chain per + // RFC 8894 §3.2, so the challenge password is the sole application-layer + // authentication boundary for SCEP enrollment. An empty shared secret would + // allow any client that can reach /scep to enroll a CSR against the configured + // issuer. The service-layer PKCSReq path also rejects this configuration + // defense-in-depth. + // + // Legacy single-profile field; merged into Profiles[0].ChallengePassword by + // ConfigLoad when Profiles is empty. + ChallengePassword string + + // RACertPath is the path to a PEM-encoded RA (Registration Authority) + // certificate used by the RFC 8894 SCEP path. SCEP clients encrypt their + // PKCS#10 CSR to this cert's public key (via the EnvelopedData wrapper, RFC + // 8894 §3.2.2). The certctl server uses RAKeyPath to decrypt inbound + // EnvelopedData and to sign outbound CertRep PKIMessage signerInfo (RFC + // 8894 §3.3.2). + // + // Required when Enabled is true; Config.Validate() refuses to start without + // it. Without an RA pair the new RFC 8894 path silently falls through to + // the MVP raw-CSR path on every request and the operator's intent is + // unclear — fail loud at startup instead. + // + // Generation: a self-signed RA cert with subject "CN=-RA" and + // the id-kp-emailProtection / id-kp-cmcRA EKU is sufficient. The RA cert + // SHOULD be the same cert returned by GetCACert (RFC 8894 §3.5.1) so + // clients encrypt to a key the server can decrypt with. See + // docs/legacy-est-scep.md for the openssl recipe. + RACertPath string + + // RAKeyPath is the path to the PEM-encoded private key matching RACertPath. + // File MUST be mode 0600 (owner read/write only); preflight refuses to load + // a world-readable RA key as defense-in-depth against credential leak. The + // server only ever reads this file at startup; rotation requires a restart + // (per the existing CERTCTL_TLS_CERT_PATH precedent in cmd/server/tls.go). + // + // Legacy single-profile field; merged into Profiles[0].RAKeyPath by + // ConfigLoad when Profiles is empty. + RAKeyPath string +} + +// SCEPProfileConfig is one SCEP endpoint's configuration. Each profile is +// bound to one issuer + one optional certctl CertificateProfile + one RA +// pair + one challenge password (the per-profile Intune trust anchor lands +// here in Phase 8 of the master bundle). +// +// Multi-profile motivation: a real enterprise deployment exposes distinct +// SCEP endpoints to distinct fleets — corp-laptop CA bound to one issuer +// with one challenge password; IoT CA bound to a different issuer with a +// different challenge password — so a single set of credentials can never +// enroll across CA boundaries by accident. Each SCEPProfileConfig drives +// a separate handler + service instance built at server startup. +type SCEPProfileConfig struct { + // PathID is the URL segment after /scep/. Empty string maps to the legacy + // /scep root for backward compatibility (so existing operators with the + // flat single-profile 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", "server" — the URL becomes + // /scep/corp, /scep/iot, /scep/server. + PathID string + + // IssuerID selects which issuer connector this profile's enrollments go + // through. Must reference a configured issuer. + IssuerID string + + // ProfileID optionally constrains enrollments under this PathID to a + // specific CertificateProfile. Leave empty to allow the issuer's defaults. + ProfileID string + + // ChallengePassword is the per-profile shared secret. Same constant-time + // compare semantics as the flat field; empty value at validate time fails + // the boot. + ChallengePassword string + + // RACertPath / RAKeyPath are the per-profile RA pair used by the RFC 8894 + // EnvelopedData decryption + CertRep signing path. Same preflight semantics + // as the legacy flat fields (file existence, key mode 0600, cert/key + // match, expiry, RSA-or-ECDSA alg). + RACertPath string + RAKeyPath string + + // MTLSEnabled gates the sibling `/scep-mtls/` route. When true, + // the route requires a client cert that chains to one of the certs in + // MTLSClientCATrustBundlePath. The standard `/scep[/]` route + // remains application-layer-auth (challenge password) so existing + // clients keep working — mTLS is additive, not replacement. + // + // SCEP RFC 8894 + Intune master bundle Phase 6.5: 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 AND keeps + // the challenge password (defense in depth, not replacement). Devices + // present a bootstrap cert from a trusted CA (e.g. a manufacturing-time + // cert), then SCEP-enroll for their long-lived cert. Same model Apple's + // MDM and Cisco's BRSKI use. + MTLSEnabled bool + + // MTLSClientCATrustBundlePath is the PEM bundle of CA certs that sign + // the client (device-bootstrap) certs the operator allows to enroll. + // Required when MTLSEnabled is true. Operators with multiple bootstrap + // CAs concatenate them. Validated at startup by + // `cmd/server/main.go::preflightSCEPMTLSTrustBundle` — file exists, + // parses as PEM, contains ≥1 cert, none expired. + MTLSClientCATrustBundlePath string + + // Intune is the per-profile Microsoft Intune Certificate Connector + // integration block. When Enabled is false (default), this profile only + // honors the static ChallengePassword; when true, requests with an + // Intune-shaped challenge password (length + dot-count heuristic) are + // routed to the Intune dynamic-challenge validator. + // + // SCEP RFC 8894 + Intune master bundle Phase 8.8: per-profile dispatch + // is what makes the heterogeneous-fleet story work — an operator + // running corp-laptops via Intune AND IoT devices via static challenge + // configures Intune-mode on the corp profile only; the IoT profile's + // PKCSReq path skips the Intune dispatcher entirely. + Intune SCEPIntuneProfileConfig +} + +// SCEPIntuneProfileConfig is the per-profile Microsoft Intune Certificate +// Connector integration sub-block on SCEPProfileConfig. +// +// SCEP RFC 8894 + Intune master bundle Phase 8.1. +// +// All fields here are populated from CERTCTL_SCEP_PROFILE__INTUNE_* +// env vars (e.g. CERTCTL_SCEP_PROFILE_CORP_INTUNE_ENABLED=true). Per-profile +// overrides means an operator with two Intune-backed profiles (corp + iot, +// say) can pin distinct Connectors + audiences + rate limits per fleet. +type SCEPIntuneProfileConfig struct { + // Enabled gates the Intune dynamic-challenge validation path. When + // false (default), this profile honors only the static ChallengePassword. + // When true, ConnectorCertPath becomes a required boot gate. + Enabled bool + + // ConnectorCertPath is the filesystem path to a PEM bundle of one or + // more Microsoft Intune Certificate Connector signing certs. Required + // when Enabled=true. Reloaded on SIGHUP via the per-profile + // TrustAnchorHolder wired in cmd/server/main.go. + ConnectorCertPath string + + // Audience is the expected "aud" claim value in the Intune challenge — + // typically the public SCEP endpoint URL the Connector is configured to + // call (e.g. "https://certctl.example.com/scep/corp"). Defaults to + // empty (audience check disabled) for proxy / load-balancer scenarios + // where the URL the Connector saw isn't the URL we see; operators + // who pin a public URL here gain defense-in-depth against challenge + // re-use across endpoints. + Audience string + + // ChallengeValidity caps the maximum age of an Intune challenge, on + // top of the challenge's own iat/exp claims. Default 60 minutes per + // Microsoft's published Connector defaults — operators may want a + // stricter cap to reduce the replay-window exposure on a stolen + // challenge. Zero means "use Connector's exp claim only" (no extra cap). + ChallengeValidity time.Duration + + // PerDeviceRateLimit24h caps the number of enrollments per + // (claim.Subject, claim.Issuer) pair in any rolling 24-hour window. + // Default 3 (covers legitimate first-cert + recovery + post-wipe + // re-enrollment, blocks bulk-enumeration from a compromised Connector + // signing key). Zero means "unlimited" (defense-in-depth disabled; + // not recommended for production). + PerDeviceRateLimit24h int + + // ClockSkewTolerance widens the iat/exp validation window by + // ±|tolerance| to absorb modest clock drift between the Microsoft + // Intune Certificate Connector and the certctl host. Default 60s + // per master prompt §15 ("known hazards"). Operators on tightly + // time-synced fleets can set this to zero to enforce strict + // iat/exp checks; operators on loosely synced fleets (e.g. field + // devices with no NTP) may raise to 5m. Validate() refuses any + // tolerance ≥ ChallengeValidity (which would make the per-profile + // validity cap meaningless). Source env var: + // CERTCTL_SCEP_PROFILE__INTUNE_CLOCK_SKEW_TOLERANCE. + ClockSkewTolerance time.Duration +} + +// loadSCEPProfilesFromEnv reads the indexed CERTCTL_SCEP_PROFILES env var +// (e.g. "corp,iot,server") and expands each name into a SCEPProfileConfig +// populated from CERTCTL_SCEP_PROFILE__*. Returns nil when the +// CERTCTL_SCEP_PROFILES env var is unset or empty — in that case the +// legacy-shim path (mergeSCEPLegacyIntoProfiles, 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_SCEP_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. +func loadSCEPProfilesFromEnv() []SCEPProfileConfig { + raw := strings.TrimSpace(os.Getenv("CERTCTL_SCEP_PROFILES")) + if raw == "" { + return nil + } + names := strings.Split(raw, ",") + out := make([]SCEPProfileConfig, 0, len(names)) + for _, n := range names { + n = strings.TrimSpace(n) + if n == "" { + continue + } + // The env-var key is the upper-cased name (CERTCTL_SCEP_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, SCEPProfileConfig{ + PathID: pathID, + IssuerID: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_ISSUER_ID", ""), + ProfileID: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_PROFILE_ID", ""), + ChallengePassword: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_CHALLENGE_PASSWORD", ""), + RACertPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_CERT_PATH", ""), + RAKeyPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_RA_KEY_PATH", ""), + // SCEP RFC 8894 Phase 6.5: opt-in mTLS sibling route. + MTLSEnabled: getEnvBool("CERTCTL_SCEP_PROFILE_"+envName+"_MTLS_ENABLED", false), + MTLSClientCATrustBundlePath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH", ""), + // SCEP RFC 8894 Phase 8.1: per-profile Intune Connector dispatch. + Intune: SCEPIntuneProfileConfig{ + Enabled: getEnvBool("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_ENABLED", false), + ConnectorCertPath: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_CONNECTOR_CERT_PATH", ""), + Audience: getEnv("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_AUDIENCE", ""), + ChallengeValidity: getEnvDuration("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_CHALLENGE_VALIDITY", 60*time.Minute), + PerDeviceRateLimit24h: getEnvInt("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_PER_DEVICE_RATE_LIMIT_24H", 3), + ClockSkewTolerance: getEnvDuration("CERTCTL_SCEP_PROFILE_"+envName+"_INTUNE_CLOCK_SKEW_TOLERANCE", 60*time.Second), + }, + }) + } + return out +} + +// mergeSCEPLegacyIntoProfiles is the backward-compat shim. When Profiles is +// empty AND any legacy single-profile field is populated, synthesise a +// single-element Profiles[0] with PathID="" so /scep dispatches identically +// to the pre-Phase-1.5 deploy. No-op when Profiles is non-empty (the operator +// explicitly opted into the structured form via CERTCTL_SCEP_PROFILES) or +// when SCEP is disabled. +// +// "Any legacy field populated" means at least one of ChallengePassword, +// RACertPath, RAKeyPath is non-empty. IssuerID has a non-empty default +// ("iss-local") so it can't be the trigger; ProfileID is optional. The +// trigger set matches what the Validate() refuse cares about. +func mergeSCEPLegacyIntoProfiles(c *SCEPConfig) { + if c == nil || !c.Enabled || len(c.Profiles) > 0 { + return + } + hasLegacy := c.ChallengePassword != "" || c.RACertPath != "" || c.RAKeyPath != "" + if !hasLegacy { + return + } + c.Profiles = []SCEPProfileConfig{{ + PathID: "", // empty pathID maps to the legacy /scep root + IssuerID: c.IssuerID, + ProfileID: c.ProfileID, + ChallengePassword: c.ChallengePassword, + RACertPath: c.RACertPath, + RAKeyPath: c.RAKeyPath, + }} +} + +// validSCEPPathID reports whether s is a valid SCEP profile path segment. +// The empty string is allowed (legacy root /scep). Non-empty values must +// be ASCII lowercase letters / digits / hyphens with no leading/trailing +// hyphen — keeps URL-construction trivial at the router layer and avoids +// percent-encoding surprises for SCEP clients that build the URL by string +// concat rather than url.PathEscape. +func validSCEPPathID(s string) bool { + if s == "" { + return true // empty maps to legacy /scep 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 +}