package auth_test import ( "context" "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" "crypto/rand" "crypto/sha512" "encoding/base64" "encoding/hex" "fmt" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth" intauth "github.com/modelcontextprotocol/registry/internal/auth" "github.com/modelcontextprotocol/registry/internal/config" ) // MockDNSResolver for testing type MockDNSResolver struct { txtRecords map[string][]string err error } func (m *MockDNSResolver) LookupTXT(_ context.Context, name string) ([]string, error) { if m.err != nil { return nil, m.err } return m.txtRecords[name], nil } // generateECDSAP384KeyPair generates an ECDSA P-384 key pair for testing func generateECDSAP384KeyPair(t *testing.T) ([]byte, *ecdsa.PrivateKey) { t.Helper() privateKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) require.NoError(t, err) // Compress the public key compressedPubKey := elliptic.MarshalCompressed(elliptic.P384(), privateKey.X, privateKey.Y) return compressedPubKey, privateKey } // signWithECDSAP384 signs a message using ECDSA P-384 func signWithECDSAP384(t *testing.T, privateKey *ecdsa.PrivateKey, message []byte) []byte { t.Helper() digest := sha512.Sum384(message) r, s, err := ecdsa.Sign(rand.Reader, privateKey, digest[:]) require.NoError(t, err) // Convert to R || S format (48 bytes each for P-384) signature := make([]byte, 96) r.FillBytes(signature[:48]) s.FillBytes(signature[48:]) return signature } func TestDNSAuthHandler_ExchangeToken(t *testing.T) { cfg := &config.Config{ JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", } handler := auth.NewDNSAuthHandler(cfg) // Generate a test key pair publicKey, privateKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) // Create mock DNS resolver publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey) mockResolver := &MockDNSResolver{ txtRecords: map[string][]string{ testDomain: { fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64), }, }, } handler.SetResolver(mockResolver) tests := []struct { name string domain string timestamp string signedTimestamp string setupMock func(*MockDNSResolver) expectError bool errorContains string }{ { name: "successful authentication", domain: testDomain, timestamp: time.Now().UTC().Format(time.RFC3339), setupMock: func(_ *MockDNSResolver) { // Mock is already set up with valid key }, expectError: false, }, { name: "multiple keys", domain: testDomain, timestamp: time.Now().UTC().Format(time.RFC3339), setupMock: func(m *MockDNSResolver) { publicKey, _, err := ed25519.GenerateKey(nil) require.NoError(t, err) otherPublicKeyB64 := base64.StdEncoding.EncodeToString(publicKey) m.txtRecords[testDomain] = []string{ fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", "someNonsense"), fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64), fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", otherPublicKeyB64), } }, expectError: false, }, { name: "invalid domain format", domain: "invalid..domain", timestamp: time.Now().UTC().Format(time.RFC3339), expectError: true, errorContains: "invalid domain format", }, { name: "timestamp too old", domain: testDomain, timestamp: time.Now().Add(-30 * time.Second).UTC().Format(time.RFC3339), expectError: true, errorContains: "timestamp outside valid window", }, { name: "timestamp too far in the future", domain: testDomain, timestamp: time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339), expectError: true, errorContains: "timestamp outside valid window", }, { name: "DNS lookup failure", domain: "nonexistent.com", timestamp: time.Now().UTC().Format(time.RFC3339), setupMock: func(m *MockDNSResolver) { m.err = fmt.Errorf("DNS lookup failed") }, expectError: true, errorContains: "failed to lookup DNS TXT records", }, { name: "no MCP TXT records", domain: "nokeys.com", timestamp: time.Now().UTC().Format(time.RFC3339), setupMock: func(m *MockDNSResolver) { m.txtRecords["nokeys.com"] = []string{"v=spf1 include:_spf.google.com ~all"} m.err = nil }, expectError: true, errorContains: "no MCP public key found in DNS TXT records", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Reset mock resolver mockResolver.err = nil if tt.setupMock != nil { tt.setupMock(mockResolver) } // Generate signature if not provided signedTimestamp := tt.signedTimestamp if signedTimestamp == "" && !tt.expectError { signature := ed25519.Sign(privateKey, []byte(tt.timestamp)) signedTimestamp = hex.EncodeToString(signature) } else if signedTimestamp == "" { // For error cases, generate a valid signature unless we're testing signature format if !strings.Contains(tt.errorContains, "signature") { signature := ed25519.Sign(privateKey, []byte(tt.timestamp)) signedTimestamp = hex.EncodeToString(signature) } else { signedTimestamp = "invalid" } } // Call the handler result, err := handler.ExchangeToken(context.Background(), tt.domain, tt.timestamp, signedTimestamp) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { assert.Contains(t, err.Error(), tt.errorContains) } assert.Nil(t, result) } else { assert.NoError(t, err) assert.NotNil(t, result) assert.NotEmpty(t, result.RegistryToken) // Verify the token contains expected claims jwtManager := intauth.NewJWTManager(cfg) claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken) require.NoError(t, err) assert.Equal(t, intauth.MethodDNS, claims.AuthMethod) assert.Equal(t, tt.domain, claims.AuthMethodSubject) assert.Len(t, claims.Permissions, 2) // domain and subdomain permissions // Check permissions use reverse DNS patterns patterns := make([]string, len(claims.Permissions)) for i, perm := range claims.Permissions { patterns[i] = perm.ResourcePattern } // Convert domain to reverse DNS for expected patterns parts := strings.Split(tt.domain, ".") for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { parts[i], parts[j] = parts[j], parts[i] } reverseDomain := strings.Join(parts, ".") assert.Contains(t, patterns, fmt.Sprintf("%s/*", reverseDomain)) assert.Contains(t, patterns, fmt.Sprintf("%s.*", reverseDomain)) } }) } } func TestDNSAuthHandler_Permissions(t *testing.T) { cfg := &config.Config{ JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", } handler := auth.NewDNSAuthHandler(cfg) jwtManager := intauth.NewJWTManager(cfg) // Generate a test key pair publicKey, privateKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey) tests := []struct { name string domain string expectedPatterns []string unexpectedPatterns []string }{ { name: "simple domain", domain: testDomain, expectedPatterns: []string{ "com.example/*", // exact domain pattern "com.example.*", // subdomain pattern (DNS includes subdomains) }, unexpectedPatterns: []string{ testDomain + "/*", // should be reversed "*.com.example", // wrong wildcard position }, }, { name: "subdomain", domain: "api.example.com", expectedPatterns: []string{ "com.example.api/*", // exact subdomain pattern "com.example.api.*", // subdomain pattern }, unexpectedPatterns: []string{ "com.example/*", // parent domain should not be included "api." + testDomain + "/*", // should be reversed }, }, { name: "multi-level subdomain", domain: "v1.api.example.com", expectedPatterns: []string{ "com.example.api.v1/*", // exact pattern "com.example.api.v1.*", // subdomain pattern }, unexpectedPatterns: []string{ "com.example/*", // parent domain should not be included "com.example.api/*", // intermediate domain should not be included "v1.api.example.com/*", // should be reversed }, }, { name: "hyphenated domain", domain: "my-app.example-site.com", expectedPatterns: []string{ "com.example-site.my-app/*", // exact pattern "com.example-site.my-app.*", // subdomain pattern }, unexpectedPatterns: []string{ "my-app.example-site.com/*", // should be reversed }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Set up mock resolver mockResolver := &MockDNSResolver{ txtRecords: map[string][]string{ tt.domain: { fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64), }, }, } handler.SetResolver(mockResolver) // Generate signature timestamp := time.Now().UTC().Format(time.RFC3339) signature := ed25519.Sign(privateKey, []byte(timestamp)) signedTimestamp := hex.EncodeToString(signature) // Exchange token result, err := handler.ExchangeToken(context.Background(), tt.domain, timestamp, signedTimestamp) require.NoError(t, err) require.NotNil(t, result) // Validate JWT token claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken) require.NoError(t, err) // Verify claims structure assert.Equal(t, intauth.MethodDNS, claims.AuthMethod) assert.Equal(t, tt.domain, claims.AuthMethodSubject) assert.Len(t, claims.Permissions, 2) // DNS always grants both exact and subdomain permissions // Extract permission patterns patterns := make([]string, len(claims.Permissions)) for i, perm := range claims.Permissions { patterns[i] = perm.ResourcePattern // All permissions should be for publish action assert.Equal(t, intauth.PermissionActionPublish, perm.Action) } // Check expected patterns are present for _, expectedPattern := range tt.expectedPatterns { assert.Contains(t, patterns, expectedPattern, "Expected pattern %s not found", expectedPattern) } // Check unexpected patterns are not present for _, unexpectedPattern := range tt.unexpectedPatterns { assert.NotContains(t, patterns, unexpectedPattern, "Unexpected pattern %s found", unexpectedPattern) } // Verify the permission patterns work correctly with the JWT manager's HasPermission method for _, expectedPattern := range tt.expectedPatterns { // Find the permission with this pattern var foundPerm *intauth.Permission for _, perm := range claims.Permissions { if perm.ResourcePattern == expectedPattern { foundPerm = &perm break } } require.NotNil(t, foundPerm, "Permission with pattern %s not found", expectedPattern) // Test various resource scenarios if strings.HasSuffix(expectedPattern, "/*") { // Exact domain permissions (e.g., "com.example/*") basePattern := strings.TrimSuffix(expectedPattern, "/*") testResource := basePattern + "/my-package" assert.True(t, jwtManager.HasPermission(testResource, intauth.PermissionActionPublish, claims.Permissions), "Should have permission for %s with pattern %s", testResource, expectedPattern) } else if strings.HasSuffix(expectedPattern, ".*") { // Subdomain permissions (e.g., "com.example.*") basePattern := strings.TrimSuffix(expectedPattern, ".*") testResource := basePattern + ".subdomain/my-package" assert.True(t, jwtManager.HasPermission(testResource, intauth.PermissionActionPublish, claims.Permissions), "Should have permission for %s with pattern %s", testResource, expectedPattern) } } }) } } func TestDNSAuthHandler_PermissionValidation(t *testing.T) { cfg := &config.Config{ JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", } handler := auth.NewDNSAuthHandler(cfg) jwtManager := intauth.NewJWTManager(cfg) // Generate a test key pair publicKey, privateKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey) domain := testDomain // Set up mock resolver mockResolver := &MockDNSResolver{ txtRecords: map[string][]string{ domain: { fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64), }, }, } handler.SetResolver(mockResolver) // Generate signature and exchange token timestamp := time.Now().UTC().Format(time.RFC3339) signature := ed25519.Sign(privateKey, []byte(timestamp)) signedTimestamp := hex.EncodeToString(signature) result, err := handler.ExchangeToken(context.Background(), domain, timestamp, signedTimestamp) require.NoError(t, err) claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken) require.NoError(t, err) // Test permission validation scenarios testCases := []struct { name string resource string action intauth.PermissionAction shouldPass bool }{ { name: "exact domain resource with publish action", resource: "com.example/my-package", action: intauth.PermissionActionPublish, shouldPass: true, }, { name: "subdomain resource with publish action", resource: "com.example.api/my-package", action: intauth.PermissionActionPublish, shouldPass: true, }, { name: "deep subdomain resource with publish action", resource: "com.example.v1.api/my-package", action: intauth.PermissionActionPublish, shouldPass: true, }, { name: "different domain should fail", resource: "com.otherdomain/my-package", action: intauth.PermissionActionPublish, shouldPass: false, }, { name: "partial domain match should fail", resource: "com.example-other/my-package", action: intauth.PermissionActionPublish, shouldPass: false, }, { name: "parent domain should fail", resource: "com/my-package", action: intauth.PermissionActionPublish, shouldPass: false, }, { name: "edit action should fail (not granted)", resource: "com.example/my-package", action: intauth.PermissionActionEdit, shouldPass: false, }, { name: "resource without package separator should fail", resource: "com.example", action: intauth.PermissionActionPublish, shouldPass: false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { hasPermission := jwtManager.HasPermission(tc.resource, tc.action, claims.Permissions) if tc.shouldPass { assert.True(t, hasPermission, "Expected permission for resource %s with action %s", tc.resource, tc.action) } else { assert.False(t, hasPermission, "Expected no permission for resource %s with action %s", tc.resource, tc.action) } }) } } func TestDNSAuthHandler_ExchangeToken_ECDSAP384(t *testing.T) { cfg := &config.Config{ JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", } handler := auth.NewDNSAuthHandler(cfg) // Generate ECDSA P-384 test key pair compressedPubKey, privateKey := generateECDSAP384KeyPair(t) // Create mock DNS resolver publicKeyB64 := base64.StdEncoding.EncodeToString(compressedPubKey) mockResolver := &MockDNSResolver{ txtRecords: map[string][]string{ testDomain: { fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", publicKeyB64), }, }, } handler.SetResolver(mockResolver) tests := []struct { name string domain string timestamp string signedTimestamp string setupMock func(*MockDNSResolver) expectError bool errorContains string }{ { name: "successful ECDSA P-384 authentication", domain: testDomain, timestamp: time.Now().UTC().Format(time.RFC3339), expectError: false, }, { name: "multiple keys with ECDSA P-384", domain: testDomain, timestamp: time.Now().UTC().Format(time.RFC3339), setupMock: func(m *MockDNSResolver) { // Add another ECDSA key and some other formats otherCompressedPubKey, _ := generateECDSAP384KeyPair(t) otherPublicKeyB64 := base64.StdEncoding.EncodeToString(otherCompressedPubKey) m.txtRecords[testDomain] = []string{ "v=MCPv1; k=ed25519; p=someNonsense", fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", publicKeyB64), fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", otherPublicKeyB64), } }, expectError: false, }, { name: "invalid ECDSA P-384 signature length", domain: testDomain, timestamp: time.Now().UTC().Format(time.RFC3339), signedTimestamp: "abcdef1234", // too short for ECDSA P-384 expectError: true, errorContains: "invalid signature size for ECDSA P-384", }, { name: "wrong ECDSA P-384 key for signature", domain: testDomain, timestamp: time.Now().UTC().Format(time.RFC3339), setupMock: func(m *MockDNSResolver) { // Generate different key pair for signature verification failure wrongCompressedPubKey, _ := generateECDSAP384KeyPair(t) wrongPublicKeyB64 := base64.StdEncoding.EncodeToString(wrongCompressedPubKey) m.txtRecords[testDomain] = []string{ fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", wrongPublicKeyB64), } m.err = nil }, expectError: true, errorContains: "invalid signature size for ECDSA P-384", // specific error when only one key }, { name: "invalid ECDSA P-384 key format", domain: "invalidkey.com", timestamp: time.Now().UTC().Format(time.RFC3339), setupMock: func(m *MockDNSResolver) { // Generate a key that's too short shortKey := base64.StdEncoding.EncodeToString([]byte("short")) m.txtRecords["invalidkey.com"] = []string{ fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", shortKey), } m.err = nil }, expectError: true, errorContains: "invalid ECDSA P-384 public key size", }, { name: "invalid ECDSA P-384 key compression format", domain: "invalidkey.com", timestamp: time.Now().UTC().Format(time.RFC3339), setupMock: func(m *MockDNSResolver) { // Generate a key with wrong compression byte invalidKey := make([]byte, 49) invalidKey[0] = 0x04 // uncompressed format, should be 0x02 or 0x03 invalidKeyB64 := base64.StdEncoding.EncodeToString(invalidKey) m.txtRecords["invalidkey.com"] = []string{ fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", invalidKeyB64), } m.err = nil }, expectError: true, errorContains: "invalid ECDSA P-384 public key format (must be compressed, with a leading 0x02 or 0x03 byte)", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Reset mock resolver mockResolver.err = nil if tt.setupMock != nil { tt.setupMock(mockResolver) } // Generate signature if not provided signedTimestamp := tt.signedTimestamp if signedTimestamp == "" && !tt.expectError { signature := signWithECDSAP384(t, privateKey, []byte(tt.timestamp)) signedTimestamp = hex.EncodeToString(signature) } else if signedTimestamp == "" { // For error cases, generate a valid signature unless we're testing signature format if !strings.Contains(tt.errorContains, "signature") { signature := signWithECDSAP384(t, privateKey, []byte(tt.timestamp)) signedTimestamp = hex.EncodeToString(signature) } } // Call the handler result, err := handler.ExchangeToken(context.Background(), tt.domain, tt.timestamp, signedTimestamp) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { assert.Contains(t, err.Error(), tt.errorContains) } assert.Nil(t, result) } else { assert.NoError(t, err) assert.NotNil(t, result) assert.NotEmpty(t, result.RegistryToken) // Verify the token contains expected claims jwtManager := intauth.NewJWTManager(cfg) claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken) require.NoError(t, err) assert.Equal(t, intauth.MethodDNS, claims.AuthMethod) assert.Equal(t, tt.domain, claims.AuthMethodSubject) assert.Len(t, claims.Permissions, 2) // domain and subdomain permissions // Check permissions use reverse DNS patterns patterns := make([]string, len(claims.Permissions)) for i, perm := range claims.Permissions { patterns[i] = perm.ResourcePattern } // Convert domain to reverse DNS for expected patterns parts := strings.Split(tt.domain, ".") for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { parts[i], parts[j] = parts[j], parts[i] } reverseDomain := strings.Join(parts, ".") assert.Contains(t, patterns, fmt.Sprintf("%s/*", reverseDomain)) assert.Contains(t, patterns, fmt.Sprintf("%s.*", reverseDomain)) } }) } } func TestDNSAuthHandler_ECDSAP384_Permissions(t *testing.T) { cfg := &config.Config{ JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", } handler := auth.NewDNSAuthHandler(cfg) jwtManager := intauth.NewJWTManager(cfg) // Generate ECDSA P-384 test key pair compressedPubKey, privateKey := generateECDSAP384KeyPair(t) publicKeyB64 := base64.StdEncoding.EncodeToString(compressedPubKey) tests := []struct { name string domain string expectedPatterns []string unexpectedPatterns []string }{ { name: "simple domain with ECDSA P-384", domain: testDomain, expectedPatterns: []string{ "com.example/*", // exact domain pattern "com.example.*", // subdomain pattern (DNS includes subdomains) }, unexpectedPatterns: []string{ testDomain + "/*", // should be reversed "*.com.example", // wrong wildcard position }, }, { name: "subdomain with ECDSA P-384", domain: "api.example.com", expectedPatterns: []string{ "com.example.api/*", // exact subdomain pattern "com.example.api.*", // subdomain pattern }, unexpectedPatterns: []string{ "com.example/*", // parent domain should not be included "api." + testDomain + "/*", // should be reversed }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Set up mock resolver mockResolver := &MockDNSResolver{ txtRecords: map[string][]string{ tt.domain: { fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", publicKeyB64), }, }, } handler.SetResolver(mockResolver) // Generate signature timestamp := time.Now().UTC().Format(time.RFC3339) signature := signWithECDSAP384(t, privateKey, []byte(timestamp)) signedTimestamp := hex.EncodeToString(signature) // Exchange token result, err := handler.ExchangeToken(context.Background(), tt.domain, timestamp, signedTimestamp) require.NoError(t, err) require.NotNil(t, result) // Validate JWT token claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken) require.NoError(t, err) // Verify claims structure assert.Equal(t, intauth.MethodDNS, claims.AuthMethod) assert.Equal(t, tt.domain, claims.AuthMethodSubject) assert.Len(t, claims.Permissions, 2) // DNS always grants both exact and subdomain permissions // Extract permission patterns patterns := make([]string, len(claims.Permissions)) for i, perm := range claims.Permissions { patterns[i] = perm.ResourcePattern // All permissions should be for publish action assert.Equal(t, intauth.PermissionActionPublish, perm.Action) } // Check expected patterns are present for _, expectedPattern := range tt.expectedPatterns { assert.Contains(t, patterns, expectedPattern, "Expected pattern %s not found", expectedPattern) } // Check unexpected patterns are not present for _, unexpectedPattern := range tt.unexpectedPatterns { assert.NotContains(t, patterns, unexpectedPattern, "Unexpected pattern %s found", unexpectedPattern) } }) } } func TestDNSAuthHandler_ECDSAP384_PermissionValidation(t *testing.T) { cfg := &config.Config{ JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", } handler := auth.NewDNSAuthHandler(cfg) jwtManager := intauth.NewJWTManager(cfg) // Generate ECDSA P-384 test key pair compressedPubKey, privateKey := generateECDSAP384KeyPair(t) publicKeyB64 := base64.StdEncoding.EncodeToString(compressedPubKey) domain := testDomain // Set up mock resolver mockResolver := &MockDNSResolver{ txtRecords: map[string][]string{ domain: { fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", publicKeyB64), }, }, } handler.SetResolver(mockResolver) // Generate signature and exchange token timestamp := time.Now().UTC().Format(time.RFC3339) signature := signWithECDSAP384(t, privateKey, []byte(timestamp)) signedTimestamp := hex.EncodeToString(signature) result, err := handler.ExchangeToken(context.Background(), domain, timestamp, signedTimestamp) require.NoError(t, err) claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken) require.NoError(t, err) // Test permission validation scenarios (same as Ed25519, algorithm shouldn't affect permissions) testCases := []struct { name string resource string action intauth.PermissionAction shouldPass bool }{ { name: "exact domain resource with publish action", resource: "com.example/my-package", action: intauth.PermissionActionPublish, shouldPass: true, }, { name: "subdomain resource with publish action", resource: "com.example.api/my-package", action: intauth.PermissionActionPublish, shouldPass: true, }, { name: "deep subdomain resource with publish action", resource: "com.example.v1.api/my-package", action: intauth.PermissionActionPublish, shouldPass: true, }, { name: "different domain should fail", resource: "com.otherdomain/my-package", action: intauth.PermissionActionPublish, shouldPass: false, }, { name: "edit action should fail (not granted)", resource: "com.example/my-package", action: intauth.PermissionActionEdit, shouldPass: false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { hasPermission := jwtManager.HasPermission(tc.resource, tc.action, claims.Permissions) if tc.shouldPass { assert.True(t, hasPermission, "Expected permission for resource %s with action %s", tc.resource, tc.action) } else { assert.False(t, hasPermission, "Expected no permission for resource %s with action %s", tc.resource, tc.action) } }) } } func TestDNSAuthHandler_Ed25519_vs_ECDSAP384_Equivalence(t *testing.T) { cfg := &config.Config{ JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", } handler := auth.NewDNSAuthHandler(cfg) jwtManager := intauth.NewJWTManager(cfg) // Generate Ed25519 key pair ed25519PubKey, ed25519PrivKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) ed25519PubKeyB64 := base64.StdEncoding.EncodeToString(ed25519PubKey) // Generate ECDSA P-384 key pair ecdsaCompressedPubKey, ecdsaPrivKey := generateECDSAP384KeyPair(t) ecdsaPubKeyB64 := base64.StdEncoding.EncodeToString(ecdsaCompressedPubKey) testDomains := []string{testDomain, "api.example.com", "v1.api.example.com"} for _, domain := range testDomains { t.Run(fmt.Sprintf("domain_%s", domain), func(t *testing.T) { timestamp := time.Now().UTC().Format(time.RFC3339) // Test Ed25519 mockResolver := &MockDNSResolver{ txtRecords: map[string][]string{ domain: { fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", ed25519PubKeyB64), }, }, } handler.SetResolver(mockResolver) ed25519Signature := ed25519.Sign(ed25519PrivKey, []byte(timestamp)) ed25519Result, err := handler.ExchangeToken(context.Background(), domain, timestamp, hex.EncodeToString(ed25519Signature)) require.NoError(t, err) // Test ECDSA P-384 mockResolver.txtRecords[domain] = []string{ fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", ecdsaPubKeyB64), } ecdsaSignature := signWithECDSAP384(t, ecdsaPrivKey, []byte(timestamp)) ecdsaResult, err := handler.ExchangeToken(context.Background(), domain, timestamp, hex.EncodeToString(ecdsaSignature)) require.NoError(t, err) // Validate both tokens produce identical claims structure ed25519Claims, err := jwtManager.ValidateToken(context.Background(), ed25519Result.RegistryToken) require.NoError(t, err) ecdsaClaims, err := jwtManager.ValidateToken(context.Background(), ecdsaResult.RegistryToken) require.NoError(t, err) // Compare claims (excluding token-specific fields like timestamps) assert.Equal(t, ed25519Claims.AuthMethod, ecdsaClaims.AuthMethod) assert.Equal(t, ed25519Claims.AuthMethodSubject, ecdsaClaims.AuthMethodSubject) assert.Equal(t, len(ed25519Claims.Permissions), len(ecdsaClaims.Permissions)) // Compare permission patterns ed25519Patterns := make([]string, len(ed25519Claims.Permissions)) ecdsaPatterns := make([]string, len(ecdsaClaims.Permissions)) for i, perm := range ed25519Claims.Permissions { ed25519Patterns[i] = perm.ResourcePattern } for i, perm := range ecdsaClaims.Permissions { ecdsaPatterns[i] = perm.ResourcePattern } assert.ElementsMatch(t, ed25519Patterns, ecdsaPatterns, "Permission patterns should be identical for both algorithms") // Test that both tokens grant identical permissions for various resources testResources := []string{ "com.example/package1", "com.example.api/package2", "com.example.v1.api/package3", "com.otherdomain/package4", } for _, resource := range testResources { ed25519HasPerm := jwtManager.HasPermission(resource, intauth.PermissionActionPublish, ed25519Claims.Permissions) ecdsaHasPerm := jwtManager.HasPermission(resource, intauth.PermissionActionPublish, ecdsaClaims.Permissions) assert.Equal(t, ed25519HasPerm, ecdsaHasPerm, "Permission mismatch for resource %s between Ed25519 and ECDSA P-384", resource) } }) } } func TestDNSAuthHandler_Mixed_Algorithm_Support(t *testing.T) { cfg := &config.Config{ JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", } handler := auth.NewDNSAuthHandler(cfg) // Generate both key pairs ed25519PubKey, ed25519PrivKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) ed25519PubKeyB64 := base64.StdEncoding.EncodeToString(ed25519PubKey) ecdsaCompressedPubKey, ecdsaPrivKey := generateECDSAP384KeyPair(t) ecdsaPubKeyB64 := base64.StdEncoding.EncodeToString(ecdsaCompressedPubKey) t.Run("multiple_algorithms_in_dns", func(t *testing.T) { // Set up DNS records with both algorithms mockResolver := &MockDNSResolver{ txtRecords: map[string][]string{ testDomain: { fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", ed25519PubKeyB64), fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", ecdsaPubKeyB64), "v=spf1 include:_spf.google.com ~all", // unrelated record }, }, } handler.SetResolver(mockResolver) timestamp := time.Now().UTC().Format(time.RFC3339) // Test that Ed25519 signature works when both keys are present ed25519Signature := ed25519.Sign(ed25519PrivKey, []byte(timestamp)) result, err := handler.ExchangeToken(context.Background(), testDomain, timestamp, hex.EncodeToString(ed25519Signature)) require.NoError(t, err) assert.NotNil(t, result) // Test that ECDSA P-384 signature works when both keys are present ecdsaSignature := signWithECDSAP384(t, ecdsaPrivKey, []byte(timestamp)) result, err = handler.ExchangeToken(context.Background(), testDomain, timestamp, hex.EncodeToString(ecdsaSignature)) require.NoError(t, err) assert.NotNil(t, result) }) t.Run("wrong_signature_for_algorithm", func(t *testing.T) { // Set up DNS records with both algorithms mockResolver := &MockDNSResolver{ txtRecords: map[string][]string{ testDomain: { fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", ed25519PubKeyB64), fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", ecdsaPubKeyB64), }, }, } handler.SetResolver(mockResolver) timestamp := time.Now().UTC().Format(time.RFC3339) // Use ECDSA signature with domain that has both keys - should still work // because the system tries all available keys ecdsaSignature := signWithECDSAP384(t, ecdsaPrivKey, []byte(timestamp)) result, err := handler.ExchangeToken(context.Background(), testDomain, timestamp, hex.EncodeToString(ecdsaSignature)) require.NoError(t, err) assert.NotNil(t, result) }) } // TestDNSAuthHandler_WrongSelectorProbe covers the case where the user mistakenly placed // the MCPv1 TXT record under a selector (e.g. _mcp-auth.) instead of the apex, // which has been a recurring source of confusion (#385, #1103, #1126). func TestDNSAuthHandler_WrongSelectorProbe(t *testing.T) { cfg := &config.Config{ JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", } publicKey, _, err := ed25519.GenerateKey(nil) require.NoError(t, err) publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey) mcpRecord := fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64) tests := []struct { name string txtRecords map[string][]string expectInError string }{ { name: "record placed at _mcp-auth selector", txtRecords: map[string][]string{ "_mcp-auth." + testDomain: {mcpRecord}, }, expectInError: "_mcp-auth." + testDomain, }, { name: "record placed at _mcp-registry selector", txtRecords: map[string][]string{ "_mcp-registry." + testDomain: {mcpRecord}, }, expectInError: "_mcp-registry." + testDomain, }, { name: "no record anywhere falls through to standard error", txtRecords: map[string][]string{ testDomain: {"v=spf1 ~all"}, }, expectInError: "no MCP public key found in DNS TXT records", }, { name: "malformed MCPv1 string at apex still triggers selector probe", txtRecords: map[string][]string{ // Looks like an MCPv1 record but missing the public key field. testDomain: {"v=MCPv1; k=ed25519"}, "_mcp-auth." + testDomain: {mcpRecord}, }, expectInError: "_mcp-auth." + testDomain, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := auth.NewDNSAuthHandler(cfg) handler.SetResolver(&MockDNSResolver{txtRecords: tt.txtRecords}) timestamp := time.Now().UTC().Format(time.RFC3339) _, err := handler.ExchangeToken(context.Background(), testDomain, timestamp, hex.EncodeToString(make([]byte, 64))) require.Error(t, err) assert.Contains(t, err.Error(), tt.expectInError) }) } } // TestDNSAuthHandler_StaleKeyFingerprintInError covers #1126: when only one apex record is // published and it doesn't match the key being signed with (the typical "rotated and forgot // to update DNS" failure), the error message should include a fingerprint of the published // key so the user can tell their CLI is signing with a different key than what's published. func TestDNSAuthHandler_StaleKeyFingerprintInError(t *testing.T) { cfg := &config.Config{ JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", } stalePublicKey, _, err := ed25519.GenerateKey(nil) require.NoError(t, err) stalePublicKeyB64 := base64.StdEncoding.EncodeToString(stalePublicKey) _, currentPrivateKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) handler := auth.NewDNSAuthHandler(cfg) handler.SetResolver(&MockDNSResolver{ txtRecords: map[string][]string{ testDomain: {fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", stalePublicKeyB64)}, }, }) timestamp := time.Now().UTC().Format(time.RFC3339) signature := ed25519.Sign(currentPrivateKey, []byte(timestamp)) _, err = handler.ExchangeToken(context.Background(), testDomain, timestamp, hex.EncodeToString(signature)) require.Error(t, err) expectedFingerprint := "ed25519:" + stalePublicKeyB64[:8] assert.Contains(t, err.Error(), expectedFingerprint, "error should expose the published key's fingerprint") assert.Contains(t, err.Error(), "stale", "error should hint at the stale-record cause") }