@@ -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
4450type DNSAuthHandler struct {
4551 config * config.Config
@@ -120,29 +126,34 @@ 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 and collect all valid records
131137 messageBytes := []byte (timestamp )
132- signatureValid := false
133- for _ , publicKey := range publicKeys {
134- if ed25519 .Verify (publicKey , messageBytes , signature ) {
135- signatureValid = true
136- break
138+ var validRecords []DNSAuthRecord
139+ for _ , record := range authRecords {
140+ if ed25519 .Verify (record .PublicKey , messageBytes , signature ) {
141+ validRecords = append (validRecords , record )
137142 }
138143 }
139144
140- if ! signatureValid {
145+ if len ( validRecords ) == 0 {
141146 return nil , fmt .Errorf ("signature verification failed" )
142147 }
143148
144- // Build permissions for domain and subdomains
145- permissions := h .buildPermissions (domain )
149+ // Build permissions from all valid records
150+ var permissions []auth.Permission
151+ for _ , record := range validRecords {
152+ permissions = append (permissions , h .buildPermissions (domain , record .NamePattern )... )
153+ }
154+ if len (permissions ) == 0 {
155+ return nil , fmt .Errorf ("no valid permissions found for the given name pattern" )
156+ }
146157
147158 // Create JWT claims
148159 jwtClaims := auth.JWTClaims {
@@ -160,14 +171,16 @@ func (h *DNSAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, s
160171 return tokenResponse , nil
161172}
162173
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+/=]+)` )
174+ // parseAuthRecordsFromTXT parses DNS authentication records from TXT records
175+ // Supports optional n=<pattern> parameter for name scoping
176+ func (h * DNSAuthHandler ) parseAuthRecordsFromTXT (txtRecords []string ) []DNSAuthRecord {
177+ var authRecords []DNSAuthRecord
178+ // Updated pattern to capture optional n=<pattern> parameter
179+ mcpPattern := regexp .MustCompile (`v=MCPv1;\s*k=ed25519;\s*p=([A-Za-z0-9+/=]+)(?:;\s*n=([^;]+))?` )
167180
168181 for _ , record := range txtRecords {
169182 matches := mcpPattern .FindStringSubmatch (record )
170- if len (matches ) = = 2 {
183+ if len (matches ) > = 2 {
171184 // Decode base64 public key
172185 publicKeyBytes , err := base64 .StdEncoding .DecodeString (matches [1 ])
173186 if err != nil {
@@ -178,29 +191,79 @@ func (h *DNSAuthHandler) parsePublicKeysFromTXT(txtRecords []string) []ed25519.P
178191 continue // Skip invalid key sizes
179192 }
180193
181- publicKeys = append (publicKeys , ed25519 .PublicKey (publicKeyBytes ))
194+ // Extract name pattern or default to wildcard
195+ namePattern := "*"
196+ if len (matches ) > 2 && matches [2 ] != "" {
197+ namePattern = strings .TrimSpace (matches [2 ])
198+ }
199+
200+ authRecords = append (authRecords , DNSAuthRecord {
201+ PublicKey : ed25519 .PublicKey (publicKeyBytes ),
202+ NamePattern : namePattern ,
203+ })
182204 }
183205 }
184206
185- return publicKeys
207+ return authRecords
186208}
187209
188- // buildPermissions builds permissions for a domain and its subdomains using reverse DNS notation
189- func (h * DNSAuthHandler ) buildPermissions (domain string ) []auth.Permission {
210+ // buildPermissions builds permissions based on domain and name pattern
211+ // namePattern defaults to "*" for wildcard access (backward compatible)
212+ func (h * DNSAuthHandler ) buildPermissions (domain string , namePattern string ) []auth.Permission {
190213 reverseDomain := reverseString (domain )
191214
215+ // If namePattern is "*", grant traditional wildcard permissions
216+ if namePattern == "*" {
217+ permissions := []auth.Permission {
218+ // Grant permissions for the exact domain (e.g., com.example/*)
219+ {
220+ Action : auth .PermissionActionPublish ,
221+ ResourcePattern : fmt .Sprintf ("%s/*" , reverseDomain ),
222+ },
223+ // DNS implies a hierarchy where subdomains are treated as part of the parent domain,
224+ // therefore we grant permissions for all subdomains (e.g., com.example.*)
225+ // This is in line with other DNS-based authentication methods e.g. ACME DNS-01 challenges
226+ {
227+ Action : auth .PermissionActionPublish ,
228+ ResourcePattern : fmt .Sprintf ("%s.*" , reverseDomain ),
229+ },
230+ }
231+ return permissions
232+ }
233+
234+ // For specific name patterns, grant permission only for the specified pattern
235+ // This allows DNS controllers to scope permissions to specific prefixes
236+ // The name pattern MUST be scoped to the domain it is on.
237+ // We need to ensure proper delimiter checking to prevent prefix attacks
238+ // e.g., micro.com should not be able to claim com.microsoft/*
239+ if ! strings .HasPrefix (namePattern , reverseDomain ) {
240+ return []auth.Permission {}
241+ }
242+
243+ // Check that after the reverse domain, there's either:
244+ // - nothing (exact match)
245+ // - a '.' (subdomain like com.example.api)
246+ // - a '/' (path like com.example/foo)
247+ if len (namePattern ) > len (reverseDomain ) {
248+ delimiter := namePattern [len (reverseDomain )]
249+ if delimiter != '.' && delimiter != '/' {
250+ // Invalid pattern - doesn't have proper delimiter after domain
251+ return []auth.Permission {}
252+ }
253+ }
254+
255+ // Validate server name format: should have exactly one slash
256+ // This aligns with PR #476 requirements
257+ slashCount := strings .Count (namePattern , "/" )
258+ if slashCount > 1 {
259+ // Invalid pattern - multiple slashes not allowed in server names
260+ return []auth.Permission {}
261+ }
262+
192263 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
201264 {
202265 Action : auth .PermissionActionPublish ,
203- ResourcePattern : fmt . Sprintf ( "%s.*" , reverseDomain ) ,
266+ ResourcePattern : namePattern ,
204267 },
205268 }
206269
0 commit comments