mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 09:58:52 +00:00
6375909591
Vault PKI: synchronous issuance via /v1/{mount}/sign/{role}, token auth,
revocation, CA cert retrieval, 14 tests. DigiCert CertCentral: async order
model (submit → poll → download), X-DC-DEVKEY auth, OV/EV support, PEM
bundle parsing, 16 tests. Both conditionally registered based on env vars.
Includes OpenAPI enum updates, seed data, connector docs, architecture docs,
README badges, and testing guide sign-off (Parts 38 + 39, 12 automated
smoke test assertions all passing).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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)
|