Files
certctl/internal/connector/issuer/entrust/entrust.go
T
shankar0123 3f619bcaac 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>
2026-04-15 22:24:12 -04:00

514 lines
16 KiB
Go

// 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)