## 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>
164 lines
4.7 KiB
Go
164 lines
4.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/ed25519"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha512"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"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
|
|
privateKey string
|
|
cryptoAlgorithm CryptoAlgorithm
|
|
authMethod string
|
|
}
|
|
|
|
// GetToken retrieves the registry JWT token using cryptographic authentication
|
|
func (c *CryptoProvider) GetToken(ctx context.Context) (string, error) {
|
|
if c.domain == "" {
|
|
return "", fmt.Errorf("%s domain is required", c.authMethod)
|
|
}
|
|
|
|
if c.privateKey == "" {
|
|
return "", fmt.Errorf("%s private key (hex) is required", c.authMethod)
|
|
}
|
|
|
|
// Decode private key from hex
|
|
privateKeyBytes, err := hex.DecodeString(c.privateKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid hex private key format: %w", err)
|
|
}
|
|
|
|
// Generate current timestamp
|
|
timestamp := time.Now().UTC().Format(time.RFC3339)
|
|
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, 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
|
|
}
|
|
|
|
// Login is not needed for cryptographic auth since authentication is cryptographic
|
|
func (c *CryptoProvider) Login(_ context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// exchangeTokenForRegistry exchanges signature for a registry JWT token
|
|
func (c *CryptoProvider) exchangeTokenForRegistry(ctx context.Context, domain, timestamp, signedTimestamp string) (string, error) {
|
|
if c.registryURL == "" {
|
|
return "", fmt.Errorf("registry URL is required for token exchange")
|
|
}
|
|
|
|
// Prepare the request body
|
|
payload := map[string]string{
|
|
"domain": domain,
|
|
"timestamp": timestamp,
|
|
"signed_timestamp": signedTimestamp,
|
|
}
|
|
|
|
jsonData, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
// Make the token exchange request
|
|
exchangeURL := fmt.Sprintf("%s/v0/auth/%s", c.registryURL, c.authMethod)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, bytes.NewBuffer(jsonData))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to send request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, body)
|
|
}
|
|
|
|
var tokenResp RegistryTokenResponse
|
|
err = json.Unmarshal(body, &tokenResp)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to unmarshal response: %w", err)
|
|
}
|
|
|
|
return tokenResp.RegistryToken, nil
|
|
}
|