mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 20:59:03 +00:00
81f6321326
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.
261 lines
9.3 KiB
Go
261 lines
9.3 KiB
Go
package ejbca_test
|
|
|
|
// Top-10 fix #1 of the 2026-05-03 issuer-coverage audit. Pre-fix,
|
|
// ejbca.New called tls.LoadX509KeyPair once at construction; rotating
|
|
// the client cert+key on disk required a server restart to take
|
|
// effect. Post-fix, ejbca.New constructs an mtlscache.Cache and the
|
|
// hot-path getHTTPClient calls RefreshIfStale before every API
|
|
// request — operators rotating quarterly per security policy no
|
|
// longer pay the deploy outage.
|
|
//
|
|
// This test pins the rotation behaviour end-to-end: write certA,
|
|
// make one mTLS request, write certB at the same paths, advance
|
|
// mtime, make a second request, assert the leaf cert presented on
|
|
// the wire flipped from certA to certB.
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"io"
|
|
"log/slog"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/issuer/ejbca"
|
|
)
|
|
|
|
// TestEJBCA_MTLSKeypairRotation_PicksUpNewCertWithoutRestart verifies
|
|
// the mtlscache wiring catches a hot-rotated keypair. Sequence:
|
|
//
|
|
// 1. Generate caA + caB roots (different CAs so the server can tell
|
|
// which client cert was presented by inspecting the issuer DN).
|
|
// 2. Sign clientA against caA, clientB against caB.
|
|
// 3. Spin up an httptest TLS server that requires a client cert
|
|
// signed by caA OR caB (ClientCAs pool with both roots) and
|
|
// records which CA actually signed the presented client cert.
|
|
// 4. Write clientA's cert+key to {certPath, keyPath}.
|
|
// 5. Construct ejbca.Connector via production New (mTLS mode).
|
|
// 6. Make request #1 → server records "presented cert from caA".
|
|
// 7. Overwrite {certPath, keyPath} with clientB's cert+key. Advance
|
|
// mtime via os.Chtimes (ext4 mtime granularity is 1s; advancing
|
|
// by 2s defeats the no-op cheap path).
|
|
// 8. Make request #2 → server records "presented cert from caB".
|
|
// 9. Assert the two recorded issuers differ — the cache picked up
|
|
// the rotation without ejbca.New re-running.
|
|
func TestEJBCA_MTLSKeypairRotation_PicksUpNewCertWithoutRestart(t *testing.T) {
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
dir := t.TempDir()
|
|
certPath := filepath.Join(dir, "client.crt")
|
|
keyPath := filepath.Join(dir, "client.key")
|
|
|
|
caA, caACert, caAKey := mustCA(t, "EJBCA-RotationTest-CA-A")
|
|
caB, caBCert, caBKey := mustCA(t, "EJBCA-RotationTest-CA-B")
|
|
|
|
// Sign one leaf cert per CA; both have CN="ejbca-rotation-client"
|
|
// so the only distinguishing feature on the wire is the issuer DN.
|
|
leafA, leafAKey := mustLeafSignedBy(t, caACert, caAKey)
|
|
leafB, leafBKey := mustLeafSignedBy(t, caBCert, caBKey)
|
|
|
|
// httptest TLS server with a ClientCAs pool that trusts BOTH
|
|
// roots. The handler captures the issuer DN of the presented
|
|
// cert into a thread-safe slice the test inspects after the
|
|
// requests complete.
|
|
pool := x509.NewCertPool()
|
|
pool.AddCert(caACert)
|
|
pool.AddCert(caBCert)
|
|
var (
|
|
mu sync.Mutex
|
|
seenIssuerDNs []string
|
|
seenCommonNames []string
|
|
)
|
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
|
|
http.Error(w, "no client cert", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
leaf := r.TLS.PeerCertificates[0]
|
|
mu.Lock()
|
|
seenIssuerDNs = append(seenIssuerDNs, leaf.Issuer.String())
|
|
seenCommonNames = append(seenCommonNames, leaf.Subject.CommonName)
|
|
mu.Unlock()
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}))
|
|
srv.TLS = &tls.Config{
|
|
ClientAuth: tls.RequireAndVerifyClientCert,
|
|
ClientCAs: pool,
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
srv.StartTLS()
|
|
defer srv.Close()
|
|
|
|
// Step 4 — write clientA initially.
|
|
mustWriteKeypair(t, certPath, keyPath, leafA, leafAKey)
|
|
|
|
// Step 5 — construct via production New().
|
|
cfg := &ejbca.Config{
|
|
APIUrl: srv.URL,
|
|
AuthMode: "mtls",
|
|
ClientCertPath: certPath,
|
|
ClientKeyPath: keyPath,
|
|
CAName: "Management CA",
|
|
}
|
|
conn, err := ejbca.New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("ejbca.New: %v", err)
|
|
}
|
|
|
|
// To talk to httptest's self-signed server cert, mutate the cached
|
|
// transport's RootCAs to trust the test server. The Certificates
|
|
// field (the client cert) stays intact — that's the field we're
|
|
// proving rotates without a New() re-run.
|
|
httpClient := ejbca.HTTPClientForTest(conn)
|
|
tr := httpClient.Transport.(*http.Transport)
|
|
srvPool := x509.NewCertPool()
|
|
srvPool.AddCert(srv.Certificate())
|
|
tr.TLSClientConfig.RootCAs = srvPool
|
|
|
|
// Step 6 — first request via the production code path.
|
|
clientA, err := ejbca.GetHTTPClientForTest(conn)
|
|
if err != nil {
|
|
t.Fatalf("getHTTPClient (req 1): %v", err)
|
|
}
|
|
// Re-apply the server-cert trust pool to the freshly-returned
|
|
// client's transport (cache rebuild would have wiped any prior
|
|
// mutation, but on the cheap path this is a no-op since the same
|
|
// transport pointer is returned).
|
|
if trA, ok := clientA.Transport.(*http.Transport); ok && trA.TLSClientConfig.RootCAs == nil {
|
|
trA.TLSClientConfig.RootCAs = srvPool
|
|
}
|
|
if _, err := clientA.Get(srv.URL); err != nil {
|
|
t.Fatalf("request 1: %v", err)
|
|
}
|
|
|
|
// Step 7 — overwrite the keypair with leafB, advance mtime.
|
|
mustWriteKeypair(t, certPath, keyPath, leafB, leafBKey)
|
|
future := time.Now().Add(2 * time.Second)
|
|
if err := os.Chtimes(certPath, future, future); err != nil {
|
|
t.Fatalf("chtimes cert: %v", err)
|
|
}
|
|
if err := os.Chtimes(keyPath, future, future); err != nil {
|
|
t.Fatalf("chtimes key: %v", err)
|
|
}
|
|
|
|
// Step 8 — second request. RefreshIfStale should rebuild the
|
|
// transport with leafB's keypair. The rebuild creates a new
|
|
// transport whose RootCAs is unset, so re-apply.
|
|
clientB, err := ejbca.GetHTTPClientForTest(conn)
|
|
if err != nil {
|
|
t.Fatalf("getHTTPClient (req 2): %v", err)
|
|
}
|
|
if trB, ok := clientB.Transport.(*http.Transport); ok && trB.TLSClientConfig.RootCAs == nil {
|
|
trB.TLSClientConfig.RootCAs = srvPool
|
|
}
|
|
if _, err := clientB.Get(srv.URL); err != nil {
|
|
t.Fatalf("request 2: %v", err)
|
|
}
|
|
|
|
// Step 9 — assert.
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(seenIssuerDNs) != 2 {
|
|
t.Fatalf("expected exactly 2 server-side observations, got %d: %v", len(seenIssuerDNs), seenIssuerDNs)
|
|
}
|
|
if seenIssuerDNs[0] == seenIssuerDNs[1] {
|
|
t.Fatalf("issuer DN unchanged across rotation: req1=%q req2=%q — cache did not pick up the new keypair",
|
|
seenIssuerDNs[0], seenIssuerDNs[1])
|
|
}
|
|
if want := caA.String(); seenIssuerDNs[0] != want {
|
|
t.Errorf("req 1 issuer = %q, want %q (caA)", seenIssuerDNs[0], want)
|
|
}
|
|
if want := caB.String(); seenIssuerDNs[1] != want {
|
|
t.Errorf("req 2 issuer = %q, want %q (caB)", seenIssuerDNs[1], want)
|
|
}
|
|
}
|
|
|
|
// --- test helpers ------------------------------------------------------
|
|
|
|
// mustCA generates a self-signed CA cert + key. Returns the parsed CA
|
|
// pkix.Name, the parsed cert, and the private key.
|
|
func mustCA(t *testing.T, commonName string) (pkix.Name, *x509.Certificate, *ecdsa.PrivateKey) {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("CA key gen: %v", err)
|
|
}
|
|
template := x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: commonName},
|
|
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,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatalf("CA cert: %v", err)
|
|
}
|
|
cert, err := x509.ParseCertificate(der)
|
|
if err != nil {
|
|
t.Fatalf("parse CA: %v", err)
|
|
}
|
|
return template.Subject, cert, key
|
|
}
|
|
|
|
// mustLeafSignedBy generates a leaf cert+key, signed by the supplied
|
|
// CA. Both rotation halves share the same Subject CN so the
|
|
// distinguishing field on the wire is exclusively the Issuer DN.
|
|
func mustLeafSignedBy(t *testing.T, caCert *x509.Certificate, caKey *ecdsa.PrivateKey) (*x509.Certificate, *ecdsa.PrivateKey) {
|
|
t.Helper()
|
|
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("leaf key gen: %v", err)
|
|
}
|
|
leafTemplate := x509.Certificate{
|
|
SerialNumber: big.NewInt(2),
|
|
Subject: pkix.Name{CommonName: "ejbca-rotation-client"},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, &leafTemplate, caCert, &leafKey.PublicKey, caKey)
|
|
if err != nil {
|
|
t.Fatalf("leaf cert: %v", err)
|
|
}
|
|
leaf, err := x509.ParseCertificate(der)
|
|
if err != nil {
|
|
t.Fatalf("parse leaf: %v", err)
|
|
}
|
|
return leaf, leafKey
|
|
}
|
|
|
|
func mustWriteKeypair(t *testing.T, certPath, keyPath string, leaf *x509.Certificate, leafKey *ecdsa.PrivateKey) {
|
|
t.Helper()
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leaf.Raw})
|
|
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
|
|
t.Fatalf("write cert: %v", err)
|
|
}
|
|
keyDER, err := x509.MarshalECPrivateKey(leafKey)
|
|
if err != nil {
|
|
t.Fatalf("marshal key: %v", err)
|
|
}
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
|
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
|
t.Fatalf("write key: %v", err)
|
|
}
|
|
}
|