diff --git a/internal/cli/adapter/adapter_test.go b/internal/cli/adapter/adapter_test.go index 02bfbcc6..94aed820 100644 --- a/internal/cli/adapter/adapter_test.go +++ b/internal/cli/adapter/adapter_test.go @@ -9,6 +9,7 @@ import ( "time" "mcp-runtime/internal/cli/core" + "mcp-runtime/pkg/certauth" ) func TestAdapterCommandRegistersProxyAndStdio(t *testing.T) { @@ -29,8 +30,8 @@ func TestAdapterCommandRegistersProxyAndStdio(t *testing.T) { func TestWriteCredentialFileUsesOutputRoot(t *testing.T) { t.Parallel() dir := t.TempDir() - if err := writeCredentialFile(dir, "client.key", []byte("secret"), 0o600); err != nil { - t.Fatalf("writeCredentialFile() error = %v", err) + if err := certauth.WritePrivateFile(dir, "client.key", []byte("secret"), 0o600); err != nil { + t.Fatalf("WritePrivateFile() error = %v", err) } data, err := os.ReadFile(filepath.Join(dir, "client.key")) if err != nil { @@ -39,8 +40,8 @@ func TestWriteCredentialFileUsesOutputRoot(t *testing.T) { if string(data) != "secret" { t.Fatalf("credential data = %q, want secret", string(data)) } - if err := writeCredentialFile(dir, "../escape", []byte("nope"), 0o600); err == nil { - t.Fatal("writeCredentialFile() allowed path traversal") + if err := certauth.WritePrivateFile(dir, "../escape", []byte("nope"), 0o600); err == nil { + t.Fatal("WritePrivateFile() allowed path traversal") } } diff --git a/internal/cli/adapter/credentials.go b/internal/cli/adapter/credentials.go index 9f0c6ff4..baa9bf06 100644 --- a/internal/cli/adapter/credentials.go +++ b/internal/cli/adapter/credentials.go @@ -2,17 +2,12 @@ package adapter import ( "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "encoding/pem" "fmt" - "net/url" "strings" "time" "mcp-runtime/internal/cli/platformapi" + "mcp-runtime/pkg/certauth" ) // issuedCredential is a platform-signed, session-bound mTLS credential. The @@ -30,33 +25,6 @@ type issuedCredential struct { ExpiresAt time.Time } -// buildSessionCSR generates a fresh P-256 key and a CSR whose only SAN is the -// session's SPIFFE URI. It returns the PKCS#8 key PEM and the CSR PEM. -func buildSessionCSR(trustDomain, namespace, sessionName string) (keyPEM, csrPEM []byte, spiffe *url.URL, err error) { - spiffe = &url.URL{ - Scheme: "spiffe", - Host: trustDomain, - Path: "/ns/" + namespace + "/session/" + sessionName, - } - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, nil, nil, fmt.Errorf("generate client key: %w", err) - } - csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ - URIs: []*url.URL{spiffe}, - }, key) - if err != nil { - return nil, nil, nil, fmt.Errorf("create CSR: %w", err) - } - keyDER, err := x509.MarshalPKCS8PrivateKey(key) - if err != nil { - return nil, nil, nil, fmt.Errorf("marshal client key: %w", err) - } - keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) - csrPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}) - return keyPEM, csrPEM, spiffe, nil -} - // issueAdapterCredential issues (or reuses) an adapter session for the target // MCPServer, then signs a session-bound client certificate for it. Each call // produces a fresh keypair, so rotating callers never reuse private keys. @@ -74,7 +42,7 @@ func issueAdapterCredential(ctx context.Context, client *platformapi.PlatformCli return issuedCredential{}, fmt.Errorf("create adapter session: %w", err) } - keyPEM, csrPEM, _, err := buildSessionCSR(trustDomain, session.Namespace, session.Name) + keyPEM, csrPEM, _, err := certauth.BuildSessionCSR(trustDomain, session.Namespace, session.Name) if err != nil { return issuedCredential{}, err } diff --git a/internal/cli/adapter/enroll.go b/internal/cli/adapter/enroll.go index 47971465..b050126c 100644 --- a/internal/cli/adapter/enroll.go +++ b/internal/cli/adapter/enroll.go @@ -11,6 +11,7 @@ import ( "mcp-runtime/internal/cli/core" "mcp-runtime/internal/cli/platformapi" + "mcp-runtime/pkg/certauth" ) func newEnrollCmd(_ *core.Runtime) *cobra.Command { @@ -67,7 +68,7 @@ func enrollAdapterCertificate(ctx context.Context, client *platformapi.PlatformC {"ca.crt", cred.CABundle, 0o644}, } for _, file := range files { - if err := writeCredentialFile(dir, file.name, file.data, file.mode); err != nil { + if err := certauth.WritePrivateFile(dir, file.name, file.data, file.mode); err != nil { return fmt.Errorf("write %s: %w", file.name, err) } } @@ -77,27 +78,6 @@ func enrollAdapterCertificate(ctx context.Context, client *platformapi.PlatformC return nil } -func writeCredentialFile(dir, name string, data []byte, mode os.FileMode) error { - root, err := os.OpenRoot(dir) - if err != nil { - return err - } - defer root.Close() - file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) - if err != nil { - return err - } - if err := file.Chmod(mode); err != nil { - _ = file.Close() - return err - } - if _, err := file.Write(data); err != nil { - _ = file.Close() - return err - } - return file.Close() -} - func envOrDefault(name, fallback string) string { if value := strings.TrimSpace(os.Getenv(name)); value != "" { return value diff --git a/internal/cli/adapter/mtls_test.go b/internal/cli/adapter/mtls_test.go index 3004213f..4d9f54ac 100644 --- a/internal/cli/adapter/mtls_test.go +++ b/internal/cli/adapter/mtls_test.go @@ -20,6 +20,7 @@ import ( "mcp-runtime/internal/agentadapter" "mcp-runtime/internal/cli/platformapi" + "mcp-runtime/pkg/certauth" ) // fakeMTLSServer extends the platform session endpoint with a CSR-signing @@ -120,32 +121,19 @@ func fakeMTLSServer(t *testing.T, expiresAt time.Time, certCalls *int32) (*httpt } func TestBuildSessionCSROnlySpiffeSAN(t *testing.T) { - keyPEM, csrPEM, _, err := buildSessionCSR("mcpruntime.org", "mcp-team-acme", "adapter-xyz") + keyPEM, csrPEM, spiffeID, err := certauth.BuildSessionCSR("mcpruntime.org", "mcp-team-acme", "adapter-xyz") if err != nil { - t.Fatalf("buildSessionCSR: %v", err) + t.Fatalf("BuildSessionCSR: %v", err) } if block, _ := pem.Decode(keyPEM); block == nil || block.Type != "PRIVATE KEY" { t.Fatal("key PEM is not a PRIVATE KEY block") } - block, _ := pem.Decode(csrPEM) - if block == nil || block.Type != "CERTIFICATE REQUEST" { - t.Fatal("csr PEM is not a CERTIFICATE REQUEST block") - } - csr, err := x509.ParseCertificateRequest(block.Bytes) - if err != nil { - t.Fatalf("parse CSR: %v", err) - } - if err := csr.CheckSignature(); err != nil { - t.Fatalf("csr signature: %v", err) - } - // Mirror the server-side validateAdapterCSR contract: exactly one SPIFFE URI - // SAN and no DNS/email/IP names. want := "spiffe://mcpruntime.org/ns/mcp-team-acme/session/adapter-xyz" - if len(csr.URIs) != 1 || csr.URIs[0].String() != want { - t.Fatalf("URIs = %v, want exactly [%s]", csr.URIs, want) + if spiffeID != want { + t.Fatalf("spiffeID = %q, want %q", spiffeID, want) } - if len(csr.DNSNames) != 0 || len(csr.EmailAddresses) != 0 || len(csr.IPAddresses) != 0 { - t.Fatalf("csr has unexpected SANs: dns=%v email=%v ip=%v", csr.DNSNames, csr.EmailAddresses, csr.IPAddresses) + if _, err := certauth.ValidateCSRPEM(string(csrPEM), want); err != nil { + t.Fatalf("ValidateCSRPEM: %v", err) } } diff --git a/pkg/certauth/csr.go b/pkg/certauth/csr.go new file mode 100644 index 00000000..7acd14b2 --- /dev/null +++ b/pkg/certauth/csr.go @@ -0,0 +1,94 @@ +// Package certauth provides shared certificate signing request helpers for +// session-bound mTLS credentials issued by the platform API and adapter CLI. +package certauth + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "net/url" + "os" + "strings" + + "mcp-runtime/pkg/identity" +) + +// BuildSessionCSR generates a fresh P-256 key and a CSR whose only SAN is the +// session SPIFFE URI. It returns PKCS#8 key PEM, CSR PEM, and the SPIFFE ID string. +func BuildSessionCSR(trustDomain, namespace, sessionName string) (keyPEM, csrPEM []byte, spiffeID string, err error) { + trustDomain = strings.TrimSpace(trustDomain) + if trustDomain == "" { + return nil, nil, "", fmt.Errorf("trust domain must not be empty") + } + spiffeID = identity.SessionSPIFFEID(trustDomain, namespace, sessionName) + spiffe, err := url.Parse(spiffeID) + if err != nil { + return nil, nil, "", fmt.Errorf("parse session SPIFFE URI: %w", err) + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, "", fmt.Errorf("generate client key: %w", err) + } + csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + URIs: []*url.URL{spiffe}, + }, key) + if err != nil { + return nil, nil, "", fmt.Errorf("create CSR: %w", err) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, nil, "", fmt.Errorf("marshal client key: %w", err) + } + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + csrPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}) + return keyPEM, csrPEM, spiffeID, nil +} + +// ValidateCSRPEM parses and validates a PEM CSR. The CSR must be signed, carry +// exactly one URI SAN equal to expectedSPIFFEID, and must not include DNS, +// email, or IP subject alternative names. It returns the CSR DER on success. +func ValidateCSRPEM(raw, expectedSPIFFEID string) ([]byte, error) { + block, _ := pem.Decode([]byte(raw)) + if block == nil || block.Type != "CERTIFICATE REQUEST" { + return nil, fmt.Errorf("csr must be a PEM CERTIFICATE REQUEST") + } + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse csr: %w", err) + } + if err := csr.CheckSignature(); err != nil { + return nil, fmt.Errorf("csr signature verification failed: %w", err) + } + if len(csr.URIs) != 1 || csr.URIs[0].String() != expectedSPIFFEID { + return nil, fmt.Errorf("csr must contain exactly the SPIFFE URI %q", expectedSPIFFEID) + } + if len(csr.DNSNames) != 0 || len(csr.EmailAddresses) != 0 || len(csr.IPAddresses) != 0 { + return nil, fmt.Errorf("csr may not contain DNS, email, or IP subject alternative names") + } + return block.Bytes, nil +} + +// WritePrivateFile writes data to dir/name with mode, rejecting path traversal. +func WritePrivateFile(dir, name string, data []byte, mode os.FileMode) error { + root, err := os.OpenRoot(dir) + if err != nil { + return err + } + defer root.Close() + file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return err + } + if err := file.Chmod(mode); err != nil { + _ = file.Close() + return err + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + return err + } + return file.Close() +} diff --git a/pkg/certauth/csr_test.go b/pkg/certauth/csr_test.go new file mode 100644 index 00000000..a392d9d6 --- /dev/null +++ b/pkg/certauth/csr_test.go @@ -0,0 +1,80 @@ +package certauth + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "net/url" + "strings" + "testing" +) + +func TestBuildSessionCSR(t *testing.T) { + t.Parallel() + keyPEM, csrPEM, spiffeID, err := BuildSessionCSR("mcpruntime.org", "mcp-team-acme", "adapter-xyz") + if err != nil { + t.Fatalf("BuildSessionCSR: %v", err) + } + want := "spiffe://mcpruntime.org/ns/mcp-team-acme/session/adapter-xyz" + if spiffeID != want { + t.Fatalf("spiffeID = %q, want %q", spiffeID, want) + } + if _, err := ValidateCSRPEM(string(csrPEM), want); err != nil { + t.Fatalf("ValidateCSRPEM() error = %v", err) + } + if block, _ := pem.Decode(keyPEM); block == nil || block.Type != "PRIVATE KEY" { + t.Fatal("key PEM is not a PRIVATE KEY block") + } +} + +func TestValidateCSRPEM(t *testing.T) { + t.Parallel() + const expected = "spiffe://example.org/ns/team-a/session/session-1" + _, csrPEM, _, err := BuildSessionCSR("example.org", "team-a", "session-1") + if err != nil { + t.Fatal(err) + } + if _, err := ValidateCSRPEM(string(csrPEM), expected); err != nil { + t.Fatalf("ValidateCSRPEM() error = %v", err) + } + if _, err := ValidateCSRPEM(string(csrPEM), strings.Replace(expected, "session-1", "session-2", 1)); err == nil { + t.Fatal("ValidateCSRPEM() accepted a CSR for another session") + } +} + +func TestValidateCSRPEMRejectsAdditionalSANs(t *testing.T) { + t.Parallel() + const expected = "spiffe://example.org/ns/team-a/session/session-1" + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + uri, err := url.Parse(expected) + if err != nil { + t.Fatal(err) + } + csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + URIs: []*url.URL{uri}, + DNSNames: []string{"attacker.example"}, + }, key) + if err != nil { + t.Fatal(err) + } + csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}) + if _, err := ValidateCSRPEM(string(csrPEM), expected); err == nil { + t.Fatal("ValidateCSRPEM() accepted an additional DNS SAN") + } +} + +func TestWritePrivateFileRejectsTraversal(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := WritePrivateFile(dir, "client.key", []byte("secret"), 0o600); err != nil { + t.Fatalf("WritePrivateFile() error = %v", err) + } + if err := WritePrivateFile(dir, "../escape", []byte("nope"), 0o600); err == nil { + t.Fatal("WritePrivateFile() allowed path traversal") + } +} diff --git a/pkg/identity/certificate.go b/pkg/identity/certificate.go new file mode 100644 index 00000000..5f08796d --- /dev/null +++ b/pkg/identity/certificate.go @@ -0,0 +1,39 @@ +package identity + +import ( + "crypto/x509" + "strings" +) + +// CertificateHasURI reports whether cert carries want as a URI SAN. +func CertificateHasURI(cert *x509.Certificate, want string) bool { + if cert == nil { + return false + } + for _, uri := range cert.URIs { + if uri != nil && uri.String() == want { + return true + } + } + return false +} + +// FirstClientSPIFFEURI returns the first spiffe:// URI SAN on cert whose trust +// domain matches trustDomain when trustDomain is non-empty. Returns "" when no +// matching URI is present. +func FirstClientSPIFFEURI(cert *x509.Certificate, trustDomain string) string { + if cert == nil { + return "" + } + trustDomain = strings.TrimSpace(trustDomain) + for _, uri := range cert.URIs { + if uri == nil || !strings.EqualFold(uri.Scheme, "spiffe") { + continue + } + if trustDomain != "" && !strings.EqualFold(uri.Host, trustDomain) { + continue + } + return uri.String() + } + return "" +} diff --git a/pkg/identity/session.go b/pkg/identity/session.go new file mode 100644 index 00000000..b5e392f8 --- /dev/null +++ b/pkg/identity/session.go @@ -0,0 +1,49 @@ +// Package identity provides shared SPIFFE session identity parsing and formatting +// used by the gateway, platform API, and adapter clients. +package identity + +import ( + "fmt" + "net/url" + "strings" +) + +// SessionSPIFFEID returns the canonical session-bound SPIFFE URI: +// spiffe:///ns//session/. +func SessionSPIFFEID(trustDomain, namespace, session string) string { + trustDomain = strings.TrimSpace(trustDomain) + namespace = strings.TrimSpace(namespace) + session = strings.TrimSpace(session) + return fmt.Sprintf("spiffe://%s/ns/%s/session/%s", trustDomain, url.PathEscape(namespace), url.PathEscape(session)) +} + +// ParseSessionSPIFFE parses a session-bound SPIFFE ID of the form +// spiffe:///ns//session/. It is deliberately +// strict and fails closed on any deviation (wrong scheme, trust domain, or path). +func ParseSessionSPIFFE(raw, trustDomain string) (namespace, sessionID string, ok bool) { + uri, err := url.Parse(raw) + if err != nil || uri == nil { + return "", "", false + } + if !strings.EqualFold(uri.Scheme, "spiffe") || !strings.EqualFold(uri.Host, strings.TrimSpace(trustDomain)) { + return "", "", false + } + // SPIFFE IDs must not carry query parameters or fragments; reject them to + // avoid parameter-injection bypasses. + if uri.RawQuery != "" || uri.Fragment != "" { + return "", "", false + } + parts := strings.Split(strings.Trim(uri.EscapedPath(), "/"), "/") + if len(parts) != 4 || parts[0] != "ns" || parts[2] != "session" { + return "", "", false + } + namespace, err = url.PathUnescape(parts[1]) + if err != nil || strings.TrimSpace(namespace) == "" { + return "", "", false + } + sessionID, err = url.PathUnescape(parts[3]) + if err != nil || strings.TrimSpace(sessionID) == "" { + return "", "", false + } + return namespace, sessionID, true +} diff --git a/pkg/identity/session_test.go b/pkg/identity/session_test.go new file mode 100644 index 00000000..02274a7b --- /dev/null +++ b/pkg/identity/session_test.go @@ -0,0 +1,97 @@ +package identity + +import "testing" + +func TestSessionSPIFFEID(t *testing.T) { + t.Parallel() + got := SessionSPIFFEID("example.org", "team-a", "session-1") + want := "spiffe://example.org/ns/team-a/session/session-1" + if got != want { + t.Fatalf("SessionSPIFFEID() = %q, want %q", got, want) + } +} + +func TestSessionSPIFFEIDEscapesSpecialChars(t *testing.T) { + t.Parallel() + got := SessionSPIFFEID("example.org", "team/a", "session#1") + want := "spiffe://example.org/ns/team%2Fa/session/session%231" + if got != want { + t.Fatalf("SessionSPIFFEID() = %q, want %q", got, want) + } + // Round-trips back to the original components. + ns, sess, ok := ParseSessionSPIFFE(got, "example.org") + if !ok || ns != "team/a" || sess != "session#1" { + t.Fatalf("round-trip = (%q, %q, %v), want (%q, %q, true)", ns, sess, ok, "team/a", "session#1") + } +} + +func TestParseSessionSPIFFE(t *testing.T) { + t.Parallel() + cases := []struct { + name string + raw string + trust string + wantNS string + wantSess string + wantOK bool + }{ + { + name: "valid", + raw: "spiffe://example.org/ns/team-a/session/session-1", + trust: "example.org", + wantNS: "team-a", + wantSess: "session-1", + wantOK: true, + }, + { + name: "valid with special characters", + raw: "spiffe://example.org/ns/team%2Fa/session/session%231", + trust: "example.org", + wantNS: "team/a", + wantSess: "session#1", + wantOK: true, + }, + { + name: "rejected with query", + raw: "spiffe://example.org/ns/team-a/session/session-1?foo=bar", + trust: "example.org", + wantOK: false, + }, + { + name: "rejected with fragment", + raw: "spiffe://example.org/ns/team-a/session/session-1#baz", + trust: "example.org", + wantOK: false, + }, + { + name: "wrong trust domain", + raw: "spiffe://attacker.org/ns/team-a/session/session-1", + trust: "example.org", + wantOK: false, + }, + { + name: "wrong path shape", + raw: "spiffe://example.org/user/alice/agent/codex", + trust: "example.org", + wantOK: false, + }, + { + name: "empty", + raw: "", + trust: "example.org", + wantOK: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ns, sess, ok := ParseSessionSPIFFE(tc.raw, tc.trust) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if ns != tc.wantNS || sess != tc.wantSess { + t.Fatalf("ParseSessionSPIFFE() = (%q, %q), want (%q, %q)", ns, sess, tc.wantNS, tc.wantSess) + } + }) + } +} diff --git a/services/mcp-gateway/filter_auth.go b/services/mcp-gateway/filter_auth.go index 78a3b743..3546d467 100644 --- a/services/mcp-gateway/filter_auth.go +++ b/services/mcp-gateway/filter_auth.go @@ -1,12 +1,11 @@ package main import ( - "crypto/x509" "net/http" - "net/url" "strings" "time" + "mcp-runtime/pkg/identity" policypkg "mcp-runtime/pkg/policy" ) @@ -96,7 +95,7 @@ func (s *gatewayServer) authenticateMTLS(r *http.Request, policy *policypkg.Docu } // (2) Optionally pin the trusted ingress identity. if expected := strings.TrimSpace(s.trustedProxySPIFFE); expected != "" { - if !peerCertificateHasURI(r.TLS.PeerCertificates[0], expected) { + if !identity.CertificateHasURI(r.TLS.PeerCertificates[0], expected) { return identityContext{}, "untrusted_proxy" } } @@ -106,9 +105,9 @@ func (s *gatewayServer) authenticateMTLS(r *http.Request, policy *policypkg.Docu if rawID == "" { return identityContext{}, "missing_verified_identity" } - namespace, sessionID, reason := parseSessionSPIFFE(rawID, policy.Auth.TrustDomain) - if reason != "" { - return identityContext{}, reason + namespace, sessionID, ok := identity.ParseSessionSPIFFE(rawID, policy.Auth.TrustDomain) + if !ok { + return identityContext{}, "invalid_spiffe_identity" } for _, binding := range policy.Sessions { @@ -140,46 +139,6 @@ func (s *gatewayServer) verifiedSPIFFEHeaderName() string { return defaultVerifiedSPIFFEHeader } -// parseSessionSPIFFE parses a session-bound SPIFFE ID of the form -// spiffe:///ns//session/. On any deviation -// (wrong scheme, wrong trust domain, malformed path) it returns a denial -// reason. It is deliberately strict and fails closed. -func parseSessionSPIFFE(raw, trustDomain string) (namespace, sessionID, reason string) { - uri, err := url.Parse(raw) - if err != nil || uri == nil { - return "", "", "invalid_spiffe_identity" - } - if !strings.EqualFold(uri.Scheme, "spiffe") || !strings.EqualFold(uri.Host, strings.TrimSpace(trustDomain)) { - return "", "", "invalid_spiffe_identity" - } - parts := strings.Split(strings.Trim(uri.EscapedPath(), "/"), "/") - if len(parts) != 4 || parts[0] != "ns" || parts[2] != "session" { - return "", "", "invalid_spiffe_identity" - } - namespace, err = url.PathUnescape(parts[1]) - if err != nil || strings.TrimSpace(namespace) == "" { - return "", "", "invalid_spiffe_identity" - } - sessionID, err = url.PathUnescape(parts[3]) - if err != nil || strings.TrimSpace(sessionID) == "" { - return "", "", "invalid_spiffe_identity" - } - return namespace, sessionID, "" -} - -// peerCertificateHasURI reports whether cert carries want as a URI SAN. -func peerCertificateHasURI(cert *x509.Certificate, want string) bool { - if cert == nil { - return false - } - for _, uri := range cert.URIs { - if uri != nil && uri.String() == want { - return true - } - } - return false -} - func mtlsBindingExpired(expiresAt string) bool { trimmed := strings.TrimSpace(expiresAt) if trimmed == "" { diff --git a/services/runtime-api/internal/runtimeapi/adapter_certificate.go b/services/runtime-api/internal/runtimeapi/adapter_certificate.go index b9517a11..53d10eb7 100644 --- a/services/runtime-api/internal/runtimeapi/adapter_certificate.go +++ b/services/runtime-api/internal/runtimeapi/adapter_certificate.go @@ -2,10 +2,8 @@ package runtimeapi import ( "context" - "crypto/x509" "encoding/base64" "encoding/json" - "encoding/pem" "fmt" "net/http" "os" @@ -15,6 +13,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + + "mcp-runtime/pkg/certauth" + "mcp-runtime/pkg/identity" ) const adapterCertificateRequestMaxBytes = 64 << 10 @@ -113,8 +114,8 @@ func (s *AccessService) HandleAdapterCertificate(w http.ResponseWriter, r *http. return } - expectedSPIFFEID := fmt.Sprintf("spiffe://%s/ns/%s/session/%s", trustDomain, req.Namespace, req.Session) - csrDER, err := validateAdapterCSR(req.CSR, expectedSPIFFEID) + expectedSPIFFEID := identity.SessionSPIFFEID(trustDomain, req.Namespace, req.Session) + csrDER, err := certauth.ValidateCSRPEM(req.CSR, expectedSPIFFEID) if err != nil { writeAPIError(w, http.StatusBadRequest, err.Error()) return @@ -174,24 +175,6 @@ func (s *AccessService) HandleAdapterCertificate(w http.ResponseWriter, r *http. }) } -func validateAdapterCSR(raw, expectedSPIFFEID string) ([]byte, error) { - block, _ := pem.Decode([]byte(raw)) - if block == nil || block.Type != "CERTIFICATE REQUEST" { - return nil, fmt.Errorf("csr must be a PEM CERTIFICATE REQUEST") - } - csr, err := x509.ParseCertificateRequest(block.Bytes) - if err != nil || csr.CheckSignature() != nil { - return nil, fmt.Errorf("csr is invalid or has an invalid signature") - } - if len(csr.URIs) != 1 || csr.URIs[0].String() != expectedSPIFFEID { - return nil, fmt.Errorf("csr must contain exactly the SPIFFE URI %q", expectedSPIFFEID) - } - if len(csr.DNSNames) != 0 || len(csr.EmailAddresses) != 0 || len(csr.IPAddresses) != 0 { - return nil, fmt.Errorf("csr may not contain DNS, email, or IP subject alternative names") - } - return block.Bytes, nil -} - func waitForIssuedAdapterCertificate(ctx context.Context, s *AccessService, namespace, name string) (string, string, error) { if s == nil || s.accessMgr == nil || s.k8sClients == nil { return "", "", fmt.Errorf("kubernetes not available") diff --git a/services/runtime-api/internal/runtimeapi/adapter_certificate_test.go b/services/runtime-api/internal/runtimeapi/adapter_certificate_test.go index ad29e6fa..31d60e58 100644 --- a/services/runtime-api/internal/runtimeapi/adapter_certificate_test.go +++ b/services/runtime-api/internal/runtimeapi/adapter_certificate_test.go @@ -1,66 +1,26 @@ package runtimeapi import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "encoding/pem" - "net/url" "strings" "testing" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -func TestValidateAdapterCSR(t *testing.T) { - t.Parallel() - const expected = "spiffe://example.org/ns/team-a/session/session-1" - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatal(err) - } - uri, err := url.Parse(expected) - if err != nil { - t.Fatal(err) - } - csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ - URIs: []*url.URL{uri}, - }, key) - if err != nil { - t.Fatal(err) - } - csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}) - if _, err := validateAdapterCSR(string(csrPEM), expected); err != nil { - t.Fatalf("validateAdapterCSR() error = %v", err) - } - if _, err := validateAdapterCSR(string(csrPEM), strings.Replace(expected, "session-1", "session-2", 1)); err == nil { - t.Fatal("validateAdapterCSR() accepted a CSR for another session") - } -} + "mcp-runtime/pkg/certauth" +) -func TestValidateAdapterCSRRejectsAdditionalSANs(t *testing.T) { +func TestValidateAdapterCSRUsesCertauth(t *testing.T) { t.Parallel() const expected = "spiffe://example.org/ns/team-a/session/session-1" - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + _, csrPEM, _, err := certauth.BuildSessionCSR("example.org", "team-a", "session-1") if err != nil { t.Fatal(err) } - uri, err := url.Parse(expected) - if err != nil { - t.Fatal(err) - } - csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ - URIs: []*url.URL{uri}, - DNSNames: []string{"attacker.example"}, - }, key) - if err != nil { - t.Fatal(err) + if _, err := certauth.ValidateCSRPEM(string(csrPEM), expected); err != nil { + t.Fatalf("ValidateCSRPEM() error = %v", err) } - csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}) - if _, err := validateAdapterCSR(string(csrPEM), expected); err == nil { - t.Fatal("validateAdapterCSR() accepted an additional DNS SAN") + if _, err := certauth.ValidateCSRPEM(string(csrPEM), strings.Replace(expected, "session-1", "session-2", 1)); err == nil { + t.Fatal("ValidateCSRPEM() accepted a CSR for another session") } }