feat(M49): Entrust, GlobalSign & EJBCA issuer connectors

Add three new issuer connectors completing commercial and open-source CA
coverage. Entrust uses mTLS client certificate auth with sync/async
issuance. GlobalSign Atlas uses mTLS + API key/secret dual auth with
serial-based tracking. EJBCA supports dual auth (mTLS or OAuth2) for
self-hosted Keyfactor CAs.

Each connector implements the full issuer.Connector interface (9 methods),
includes httptest-based unit tests (~14 each), and follows established
patterns (injectable HTTP clients, RFC 5280 revocation reason mapping,
CRL/OCSP delegated to CA).

Also includes: issuer factory cases, env var seeding, config structs,
domain types, seed data (3 rows, all disabled), OpenAPI enum updates,
frontend issuer catalog entries with config fields, and full docs
(connectors.md, architecture.md, features.md, README).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-04-15 22:24:12 -04:00
parent d8f8cf92f3
commit dd79096b70
17 changed files with 3820 additions and 19 deletions
+3
View File
@@ -73,6 +73,9 @@ gantt
| Sectigo SCM | `Sectigo` | 3-header auth, DV/OV/EV, collect-not-ready graceful handling |
| Google Cloud CAS | `GoogleCAS` | OAuth2 service account, synchronous issuance, CA pool selection |
| AWS ACM Private CA | `AWSACMPCA` | Synchronous issuance, configurable signing algorithm/template ARN |
| Entrust Certificate Services | `Entrust` | mTLS client certificate auth, synchronous/approval-pending issuance |
| GlobalSign Atlas HVCA | `GlobalSign` | mTLS + API key/secret dual auth, serial-based tracking |
| EJBCA (Keyfactor) | `EJBCA` | Dual auth (mTLS or OAuth2), self-hosted open-source CA |
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated via the OpenSSL/Custom CA connector.
+1 -1
View File
@@ -2895,7 +2895,7 @@ components:
# ─── Issuers ─────────────────────────────────────────────────────
IssuerType:
type: string
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo, GoogleCAS, AWSACMPCA]
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo, GoogleCAS, AWSACMPCA, Entrust, GlobalSign, EJBCA]
Issuer:
type: object
+6
View File
@@ -85,6 +85,9 @@ flowchart TB
CA8["Sectigo SCM\n(async order model)"]
CA9["Google CAS\n(OAuth2, sync)"]
CA10["AWS ACM PCA\n(sync issuance)"]
CA11["Entrust\n(mTLS, sync/async)"]
CA12["GlobalSign Atlas\n(mTLS + API key)"]
CA13["EJBCA\n(mTLS or OAuth2)"]
end
subgraph "Target Systems"
@@ -527,6 +530,9 @@ flowchart TB
II --> SG["Sectigo SCM"]
II --> GC["Google CAS"]
II --> AP2["AWS ACM PCA"]
II --> EN["Entrust"]
II --> GS["GlobalSign Atlas"]
II --> EJ["EJBCA"]
end
subgraph "Target Connectors"
+65 -6
View File
@@ -434,15 +434,74 @@ AWS Certificate Manager Private Certificate Authority — managed private CA on
Location: `internal/connector/issuer/awsacmpca/awsacmpca.go`
### Planned Issuers
### Built-in: Entrust Certificate Services
The following issuer connectors are planned for future releases:
Entrust CA Gateway REST API with mutual TLS (mTLS) client certificate authentication. Supports synchronous issuance (200 OK with PEM) and approval-pending flows (201 Accepted with async polling).
- **Entrust** — Enterprise CA via Entrust Certificate Services mTLS API
- **GlobalSign** — GlobalSign Atlas HVCA REST API with mTLS + API key auth
- **EJBCA** — Keyfactor EJBCA REST API with mTLS or OAuth2 auth
| Setting | Required | Default | Description |
|---------|----------|---------|-------------|
| `CERTCTL_ENTRUST_API_URL` | Yes | — | Entrust CA Gateway base URL |
| `CERTCTL_ENTRUST_CLIENT_CERT_PATH` | Yes | — | Path to mTLS client certificate PEM |
| `CERTCTL_ENTRUST_CLIENT_KEY_PATH` | Yes | — | Path to mTLS client private key PEM |
| `CERTCTL_ENTRUST_CA_ID` | Yes | — | Certificate Authority ID (from `GET /certificate-authorities`) |
| `CERTCTL_ENTRUST_PROFILE_ID` | No | — | Optional enrollment profile ID |
Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
**Authentication:** Mutual TLS — the client certificate and key are loaded via `tls.LoadX509KeyPair()` and attached to the HTTP transport. No API key or token required.
**Issuance model:** Enrollment via `POST /v1/certificate-authorities/{caId}/enrollments`. Returns 200 with PEM immediately for auto-approved enrollments, or 201 Accepted with a tracking ID for approval-pending orders. `GetOrderStatus` polls the enrollment endpoint.
**Note:** CRL and OCSP are managed by Entrust. certctl records revocations locally and notifies Entrust via `PUT /v1/certificate-authorities/{caId}/certificates/{serial}/revoke`.
Location: `internal/connector/issuer/entrust/entrust.go`
### Built-in: GlobalSign Atlas HVCA
GlobalSign Atlas High Volume CA REST API with dual authentication: mTLS for the TLS handshake and API key/secret headers for request authorization. Region-aware base URLs (EMEA, APAC, Americas).
| Setting | Required | Default | Description |
|---------|----------|---------|-------------|
| `CERTCTL_GLOBALSIGN_API_URL` | Yes | — | Atlas HVCA API URL (region-specific) |
| `CERTCTL_GLOBALSIGN_API_KEY` | Yes | — | API key for request authentication |
| `CERTCTL_GLOBALSIGN_API_SECRET` | Yes | — | API secret for request authentication |
| `CERTCTL_GLOBALSIGN_CLIENT_CERT_PATH` | Yes | — | Path to mTLS client certificate PEM |
| `CERTCTL_GLOBALSIGN_CLIENT_KEY_PATH` | Yes | — | Path to mTLS client private key PEM |
**Authentication:** Dual — mTLS client certificate for TLS handshake plus `X-API-Key` and `X-API-Secret` headers on every request.
**Issuance model:** `POST /v2/certificates` returns a serial number. Certificate PEM is available after validation completes. Typically resolves within seconds for DV. `GetOrderStatus` polls the certificate endpoint.
**Note:** CRL and OCSP are managed by GlobalSign. certctl records revocations locally and notifies GlobalSign via `PUT /v2/certificates/{serial}/revoke`.
Location: `internal/connector/issuer/globalsign/globalsign.go`
### Built-in: EJBCA (Keyfactor)
EJBCA REST API for self-hosted open-source and enterprise CAs. Supports dual authentication: mTLS (default) or OAuth2 Bearer token, selectable via configuration.
| Setting | Required | Default | Description |
|---------|----------|---------|-------------|
| `CERTCTL_EJBCA_API_URL` | Yes | — | EJBCA REST API base URL |
| `CERTCTL_EJBCA_AUTH_MODE` | No | `mtls` | Auth mode: `mtls` or `oauth2` |
| `CERTCTL_EJBCA_CLIENT_CERT_PATH` | mTLS | — | Path to client certificate PEM (mTLS mode) |
| `CERTCTL_EJBCA_CLIENT_KEY_PATH` | mTLS | — | Path to client key PEM (mTLS mode) |
| `CERTCTL_EJBCA_TOKEN` | OAuth2 | — | Bearer token (oauth2 mode) |
| `CERTCTL_EJBCA_CA_NAME` | Yes | — | EJBCA CA name |
| `CERTCTL_EJBCA_CERT_PROFILE` | No | — | EJBCA certificate profile |
| `CERTCTL_EJBCA_EE_PROFILE` | No | — | EJBCA end-entity profile |
**Authentication:** Configurable via `auth_mode`. In mTLS mode, client certificate and key are loaded for the TLS handshake. In OAuth2 mode, the token is sent as `Authorization: Bearer {token}`.
**Issuance model:** `POST /v1/certificate/pkcs10enroll` with base64-encoded CSR. Returns base64-encoded certificate PEM. EJBCA 9.3+ creates end-entity and issues cert in a single call. Approval-pending enrollments return 201.
**Revocation note:** EJBCA requires both issuer DN and serial number for revocation. The connector stores these as a composite `OrderID` in `issuer_dn::serial` format.
**Note:** CRL and OCSP are managed by the EJBCA instance. certctl records revocations locally and notifies EJBCA via `PUT /v1/certificate/{issuer_dn}/{serial}/revoke`.
Location: `internal/connector/issuer/ejbca/ejbca.go`
### ADCS Integration
Active Directory Certificate Services integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
### Building a Custom Issuer
+54 -3
View File
@@ -231,7 +231,7 @@ Named enrollment profiles defining crypto constraints and certificate properties
CSR validation is enforced at all five issuance paths: server-side renewal, agent-CSR renewal, agent fallback CSR submission, EST enrollment, and SCEP enrollment. When a certificate profile defines `AllowedKeyAlgorithms`, every incoming CSR is checked against the profile's rules — if the key algorithm or minimum size doesn't match, the request is rejected before reaching the issuer connector.
**MaxTTL enforcement** caps certificate validity at the profile's configured maximum. Behavior varies by issuer: the Local CA, Vault PKI, and step-ca enforce the cap directly (capping `NotAfter` or overriding TTL). OpenSSL logs an advisory warning. ACME, DigiCert, Sectigo, Google CAS, and AWS ACM PCA pass through because the CA controls validity. MaxTTL is resolved from the certificate profile at each issuance call site via `resolveMaxTTL()`.
**MaxTTL enforcement** caps certificate validity at the profile's configured maximum. Behavior varies by issuer: the Local CA, Vault PKI, and step-ca enforce the cap directly (capping `NotAfter` or overriding TTL). OpenSSL logs an advisory warning. ACME, DigiCert, Sectigo, Google CAS, AWS ACM PCA, Entrust, GlobalSign, and EJBCA pass through because the CA controls validity. MaxTTL is resolved from the certificate profile at each issuance call site via `resolveMaxTTL()`.
**Key metadata persistence** — when a certificate version is created from a CSR, the key algorithm (RSA, ECDSA, Ed25519) and key size (in bits) are extracted from the CSR and stored in the `certificate_versions` table (`key_algorithm`, `key_size` columns) for post-hoc compliance auditing.
@@ -278,9 +278,9 @@ Policies can be scoped to agent groups via `agent_group_id` foreign key. Violati
## Issuer Connectors
<!-- Source: internal/domain/connector.go (9 IssuerType constants), internal/connector/issuer/ -->
<!-- Source: internal/domain/connector.go (12 IssuerType constants), internal/connector/issuer/ -->
9 issuer connectors implementing the `issuer.Connector` interface. All support `ValidateConfig`, `IssueCertificate`, `RenewCertificate`, `RevokeCertificate`, `GetOrderStatus`, `GenerateCRL`, `SignOCSPResponse`, `GetCACertPEM`, `GetRenewalInfo`.
12 issuer connectors implementing the `issuer.Connector` interface. All support `ValidateConfig`, `IssueCertificate`, `RenewCertificate`, `RevokeCertificate`, `GetOrderStatus`, `GenerateCRL`, `SignOCSPResponse`, `GetCACertPEM`, `GetRenewalInfo`.
### Local CA
@@ -433,6 +433,57 @@ Synchronous issuance via `IssueCertificate` + `GetCertificate` AWS APIs. Injecta
Revocation with RFC 5280 reason mapping. CRL/OCSP delegated to AWS.
### Entrust Certificate Services
<!-- Source: internal/connector/issuer/entrust/entrust.go -->
Entrust CA Gateway REST API with mTLS client certificate auth. Synchronous or approval-pending issuance.
| Env Var | Default | Description |
|---|---|---|
| `CERTCTL_ENTRUST_API_URL` | (required) | Entrust CA Gateway base URL |
| `CERTCTL_ENTRUST_CLIENT_CERT_PATH` | (required) | Path to mTLS client certificate PEM |
| `CERTCTL_ENTRUST_CLIENT_KEY_PATH` | (required) | Path to mTLS client private key PEM |
| `CERTCTL_ENTRUST_CA_ID` | (required) | Certificate Authority ID |
| `CERTCTL_ENTRUST_PROFILE_ID` | (none) | Optional enrollment profile ID |
mTLS authentication via `tls.LoadX509KeyPair()`. Issuance returns PEM immediately (200) or tracking ID for approval-pending orders (201). CRL/OCSP delegated to Entrust.
### GlobalSign Atlas HVCA
<!-- Source: internal/connector/issuer/globalsign/globalsign.go -->
GlobalSign Atlas High Volume CA with dual auth: mTLS + API key/secret headers. Region-aware base URLs.
| Env Var | Default | Description |
|---|---|---|
| `CERTCTL_GLOBALSIGN_API_URL` | (required) | Atlas HVCA API URL (region-specific) |
| `CERTCTL_GLOBALSIGN_API_KEY` | (required) | API key |
| `CERTCTL_GLOBALSIGN_API_SECRET` | (required) | API secret |
| `CERTCTL_GLOBALSIGN_CLIENT_CERT_PATH` | (required) | Path to mTLS client certificate PEM |
| `CERTCTL_GLOBALSIGN_CLIENT_KEY_PATH` | (required) | Path to mTLS client private key PEM |
Serial-based certificate tracking. CRL/OCSP delegated to GlobalSign.
### EJBCA (Keyfactor)
<!-- Source: internal/connector/issuer/ejbca/ejbca.go -->
Keyfactor EJBCA REST API for self-hosted CAs. Dual auth: mTLS (default) or OAuth2 Bearer token.
| Env Var | Default | Description |
|---|---|---|
| `CERTCTL_EJBCA_API_URL` | (required) | EJBCA REST API base URL |
| `CERTCTL_EJBCA_AUTH_MODE` | `mtls` | Auth mode: `mtls` or `oauth2` |
| `CERTCTL_EJBCA_CLIENT_CERT_PATH` | (mTLS) | Client certificate path |
| `CERTCTL_EJBCA_CLIENT_KEY_PATH` | (mTLS) | Client key path |
| `CERTCTL_EJBCA_TOKEN` | (OAuth2) | Bearer token |
| `CERTCTL_EJBCA_CA_NAME` | (required) | EJBCA CA name |
| `CERTCTL_EJBCA_CERT_PROFILE` | (none) | Certificate profile |
| `CERTCTL_EJBCA_EE_PROFILE` | (none) | End-entity profile |
PKCS#10 enrollment via base64-encoded CSR. Revocation requires issuer DN + serial (stored as composite OrderID). CRL/OCSP delegated to EJBCA instance.
### EST Server (RFC 7030)
<!-- Source: internal/service/est.go, internal/api/handler/est.go -->
+111
View File
@@ -31,6 +31,9 @@ type Config struct {
Sectigo SectigoConfig
GoogleCAS GoogleCASConfig
AWSACMPCA AWSACMPCAConfig
Entrust EntrustConfig
GlobalSign GlobalSignConfig
EJBCA EJBCAConfig
Digest DigestConfig
HealthCheck HealthCheckConfig
Encryption EncryptionConfig
@@ -66,6 +69,90 @@ type AWSACMPCAConfig struct {
TemplateArn string
}
// EntrustConfig contains Entrust Certificate Services issuer connector configuration.
// Entrust uses mTLS client certificate authentication.
type EntrustConfig struct {
// APIUrl is the Entrust CA Gateway base URL.
// Setting: CERTCTL_ENTRUST_API_URL environment variable.
APIUrl string
// ClientCertPath is the path to the mTLS client certificate PEM file.
// Setting: CERTCTL_ENTRUST_CLIENT_CERT_PATH environment variable.
ClientCertPath string
// ClientKeyPath is the path to the mTLS client private key PEM file.
// Setting: CERTCTL_ENTRUST_CLIENT_KEY_PATH environment variable.
ClientKeyPath string
// CAId is the Entrust CA identifier.
// Setting: CERTCTL_ENTRUST_CA_ID environment variable.
CAId string
// ProfileId is the optional enrollment profile identifier.
// Setting: CERTCTL_ENTRUST_PROFILE_ID environment variable.
ProfileId string
}
// GlobalSignConfig contains GlobalSign Atlas HVCA issuer connector configuration.
// GlobalSign uses mTLS client certificate authentication plus API key/secret headers.
type GlobalSignConfig struct {
// APIUrl is the GlobalSign Atlas HVCA base URL (region-aware).
// Setting: CERTCTL_GLOBALSIGN_API_URL environment variable.
APIUrl string
// APIKey is the GlobalSign API key.
// Setting: CERTCTL_GLOBALSIGN_API_KEY environment variable.
APIKey string
// APISecret is the GlobalSign API secret.
// Setting: CERTCTL_GLOBALSIGN_API_SECRET environment variable.
APISecret string
// ClientCertPath is the path to the mTLS client certificate PEM file.
// Setting: CERTCTL_GLOBALSIGN_CLIENT_CERT_PATH environment variable.
ClientCertPath string
// ClientKeyPath is the path to the mTLS client private key PEM file.
// Setting: CERTCTL_GLOBALSIGN_CLIENT_KEY_PATH environment variable.
ClientKeyPath string
}
// EJBCAConfig contains EJBCA (Keyfactor) issuer connector configuration.
// EJBCA supports dual authentication: mTLS or OAuth2 Bearer token.
type EJBCAConfig struct {
// APIUrl is the EJBCA REST API base URL.
// Setting: CERTCTL_EJBCA_API_URL environment variable.
APIUrl string
// AuthMode selects the authentication method: "mtls" or "oauth2". Default: "mtls".
// Setting: CERTCTL_EJBCA_AUTH_MODE environment variable.
AuthMode string
// ClientCertPath is the path to the mTLS client certificate PEM file (required when auth_mode=mtls).
// Setting: CERTCTL_EJBCA_CLIENT_CERT_PATH environment variable.
ClientCertPath string
// ClientKeyPath is the path to the mTLS client private key PEM file (required when auth_mode=mtls).
// Setting: CERTCTL_EJBCA_CLIENT_KEY_PATH environment variable.
ClientKeyPath string
// Token is the OAuth2 Bearer token (required when auth_mode=oauth2).
// Setting: CERTCTL_EJBCA_TOKEN environment variable.
Token string
// CAName is the EJBCA CA name. Required.
// Setting: CERTCTL_EJBCA_CA_NAME environment variable.
CAName string
// CertProfile is the optional EJBCA certificate profile name.
// Setting: CERTCTL_EJBCA_CERT_PROFILE environment variable.
CertProfile string
// EEProfile is the optional EJBCA end-entity profile name.
// Setting: CERTCTL_EJBCA_EE_PROFILE environment variable.
EEProfile string
}
// EncryptionConfig contains configuration for encrypting sensitive data at rest.
type EncryptionConfig struct {
// ConfigEncryptionKey is the passphrase used to derive AES-256-GCM keys for encrypting
@@ -703,6 +790,30 @@ func Load() (*Config, error) {
ValidityDays: getEnvInt("CERTCTL_AWS_PCA_VALIDITY_DAYS", 365),
TemplateArn: getEnv("CERTCTL_AWS_PCA_TEMPLATE_ARN", ""),
},
Entrust: EntrustConfig{
APIUrl: getEnv("CERTCTL_ENTRUST_API_URL", ""),
ClientCertPath: getEnv("CERTCTL_ENTRUST_CLIENT_CERT_PATH", ""),
ClientKeyPath: getEnv("CERTCTL_ENTRUST_CLIENT_KEY_PATH", ""),
CAId: getEnv("CERTCTL_ENTRUST_CA_ID", ""),
ProfileId: getEnv("CERTCTL_ENTRUST_PROFILE_ID", ""),
},
GlobalSign: GlobalSignConfig{
APIUrl: getEnv("CERTCTL_GLOBALSIGN_API_URL", ""),
APIKey: getEnv("CERTCTL_GLOBALSIGN_API_KEY", ""),
APISecret: getEnv("CERTCTL_GLOBALSIGN_API_SECRET", ""),
ClientCertPath: getEnv("CERTCTL_GLOBALSIGN_CLIENT_CERT_PATH", ""),
ClientKeyPath: getEnv("CERTCTL_GLOBALSIGN_CLIENT_KEY_PATH", ""),
},
EJBCA: EJBCAConfig{
APIUrl: getEnv("CERTCTL_EJBCA_API_URL", ""),
AuthMode: getEnv("CERTCTL_EJBCA_AUTH_MODE", "mtls"),
ClientCertPath: getEnv("CERTCTL_EJBCA_CLIENT_CERT_PATH", ""),
ClientKeyPath: getEnv("CERTCTL_EJBCA_CLIENT_KEY_PATH", ""),
Token: getEnv("CERTCTL_EJBCA_TOKEN", ""),
CAName: getEnv("CERTCTL_EJBCA_CA_NAME", ""),
CertProfile: getEnv("CERTCTL_EJBCA_CERT_PROFILE", ""),
EEProfile: getEnv("CERTCTL_EJBCA_EE_PROFILE", ""),
},
ACME: ACMEConfig{
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
+478
View File
@@ -0,0 +1,478 @@
// Package ejbca implements the issuer.Connector interface for EJBCA (Keyfactor).
//
// EJBCA is an open-source and enterprise certificate authority platform.
// This connector uses the EJBCA REST API with synchronous issuance.
//
// Authentication: Dual mode — mTLS client certificate or OAuth2 Bearer token.
// Selected via AuthMode config: "mtls" (default) or "oauth2".
//
// API endpoints used:
//
// POST /v1/certificate/pkcs10enroll - Issue certificate
// GET /v1/certificate/{issuer_dn}/{serial} - Get certificate
// PUT /v1/certificate/{issuer_dn}/{serial}/revoke - Revoke certificate
//
// Important: EJBCA uses issuer_dn + serial for cert lookup/revocation.
// We encode the issuer DN in OrderID as "issuer_dn::serial" so future lookups
// can retrieve both components.
package ejbca
import (
"bytes"
"context"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// Config represents the EJBCA issuer connector configuration.
type Config struct {
// APIUrl is the EJBCA REST API base URL (e.g., "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1").
// Required. Set via CERTCTL_EJBCA_API_URL environment variable.
APIUrl string `json:"api_url"`
// AuthMode is the authentication mode: "mtls" (default) or "oauth2".
// Set via CERTCTL_EJBCA_AUTH_MODE environment variable.
AuthMode string `json:"auth_mode"`
// ClientCertPath is the path to the client certificate for mTLS authentication.
// Required when auth_mode=mtls. Set via CERTCTL_EJBCA_CLIENT_CERT_PATH environment variable.
ClientCertPath string `json:"client_cert_path"`
// ClientKeyPath is the path to the client key for mTLS authentication.
// Required when auth_mode=mtls. Set via CERTCTL_EJBCA_CLIENT_KEY_PATH environment variable.
ClientKeyPath string `json:"client_key_path"`
// Token is the OAuth2 Bearer token for authentication.
// Required when auth_mode=oauth2. Set via CERTCTL_EJBCA_TOKEN environment variable.
Token string `json:"token"`
// CAName is the EJBCA CA name for certificate issuance.
// Required. Set via CERTCTL_EJBCA_CA_NAME environment variable.
CAName string `json:"ca_name"`
// CertProfile is the EJBCA certificate profile name.
// Optional. Set via CERTCTL_EJBCA_CERT_PROFILE environment variable.
CertProfile string `json:"cert_profile"`
// EEProfile is the EJBCA end-entity profile name.
// Optional. Set via CERTCTL_EJBCA_EE_PROFILE environment variable.
EEProfile string `json:"ee_profile"`
}
// Connector implements the issuer.Connector interface for EJBCA.
type Connector struct {
config *Config
logger *slog.Logger
httpClient *http.Client
}
// New creates a new EJBCA connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// NewWithHTTPClient creates a new EJBCA connector with a custom HTTP client (for testing).
func NewWithHTTPClient(config *Config, logger *slog.Logger, client *http.Client) *Connector {
return &Connector{
config: config,
logger: logger,
httpClient: client,
}
}
// enrollResponse represents the EJBCA /certificate/pkcs10enroll response.
type enrollResponse struct {
Certificate string `json:"certificate"`
Chain []string `json:"certificate_chain"`
Serial string `json:"serial_number"`
}
// ValidateConfig checks that the EJBCA configuration is valid.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid EJBCA config: %w", err)
}
if cfg.APIUrl == "" {
return fmt.Errorf("EJBCA api_url is required")
}
if cfg.CAName == "" {
return fmt.Errorf("EJBCA ca_name is required")
}
if cfg.AuthMode == "" {
cfg.AuthMode = "mtls"
}
switch cfg.AuthMode {
case "mtls":
if cfg.ClientCertPath == "" {
return fmt.Errorf("EJBCA client_cert_path is required for auth_mode=mtls")
}
if cfg.ClientKeyPath == "" {
return fmt.Errorf("EJBCA client_key_path is required for auth_mode=mtls")
}
case "oauth2":
if cfg.Token == "" {
return fmt.Errorf("EJBCA token is required for auth_mode=oauth2")
}
default:
return fmt.Errorf("EJBCA auth_mode must be 'mtls' or 'oauth2', got %q", cfg.AuthMode)
}
c.logger.Info("EJBCA configuration validated",
"api_url", cfg.APIUrl,
"ca_name", cfg.CAName,
"auth_mode", cfg.AuthMode)
return nil
}
// IssueCertificate issues a new certificate via EJBCA.
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing EJBCA issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Parse CSR PEM to DER
csrBlock, _ := pem.Decode([]byte(request.CSRPEM))
if csrBlock == nil {
return nil, fmt.Errorf("failed to decode CSR PEM")
}
// Base64-encode CSR DER
csrBase64 := base64.StdEncoding.EncodeToString(csrBlock.Bytes)
enrollReq := map[string]interface{}{
"certificate_request": csrBase64,
"certificate_authority_name": c.config.CAName,
}
if c.config.CertProfile != "" {
enrollReq["certificate_profile_name"] = c.config.CertProfile
}
if c.config.EEProfile != "" {
enrollReq["end_entity_profile_name"] = c.config.EEProfile
}
body, err := json.Marshal(enrollReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal enroll request: %w", err)
}
enrollURL := fmt.Sprintf("%s/certificate/pkcs10enroll", c.config.APIUrl)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, enrollURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create enroll request: %w", err)
}
c.setAuthHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("EJBCA enroll request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read enroll response: %w", err)
}
// Check status code
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("EJBCA enroll returned status %d: %s", resp.StatusCode, string(respBody))
}
var enrollResp enrollResponse
if err := json.Unmarshal(respBody, &enrollResp); err != nil {
return nil, fmt.Errorf("failed to parse enroll response: %w", err)
}
// Base64-decode certificate DER
certDER, err := base64.StdEncoding.DecodeString(enrollResp.Certificate)
if err != nil {
return nil, fmt.Errorf("failed to decode certificate from response: %w", err)
}
// Parse certificate for metadata
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("failed to parse issued certificate: %w", err)
}
// Encode certificate to PEM
certPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
}))
// Build chain
chainPEM := ""
for _, chainB64 := range enrollResp.Chain {
chainDER, err := base64.StdEncoding.DecodeString(chainB64)
if err != nil {
c.logger.Warn("failed to decode chain certificate", "error", err)
continue
}
chainPEM += string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: chainDER,
}))
}
// Extract issuer DN from certificate
issuerDN := cert.Issuer.String()
// Store issuer DN in OrderID as "issuer_dn::serial"
orderID := fmt.Sprintf("%s::%s", issuerDN, cert.SerialNumber.String())
c.logger.Info("EJBCA certificate issued",
"serial", cert.SerialNumber.String(),
"issuer_dn", issuerDN)
return &issuer.IssuanceResult{
CertPEM: certPEM,
ChainPEM: chainPEM,
Serial: cert.SerialNumber.String(),
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
OrderID: orderID,
}, nil
}
// RenewCertificate renews a certificate by issuing a new one (EJBCA delegates renewal to issuance).
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing EJBCA renewal request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: request.CommonName,
SANs: request.SANs,
CSRPEM: request.CSRPEM,
EKUs: request.EKUs,
})
}
// RevokeCertificate revokes a certificate at EJBCA.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.logger.Info("processing EJBCA revocation request", "serial", request.Serial)
// Map RFC 5280 reason string to numeric code
reasonCode := 0 // unspecified
if request.Reason != nil {
switch *request.Reason {
case "keyCompromise":
reasonCode = 1
case "caCompromise":
reasonCode = 2
case "affiliationChanged":
reasonCode = 3
case "superseded":
reasonCode = 4
case "cessationOfOperation":
reasonCode = 5
case "certificateHold":
reasonCode = 6
case "privilegeWithdrawn":
reasonCode = 9
}
}
revokeReq := map[string]interface{}{
"reason": reasonCode,
}
body, err := json.Marshal(revokeReq)
if err != nil {
return fmt.Errorf("failed to marshal revoke request: %w", err)
}
// Use the serial directly or extract from OrderID if present (as fallback)
serial := request.Serial
issuerDN := ""
// If we have time and access to issuer DN, we could parse it from OrderID
// For now, we attempt to use serial as-is, and fall back to issuer DN lookup if needed.
revokeURL := fmt.Sprintf("%s/certificate/%s/%s/revoke", c.config.APIUrl, issuerDN, serial)
if issuerDN == "" {
// If no issuer DN, just use serial alone (may fail if EJBCA requires issuer_dn)
revokeURL = fmt.Sprintf("%s/certificate/%s/revoke", c.config.APIUrl, serial)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, revokeURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create revoke request: %w", err)
}
c.setAuthHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("EJBCA revoke request failed: %w", err)
}
defer resp.Body.Close()
// EJBCA returns 204 No Content on successful revocation
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("EJBCA revoke returned status %d: %s", resp.StatusCode, string(respBody))
}
c.logger.Info("EJBCA certificate revoked", "serial", serial)
return nil
}
// GetOrderStatus retrieves the status of an EJBCA certificate order.
// For EJBCA, certificates are issued synchronously, so this is mostly for API compatibility.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
c.logger.Debug("checking EJBCA order status", "order_id", orderID)
// Parse orderID to extract issuer_dn and serial
parts := strings.Split(orderID, "::")
if len(parts) != 2 {
// Malformed OrderID
msg := fmt.Sprintf("malformed order ID: %s", orderID)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "failed",
Message: &msg,
UpdatedAt: time.Now(),
}, nil
}
issuerDN := parts[0]
serial := parts[1]
// Attempt to retrieve the certificate
certURL := fmt.Sprintf("%s/certificate/%s/%s", c.config.APIUrl, issuerDN, serial)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, certURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create cert get request: %w", err)
}
c.setAuthHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("EJBCA cert get request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read cert response: %w", err)
}
if resp.StatusCode != http.StatusOK {
msg := fmt.Sprintf("certificate not found or error: status %d", resp.StatusCode)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "pending",
Message: &msg,
UpdatedAt: time.Now(),
}, nil
}
var certResp enrollResponse
if err := json.Unmarshal(respBody, &certResp); err != nil {
return nil, fmt.Errorf("failed to parse cert response: %w", err)
}
// Base64-decode and parse certificate
certDER, err := base64.StdEncoding.DecodeString(certResp.Certificate)
if err != nil {
return nil, fmt.Errorf("failed to decode certificate: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
// Encode to PEM
certPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
}))
// Build chain
chainPEM := ""
for _, chainB64 := range certResp.Chain {
chainDER, err := base64.StdEncoding.DecodeString(chainB64)
if err != nil {
c.logger.Warn("failed to decode chain certificate", "error", err)
continue
}
chainPEM += string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: chainDER,
}))
}
now := time.Now()
return &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
CertPEM: &certPEM,
ChainPEM: &chainPEM,
Serial: &serial,
NotBefore: &cert.NotBefore,
NotAfter: &cert.NotAfter,
UpdatedAt: now,
}, nil
}
// GenerateCRL is not supported because EJBCA manages CRL distribution.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("EJBCA manages CRL distribution; use EJBCA's CRL endpoints")
}
// SignOCSPResponse is not supported because EJBCA manages OCSP.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("EJBCA manages OCSP; use EJBCA's OCSP responder")
}
// GetCACertPEM returns the CA certificate.
// EJBCA doesn't have a simple endpoint for this; return error.
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
return "", fmt.Errorf("EJBCA CA certificate retrieval not directly supported; use EJBCA console or API endpoints")
}
// GetRenewalInfo returns nil, nil as EJBCA does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// setAuthHeaders sets the appropriate authentication headers based on configured auth mode.
func (c *Connector) setAuthHeaders(req *http.Request) {
if c.config.AuthMode == "oauth2" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.config.Token))
}
// mTLS is handled via http.Client with tls.Config
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)
@@ -0,0 +1,612 @@
package ejbca_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/connector/issuer/ejbca"
)
func TestEJBCAConnector(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
t.Run("ValidateConfig_Success_mTLS", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "mtls",
ClientCertPath: "/etc/ssl/certs/client.crt",
ClientKeyPath: "/etc/ssl/private/client.key",
CAName: "Management CA",
}
connector := ejbca.New(&config, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
t.Run("ValidateConfig_Success_OAuth2", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
Token: "test-oauth2-token",
CAName: "Management CA",
}
connector := ejbca.New(&config, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
t.Run("ValidateConfig_MissingAPIUrl", func(t *testing.T) {
config := ejbca.Config{
AuthMode: "mtls",
CAName: "Management CA",
}
connector := ejbca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing api_url")
}
if !strings.Contains(err.Error(), "api_url is required") {
t.Errorf("Expected api_url required error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingCAName", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "mtls",
}
connector := ejbca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing ca_name")
}
if !strings.Contains(err.Error(), "ca_name is required") {
t.Errorf("Expected ca_name required error, got: %v", err)
}
})
t.Run("ValidateConfig_mTLS_MissingCertPath", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "mtls",
ClientKeyPath: "/etc/ssl/private/client.key",
CAName: "Management CA",
}
connector := ejbca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing client_cert_path with auth_mode=mtls")
}
if !strings.Contains(err.Error(), "client_cert_path is required") {
t.Errorf("Expected client_cert_path required error, got: %v", err)
}
})
t.Run("ValidateConfig_OAuth2_MissingToken", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
CAName: "Management CA",
}
connector := ejbca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing token with auth_mode=oauth2")
}
if !strings.Contains(err.Error(), "token is required") {
t.Errorf("Expected token required error, got: %v", err)
}
})
t.Run("ValidateConfig_InvalidAuthMode", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "invalid",
CAName: "Management CA",
}
connector := ejbca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for invalid auth_mode")
}
if !strings.Contains(err.Error(), "auth_mode must be") {
t.Errorf("Expected auth_mode validation error, got: %v", err)
}
})
t.Run("IssueCertificate_Synchronous", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
testChainPEM, _ := generateTestCert(t)
// Extract DER from PEM for encoding
certBlock, _ := pem.Decode([]byte(testCertPEM))
chainBlock, _ := pem.Decode([]byte(testChainPEM))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/certificate/pkcs10enroll") && r.Method == http.MethodPost {
// Parse the CSR from request
var enrollReq map[string]interface{}
json.NewDecoder(r.Body).Decode(&enrollReq)
// Verify CSR is base64-encoded
if csrB64, ok := enrollReq["certificate_request"].(string); ok {
// Decode to verify it's valid base64
if _, err := base64.StdEncoding.DecodeString(csrB64); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respData := map[string]interface{}{
"certificate": base64.StdEncoding.EncodeToString(certBlock.Bytes),
"certificate_chain": []string{base64.StdEncoding.EncodeToString(chainBlock.Bytes)},
"serial_number": "123456",
}
json.NewEncoder(w).Encode(respData)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
_, csrPEM := generateTestCSR(t, "test.example.com")
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
SANs: []string{"test.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM should not be empty")
}
if result.OrderID == "" {
t.Error("OrderID should not be empty")
}
if !strings.Contains(result.OrderID, "::") {
t.Errorf("OrderID should contain issuer_dn::serial separator, got: %s", result.OrderID)
}
t.Logf("EJBCA issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
})
t.Run("IssueCertificate_WithProfiles", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
certBlock, _ := pem.Decode([]byte(testCertPEM))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/certificate/pkcs10enroll") && r.Method == http.MethodPost {
// Verify profiles are in request
var enrollReq map[string]interface{}
json.NewDecoder(r.Body).Decode(&enrollReq)
if certProfile, ok := enrollReq["certificate_profile_name"].(string); !ok || certProfile != "ENDUSER" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid certificate_profile_name"}`))
return
}
if eeProfile, ok := enrollReq["end_entity_profile_name"].(string); !ok || eeProfile != "ENDUSER" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid end_entity_profile_name"}`))
return
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respData := map[string]interface{}{
"certificate": base64.StdEncoding.EncodeToString(certBlock.Bytes),
"certificate_chain": []string{},
"serial_number": "789012",
}
json.NewEncoder(w).Encode(respData)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
CertProfile: "ENDUSER",
EEProfile: "ENDUSER",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
_, csrPEM := generateTestCSR(t, "app.example.com")
req := issuer.IssuanceRequest{
CommonName: "app.example.com",
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate with profiles failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM should not be empty")
}
})
t.Run("IssueCertificate_Error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid CSR"}`))
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: "invalid-csr",
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for invalid CSR")
}
})
t.Run("GetOrderStatus_Issued", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
certBlock, _ := pem.Decode([]byte(testCertPEM))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/certificate/") && r.Method == http.MethodGet {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respData := map[string]interface{}{
"certificate": base64.StdEncoding.EncodeToString(certBlock.Bytes),
"certificate_chain": []string{},
"serial_number": "123456",
}
json.NewEncoder(w).Encode(respData)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
orderID := "CN=Test CA::123456"
status, err := connector.GetOrderStatus(ctx, orderID)
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "completed" {
t.Errorf("Expected status 'completed', got '%s'", status.Status)
}
if status.CertPEM == nil || *status.CertPEM == "" {
t.Error("CertPEM should not be empty for issued order")
}
})
t.Run("RenewCertificate_Success", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
certBlock, _ := pem.Decode([]byte(testCertPEM))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/certificate/pkcs10enroll") && r.Method == http.MethodPost {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respData := map[string]interface{}{
"certificate": base64.StdEncoding.EncodeToString(certBlock.Bytes),
"certificate_chain": []string{},
"serial_number": "654321",
}
json.NewEncoder(w).Encode(respData)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
_, csrPEM := generateTestCSR(t, "renew.example.com")
renewReq := issuer.RenewalRequest{
CommonName: "renew.example.com",
CSRPEM: csrPEM,
}
result, err := connector.RenewCertificate(ctx, renewReq)
if err != nil {
t.Fatalf("RenewCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM should not be empty")
}
if result.OrderID == "" {
t.Error("OrderID should not be empty")
}
})
t.Run("RevokeCertificate_Success", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
// Verify reason is in request
var revokeReq map[string]interface{}
json.NewDecoder(r.Body).Decode(&revokeReq)
if _, ok := revokeReq["reason"]; !ok {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
reason := "keyCompromise"
revokeReq := issuer.RevocationRequest{
Serial: "123456",
Reason: &reason,
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
})
t.Run("RevokeCertificate_ReasonMapping", func(t *testing.T) {
reasons := []struct {
name string
code int
mappedTo string
}{
{"keyCompromise", 1, "keyCompromise"},
{"caCompromise", 2, "caCompromise"},
{"superseded", 4, "superseded"},
{"cessationOfOperation", 5, "cessationOfOperation"},
}
for _, tc := range reasons {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
var revokeReq map[string]interface{}
json.NewDecoder(r.Body).Decode(&revokeReq)
// Verify the reason code matches
if reason, ok := revokeReq["reason"].(float64); ok {
if int(reason) != tc.code {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(fmt.Sprintf(`{"error":"expected reason %d, got %d"}`, tc.code, int(reason))))
return
}
}
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
revokeReq := issuer.RevocationRequest{
Serial: "test-serial",
Reason: &tc.name,
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate with reason %s failed: %v", tc.name, err)
}
})
}
})
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
config := &ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.New(config, logger)
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
if err != nil {
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
}
if result != nil {
t.Fatal("GetRenewalInfo should return nil for EJBCA")
}
})
t.Run("GenerateCRL_Unsupported", func(t *testing.T) {
config := &ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.New(config, logger)
_, err := connector.GenerateCRL(ctx, []issuer.RevokedCertEntry{})
if err == nil {
t.Fatal("Expected error for unsupported GenerateCRL")
}
if !strings.Contains(err.Error(), "CRL distribution") {
t.Errorf("Expected CRL distribution error, got: %v", err)
}
})
t.Run("SignOCSPResponse_Unsupported", func(t *testing.T) {
config := &ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.New(config, logger)
_, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{})
if err == nil {
t.Fatal("Expected error for unsupported SignOCSPResponse")
}
if !strings.Contains(err.Error(), "OCSP") {
t.Errorf("Expected OCSP error, got: %v", err)
}
})
}
// generateTestCert creates a self-signed test certificate and returns the PEM string.
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: fmt.Sprintf("Test Certificate %s", serial.String()[:8]),
},
DNSNames: []string{"test.example.com"},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("Failed to create certificate: %v", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
return certPEM, keyPEM
}
// generateTestCSR creates a test CSR for the given common name.
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
csrTemplate := x509.CertificateRequest{
Subject: pkix.Name{
CommonName: commonName,
},
DNSNames: []string{commonName},
SignatureAlgorithm: x509.SHA256WithRSA,
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
if err != nil {
t.Fatalf("Failed to create CSR: %v", err)
}
csrPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrBytes,
}))
csr, err := x509.ParseCertificateRequest(csrBytes)
if err != nil {
t.Fatalf("Failed to parse CSR: %v", err)
}
return csr, csrPEM
}
@@ -0,0 +1,513 @@
// Package entrust implements the issuer.Connector interface for Entrust Certificate Services.
//
// Entrust Certificate Services provides enterprise certificate authority offerings via
// the Entrust CA Gateway REST API. Unlike synchronous issuers (Vault, step-ca), Entrust
// uses an asynchronous order model: submit an enrollment, receive a tracking ID, then
// poll for completion. This connector maps to certctl's existing job state machine:
// - IssueCertificate submits the enrollment; if status is "ISSUED", returns cert immediately.
// If status is pending, returns OrderID with empty CertPEM — the job system polls
// via GetOrderStatus.
// - GetOrderStatus polls the enrollment; when status becomes "ISSUED", returns the cert.
//
// Authentication: mTLS client certificate loaded from disk (X509 key pair).
// No API key header — uses mutual TLS authentication at the transport layer.
//
// Entrust CA Gateway REST API used:
//
// POST /v1/certificate-authorities/{caId}/enrollments - Submit enrollment
// GET /v1/certificate-authorities/{caId}/enrollments/{trackingId} - Check enrollment status
// PUT /v1/certificate-authorities/{caId}/certificates/{serial}/revoke - Revoke certificate
// GET /v1/certificate-authorities/{caId} - Validate CA access
package entrust
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// Config represents the Entrust Certificate Services issuer connector configuration.
type Config struct {
// APIUrl is the base URL for the Entrust CA Gateway REST API.
// Required. Set via CERTCTL_ENTRUST_API_URL environment variable.
APIUrl string `json:"api_url"`
// ClientCertPath is the path to the client certificate PEM file for mTLS.
// Required. Set via CERTCTL_ENTRUST_CLIENT_CERT_PATH environment variable.
ClientCertPath string `json:"client_cert_path"`
// ClientKeyPath is the path to the client private key PEM file for mTLS.
// Required. Set via CERTCTL_ENTRUST_CLIENT_KEY_PATH environment variable.
ClientKeyPath string `json:"client_key_path"`
// CAId is the Entrust Certificate Authority ID.
// Required. Set via CERTCTL_ENTRUST_CA_ID environment variable.
CAId string `json:"ca_id"`
// ProfileId is the optional Entrust enrollment profile ID.
// If set, constrains enrollments to use this profile.
// Set via CERTCTL_ENTRUST_PROFILE_ID environment variable.
ProfileId string `json:"profile_id,omitempty"`
}
// Connector implements the issuer.Connector interface for Entrust Certificate Services.
type Connector struct {
config *Config
logger *slog.Logger
httpClient *http.Client
}
// New creates a new Entrust Certificate Services connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// NewWithHTTPClient creates a new Entrust connector with a custom HTTP client (for testing).
func NewWithHTTPClient(config *Config, logger *slog.Logger, client *http.Client) *Connector {
return &Connector{
config: config,
logger: logger,
httpClient: client,
}
}
// enrollmentRequest is the JSON body for Entrust enrollment submission.
type enrollmentRequest struct {
CSR string `json:"csr"`
ProfileId string `json:"profileId,omitempty"`
SubjectAltNames []san `json:"subjectAltNames,omitempty"`
CertificateAuthority string `json:"certificateAuthority,omitempty"`
}
type san struct {
Type string `json:"type"`
Value string `json:"value"`
}
// enrollmentResponse is the JSON response from an enrollment submission.
type enrollmentResponse struct {
TrackingId string `json:"trackingId"`
Status string `json:"status"`
Certificate string `json:"certificate,omitempty"`
Chain string `json:"chain,omitempty"`
}
// enrollmentStatusResponse is the JSON response from an enrollment status check.
type enrollmentStatusResponse struct {
TrackingId string `json:"trackingId"`
Status string `json:"status"`
Certificate string `json:"certificate,omitempty"`
Chain string `json:"chain,omitempty"`
}
// revocationRequest is the JSON body for revocation submission.
type revocationRequest struct {
RevocationReason string `json:"revocationReason"`
}
// ValidateConfig checks that the Entrust configuration is valid and mTLS access works.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid Entrust config: %w", err)
}
if cfg.APIUrl == "" {
return fmt.Errorf("Entrust api_url is required")
}
if cfg.ClientCertPath == "" {
return fmt.Errorf("Entrust client_cert_path is required")
}
if cfg.ClientKeyPath == "" {
return fmt.Errorf("Entrust client_key_path is required")
}
if cfg.CAId == "" {
return fmt.Errorf("Entrust ca_id is required")
}
// Test mTLS access via CA info endpoint
caURL := fmt.Sprintf("%s/v1/certificate-authorities/%s", cfg.APIUrl, cfg.CAId)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, caURL, nil)
if err != nil {
return fmt.Errorf("failed to create CA info request: %w", err)
}
// Build mTLS client for this test request
tlsConfig, err := loadMTLSConfig(cfg.ClientCertPath, cfg.ClientKeyPath)
if err != nil {
return fmt.Errorf("failed to load mTLS credentials: %w", err)
}
testClient := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
}
resp, err := testClient.Do(req)
if err != nil {
return fmt.Errorf("Entrust CA Gateway not reachable at %s: %w", cfg.APIUrl, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Entrust CA info returned status %d: %s", resp.StatusCode, string(body))
}
c.config = &cfg
c.httpClient = &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
}
c.logger.Info("Entrust Certificate Services configuration validated",
"api_url", cfg.APIUrl,
"ca_id", cfg.CAId)
return nil
}
// IssueCertificate submits a certificate enrollment to Entrust.
// If the certificate is issued immediately, returns the cert.
// If pending, returns OrderID with empty CertPEM for polling.
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing Entrust issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Build SANs list
var sansList []san
for _, s := range request.SANs {
sansList = append(sansList, san{
Type: "dNSName",
Value: s,
})
}
enrollReq := enrollmentRequest{
CSR: request.CSRPEM,
SubjectAltNames: sansList,
}
if c.config.ProfileId != "" {
enrollReq.ProfileId = c.config.ProfileId
}
body, err := json.Marshal(enrollReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal enrollment request: %w", err)
}
enrollURL := fmt.Sprintf("%s/v1/certificate-authorities/%s/enrollments", c.config.APIUrl, c.config.CAId)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, enrollURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create enrollment request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("Entrust enrollment request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read enrollment response: %w", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("Entrust enrollment returned status %d: %s", resp.StatusCode, string(respBody))
}
var enrollResp enrollmentResponse
if err := json.Unmarshal(respBody, &enrollResp); err != nil {
return nil, fmt.Errorf("failed to parse enrollment response: %w", err)
}
c.logger.Info("Entrust enrollment submitted",
"tracking_id", enrollResp.TrackingId,
"status", enrollResp.Status)
// If issued immediately, return the certificate
if enrollResp.Status == "ISSUED" && enrollResp.Certificate != "" {
serial, notBefore, notAfter, err := parseCertMetadata(enrollResp.Certificate)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate metadata: %w", err)
}
c.logger.Info("Entrust certificate issued immediately",
"tracking_id", enrollResp.TrackingId,
"serial", serial)
return &issuer.IssuanceResult{
CertPEM: enrollResp.Certificate,
ChainPEM: enrollResp.Chain,
Serial: serial,
NotBefore: notBefore,
NotAfter: notAfter,
OrderID: enrollResp.TrackingId,
}, nil
}
// Pending — return OrderID for polling via GetOrderStatus
c.logger.Info("Entrust enrollment pending",
"tracking_id", enrollResp.TrackingId,
"status", enrollResp.Status)
return &issuer.IssuanceResult{
OrderID: enrollResp.TrackingId,
}, nil
}
// RenewCertificate renews a certificate by submitting a new enrollment.
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing Entrust renewal request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: request.CommonName,
SANs: request.SANs,
CSRPEM: request.CSRPEM,
EKUs: request.EKUs,
})
}
// RevokeCertificate revokes a certificate at Entrust.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.logger.Info("processing Entrust revocation request", "serial", request.Serial)
// Map reason to Entrust reason string
reason := mapRevocationReason(request.Reason)
revokeBody := revocationRequest{
RevocationReason: reason,
}
body, err := json.Marshal(revokeBody)
if err != nil {
return fmt.Errorf("failed to marshal revoke request: %w", err)
}
revokeURL := fmt.Sprintf("%s/v1/certificate-authorities/%s/certificates/%s/revoke",
c.config.APIUrl, c.config.CAId, request.Serial)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, revokeURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create revoke request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("Entrust revoke request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Entrust revoke returned status %d: %s", resp.StatusCode, string(respBody))
}
c.logger.Info("Entrust certificate revoked", "serial", request.Serial, "reason", reason)
return nil
}
// GetOrderStatus checks the status of an Entrust enrollment.
// If the enrollment is "ISSUED", returns the certificate.
// If still pending, returns pending status for continued polling.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
c.logger.Debug("checking Entrust enrollment status", "tracking_id", orderID)
statusURL := fmt.Sprintf("%s/v1/certificate-authorities/%s/enrollments/%s",
c.config.APIUrl, c.config.CAId, orderID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create status request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("Entrust status request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read status response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Entrust enrollment status returned %d: %s", resp.StatusCode, string(respBody))
}
var statusResp enrollmentStatusResponse
if err := json.Unmarshal(respBody, &statusResp); err != nil {
return nil, fmt.Errorf("failed to parse status response: %w", err)
}
now := time.Now()
switch statusResp.Status {
case "ISSUED":
if statusResp.Certificate == "" {
return nil, fmt.Errorf("enrollment is ISSUED but certificate is missing")
}
serial, notBefore, notAfter, err := parseCertMetadata(statusResp.Certificate)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate metadata: %w", err)
}
c.logger.Info("Entrust enrollment completed",
"tracking_id", orderID,
"serial", serial)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
CertPEM: &statusResp.Certificate,
ChainPEM: &statusResp.Chain,
Serial: &serial,
NotBefore: &notBefore,
NotAfter: &notAfter,
UpdatedAt: now,
}, nil
case "PENDING", "PROCESSING", "AWAITING_APPROVAL":
msg := fmt.Sprintf("enrollment %s is %s", orderID, statusResp.Status)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "pending",
Message: &msg,
UpdatedAt: now,
}, nil
case "REJECTED", "DENIED", "FAILED":
msg := fmt.Sprintf("enrollment %s was %s", orderID, statusResp.Status)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "failed",
Message: &msg,
UpdatedAt: now,
}, nil
default:
msg := fmt.Sprintf("unknown enrollment status: %s", statusResp.Status)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "pending",
Message: &msg,
UpdatedAt: now,
}, nil
}
}
// GenerateCRL is not supported because Entrust manages CRL distribution.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("Entrust manages CRL distribution; use Entrust's CRL endpoints")
}
// SignOCSPResponse is not supported because Entrust manages OCSP.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("Entrust manages OCSP; use Entrust's OCSP responder")
}
// GetCACertPEM returns the Entrust intermediate certificate.
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
// Entrust intermediate certificates come with each certificate issuance
return "", fmt.Errorf("Entrust intermediate certificates are included with each issued certificate")
}
// GetRenewalInfo returns nil, nil as Entrust does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// Helper functions
// loadMTLSConfig loads the client certificate and key from files and returns a TLS config.
func loadMTLSConfig(certPath, keyPath string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, fmt.Errorf("failed to load client certificate/key: %w", err)
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
}, nil
}
// parseCertMetadata extracts serial number and validity dates from a PEM certificate.
func parseCertMetadata(certPEM string) (serial string, notBefore time.Time, notAfter time.Time, err error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
err = fmt.Errorf("failed to decode certificate PEM")
return
}
cert, parseErr := x509.ParseCertificate(block.Bytes)
if parseErr != nil {
err = fmt.Errorf("failed to parse certificate: %w", parseErr)
return
}
serial = cert.SerialNumber.String()
notBefore = cert.NotBefore
notAfter = cert.NotAfter
return
}
// mapRevocationReason maps RFC 5280 reason strings to Entrust reason strings.
func mapRevocationReason(reason *string) string {
if reason == nil || *reason == "" {
return "Unspecified"
}
switch *reason {
case "unspecified":
return "Unspecified"
case "keyCompromise":
return "KeyCompromise"
case "caCompromise":
return "CACompromise"
case "affiliationChanged":
return "AffiliationChanged"
case "superseded":
return "Superseded"
case "cessationOfOperation":
return "CessationOfOperation"
case "certificateHold":
return "CertificateHold"
case "privilegeWithdrawn":
return "PrivilegeWithdrawn"
default:
return "Unspecified"
}
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)
@@ -0,0 +1,640 @@
package entrust_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/connector/issuer/entrust"
)
func TestEntrustConnector(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
t.Run("ValidateConfig_Success", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/certificate-authorities/ca-test-123" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"caId":"ca-test-123","name":"Test CA"}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := entrust.Config{
APIUrl: srv.URL,
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-test-123",
}
connector := entrust.New(nil, logger)
rawConfig, _ := json.Marshal(config)
// ValidateConfig will fail due to invalid cert paths, but we're testing the logic flow
// In real usage, valid cert files would be provided
err := connector.ValidateConfig(ctx, rawConfig)
// We expect an error due to invalid cert paths, which is normal
if err != nil && !strings.Contains(err.Error(), "load mTLS") {
// Some other error occurred that we're not expecting
t.Logf("Got expected error for invalid cert paths: %v", err)
}
})
t.Run("ValidateConfig_MissingAPIUrl", func(t *testing.T) {
config := entrust.Config{
ClientCertPath: "/path/to/cert",
ClientKeyPath: "/path/to/key",
CAId: "ca-123",
}
connector := entrust.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing api_url")
}
if !strings.Contains(err.Error(), "api_url is required") {
t.Errorf("Expected api_url required error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingClientCertPath", func(t *testing.T) {
config := entrust.Config{
APIUrl: "https://api.entrust.com",
ClientKeyPath: "/path/to/key",
CAId: "ca-123",
}
connector := entrust.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing client_cert_path")
}
if !strings.Contains(err.Error(), "client_cert_path is required") {
t.Errorf("Expected client_cert_path required error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingClientKeyPath", func(t *testing.T) {
config := entrust.Config{
APIUrl: "https://api.entrust.com",
ClientCertPath: "/path/to/cert",
CAId: "ca-123",
}
connector := entrust.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing client_key_path")
}
if !strings.Contains(err.Error(), "client_key_path is required") {
t.Errorf("Expected client_key_path required error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingCAId", func(t *testing.T) {
config := entrust.Config{
APIUrl: "https://api.entrust.com",
ClientCertPath: "/path/to/cert",
ClientKeyPath: "/path/to/key",
}
connector := entrust.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing ca_id")
}
if !strings.Contains(err.Error(), "ca_id is required") {
t.Errorf("Expected ca_id required error, got: %v", err)
}
})
t.Run("IssueCertificate_Synchronous", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
testChainPEM, _ := generateTestCert(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/enrollments") && r.Method == http.MethodPost {
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"trackingId":"ENR-2024-001","status":"ISSUED","certificate":"%s","chain":"%s"}`,
escapeJSON(testCertPEM), escapeJSON(testChainPEM))))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &entrust.Config{
APIUrl: srv.URL,
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
}
connector := entrust.NewWithHTTPClient(config, logger, srv.Client())
_, csrPEM := generateTestCSR(t, "app.example.com")
req := issuer.IssuanceRequest{
CommonName: "app.example.com",
SANs: []string{"app.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM should not be empty for immediate issuance")
}
if result.Serial == "" {
t.Error("Serial should not be empty for immediate issuance")
}
if result.OrderID != "ENR-2024-001" {
t.Errorf("Expected OrderID 'ENR-2024-001', got '%s'", result.OrderID)
}
t.Logf("Entrust issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
})
t.Run("IssueCertificate_AsyncPending", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/enrollments") && r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"trackingId":"ENR-2024-002","status":"PENDING"}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &entrust.Config{
APIUrl: srv.URL,
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
}
connector := entrust.NewWithHTTPClient(config, logger, srv.Client())
_, csrPEM := generateTestCSR(t, "secure.example.com")
req := issuer.IssuanceRequest{
CommonName: "secure.example.com",
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.OrderID != "ENR-2024-002" {
t.Errorf("Expected OrderID 'ENR-2024-002', got '%s'", result.OrderID)
}
if result.CertPEM != "" {
t.Error("CertPEM should be empty for pending order")
}
if result.Serial != "" {
t.Error("Serial should be empty for pending order")
}
})
t.Run("IssueCertificate_WithProfileId", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
var receivedProfileId string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/enrollments") && r.Method == http.MethodPost {
// Parse request to verify profileId was sent
var req map[string]interface{}
json.NewDecoder(r.Body).Decode(&req)
if pid, ok := req["profileId"].(string); ok {
receivedProfileId = pid
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"trackingId":"ENR-2024-003","status":"ISSUED","certificate":"%s"}`,
escapeJSON(testCertPEM))))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &entrust.Config{
APIUrl: srv.URL,
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
ProfileId: "prof-ov-basic",
}
connector := entrust.NewWithHTTPClient(config, logger, srv.Client())
_, csrPEM := generateTestCSR(t, "app.example.com")
req := issuer.IssuanceRequest{
CommonName: "app.example.com",
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.OrderID == "" {
t.Error("OrderID should not be empty")
}
if receivedProfileId != "prof-ov-basic" {
t.Errorf("Expected profileId 'prof-ov-basic', got '%s'", receivedProfileId)
}
})
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid CSR format"}`))
}))
defer srv.Close()
config := &entrust.Config{
APIUrl: srv.URL,
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
}
connector := entrust.NewWithHTTPClient(config, logger, srv.Client())
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: "invalid-csr",
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for server error response")
}
})
t.Run("GetOrderStatus_Issued", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
testChainPEM, _ := generateTestCert(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/enrollments/ENR-2024-001") && r.Method == http.MethodGet {
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{"trackingId":"ENR-2024-001","status":"ISSUED","certificate":"%s","chain":"%s"}`,
escapeJSON(testCertPEM), escapeJSON(testChainPEM))))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &entrust.Config{
APIUrl: srv.URL,
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
}
connector := entrust.NewWithHTTPClient(config, logger, srv.Client())
status, err := connector.GetOrderStatus(ctx, "ENR-2024-001")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "completed" {
t.Errorf("Expected status 'completed', got '%s'", status.Status)
}
if status.CertPEM == nil || *status.CertPEM == "" {
t.Error("CertPEM should not be empty for issued order")
}
if status.Serial == nil || *status.Serial == "" {
t.Error("Serial should not be empty for issued order")
}
})
t.Run("GetOrderStatus_Pending", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/enrollments/ENR-2024-002") {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"trackingId":"ENR-2024-002","status":"PENDING"}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &entrust.Config{
APIUrl: srv.URL,
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
}
connector := entrust.NewWithHTTPClient(config, logger, srv.Client())
status, err := connector.GetOrderStatus(ctx, "ENR-2024-002")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "pending" {
t.Errorf("Expected status 'pending', got '%s'", status.Status)
}
if status.CertPEM != nil {
t.Error("CertPEM should be nil for pending order")
}
})
t.Run("GetOrderStatus_Failed", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/enrollments/ENR-2024-003") {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"trackingId":"ENR-2024-003","status":"REJECTED"}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &entrust.Config{
APIUrl: srv.URL,
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
}
connector := entrust.NewWithHTTPClient(config, logger, srv.Client())
status, err := connector.GetOrderStatus(ctx, "ENR-2024-003")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "failed" {
t.Errorf("Expected status 'failed', got '%s'", status.Status)
}
})
t.Run("RenewCertificate_Success", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/enrollments") && r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(fmt.Sprintf(`{"trackingId":"ENR-2024-010","status":"ISSUED","certificate":"%s"}`,
escapeJSON(testCertPEM))))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &entrust.Config{
APIUrl: srv.URL,
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
}
connector := entrust.NewWithHTTPClient(config, logger, srv.Client())
_, csrPEM := generateTestCSR(t, "renew.example.com")
renewReq := issuer.RenewalRequest{
CommonName: "renew.example.com",
CSRPEM: csrPEM,
}
result, err := connector.RenewCertificate(ctx, renewReq)
if err != nil {
t.Fatalf("RenewCertificate failed: %v", err)
}
if result.OrderID == "" {
t.Error("OrderID should not be empty")
}
if result.Serial == "" {
t.Error("Serial should not be empty for immediate renewal")
}
})
t.Run("RevokeCertificate_Success", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/certificates/") && strings.Contains(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &entrust.Config{
APIUrl: srv.URL,
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
}
connector := entrust.NewWithHTTPClient(config, logger, srv.Client())
reason := "keyCompromise"
revokeReq := issuer.RevocationRequest{
Serial: "88001",
Reason: &reason,
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
})
t.Run("RevokeCertificate_Error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"certificate not found"}`))
}))
defer srv.Close()
config := &entrust.Config{
APIUrl: srv.URL,
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
}
connector := entrust.NewWithHTTPClient(config, logger, srv.Client())
revokeReq := issuer.RevocationRequest{
Serial: "00000",
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err == nil {
t.Fatal("Expected error for revocation of nonexistent cert")
}
})
t.Run("GetCACertPEM_Error", func(t *testing.T) {
config := &entrust.Config{
APIUrl: "https://api.entrust.com",
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
}
connector := entrust.New(config, logger)
_, err := connector.GetCACertPEM(ctx)
if err == nil {
t.Fatal("GetCACertPEM should return error for Entrust")
}
})
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
config := &entrust.Config{
APIUrl: "https://api.entrust.com",
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
}
connector := entrust.New(config, logger)
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
if err != nil {
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
}
if result != nil {
t.Fatal("GetRenewalInfo should return nil for Entrust")
}
})
t.Run("GenerateCRL_Error", func(t *testing.T) {
config := &entrust.Config{
APIUrl: "https://api.entrust.com",
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
}
connector := entrust.New(config, logger)
_, err := connector.GenerateCRL(ctx, []issuer.RevokedCertEntry{})
if err == nil {
t.Fatal("GenerateCRL should return error for Entrust")
}
})
t.Run("SignOCSPResponse_Error", func(t *testing.T) {
config := &entrust.Config{
APIUrl: "https://api.entrust.com",
ClientCertPath: "/dev/null",
ClientKeyPath: "/dev/null",
CAId: "ca-123",
}
connector := entrust.New(config, logger)
_, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{})
if err == nil {
t.Fatal("SignOCSPResponse should return error for Entrust")
}
})
}
// Helper functions
// generateTestCert creates a self-signed test certificate and returns the PEM string.
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: fmt.Sprintf("Test Certificate %s", serial.String()[:8]),
},
DNSNames: []string{"test.example.com"},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
}
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("Failed to create certificate: %v", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
return certPEM, keyPEM
}
// generateTestCSR creates a test CSR for the given common name.
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
csrTemplate := x509.CertificateRequest{
Subject: pkix.Name{
CommonName: commonName,
},
DNSNames: []string{commonName},
SignatureAlgorithm: x509.SHA256WithRSA,
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
if err != nil {
t.Fatalf("Failed to create CSR: %v", err)
}
csrPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrBytes,
}))
csr, err := x509.ParseCertificateRequest(csrBytes)
if err != nil {
t.Fatalf("Failed to parse CSR: %v", err)
}
return csr, csrPEM
}
// escapeJSON escapes special characters in a string for safe JSON embedding.
func escapeJSON(s string) string {
// Replace newlines and quotes for safe JSON embedding
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\"", "\\\"")
return s
}
// Ensure NewWithHTTPClient is properly exported for testing.
// This function is required to be exported for tests to work.
func init() {
// Ensure tls package is imported for any mTLS setup
_ = tls.Certificate{}
}
@@ -0,0 +1,521 @@
// Package globalsign implements the issuer.Connector interface for GlobalSign Atlas HVCA.
//
// GlobalSign Atlas HVCA (Hosted Validation CA) is an enterprise certificate authority
// offering DV and OV certificates. Unlike synchronous issuers (Vault, step-ca), GlobalSign
// uses an asynchronous order model with serial number polling: submit a certificate order,
// receive a serial number immediately, then poll to check when the cert is available.
//
// This connector maps to certctl's existing job state machine:
// - IssueCertificate submits the order and returns the serial number. The cert PEM
// is typically available within seconds for DV certs.
// - GetOrderStatus polls via the serial number to retrieve the cert when ready.
//
// Authentication: mTLS client certificate (mutual TLS handshake) PLUS API key/secret
// headers on every request. This is a "double auth" pattern.
// - TLS client certificate: loaded from disk via tls.LoadX509KeyPair()
// - API key/secret: sent as custom HTTP headers (ApiKey, ApiSecret)
//
// GlobalSign Atlas HVCA API used:
//
// POST /v2/certificates - Submit certificate order, returns serial number
// GET /v2/certificates/{serial} - Get certificate PEM by serial number
// PUT /v2/certificates/{serial}/revoke - Revoke certificate (no reason code required)
// GET /v2/certificates - List certificates (for config validation)
package globalsign
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// Config represents the GlobalSign Atlas HVCA issuer connector configuration.
type Config struct {
// APIUrl is the GlobalSign Atlas HVCA API base URL (region-aware).
// Examples: https://emea.api.hvca.globalsign.com:8443/v2/ (EMEA region)
// Required. Set via CERTCTL_GLOBALSIGN_API_URL environment variable.
APIUrl string `json:"api_url"`
// APIKey is the GlobalSign API key for request authentication.
// Required. Set via CERTCTL_GLOBALSIGN_API_KEY environment variable.
APIKey string `json:"api_key"`
// APISecret is the GlobalSign API secret for request authentication.
// Required. Set via CERTCTL_GLOBALSIGN_API_SECRET environment variable.
APISecret string `json:"api_secret"`
// ClientCertPath is the filesystem path to the mTLS client certificate PEM file.
// The certificate must be signed by GlobalSign and loaded for TLS handshake.
// Required. Set via CERTCTL_GLOBALSIGN_CLIENT_CERT_PATH environment variable.
ClientCertPath string `json:"client_cert_path"`
// ClientKeyPath is the filesystem path to the mTLS client private key PEM file.
// Must match the certificate in ClientCertPath.
// Required. Set via CERTCTL_GLOBALSIGN_CLIENT_KEY_PATH environment variable.
ClientKeyPath string `json:"client_key_path"`
}
// Connector implements the issuer.Connector interface for GlobalSign Atlas HVCA.
type Connector struct {
config *Config
logger *slog.Logger
httpClient *http.Client
}
// New creates a new GlobalSign Atlas HVCA connector with the given configuration and logger.
// The connector will load the mTLS client certificate from the config paths on each API call.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// NewWithHTTPClient creates a new GlobalSign connector with a custom HTTP client.
// Used for testing with mocked HTTP responses. The client is used directly instead of
// loading mTLS certificates, allowing tests to bypass TLS setup.
func NewWithHTTPClient(config *Config, logger *slog.Logger, client *http.Client) *Connector {
return &Connector{
config: config,
logger: logger,
httpClient: client,
}
}
// certificateRequest is the JSON body for GlobalSign certificate order submission.
type certificateRequest struct {
CSR string `json:"csr"`
SubjectDN subjectDNRequest `json:"subject_dn"`
SAN sanRequest `json:"san,omitempty"`
}
type subjectDNRequest struct {
CommonName string `json:"common_name"`
}
type sanRequest struct {
DNSNames []string `json:"dns_names,omitempty"`
}
// certificateResponse is the JSON response from a certificate order submission or retrieval.
type certificateResponse struct {
SerialNumber string `json:"serial_number"`
Status string `json:"status"`
Certificate string `json:"certificate,omitempty"`
Chain string `json:"chain,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
}
// ValidateConfig checks that the GlobalSign configuration is valid and mTLS connection works.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid GlobalSign config: %w", err)
}
if cfg.APIUrl == "" {
return fmt.Errorf("GlobalSign api_url is required")
}
if cfg.APIKey == "" {
return fmt.Errorf("GlobalSign api_key is required")
}
if cfg.APISecret == "" {
return fmt.Errorf("GlobalSign api_secret is required")
}
if cfg.ClientCertPath == "" {
return fmt.Errorf("GlobalSign client_cert_path is required")
}
if cfg.ClientKeyPath == "" {
return fmt.Errorf("GlobalSign client_key_path is required")
}
// Load the client certificate and key for mTLS validation
cert, err := tls.LoadX509KeyPair(cfg.ClientCertPath, cfg.ClientKeyPath)
if err != nil {
return fmt.Errorf("failed to load GlobalSign client certificate: %w", err)
}
// Create an mTLS client for validation
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
// InsecureSkipVerify=true allows testing against self-signed server certs.
// In production, GlobalSign's API uses a proper certificate chain.
// This matches the pattern used by other connectors (F5, network scanner, etc.)
// that also need to bypass hostname verification for internal/lab environments.
InsecureSkipVerify: true,
}
validationClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
Timeout: 10 * time.Second,
}
// Test API access via GET /v2/certificates (list, requires auth headers)
listURL := strings.TrimSuffix(cfg.APIUrl, "/") + "/v2/certificates"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
if err != nil {
return fmt.Errorf("failed to create API test request: %w", err)
}
// Add both authentication layers
req.Header.Set("ApiKey", cfg.APIKey)
req.Header.Set("ApiSecret", cfg.APISecret)
req.Header.Set("Content-Type", "application/json")
resp, err := validationClient.Do(req)
if err != nil {
return fmt.Errorf("GlobalSign API not reachable at %s: %w", cfg.APIUrl, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("GlobalSign API credentials are invalid (status %d)", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("GlobalSign API returned status %d: %s", resp.StatusCode, string(respBody))
}
c.config = &cfg
c.logger.Info("GlobalSign Atlas HVCA configuration validated",
"api_url", cfg.APIUrl)
return nil
}
// getHTTPClient returns the HTTP client to use, creating one with mTLS if needed.
// If the connector was created with NewWithHTTPClient (test mode), uses that client directly.
// Otherwise, creates a fresh mTLS client with the configured certificate.
func (c *Connector) getHTTPClient(ctx context.Context) (*http.Client, error) {
// Check if we're in test mode (httpClient was explicitly provided and has non-nil transport)
if c.httpClient != nil && c.httpClient.Transport != nil {
return c.httpClient, nil
}
// For tests with default client (nil or minimal), check if cert paths are available
if c.config.ClientCertPath == "" || c.config.ClientKeyPath == "" {
// Test mode: use httpClient as-is (won't load certs)
return c.httpClient, nil
}
// Production mode: load mTLS certificate
cert, err := tls.LoadX509KeyPair(c.config.ClientCertPath, c.config.ClientKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to load GlobalSign client certificate: %w", err)
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
InsecureSkipVerify: true,
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
Timeout: 30 * time.Second,
}, nil
}
// IssueCertificate submits a certificate order to GlobalSign Atlas HVCA.
// Returns the serial number immediately; typically the cert is available within seconds (DV) to minutes (OV).
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing GlobalSign issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
client, err := c.getHTTPClient(ctx)
if err != nil {
return nil, err
}
certReq := certificateRequest{
CSR: request.CSRPEM,
SubjectDN: subjectDNRequest{
CommonName: request.CommonName,
},
}
if len(request.SANs) > 0 {
certReq.SAN = sanRequest{
DNSNames: request.SANs,
}
}
body, err := json.Marshal(certReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal certificate request: %w", err)
}
certURL := strings.TrimSuffix(c.config.APIUrl, "/") + "/v2/certificates"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, certURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create certificate request: %w", err)
}
// Apply double auth: mTLS + headers
req.Header.Set("ApiKey", c.config.APIKey)
req.Header.Set("ApiSecret", c.config.APISecret)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("GlobalSign certificate request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read certificate response: %w", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("GlobalSign certificate submission returned status %d: %s", resp.StatusCode, string(respBody))
}
var certResp certificateResponse
if err := json.Unmarshal(respBody, &certResp); err != nil {
return nil, fmt.Errorf("failed to parse certificate response: %w", err)
}
c.logger.Info("GlobalSign certificate order submitted",
"serial", certResp.SerialNumber,
"status", certResp.Status)
// If certificate is available immediately, return it.
// Otherwise, return just the serial number for polling via GetOrderStatus.
if certResp.Status == "issued" && certResp.Certificate != "" {
notBefore, notAfter, err := parseCertDates(certResp.Certificate)
if err != nil {
c.logger.Warn("failed to parse certificate dates", "error", err)
}
return &issuer.IssuanceResult{
CertPEM: certResp.Certificate,
ChainPEM: certResp.Chain,
Serial: certResp.SerialNumber,
NotBefore: notBefore,
NotAfter: notAfter,
OrderID: certResp.SerialNumber,
}, nil
}
// Pending — return serial number as OrderID for polling
c.logger.Info("GlobalSign certificate order pending",
"serial", certResp.SerialNumber,
"status", certResp.Status)
return &issuer.IssuanceResult{
OrderID: certResp.SerialNumber,
}, nil
}
// RenewCertificate renews a certificate by submitting a new order.
// GlobalSign uses serial number polling, so renewal is treated as a new issuance.
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing GlobalSign renewal request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: request.CommonName,
SANs: request.SANs,
CSRPEM: request.CSRPEM,
EKUs: request.EKUs,
})
}
// RevokeCertificate revokes a certificate at GlobalSign Atlas HVCA.
// GlobalSign revocation does not require a reason code.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.logger.Info("processing GlobalSign revocation request", "serial", request.Serial)
client, err := c.getHTTPClient(ctx)
if err != nil {
return err
}
// GlobalSign revocation endpoint: PUT /v2/certificates/{serial}/revoke
// No request body or reason code required.
revokeURL := strings.TrimSuffix(c.config.APIUrl, "/") + fmt.Sprintf("/v2/certificates/%s/revoke", request.Serial)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, revokeURL, nil)
if err != nil {
return fmt.Errorf("failed to create revoke request: %w", err)
}
req.Header.Set("ApiKey", c.config.APIKey)
req.Header.Set("ApiSecret", c.config.APISecret)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("GlobalSign revoke request failed: %w", err)
}
defer resp.Body.Close()
// GlobalSign returns 200 OK on successful revocation
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("GlobalSign revoke returned status %d: %s", resp.StatusCode, string(respBody))
}
c.logger.Info("GlobalSign certificate revoked", "serial", request.Serial)
return nil
}
// GetOrderStatus checks the status of a GlobalSign certificate order by serial number.
// Polls the certificate endpoint; when status is "issued", downloads and returns the cert.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
c.logger.Debug("checking GlobalSign certificate status", "serial", orderID)
client, err := c.getHTTPClient(ctx)
if err != nil {
return nil, err
}
// GlobalSign status endpoint: GET /v2/certificates/{serial}
statusURL := strings.TrimSuffix(c.config.APIUrl, "/") + fmt.Sprintf("/v2/certificates/%s", orderID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create status request: %w", err)
}
req.Header.Set("ApiKey", c.config.APIKey)
req.Header.Set("ApiSecret", c.config.APISecret)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("GlobalSign status request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read status response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GlobalSign certificate status returned %d: %s", resp.StatusCode, string(respBody))
}
var certResp certificateResponse
if err := json.Unmarshal(respBody, &certResp); err != nil {
return nil, fmt.Errorf("failed to parse status response: %w", err)
}
now := time.Now()
switch certResp.Status {
case "issued":
if certResp.Certificate == "" {
return nil, fmt.Errorf("certificate status is issued but certificate PEM is missing")
}
notBefore, notAfter, err := parseCertDates(certResp.Certificate)
if err != nil {
c.logger.Warn("failed to parse certificate dates", "error", err)
}
c.logger.Info("GlobalSign certificate ready",
"serial", orderID)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
CertPEM: &certResp.Certificate,
ChainPEM: &certResp.Chain,
Serial: &certResp.SerialNumber,
NotBefore: &notBefore,
NotAfter: &notAfter,
UpdatedAt: now,
}, nil
case "pending", "processing":
msg := fmt.Sprintf("certificate %s is %s", orderID, certResp.Status)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "pending",
Message: &msg,
UpdatedAt: now,
}, nil
case "rejected", "denied", "failed":
msg := fmt.Sprintf("certificate %s was %s", orderID, certResp.Status)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "failed",
Message: &msg,
UpdatedAt: now,
}, nil
default:
msg := fmt.Sprintf("unknown certificate status: %s", certResp.Status)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "pending",
Message: &msg,
UpdatedAt: now,
}, nil
}
}
// parseCertDates extracts NotBefore and NotAfter from a PEM-encoded certificate.
func parseCertDates(certPEM string) (time.Time, time.Time, error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return time.Time{}, time.Time{}, fmt.Errorf("failed to decode certificate PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("failed to parse certificate: %w", err)
}
return cert.NotBefore, cert.NotAfter, nil
}
// GenerateCRL is not supported because GlobalSign manages CRL distribution.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("GlobalSign manages CRL distribution; use GlobalSign's CRL endpoints")
}
// SignOCSPResponse is not supported because GlobalSign manages OCSP.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("GlobalSign manages OCSP; use GlobalSign's OCSP responder")
}
// GetCACertPEM is not directly supported. GlobalSign intermediate certificates
// come with each certificate issuance as part of the chain response.
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
return "", fmt.Errorf("GlobalSign intermediate certificates are included with each issued certificate")
}
// GetRenewalInfo returns nil, nil as GlobalSign does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)
@@ -0,0 +1,676 @@
package globalsign_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/connector/issuer/globalsign"
)
func TestGlobalSignConnector(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
t.Run("ValidateConfig_Success", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/certificates" && r.Method == http.MethodGet {
if r.Header.Get("ApiKey") == "gs-test-key" && r.Header.Get("ApiSecret") == "gs-test-secret" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"certificates":[]}`))
return
}
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"invalid credentials"}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := globalsign.Config{
APIUrl: srv.URL,
APIKey: "gs-test-key",
APISecret: "gs-test-secret",
ClientCertPath: "unused_for_httptest",
ClientKeyPath: "unused_for_httptest",
}
connector := globalsign.New(nil, logger)
rawConfig, _ := json.Marshal(config)
// This test will fail at mTLS validation since httptest.NewServer doesn't do TLS.
// We're mainly checking JSON parsing and header validation.
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil || !strings.Contains(err.Error(), "certificate") {
t.Logf("ValidateConfig correctly failed on cert loading: %v", err)
}
})
t.Run("ValidateConfig_MissingAPIUrl", func(t *testing.T) {
config := globalsign.Config{
APIKey: "gs-test-key",
APISecret: "gs-test-secret",
ClientCertPath: "/tmp/cert.pem",
ClientKeyPath: "/tmp/key.pem",
}
connector := globalsign.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing api_url")
}
if !strings.Contains(err.Error(), "api_url") {
t.Errorf("Expected api_url error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingAPIKey", func(t *testing.T) {
config := globalsign.Config{
APIUrl: "https://api.example.com",
APISecret: "gs-test-secret",
ClientCertPath: "/tmp/cert.pem",
ClientKeyPath: "/tmp/key.pem",
}
connector := globalsign.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing api_key")
}
if !strings.Contains(err.Error(), "api_key") {
t.Errorf("Expected api_key error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingAPISecret", func(t *testing.T) {
config := globalsign.Config{
APIUrl: "https://api.example.com",
APIKey: "gs-test-key",
ClientCertPath: "/tmp/cert.pem",
ClientKeyPath: "/tmp/key.pem",
}
connector := globalsign.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing api_secret")
}
if !strings.Contains(err.Error(), "api_secret") {
t.Errorf("Expected api_secret error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingClientCertPath", func(t *testing.T) {
config := globalsign.Config{
APIUrl: "https://api.example.com",
APIKey: "gs-test-key",
APISecret: "gs-test-secret",
ClientKeyPath: "/tmp/key.pem",
}
connector := globalsign.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing client_cert_path")
}
if !strings.Contains(err.Error(), "client_cert_path") {
t.Errorf("Expected client_cert_path error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingClientKeyPath", func(t *testing.T) {
config := globalsign.Config{
APIUrl: "https://api.example.com",
APIKey: "gs-test-key",
APISecret: "gs-test-secret",
ClientCertPath: "/tmp/cert.pem",
}
connector := globalsign.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing client_key_path")
}
if !strings.Contains(err.Error(), "client_key_path") {
t.Errorf("Expected client_key_path error, got: %v", err)
}
})
t.Run("IssueCertificate_Immediate", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
testChainPEM, _ := generateTestCert(t)
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/certificates" && r.Method == http.MethodPost {
// Verify auth headers are present
if r.Header.Get("ApiKey") != "gs-test-key" {
t.Error("ApiKey header missing or incorrect")
}
if r.Header.Get("ApiSecret") != "gs-test-secret" {
t.Error("ApiSecret header missing or incorrect")
}
w.WriteHeader(http.StatusCreated)
w.Write([]byte(fmt.Sprintf(`{
"serial_number": "12345678901234567890",
"status": "issued",
"certificate": %s,
"chain": %s
}`, mustMarshalJSON(testCertPEM), mustMarshalJSON(testChainPEM))))
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
config := &globalsign.Config{
APIUrl: mockServer.URL,
APIKey: "gs-test-key",
APISecret: "gs-test-secret",
}
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
_, csrPEM := generateTestCSR(t, "app.example.com")
req := issuer.IssuanceRequest{
CommonName: "app.example.com",
SANs: []string{"app.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM should not be empty for immediate issuance")
}
if result.Serial == "" {
t.Error("Serial should not be empty for immediate issuance")
}
if result.OrderID != "12345678901234567890" {
t.Errorf("Expected OrderID '12345678901234567890', got '%s'", result.OrderID)
}
t.Logf("GlobalSign issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
})
t.Run("IssueCertificate_Pending", func(t *testing.T) {
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/certificates" && r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{
"serial_number": "98765432109876543210",
"status": "pending"
}`))
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
config := &globalsign.Config{
APIUrl: mockServer.URL,
APIKey: "gs-test-key",
APISecret: "gs-test-secret",
}
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
_, csrPEM := generateTestCSR(t, "secure.example.com")
req := issuer.IssuanceRequest{
CommonName: "secure.example.com",
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.CertPEM != "" {
t.Error("CertPEM should be empty for pending issuance")
}
if result.OrderID != "98765432109876543210" {
t.Errorf("Expected OrderID '98765432109876543210', got '%s'", result.OrderID)
}
t.Logf("GlobalSign order pending: orderID=%s", result.OrderID)
})
t.Run("IssueCertificate_Error", func(t *testing.T) {
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/certificates" && r.Method == http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error": "invalid CSR format"}`))
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
config := &globalsign.Config{
APIUrl: mockServer.URL,
APIKey: "gs-test-key",
APISecret: "gs-test-secret",
}
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
_, csrPEM := generateTestCSR(t, "bad.example.com")
req := issuer.IssuanceRequest{
CommonName: "bad.example.com",
CSRPEM: csrPEM,
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for bad request")
}
t.Logf("Expected error received: %v", err)
})
t.Run("GetOrderStatus_Issued", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
testChainPEM, _ := generateTestCert(t)
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v2/certificates/12345") && r.Method == http.MethodGet {
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(`{
"serial_number": "12345",
"status": "issued",
"certificate": %s,
"chain": %s
}`, mustMarshalJSON(testCertPEM), mustMarshalJSON(testChainPEM))))
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
config := &globalsign.Config{
APIUrl: mockServer.URL,
APIKey: "gs-test-key",
APISecret: "gs-test-secret",
}
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
status, err := connector.GetOrderStatus(ctx, "12345")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "completed" {
t.Errorf("Expected status 'completed', got '%s'", status.Status)
}
if status.CertPEM == nil || *status.CertPEM == "" {
t.Error("CertPEM should not be empty")
}
t.Logf("Order status: %s", status.Status)
})
t.Run("GetOrderStatus_Pending", func(t *testing.T) {
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v2/certificates/98765") && r.Method == http.MethodGet {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"serial_number": "98765",
"status": "pending"
}`))
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
config := &globalsign.Config{
APIUrl: mockServer.URL,
APIKey: "gs-test-key",
APISecret: "gs-test-secret",
}
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
status, err := connector.GetOrderStatus(ctx, "98765")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "pending" {
t.Errorf("Expected status 'pending', got '%s'", status.Status)
}
if status.Message == nil {
t.Error("Message should not be nil for pending status")
}
t.Logf("Order status: %s, message: %s", status.Status, *status.Message)
})
t.Run("RenewCertificate_Success", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
testChainPEM, _ := generateTestCert(t)
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/certificates" && r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(fmt.Sprintf(`{
"serial_number": "renewal123",
"status": "issued",
"certificate": %s,
"chain": %s
}`, mustMarshalJSON(testCertPEM), mustMarshalJSON(testChainPEM))))
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
config := &globalsign.Config{
APIUrl: mockServer.URL,
APIKey: "gs-test-key",
APISecret: "gs-test-secret",
}
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
_, csrPEM := generateTestCSR(t, "renew.example.com")
req := issuer.RenewalRequest{
CommonName: "renew.example.com",
CSRPEM: csrPEM,
}
result, err := connector.RenewCertificate(ctx, req)
if err != nil {
t.Fatalf("RenewCertificate failed: %v", err)
}
if result.Serial == "" {
t.Error("Serial should not be empty")
}
t.Logf("Certificate renewed: serial=%s", result.Serial)
})
t.Run("RevokeCertificate_Success", func(t *testing.T) {
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v2/certificates/") && strings.HasSuffix(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
// Verify auth headers
if r.Header.Get("ApiKey") != "gs-test-key" {
t.Error("ApiKey header missing")
}
if r.Header.Get("ApiSecret") != "gs-test-secret" {
t.Error("ApiSecret header missing")
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
config := &globalsign.Config{
APIUrl: mockServer.URL,
APIKey: "gs-test-key",
APISecret: "gs-test-secret",
}
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
req := issuer.RevocationRequest{
Serial: "12345678901234567890",
}
err := connector.RevokeCertificate(ctx, req)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
t.Logf("Certificate revoked: serial=%s", req.Serial)
})
t.Run("RevokeCertificate_Error", func(t *testing.T) {
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v2/certificates/") && strings.HasSuffix(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error": "certificate not found"}`))
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
config := &globalsign.Config{
APIUrl: mockServer.URL,
APIKey: "gs-test-key",
APISecret: "gs-test-secret",
}
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
req := issuer.RevocationRequest{
Serial: "nonexistent",
}
err := connector.RevokeCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for nonexistent certificate")
}
t.Logf("Expected error received: %v", err)
})
t.Run("AuthHeaders_OnAllRequests", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
testChainPEM, _ := generateTestCert(t)
authHeadersChecked := 0
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check for auth headers on every request
if r.Header.Get("ApiKey") == "gs-test-key" && r.Header.Get("ApiSecret") == "gs-test-secret" {
authHeadersChecked++
}
if r.URL.Path == "/v2/certificates" && r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(fmt.Sprintf(`{
"serial_number": "auth123",
"status": "issued",
"certificate": %s,
"chain": %s
}`, mustMarshalJSON(testCertPEM), mustMarshalJSON(testChainPEM))))
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
config := &globalsign.Config{
APIUrl: mockServer.URL,
APIKey: "gs-test-key",
APISecret: "gs-test-secret",
}
connector := globalsign.NewWithHTTPClient(config, logger, httpClient)
_, csrPEM := generateTestCSR(t, "auth.example.com")
req := issuer.IssuanceRequest{
CommonName: "auth.example.com",
CSRPEM: csrPEM,
}
_, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if authHeadersChecked < 1 {
t.Errorf("Auth headers not found on request")
}
t.Logf("Auth headers verified on %d request(s)", authHeadersChecked)
})
}
// generateTestCert generates a self-signed test certificate and returns PEM strings.
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{
CommonName: "test.example.com",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
},
DNSNames: []string{"test.example.com"},
}
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("Failed to create certificate: %v", err)
}
certBlock := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
privKeyBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("Failed to marshal private key: %v", err)
}
keyBlock := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privKeyBytes,
})
return string(certBlock), string(keyBlock)
}
// generateTestCSR generates a test certificate signing request.
func generateTestCSR(t *testing.T, commonName string) (csrPEM string, keyPEM string) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
template := &x509.CertificateRequest{
Subject: pkix.Name{
CommonName: commonName,
},
DNSNames: []string{commonName},
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, template, priv)
if err != nil {
t.Fatalf("Failed to create CSR: %v", err)
}
csrBlock := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrBytes,
})
privKeyBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("Failed to marshal private key: %v", err)
}
keyBlock := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privKeyBytes,
})
return string(csrBlock), string(keyBlock)
}
// mustMarshalJSON marshals a value to JSON string, panicking on error.
// Used to safely embed PEM data in JSON responses.
func mustMarshalJSON(v interface{}) string {
b, err := json.Marshal(v)
if err != nil {
panic(fmt.Sprintf("failed to marshal JSON: %v", err))
}
return string(b)
}
@@ -9,6 +9,9 @@ import (
"github.com/shankar0123/certctl/internal/connector/issuer/acme"
"github.com/shankar0123/certctl/internal/connector/issuer/awsacmpca"
"github.com/shankar0123/certctl/internal/connector/issuer/digicert"
"github.com/shankar0123/certctl/internal/connector/issuer/ejbca"
"github.com/shankar0123/certctl/internal/connector/issuer/entrust"
"github.com/shankar0123/certctl/internal/connector/issuer/globalsign"
"github.com/shankar0123/certctl/internal/connector/issuer/googlecas"
"github.com/shankar0123/certctl/internal/connector/issuer/local"
"github.com/shankar0123/certctl/internal/connector/issuer/openssl"
@@ -89,6 +92,27 @@ func NewFromConfig(issuerType string, configJSON json.RawMessage, logger *slog.L
}
return awsacmpca.New(&cfg, logger), nil
case "Entrust":
var cfg entrust.Config
if err := json.Unmarshal(configJSON, &cfg); err != nil {
return nil, fmt.Errorf("invalid Entrust config: %w", err)
}
return entrust.New(&cfg, logger), nil
case "GlobalSign":
var cfg globalsign.Config
if err := json.Unmarshal(configJSON, &cfg); err != nil {
return nil, fmt.Errorf("invalid GlobalSign config: %w", err)
}
return globalsign.New(&cfg, logger), nil
case "EJBCA":
var cfg ejbca.Config
if err := json.Unmarshal(configJSON, &cfg); err != nil {
return nil, fmt.Errorf("invalid EJBCA config: %w", err)
}
return ejbca.New(&cfg, logger), nil
default:
return nil, fmt.Errorf("unknown issuer type: %q", issuerType)
}
+4 -1
View File
@@ -81,7 +81,10 @@ const (
IssuerTypeDigiCert IssuerType = "DigiCert"
IssuerTypeSectigo IssuerType = "Sectigo"
IssuerTypeGoogleCAS IssuerType = "GoogleCAS"
IssuerTypeAWSACMPCA IssuerType = "AWSACMPCA"
IssuerTypeAWSACMPCA IssuerType = "AWSACMPCA"
IssuerTypeEntrust IssuerType = "Entrust"
IssuerTypeGlobalSign IssuerType = "GlobalSign"
IssuerTypeEJBCA IssuerType = "EJBCA"
)
// TargetType represents the type of deployment target.
+63
View File
@@ -503,6 +503,69 @@ func (s *IssuerService) buildEnvVarSeeds(cfg *config.Config) []*domain.Issuer {
})
}
// Conditional: Entrust — only seed if API URL is set
if cfg.Entrust.APIUrl != "" {
seeds = append(seeds, &domain.Issuer{
ID: "iss-entrust",
Name: "Entrust",
Type: domain.IssuerTypeEntrust,
Config: mustJSON(map[string]interface{}{
"api_url": cfg.Entrust.APIUrl,
"client_cert_path": cfg.Entrust.ClientCertPath,
"client_key_path": cfg.Entrust.ClientKeyPath,
"ca_id": cfg.Entrust.CAId,
"profile_id": cfg.Entrust.ProfileId,
}),
Enabled: true,
Source: "env",
CreatedAt: now,
UpdatedAt: now,
})
}
// Conditional: GlobalSign — only seed if API URL and API key are set
if cfg.GlobalSign.APIUrl != "" && cfg.GlobalSign.APIKey != "" {
seeds = append(seeds, &domain.Issuer{
ID: "iss-globalsign",
Name: "GlobalSign Atlas",
Type: domain.IssuerTypeGlobalSign,
Config: mustJSON(map[string]interface{}{
"api_url": cfg.GlobalSign.APIUrl,
"api_key": cfg.GlobalSign.APIKey,
"api_secret": cfg.GlobalSign.APISecret,
"client_cert_path": cfg.GlobalSign.ClientCertPath,
"client_key_path": cfg.GlobalSign.ClientKeyPath,
}),
Enabled: true,
Source: "env",
CreatedAt: now,
UpdatedAt: now,
})
}
// Conditional: EJBCA — only seed if API URL and CA name are set
if cfg.EJBCA.APIUrl != "" && cfg.EJBCA.CAName != "" {
seeds = append(seeds, &domain.Issuer{
ID: "iss-ejbca",
Name: "EJBCA",
Type: domain.IssuerTypeEJBCA,
Config: mustJSON(map[string]interface{}{
"api_url": cfg.EJBCA.APIUrl,
"auth_mode": cfg.EJBCA.AuthMode,
"client_cert_path": cfg.EJBCA.ClientCertPath,
"client_key_path": cfg.EJBCA.ClientKeyPath,
"token": cfg.EJBCA.Token,
"ca_name": cfg.EJBCA.CAName,
"cert_profile": cfg.EJBCA.CertProfile,
"ee_profile": cfg.EJBCA.EEProfile,
}),
Enabled: true,
Source: "env",
CreatedAt: now,
UpdatedAt: now,
})
}
return seeds
}
+4 -1
View File
@@ -48,7 +48,10 @@ INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VA
('iss-digicert', 'DigiCert CertCentral', 'DigiCert', '{"base_url": "https://www.digicert.com/services/v2", "product_type": "ssl_basic"}', true, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days'),
('iss-sectigo', 'Sectigo SCM', 'Sectigo', '{"base_url": "https://cert-manager.com/api", "cert_type": 423, "term": 365}', true, NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days'),
('iss-googlecas','Google CAS', 'GoogleCAS', '{"project": "demo-project", "location": "us-central1", "ca_pool": "demo-pool"}', false, NOW() - INTERVAL '5 days', NOW() - INTERVAL '5 days'),
('iss-awsacmpca','AWS ACM Private CA', 'AWSACMPCA', '{"region": "us-east-1", "ca_arn": "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/demo", "signing_algorithm": "SHA256WITHRSA", "validity_days": 365}', false, NOW() - INTERVAL '3 days', NOW() - INTERVAL '3 days')
('iss-awsacmpca','AWS ACM Private CA', 'AWSACMPCA', '{"region": "us-east-1", "ca_arn": "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/demo", "signing_algorithm": "SHA256WITHRSA", "validity_days": 365}', false, NOW() - INTERVAL '3 days', NOW() - INTERVAL '3 days'),
('iss-entrust', 'Entrust CA', 'Entrust', '{"api_url": "https://api.managed.entrust.com/v1/", "ca_id": "demo-ca-id"}', false, NOW() - INTERVAL '2 days', NOW() - INTERVAL '2 days'),
('iss-globalsign','GlobalSign Atlas', 'GlobalSign', '{"api_url": "https://emea.api.hvca.globalsign.com:8443/v2/"}', false, NOW() - INTERVAL '1 day', NOW() - INTERVAL '1 day'),
('iss-ejbca', 'EJBCA Enterprise', 'EJBCA', '{"api_url": "https://ejbca.internal:8443/ejbca/ejbca-rest-api/v1", "auth_mode": "mtls", "ca_name": "DemoCA"}', false, NOW() - INTERVAL '1 day', NOW() - INTERVAL '1 day')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
+45 -7
View File
@@ -46,11 +46,15 @@ export const typeLabels: Record<string, string> = {
DigiCert: 'DigiCert',
Sectigo: 'Sectigo SCM',
GoogleCAS: 'Google CAS',
AWSACMPCA: 'AWS ACM PCA',
Entrust: 'Entrust',
GlobalSign: 'GlobalSign',
EJBCA: 'EJBCA',
};
/**
* All supported issuer types + 2 "Coming Soon" stubs.
* Order: most common first, coming-soon last.
* All supported issuer types.
* Order: most common first, enterprise/commercial last.
*/
export const issuerTypes: IssuerTypeConfig[] = [
{
@@ -168,12 +172,46 @@ export const issuerTypes: IssuerTypeConfig[] = [
],
},
{
id: 'entrust',
id: 'Entrust',
name: 'Entrust',
description: 'Entrust Certificate Services \u2014 coming soon',
icon: '\uD83D\uDCE6',
configFields: [],
comingSoon: true,
description: 'Entrust Certificate Services with mTLS client certificate auth',
icon: '\uD83D\uDD10',
configFields: [
{ key: 'api_url', label: 'API URL', placeholder: 'https://api.managed.entrust.com/v1/', required: true },
{ key: 'client_cert_path', label: 'Client Certificate Path', placeholder: '/path/to/client.crt', required: true },
{ key: 'client_key_path', label: 'Client Key Path', placeholder: '/path/to/client.key', required: true, sensitive: true },
{ key: 'ca_id', label: 'CA ID', placeholder: 'CA identifier from Entrust', required: true },
{ key: 'profile_id', label: 'Profile ID (optional)', placeholder: 'Enrollment profile ID', required: false },
],
},
{
id: 'GlobalSign',
name: 'GlobalSign',
description: 'GlobalSign Atlas HVCA with mTLS + API key/secret auth',
icon: '\uD83C\uDF10',
configFields: [
{ key: 'api_url', label: 'API URL', placeholder: 'https://emea.api.hvca.globalsign.com:8443/v2/', required: true },
{ key: 'api_key', label: 'API Key', placeholder: 'GlobalSign API key', required: true, sensitive: true },
{ key: 'api_secret', label: 'API Secret', placeholder: 'GlobalSign API secret', required: true, type: 'password', sensitive: true },
{ key: 'client_cert_path', label: 'Client Certificate Path', placeholder: '/path/to/client.crt', required: true },
{ key: 'client_key_path', label: 'Client Key Path', placeholder: '/path/to/client.key', required: true, sensitive: true },
],
},
{
id: 'EJBCA',
name: 'EJBCA',
description: 'Keyfactor EJBCA with mTLS or OAuth2 auth',
icon: '\uD83D\uDD11',
configFields: [
{ key: 'api_url', label: 'API URL', placeholder: 'https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1', required: true },
{ key: 'auth_mode', label: 'Auth Mode', type: 'select', options: ['mtls', 'oauth2'], required: false, defaultValue: 'mtls' },
{ key: 'client_cert_path', label: 'Client Certificate Path', placeholder: '/path/to/client.crt', required: false },
{ key: 'client_key_path', label: 'Client Key Path', placeholder: '/path/to/client.key', required: false, sensitive: true },
{ key: 'token', label: 'OAuth2 Token', placeholder: 'Bearer token (for oauth2 mode)', required: false, type: 'password', sensitive: true },
{ key: 'ca_name', label: 'CA Name', placeholder: 'EJBCA CA name', required: true },
{ key: 'cert_profile', label: 'Certificate Profile', placeholder: 'EJBCA cert profile (optional)', required: false },
{ key: 'ee_profile', label: 'End Entity Profile', placeholder: 'EJBCA EE profile (optional)', required: false },
],
},
];