feat: TLS 1.3 minimum + PQ encryption (#243)

This commit is contained in:
Julian Sommer
2026-05-21 11:52:41 +10:00
committed by GitHub
parent 050ac8f861
commit a33b91553d
10 changed files with 295 additions and 9 deletions
+37
View File
@@ -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,
}
+169
View File
@@ -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
}
+4 -2
View File
@@ -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{
+5 -1
View File
@@ -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,
},
}
+3 -1
View File
@@ -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,
+5 -2
View File
@@ -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,
},
}
+4 -2
View File
@@ -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{
+4 -1
View File
@@ -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,
}))
}
+45
View File
@@ -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)
})
}
}
+19
View File
@@ -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)