From 477017d98dfb775bb958a53103bcd930ac6e10e2 Mon Sep 17 00:00:00 2001 From: Jin Hou Date: Thu, 29 Oct 2026 05:41:48 +0000 Subject: [PATCH 1/2] test: verify X25519MLKEM768 post-quantum TLS key exchange and fallback --- dialer_test.go | 13 +- internal/cloudsql/instance_test.go | 14 ++ internal/mock/cloudsql.go | 48 ++++- pqc_test.go | 272 +++++++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 pqc_test.go diff --git a/dialer_test.go b/dialer_test.go index b5bfdf51..f4e2bb54 100644 --- a/dialer_test.go +++ b/dialer_test.go @@ -83,11 +83,12 @@ func withMockDNSResolver(r cloudsql.NetResolver) Option { // setupConfig holds all the configuration to use when setting up a dialer. type setupConfig struct { - testInstance mock.FakeCSQLInstance - skipServer bool - skipVerify bool - reqs []*mock.Request - dialerOptions []Option + testInstance mock.FakeCSQLInstance + skipServer bool + skipVerify bool + reqs []*mock.Request + dialerOptions []Option + serverProxyOptions []mock.ServerProxyOption } // setupDialer configures a Dialer with an HTTP client configured to point at a @@ -102,7 +103,7 @@ func setupDialer(t *testing.T, c setupConfig) *Dialer { } stop := func() {} if !c.skipServer { - stop = mock.StartServerProxy(t, c.testInstance) + stop = mock.StartServerProxy(t, c.testInstance, c.serverProxyOptions...) } t.Cleanup(func() { stop() diff --git a/internal/cloudsql/instance_test.go b/internal/cloudsql/instance_test.go index b86c915d..b2a37be6 100644 --- a/internal/cloudsql/instance_test.go +++ b/internal/cloudsql/instance_test.go @@ -172,6 +172,13 @@ func TestConnectionInfoTLSConfig(t *testing.T) { ) } + if len(got.CurvePreferences) != 0 { + t.Fatalf( + "want empty CurvePreferences so crypto/tls uses default post-quantum key exchange (X25519MLKEM768), got = %v", + got.CurvePreferences, + ) + } + if got.Certificates[0].Leaf != ci.ClientCertificate.Leaf { t.Fatal("leaf certificates do not match") } @@ -527,6 +534,13 @@ func TestConnectionInfoTLSConfigForCAS(t *testing.T) { "want CLIENT_PROTOCOL_TLS 1.3, got = %v", got.MinVersion, ) } + + if len(got.CurvePreferences) != 0 { + t.Fatalf( + "want empty CurvePreferences so crypto/tls uses default post-quantum key exchange (X25519MLKEM768), got = %v", + got.CurvePreferences, + ) + } if got.Certificates[0].Leaf != ci.ClientCertificate.Leaf { t.Fatal("leaf certificates do not match") } diff --git a/internal/mock/cloudsql.go b/internal/mock/cloudsql.go index f9b68787..0147523e 100644 --- a/internal/mock/cloudsql.go +++ b/internal/mock/cloudsql.go @@ -295,14 +295,43 @@ func GenerateCertWithCommonName(i FakeCSQLInstance, cn string) []byte { return i.certs.generateServerCertWithCn(cn).Raw } +// ServerProxyOption configures StartServerProxy. +type ServerProxyOption func(*serverProxyConfig) + +type serverProxyConfig struct { + curvePreferences []tls.CurveID + onHandshake func(tls.ConnectionState) +} + +// WithServerCurvePreferences configures the TLS CurvePreferences used by the +// fake server proxy. When unset, crypto/tls defaults are used. +func WithServerCurvePreferences(curves ...tls.CurveID) ServerProxyOption { + return func(c *serverProxyConfig) { + c.curvePreferences = curves + } +} + +// WithServerConnectionStateHook registers a callback invoked with the server's +// tls.ConnectionState after each accepted TLS handshake completes. +func WithServerConnectionStateHook(fn func(tls.ConnectionState)) ServerProxyOption { + return func(c *serverProxyConfig) { + c.onHandshake = fn + } +} + // StartServerProxy starts a fake server proxy and listens on the provided port // on all interfaces, configured with TLS as specified by the FakeCSQLInstance. // Callers should invoke the returned function to clean up all resources. -func StartServerProxy(t *testing.T, i FakeCSQLInstance) func() { +func StartServerProxy(t *testing.T, i FakeCSQLInstance, opts ...ServerProxyOption) func() { + var proxyCfg serverProxyConfig + for _, opt := range opts { + opt(&proxyCfg) + } ln, err := startListenerWithRetries(&tls.Config{ - Certificates: i.certs.serverChain(i.useStandardTLSValidation), - ClientCAs: i.certs.clientCAPool(), - ClientAuth: tls.RequireAndVerifyClientCert, + Certificates: i.certs.serverChain(i.useStandardTLSValidation), + ClientCAs: i.certs.clientCAPool(), + ClientAuth: tls.RequireAndVerifyClientCert, + CurvePreferences: proxyCfg.curvePreferences, }) if err != nil { t.Fatalf("failed to start listener: %v", err) @@ -330,6 +359,17 @@ func StartServerProxy(t *testing.T, i FakeCSQLInstance) func() { } go func(c net.Conn) { + if proxyCfg.onHandshake != nil { + if tlsConn, ok := c.(*tls.Conn); ok { + hErr := tlsConn.Handshake() + if hErr != nil { + t.Logf("Fake server TLS handshake error: %v", hErr) + } + if hErr == nil { + proxyCfg.onHandshake(tlsConn.ConnectionState()) + } + } + } csqlBytes := make([]byte, 8) c.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) _, rErr := c.Read(csqlBytes) diff --git a/pqc_test.go b/pqc_test.go new file mode 100644 index 00000000..0ad1edf1 --- /dev/null +++ b/pqc_test.go @@ -0,0 +1,272 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cloudsqlconn + +import ( + "context" + "crypto/tls" + "io" + "net" + "testing" + "time" + + "cloud.google.com/go/cloudsqlconn/internal/cloudsql" + "cloud.google.com/go/cloudsqlconn/internal/mock" +) + +// unwrapTLSConn extracts the underlying *tls.Conn from a net.Conn returned by +// Dialer.Dial so tests can inspect the negotiated tls.ConnectionState. +func unwrapTLSConn(t *testing.T, conn net.Conn) *tls.Conn { + t.Helper() + ic, ok := conn.(*instrumentedConn) + if !ok { + t.Fatalf("expected *instrumentedConn, got %T", conn) + } + inner := ic.Conn + if mdxConn, isMDX := inner.(*cloudsql.MDXConn); isMDX { + inner = mdxConn.Conn + } + tlsConn, ok := inner.(*tls.Conn) + if !ok { + t.Fatalf("expected underlying *tls.Conn, got %T", inner) + } + return tlsConn +} + +// assertTLSHandshakeAndExchange dials the mock instance, exchanges application +// data end to end, and verifies that both the client and mock server negotiated +// TLS 1.3 with wantCurve. +func assertTLSHandshakeAndExchange( + t *testing.T, + d *Dialer, + inst mock.FakeCSQLInstance, + serverStates <-chan tls.ConnectionState, + wantCurve tls.CurveID, + dialOpts ...DialOption, +) { + t.Helper() + conn, err := d.Dial(context.Background(), inst.String(), dialOpts...) + if err != nil { + t.Fatalf("expected Dial to succeed, got error: %v", err) + } + defer func() { _ = conn.Close() }() + + clientState := unwrapTLSConn(t, conn).ConnectionState() + if !clientState.HandshakeComplete { + t.Fatal("expected client TLS handshake to be complete") + } + if clientState.Version != tls.VersionTLS13 { + t.Fatalf("client TLS version: want = 0x%04x (TLS 1.3), got = 0x%04x", tls.VersionTLS13, clientState.Version) + } + if clientState.CurveID != wantCurve { + t.Fatalf("client negotiated CurveID: want = %v (0x%04x), got = %v (0x%04x)", + wantCurve, uint16(wantCurve), clientState.CurveID, uint16(clientState.CurveID)) + } + + if _, err := conn.Write([]byte("hello world")); err != nil { + t.Fatalf("expected Write to succeed, got error: %v", err) + } + data, err := io.ReadAll(conn) + if err != nil { + t.Fatalf("expected ReadAll to succeed, got error: %v", err) + } + const wantPayload = "my-instance" + if string(data) != wantPayload { + t.Fatalf("unexpected response payload: want = %q, got = %q", wantPayload, string(data)) + } + + select { + case serverState := <-serverStates: + if !serverState.HandshakeComplete { + t.Fatal("expected server TLS handshake to be complete") + } + if serverState.Version != tls.VersionTLS13 { + t.Fatalf("server TLS version: want = 0x%04x (TLS 1.3), got = 0x%04x", tls.VersionTLS13, serverState.Version) + } + if serverState.CurveID != wantCurve { + t.Fatalf("server negotiated CurveID: want = %v (0x%04x), got = %v (0x%04x)", + wantCurve, uint16(wantCurve), serverState.CurveID, uint16(serverState.CurveID)) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for server TLS ConnectionState") + } +} + +func TestDialerPQCDefaultHandshake(t *testing.T) { + if uint16(tls.X25519MLKEM768) != 0x11ec { + t.Fatalf("unexpected IANA code point for tls.X25519MLKEM768: got 0x%04x, want 0x11ec", uint16(tls.X25519MLKEM768)) + } + + tcs := []struct { + desc string + instOpts []mock.FakeCSQLInstanceOption + dialOpts []DialOption + }{ + { + desc: "default connection negotiates X25519MLKEM768", + }, + { + desc: "metadata exchange connection negotiates X25519MLKEM768", + dialOpts: []DialOption{ + WithMdxClientProtocolType(cloudsql.ClientProtocolTCP), + }, + }, + { + desc: "CAS instance connection negotiates X25519MLKEM768", + instOpts: []mock.FakeCSQLInstanceOption{ + mock.WithDNS("db.example.com"), + mock.WithServerCAMode("GOOGLE_MANAGED_CAS_CA"), + }, + }, + } + + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + inst := mock.NewFakeCSQLInstance( + "my-project", "my-region", "my-instance", + tc.instOpts..., + ) + serverStates := make(chan tls.ConnectionState, 1) + d := setupDialer(t, setupConfig{ + testInstance: inst, + reqs: []*mock.Request{ + mock.InstanceGetSuccess(inst, 1), + mock.CreateEphemeralSuccess(inst, 1), + }, + serverProxyOptions: []mock.ServerProxyOption{ + mock.WithServerConnectionStateHook(func(st tls.ConnectionState) { + select { + case serverStates <- st: + default: + } + }), + }, + }) + t.Cleanup(func() { _ = d.Close() }) + + assertTLSHandshakeAndExchange(t, d, inst, serverStates, tls.X25519MLKEM768, tc.dialOpts...) + }) + } +} + +func TestDialerPQCClassicalServerFallback(t *testing.T) { + tcs := []struct { + desc string + serverCurves []tls.CurveID + wantCurve tls.CurveID + }{ + { + desc: "falls back to X25519 when server supports classical curves only", + serverCurves: []tls.CurveID{tls.X25519, tls.CurveP256}, + wantCurve: tls.X25519, + }, + { + desc: "falls back to CurveP256 via HelloRetryRequest when server supports P-256 only", + serverCurves: []tls.CurveID{tls.CurveP256}, + wantCurve: tls.CurveP256, + }, + } + + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + inst := mock.NewFakeCSQLInstance( + "my-project", "my-region", "my-instance", + ) + serverStates := make(chan tls.ConnectionState, 1) + d := setupDialer(t, setupConfig{ + testInstance: inst, + reqs: []*mock.Request{ + mock.InstanceGetSuccess(inst, 1), + mock.CreateEphemeralSuccess(inst, 1), + }, + serverProxyOptions: []mock.ServerProxyOption{ + mock.WithServerCurvePreferences(tc.serverCurves...), + mock.WithServerConnectionStateHook(func(st tls.ConnectionState) { + select { + case serverStates <- st: + default: + } + }), + }, + }) + t.Cleanup(func() { _ = d.Close() }) + + assertTLSHandshakeAndExchange(t, d, inst, serverStates, tc.wantCurve) + }) + } +} + +func TestDialerPQCOptOutWithGODEBUG(t *testing.T) { + // Configure the mock server with explicit CurvePreferences including + // X25519MLKEM768 so the server continues offering PQC even when process-wide + // GODEBUG=tlsmlkem=0 is set to test client opt-out. + serverCurves := []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256} + + t.Run("negotiates X25519 when GODEBUG=tlsmlkem=0", func(t *testing.T) { + t.Setenv("GODEBUG", "tlsmlkem=0") + + inst := mock.NewFakeCSQLInstance( + "my-project", "my-region", "my-instance", + ) + serverStates := make(chan tls.ConnectionState, 1) + d := setupDialer(t, setupConfig{ + testInstance: inst, + reqs: []*mock.Request{ + mock.InstanceGetSuccess(inst, 1), + mock.CreateEphemeralSuccess(inst, 1), + }, + serverProxyOptions: []mock.ServerProxyOption{ + mock.WithServerCurvePreferences(serverCurves...), + mock.WithServerConnectionStateHook(func(st tls.ConnectionState) { + select { + case serverStates <- st: + default: + } + }), + }, + }) + t.Cleanup(func() { _ = d.Close() }) + + assertTLSHandshakeAndExchange(t, d, inst, serverStates, tls.X25519) + }) + + t.Run("negotiates X25519MLKEM768 when GODEBUG=tlsmlkem=1", func(t *testing.T) { + t.Setenv("GODEBUG", "tlsmlkem=1") + + inst := mock.NewFakeCSQLInstance( + "my-project", "my-region", "my-instance", + ) + serverStates := make(chan tls.ConnectionState, 1) + d := setupDialer(t, setupConfig{ + testInstance: inst, + reqs: []*mock.Request{ + mock.InstanceGetSuccess(inst, 1), + mock.CreateEphemeralSuccess(inst, 1), + }, + serverProxyOptions: []mock.ServerProxyOption{ + mock.WithServerCurvePreferences(serverCurves...), + mock.WithServerConnectionStateHook(func(st tls.ConnectionState) { + select { + case serverStates <- st: + default: + } + }), + }, + }) + t.Cleanup(func() { _ = d.Close() }) + + assertTLSHandshakeAndExchange(t, d, inst, serverStates, tls.X25519MLKEM768) + }) +} From d138c704490f9fcae1ea4f27e84a7074e3152e19 Mon Sep 17 00:00:00 2001 From: Jin Hou Date: Thu, 29 Oct 2026 05:46:00 +0000 Subject: [PATCH 2/2] docs: document Post-Quantum Cryptography (PQC) TLS support --- README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 322aa228..1d4d3611 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ Go language. Using a Cloud SQL connector provides a native alternative to the * **IAM Authorization:** uses IAM permissions to control who/what can connect to your Cloud SQL instances -* **Improved Security:** uses robust, updated TLS 1.3 encryption and - identity verification between the client connector and the server-side proxy, - independent of the database protocol. +* **Improved Security:** uses robust, updated TLS 1.3 encryption (including + automatic [hybrid post-quantum key exchange](#post-quantum-cryptography-pqc)) + and identity verification between the client connector and the server-side + proxy, independent of the database protocol. * **Convenience:** removes the requirement to use and distribute SSL certificates, as well as manage firewalls or source/destination IP addresses. * (optionally) **IAM DB Authentication:** provides support for @@ -452,6 +453,55 @@ dsn := "user=test-sa dbname=mydb sslmode=disable" [configure-iam-authn]: https://cloud.google.com/sql/docs/postgres/create-edit-iam-instances#configure-iam-db-instance [add-iam-user]: https://cloud.google.com/sql/docs/postgres/create-manage-iam-users#creating-a-database-user +### Post-Quantum Cryptography (PQC) + +The Cloud SQL Go Connector establishes mTLS connections over TLS 1.3 using Go's +standard `crypto/tls` package with default curve preferences. When built with +**Go 1.24 or later** and when your application's main `go.mod` declares +**`go 1.24` or higher**, the connector automatically offers and negotiates the +hybrid post-quantum key exchange group **`X25519MLKEM768`** (ML-KEM-768 combined +with X25519) whenever the Cloud SQL instance supports it. No code changes or +dialer options are required. + +#### `go.mod` language version requirement (`tlsmlkem`) + +Go 1.24+ controls default `X25519MLKEM768` support through the `tlsmlkem` +`GODEBUG` setting, whose default depends on the `go` directive in your +**application's main `go.mod`**—not just the Go toolchain version used to +compile the binary: + +- `cloud.google.com/go/cloudsqlconn` `v1.19.0` and later already require + `go >= 1.24` in `go.mod` (and `v1.25.3+` requires `go >= 1.26.0`), so + applications importing `v1.19.0+` automatically have `go 1.24` or higher in + their main `go.mod` and enable `X25519MLKEM768` by default. +- If your application pins an older connector release (`< v1.19.0`) and keeps + `go 1.23` or earlier in its main `go.mod`, compiling with Go 1.24+ still + defaults to `tlsmlkem=0` (classical curves only). To enable `X25519MLKEM768` + in that configuration, update your main `go.mod` directive to `go 1.24` (or + higher), add `godebug tlsmlkem=1` to `go.mod`, or add + `//go:debug tlsmlkem=1` to your `main` package. + +#### Backwards compatibility + +Hybrid post-quantum key exchange is backwards-compatible. When `X25519MLKEM768` +is enabled, Go's `crypto/tls` advertises both hybrid (`X25519MLKEM768`) and +classical (`X25519`, `P-256`, etc.) groups in the TLS 1.3 `ClientHello`. If a +Cloud SQL instance does not yet support `X25519MLKEM768`, the TLS handshake +automatically falls back to classical `X25519` without error. + +#### Opting out + +To disable post-quantum key exchange and restrict TLS handshakes to classical +curves, set the `GODEBUG` environment variable at runtime: + +```sh +GODEBUG=tlsmlkem=0 +``` + +Alternatively, configure the opt-out at build time by adding +`godebug tlsmlkem=0` to your main `go.mod` or `//go:debug tlsmlkem=0` to a file +in your `main` package. + ### Enabling Metrics and Tracing This library includes support for metrics and tracing using [OpenCensus][].