Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 63 additions & 20 deletions cmd/publisher/auth/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ package auth
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/sha512"
"encoding/hex"
"encoding/json"
"fmt"
Expand All @@ -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
Expand All @@ -26,38 +42,65 @@ 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)
}

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
Expand Down
11 changes: 6 additions & 5 deletions cmd/publisher/auth/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}
}
Expand Down
11 changes: 6 additions & 5 deletions cmd/publisher/auth/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}
}
Expand Down
23 changes: 20 additions & 3 deletions cmd/publisher/commands/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand All @@ -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 {
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/publishing/publish-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 29 additions & 4 deletions docs/reference/cli/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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]
Expand Down
Loading