Add support for ECDSA P-384 (#685)

## Motivation and Context

See
https://github.com/modelcontextprotocol/registry/issues/482#issuecomment-3436993157
for full details.

This adds ECDSA P-384 (a popular NIST approved curve) in addition to
Ed25519.

## How Has This Been Tested?

I have tested with a local deployment against `mcp.joelverhagen.com`.
Also, I have added significant tests.

## Breaking Changes

None. (hopefully)

## 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)
- [x] 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
<!-- Add any other context, implementation notes, or design decisions
-->

@domdomegg and I spoke a bit about this on Discord:
https://discord.com/channels/1358869848138059966/1369487942862504016/1423710918180606052

---------

Co-authored-by: adam jones <domdomegg+git@gmail.com>
This commit is contained in:
Joel Verhagen 2025-10-24 14:02:33 -04:00 • committed by GitHub
commit 6c0f085708
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1389 additions and 74 deletions

View file

@ -3,7 +3,11 @@ package auth
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/sha512"
"encoding/hex"
"encoding/json"
"fmt"
@ -12,12 +16,24 @@ import (
"time"
)
type CryptoAlgorithm string
const (
AlgorithmEd25519 CryptoAlgorithm = "ed25519"
// ECDSA with NIST P-384 curve
// public key is in compressed format
// signature is in R || S format
AlgorithmECDSAP384 CryptoAlgorithm = "ecdsap384"
)
// CryptoProvider provides common functionality for DNS and HTTP authentication
type CryptoProvider struct {
registryURL string
domain string
hexSeed string
authMethod string
registryURL string
domain string
privateKey string
cryptoAlgorithm CryptoAlgorithm
authMethod string
}
// GetToken retrieves the registry JWT token using cryptographic authentication
@ -26,31 +42,26 @@ func (c *CryptoProvider) GetToken(ctx context.Context) (string, error) {
return "", fmt.Errorf("%s domain is required", c.authMethod)
}
if c.hexSeed == "" {
return "", fmt.Errorf("%s private key (hex seed) is required", c.authMethod)
if c.privateKey == "" {
return "", fmt.Errorf("%s private key (hex) is required", c.authMethod)
}
// Decode hex seed to private key
seedBytes, err := hex.DecodeString(c.hexSeed)
// Decode private key from hex
privateKeyBytes, err := hex.DecodeString(c.privateKey)
if err != nil {
return "", fmt.Errorf("invalid hex seed format: %w", err)
return "", fmt.Errorf("invalid hex private key format: %w", err)
}
if len(seedBytes) != ed25519.SeedSize {
return "", fmt.Errorf("invalid seed length: expected %d bytes, got %d", ed25519.SeedSize, len(seedBytes))
}
privateKey := ed25519.NewKeyFromSeed(seedBytes)
// Generate current timestamp
timestamp := time.Now().UTC().Format(time.RFC3339)
// Sign the timestamp
signature := ed25519.Sign(privateKey, []byte(timestamp))
signedTimestamp := hex.EncodeToString(signature)
signedTimestamp, err := c.signMessage(privateKeyBytes, []byte(timestamp))
if err != nil {
return "", fmt.Errorf("failed to sign timestamp: %w", err)
}
signedTimestampHex := hex.EncodeToString(signedTimestamp)
// Exchange signature for registry token
registryToken, err := c.exchangeTokenForRegistry(ctx, c.domain, timestamp, signedTimestamp)
registryToken, err := c.exchangeTokenForRegistry(ctx, c.domain, timestamp, signedTimestampHex)
if err != nil {
return "", fmt.Errorf("failed to exchange %s signature: %w", c.authMethod, err)
}
@ -58,6 +69,38 @@ func (c *CryptoProvider) GetToken(ctx context.Context) (string, error) {
return registryToken, nil
}
func (c *CryptoProvider) signMessage(privateKeyBytes []byte, message []byte) ([]byte, error) {
switch c.cryptoAlgorithm {
case AlgorithmEd25519:
if len(privateKeyBytes) != ed25519.SeedSize {
return nil, fmt.Errorf("invalid seed length: expected %d bytes, got %d", ed25519.SeedSize, len(privateKeyBytes))
}
privateKey := ed25519.NewKeyFromSeed(privateKeyBytes)
signature := ed25519.Sign(privateKey, message)
return signature, nil
case AlgorithmECDSAP384:
if len(privateKeyBytes) != 48 {
return nil, fmt.Errorf("invalid seed length for ECDSA P-384: expected 48 bytes, got %d", len(privateKeyBytes))
}
digest := sha512.Sum384(message)
curve := elliptic.P384()
privateKey, err := ecdsa.ParseRawPrivateKey(curve, privateKeyBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse ECDSA private key: %w", err)
}
r, s, err := ecdsa.Sign(rand.Reader, privateKey, digest[:])
if err != nil {
return nil, fmt.Errorf("failed to sign message: %w", err)
}
signature := append(r.Bytes(), s.Bytes()...)
return signature, nil
default:
return nil, fmt.Errorf("unsupported crypto algorithm: %s", c.cryptoAlgorithm)
}
}
// NeedsLogin always returns false for cryptographic auth since no interactive login is needed
func (c *CryptoProvider) NeedsLogin() bool {
return false

View file

@ -5,13 +5,14 @@ type DNSProvider struct {
}
// NewDNSProvider creates a new DNS-based auth provider
func NewDNSProvider(registryURL, domain, hexSeed string) Provider {
func NewDNSProvider(registryURL, domain, privateKey string, cryptoAlgorithm CryptoAlgorithm) Provider {
return &DNSProvider{
CryptoProvider: &CryptoProvider{
registryURL: registryURL,
domain: domain,
hexSeed: hexSeed,
authMethod: "dns",
registryURL: registryURL,
domain: domain,
privateKey: privateKey,
cryptoAlgorithm: cryptoAlgorithm,
authMethod: "dns",
},
}
}

View file

@ -5,13 +5,14 @@ type HTTPProvider struct {
}
// NewHTTPProvider creates a new HTTP-based auth provider
func NewHTTPProvider(registryURL, domain, hexSeed string) Provider {
func NewHTTPProvider(registryURL, domain, privateKey string, cryptoAlgorithm CryptoAlgorithm) Provider {
return &HTTPProvider{
CryptoProvider: &CryptoProvider{
registryURL: registryURL,
domain: domain,
hexSeed: hexSeed,
authMethod: "http",
registryURL: registryURL,
domain: domain,
privateKey: privateKey,
cryptoAlgorithm: cryptoAlgorithm,
authMethod: "http",
},
}
}

View file

@ -17,6 +17,21 @@ const (
TokenFileName = ".mcp_publisher_token" //nolint:gosec // Not a credential, just a filename
)
type CryptoAlgorithm auth.CryptoAlgorithm
func (c *CryptoAlgorithm) String() string {
return string(*c)
}
func (c *CryptoAlgorithm) Set(v string) error {
switch v {
case string(auth.AlgorithmEd25519), string(auth.AlgorithmECDSAP384):
*c = CryptoAlgorithm(v)
return nil
}
return fmt.Errorf("invalid algorithm: %q (allowed: ed25519, ecdsap384)", v)
}
func LoginCommand(args []string) error {
if len(args) < 1 {
return errors.New("authentication method required\n\nUsage: mcp-publisher login <method>\n\nMethods:\n github Interactive GitHub authentication\n github-oidc GitHub Actions OIDC authentication\n dns DNS-based authentication (requires --domain and --private-key)\n http HTTP-based authentication (requires --domain and --private-key)\n none Anonymous authentication (for testing)")
@ -28,13 +43,15 @@ func LoginCommand(args []string) error {
loginFlags := flag.NewFlagSet("login", flag.ExitOnError)
var domain string
var privateKey string
var cryptoAlgorithm = CryptoAlgorithm(auth.AlgorithmEd25519)
var registryURL string
loginFlags.StringVar(&registryURL, "registry", DefaultRegistryURL, "Registry URL")
if method == "dns" || method == "http" {
loginFlags.StringVar(&domain, "domain", "", "Domain name")
loginFlags.StringVar(&privateKey, "private-key", "", "Private key (64-char hex)")
loginFlags.StringVar(&privateKey, "private-key", "", "Private key (hex)")
loginFlags.Var(&cryptoAlgorithm, "algorithm", "Cryptographic algorithm (ed25519, ecdsap384)")
}
if err := loginFlags.Parse(args[1:]); err != nil {
@ -52,12 +69,12 @@ func LoginCommand(args []string) error {
if domain == "" || privateKey == "" {
return errors.New("dns authentication requires --domain and --private-key")
}
authProvider = auth.NewDNSProvider(registryURL, domain, privateKey)
authProvider = auth.NewDNSProvider(registryURL, domain, privateKey, auth.CryptoAlgorithm(cryptoAlgorithm))
case "http":
if domain == "" || privateKey == "" {
return errors.New("http authentication requires --domain and --private-key")
}
authProvider = auth.NewHTTPProvider(registryURL, domain, privateKey)
authProvider = auth.NewHTTPProvider(registryURL, domain, privateKey, auth.CryptoAlgorithm(cryptoAlgorithm))
case "none":
authProvider = auth.NewNoneProvider(registryURL)
default:

View file

@ -468,6 +468,8 @@ echo "yourcompany.com. IN TXT \"v=MCPv1; k=ed25519; p=$(openssl pkey -in key.pem
mcp-publisher login dns --domain yourcompany.com --private-key $(openssl pkey -in key.pem -noout -text | grep -A3 "priv:" | tail -n +2 | tr -d ' :\n')
```
The ECDSA P-384 crypto algorithm is also supported, along with HTTP-based authenication. See the [publisher CLI commands reference](../../reference/cli/commands.md) for more details.
## Step 5: Publish Your Server
With authentication complete, publish your server:

View file

@ -80,9 +80,9 @@ mcp-publisher login dns --domain=example.com --private-key=HEX_KEY [--registry=U
```
- Verifies domain ownership via DNS TXT record
- Grants access to `com.example.*` namespaces
- Requires Ed25519 private key (64-character hex)
- Requires Ed25519 private key (64-character hex) or ECDSA P-384 private key (96-character hex)
**Setup:**
**Setup:** (for Ed25519, recommended)
```bash
# Generate keypair
openssl genpkey -algorithm Ed25519 -out key.pem
@ -97,15 +97,30 @@ openssl pkey -in key.pem -pubout -outform DER | tail -c 32 | base64
openssl pkey -in key.pem -noout -text | grep -A3 "priv:" | tail -n +2 | tr -d ' :\n'
```
**Setup:** (for ECDSA P-384)
```bash
# Generate keypair
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:secp384r1 -out key.pem
# Get public key for DNS record
openssl ec -in key.pem -text -noout -conv_form compressed | grep -A4 "pub:" | tail -n +2 | tr -d ' :\n' | xxd -r -p | base64
# Add DNS TXT record:
# example.com. IN TXT "v=MCPv1; k=ecdsap384; p=PUBLIC_KEY"
# Extract private key for login
openssl ec -in <pem path> -noout -text | grep -A4 "priv:" | tail -n +2 | tr -d ' :\n'
```
#### HTTP Verification
```bash
mcp-publisher login http --domain=example.com --private-key=HEX_KEY [--registry=URL]
```
- Verifies domain ownership via HTTPS endpoint
- Grants access to `com.example.*` namespaces
- Requires Ed25519 private key (64-character hex)
- Requires Ed25519 private key (64-character hex) or ECDSA P-384 private key (96-character hex)
**Setup:**
**Setup:** (for Ed25519, recommended)
```bash
# Generate keypair (same as DNS)
openssl genpkey -algorithm Ed25519 -out key.pem
@ -115,6 +130,16 @@ openssl genpkey -algorithm Ed25519 -out key.pem
# Content: v=MCPv1; k=ed25519; p=PUBLIC_KEY
```
**Setup:** (for ECDSA P-384)
```bash
# Generate keypair (same as DNS)
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:secp384r1 -out key.pem
# Host public key at:
# https://example.com/.well-known/mcp-registry-auth
# Content: v=MCPv1; k=ecdsap384; p=PUBLIC_KEY
```
#### Anonymous (Testing)
```bash
mcp-publisher login none [--registry=URL]

View file

@ -2,10 +2,14 @@ package auth
import (
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"fmt"
"math/big"
"regexp"
"strings"
"time"
@ -14,11 +18,29 @@ import (
"github.com/modelcontextprotocol/registry/internal/config"
)
// CryptoAlgorithm represents the cryptographic algorithm used for a public key
type CryptoAlgorithm string
const (
AlgorithmEd25519 CryptoAlgorithm = "ed25519"
// ECDSA with NIST P-384 curve
// public key is in compressed format
// signature is in R || S format
AlgorithmECDSAP384 CryptoAlgorithm = "ecdsap384"
)
// PublicKeyInfo contains a public key along with its algorithm type
type PublicKeyInfo struct {
Algorithm CryptoAlgorithm
Key any
}
// SignatureTokenExchangeInput represents the common input structure for token exchange
type SignatureTokenExchangeInput struct {
Domain string `json:"domain" doc:"Domain name" example:"example.com" required:"true"`
Timestamp string `json:"timestamp" doc:"RFC3339 timestamp" example:"2023-01-01T00:00:00Z" required:"true"`
SignedTimestamp string `json:"signed_timestamp" doc:"Hex-encoded Ed25519 signature of timestamp" example:"abcdef1234567890" required:"true"`
SignedTimestamp string `json:"signed_timestamp" doc:"Hex-encoded signature of timestamp" example:"abcdef1234567890" required:"true"`
}
// KeyFetcher defines a function type for fetching keys from external sources
@ -64,20 +86,52 @@ func DecodeAndValidateSignature(signedTimestamp string) ([]byte, error) {
return nil, fmt.Errorf("invalid signature format, must be hex: %w", err)
}
if len(signature) != ed25519.SignatureSize {
return nil, fmt.Errorf("invalid signature length: expected %d, got %d", ed25519.SignatureSize, len(signature))
}
return signature, nil
}
func VerifySignatureWithKeys(publicKeys []ed25519.PublicKey, messageBytes []byte, signature []byte) bool {
for _, publicKey := range publicKeys {
if ed25519.Verify(publicKey, messageBytes, signature) {
return true
func VerifySignatureWithKeys(publicKeys []PublicKeyInfo, messageBytes []byte, signature []byte) error {
for _, publicKeyInfo := range publicKeys {
err := publicKeyInfo.VerifySignature(messageBytes, signature)
if err == nil {
return nil
}
if len(publicKeys) == 1 {
return err
}
}
return false
return fmt.Errorf("signature verification failed")
}
// VerifySignature verifies a signature using the appropriate algorithm
func (pki *PublicKeyInfo) VerifySignature(message, signature []byte) error {
switch pki.Algorithm {
case AlgorithmEd25519:
if ed25519Key, ok := pki.Key.(ed25519.PublicKey); ok {
if len(signature) != ed25519.SignatureSize {
return fmt.Errorf("invalid signature size for Ed25519")
}
if !ed25519.Verify(ed25519Key, message, signature) {
return fmt.Errorf("Ed25519 signature verification failed")
}
return nil
}
case AlgorithmECDSAP384:
if ecdsaKey, ok := pki.Key.(ecdsa.PublicKey); ok {
if len(signature) != 96 {
return fmt.Errorf("invalid signature size for ECDSA P-384")
}
r := new(big.Int).SetBytes(signature[:48])
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 nil
}
}
return fmt.Errorf("unsupported public key algorithm")
}
// BuildPermissions builds permissions for a domain with optional subdomain support
@ -143,21 +197,39 @@ func (h *CoreAuthHandler) ExchangeToken(
return nil, fmt.Errorf("failed to fetch keys: %w", err)
}
publicKeys := ParseMCPKeysFromStrings(keyStrings)
if len(publicKeys) == 0 {
publicKeysAndErrors := ParseMCPKeysFromStrings(keyStrings)
if len(publicKeysAndErrors) == 0 {
switch authMethod {
case auth.MethodHTTP:
return nil, fmt.Errorf("failed to parse public key")
return nil, fmt.Errorf("no MCP public key found in HTTP response")
case auth.MethodDNS:
return nil, fmt.Errorf("no valid MCP public keys found in DNS TXT records")
return nil, fmt.Errorf("no MCP public key found in DNS TXT records")
case auth.MethodGitHubAT, auth.MethodGitHubOIDC, auth.MethodOIDC, auth.MethodNone:
return nil, fmt.Errorf("no valid MCP public keys found using %s authentication", authMethod)
default:
return nil, fmt.Errorf("no MCP public key found using %s authentication", authMethod)
}
}
// provide a specific error message if there's only one key found
if len(publicKeysAndErrors) == 1 && publicKeysAndErrors[0].error != nil {
return nil, publicKeysAndErrors[0].error
}
var publicKeys []PublicKeyInfo
for _, pke := range publicKeysAndErrors {
if pke.error == nil {
publicKeys = append(publicKeys, *pke.PublicKeyInfo)
}
}
if len(publicKeys) == 0 {
return nil, fmt.Errorf("no valid MCP public key found")
}
messageBytes := []byte(timestamp)
if !VerifySignatureWithKeys(publicKeys, messageBytes, signature) {
return nil, fmt.Errorf("signature verification failed")
err = VerifySignatureWithKeys(publicKeys, messageBytes, signature)
if err != nil {
return nil, err
}
permissions := BuildPermissions(domain, includeSubdomains)
@ -165,30 +237,68 @@ func (h *CoreAuthHandler) ExchangeToken(
return h.CreateJWTClaimsAndToken(ctx, authMethod, domain, permissions)
}
func ParseMCPKeysFromStrings(inputs []string) []ed25519.PublicKey {
var publicKeys []ed25519.PublicKey
mcpPattern := regexp.MustCompile(`v=MCPv1;\s*k=ed25519;\s*p=([A-Za-z0-9+/=]+)`)
func ParseMCPKeysFromStrings(inputs []string) []struct {
*PublicKeyInfo
error
} {
var publicKeys []struct {
*PublicKeyInfo
error
}
for _, input := range inputs {
matches := mcpPattern.FindStringSubmatch(input)
if len(matches) == 2 {
// Decode base64 public key
publicKeyBytes, err := base64.StdEncoding.DecodeString(matches[1])
if err != nil {
continue // Skip invalid keys
}
// proof record pattern: v=MCPv1; k=<algo>; p=<base64-public-key>
cryptoPattern := regexp.MustCompile(`v=MCPv1;\s*k=([^;]+);\s*p=([A-Za-z0-9+/=]+)`)
if len(publicKeyBytes) != ed25519.PublicKeySize {
continue // Skip invalid key sizes
}
publicKeys = append(publicKeys, ed25519.PublicKey(publicKeyBytes))
for _, record := range inputs {
if matches := cryptoPattern.FindStringSubmatch(record); len(matches) == 3 {
publicKey, err := ParsePublicKey(matches[1], matches[2])
publicKeys = append(publicKeys, struct {
*PublicKeyInfo
error
}{publicKey, err})
}
}
return publicKeys
}
func ParsePublicKey(algorithm, publicKey string) (*PublicKeyInfo, error) {
publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKey)
if err != nil {
return nil, fmt.Errorf("failed to decode public key: %w", err)
}
// match to a supported crypto algorithm
switch algorithm {
case string(AlgorithmEd25519):
if len(publicKeyBytes) != ed25519.PublicKeySize {
return nil, fmt.Errorf("invalid Ed25519 public key size")
}
return &PublicKeyInfo{
Algorithm: AlgorithmEd25519,
Key: ed25519.PublicKey(publicKeyBytes),
}, nil
case string(AlgorithmECDSAP384):
if len(publicKeyBytes) != 49 {
return nil, fmt.Errorf("invalid ECDSA P-384 public key size")
}
if publicKeyBytes[0] != 0x02 && publicKeyBytes[0] != 0x03 {
return nil, fmt.Errorf("invalid ECDSA P-384 public key format (must be compressed, with a leading 0x02 or 0x03 byte)")
}
curve := elliptic.P384()
x, y := elliptic.UnmarshalCompressed(curve, publicKeyBytes)
if x == nil || y == nil {
return nil, fmt.Errorf("failed to decompress ECDSA P-384 public key")
}
return &PublicKeyInfo{
Algorithm: AlgorithmECDSAP384,
Key: ecdsa.PublicKey{Curve: curve, X: x, Y: y},
}, nil
}
return nil, fmt.Errorf("unsupported public key algorithm: %s", algorithm)
}
// ReverseString reverses a domain string (example.com -> com.example)
func ReverseString(domain string) string {
parts := strings.Split(domain, ".")

View file

@ -2,7 +2,11 @@ package auth_test
import (
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"fmt"
@ -31,6 +35,31 @@ func (m *MockDNSResolver) LookupTXT(_ context.Context, name string) ([]string, e
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",
@ -127,7 +156,7 @@ func TestDNSAuthHandler_ExchangeToken(t *testing.T) {
m.err = nil
},
expectError: true,
errorContains: "no valid MCP public keys found",
errorContains: "no MCP public key found in DNS TXT records",
},
}
@ -458,3 +487,506 @@ func TestDNSAuthHandler_PermissionValidation(t *testing.T) {
})
}
}
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: "signature verification failed", // general error when trying all keys
},
{
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)
})
}

View file

@ -3,7 +3,11 @@ package auth_test
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/sha512"
"crypto/tls"
"encoding/base64"
"encoding/hex"
@ -58,6 +62,31 @@ func (m *MockHTTPKeyFetcher) FetchKey(_ context.Context, domain string) (string,
return m.keyResponses[domain], nil
}
// generateECDSAP384KeyPair generates an ECDSA P-384 key pair for testing
func generateECDSAP384KeyPairHTTP(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
}
// signWithECDSAP384HTTP signs a message using ECDSA P-384
func signWithECDSAP384HTTP(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 TestHTTPAuthHandler_ExchangeToken(t *testing.T) {
cfg := &config.Config{
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
@ -137,7 +166,7 @@ func TestHTTPAuthHandler_ExchangeToken(t *testing.T) {
timestamp: time.Now().UTC().Format(time.RFC3339),
signedTimestamp: "abcdef", // too short
expectError: true,
errorContains: "invalid signature length",
errorContains: "invalid signature size for Ed25519",
},
{
name: "HTTP key fetch failure",
@ -158,7 +187,7 @@ func TestHTTPAuthHandler_ExchangeToken(t *testing.T) {
m.err = nil
},
expectError: true,
errorContains: "failed to parse public key",
errorContains: "no MCP public key found in HTTP response",
},
{
name: "invalid base64 key",
@ -169,7 +198,7 @@ func TestHTTPAuthHandler_ExchangeToken(t *testing.T) {
m.err = nil
},
expectError: true,
errorContains: "failed to parse public key",
errorContains: "failed to decode public key: illegal base64 data at input byte 4",
},
{
name: "wrong key size",
@ -182,7 +211,7 @@ func TestHTTPAuthHandler_ExchangeToken(t *testing.T) {
m.err = nil
},
expectError: true,
errorContains: "failed to parse public key",
errorContains: "invalid Ed25519 public key size",
},
{
name: "signature verification failure",
@ -737,3 +766,558 @@ func TestHTTPvsDNS_PermissionDifferences(t *testing.T) {
})
}
}
func TestHTTPAuthHandler_ExchangeToken_ECDSAP384(t *testing.T) {
cfg := &config.Config{
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}
handler := auth.NewHTTPAuthHandler(cfg)
// Generate ECDSA P-384 test key pair
compressedPubKey, privateKey := generateECDSAP384KeyPairHTTP(t)
// Create mock HTTP key fetcher
publicKeyB64 := base64.StdEncoding.EncodeToString(compressedPubKey)
mockFetcher := &MockHTTPKeyFetcher{
keyResponses: map[string]string{
testDomain: fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", publicKeyB64),
},
}
handler.SetFetcher(mockFetcher)
tests := []struct {
name string
domain string
timestamp string
signedTimestamp string
setupMock func(*MockHTTPKeyFetcher)
expectError bool
errorContains string
}{
{
name: "successful ECDSA P-384 authentication",
domain: testDomain,
timestamp: time.Now().UTC().Format(time.RFC3339),
setupMock: func(_ *MockHTTPKeyFetcher) {
// Mock is already set up with valid key
},
expectError: false,
},
{
name: "invalid ECDSA P-384 signature format",
domain: testDomain,
timestamp: time.Now().UTC().Format(time.RFC3339),
signedTimestamp: "invalid-hex",
expectError: true,
errorContains: "invalid signature format",
},
{
name: "ECDSA P-384 signature wrong length",
domain: testDomain,
timestamp: time.Now().UTC().Format(time.RFC3339),
signedTimestamp: "abcdef", // too short
expectError: true,
errorContains: "invalid signature size for ECDSA P-384",
},
{
name: "HTTP ECDSA P-384 key fetch failure",
domain: "nonexistent.com",
timestamp: time.Now().UTC().Format(time.RFC3339),
setupMock: func(m *MockHTTPKeyFetcher) {
m.err = fmt.Errorf("HTTP 404: not found")
},
expectError: true,
errorContains: "failed to fetch public key",
},
{
name: "invalid ECDSA P-384 key format",
domain: "invalidkey.com",
timestamp: time.Now().UTC().Format(time.RFC3339),
setupMock: func(m *MockHTTPKeyFetcher) {
m.keyResponses["invalidkey.com"] = "invalid key format"
m.err = nil
},
expectError: true,
errorContains: "no MCP public key found in HTTP response",
},
{
name: "invalid base64 ECDSA P-384 key",
domain: "badkey.com",
timestamp: time.Now().UTC().Format(time.RFC3339),
setupMock: func(m *MockHTTPKeyFetcher) {
m.keyResponses["badkey.com"] = "v=MCPv1; k=ecdsap384; p=invalid-base64!!!"
m.err = nil
},
expectError: true,
errorContains: "failed to decode public key: illegal base64 data at input byte 4",
},
{
name: "wrong ECDSA P-384 key size",
domain: "wrongsize.com",
timestamp: time.Now().UTC().Format(time.RFC3339),
setupMock: func(m *MockHTTPKeyFetcher) {
// Generate a key that's too short
shortKey := base64.StdEncoding.EncodeToString([]byte("short"))
m.keyResponses["wrongsize.com"] = fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", shortKey)
m.err = nil
},
expectError: true,
errorContains: "invalid ECDSA P-384 public key size",
},
{
name: "ECDSA P-384 signature verification failure",
domain: testDomain,
timestamp: time.Now().UTC().Format(time.RFC3339),
setupMock: func(m *MockHTTPKeyFetcher) {
// Generate different key pair for signature verification failure
wrongCompressedPubKey, _ := generateECDSAP384KeyPairHTTP(t)
wrongPublicKeyB64 := base64.StdEncoding.EncodeToString(wrongCompressedPubKey)
m.keyResponses[testDomain] = fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", wrongPublicKeyB64)
m.err = nil
},
expectError: true,
errorContains: "signature verification failed",
},
{
name: "invalid ECDSA P-384 key compression format",
domain: "invalidcompression.com",
timestamp: time.Now().UTC().Format(time.RFC3339),
setupMock: func(m *MockHTTPKeyFetcher) {
// 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.keyResponses["invalidcompression.com"] = 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 fetcher
mockFetcher.err = nil
if tt.setupMock != nil {
tt.setupMock(mockFetcher)
}
// Generate signature if not provided
signedTimestamp := tt.signedTimestamp
if signedTimestamp == "" {
// Generate a valid signature for all cases
signature := signWithECDSAP384HTTP(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.MethodHTTP, claims.AuthMethod)
assert.Equal(t, tt.domain, claims.AuthMethodSubject)
assert.Len(t, claims.Permissions, 1) // domain permissions only
// 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))
}
})
}
}
func TestHTTPAuthHandler_ECDSAP384_Permissions(t *testing.T) {
cfg := &config.Config{
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}
handler := auth.NewHTTPAuthHandler(cfg)
jwtManager := intauth.NewJWTManager(cfg)
// Generate ECDSA P-384 test key pair
compressedPubKey, privateKey := generateECDSAP384KeyPairHTTP(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 only (HTTP does not include subdomains)
},
unexpectedPatterns: []string{
"com.example.*", // HTTP should not grant subdomain permissions
"example.com/*", // 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 only
},
unexpectedPatterns: []string{
"com.example.api.*", // HTTP should not grant subdomain permissions
"com.example/*", // parent domain should not be included
"api.example.com/*", // should be reversed
},
},
{
name: "hyphenated domain with ECDSA P-384",
domain: "my-app.example-site.com",
expectedPatterns: []string{
"com.example-site.my-app/*", // exact pattern only
},
unexpectedPatterns: []string{
"com.example-site.my-app.*", // HTTP should not grant subdomain permissions
"my-app.example-site.com/*", // should be reversed
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up mock fetcher
mockFetcher := &MockHTTPKeyFetcher{
keyResponses: map[string]string{
tt.domain: fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", publicKeyB64),
},
}
handler.SetFetcher(mockFetcher)
// Generate signature
timestamp := time.Now().UTC().Format(time.RFC3339)
signature := signWithECDSAP384HTTP(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.MethodHTTP, claims.AuthMethod)
assert.Equal(t, tt.domain, claims.AuthMethodSubject)
assert.Len(t, claims.Permissions, 1) // HTTP only grants exact domain 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 resource scenarios - only exact domain should work for HTTP
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)
}
})
}
}
func TestHTTPAuthHandler_ECDSAP384_PermissionValidation(t *testing.T) {
cfg := &config.Config{
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}
handler := auth.NewHTTPAuthHandler(cfg)
jwtManager := intauth.NewJWTManager(cfg)
// Generate ECDSA P-384 test key pair
compressedPubKey, privateKey := generateECDSAP384KeyPairHTTP(t)
publicKeyB64 := base64.StdEncoding.EncodeToString(compressedPubKey)
domain := testDomain
// Set up mock fetcher
mockFetcher := &MockHTTPKeyFetcher{
keyResponses: map[string]string{
domain: fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", publicKeyB64),
},
}
handler.SetFetcher(mockFetcher)
// Generate signature and exchange token
timestamp := time.Now().UTC().Format(time.RFC3339)
signature := signWithECDSAP384HTTP(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 should fail for HTTP",
resource: "com.example.api/my-package",
action: intauth.PermissionActionPublish,
shouldPass: false, // HTTP does not grant subdomain permissions
},
{
name: "deep subdomain resource should fail for HTTP",
resource: "com.example.v1.api/my-package",
action: intauth.PermissionActionPublish,
shouldPass: false, // HTTP does not grant subdomain permissions
},
{
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 TestHTTPAuthHandler_Ed25519_vs_ECDSAP384_Equivalence(t *testing.T) {
cfg := &config.Config{
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}
handler := auth.NewHTTPAuthHandler(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 := generateECDSAP384KeyPairHTTP(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
mockFetcher := &MockHTTPKeyFetcher{
keyResponses: map[string]string{
domain: fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", ed25519PubKeyB64),
},
}
handler.SetFetcher(mockFetcher)
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
mockFetcher.keyResponses[domain] = fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", ecdsaPubKeyB64)
ecdsaSignature := signWithECDSAP384HTTP(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 (should be identical for HTTP auth)
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 TestHTTPAuthHandler_Algorithm_Support(t *testing.T) {
cfg := &config.Config{
JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}
handler := auth.NewHTTPAuthHandler(cfg)
// Generate both key pairs
ed25519PubKey, ed25519PrivKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
ed25519PubKeyB64 := base64.StdEncoding.EncodeToString(ed25519PubKey)
ecdsaCompressedPubKey, ecdsaPrivKey := generateECDSAP384KeyPairHTTP(t)
ecdsaPubKeyB64 := base64.StdEncoding.EncodeToString(ecdsaCompressedPubKey)
t.Run("single_ed25519_key", func(t *testing.T) {
// HTTP should work with a single Ed25519 key
mockFetcher := &MockHTTPKeyFetcher{
keyResponses: map[string]string{
testDomain: fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", ed25519PubKeyB64),
},
}
handler.SetFetcher(mockFetcher)
timestamp := time.Now().UTC().Format(time.RFC3339)
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)
})
t.Run("single_ecdsa_p384_key", func(t *testing.T) {
// HTTP should work with a single ECDSA P-384 key
mockFetcher := &MockHTTPKeyFetcher{
keyResponses: map[string]string{
testDomain: fmt.Sprintf("v=MCPv1; k=ecdsap384; p=%s", ecdsaPubKeyB64),
},
}
handler.SetFetcher(mockFetcher)
timestamp := time.Now().UTC().Format(time.RFC3339)
ecdsaSignature := signWithECDSAP384HTTP(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("http_response_with_extra_content", func(t *testing.T) {
// HTTP response can contain extra content, but only one key should be parsed
httpResponse := fmt.Sprintf("MCP Registry Auth Keys:\nv=MCPv1; k=ed25519; p=%s\nSome other text",
ed25519PubKeyB64)
mockFetcher := &MockHTTPKeyFetcher{
keyResponses: map[string]string{
testDomain: httpResponse,
},
}
handler.SetFetcher(mockFetcher)
timestamp := time.Now().UTC().Format(time.RFC3339)
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)
})
t.Run("wrong_signature_for_single_key", func(t *testing.T) {
// If there's only one key and the signature doesn't match, it should fail
mockFetcher := &MockHTTPKeyFetcher{
keyResponses: map[string]string{
testDomain: fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", ed25519PubKeyB64),
},
}
handler.SetFetcher(mockFetcher)
timestamp := time.Now().UTC().Format(time.RFC3339)
// Use ECDSA signature with Ed25519 key - should fail
ecdsaSignature := signWithECDSAP384HTTP(t, ecdsaPrivKey, []byte(timestamp))
result, err := handler.ExchangeToken(context.Background(), testDomain, timestamp, hex.EncodeToString(ecdsaSignature))
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "invalid signature size for Ed25519")
})
}