TUN-10413: Centralize TLS curve configuration in crypto/ and adopt X25519MLKEM768 for QUIC/H2

Introduce a new crypto/ package as the single source of truth for TLS
curve preferences used on every edge-facing connection, and adopt
X25519MLKEM768 as the primary post-quantum key exchange for both QUIC
and HTTP/2:

  PQ Prefer (default):     X25519MLKEM768, P256Kyber768Draft00, CurveP256
  PQ Strict (--post-quantum): X25519MLKEM768, P256Kyber768Draft00

The curve list is identical under FIPS and non-FIPS builds, so
crypto.GetCurvePreferences takes only a features.PostQuantumMode and
returns a fresh slice on every call.

HTTP/2 now applies these curve preferences the same way QUIC does. The
previous PostQuantumStrict rejection in serveHTTP2 and the forced
QUIC-only selection in NewProtocolSelector are removed since both
transports support the same post-quantum curves; the needPQ parameter
is dropped from NewProtocolSelector accordingly.

Also fix a shared tls.Config race: both the QUIC and HTTP/2 paths now
Clone() the per-protocol entry from TunnelConfig.EdgeTLSConfigs before
mutating CurvePreferences instead of writing through the shared map
entry.

Legacy Kyber draft curve X25519Kyber768Draft00
and the unused removeDuplicates helper are removed along with the old
supervisor/pqtunnels.go / _test.go files.

AGENTS.md is updated with guidance on the new crypto/ package, the
cfdcrypto import alias, the tls.Config cloning rule, and the lint
workflow implications of .golangci.yaml's whole-files: true setting.
This commit is contained in:
lneto
2026-04-20 11:48:20 +01:00
committed by Luis Neto
parent ae3799a098
commit f674b82e2a
12 changed files with 326 additions and 235 deletions
-60
View File
@@ -1,60 +0,0 @@
package supervisor
import (
"crypto/tls"
"fmt"
"github.com/cloudflare/cloudflared/features"
)
const (
X25519Kyber768Draft00PQKex = tls.CurveID(0x6399) // X25519Kyber768Draft00
X25519Kyber768Draft00PQKexName = "X25519Kyber768Draft00"
P256Kyber768Draft00PQKex = tls.CurveID(0xfe32) // P256Kyber768Draft00
P256Kyber768Draft00PQKexName = "P256Kyber768Draft00"
X25519MLKEM768PQKex = tls.CurveID(0x11ec) // X25519MLKEM768
X25519MLKEM768PQKexName = "X25519MLKEM768"
)
var (
nonFipsPostQuantumStrictPKex []tls.CurveID = []tls.CurveID{X25519MLKEM768PQKex}
nonFipsPostQuantumPreferPKex []tls.CurveID = []tls.CurveID{X25519MLKEM768PQKex}
fipsPostQuantumStrictPKex []tls.CurveID = []tls.CurveID{P256Kyber768Draft00PQKex}
fipsPostQuantumPreferPKex []tls.CurveID = []tls.CurveID{P256Kyber768Draft00PQKex, tls.CurveP256}
)
func removeDuplicates(curves []tls.CurveID) []tls.CurveID {
bucket := make(map[tls.CurveID]bool)
var result []tls.CurveID
for _, curve := range curves {
if _, ok := bucket[curve]; !ok {
bucket[curve] = true
result = append(result, curve)
}
}
return result
}
func curvePreference(pqMode features.PostQuantumMode, fipsEnabled bool, currentCurve []tls.CurveID) ([]tls.CurveID, error) {
switch pqMode {
case features.PostQuantumStrict:
// If the user passes the -post-quantum flag, we override
// CurvePreferences to only support hybrid post-quantum key agreements.
if fipsEnabled {
return fipsPostQuantumStrictPKex, nil
}
return nonFipsPostQuantumStrictPKex, nil
case features.PostQuantumPrefer:
if fipsEnabled {
// Ensure that all curves returned are FIPS compliant.
// Moreover the first curves are post-quantum and then the
// non post-quantum.
return fipsPostQuantumPreferPKex, nil
}
curves := append(nonFipsPostQuantumPreferPKex, currentCurve...)
curves = removeDuplicates(curves)
return curves, nil
default:
return nil, fmt.Errorf("Unexpected post quantum mode")
}
}
-119
View File
@@ -1,119 +0,0 @@
package supervisor
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"slices"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/cloudflare/cloudflared/features"
"github.com/cloudflare/cloudflared/fips"
)
func TestCurvePreferences(t *testing.T) {
// This tests if the correct curves are returned
// given a PostQuantumMode and a FIPS enabled bool
t.Parallel()
tests := []struct {
name string
currentCurves []tls.CurveID
expectedCurves []tls.CurveID
pqMode features.PostQuantumMode
fipsEnabled bool
}{
{
name: "FIPS with Prefer PQ",
pqMode: features.PostQuantumPrefer,
fipsEnabled: true,
currentCurves: []tls.CurveID{tls.CurveP384},
expectedCurves: []tls.CurveID{P256Kyber768Draft00PQKex, tls.CurveP256},
},
{
name: "FIPS with Strict PQ",
pqMode: features.PostQuantumStrict,
fipsEnabled: true,
currentCurves: []tls.CurveID{tls.CurveP256, tls.CurveP384},
expectedCurves: []tls.CurveID{P256Kyber768Draft00PQKex},
},
{
name: "FIPS with Prefer PQ - no duplicates",
pqMode: features.PostQuantumPrefer,
fipsEnabled: true,
currentCurves: []tls.CurveID{tls.CurveP256},
expectedCurves: []tls.CurveID{P256Kyber768Draft00PQKex, tls.CurveP256},
},
{
name: "Non FIPS with Prefer PQ",
pqMode: features.PostQuantumPrefer,
fipsEnabled: false,
currentCurves: []tls.CurveID{tls.CurveP256},
expectedCurves: []tls.CurveID{X25519MLKEM768PQKex, tls.CurveP256},
},
{
name: "Non FIPS with Prefer PQ - no duplicates",
pqMode: features.PostQuantumPrefer,
fipsEnabled: false,
currentCurves: []tls.CurveID{X25519Kyber768Draft00PQKex, tls.CurveP256},
expectedCurves: []tls.CurveID{X25519MLKEM768PQKex, X25519Kyber768Draft00PQKex, tls.CurveP256},
},
{
name: "Non FIPS with Prefer PQ - correct preference order",
pqMode: features.PostQuantumPrefer,
fipsEnabled: false,
currentCurves: []tls.CurveID{tls.CurveP256, X25519Kyber768Draft00PQKex},
expectedCurves: []tls.CurveID{X25519MLKEM768PQKex, tls.CurveP256, X25519Kyber768Draft00PQKex},
},
{
name: "Non FIPS with Strict PQ",
pqMode: features.PostQuantumStrict,
fipsEnabled: false,
currentCurves: []tls.CurveID{tls.CurveP256},
expectedCurves: []tls.CurveID{X25519MLKEM768PQKex},
},
}
for _, tcase := range tests {
t.Run(tcase.name, func(t *testing.T) {
t.Parallel()
curves, err := curvePreference(tcase.pqMode, tcase.fipsEnabled, tcase.currentCurves)
require.NoError(t, err)
assert.Equal(t, tcase.expectedCurves, curves)
})
}
}
func runClientServerHandshake(t *testing.T, curves []tls.CurveID) []tls.CurveID {
var advertisedCurves []tls.CurveID
ts := httptest.NewUnstartedServer(nil)
ts.TLS = &tls.Config{ // nolint: gosec
GetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) {
advertisedCurves = slices.Clone(chi.SupportedCurves)
return nil, nil
},
}
ts.StartTLS()
defer ts.Close()
clientTlsConfig := ts.Client().Transport.(*http.Transport).TLSClientConfig
clientTlsConfig.CurvePreferences = curves
resp, err := ts.Client().Head(ts.URL)
if err != nil {
t.Error(err)
return nil
}
defer resp.Body.Close()
return advertisedCurves
}
func TestSupportedCurvesNegotiation(t *testing.T) {
for _, tcase := range []features.PostQuantumMode{features.PostQuantumPrefer} {
curves, err := curvePreference(tcase, fips.IsFipsEnabled(), make([]tls.CurveID, 0))
require.NoError(t, err)
advertisedCurves := runClientServerHandshake(t, curves)
assert.Equal(t, curves, advertisedCurves)
}
}
+22 -16
View File
@@ -20,6 +20,7 @@ import (
"github.com/cloudflare/cloudflared/client"
"github.com/cloudflare/cloudflared/connection"
"github.com/cloudflare/cloudflared/connection/dialopts"
cfdcrypto "github.com/cloudflare/cloudflared/crypto"
"github.com/cloudflare/cloudflared/edgediscovery"
"github.com/cloudflare/cloudflared/edgediscovery/allregions"
"github.com/cloudflare/cloudflared/features"
@@ -90,6 +91,10 @@ func (c *TunnelConfig) connectionOptions(originLocalAddr string, previousAttempt
return c.ClientConfig.ConnectionOptionsSnapshot(originIP, previousAttempts)
}
func (c *TunnelConfig) connectionFeatures() features.FeatureSnapshot {
return c.ClientConfig.ConnectionFeaturesSnapshot()
}
func StartTunnelDaemon(
ctx context.Context,
config *TunnelConfig,
@@ -473,12 +478,21 @@ func (e *EdgeTunnelServer) serveConnection(
connIndex)
case connection.HTTP2:
edgeConn, err := edgediscovery.DialEdge(ctx, dialTimeout, e.config.EdgeTLSConfigs[protocol], addr.TCP, e.edgeBindAddr)
tlsConfig, err := cfdcrypto.TLSConfigWithCurvePreferences(e.config.EdgeTLSConfigs[protocol], e.config.connectionFeatures().PostQuantum)
if err != nil {
return fmt.Errorf("could not create TLS configuration: %w", err), true
}
connLog.Logger().Info().Msgf("Tunnel connection curve preferences: %v", tlsConfig.CurvePreferences)
edgeConn, err := edgediscovery.DialEdge(ctx, dialTimeout, tlsConfig, addr.TCP, e.edgeBindAddr)
if err != nil {
connLog.ConnAwareLogger().Err(err).Msg("Unable to establish connection with Cloudflare edge")
return err, true
}
// Rebuild the connection options with the local address now that the
// edge socket is established.
// nolint: gosec
connOptions := e.config.connectionOptions(edgeConn.LocalAddr().String(), uint8(backoff.Retries()))
// nolint: zerologlint
@@ -516,11 +530,9 @@ func (e *EdgeTunnelServer) serveHTTP2(
controlStreamHandler connection.ControlStreamHandler,
connIndex uint8,
) error {
pqMode := connOptions.FeatureSnapshot.PostQuantum
if pqMode == features.PostQuantumStrict {
return unrecoverableError{errors.New("HTTP/2 transport does not support post-quantum")}
}
// HTTP/2 supports post-quantum key exchange the same way QUIC does. Curve
// preferences are applied by the caller before the TLS handshake in
// DialEdge (see TUN-10413).
connLog.Logger().Debug().Msgf("Connecting via http2")
h2conn := connection.NewHTTP2Connection(
tlsServerConn,
@@ -558,18 +570,12 @@ func (e *EdgeTunnelServer) serveQUIC(
controlStreamHandler connection.ControlStreamHandler,
connIndex uint8,
) (err error, recoverable bool) {
tlsConfig := e.config.EdgeTLSConfigs[connection.QUIC]
pqMode := connOptions.FeatureSnapshot.PostQuantum
curvePref, err := curvePreference(pqMode, fips.IsFipsEnabled(), tlsConfig.CurvePreferences)
config, err := cfdcrypto.TLSConfigWithCurvePreferences(e.config.EdgeTLSConfigs[connection.QUIC], connOptions.FeatureSnapshot.PostQuantum)
if err != nil {
connLogger.ConnAwareLogger().Err(err).Msgf("failed to get curve preferences")
return err, true
return fmt.Errorf("could not create TLS configuration: %w", err), true
}
connLogger.Logger().Info().Msgf("Tunnel connection curve preferences: %v", curvePref)
tlsConfig.CurvePreferences = curvePref
connLogger.Logger().Info().Msgf("Tunnel connection curve preferences: %v", config.CurvePreferences)
// quic-go 0.44 increases the initial packet size to 1280 by default. That breaks anyone running tunnel through WARP
// because WARP MTU is 1280.
@@ -596,7 +602,7 @@ func (e *EdgeTunnelServer) serveQUIC(
conn, err := connection.DialQuic(
ctx,
quicConfig,
tlsConfig,
config,
edgeAddr,
e.edgeBindAddr,
connIndex,
+3 -4
View File
@@ -7,6 +7,7 @@ import (
"github.com/quic-go/quic-go"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/cloudflare/cloudflared/connection"
"github.com/cloudflare/cloudflared/edgediscovery"
@@ -43,12 +44,11 @@ func TestWaitForBackoffFallback(t *testing.T) {
"auto",
"",
false,
false,
mockFetcher.fetch(),
resolveTTL,
&log,
)
assert.NoError(t, err)
require.NoError(t, err)
initProtocol := protocolSelector.Current()
assert.Equal(t, connection.QUIC, initProtocol)
@@ -106,12 +106,11 @@ func TestWaitForBackoffFallback(t *testing.T) {
"quic",
"",
false,
false,
mockFetcher.fetch(),
resolveTTL,
&log,
)
assert.NoError(t, err)
require.NoError(t, err)
protoFallback = &protocolFallback{backoff, protocolSelector.Current(), false}
for i := 0; i < int(maxRetries-1); i++ {
protoFallback.BackoffTimer() // simulate retry