From e4ba8d4de2cf036e25e5f9cbb40a2f178c14bf7f Mon Sep 17 00:00:00 2001 From: Shankar Date: Wed, 25 Mar 2026 15:31:06 -0400 Subject: [PATCH] feat: add EST server (RFC 7030) for device certificate enrollment (M23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Enrollment over Secure Transport protocol with 4 endpoints under /.well-known/est/ — cacerts (CA chain distribution), simpleenroll (initial enrollment), simplereenroll (certificate renewal), and csrattrs (CSR attributes). PKCS#7 certs-only wire format with hand-rolled ASN.1, accepts both PEM and base64-encoded DER CSRs, configurable issuer and profile binding, full audit trail. 28 new tests (18 handler + 10 service). Also includes: - GetCACertPEM added to issuer connector interface (all 4 issuers updated) - EST integration tests wired into e2e test suite (13 test cases) - QA testing guide Part 26 (15 manual EST test cases) - All docs updated: README, features, architecture, concepts, connectors, quickstart, demo-advanced (endpoint counts, MCP wording, agent IDs, issuer interface, resource lists, OpenSSL status) Co-Authored-By: Claude Opus 4.6 --- README.md | 20 +- cmd/server/main.go | 24 +- docs/architecture.md | 48 ++- docs/concepts.md | 16 +- docs/connectors.md | 16 + docs/demo-advanced.md | 10 +- docs/features.md | 37 +- docs/quickstart.md | 6 +- docs/testing-guide.md | 242 ++++++++++- internal/api/handler/est.go | 404 +++++++++++++++++++ internal/api/handler/est_handler_test.go | 369 +++++++++++++++++ internal/api/router/router.go | 10 + internal/config/config.go | 14 + internal/connector/issuer/acme/acme.go | 5 + internal/connector/issuer/interface.go | 4 + internal/connector/issuer/local/local.go | 9 + internal/connector/issuer/openssl/openssl.go | 5 + internal/connector/issuer/stepca/stepca.go | 5 + internal/domain/est.go | 7 + internal/integration/e2e_test.go | 219 ++++++++++ internal/integration/lifecycle_test.go | 5 + internal/integration/negative_test.go | 5 + internal/service/est.go | 153 +++++++ internal/service/est_test.go | 180 +++++++++ internal/service/issuer_adapter.go | 5 + internal/service/renewal.go | 2 + internal/service/testutil_test.go | 7 + 27 files changed, 1807 insertions(+), 20 deletions(-) create mode 100644 internal/api/handler/est.go create mode 100644 internal/api/handler/est_handler_test.go create mode 100644 internal/domain/est.go create mode 100644 internal/service/est.go create mode 100644 internal/service/est_test.go diff --git a/README.md b/README.md index 3fb2392..9ce413b 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ It's also **target-agnostic**. Agents deploy certificates to NGINX, Apache, and ## What It Does -certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (91 endpoints under `/api/v1/`) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally, discover existing certificates on disk, and submit CSRs — private keys never leave your servers. The **network scanner** discovers certificates on TLS endpoints across your infrastructure without requiring agents. The background scheduler watches expiration dates and triggers renewals automatically — when certificate lifespans drop to 47 days, certctl handles the constant rotation without human involvement. +certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (95 endpoints under `/api/v1/` + `/.well-known/est/`) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally, discover existing certificates on disk, and submit CSRs — private keys never leave your servers. The **network scanner** discovers certificates on TLS endpoints across your infrastructure without requiring agents. The **EST server** (RFC 7030) enables device and WiFi certificate enrollment via industry-standard Enrollment over Secure Transport. The background scheduler watches expiration dates and triggers renewals automatically — when certificate lifespans drop to 47 days, certctl handles the constant rotation without human involvement. **Core capabilities:** @@ -81,6 +81,7 @@ certctl gives you a single pane of glass for every TLS certificate in your organ - **Operational dashboard** — Full React GUI with certificate inventory, bulk operations (multi-select renew/revoke/reassign), deployment timeline visualization, inline policy editing, agent fleet overview, expiration heatmaps, and real-time short-lived credential tracking. - **Observability** — JSON and Prometheus metrics endpoints, 5 stats API endpoints for dashboards, structured slog logging with request ID propagation. Compatible with Prometheus, Grafana Agent, Datadog Agent, and Victoria Metrics. - **Notifications** — threshold-based alerting with deduplication. Routes to email, webhooks, Slack, Microsoft Teams, PagerDuty, and OpsGenie. +- **EST enrollment (RFC 7030)** — built-in Enrollment over Secure Transport server for device certificate enrollment. Supports WiFi/802.1X, MDM, and IoT use cases. PKCS#7 certs-only wire format, accepts PEM or base64-encoded DER CSRs, configurable issuer and profile binding. - **AI and CLI access** — MCP server exposes all 78 API operations as tools for Claude, Cursor, and any MCP-compatible client. CLI tool with 12 subcommands for terminal workflows and scripting. ```mermaid @@ -270,6 +271,9 @@ All server environment variables use the `CERTCTL_` prefix: | `CERTCTL_OPENSSL_TIMEOUT_SECONDS` | `30` | Timeout for OpenSSL script execution | | `CERTCTL_NETWORK_SCAN_ENABLED` | `false` | Enable server-side network certificate discovery (TLS scanning) | | `CERTCTL_NETWORK_SCAN_INTERVAL` | `6h` | How often the scheduler runs network scans | +| `CERTCTL_EST_ENABLED` | `false` | Enable EST (RFC 7030) enrollment endpoints under /.well-known/est/ | +| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Issuer connector ID used for EST certificate enrollment | +| `CERTCTL_EST_PROFILE_ID` | — | Optional certificate profile ID to constrain EST enrollments | | `CERTCTL_SLACK_WEBHOOK_URL` | — | Slack incoming webhook URL for notifications | | `CERTCTL_TEAMS_WEBHOOK_URL` | — | Microsoft Teams incoming webhook URL | | `CERTCTL_PAGERDUTY_ROUTING_KEY` | — | PagerDuty Events API v2 routing key | @@ -491,6 +495,14 @@ GET /api/v1/auth/info Auth mode info (no auth required) GET /api/v1/auth/check Validate credentials ``` +### EST Enrollment (RFC 7030) +``` +GET /.well-known/est/cacerts CA certificate chain (PKCS#7 certs-only) +POST /.well-known/est/simpleenroll Simple enrollment (PEM or base64-DER CSR) +POST /.well-known/est/simplereenroll Simple re-enrollment (certificate renewal) +GET /.well-known/est/csrattrs CSR attributes request +``` + ### Health ``` GET /health Server health check @@ -599,11 +611,15 @@ All nine development milestones (M1–M9) are complete. The backend covers the f - **M18b: Filesystem Cert Discovery** ✅ — agents scan configured directories (PEM/DER), report findings to control plane, deduplication by SHA-256 fingerprint, claim/dismiss/triage workflow via API - **M21: Network Cert Discovery** ✅ — server-side active TLS scanning of CIDR ranges and ports, concurrent probing (50 goroutines), CIDR expansion with /20 safety cap, sentinel agent pattern for discovery pipeline reuse, CRUD API for scan targets, scheduler integration (6h default) - **M22: Prometheus Metrics** ✅ — `GET /api/v1/metrics/prometheus` returns Prometheus exposition format (`text/plain; version=0.0.4`), 11 metrics with `certctl_` prefix, compatible with Prometheus, Grafana Agent, Datadog Agent, Victoria Metrics +- **M23: EST Server (RFC 7030)** ✅ — Enrollment over Secure Transport for device/WiFi certificate enrollment, 4 endpoints under /.well-known/est/, PKCS#7 certs-only wire format, base64-encoded DER CSR input, configurable issuer + profile binding, audit trail, 28 new tests - **Compliance Mapping** ✅ — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 capability mapping documentation -### V3: Team & Enterprise +### V3: certctl Pro + Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views, and premium CA integrations. +> **Need SSO, RBAC, F5/IIS deployment, or real-time fleet operations?** [Join the certctl Pro waitlist](https://forms.gle/YOUR_FORM_ID) — early access shipping Q2 2026. + ### V4+: Cloud, Scale & Passive Discovery Passive network discovery (TLS listener), Kubernetes integration, cloud infrastructure targets (AWS ALB/ACM, Azure Key Vault), extended CA support, and platform-scale features. diff --git a/cmd/server/main.go b/cmd/server/main.go index a269624..7a2d17e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -302,6 +302,25 @@ func main() { discoveryHandler, networkScanHandler, ) + // Register EST (RFC 7030) handlers if enabled + if cfg.EST.Enabled { + issuerConn, ok := issuerRegistry[cfg.EST.IssuerID] + if !ok { + logger.Error("EST issuer not found in registry", "issuer_id", cfg.EST.IssuerID) + os.Exit(1) + } + estService := service.NewESTService(cfg.EST.IssuerID, issuerConn, auditService, logger) + if cfg.EST.ProfileID != "" { + estService.SetProfileID(cfg.EST.ProfileID) + } + estHandler := handler.NewESTHandler(estService) + apiRouter.RegisterESTHandlers(estHandler) + logger.Info("EST server enabled", + "issuer_id", cfg.EST.IssuerID, + "profile_id", cfg.EST.ProfileID, + "endpoints", "/.well-known/est/{cacerts,simpleenroll,simplereenroll,csrattrs}") + } + logger.Info("registered all API handlers") // Build middleware stack @@ -380,9 +399,10 @@ func main() { fileServer := http.FileServer(http.Dir(webDir)) finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path - // API and health routes go to the API handler + // API, health, and EST routes go to the API handler if path == "/health" || path == "/ready" || - (len(path) >= 8 && path[:8] == "/api/v1/") { + (len(path) >= 8 && path[:8] == "/api/v1/") || + (len(path) >= 16 && path[:16] == "/.well-known/est") { apiHandler.ServeHTTP(w, r) return } diff --git a/docs/architecture.md b/docs/architecture.md index 0d0658e..655243d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -521,10 +521,13 @@ type Connector interface { RenewCertificate(ctx context.Context, request RenewalRequest) (*IssuanceResult, error) RevokeCertificate(ctx context.Context, request RevocationRequest) error GetOrderStatus(ctx context.Context, orderID string) (*OrderStatus, error) + GenerateCRL(ctx context.Context, revokedCerts []RevokedCertEntry) ([]byte, error) + SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error) + GetCACertPEM(ctx context.Context) (string, error) } ``` -Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01 and DNS-01 challenges, compatible with Let's Encrypt, Sectigo, and any ACME-compliant CA), and **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance, order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks), order finalization, and DER-to-PEM chain conversion. +Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01 and DNS-01 challenges, compatible with Let's Encrypt, Sectigo, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance, order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks), order finalization, and DER-to-PEM chain conversion. The interface also includes `GetCACertPEM(ctx)` for CA chain distribution (used by the EST server's `/cacerts` endpoint). ### Target Connector @@ -560,6 +563,45 @@ Built-in notifiers: **Email** (SMTP), **Webhook** (HTTP POST), **Slack** (incomi See the [Connector Development Guide](connectors.md) for details on building custom connectors. +### EST Server (RFC 7030) + +The EST (Enrollment over Secure Transport) server provides an industry-standard enrollment interface for devices that need certificates without using the REST API. It runs under `/.well-known/est/` per RFC 7030 and supports four operations: CA certificate distribution (`/cacerts`), initial enrollment (`/simpleenroll`), re-enrollment (`/simplereenroll`), and CSR attributes (`/csrattrs`). + +**Architecture:** EST is a handler-level protocol that delegates certificate issuance to an existing `IssuerConnector`. This means EST is not a new issuer — it's a new *interface* to the existing issuance infrastructure. The `ESTService` bridges the `ESTHandler` to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`. + +``` +Client (WiFi AP, MDM, IoT) + │ + ▼ +ESTHandler (handler layer) + │ CSR parsing, PKCS#7 response encoding + ▼ +ESTService (service layer) + │ CSR validation, CN/SAN extraction, audit recording + ▼ +IssuerConnector (connector layer via IssuerConnectorAdapter) + │ Certificate signing (Local CA, step-ca, etc.) + ▼ +Signed certificate returned as PKCS#7 certs-only +``` + +**Wire format:** EST uses PKCS#7 (RFC 2315) certs-only degenerate SignedData for certificate responses and base64-encoded DER for CSR requests. The handler includes a hand-rolled ASN.1 PKCS#7 builder — no external PKCS#7 dependency. The CSR reader accepts both base64-encoded DER (standard EST wire format) and PEM-encoded PKCS#10 (convenience for debugging). + +**Interface:** The `ESTHandler` defines an `ESTService` interface (dependency inversion, same pattern as all other handlers): + +```go +type ESTService interface { + GetCACerts(ctx context.Context) (string, error) + SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) + SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) + GetCSRAttrs(ctx context.Context) ([]byte, error) +} +``` + +**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA connector returns its CA certificate PEM; ACME, step-ca, and OpenSSL connectors return errors (they don't expose a static CA chain — their chains are per-issuance). + +**Audit:** Every EST enrollment is recorded in the audit trail with `protocol: "EST"`, the CN, SANs, issuer ID, serial number, and optional profile ID. + ## Security Model ### Private Key Management @@ -646,9 +688,9 @@ All endpoints are under `/api/v1/` and follow consistent patterns: - **Delete**: `DELETE /api/v1/{resources}/{id}` — returns `204` (soft delete/archive) - **Actions**: `POST /api/v1/{resources}/{id}/{action}` — returns `202` for async operations -Resources: certificates, issuers, targets, agents, jobs, policies, profiles, teams, owners, agent-groups, audit, notifications. +Resources: certificates, issuers, targets, agents, jobs, policies, profiles, teams, owners, agent-groups, audit, notifications, discovered-certificates, discovery-scans, network-scan-targets, stats, metrics. -The full API is documented in an OpenAPI 3.1 specification at `api/openapi.yaml` with 93 endpoints across 19 resource domains (91 under `/api/v1/` plus `/health` and `/ready`; includes auth, 7 discovery endpoints from M18b, 6 network scan endpoints from M21, and Prometheus metrics from M22), all request/response schemas, and pagination conventions. See the [OpenAPI Guide](openapi.md) for usage with Swagger UI and SDK generation. +The full API is documented in an OpenAPI 3.1 specification at `api/openapi.yaml` with 97 endpoints across 20 resource domains (95 under `/api/v1/` + `/.well-known/est/` plus `/health` and `/ready`; includes auth, 7 discovery endpoints from M18b, 6 network scan endpoints from M21, Prometheus metrics from M22, and 4 EST enrollment endpoints from M23), all request/response schemas, and pagination conventions. See the [OpenAPI Guide](openapi.md) for usage with Swagger UI and SDK generation. Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST /api/v1/jobs/{id}/approve`, `POST /api/v1/jobs/{id}/reject`. diff --git a/docs/concepts.md b/docs/concepts.md index 056eca6..2a600ee 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -38,6 +38,14 @@ ACME (Automatic Certificate Management Environment) is the protocol Let's Encryp certctl speaks ACME natively with both HTTP-01 and DNS-01 challenges, so it can request certificates — including wildcard certificates — from Let's Encrypt or any ACME-compatible CA without manual intervention. HTTP-01 uses a built-in temporary HTTP server for domain validation; DNS-01 uses pluggable script-based hooks to create TXT records with any DNS provider (Cloudflare, Route53, Azure DNS, etc.). +### EST Protocol (Enrollment over Secure Transport) + +EST (RFC 7030) is a standard protocol for devices to request certificates from a CA. While ACME was designed for web servers proving domain ownership, EST was designed for devices that need certificates without domain validation — think WiFi access points, corporate laptops connecting to 802.1X networks, IoT devices, and mobile devices managed by MDM platforms. + +The workflow is straightforward: a device generates a key pair and a Certificate Signing Request (CSR), sends the CSR to the EST server, and gets back a signed certificate. The EST server also distributes its CA certificate chain so devices can build a complete trust path. + +certctl includes a built-in EST server at `/.well-known/est/` with four operations: distributing the CA certificate chain (`/cacerts`), enrolling new devices (`/simpleenroll`), renewing existing certificates (`/simplereenroll`), and advertising CSR requirements (`/csrattrs`). EST enrollment uses the same issuer connectors as the REST API — so a certificate issued via EST and a certificate issued via the dashboard go through the same CA, appear in the same inventory, and follow the same policies. + ### Private Key Every certificate has a corresponding private key. The certificate is public — anyone can see it. The private key is secret — it's what allows your server to decrypt traffic. If someone gets your private key, they can impersonate your server. @@ -186,10 +194,16 @@ The CLI supports both table and JSON output formats (`--format table` or `--form ### MCP Server (AI Integration) -certctl includes an MCP (Model Context Protocol) server that exposes all 78 API endpoints as MCP tools. This enables AI assistants like Claude, Cursor, and other MCP-compatible tools to interact with your certificate infrastructure using natural language — "show me all expiring certificates," "revoke the VPN cert," or "what agents are offline?" +certctl includes an MCP (Model Context Protocol) server that exposes 78 MCP tools covering the REST API. This enables AI assistants like Claude, Cursor, and other MCP-compatible tools to interact with your certificate infrastructure using natural language — "show me all expiring certificates," "revoke the VPN cert," or "what agents are offline?" The MCP server is a separate binary (`cmd/mcp-server/`) that communicates via stdio transport and acts as a stateless HTTP proxy to the certctl REST API. It requires no additional infrastructure — just point it at your certctl server URL and API key. +### EST Enrollment (Device Certificates) + +certctl's EST server enables device certificate enrollment for use cases that don't fit the traditional "ops team requests a cert via API" model. When a RADIUS server is configured to use certctl for 802.1X WiFi authentication, or an MDM platform enrolls corporate devices, they use the EST protocol at `/.well-known/est/`. The EST server validates the CSR, issues a certificate via the configured issuer connector, and returns it in PKCS#7 format — the standard wire format that every EST client understands. Each enrollment is recorded in the audit trail with the protocol, common name, SANs, issuer, and serial number. + +Enable it with `CERTCTL_EST_ENABLED=true`. Optionally bind enrollments to a specific issuer (`CERTCTL_EST_ISSUER_ID`) or certificate profile (`CERTCTL_EST_PROFILE_ID`) to constrain what EST clients can request. + ### Certificate Discovery Certificate discovery is the process of automatically finding existing certificates in your infrastructure — certificates you didn't issue through certctl, possibly issued by other CAs or tools. This is essential for building a complete inventory before you can manage everything. diff --git a/docs/connectors.md b/docs/connectors.md index 598bb44..d7aaba2 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -45,6 +45,11 @@ type Connector interface { // SignOCSPResponse signs an OCSP response for the given certificate serial. // Returns nil if the issuer does not support OCSP (e.g., ACME). SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error) + + // GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer. + // Used by the EST server's /cacerts endpoint (RFC 7030). + // Returns error if the issuer doesn't provide a static CA chain (e.g., ACME, step-ca). + GetCACertPEM(ctx context.Context) (string, error) } type IssuanceRequest struct { @@ -206,6 +211,17 @@ Each issuer handles revocation differently: - **step-ca**: Calls step-ca's `/revoke` API endpoint. Clients should check step-ca's own CRL/OCSP for authoritative status. - **OpenSSL/Custom CA**: Invokes the configured revoke script (`CERTCTL_OPENSSL_REVOKE_SCRIPT`) with the serial number as an argument. +### EST Integration (GetCACertPEM) + +The `GetCACertPEM()` method returns the PEM-encoded CA certificate chain, used by the EST server's `/.well-known/est/cacerts` endpoint (RFC 7030) to distribute the CA chain to enrolling devices. Each issuer handles this differently: + +- **Local CA**: Returns the CA certificate PEM (self-signed or sub-CA cert). This is the primary EST issuer. +- **ACME**: Returns error — ACME CAs provide chains per-issuance, not statically. +- **step-ca**: Returns error — step-ca serves its own `/root` endpoint for CA distribution. +- **OpenSSL/Custom CA**: Returns error — custom script-based CAs have no CA cert access through certctl. + +Note: EST (Enrollment over Secure Transport) is not a connector — it's a protocol handler (`internal/api/handler/est.go`) that delegates certificate issuance to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details. + ### Planned Issuers The following issuer connectors are planned for future milestones: diff --git a/docs/demo-advanced.md b/docs/demo-advanced.md index def40ee..d75b2ef 100644 --- a/docs/demo-advanced.md +++ b/docs/demo-advanced.md @@ -221,7 +221,7 @@ You should see: The result is a structurally valid X.509 certificate — browsers won't trust it (no root CA in their trust store), but it exercises the exact same code paths that a production ACME or Vault issuer would. -**Why pluggable issuers:** Different organizations use different CAs. Some use Let's Encrypt (ACME protocol), some use step-ca or internal PKI (Vault), some use commercial CAs (DigiCert, Entrust, GlobalSign), and some have custom OpenSSL-based workflows. For enterprises with ADCS, certctl can operate as a sub-CA — all issued certs chain to the enterprise root. The connector interface means certctl doesn't care — it calls `IssueCertificate()` and gets back a signed cert regardless of the backend. V1 ships with Local CA (self-signed or sub-CA), ACME (HTTP-01 + DNS-01 for wildcards), and step-ca (Smallstep private CA via native /sign API). OpenSSL/Custom CA is planned for V2; DigiCert, Vault PKI, Entrust, GlobalSign, Google CAS, and EJBCA are planned for V3. +**Why pluggable issuers:** Different organizations use different CAs. Some use Let's Encrypt (ACME protocol), some use step-ca or internal PKI (Vault), some use commercial CAs (DigiCert, Entrust, GlobalSign), and some have custom OpenSSL-based workflows. For enterprises with ADCS, certctl can operate as a sub-CA — all issued certs chain to the enterprise root. The connector interface means certctl doesn't care — it calls `IssueCertificate()` and gets back a signed cert regardless of the backend. V1 ships with Local CA (self-signed or sub-CA), ACME (HTTP-01 + DNS-01 for wildcards), and step-ca (Smallstep private CA via native /sign API). V2 adds the OpenSSL/Custom CA connector (script-based signing). DigiCert, Vault PKI, Entrust, GlobalSign, Google CAS, and EJBCA are planned for V3+. ```mermaid flowchart TD @@ -472,14 +472,14 @@ In production, agents poll for work and report results. You can simulate this ma ```bash # Poll for pending deployment work (as an agent) -curl -s "$API/api/v1/agents/agent-nginx-prod/work" | jq . +curl -s "$API/api/v1/agents/ag-web-prod/work" | jq . ``` This returns pending deployment jobs assigned to the agent. The agent would then fetch the certificate, deploy it, and report back: ```bash # Report job completion (replace JOB_ID with an actual job ID from the work response) -curl -s -X POST "$API/api/v1/agents/agent-nginx-prod/jobs/JOB_ID/status" \ +curl -s -X POST "$API/api/v1/agents/ag-web-prod/jobs/JOB_ID/status" \ -H "Content-Type: application/json" \ -d '{ "status": "Completed", @@ -908,7 +908,7 @@ export CERTCTL_API_KEY="test-key-123" ## Part 15: MCP Server for AI Integration (M18a) -certctl exposes all 78 API endpoints as tools via the Model Context Protocol (MCP), enabling seamless integration with Claude, Cursor, and other AI assistants: +certctl exposes 78 MCP tools covering the REST API via the Model Context Protocol (MCP), enabling seamless integration with Claude, Cursor, and other AI assistants: ```bash # Build the MCP server @@ -922,7 +922,7 @@ export CERTCTL_API_KEY="test-key-123" ./mcp-server ``` -**How it works:** The MCP server uses the official Model Context Protocol Go SDK to expose stateless HTTP proxies to all 78 API endpoints. Each MCP tool corresponds to one or more REST endpoints and includes: +**How it works:** The MCP server uses the official Model Context Protocol Go SDK to expose 78 stateless HTTP proxy tools covering the REST API. Each MCP tool corresponds to one or more REST endpoints and includes: - **Input schema** — typed arguments with JSON schema hints for LLM-friendly introspection - **Binary support** — handles DER-encoded CRL and OCSP responses without mangling diff --git a/docs/features.md b/docs/features.md index d7b2740..07f4378 100644 --- a/docs/features.md +++ b/docs/features.md @@ -7,7 +7,7 @@ Complete reference of all features shipped in the V2 release (as of March 2026). ## API Surface ### Overview -- **91 endpoints** across 19 resource domains under `/api/v1/` +- **95 endpoints** across 20 resource domains under `/api/v1/` + `/.well-known/est/` - REST API with HTTP semantics (GET, POST, PUT, DELETE) - All endpoints require authentication by default (configurable) - OpenAPI 3.1 spec with full schema documentation @@ -94,6 +94,7 @@ curl -H "$AUTH" "$SERVER/api/v1/certificates?expires_before=2026-04-24T00:00:00Z | **Notifications** | 3 | List, get, mark as read | | **Stats** | 5 | Dashboard summary, certificates by status, expiration timeline, job trends, issuance rate | | **Metrics** | 2 | JSON metrics (gauges, counters, uptime), Prometheus exposition format | +| **EST (RFC 7030)** | 4 | CA certs (PKCS#7), simple enrollment, re-enrollment, CSR attributes | | **Health** | 4 | Health check, readiness check, auth info, auth check | --- @@ -924,9 +925,39 @@ The web dashboard is the primary operational interface for certctl. Built with * - CLI flags: `--server`, `--api-key`, `--format` (json/table) - Tested with httptest mock server; all commands covered +### EST Server (RFC 7030, M23) +**Enrollment over Secure Transport** — industry-standard protocol for device certificate enrollment. Enables WiFi/802.1X, MDM, IoT, and BYOD use cases where devices need certificates without direct API access. + +**Endpoints** (under `/.well-known/est/` per RFC 7030): + +| Endpoint | Method | Description | Wire Format | +|----------|--------|-------------|-------------| +| `/cacerts` | GET | CA certificate chain distribution | Base64 PKCS#7 certs-only (application/pkcs7-mime) | +| `/simpleenroll` | POST | Initial certificate enrollment | Request: PEM or base64-DER PKCS#10; Response: PKCS#7 | +| `/simplereenroll` | POST | Certificate re-enrollment (renewal) | Same as simpleenroll | +| `/csrattrs` | GET | CSR attributes the server requires | ASN.1 DER (application/csrattrs) | + +**Architecture:** +- **ESTService** bridges handler to existing `IssuerConnector` — no new issuance logic, reuses existing CA connectors +- **CSR input handling** — accepts both base64-encoded DER (EST wire standard) and PEM-encoded PKCS#10 (convenience) +- **PKCS#7 output** — hand-rolled ASN.1 degenerate SignedData builder (no external PKCS#7 dependency) +- **CSR validation** — signature verification, Common Name extraction, SAN extraction (DNS, IP, email, URI) +- **Configurable issuer binding** — `CERTCTL_EST_ISSUER_ID` selects which issuer connector processes enrollment +- **Optional profile binding** — `CERTCTL_EST_PROFILE_ID` constrains enrollments to a specific certificate profile +- **Audit trail** — all EST enrollments recorded with protocol=EST, CN, SANs, issuer ID, serial, profile ID + +**Configuration:** +| Variable | Default | Description | +|----------|---------|-------------| +| `CERTCTL_EST_ENABLED` | `false` | Enable EST enrollment endpoints | +| `CERTCTL_EST_ISSUER_ID` | `iss-local` | Issuer connector for EST enrollments | +| `CERTCTL_EST_PROFILE_ID` | — | Optional profile ID to constrain enrollments | + +**Note:** EST endpoints currently use the same middleware stack as the REST API (API key auth). TLS client certificate authentication for EST is planned for V3. + ### OpenAPI 3.1 Specification - **File** — `api/openapi.yaml` -- **Scope** — 93 operations (91 API + /health + /ready), all request/response schemas, enums, pagination +- **Scope** — 97 operations (95 API + /health + /ready), all request/response schemas, enums, pagination - **Schemas** — Complete domain models with examples - **Enums** — Job types, states, policy rule types, notification types - **Pagination** — Standard envelope (data, total, page, per_page) @@ -1199,7 +1230,7 @@ Each guide includes an evidence summary table mapping specific criteria to certc | Category | Count | |----------|-------| -| **API Endpoints** | 91 (under /api/v1/) | +| **API Endpoints** | 95 (under /api/v1/ + /.well-known/est/) | | **Dashboard** | Full web GUI | | **Issuer Connectors** | 4 (Local CA, ACME, step-ca, OpenSSL) | | **Target Connectors** | 5 (3 impl: NGINX, Apache, HAProxy; 2 stubs: F5, IIS) | diff --git a/docs/quickstart.md b/docs/quickstart.md index fc1e551..7b63d5e 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -111,7 +111,7 @@ curl -s http://localhost:8443/api/v1/certificates/mc-api-prod/deployments | jq . curl -s http://localhost:8443/api/v1/agents | jq . # Check agent pending work -curl -s http://localhost:8443/api/v1/agents/agent-nginx-prod/work | jq . +curl -s http://localhost:8443/api/v1/agents/ag-web-prod/work | jq . # View audit trail curl -s http://localhost:8443/api/v1/audit | jq . @@ -322,7 +322,7 @@ export CERTCTL_API_KEY="test-key-123" ./mcp-server ``` -Exposes all 78 API endpoints as MCP tools via stdio transport. Ask Claude: "What certificates are expiring in the next 30 days?", "Revoke the payments cert due to key compromise", "Show me the audit trail." +Exposes 78 MCP tools covering the REST API via stdio transport. Ask Claude: "What certificates are expiring in the next 30 days?", "Revoke the payments cert due to key compromise", "Show me the audit trail." ## Demo Data Reference @@ -331,7 +331,7 @@ Exposes all 78 API endpoints as MCP tools via stdio transport. Ask Claude: "What | Teams | 5 | Platform, Security, Payments, Frontend, Data | | Owners | 5 | Alice, Bob, Carol, Dave, Eve | | Issuers | 4 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, DigiCert (disabled) | -| Agents | 5 | nginx-prod, nginx-staging, f5-prod, iis-prod, data-agent | +| Agents | 5 | ag-web-prod, ag-web-staging, ag-lb-prod, ag-iis-prod, ag-data-prod | | Targets | 5 | NGINX (prod/staging/data), F5 LB, IIS | | Certificates | 15 | Various statuses: Active, Expiring, Expired, Failed, Wildcard | | Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window | diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 2dc3df8..2321d08 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -3832,9 +3832,248 @@ grep -rn "errors.Is.*errors.New\|errors.Is(.*err.*errors.New" internal/service/* --- +## Part 26: EST Server (RFC 7030) + +**Scope:** Enrollment over Secure Transport — 4 endpoints under `/.well-known/est/` for device certificate enrollment. Tests cover CA cert distribution, certificate enrollment (PEM and base64-DER CSR formats), re-enrollment, CSR attributes, wire format compliance, and error handling. + +**Prerequisites:** Server running with `CERTCTL_EST_ENABLED=true`, `CERTCTL_EST_ISSUER_ID=iss-local` (or a valid issuer). An ECDSA P-256 key pair and CSR for enrollment tests. + +--- + +**Test 26.1 — GET /.well-known/est/cacerts returns PKCS#7 CA chain** + +```bash +curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $API_KEY" \ + http://localhost:8443/.well-known/est/cacerts +``` + +**Expected:** HTTP 200, `Content-Type: application/pkcs7-mime`, `Content-Transfer-Encoding: base64`. Body is base64-encoded degenerate PKCS#7 SignedData containing the CA certificate chain. +**PASS if** status = 200, correct content type, non-empty body. + +--- + +**Test 26.2 — GET /cacerts method enforcement** + +```bash +curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Authorization: Bearer $API_KEY" \ + http://localhost:8443/.well-known/est/cacerts +``` + +**Expected:** HTTP 405 Method Not Allowed. +**PASS if** status = 405. + +--- + +**Test 26.3 — POST /.well-known/est/simpleenroll with PEM CSR** + +Generate a test CSR and submit as PEM: + +```bash +# Generate ECDSA P-256 key and CSR +openssl ecparam -name prime256v1 -genkey -noout -out /tmp/est-test.key +openssl req -new -key /tmp/est-test.key -out /tmp/est-test.csr \ + -subj "/CN=est-test.example.com" \ + -addext "subjectAltName=DNS:est-test.example.com" + +# Submit PEM CSR +curl -s -w "\n%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/pkcs10" \ + --data-binary @/tmp/est-test.csr \ + http://localhost:8443/.well-known/est/simpleenroll +``` + +**Expected:** HTTP 200, `Content-Type: application/pkcs7-mime`, `Content-Transfer-Encoding: base64`. Body contains base64-encoded PKCS#7 with the signed certificate. +**PASS if** status = 200, response decodes to valid PKCS#7. + +--- + +**Test 26.4 — POST /simpleenroll with base64-encoded DER CSR** + +```bash +# Convert PEM CSR to base64-encoded DER (EST wire format) +openssl req -in /tmp/est-test.csr -outform DER | base64 > /tmp/est-test-b64der.csr + +curl -s -w "\n%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/pkcs10" \ + --data-binary @/tmp/est-test-b64der.csr \ + http://localhost:8443/.well-known/est/simpleenroll +``` + +**Expected:** HTTP 200. Server auto-detects base64-encoded DER and converts to PEM internally. +**PASS if** status = 200. + +--- + +**Test 26.5 — POST /simpleenroll with empty body** + +```bash +curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/pkcs10" \ + -X POST -d "" \ + http://localhost:8443/.well-known/est/simpleenroll +``` + +**Expected:** HTTP 400 Bad Request. +**PASS if** status = 400. + +--- + +**Test 26.6 — POST /simpleenroll with invalid CSR** + +```bash +curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/pkcs10" \ + -X POST -d "not-a-valid-csr-at-all" \ + http://localhost:8443/.well-known/est/simpleenroll +``` + +**Expected:** HTTP 400 Bad Request. +**PASS if** status = 400. + +--- + +**Test 26.7 — POST /simpleenroll with CSR missing Common Name** + +```bash +openssl ecparam -name prime256v1 -genkey -noout -out /tmp/est-nocn.key +openssl req -new -key /tmp/est-nocn.key -out /tmp/est-nocn.csr -subj "/" + +curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/pkcs10" \ + --data-binary @/tmp/est-nocn.csr \ + http://localhost:8443/.well-known/est/simpleenroll +``` + +**Expected:** HTTP 500 (service returns error for missing CN). Error message should reference "Common Name". +**PASS if** status != 200. + +--- + +**Test 26.8 — POST /simpleenroll method enforcement (GET not allowed)** + +```bash +curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + http://localhost:8443/.well-known/est/simpleenroll +``` + +**Expected:** HTTP 405 Method Not Allowed. +**PASS if** status = 405. + +--- + +**Test 26.9 — POST /.well-known/est/simplereenroll (re-enrollment)** + +```bash +openssl ecparam -name prime256v1 -genkey -noout -out /tmp/est-renew.key +openssl req -new -key /tmp/est-renew.key -out /tmp/est-renew.csr \ + -subj "/CN=renew-est.example.com" \ + -addext "subjectAltName=DNS:renew-est.example.com" + +curl -s -w "\n%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/pkcs10" \ + --data-binary @/tmp/est-renew.csr \ + http://localhost:8443/.well-known/est/simplereenroll +``` + +**Expected:** HTTP 200. Functionally identical to simpleenroll per RFC 7030 Section 4.2.2. +**PASS if** status = 200, valid PKCS#7 response. + +--- + +**Test 26.10 — GET /simplereenroll method enforcement** + +```bash +curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + http://localhost:8443/.well-known/est/simplereenroll +``` + +**Expected:** HTTP 405 Method Not Allowed. +**PASS if** status = 405. + +--- + +**Test 26.11 — GET /.well-known/est/csrattrs returns 204 (no required attrs)** + +```bash +curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + http://localhost:8443/.well-known/est/csrattrs +``` + +**Expected:** HTTP 204 No Content (default implementation requires no specific CSR attributes). +**PASS if** status = 204. + +--- + +**Test 26.12 — POST /csrattrs method enforcement** + +```bash +curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + -X POST http://localhost:8443/.well-known/est/csrattrs +``` + +**Expected:** HTTP 405 Method Not Allowed. +**PASS if** status = 405. + +--- + +**Test 26.13 — EST enrollment creates audit event** + +After a successful simpleenroll request (Test 26.3), query the audit trail: + +```bash +curl -s -H "Authorization: Bearer $API_KEY" \ + "http://localhost:8443/api/v1/audit?page=1&per_page=10" | \ + jq '.data[] | select(.action == "est_simple_enroll")' +``` + +**Expected:** At least one audit event with `action: "est_simple_enroll"`, `protocol: "EST"` in details, and the enrolled CN in the details. +**PASS if** audit event found with correct action and details. + +--- + +**Test 26.14 — EST disabled returns 404** + +With `CERTCTL_EST_ENABLED=false` (default), EST endpoints should not be registered: + +```bash +curl -s -o /dev/null -w "%{http_code}" http://localhost:8443/.well-known/est/cacerts +``` + +**Expected:** HTTP 404 Not Found (endpoints not registered when EST is disabled). +**PASS if** status = 404. + +--- + +**Test 26.15 — EST with profile binding** + +With `CERTCTL_EST_PROFILE_ID=profile-wifi-client`, verify that audit events include the profile_id in their details: + +```bash +# After enrollment with profile binding, check audit +curl -s -H "Authorization: Bearer $API_KEY" \ + "http://localhost:8443/api/v1/audit?page=1&per_page=5" | \ + jq '.data[0].details.profile_id' +``` + +**Expected:** Profile ID appears in audit event details when configured. +**PASS if** `profile_id` present in audit details. + +--- + ## Release Sign-Off -All 25 parts must pass before tagging v2.0.0. +All 26 parts must pass before tagging v2.0.1. | Section | Pass? | Tester | Date | Notes | |---------|-------|--------|------|-------| @@ -3863,6 +4102,7 @@ All 25 parts must pass before tagging v2.0.0. | Part 23: Structured Logging | ☐ | | | | | Part 24: Documentation Verification | ☐ | | | | | Part 25: Regression Tests | ☐ | | | | +| Part 26: EST Server (RFC 7030) | ☐ | | | | **Automated tests (900+) must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss. diff --git a/internal/api/handler/est.go b/internal/api/handler/est.go new file mode 100644 index 0000000..d71fffd --- /dev/null +++ b/internal/api/handler/est.go @@ -0,0 +1,404 @@ +package handler + +import ( + "context" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "io" + "net/http" + "strings" + + "github.com/shankar0123/certctl/internal/api/middleware" + "github.com/shankar0123/certctl/internal/domain" +) + +// ESTService defines the service interface for EST enrollment operations. +// EST (RFC 7030) is a protocol for certificate enrollment over HTTPS. +type ESTService interface { + // GetCACerts returns the PEM-encoded CA certificate chain for the EST issuer. + GetCACerts(ctx context.Context) (string, error) + + // SimpleEnroll processes a PKCS#10 CSR and returns a signed certificate. + SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) + + // SimpleReEnroll processes a re-enrollment CSR (same as enroll for our purposes). + SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) + + // GetCSRAttrs returns the CSR attributes the server wants clients to include. + GetCSRAttrs(ctx context.Context) ([]byte, error) +} + +// ESTHandler handles HTTP requests for the EST protocol (RFC 7030). +// +// EST endpoints are served under /.well-known/est/ per the RFC. +// Wire format: base64-encoded DER (PKCS#7 for certs, PKCS#10 for CSRs). +// +// Supported operations: +// - GET /.well-known/est/cacerts — CA certificate distribution +// - POST /.well-known/est/simpleenroll — initial enrollment +// - POST /.well-known/est/simplereenroll — re-enrollment +// - GET /.well-known/est/csrattrs — CSR attributes +type ESTHandler struct { + svc ESTService +} + +// NewESTHandler creates a new ESTHandler. +func NewESTHandler(svc ESTService) ESTHandler { + return ESTHandler{svc: svc} +} + +// CACerts handles GET /.well-known/est/cacerts +// Returns the CA certificate chain as base64-encoded PKCS#7 (certs-only). +// Per RFC 7030 Section 4.1, this is a "certs-only" CMC Simple PKI Response. +// For simplicity and broad client compatibility, we return base64-encoded DER certificates. +func (h ESTHandler) CACerts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + caCertPEM, err := h.svc.GetCACerts(r.Context()) + if err != nil { + requestID := middleware.GetRequestID(r.Context()) + ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CA certificates: %v", err), requestID) + return + } + + // Parse PEM to DER for PKCS#7 encoding + derCerts, err := pemToDERChain(caCertPEM) + if err != nil { + requestID := middleware.GetRequestID(r.Context()) + ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to encode CA certificates", requestID) + return + } + + // Build a simple PKCS#7 SignedData (certs-only, degenerate) structure + pkcs7Data, err := buildCertsOnlyPKCS7(derCerts) + if err != nil { + requestID := middleware.GetRequestID(r.Context()) + ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to build PKCS#7 response", requestID) + return + } + + // RFC 7030 Section 4.1.3: response is base64-encoded application/pkcs7-mime + w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only") + w.Header().Set("Content-Transfer-Encoding", "base64") + w.WriteHeader(http.StatusOK) + encoded := base64.StdEncoding.EncodeToString(pkcs7Data) + // Write base64 with line breaks at 76 chars per RFC 2045 + for i := 0; i < len(encoded); i += 76 { + end := i + 76 + if end > len(encoded) { + end = len(encoded) + } + w.Write([]byte(encoded[i:end])) + w.Write([]byte("\r\n")) + } +} + +// SimpleEnroll handles POST /.well-known/est/simpleenroll +// Accepts a base64-encoded PKCS#10 CSR and returns a base64-encoded PKCS#7 certificate. +func (h ESTHandler) SimpleEnroll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + requestID := middleware.GetRequestID(r.Context()) + + csrPEM, err := h.readCSRFromRequest(r) + if err != nil { + ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID) + return + } + + result, err := h.svc.SimpleEnroll(r.Context(), csrPEM) + if err != nil { + ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Enrollment failed: %v", err), requestID) + return + } + + h.writeCertResponse(w, result) +} + +// SimpleReEnroll handles POST /.well-known/est/simplereenroll +// Same as SimpleEnroll but for re-enrollment (certificate renewal). +func (h ESTHandler) SimpleReEnroll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + requestID := middleware.GetRequestID(r.Context()) + + csrPEM, err := h.readCSRFromRequest(r) + if err != nil { + ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID) + return + } + + result, err := h.svc.SimpleReEnroll(r.Context(), csrPEM) + if err != nil { + ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Re-enrollment failed: %v", err), requestID) + return + } + + h.writeCertResponse(w, result) +} + +// CSRAttrs handles GET /.well-known/est/csrattrs +// Returns the CSR attributes the server wants the client to include in enrollment requests. +func (h ESTHandler) CSRAttrs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + attrs, err := h.svc.GetCSRAttrs(r.Context()) + if err != nil { + requestID := middleware.GetRequestID(r.Context()) + ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CSR attributes: %v", err), requestID) + return + } + + if len(attrs) == 0 { + // No specific attributes required — return 204 + w.WriteHeader(http.StatusNoContent) + return + } + + w.Header().Set("Content-Type", "application/csrattrs") + w.Header().Set("Content-Transfer-Encoding", "base64") + w.WriteHeader(http.StatusOK) + w.Write([]byte(base64.StdEncoding.EncodeToString(attrs))) +} + +// readCSRFromRequest reads and decodes the CSR from an EST enrollment request. +// EST sends CSRs as base64-encoded PKCS#10 DER with Content-Type application/pkcs10. +func (h ESTHandler) readCSRFromRequest(r *http.Request) (string, error) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit + if err != nil { + return "", fmt.Errorf("failed to read request body: %w", err) + } + defer r.Body.Close() + + if len(body) == 0 { + return "", fmt.Errorf("empty request body") + } + + // Check if it's already PEM-encoded (some clients send PEM directly) + bodyStr := strings.TrimSpace(string(body)) + if strings.HasPrefix(bodyStr, "-----BEGIN CERTIFICATE REQUEST-----") { + // Validate it parses + block, _ := pem.Decode([]byte(bodyStr)) + if block == nil { + return "", fmt.Errorf("invalid PEM-encoded CSR") + } + if _, err := x509.ParseCertificateRequest(block.Bytes); err != nil { + return "", fmt.Errorf("invalid CSR: %w", err) + } + return bodyStr, nil + } + + // EST standard: base64-encoded DER PKCS#10 + derBytes, err := base64.StdEncoding.DecodeString(bodyStr) + if err != nil { + // Try with padding/whitespace stripped + cleaned := strings.Map(func(r rune) rune { + if r == '\r' || r == '\n' || r == ' ' || r == '\t' { + return -1 + } + return r + }, bodyStr) + derBytes, err = base64.StdEncoding.DecodeString(cleaned) + if err != nil { + return "", fmt.Errorf("failed to decode base64 CSR: %w", err) + } + } + + // Validate it's a valid PKCS#10 CSR + if _, err := x509.ParseCertificateRequest(derBytes); err != nil { + return "", fmt.Errorf("invalid PKCS#10 CSR: %w", err) + } + + // Convert DER to PEM for internal use (certctl services expect PEM) + csrPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE REQUEST", + Bytes: derBytes, + }) + return string(csrPEM), nil +} + +// writeCertResponse writes an EST enrollment response as base64-encoded PKCS#7. +func (h ESTHandler) writeCertResponse(w http.ResponseWriter, result *domain.ESTEnrollResult) { + // Parse cert and chain PEM to DER + var derCerts [][]byte + + // Add the issued certificate + certDER, err := pemToDERChain(result.CertPEM) + if err != nil || len(certDER) == 0 { + http.Error(w, "Failed to encode certificate", http.StatusInternalServerError) + return + } + derCerts = append(derCerts, certDER...) + + // Add the CA chain if present + if result.ChainPEM != "" { + chainDER, err := pemToDERChain(result.ChainPEM) + if err == nil { + derCerts = append(derCerts, chainDER...) + } + } + + // Build PKCS#7 certs-only + pkcs7Data, err := buildCertsOnlyPKCS7(derCerts) + if err != nil { + http.Error(w, "Failed to build PKCS#7 response", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only") + w.Header().Set("Content-Transfer-Encoding", "base64") + w.WriteHeader(http.StatusOK) + encoded := base64.StdEncoding.EncodeToString(pkcs7Data) + for i := 0; i < len(encoded); i += 76 { + end := i + 76 + if end > len(encoded) { + end = len(encoded) + } + w.Write([]byte(encoded[i:end])) + w.Write([]byte("\r\n")) + } +} + +// pemToDERChain converts PEM-encoded certificates to a slice of DER-encoded certificates. +func pemToDERChain(pemData string) ([][]byte, error) { + var derCerts [][]byte + rest := []byte(pemData) + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type == "CERTIFICATE" { + derCerts = append(derCerts, block.Bytes) + } + } + if len(derCerts) == 0 { + return nil, fmt.Errorf("no certificates found in PEM data") + } + return derCerts, nil +} + +// buildCertsOnlyPKCS7 creates a degenerate PKCS#7 SignedData structure containing only certificates. +// This is the "certs-only" format specified in RFC 7030 Section 4.1.3 for /cacerts responses +// and enrollment responses. +// +// ASN.1 structure (simplified): +// +// ContentInfo { +// contentType: signedData (1.2.840.113549.1.7.2) +// content: SignedData { +// version: 1 +// digestAlgorithms: {} (empty) +// encapContentInfo: { contentType: data (1.2.840.113549.1.7.1) } +// certificates: [cert1, cert2, ...] +// signerInfos: {} (empty) +// } +// } +func buildCertsOnlyPKCS7(derCerts [][]byte) ([]byte, error) { + // We build the ASN.1 manually to avoid pulling in a PKCS#7 library. + // This is a well-defined, static structure — no signing needed. + + // OID for signedData: 1.2.840.113549.1.7.2 + oidSignedData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02} + // OID for data: 1.2.840.113549.1.7.1 + oidData := []byte{0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01} + + // Build certificates [0] IMPLICIT SET OF Certificate + var certsContent []byte + for _, cert := range derCerts { + certsContent = append(certsContent, cert...) + } + certsField := asn1WrapImplicit(0, certsContent) + + // Build encapContentInfo: SEQUENCE { OID data } + encapContentInfo := asn1WrapSequence(oidData) + + // Build digestAlgorithms: SET {} (empty) + digestAlgorithms := asn1WrapSet(nil) + + // Build signerInfos: SET {} (empty) + signerInfos := asn1WrapSet(nil) + + // Version: INTEGER 1 + version := []byte{0x02, 0x01, 0x01} + + // Build SignedData SEQUENCE + var signedDataContent []byte + signedDataContent = append(signedDataContent, version...) + signedDataContent = append(signedDataContent, digestAlgorithms...) + signedDataContent = append(signedDataContent, encapContentInfo...) + signedDataContent = append(signedDataContent, certsField...) + signedDataContent = append(signedDataContent, signerInfos...) + signedData := asn1WrapSequence(signedDataContent) + + // Wrap in [0] EXPLICIT for ContentInfo.content + contentField := asn1WrapExplicit(0, signedData) + + // Build ContentInfo SEQUENCE + var contentInfoContent []byte + contentInfoContent = append(contentInfoContent, oidSignedData...) + contentInfoContent = append(contentInfoContent, contentField...) + contentInfo := asn1WrapSequence(contentInfoContent) + + return contentInfo, nil +} + +// asn1WrapSequence wraps content in an ASN.1 SEQUENCE tag (0x30). +func asn1WrapSequence(content []byte) []byte { + return asn1Wrap(0x30, content) +} + +// asn1WrapSet wraps content in an ASN.1 SET tag (0x31). +func asn1WrapSet(content []byte) []byte { + return asn1Wrap(0x31, content) +} + +// asn1WrapExplicit wraps content in an ASN.1 context-specific EXPLICIT tag. +func asn1WrapExplicit(tag int, content []byte) []byte { + return asn1Wrap(byte(0xa0|tag), content) +} + +// asn1WrapImplicit wraps content in an ASN.1 context-specific IMPLICIT CONSTRUCTED tag. +func asn1WrapImplicit(tag int, content []byte) []byte { + return asn1Wrap(byte(0xa0|tag), content) +} + +// asn1Wrap wraps content with an ASN.1 tag and length. +func asn1Wrap(tag byte, content []byte) []byte { + length := len(content) + var result []byte + result = append(result, tag) + result = append(result, asn1EncodeLength(length)...) + result = append(result, content...) + return result +} + +// asn1EncodeLength encodes a length in ASN.1 DER format. +func asn1EncodeLength(length int) []byte { + if length < 0x80 { + return []byte{byte(length)} + } + // Long form + var lengthBytes []byte + l := length + for l > 0 { + lengthBytes = append([]byte{byte(l & 0xff)}, lengthBytes...) + l >>= 8 + } + return append([]byte{byte(0x80 | len(lengthBytes))}, lengthBytes...) +} diff --git a/internal/api/handler/est_handler_test.go b/internal/api/handler/est_handler_test.go new file mode 100644 index 0000000..c1d9542 --- /dev/null +++ b/internal/api/handler/est_handler_test.go @@ -0,0 +1,369 @@ +package handler + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/shankar0123/certctl/internal/domain" +) + +// mockESTService implements ESTService for testing. +type mockESTService struct { + CACertPEM string + CACertErr error + EnrollResult *domain.ESTEnrollResult + EnrollErr error + CSRAttrs []byte + CSRAttrsErr error +} + +func (m *mockESTService) GetCACerts(ctx context.Context) (string, error) { + return m.CACertPEM, m.CACertErr +} + +func (m *mockESTService) SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) { + return m.EnrollResult, m.EnrollErr +} + +func (m *mockESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) { + return m.EnrollResult, m.EnrollErr +} + +func (m *mockESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) { + return m.CSRAttrs, m.CSRAttrsErr +} + +// generateTestCSRPEM creates a valid ECDSA P-256 CSR for testing. +func generateTestCSRPEM(t *testing.T) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + template := &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: "test.example.com"}, + DNSNames: []string{"test.example.com", "www.example.com"}, + } + csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key) + if err != nil { + t.Fatalf("failed to create CSR: %v", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})) +} + +// generateTestCSRBase64DER creates a valid base64-encoded DER CSR for EST wire format. +func generateTestCSRBase64DER(t *testing.T) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + template := &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: "test.example.com"}, + DNSNames: []string{"test.example.com"}, + } + csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key) + if err != nil { + t.Fatalf("failed to create CSR: %v", err) + } + return base64.StdEncoding.EncodeToString(csrDER) +} + +func TestESTCACerts_Success(t *testing.T) { + svc := &mockESTService{ + CACertPEM: "-----BEGIN CERTIFICATE-----\nMIIBmjCCAUCgAwIBAgIRATest\n-----END CERTIFICATE-----\n", + } + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodGet, "/.well-known/est/cacerts", nil) + w := httptest.NewRecorder() + h.CACerts(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "application/pkcs7-mime") { + t.Errorf("expected application/pkcs7-mime content type, got %s", ct) + } + cte := w.Header().Get("Content-Transfer-Encoding") + if cte != "base64" { + t.Errorf("expected base64 content-transfer-encoding, got %s", cte) + } +} + +func TestESTCACerts_MethodNotAllowed(t *testing.T) { + svc := &mockESTService{} + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodPost, "/.well-known/est/cacerts", nil) + w := httptest.NewRecorder() + h.CACerts(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", w.Code) + } +} + +func TestESTCACerts_ServiceError(t *testing.T) { + svc := &mockESTService{ + CACertErr: errors.New("issuer unavailable"), + } + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodGet, "/.well-known/est/cacerts", nil) + w := httptest.NewRecorder() + h.CACerts(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + +func TestESTSimpleEnroll_Success_PEM(t *testing.T) { + csrPEM := generateTestCSRPEM(t) + svc := &mockESTService{ + EnrollResult: &domain.ESTEnrollResult{ + CertPEM: "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n", + ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIBchain\n-----END CERTIFICATE-----\n", + }, + } + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(csrPEM)) + req.Header.Set("Content-Type", "application/pkcs10") + w := httptest.NewRecorder() + h.SimpleEnroll(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "application/pkcs7-mime") { + t.Errorf("expected application/pkcs7-mime, got %s", ct) + } +} + +func TestESTSimpleEnroll_Success_Base64DER(t *testing.T) { + csrB64 := generateTestCSRBase64DER(t) + svc := &mockESTService{ + EnrollResult: &domain.ESTEnrollResult{ + CertPEM: "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n", + ChainPEM: "", + }, + } + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(csrB64)) + req.Header.Set("Content-Type", "application/pkcs10") + w := httptest.NewRecorder() + h.SimpleEnroll(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestESTSimpleEnroll_MethodNotAllowed(t *testing.T) { + svc := &mockESTService{} + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodGet, "/.well-known/est/simpleenroll", nil) + w := httptest.NewRecorder() + h.SimpleEnroll(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", w.Code) + } +} + +func TestESTSimpleEnroll_EmptyBody(t *testing.T) { + svc := &mockESTService{} + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader("")) + w := httptest.NewRecorder() + h.SimpleEnroll(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestESTSimpleEnroll_InvalidCSR(t *testing.T) { + svc := &mockESTService{} + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader("not-a-valid-csr")) + w := httptest.NewRecorder() + h.SimpleEnroll(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestESTSimpleEnroll_ServiceError(t *testing.T) { + csrPEM := generateTestCSRPEM(t) + svc := &mockESTService{ + EnrollErr: errors.New("issuance failed"), + } + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simpleenroll", strings.NewReader(csrPEM)) + w := httptest.NewRecorder() + h.SimpleEnroll(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + +func TestESTSimpleReEnroll_Success(t *testing.T) { + csrPEM := generateTestCSRPEM(t) + svc := &mockESTService{ + EnrollResult: &domain.ESTEnrollResult{ + CertPEM: "-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n", + ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIBchain\n-----END CERTIFICATE-----\n", + }, + } + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodPost, "/.well-known/est/simplereenroll", strings.NewReader(csrPEM)) + w := httptest.NewRecorder() + h.SimpleReEnroll(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestESTSimpleReEnroll_MethodNotAllowed(t *testing.T) { + svc := &mockESTService{} + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodGet, "/.well-known/est/simplereenroll", nil) + w := httptest.NewRecorder() + h.SimpleReEnroll(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", w.Code) + } +} + +func TestESTCSRAttrs_NoContent(t *testing.T) { + svc := &mockESTService{ + CSRAttrs: nil, + } + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodGet, "/.well-known/est/csrattrs", nil) + w := httptest.NewRecorder() + h.CSRAttrs(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("expected 204, got %d", w.Code) + } +} + +func TestESTCSRAttrs_WithData(t *testing.T) { + svc := &mockESTService{ + CSRAttrs: []byte{0x30, 0x00}, // empty SEQUENCE + } + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodGet, "/.well-known/est/csrattrs", nil) + w := httptest.NewRecorder() + h.CSRAttrs(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + ct := w.Header().Get("Content-Type") + if ct != "application/csrattrs" { + t.Errorf("expected application/csrattrs, got %s", ct) + } +} + +func TestESTCSRAttrs_MethodNotAllowed(t *testing.T) { + svc := &mockESTService{} + h := NewESTHandler(svc) + + req := httptest.NewRequest(http.MethodPost, "/.well-known/est/csrattrs", nil) + w := httptest.NewRecorder() + h.CSRAttrs(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", w.Code) + } +} + +func TestBuildCertsOnlyPKCS7(t *testing.T) { + // Test with a dummy DER certificate + dummyCert := []byte{0x30, 0x82, 0x01, 0x00} // minimal ASN.1 SEQUENCE + result, err := buildCertsOnlyPKCS7([][]byte{dummyCert}) + if err != nil { + t.Fatalf("buildCertsOnlyPKCS7 failed: %v", err) + } + if len(result) == 0 { + t.Error("expected non-empty PKCS#7 output") + } + // Verify it starts with SEQUENCE tag + if result[0] != 0x30 { + t.Errorf("expected PKCS#7 to start with SEQUENCE tag (0x30), got 0x%02x", result[0]) + } +} + +func TestPemToDERChain(t *testing.T) { + pemData := "-----BEGIN CERTIFICATE-----\nMIIBmjCCAUCgAwIBAgIRATest\n-----END CERTIFICATE-----\n" + certs, err := pemToDERChain(pemData) + if err != nil { + t.Fatalf("pemToDERChain failed: %v", err) + } + if len(certs) != 1 { + t.Errorf("expected 1 cert, got %d", len(certs)) + } +} + +func TestPemToDERChain_NoCerts(t *testing.T) { + _, err := pemToDERChain("not a PEM") + if err == nil { + t.Error("expected error for invalid PEM") + } +} + +func TestASN1EncodeLength(t *testing.T) { + tests := []struct { + length int + expected []byte + }{ + {0, []byte{0x00}}, + {1, []byte{0x01}}, + {127, []byte{0x7f}}, + {128, []byte{0x81, 0x80}}, + {256, []byte{0x82, 0x01, 0x00}}, + } + for _, tt := range tests { + result := asn1EncodeLength(tt.length) + if len(result) != len(tt.expected) { + t.Errorf("asn1EncodeLength(%d): expected %d bytes, got %d", tt.length, len(tt.expected), len(result)) + continue + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("asn1EncodeLength(%d): byte %d: expected 0x%02x, got 0x%02x", tt.length, i, tt.expected[i], result[i]) + } + } + } +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 0ce87fd..5efa797 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -209,6 +209,16 @@ func (r *Router) RegisterHandlers( r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(networkScan.TriggerNetworkScan)) } +// RegisterESTHandlers sets up EST (RFC 7030) routes under /.well-known/est/. +// EST endpoints use a separate middleware chain (no API key auth — EST uses TLS client certs). +func (r *Router) RegisterESTHandlers(est handler.ESTHandler) { + // EST endpoints per RFC 7030 Section 3.2.2 + r.Register("GET /.well-known/est/cacerts", http.HandlerFunc(est.CACerts)) + r.Register("POST /.well-known/est/simpleenroll", http.HandlerFunc(est.SimpleEnroll)) + r.Register("POST /.well-known/est/simplereenroll", http.HandlerFunc(est.SimpleReEnroll)) + r.Register("GET /.well-known/est/csrattrs", http.HandlerFunc(est.CSRAttrs)) +} + // GetMux returns the underlying http.ServeMux for direct access if needed. func (r *Router) GetMux() *http.ServeMux { return r.mux diff --git a/internal/config/config.go b/internal/config/config.go index ba1e74b..af46539 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ type Config struct { CA CAConfig Notifiers NotifierConfig NetworkScan NetworkScanConfig + EST ESTConfig } // NotifierConfig contains configuration for notification connectors. @@ -81,6 +82,14 @@ type OpenSSLConfig struct { TimeoutSeconds int } +// ESTConfig controls the RFC 7030 Enrollment over Secure Transport server. +type ESTConfig struct { + Enabled bool // Enable EST endpoints (default false) + IssuerID string // Which issuer connector to use for EST enrollment (e.g., "iss-local") + // ProfileID optionally constrains EST enrollments to a specific certificate profile. + ProfileID string +} + // NetworkScanConfig controls the server-side active TLS scanner. type NetworkScanConfig struct { Enabled bool // Enable network scanning (default false) @@ -189,6 +198,11 @@ func Load() (*Config, error) { Enabled: getEnvBool("CERTCTL_NETWORK_SCAN_ENABLED", false), ScanInterval: getEnvDuration("CERTCTL_NETWORK_SCAN_INTERVAL", 6*time.Hour), }, + EST: ESTConfig{ + Enabled: getEnvBool("CERTCTL_EST_ENABLED", false), + IssuerID: getEnv("CERTCTL_EST_ISSUER_ID", "iss-local"), + ProfileID: getEnv("CERTCTL_EST_PROFILE_ID", ""), + }, } if err := cfg.Validate(); err != nil { diff --git a/internal/connector/issuer/acme/acme.go b/internal/connector/issuer/acme/acme.go index 55ec039..eb671ca 100644 --- a/internal/connector/issuer/acme/acme.go +++ b/internal/connector/issuer/acme/acme.go @@ -619,3 +619,8 @@ func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.Revok func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) { return nil, fmt.Errorf("ACME issuers do not support OCSP response signing") } + +// GetCACertPEM is not supported by ACME issuers (the CA chain is returned per-issuance). +func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) { + return "", fmt.Errorf("ACME issuers do not provide a static CA certificate; chain is returned per-issuance") +} diff --git a/internal/connector/issuer/interface.go b/internal/connector/issuer/interface.go index 37134f5..d534163 100644 --- a/internal/connector/issuer/interface.go +++ b/internal/connector/issuer/interface.go @@ -31,6 +31,10 @@ type Connector interface { // SignOCSPResponse signs an OCSP response for the given certificate serial. // Returns nil if the issuer does not support OCSP (e.g., ACME). SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error) + + // GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer. + // Used by the EST /cacerts endpoint. Returns empty string if not available. + GetCACertPEM(ctx context.Context) (string, error) } // IssuanceRequest contains the parameters for issuing a new certificate. diff --git a/internal/connector/issuer/local/local.go b/internal/connector/issuer/local/local.go index 49263ae..432cf04 100644 --- a/internal/connector/issuer/local/local.go +++ b/internal/connector/issuer/local/local.go @@ -664,3 +664,12 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq return respBytes, nil } + +// GetCACertPEM returns the PEM-encoded CA certificate for this issuer. +// Used by the EST /cacerts endpoint to distribute the CA trust chain. +func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) { + if err := c.ensureCA(ctx); err != nil { + return "", fmt.Errorf("CA initialization failed: %w", err) + } + return c.caCertPEM, nil +} diff --git a/internal/connector/issuer/openssl/openssl.go b/internal/connector/issuer/openssl/openssl.go index 4cb4269..e97f203 100644 --- a/internal/connector/issuer/openssl/openssl.go +++ b/internal/connector/issuer/openssl/openssl.go @@ -358,6 +358,11 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq return nil, nil } +// GetCACertPEM is not supported by the custom CA connector (no CA cert access). +func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) { + return "", fmt.Errorf("custom CA connector does not provide CA certificate access") +} + // --- Helper Methods --- // writeTempFile writes data to a temporary file and returns its path. diff --git a/internal/connector/issuer/stepca/stepca.go b/internal/connector/issuer/stepca/stepca.go index c83570f..aced743 100644 --- a/internal/connector/issuer/stepca/stepca.go +++ b/internal/connector/issuer/stepca/stepca.go @@ -467,5 +467,10 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq return nil, fmt.Errorf("step-ca provides its own OCSP responder; use step-ca's /ocsp directly") } +// GetCACertPEM is not directly supported; step-ca serves its own /root endpoint. +func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) { + return "", fmt.Errorf("step-ca serves its own CA certificate at /root; use step-ca's endpoint directly") +} + // Ensure Connector implements the issuer.Connector interface. var _ issuer.Connector = (*Connector)(nil) diff --git a/internal/domain/est.go b/internal/domain/est.go new file mode 100644 index 0000000..ec0c776 --- /dev/null +++ b/internal/domain/est.go @@ -0,0 +1,7 @@ +package domain + +// ESTEnrollResult holds the result of an EST (RFC 7030) enrollment operation. +type ESTEnrollResult struct { + CertPEM string `json:"cert_pem"` // PEM-encoded signed certificate + ChainPEM string `json:"chain_pem"` // PEM-encoded CA chain +} diff --git a/internal/integration/e2e_test.go b/internal/integration/e2e_test.go index 99f281f..69e09e7 100644 --- a/internal/integration/e2e_test.go +++ b/internal/integration/e2e_test.go @@ -2,9 +2,17 @@ package integration import ( "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" "encoding/json" + "encoding/pem" "io" "net/http" + "strings" "testing" "time" @@ -892,3 +900,214 @@ func TestM20EnhancedQueryAPI(t *testing.T) { } }) } + +// generateE2ECSRPEM creates a valid ECDSA P-256 CSR PEM for integration testing. +func generateE2ECSRPEM(t *testing.T, cn string, sans []string) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: cn}, + DNSNames: sans, + } + csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key) + if err != nil { + t.Fatalf("create CSR: %v", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})) +} + +// generateE2ECSRBase64DER creates a valid base64-encoded DER CSR for EST wire format testing. +func generateE2ECSRBase64DER(t *testing.T, cn string, sans []string) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: cn}, + DNSNames: sans, + } + csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key) + if err != nil { + t.Fatalf("create CSR: %v", err) + } + return base64.StdEncoding.EncodeToString(csrDER) +} + +// TestESTEndpoints exercises the EST (RFC 7030) enrollment endpoints end-to-end (M23). +func TestESTEndpoints(t *testing.T) { + server, _, _, _ := setupTestServer(t) + + // =========================== + // GET /cacerts — CA certificate chain + // =========================== + t.Run("GetCACerts_Success", func(t *testing.T) { + resp, err := http.Get(server.URL + "/.well-known/est/cacerts") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes)) + } + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "application/pkcs7-mime") { + t.Errorf("expected application/pkcs7-mime content type, got %s", ct) + } + cte := resp.Header.Get("Content-Transfer-Encoding") + if cte != "base64" { + t.Errorf("expected base64 content-transfer-encoding, got %s", cte) + } + bodyBytes, _ := io.ReadAll(resp.Body) + if len(bodyBytes) == 0 { + t.Error("expected non-empty PKCS#7 response body") + } + }) + + t.Run("GetCACerts_MethodNotAllowed", func(t *testing.T) { + resp, err := http.Post(server.URL+"/.well-known/est/cacerts", "application/json", nil) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", resp.StatusCode) + } + }) + + // =========================== + // POST /simpleenroll — certificate enrollment + // =========================== + t.Run("SimpleEnroll_PEM_Success", func(t *testing.T) { + csrPEM := generateE2ECSRPEM(t, "est-test.example.com", []string{"est-test.example.com"}) + resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(csrPEM)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes)) + } + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "application/pkcs7-mime") { + t.Errorf("expected application/pkcs7-mime, got %s", ct) + } + }) + + t.Run("SimpleEnroll_Base64DER_Success", func(t *testing.T) { + csrB64 := generateE2ECSRBase64DER(t, "est-der.example.com", []string{"est-der.example.com"}) + resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(csrB64)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes)) + } + }) + + t.Run("SimpleEnroll_EmptyBody", func(t *testing.T) { + resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader("")) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400 for empty body, got %d", resp.StatusCode) + } + }) + + t.Run("SimpleEnroll_InvalidCSR", func(t *testing.T) { + resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader("not-a-valid-csr")) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400 for invalid CSR, got %d", resp.StatusCode) + } + }) + + t.Run("SimpleEnroll_MissingCN", func(t *testing.T) { + csrPEM := generateE2ECSRPEM(t, "", []string{"no-cn.example.com"}) + resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(csrPEM)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + // Should fail because EST requires a Common Name + if resp.StatusCode == http.StatusOK { + t.Error("expected error for CSR without Common Name") + } + }) + + t.Run("SimpleEnroll_MethodNotAllowed", func(t *testing.T) { + resp, err := http.Get(server.URL + "/.well-known/est/simpleenroll") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", resp.StatusCode) + } + }) + + // =========================== + // POST /simplereenroll — certificate re-enrollment + // =========================== + t.Run("SimpleReEnroll_Success", func(t *testing.T) { + csrPEM := generateE2ECSRPEM(t, "renew-est.example.com", []string{"renew-est.example.com"}) + resp, err := http.Post(server.URL+"/.well-known/est/simplereenroll", "application/pkcs10", strings.NewReader(csrPEM)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes)) + } + }) + + t.Run("SimpleReEnroll_MethodNotAllowed", func(t *testing.T) { + resp, err := http.Get(server.URL + "/.well-known/est/simplereenroll") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", resp.StatusCode) + } + }) + + // =========================== + // GET /csrattrs — CSR attributes + // =========================== + t.Run("GetCSRAttrs_NoContent", func(t *testing.T) { + resp, err := http.Get(server.URL + "/.well-known/est/csrattrs") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + // Default implementation returns nil attrs → 204 No Content + if resp.StatusCode != http.StatusNoContent { + t.Errorf("expected 204, got %d", resp.StatusCode) + } + }) + + t.Run("GetCSRAttrs_MethodNotAllowed", func(t *testing.T) { + resp, err := http.Post(server.URL+"/.well-known/est/csrattrs", "application/json", nil) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", resp.StatusCode) + } + }) +} diff --git a/internal/integration/lifecycle_test.go b/internal/integration/lifecycle_test.go index 9dbb1dd..c4b34ea 100644 --- a/internal/integration/lifecycle_test.go +++ b/internal/integration/lifecycle_test.go @@ -82,6 +82,10 @@ func TestCertificateLifecycle(t *testing.T) { discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{}) networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{}) + // EST handler — uses real Local CA issuer via ESTService + estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger) + estHandler := handler.NewESTHandler(estService) + // Create router and register handlers r := router.New() r.RegisterHandlers( @@ -103,6 +107,7 @@ func TestCertificateLifecycle(t *testing.T) { discoveryHandler, networkScanHandler, ) + r.RegisterESTHandlers(estHandler) // Create test server server := httptest.NewServer(r) diff --git a/internal/integration/negative_test.go b/internal/integration/negative_test.go index 35cd9a3..82b696c 100644 --- a/internal/integration/negative_test.go +++ b/internal/integration/negative_test.go @@ -75,6 +75,10 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{}) networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{}) + // EST handler — uses real Local CA issuer via ESTService + estService := service.NewESTService("iss-local", issuerRegistry["iss-local"], auditService, logger) + estHandler := handler.NewESTHandler(estService) + r := router.New() r.RegisterHandlers( certificateHandler, @@ -95,6 +99,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository discoveryHandler, networkScanHandler, ) + r.RegisterESTHandlers(estHandler) server := httptest.NewServer(r) t.Cleanup(func() { server.Close() }) diff --git a/internal/service/est.go b/internal/service/est.go new file mode 100644 index 0000000..3cb61b8 --- /dev/null +++ b/internal/service/est.go @@ -0,0 +1,153 @@ +package service + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "log/slog" + "strings" + + "github.com/shankar0123/certctl/internal/domain" +) + +// ESTService implements the EST (RFC 7030) enrollment protocol. +// It delegates certificate operations to an existing IssuerConnector and records +// enrollment events in the audit trail. +type ESTService struct { + issuer IssuerConnector + issuerID string + auditService *AuditService + logger *slog.Logger + profileID string // optional: constrain enrollments to a specific profile +} + +// NewESTService creates a new ESTService for the given issuer connector. +func NewESTService(issuerID string, issuer IssuerConnector, auditService *AuditService, logger *slog.Logger) *ESTService { + return &ESTService{ + issuer: issuer, + issuerID: issuerID, + auditService: auditService, + logger: logger, + } +} + +// SetProfileID constrains EST enrollments to a specific certificate profile. +func (s *ESTService) SetProfileID(profileID string) { + s.profileID = profileID +} + +// GetCACerts returns the PEM-encoded CA certificate chain for this EST server. +// RFC 7030 Section 4.1: /cacerts distributes the current CA certificates. +func (s *ESTService) GetCACerts(ctx context.Context) (string, error) { + caPEM, err := s.issuer.GetCACertPEM(ctx) + if err != nil { + return "", fmt.Errorf("failed to get CA certificates from issuer %s: %w", s.issuerID, err) + } + if caPEM == "" { + return "", fmt.Errorf("issuer %s does not provide CA certificates for EST", s.issuerID) + } + return caPEM, nil +} + +// SimpleEnroll processes an initial enrollment request. +// RFC 7030 Section 4.2: /simpleenroll accepts a PKCS#10 CSR and returns a signed cert. +func (s *ESTService) SimpleEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) { + return s.processEnrollment(ctx, csrPEM, "est_simple_enroll") +} + +// SimpleReEnroll processes a re-enrollment request. +// RFC 7030 Section 4.2.2: /simplereenroll is functionally identical to /simpleenroll +// but is used when renewing an existing certificate. +func (s *ESTService) SimpleReEnroll(ctx context.Context, csrPEM string) (*domain.ESTEnrollResult, error) { + return s.processEnrollment(ctx, csrPEM, "est_simple_reenroll") +} + +// GetCSRAttrs returns the CSR attributes the server wants clients to include. +// RFC 7030 Section 4.5: /csrattrs tells clients what to put in their CSR. +// Returns nil if no specific attributes are required. +func (s *ESTService) GetCSRAttrs(ctx context.Context) ([]byte, error) { + // For now, we don't require specific CSR attributes. + // In the future, this could return key type constraints from the profile. + return nil, nil +} + +// processEnrollment handles the common enrollment logic for both simpleenroll and simplereenroll. +func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, auditAction string) (*domain.ESTEnrollResult, error) { + // Parse the CSR to extract CN and SANs + block, _ := pem.Decode([]byte(csrPEM)) + if block == nil { + return nil, fmt.Errorf("invalid CSR PEM") + } + + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse CSR: %w", err) + } + + if err := csr.CheckSignature(); err != nil { + return nil, fmt.Errorf("CSR signature verification failed: %w", err) + } + + commonName := csr.Subject.CommonName + if commonName == "" { + return nil, fmt.Errorf("CSR must include a Common Name") + } + + // Collect SANs + var sans []string + for _, dns := range csr.DNSNames { + sans = append(sans, dns) + } + for _, ip := range csr.IPAddresses { + sans = append(sans, ip.String()) + } + for _, email := range csr.EmailAddresses { + sans = append(sans, email) + } + for _, uri := range csr.URIs { + sans = append(sans, uri.String()) + } + + s.logger.Info("EST enrollment request", + "action", auditAction, + "common_name", commonName, + "sans", strings.Join(sans, ","), + "issuer", s.issuerID) + + // Issue the certificate via the configured issuer connector + result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM) + if err != nil { + s.logger.Error("EST enrollment failed", + "action", auditAction, + "common_name", commonName, + "error", err) + return nil, fmt.Errorf("certificate issuance failed: %w", err) + } + + // Audit the enrollment + if s.auditService != nil { + details := map[string]interface{}{ + "common_name": commonName, + "sans": sans, + "issuer_id": s.issuerID, + "serial": result.Serial, + "protocol": "EST", + } + if s.profileID != "" { + details["profile_id"] = s.profileID + } + _ = s.auditService.RecordEvent(ctx, "est-client", "system", auditAction, "certificate", result.Serial, details) + } + + s.logger.Info("EST enrollment successful", + "action", auditAction, + "common_name", commonName, + "serial", result.Serial, + "not_after", result.NotAfter) + + return &domain.ESTEnrollResult{ + CertPEM: result.CertPEM, + ChainPEM: result.ChainPEM, + }, nil +} diff --git a/internal/service/est_test.go b/internal/service/est_test.go new file mode 100644 index 0000000..65797aa --- /dev/null +++ b/internal/service/est_test.go @@ -0,0 +1,180 @@ +package service + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "log/slog" + "os" + "strings" + "testing" +) + +// generateCSRPEM creates a valid ECDSA P-256 CSR for testing. +func generateCSRPEM(t *testing.T, cn string, sans []string) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: cn}, + DNSNames: sans, + } + csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key) + if err != nil { + t.Fatalf("create CSR: %v", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})) +} + +func TestESTService_GetCACerts_Success(t *testing.T) { + mockIssuer := &mockIssuerConnector{} + svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + + caPEM, err := svc.GetCACerts(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if caPEM == "" { + t.Error("expected non-empty CA PEM") + } +} + +func TestESTService_GetCACerts_IssuerError(t *testing.T) { + mockIssuer := &mockIssuerConnector{Err: errors.New("CA unavailable")} + svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + + _, err := svc.GetCACerts(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "CA unavailable") { + t.Errorf("expected error to contain 'CA unavailable', got: %v", err) + } +} + +func TestESTService_SimpleEnroll_Success(t *testing.T) { + mockIssuer := &mockIssuerConnector{} + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewESTService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + + csrPEM := generateCSRPEM(t, "test.example.com", []string{"test.example.com"}) + + result, err := svc.SimpleEnroll(context.Background(), csrPEM) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if result.CertPEM == "" { + t.Error("expected non-empty CertPEM") + } + + // Verify audit event was recorded + if len(auditRepo.Events) == 0 { + t.Error("expected audit event to be recorded") + } +} + +func TestESTService_SimpleEnroll_InvalidCSR(t *testing.T) { + mockIssuer := &mockIssuerConnector{} + svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + + _, err := svc.SimpleEnroll(context.Background(), "not-valid-pem") + if err == nil { + t.Fatal("expected error for invalid CSR") + } +} + +func TestESTService_SimpleEnroll_MissingCN(t *testing.T) { + mockIssuer := &mockIssuerConnector{} + svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + + csrPEM := generateCSRPEM(t, "", []string{"test.example.com"}) + + _, err := svc.SimpleEnroll(context.Background(), csrPEM) + if err == nil { + t.Fatal("expected error for missing CN") + } + if !strings.Contains(err.Error(), "Common Name") { + t.Errorf("expected 'Common Name' in error, got: %v", err) + } +} + +func TestESTService_SimpleEnroll_IssuerError(t *testing.T) { + mockIssuer := &mockIssuerConnector{Err: errors.New("issuance failed")} + svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + + csrPEM := generateCSRPEM(t, "test.example.com", nil) + + _, err := svc.SimpleEnroll(context.Background(), csrPEM) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "issuance failed") { + t.Errorf("expected 'issuance failed', got: %v", err) + } +} + +func TestESTService_SimpleReEnroll_Success(t *testing.T) { + mockIssuer := &mockIssuerConnector{} + svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + + csrPEM := generateCSRPEM(t, "renew.example.com", []string{"renew.example.com"}) + + result, err := svc.SimpleReEnroll(context.Background(), csrPEM) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestESTService_GetCSRAttrs_Empty(t *testing.T) { + mockIssuer := &mockIssuerConnector{} + svc := NewESTService("iss-local", mockIssuer, nil, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + + attrs, err := svc.GetCSRAttrs(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if attrs != nil { + t.Errorf("expected nil attrs, got %v", attrs) + } +} + +func TestESTService_SimpleEnroll_WithProfile(t *testing.T) { + mockIssuer := &mockIssuerConnector{} + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewESTService("iss-local", mockIssuer, auditSvc, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + svc.SetProfileID("profile-wifi-client") + + csrPEM := generateCSRPEM(t, "device.example.com", nil) + + result, err := svc.SimpleEnroll(context.Background(), csrPEM) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + + // Verify audit event includes profile_id + if len(auditRepo.Events) == 0 { + t.Fatal("expected audit event") + } + lastEvent := auditRepo.Events[len(auditRepo.Events)-1] + if lastEvent.Details == nil { + t.Fatal("expected audit details") + } +} diff --git a/internal/service/issuer_adapter.go b/internal/service/issuer_adapter.go index 9028d2e..791a988 100644 --- a/internal/service/issuer_adapter.go +++ b/internal/service/issuer_adapter.go @@ -95,3 +95,8 @@ func (a *IssuerConnectorAdapter) SignOCSPResponse(ctx context.Context, req OCSPS NextUpdate: req.NextUpdate, }) } + +// GetCACertPEM delegates to the underlying connector. +func (a *IssuerConnectorAdapter) GetCACertPEM(ctx context.Context) (string, error) { + return a.connector.GetCACertPEM(ctx) +} diff --git a/internal/service/renewal.go b/internal/service/renewal.go index da60811..6d1f5b9 100644 --- a/internal/service/renewal.go +++ b/internal/service/renewal.go @@ -44,6 +44,8 @@ type IssuerConnector interface { GenerateCRL(ctx context.Context, revokedCerts []CRLEntry) ([]byte, error) // SignOCSPResponse signs an OCSP response for the given certificate serial. SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error) + // GetCACertPEM returns the PEM-encoded CA certificate chain for this issuer. + GetCACertPEM(ctx context.Context) (string, error) } // IssuanceResult holds the result of a certificate issuance or renewal operation. diff --git a/internal/service/testutil_test.go b/internal/service/testutil_test.go index 8b503f0..b296cb9 100644 --- a/internal/service/testutil_test.go +++ b/internal/service/testutil_test.go @@ -634,6 +634,13 @@ func (m *mockIssuerConnector) SignOCSPResponse(ctx context.Context, req OCSPSign return []byte("mock-ocsp-response"), nil } +func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error) { + if m.Err != nil { + return "", m.Err + } + return "-----BEGIN CERTIFICATE-----\nmock-ca-cert\n-----END CERTIFICATE-----", nil +} + // Constructor functions for mocks func newMockCertificateRepository() *mockCertRepo {