diff --git a/cmd/publisher/auth/common.go b/cmd/publisher/auth/common.go index a1c7b7a3..94b0cbb5 100644 --- a/cmd/publisher/auth/common.go +++ b/cmd/publisher/auth/common.go @@ -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 diff --git a/cmd/publisher/auth/dns.go b/cmd/publisher/auth/dns.go index 76183184..f14d7341 100644 --- a/cmd/publisher/auth/dns.go +++ b/cmd/publisher/auth/dns.go @@ -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", }, } } diff --git a/cmd/publisher/auth/http.go b/cmd/publisher/auth/http.go index c4a26d19..788c72d8 100644 --- a/cmd/publisher/auth/http.go +++ b/cmd/publisher/auth/http.go @@ -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", }, } } diff --git a/cmd/publisher/commands/login.go b/cmd/publisher/commands/login.go index 964c6756..940b9f91 100644 --- a/cmd/publisher/commands/login.go +++ b/cmd/publisher/commands/login.go @@ -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 \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: diff --git a/docs/guides/publishing/publish-server.md b/docs/guides/publishing/publish-server.md index cc45fa18..b1ab79e9 100644 --- a/docs/guides/publishing/publish-server.md +++ b/docs/guides/publishing/publish-server.md @@ -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: diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index ff1d9c90..18d4c5c9 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -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 -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] diff --git a/internal/api/handlers/v0/auth/common.go b/internal/api/handlers/v0/auth/common.go index e290a234..40a6ba7e 100644 --- a/internal/api/handlers/v0/auth/common.go +++ b/internal/api/handlers/v0/auth/common.go @@ -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 []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 signature, nil + return fmt.Errorf("signature verification failed") } -func VerifySignatureWithKeys(publicKeys []ed25519.PublicKey, messageBytes []byte, signature []byte) bool { - for _, publicKey := range publicKeys { - if ed25519.Verify(publicKey, messageBytes, signature) { - return true +// 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 false + 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+/=]+)`) - - 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 - } +func ParseMCPKeysFromStrings(inputs []string) []struct { + *PublicKeyInfo + error +} { + var publicKeys []struct { + *PublicKeyInfo + error + } - if len(publicKeyBytes) != ed25519.PublicKeySize { - continue // Skip invalid key sizes - } + // proof record pattern: v=MCPv1; k=; p= + cryptoPattern := regexp.MustCompile(`v=MCPv1;\s*k=([^;]+);\s*p=([A-Za-z0-9+/=]+)`) - 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, ".") diff --git a/internal/api/handlers/v0/auth/dns_test.go b/internal/api/handlers/v0/auth/dns_test.go index 994cb967..f0f3a566 100644 --- a/internal/api/handlers/v0/auth/dns_test.go +++ b/internal/api/handlers/v0/auth/dns_test.go @@ -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) + }) +} diff --git a/internal/api/handlers/v0/auth/http_test.go b/internal/api/handlers/v0/auth/http_test.go index 2668ada9..d3781ba7 100644 --- a/internal/api/handlers/v0/auth/http_test.go +++ b/internal/api/handlers/v0/auth/http_test.go @@ -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") + }) +}