mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 12:28:55 +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
684 lines
24 KiB
Go
684 lines
24 KiB
Go
// 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)
|