From c014c17cc69db52a0943a5bce28379ee80576f5b Mon Sep 17 00:00:00 2001 From: Shankar Date: Sat, 28 Mar 2026 16:16:19 -0400 Subject: [PATCH] feat(m27): certificate export (PEM/PKCS#12) and S/MIME EKU support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add certificate export in PEM (JSON or file download) and PKCS#12 formats. Private keys are never included — they stay on agents. Add EKU-aware issuance threading profile EKUs (serverAuth, clientAuth, codeSigning, emailProtection, timeStamping) through the full issuance pipeline. Fix agent CSR SAN splitting for email addresses, adaptive KeyUsage flags for S/MIME vs TLS, and a pre-existing generateID collision bug in deployment job creation. Co-Authored-By: Claude Opus 4.6 --- README.md | 15 +- api/openapi.yaml | 85 ++++++ cmd/agent/main.go | 14 +- cmd/server/main.go | 4 + docs/architecture.md | 2 + docs/connectors.md | 2 + docs/features.md | 80 +++++- go.mod | 1 + go.sum | 2 + internal/api/handler/export.go | 132 +++++++++ internal/api/handler/export_handler_test.go | 282 ++++++++++++++++++++ internal/api/router/router.go | 5 + internal/connector/issuer/interface.go | 2 + internal/connector/issuer/local/local.go | 90 ++++++- internal/service/agent.go | 15 +- internal/service/est.go | 3 +- internal/service/export.go | 185 +++++++++++++ internal/service/export_test.go | 220 +++++++++++++++ internal/service/issuer_adapter.go | 6 +- internal/service/issuer_adapter_test.go | 12 +- internal/service/renewal.go | 43 ++- internal/service/testutil_test.go | 6 +- migrations/seed_demo.sql | 8 + web/src/api/client.test.ts | 80 ++++++ web/src/api/client.ts | 27 ++ web/src/pages/CertificateDetailPage.tsx | 86 +++++- 26 files changed, 1354 insertions(+), 53 deletions(-) create mode 100644 internal/api/handler/export.go create mode 100644 internal/api/handler/export_handler_test.go create mode 100644 internal/service/export.go create mode 100644 internal/service/export_test.go diff --git a/README.md b/README.md index f2249ec..192c561 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ For a detailed comparison with CertKit, KeyTalk, and enterprise platforms (Venaf certctl gives you a single pane of glass for every TLS certificate in your organization: - **Web dashboard** — full certificate inventory with status, ownership, expiration heatmaps, and bulk operations -- **REST API** — 93 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation +- **REST API** — 95 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation - **Agents** — generate private keys locally, discover existing certs on disk, submit CSRs (private keys never leave your servers) - **Network scanner** — discovers certificates on TLS endpoints across CIDR ranges without requiring agents - **EST server** (RFC 7030) — device and WiFi certificate enrollment via industry-standard protocol @@ -350,7 +350,7 @@ make docker-clean # Stop + remove volumes ## API Overview -93 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml). +95 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml). ### Key Endpoints ``` @@ -358,6 +358,8 @@ make docker-clean # Stop + remove volumes GET /api/v1/certificates List (filter, sort, cursor, sparse fields) POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code +GET /api/v1/certificates/{id}/export/pem Export PEM (JSON or file download) +POST /api/v1/certificates/{id}/export/pkcs12 Export PKCS#12 bundle (no private key) GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown) @@ -457,7 +459,7 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector ### V2: Operational Maturity -18 milestones complete, 1100+ tests. See the [Feature Inventory](docs/features.md) for details on every capability. +21 milestones complete, 1100+ tests. See the [Feature Inventory](docs/features.md) for details on every capability. **What shipped (all ✅):** @@ -476,11 +478,8 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector - **Post-Deployment TLS Verification** — agent-side TLS probe confirms the target is serving the correct certificate by SHA-256 fingerprint match - **Traefik + Caddy Targets** — Traefik (file provider, auto-reload) and Caddy (Admin API hot-reload or file-based) - -**Coming next:** - -- **Certificate Export** (v2.1.x) — single-cert download in PFX/PKCS12, DER, and PEM formats -- **S/MIME Support** (v2.2.x) — profile EKU constraints for S/MIME (emailProtection), code signing, and custom EKUs +- **Certificate Export** — PEM (JSON or file download) and PKCS#12 formats, private keys never included (agent-side only), audit trail +- **S/MIME Support** — EKU-aware issuance (emailProtection, codeSigning, timeStamping), adaptive KeyUsage flags, email SAN routing ### V3: certctl Pro diff --git a/api/openapi.yaml b/api/openapi.yaml index a86cecc..9a2d086 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -367,6 +367,84 @@ paths: "500": $ref: "#/components/responses/InternalError" + # ─── Certificate Export ────────────────────────────────────────────── + /api/v1/certificates/{id}/export/pem: + get: + tags: [Certificates] + summary: Export certificate as PEM + description: | + Returns the certificate and its chain in PEM format. By default returns JSON + with cert_pem, chain_pem, and full_pem fields. Add ?download=true to get the + full PEM chain as a file download with Content-Disposition headers. + operationId: exportCertificatePEM + parameters: + - $ref: "#/components/parameters/resourceId" + - name: download + in: query + schema: + type: string + enum: ["true"] + description: Set to "true" to get a file download instead of JSON. + responses: + "200": + description: PEM export + content: + application/json: + schema: + type: object + properties: + cert_pem: + type: string + description: Leaf certificate PEM + chain_pem: + type: string + description: Intermediate/root chain PEM + full_pem: + type: string + description: Full PEM chain (cert + intermediates) + application/x-pem-file: + schema: + type: string + format: binary + description: Full PEM file (when download=true) + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" + + /api/v1/certificates/{id}/export/pkcs12: + post: + tags: [Certificates] + summary: Export certificate as PKCS#12 + description: | + Returns a PKCS#12 (.p12) bundle containing the certificate and chain. + Private keys are NOT included — they live on agents and never touch the control plane. + The bundle is encrypted with the provided password (or empty password if omitted). + operationId: exportCertificatePKCS12 + parameters: + - $ref: "#/components/parameters/resourceId" + requestBody: + content: + application/json: + schema: + type: object + properties: + password: + type: string + description: Password to encrypt the PKCS#12 bundle (can be empty) + responses: + "200": + description: PKCS#12 binary + content: + application/x-pkcs12: + schema: + type: string + format: binary + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" + # ─── CRL & OCSP ───────────────────────────────────────────────────── /api/v1/crl: get: @@ -2712,8 +2790,15 @@ components: type: integer allowed_ekus: type: array + description: Extended Key Usages to include in issued certificates items: type: string + enum: + - serverAuth + - clientAuth + - codeSigning + - emailProtection + - timeStamping required_san_patterns: type: array items: diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 31b7373..132b432 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -344,11 +344,23 @@ func (a *Agent) executeCSRJob(ctx context.Context, job JobItem) { } // Step 3: Create CSR with common name and SANs + // Split SANs into DNS names and email addresses for proper CSR encoding + var dnsNames []string + var emailAddresses []string + for _, san := range job.SANs { + if strings.Contains(san, "@") { + emailAddresses = append(emailAddresses, san) + } else { + dnsNames = append(dnsNames, san) + } + } + csrTemplate := &x509.CertificateRequest{ Subject: pkix.Name{ CommonName: job.CommonName, }, - DNSNames: job.SANs, + DNSNames: dnsNames, + EmailAddresses: emailAddresses, } csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey) diff --git a/cmd/server/main.go b/cmd/server/main.go index 02c6d51..84ca139 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -209,6 +209,7 @@ func main() { deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService) jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger) agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService) + agentService.SetProfileRepo(profileRepo) issuerService := service.NewIssuerService(issuerRepo, auditService) targetService := service.NewTargetService(targetRepo, auditService) profileService := service.NewProfileService(profileRepo, auditService) @@ -262,6 +263,8 @@ func main() { networkScanHandler := handler.NewNetworkScanHandler(networkScanService) verificationService := service.NewVerificationService(jobRepo, auditService, logger) verificationHandler := handler.NewVerificationHandler(verificationService) + exportService := service.NewExportService(certificateRepo, auditService) + exportHandler := handler.NewExportHandler(exportService) logger.Info("initialized all handlers") // Create context with cancellation @@ -315,6 +318,7 @@ func main() { Discovery: discoveryHandler, NetworkScan: networkScanHandler, Verification: verificationHandler, + Export: exportHandler, }) // Register EST (RFC 7030) handlers if enabled if cfg.EST.Enabled { diff --git a/docs/architecture.md b/docs/architecture.md index 1ebb2f5..48bb3ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -778,6 +778,8 @@ Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST Certificate revocation: `POST /api/v1/certificates/{id}/revoke` with optional `{"reason": "keyCompromise"}`. Supports RFC 5280 reason codes (unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn). Returns the updated certificate status. Best-effort issuer notification — the revocation succeeds even if the issuer connector is unavailable. A JSON-formatted CRL is available at `GET /api/v1/crl`, and a DER-encoded X.509 CRL signed by the issuing CA at `GET /api/v1/crl/{issuer_id}`. An embedded OCSP responder serves signed responses at `GET /api/v1/ocsp/{issuer_id}/{serial}`. Short-lived certificates (profile TTL < 1 hour) are exempt from CRL/OCSP — expiry is sufficient revocation. +Certificate export (M27): `GET /api/v1/certificates/{id}/export/pem` returns PEM-encoded certificate and chain, and `POST /api/v1/certificates/{id}/export/pkcs12` returns a PKCS#12 bundle (binary). Private keys are never exported — they remain on agents. All exports are audited with actor, timestamp, and format. + Health checks live outside the API prefix: `GET /health` and `GET /ready`. ## MCP Server diff --git a/docs/connectors.md b/docs/connectors.md index 67a4e2c..2be094f 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -147,6 +147,8 @@ The Local CA issuer signs certificates using Go's `crypto/x509` library. It supp **CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation via `GET /api/v1/crl/{issuer_id}` with 24-hour validity. An embedded OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}` returns signed OCSP responses for issued certificates (good/revoked/unknown status). Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials. +**Extended Key Usage (EKU) support (M27):** The Local CA respects EKU constraints from certificate profiles and adjusts key usage flags accordingly. For S/MIME certificates (emailProtection EKU), it uses `DigitalSignature | ContentCommitment` instead of the TLS default. For TLS certificates (serverAuth/clientAuth EKU), it uses `DigitalSignature | KeyEncipherment`. This enables support for multiple certificate types — TLS, S/MIME, code signing, timestamping — from a single CA. + Configuration: ```json { diff --git a/docs/features.md b/docs/features.md index 7b83aaf..aec8b6d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -78,7 +78,7 @@ curl -H "$AUTH" "$SERVER/api/v1/certificates?expires_before=2026-04-24T00:00:00Z | Domain | Endpoints | Key Operations | |--------|-----------|-----------------| -| **Certificates** | 11 | List, create, get, update (archive), versions, deployments, trigger renewal, trigger deployment, revoke | +| **Certificates** | 13 | List, create, get, update (archive), versions, deployments, trigger renewal, trigger deployment, revoke, export (PEM/PKCS#12) | | **CRL & OCSP** | 3 | JSON CRL, DER CRL per issuer, OCSP responder | | **Issuers** | 6 | List, create, get, update, delete, test connection | | **Targets** | 5 | List, create, get, update, delete | @@ -218,34 +218,86 @@ curl $SERVER/api/v1/ocsp/iss-local/ABC123DEF456 --- +## Certificate Export + +Operators need to export certificates for use in third-party systems or for compliance audits. certctl provides two export formats: PEM (cert + chain, JSON or file download) and PKCS#12 (cert + chain in a passwordless bundle for compatibility with systems like Java keystores and Windows certificate stores). + +**Important:** Private keys are never exported — they remain on agents where they were generated. This is a core security property. Exports only bundle the public certificate material (cert + chain). + +```bash +# Export as PEM (returns JSON with base64-encoded data + chain) +curl -H "$AUTH" "$SERVER/api/v1/certificates/mc-api-prod/export/pem" +# {"certificate_pem":"-----BEGIN CERTIFICATE-----\n...", "chain_pem":"-----BEGIN CERTIFICATE-----\n..."} + +# Export as PKCS#12 file (binary download, no password) +curl -H "$AUTH" "$SERVER/api/v1/certificates/mc-api-prod/export/pkcs12" > cert.p12 + +# Via CLI +certctl-cli certs export mc-api-prod --format pem --out cert.pem +certctl-cli certs export mc-api-prod --format pkcs12 --out cert.p12 +``` + +| Field | Details | +|-------|---------| +| **Formats** | PEM (text, cert + chain), PKCS#12 (binary, cert + chain, passwordless) | +| **Private Key Inclusion** | Never — private keys remain on agents | +| **Audit Trail** | All exports recorded with actor, timestamp, export format | +| **API Endpoints** | `GET /api/v1/certificates/{id}/export/pem`, `POST /api/v1/certificates/{id}/export/pkcs12` | +| **GUI** | Export PEM and Export PKCS#12 buttons on certificate detail page | + +--- + ## Certificate Profiles ### Profile Model -Named enrollment profiles defining certificate issuance constraints. Profiles prevent drift — without them, different teams might issue certs with inconsistent key sizes, TTLs, or key algorithms. A profile says "all certs in this category must use ECDSA P-256, max 90-day TTL, serverAuth EKU only." +Named enrollment profiles defining certificate issuance constraints. Profiles prevent drift — without them, different teams might issue certs with inconsistent key sizes, TTLs, or key algorithms. A profile says "all certs in this category must use ECDSA P-256, max 90-day TTL, serverAuth and clientAuth EKUs only." + +Profiles also support **Extended Key Usage (EKU)** constraints, enabling S/MIME and device certificates. Common EKUs: +- `serverAuth` — TLS server certificates (HTTPS, mail servers) +- `clientAuth` — TLS client certificates (mutual TLS, device auth) +- `emailProtection` — S/MIME signing and encryption +- `codeSigning` — Code signing and software updates +- `timeStamping` — Trusted timestamps ```bash -# Create a profile enforcing short-lived certs with ECDSA keys +# Create a TLS profile curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{ - "name": "Short-Lived Service Mesh", + "name": "Standard TLS", "allowed_key_algorithms": ["ECDSA"], - "max_ttl_hours": 1, + "max_ttl_hours": 2160, + "allowed_ekus": ["serverAuth"] +}' + +# Create an S/MIME profile +curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{ + "name": "S/MIME Email", + "allowed_key_algorithms": ["RSA", "ECDSA"], + "max_ttl_hours": 8760, + "allowed_ekus": ["emailProtection"] +}' + +# Create a multi-purpose profile +curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{ + "name": "Multi-Purpose", + "allowed_key_algorithms": ["ECDSA"], + "max_ttl_hours": 2160, "allowed_ekus": ["serverAuth", "clientAuth"] }' # Assign profile to a certificate curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/certificates/mc-api-prod -d '{ - "profile_id": "prof-short-lived" + "profile_id": "prof-standard-tls" }' # List all profiles -curl -H "$AUTH" "$SERVER/api/v1/profiles" | jq '.data[] | {id, name, max_ttl_hours, allowed_key_algorithms}' +curl -H "$AUTH" "$SERVER/api/v1/profiles" | jq '.data[] | {id, name, max_ttl_hours, allowed_key_algorithms, allowed_ekus}' # Get profile details curl -H "$AUTH" "$SERVER/api/v1/profiles/prof-standard-tls" | jq . # Update profile constraints curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles/prof-standard-tls -d '{ - "name": "Standard TLS", "max_ttl_hours": 2160, "allowed_key_algorithms": ["RSA", "ECDSA"] + "name": "Standard TLS", "max_ttl_hours": 2160, "allowed_key_algorithms": ["RSA", "ECDSA"], "allowed_ekus": ["serverAuth"] }' ``` @@ -255,14 +307,22 @@ curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles/prof-standard-tls -d '{ | **Name** | Human-readable profile name | | **Allowed Key Algorithms** | RSA, ECDSA, Ed25519 with minimum key sizes (e.g., RSA 2048+, ECDSA P-256+) | | **Max TTL** | Maximum certificate lifetime (days or duration) | -| **Allowed EKUs** | Extended key usage OIDs (serverAuth, clientAuth, etc.) | +| **Allowed EKUs** | Extended key usage OIDs (serverAuth, clientAuth, emailProtection, codeSigning, timeStamping) | | **Required SANs** | Mandatory Subject Alternative Names (patterns or fixed values) | | **Short-Lived Support** | TTL < 1 hour triggers CRL/OCSP exemption | ### GUI Management - Full CRUD page with profile details -- Crypto constraint badges visible in list view +- EKU constraint badges visible in list view (serverAuth, clientAuth, emailProtection, etc.) - Profile assignment dropdown on certificate detail +- S/MIME profile creation wizard with email SAN configuration + +### S/MIME Support +When a profile specifies `emailProtection` EKU, certctl adapts the issuance flow for email certificates: +- **SAN handling** — email addresses in SANs are formatted as `rfc822Name` (not DNS names) +- **Key usage** — S/MIME certs use `DigitalSignature | ContentCommitment` instead of the TLS default `DigitalSignature | KeyEncipherment` +- **Agent CSR generation** — agents correctly distinguish DNS SANs from email SANs based on profile EKU +- **Issuer constraints** — Local CA and other issuers thread EKUs through the signing pipeline --- diff --git a/go.mod b/go.mod index c5a6705..61dfb8b 100644 --- a/go.mod +++ b/go.mod @@ -63,4 +63,5 @@ require ( golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.40.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect ) diff --git a/go.sum b/go.sum index 904471c..1a895bb 100644 --- a/go.sum +++ b/go.sum @@ -210,3 +210,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0= +software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/api/handler/export.go b/internal/api/handler/export.go new file mode 100644 index 0000000..490e3cc --- /dev/null +++ b/internal/api/handler/export.go @@ -0,0 +1,132 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/shankar0123/certctl/internal/api/middleware" + "github.com/shankar0123/certctl/internal/service" +) + +// ExportService defines the service interface for certificate export operations. +type ExportService interface { + ExportPEM(ctx context.Context, certID string) (*service.ExportPEMResult, error) + ExportPKCS12(ctx context.Context, certID string, password string) ([]byte, error) +} + +// ExportHandler handles HTTP requests for certificate export operations. +type ExportHandler struct { + svc ExportService +} + +// NewExportHandler creates a new ExportHandler with a service dependency. +func NewExportHandler(svc ExportService) ExportHandler { + return ExportHandler{svc: svc} +} + +// ExportPEM exports a certificate and its chain in PEM format. +// GET /api/v1/certificates/{id}/export/pem +func (h ExportHandler) ExportPEM(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + requestID := middleware.GetRequestID(r.Context()) + + // Extract certificate ID from path: /api/v1/certificates/{id}/export/pem + id := extractCertIDFromExportPath(r.URL.Path) + if id == "" { + ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID) + return + } + + result, err := h.svc.ExportPEM(r.Context(), id) + if err != nil { + if strings.Contains(err.Error(), "not found") { + ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID) + return + } + ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export certificate", requestID) + return + } + + // Check if client wants file download via Accept header or ?download=true query param + if r.URL.Query().Get("download") == "true" { + w.Header().Set("Content-Type", "application/x-pem-file") + w.Header().Set("Content-Disposition", "attachment; filename=\"certificate.pem\"") + w.WriteHeader(http.StatusOK) + w.Write([]byte(result.FullPEM)) + return + } + + JSON(w, http.StatusOK, result) +} + +// ExportPKCS12 exports a certificate and chain in PKCS#12 format. +// POST /api/v1/certificates/{id}/export/pkcs12 +// Body: { "password": "optional-password" } +func (h ExportHandler) ExportPKCS12(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + requestID := middleware.GetRequestID(r.Context()) + + // Extract certificate ID from path: /api/v1/certificates/{id}/export/pkcs12 + id := extractCertIDFromExportPath(r.URL.Path) + if id == "" { + ErrorWithRequestID(w, http.StatusBadRequest, "Certificate ID is required", requestID) + return + } + + // Parse optional password from request body (may be empty) + var req struct { + Password string `json:"password"` + } + // Body is optional — empty body means empty password + _ = parseJSONBody(r, &req) + + pfxData, err := h.svc.ExportPKCS12(r.Context(), id, req.Password) + if err != nil { + if strings.Contains(err.Error(), "not found") { + ErrorWithRequestID(w, http.StatusNotFound, "Certificate not found", requestID) + return + } + ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to export PKCS#12", requestID) + return + } + + w.Header().Set("Content-Type", "application/x-pkcs12") + w.Header().Set("Content-Disposition", "attachment; filename=\"certificate.p12\"") + w.WriteHeader(http.StatusOK) + w.Write(pfxData) +} + +// extractCertIDFromExportPath extracts the certificate ID from an export path. +// Path format: /api/v1/certificates/{id}/export/pem or /api/v1/certificates/{id}/export/pkcs12 +func extractCertIDFromExportPath(path string) string { + prefix := "/api/v1/certificates/" + if !strings.HasPrefix(path, prefix) { + return "" + } + rest := strings.TrimPrefix(path, prefix) + // rest should be "{id}/export/pem" or "{id}/export/pkcs12" + parts := strings.Split(rest, "/") + if len(parts) < 3 || parts[1] != "export" { + return "" + } + return parts[0] +} + +// parseJSONBody is a helper that decodes JSON from the request body. +// Returns an error if the body is malformed, nil if body is empty. +func parseJSONBody(r *http.Request, v interface{}) error { + if r.Body == nil { + return nil + } + return json.NewDecoder(r.Body).Decode(v) +} diff --git a/internal/api/handler/export_handler_test.go b/internal/api/handler/export_handler_test.go new file mode 100644 index 0000000..5a62448 --- /dev/null +++ b/internal/api/handler/export_handler_test.go @@ -0,0 +1,282 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/shankar0123/certctl/internal/service" +) + +// MockExportService is a mock implementation of ExportService interface. +type MockExportService struct { + ExportPEMFn func(ctx context.Context, certID string) (*service.ExportPEMResult, error) + ExportPKCS12Fn func(ctx context.Context, certID string, password string) ([]byte, error) +} + +func (m *MockExportService) ExportPEM(ctx context.Context, certID string) (*service.ExportPEMResult, error) { + if m.ExportPEMFn != nil { + return m.ExportPEMFn(ctx, certID) + } + return nil, nil +} + +func (m *MockExportService) ExportPKCS12(ctx context.Context, certID string, password string) ([]byte, error) { + if m.ExportPKCS12Fn != nil { + return m.ExportPKCS12Fn(ctx, certID, password) + } + return nil, nil +} + +func TestExportPEM_Success(t *testing.T) { + mockSvc := &MockExportService{ + ExportPEMFn: func(_ context.Context, certID string) (*service.ExportPEMResult, error) { + if certID != "mc-test-1" { + t.Errorf("expected certID mc-test-1, got %s", certID) + } + return &service.ExportPEMResult{ + CertPEM: "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n", + ChainPEM: "-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n", + FullPEM: "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nBBB\n-----END CERTIFICATE-----\n", + }, nil + }, + } + h := NewExportHandler(mockSvc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pem", nil) + w := httptest.NewRecorder() + + h.ExportPEM(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("expected application/json content type, got %s", ct) + } + + var result service.ExportPEMResult + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if result.CertPEM == "" { + t.Error("expected non-empty CertPEM") + } + if result.ChainPEM == "" { + t.Error("expected non-empty ChainPEM") + } + if result.FullPEM == "" { + t.Error("expected non-empty FullPEM") + } +} + +func TestExportPEM_Download(t *testing.T) { + mockSvc := &MockExportService{ + ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) { + return &service.ExportPEMResult{ + CertPEM: "cert", + ChainPEM: "chain", + FullPEM: "full-pem-content", + }, nil + }, + } + h := NewExportHandler(mockSvc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pem?download=true", nil) + w := httptest.NewRecorder() + + h.ExportPEM(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/x-pem-file" { + t.Errorf("expected application/x-pem-file, got %s", ct) + } + if cd := w.Header().Get("Content-Disposition"); cd != `attachment; filename="certificate.pem"` { + t.Errorf("expected Content-Disposition attachment, got %s", cd) + } + if w.Body.String() != "full-pem-content" { + t.Errorf("expected full-pem-content body, got %s", w.Body.String()) + } +} + +func TestExportPEM_NotFound(t *testing.T) { + mockSvc := &MockExportService{ + ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) { + return nil, fmt.Errorf("certificate not found") + }, + } + h := NewExportHandler(mockSvc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/nonexistent/export/pem", nil) + w := httptest.NewRecorder() + + h.ExportPEM(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestExportPEM_ServiceError(t *testing.T) { + mockSvc := &MockExportService{ + ExportPEMFn: func(_ context.Context, _ string) (*service.ExportPEMResult, error) { + return nil, fmt.Errorf("internal error") + }, + } + h := NewExportHandler(mockSvc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pem", nil) + w := httptest.NewRecorder() + + h.ExportPEM(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } +} + +func TestExportPEM_MethodNotAllowed(t *testing.T) { + h := NewExportHandler(&MockExportService{}) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pem", nil) + w := httptest.NewRecorder() + + h.ExportPEM(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", w.Code) + } +} + +func TestExportPKCS12_Success(t *testing.T) { + pfxData := []byte{0x30, 0x82, 0x01, 0x00} // mock PKCS#12 data + mockSvc := &MockExportService{ + ExportPKCS12Fn: func(_ context.Context, certID string, password string) ([]byte, error) { + if certID != "mc-test-1" { + t.Errorf("expected certID mc-test-1, got %s", certID) + } + if password != "mysecret" { + t.Errorf("expected password mysecret, got %s", password) + } + return pfxData, nil + }, + } + h := NewExportHandler(mockSvc) + + body := strings.NewReader(`{"password":"mysecret"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", body) + w := httptest.NewRecorder() + + h.ExportPKCS12(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/x-pkcs12" { + t.Errorf("expected application/x-pkcs12, got %s", ct) + } + if cd := w.Header().Get("Content-Disposition"); cd != `attachment; filename="certificate.p12"` { + t.Errorf("expected Content-Disposition attachment, got %s", cd) + } + if len(w.Body.Bytes()) != len(pfxData) { + t.Errorf("expected %d bytes, got %d", len(pfxData), len(w.Body.Bytes())) + } +} + +func TestExportPKCS12_EmptyPassword(t *testing.T) { + mockSvc := &MockExportService{ + ExportPKCS12Fn: func(_ context.Context, _ string, password string) ([]byte, error) { + if password != "" { + t.Errorf("expected empty password, got %s", password) + } + return []byte{0x30}, nil + }, + } + h := NewExportHandler(mockSvc) + + // Empty body — password defaults to "" + req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", nil) + w := httptest.NewRecorder() + + h.ExportPKCS12(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestExportPKCS12_NotFound(t *testing.T) { + mockSvc := &MockExportService{ + ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) { + return nil, fmt.Errorf("certificate not found") + }, + } + h := NewExportHandler(mockSvc) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/nonexistent/export/pkcs12", nil) + w := httptest.NewRecorder() + + h.ExportPKCS12(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestExportPKCS12_ServiceError(t *testing.T) { + mockSvc := &MockExportService{ + ExportPKCS12Fn: func(_ context.Context, _ string, _ string) ([]byte, error) { + return nil, fmt.Errorf("encoding error") + }, + } + h := NewExportHandler(mockSvc) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-test-1/export/pkcs12", nil) + w := httptest.NewRecorder() + + h.ExportPKCS12(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } +} + +func TestExportPKCS12_MethodNotAllowed(t *testing.T) { + h := NewExportHandler(&MockExportService{}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-test-1/export/pkcs12", nil) + w := httptest.NewRecorder() + + h.ExportPKCS12(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", w.Code) + } +} + +func TestExtractCertIDFromExportPath(t *testing.T) { + tests := []struct { + path string + expected string + }{ + {"/api/v1/certificates/mc-test-1/export/pem", "mc-test-1"}, + {"/api/v1/certificates/mc-api-prod/export/pkcs12", "mc-api-prod"}, + {"/api/v1/certificates//export/pem", ""}, + {"/api/v1/other/mc-test-1/export/pem", ""}, + {"/api/v1/certificates/mc-test-1", ""}, + {"", ""}, + } + + for _, tt := range tests { + got := extractCertIDFromExportPath(tt.path) + if got != tt.expected { + t.Errorf("extractCertIDFromExportPath(%q) = %q, want %q", tt.path, got, tt.expected) + } + } +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index bd155f1..7634741 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -63,6 +63,7 @@ type HandlerRegistry struct { Discovery handler.DiscoveryHandler NetworkScan handler.NetworkScanHandler Verification handler.VerificationHandler + Export handler.ExportHandler } // RegisterHandlers sets up all API routes with their handlers. @@ -99,6 +100,10 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) { r.Register("POST /api/v1/certificates/{id}/deploy", http.HandlerFunc(reg.Certificates.TriggerDeployment)) r.Register("POST /api/v1/certificates/{id}/revoke", http.HandlerFunc(reg.Certificates.RevokeCertificate)) + // Export endpoints: /api/v1/certificates/{id}/export/{format} + r.Register("GET /api/v1/certificates/{id}/export/pem", http.HandlerFunc(reg.Export.ExportPEM)) + r.Register("POST /api/v1/certificates/{id}/export/pkcs12", http.HandlerFunc(reg.Export.ExportPKCS12)) + // CRL endpoints: /api/v1/crl (JSON) and /api/v1/crl/{issuer_id} (DER) r.Register("GET /api/v1/crl", http.HandlerFunc(reg.Certificates.GetCRL)) r.Register("GET /api/v1/crl/{issuer_id}", http.HandlerFunc(reg.Certificates.GetDERCRL)) diff --git a/internal/connector/issuer/interface.go b/internal/connector/issuer/interface.go index d534163..822feeb 100644 --- a/internal/connector/issuer/interface.go +++ b/internal/connector/issuer/interface.go @@ -42,6 +42,7 @@ type IssuanceRequest struct { CommonName string `json:"common_name"` SANs []string `json:"sans"` CSRPEM string `json:"csr_pem"` + EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection" } // IssuanceResult contains the result of a successful certificate issuance. @@ -59,6 +60,7 @@ type RenewalRequest struct { CommonName string `json:"common_name"` SANs []string `json:"sans"` CSRPEM string `json:"csr_pem"` + EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection" OrderID *string `json:"order_id,omitempty"` } diff --git a/internal/connector/issuer/local/local.go b/internal/connector/issuer/local/local.go index 432cf04..84ddd49 100644 --- a/internal/connector/issuer/local/local.go +++ b/internal/connector/issuer/local/local.go @@ -184,8 +184,8 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc return nil, fmt.Errorf("CSR signature verification failed: %w", err) } - // Generate certificate - cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs) + // Generate certificate with EKUs from request + cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs) if err != nil { c.logger.Error("failed to generate certificate", "error", err) return nil, fmt.Errorf("certificate generation failed: %w", err) @@ -242,8 +242,8 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal return nil, fmt.Errorf("CSR signature verification failed: %w", err) } - // Generate certificate - cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs) + // Generate certificate with EKUs from request + cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs) if err != nil { c.logger.Error("failed to generate certificate", "error", err) return nil, fmt.Errorf("certificate generation failed: %w", err) @@ -467,7 +467,8 @@ func parsePrivateKey(block *pem.Block) (crypto.Signer, error) { // generateCertificate creates an X.509 certificate signed by the local CA. // It uses the CSR subject and adds any additional SANs from the request. -func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string) (*x509.Certificate, string, string, error) { +// If ekus is non-empty, those EKUs are used instead of the default serverAuth+clientAuth. +func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string, ekus []string) (*x509.Certificate, string, string, error) { // Generate random serial number serialNum, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159)) if err != nil { @@ -506,18 +507,18 @@ func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additional } } + // Resolve EKUs: use provided list or fall back to default TLS EKUs + resolvedEKUs, keyUsage := resolveEKUsAndKeyUsage(ekus) + // Create certificate template now := time.Now() template := &x509.Certificate{ - SerialNumber: serialNum, - Subject: csr.Subject, - NotBefore: now, - NotAfter: now.AddDate(0, 0, c.config.ValidityDays), - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, - ExtKeyUsage: []x509.ExtKeyUsage{ - x509.ExtKeyUsageServerAuth, - x509.ExtKeyUsageClientAuth, - }, + SerialNumber: serialNum, + Subject: csr.Subject, + NotBefore: now, + NotAfter: now.AddDate(0, 0, c.config.ValidityDays), + KeyUsage: keyUsage, + ExtKeyUsage: resolvedEKUs, DNSNames: dnsNames, EmailAddresses: emails, SubjectKeyId: hashPublicKey(csr.PublicKey), @@ -580,6 +581,67 @@ func isEmail(s string) bool { return false } +// ekuNameToX509 maps EKU string names (from domain.ValidEKUs) to x509.ExtKeyUsage constants. +var ekuNameToX509 = map[string]x509.ExtKeyUsage{ + "serverAuth": x509.ExtKeyUsageServerAuth, + "clientAuth": x509.ExtKeyUsageClientAuth, + "codeSigning": x509.ExtKeyUsageCodeSigning, + "emailProtection": x509.ExtKeyUsageEmailProtection, + "timeStamping": x509.ExtKeyUsageTimeStamping, +} + +// resolveEKUsAndKeyUsage maps EKU string names to x509.ExtKeyUsage constants and computes +// appropriate KeyUsage flags. If ekus is empty/nil, falls back to default TLS EKUs. +// +// Key usage selection: +// - TLS (serverAuth/clientAuth): DigitalSignature | KeyEncipherment +// - S/MIME (emailProtection): DigitalSignature | ContentCommitment (for non-repudiation) +// - Mixed: union of both +func resolveEKUsAndKeyUsage(ekus []string) ([]x509.ExtKeyUsage, x509.KeyUsage) { + if len(ekus) == 0 { + // Default: TLS server + client + return []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment + } + + var resolved []x509.ExtKeyUsage + hasEmail := false + hasTLS := false + + for _, name := range ekus { + if eku, ok := ekuNameToX509[name]; ok { + resolved = append(resolved, eku) + if name == "emailProtection" { + hasEmail = true + } + if name == "serverAuth" || name == "clientAuth" { + hasTLS = true + } + } + } + + // If no valid EKUs were resolved, fall back to default + if len(resolved) == 0 { + return []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment + } + + // Compute KeyUsage based on EKU mix + keyUsage := x509.KeyUsageDigitalSignature + if hasTLS { + keyUsage |= x509.KeyUsageKeyEncipherment + } + if hasEmail { + keyUsage |= x509.KeyUsageContentCommitment // non-repudiation for S/MIME + } + + return resolved, keyUsage +} + // hashPublicKey generates a subject key identifier from a public key. func hashPublicKey(pub interface{}) []byte { h := sha256.New() diff --git a/internal/service/agent.go b/internal/service/agent.go index 285937c..9a64482 100644 --- a/internal/service/agent.go +++ b/internal/service/agent.go @@ -19,6 +19,7 @@ type AgentService struct { certRepo repository.CertificateRepository jobRepo repository.JobRepository targetRepo repository.TargetRepository + profileRepo repository.CertificateProfileRepository auditService *AuditService issuerRegistry map[string]IssuerConnector renewalService *RenewalService @@ -45,6 +46,11 @@ func NewAgentService( } } +// SetProfileRepo sets the profile repository for EKU resolution during CSR signing. +func (s *AgentService) SetProfileRepo(repo repository.CertificateProfileRepository) { + s.profileRepo = repo +} + // Register creates a new agent and returns its API key (only once). func (s *AgentService) Register(ctx context.Context, name string, hostname string) (*domain.Agent, string, error) { if name == "" || hostname == "" { @@ -159,7 +165,14 @@ func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID str // Fallback: direct issuer signing (no AwaitingCSR job — ad-hoc CSR submission) connector, ok := s.issuerRegistry[cert.IssuerID] if ok { - result, err := connector.IssueCertificate(ctx, cert.CommonName, cert.SANs, string(csrPEM)) + // Resolve EKUs from the certificate profile if available + var ekus []string + if cert.CertificateProfileID != "" && s.profileRepo != nil { + if profile, profileErr := s.profileRepo.Get(ctx, cert.CertificateProfileID); profileErr == nil && profile != nil { + ekus = profile.AllowedEKUs + } + } + result, err := connector.IssueCertificate(ctx, cert.CommonName, cert.SANs, string(csrPEM), ekus) if err != nil { return fmt.Errorf("issuer signing failed: %w", err) } diff --git a/internal/service/est.go b/internal/service/est.go index 3cb61b8..84cc4ad 100644 --- a/internal/service/est.go +++ b/internal/service/est.go @@ -116,7 +116,8 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit "issuer", s.issuerID) // Issue the certificate via the configured issuer connector - result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM) + // EST enrollments use default EKUs (nil = serverAuth + clientAuth fallback in connector) + result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, nil) if err != nil { s.logger.Error("EST enrollment failed", "action", auditAction, diff --git a/internal/service/export.go b/internal/service/export.go new file mode 100644 index 0000000..f010784 --- /dev/null +++ b/internal/service/export.go @@ -0,0 +1,185 @@ +package service + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "log/slog" + + "github.com/shankar0123/certctl/internal/domain" + "github.com/shankar0123/certctl/internal/repository" + "software.sslmate.com/src/go-pkcs12" +) + +// ExportService provides certificate export functionality (PEM and PKCS#12). +type ExportService struct { + certRepo repository.CertificateRepository + auditService *AuditService +} + +// NewExportService creates a new export service. +func NewExportService( + certRepo repository.CertificateRepository, + auditService *AuditService, +) *ExportService { + return &ExportService{ + certRepo: certRepo, + auditService: auditService, + } +} + +// ExportPEMResult contains the PEM-encoded certificate chain. +type ExportPEMResult struct { + CertPEM string `json:"cert_pem"` + ChainPEM string `json:"chain_pem"` + FullPEM string `json:"full_pem"` // cert + chain concatenated +} + +// ExportPEM returns the PEM-encoded certificate and chain for the latest version. +func (s *ExportService) ExportPEM(ctx context.Context, certID string) (*ExportPEMResult, error) { + // Verify certificate exists + cert, err := s.certRepo.Get(ctx, certID) + if err != nil { + return nil, fmt.Errorf("certificate not found: %w", err) + } + + // Get latest version (contains the PEM chain) + version, err := s.certRepo.GetLatestVersion(ctx, certID) + if err != nil { + return nil, fmt.Errorf("no certificate version found: %w", err) + } + + // Split PEM chain into leaf cert + chain + certPEM, chainPEM := splitPEMChain(version.PEMChain) + + // Audit the export + if s.auditService != nil { + if auditErr := s.auditService.RecordEvent(ctx, "api", domain.ActorTypeUser, + "export_pem", "certificate", cert.ID, + map[string]interface{}{"serial": version.SerialNumber}); auditErr != nil { + slog.Error("failed to record audit event", "error", auditErr) + } + } + + return &ExportPEMResult{ + CertPEM: certPEM, + ChainPEM: chainPEM, + FullPEM: version.PEMChain, + }, nil +} + +// ExportPKCS12 returns a PKCS#12 bundle containing the certificate chain. +// The private key is NOT included — it lives on the agent and never touches the control plane. +// The PKCS#12 bundle is encrypted with the provided password (can be empty for cert-only bundles). +func (s *ExportService) ExportPKCS12(ctx context.Context, certID string, password string) ([]byte, error) { + // Verify certificate exists + cert, err := s.certRepo.Get(ctx, certID) + if err != nil { + return nil, fmt.Errorf("certificate not found: %w", err) + } + + // Get latest version + version, err := s.certRepo.GetLatestVersion(ctx, certID) + if err != nil { + return nil, fmt.Errorf("no certificate version found: %w", err) + } + + // Parse PEM chain into x509.Certificate objects + certs, err := parsePEMCertificates(version.PEMChain) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate chain: %w", err) + } + + if len(certs) == 0 { + return nil, fmt.Errorf("no certificates found in PEM chain") + } + + // Build PKCS#12 bundle: leaf cert + CA chain (no private key) + leaf := certs[0] + var caCerts []*x509.Certificate + if len(certs) > 1 { + caCerts = certs[1:] + } + + // Encode as PKCS#12 trust store (cert-only bundle, no private key) + pfxData, err := encodePKCS12CertOnly(leaf, caCerts, password) + if err != nil { + return nil, fmt.Errorf("failed to encode PKCS#12: %w", err) + } + + // Audit the export + if s.auditService != nil { + if auditErr := s.auditService.RecordEvent(ctx, "api", domain.ActorTypeUser, + "export_pkcs12", "certificate", cert.ID, + map[string]interface{}{"serial": version.SerialNumber, "has_private_key": false}); auditErr != nil { + slog.Error("failed to record audit event", "error", auditErr) + } + } + + return pfxData, nil +} + +// encodePKCS12CertOnly creates a PKCS#12 bundle with certificate(s) but no private key. +// Uses the go-pkcs12 library's Modern encoder for strong encryption. +func encodePKCS12CertOnly(leaf *x509.Certificate, caCerts []*x509.Certificate, password string) ([]byte, error) { + // go-pkcs12's Modern.Encode expects a private key; for cert-only bundles we use + // EncodeTrustStore which stores certs as trusted entries. + // Include the leaf in the trust store alongside CA certs. + allCerts := make([]*x509.Certificate, 0, 1+len(caCerts)) + allCerts = append(allCerts, leaf) + allCerts = append(allCerts, caCerts...) + return pkcs12.Modern.EncodeTrustStore(allCerts, password) +} + +// splitPEMChain splits a PEM chain into the first certificate (leaf) and remaining chain. +func splitPEMChain(fullPEM string) (string, string) { + data := []byte(fullPEM) + var blocks []*pem.Block + for { + var block *pem.Block + block, data = pem.Decode(data) + if block == nil { + break + } + if block.Type == "CERTIFICATE" { + blocks = append(blocks, block) + } + } + + if len(blocks) == 0 { + return fullPEM, "" + } + + certPEM := string(pem.EncodeToMemory(blocks[0])) + var chainPEM string + for i := 1; i < len(blocks); i++ { + chainPEM += string(pem.EncodeToMemory(blocks[i])) + } + + return certPEM, chainPEM +} + +// parsePEMCertificates parses all certificates from a PEM-encoded string. +func parsePEMCertificates(pemData string) ([]*x509.Certificate, error) { + var certs []*x509.Certificate + data := []byte(pemData) + + for { + var block *pem.Block + block, data = pem.Decode(data) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + certs = append(certs, cert) + } + + return certs, nil +} diff --git a/internal/service/export_test.go b/internal/service/export_test.go new file mode 100644 index 0000000..997dce6 --- /dev/null +++ b/internal/service/export_test.go @@ -0,0 +1,220 @@ +package service + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/domain" +) + +// generateTestCertPEM creates a self-signed test certificate PEM for export tests. +func generateTestCertPEM(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.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "Test Cert", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("failed to create cert: %v", err) + } + + return string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + })) +} + +func newMockCertRepoWithVersion(certID string, cert *domain.ManagedCertificate, version *domain.CertificateVersion) *mockCertRepo { + repo := &mockCertRepo{ + Certs: make(map[string]*domain.ManagedCertificate), + Versions: make(map[string][]*domain.CertificateVersion), + } + if cert != nil { + repo.Certs[certID] = cert + } + if version != nil { + repo.Versions[certID] = []*domain.CertificateVersion{version} + } + return repo +} + +func TestExportPEM_Success(t *testing.T) { + certPEM := "-----BEGIN CERTIFICATE-----\nMIIBxz...\n-----END CERTIFICATE-----\n" + chainPEM := "-----BEGIN CERTIFICATE-----\nMIIByz...\n-----END CERTIFICATE-----\n" + fullPEM := certPEM + chainPEM + + certRepo := newMockCertRepoWithVersion("mc-test-1", + &domain.ManagedCertificate{ + ID: "mc-test-1", + CommonName: "test.example.com", + Status: domain.CertificateStatusActive, + }, + &domain.CertificateVersion{ + ID: "cv-1", + CertificateID: "mc-test-1", + SerialNumber: "abc123", + PEMChain: fullPEM, + }, + ) + auditSvc := &AuditService{auditRepo: &mockAuditRepo{}} + svc := NewExportService(certRepo, auditSvc) + + result, err := svc.ExportPEM(context.Background(), "mc-test-1") + if err != nil { + t.Fatalf("ExportPEM failed: %v", err) + } + if result.FullPEM == "" { + t.Error("expected non-empty FullPEM") + } + if result.CertPEM == "" { + t.Error("expected non-empty CertPEM") + } +} + +func TestExportPEM_CertNotFound(t *testing.T) { + certRepo := &mockCertRepo{ + Certs: make(map[string]*domain.ManagedCertificate), + Versions: make(map[string][]*domain.CertificateVersion), + } + svc := NewExportService(certRepo, nil) + + _, err := svc.ExportPEM(context.Background(), "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent certificate") + } +} + +func TestExportPEM_NoVersion(t *testing.T) { + certRepo := newMockCertRepoWithVersion("mc-test-1", + &domain.ManagedCertificate{ + ID: "mc-test-1", + CommonName: "test.example.com", + }, + nil, // no version + ) + svc := NewExportService(certRepo, nil) + + _, err := svc.ExportPEM(context.Background(), "mc-test-1") + if err == nil { + t.Fatal("expected error when no version exists") + } +} + +func TestExportPKCS12_Success(t *testing.T) { + testCertPEM := generateTestCertPEM(t) + + certRepo := newMockCertRepoWithVersion("mc-test-1", + &domain.ManagedCertificate{ + ID: "mc-test-1", + CommonName: "test.example.com", + Status: domain.CertificateStatusActive, + }, + &domain.CertificateVersion{ + ID: "cv-1", + CertificateID: "mc-test-1", + SerialNumber: "abc123", + PEMChain: testCertPEM, + }, + ) + auditSvc := &AuditService{auditRepo: &mockAuditRepo{}} + svc := NewExportService(certRepo, auditSvc) + + pfxData, err := svc.ExportPKCS12(context.Background(), "mc-test-1", "testpass") + if err != nil { + t.Fatalf("ExportPKCS12 failed: %v", err) + } + if len(pfxData) == 0 { + t.Error("expected non-empty PKCS#12 data") + } +} + +func TestExportPKCS12_EmptyPassword(t *testing.T) { + testCertPEM := generateTestCertPEM(t) + + certRepo := newMockCertRepoWithVersion("mc-test-1", + &domain.ManagedCertificate{ID: "mc-test-1"}, + &domain.CertificateVersion{ + ID: "cv-1", + CertificateID: "mc-test-1", + PEMChain: testCertPEM, + }, + ) + svc := NewExportService(certRepo, nil) + + pfxData, err := svc.ExportPKCS12(context.Background(), "mc-test-1", "") + if err != nil { + t.Fatalf("ExportPKCS12 with empty password failed: %v", err) + } + if len(pfxData) == 0 { + t.Error("expected non-empty PKCS#12 data") + } +} + +func TestExportPKCS12_CertNotFound(t *testing.T) { + certRepo := &mockCertRepo{ + Certs: make(map[string]*domain.ManagedCertificate), + Versions: make(map[string][]*domain.CertificateVersion), + } + svc := NewExportService(certRepo, nil) + + _, err := svc.ExportPKCS12(context.Background(), "nonexistent", "pass") + if err == nil { + t.Fatal("expected error for nonexistent certificate") + } +} + +func TestSplitPEMChain_TwoCerts(t *testing.T) { + cert1 := "-----BEGIN CERTIFICATE-----\nAAA=\n-----END CERTIFICATE-----\n" + cert2 := "-----BEGIN CERTIFICATE-----\nBBB=\n-----END CERTIFICATE-----\n" + + certPEM, chainPEM := splitPEMChain(cert1 + cert2) + if certPEM == "" { + t.Error("expected non-empty certPEM") + } + if chainPEM == "" { + t.Error("expected non-empty chainPEM") + } +} + +func TestSplitPEMChain_SingleCert(t *testing.T) { + cert1 := "-----BEGIN CERTIFICATE-----\nAAA=\n-----END CERTIFICATE-----\n" + + certPEM, chainPEM := splitPEMChain(cert1) + if certPEM == "" { + t.Error("expected non-empty certPEM") + } + if chainPEM != "" { + t.Errorf("expected empty chainPEM, got %q", chainPEM) + } +} + +func TestSplitPEMChain_EmptyInput(t *testing.T) { + certPEM, chainPEM := splitPEMChain("") + if certPEM != "" { + t.Errorf("expected empty certPEM for empty input, got %q", certPEM) + } + if chainPEM != "" { + t.Errorf("expected empty chainPEM for empty input, got %q", chainPEM) + } +} diff --git a/internal/service/issuer_adapter.go b/internal/service/issuer_adapter.go index 791a988..0d74ef6 100644 --- a/internal/service/issuer_adapter.go +++ b/internal/service/issuer_adapter.go @@ -20,11 +20,12 @@ func NewIssuerConnectorAdapter(c issuer.Connector) IssuerConnector { // IssueCertificate delegates to the underlying connector's IssueCertificate method, // translating between service-layer and connector-layer types. -func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) { +func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) { result, err := a.connector.IssueCertificate(ctx, issuer.IssuanceRequest{ CommonName: commonName, SANs: sans, CSRPEM: csrPEM, + EKUs: ekus, }) if err != nil { return nil, err @@ -40,11 +41,12 @@ func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonNam // RenewCertificate delegates to the underlying connector's RenewCertificate method, // translating between service-layer and connector-layer types. -func (a *IssuerConnectorAdapter) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) { +func (a *IssuerConnectorAdapter) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) { result, err := a.connector.RenewCertificate(ctx, issuer.RenewalRequest{ CommonName: commonName, SANs: sans, CSRPEM: csrPEM, + EKUs: ekus, }) if err != nil { return nil, err diff --git a/internal/service/issuer_adapter_test.go b/internal/service/issuer_adapter_test.go index 23d2787..e75ba5d 100644 --- a/internal/service/issuer_adapter_test.go +++ b/internal/service/issuer_adapter_test.go @@ -120,7 +120,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_Success(t *testing.T) { adapter := NewIssuerConnectorAdapter(mock) - result, err := adapter.IssueCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----") + result, err := adapter.IssueCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----", nil) if err != nil { t.Fatalf("IssueCertificate failed: %v", err) @@ -157,7 +157,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_Error(t *testing.T) { adapter := NewIssuerConnectorAdapter(mock) - result, err := adapter.IssueCertificate(ctx, "example.com", []string{}, "csr") + result, err := adapter.IssueCertificate(ctx, "example.com", []string{}, "csr", nil) if err == nil { t.Fatal("expected error, got nil") @@ -191,7 +191,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_RequestTranslation(t *testing.T sans := []string{"www.test.example.com", "api.test.example.com"} csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----" - _, err := adapter.IssueCertificate(ctx, commonName, sans, csrPEM) + _, err := adapter.IssueCertificate(ctx, commonName, sans, csrPEM, nil) if err != nil { t.Fatalf("IssueCertificate failed: %v", err) @@ -241,7 +241,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_Success(t *testing.T) { adapter := NewIssuerConnectorAdapter(mock) - result, err := adapter.RenewCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----") + result, err := adapter.RenewCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----", nil) if err != nil { t.Fatalf("RenewCertificate failed: %v", err) @@ -278,7 +278,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_Error(t *testing.T) { adapter := NewIssuerConnectorAdapter(mock) - result, err := adapter.RenewCertificate(ctx, "example.com", []string{}, "csr") + result, err := adapter.RenewCertificate(ctx, "example.com", []string{}, "csr", nil) if err == nil { t.Fatal("expected error, got nil") @@ -312,7 +312,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_RequestTranslation(t *testing.T sans := []string{"www.renew.example.com"} csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nRENEW-CSR\n-----END CERTIFICATE REQUEST-----" - _, err := adapter.RenewCertificate(ctx, commonName, sans, csrPEM) + _, err := adapter.RenewCertificate(ctx, commonName, sans, csrPEM, nil) if err != nil { t.Fatalf("RenewCertificate failed: %v", err) diff --git a/internal/service/renewal.go b/internal/service/renewal.go index 6d1f5b9..7823e5a 100644 --- a/internal/service/renewal.go +++ b/internal/service/renewal.go @@ -12,6 +12,8 @@ import ( "fmt" "log/slog" "math/big" + "strings" + "sync/atomic" "time" "github.com/shankar0123/certctl/internal/domain" @@ -35,9 +37,9 @@ type RenewalService struct { // inversion. Use IssuerConnectorAdapter to bridge between the two. type IssuerConnector interface { // IssueCertificate issues a new certificate using the provided CSR PEM. - IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) + IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) // RenewCertificate renews a certificate using the provided CSR PEM. - RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) + RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) // RevokeCertificate revokes a certificate by serial number with an optional reason. RevokeCertificate(ctx context.Context, serial string, reason string) error // GenerateCRL generates a DER-encoded X.509 CRL from the given revocation entries. @@ -348,11 +350,23 @@ func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *do return fmt.Errorf("failed to generate private key: %w", err) } + // Split SANs into DNS names and email addresses for proper CSR encoding + var csrDNSNames []string + var csrEmailAddresses []string + for _, san := range cert.SANs { + if strings.Contains(san, "@") { + csrEmailAddresses = append(csrEmailAddresses, san) + } else { + csrDNSNames = append(csrDNSNames, san) + } + } + csrTemplate := &x509.CertificateRequest{ Subject: pkix.Name{ CommonName: cert.CommonName, }, - DNSNames: cert.SANs, + DNSNames: csrDNSNames, + EmailAddresses: csrEmailAddresses, } csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey) @@ -372,8 +386,16 @@ func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *do Bytes: x509.MarshalPKCS1PrivateKey(privKey), })) + // Resolve EKUs from the certificate profile + var ekus []string + if cert.CertificateProfileID != "" && s.profileRepo != nil { + if profile, profileErr := s.profileRepo.Get(ctx, cert.CertificateProfileID); profileErr == nil && profile != nil { + ekus = profile.AllowedEKUs + } + } + // Call issuer connector to renew - result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM) + result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM, ekus) if err != nil { s.failJob(ctx, job, fmt.Sprintf("issuer renewal failed: %v", err)) if notifErr := s.notificationSvc.SendRenewalNotification(ctx, cert, false, err); notifErr != nil { @@ -480,8 +502,14 @@ func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domai return fmt.Errorf("failed to update job status: %w", err) } + // Resolve EKUs from the certificate profile (for S/MIME, email certs, etc.) + var ekus []string + if profile != nil && len(profile.AllowedEKUs) > 0 { + ekus = profile.AllowedEKUs + } + // Sign the agent-submitted CSR via issuer - result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM) + result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM, ekus) if err != nil { s.failJob(ctx, job, fmt.Sprintf("issuer signing failed: %v", err)) if notifErr := s.notificationSvc.SendRenewalNotification(ctx, cert, false, err); notifErr != nil { @@ -708,6 +736,9 @@ func (s *RenewalService) ExpireShortLivedCertificates(ctx context.Context) error } // generateID is a helper to generate unique IDs. In production, use a proper ID generator. +var idCounter atomic.Int64 + func generateID(prefix string) string { - return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) + counter := idCounter.Add(1) + return fmt.Sprintf("%s-%d-%d", prefix, time.Now().UnixNano(), counter) } diff --git a/internal/service/testutil_test.go b/internal/service/testutil_test.go index 0370508..27fa2ea 100644 --- a/internal/service/testutil_test.go +++ b/internal/service/testutil_test.go @@ -589,7 +589,7 @@ type mockIssuerConnector struct { Err error } -func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) { +func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) { if m.Err != nil { return nil, m.Err } @@ -606,11 +606,11 @@ func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName s }, nil } -func (m *mockIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) { +func (m *mockIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) { if m.Err != nil { return nil, m.Err } - return m.IssueCertificate(ctx, commonName, sans, csrPEM) + return m.IssueCertificate(ctx, commonName, sans, csrPEM, ekus) } func (m *mockIssuerConnector) RevokeCertificate(ctx context.Context, serial string, reason string) error { diff --git a/migrations/seed_demo.sql b/migrations/seed_demo.sql index 6cc9b62..ccd70f9 100644 --- a/migrations/seed_demo.sql +++ b/migrations/seed_demo.sql @@ -87,6 +87,14 @@ INSERT INTO certificate_profiles (id, name, description, allowed_key_algorithms, 4060800, -- 47 days (Ballot SC-081v3 target) '["serverAuth"]'::jsonb, '[".*\\.example\\.com$"]'::jsonb, + '', false, true, NOW(), NOW()), + + ('prof-smime', 'S/MIME Email', + 'S/MIME certificate profile for email signing and encryption. Requires emailProtection EKU.', + '[{"algorithm": "ECDSA", "min_size": 256}, {"algorithm": "RSA", "min_size": 2048}]'::jsonb, + 31536000, -- 365 days + '["emailProtection"]'::jsonb, + '[]'::jsonb, '', false, true, NOW(), NOW()) ON CONFLICT (id) DO NOTHING; diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 33a989a..13eceaa 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -11,6 +11,9 @@ import { updateCertificate, archiveCertificate, revokeCertificate, + exportCertificatePEM, + downloadCertificatePEM, + exportCertificatePKCS12, getAgents, getAgent, registerAgent, @@ -798,4 +801,81 @@ describe('API Client', () => { expect(init.method).toBe('POST'); }); }); + + // ─── Certificate Export ──────────────────────────────── + + describe('Certificate Export', () => { + it('exportCertificatePEM fetches PEM data as JSON', async () => { + const pemResult = { cert_pem: 'CERT', chain_pem: 'CHAIN', full_pem: 'FULL' }; + mockFetch.mockReturnValueOnce(mockJsonResponse(pemResult)); + const result = await exportCertificatePEM('mc-1'); + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/certificates/mc-1/export/pem'); + expect(result.cert_pem).toBe('CERT'); + expect(result.full_pem).toBe('FULL'); + }); + + it('downloadCertificatePEM fetches blob with download=true', async () => { + const mockBlob = new Blob(['pem-data'], { type: 'application/x-pem-file' }); + mockFetch.mockReturnValueOnce( + Promise.resolve({ + ok: true, + status: 200, + blob: () => Promise.resolve(mockBlob), + } as Response) + ); + const blob = await downloadCertificatePEM('mc-1'); + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/certificates/mc-1/export/pem?download=true'); + expect(blob).toBeInstanceOf(Blob); + }); + + it('downloadCertificatePEM includes auth header', async () => { + setApiKey('export-key'); + const mockBlob = new Blob(['data']); + mockFetch.mockReturnValueOnce( + Promise.resolve({ + ok: true, + status: 200, + blob: () => Promise.resolve(mockBlob), + } as Response) + ); + await downloadCertificatePEM('mc-1'); + const [, init] = mockFetch.mock.calls[0]; + expect(init.headers['Authorization']).toBe('Bearer export-key'); + }); + + it('exportCertificatePKCS12 sends POST with password', async () => { + const mockBlob = new Blob([new Uint8Array([0x30])], { type: 'application/x-pkcs12' }); + mockFetch.mockReturnValueOnce( + Promise.resolve({ + ok: true, + status: 200, + blob: () => Promise.resolve(mockBlob), + } as Response) + ); + const blob = await exportCertificatePKCS12('mc-1', 'mypass'); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/certificates/mc-1/export/pkcs12'); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body); + expect(body.password).toBe('mypass'); + expect(blob).toBeInstanceOf(Blob); + }); + + it('exportCertificatePKCS12 uses empty password by default', async () => { + const mockBlob = new Blob([new Uint8Array([0x30])]); + mockFetch.mockReturnValueOnce( + Promise.resolve({ + ok: true, + status: 200, + blob: () => Promise.resolve(mockBlob), + } as Response) + ); + await exportCertificatePKCS12('mc-1'); + const [, init] = mockFetch.mock.calls[0]; + const body = JSON.parse(init.body); + expect(body.password).toBe(''); + }); + }); }); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index bf71133..e78ff12 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -95,6 +95,33 @@ export const revokeCertificate = (id: string, reason: string) => body: JSON.stringify({ reason }), }); +// Certificate Export +export const exportCertificatePEM = (id: string) => + fetchJSON<{ cert_pem: string; chain_pem: string; full_pem: string }>(`${BASE}/certificates/${id}/export/pem`); + +export const downloadCertificatePEM = (id: string) => { + const headers: Record = {}; + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; + return fetch(`${BASE}/certificates/${id}/export/pem?download=true`, { headers }) + .then(r => { + if (!r.ok) throw new Error('Export failed'); + return r.blob(); + }); +}; + +export const exportCertificatePKCS12 = (id: string, password: string = '') => { + const headers: Record = { 'Content-Type': 'application/json' }; + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; + return fetch(`${BASE}/certificates/${id}/export/pkcs12`, { + method: 'POST', + headers, + body: JSON.stringify({ password }), + }).then(r => { + if (!r.ok) throw new Error('Export failed'); + return r.blob(); + }); +}; + // Agents export const getAgents = (params: Record = {}) => { const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); diff --git a/web/src/pages/CertificateDetailPage.tsx b/web/src/pages/CertificateDetailPage.tsx index 6bdf85e..c43dac9 100644 --- a/web/src/pages/CertificateDetailPage.tsx +++ b/web/src/pages/CertificateDetailPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getPolicies, getProfiles } from '../api/client'; +import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getPolicies, getProfiles, downloadCertificatePEM, exportCertificatePKCS12 } from '../api/client'; import { REVOCATION_REASONS } from '../api/types'; import PageHeader from '../components/PageHeader'; import StatusBadge from '../components/StatusBadge'; @@ -226,6 +226,9 @@ export default function CertificateDetailPage() { const [deployTargetId, setDeployTargetId] = useState(''); const [showRevoke, setShowRevoke] = useState(false); const [revokeReason, setRevokeReason] = useState('unspecified'); + const [showExport, setShowExport] = useState(false); + const [pkcs12Password, setPkcs12Password] = useState(''); + const [exporting, setExporting] = useState(false); const { data: cert, isLoading, error, refetch } = useQuery({ queryKey: ['certificate', id], @@ -280,6 +283,42 @@ export default function CertificateDetailPage() { }, }); + const handleExportPEM = async () => { + setExporting(true); + try { + const blob = await downloadCertificatePEM(id!); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${cert?.common_name || id}.pem`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + alert(`Export failed: ${err instanceof Error ? err.message : err}`); + } finally { + setExporting(false); + } + }; + + const handleExportPKCS12 = async () => { + setExporting(true); + try { + const blob = await exportCertificatePKCS12(id!, pkcs12Password); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${cert?.common_name || id}.p12`; + a.click(); + URL.revokeObjectURL(url); + setShowExport(false); + setPkcs12Password(''); + } catch (err) { + alert(`Export failed: ${err instanceof Error ? err.message : err}`); + } finally { + setExporting(false); + } + }; + if (isLoading) { return ( <> @@ -313,6 +352,19 @@ export default function CertificateDetailPage() { + + + + + + + )} + {/* Revoke Modal */} {showRevoke && (
setShowRevoke(false)}>