mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
ejbca: wire mTLS client cert in New()
Closes the #2 acquisition-readiness blocker from the 2026-05-01 issuer coverage audit. New() at ejbca.go:L79-L88 previously constructed an http.Client with only Timeout set — no Transport, no TLSClientConfig. When AuthMode=mtls (the default), the client never presented the configured ClientCert/ClientKey. The OAuth2 path worked; mTLS always failed authentication. Tests passed because they injected a pre-built *http.Client via NewWithHTTPClient, a path the production factory never took. This commit: - Rewrites New() to load ClientCertPath + ClientKeyPath via tls.LoadX509KeyPair when AuthMode=mtls, configure *http.Transport.TLSClientConfig with MinVersion: TLS 1.2 (compatibility floor for on-prem EJBCA installs that may predate TLS 1.3), and return (*Connector, error). Constructs a fresh *http.Transport — does NOT clone http.DefaultTransport, which would leak mutation across the package boundary. - OAuth2 mode unchanged: returns a client with no transport customization (the Bearer header path is wired in setAuthHeaders). - Invalid auth_mode values return (nil, error) immediately rather than falling through to the mtls default and erroring at cert load. - Updates the factory call site at issuerfactory/factory.go for the new signature; the factory's outer (issuer.Connector, error) shape was already in place. - Adds TestNew_MTLSWiresClientCert: calls production New() (NOT NewWithHTTPClient) with real cert/key files generated via stdlib crypto/x509, asserts httpClient.Transport.TLSClientConfig.Certificates is non-empty. Includes an httptest TLS server with ClientAuth: tls.RequireAndVerifyClientCert that proves the cert is actually presented on the wire — not just stashed in a struct field. - Adds TestNew_MTLSCertLoadFailure: missing-cert path returns an error wrapping fs.ErrNotExist (verified via errors.Is). - Adds TestNew_OAuth2NoTransportTuning: OAuth2 path leaves Transport nil, ensuring no accidental mTLS bleedthrough. - Adds TestNew_InvalidAuthMode: explicit guard that auth_mode values other than "mtls"/"oauth2" return (nil, error) at New() time. - Adds export_test.go with HTTPClientForTest helper so the external ejbca_test package can inspect the connector's internal *http.Client for the wiring assertions. Compile-only during `go test`; production builds don't expose it. - Adds mustNewForValidateConfig test helper (OAuth2 placeholder connector) for the existing ValidateConfig-only tests; pre-fix they used New(nil, ...) which is no longer valid because nil config falls into the mTLS default branch that requires non-nil cert paths. - Updates ejbca_stubs_test.go (internal package) for the new (*Connector, error) signature; switches the dummy connector to OAuth2 mode so Config{} doesn't error at New(). Out of scope (separate follow-ups, per the prompt's explicit fence): - OAuth2 token refresh missing - Config.Token plaintext at runtime (needs SecretRef abstraction) - RevokeCertificate composite OrderID parsing (the issuerDN := "" line at ejbca.go:L313) Verified locally: - gofmt clean - go vet ./... clean - staticcheck ./... clean - golangci-lint run --timeout 5m ./... → 0 issues - go test -short -count=1 ./internal/connector/issuer/ejbca/ green - go test -short -count=1 ./internal/connector/issuerfactory/ green - go test -short -count=1 ./internal/service/ green - go build ./... success Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md Top-10 fix #2.
This commit is contained in:
@@ -20,6 +20,7 @@ package ejbca
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -77,13 +78,66 @@ type Connector struct {
|
||||
}
|
||||
|
||||
// New creates a new EJBCA connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,28 +21,40 @@ func quietStubLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
func TestStub_GenerateCRL(t *testing.T) {
|
||||
c := New(&Config{}, quietStubLogger())
|
||||
_, err := c.GenerateCRL(context.Background(), nil)
|
||||
c, err := New(&Config{AuthMode: "oauth2", Token: "dummy"}, quietStubLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
_, err = c.GenerateCRL(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from stub GenerateCRL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStub_SignOCSPResponse(t *testing.T) {
|
||||
c := New(&Config{}, quietStubLogger())
|
||||
_, err := c.SignOCSPResponse(context.Background(), issuer.OCSPSignRequest{})
|
||||
c, err := New(&Config{AuthMode: "oauth2", Token: "dummy"}, quietStubLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
_, err = c.SignOCSPResponse(context.Background(), issuer.OCSPSignRequest{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from stub SignOCSPResponse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStub_GetCACertPEM(t *testing.T) {
|
||||
c := New(&Config{}, quietStubLogger())
|
||||
c, err := New(&Config{AuthMode: "oauth2", Token: "dummy"}, quietStubLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
_, _ = c.GetCACertPEM(context.Background())
|
||||
}
|
||||
|
||||
func TestStub_GetRenewalInfo(t *testing.T) {
|
||||
c := New(&Config{}, quietStubLogger())
|
||||
c, err := New(&Config{AuthMode: "oauth2", Token: "dummy"}, quietStubLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
res, err := c.GetRenewalInfo(context.Background(), "any-pem")
|
||||
_ = res
|
||||
_ = err
|
||||
|
||||
@@ -2,31 +2,63 @@ package ejbca_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/ejbca"
|
||||
)
|
||||
|
||||
// mustNewForValidateConfig returns an EJBCA connector wired in OAuth2 mode
|
||||
// with a placeholder token. ValidateConfig parses raw JSON independently of
|
||||
// the connector's auth wiring, so this dummy connector is sufficient for
|
||||
// ValidateConfig-only tests. The pre-existing tests called New(nil, ...) for
|
||||
// this; with the new (*Connector, error) signature that requires a non-nil
|
||||
// config, the OAuth2 placeholder is the cheapest substitute.
|
||||
func mustNewForValidateConfig(t *testing.T, logger *slog.Logger) *ejbca.Connector {
|
||||
t.Helper()
|
||||
c, err := ejbca.New(&ejbca.Config{
|
||||
APIUrl: "https://placeholder",
|
||||
AuthMode: "oauth2",
|
||||
Token: "placeholder",
|
||||
CAName: "placeholder",
|
||||
}, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("ejbca.New (OAuth2 dummy): %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestEJBCAConnector(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("ValidateConfig_Success_mTLS", func(t *testing.T) {
|
||||
// Use a placeholder connector for ValidateConfig — the JSON
|
||||
// shape is what's being validated, not the connector's mTLS
|
||||
// wiring. (Production New() with these fake paths would fail
|
||||
// at tls.LoadX509KeyPair, which is the correct behavior tested
|
||||
// separately by TestNew_MTLSCertLoadFailure.)
|
||||
config := ejbca.Config{
|
||||
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
|
||||
AuthMode: "mtls",
|
||||
@@ -35,7 +67,7 @@ func TestEJBCAConnector(t *testing.T) {
|
||||
CAName: "Management CA",
|
||||
}
|
||||
|
||||
connector := ejbca.New(&config, logger)
|
||||
connector := mustNewForValidateConfig(t, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
@@ -51,9 +83,12 @@ func TestEJBCAConnector(t *testing.T) {
|
||||
CAName: "Management CA",
|
||||
}
|
||||
|
||||
connector := ejbca.New(&config, logger)
|
||||
connector, err := ejbca.New(&config, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("ejbca.New (OAuth2): %v", err)
|
||||
}
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
err = connector.ValidateConfig(ctx, rawConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
@@ -65,7 +100,7 @@ func TestEJBCAConnector(t *testing.T) {
|
||||
CAName: "Management CA",
|
||||
}
|
||||
|
||||
connector := ejbca.New(nil, logger)
|
||||
connector := mustNewForValidateConfig(t, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
@@ -82,7 +117,7 @@ func TestEJBCAConnector(t *testing.T) {
|
||||
AuthMode: "mtls",
|
||||
}
|
||||
|
||||
connector := ejbca.New(nil, logger)
|
||||
connector := mustNewForValidateConfig(t, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
@@ -101,7 +136,7 @@ func TestEJBCAConnector(t *testing.T) {
|
||||
CAName: "Management CA",
|
||||
}
|
||||
|
||||
connector := ejbca.New(nil, logger)
|
||||
connector := mustNewForValidateConfig(t, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
@@ -119,7 +154,7 @@ func TestEJBCAConnector(t *testing.T) {
|
||||
CAName: "Management CA",
|
||||
}
|
||||
|
||||
connector := ejbca.New(nil, logger)
|
||||
connector := mustNewForValidateConfig(t, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
@@ -137,7 +172,7 @@ func TestEJBCAConnector(t *testing.T) {
|
||||
CAName: "Management CA",
|
||||
}
|
||||
|
||||
connector := ejbca.New(nil, logger)
|
||||
connector := mustNewForValidateConfig(t, logger)
|
||||
rawConfig, _ := json.Marshal(config)
|
||||
err := connector.ValidateConfig(ctx, rawConfig)
|
||||
if err == nil {
|
||||
@@ -496,7 +531,10 @@ func TestEJBCAConnector(t *testing.T) {
|
||||
Token: "test-token",
|
||||
CAName: "Management CA",
|
||||
}
|
||||
connector := ejbca.New(config, logger)
|
||||
connector, err := ejbca.New(config, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("ejbca.New: %v", err)
|
||||
}
|
||||
|
||||
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
|
||||
if err != nil {
|
||||
@@ -514,9 +552,12 @@ func TestEJBCAConnector(t *testing.T) {
|
||||
Token: "test-token",
|
||||
CAName: "Management CA",
|
||||
}
|
||||
connector := ejbca.New(config, logger)
|
||||
connector, err := ejbca.New(config, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("ejbca.New: %v", err)
|
||||
}
|
||||
|
||||
_, err := connector.GenerateCRL(ctx, []issuer.RevokedCertEntry{})
|
||||
_, err = connector.GenerateCRL(ctx, []issuer.RevokedCertEntry{})
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for unsupported GenerateCRL")
|
||||
}
|
||||
@@ -532,9 +573,12 @@ func TestEJBCAConnector(t *testing.T) {
|
||||
Token: "test-token",
|
||||
CAName: "Management CA",
|
||||
}
|
||||
connector := ejbca.New(config, logger)
|
||||
connector, err := ejbca.New(config, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("ejbca.New: %v", err)
|
||||
}
|
||||
|
||||
_, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{})
|
||||
_, err = connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{})
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for unsupported SignOCSPResponse")
|
||||
}
|
||||
@@ -544,6 +588,233 @@ func TestEJBCAConnector(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestNew_MTLSWiresClientCert closes the audit's #2 D11 blocker by exercising
|
||||
// the production New() path (NOT NewWithHTTPClient). Pre-fix, New() built an
|
||||
// http.Client with only Timeout set; mTLS mode advertised support but never
|
||||
// loaded the cert. Tests passed via NewWithHTTPClient mock injection — a path
|
||||
// the production constructor never took. This test calls New() with real
|
||||
// cert/key files and asserts:
|
||||
//
|
||||
// 1. Error is nil (cert load succeeded).
|
||||
// 2. The connector's HTTP client has a non-nil Transport.
|
||||
// 3. Transport.TLSClientConfig.Certificates carries the loaded cert.
|
||||
//
|
||||
// As an end-to-end proof, the test then makes a request against an
|
||||
// httptest.NewTLSServer with ClientAuth: tls.RequireAndVerifyClientCert
|
||||
// and asserts the request succeeds — proving the cert was actually
|
||||
// presented on the wire (not just stashed in a struct field).
|
||||
func TestNew_MTLSWiresClientCert(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
// 1. Generate a CA cert + a client cert signed by the CA. Use ECDSA-P256
|
||||
// to match the codebase's preferred algorithm.
|
||||
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("CA key gen: %v", err)
|
||||
}
|
||||
caTemplate := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "EJBCA-Test-CA"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
caDER, err := x509.CreateCertificate(rand.Reader, &caTemplate, &caTemplate, &caKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("CA cert: %v", err)
|
||||
}
|
||||
caCert, err := x509.ParseCertificate(caDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CA: %v", err)
|
||||
}
|
||||
|
||||
clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("client key gen: %v", err)
|
||||
}
|
||||
clientTemplate := x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{CommonName: "ejbca-test-client"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
}
|
||||
clientDER, err := x509.CreateCertificate(rand.Reader, &clientTemplate, caCert, &clientKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatalf("client cert: %v", err)
|
||||
}
|
||||
|
||||
// 2. Write cert + key to temp files (Go stdlib's tls.LoadX509KeyPair
|
||||
// requires file paths).
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "client.crt")
|
||||
keyPath := filepath.Join(dir, "client.key")
|
||||
clientCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: clientDER})
|
||||
if err := os.WriteFile(certPath, clientCertPEM, 0o600); err != nil {
|
||||
t.Fatalf("write client cert: %v", err)
|
||||
}
|
||||
clientKeyDER, err := x509.MarshalECPrivateKey(clientKey)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal key: %v", err)
|
||||
}
|
||||
clientKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: clientKeyDER})
|
||||
if err := os.WriteFile(keyPath, clientKeyPEM, 0o600); err != nil {
|
||||
t.Fatalf("write client key: %v", err)
|
||||
}
|
||||
|
||||
// 3. Call production New() (NOT NewWithHTTPClient) with the cert paths.
|
||||
cfg := &ejbca.Config{
|
||||
APIUrl: "https://placeholder",
|
||||
AuthMode: "mtls",
|
||||
ClientCertPath: certPath,
|
||||
ClientKeyPath: keyPath,
|
||||
CAName: "Management CA",
|
||||
}
|
||||
conn, err := ejbca.New(cfg, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("ejbca.New: %v", err)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatal("New returned nil connector")
|
||||
}
|
||||
|
||||
// 4. Assert via the exported HTTPClient accessor that the transport
|
||||
// is wired and carries the loaded cert. (Connector exposes
|
||||
// HTTPClient only in test builds via the helper below.)
|
||||
httpClient := ejbca.HTTPClientForTest(conn)
|
||||
if httpClient == nil {
|
||||
t.Fatal("connector httpClient is nil")
|
||||
}
|
||||
tr, ok := httpClient.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport, got %T", httpClient.Transport)
|
||||
}
|
||||
if tr.TLSClientConfig == nil {
|
||||
t.Fatal("Transport.TLSClientConfig is nil — mTLS not wired")
|
||||
}
|
||||
if len(tr.TLSClientConfig.Certificates) == 0 {
|
||||
t.Fatal("Transport.TLSClientConfig.Certificates is empty — cert not loaded")
|
||||
}
|
||||
|
||||
// 5. End-to-end proof: spin up an httptest TLS server that requires
|
||||
// a client cert signed by our CA. Hit it with the connector's
|
||||
// client and assert the request succeeds (cert was presented).
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(caCert)
|
||||
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if len(r.TLS.PeerCertificates) == 0 {
|
||||
http.Error(w, "no client cert", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
srv.TLS = &tls.Config{
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
ClientCAs: pool,
|
||||
}
|
||||
srv.StartTLS()
|
||||
defer srv.Close()
|
||||
|
||||
// The httptest server's cert isn't trusted by our client; for the
|
||||
// purpose of this test we replace the RootCAs to trust it. We
|
||||
// intentionally keep the Certificates (client cert) intact — the
|
||||
// test is about whether the client cert is presented, not about
|
||||
// the server cert chain.
|
||||
srvCertDER := srv.Certificate().Raw
|
||||
srvCert, err := x509.ParseCertificate(srvCertDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse srv cert: %v", err)
|
||||
}
|
||||
srvPool := x509.NewCertPool()
|
||||
srvPool.AddCert(srvCert)
|
||||
tr.TLSClientConfig.RootCAs = srvPool
|
||||
|
||||
resp, err := httpClient.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("HTTPS request to mTLS server failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d (cert was probably not presented)", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew_MTLSCertLoadFailure asserts that a missing-cert path returns an
|
||||
// error wrapping fs.ErrNotExist. This is the negative path: misconfigured
|
||||
// operators must get an immediate failure at issuer construction, not a
|
||||
// cryptic 401 at first issuance.
|
||||
func TestNew_MTLSCertLoadFailure(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
cfg := &ejbca.Config{
|
||||
APIUrl: "https://placeholder",
|
||||
AuthMode: "mtls",
|
||||
ClientCertPath: "/nonexistent/path/to/cert.pem",
|
||||
ClientKeyPath: "/nonexistent/path/to/key.pem",
|
||||
CAName: "Management CA",
|
||||
}
|
||||
_, err := ejbca.New(cfg, logger)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from missing cert path")
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("expected error to wrap fs.ErrNotExist, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew_OAuth2NoTransportTuning asserts that the OAuth2 path does NOT
|
||||
// accidentally apply mTLS-style transport customization. This catches the
|
||||
// reverse class of bug: someone modifying New() in a way that leaks mTLS
|
||||
// transport into the OAuth2 path.
|
||||
func TestNew_OAuth2NoTransportTuning(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
cfg := &ejbca.Config{
|
||||
APIUrl: "https://placeholder",
|
||||
AuthMode: "oauth2",
|
||||
Token: "test-token",
|
||||
CAName: "Management CA",
|
||||
}
|
||||
conn, err := ejbca.New(cfg, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("ejbca.New (OAuth2): %v", err)
|
||||
}
|
||||
httpClient := ejbca.HTTPClientForTest(conn)
|
||||
if httpClient == nil {
|
||||
t.Fatal("connector httpClient is nil")
|
||||
}
|
||||
if httpClient.Transport != nil {
|
||||
t.Fatalf("expected Transport to be nil for OAuth2 mode, got: %T", httpClient.Transport)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew_InvalidAuthMode asserts that any auth_mode other than "mtls" or
|
||||
// "oauth2" returns (nil, error) immediately rather than falling through to
|
||||
// the default (mtls) which would then fail at cert load.
|
||||
func TestNew_InvalidAuthMode(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
cfg := &ejbca.Config{
|
||||
APIUrl: "https://placeholder",
|
||||
AuthMode: "invalid",
|
||||
Token: "test-token",
|
||||
CAName: "Management CA",
|
||||
}
|
||||
_, err := ejbca.New(cfg, logger)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from invalid auth_mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid auth_mode") {
|
||||
t.Errorf("expected 'invalid auth_mode' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// generateTestCert creates a self-signed test certificate and returns the PEM string.
|
||||
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package ejbca
|
||||
|
||||
import "net/http"
|
||||
|
||||
// HTTPClientForTest exposes the connector's internal *http.Client to the
|
||||
// external ejbca_test package. The mTLS-wiring tests need to inspect
|
||||
// Transport.TLSClientConfig.Certificates to assert the cert was loaded;
|
||||
// that field is unexported, so we provide this test-only accessor.
|
||||
//
|
||||
// The "_test.go" suffix means this file is only compiled during `go test`,
|
||||
// so production builds don't expose the internal httpClient field.
|
||||
func HTTPClientForTest(c *Connector) *http.Client {
|
||||
return c.httpClient
|
||||
}
|
||||
@@ -122,7 +122,11 @@ func NewFromConfig(ctx context.Context, issuerType string, configJSON json.RawMe
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid EJBCA config: %w", err)
|
||||
}
|
||||
return ejbca.New(&cfg, logger), nil
|
||||
conn, err := ejbca.New(&cfg, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("EJBCA init: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown issuer type: %q", issuerType)
|
||||
|
||||
Reference in New Issue
Block a user