A node's identity was whatever the agent said it was. With mutual TLS the control plane compares the client certificate's common name against the claimed node id and refuses the connection when they disagree, so identity becomes something a node proves rather than something it picks. A public authority cannot supply these certificates: Let's Encrypt and its peers stopped issuing the clientAuth usage, and Go verifies client chains against it. Hence internal/tlsconf/ca.go, a small authority whose key lives on one machine and whose root is trusted everywhere, and `llmgw ctl cert` over it so issuing is reproducible instead of remembered openssl. The server certificate's hosts matter more than they look: hostname verification reads subject alternative names and ignores the common name entirely, so a certificate without the address agents dial — bare IP included — is rejected no matter what its subject says. There is a test for exactly that, because it is the failure everyone hits once. requireIdentity stays wired to false until the configuration lands, so this commit changes no running behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
269 lines
7.9 KiB
Go
269 lines
7.9 KiB
Go
package tlsconf
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"golang.org/x/net/http2"
|
|
)
|
|
|
|
func newCA(t *testing.T) *CA {
|
|
t.Helper()
|
|
ca, err := NewCA("llmgw test CA")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ca
|
|
}
|
|
|
|
// writeCluster issues a server and client certificate and returns the configs
|
|
// that would load them, exactly as the real components do.
|
|
func writeCluster(t *testing.T, ca *CA, serverHosts []string, nodeID string) (server, client Config) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
|
|
if err := ca.Write(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv, err := ca.IssueServer("llmgw-ctl", serverHosts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := srv.Write(dir, "server"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cli, err := ca.IssueClient(nodeID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cli.Write(dir, "client"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
caPath := filepath.Join(dir, "ca.pem")
|
|
return Config{CA: caPath, Cert: filepath.Join(dir, "server.pem"), Key: filepath.Join(dir, "server-key.pem")},
|
|
Config{CA: caPath, Cert: filepath.Join(dir, "client.pem"), Key: filepath.Join(dir, "client-key.pem")}
|
|
}
|
|
|
|
func TestMutualAuthenticationSucceeds(t *testing.T) {
|
|
ca := newCA(t)
|
|
serverCfg, clientCfg := writeCluster(t, ca, []string{"127.0.0.1"}, "llmgw-node01")
|
|
|
|
stls, err := serverCfg.ServerConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// The identity the handler sees is what the control plane keys on.
|
|
if len(r.TLS.PeerCertificates) == 0 {
|
|
t.Error("no client certificate reached the handler")
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(r.TLS.PeerCertificates[0].Subject.CommonName))
|
|
}))
|
|
srv.TLS = stls
|
|
srv.StartTLS()
|
|
defer srv.Close()
|
|
|
|
ctls, err := clientCfg.ClientConfig("127.0.0.1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := &http.Client{Transport: &http2.Transport{TLSClientConfig: ctls}}
|
|
|
|
resp, err := c.Get(srv.URL) //nolint:noctx // test
|
|
if err != nil {
|
|
t.Fatalf("mutual authentication failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
buf := make([]byte, 64)
|
|
n, _ := resp.Body.Read(buf)
|
|
if got := string(buf[:n]); got != "llmgw-node01" {
|
|
t.Errorf("the handler saw common name %q", got)
|
|
}
|
|
}
|
|
|
|
func TestServerRejectsClientWithoutCertificate(t *testing.T) {
|
|
// An agent that cannot prove who it is must not reach the control plane at
|
|
// all; accepting it unauthenticated would leave identity as forgeable as it
|
|
// was before TLS.
|
|
ca := newCA(t)
|
|
serverCfg, _ := writeCluster(t, ca, []string{"127.0.0.1"}, "llmgw-node01")
|
|
|
|
stls, _ := serverCfg.ServerConfig()
|
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
srv.TLS = stls
|
|
srv.StartTLS()
|
|
defer srv.Close()
|
|
|
|
pool := x509.NewCertPool()
|
|
pool.AppendCertsFromPEM(ca.PEM)
|
|
c := &http.Client{Transport: &http2.Transport{TLSClientConfig: &tls.Config{
|
|
RootCAs: pool, ServerName: "127.0.0.1", MinVersion: tls.VersionTLS13,
|
|
NextProtos: []string{"h2"},
|
|
}}}
|
|
|
|
if _, err := c.Get(srv.URL); err == nil { //nolint:noctx // test
|
|
t.Fatal("a client with no certificate was accepted")
|
|
}
|
|
}
|
|
|
|
func TestServerRejectsCertificateFromAnotherCA(t *testing.T) {
|
|
ca := newCA(t)
|
|
serverCfg, _ := writeCluster(t, ca, []string{"127.0.0.1"}, "llmgw-node01")
|
|
|
|
// An attacker's own authority, with a certificate naming a real node.
|
|
other := newCA(t)
|
|
_, foreign := writeCluster(t, other, []string{"127.0.0.1"}, "llmgw-node01")
|
|
|
|
stls, _ := serverCfg.ServerConfig()
|
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
srv.TLS = stls
|
|
srv.StartTLS()
|
|
defer srv.Close()
|
|
|
|
ctls, _ := foreign.ClientConfig("127.0.0.1")
|
|
// It still needs to trust the server, or the failure would prove nothing.
|
|
pool := x509.NewCertPool()
|
|
pool.AppendCertsFromPEM(ca.PEM)
|
|
ctls.RootCAs = pool
|
|
|
|
c := &http.Client{Transport: &http2.Transport{TLSClientConfig: ctls}}
|
|
if _, err := c.Get(srv.URL); err == nil { //nolint:noctx // test
|
|
t.Fatal("a certificate from an unrelated authority was accepted")
|
|
}
|
|
}
|
|
|
|
func TestClientRejectsServerWithoutMatchingSAN(t *testing.T) {
|
|
// The trap worth having a test for: hostname verification reads subject
|
|
// alternative names and ignores the common name entirely, so a certificate
|
|
// issued only for a DNS name fails when agents dial an IP.
|
|
ca := newCA(t)
|
|
serverCfg, clientCfg := writeCluster(t, ca, []string{"llmgw-ctl.example"}, "llmgw-node01")
|
|
|
|
stls, _ := serverCfg.ServerConfig()
|
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
srv.TLS = stls
|
|
srv.StartTLS()
|
|
defer srv.Close()
|
|
|
|
ctls, _ := clientCfg.ClientConfig("127.0.0.1")
|
|
c := &http.Client{Transport: &http2.Transport{TLSClientConfig: ctls}}
|
|
|
|
_, err := c.Get(srv.URL) //nolint:noctx // test
|
|
if err == nil {
|
|
t.Fatal("a certificate with no matching SAN was accepted")
|
|
}
|
|
}
|
|
|
|
func TestIssuedCertificatesCarryTheRightUsage(t *testing.T) {
|
|
// The reason a publicly issued certificate cannot be used here: authorities
|
|
// stopped including clientAuth, and Go verifies client chains against it.
|
|
ca := newCA(t)
|
|
|
|
srv, err := ca.IssueServer("llmgw-ctl", []string{"10.0.0.140"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cli, err := ca.IssueClient("llmgw-node01")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
parse := func(l Leaf) *x509.Certificate {
|
|
t.Helper()
|
|
c, err := tls.X509KeyPair(l.CertPEM, l.KeyPEM)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
leaf, err := x509.ParseCertificate(c.Certificate[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return leaf
|
|
}
|
|
|
|
s := parse(srv)
|
|
if len(s.ExtKeyUsage) != 1 || s.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth {
|
|
t.Errorf("server EKU=%v", s.ExtKeyUsage)
|
|
}
|
|
if len(s.IPAddresses) != 1 || !s.IPAddresses[0].Equal(net.ParseIP("10.0.0.140")) {
|
|
t.Errorf("the IP SAN is missing: %v", s.IPAddresses)
|
|
}
|
|
|
|
c := parse(cli)
|
|
if len(c.ExtKeyUsage) != 1 || c.ExtKeyUsage[0] != x509.ExtKeyUsageClientAuth {
|
|
t.Errorf("client EKU=%v", c.ExtKeyUsage)
|
|
}
|
|
if c.Subject.CommonName != "llmgw-node01" {
|
|
t.Errorf("the node id must be the common name, got %q", c.Subject.CommonName)
|
|
}
|
|
}
|
|
|
|
func TestServerCertificateRequiresHosts(t *testing.T) {
|
|
ca := newCA(t)
|
|
if _, err := ca.IssueServer("llmgw-ctl", nil); err == nil {
|
|
t.Error("a server certificate with no SAN was issued; every client would reject it")
|
|
}
|
|
}
|
|
|
|
func TestCARoundTripsThroughDisk(t *testing.T) {
|
|
ca := newCA(t)
|
|
dir := t.TempDir()
|
|
if err := ca.Write(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
loaded, err := LoadCA(filepath.Join(dir, "ca.pem"), filepath.Join(dir, "ca-key.pem"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A certificate issued by the reloaded authority must verify against the
|
|
// original, or issuing from a second machine would silently produce
|
|
// certificates nothing accepts.
|
|
leaf, err := loaded.IssueClient("llmgw-node02")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pool := x509.NewCertPool()
|
|
pool.AppendCertsFromPEM(ca.PEM)
|
|
|
|
kp, _ := tls.X509KeyPair(leaf.CertPEM, leaf.KeyPEM)
|
|
parsed, _ := x509.ParseCertificate(kp.Certificate[0])
|
|
if _, err := parsed.Verify(x509.VerifyOptions{
|
|
Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
|
}); err != nil {
|
|
t.Errorf("a certificate from the reloaded CA does not verify: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConfigValidation(t *testing.T) {
|
|
if err := (Config{CA: "x", Cert: "y"}).Validate(); err == nil {
|
|
t.Error("a certificate without its key was accepted")
|
|
}
|
|
if err := (Config{Cert: "y", Key: "z"}).Validate(); err == nil {
|
|
t.Error("a certificate without a CA was accepted")
|
|
}
|
|
if err := (Config{Insecure: true, CA: "x"}).Validate(); err == nil {
|
|
t.Error("insecure alongside certificate paths was accepted")
|
|
}
|
|
if err := (Config{Insecure: true}).Validate(); err != nil {
|
|
t.Errorf("plain insecure was rejected: %v", err)
|
|
}
|
|
if err := (Config{}).Validate(); err != nil {
|
|
t.Errorf("an empty config was rejected: %v", err)
|
|
}
|
|
}
|