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
4 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: