Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions internal/cli/adapter/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"mcp-runtime/internal/cli/core"
"mcp-runtime/pkg/certauth"
)

func TestAdapterCommandRegistersProxyAndStdio(t *testing.T) {
Expand All @@ -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 {
Expand All @@ -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")
}
}

Expand Down
36 changes: 2 additions & 34 deletions internal/cli/adapter/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
}
Expand Down
24 changes: 2 additions & 22 deletions internal/cli/adapter/enroll.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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
Expand Down
26 changes: 7 additions & 19 deletions internal/cli/adapter/mtls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
94 changes: 94 additions & 0 deletions pkg/certauth/csr.go
Original file line number Diff line number Diff line change
@@ -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)
}
Comment on lines +58 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Separating the CSR parsing and signature verification checks improves readability and preserves the underlying error details, which is highly beneficial for debugging.

Suggested change
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil || csr.CheckSignature() != nil {
return nil, fmt.Errorf("csr is invalid or has an invalid signature")
}
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()
}
80 changes: 80 additions & 0 deletions pkg/certauth/csr_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
39 changes: 39 additions & 0 deletions pkg/identity/certificate.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
Loading
Loading