Files
certctl/internal/connector/issuer/ejbca/ejbca.go
T
shankar0123 2a384c690e secret: migrate EJBCA / GlobalSign / Sectigo credentials to *secret.Ref (Phase 2)
Phase 2 of the #6 acquisition-readiness fix from the 2026-05-01 issuer
coverage audit. Phase 1 (commit 633a10a) shipped the secret.Ref opaque
credential type with PBKDF2-derived key, ChaCha20-Poly1305 envelope,
String/MarshalJSON redaction to "[redacted]", and the Use callback
that zero-fills the per-call buffer after the consumer returns.

This commit applies the type to the three connectors flagged by the
audit and adds the JSON-roundtrip glue that the production factory
path needs.

Shared (internal/secret/):

- Add UnmarshalJSON on *Ref so json.Unmarshal of a stored config
  blob (issuerfactory.NewFromConfig) parses the bytes-as-string into
  NewRefFromString without callers having to know the field type
  changed. Null and missing keys leave the receiver nil; non-string
  payloads (numbers, bools) are rejected with a typed error. Pinned
  by TestRef_UnmarshalJSON: string_value, null, missing_key,
  number_rejected, roundtrip_marshal_then_unmarshal (the round-trip
  goes through "[redacted]" intentionally — JSON-marshal-then-
  unmarshal of a Config with secrets is NOT a supported test pattern;
  callers that construct a rawConfig must use a JSON literal with
  the real values).

Per-connector migration:

- EJBCA (ejbca.go): Config.Token: string → *secret.Ref. ValidateConfig
  empty-check uses Token.IsEmpty() (nil-safe). setAuthHeaders rewritten
  to call Token.Use; the Bearer header string is built inside the
  callback and the buffer is zeroed on return. mTLS path is
  unaffected.

- GlobalSign (globalsign.go): Config.APIKey + Config.APISecret: string
  → *secret.Ref. Both ValidateConfig empty-checks use IsEmpty().
  Extracted setAuthHeaders helper consolidates the four duplicated
  triple-Set sites (ValidateConfig probe, IssueCertificate,
  RevokeCertificate, pollCertificateOnce) so any future header-shape
  change applies once. ValidateConfig now pulls from the local cfg
  (post-Unmarshal) so the helper takes a *Config rather than the
  receiver — needed because ValidateConfig writes the validated cfg
  onto c.config only AFTER the probe succeeds.

- Sectigo (sectigo.go): Config.Login + Config.Password: string →
  *secret.Ref. CustomerURI stays plain string (org identifier, not
  a credential). setAuthHeaders rewritten to call Login.Use +
  Password.Use; ValidateConfig's inline header writes use the same
  pattern (the ValidateConfig probe writes to a local cfg, not
  c.config, so it can't share setAuthHeaders without rewiring — the
  inline form is fine, kept consistent in shape).

Test migration:

- ejbca_test.go, ejbca_failure_test.go, ejbca_stubs_test.go: bulk
  Token: "X" → Token: secret.NewRefFromString("X") via sed; secret
  import added.
- globalsign_test.go, globalsign_failure_test.go: same pattern for
  APIKey + APISecret.
- sectigo_test.go, sectigo_failure_test.go: same pattern for Login +
  Password.

Two tests (TestGlobalSign_ServerTLSConfig/PinnedCA_TrustsExpectedServer
and TestSectigoConnector/ValidateConfig_Success) used to construct
rawConfig via json.Marshal(config) → ValidateConfig(rawConfig). After
the migration, json.Marshal redacts *secret.Ref to "[redacted]" by
design, so the roundtripped rawConfig wrote "[redacted]" as the
actual header value and the mock server's auth-header check 403'd.
Both tests now build rawConfig as a JSON literal (the production-
shape input — the factory path always feeds rawConfig from the DB
or env, never from json.Marshal of an in-memory Config). The new
tests have a comment explaining the trap so the next person who
adds a similar test sees the pattern.

Out of scope (intentional):

- The `internal/config/config.SectigoConfig` / `GlobalSignConfig` /
  `EJBCAConfig` env-var-loader structs are still plain strings —
  those types are the env-load shape, not the steady-state runtime
  shape. The seed path in service/issuer.go json-marshals them into
  a map[string]interface{} which the factory then UnmarshalJSON's
  into the connector Config; the new UnmarshalJSON on *Ref handles
  the conversion at the boundary.
- DigiCert.APIKey + Vault.Token are still plain strings; Phase 3
  will pick them up. The audit explicitly named EJBCA / GlobalSign /
  Sectigo as the Phase 2 scope (RESULTS.md L633).

Verified locally:
- gofmt -l . clean
- go vet ./... clean
- staticcheck across all four packages clean
- go test -short -count=1 across secret, ejbca, globalsign, sectigo,
  issuerfactory, service, api/handler: green

Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md
Top-10 fix #6 — Phase 2.
2026-05-02 12:53:58 +00:00

548 lines
17 KiB
Go

// Package ejbca implements the issuer.Connector interface for EJBCA (Keyfactor).
//
// EJBCA is an open-source and enterprise certificate authority platform.
// This connector uses the EJBCA REST API with synchronous issuance.
//
// Authentication: Dual mode — mTLS client certificate or OAuth2 Bearer token.
// Selected via AuthMode config: "mtls" (default) or "oauth2".
//
// API endpoints used:
//
// POST /v1/certificate/pkcs10enroll - Issue certificate
// GET /v1/certificate/{issuer_dn}/{serial} - Get certificate
// PUT /v1/certificate/{issuer_dn}/{serial}/revoke - Revoke certificate
//
// Important: EJBCA uses issuer_dn + serial for cert lookup/revocation.
// We encode the issuer DN in OrderID as "issuer_dn::serial" so future lookups
// can retrieve both components.
package ejbca
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/secret"
)
// Config represents the EJBCA issuer connector configuration.
type Config struct {
// APIUrl is the EJBCA REST API base URL (e.g., "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1").
// Required. Set via CERTCTL_EJBCA_API_URL environment variable.
APIUrl string `json:"api_url"`
// AuthMode is the authentication mode: "mtls" (default) or "oauth2".
// Set via CERTCTL_EJBCA_AUTH_MODE environment variable.
AuthMode string `json:"auth_mode"`
// ClientCertPath is the path to the client certificate for mTLS authentication.
// Required when auth_mode=mtls. Set via CERTCTL_EJBCA_CLIENT_CERT_PATH environment variable.
ClientCertPath string `json:"client_cert_path"`
// ClientKeyPath is the path to the client key for mTLS authentication.
// Required when auth_mode=mtls. Set via CERTCTL_EJBCA_CLIENT_KEY_PATH environment variable.
ClientKeyPath string `json:"client_key_path"`
// Token is the OAuth2 Bearer token for authentication.
// Required when auth_mode=oauth2. Set via CERTCTL_EJBCA_TOKEN environment variable.
//
// Type: *secret.Ref (audit fix #6 Phase 2). Wrapping the token in
// a Ref means: it never stringifies (Config marshals as
// "[redacted]"), the bytes are zeroed after each Use/WriteTo
// invocation (defeats heap-dump extraction), and outbound HTTP
// header writes go through Ref.WriteTo so the staging buffer is
// short-lived. JSON unmarshal of a string value populates the
// Ref via NewRefFromString.
Token *secret.Ref `json:"token"`
// CAName is the EJBCA CA name for certificate issuance.
// Required. Set via CERTCTL_EJBCA_CA_NAME environment variable.
CAName string `json:"ca_name"`
// CertProfile is the EJBCA certificate profile name.
// Optional. Set via CERTCTL_EJBCA_CERT_PROFILE environment variable.
CertProfile string `json:"cert_profile"`
// EEProfile is the EJBCA end-entity profile name.
// Optional. Set via CERTCTL_EJBCA_EE_PROFILE environment variable.
EEProfile string `json:"ee_profile"`
}
// Connector implements the issuer.Connector interface for EJBCA.
type Connector struct {
config *Config
logger *slog.Logger
httpClient *http.Client
}
// New creates a new EJBCA connector with the given configuration and logger.
//
// When config.AuthMode is "mtls" (or empty — mtls is the default), New
// loads config.ClientCertPath + config.ClientKeyPath via tls.LoadX509KeyPair
// and configures *http.Transport.TLSClientConfig so the client presents the
// cert on every request. When AuthMode is "oauth2", New returns a client
// with no transport customization (the OAuth2 Bearer header path is wired
// in setAuthHeaders). Any other AuthMode value returns (nil, error).
//
// Returns an error if mTLS cert/key load fails (missing file, malformed
// PEM, mismatched cert/key) so misconfigured operators get an immediate
// failure at issuer construction rather than a cryptic 401 at first
// issuance.
//
// Callers wanting to inject a pre-built *http.Client (tests, fake EJBCA
// servers) should use NewWithHTTPClient.
func New(config *Config, logger *slog.Logger) (*Connector, error) {
authMode := "mtls"
if config != nil && config.AuthMode != "" {
authMode = config.AuthMode
}
switch authMode {
case "mtls":
// Build a fresh *http.Transport (do NOT clone http.DefaultTransport
// — mutation would leak across the package boundary). Set
// MinVersion: TLS 1.2 as a compatibility floor for on-prem EJBCA
// installs that may predate TLS 1.3.
if config == nil || config.ClientCertPath == "" || config.ClientKeyPath == "" {
return nil, fmt.Errorf("EJBCA mTLS requires client_cert_path and client_key_path")
}
cert, err := tls.LoadX509KeyPair(config.ClientCertPath, config.ClientKeyPath)
if err != nil {
return nil, fmt.Errorf("EJBCA mTLS cert load: %w", err)
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
},
}
return &Connector{
config: config,
logger: logger,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
},
}, nil
case "oauth2":
// OAuth2 path uses default transport; setAuthHeaders adds the
// Bearer header on every request.
return &Connector{
config: config,
logger: logger,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}, nil
default:
return nil, fmt.Errorf("EJBCA invalid auth_mode %q (must be \"mtls\" or \"oauth2\")", authMode)
}
}
// NewWithHTTPClient creates a new EJBCA connector with a custom HTTP client (for testing).
func NewWithHTTPClient(config *Config, logger *slog.Logger, client *http.Client) *Connector {
return &Connector{
config: config,
logger: logger,
httpClient: client,
}
}
// enrollResponse represents the EJBCA /certificate/pkcs10enroll response.
type enrollResponse struct {
Certificate string `json:"certificate"`
Chain []string `json:"certificate_chain"`
Serial string `json:"serial_number"`
}
// ValidateConfig checks that the EJBCA configuration is valid.
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 EJBCA config: %w", err)
}
if cfg.APIUrl == "" {
return fmt.Errorf("EJBCA api_url is required")
}
if cfg.CAName == "" {
return fmt.Errorf("EJBCA ca_name is required")
}
if cfg.AuthMode == "" {
cfg.AuthMode = "mtls"
}
switch cfg.AuthMode {
case "mtls":
if cfg.ClientCertPath == "" {
return fmt.Errorf("EJBCA client_cert_path is required for auth_mode=mtls")
}
if cfg.ClientKeyPath == "" {
return fmt.Errorf("EJBCA client_key_path is required for auth_mode=mtls")
}
case "oauth2":
if cfg.Token.IsEmpty() {
return fmt.Errorf("EJBCA token is required for auth_mode=oauth2")
}
default:
return fmt.Errorf("EJBCA auth_mode must be 'mtls' or 'oauth2', got %q", cfg.AuthMode)
}
c.logger.Info("EJBCA configuration validated",
"api_url", cfg.APIUrl,
"ca_name", cfg.CAName,
"auth_mode", cfg.AuthMode)
return nil
}
// IssueCertificate issues a new certificate via EJBCA.
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing EJBCA issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Parse CSR PEM to DER
csrBlock, _ := pem.Decode([]byte(request.CSRPEM))
if csrBlock == nil {
return nil, fmt.Errorf("failed to decode CSR PEM")
}
// Base64-encode CSR DER
csrBase64 := base64.StdEncoding.EncodeToString(csrBlock.Bytes)
enrollReq := map[string]interface{}{
"certificate_request": csrBase64,
"certificate_authority_name": c.config.CAName,
}
if c.config.CertProfile != "" {
enrollReq["certificate_profile_name"] = c.config.CertProfile
}
if c.config.EEProfile != "" {
enrollReq["end_entity_profile_name"] = c.config.EEProfile
}
body, err := json.Marshal(enrollReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal enroll request: %w", err)
}
enrollURL := fmt.Sprintf("%s/certificate/pkcs10enroll", c.config.APIUrl)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, enrollURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create enroll request: %w", err)
}
c.setAuthHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("EJBCA enroll request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read enroll response: %w", err)
}
// Check status code
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("EJBCA enroll returned status %d: %s", resp.StatusCode, string(respBody))
}
var enrollResp enrollResponse
if err := json.Unmarshal(respBody, &enrollResp); err != nil {
return nil, fmt.Errorf("failed to parse enroll response: %w", err)
}
// Base64-decode certificate DER
certDER, err := base64.StdEncoding.DecodeString(enrollResp.Certificate)
if err != nil {
return nil, fmt.Errorf("failed to decode certificate from response: %w", err)
}
// Parse certificate for metadata
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("failed to parse issued certificate: %w", err)
}
// Encode certificate to PEM
certPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
}))
// Build chain
chainPEM := ""
for _, chainB64 := range enrollResp.Chain {
chainDER, err := base64.StdEncoding.DecodeString(chainB64)
if err != nil {
c.logger.Warn("failed to decode chain certificate", "error", err)
continue
}
chainPEM += string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: chainDER,
}))
}
// Extract issuer DN from certificate
issuerDN := cert.Issuer.String()
// Store issuer DN in OrderID as "issuer_dn::serial"
orderID := fmt.Sprintf("%s::%s", issuerDN, cert.SerialNumber.String())
c.logger.Info("EJBCA certificate issued",
"serial", cert.SerialNumber.String(),
"issuer_dn", issuerDN)
return &issuer.IssuanceResult{
CertPEM: certPEM,
ChainPEM: chainPEM,
Serial: cert.SerialNumber.String(),
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
OrderID: orderID,
}, nil
}
// RenewCertificate renews a certificate by issuing a new one (EJBCA delegates renewal to issuance).
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing EJBCA 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 EJBCA.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.logger.Info("processing EJBCA revocation request", "serial", request.Serial)
// Map RFC 5280 reason string to numeric code
reasonCode := 0 // unspecified
if request.Reason != nil {
switch *request.Reason {
case "keyCompromise":
reasonCode = 1
case "caCompromise":
reasonCode = 2
case "affiliationChanged":
reasonCode = 3
case "superseded":
reasonCode = 4
case "cessationOfOperation":
reasonCode = 5
case "certificateHold":
reasonCode = 6
case "privilegeWithdrawn":
reasonCode = 9
}
}
revokeReq := map[string]interface{}{
"reason": reasonCode,
}
body, err := json.Marshal(revokeReq)
if err != nil {
return fmt.Errorf("failed to marshal revoke request: %w", err)
}
// Use the serial directly or extract from OrderID if present (as fallback)
serial := request.Serial
issuerDN := ""
// If we have time and access to issuer DN, we could parse it from OrderID
// For now, we attempt to use serial as-is, and fall back to issuer DN lookup if needed.
revokeURL := fmt.Sprintf("%s/certificate/%s/%s/revoke", c.config.APIUrl, issuerDN, serial)
if issuerDN == "" {
// If no issuer DN, just use serial alone (may fail if EJBCA requires issuer_dn)
revokeURL = fmt.Sprintf("%s/certificate/%s/revoke", c.config.APIUrl, 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)
}
c.setAuthHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("EJBCA revoke request failed: %w", err)
}
defer resp.Body.Close()
// EJBCA returns 204 No Content on successful revocation
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("EJBCA revoke returned status %d: %s", resp.StatusCode, string(respBody))
}
c.logger.Info("EJBCA certificate revoked", "serial", serial)
return nil
}
// GetOrderStatus retrieves the status of an EJBCA certificate order.
// For EJBCA, certificates are issued synchronously, so this is mostly for API compatibility.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
c.logger.Debug("checking EJBCA order status", "order_id", orderID)
// Parse orderID to extract issuer_dn and serial
parts := strings.Split(orderID, "::")
if len(parts) != 2 {
// Malformed OrderID
msg := fmt.Sprintf("malformed order ID: %s", orderID)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "failed",
Message: &msg,
UpdatedAt: time.Now(),
}, nil
}
issuerDN := parts[0]
serial := parts[1]
// Attempt to retrieve the certificate
certURL := fmt.Sprintf("%s/certificate/%s/%s", c.config.APIUrl, issuerDN, serial)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, certURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create cert get request: %w", err)
}
c.setAuthHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("EJBCA cert get request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read cert response: %w", err)
}
if resp.StatusCode != http.StatusOK {
msg := fmt.Sprintf("certificate not found or error: status %d", resp.StatusCode)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "pending",
Message: &msg,
UpdatedAt: time.Now(),
}, nil
}
var certResp enrollResponse
if err := json.Unmarshal(respBody, &certResp); err != nil {
return nil, fmt.Errorf("failed to parse cert response: %w", err)
}
// Base64-decode and parse certificate
certDER, err := base64.StdEncoding.DecodeString(certResp.Certificate)
if err != nil {
return nil, fmt.Errorf("failed to decode certificate: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
// Encode to PEM
certPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
}))
// Build chain
chainPEM := ""
for _, chainB64 := range certResp.Chain {
chainDER, err := base64.StdEncoding.DecodeString(chainB64)
if err != nil {
c.logger.Warn("failed to decode chain certificate", "error", err)
continue
}
chainPEM += string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: chainDER,
}))
}
now := time.Now()
return &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
CertPEM: &certPEM,
ChainPEM: &chainPEM,
Serial: &serial,
NotBefore: &cert.NotBefore,
NotAfter: &cert.NotAfter,
UpdatedAt: now,
}, nil
}
// GenerateCRL is not supported because EJBCA manages CRL distribution.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("EJBCA manages CRL distribution; use EJBCA's CRL endpoints")
}
// SignOCSPResponse is not supported because EJBCA manages OCSP.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("EJBCA manages OCSP; use EJBCA's OCSP responder")
}
// GetCACertPEM returns the CA certificate.
// EJBCA doesn't have a simple endpoint for this; return error.
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
return "", fmt.Errorf("EJBCA CA certificate retrieval not directly supported; use EJBCA console or API endpoints")
}
// GetRenewalInfo returns nil, nil as EJBCA does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// setAuthHeaders sets the appropriate authentication headers based on
// configured auth mode. For OAuth2, the Bearer token is fetched from
// the *secret.Ref via Use; the staging buffer is zeroed after the
// header value is constructed (audit fix #6 Phase 2).
func (c *Connector) setAuthHeaders(req *http.Request) {
if c.config.AuthMode == "oauth2" && c.config.Token != nil {
_ = c.config.Token.Use(func(buf []byte) error {
req.Header.Set("Authorization", "Bearer "+string(buf))
return nil
})
}
// mTLS is handled via http.Client with tls.Config
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)