From a33b91553d897da3e3e194971848801e2a4e26ee Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Thu, 21 May 2026 11:52:41 +1000 Subject: [PATCH] feat: TLS 1.3 minimum + PQ encryption (#243) --- internal/tlsconfig/curves.go | 37 +++++ internal/tlsconfig/curves_test.go | 169 +++++++++++++++++++++ spinifex/daemon/daemon.go | 6 +- spinifex/formation/formation.go | 6 +- spinifex/lbagent/agent.go | 4 +- spinifex/services/awsgw/awsgw.go | 7 +- spinifex/services/spinifexui/spinifexui.go | 6 +- spinifex/utils/nats.go | 5 +- tests/e2e/cert/tlsposture_test.go | 45 ++++++ tests/e2e/harness/tls.go | 19 +++ 10 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 internal/tlsconfig/curves.go create mode 100644 internal/tlsconfig/curves_test.go create mode 100644 tests/e2e/cert/tlsposture_test.go diff --git a/internal/tlsconfig/curves.go b/internal/tlsconfig/curves.go new file mode 100644 index 000000000..f6d1a346a --- /dev/null +++ b/internal/tlsconfig/curves.go @@ -0,0 +1,37 @@ +// Package tlsconfig pins the cluster-wide TLS 1.3 curve preference list. +// +// The list is hardcoded because mixed classification on a single cluster is +// not supported and the certified FIPS build is itself the per-deployment +// choice. A typo in a config string would silently downgrade where a missing +// constant cannot. +package tlsconfig + +import "crypto/tls" + +// Curves is the fixed TLS 1.3 curve allowlist: +// +// - X25519MLKEM768 — Cat 3 ML-KEM hybrid, wins under Go 1.26 stdlib filter +// - SecP384r1MLKEM1024 — Cat 5 ML-KEM hybrid, fallback for peers that prefer it +// - X25519 — classical TLS 1.3 fallback for SDKs without ML-KEM (aws CLI v2 today) +// - CurveP384 — classical TLS 1.3 fallback (Java/.NET enterprise SDKs) +// +// PQ-capable peers (browsers since 2024, Go aws-sdk v2 with Go 1.24+) always +// negotiate the hybrid first; classical fallback only triggers for peers that +// cannot do ML-KEM. Those connections still get TLS 1.3 + AES-256-GCM but +// remain HNDL-exposed — accepted trade-off until AWS CLI v2 / bundled +// OpenSSL ships ML-KEM (currently lagging upstream OpenSSL 3.5). +// +// Weak/niche entries from the Go stdlib default (SecP256r1MLKEM768, P-256, +// P-521) are intentionally excluded so they cannot be advertised. +// +// Go 1.26 stdlib treats tls.Config.CurvePreferences as an allowlist filter +// applied to the stdlib default order, not as a user-supplied order. +// +// Callers must not mutate this slice. Assign it directly to +// tls.Config.CurvePreferences; do not append to it. +var Curves = []tls.CurveID{ + tls.X25519MLKEM768, + tls.SecP384r1MLKEM1024, + tls.X25519, + tls.CurveP384, +} diff --git a/internal/tlsconfig/curves_test.go b/internal/tlsconfig/curves_test.go new file mode 100644 index 000000000..dc86d3b71 --- /dev/null +++ b/internal/tlsconfig/curves_test.go @@ -0,0 +1,169 @@ +package tlsconfig_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + "github.com/mulgadc/spinifex/internal/tlsconfig" +) + +func TestCurves_OnlyApprovedEntries(t *testing.T) { + want := []tls.CurveID{ + tls.X25519MLKEM768, + tls.SecP384r1MLKEM1024, + tls.X25519, + tls.CurveP384, + } + if !slices.Equal(tlsconfig.Curves, want) { + t.Errorf("Curves = %v, want %v", tlsconfig.Curves, want) + } +} + +func TestCurves_ExcludesWeakPrimitives(t *testing.T) { + forbidden := []tls.CurveID{ + tls.SecP256r1MLKEM768, // weakest ML-KEM hybrid (P-256 component) + tls.CurveP256, // not CNSA 2.0 + tls.CurveP521, // niche, no current peers + } + for _, c := range forbidden { + if slices.Contains(tlsconfig.Curves, c) { + t.Errorf("Curves includes forbidden curve %v", c) + } + } +} + +// TestIntegration_NegotiatesPQHybrid spins up an httptest TLS server +// configured with Curves + TLS 1.3 minimum, dials it, and asserts a PQ +// hybrid is negotiated at TLS 1.3. Per Go 1.26 stdlib filter behavior the +// winner is X25519MLKEM768; the assertion checks for any approved hybrid +// to remain stable across stdlib reorderings of our 4 entries. +func TestIntegration_NegotiatesPQHybrid(t *testing.T) { + cert := selfSignedCert(t) + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + })) + srv.TLS = &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS13, + CurvePreferences: tlsconfig.Curves, + } + srv.StartTLS() + defer srv.Close() + + pool := x509.NewCertPool() + pool.AddCert(srv.Certificate()) + + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: pool, + CurvePreferences: tlsconfig.Curves, + }, + }, + } + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("https get: %v", err) + } + defer resp.Body.Close() + + state := resp.TLS + if state == nil { + t.Fatal("response has no TLS state") + } + if state.Version != tls.VersionTLS13 { + t.Errorf("negotiated version = 0x%x, want TLS 1.3 (0x%x)", state.Version, tls.VersionTLS13) + } + pqHybrids := []tls.CurveID{tls.X25519MLKEM768, tls.SecP384r1MLKEM1024} + if !slices.Contains(pqHybrids, state.CurveID) { + t.Errorf("negotiated curve = %v, want one of %v (PQ hybrid)", state.CurveID, pqHybrids) + } +} + +// TestIntegration_TLS12ClientRejected confirms MinVersion: tls.VersionTLS13 +// is enforced — a TLS 1.2-pinned client gets a handshake error rather than +// silently downgrading. This is the negative test that closes the HNDL gap +// on external surfaces. +func TestIntegration_TLS12ClientRejected(t *testing.T) { + cert := selfSignedCert(t) + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + })) + srv.TLS = &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS13, + CurvePreferences: tlsconfig.Curves, + } + srv.StartTLS() + defer srv.Close() + + pool := x509.NewCertPool() + pool.AddCert(srv.Certificate()) + + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: pool, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS12, + }, + }, + } + _, err := client.Get(srv.URL) + if err == nil { + t.Fatal("TLS 1.2-pinned client succeeded; MinVersion not enforced") + } + if !strings.Contains(err.Error(), "protocol version") && + !strings.Contains(err.Error(), "tls:") { + t.Errorf("expected protocol-version handshake error, got: %v", err) + } +} + +func selfSignedCert(t *testing.T) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "tlsconfig-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create cert: %v", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + pair, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + t.Fatalf("X509KeyPair: %v", err) + } + return pair +} diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go index a23b1a2fd..7801f2d22 100644 --- a/spinifex/daemon/daemon.go +++ b/spinifex/daemon/daemon.go @@ -28,6 +28,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" "github.com/go-chi/chi/v5" + "github.com/mulgadc/spinifex/internal/tlsconfig" "github.com/mulgadc/spinifex/spinifex/admin" "github.com/mulgadc/spinifex/spinifex/awserrors" "github.com/mulgadc/spinifex/spinifex/config" @@ -1609,8 +1610,9 @@ func (d *Daemon) ClusterManager() error { } tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS13, + CurvePreferences: tlsconfig.Curves, } d.clusterServer = &http.Server{ diff --git a/spinifex/formation/formation.go b/spinifex/formation/formation.go index 811cdc48e..d0e72ced2 100644 --- a/spinifex/formation/formation.go +++ b/spinifex/formation/formation.go @@ -13,6 +13,8 @@ import ( "net/http" "sync" "time" + + "github.com/mulgadc/spinifex/internal/tlsconfig" ) // NodeInfo describes a node participating in cluster formation. @@ -233,7 +235,9 @@ func (fs *FormationServer) Start(bindAddr string) error { IdleTimeout: 30 * time.Second, ReadHeaderTimeout: 5 * time.Second, TLSConfig: &tls.Config{ - Certificates: []tls.Certificate{tlsCert}, + Certificates: []tls.Certificate{tlsCert}, + MinVersion: tls.VersionTLS13, + CurvePreferences: tlsconfig.Curves, }, } diff --git a/spinifex/lbagent/agent.go b/spinifex/lbagent/agent.go index e64f36e81..4fffb401b 100644 --- a/spinifex/lbagent/agent.go +++ b/spinifex/lbagent/agent.go @@ -22,6 +22,7 @@ import ( "github.com/aws/aws-sdk-go/aws/credentials" v4 "github.com/aws/aws-sdk-go/aws/signer/v4" + "github.com/mulgadc/spinifex/internal/tlsconfig" ) const ( @@ -77,7 +78,8 @@ func New(lbID, gatewayURL, accessKey, secretKey, region string) (*Agent, error) Timeout: 10 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, + MinVersion: tls.VersionTLS13, + CurvePreferences: tlsconfig.Curves, }, MaxIdleConns: 2, IdleConnTimeout: 30 * time.Second, diff --git a/spinifex/services/awsgw/awsgw.go b/spinifex/services/awsgw/awsgw.go index 3850a375c..72c0e4682 100644 --- a/spinifex/services/awsgw/awsgw.go +++ b/spinifex/services/awsgw/awsgw.go @@ -11,6 +11,7 @@ import ( "time" "github.com/mulgadc/predastore/ratelimit" + "github.com/mulgadc/spinifex/internal/tlsconfig" "github.com/mulgadc/spinifex/spinifex/admin" "github.com/mulgadc/spinifex/spinifex/config" "github.com/mulgadc/spinifex/spinifex/gateway" @@ -190,8 +191,10 @@ func launchService(config *config.ClusterConfig) error { Handler: handler, ReadHeaderTimeout: 10 * time.Second, TLSConfig: &tls.Config{ - Certificates: []tls.Certificate{cert}, - NextProtos: []string{"h2", "http/1.1"}, + Certificates: []tls.Certificate{cert}, + NextProtos: []string{"h2", "http/1.1"}, + MinVersion: tls.VersionTLS13, + CurvePreferences: tlsconfig.Curves, }, } diff --git a/spinifex/services/spinifexui/spinifexui.go b/spinifex/services/spinifexui/spinifexui.go index 3ad55e0b0..4c797b2a9 100644 --- a/spinifex/services/spinifexui/spinifexui.go +++ b/spinifex/services/spinifexui/spinifexui.go @@ -18,6 +18,7 @@ import ( "time" "github.com/go-chi/chi/v5/middleware" + "github.com/mulgadc/spinifex/internal/tlsconfig" "github.com/mulgadc/spinifex/spinifex/utils" ) @@ -262,8 +263,9 @@ func (svc *Service) launchService() error { } tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS13, + CurvePreferences: tlsconfig.Curves, } splitLn := &tlsSplitListener{ diff --git a/spinifex/utils/nats.go b/spinifex/utils/nats.go index cafdce94a..91fea4fd6 100644 --- a/spinifex/utils/nats.go +++ b/spinifex/utils/nats.go @@ -11,6 +11,7 @@ import ( "os" "time" + "github.com/mulgadc/spinifex/internal/tlsconfig" "github.com/nats-io/nats.go" ) @@ -79,7 +80,9 @@ func ConnectNATS(host, token, caCertPath string, opts ...RetryOption) (*nats.Con return nil, fmt.Errorf("%w from %s", ErrCACertParse, caCertPath) } natsOpts = append(natsOpts, nats.Secure(&tls.Config{ - RootCAs: pool, + RootCAs: pool, + MinVersion: tls.VersionTLS13, + CurvePreferences: tlsconfig.Curves, })) } diff --git a/tests/e2e/cert/tlsposture_test.go b/tests/e2e/cert/tlsposture_test.go new file mode 100644 index 000000000..3a7b015e1 --- /dev/null +++ b/tests/e2e/cert/tlsposture_test.go @@ -0,0 +1,45 @@ +//go:build e2e + +package cert + +import ( + "crypto/tls" + "slices" + "testing" + + "github.com/mulgadc/spinifex/tests/e2e/harness" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTLSPosture asserts the deployed awsgw listeners actually negotiate +// TLS 1.3 and a PQ-hybrid curve from the pinned policy. This is the live +// proof for the PQC Phase 2.1 work (docs/development/improvements/pqc-roadmap.md): +// it runs under the production FIPS build (GOFIPS140=v1.0.0, +// GODEBUG=fips140=on) and catches regressions where unit tests pass but +// something in the deploy path silently disables ML-KEM. +func TestTLSPosture(t *testing.T) { + env := harness.LoadEnv(t) + + approvedHybrids := []tls.CurveID{ + tls.X25519MLKEM768, // Cat 3, wins under Go 1.26 stdlib default order + tls.SecP384r1MLKEM1024, // Cat 5, CNSA 2.0 + } + + for _, ip := range env.ServiceIPs { + ip := ip + t.Run(ip, func(t *testing.T) { + t.Parallel() + version, curve, err := harness.FetchTLSPosture(ip, env.AWSGWPort, env.DefaultTimeout) + require.NoErrorf(t, err, "tls posture probe %s:%d", ip, env.AWSGWPort) + + assert.Equalf(t, uint16(tls.VersionTLS13), version, + "negotiated version on %s:%d = 0x%x, want TLS 1.3", ip, env.AWSGWPort, version) + assert.Truef(t, slices.Contains(approvedHybrids, curve), + "negotiated curve on %s:%d = %v, want one of %v (PQ hybrid)", + ip, env.AWSGWPort, curve, approvedHybrids) + t.Logf("awsgw %s:%d negotiated version=%s curve=%v", + ip, env.AWSGWPort, tls.VersionName(version), curve) + }) + } +} diff --git a/tests/e2e/harness/tls.go b/tests/e2e/harness/tls.go index 68ecb7258..495a7d2b9 100644 --- a/tests/e2e/harness/tls.go +++ b/tests/e2e/harness/tls.go @@ -53,6 +53,25 @@ func HTTPSGet(url string, caPool *x509.CertPool, timeout time.Duration) (int, [] return resp.StatusCode, body, nil } +// FetchTLSPosture opens a TLS connection to host:port and returns the +// negotiated version and curve. Trust is bypassed because the goal is to +// observe the handshake, not validate the cert chain. Used by e2e tests to +// assert that deployed services actually negotiate TLS 1.3 + PQ hybrid +// (see internal/tlsconfig.Curves) under the production FIPS build. +func FetchTLSPosture(host string, port int, timeout time.Duration) (version uint16, curve tls.CurveID, err error) { + dialer := &net.Dialer{Timeout: timeout} + conn, err := tls.DialWithDialer(dialer, "tcp", fmt.Sprintf("%s:%d", host, port), &tls.Config{ + InsecureSkipVerify: true, + ServerName: host, + }) + if err != nil { + return 0, 0, fmt.Errorf("tls dial %s:%d: %w", host, port, err) + } + defer conn.Close() + state := conn.ConnectionState() + return state.Version, state.CurveID, nil +} + // LoadCAPool reads a PEM-encoded CA bundle from path and returns an x509 pool. func LoadCAPool(path string) (*x509.CertPool, error) { raw, err := os.ReadFile(path)