mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
7cb453a336
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.
525 lines
17 KiB
Go
525 lines
17 KiB
Go
// Package digicert implements the issuer.Connector interface for DigiCert CertCentral.
|
|
//
|
|
// DigiCert CertCentral is an enterprise certificate authority offering DV, OV, and EV
|
|
// certificates. Unlike synchronous issuers (Vault, step-ca), DigiCert uses an
|
|
// asynchronous order model: submit an order, receive an order ID, then poll for
|
|
// completion. OV/EV certificates require organization validation which may take hours
|
|
// or days; DV certificates may be issued immediately.
|
|
//
|
|
// This connector maps to certctl's existing job state machine:
|
|
// - IssueCertificate submits the order; 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 order; when status becomes "issued", downloads and
|
|
// parses the PEM bundle.
|
|
//
|
|
// Authentication: API key via X-DC-DEVKEY header.
|
|
//
|
|
// DigiCert CertCentral API used:
|
|
//
|
|
// POST /order/certificate/{product_type} - Submit certificate order
|
|
// GET /order/certificate/{order_id} - Check order status
|
|
// GET /certificate/{certificate_id}/download/format/pem_all - Download cert bundle
|
|
// PUT /certificate/{certificate_id}/revoke - Revoke certificate
|
|
// GET /user/me - Validate API credentials
|
|
package digicert
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
|
)
|
|
|
|
// Config represents the DigiCert CertCentral issuer connector configuration.
|
|
type Config struct {
|
|
// APIKey is the CertCentral API key for authentication.
|
|
// Required. Set via CERTCTL_DIGICERT_API_KEY environment variable.
|
|
APIKey string `json:"api_key"`
|
|
|
|
// OrgID is the DigiCert organization ID for certificate orders.
|
|
// Required. Set via CERTCTL_DIGICERT_ORG_ID environment variable.
|
|
OrgID string `json:"org_id"`
|
|
|
|
// ProductType is the DigiCert product type for certificate orders.
|
|
// Default: "ssl_basic". Set via CERTCTL_DIGICERT_PRODUCT_TYPE environment variable.
|
|
// Common values: "ssl_basic", "ssl_wildcard", "ssl_ev_basic", "ssl_plus", "ssl_multi_domain".
|
|
ProductType string `json:"product_type"`
|
|
|
|
// BaseURL is the DigiCert CertCentral API base URL.
|
|
// Default: "https://www.digicert.com/services/v2".
|
|
// Set via CERTCTL_DIGICERT_BASE_URL environment variable.
|
|
BaseURL string `json:"base_url"`
|
|
}
|
|
|
|
// Connector implements the issuer.Connector interface for DigiCert CertCentral.
|
|
type Connector struct {
|
|
config *Config
|
|
logger *slog.Logger
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// New creates a new DigiCert CertCentral connector with the given configuration and logger.
|
|
func New(config *Config, logger *slog.Logger) *Connector {
|
|
if config != nil {
|
|
if config.ProductType == "" {
|
|
config.ProductType = "ssl_basic"
|
|
}
|
|
if config.BaseURL == "" {
|
|
config.BaseURL = "https://www.digicert.com/services/v2"
|
|
}
|
|
}
|
|
|
|
return &Connector{
|
|
config: config,
|
|
logger: logger,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// orderRequest is the JSON body for DigiCert certificate order submission.
|
|
type orderRequest struct {
|
|
Certificate orderCert `json:"certificate"`
|
|
Organization orderOrg `json:"organization"`
|
|
ValidityYears int `json:"validity_years"`
|
|
}
|
|
|
|
type orderCert struct {
|
|
CommonName string `json:"common_name"`
|
|
CSR string `json:"csr"`
|
|
DNSNames []string `json:"dns_names,omitempty"`
|
|
}
|
|
|
|
type orderOrg struct {
|
|
ID json.Number `json:"id"`
|
|
}
|
|
|
|
// orderResponse is the JSON response from a certificate order submission.
|
|
type orderResponse struct {
|
|
ID int `json:"id"`
|
|
Status string `json:"status"`
|
|
CertificateID int `json:"certificate_id,omitempty"`
|
|
}
|
|
|
|
// orderStatusResponse is the JSON response from an order status check.
|
|
type orderStatusResponse struct {
|
|
ID int `json:"id"`
|
|
Status string `json:"status"`
|
|
Certificate struct {
|
|
ID int `json:"id"`
|
|
CommonName string `json:"common_name"`
|
|
} `json:"certificate"`
|
|
}
|
|
|
|
// ValidateConfig checks that the DigiCert configuration is valid and API 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 DigiCert config: %w", err)
|
|
}
|
|
|
|
if cfg.APIKey == "" {
|
|
return fmt.Errorf("DigiCert api_key is required")
|
|
}
|
|
|
|
if cfg.OrgID == "" {
|
|
return fmt.Errorf("DigiCert org_id is required")
|
|
}
|
|
|
|
if cfg.ProductType == "" {
|
|
cfg.ProductType = "ssl_basic"
|
|
}
|
|
if cfg.BaseURL == "" {
|
|
cfg.BaseURL = "https://www.digicert.com/services/v2"
|
|
}
|
|
|
|
// Test API access via /user/me
|
|
meURL := cfg.BaseURL + "/user/me"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create API test request: %w", err)
|
|
}
|
|
req.Header.Set("X-DC-DEVKEY", cfg.APIKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("DigiCert API not reachable at %s: %w", cfg.BaseURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
|
|
return fmt.Errorf("DigiCert API key is invalid (status %d)", resp.StatusCode)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("DigiCert API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
c.config = &cfg
|
|
c.logger.Info("DigiCert CertCentral configuration validated",
|
|
"base_url", cfg.BaseURL,
|
|
"product_type", cfg.ProductType)
|
|
|
|
return nil
|
|
}
|
|
|
|
// IssueCertificate submits a certificate order to DigiCert CertCentral.
|
|
// If the certificate is issued immediately (DV certs), returns the cert.
|
|
// If pending (OV/EV certs), returns OrderID with empty CertPEM for polling.
|
|
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
|
c.logger.Info("processing DigiCert issuance request",
|
|
"common_name", request.CommonName,
|
|
"san_count", len(request.SANs),
|
|
"product_type", c.config.ProductType)
|
|
|
|
orderReq := orderRequest{
|
|
Certificate: orderCert{
|
|
CommonName: request.CommonName,
|
|
CSR: request.CSRPEM,
|
|
DNSNames: request.SANs,
|
|
},
|
|
Organization: orderOrg{
|
|
ID: json.Number(c.config.OrgID),
|
|
},
|
|
ValidityYears: 1,
|
|
}
|
|
|
|
body, err := json.Marshal(orderReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal order request: %w", err)
|
|
}
|
|
|
|
orderURL := fmt.Sprintf("%s/order/certificate/%s", c.config.BaseURL, c.config.ProductType)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, orderURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create order request: %w", err)
|
|
}
|
|
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("DigiCert order request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read order response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
return nil, fmt.Errorf("DigiCert order returned status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var orderResp orderResponse
|
|
if err := json.Unmarshal(respBody, &orderResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
|
}
|
|
|
|
orderID := fmt.Sprintf("%d", orderResp.ID)
|
|
|
|
c.logger.Info("DigiCert order submitted",
|
|
"order_id", orderID,
|
|
"status", orderResp.Status)
|
|
|
|
// If issued immediately (DV certs), download the certificate
|
|
if orderResp.Status == "issued" && orderResp.CertificateID > 0 {
|
|
certPEM, chainPEM, serial, notBefore, notAfter, err := c.downloadCertificate(ctx, orderResp.CertificateID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to download certificate: %w", err)
|
|
}
|
|
|
|
c.logger.Info("DigiCert certificate issued immediately",
|
|
"order_id", orderID,
|
|
"serial", serial)
|
|
|
|
return &issuer.IssuanceResult{
|
|
CertPEM: certPEM,
|
|
ChainPEM: chainPEM,
|
|
Serial: serial,
|
|
NotBefore: notBefore,
|
|
NotAfter: notAfter,
|
|
OrderID: orderID,
|
|
}, nil
|
|
}
|
|
|
|
// Pending — return OrderID for polling via GetOrderStatus
|
|
c.logger.Info("DigiCert order pending validation",
|
|
"order_id", orderID,
|
|
"status", orderResp.Status)
|
|
|
|
return &issuer.IssuanceResult{
|
|
OrderID: orderID,
|
|
}, nil
|
|
}
|
|
|
|
// RenewCertificate renews a certificate by submitting a new order.
|
|
// DigiCert uses reissue for renewal, but for simplicity we submit a new order
|
|
// (reissue requires the original order ID which may not be available).
|
|
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
|
c.logger.Info("processing DigiCert 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 DigiCert CertCentral.
|
|
// DigiCert revocation uses certificate_id, so we extract it from the serial
|
|
// by looking up the order. For simplicity, we use the serial as the cert ID
|
|
// (the caller should provide the DigiCert certificate ID).
|
|
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
|
c.logger.Info("processing DigiCert revocation request", "serial", request.Serial)
|
|
|
|
reason := "unspecified"
|
|
if request.Reason != nil {
|
|
reason = *request.Reason
|
|
}
|
|
|
|
revokeBody := map[string]interface{}{
|
|
"reason": reason,
|
|
}
|
|
|
|
body, err := json.Marshal(revokeBody)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal revoke request: %w", err)
|
|
}
|
|
|
|
// DigiCert uses certificate_id in the URL path for revocation
|
|
revokeURL := fmt.Sprintf("%s/certificate/%s/revoke", c.config.BaseURL, 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("X-DC-DEVKEY", c.config.APIKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("DigiCert revoke request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// DigiCert returns 204 No Content on successful revocation
|
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("DigiCert revoke returned status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
c.logger.Info("DigiCert certificate revoked", "serial", request.Serial, "reason", reason)
|
|
return nil
|
|
}
|
|
|
|
// GetOrderStatus checks the status of a DigiCert certificate order.
|
|
// If the order is "issued", downloads the certificate and returns it.
|
|
// 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 DigiCert order status", "order_id", orderID)
|
|
|
|
statusURL := fmt.Sprintf("%s/order/certificate/%s", c.config.BaseURL, orderID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create status request: %w", err)
|
|
}
|
|
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("DigiCert 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("DigiCert order status returned %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var statusResp orderStatusResponse
|
|
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.ID == 0 {
|
|
return nil, fmt.Errorf("order is issued but certificate_id is missing")
|
|
}
|
|
|
|
certPEM, chainPEM, serial, notBefore, notAfter, err := c.downloadCertificate(ctx, statusResp.Certificate.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to download certificate: %w", err)
|
|
}
|
|
|
|
c.logger.Info("DigiCert order completed",
|
|
"order_id", orderID,
|
|
"serial", serial)
|
|
|
|
return &issuer.OrderStatus{
|
|
OrderID: orderID,
|
|
Status: "completed",
|
|
CertPEM: &certPEM,
|
|
ChainPEM: &chainPEM,
|
|
Serial: &serial,
|
|
NotBefore: ¬Before,
|
|
NotAfter: ¬After,
|
|
UpdatedAt: now,
|
|
}, nil
|
|
|
|
case "pending", "processing":
|
|
msg := fmt.Sprintf("order %s is %s", orderID, statusResp.Status)
|
|
return &issuer.OrderStatus{
|
|
OrderID: orderID,
|
|
Status: "pending",
|
|
Message: &msg,
|
|
UpdatedAt: now,
|
|
}, nil
|
|
|
|
case "rejected", "denied":
|
|
msg := fmt.Sprintf("order %s was %s", orderID, statusResp.Status)
|
|
return &issuer.OrderStatus{
|
|
OrderID: orderID,
|
|
Status: "failed",
|
|
Message: &msg,
|
|
UpdatedAt: now,
|
|
}, nil
|
|
|
|
default:
|
|
msg := fmt.Sprintf("unknown order status: %s", statusResp.Status)
|
|
return &issuer.OrderStatus{
|
|
OrderID: orderID,
|
|
Status: "pending",
|
|
Message: &msg,
|
|
UpdatedAt: now,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// downloadCertificate downloads the PEM bundle for a DigiCert certificate.
|
|
func (c *Connector) downloadCertificate(ctx context.Context, certificateID int) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
|
downloadURL := fmt.Sprintf("%s/certificate/%d/download/format/pem_all", c.config.BaseURL, certificateID)
|
|
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
|
if reqErr != nil {
|
|
err = fmt.Errorf("failed to create download request: %w", reqErr)
|
|
return
|
|
}
|
|
req.Header.Set("X-DC-DEVKEY", c.config.APIKey)
|
|
|
|
resp, doErr := c.httpClient.Do(req)
|
|
if doErr != nil {
|
|
err = fmt.Errorf("DigiCert download request failed: %w", doErr)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
err = fmt.Errorf("DigiCert download returned status %d: %s", resp.StatusCode, string(body))
|
|
return
|
|
}
|
|
|
|
body, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
err = fmt.Errorf("failed to read download response: %w", readErr)
|
|
return
|
|
}
|
|
|
|
// Parse the PEM bundle: first cert is the leaf, rest are intermediates
|
|
certPEM, chainPEM, serial, notBefore, notAfter, err = parsePEMBundle(string(body))
|
|
return
|
|
}
|
|
|
|
// parsePEMBundle splits a PEM bundle into leaf cert and chain, extracting metadata.
|
|
func parsePEMBundle(bundle string) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
|
|
var certs []string
|
|
remaining := bundle
|
|
|
|
for {
|
|
var block *pem.Block
|
|
block, rest := pem.Decode([]byte(remaining))
|
|
if block == nil {
|
|
break
|
|
}
|
|
if block.Type == "CERTIFICATE" {
|
|
certs = append(certs, string(pem.EncodeToMemory(block)))
|
|
}
|
|
remaining = string(rest)
|
|
}
|
|
|
|
if len(certs) == 0 {
|
|
err = fmt.Errorf("no certificates found in PEM bundle")
|
|
return
|
|
}
|
|
|
|
certPEM = certs[0]
|
|
if len(certs) > 1 {
|
|
chainPEM = strings.Join(certs[1:], "")
|
|
}
|
|
|
|
// Parse leaf cert for metadata
|
|
block, _ := pem.Decode([]byte(certPEM))
|
|
if block == nil {
|
|
err = fmt.Errorf("failed to decode leaf certificate PEM")
|
|
return
|
|
}
|
|
|
|
cert, parseErr := x509.ParseCertificate(block.Bytes)
|
|
if parseErr != nil {
|
|
err = fmt.Errorf("failed to parse leaf certificate: %w", parseErr)
|
|
return
|
|
}
|
|
|
|
serial = cert.SerialNumber.String()
|
|
notBefore = cert.NotBefore
|
|
notAfter = cert.NotAfter
|
|
return
|
|
}
|
|
|
|
// GenerateCRL is not supported because DigiCert manages CRL distribution.
|
|
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
|
return nil, fmt.Errorf("DigiCert manages CRL distribution; use DigiCert's CRL endpoints")
|
|
}
|
|
|
|
// SignOCSPResponse is not supported because DigiCert manages OCSP.
|
|
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
|
return nil, fmt.Errorf("DigiCert manages OCSP; use DigiCert's OCSP responder")
|
|
}
|
|
|
|
// GetCACertPEM is not directly supported. DigiCert intermediate certificates
|
|
// come with each certificate issuance as part of the PEM bundle.
|
|
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
|
return "", fmt.Errorf("DigiCert intermediate certificates are included with each issued certificate")
|
|
}
|
|
|
|
// GetRenewalInfo returns nil, nil as DigiCert does not support ACME Renewal Information (ARI).
|
|
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// Ensure Connector implements the issuer.Connector interface.
|
|
var _ issuer.Connector = (*Connector)(nil)
|