ejbca: port mTLS keypair to mtlscache (close Bundle M for the last issuer)

Closes Top-10 fix #1 of the 2026-05-03 issuer-coverage audit (see
cowork/issuer-coverage-audit-2026-05-03/RESULTS.md). Pre-fix,
ejbca.go::New called tls.LoadX509KeyPair once at construction and
configured the keypair into *http.Transport.TLSClientConfig with
no mtime watch. mTLS rotation required a server restart — quarterly
rotation per any reasonable security policy = quarterly deploy
outage.

Bundle M from the prior 2026-05-01 audit shipped the mtlscache
helper at internal/connector/issuer/mtlscache/cache.go and wired
it into Entrust + GlobalSign. EJBCA was missed in Bundle M's
scope. This commit ports the same helper onto EJBCA's
auth_mode=mtls path. The OAuth2 path is unchanged.

Implementation:
  - New imports internal/connector/issuer/mtlscache.
  - Connector struct gains an mtls *mtlscache.Cache field
    (mirroring Entrust + GlobalSign).
  - New()'s case 'mtls': replaces tls.LoadX509KeyPair + manual
    *http.Transport with mtlscache.New(certPath, keyPath,
    Options{HTTPTimeout: 30s}). Cache build happens at construction
    so misconfigured operators fail fast (matches pre-fix
    behaviour).
  - New helper getHTTPClient() returns the cached client; on the
    mTLS path it calls RefreshIfStale before returning so the
    next request uses the new keypair if disk has rotated. On
    OAuth2 / test paths (c.mtls == nil), returns c.httpClient
    as-is.
  - All 3 c.httpClient.Do call sites (IssueCertificate enroll,
    RevokeCertificate revoke, GetOrderStatus cert lookup) replaced
    with c.getHTTPClient() + client.Do.
  - crypto/tls import removed (no longer used at this layer).

Tests:
  - TestEJBCA_MTLSKeypairRotation_PicksUpNewCertWithoutRestart
    (new, ejbca_mtls_rotation_test.go): generates two CAs (caA,
    caB), signs leafA + leafB, spins up an httptest TLS server
    that trusts both CAs and records the issuer DN of every
    presented client cert, writes leafA, makes request 1, writes
    leafB + advances mtime by 2s, makes request 2. Asserts the
    server saw caA's DN on req 1 and caB's DN on req 2 — the
    cache picked up the rotation without ejbca.New re-running.
  - export_test.go: GetHTTPClientForTest helper exposes the
    private getHTTPClient so the rotation test drives the
    production code path.
  - All existing EJBCA tests still pass (TestNew_MTLSWiresClientCert,
    TestNew_MTLSCertLoadFailure, TestNew_OAuth2NoTransportTuning,
    TestNew_InvalidAuthMode).

Verified locally:
  - gofmt clean across the repo.
  - go vet ./... clean across the repo.
  - go test -race -count=1 -short ./internal/connector/issuer/ejbca/...
    ./internal/connector/issuer/mtlscache/... green.

Audit reference: cowork/issuer-coverage-audit-2026-05-03/
RESULTS.md Top-10 fix #1.
This commit is contained in:
shankar0123
2026-05-03 20:38:19 +00:00
parent 340df70abd
commit 88e7d0c17b
3 changed files with 337 additions and 27 deletions
+68 -27
View File
@@ -20,7 +20,6 @@ package ejbca
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
@@ -33,6 +32,7 @@ import (
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/connector/issuer/mtlscache"
"github.com/shankar0123/certctl/internal/secret"
)
@@ -84,16 +84,32 @@ 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, AND picks up rotated certs
// on the next call without a process restart. nil on the
// OAuth2 path (auth_mode=oauth2) and on the test path
// (NewWithHTTPClient). Closes Top-10 fix #1 of the
// 2026-05-03 issuer-coverage audit.
mtls *mtlscache.Cache
}
// 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).
// builds an mtlscache.Cache from config.ClientCertPath + config.ClientKeyPath.
// The cache parses the keypair once and configures
// *http.Transport.TLSClientConfig so the client presents the cert on every
// request. Subsequent calls go through getHTTPClient, which calls
// RefreshIfStale on the cache — operators rotating the cert+key on disk
// (e.g. quarterly per security policy) get the new keypair on the next
// API call without a server restart. Closes Top-10 fix #1 of the
// 2026-05-03 issuer-coverage audit.
//
// 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
@@ -110,30 +126,24 @@ func New(config *Config, logger *slog.Logger) (*Connector, error) {
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)
// Build the cache up-front so misconfigured operators fail fast
// at construction rather than discover a broken cert path on
// the first issuance call. mtlscache enforces TLS 1.2 floor
// (compat with on-prem EJBCA installs that predate TLS 1.3).
cache, err := mtlscache.New(config.ClientCertPath, config.ClientKeyPath, mtlscache.Options{
HTTPTimeout: 30 * time.Second,
})
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 nil, fmt.Errorf("EJBCA mTLS cache build: %w", err)
}
return &Connector{
config: config,
logger: logger,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
},
config: config,
logger: logger,
httpClient: cache.Client(),
mtls: cache,
}, nil
case "oauth2":
// OAuth2 path uses default transport; setAuthHeaders adds the
@@ -159,6 +169,25 @@ func NewWithHTTPClient(config *Config, logger *slog.Logger, client *http.Client)
}
}
// getHTTPClient returns the HTTP client to use for an EJBCA API call.
// On the mTLS path (auth_mode=mtls), it calls RefreshIfStale on the
// mtlscache so a rotated keypair on disk is picked up before the next
// request — operators rotating their EJBCA client cert quarterly no
// longer need a server restart. On the OAuth2 path (auth_mode=oauth2)
// or the test path (NewWithHTTPClient), it returns c.httpClient as-is
// because there's no keypair to refresh. Closes Top-10 fix #1 of the
// 2026-05-03 issuer-coverage audit. Mirrors the Entrust/GlobalSign
// pattern from Bundle M of the 2026-05-01 audit.
func (c *Connector) getHTTPClient() (*http.Client, error) {
if c.mtls == nil {
return c.httpClient, nil
}
if err := c.mtls.RefreshIfStale(); err != nil {
return nil, fmt.Errorf("EJBCA mTLS cache refresh: %w", err)
}
return c.mtls.Client(), nil
}
// enrollResponse represents the EJBCA /certificate/pkcs10enroll response.
type enrollResponse struct {
Certificate string `json:"certificate"`
@@ -250,7 +279,11 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc
c.setAuthHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
client, err := c.getHTTPClient()
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("EJBCA enroll request failed: %w", err)
}
@@ -392,7 +425,11 @@ func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.Revoca
c.setAuthHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
client, err := c.getHTTPClient()
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("EJBCA revoke request failed: %w", err)
}
@@ -438,7 +475,11 @@ func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer
c.setAuthHeaders(req)
resp, err := c.httpClient.Do(req)
client, err := c.getHTTPClient()
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("EJBCA cert get request failed: %w", err)
}