mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:31:36 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63e6f3ef91 | |||
| a00bb349c4 |
@@ -19,7 +19,7 @@ Change Date: March 14, 2033
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
For information about alternative licensing arrangements for the Licensed Work,
|
||||
please contact: skreddy040@gmail.com
|
||||
please contact: certctl@proton.me
|
||||
|
||||
Notice
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ For a detailed comparison with CertKit, KeyTalk, and enterprise platforms (Venaf
|
||||
certctl gives you a single pane of glass for every TLS certificate in your organization:
|
||||
|
||||
- **Web dashboard** — full certificate inventory with status, ownership, expiration heatmaps, and bulk operations
|
||||
- **REST API** — 93 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation
|
||||
- **REST API** — 95 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation
|
||||
- **Agents** — generate private keys locally, discover existing certs on disk, submit CSRs (private keys never leave your servers)
|
||||
- **Network scanner** — discovers certificates on TLS endpoints across CIDR ranges without requiring agents
|
||||
- **EST server** (RFC 7030) — device and WiFi certificate enrollment via industry-standard protocol
|
||||
@@ -350,7 +350,7 @@ make docker-clean # Stop + remove volumes
|
||||
|
||||
## API Overview
|
||||
|
||||
93 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml).
|
||||
95 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml).
|
||||
|
||||
### Key Endpoints
|
||||
```
|
||||
@@ -358,6 +358,8 @@ make docker-clean # Stop + remove volumes
|
||||
GET /api/v1/certificates List (filter, sort, cursor, sparse fields)
|
||||
POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted
|
||||
POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code
|
||||
GET /api/v1/certificates/{id}/export/pem Export PEM (JSON or file download)
|
||||
POST /api/v1/certificates/{id}/export/pkcs12 Export PKCS#12 bundle (no private key)
|
||||
GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL
|
||||
GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown)
|
||||
|
||||
@@ -457,7 +459,7 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
|
||||
|
||||
### V2: Operational Maturity
|
||||
|
||||
18 milestones complete, 1100+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
|
||||
21 milestones complete, 1100+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
|
||||
|
||||
**What shipped (all ✅):**
|
||||
|
||||
@@ -476,11 +478,8 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
|
||||
|
||||
- **Post-Deployment TLS Verification** — agent-side TLS probe confirms the target is serving the correct certificate by SHA-256 fingerprint match
|
||||
- **Traefik + Caddy Targets** — Traefik (file provider, auto-reload) and Caddy (Admin API hot-reload or file-based)
|
||||
|
||||
**Coming next:**
|
||||
|
||||
- **Certificate Export** (v2.1.x) — single-cert download in PFX/PKCS12, DER, and PEM formats
|
||||
- **S/MIME Support** (v2.2.x) — profile EKU constraints for S/MIME (emailProtection), code signing, and custom EKUs
|
||||
- **Certificate Export** — PEM (JSON or file download) and PKCS#12 formats, private keys never included (agent-side only), audit trail
|
||||
- **S/MIME Support** — EKU-aware issuance (emailProtection, codeSigning, timeStamping), adaptive KeyUsage flags, email SAN routing
|
||||
|
||||
### V3: certctl Pro
|
||||
|
||||
@@ -493,3 +492,5 @@ Passive network discovery (TLS listener), Kubernetes integration (cert-manager e
|
||||
|
||||
Certctl is licensed under the [Business Source License 1.1](LICENSE). The source code is publicly available and free to use, modify, and self-host. The one restriction: you may not offer certctl as a managed/hosted certificate management service to third parties.
|
||||
|
||||
For licensing inquiries: certctl@proton.me
|
||||
|
||||
|
||||
@@ -367,6 +367,84 @@ paths:
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
# ─── Certificate Export ──────────────────────────────────────────────
|
||||
/api/v1/certificates/{id}/export/pem:
|
||||
get:
|
||||
tags: [Certificates]
|
||||
summary: Export certificate as PEM
|
||||
description: |
|
||||
Returns the certificate and its chain in PEM format. By default returns JSON
|
||||
with cert_pem, chain_pem, and full_pem fields. Add ?download=true to get the
|
||||
full PEM chain as a file download with Content-Disposition headers.
|
||||
operationId: exportCertificatePEM
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/resourceId"
|
||||
- name: download
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: ["true"]
|
||||
description: Set to "true" to get a file download instead of JSON.
|
||||
responses:
|
||||
"200":
|
||||
description: PEM export
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
cert_pem:
|
||||
type: string
|
||||
description: Leaf certificate PEM
|
||||
chain_pem:
|
||||
type: string
|
||||
description: Intermediate/root chain PEM
|
||||
full_pem:
|
||||
type: string
|
||||
description: Full PEM chain (cert + intermediates)
|
||||
application/x-pem-file:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
description: Full PEM file (when download=true)
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
/api/v1/certificates/{id}/export/pkcs12:
|
||||
post:
|
||||
tags: [Certificates]
|
||||
summary: Export certificate as PKCS#12
|
||||
description: |
|
||||
Returns a PKCS#12 (.p12) bundle containing the certificate and chain.
|
||||
Private keys are NOT included — they live on agents and never touch the control plane.
|
||||
The bundle is encrypted with the provided password (or empty password if omitted).
|
||||
operationId: exportCertificatePKCS12
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/resourceId"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
description: Password to encrypt the PKCS#12 bundle (can be empty)
|
||||
responses:
|
||||
"200":
|
||||
description: PKCS#12 binary
|
||||
content:
|
||||
application/x-pkcs12:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
# ─── CRL & OCSP ─────────────────────────────────────────────────────
|
||||
/api/v1/crl:
|
||||
get:
|
||||
@@ -2712,8 +2790,15 @@ components:
|
||||
type: integer
|
||||
allowed_ekus:
|
||||
type: array
|
||||
description: Extended Key Usages to include in issued certificates
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- serverAuth
|
||||
- clientAuth
|
||||
- codeSigning
|
||||
- emailProtection
|
||||
- timeStamping
|
||||
required_san_patterns:
|
||||
type: array
|
||||
items:
|
||||
|
||||
+13
-1
@@ -344,11 +344,23 @@ func (a *Agent) executeCSRJob(ctx context.Context, job JobItem) {
|
||||
}
|
||||
|
||||
// Step 3: Create CSR with common name and SANs
|
||||
// Split SANs into DNS names and email addresses for proper CSR encoding
|
||||
var dnsNames []string
|
||||
var emailAddresses []string
|
||||
for _, san := range job.SANs {
|
||||
if strings.Contains(san, "@") {
|
||||
emailAddresses = append(emailAddresses, san)
|
||||
} else {
|
||||
dnsNames = append(dnsNames, san)
|
||||
}
|
||||
}
|
||||
|
||||
csrTemplate := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: job.CommonName,
|
||||
},
|
||||
DNSNames: job.SANs,
|
||||
DNSNames: dnsNames,
|
||||
EmailAddresses: emailAddresses,
|
||||
}
|
||||
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey)
|
||||
|
||||
@@ -209,6 +209,7 @@ func main() {
|
||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
|
||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
||||
agentService.SetProfileRepo(profileRepo)
|
||||
issuerService := service.NewIssuerService(issuerRepo, auditService)
|
||||
targetService := service.NewTargetService(targetRepo, auditService)
|
||||
profileService := service.NewProfileService(profileRepo, auditService)
|
||||
@@ -262,6 +263,8 @@ func main() {
|
||||
networkScanHandler := handler.NewNetworkScanHandler(networkScanService)
|
||||
verificationService := service.NewVerificationService(jobRepo, auditService, logger)
|
||||
verificationHandler := handler.NewVerificationHandler(verificationService)
|
||||
exportService := service.NewExportService(certificateRepo, auditService)
|
||||
exportHandler := handler.NewExportHandler(exportService)
|
||||
logger.Info("initialized all handlers")
|
||||
|
||||
// Create context with cancellation
|
||||
@@ -315,6 +318,7 @@ func main() {
|
||||
Discovery: discoveryHandler,
|
||||
NetworkScan: networkScanHandler,
|
||||
Verification: verificationHandler,
|
||||
Export: exportHandler,
|
||||
})
|
||||
// Register EST (RFC 7030) handlers if enabled
|
||||
if cfg.EST.Enabled {
|
||||
|
||||
@@ -778,6 +778,8 @@ Jobs support additional action endpoints: `POST /api/v1/jobs/{id}/cancel`, `POST
|
||||
|
||||
Certificate revocation: `POST /api/v1/certificates/{id}/revoke` with optional `{"reason": "keyCompromise"}`. Supports RFC 5280 reason codes (unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn). Returns the updated certificate status. Best-effort issuer notification — the revocation succeeds even if the issuer connector is unavailable. A JSON-formatted CRL is available at `GET /api/v1/crl`, and a DER-encoded X.509 CRL signed by the issuing CA at `GET /api/v1/crl/{issuer_id}`. An embedded OCSP responder serves signed responses at `GET /api/v1/ocsp/{issuer_id}/{serial}`. Short-lived certificates (profile TTL < 1 hour) are exempt from CRL/OCSP — expiry is sufficient revocation.
|
||||
|
||||
Certificate export (M27): `GET /api/v1/certificates/{id}/export/pem` returns PEM-encoded certificate and chain, and `POST /api/v1/certificates/{id}/export/pkcs12` returns a PKCS#12 bundle (binary). Private keys are never exported — they remain on agents. All exports are audited with actor, timestamp, and format.
|
||||
|
||||
Health checks live outside the API prefix: `GET /health` and `GET /ready`.
|
||||
|
||||
## MCP Server
|
||||
|
||||
@@ -147,6 +147,8 @@ The Local CA issuer signs certificates using Go's `crypto/x509` library. It supp
|
||||
|
||||
**CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation via `GET /api/v1/crl/{issuer_id}` with 24-hour validity. An embedded OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}` returns signed OCSP responses for issued certificates (good/revoked/unknown status). Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials.
|
||||
|
||||
**Extended Key Usage (EKU) support (M27):** The Local CA respects EKU constraints from certificate profiles and adjusts key usage flags accordingly. For S/MIME certificates (emailProtection EKU), it uses `DigitalSignature | ContentCommitment` instead of the TLS default. For TLS certificates (serverAuth/clientAuth EKU), it uses `DigitalSignature | KeyEncipherment`. This enables support for multiple certificate types — TLS, S/MIME, code signing, timestamping — from a single CA.
|
||||
|
||||
Configuration:
|
||||
```json
|
||||
{
|
||||
|
||||
+70
-10
@@ -78,7 +78,7 @@ curl -H "$AUTH" "$SERVER/api/v1/certificates?expires_before=2026-04-24T00:00:00Z
|
||||
|
||||
| Domain | Endpoints | Key Operations |
|
||||
|--------|-----------|-----------------|
|
||||
| **Certificates** | 11 | List, create, get, update (archive), versions, deployments, trigger renewal, trigger deployment, revoke |
|
||||
| **Certificates** | 13 | List, create, get, update (archive), versions, deployments, trigger renewal, trigger deployment, revoke, export (PEM/PKCS#12) |
|
||||
| **CRL & OCSP** | 3 | JSON CRL, DER CRL per issuer, OCSP responder |
|
||||
| **Issuers** | 6 | List, create, get, update, delete, test connection |
|
||||
| **Targets** | 5 | List, create, get, update, delete |
|
||||
@@ -218,34 +218,86 @@ curl $SERVER/api/v1/ocsp/iss-local/ABC123DEF456
|
||||
|
||||
---
|
||||
|
||||
## Certificate Export
|
||||
|
||||
Operators need to export certificates for use in third-party systems or for compliance audits. certctl provides two export formats: PEM (cert + chain, JSON or file download) and PKCS#12 (cert + chain in a passwordless bundle for compatibility with systems like Java keystores and Windows certificate stores).
|
||||
|
||||
**Important:** Private keys are never exported — they remain on agents where they were generated. This is a core security property. Exports only bundle the public certificate material (cert + chain).
|
||||
|
||||
```bash
|
||||
# Export as PEM (returns JSON with base64-encoded data + chain)
|
||||
curl -H "$AUTH" "$SERVER/api/v1/certificates/mc-api-prod/export/pem"
|
||||
# {"certificate_pem":"-----BEGIN CERTIFICATE-----\n...", "chain_pem":"-----BEGIN CERTIFICATE-----\n..."}
|
||||
|
||||
# Export as PKCS#12 file (binary download, no password)
|
||||
curl -H "$AUTH" "$SERVER/api/v1/certificates/mc-api-prod/export/pkcs12" > cert.p12
|
||||
|
||||
# Via CLI
|
||||
certctl-cli certs export mc-api-prod --format pem --out cert.pem
|
||||
certctl-cli certs export mc-api-prod --format pkcs12 --out cert.p12
|
||||
```
|
||||
|
||||
| Field | Details |
|
||||
|-------|---------|
|
||||
| **Formats** | PEM (text, cert + chain), PKCS#12 (binary, cert + chain, passwordless) |
|
||||
| **Private Key Inclusion** | Never — private keys remain on agents |
|
||||
| **Audit Trail** | All exports recorded with actor, timestamp, export format |
|
||||
| **API Endpoints** | `GET /api/v1/certificates/{id}/export/pem`, `POST /api/v1/certificates/{id}/export/pkcs12` |
|
||||
| **GUI** | Export PEM and Export PKCS#12 buttons on certificate detail page |
|
||||
|
||||
---
|
||||
|
||||
## Certificate Profiles
|
||||
|
||||
### Profile Model
|
||||
Named enrollment profiles defining certificate issuance constraints. Profiles prevent drift — without them, different teams might issue certs with inconsistent key sizes, TTLs, or key algorithms. A profile says "all certs in this category must use ECDSA P-256, max 90-day TTL, serverAuth EKU only."
|
||||
Named enrollment profiles defining certificate issuance constraints. Profiles prevent drift — without them, different teams might issue certs with inconsistent key sizes, TTLs, or key algorithms. A profile says "all certs in this category must use ECDSA P-256, max 90-day TTL, serverAuth and clientAuth EKUs only."
|
||||
|
||||
Profiles also support **Extended Key Usage (EKU)** constraints, enabling S/MIME and device certificates. Common EKUs:
|
||||
- `serverAuth` — TLS server certificates (HTTPS, mail servers)
|
||||
- `clientAuth` — TLS client certificates (mutual TLS, device auth)
|
||||
- `emailProtection` — S/MIME signing and encryption
|
||||
- `codeSigning` — Code signing and software updates
|
||||
- `timeStamping` — Trusted timestamps
|
||||
|
||||
```bash
|
||||
# Create a profile enforcing short-lived certs with ECDSA keys
|
||||
# Create a TLS profile
|
||||
curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{
|
||||
"name": "Short-Lived Service Mesh",
|
||||
"name": "Standard TLS",
|
||||
"allowed_key_algorithms": ["ECDSA"],
|
||||
"max_ttl_hours": 1,
|
||||
"max_ttl_hours": 2160,
|
||||
"allowed_ekus": ["serverAuth"]
|
||||
}'
|
||||
|
||||
# Create an S/MIME profile
|
||||
curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{
|
||||
"name": "S/MIME Email",
|
||||
"allowed_key_algorithms": ["RSA", "ECDSA"],
|
||||
"max_ttl_hours": 8760,
|
||||
"allowed_ekus": ["emailProtection"]
|
||||
}'
|
||||
|
||||
# Create a multi-purpose profile
|
||||
curl -X POST -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles -d '{
|
||||
"name": "Multi-Purpose",
|
||||
"allowed_key_algorithms": ["ECDSA"],
|
||||
"max_ttl_hours": 2160,
|
||||
"allowed_ekus": ["serverAuth", "clientAuth"]
|
||||
}'
|
||||
|
||||
# Assign profile to a certificate
|
||||
curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/certificates/mc-api-prod -d '{
|
||||
"profile_id": "prof-short-lived"
|
||||
"profile_id": "prof-standard-tls"
|
||||
}'
|
||||
|
||||
# List all profiles
|
||||
curl -H "$AUTH" "$SERVER/api/v1/profiles" | jq '.data[] | {id, name, max_ttl_hours, allowed_key_algorithms}'
|
||||
curl -H "$AUTH" "$SERVER/api/v1/profiles" | jq '.data[] | {id, name, max_ttl_hours, allowed_key_algorithms, allowed_ekus}'
|
||||
|
||||
# Get profile details
|
||||
curl -H "$AUTH" "$SERVER/api/v1/profiles/prof-standard-tls" | jq .
|
||||
|
||||
# Update profile constraints
|
||||
curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles/prof-standard-tls -d '{
|
||||
"name": "Standard TLS", "max_ttl_hours": 2160, "allowed_key_algorithms": ["RSA", "ECDSA"]
|
||||
"name": "Standard TLS", "max_ttl_hours": 2160, "allowed_key_algorithms": ["RSA", "ECDSA"], "allowed_ekus": ["serverAuth"]
|
||||
}'
|
||||
```
|
||||
|
||||
@@ -255,14 +307,22 @@ curl -X PUT -H "$AUTH" -H "$CT" $SERVER/api/v1/profiles/prof-standard-tls -d '{
|
||||
| **Name** | Human-readable profile name |
|
||||
| **Allowed Key Algorithms** | RSA, ECDSA, Ed25519 with minimum key sizes (e.g., RSA 2048+, ECDSA P-256+) |
|
||||
| **Max TTL** | Maximum certificate lifetime (days or duration) |
|
||||
| **Allowed EKUs** | Extended key usage OIDs (serverAuth, clientAuth, etc.) |
|
||||
| **Allowed EKUs** | Extended key usage OIDs (serverAuth, clientAuth, emailProtection, codeSigning, timeStamping) |
|
||||
| **Required SANs** | Mandatory Subject Alternative Names (patterns or fixed values) |
|
||||
| **Short-Lived Support** | TTL < 1 hour triggers CRL/OCSP exemption |
|
||||
|
||||
### GUI Management
|
||||
- Full CRUD page with profile details
|
||||
- Crypto constraint badges visible in list view
|
||||
- EKU constraint badges visible in list view (serverAuth, clientAuth, emailProtection, etc.)
|
||||
- Profile assignment dropdown on certificate detail
|
||||
- S/MIME profile creation wizard with email SAN configuration
|
||||
|
||||
### S/MIME Support
|
||||
When a profile specifies `emailProtection` EKU, certctl adapts the issuance flow for email certificates:
|
||||
- **SAN handling** — email addresses in SANs are formatted as `rfc822Name` (not DNS names)
|
||||
- **Key usage** — S/MIME certs use `DigitalSignature | ContentCommitment` instead of the TLS default `DigitalSignature | KeyEncipherment`
|
||||
- **Agent CSR generation** — agents correctly distinguish DNS SANs from email SANs based on profile EKU
|
||||
- **Issuer constraints** — Local CA and other issuers thread EKUs through the signing pipeline
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -63,4 +63,5 @@ require (
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect
|
||||
)
|
||||
|
||||
@@ -210,3 +210,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
@@ -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
|
||||
NetworkScan handler.NetworkScanHandler
|
||||
Verification handler.VerificationHandler
|
||||
Export handler.ExportHandler
|
||||
}
|
||||
|
||||
// RegisterHandlers sets up all API routes with their handlers.
|
||||
@@ -99,6 +100,10 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
r.Register("POST /api/v1/certificates/{id}/deploy", http.HandlerFunc(reg.Certificates.TriggerDeployment))
|
||||
r.Register("POST /api/v1/certificates/{id}/revoke", http.HandlerFunc(reg.Certificates.RevokeCertificate))
|
||||
|
||||
// Export endpoints: /api/v1/certificates/{id}/export/{format}
|
||||
r.Register("GET /api/v1/certificates/{id}/export/pem", http.HandlerFunc(reg.Export.ExportPEM))
|
||||
r.Register("POST /api/v1/certificates/{id}/export/pkcs12", http.HandlerFunc(reg.Export.ExportPKCS12))
|
||||
|
||||
// CRL endpoints: /api/v1/crl (JSON) and /api/v1/crl/{issuer_id} (DER)
|
||||
r.Register("GET /api/v1/crl", http.HandlerFunc(reg.Certificates.GetCRL))
|
||||
r.Register("GET /api/v1/crl/{issuer_id}", http.HandlerFunc(reg.Certificates.GetDERCRL))
|
||||
|
||||
@@ -42,6 +42,7 @@ type IssuanceRequest struct {
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||
}
|
||||
|
||||
// IssuanceResult contains the result of a successful certificate issuance.
|
||||
@@ -59,6 +60,7 @@ type RenewalRequest struct {
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
EKUs []string `json:"ekus,omitempty"` // e.g., "serverAuth", "clientAuth", "emailProtection"
|
||||
OrderID *string `json:"order_id,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -184,8 +184,8 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
|
||||
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
// Generate certificate
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs)
|
||||
// Generate certificate with EKUs from request
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to generate certificate", "error", err)
|
||||
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
||||
@@ -242,8 +242,8 @@ func (c *Connector) RenewCertificate(ctx context.Context, request issuer.Renewal
|
||||
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
// Generate certificate
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs)
|
||||
// Generate certificate with EKUs from request
|
||||
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to generate certificate", "error", err)
|
||||
return nil, fmt.Errorf("certificate generation failed: %w", err)
|
||||
@@ -467,7 +467,8 @@ func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
|
||||
|
||||
// generateCertificate creates an X.509 certificate signed by the local CA.
|
||||
// It uses the CSR subject and adds any additional SANs from the request.
|
||||
func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string) (*x509.Certificate, string, string, error) {
|
||||
// If ekus is non-empty, those EKUs are used instead of the default serverAuth+clientAuth.
|
||||
func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string, ekus []string) (*x509.Certificate, string, string, error) {
|
||||
// Generate random serial number
|
||||
serialNum, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159))
|
||||
if err != nil {
|
||||
@@ -506,18 +507,18 @@ func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additional
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve EKUs: use provided list or fall back to default TLS EKUs
|
||||
resolvedEKUs, keyUsage := resolveEKUsAndKeyUsage(ekus)
|
||||
|
||||
// Create certificate template
|
||||
now := time.Now()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serialNum,
|
||||
Subject: csr.Subject,
|
||||
NotBefore: now,
|
||||
NotAfter: now.AddDate(0, 0, c.config.ValidityDays),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
x509.ExtKeyUsageClientAuth,
|
||||
},
|
||||
SerialNumber: serialNum,
|
||||
Subject: csr.Subject,
|
||||
NotBefore: now,
|
||||
NotAfter: now.AddDate(0, 0, c.config.ValidityDays),
|
||||
KeyUsage: keyUsage,
|
||||
ExtKeyUsage: resolvedEKUs,
|
||||
DNSNames: dnsNames,
|
||||
EmailAddresses: emails,
|
||||
SubjectKeyId: hashPublicKey(csr.PublicKey),
|
||||
@@ -580,6 +581,67 @@ func isEmail(s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ekuNameToX509 maps EKU string names (from domain.ValidEKUs) to x509.ExtKeyUsage constants.
|
||||
var ekuNameToX509 = map[string]x509.ExtKeyUsage{
|
||||
"serverAuth": x509.ExtKeyUsageServerAuth,
|
||||
"clientAuth": x509.ExtKeyUsageClientAuth,
|
||||
"codeSigning": x509.ExtKeyUsageCodeSigning,
|
||||
"emailProtection": x509.ExtKeyUsageEmailProtection,
|
||||
"timeStamping": x509.ExtKeyUsageTimeStamping,
|
||||
}
|
||||
|
||||
// resolveEKUsAndKeyUsage maps EKU string names to x509.ExtKeyUsage constants and computes
|
||||
// appropriate KeyUsage flags. If ekus is empty/nil, falls back to default TLS EKUs.
|
||||
//
|
||||
// Key usage selection:
|
||||
// - TLS (serverAuth/clientAuth): DigitalSignature | KeyEncipherment
|
||||
// - S/MIME (emailProtection): DigitalSignature | ContentCommitment (for non-repudiation)
|
||||
// - Mixed: union of both
|
||||
func resolveEKUsAndKeyUsage(ekus []string) ([]x509.ExtKeyUsage, x509.KeyUsage) {
|
||||
if len(ekus) == 0 {
|
||||
// Default: TLS server + client
|
||||
return []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
x509.ExtKeyUsageClientAuth,
|
||||
}, x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment
|
||||
}
|
||||
|
||||
var resolved []x509.ExtKeyUsage
|
||||
hasEmail := false
|
||||
hasTLS := false
|
||||
|
||||
for _, name := range ekus {
|
||||
if eku, ok := ekuNameToX509[name]; ok {
|
||||
resolved = append(resolved, eku)
|
||||
if name == "emailProtection" {
|
||||
hasEmail = true
|
||||
}
|
||||
if name == "serverAuth" || name == "clientAuth" {
|
||||
hasTLS = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid EKUs were resolved, fall back to default
|
||||
if len(resolved) == 0 {
|
||||
return []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
x509.ExtKeyUsageClientAuth,
|
||||
}, x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment
|
||||
}
|
||||
|
||||
// Compute KeyUsage based on EKU mix
|
||||
keyUsage := x509.KeyUsageDigitalSignature
|
||||
if hasTLS {
|
||||
keyUsage |= x509.KeyUsageKeyEncipherment
|
||||
}
|
||||
if hasEmail {
|
||||
keyUsage |= x509.KeyUsageContentCommitment // non-repudiation for S/MIME
|
||||
}
|
||||
|
||||
return resolved, keyUsage
|
||||
}
|
||||
|
||||
// hashPublicKey generates a subject key identifier from a public key.
|
||||
func hashPublicKey(pub interface{}) []byte {
|
||||
h := sha256.New()
|
||||
|
||||
@@ -19,6 +19,7 @@ type AgentService struct {
|
||||
certRepo repository.CertificateRepository
|
||||
jobRepo repository.JobRepository
|
||||
targetRepo repository.TargetRepository
|
||||
profileRepo repository.CertificateProfileRepository
|
||||
auditService *AuditService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
renewalService *RenewalService
|
||||
@@ -45,6 +46,11 @@ func NewAgentService(
|
||||
}
|
||||
}
|
||||
|
||||
// SetProfileRepo sets the profile repository for EKU resolution during CSR signing.
|
||||
func (s *AgentService) SetProfileRepo(repo repository.CertificateProfileRepository) {
|
||||
s.profileRepo = repo
|
||||
}
|
||||
|
||||
// Register creates a new agent and returns its API key (only once).
|
||||
func (s *AgentService) Register(ctx context.Context, name string, hostname string) (*domain.Agent, string, error) {
|
||||
if name == "" || hostname == "" {
|
||||
@@ -159,7 +165,14 @@ func (s *AgentService) SubmitCSR(ctx context.Context, agentID string, certID str
|
||||
// Fallback: direct issuer signing (no AwaitingCSR job — ad-hoc CSR submission)
|
||||
connector, ok := s.issuerRegistry[cert.IssuerID]
|
||||
if ok {
|
||||
result, err := connector.IssueCertificate(ctx, cert.CommonName, cert.SANs, string(csrPEM))
|
||||
// Resolve EKUs from the certificate profile if available
|
||||
var ekus []string
|
||||
if cert.CertificateProfileID != "" && s.profileRepo != nil {
|
||||
if profile, profileErr := s.profileRepo.Get(ctx, cert.CertificateProfileID); profileErr == nil && profile != nil {
|
||||
ekus = profile.AllowedEKUs
|
||||
}
|
||||
}
|
||||
result, err := connector.IssueCertificate(ctx, cert.CommonName, cert.SANs, string(csrPEM), ekus)
|
||||
if err != nil {
|
||||
return fmt.Errorf("issuer signing failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -116,7 +116,8 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
|
||||
"issuer", s.issuerID)
|
||||
|
||||
// Issue the certificate via the configured issuer connector
|
||||
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM)
|
||||
// EST enrollments use default EKUs (nil = serverAuth + clientAuth fallback in connector)
|
||||
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, nil)
|
||||
if err != nil {
|
||||
s.logger.Error("EST enrollment failed",
|
||||
"action", auditAction,
|
||||
|
||||
@@ -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,
|
||||
// translating between service-layer and connector-layer types.
|
||||
func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) {
|
||||
func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
|
||||
result, err := a.connector.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||
CommonName: commonName,
|
||||
SANs: sans,
|
||||
CSRPEM: csrPEM,
|
||||
EKUs: ekus,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -40,11 +41,12 @@ func (a *IssuerConnectorAdapter) IssueCertificate(ctx context.Context, commonNam
|
||||
|
||||
// RenewCertificate delegates to the underlying connector's RenewCertificate method,
|
||||
// translating between service-layer and connector-layer types.
|
||||
func (a *IssuerConnectorAdapter) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) {
|
||||
func (a *IssuerConnectorAdapter) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
|
||||
result, err := a.connector.RenewCertificate(ctx, issuer.RenewalRequest{
|
||||
CommonName: commonName,
|
||||
SANs: sans,
|
||||
CSRPEM: csrPEM,
|
||||
EKUs: ekus,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -120,7 +120,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_Success(t *testing.T) {
|
||||
|
||||
adapter := NewIssuerConnectorAdapter(mock)
|
||||
|
||||
result, err := adapter.IssueCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----")
|
||||
result, err := adapter.IssueCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
@@ -157,7 +157,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_Error(t *testing.T) {
|
||||
|
||||
adapter := NewIssuerConnectorAdapter(mock)
|
||||
|
||||
result, err := adapter.IssueCertificate(ctx, "example.com", []string{}, "csr")
|
||||
result, err := adapter.IssueCertificate(ctx, "example.com", []string{}, "csr", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
@@ -191,7 +191,7 @@ func TestIssuerConnectorAdapter_IssueCertificate_RequestTranslation(t *testing.T
|
||||
sans := []string{"www.test.example.com", "api.test.example.com"}
|
||||
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----"
|
||||
|
||||
_, err := adapter.IssueCertificate(ctx, commonName, sans, csrPEM)
|
||||
_, err := adapter.IssueCertificate(ctx, commonName, sans, csrPEM, nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("IssueCertificate failed: %v", err)
|
||||
@@ -241,7 +241,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_Success(t *testing.T) {
|
||||
|
||||
adapter := NewIssuerConnectorAdapter(mock)
|
||||
|
||||
result, err := adapter.RenewCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----")
|
||||
result, err := adapter.RenewCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("RenewCertificate failed: %v", err)
|
||||
@@ -278,7 +278,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_Error(t *testing.T) {
|
||||
|
||||
adapter := NewIssuerConnectorAdapter(mock)
|
||||
|
||||
result, err := adapter.RenewCertificate(ctx, "example.com", []string{}, "csr")
|
||||
result, err := adapter.RenewCertificate(ctx, "example.com", []string{}, "csr", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
@@ -312,7 +312,7 @@ func TestIssuerConnectorAdapter_RenewCertificate_RequestTranslation(t *testing.T
|
||||
sans := []string{"www.renew.example.com"}
|
||||
csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nRENEW-CSR\n-----END CERTIFICATE REQUEST-----"
|
||||
|
||||
_, err := adapter.RenewCertificate(ctx, commonName, sans, csrPEM)
|
||||
_, err := adapter.RenewCertificate(ctx, commonName, sans, csrPEM, nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("RenewCertificate failed: %v", err)
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
@@ -35,9 +37,9 @@ type RenewalService struct {
|
||||
// inversion. Use IssuerConnectorAdapter to bridge between the two.
|
||||
type IssuerConnector interface {
|
||||
// IssueCertificate issues a new certificate using the provided CSR PEM.
|
||||
IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error)
|
||||
IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error)
|
||||
// RenewCertificate renews a certificate using the provided CSR PEM.
|
||||
RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error)
|
||||
RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error)
|
||||
// RevokeCertificate revokes a certificate by serial number with an optional reason.
|
||||
RevokeCertificate(ctx context.Context, serial string, reason string) error
|
||||
// GenerateCRL generates a DER-encoded X.509 CRL from the given revocation entries.
|
||||
@@ -348,11 +350,23 @@ func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *do
|
||||
return fmt.Errorf("failed to generate private key: %w", err)
|
||||
}
|
||||
|
||||
// Split SANs into DNS names and email addresses for proper CSR encoding
|
||||
var csrDNSNames []string
|
||||
var csrEmailAddresses []string
|
||||
for _, san := range cert.SANs {
|
||||
if strings.Contains(san, "@") {
|
||||
csrEmailAddresses = append(csrEmailAddresses, san)
|
||||
} else {
|
||||
csrDNSNames = append(csrDNSNames, san)
|
||||
}
|
||||
}
|
||||
|
||||
csrTemplate := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: cert.CommonName,
|
||||
},
|
||||
DNSNames: cert.SANs,
|
||||
DNSNames: csrDNSNames,
|
||||
EmailAddresses: csrEmailAddresses,
|
||||
}
|
||||
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, privKey)
|
||||
@@ -372,8 +386,16 @@ func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *do
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
||||
}))
|
||||
|
||||
// Resolve EKUs from the certificate profile
|
||||
var ekus []string
|
||||
if cert.CertificateProfileID != "" && s.profileRepo != nil {
|
||||
if profile, profileErr := s.profileRepo.Get(ctx, cert.CertificateProfileID); profileErr == nil && profile != nil {
|
||||
ekus = profile.AllowedEKUs
|
||||
}
|
||||
}
|
||||
|
||||
// Call issuer connector to renew
|
||||
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM)
|
||||
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM, ekus)
|
||||
if err != nil {
|
||||
s.failJob(ctx, job, fmt.Sprintf("issuer renewal failed: %v", err))
|
||||
if notifErr := s.notificationSvc.SendRenewalNotification(ctx, cert, false, err); notifErr != nil {
|
||||
@@ -480,8 +502,14 @@ func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domai
|
||||
return fmt.Errorf("failed to update job status: %w", err)
|
||||
}
|
||||
|
||||
// Resolve EKUs from the certificate profile (for S/MIME, email certs, etc.)
|
||||
var ekus []string
|
||||
if profile != nil && len(profile.AllowedEKUs) > 0 {
|
||||
ekus = profile.AllowedEKUs
|
||||
}
|
||||
|
||||
// Sign the agent-submitted CSR via issuer
|
||||
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM)
|
||||
result, err := connector.RenewCertificate(ctx, cert.CommonName, cert.SANs, csrPEM, ekus)
|
||||
if err != nil {
|
||||
s.failJob(ctx, job, fmt.Sprintf("issuer signing failed: %v", err))
|
||||
if notifErr := s.notificationSvc.SendRenewalNotification(ctx, cert, false, err); notifErr != nil {
|
||||
@@ -708,6 +736,9 @@ func (s *RenewalService) ExpireShortLivedCertificates(ctx context.Context) error
|
||||
}
|
||||
|
||||
// generateID is a helper to generate unique IDs. In production, use a proper ID generator.
|
||||
var idCounter atomic.Int64
|
||||
|
||||
func generateID(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||
counter := idCounter.Add(1)
|
||||
return fmt.Sprintf("%s-%d-%d", prefix, time.Now().UnixNano(), counter)
|
||||
}
|
||||
|
||||
@@ -589,7 +589,7 @@ type mockIssuerConnector struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) {
|
||||
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
|
||||
if m.Err != nil {
|
||||
return nil, m.Err
|
||||
}
|
||||
@@ -606,11 +606,11 @@ func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName s
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error) {
|
||||
func (m *mockIssuerConnector) RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
|
||||
if m.Err != nil {
|
||||
return nil, m.Err
|
||||
}
|
||||
return m.IssueCertificate(ctx, commonName, sans, csrPEM)
|
||||
return m.IssueCertificate(ctx, commonName, sans, csrPEM, ekus)
|
||||
}
|
||||
|
||||
func (m *mockIssuerConnector) RevokeCertificate(ctx context.Context, serial string, reason string) error {
|
||||
|
||||
@@ -87,6 +87,14 @@ INSERT INTO certificate_profiles (id, name, description, allowed_key_algorithms,
|
||||
4060800, -- 47 days (Ballot SC-081v3 target)
|
||||
'["serverAuth"]'::jsonb,
|
||||
'[".*\\.example\\.com$"]'::jsonb,
|
||||
'', false, true, NOW(), NOW()),
|
||||
|
||||
('prof-smime', 'S/MIME Email',
|
||||
'S/MIME certificate profile for email signing and encryption. Requires emailProtection EKU.',
|
||||
'[{"algorithm": "ECDSA", "min_size": 256}, {"algorithm": "RSA", "min_size": 2048}]'::jsonb,
|
||||
31536000, -- 365 days
|
||||
'["emailProtection"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'', false, true, NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
updateCertificate,
|
||||
archiveCertificate,
|
||||
revokeCertificate,
|
||||
exportCertificatePEM,
|
||||
downloadCertificatePEM,
|
||||
exportCertificatePKCS12,
|
||||
getAgents,
|
||||
getAgent,
|
||||
registerAgent,
|
||||
@@ -798,4 +801,81 @@ describe('API Client', () => {
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Certificate Export ────────────────────────────────
|
||||
|
||||
describe('Certificate Export', () => {
|
||||
it('exportCertificatePEM fetches PEM data as JSON', async () => {
|
||||
const pemResult = { cert_pem: 'CERT', chain_pem: 'CHAIN', full_pem: 'FULL' };
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse(pemResult));
|
||||
const result = await exportCertificatePEM('mc-1');
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/certificates/mc-1/export/pem');
|
||||
expect(result.cert_pem).toBe('CERT');
|
||||
expect(result.full_pem).toBe('FULL');
|
||||
});
|
||||
|
||||
it('downloadCertificatePEM fetches blob with download=true', async () => {
|
||||
const mockBlob = new Blob(['pem-data'], { type: 'application/x-pem-file' });
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as Response)
|
||||
);
|
||||
const blob = await downloadCertificatePEM('mc-1');
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/certificates/mc-1/export/pem?download=true');
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it('downloadCertificatePEM includes auth header', async () => {
|
||||
setApiKey('export-key');
|
||||
const mockBlob = new Blob(['data']);
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as Response)
|
||||
);
|
||||
await downloadCertificatePEM('mc-1');
|
||||
const [, init] = mockFetch.mock.calls[0];
|
||||
expect(init.headers['Authorization']).toBe('Bearer export-key');
|
||||
});
|
||||
|
||||
it('exportCertificatePKCS12 sends POST with password', async () => {
|
||||
const mockBlob = new Blob([new Uint8Array([0x30])], { type: 'application/x-pkcs12' });
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as Response)
|
||||
);
|
||||
const blob = await exportCertificatePKCS12('mc-1', 'mypass');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/certificates/mc-1/export/pkcs12');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.password).toBe('mypass');
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it('exportCertificatePKCS12 uses empty password by default', async () => {
|
||||
const mockBlob = new Blob([new Uint8Array([0x30])]);
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as Response)
|
||||
);
|
||||
await exportCertificatePKCS12('mc-1');
|
||||
const [, init] = mockFetch.mock.calls[0];
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.password).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,33 @@ export const revokeCertificate = (id: string, reason: string) =>
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
|
||||
// Certificate Export
|
||||
export const exportCertificatePEM = (id: string) =>
|
||||
fetchJSON<{ cert_pem: string; chain_pem: string; full_pem: string }>(`${BASE}/certificates/${id}/export/pem`);
|
||||
|
||||
export const downloadCertificatePEM = (id: string) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
return fetch(`${BASE}/certificates/${id}/export/pem?download=true`, { headers })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Export failed');
|
||||
return r.blob();
|
||||
});
|
||||
};
|
||||
|
||||
export const exportCertificatePKCS12 = (id: string, password: string = '') => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
return fetch(`${BASE}/certificates/${id}/export/pkcs12`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ password }),
|
||||
}).then(r => {
|
||||
if (!r.ok) throw new Error('Export failed');
|
||||
return r.blob();
|
||||
});
|
||||
};
|
||||
|
||||
// Agents
|
||||
export const getAgents = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getPolicies, getProfiles } from '../api/client';
|
||||
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getPolicies, getProfiles, downloadCertificatePEM, exportCertificatePKCS12 } from '../api/client';
|
||||
import { REVOCATION_REASONS } from '../api/types';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
@@ -226,6 +226,9 @@ export default function CertificateDetailPage() {
|
||||
const [deployTargetId, setDeployTargetId] = useState('');
|
||||
const [showRevoke, setShowRevoke] = useState(false);
|
||||
const [revokeReason, setRevokeReason] = useState('unspecified');
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
const [pkcs12Password, setPkcs12Password] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const { data: cert, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['certificate', id],
|
||||
@@ -280,6 +283,42 @@ export default function CertificateDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleExportPEM = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await downloadCertificatePEM(id!);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${cert?.common_name || id}.pem`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPKCS12 = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await exportCertificatePKCS12(id!, pkcs12Password);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${cert?.common_name || id}.p12`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setShowExport(false);
|
||||
setPkcs12Password('');
|
||||
} catch (err) {
|
||||
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
@@ -313,6 +352,19 @@ export default function CertificateDetailPage() {
|
||||
<button onClick={() => navigate('/certificates')} className="btn btn-ghost text-xs">
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportPEM}
|
||||
disabled={exporting}
|
||||
className="btn btn-ghost text-xs border border-surface-border disabled:opacity-50"
|
||||
>
|
||||
{exporting ? 'Exporting...' : 'Export PEM'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowExport(true)}
|
||||
className="btn btn-ghost text-xs border border-surface-border"
|
||||
>
|
||||
Export PKCS#12
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeploy(true)}
|
||||
disabled={isArchived || isRevoked}
|
||||
@@ -546,6 +598,38 @@ export default function CertificateDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PKCS#12 Export Modal */}
|
||||
{showExport && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowExport(false)}>
|
||||
<div className="bg-surface border border-surface-border rounded p-6 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-ink mb-2">Export PKCS#12</h2>
|
||||
<p className="text-sm text-ink-muted mb-4">
|
||||
Downloads a .p12 file containing the certificate chain. Private keys are not included (they remain on the agent).
|
||||
</p>
|
||||
<label className="text-xs text-ink-muted block mb-2">Password (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={pkcs12Password}
|
||||
onChange={e => setPkcs12Password(e.target.value)}
|
||||
placeholder="Leave empty for no encryption"
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink mb-4 focus:outline-none focus:border-brand-400"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => { setShowExport(false); setPkcs12Password(''); }} className="btn btn-ghost text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportPKCS12}
|
||||
disabled={exporting}
|
||||
className="btn btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{exporting ? 'Exporting...' : 'Download .p12'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revoke Modal */}
|
||||
{showRevoke && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowRevoke(false)}>
|
||||
|
||||
Reference in New Issue
Block a user