Compare commits

...

2 Commits

Author SHA1 Message Date
shankar0123 63e6f3ef91 chore: update license contact email to certctl@proton.me
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 16:24:34 -04:00
shankar0123 a00bb349c4 feat(m27): certificate export (PEM/PKCS#12) and S/MIME EKU support
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 <noreply@anthropic.com>
2026-03-28 16:16:19 -04:00
27 changed files with 1357 additions and 54 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ Change Date: March 14, 2033
Change License: Apache License, Version 2.0
For information about alternative licensing arrangements for the Licensed Work,
please contact: skreddy040@gmail.com
please contact: certctl@proton.me
Notice
+9 -8
View File
@@ -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
@@ -493,3 +492,5 @@ Passive network discovery (TLS listener), Kubernetes integration (cert-manager e
Certctl is licensed under the [Business Source License 1.1](LICENSE). The source code is publicly available and free to use, modify, and self-host. The one restriction: you may not offer certctl as a managed/hosted certificate management service to third parties.
For licensing inquiries: certctl@proton.me
+85
View File
@@ -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:
+13 -1
View File
@@ -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)
+4
View File
@@ -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 {
+2
View File
@@ -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
+2
View File
@@ -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
{
+70 -10
View File
@@ -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
---
+1
View File
@@ -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
)
+2
View File
@@ -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=
+132
View File
@@ -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)
}
+282
View File
@@ -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)
}
}
}
+5
View File
@@ -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))
+2
View File
@@ -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"`
}
+76 -14
View File
@@ -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()
+14 -1
View File
@@ -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)
}
+2 -1
View File
@@ -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,
+185
View File
@@ -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
}
+220
View File
@@ -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)
}
}
+4 -2
View File
@@ -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
+6 -6
View File
@@ -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)
+37 -6
View File
@@ -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)
}
+3 -3
View File
@@ -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 {
+8
View File
@@ -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;
+80
View File
@@ -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('');
});
});
});
+27
View File
@@ -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<string, string> = {};
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<string, string> = { '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<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+85 -1
View File
@@ -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() {
<button onClick={() => navigate('/certificates')} className="btn btn-ghost text-xs">
Back
</button>
<button
onClick={handleExportPEM}
disabled={exporting}
className="btn btn-ghost text-xs border border-surface-border disabled:opacity-50"
>
{exporting ? 'Exporting...' : 'Export PEM'}
</button>
<button
onClick={() => setShowExport(true)}
className="btn btn-ghost text-xs border border-surface-border"
>
Export PKCS#12
</button>
<button
onClick={() => setShowDeploy(true)}
disabled={isArchived || isRevoked}
@@ -546,6 +598,38 @@ export default function CertificateDetailPage() {
</div>
)}
{/* PKCS#12 Export Modal */}
{showExport && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowExport(false)}>
<div className="bg-surface border border-surface-border rounded p-6 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-2">Export PKCS#12</h2>
<p className="text-sm text-ink-muted mb-4">
Downloads a .p12 file containing the certificate chain. Private keys are not included (they remain on the agent).
</p>
<label className="text-xs text-ink-muted block mb-2">Password (optional)</label>
<input
type="password"
value={pkcs12Password}
onChange={e => setPkcs12Password(e.target.value)}
placeholder="Leave empty for no encryption"
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink mb-4 focus:outline-none focus:border-brand-400"
/>
<div className="flex justify-end gap-3">
<button onClick={() => { setShowExport(false); setPkcs12Password(''); }} className="btn btn-ghost text-sm">
Cancel
</button>
<button
onClick={handleExportPKCS12}
disabled={exporting}
className="btn btn-primary text-sm disabled:opacity-50"
>
{exporting ? 'Exporting...' : 'Download .p12'}
</button>
</div>
</div>
</div>
)}
{/* Revoke Modal */}
{showRevoke && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowRevoke(false)}>