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>
This commit is contained in:
Radoslav Dimitrov 2026-04-25 11:22:06 -04:00 • committed by GitHub
commit c5f50e817b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 236 additions and 11 deletions

View file

@ -51,6 +51,12 @@ Successfully authenticated!
DNS authentication is a domain-based authentication method that relies on a DNS TXT record.
<Warning>
The TXT record must be placed on the **apex** of your domain (e.g. `example.com`), **not** under a selector like `_mcp-auth.example.com` or `_mcp-registry.example.com`. MCP DNS auth follows SPF-style placement (apex), not DKIM-style (selector). If you put the record under a selector, the registry will not see it and authentication will fail with a generic signature error.
If you rotate keys, also remember to remove the previous TXT record from the apex — a stale record left behind will be tried first and cause verification to fail.
</Warning>
To perform DNS authentication using the `mcp-publisher` CLI tool, run the following commands in your server project directory to generate a TXT record based on a public/private key pair:
<CodeGroup>

View file

@ -8,6 +8,7 @@ import (
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"math/big"
"regexp"
@ -18,6 +19,17 @@ import (
"github.com/modelcontextprotocol/registry/internal/config"
)
// ErrSignatureMismatch is returned by VerifySignature when the signature is structurally
// valid but does not verify against the public key. Distinguishing this from structural
// failures (wrong size, bad key format) lets the caller add fingerprint hints only when
// the failure is actually a "wrong key" situation.
var ErrSignatureMismatch = errors.New("signature does not match public key")
// MCPProofRecordPattern matches a well-formed MCPv1 DNS/HTTP proof record:
// "v=MCPv1; k=<algo>; p=<base64-public-key>". Shared so callers checking for the
// presence of a valid record see exactly what the parser will accept.
var MCPProofRecordPattern = regexp.MustCompile(`v=MCPv1;\s*k=([^;]+);\s*p=([A-Za-z0-9+/=]+)`)
// CryptoAlgorithm represents the cryptographic algorithm used for a public key
type CryptoAlgorithm string
@ -90,18 +102,72 @@ func DecodeAndValidateSignature(signedTimestamp string) ([]byte, error) {
}
func VerifySignatureWithKeys(publicKeys []PublicKeyInfo, messageBytes []byte, signature []byte) error {
var lastErr error
allMismatch := true
for _, publicKeyInfo := range publicKeys {
err := publicKeyInfo.VerifySignature(messageBytes, signature)
if err == nil {
return nil
}
if len(publicKeys) == 1 {
return err
lastErr = err
if !errors.Is(err, ErrSignatureMismatch) {
allMismatch = false
}
}
return fmt.Errorf("signature verification failed")
// If at least one key failed for a structural reason (wrong size, unsupported algorithm),
// surface that error directly — it's more actionable than a generic "didn't match" message.
if !allMismatch {
return lastErr
}
// Every key was tried and produced a clean cryptographic mismatch. Include short
// fingerprints of every key that was tried so users can tell which published keys the
// registry actually saw — the most common cause of this error is a stale record left
// behind after a key rotation, which is otherwise indistinguishable from a generic
// crypto failure.
fingerprints := make([]string, 0, len(publicKeys))
for _, publicKeyInfo := range publicKeys {
fingerprints = append(fingerprints, publicKeyInfo.Fingerprint())
}
if len(publicKeys) == 1 {
return fmt.Errorf(
"signature verification failed (tried published key %s); "+
"if this is not the key you are signing with, the published record may be stale",
fingerprints[0],
)
}
return fmt.Errorf(
"signature verification failed against all %d published keys (tried: %s); "+
"if you recently rotated keys, remove any stale records from the apex domain",
len(publicKeys), strings.Join(fingerprints, ", "),
)
}
// Fingerprint returns a short, human-readable identifier for the public key.
// Format: "<algorithm>:<first 8 base64 chars of the raw key>". Public keys are not secret,
// but truncating keeps error messages readable.
func (pki *PublicKeyInfo) Fingerprint() string {
const prefixLen = 8
var raw []byte
switch pki.Algorithm {
case AlgorithmEd25519:
if k, ok := pki.Key.(ed25519.PublicKey); ok {
raw = k
}
case AlgorithmECDSAP384:
if k, ok := pki.Key.(ecdsa.PublicKey); ok {
raw = elliptic.MarshalCompressed(k.Curve, k.X, k.Y) //nolint:staticcheck // SA1019: matches the encoding used in DNS records
}
}
if len(raw) == 0 {
return fmt.Sprintf("%s:unknown", pki.Algorithm)
}
encoded := base64.StdEncoding.EncodeToString(raw)
if len(encoded) > prefixLen {
encoded = encoded[:prefixLen]
}
return fmt.Sprintf("%s:%s", pki.Algorithm, encoded)
}
// VerifySignature verifies a signature using the appropriate algorithm
@ -113,7 +179,7 @@ func (pki *PublicKeyInfo) VerifySignature(message, signature []byte) error {
return fmt.Errorf("invalid signature size for Ed25519")
}
if !ed25519.Verify(ed25519Key, message, signature) {
return fmt.Errorf("Ed25519 signature verification failed")
return fmt.Errorf("Ed25519: %w", ErrSignatureMismatch)
}
return nil
}
@ -126,7 +192,7 @@ func (pki *PublicKeyInfo) VerifySignature(message, signature []byte) error {
s := new(big.Int).SetBytes(signature[48:])
digest := sha512.Sum384(message)
if !ecdsa.Verify(&ecdsaKey, digest[:], r, s) {
return fmt.Errorf("ECDSA P-384 signature verification failed")
return fmt.Errorf("ECDSA P-384: %w", ErrSignatureMismatch)
}
return nil
}
@ -246,11 +312,8 @@ func ParseMCPKeysFromStrings(inputs []string) []struct {
error
}
// proof record pattern: v=MCPv1; k=<algo>; p=<base64-public-key>
cryptoPattern := regexp.MustCompile(`v=MCPv1;\s*k=([^;]+);\s*p=([A-Za-z0-9+/=]+)`)
for _, record := range inputs {
if matches := cryptoPattern.FindStringSubmatch(record); len(matches) == 3 {
if matches := MCPProofRecordPattern.FindStringSubmatch(record); len(matches) == 3 {
publicKey, err := ParsePublicKey(matches[1], matches[2])
publicKeys = append(publicKeys, struct {
*PublicKeyInfo

View file

@ -75,6 +75,11 @@ func RegisterDNSEndpoint(api huma.API, pathPrefix string, cfg *config.Config) {
})
}
// commonWrongSelectors lists subdomain prefixes that users frequently mistake for the
// MCP DNS auth record location (DKIM-style intuition). MCP DNS auth uses the apex,
// like SPF — see #385, #1103, #1126 for the recurring confusion.
var commonWrongSelectors = []string{"_mcp-auth", "_mcp-registry"}
// ExchangeToken exchanges DNS signature for a Registry JWT token
func (h *DNSAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, signedTimestamp string) (*auth.TokenResponse, error) {
keyFetcher := func(ctx context.Context, domain string) ([]string, error) {
@ -90,9 +95,63 @@ func (h *DNSAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, s
if err != nil {
return nil, fmt.Errorf("failed to lookup DNS TXT records: %w", err)
}
if !hasMCPRecord(txtRecords) {
if found := h.findMisplacedSelector(timeoutCtx, domain); found != "" {
return nil, fmt.Errorf(
"no MCPv1 TXT record at %q, but one was found at %q — "+
"MCP DNS auth requires the record at the apex domain, not under a selector",
domain, found,
)
}
}
return txtRecords, nil
}
allowSubdomains := true
return h.CoreAuthHandler.ExchangeToken(ctx, domain, timestamp, signedTimestamp, keyFetcher, allowSubdomains, auth.MethodDNS)
}
// hasMCPRecord reports whether any of the supplied TXT records contains a well-formed
// MCPv1 proof record. Uses the same strict pattern as the parser so a malformed
// "v=MCPv1" string at the apex doesn't suppress the misplaced-selector probe.
func hasMCPRecord(records []string) bool {
for _, r := range records {
if MCPProofRecordPattern.MatchString(r) {
return true
}
}
return false
}
// findMisplacedSelector probes a small fixed set of common wrong selectors and returns the
// first one that holds an MCPv1 record, or "" if none do. Lookups run in parallel with a
// short individual timeout so a slow/missing zone never delays the response by much.
func (h *DNSAuthHandler) findMisplacedSelector(ctx context.Context, domain string) string {
type result struct {
name string
found bool
}
results := make(chan result, len(commonWrongSelectors))
for _, selector := range commonWrongSelectors {
name := selector + "." + domain
go func(name string) {
lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
records, err := h.resolver.LookupTXT(lookupCtx, name)
if err != nil {
results <- result{name: name, found: false}
return
}
results <- result{name: name, found: hasMCPRecord(records)}
}(name)
}
for range commonWrongSelectors {
r := <-results
if r.found {
return r.name
}
}
return ""
}

View file

@ -546,7 +546,7 @@ func TestDNSAuthHandler_ExchangeToken_ECDSAP384(t *testing.T) {
timestamp: time.Now().UTC().Format(time.RFC3339),
signedTimestamp: "abcdef1234", // too short for ECDSA P-384
expectError: true,
errorContains: "signature verification failed", // general error when trying all keys
errorContains: "invalid signature size for ECDSA P-384",
},
{
name: "wrong ECDSA P-384 key for signature",
@ -990,3 +990,100 @@ func TestDNSAuthHandler_Mixed_Algorithm_Support(t *testing.T) {
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")
}