Files
certctl/internal/connector/issuer/entrust/entrust.go
T
shankar0123 7cb453a336 chore(fmt): repo-wide gofmt -w sweep — close drift surfaced by ci-pipeline-cleanup Phase 4
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 0f205a8) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.

Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.

The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
2026-04-30 22:33:57 +00: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)