Skip to content

Commit 29502ea

Browse files
committed
feat: add server name scoping support in DNS TXT records (#481)
Implements optional n=<pattern> parameter in DNS TXT records to allow fine-grained permission scoping for server names. This enables DNS controllers to delegate limited namespaces to different teams while maintaining security isolation. Key changes: - Added DNSAuthRecord struct to support name patterns alongside public keys - Updated TXT record parsing to extract optional n=<pattern> parameter - Modified buildPermissions to use specific patterns when provided - Maintains full backward compatibility (defaults to wildcard when n= not specified) - Added comprehensive tests for all scoping scenarios Example usage: v=MCPv1; k=ed25519; p=<key>; n=com.example/team-foo-* This change addresses Microsoft's requirement for granular permission control within path portions while preserving existing behavior for records without the n= parameter. Fixes #481
1 parent 63cf08e commit 29502ea

File tree

2 files changed

+229
-29
lines changed

2 files changed

+229
-29
lines changed

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

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ func (r *DefaultDNSResolver) LookupTXT(ctx context.Context, name string) ([]stri
4040
return (&net.Resolver{}).LookupTXT(ctx, name)
4141
}
4242

43+
// DNSAuthRecord represents a DNS TXT authentication record with optional name pattern
44+
type DNSAuthRecord struct {
45+
PublicKey ed25519.PublicKey
46+
NamePattern string // Defaults to "*" for wildcard access
47+
}
48+
4349
// DNSAuthHandler handles DNS-based authentication
4450
type DNSAuthHandler struct {
4551
config *config.Config
@@ -120,29 +126,29 @@ func (h *DNSAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, s
120126
return nil, fmt.Errorf("failed to lookup DNS TXT records: %w", err)
121127
}
122128

123-
// Parse public keys from TXT records
124-
publicKeys := h.parsePublicKeysFromTXT(txtRecords)
129+
// Parse auth records from TXT records
130+
authRecords := h.parseAuthRecordsFromTXT(txtRecords)
125131

126-
if len(publicKeys) == 0 {
132+
if len(authRecords) == 0 {
127133
return nil, fmt.Errorf("no valid MCP public keys found in DNS TXT records")
128134
}
129135

130-
// Verify signature with any of the public keys
136+
// Verify signature with any of the auth records
131137
messageBytes := []byte(timestamp)
132-
signatureValid := false
133-
for _, publicKey := range publicKeys {
134-
if ed25519.Verify(publicKey, messageBytes, signature) {
135-
signatureValid = true
138+
var validRecord *DNSAuthRecord
139+
for i, record := range authRecords {
140+
if ed25519.Verify(record.PublicKey, messageBytes, signature) {
141+
validRecord = &authRecords[i]
136142
break
137143
}
138144
}
139145

140-
if !signatureValid {
146+
if validRecord == nil {
141147
return nil, fmt.Errorf("signature verification failed")
142148
}
143149

144-
// Build permissions for domain and subdomains
145-
permissions := h.buildPermissions(domain)
150+
// Build permissions using the name pattern from the valid record
151+
permissions := h.buildPermissions(domain, validRecord.NamePattern)
146152

147153
// Create JWT claims
148154
jwtClaims := auth.JWTClaims{
@@ -160,14 +166,16 @@ func (h *DNSAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, s
160166
return tokenResponse, nil
161167
}
162168

163-
// parsePublicKeysFromTXT parses Ed25519 public keys from DNS TXT records
164-
func (h *DNSAuthHandler) parsePublicKeysFromTXT(txtRecords []string) []ed25519.PublicKey {
165-
var publicKeys []ed25519.PublicKey
166-
mcpPattern := regexp.MustCompile(`v=MCPv1;\s*k=ed25519;\s*p=([A-Za-z0-9+/=]+)`)
169+
// parseAuthRecordsFromTXT parses DNS authentication records from TXT records
170+
// Supports optional n=<pattern> parameter for name scoping
171+
func (h *DNSAuthHandler) parseAuthRecordsFromTXT(txtRecords []string) []DNSAuthRecord {
172+
var authRecords []DNSAuthRecord
173+
// Updated pattern to capture optional n=<pattern> parameter
174+
mcpPattern := regexp.MustCompile(`v=MCPv1;\s*k=ed25519;\s*p=([A-Za-z0-9+/=]+)(?:;\s*n=([^;]+))?`)
167175

168176
for _, record := range txtRecords {
169177
matches := mcpPattern.FindStringSubmatch(record)
170-
if len(matches) == 2 {
178+
if len(matches) >= 2 {
171179
// Decode base64 public key
172180
publicKeyBytes, err := base64.StdEncoding.DecodeString(matches[1])
173181
if err != nil {
@@ -178,29 +186,52 @@ func (h *DNSAuthHandler) parsePublicKeysFromTXT(txtRecords []string) []ed25519.P
178186
continue // Skip invalid key sizes
179187
}
180188

181-
publicKeys = append(publicKeys, ed25519.PublicKey(publicKeyBytes))
189+
// Extract name pattern or default to wildcard
190+
namePattern := "*"
191+
if len(matches) > 2 && matches[2] != "" {
192+
namePattern = strings.TrimSpace(matches[2])
193+
}
194+
195+
authRecords = append(authRecords, DNSAuthRecord{
196+
PublicKey: ed25519.PublicKey(publicKeyBytes),
197+
NamePattern: namePattern,
198+
})
182199
}
183200
}
184201

185-
return publicKeys
202+
return authRecords
186203
}
187204

188-
// buildPermissions builds permissions for a domain and its subdomains using reverse DNS notation
189-
func (h *DNSAuthHandler) buildPermissions(domain string) []auth.Permission {
205+
// buildPermissions builds permissions based on domain and name pattern
206+
// namePattern defaults to "*" for wildcard access (backward compatible)
207+
func (h *DNSAuthHandler) buildPermissions(domain string, namePattern string) []auth.Permission {
190208
reverseDomain := reverseString(domain)
191209

210+
// If namePattern is "*", grant traditional wildcard permissions
211+
if namePattern == "*" {
212+
permissions := []auth.Permission{
213+
// Grant permissions for the exact domain (e.g., com.example/*)
214+
{
215+
Action: auth.PermissionActionPublish,
216+
ResourcePattern: fmt.Sprintf("%s/*", reverseDomain),
217+
},
218+
// DNS implies a hierarchy where subdomains are treated as part of the parent domain,
219+
// therefore we grant permissions for all subdomains (e.g., com.example.*)
220+
// This is in line with other DNS-based authentication methods e.g. ACME DNS-01 challenges
221+
{
222+
Action: auth.PermissionActionPublish,
223+
ResourcePattern: fmt.Sprintf("%s.*", reverseDomain),
224+
},
225+
}
226+
return permissions
227+
}
228+
229+
// For specific name patterns, grant permission only for the specified pattern
230+
// This allows DNS controllers to scope permissions to specific prefixes
192231
permissions := []auth.Permission{
193-
// Grant permissions for the exact domain (e.g., com.example/*)
194-
{
195-
Action: auth.PermissionActionPublish,
196-
ResourcePattern: fmt.Sprintf("%s/*", reverseDomain),
197-
},
198-
// DNS implies a hierarchy where subdomains are treated as part of the parent domain,
199-
// therefore we grant permissions for all subdomains (e.g., com.example.*)
200-
// This is in line with other DNS-based authentication methods e.g. ACME DNS-01 challenges
201232
{
202233
Action: auth.PermissionActionPublish,
203-
ResourcePattern: fmt.Sprintf("%s.*", reverseDomain),
234+
ResourcePattern: namePattern,
204235
},
205236
}
206237

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

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,172 @@ func TestDNSAuthHandler_ExchangeToken(t *testing.T) {
194194
})
195195
}
196196
}
197+
198+
func TestDNSAuthHandler_NamePatternScoping(t *testing.T) {
199+
cfg := &config.Config{
200+
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
201+
}
202+
handler := auth.NewDNSAuthHandler(cfg)
203+
204+
// Generate test key pairs
205+
publicKey1, privateKey1, err := ed25519.GenerateKey(nil)
206+
require.NoError(t, err)
207+
publicKey2, privateKey2, err := ed25519.GenerateKey(nil)
208+
require.NoError(t, err)
209+
210+
publicKeyB64_1 := base64.StdEncoding.EncodeToString(publicKey1)
211+
publicKeyB64_2 := base64.StdEncoding.EncodeToString(publicKey2)
212+
213+
tests := []struct {
214+
name string
215+
domain string
216+
txtRecords []string
217+
usePrivateKey ed25519.PrivateKey
218+
expectedPatterns []string
219+
expectError bool
220+
errorContains string
221+
expectedNumPerms int
222+
}{
223+
{
224+
name: "wildcard pattern (backward compatible)",
225+
domain: "example.com",
226+
txtRecords: []string{
227+
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64_1),
228+
},
229+
usePrivateKey: privateKey1,
230+
expectedPatterns: []string{"com.example/*", "com.example.*"},
231+
expectedNumPerms: 2,
232+
expectError: false,
233+
},
234+
{
235+
name: "specific name pattern",
236+
domain: "microsoft.com",
237+
txtRecords: []string{
238+
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s; n=com.microsoft/team-foo-*", publicKeyB64_1),
239+
},
240+
usePrivateKey: privateKey1,
241+
expectedPatterns: []string{"com.microsoft/team-foo-*"},
242+
expectedNumPerms: 1,
243+
expectError: false,
244+
},
245+
{
246+
name: "multiple keys with different patterns",
247+
domain: "example.com",
248+
txtRecords: []string{
249+
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s; n=com.example/app1-*", publicKeyB64_1),
250+
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s; n=com.example/app2-*", publicKeyB64_2),
251+
},
252+
usePrivateKey: privateKey2,
253+
expectedPatterns: []string{"com.example/app2-*"},
254+
expectedNumPerms: 1,
255+
expectError: false,
256+
},
257+
{
258+
name: "explicit wildcard in n parameter",
259+
domain: "test.com",
260+
txtRecords: []string{
261+
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s; n=*", publicKeyB64_1),
262+
},
263+
usePrivateKey: privateKey1,
264+
expectedPatterns: []string{"com.test/*", "com.test.*"},
265+
expectedNumPerms: 2,
266+
expectError: false,
267+
},
268+
{
269+
name: "subdomain scoping pattern",
270+
domain: "example.com",
271+
txtRecords: []string{
272+
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s; n=com.example.api/*", publicKeyB64_1),
273+
},
274+
usePrivateKey: privateKey1,
275+
expectedPatterns: []string{"com.example.api/*"},
276+
expectedNumPerms: 1,
277+
expectError: false,
278+
},
279+
{
280+
name: "pattern with spaces (should trim)",
281+
domain: "example.com",
282+
txtRecords: []string{
283+
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s; n= com.example/test-* ", publicKeyB64_1),
284+
},
285+
usePrivateKey: privateKey1,
286+
expectedPatterns: []string{"com.example/test-*"},
287+
expectedNumPerms: 1,
288+
expectError: false,
289+
},
290+
{
291+
name: "mixing patterns - one with, one without",
292+
domain: "example.com",
293+
txtRecords: []string{
294+
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", publicKeyB64_1),
295+
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s; n=com.example/limited-*", publicKeyB64_2),
296+
},
297+
usePrivateKey: privateKey1,
298+
expectedPatterns: []string{"com.example/*", "com.example.*"},
299+
expectedNumPerms: 2,
300+
expectError: false,
301+
},
302+
{
303+
name: "complex pattern with multiple path segments",
304+
domain: "example.com",
305+
txtRecords: []string{
306+
fmt.Sprintf("v=MCPv1; k=ed25519; p=%s; n=com.example/services/auth/*", publicKeyB64_1),
307+
},
308+
usePrivateKey: privateKey1,
309+
expectedPatterns: []string{"com.example/services/auth/*"},
310+
expectedNumPerms: 1,
311+
expectError: false,
312+
},
313+
}
314+
315+
for _, tt := range tests {
316+
t.Run(tt.name, func(t *testing.T) {
317+
// Create mock DNS resolver
318+
mockResolver := &MockDNSResolver{
319+
txtRecords: map[string][]string{
320+
tt.domain: tt.txtRecords,
321+
},
322+
}
323+
handler.SetResolver(mockResolver)
324+
325+
// Create timestamp and signature
326+
timestamp := time.Now().UTC().Format(time.RFC3339)
327+
signature := ed25519.Sign(tt.usePrivateKey, []byte(timestamp))
328+
signedTimestamp := hex.EncodeToString(signature)
329+
330+
// Call the handler
331+
result, err := handler.ExchangeToken(context.Background(), tt.domain, timestamp, signedTimestamp)
332+
333+
if tt.expectError {
334+
assert.Error(t, err)
335+
if tt.errorContains != "" {
336+
assert.Contains(t, err.Error(), tt.errorContains)
337+
}
338+
assert.Nil(t, result)
339+
} else {
340+
assert.NoError(t, err)
341+
assert.NotNil(t, result)
342+
assert.NotEmpty(t, result.RegistryToken)
343+
344+
// Verify the token contains expected claims
345+
jwtManager := intauth.NewJWTManager(cfg)
346+
claims, err := jwtManager.ValidateToken(context.Background(), result.RegistryToken)
347+
require.NoError(t, err)
348+
349+
assert.Equal(t, intauth.MethodDNS, claims.AuthMethod)
350+
assert.Equal(t, tt.domain, claims.AuthMethodSubject)
351+
assert.Len(t, claims.Permissions, tt.expectedNumPerms)
352+
353+
// Check permissions match expected patterns
354+
patterns := make([]string, len(claims.Permissions))
355+
for i, perm := range claims.Permissions {
356+
patterns[i] = perm.ResourcePattern
357+
}
358+
359+
for _, expectedPattern := range tt.expectedPatterns {
360+
assert.Contains(t, patterns, expectedPattern, "Expected pattern %s not found in permissions", expectedPattern)
361+
}
362+
}
363+
})
364+
}
365+
}

0 commit comments

Comments
 (0)