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:
parent
0ef5b90ed7
commit
6c0f085708
9 changed files with 1389 additions and 74 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(®istryURL, "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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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, ".")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue