// Copyright 2026 certctl LLC. All rights reserved. // SPDX-License-Identifier: BUSL-1.1 // 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/certctl-io/certctl/internal/connector/issuer" "github.com/certctl-io/certctl/internal/connector/issuer/asyncpoll" "github.com/certctl-io/certctl/internal/connector/issuer/mtlscache" "github.com/certctl-io/certctl/internal/secret" ) // 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. // // Type: *secret.Ref (audit fix #6 Phase 2). Never stringifies; // MarshalJSON returns "[redacted]"; bytes are zeroed after each // header write via Ref.Use. APIKey *secret.Ref `json:"api_key"` // APISecret is the GlobalSign API secret for request authentication. // Required. Set via CERTCTL_GLOBALSIGN_API_SECRET environment variable. // Same *secret.Ref protections as APIKey. APISecret *secret.Ref `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"` // PollMaxWaitSeconds caps how long GetOrderStatus blocks doing // internal exponential-backoff polling before returning // StillPending. Default 600 (10 minutes). GlobalSign tracks // orders by serial number rather than order ID, but the polling // shape is identical. // // Set via CERTCTL_GLOBALSIGN_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 GlobalSign Atlas HVCA. 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. Audit fix #10. nil in test mode // (NewWithHTTPClient) and on the first ValidateConfig call // before the cache is wired; getHTTPClient falls through to // httpClient when nil so test paths keep their behaviour. mtls *mtlscache.Cache } // 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.IsEmpty() { return fmt.Errorf("GlobalSign api_key is required") } if cfg.APISecret.IsEmpty() { 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 setAuthHeaders(req, &cfg) 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, returns the cached mTLS client (audit fix #10), refreshing it // 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. func (c *Connector) getHTTPClient(ctx context.Context) (*http.Client, error) { // Test mode: NewWithHTTPClient supplied a pre-built client with a // non-nil Transport. The cache layer must NOT intercept this // branch — tests need their httptest-backed transport, not an // mTLS one against a (probably non-existent) cert file. if c.httpClient != nil && c.httpClient.Transport != nil { return c.httpClient, nil } // Test mode 2: bare default client + no cert paths configured. // Same rationale — return what the caller supplied as-is. if c.config.ClientCertPath == "" || c.config.ClientKeyPath == "" { 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 { // Capture the config pointer so the TLSConfigBuilder closure // reads the current ServerCAPath. The cache itself owns the // rebuild on rotation. cfg := c.config cache, err := mtlscache.New(c.config.ClientCertPath, c.config.ClientKeyPath, mtlscache.Options{ TLSConfigBuilder: func(cert tls.Certificate) (*tls.Config, error) { return buildServerTLSConfig(cfg, cert) }, HTTPTimeout: 30 * time.Second, }) if err != nil { return nil, fmt.Errorf("failed to load GlobalSign client certificate (mTLS cache build): %w", err) } c.mtls = cache } else if err := c.mtls.RefreshIfStale(); err != nil { // stat / parse failure on rotation should bubble up — a // missing cert file is a real outage signal. The cache // keeps serving the previous keypair on parse error // because reload only commits on success, but stat error // is surfaced to the caller. return nil, fmt.Errorf("failed to refresh GlobalSign mTLS cache: %w", err) } return c.mtls.Client(), nil } // setAuthHeaders writes the GlobalSign double-auth headers (ApiKey, // ApiSecret) plus Content-Type: application/json onto req. The secret // values are pulled from the *secret.Ref via Use, which zero-fills the // per-call buffer after the header string is set; the Ref's underlying // bytes remain encrypted at rest. The Use return value is intentionally // ignored — Set never errors and the only failure modes inside Use are // nil-Ref / empty-Ref which the upstream IsEmpty validation has already // excluded for production paths. ValidateConfig and the steady-state // IssueCertificate / RevokeCertificate / pollCertificateOnce sites all // route through here so any future header-shape change applies once. // // Audit fix #6 Phase 2. func setAuthHeaders(req *http.Request, cfg *Config) { if cfg.APIKey != nil { _ = cfg.APIKey.Use(func(buf []byte) error { req.Header.Set("ApiKey", string(buf)) return nil }) } if cfg.APISecret != nil { _ = cfg.APISecret.Use(func(buf []byte) error { req.Header.Set("ApiSecret", string(buf)) return nil }) } req.Header.Set("Content-Type", "application/json") } // 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 setAuthHeaders(req, c.config) 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) } setAuthHeaders(req, c.config) 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, using bounded internal polling (asyncpoll.Poll). // One call blocks for up to PollMaxWait (default 10m) doing // exponential backoff with jitter; returns Done with the cert, // Failed with the rejection reason, or StillPending if the deadline // expires (caller can re-invoke). // // Audit fix #5 Phase 2: previously each scheduler tick made one HTTP // call against an unready order. GlobalSign tracks orders by serial // number rather than order ID, but the polling shape is identical. func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) { c.logger.Debug("checking GlobalSign certificate status", "serial", 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.pollCertificateOnce(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("certificate %s still pending after PollMaxWait", orderID) } return &issuer.OrderStatus{ OrderID: orderID, Status: "pending", Message: &msg, UpdatedAt: now, }, nil } } // pollCertificateOnce makes one HTTP GET against the GlobalSign Atlas // HVCA certificate status endpoint and translates the response into // an asyncpoll.Result. 4xx (not 429) is permanent; 5xx / 429 / network // is transient. func (c *Connector) pollCertificateOnce(ctx context.Context, orderID string) (*issuer.OrderStatus, asyncpoll.Result, error) { client, err := c.getHTTPClient(ctx) if err != nil { return nil, asyncpoll.Failed, err } 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, asyncpoll.Failed, fmt.Errorf("failed to create status request: %w", err) } setAuthHeaders(req, c.config) resp, err := client.Do(req) if err != nil { return nil, asyncpoll.StillPending, fmt.Errorf("GlobalSign 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 { statusErr := fmt.Errorf("GlobalSign certificate status returned %d: %s", resp.StatusCode, string(respBody)) if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { return nil, asyncpoll.StillPending, statusErr } return nil, asyncpoll.Failed, statusErr } var certResp certificateResponse if err := json.Unmarshal(respBody, &certResp); err != nil { return nil, asyncpoll.Failed, fmt.Errorf("failed to parse status response: %w", err) } now := time.Now() switch certResp.Status { case "issued": if certResp.Certificate == "" { return nil, asyncpoll.Failed, 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: ¬Before, NotAfter: ¬After, UpdatedAt: now, }, asyncpoll.Done, 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, }, asyncpoll.StillPending, 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, }, asyncpoll.Done, nil default: msg := fmt.Sprintf("unknown certificate status: %s", certResp.Status) return &issuer.OrderStatus{ OrderID: orderID, Status: "pending", Message: &msg, UpdatedAt: now, }, asyncpoll.StillPending, 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)