## 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>
1089 lines
36 KiB
Go
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")
|
|
}
|