CI is red on `main` and on every open PR (including the Dependabot PRs #1531, #1552–#1554) with: ``` panic: file requires newer Go version go1.27 (application built with go1.26) golangci-lint exit with code 2 ``` ## Cause `actions/setup-go` with `go-version: 'stable'` started resolving to **go1.27.0**. The pinned golangci-lint **v2.11.4** release binary is built with **go1.26**, and its `go/types` refuses to typecheck source files declaring a newer language version. Nothing in the failing PRs is at fault. ## Fix Bump to **golangci-lint v2.13.1**, whose release binaries are built with go1.27, so linting works under `stable` again. That bump also pulls in newer linter versions, which surface 57 findings this tree did not have before. Handled as follows: | Linter | Count | Resolution | |---|---|---| | `goconst` | 50 | 39 were repeated literals in `_test.go` files — goconst started counting those in v2.12.0 (golangci-lint#6480 exposed `ignore-tests`). Fixture data like `"1.0.0"` (89 occurrences) is not a missing constant, so `goconst.ignore-tests: true`. The other 11 were real duplication in production code (OpenAPI tags, the `bearer` security scheme name, `$ref`, `http`/`https`) and became named constants. | | `gosec` G710 | 1 | **True positive, fixed.** See below. | | `staticcheck` SA1019 | 6 | Go 1.26 deprecated `ecdsa.PublicKey.X/Y` and `PrivateKey.D`. Annotated with the same targeted `nolint` + rationale already used for `ScalarBaseMult` in `cmd/publisher/auth/common.go`. | ### The open redirect is real `TrailingSlashMiddleware` copied the entire `*url.URL` before rewriting the path. For an absolute-form request URI — legal for a Go HTTP server to parse, e.g. `GET http://evil.com/foo/ HTTP/1.1` — `r.URL.Scheme` and `r.URL.Host` are populated, and they were echoed straight back in the `Location` header. The redirect target is now built from the cleaned path and query alone. The existing `path.Clean` hardening for GHSA-v8vw-gw5j-w7m6 (protocol-relative `//evil.com/`) is unchanged. ### Also Switched `gomodguard` → `gomodguard_v2`; the former is deprecated as of v2.12.0 and was emitting a warning on every run. ## Verification - `golangci-lint v2.13.1` against this tree: **0 issues**, no deprecation warnings - `make validate`: all validations passed - `go build ./...` and tests for every touched package pass (DB-backed tests need Postgres; left to CI) ## Note on migrating the ecdsa key paths Moving those six sites to `PublicKey.Bytes` / `ParseUncompressedPublicKey` / `crypto/ecdh` touches key parsing and signature verification. It should be its own reviewed PR rather than riding along on a CI unblock. Happy to open one. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
423 lines
14 KiB
Go
423 lines
14 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/ed25519"
|
|
"crypto/elliptic"
|
|
"crypto/sha512"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/registry/internal/auth"
|
|
"github.com/modelcontextprotocol/registry/internal/config"
|
|
)
|
|
|
|
// readErrorBody reads an upstream error response body for inclusion in an
|
|
// error message. The cap protects against a misbehaving upstream returning a
|
|
// huge body — error diagnostics never need more than a few KB.
|
|
func readErrorBody(r io.Reader) string {
|
|
const maxErrorBodySize = 8 * 1024
|
|
b, _ := io.ReadAll(io.LimitReader(r, maxErrorBodySize))
|
|
return string(b)
|
|
}
|
|
|
|
// ErrSignatureMismatch is returned by VerifySignature when the signature is structurally
|
|
// valid but does not verify against the public key. Distinguishing this from structural
|
|
// failures (wrong size, bad key format) lets the caller add fingerprint hints only when
|
|
// the failure is actually a "wrong key" situation.
|
|
var ErrSignatureMismatch = errors.New("signature does not match public key")
|
|
|
|
// MCPProofRecordPattern matches a well-formed MCPv1 DNS/HTTP proof record:
|
|
// "v=MCPv1; k=<algo>; p=<base64-public-key>". Shared so callers checking for the
|
|
// presence of a valid record see exactly what the parser will accept.
|
|
var MCPProofRecordPattern = regexp.MustCompile(`v=MCPv1;\s*k=([^;]+);\s*p=([A-Za-z0-9+/=]+)`)
|
|
|
|
// 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 signature of timestamp" example:"abcdef1234567890" required:"true"`
|
|
}
|
|
|
|
// KeyFetcher defines a function type for fetching keys from external sources
|
|
type KeyFetcher func(ctx context.Context, domain string) ([]string, error)
|
|
|
|
// CoreAuthHandler represents the common handler structure
|
|
type CoreAuthHandler struct {
|
|
config *config.Config
|
|
jwtManager *auth.JWTManager
|
|
}
|
|
|
|
// NewCoreAuthHandler creates a new core authentication handler
|
|
func NewCoreAuthHandler(cfg *config.Config) *CoreAuthHandler {
|
|
return &CoreAuthHandler{
|
|
config: cfg,
|
|
jwtManager: auth.NewJWTManager(cfg),
|
|
}
|
|
}
|
|
|
|
// ValidateDomainAndTimestamp validates the domain format and timestamp
|
|
func ValidateDomainAndTimestamp(domain, timestamp string) (*time.Time, error) {
|
|
if !IsValidDomain(domain) {
|
|
return nil, fmt.Errorf("invalid domain format")
|
|
}
|
|
|
|
if isGitHubPagesDomain(domain) {
|
|
return nil, fmt.Errorf(
|
|
"github.io domains cannot be used with DNS/HTTP authentication; " +
|
|
"use GitHub authentication for io.github.* namespaces")
|
|
}
|
|
|
|
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, to allow for clock skew
|
|
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)")
|
|
}
|
|
|
|
return &ts, nil
|
|
}
|
|
|
|
func DecodeAndValidateSignature(signedTimestamp string) ([]byte, error) {
|
|
signature, err := hex.DecodeString(signedTimestamp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid signature format, must be hex: %w", err)
|
|
}
|
|
|
|
return signature, nil
|
|
}
|
|
|
|
func VerifySignatureWithKeys(publicKeys []PublicKeyInfo, messageBytes []byte, signature []byte) error {
|
|
var lastErr error
|
|
allMismatch := true
|
|
for _, publicKeyInfo := range publicKeys {
|
|
err := publicKeyInfo.VerifySignature(messageBytes, signature)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
lastErr = err
|
|
if !errors.Is(err, ErrSignatureMismatch) {
|
|
allMismatch = false
|
|
}
|
|
}
|
|
|
|
// If at least one key failed for a structural reason (wrong size, unsupported algorithm),
|
|
// surface that error directly — it's more actionable than a generic "didn't match" message.
|
|
if !allMismatch {
|
|
return lastErr
|
|
}
|
|
|
|
// Every key was tried and produced a clean cryptographic mismatch. Include short
|
|
// fingerprints of every key that was tried so users can tell which published keys the
|
|
// registry actually saw — the most common cause of this error is a stale record left
|
|
// behind after a key rotation, which is otherwise indistinguishable from a generic
|
|
// crypto failure.
|
|
fingerprints := make([]string, 0, len(publicKeys))
|
|
for _, publicKeyInfo := range publicKeys {
|
|
fingerprints = append(fingerprints, publicKeyInfo.Fingerprint())
|
|
}
|
|
if len(publicKeys) == 1 {
|
|
return fmt.Errorf(
|
|
"signature verification failed (tried published key %s); "+
|
|
"if this is not the key you are signing with, the published record may be stale",
|
|
fingerprints[0],
|
|
)
|
|
}
|
|
return fmt.Errorf(
|
|
"signature verification failed against all %d published keys (tried: %s); "+
|
|
"if you recently rotated keys, remove any stale records from the apex domain",
|
|
len(publicKeys), strings.Join(fingerprints, ", "),
|
|
)
|
|
}
|
|
|
|
// Fingerprint returns a short, human-readable identifier for the public key.
|
|
// Format: "<algorithm>:<first 8 base64 chars of the raw key>". Public keys are not secret,
|
|
// but truncating keeps error messages readable.
|
|
func (pki *PublicKeyInfo) Fingerprint() string {
|
|
const prefixLen = 8
|
|
var raw []byte
|
|
switch pki.Algorithm {
|
|
case AlgorithmEd25519:
|
|
if k, ok := pki.Key.(ed25519.PublicKey); ok {
|
|
raw = k
|
|
}
|
|
case AlgorithmECDSAP384:
|
|
if k, ok := pki.Key.(ecdsa.PublicKey); ok {
|
|
raw = elliptic.MarshalCompressed(k.Curve, k.X, k.Y) //nolint:staticcheck // SA1019: matches the encoding used in DNS records
|
|
}
|
|
}
|
|
if len(raw) == 0 {
|
|
return fmt.Sprintf("%s:unknown", pki.Algorithm)
|
|
}
|
|
encoded := base64.StdEncoding.EncodeToString(raw)
|
|
if len(encoded) > prefixLen {
|
|
encoded = encoded[:prefixLen]
|
|
}
|
|
return fmt.Sprintf("%s:%s", pki.Algorithm, encoded)
|
|
}
|
|
|
|
// 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: %w", ErrSignatureMismatch)
|
|
}
|
|
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: %w", ErrSignatureMismatch)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("unsupported public key algorithm")
|
|
}
|
|
|
|
// BuildPermissions builds permissions for a domain with optional subdomain support
|
|
func BuildPermissions(domain string, includeSubdomains bool) []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),
|
|
},
|
|
}
|
|
|
|
if includeSubdomains {
|
|
permissions = append(permissions, auth.Permission{
|
|
Action: auth.PermissionActionPublish,
|
|
ResourcePattern: fmt.Sprintf("%s.*", reverseDomain),
|
|
})
|
|
}
|
|
|
|
return permissions
|
|
}
|
|
|
|
// CreateJWTClaimsAndToken creates JWT claims and generates a token response
|
|
func (h *CoreAuthHandler) CreateJWTClaimsAndToken(ctx context.Context, authMethod auth.Method, domain string, permissions []auth.Permission) (*auth.TokenResponse, error) {
|
|
// Create JWT claims
|
|
jwtClaims := auth.JWTClaims{
|
|
AuthMethod: authMethod,
|
|
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
|
|
}
|
|
|
|
// ExchangeToken is a shared method for token exchange that takes a key fetcher function,
|
|
// subdomain inclusion flag, and auth method
|
|
func (h *CoreAuthHandler) ExchangeToken(
|
|
ctx context.Context,
|
|
domain, timestamp, signedTimestamp string,
|
|
keyFetcher KeyFetcher,
|
|
includeSubdomains bool,
|
|
authMethod auth.Method) (*auth.TokenResponse, error) {
|
|
_, err := ValidateDomainAndTimestamp(domain, timestamp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
signature, err := DecodeAndValidateSignature(signedTimestamp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
keyStrings, err := keyFetcher(ctx, domain)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch keys: %w", err)
|
|
}
|
|
|
|
publicKeysAndErrors := ParseMCPKeysFromStrings(keyStrings)
|
|
if len(publicKeysAndErrors) == 0 {
|
|
switch authMethod {
|
|
case auth.MethodHTTP:
|
|
return nil, fmt.Errorf("no MCP public key found in HTTP response")
|
|
case auth.MethodDNS:
|
|
return nil, fmt.Errorf("no MCP public key found in DNS TXT records")
|
|
case auth.MethodGitHubAT, auth.MethodGitHubOIDC, auth.MethodOIDC, auth.MethodNone:
|
|
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)
|
|
err = VerifySignatureWithKeys(publicKeys, messageBytes, signature)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
permissions := BuildPermissions(domain, includeSubdomains)
|
|
|
|
return h.CreateJWTClaimsAndToken(ctx, authMethod, domain, permissions)
|
|
}
|
|
|
|
func ParseMCPKeysFromStrings(inputs []string) []struct {
|
|
*PublicKeyInfo
|
|
error
|
|
} {
|
|
var publicKeys []struct {
|
|
*PublicKeyInfo
|
|
error
|
|
}
|
|
|
|
for _, record := range inputs {
|
|
if matches := MCPProofRecordPattern.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}, //nolint:staticcheck // SA1019: needs crypto/ecdh refactor
|
|
}, 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, ".")
|
|
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
|
|
parts[i], parts[j] = parts[j], parts[i]
|
|
}
|
|
return strings.Join(parts, ".")
|
|
}
|
|
|
|
// isGitHubPagesDomain reports whether domain is github.io or a subdomain of it.
|
|
// GitHub Pages serves <name>.github.io from the <name>/<name>.github.io repository,
|
|
// so the HTTP proof only demonstrates push access to that one repository. For an
|
|
// organization that is a far weaker bar than the org-Owner ("admin") check the
|
|
// GitHub authentication method enforces for io.github.<org>/* namespaces — accepting
|
|
// the proof here would let any member with write access to the Pages repo mint the
|
|
// whole org namespace. Users of io.github.* namespaces authenticate via GitHub.
|
|
func isGitHubPagesDomain(domain string) bool {
|
|
d := strings.ToLower(domain)
|
|
return d == "github.io" || strings.HasSuffix(d, ".github.io")
|
|
}
|
|
|
|
func IsValidDomain(domain string) bool {
|
|
if len(domain) == 0 || len(domain) > 253 {
|
|
return false
|
|
}
|
|
|
|
// Reject IP literals — this auth method proves domain ownership, not IP
|
|
// ownership, and IP literals are an SSRF vector into internal networks.
|
|
if net.ParseIP(domain) != nil {
|
|
return false
|
|
}
|
|
|
|
// Require at least one dot — rejects single-label names like "localhost"
|
|
// or "kubernetes" that resolve only inside private networks.
|
|
if !strings.Contains(domain, ".") {
|
|
return false
|
|
}
|
|
|
|
// Check for valid characters and structure
|
|
domainPattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$`)
|
|
return domainPattern.MatchString(domain)
|
|
}
|