Files
certctl/internal/connector/issuer/globalsign/globalsign.go
T
shankar0123 7cb453a336 chore(fmt): repo-wide gofmt -w sweep — close drift surfaced by ci-pipeline-cleanup Phase 4
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.
2026-04-30 22:33:57 +00:00

561 lines
19 KiB
Go

// Package globalsign implements the issuer.Connector interface for GlobalSign Atlas HVCA.
//
// GlobalSign Atlas HVCA (Hosted Validation CA) is an enterprise certificate authority
// offering DV and OV certificates. Unlike synchronous issuers (Vault, step-ca), GlobalSign
// uses an asynchronous order model with serial number polling: submit a certificate order,
// receive a serial number immediately, then poll to check when the cert is available.
//
// This connector maps to certctl's existing job state machine:
// - IssueCertificate submits the order and returns the serial number. The cert PEM
// is typically available within seconds for DV certs.
// - GetOrderStatus polls via the serial number to retrieve the cert when ready.
//
// Authentication: mTLS client certificate (mutual TLS handshake) PLUS API key/secret
// headers on every request. This is a "double auth" pattern.
// - TLS client certificate: loaded from disk via tls.LoadX509KeyPair()
// - API key/secret: sent as custom HTTP headers (ApiKey, ApiSecret)
//
// GlobalSign Atlas HVCA API used:
//
// POST /v2/certificates - Submit certificate order, returns serial number
// GET /v2/certificates/{serial} - Get certificate PEM by serial number
// PUT /v2/certificates/{serial}/revoke - Revoke certificate (no reason code required)
// GET /v2/certificates - List certificates (for config validation)
package globalsign
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// Config represents the GlobalSign Atlas HVCA issuer connector configuration.
type Config struct {
// APIUrl is the GlobalSign Atlas HVCA API base URL (region-aware).
// Examples: https://emea.api.hvca.globalsign.com:8443/v2/ (EMEA region)
// Required. Set via CERTCTL_GLOBALSIGN_API_URL environment variable.
APIUrl string `json:"api_url"`
// APIKey is the GlobalSign API key for request authentication.
// Required. Set via CERTCTL_GLOBALSIGN_API_KEY environment variable.
APIKey string `json:"api_key"`
// APISecret is the GlobalSign API secret for request authentication.
// Required. Set via CERTCTL_GLOBALSIGN_API_SECRET environment variable.
APISecret string `json:"api_secret"`
// ClientCertPath is the filesystem path to the mTLS client certificate PEM file.
// The certificate must be signed by GlobalSign and loaded for TLS handshake.
// Required. Set via CERTCTL_GLOBALSIGN_CLIENT_CERT_PATH environment variable.
ClientCertPath string `json:"client_cert_path"`
// ClientKeyPath is the filesystem path to the mTLS client private key PEM file.
// Must match the certificate in ClientCertPath.
// Required. Set via CERTCTL_GLOBALSIGN_CLIENT_KEY_PATH environment variable.
ClientKeyPath string `json:"client_key_path"`
// ServerCAPath is the filesystem path to a PEM file containing the CA
// certificate(s) used to verify the GlobalSign Atlas HVCA API server certificate.
// Optional. If empty, the system trust store is used. This option exists for
// private/lab deployments of GlobalSign Atlas that terminate TLS with an
// internal CA not present in the host's default trust bundle.
// Set via CERTCTL_GLOBALSIGN_SERVER_CA_PATH environment variable.
ServerCAPath string `json:"server_ca_path,omitempty"`
}
// Connector implements the issuer.Connector interface for GlobalSign Atlas HVCA.
type Connector struct {
config *Config
logger *slog.Logger
httpClient *http.Client
}
// New creates a new GlobalSign Atlas HVCA connector with the given configuration and logger.
// The connector will load the mTLS client certificate from the config paths on each API call.
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 GlobalSign connector with a custom HTTP client.
// Used for testing with mocked HTTP responses. The client is used directly instead of
// loading mTLS certificates, allowing tests to bypass TLS setup.
func NewWithHTTPClient(config *Config, logger *slog.Logger, client *http.Client) *Connector {
return &Connector{
config: config,
logger: logger,
httpClient: client,
}
}
// certificateRequest is the JSON body for GlobalSign certificate order submission.
type certificateRequest struct {
CSR string `json:"csr"`
SubjectDN subjectDNRequest `json:"subject_dn"`
SAN sanRequest `json:"san,omitempty"`
}
type subjectDNRequest struct {
CommonName string `json:"common_name"`
}
type sanRequest struct {
DNSNames []string `json:"dns_names,omitempty"`
}
// certificateResponse is the JSON response from a certificate order submission or retrieval.
type certificateResponse struct {
SerialNumber string `json:"serial_number"`
Status string `json:"status"`
Certificate string `json:"certificate,omitempty"`
Chain string `json:"chain,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
}
// ValidateConfig checks that the GlobalSign configuration is valid and mTLS connection 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 GlobalSign config: %w", err)
}
if cfg.APIUrl == "" {
return fmt.Errorf("GlobalSign api_url is required")
}
if cfg.APIKey == "" {
return fmt.Errorf("GlobalSign api_key is required")
}
if cfg.APISecret == "" {
return fmt.Errorf("GlobalSign api_secret is required")
}
if cfg.ClientCertPath == "" {
return fmt.Errorf("GlobalSign client_cert_path is required")
}
if cfg.ClientKeyPath == "" {
return fmt.Errorf("GlobalSign client_key_path is required")
}
// Load the client certificate and key for mTLS validation
cert, err := tls.LoadX509KeyPair(cfg.ClientCertPath, cfg.ClientKeyPath)
if err != nil {
return fmt.Errorf("failed to load GlobalSign client certificate: %w", err)
}
// Build a verifying mTLS TLS config. If ServerCAPath is set, that PEM
// bundle is used as the trust anchor for the server certificate;
// otherwise the system trust store is used. TLS 1.2 is the minimum.
tlsConfig, err := buildServerTLSConfig(&cfg, cert)
if err != nil {
return fmt.Errorf("failed to build GlobalSign TLS config: %w", err)
}
validationClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
Timeout: 10 * time.Second,
}
// Test API access via GET /v2/certificates (list, requires auth headers)
listURL := strings.TrimSuffix(cfg.APIUrl, "/") + "/v2/certificates"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
if err != nil {
return fmt.Errorf("failed to create API test request: %w", err)
}
// Add both authentication layers
req.Header.Set("ApiKey", cfg.APIKey)
req.Header.Set("ApiSecret", cfg.APISecret)
req.Header.Set("Content-Type", "application/json")
resp, err := validationClient.Do(req)
if err != nil {
return fmt.Errorf("GlobalSign API not reachable at %s: %w", cfg.APIUrl, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("GlobalSign API credentials are invalid (status %d)", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("GlobalSign API returned status %d: %s", resp.StatusCode, string(respBody))
}
c.config = &cfg
c.logger.Info("GlobalSign Atlas HVCA configuration validated",
"api_url", cfg.APIUrl)
return nil
}
// getHTTPClient returns the HTTP client to use, creating one with mTLS if needed.
// If the connector was created with NewWithHTTPClient (test mode), uses that client directly.
// Otherwise, creates a fresh mTLS client with the configured certificate.
func (c *Connector) getHTTPClient(ctx context.Context) (*http.Client, error) {
// Check if we're in test mode (httpClient was explicitly provided and has non-nil transport)
if c.httpClient != nil && c.httpClient.Transport != nil {
return c.httpClient, nil
}
// For tests with default client (nil or minimal), check if cert paths are available
if c.config.ClientCertPath == "" || c.config.ClientKeyPath == "" {
// Test mode: use httpClient as-is (won't load certs)
return c.httpClient, nil
}
// Production mode: load mTLS certificate
cert, err := tls.LoadX509KeyPair(c.config.ClientCertPath, c.config.ClientKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to load GlobalSign client certificate: %w", err)
}
tlsConfig, err := buildServerTLSConfig(c.config, cert)
if err != nil {
return nil, fmt.Errorf("failed to build GlobalSign TLS config: %w", err)
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
Timeout: 30 * time.Second,
}, nil
}
// buildServerTLSConfig returns a TLS configuration for the GlobalSign Atlas
// HVCA API client. It always verifies the server certificate. When
// cfg.ServerCAPath is set, the PEM bundle at that path is used as the
// trust anchor (enables pinning a private/lab CA); otherwise the host's
// system trust store is used. TLS 1.2 is the minimum protocol version.
//
// This helper is the single source of truth for both the ValidateConfig
// probe client and the steady-state getHTTPClient production client, so
// any future TLS policy change applies uniformly.
func buildServerTLSConfig(cfg *Config, clientCert tls.Certificate) (*tls.Config, error) {
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{clientCert},
MinVersion: tls.VersionTLS12,
}
if cfg.ServerCAPath != "" {
caPEM, err := os.ReadFile(cfg.ServerCAPath)
if err != nil {
return nil, fmt.Errorf("failed to read server CA bundle at %s: %w", cfg.ServerCAPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("no valid PEM certificates found in server CA bundle at %s", cfg.ServerCAPath)
}
tlsConfig.RootCAs = pool
}
return tlsConfig, nil
}
// IssueCertificate submits a certificate order to GlobalSign Atlas HVCA.
// Returns the serial number immediately; typically the cert is available within seconds (DV) to minutes (OV).
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing GlobalSign issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
client, err := c.getHTTPClient(ctx)
if err != nil {
return nil, err
}
certReq := certificateRequest{
CSR: request.CSRPEM,
SubjectDN: subjectDNRequest{
CommonName: request.CommonName,
},
}
if len(request.SANs) > 0 {
certReq.SAN = sanRequest{
DNSNames: request.SANs,
}
}
body, err := json.Marshal(certReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal certificate request: %w", err)
}
certURL := strings.TrimSuffix(c.config.APIUrl, "/") + "/v2/certificates"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, certURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create certificate request: %w", err)
}
// Apply double auth: mTLS + headers
req.Header.Set("ApiKey", c.config.APIKey)
req.Header.Set("ApiSecret", c.config.APISecret)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("GlobalSign certificate request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read certificate response: %w", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("GlobalSign certificate submission returned status %d: %s", resp.StatusCode, string(respBody))
}
var certResp certificateResponse
if err := json.Unmarshal(respBody, &certResp); err != nil {
return nil, fmt.Errorf("failed to parse certificate response: %w", err)
}
c.logger.Info("GlobalSign certificate order submitted",
"serial", certResp.SerialNumber,
"status", certResp.Status)
// If certificate is available immediately, return it.
// Otherwise, return just the serial number for polling via GetOrderStatus.
if certResp.Status == "issued" && certResp.Certificate != "" {
notBefore, notAfter, err := parseCertDates(certResp.Certificate)
if err != nil {
c.logger.Warn("failed to parse certificate dates", "error", err)
}
return &issuer.IssuanceResult{
CertPEM: certResp.Certificate,
ChainPEM: certResp.Chain,
Serial: certResp.SerialNumber,
NotBefore: notBefore,
NotAfter: notAfter,
OrderID: certResp.SerialNumber,
}, nil
}
// Pending — return serial number as OrderID for polling
c.logger.Info("GlobalSign certificate order pending",
"serial", certResp.SerialNumber,
"status", certResp.Status)
return &issuer.IssuanceResult{
OrderID: certResp.SerialNumber,
}, nil
}
// RenewCertificate renews a certificate by submitting a new order.
// GlobalSign uses serial number polling, so renewal is treated as a new issuance.
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing GlobalSign 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 GlobalSign Atlas HVCA.
// GlobalSign revocation does not require a reason code.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.logger.Info("processing GlobalSign revocation request", "serial", request.Serial)
client, err := c.getHTTPClient(ctx)
if err != nil {
return err
}
// GlobalSign revocation endpoint: PUT /v2/certificates/{serial}/revoke
// No request body or reason code required.
revokeURL := strings.TrimSuffix(c.config.APIUrl, "/") + fmt.Sprintf("/v2/certificates/%s/revoke", request.Serial)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, revokeURL, nil)
if err != nil {
return fmt.Errorf("failed to create revoke request: %w", err)
}
req.Header.Set("ApiKey", c.config.APIKey)
req.Header.Set("ApiSecret", c.config.APISecret)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("GlobalSign revoke request failed: %w", err)
}
defer resp.Body.Close()
// GlobalSign returns 200 OK on successful revocation
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("GlobalSign revoke returned status %d: %s", resp.StatusCode, string(respBody))
}
c.logger.Info("GlobalSign certificate revoked", "serial", request.Serial)
return nil
}
// GetOrderStatus checks the status of a GlobalSign certificate order by serial number.
// Polls the certificate endpoint; when status is "issued", downloads and returns the cert.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
c.logger.Debug("checking GlobalSign certificate status", "serial", orderID)
client, err := c.getHTTPClient(ctx)
if err != nil {
return nil, err
}
// GlobalSign status endpoint: GET /v2/certificates/{serial}
statusURL := strings.TrimSuffix(c.config.APIUrl, "/") + fmt.Sprintf("/v2/certificates/%s", 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("ApiKey", c.config.APIKey)
req.Header.Set("ApiSecret", c.config.APISecret)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("GlobalSign 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("GlobalSign certificate status returned %d: %s", resp.StatusCode, string(respBody))
}
var certResp certificateResponse
if err := json.Unmarshal(respBody, &certResp); err != nil {
return nil, fmt.Errorf("failed to parse status response: %w", err)
}
now := time.Now()
switch certResp.Status {
case "issued":
if certResp.Certificate == "" {
return nil, fmt.Errorf("certificate status is issued but certificate PEM is missing")
}
notBefore, notAfter, err := parseCertDates(certResp.Certificate)
if err != nil {
c.logger.Warn("failed to parse certificate dates", "error", err)
}
c.logger.Info("GlobalSign certificate ready",
"serial", orderID)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
CertPEM: &certResp.Certificate,
ChainPEM: &certResp.Chain,
Serial: &certResp.SerialNumber,
NotBefore: &notBefore,
NotAfter: &notAfter,
UpdatedAt: now,
}, nil
case "pending", "processing":
msg := fmt.Sprintf("certificate %s is %s", orderID, certResp.Status)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "pending",
Message: &msg,
UpdatedAt: now,
}, nil
case "rejected", "denied", "failed":
msg := fmt.Sprintf("certificate %s was %s", orderID, certResp.Status)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "failed",
Message: &msg,
UpdatedAt: now,
}, nil
default:
msg := fmt.Sprintf("unknown certificate status: %s", certResp.Status)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "pending",
Message: &msg,
UpdatedAt: now,
}, nil
}
}
// parseCertDates extracts NotBefore and NotAfter from a PEM-encoded certificate.
func parseCertDates(certPEM string) (time.Time, time.Time, error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return time.Time{}, time.Time{}, fmt.Errorf("failed to decode certificate PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("failed to parse certificate: %w", err)
}
return cert.NotBefore, cert.NotAfter, nil
}
// GenerateCRL is not supported because GlobalSign manages CRL distribution.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("GlobalSign manages CRL distribution; use GlobalSign's CRL endpoints")
}
// SignOCSPResponse is not supported because GlobalSign manages OCSP.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("GlobalSign manages OCSP; use GlobalSign's OCSP responder")
}
// GetCACertPEM is not directly supported. GlobalSign intermediate certificates
// come with each certificate issuance as part of the chain response.
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
return "", fmt.Errorf("GlobalSign intermediate certificates are included with each issued certificate")
}
// GetRenewalInfo returns nil, nil as GlobalSign 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)