Files
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

644 lines
21 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
// 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: &notBefore,
NotAfter: &notAfter,
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)