registry/internal/api/handlers/v0/auth/dns_test.go
Radoslav Dimitrov c5f50e817b
fix(auth): better DNS verification errors and wrong-selector hint (#1202)
## Summary

DNS auth users have repeatedly hit two failure modes that both produce
the same opaque "Ed25519 signature verification failed" error, making
them indistinguishable to debug. This PR makes both diagnosable
end-to-end without changing the auth protocol or any successful path.

The two recurring failure modes:

1. **Wrong placement** — TXT record put under a selector
(`_mcp-auth.<domain>` or `_mcp-registry.<domain>`) instead of the apex.
DKIM-style intuition, but MCP DNS auth uses SPF-style apex placement.
See #385 (PR #388 fixed the docs but the failure recurred), #1103,
#1126.
2. **Stale apex record** — old TXT record left behind after a key
rotation, silently tried first and rejected.

## Changes

**`internal/api/handlers/v0/auth/dns.go`** — when the apex has no
`v=MCPv1` record, probe `_mcp-auth.<domain>` and
`_mcp-registry.<domain>` in parallel (2s individual timeout). If a
record is found there, return a targeted error pointing the user at the
apex. Costs zero extra DNS work in the success path.

**`internal/api/handlers/v0/auth/common.go`** — when verification fails
against records that *are* present, include short fingerprints of every
key the registry tried (`ed25519:YJLsHFhu`-style 8-char prefix). A new
`ErrSignatureMismatch` sentinel keeps structural errors (wrong signature
size, unsupported algorithm) passing through unchanged via `errors.Is`,
so existing assertions on those messages still hold.

**`docs/modelcontextprotocol-io/authentication.mdx`** — anti-pattern
`<Warning>` callout in the DNS section explicitly warning against
selector placement and stale records.

**Tests** — two new tests:
- `TestDNSAuthHandler_WrongSelectorProbe` — verifies the probe surfaces
records found at `_mcp-auth.` / `_mcp-registry.` and falls through to
the standard error when nothing is found anywhere
- `TestDNSAuthHandler_StaleKeyFingerprintInError` — verifies the
fingerprint hint appears in the failure message for #1126's exact
scenario

One existing test assertion was updated from a generic `"signature
verification failed"` substring to the more specific `"invalid signature
size for ECDSA P-384"` it should always have been asserting; the new
sentinel-based logic surfaces structural errors verbatim.

## Test plan

- [x] `make lint` — clean
- [x] `make test-unit` — all auth tests pass (existing + 2 new)
- [x] Verified existing single-key and multi-key paths preserve their
original (more specific) error messages where they were already specific
- [ ] Reviewer to sanity-check the docs callout renders well in the
Mintlify theme

## Out of scope

The publisher-side preflight DNS check (cross-link in #845) is
intentionally **not** in this PR — that belongs in the broader CLI UX
work tracked by #845.

Refs #845, #385, #1103
Fixes #1126

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:22:06 -04:00

1089 lines
36 KiB
Go

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: "single part domain",
domain: "localhost",
expectedPatterns: []string{
"localhost/*", // exact pattern (no reversal needed)
"localhost.*", // subdomain pattern
},
unexpectedPatterns: []string{
"*.localhost", // wrong wildcard position
},
},
{
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.<domain>) 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")
}