From 744cf52173e77ca44be3a73f2f549f9280269daa Mon Sep 17 00:00:00 2001 From: Adam Jones Date: Tue, 19 Aug 2025 11:16:56 +0100 Subject: [PATCH 1/4] feat: Add HTTP-based authentication support to validate domain ownership --- internal/api/handlers/v0/auth/http.go | 230 +++++++++++++++++++ internal/api/handlers/v0/auth/http_test.go | 244 +++++++++++++++++++++ internal/api/handlers/v0/auth/main.go | 3 + internal/model/model.go | 2 + tools/publisher/README.md | 28 +++ tools/publisher/auth/common.go | 121 ++++++++++ tools/publisher/auth/dns.go | 130 +---------- tools/publisher/auth/http.go | 23 ++ tools/publisher/main.go | 9 + 9 files changed, 670 insertions(+), 120 deletions(-) create mode 100644 internal/api/handlers/v0/auth/http.go create mode 100644 internal/api/handlers/v0/auth/http_test.go create mode 100644 tools/publisher/auth/common.go create mode 100644 tools/publisher/auth/http.go diff --git a/internal/api/handlers/v0/auth/http.go b/internal/api/handlers/v0/auth/http.go new file mode 100644 index 00000000..82d2aea8 --- /dev/null +++ b/internal/api/handlers/v0/auth/http.go @@ -0,0 +1,230 @@ +package auth + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" + "github.com/modelcontextprotocol/registry/internal/auth" + "github.com/modelcontextprotocol/registry/internal/config" + "github.com/modelcontextprotocol/registry/internal/model" +) + +// HTTPTokenExchangeInput represents the input for HTTP-based authentication +type HTTPTokenExchangeInput struct { + Body 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"` + } +} + +// HTTPKeyFetcher defines the interface for fetching HTTP keys +type HTTPKeyFetcher interface { + FetchKey(ctx context.Context, domain string) (string, error) +} + +// DefaultHTTPKeyFetcher uses Go's standard HTTP client +type DefaultHTTPKeyFetcher struct { + client *http.Client +} + +// NewDefaultHTTPKeyFetcher creates a new HTTP key fetcher with timeout +func NewDefaultHTTPKeyFetcher() *DefaultHTTPKeyFetcher { + return &DefaultHTTPKeyFetcher{ + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// FetchKey fetches the public key from the well-known HTTP endpoint +func (f *DefaultHTTPKeyFetcher) FetchKey(ctx context.Context, domain string) (string, error) { + url := fmt.Sprintf("https://%s/.well-known/mcp-registry-auth", domain) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "text/plain") + req.Header.Set("User-Agent", "mcp-registry/1.0") + + resp, err := f.client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch key: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d: failed to fetch key from %s", resp.StatusCode, url) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + return strings.TrimSpace(string(body)), nil +} + +// HTTPAuthHandler handles HTTP-based authentication +type HTTPAuthHandler struct { + config *config.Config + jwtManager *auth.JWTManager + fetcher HTTPKeyFetcher +} + +// NewHTTPAuthHandler creates a new HTTP authentication handler +func NewHTTPAuthHandler(cfg *config.Config) *HTTPAuthHandler { + return &HTTPAuthHandler{ + config: cfg, + jwtManager: auth.NewJWTManager(cfg), + fetcher: NewDefaultHTTPKeyFetcher(), + } +} + +// SetFetcher sets a custom HTTP key fetcher (used for testing) +func (h *HTTPAuthHandler) SetFetcher(fetcher HTTPKeyFetcher) { + h.fetcher = fetcher +} + +// RegisterHTTPEndpoint registers the HTTP authentication endpoint +func RegisterHTTPEndpoint(api huma.API, cfg *config.Config) { + handler := NewHTTPAuthHandler(cfg) + + // HTTP authentication endpoint + huma.Register(api, huma.Operation{ + OperationID: "exchange-http-token", + Method: http.MethodPost, + Path: "/v0/auth/http", + Summary: "Exchange HTTP signature for Registry JWT", + Description: "Authenticate using HTTP-hosted public key and signed timestamp", + Tags: []string{"auth"}, + }, func(ctx context.Context, input *HTTPTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) { + response, err := handler.ExchangeToken(ctx, input.Body.Domain, input.Body.Timestamp, input.Body.SignedTimestamp) + if err != nil { + return nil, huma.Error401Unauthorized("HTTP authentication failed", err) + } + + return &v0.Response[auth.TokenResponse]{ + Body: *response, + }, nil + }) +} + +// ExchangeToken exchanges HTTP signature for a Registry JWT token +func (h *HTTPAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, signedTimestamp string) (*auth.TokenResponse, error) { + // Validate domain format + if !isValidDomain(domain) { + return nil, fmt.Errorf("invalid domain format") + } + + // Parse and validate timestamp + ts, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + return nil, fmt.Errorf("invalid timestamp format: %w", err) + } + + // Check timestamp is within 15 seconds + now := time.Now() + if ts.Before(now.Add(-15*time.Second)) || ts.After(now.Add(15*time.Second)) { + return nil, fmt.Errorf("timestamp outside valid window (±15 seconds)") + } + + // Decode signature + signature, err := hex.DecodeString(signedTimestamp) + if err != nil { + 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)) + } + + // Fetch public key from HTTP endpoint + keyResponse, err := h.fetcher.FetchKey(ctx, domain) + if err != nil { + return nil, fmt.Errorf("failed to fetch public key: %w", err) + } + + // Parse public key from HTTP response + publicKey, err := h.parsePublicKeyFromHTTP(keyResponse) + if err != nil { + return nil, fmt.Errorf("failed to parse public key: %w", err) + } + + // Verify signature + messageBytes := []byte(timestamp) + if !ed25519.Verify(publicKey, messageBytes, signature) { + return nil, fmt.Errorf("signature verification failed") + } + + // Build permissions for domain and subdomains + permissions := h.buildPermissions(domain) + + // Create JWT claims + jwtClaims := auth.JWTClaims{ + AuthMethod: model.AuthMethodHTTP, + AuthMethodSubject: domain, + Permissions: permissions, + } + + // Generate Registry JWT token + tokenResponse, err := h.jwtManager.GenerateTokenResponse(ctx, jwtClaims) + if err != nil { + return nil, fmt.Errorf("failed to generate JWT token: %w", err) + } + + return tokenResponse, nil +} + +// parsePublicKeyFromHTTP parses Ed25519 public key from HTTP response +func (h *HTTPAuthHandler) parsePublicKeyFromHTTP(response string) (ed25519.PublicKey, error) { + // Expected format: v=MCPv1; k=ed25519; p= + mcpPattern := regexp.MustCompile(`v=MCPv1;\s*k=ed25519;\s*p=([A-Za-z0-9+/=]+)`) + + matches := mcpPattern.FindStringSubmatch(response) + if len(matches) != 2 { + return nil, fmt.Errorf("invalid key format, expected: v=MCPv1; k=ed25519; p=") + } + + // Decode base64 public key + publicKeyBytes, err := base64.StdEncoding.DecodeString(matches[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode base64 public key: %w", err) + } + + if len(publicKeyBytes) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid public key length: expected %d, got %d", ed25519.PublicKeySize, len(publicKeyBytes)) + } + + return ed25519.PublicKey(publicKeyBytes), nil +} + +// buildPermissions builds permissions for a domain and its subdomains using reverse DNS notation +func (h *HTTPAuthHandler) buildPermissions(domain string) []auth.Permission { + reverseDomain := reverseString(domain) + + permissions := []auth.Permission{ + // Grant permissions for the exact domain (e.g., com.example/*) + { + Action: auth.PermissionActionPublish, + ResourcePattern: fmt.Sprintf("%s/*", reverseDomain), + }, + // HTTP does not imply a hierarchy of ownership of subdomains, unlike DNS + // Therefore this does not give permissions for subdomains + // This is consistent with similar protocols, e.g. ACME HTTP-01 + } + + return permissions +} diff --git a/internal/api/handlers/v0/auth/http_test.go b/internal/api/handlers/v0/auth/http_test.go new file mode 100644 index 00000000..6907673e --- /dev/null +++ b/internal/api/handlers/v0/auth/http_test.go @@ -0,0 +1,244 @@ +package auth_test + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth" + intauth "github.com/modelcontextprotocol/registry/internal/auth" + "github.com/modelcontextprotocol/registry/internal/config" + "github.com/modelcontextprotocol/registry/internal/model" +) + +// MockHTTPKeyFetcher for testing +type MockHTTPKeyFetcher struct { + keyResponses map[string]string + err error +} + +func (m *MockHTTPKeyFetcher) FetchKey(_ context.Context, domain string) (string, error) { + if m.err != nil { + return "", m.err + } + return m.keyResponses[domain], nil +} + +func TestHTTPAuthHandler_ExchangeToken(t *testing.T) { + cfg := &config.Config{ + JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + } + handler := auth.NewHTTPAuthHandler(cfg) + + // Generate a test key pair + publicKey, privateKey, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + // Create mock HTTP key fetcher + publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey) + mockFetcher := &MockHTTPKeyFetcher{ + keyResponses: map[string]string{ + "example.com": fmt.Sprintf("v=MCPv1; k=ed25519; 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 authentication", + domain: "example.com", + timestamp: time.Now().UTC().Format(time.RFC3339), + setupMock: func(_ *MockHTTPKeyFetcher) { + // Mock is already set up with valid key + }, + expectError: false, + }, + { + name: "invalid domain format", + domain: "invalid..domain", + timestamp: time.Now().UTC().Format(time.RFC3339), + expectError: true, + errorContains: "invalid domain format", + }, + { + name: "invalid timestamp format", + domain: "example.com", + timestamp: "invalid-timestamp", + expectError: true, + errorContains: "invalid timestamp format", + }, + { + name: "timestamp too old", + domain: "example.com", + timestamp: time.Now().Add(-30 * time.Second).UTC().Format(time.RFC3339), + expectError: true, + errorContains: "timestamp outside valid window", + }, + { + name: "timestamp too far in the future", + domain: "example.com", + timestamp: time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339), + expectError: true, + errorContains: "timestamp outside valid window", + }, + { + name: "invalid signature format", + domain: "example.com", + timestamp: time.Now().UTC().Format(time.RFC3339), + signedTimestamp: "invalid-hex", + expectError: true, + errorContains: "invalid signature format", + }, + { + name: "signature wrong length", + domain: "example.com", + timestamp: time.Now().UTC().Format(time.RFC3339), + signedTimestamp: "abcdef", // too short + expectError: true, + errorContains: "invalid signature length", + }, + { + name: "HTTP 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 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: "invalid key format", + }, + { + name: "invalid base64 key", + domain: "badkey.com", + timestamp: time.Now().UTC().Format(time.RFC3339), + setupMock: func(m *MockHTTPKeyFetcher) { + m.keyResponses["badkey.com"] = "v=MCPv1; k=ed25519; p=invalid-base64!!!" + m.err = nil + }, + expectError: true, + errorContains: "failed to decode base64 public key", + }, + { + name: "wrong 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=ed25519; p=%s", shortKey) + m.err = nil + }, + expectError: true, + errorContains: "invalid public key length", + }, + { + name: "signature verification failure", + domain: "example.com", + timestamp: time.Now().UTC().Format(time.RFC3339), + setupMock: func(m *MockHTTPKeyFetcher) { + // Generate different key pair for signature verification failure + wrongPublicKey, _, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + wrongPublicKeyB64 := base64.StdEncoding.EncodeToString(wrongPublicKey) + m.keyResponses["example.com"] = fmt.Sprintf("v=MCPv1; k=ed25519; p=%s", wrongPublicKeyB64) + m.err = nil + }, + expectError: true, + errorContains: "signature verification failed", + }, + } + + 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 := ed25519.Sign(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, model.AuthMethodHTTP, 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 TestDefaultHTTPKeyFetcher_FetchKey(t *testing.T) { + // This test would require a real HTTP server or more sophisticated mocking + // For now, we'll test the basic structure + fetcher := auth.NewDefaultHTTPKeyFetcher() + assert.NotNil(t, fetcher) + + // Test that it returns an error for non-existent domains + // (This will fail with network error, which is expected) + _, err := fetcher.FetchKey(context.Background(), "nonexistent-test-domain-12345.com") + assert.Error(t, err) +} diff --git a/internal/api/handlers/v0/auth/main.go b/internal/api/handlers/v0/auth/main.go index cc9740ef..71e778a2 100644 --- a/internal/api/handlers/v0/auth/main.go +++ b/internal/api/handlers/v0/auth/main.go @@ -16,6 +16,9 @@ func RegisterAuthEndpoints(api huma.API, cfg *config.Config) { // Register DNS-based authentication endpoint RegisterDNSEndpoint(api, cfg) + // Register HTTP-based authentication endpoint + RegisterHTTPEndpoint(api, cfg) + // Register anonymous authentication endpoint RegisterNoneEndpoint(api, cfg) diff --git a/internal/model/model.go b/internal/model/model.go index e3204ba1..b421d9b5 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -10,6 +10,8 @@ const ( AuthMethodGitHubOIDC AuthMethod = "github-oidc" // DNS-based public/private key authentication AuthMethodDNS AuthMethod = "dns" + // HTTP-based public/private key authentication + AuthMethodHTTP AuthMethod = "http" // No authentication - should only be used for local development and testing AuthMethodNone AuthMethod = "none" ) diff --git a/tools/publisher/README.md b/tools/publisher/README.md index 5552ac93..75ebf165 100644 --- a/tools/publisher/README.md +++ b/tools/publisher/README.md @@ -49,9 +49,12 @@ The tool supports two main commands: - `github-at`: Interactive GitHub OAuth device flow authentication - `github-oidc`: GitHub Actions OIDC authentication (for CI/CD) - `dns`: DNS-based public/private key authentication + - `http`: HTTP-based public/private key authentication - `none`: No authentication (for registry contributors testing locally) - `--dns-domain`: Domain name for DNS authentication (required for dns auth method) - `--dns-private-key`: 64-character hex seed for DNS authentication (required for dns auth method) +- `--http-domain`: Domain name for HTTP authentication (required for http auth method) +- `--http-private-key`: 64-character hex seed for HTTP authentication (required for http auth method) ## Creating a server.json file @@ -210,6 +213,31 @@ For domain-based authentication using public/private key cryptography: This grants publishing permissions for both `example.com/*` and `*.example.com/*` namespaces. +### HTTP Authentication (`http`) + +For domain-based authentication using HTTP-hosted public keys: + +1. **Generate Ed25519 keypair**: + ```bash + openssl genpkey -algorithm Ed25519 -out /tmp/key.pem && \ + echo "\n\nFile to host on your domain:" && \ + echo " URL: https://yoursite.com/.well-known/mcp-registry-auth" && \ + echo " Content: v=MCPv1; k=ed25519; p=$(openssl pkey -in /tmp/key.pem -pubout -outform DER | tail -c 32 | base64)" && \ + echo "" && \ + echo "Private key for --http-private-key flag:" && \ + echo " $(openssl pkey -in /tmp/key.pem -noout -text | grep -A3 "priv:" | tail -n +2 | tr -d ' :\n')\n" && \ + rm /tmp/key.pem + ``` +2. **Host public key**: Create an HTTP endpoint at `https://yoursite.com/.well-known/mcp-registry-auth` that returns: `v=MCPv1; k=ed25519; p=` +3. **Use CLI arguments**: Provide domain and private key via command line flags + +```bash +./bin/mcp-publisher publish --registry-url --mcp-file \ + --auth-method http --http-domain example.com --http-private-key abc123... +``` + +This grants publishing permissions for the `example.com/*` namespace. + ### No Authentication (`none`) Mainly for registry contributors, for testing locally: diff --git a/tools/publisher/auth/common.go b/tools/publisher/auth/common.go new file mode 100644 index 00000000..8ec1e016 --- /dev/null +++ b/tools/publisher/auth/common.go @@ -0,0 +1,121 @@ +package auth + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// CryptoProvider provides common functionality for DNS and HTTP authentication +type CryptoProvider struct { + registryURL string + domain string + hexSeed string + authMethod string +} + +// GetToken retrieves the registry JWT token using cryptographic authentication +func (c *CryptoProvider) GetToken(ctx context.Context) (string, error) { + if c.domain == "" { + return "", fmt.Errorf("%s domain is required", c.authMethod) + } + + if c.hexSeed == "" { + return "", fmt.Errorf("%s private key (hex seed) is required", c.authMethod) + } + + // Decode hex seed to private key + seedBytes, err := hex.DecodeString(c.hexSeed) + if err != nil { + return "", fmt.Errorf("invalid hex seed 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) + + // Exchange signature for registry token + registryToken, err := c.exchangeTokenForRegistry(ctx, c.domain, timestamp, signedTimestamp) + if err != nil { + return "", fmt.Errorf("failed to exchange %s signature: %w", c.authMethod, err) + } + + return registryToken, nil +} + +// NeedsLogin always returns false for cryptographic auth since no interactive login is needed +func (c *CryptoProvider) NeedsLogin() bool { + return false +} + +// Login is not needed for cryptographic auth since authentication is cryptographic +func (c *CryptoProvider) Login(_ context.Context) error { + return nil +} + +// exchangeTokenForRegistry exchanges signature for a registry JWT token +func (c *CryptoProvider) exchangeTokenForRegistry(ctx context.Context, domain, timestamp, signedTimestamp string) (string, error) { + if c.registryURL == "" { + return "", fmt.Errorf("registry URL is required for token exchange") + } + + // Prepare the request body + payload := map[string]string{ + "domain": domain, + "timestamp": timestamp, + "signed_timestamp": signedTimestamp, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + // Make the token exchange request + exchangeURL := fmt.Sprintf("%s/v0/auth/%s", c.registryURL, c.authMethod) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, body) + } + + var tokenResp RegistryTokenResponse + err = json.Unmarshal(body, &tokenResp) + if err != nil { + return "", fmt.Errorf("failed to unmarshal response: %w", err) + } + + return tokenResp.RegistryToken, nil +} \ No newline at end of file diff --git a/tools/publisher/auth/dns.go b/tools/publisher/auth/dns.go index bf2de2f6..9c349445 100644 --- a/tools/publisher/auth/dns.go +++ b/tools/publisher/auth/dns.go @@ -1,133 +1,23 @@ +//nolint:ireturn package auth -import ( - "bytes" - "context" - "crypto/ed25519" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - type DNSProvider struct { - registryURL string - domain string - hexSeed string + *CryptoProvider } -//nolint:ireturn -func NewDNSProvider(registryURL, domain, hexSeed string) Provider { +// NewDNSProvider creates a new DNS-based auth provider +func NewDNSProvider(registryURL, domain, hexSeed string) Provider { //nolint:ireturn return &DNSProvider{ - registryURL: registryURL, - domain: domain, - hexSeed: hexSeed, - } -} - -// GetToken retrieves the registry JWT token using DNS authentication -func (d *DNSProvider) GetToken(ctx context.Context) (string, error) { - if d.domain == "" { - return "", fmt.Errorf("DNS domain is required") - } - - if d.hexSeed == "" { - return "", fmt.Errorf("DNS private key (hex seed) is required") - } - - // Decode hex seed to private key - seedBytes, err := hex.DecodeString(d.hexSeed) - if err != nil { - return "", fmt.Errorf("invalid hex seed format: %w", err) + CryptoProvider: &CryptoProvider{ + registryURL: registryURL, + domain: domain, + hexSeed: hexSeed, + authMethod: "dns", + }, } - - 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) - - // Exchange signature for registry token - registryToken, err := d.exchangeDNSTokenForRegistry(ctx, d.domain, timestamp, signedTimestamp) - if err != nil { - return "", fmt.Errorf("failed to exchange DNS signature: %w", err) - } - - return registryToken, nil -} - -// NeedsLogin always returns false for DNS since no interactive login is needed -func (d *DNSProvider) NeedsLogin() bool { - return false -} - -// Login is not needed for DNS since authentication is cryptographic -func (d *DNSProvider) Login(_ context.Context) error { - return nil } // Name returns the name of this auth provider func (d *DNSProvider) Name() string { return "dns" } - -// exchangeDNSTokenForRegistry exchanges DNS signature for a registry JWT token -func (d *DNSProvider) exchangeDNSTokenForRegistry(ctx context.Context, domain, timestamp, signedTimestamp string) (string, error) { - if d.registryURL == "" { - return "", fmt.Errorf("registry URL is required for token exchange") - } - - // Prepare the request body - payload := map[string]string{ - "domain": domain, - "timestamp": timestamp, - "signed_timestamp": signedTimestamp, - } - - jsonData, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("failed to marshal request: %w", err) - } - - // Make the token exchange request - exchangeURL := d.registryURL + "/v0/auth/dns" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, bytes.NewBuffer(jsonData)) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, body) - } - - var tokenResp RegistryTokenResponse - err = json.Unmarshal(body, &tokenResp) - if err != nil { - return "", fmt.Errorf("failed to unmarshal response: %w", err) - } - - return tokenResp.RegistryToken, nil -} diff --git a/tools/publisher/auth/http.go b/tools/publisher/auth/http.go new file mode 100644 index 00000000..b0811872 --- /dev/null +++ b/tools/publisher/auth/http.go @@ -0,0 +1,23 @@ +//nolint:ireturn +package auth + +type HTTPProvider struct { + *CryptoProvider +} + +// NewHTTPProvider creates a new HTTP-based auth provider +func NewHTTPProvider(registryURL, domain, hexSeed string) Provider { //nolint:ireturn + return &HTTPProvider{ + CryptoProvider: &CryptoProvider{ + registryURL: registryURL, + domain: domain, + hexSeed: hexSeed, + authMethod: "http", + }, + } +} + +// Name returns the name of this auth provider +func (h *HTTPProvider) Name() string { + return "http" +} \ No newline at end of file diff --git a/tools/publisher/main.go b/tools/publisher/main.go index 6ebf2d79..10cf1e24 100644 --- a/tools/publisher/main.go +++ b/tools/publisher/main.go @@ -99,6 +99,8 @@ func publishCommand() error { var authMethod string var dnsDomain string var dnsPrivateKey string + var httpDomain string + var httpPrivateKey string // Command-line flags for configuration publishFlags.StringVar(®istryURL, "registry-url", "", "URL of the registry (required)") @@ -107,6 +109,8 @@ func publishCommand() error { publishFlags.StringVar(&authMethod, "auth-method", "github-at", "authentication method (default: github-at)") publishFlags.StringVar(&dnsDomain, "dns-domain", "", "domain name for DNS authentication (required for dns auth method)") publishFlags.StringVar(&dnsPrivateKey, "dns-private-key", "", "64-character hex seed for DNS authentication (required for dns auth method)") + publishFlags.StringVar(&httpDomain, "http-domain", "", "domain name for HTTP authentication (required for http auth method)") + publishFlags.StringVar(&httpPrivateKey, "http-private-key", "", "64-character hex seed for HTTP authentication (required for http auth method)") // Set custom usage function publishFlags.Usage = func() { @@ -121,6 +125,8 @@ func publishCommand() error { fmt.Fprint(os.Stdout, " --auth-method string authentication method (default: github-at)\n") fmt.Fprint(os.Stdout, " --dns-domain string domain name for DNS authentication\n") fmt.Fprint(os.Stdout, " --dns-private-key string 64-character hex seed for DNS authentication\n") + fmt.Fprint(os.Stdout, " --http-domain string domain name for HTTP authentication\n") + fmt.Fprint(os.Stdout, " --http-private-key string 64-character hex seed for HTTP authentication\n") } if err := publishFlags.Parse(os.Args[2:]); err != nil { @@ -149,6 +155,9 @@ func publishCommand() error { case "dns": log.Println("Using DNS-based authentication") authProvider = auth.NewDNSProvider(registryURL, dnsDomain, dnsPrivateKey) + case "http": + log.Println("Using HTTP-based authentication") + authProvider = auth.NewHTTPProvider(registryURL, httpDomain, httpPrivateKey) case "none": log.Println("Using anonymous authentication") authProvider = auth.NewNoneProvider(registryURL) From 9b9504e42c62e3e05f9aa377ac21d6322c0b7116 Mon Sep 17 00:00:00 2001 From: Adam Jones Date: Tue, 19 Aug 2025 11:26:40 +0100 Subject: [PATCH 2/4] Disable redirects in HTTP resolver --- internal/api/handlers/v0/auth/http.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/api/handlers/v0/auth/http.go b/internal/api/handlers/v0/auth/http.go index 82d2aea8..b2d6f777 100644 --- a/internal/api/handlers/v0/auth/http.go +++ b/internal/api/handlers/v0/auth/http.go @@ -43,6 +43,11 @@ func NewDefaultHTTPKeyFetcher() *DefaultHTTPKeyFetcher { return &DefaultHTTPKeyFetcher{ client: &http.Client{ Timeout: 10 * time.Second, + // Disable redirects for security purposes: + // Prevents people doing weird things like sending us to internal endpoints at different paths + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, }, } } From 02203e0086f4139fe78b0b83c029e6548c9b3894 Mon Sep 17 00:00:00 2001 From: Adam Jones Date: Tue, 19 Aug 2025 11:55:27 +0100 Subject: [PATCH 3/4] feat: Limit response size in HTTP key fetcher to prevent DoS attacks --- internal/api/handlers/v0/auth/http.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/api/handlers/v0/auth/http.go b/internal/api/handlers/v0/auth/http.go index b2d6f777..c162b9b6 100644 --- a/internal/api/handlers/v0/auth/http.go +++ b/internal/api/handlers/v0/auth/http.go @@ -74,6 +74,9 @@ func (f *DefaultHTTPKeyFetcher) FetchKey(ctx context.Context, domain string) (st return "", fmt.Errorf("HTTP %d: failed to fetch key from %s", resp.StatusCode, url) } + // Limit response size to prevent DoS attacks + resp.Body = http.MaxBytesReader(nil, resp.Body, 4096) + body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read response body: %w", err) From 77b6cc0372e06c26cbd2b24249bcb6032ff4ad6f Mon Sep 17 00:00:00 2001 From: Adam Jones Date: Thu, 21 Aug 2025 22:52:24 +0100 Subject: [PATCH 4/4] Fix lint due to golangci bump on main --- internal/api/handlers/v0/auth/http.go | 2 +- tools/publisher/auth/dns.go | 5 +++-- tools/publisher/auth/http.go | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/api/handlers/v0/auth/http.go b/internal/api/handlers/v0/auth/http.go index c162b9b6..a2616403 100644 --- a/internal/api/handlers/v0/auth/http.go +++ b/internal/api/handlers/v0/auth/http.go @@ -45,7 +45,7 @@ func NewDefaultHTTPKeyFetcher() *DefaultHTTPKeyFetcher { Timeout: 10 * time.Second, // Disable redirects for security purposes: // Prevents people doing weird things like sending us to internal endpoints at different paths - CheckRedirect: func(req *http.Request, via []*http.Request) error { + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, }, diff --git a/tools/publisher/auth/dns.go b/tools/publisher/auth/dns.go index 9c349445..a285f04c 100644 --- a/tools/publisher/auth/dns.go +++ b/tools/publisher/auth/dns.go @@ -1,4 +1,3 @@ -//nolint:ireturn package auth type DNSProvider struct { @@ -6,7 +5,9 @@ type DNSProvider struct { } // NewDNSProvider creates a new DNS-based auth provider -func NewDNSProvider(registryURL, domain, hexSeed string) Provider { //nolint:ireturn +// +//nolint:ireturn +func NewDNSProvider(registryURL, domain, hexSeed string) Provider { return &DNSProvider{ CryptoProvider: &CryptoProvider{ registryURL: registryURL, diff --git a/tools/publisher/auth/http.go b/tools/publisher/auth/http.go index b0811872..2e189edb 100644 --- a/tools/publisher/auth/http.go +++ b/tools/publisher/auth/http.go @@ -1,4 +1,3 @@ -//nolint:ireturn package auth type HTTPProvider struct { @@ -6,7 +5,9 @@ type HTTPProvider struct { } // NewHTTPProvider creates a new HTTP-based auth provider -func NewHTTPProvider(registryURL, domain, hexSeed string) Provider { //nolint:ireturn +// +//nolint:ireturn +func NewHTTPProvider(registryURL, domain, hexSeed string) Provider { return &HTTPProvider{ CryptoProvider: &CryptoProvider{ registryURL: registryURL,