From 46e17c56fbaf11b0e1a328709377727ef84c3764 Mon Sep 17 00:00:00 2001 From: Shankar Date: Wed, 29 Apr 2026 01:48:23 +0000 Subject: [PATCH] main: wire CRL/OCSP responder services into runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activates the CRL/OCSP responder pipeline that landed dormant in phases 1-4 (commits dc44826, 6d1da84, ff20fba, c76bfcf): * IssuerRegistry gains SetLocalIssuerDeps + LocalIssuerDeps struct. Rebuild type-asserts each constructed connector to *local.Connector and injects ocspResponderRepo + signerDriver + IssuerID + key dir + (optional) rotation-grace + validity overrides. Non-local connectors are unaffected (the type-assert fails silently). Adapter pattern preserved: callers still see service.IssuerConnector. * cmd/server/main.go: - constructs CRLCacheRepository + OCSPResponderRepository from db - constructs signer.FileDriver (default; PKCS#11 driver plugs in later via the same Driver interface, no main.go changes needed) - calls issuerRegistry.SetLocalIssuerDeps(...) BEFORE BuildRegistry so the deps are in place when local connectors are constructed - wires CRLCacheService into CertificateService via SetCRLCacheSvc (Phase 4 cache-aware GenerateDERCRL path now active) - calls scheduler.SetCRLCacheService + SetCRLGenerationInterval after sched is constructed; logs the interval at startup * config: new OCSPResponderConfig struct + Scheduler.CRLGenerationInterval field. Three new env vars: CERTCTL_OCSP_RESPONDER_KEY_DIR (no default; operator MUST set in prod) CERTCTL_OCSP_RESPONDER_ROTATION_GRACE (default 7d) CERTCTL_OCSP_RESPONDER_VALIDITY (default 30d) CERTCTL_CRL_GENERATION_INTERVAL (default 1h) Backward compat: when env vars are unset, the responder bootstrap path still activates (with default rotation grace + validity, key dir = cwd which is fine for tests), and the CRL cache pre-populates on the 1h interval. Operators not running the local issuer see no behavior change. go vet clean across the full module. Targeted tests for config + service + scheduler packages all green. Full module build deferred to CI (sandbox /sessions disk pressure prevented unzipping a transitive dep — same disk-full pattern the prior commits hit; not a code issue). --- cmd/server/main.go | 89 +++++++++++++++++++++-------- internal/config/config.go | 46 +++++++++++++++ internal/service/issuer_registry.go | 63 ++++++++++++++++++++ 3 files changed, 175 insertions(+), 23 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 52553d8..955c544 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -25,6 +25,7 @@ import ( notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty" notifyslack "github.com/shankar0123/certctl/internal/connector/notifier/slack" notifyteams "github.com/shankar0123/certctl/internal/connector/notifier/teams" + "github.com/shankar0123/certctl/internal/crypto/signer" "github.com/shankar0123/certctl/internal/domain" "github.com/shankar0123/certctl/internal/repository/postgres" "github.com/shankar0123/certctl/internal/scheduler" @@ -288,9 +289,38 @@ func main() { caOperationsSvc := service.NewCAOperationsSvc(revocationRepo, certificateRepo, profileRepo) caOperationsSvc.SetIssuerRegistry(issuerRegistry) + // Bundle CRL/OCSP-Responder: wire CRL cache + OCSP responder + // repositories. The CRL cache lets the HTTP CRL endpoint serve from + // pre-generated bytes (Phase 3). The OCSP responder repo lets the + // local issuer bootstrap a dedicated responder cert per RFC 6960 + // §2.6 instead of signing OCSP with the CA key directly (Phase 2). + // + // The signer.FileDriver is the production driver; it provides keys + // to the responder bootstrap path. Future drivers (PKCS#11, cloud + // KMS) plug in via the same Driver interface without changing this + // wiring. The DirHardener / Marshaler hooks stay nil here — the + // bootstrap path's GenerateOutPath sets the destination per + // responder; the local issuer's existing keystore.ensureKeyDirSecure + // equivalent is invoked by FileDriver.Generate when DirHardener is + // supplied at the call site. + crlCacheRepo := postgres.NewCRLCacheRepository(db) + ocspResponderRepo := postgres.NewOCSPResponderRepository(db) + signerDriver := &signer.FileDriver{} + issuerRegistry.SetLocalIssuerDeps(&service.LocalIssuerDeps{ + OCSPResponderRepo: ocspResponderRepo, + SignerDriver: signerDriver, + KeyDir: cfg.OCSPResponder.KeyDir, + RotationGrace: cfg.OCSPResponder.RotationGrace, + Validity: cfg.OCSPResponder.Validity, + }) + crlCacheService := service.NewCRLCacheService(crlCacheRepo, caOperationsSvc, issuerRegistry, logger) + // Wire sub-services into CertificateService certificateService.SetRevocationSvc(revocationSvc) certificateService.SetCAOperationsSvc(caOperationsSvc) + // CRL cache makes GenerateDERCRL serve from the pre-generated cache + // instead of regenerating per request (CRL/OCSP-Responder Phase 4). + certificateService.SetCRLCacheSvc(crlCacheService) certificateService.SetTargetRepo(targetRepo) certificateService.SetJobRepo(jobRepo) certificateService.SetKeygenMode(cfg.Keygen.Mode) @@ -570,6 +600,19 @@ func main() { // here alongside the other scheduler-interval setters so the // documented env var actually takes effect. sched.SetShortLivedExpiryCheckInterval(cfg.Scheduler.ShortLivedExpiryCheckInterval) + + // CRL/OCSP-Responder Phase 3: drive the crlGenerationLoop. The cache + // service walks every issuer in the registry, regenerates the CRL, + // and persists into crl_cache. The HTTP /.well-known/pki/crl/ handler + // reads from the cache via certificateService.GenerateDERCRL (which + // consults crlCacheService when wired). The loop is gated on the + // service being non-nil, mirroring how digestService and others are + // wired conditionally below. + sched.SetCRLCacheService(crlCacheService) + sched.SetCRLGenerationInterval(cfg.Scheduler.CRLGenerationInterval) + logger.Info("CRL pre-generation scheduler enabled", + "interval", cfg.Scheduler.CRLGenerationInterval.String()) + if cfg.NetworkScan.Enabled { sched.SetNetworkScanInterval(cfg.NetworkScan.ScanInterval) logger.Info("network scanning enabled", "interval", cfg.NetworkScan.ScanInterval.String()) @@ -611,28 +654,28 @@ func main() { // Build the API router with all handlers apiRouter := router.New() apiRouter.RegisterHandlers(router.HandlerRegistry{ - Certificates: certificateHandler, - Issuers: issuerHandler, - Targets: targetHandler, - Agents: agentHandler, - Jobs: jobHandler, - Policies: policyHandler, - RenewalPolicies: renewalPolicyHandler, - Profiles: profileHandler, - Teams: teamHandler, - Owners: ownerHandler, - AgentGroups: agentGroupHandler, - Audit: auditHandler, - Notifications: notificationHandler, - Stats: statsHandler, - Metrics: metricsHandler, - Health: healthHandler, - Discovery: discoveryHandler, - NetworkScan: networkScanHandler, - Verification: verificationHandler, - Export: exportHandler, - Digest: *digestHandler, - HealthChecks: healthCheckHandler, + Certificates: certificateHandler, + Issuers: issuerHandler, + Targets: targetHandler, + Agents: agentHandler, + Jobs: jobHandler, + Policies: policyHandler, + RenewalPolicies: renewalPolicyHandler, + Profiles: profileHandler, + Teams: teamHandler, + Owners: ownerHandler, + AgentGroups: agentGroupHandler, + Audit: auditHandler, + Notifications: notificationHandler, + Stats: statsHandler, + Metrics: metricsHandler, + Health: healthHandler, + Discovery: discoveryHandler, + NetworkScan: networkScanHandler, + Verification: verificationHandler, + Export: exportHandler, + Digest: *digestHandler, + HealthChecks: healthCheckHandler, BulkRevocation: bulkRevocationHandler, BulkRenewal: bulkRenewalHandler, BulkReassignment: bulkReassignmentHandler, @@ -1104,7 +1147,7 @@ func preflightEnrollmentIssuer(ctx context.Context, protocol, issuerID string, i // - /api/v1/* → auth (Bearer token required) // - /assets/* → static file server (dashboard only) // - anything else → SPA index.html fallback (dashboard only) -// OR apiHandler (no dashboard) +// OR apiHandler (no dashboard) // // EST/SCEP clients (IoT devices, 802.1X supplicants, MDM endpoints, network // appliances) cannot present certctl Bearer tokens, so those endpoints must be diff --git a/internal/config/config.go b/internal/config/config.go index 11cda05..42a1d2b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,34 @@ type Config struct { HealthCheck HealthCheckConfig Encryption EncryptionConfig CloudDiscovery CloudDiscoveryConfig + OCSPResponder OCSPResponderConfig +} + +// OCSPResponderConfig configures the dedicated OCSP-responder cert +// per issuer (RFC 6960 §2.6 + §4.2.2.2). When unset, the local issuer +// falls back to signing OCSP responses with the CA key directly. +// +// Bundle CRL/OCSP-Responder Phase 2. +type OCSPResponderConfig struct { + // KeyDir is the filesystem directory where FileDriver-backed + // responder keys are written. Operators MUST set this in + // production (the default of "" maps to cwd, which is fine for + // tests but not for serious deployments). + // Setting: CERTCTL_OCSP_RESPONDER_KEY_DIR. + KeyDir string + + // RotationGrace is the window before NotAfter at which the + // responder cert is rotated. Default: 7 days. Operators with + // stricter relying-party caching expectations may shorten; + // operators with looser ones may lengthen. + // Setting: CERTCTL_OCSP_RESPONDER_ROTATION_GRACE. + RotationGrace time.Duration + + // Validity is how long a freshly-bootstrapped responder cert is + // valid for. Default: 30 days. Shorter validity means more + // frequent rotations + smaller revocation-list windows. + // Setting: CERTCTL_OCSP_RESPONDER_VALIDITY. + Validity time.Duration } // AWSACMPCAConfig contains AWS ACM Private CA issuer connector configuration. @@ -806,6 +834,14 @@ type SchedulerConfig struct { // had no path. Post-C-1 main.go wires this knob. // Setting: CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL environment variable. ShortLivedExpiryCheckInterval time.Duration + + // CRLGenerationInterval is how often the scheduler pre-generates + // CRLs into the crl_cache table. The /.well-known/pki/crl/{issuer_id} + // HTTP endpoint reads from this cache instead of regenerating per + // request. Default: 1 hour. + // Setting: CERTCTL_CRL_GENERATION_INTERVAL environment variable. + // Bundle CRL/OCSP-Responder Phase 3. + CRLGenerationInterval time.Duration } // LogConfig contains logging configuration. @@ -1015,6 +1051,11 @@ func Load() (*Config, error) { // C-1 closure: matches the in-memory default at // internal/scheduler/scheduler.go:145 (30 * time.Second). ShortLivedExpiryCheckInterval: getEnvDuration("CERTCTL_SHORT_LIVED_EXPIRY_CHECK_INTERVAL", 30*time.Second), + // CRL/OCSP-Responder Phase 3: pre-generation cadence. + // Default 1h matches the in-scheduler default; relying-party + // CRL refresh expectations under RFC 5280 are typically + // hourly to daily, so 1h gives operators plenty of margin. + CRLGenerationInterval: getEnvDuration("CERTCTL_CRL_GENERATION_INTERVAL", 1*time.Hour), }, Log: LogConfig{ Level: getEnv("CERTCTL_LOG_LEVEL", "info"), @@ -1194,6 +1235,11 @@ func Load() (*Config, error) { Credentials: getEnv("CERTCTL_GCP_SM_CREDENTIALS", ""), }, }, + OCSPResponder: OCSPResponderConfig{ + KeyDir: getEnv("CERTCTL_OCSP_RESPONDER_KEY_DIR", ""), + RotationGrace: getEnvDuration("CERTCTL_OCSP_RESPONDER_ROTATION_GRACE", 7*24*time.Hour), + Validity: getEnvDuration("CERTCTL_OCSP_RESPONDER_VALIDITY", 30*24*time.Hour), + }, } // Parse CERTCTL_API_KEYS_NAMED for named key authentication (M-002). diff --git a/internal/service/issuer_registry.go b/internal/service/issuer_registry.go index fdc5647..f7f5e8c 100644 --- a/internal/service/issuer_registry.go +++ b/internal/service/issuer_registry.go @@ -5,10 +5,14 @@ import ( "fmt" "log/slog" "sync" + "time" + "github.com/shankar0123/certctl/internal/connector/issuer/local" "github.com/shankar0123/certctl/internal/connector/issuerfactory" "github.com/shankar0123/certctl/internal/crypto" + "github.com/shankar0123/certctl/internal/crypto/signer" "github.com/shankar0123/certctl/internal/domain" + "github.com/shankar0123/certctl/internal/repository" ) // IssuerRegistry is a thread-safe registry of issuer connectors. @@ -18,6 +22,29 @@ type IssuerRegistry struct { mu sync.RWMutex issuers map[string]IssuerConnector logger *slog.Logger + + // localDeps, when set, is injected into every *local.Connector + // constructed by Rebuild via SetOCSPResponderRepo + SetSignerDriver + // + SetIssuerID + SetOCSPResponderKeyDir. Wires the dedicated OCSP + // responder cert flow (RFC 6960 §2.6); see Bundle CRL/OCSP-Responder + // Phase 2. When unset, local connectors fall back to signing OCSP + // with the CA key directly (the historical behaviour, preserved for + // callers that don't supply these deps). + localDeps *LocalIssuerDeps +} + +// LocalIssuerDeps groups the optional dependencies that the local +// issuer needs for the dedicated OCSP responder cert flow. All fields +// are required when localDeps is set on the registry; nil-checking +// individual fields would partially-initialize the responder path +// which is worse than the all-or-nothing fallback to direct CA-key +// signing. +type LocalIssuerDeps struct { + OCSPResponderRepo repository.OCSPResponderRepository + SignerDriver signer.Driver + KeyDir string // where FileDriver-backed responder keys land + RotationGrace time.Duration // optional override; default 7d if zero + Validity time.Duration // optional override; default 30d if zero } // NewIssuerRegistry creates a new empty issuer registry. @@ -28,6 +55,17 @@ func NewIssuerRegistry(logger *slog.Logger) *IssuerRegistry { } } +// SetLocalIssuerDeps configures the per-local-connector dependencies +// applied by Rebuild. Must be called before BuildRegistry / Rebuild +// so the deps are in place when local connectors are constructed. +// +// Bundle CRL/OCSP-Responder Phase 2. +func (r *IssuerRegistry) SetLocalIssuerDeps(deps *LocalIssuerDeps) { + r.mu.Lock() + defer r.mu.Unlock() + r.localDeps = deps +} + // Get returns the issuer connector for the given ID and whether it exists. func (r *IssuerRegistry) Get(id string) (IssuerConnector, bool) { r.mu.RLock() @@ -109,6 +147,31 @@ func (r *IssuerRegistry) Rebuild(configs []*domain.Issuer, encryptionKey string) continue } + // Bundle CRL/OCSP-Responder Phase 2: when local deps are + // configured on the registry, inject them into every freshly- + // constructed *local.Connector so its SignOCSPResponse takes + // the dedicated responder cert path. Type-assert is the + // pragmatic seam — the factory returns issuer.Connector so + // this is the only place that knows what concrete type was + // just built. + if localConn, ok := connector.(*local.Connector); ok && r.localDeps != nil { + localConn.SetIssuerID(cfg.ID) + localConn.SetOCSPResponderRepo(r.localDeps.OCSPResponderRepo) + localConn.SetSignerDriver(r.localDeps.SignerDriver) + if r.localDeps.KeyDir != "" { + localConn.SetOCSPResponderKeyDir(r.localDeps.KeyDir) + } + if r.localDeps.RotationGrace > 0 { + localConn.SetOCSPResponderRotationGrace(r.localDeps.RotationGrace) + } + if r.localDeps.Validity > 0 { + localConn.SetOCSPResponderValidity(r.localDeps.Validity) + } + r.logger.Info("local issuer wired with dedicated OCSP responder deps", + "id", cfg.ID, + "key_dir", r.localDeps.KeyDir) + } + newIssuers[cfg.ID] = NewIssuerConnectorAdapter(connector) r.logger.Info("issuer loaded into registry", "id", cfg.ID, "type", cfg.Type) }