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/certctl-io/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) } }