Files
Ilker ManapandClaude Opus 5 1e3cafdc86 Add a private CA and mutual TLS for the agent stream
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>
2026-09-16 07:28:56 +02:00

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)
}
}