mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
feat: TLS 1.3 minimum + PQ encryption (#243)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user