From 399dae9c8757a2f85b0958a861ef41ea6a240155 Mon Sep 17 00:00:00 2001 From: aswin Date: Tue, 10 Nov 2026 11:34:43 -0800 Subject: [PATCH] =?UTF-8?q?eipc:=20TLS=20honesty=20=E2=80=94=20labels=20ma?= =?UTF-8?q?tch=20negotiated=20TLS,=20verified=20client=20dials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TLSEnabled now means TLS is actually negotiated: the server log label comes from the transport, a half-configured cert/key pair fails loudly instead of listening plaintext, and the client verifies the server against a CA bundle instead of InsecureSkipVerify. --- cmd/eipc-client/main.go | 12 +- cmd/eipc-server/main.go | 2 +- config/config.go | 10 +- config/config_test.go | 12 +- docs/api-reference.md | 2 +- docs/security-model.md | 1 + transport/tcp/tcp.go | 53 ++++++- transport/tcp/tcp_tls_test.go | 230 +++++++++++++++++++++++++++++-- transport/tcp/tls_config.go | 67 ++++++++- transport/tcp/tls_config_test.go | 75 ++++++++++ 10 files changed, 434 insertions(+), 30 deletions(-) diff --git a/cmd/eipc-client/main.go b/cmd/eipc-client/main.go index 84a6066..88bda9f 100644 --- a/cmd/eipc-client/main.go +++ b/cmd/eipc-client/main.go @@ -36,10 +36,18 @@ func main() { log.Printf("EIPC client connecting to %s as %s", addr, serviceID) tcpTransport := tcp.New() - if err := tcpTransport.SetupTLSFromEnv(); err != nil { + if err := tcpTransport.SetupClientTLSFromEnv(); err != nil { log.Fatalf("TLS setup: %v", err) } + // The mode is read off the transport, not the environment: with no + // CA configured the dial is plaintext, and the log must say so. + tlsMode := "plaintext" + if tcpTransport.TLSEnabled() { + tlsMode = "TLS (verified)" + } + log.Printf("transport: %s", tlsMode) + conn, err := tcpTransport.Dial(addr) if err != nil { log.Fatalf("dial: %v", err) @@ -224,7 +232,7 @@ func main() { fmt.Println() fmt.Println("=== EIPC Demo Complete (Hardened) ===") fmt.Println("End-to-end flow demonstrated:") - fmt.Println(" 1. Client connected to server (TLS if configured)") + fmt.Printf(" 1. Client connected to server (%s)\n", tlsMode) fmt.Println(" 2. Server sent challenge nonce") fmt.Println(" 3. Client proved secret via HMAC-SHA256 response") fmt.Println(" 4. Server validated & issued session token") diff --git a/cmd/eipc-server/main.go b/cmd/eipc-server/main.go index 707e0ce..73edddf 100644 --- a/cmd/eipc-server/main.go +++ b/cmd/eipc-server/main.go @@ -295,7 +295,7 @@ func main() { defer tcpTransport.Close() tlsMode := "plaintext" - if config.TLSEnabled() { + if tcpTransport.TLSEnabled() { tlsMode = "TLS" } log.Printf("EIPC server listening on %s [%s] (max_conns=%d, session_ttl=%s)", diff --git a/config/config.go b/config/config.go index dc6f41e..06b865d 100644 --- a/config/config.go +++ b/config/config.go @@ -61,7 +61,13 @@ func LoadListenAddr() string { return "127.0.0.1:9090" } -// TLSEnabled returns true if TLS cert files are configured or auto-cert is enabled. +// TLSEnabled returns true if the TLS environment will actually engage the +// server transport: a cert/key pair, or auto-cert. A lone EIPC_TLS_CERT +// without EIPC_TLS_KEY does not engage TLS (SetupTLSFromEnv rejects a +// half-configured pair), so it must not report enabled here either — +// reporting it enabled is what let the server log [TLS] on a plaintext +// socket. func TLSEnabled() bool { - return os.Getenv("EIPC_TLS_CERT") != "" || os.Getenv("EIPC_TLS_AUTO_CERT") == "true" + cert, key := os.Getenv("EIPC_TLS_CERT"), os.Getenv("EIPC_TLS_KEY") + return (cert != "" && key != "") || os.Getenv("EIPC_TLS_AUTO_CERT") == "true" } diff --git a/config/config_test.go b/config/config_test.go index 1890f1b..6576f2e 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -139,15 +139,25 @@ func TestLoadListenAddr_Custom(t *testing.T) { func TestTLSEnabled(t *testing.T) { os.Unsetenv("EIPC_TLS_CERT") + os.Unsetenv("EIPC_TLS_KEY") os.Unsetenv("EIPC_TLS_AUTO_CERT") if TLSEnabled() { t.Error("TLS should be disabled by default") } + // A lone cert without its key does not engage TLS: SetupTLSFromEnv + // rejects the half-configured pair, so the predicate must not + // report enabled either — that was the [TLS]-on-plaintext lie. os.Setenv("EIPC_TLS_CERT", "/path/to/cert.pem") defer os.Unsetenv("EIPC_TLS_CERT") + if TLSEnabled() { + t.Error("TLS should be disabled with EIPC_TLS_CERT set but no EIPC_TLS_KEY") + } + + os.Setenv("EIPC_TLS_KEY", "/path/to/key.pem") + defer os.Unsetenv("EIPC_TLS_KEY") if !TLSEnabled() { - t.Error("TLS should be enabled with EIPC_TLS_CERT set") + t.Error("TLS should be enabled with EIPC_TLS_CERT and EIPC_TLS_KEY set") } } diff --git a/docs/api-reference.md b/docs/api-reference.md index 82e4b89..52b1ab9 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -516,4 +516,4 @@ Environment variable configuration loader. | `LoadSessionTTL()` | `EIPC_SESSION_TTL` | `1h` | Session lifetime | | `LoadMaxConnections()` | `EIPC_MAX_CONNECTIONS` | `64` | Max concurrent connections | | `LoadListenAddr()` | `EIPC_LISTEN_ADDR` | `127.0.0.1:9090` | Server listen address | -| `TLSEnabled()` | `EIPC_TLS_CERT` or `EIPC_TLS_AUTO_CERT` | `false` | TLS mode check | +| `TLSEnabled()` | `EIPC_TLS_CERT` + `EIPC_TLS_KEY`, or `EIPC_TLS_AUTO_CERT` | `false` | TLS mode check (true only when TLS will actually engage) | diff --git a/docs/security-model.md b/docs/security-model.md index e91762c..d8b3d4b 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -343,4 +343,5 @@ Events captured: | `EIPC_MAX_CONNECTIONS` | Max concurrent connections | `64` | | `EIPC_TLS_CERT` | TLS certificate path | — | | `EIPC_TLS_KEY` | TLS private key path | — | +| `EIPC_TLS_CA` | CA bundle the client trusts to verify the server (RootCAs); also the mTLS CA on the server | — | | `EIPC_TLS_AUTO_CERT` | Auto-generate self-signed cert | `false` | diff --git a/transport/tcp/tcp.go b/transport/tcp/tcp.go index 6d8e6cc..9bb290f 100644 --- a/transport/tcp/tcp.go +++ b/transport/tcp/tcp.go @@ -42,7 +42,17 @@ func (t *Transport) WithTLSConfig(cfg *tls.Config) { t.tlsConfig = cfg } -// Listen starts a TCP listener on the given address (e.g. "127.0.0.1:9090"). +// TLSEnabled reports whether this transport will actually negotiate TLS: +// true only when a TLS config has been set (WithTLS, WithTLSConfig, +// SetupTLSFromEnv, SetupClientTLSFromEnv). This is the ground truth for +// log labels — it must be used instead of sniffing the TLS environment +// variables, which can claim TLS while the socket stays plaintext. +func (t *Transport) TLSEnabled() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.tlsConfig != nil +} + // If TLS is configured, wraps listener with tls.NewListener. func (t *Transport) Listen(address string) error { t.mu.Lock() @@ -129,14 +139,23 @@ func (t *Transport) Addr() string { return "" } -// SetupTLSFromEnv configures TLS from environment variables. +// SetupTLSFromEnv configures server-side TLS from environment variables. // EIPC_TLS_CERT, EIPC_TLS_KEY for cert/key files. // EIPC_TLS_CA for mTLS CA file. // EIPC_TLS_AUTO_CERT=true for auto-generated self-signed cert. +// +// A cert path without its key path (or vice versa) is an error, not a +// silent fall back to plaintext: a half-configured server must fail +// loudly at startup rather than listen unencrypted while the operator +// believes TLS is on. func (t *Transport) SetupTLSFromEnv() error { certFile := os.Getenv("EIPC_TLS_CERT") keyFile := os.Getenv("EIPC_TLS_KEY") + if (certFile == "") != (keyFile == "") { + return fmt.Errorf("EIPC_TLS_CERT and EIPC_TLS_KEY must be set together") + } + if certFile != "" && keyFile != "" { return t.WithTLS(certFile, keyFile, os.Getenv("EIPC_TLS_CA")) } @@ -152,3 +171,33 @@ func (t *Transport) SetupTLSFromEnv() error { return nil } + +// SetupClientTLSFromEnv configures client-side TLS from environment +// variables, for dialing a server whose certificate must be verified. +// EIPC_TLS_CA is the CA bundle the server is verified against (RootCAs); +// without it the client verifies against the system roots. +// EIPC_TLS_CERT/EIPC_TLS_KEY optionally present a client certificate +// for mTLS; they must be set together. +// +// This is the client counterpart of SetupTLSFromEnv and the only env +// setup a dialing client should use: the server setup builds a config +// with Certificates/ClientCAs and never RootCAs, so a client built from +// it cannot verify any server certificate and the only working dial was +// InsecureSkipVerify. EIPC_TLS_AUTO_CERT is ignored here — minting a +// self-signed certificate creates a server identity, not client trust. +func (t *Transport) SetupClientTLSFromEnv() error { + caFile := os.Getenv("EIPC_TLS_CA") + certFile := os.Getenv("EIPC_TLS_CERT") + keyFile := os.Getenv("EIPC_TLS_KEY") + + if caFile == "" && certFile == "" && keyFile == "" { + return nil + } + + cfg, err := LoadClientTLSConfig(caFile, certFile, keyFile) + if err != nil { + return err + } + t.tlsConfig = cfg + return nil +} diff --git a/transport/tcp/tcp_tls_test.go b/transport/tcp/tcp_tls_test.go index 34c459a..8846d5e 100644 --- a/transport/tcp/tcp_tls_test.go +++ b/transport/tcp/tcp_tls_test.go @@ -5,7 +5,10 @@ package tcp import ( "crypto/tls" + "net" + "os" "path/filepath" + "strings" "testing" "time" @@ -126,32 +129,155 @@ func TestSetupTLSFromEnvMissingFiles(t *testing.T) { } func TestSetupTLSFromEnvCertWithoutKey(t *testing.T) { - // The file path engages only when cert AND key are both set; a - // lone cert path is ignored and the transport stays unconfigured. + // A lone cert path is half-configured TLS: the server must fail + // loudly at startup rather than silently listen on plaintext while + // the operator believes TLS is on. dir := t.TempDir() certFile, _ := writeCertKeyFiles(t, dir) clearTLSEnv(t, certFile, "", "", "") tr := New() - if err := tr.SetupTLSFromEnv(); err != nil { - t.Fatalf("SetupTLSFromEnv: %v", err) + if err := tr.SetupTLSFromEnv(); err == nil { + t.Fatal("SetupTLSFromEnv: got nil error, want an error for a cert path without a key path") + } + if tr.tlsConfig != nil { + t.Errorf("tlsConfig: got non-nil after a failed load, want nil") + } +} + +func TestSetupTLSFromEnvKeyWithoutCert(t *testing.T) { + // Symmetric with the cert-without-key case: a lone key path is + // half-configured TLS and must fail, not fall back to plaintext. + dir := t.TempDir() + _, keyFile := writeCertKeyFiles(t, dir) + clearTLSEnv(t, "", keyFile, "", "") + + tr := New() + if err := tr.SetupTLSFromEnv(); err == nil { + t.Fatal("SetupTLSFromEnv: got nil error, want an error for a key path without a cert path") + } + if tr.tlsConfig != nil { + t.Errorf("tlsConfig: got non-nil after a failed load, want nil") + } +} + +func TestTransportTLSEnabled(t *testing.T) { + clearTLSEnv(t, "", "", "", "") + + tr := New() + if tr.TLSEnabled() { + t.Error("TLSEnabled: got true on a fresh transport, want false") + } + + cfg, err := AutoTLSConfig() + if err != nil { + t.Fatalf("AutoTLSConfig: %v", err) + } + tr.WithTLSConfig(cfg) + if !tr.TLSEnabled() { + t.Error("TLSEnabled: got false after WithTLSConfig, want true") + } +} + +func TestSetupClientTLSFromEnvNoEnv(t *testing.T) { + clearTLSEnv(t, "", "", "", "") + + tr := New() + if err := tr.SetupClientTLSFromEnv(); err != nil { + t.Fatalf("SetupClientTLSFromEnv: %v", err) } if tr.tlsConfig != nil { - t.Errorf("tlsConfig: got non-nil, want nil for a cert path without a key path") + t.Errorf("tlsConfig: got non-nil, want nil with no client TLS env set") + } + if tr.TLSEnabled() { + t.Error("TLSEnabled: got true with no client TLS env set, want false") + } +} + +func TestSetupClientTLSFromEnvCA(t *testing.T) { + dir := t.TempDir() + certFile, _ := writeCertKeyFiles(t, dir) + clearTLSEnv(t, "", "", certFile, "") + + tr := New() + if err := tr.SetupClientTLSFromEnv(); err != nil { + t.Fatalf("SetupClientTLSFromEnv: %v", err) + } + if tr.tlsConfig == nil { + t.Fatal("tlsConfig: got nil, want a config with EIPC_TLS_CA set") + } + if tr.tlsConfig.RootCAs == nil { + t.Error("RootCAs: got nil, want a pool from EIPC_TLS_CA") + } + if tr.tlsConfig.InsecureSkipVerify { + t.Error("InsecureSkipVerify: got true, want false — verification is always on") + } + if !tr.TLSEnabled() { + t.Error("TLSEnabled: got false with EIPC_TLS_CA set, want true") + } +} + +func TestSetupClientTLSFromEnvWithClientCert(t *testing.T) { + dir := t.TempDir() + certFile, keyFile := writeCertKeyFiles(t, dir) + clearTLSEnv(t, certFile, keyFile, certFile, "") + + tr := New() + if err := tr.SetupClientTLSFromEnv(); err != nil { + t.Fatalf("SetupClientTLSFromEnv: %v", err) + } + if tr.tlsConfig == nil { + t.Fatal("tlsConfig: got nil, want a config with CA + client cert/key set") + } + if len(tr.tlsConfig.Certificates) != 1 { + t.Errorf("Certificates: got %d, want 1 for mTLS client cert", len(tr.tlsConfig.Certificates)) + } +} + +func TestSetupClientTLSFromEnvHalfClientCert(t *testing.T) { + // A client cert without its key is half-configured, same as the + // server side: fail instead of dialing with a silently dropped + // client identity. + dir := t.TempDir() + certFile, _ := writeCertKeyFiles(t, dir) + clearTLSEnv(t, certFile, "", "", "") + + tr := New() + if err := tr.SetupClientTLSFromEnv(); err == nil { + t.Fatal("SetupClientTLSFromEnv: got nil error, want an error for a client cert without a key") + } + if tr.tlsConfig != nil { + t.Errorf("tlsConfig: got non-nil after a failed load, want nil") + } +} + +func TestSetupClientTLSFromEnvBadCA(t *testing.T) { + dir := t.TempDir() + badCA := filepath.Join(dir, "bad-ca.pem") + if err := os.WriteFile(badCA, []byte("this is not a PEM certificate\n"), 0o600); err != nil { + t.Fatalf("write bad CA: %v", err) + } + clearTLSEnv(t, "", "", badCA, "") + + tr := New() + if err := tr.SetupClientTLSFromEnv(); err == nil { + t.Fatal("SetupClientTLSFromEnv: got nil error, want an error for an unparseable CA file") } } // TestTLSRoundTrip drives a real TLS listener/dial pair and exchanges // protocol frames in both directions, mirroring TestTCPBidirectional -// over TLS. The generated server certificate is not a CA and its only -// SAN is "localhost", so the client's config skips verification — -// certificate verification itself is pinned at the config level by -// the tls_config_test.go tests; this test pins the transport wiring -// (tls.NewListener on Listen, tls.Dial on Dial). +// over TLS. The generated server certificate is self-signed with SAN +// "localhost", so the client verifies it by trusting that exact +// certificate as its RootCA — a real verified dial, with no +// InsecureSkipVerify anywhere in the test. func TestTLSRoundTrip(t *testing.T) { - serverCfg, err := AutoTLSConfig() + dir := t.TempDir() + certFile, keyFile := writeCertKeyFiles(t, dir) + + serverCfg, err := LoadTLSConfig(certFile, keyFile, "") if err != nil { - t.Fatalf("AutoTLSConfig: %v", err) + t.Fatalf("LoadTLSConfig: %v", err) } srv := New() @@ -161,10 +287,24 @@ func TestTLSRoundTrip(t *testing.T) { } defer srv.Close() + clientCfg, err := LoadClientTLSConfig(certFile, "", "") + if err != nil { + t.Fatalf("LoadClientTLSConfig: %v", err) + } + if clientCfg.InsecureSkipVerify { + t.Fatal("InsecureSkipVerify: got true, want false — the verified dial must not skip verification") + } + client := New() - client.WithTLSConfig(&tls.Config{InsecureSkipVerify: true}) //nolint:gosec // test-only client config; see comment above + client.WithTLSConfig(clientCfg) - addr := srv.Addr() + // Dial by name so the "localhost" SAN on the generated certificate + // matches the verified ServerName. + _, port, err := net.SplitHostPort(srv.Addr()) + if err != nil { + t.Fatalf("split host/port %q: %v", srv.Addr(), err) + } + addr := net.JoinHostPort("localhost", port) request := &protocol.Frame{ Version: core.ProtocolVersion, @@ -233,3 +373,65 @@ func TestTLSRoundTrip(t *testing.T) { t.Fatal("timeout waiting for TLS response") } } + +// TestTLSRoundTripWrongCARejected dials a TLS server while trusting a +// different certificate as the CA: the handshake must fail on +// verification, proving the verified dial in TestTLSRoundTrip is really +// verifying and not silently accepting any certificate. +func TestTLSRoundTripWrongCARejected(t *testing.T) { + serverDir := t.TempDir() + serverCert, serverKey := writeCertKeyFiles(t, serverDir) + serverCfg, err := LoadTLSConfig(serverCert, serverKey, "") + if err != nil { + t.Fatalf("LoadTLSConfig: %v", err) + } + + srv := New() + srv.WithTLSConfig(serverCfg) + if err := srv.Listen("127.0.0.1:0"); err != nil { + t.Fatalf("listen: %v", err) + } + defer srv.Close() + + // The server must Accept AND read so the handshake runs: + // tls.Listener.Accept does not handshake — it completes on first + // I/O. Without a Read the client never receives the certificate + // and the dial fails with EOF instead of the verification error + // this test pins. The Read fails once the client aborts the + // handshake; that error is expected and discarded here. + go func() { + conn, err := srv.Accept() + if err != nil { + return + } + defer conn.Close() + _, _ = conn.Receive() + }() + + // A second, unrelated self-signed certificate is the wrong trust + // anchor: it shares nothing with the server's certificate. + wrongDir := t.TempDir() + wrongCert, _ := writeCertKeyFiles(t, wrongDir) + + clientCfg, err := LoadClientTLSConfig(wrongCert, "", "") + if err != nil { + t.Fatalf("LoadClientTLSConfig: %v", err) + } + + client := New() + client.WithTLSConfig(clientCfg) + + _, port, err := net.SplitHostPort(srv.Addr()) + if err != nil { + t.Fatalf("split host/port %q: %v", srv.Addr(), err) + } + + conn, err := client.Dial(net.JoinHostPort("localhost", port)) + if err == nil { + conn.Close() + t.Fatal("tls dial with the wrong CA: got nil error, want a certificate verification failure") + } + if !strings.Contains(err.Error(), "certificate") { + t.Errorf("tls dial with the wrong CA: got %v, want an error mentioning the certificate", err) + } +} diff --git a/transport/tcp/tls_config.go b/transport/tcp/tls_config.go index b0b045f..fe6baef 100644 --- a/transport/tcp/tls_config.go +++ b/transport/tcp/tls_config.go @@ -61,6 +61,22 @@ func GenerateSelfSignedCert() (tls.Certificate, error) { return tls.X509KeyPair(certPEM, keyPEM) } +// loadCertPool reads a PEM file and returns a pool holding its certificates. +// It is shared by the server and client constructors so a CA file is parsed +// exactly once, the same way, on both sides. +func loadCertPool(caFile string) (*x509.CertPool, error) { + caFile = filepath.Clean(caFile) + caPEM, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read CA file: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("failed to parse CA certificate") + } + return pool, nil +} + // LoadTLSConfig builds a *tls.Config from cert/key/CA file paths. // If caFile is provided, mTLS (RequireAndVerifyClientCert) is enabled. func LoadTLSConfig(certFile, keyFile, caFile string) (*tls.Config, error) { @@ -78,14 +94,9 @@ func LoadTLSConfig(certFile, keyFile, caFile string) (*tls.Config, error) { } if caFile != "" { - caFile = filepath.Clean(caFile) - caPEM, err := os.ReadFile(caFile) + pool, err := loadCertPool(caFile) if err != nil { - return nil, fmt.Errorf("read CA file: %w", err) - } - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(caPEM) { - return nil, fmt.Errorf("failed to parse CA certificate") + return nil, err } cfg.ClientCAs = pool cfg.ClientAuth = tls.RequireAndVerifyClientCert @@ -94,6 +105,48 @@ func LoadTLSConfig(certFile, keyFile, caFile string) (*tls.Config, error) { return cfg, nil } +// LoadClientTLSConfig builds a *tls.Config for dialing a server. +// +// Argument order is CA-first because the CA is the point of a client +// config: caFile is the CA bundle the server's certificate is verified +// against (RootCAs). Pass "" to verify against the system roots. +// certFile/keyFile optionally present a client certificate for mTLS; +// they must be given together or not at all. +// +// Verification is always on: the returned config never sets +// InsecureSkipVerify. Before this constructor existed, the only way to +// dial a server with a self-signed or private-CA certificate was +// InsecureSkipVerify, because the server-side LoadTLSConfig sets +// Certificates/ClientCAs and never RootCAs. +func LoadClientTLSConfig(caFile, certFile, keyFile string) (*tls.Config, error) { + cfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + if caFile != "" { + pool, err := loadCertPool(caFile) + if err != nil { + return nil, err + } + cfg.RootCAs = pool + } + + if (certFile == "") != (keyFile == "") { + return nil, fmt.Errorf("client certificate and key must be set together") + } + if certFile != "" { + certFile = filepath.Clean(certFile) + keyFile = filepath.Clean(keyFile) + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("load client key pair: %w", err) + } + cfg.Certificates = []tls.Certificate{cert} + } + + return cfg, nil +} + // AutoTLSConfig generates a self-signed cert for development use. // It is called by SetupTLSFromEnv when EIPC_TLS_AUTO_CERT is exactly // "true"; this function itself reads no environment variables. diff --git a/transport/tcp/tls_config_test.go b/transport/tcp/tls_config_test.go index c1bdbb1..95d2c6c 100644 --- a/transport/tcp/tls_config_test.go +++ b/transport/tcp/tls_config_test.go @@ -149,6 +149,81 @@ func TestLoadTLSConfigBadCA(t *testing.T) { } } +func TestLoadClientTLSConfigCAOnly(t *testing.T) { + dir := t.TempDir() + certFile, _ := writeCertKeyFiles(t, dir) + cfg, err := LoadClientTLSConfig(certFile, "", "") + if err != nil { + t.Fatalf("LoadClientTLSConfig: %v", err) + } + if cfg.RootCAs == nil { + t.Error("RootCAs: got nil, want a pool from the CA file") + } + if cfg.MinVersion != tls.VersionTLS12 { + t.Errorf("MinVersion: got %x, want TLS 1.2 (%x)", cfg.MinVersion, tls.VersionTLS12) + } + if cfg.InsecureSkipVerify { + t.Error("InsecureSkipVerify: got true, want false — verification is always on") + } + if len(cfg.Certificates) != 0 { + t.Errorf("Certificates: got %d, want 0 without a client cert/key pair", len(cfg.Certificates)) + } +} + +func TestLoadClientTLSConfigSystemRoots(t *testing.T) { + // No CA file means "verify against the system roots": still a + // verifying config, never a skipped one. + cfg, err := LoadClientTLSConfig("", "", "") + if err != nil { + t.Fatalf("LoadClientTLSConfig: %v", err) + } + if cfg.RootCAs != nil { + t.Error("RootCAs: got non-nil pool, want nil (system roots) with no CA file") + } + if cfg.InsecureSkipVerify { + t.Error("InsecureSkipVerify: got true, want false — verification is always on") + } +} + +func TestLoadClientTLSConfigWithClientCert(t *testing.T) { + dir := t.TempDir() + certFile, keyFile := writeCertKeyFiles(t, dir) + cfg, err := LoadClientTLSConfig(certFile, certFile, keyFile) + if err != nil { + t.Fatalf("LoadClientTLSConfig: %v", err) + } + if len(cfg.Certificates) != 1 { + t.Errorf("Certificates: got %d, want 1 with a client cert/key pair", len(cfg.Certificates)) + } + if cfg.RootCAs == nil { + t.Error("RootCAs: got nil, want a pool from the CA file") + } +} + +func TestLoadClientTLSConfigErrors(t *testing.T) { + dir := t.TempDir() + certFile, keyFile := writeCertKeyFiles(t, dir) + + if _, err := LoadClientTLSConfig(filepath.Join(dir, "missing-ca.pem"), "", ""); err == nil { + t.Error("LoadClientTLSConfig with missing CA file: got nil error, want error") + } + + badCA := filepath.Join(dir, "bad-ca.pem") + if err := os.WriteFile(badCA, []byte("this is not a PEM certificate\n"), 0o600); err != nil { + t.Fatalf("write bad CA: %v", err) + } + if _, err := LoadClientTLSConfig(badCA, "", ""); err == nil { + t.Error("LoadClientTLSConfig with unparseable CA PEM: got nil error, want error") + } + + if _, err := LoadClientTLSConfig("", certFile, ""); err == nil { + t.Error("LoadClientTLSConfig with client cert but no key: got nil error, want error") + } + if _, err := LoadClientTLSConfig("", "", keyFile); err == nil { + t.Error("LoadClientTLSConfig with client key but no cert: got nil error, want error") + } +} + func TestAutoTLSConfig(t *testing.T) { // Pinned behaviour at HEAD: AutoTLSConfig's doc comment says it is // "Controlled by EIPC_TLS_AUTO_CERT env var", but the implementation