// 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/certctl-io/certctl/internal/connector/issuer" "github.com/certctl-io/certctl/internal/connector/issuer/asyncpoll" "github.com/certctl-io/certctl/internal/connector/issuer/mtlscache" ) // 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"` // PollMaxWaitSeconds caps how long GetOrderStatus blocks doing // internal exponential-backoff polling before returning // StillPending. Default 600 (10 minutes); operators using // approval-pending workflows where humans approve enrollments // should bump this to a higher value (e.g., 86400 = 24h) so a // single scheduler tick can wait through the approval window. // // Set via CERTCTL_ENTRUST_POLL_MAX_WAIT_SECONDS. Audit fix #5. PollMaxWaitSeconds int `json:"poll_max_wait_seconds,omitempty"` } // pollMaxWait returns the configured PollMaxWait as a time.Duration, // or the asyncpoll package default if unset. func (c *Config) pollMaxWait() time.Duration { if c.PollMaxWaitSeconds <= 0 { return asyncpoll.DefaultMaxWait } return time.Duration(c.PollMaxWaitSeconds) * time.Second } // Connector implements the issuer.Connector interface for Entrust Certificate Services. type Connector struct { config *Config logger *slog.Logger httpClient *http.Client // mtls caches the parsed client keypair + a precomputed // *http.Transport so steady-state API calls don't re-parse // the keypair on every request. nil in test mode // (NewWithHTTPClient) and on the first ValidateConfig call. // Audit fix #10. mtls *mtlscache.Cache } // 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 } // getHTTPClient returns the HTTP client to use for an Entrust API // call. If a test injected a custom client via NewWithHTTPClient (or // ValidateConfig pre-built one with its own transport), that client // is returned as-is — the cache layer must not intercept the test // path. Otherwise a cached mTLS client is returned, refreshing the // keypair from disk if the cert file's mtime has advanced since the // last load — rotation-via-mv-f takes effect on the next call without // a process restart. Audit fix #10. func (c *Connector) getHTTPClient(ctx context.Context) (*http.Client, error) { // Test mode: NewWithHTTPClient + custom transport, OR a // ValidateConfig-built client. Either way, the caller has // already wired the transport they want; don't override. if c.httpClient != nil && c.httpClient.Transport != nil { return c.httpClient, nil } if c.config == nil || c.config.ClientCertPath == "" || c.config.ClientKeyPath == "" { // Cert paths not configured — return whatever was supplied // at construction (typically the bare default-timeout // client from New). return c.httpClient, nil } // Production mode: lazy-build the cache on the first call so // the constructor stays cheap (no disk I/O). Subsequent calls // take the fast path through the cache's RWMutex. if c.mtls == nil { cache, err := mtlscache.New(c.config.ClientCertPath, c.config.ClientKeyPath, mtlscache.Options{ HTTPTimeout: 30 * time.Second, }) if err != nil { return nil, fmt.Errorf("failed to build Entrust mTLS cache: %w", err) } c.mtls = cache } else if err := c.mtls.RefreshIfStale(); err != nil { return nil, fmt.Errorf("failed to refresh Entrust mTLS cache: %w", err) } return c.mtls.Client(), 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") client, err := c.getHTTPClient(ctx) if err != nil { return nil, err } resp, err := client.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") client, err := c.getHTTPClient(ctx) if err != nil { return err } resp, err := client.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 using // bounded internal polling (asyncpoll.Poll). One call blocks for up // to PollMaxWait (default 10m; operators using approval-pending // workflows can raise to 24h) doing exponential backoff with jitter. // // Audit fix #5 Phase 2: previously each scheduler tick made one HTTP // call. Approval-pending enrollments now ride the backoff schedule // rather than tight-loop polling. func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) { c.logger.Debug("checking Entrust enrollment status", "tracking_id", orderID) var done *issuer.OrderStatus var lastPendingMsg string cfg := asyncpoll.Config{MaxWait: c.config.pollMaxWait()} res, err := asyncpoll.Poll(ctx, cfg, func(ctx context.Context) (asyncpoll.Result, error) { status, result, pollErr := c.pollEnrollmentOnce(ctx, orderID) if status != nil { switch result { case asyncpoll.Done: done = status case asyncpoll.StillPending: if status.Message != nil { lastPendingMsg = *status.Message } } } return result, pollErr }) now := time.Now() switch res { case asyncpoll.Done: return done, nil case asyncpoll.Failed: return nil, err default: msg := lastPendingMsg if msg == "" { msg = fmt.Sprintf("enrollment %s still pending after PollMaxWait", orderID) } return &issuer.OrderStatus{ OrderID: orderID, Status: "pending", Message: &msg, UpdatedAt: now, }, nil } } // pollEnrollmentOnce makes one HTTP GET against the Entrust enrollment // status endpoint. 4xx (not 429) is permanent; 5xx / 429 / network is // transient and rides the backoff schedule. func (c *Connector) pollEnrollmentOnce(ctx context.Context, orderID string) (*issuer.OrderStatus, asyncpoll.Result, error) { 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, asyncpoll.Failed, fmt.Errorf("failed to create status request: %w", err) } client, err := c.getHTTPClient(ctx) if err != nil { return nil, asyncpoll.Failed, fmt.Errorf("Entrust status client init: %w", err) } resp, err := client.Do(req) if err != nil { return nil, asyncpoll.StillPending, fmt.Errorf("Entrust status request failed: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, asyncpoll.StillPending, fmt.Errorf("failed to read status response: %w", err) } if resp.StatusCode != http.StatusOK { err := fmt.Errorf("Entrust enrollment status returned %d: %s", resp.StatusCode, string(respBody)) if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { return nil, asyncpoll.StillPending, err } return nil, asyncpoll.Failed, err } var statusResp enrollmentStatusResponse if err := json.Unmarshal(respBody, &statusResp); err != nil { return nil, asyncpoll.Failed, fmt.Errorf("failed to parse status response: %w", err) } now := time.Now() switch statusResp.Status { case "ISSUED": if statusResp.Certificate == "" { return nil, asyncpoll.Failed, fmt.Errorf("enrollment is ISSUED but certificate is missing") } serial, notBefore, notAfter, err := parseCertMetadata(statusResp.Certificate) if err != nil { return nil, asyncpoll.Failed, 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: ¬Before, NotAfter: ¬After, UpdatedAt: now, }, asyncpoll.Done, 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, }, asyncpoll.StillPending, 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, }, asyncpoll.Done, nil default: msg := fmt.Sprintf("unknown enrollment status: %s", statusResp.Status) return &issuer.OrderStatus{ OrderID: orderID, Status: "pending", Message: &msg, UpdatedAt: now, }, asyncpoll.StillPending, 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)