Skip to content

Commit 316cbb0

Browse files
authored
auth: Add DNS auth (#279)
Fixes #270 Closes #233 Adds support for DNS-based authentication for proving ownership of a particular domain. E.g. if you can add a TXT record to `anthropic.com`, you can publish anything at `com.anthropic/*` or `com.anthropic.*`. Notes: - Uses Ed25519 signatures with 15-second timestamp windows to prevent replay attacks - Supports multiple public keys in DNS TXT records for key rotation scenarios - Publisher CLI supports DNS auth via --dns-domain and --dns-private-key flags - Includes key generation commands in publisher README for easy setup ## Motivation and Context This implements DNS-based public/private key authentication to allow people to actually publish at their own domains. Currently, authentication requires GitHub accounts which limits publishing to the io.github.* namespace. DNS-based auth enables: - Publishing under your actual domain (e.g., com.anthropic/* for anthropic.com) - Independence from GitHub or other external auth providers - Organizations managing their own keys and access to publishing ## How Has This Been Tested? - Unit tests cover signature verification, DNS TXT parsing, timestamp validation, and error cases - Tested locally by editing my own DNS records - All existing auth methods covered by passing tests ## Breaking Changes None ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed ## Additional context Will be adding HTTP challenge auth in a similar way as a separate PR, e.g. for verifying you own `github.com` by hosting a `github.com/.well-known/mcp-server-challenge` or similar. Just mentioning this as we discussed HTTP challenges as closely related to DNS challenges elsewhere, and want to reassure reviewer that this hasn't been forgotten with this approach. :) Update: tracking the HTTP auth in #280
1 parent 6dad0e3 commit 316cbb0

File tree

7 files changed

+604
-4
lines changed

7 files changed

+604
-4
lines changed

internal/api/handlers/v0/auth/dns.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"crypto/ed25519"
6+
"encoding/base64"
7+
"encoding/hex"
8+
"fmt"
9+
"net"
10+
"net/http"
11+
"regexp"
12+
"strings"
13+
"time"
14+
15+
"github.com/danielgtaylor/huma/v2"
16+
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
17+
"github.com/modelcontextprotocol/registry/internal/auth"
18+
"github.com/modelcontextprotocol/registry/internal/config"
19+
"github.com/modelcontextprotocol/registry/internal/model"
20+
)
21+
22+
// DNSTokenExchangeInput represents the input for DNS-based authentication
23+
type DNSTokenExchangeInput struct {
24+
Body struct {
25+
Domain string `json:"domain" doc:"Domain name" example:"example.com" required:"true"`
26+
Timestamp string `json:"timestamp" doc:"RFC3339 timestamp" example:"2023-01-01T00:00:00Z" required:"true"`
27+
SignedTimestamp string `json:"signed_timestamp" doc:"Hex-encoded Ed25519 signature of timestamp" example:"abcdef1234567890" required:"true"`
28+
}
29+
}
30+
31+
// DNSResolver defines the interface for DNS resolution
32+
type DNSResolver interface {
33+
LookupTXT(ctx context.Context, name string) ([]string, error)
34+
}
35+
36+
// DefaultDNSResolver uses Go's standard DNS resolution
37+
type DefaultDNSResolver struct{}
38+
39+
// LookupTXT performs DNS TXT record lookup
40+
func (r *DefaultDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
41+
return (&net.Resolver{}).LookupTXT(ctx, name)
42+
}
43+
44+
// DNSAuthHandler handles DNS-based authentication
45+
type DNSAuthHandler struct {
46+
config *config.Config
47+
jwtManager *auth.JWTManager
48+
resolver DNSResolver
49+
}
50+
51+
// NewDNSAuthHandler creates a new DNS authentication handler
52+
func NewDNSAuthHandler(cfg *config.Config) *DNSAuthHandler {
53+
return &DNSAuthHandler{
54+
config: cfg,
55+
jwtManager: auth.NewJWTManager(cfg),
56+
resolver: &DefaultDNSResolver{},
57+
}
58+
}
59+
60+
// SetResolver sets a custom DNS resolver (used for testing)
61+
func (h *DNSAuthHandler) SetResolver(resolver DNSResolver) {
62+
h.resolver = resolver
63+
}
64+
65+
// RegisterDNSEndpoint registers the DNS authentication endpoint
66+
func RegisterDNSEndpoint(api huma.API, cfg *config.Config) {
67+
handler := NewDNSAuthHandler(cfg)
68+
69+
// DNS authentication endpoint
70+
huma.Register(api, huma.Operation{
71+
OperationID: "exchange-dns-token",
72+
Method: http.MethodPost,
73+
Path: "/v0/auth/dns",
74+
Summary: "Exchange DNS signature for Registry JWT",
75+
Description: "Authenticate using DNS TXT record public key and signed timestamp",
76+
Tags: []string{"auth"},
77+
}, func(ctx context.Context, input *DNSTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
78+
response, err := handler.ExchangeToken(ctx, input.Body.Domain, input.Body.Timestamp, input.Body.SignedTimestamp)
79+
if err != nil {
80+
return nil, huma.Error401Unauthorized("DNS authentication failed", err)
81+
}
82+
83+
return &v0.Response[auth.TokenResponse]{
84+
Body: *response,
85+
}, nil
86+
})
87+
}
88+
89+
// ExchangeToken exchanges DNS signature for a Registry JWT token
90+
func (h *DNSAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, signedTimestamp string) (*auth.TokenResponse, error) {
91+
// Validate domain format
92+
if !isValidDomain(domain) {
93+
return nil, fmt.Errorf("invalid domain format")
94+
}
95+
96+
// Parse and validate timestamp
97+
ts, err := time.Parse(time.RFC3339, timestamp)
98+
if err != nil {
99+
return nil, fmt.Errorf("invalid timestamp format: %w", err)
100+
}
101+
102+
// Check timestamp is within 15 seconds
103+
now := time.Now()
104+
if ts.Before(now.Add(-15*time.Second)) || ts.After(now.Add(15*time.Second)) {
105+
return nil, fmt.Errorf("timestamp outside valid window (±15 seconds)")
106+
}
107+
108+
// Decode signature
109+
signature, err := hex.DecodeString(signedTimestamp)
110+
if err != nil {
111+
return nil, fmt.Errorf("invalid signature format, must be hex: %w", err)
112+
}
113+
114+
if len(signature) != ed25519.SignatureSize {
115+
return nil, fmt.Errorf("invalid signature length: expected %d, got %d", ed25519.SignatureSize, len(signature))
116+
}
117+
118+
// Lookup DNS TXT records
119+
txtRecords, err := h.resolver.LookupTXT(ctx, domain)
120+
if err != nil {
121+
return nil, fmt.Errorf("failed to lookup DNS TXT records: %w", err)
122+
}
123+
124+
// Parse public keys from TXT records
125+
publicKeys := h.parsePublicKeysFromTXT(txtRecords)
126+
127+
if len(publicKeys) == 0 {
128+
return nil, fmt.Errorf("no valid MCP public keys found in DNS TXT records")
129+
}
130+
131+
// Verify signature with any of the public keys
132+
messageBytes := []byte(timestamp)
133+
signatureValid := false
134+
for _, publicKey := range publicKeys {
135+
if ed25519.Verify(publicKey, messageBytes, signature) {
136+
signatureValid = true
137+
break
138+
}
139+
}
140+
141+
if !signatureValid {
142+
return nil, fmt.Errorf("signature verification failed")
143+
}
144+
145+
// Build permissions for domain and subdomains
146+
permissions := h.buildPermissions(domain)
147+
148+
// Create JWT claims
149+
jwtClaims := auth.JWTClaims{
150+
AuthMethod: model.AuthMethodDNS,
151+
AuthMethodSubject: domain,
152+
Permissions: permissions,
153+
}
154+
155+
// Generate Registry JWT token
156+
tokenResponse, err := h.jwtManager.GenerateTokenResponse(ctx, jwtClaims)
157+
if err != nil {
158+
return nil, fmt.Errorf("failed to generate JWT token: %w", err)
159+
}
160+
161+
return tokenResponse, nil
162+
}
163+
164+
// parsePublicKeysFromTXT parses Ed25519 public keys from DNS TXT records
165+
func (h *DNSAuthHandler) parsePublicKeysFromTXT(txtRecords []string) []ed25519.PublicKey {
166+
var publicKeys []ed25519.PublicKey
167+
mcpPattern := regexp.MustCompile(`v=MCPv1;\s*k=ed25519;\s*p=([A-Za-z0-9+/=]+)`)
168+
169+
for _, record := range txtRecords {
170+
matches := mcpPattern.FindStringSubmatch(record)
171+
if len(matches) == 2 {
172+
// Decode base64 public key
173+
publicKeyBytes, err := base64.StdEncoding.DecodeString(matches[1])
174+
if err != nil {
175+
continue // Skip invalid keys
176+
}
177+
178+
if len(publicKeyBytes) != ed25519.PublicKeySize {
179+
continue // Skip invalid key sizes
180+
}
181+
182+
publicKeys = append(publicKeys, ed25519.PublicKey(publicKeyBytes))
183+
}
184+
}
185+
186+
return publicKeys
187+
}
188+
189+
// buildPermissions builds permissions for a domain and its subdomains using reverse DNS notation
190+
func (h *DNSAuthHandler) buildPermissions(domain string) []auth.Permission {
191+
reverseDomain := reverseString(domain)
192+
193+
permissions := []auth.Permission{
194+
// Grant permissions for the exact domain (e.g., com.example/*)
195+
{
196+
Action: auth.PermissionActionPublish,
197+
ResourcePattern: fmt.Sprintf("%s/*", reverseDomain),
198+
},
199+
// DNS implies a hierarchy where subdomains are treated as part of the parent domain,
200+
// therefore we grant permissions for all subdomains (e.g., com.example.*)
201+
// This is in line with other DNS-based authentication methods e.g. ACME DNS-01 challenges
202+
{
203+
Action: auth.PermissionActionPublish,
204+
ResourcePattern: fmt.Sprintf("%s.*", reverseDomain),
205+
},
206+
}
207+
208+
return permissions
209+
}
210+
211+
// reverseString reverses a domain string (example.com -> com.example)
212+
func reverseString(domain string) string {
213+
parts := strings.Split(domain, ".")
214+
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
215+
parts[i], parts[j] = parts[j], parts[i]
216+
}
217+
return strings.Join(parts, ".")
218+
}
219+
220+
func isValidDomain(domain string) bool {
221+
if len(domain) == 0 || len(domain) > 253 {
222+
return false
223+
}
224+
225+
// Check for valid characters and structure
226+
domainPattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$`)
227+
return domainPattern.MatchString(domain)
228+
}

0 commit comments

Comments
 (0)