mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:31:30 +00:00
21aeed4f4e
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
644 lines
21 KiB
Go
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: ¬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)
|