mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
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>
This commit is contained in:
@@ -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:
|
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
|
- **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)
|
- **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
|
- **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
|
- **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
|
## 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
|
### Key Endpoints
|
||||||
```
|
```
|
||||||
@@ -358,6 +358,8 @@ make docker-clean # Stop + remove volumes
|
|||||||
GET /api/v1/certificates List (filter, sort, cursor, sparse fields)
|
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}/renew Trigger renewal → 202 Accepted
|
||||||
POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code
|
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/crl/{issuer_id} DER-encoded X.509 CRL
|
||||||
GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown)
|
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
|
### 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 ✅):**
|
**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
|
- **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)
|
- **Traefik + Caddy Targets** — Traefik (file provider, auto-reload) and Caddy (Admin API hot-reload or file-based)
|
||||||
|
- **Certificate Export** — PEM (JSON or file download) and PKCS#12 formats, private keys never included (agent-side only), audit trail
|
||||||
**Coming next:**
|
- **S/MIME Support** — EKU-aware issuance (emailProtection, codeSigning, timeStamping), adaptive KeyUsage flags, email SAN routing
|
||||||
|
|
||||||
- **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
|
|
||||||
|
|
||||||
### V3: certctl Pro
|
### V3: certctl Pro
|
||||||
|
|
||||||
|
|||||||
@@ -367,6 +367,84 @@ paths:
|
|||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$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 ─────────────────────────────────────────────────────
|
# ─── CRL & OCSP ─────────────────────────────────────────────────────
|
||||||
/api/v1/crl:
|
/api/v1/crl:
|
||||||
get:
|
get:
|
||||||
@@ -2712,8 +2790,15 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
allowed_ekus:
|
allowed_ekus:
|
||||||
type: array
|
type: array
|
||||||
|
description: Extended Key Usages to include in issued certificates
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
enum:
|
||||||
|
- serverAuth
|
||||||
|
- clientAuth
|
||||||
|
- codeSigning
|
||||||
|
- emailProtection
|
||||||
|
- timeStamping
|
||||||
required_san_patterns:
|
required_san_patterns:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
|
|||||||
+13
-1
@@ -344,11 +344,23 @@ func (a *Agent) executeCSRJob(ctx context.Context, job JobItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Create CSR with common name and SANs
|
// 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{
|
csrTemplate := &x509.CertificateRequest{
|
||||||
Subject: pkix.Name{
|
Subject: pkix.Name{
|
||||||
CommonName: job.CommonName,
|
CommonName: job.CommonName,
|
||||||
},
|
},
|
||||||
DNSNames: job.SANs,
|
DNSNames: dnsNames,
|
||||||
|
EmailAddresses: emailAddresses,
|
||||||
}
|
}
|
||||||
|
|
||||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey)
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey)
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ func main() {
|
|||||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
||||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||||
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||||
|
agentService.SetProfileRepo(profileRepo)
|
||||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
||||||
targetService := service.NewTargetService(targetRepo, auditService)
|
targetService := service.NewTargetService(targetRepo, auditService)
|
||||||
profileService := service.NewProfileService(profileRepo, auditService)
|
profileService := service.NewProfileService(profileRepo, auditService)
|
||||||
@@ -262,6 +263,8 @@ func main() {
|
|||||||
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
|
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
|
||||||
verificationService := service.NewVerificationService(jobRepo, auditService, logger)
|
verificationService := service.NewVerificationService(jobRepo, auditService, logger)
|
||||||
verificationHandler := handler.NewVerificationHandler(verificationService)
|
verificationHandler := handler.NewVerificationHandler(verificationService)
|
||||||
|
exportService := service.NewExportService(certificateRepo, auditService)
|
||||||
|
exportHandler := handler.NewExportHandler(exportService)
|
||||||
logger.Info("initialized all handlers")
|
logger.Info("initialized all handlers")
|
||||||
|
|
||||||
// Create context with cancellation
|
// Create context with cancellation
|
||||||
@@ -315,6 +318,7 @@ func main() {
|
|||||||
Discovery: discoveryHandler,
|
Discovery: discoveryHandler,
|
||||||
NetworkScan: networkScanHandler,
|
NetworkScan: networkScanHandler,
|
||||||
Verification: verificationHandler,
|
Verification: verificationHandler,
|
||||||
|
Export: exportHandler,
|
||||||
})
|
})
|
||||||
// Register EST (RFC 7030) handlers if enabled
|
// Register EST (RFC 7030) handlers if enabled
|
||||||
if cfg.EST.Enabled {
|
if cfg.EST.Enabled {
|
||||||
|
|||||||
@@ -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 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`.
|
Health checks live outside the API prefix: `GET /health` and `GET /ready`.
|
||||||
|
|
||||||
## MCP Server
|
## MCP Server
|
||||||
|
|||||||
@@ -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.
|
**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:
|
Configuration:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
+70
-10
@@ -78,7 +78,7 @@ curl -H "$AUTH" "$SERVER/api/v1/certificates?expires_before=2026-04-24T00:00:00Z
|
|||||||
|
|
||||||
| Domain | Endpoints | Key Operations |
|
| 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 |
|
| **CRL & OCSP** | 3 | JSON CRL, DER CRL per issuer, OCSP responder |
|
||||||
| **Issuers** | 6 | List, create, get, update, delete, test connection |
|
| **Issuers** | 6 | List, create, get, update, delete, test connection |
|
||||||
| **Targets** | 5 | List, create, get, update, delete |
|
| **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
|
## Certificate Profiles
|
||||||
|
|
||||||
### Profile Model
|
### 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
|
```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 '{
|
curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{
|
||||||
"name": "Short-Lived Service Mesh",
|
"name": "Standard TLS",
|
||||||
"allowed_key_algorithms": ["ECDSA"],
|
"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"]
|
"allowed_ekus": ["serverAuth", "clientAuth"]
|
||||||
}'
|
}'
|
||||||
|
|
||||||
# Assign profile to a certificate
|
# Assign profile to a certificate
|
||||||
curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/certificates/mc-api-prod -d '{
|
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
|
# 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
|
# Get profile details
|
||||||
curl -H "$AUTH" "$SERVER/api/v1/profiles/prof-standard-tls" | jq .
|
curl -H "$AUTH" "$SERVER/api/v1/profiles/prof-standard-tls" | jq .
|
||||||
|
|
||||||
# Update profile constraints
|
# Update profile constraints
|
||||||
curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles/prof-standard-tls -d '{
|
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 |
|
| **Name** | Human-readable profile name |
|
||||||
| **Allowed Key Algorithms** | RSA, ECDSA, Ed25519 with minimum key sizes (e.g., RSA 2048+, ECDSA P-256+) |
|
| **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) |
|
| **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) |
|
| **Required SANs** | Mandatory Subject Alternative Names (patterns or fixed values) |
|
||||||
| **Short-Lived Support** | TTL < 1 hour triggers CRL/OCSP exemption |
|
| **Short-Lived Support** | TTL < 1 hour triggers CRL/OCSP exemption |
|
||||||
|
|
||||||
### GUI Management
|
### GUI Management
|
||||||
- Full CRUD page with profile details
|
- 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
|
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -63,4 +63,5 @@ require (
|
|||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
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 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ type HandlerRegistry struct {
|
|||||||
Discovery handler.DiscoveryHandler
|
Discovery handler.DiscoveryHandler
|
||||||
NetworkScan handler.NetworkScanHandler
|
NetworkScan handler.NetworkScanHandler
|
||||||
Verification handler.VerificationHandler
|
Verification handler.VerificationHandler
|
||||||
|
Export handler.ExportHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterHandlers sets up all API routes with their handlers.
|
// 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}/deploy", http.HandlerFunc(reg.Certificates.TriggerDeployment))
|
||||||
r.Register("POST /api/v1/certificates/{id}/revoke", http.HandlerFunc(reg.Certificates.RevokeCertificate))
|
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)
|
// 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", http.HandlerFunc(reg.Certificates.GetCRL))
|
||||||
r.Register("GET /api/v1/crl/{issuer_id}", http.HandlerFunc(reg.Certificates.GetDERCRL))
|
r.Register("GET /api/v1/crl/{issuer_id}", http.HandlerFunc(reg.Certificates.GetDERCRL))
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type IssuanceRequest struct {
|
|||||||
CommonName string `json:"common_name"`
|
CommonName string `json:"common_name"`
|
||||||
SANs []string `json:"sans"`
|
SANs []string `json:"sans"`
|
||||||
CSRPEM string `json:"csr_pem"`
|
CSRPEM string `json:"csr_pem"`
|
||||||
|
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssuanceResult contains the result of a successful certificate issuance.
|
// IssuanceResult contains the result of a successful certificate issuance.
|
||||||
@@ -59,6 +60,7 @@ type RenewalRequest struct {
|
|||||||
CommonName string `json:"common_name"`
|
CommonName string `json:"common_name"`
|
||||||
SANs []string `json:"sans"`
|
SANs []string `json:"sans"`
|
||||||
CSRPEM string `json:"csr_pem"`
|
CSRPEM string `json:"csr_pem"`
|
||||||
|
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||||
OrderID *string `json:"order_id,omitempty"`
|
OrderID *string `json:"order_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
|||||||
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate certificate
|
// Generate certificate with EKUs from request
|
||||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs)
|
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("failed to generate certificate", "error", err)
|
c.logger.Error("failed to generate certificate", "error", err)
|
||||||
return nil, fmt.Errorf("certificate generation failed: %w", 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)
|
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate certificate
|
// Generate certificate with EKUs from request
|
||||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs)
|
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Error("failed to generate certificate", "error", err)
|
c.logger.Error("failed to generate certificate", "error", err)
|
||||||
return nil, fmt.Errorf("certificate generation failed: %w", 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.
|
// generateCertificate creates an X.509 certificate signed by the local CA.
|
||||||
// It uses the CSR subject and adds any additional SANs from the request.
|
// 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
|
// Generate random serial number
|
||||||
serialNum, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159))
|
serialNum, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159))
|
||||||
if err != nil {
|
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
|
// Create certificate template
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
template := &x509.Certificate{
|
template := &x509.Certificate{
|
||||||
SerialNumber: serialNum,
|
SerialNumber: serialNum,
|
||||||
Subject: csr.Subject,
|
Subject: csr.Subject,
|
||||||
NotBefore: now,
|
NotBefore: now,
|
||||||
NotAfter: now.AddDate(0, 0, c.config.ValidityDays),
|
NotAfter: now.AddDate(0, 0, c.config.ValidityDays),
|
||||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
KeyUsage: keyUsage,
|
||||||
ExtKeyUsage: []x509.ExtKeyUsage{
|
ExtKeyUsage: resolvedEKUs,
|
||||||
x509.ExtKeyUsageServerAuth,
|
|
||||||
x509.ExtKeyUsageClientAuth,
|
|
||||||
},
|
|
||||||
DNSNames: dnsNames,
|
DNSNames: dnsNames,
|
||||||
EmailAddresses: emails,
|
EmailAddresses: emails,
|
||||||
SubjectKeyId: hashPublicKey(csr.PublicKey),
|
SubjectKeyId: hashPublicKey(csr.PublicKey),
|
||||||
@@ -580,6 +581,67 @@ func isEmail(s string) bool {
|
|||||||
return false
|
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.
|
// hashPublicKey generates a subject key identifier from a public key.
|
||||||
func hashPublicKey(pub interface{}) []byte {
|
func hashPublicKey(pub interface{}) []byte {
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type AgentService struct {
|
|||||||
certRepo repository.CertificateRepository
|
certRepo repository.CertificateRepository
|
||||||
jobRepo repository.JobRepository
|
jobRepo repository.JobRepository
|
||||||
targetRepo repository.TargetRepository
|
targetRepo repository.TargetRepository
|
||||||
|
profileRepo repository.CertificateProfileRepository
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
issuerRegistry map[string]IssuerConnector
|
issuerRegistry map[string]IssuerConnector
|
||||||
renewalService *RenewalService
|
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).
|
// 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) {
|
func (s *AgentService) Register(ctx context.Context, name string, hostname string) (*domain.Agent, string, error) {
|
||||||
if name == "" || hostname == "" {
|
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)
|
// Fallback: direct issuer signing (no AwaitingCSR job — ad-hoc CSR submission)
|
||||||
connector, ok := s.issuerRegistry[cert.IssuerID]
|
connector, ok := s.issuerRegistry[cert.IssuerID]
|
||||||
if ok {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("issuer signing failed: %w", err)
|
return fmt.Errorf("issuer signing failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
|
|||||||
"issuer", s.issuerID)
|
"issuer", s.issuerID)
|
||||||
|
|
||||||
// Issue the certificate via the configured issuer connector
|
// 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 {
|
if err != nil {
|
||||||
s.logger.Error("EST enrollment failed",
|
s.logger.Error("EST enrollment failed",
|
||||||
"action", auditAction,
|
"action", auditAction,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,12 @@ func NewIssuerConnectorAdapter(c issuer.Connector) IssuerConnector {
|
|||||||
|
|
||||||
// IssueCertificate delegates to the underlying connector's IssueCertificate method,
|
// IssueCertificate delegates to the underlying connector's IssueCertificate method,
|
||||||
// translating between service-layer and connector-layer types.
|
// 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{
|
result, err := a.connector.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||||
CommonName: commonName,
|
CommonName: commonName,
|
||||||
SANs: sans,
|
SANs: sans,
|
||||||
CSRPEM: csrPEM,
|
CSRPEM: csrPEM,
|
||||||
|
EKUs: ekus,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -40,11 +41,12 @@ func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonNam
|
|||||||
|
|
||||||
// RenewCertificate delegates to the underlying connector's RenewCertificate method,
|
// RenewCertificate delegates to the underlying connector's RenewCertificate method,
|
||||||
// translating between service-layer and connector-layer types.
|
// 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{
|
result, err := a.connector.RenewCertificate(ctx, issuer.RenewalRequest{
|
||||||
CommonName: commonName,
|
CommonName: commonName,
|
||||||
SANs: sans,
|
SANs: sans,
|
||||||
CSRPEM: csrPEM,
|
CSRPEM: csrPEM,
|
||||||
|
EKUs: ekus,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_Success(t *testing.T) {
|
|||||||
|
|
||||||
adapter := NewIssuerConnectorAdapter(mock)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("IssueCertificate failed: %v", err)
|
t.Fatalf("IssueCertificate failed: %v", err)
|
||||||
@@ -157,7 +157,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_Error(t *testing.T) {
|
|||||||
|
|
||||||
adapter := NewIssuerConnectorAdapter(mock)
|
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 {
|
if err == nil {
|
||||||
t.Fatal("expected error, got 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"}
|
sans := []string{"www.test.example.com", "api.test.example.com"}
|
||||||
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----"
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("IssueCertificate failed: %v", err)
|
t.Fatalf("IssueCertificate failed: %v", err)
|
||||||
@@ -241,7 +241,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_Success(t *testing.T) {
|
|||||||
|
|
||||||
adapter := NewIssuerConnectorAdapter(mock)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("RenewCertificate failed: %v", err)
|
t.Fatalf("RenewCertificate failed: %v", err)
|
||||||
@@ -278,7 +278,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_Error(t *testing.T) {
|
|||||||
|
|
||||||
adapter := NewIssuerConnectorAdapter(mock)
|
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 {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
@@ -312,7 +312,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_RequestTranslation(t *testing.T
|
|||||||
sans := []string{"www.renew.example.com"}
|
sans := []string{"www.renew.example.com"}
|
||||||
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nRENEW-CSR\n-----END CERTIFICATE REQUEST-----"
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("RenewCertificate failed: %v", err)
|
t.Fatalf("RenewCertificate failed: %v", err)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
@@ -35,9 +37,9 @@ type RenewalService struct {
|
|||||||
// inversion. Use IssuerConnectorAdapter to bridge between the two.
|
// inversion. Use IssuerConnectorAdapter to bridge between the two.
|
||||||
type IssuerConnector interface {
|
type IssuerConnector interface {
|
||||||
// IssueCertificate issues a new certificate using the provided CSR PEM.
|
// 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 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 revokes a certificate by serial number with an optional reason.
|
||||||
RevokeCertificate(ctx context.Context, serial string, reason string) error
|
RevokeCertificate(ctx context.Context, serial string, reason string) error
|
||||||
// GenerateCRL generates a DER-encoded X.509 CRL from the given revocation entries.
|
// 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)
|
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{
|
csrTemplate := &x509.CertificateRequest{
|
||||||
Subject: pkix.Name{
|
Subject: pkix.Name{
|
||||||
CommonName: cert.CommonName,
|
CommonName: cert.CommonName,
|
||||||
},
|
},
|
||||||
DNSNames: cert.SANs,
|
DNSNames: csrDNSNames,
|
||||||
|
EmailAddresses: csrEmailAddresses,
|
||||||
}
|
}
|
||||||
|
|
||||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey)
|
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),
|
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
|
// 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 {
|
if err != nil {
|
||||||
s.failJob(ctx, job, fmt.Sprintf("issuer renewal failed: %v", err))
|
s.failJob(ctx, job, fmt.Sprintf("issuer renewal failed: %v", err))
|
||||||
if notifErr := s.notificationSvc.SendRenewalNotification(ctx, cert, false, err); notifErr != nil {
|
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)
|
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
|
// 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 {
|
if err != nil {
|
||||||
s.failJob(ctx, job, fmt.Sprintf("issuer signing failed: %v", err))
|
s.failJob(ctx, job, fmt.Sprintf("issuer signing failed: %v", err))
|
||||||
if notifErr := s.notificationSvc.SendRenewalNotification(ctx, cert, false, err); notifErr != nil {
|
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.
|
// generateID is a helper to generate unique IDs. In production, use a proper ID generator.
|
||||||
|
var idCounter atomic.Int64
|
||||||
|
|
||||||
func generateID(prefix string) string {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ type mockIssuerConnector struct {
|
|||||||
Err error
|
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 {
|
if m.Err != nil {
|
||||||
return nil, m.Err
|
return nil, m.Err
|
||||||
}
|
}
|
||||||
@@ -606,11 +606,11 @@ func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName s
|
|||||||
}, nil
|
}, 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 {
|
if m.Err != nil {
|
||||||
return nil, m.Err
|
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 {
|
func (m *mockIssuerConnector) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ INSERT INTO certificate_profiles (id, name, description, allowed_key_algorithms,
|
|||||||
4060800, -- 47 days (Ballot SC-081v3 target)
|
4060800, -- 47 days (Ballot SC-081v3 target)
|
||||||
'["serverAuth"]'::jsonb,
|
'["serverAuth"]'::jsonb,
|
||||||
'[".*\\.example\\.com$"]'::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())
|
'', false, true, NOW(), NOW())
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
updateCertificate,
|
updateCertificate,
|
||||||
archiveCertificate,
|
archiveCertificate,
|
||||||
revokeCertificate,
|
revokeCertificate,
|
||||||
|
exportCertificatePEM,
|
||||||
|
downloadCertificatePEM,
|
||||||
|
exportCertificatePKCS12,
|
||||||
getAgents,
|
getAgents,
|
||||||
getAgent,
|
getAgent,
|
||||||
registerAgent,
|
registerAgent,
|
||||||
@@ -798,4 +801,81 @@ describe('API Client', () => {
|
|||||||
expect(init.method).toBe('POST');
|
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,6 +95,33 @@ export const revokeCertificate = (id: string, reason: string) =>
|
|||||||
body: JSON.stringify({ reason }),
|
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
|
// Agents
|
||||||
export const getAgents = (params: Record<string, string> = {}) => {
|
export const getAgents = (params: Record<string, string> = {}) => {
|
||||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { REVOCATION_REASONS } from '../api/types';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
@@ -226,6 +226,9 @@ export default function CertificateDetailPage() {
|
|||||||
const [deployTargetId, setDeployTargetId] = useState('');
|
const [deployTargetId, setDeployTargetId] = useState('');
|
||||||
const [showRevoke, setShowRevoke] = useState(false);
|
const [showRevoke, setShowRevoke] = useState(false);
|
||||||
const [revokeReason, setRevokeReason] = useState('unspecified');
|
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({
|
const { data: cert, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['certificate', id],
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -313,6 +352,19 @@ export default function CertificateDetailPage() {
|
|||||||
<button onClick={() => navigate('/certificates')} className="btn btn-ghost text-xs">
|
<button onClick={() => navigate('/certificates')} className="btn btn-ghost text-xs">
|
||||||
Back
|
Back
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setShowDeploy(true)}
|
onClick={() => setShowDeploy(true)}
|
||||||
disabled={isArchived || isRevoked}
|
disabled={isArchived || isRevoked}
|
||||||
@@ -546,6 +598,38 @@ export default function CertificateDetailPage() {
|
|||||||
</div>
|
</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 */}
|
{/* Revoke Modal */}
|
||||||
{showRevoke && (
|
{showRevoke && (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowRevoke(false)}>
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowRevoke(false)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user