diff --git a/src/cmd/go/alldocs.go b/src/cmd/go/alldocs.go index acefe27fb38e66..e70d80f9ac61fa 100644 --- a/src/cmd/go/alldocs.go +++ b/src/cmd/go/alldocs.go @@ -2707,6 +2707,20 @@ // go command will run 'git credential approve/reject' to update // the credential helper's cache. // +// mtls https-origin cert-file [key-file] +// +// Uses the client certificate and private key in the named files to +// authenticate HTTPS requests to https-origin. The origin must not contain +// user information, a non-root path, a query, or a fragment. If its port is +// omitted, port 443 is used. Its hostname must use ASCII or Punycode. The file +// paths must be absolute. If key-file is omitted, cert-file must contain both +// the certificate and private key. The files must contain PEM-encoded data, +// and the private key must not be encrypted. +// The files are read only when a request is made to the matching origin. +// This method configures client authentication only; server certificates are +// verified using the go command's usual trust configuration. +// This method cannot be used with GOINSECURE or an HTTPS proxy. +// // command // // Executes the given command (a space-separated argument list) and attaches diff --git a/src/cmd/go/internal/auth/auth.go b/src/cmd/go/internal/auth/auth.go index 80f54453dc73a0..b30375697284e4 100644 --- a/src/cmd/go/internal/auth/auth.go +++ b/src/cmd/go/internal/auth/auth.go @@ -8,8 +8,11 @@ package auth import ( "cmd/go/internal/base" "cmd/go/internal/cfg" + "cmd/go/internal/web/intercept" + "cmd/internal/quoted" "fmt" "log" + "net" "net/http" "net/url" "os" @@ -20,10 +23,43 @@ import ( ) var ( - credentialCache sync.Map // prefix → http.Header - authOnce sync.Once + credentialCache sync.Map // prefix → http.Header + clientCertificateCache sync.Map // origin → ClientCertificate + authOnce sync.Once ) +// A ClientCertificate describes a certificate and private key to use for +// HTTPS requests to Origin. CertFile may contain both the certificate and key, +// in which case KeyFile is equal to CertFile. +type ClientCertificate struct { + Origin string + CertFile string + KeyFile string +} + +// ClientCertificateForRequest returns the client certificate configured for +// req's HTTPS origin, as derived from req.URL. When test hooks are enabled, +// req.Host takes precedence over req.URL.Host so that test interceptors, +// which rewrite req.URL to point at a local test server, preserve the logical +// request origin. req.Host must never influence the origin otherwise: a +// certificate must not be selected by a Host header that differs from the +// host the connection is made to. +func ClientCertificateForRequest(req *http.Request) (ClientCertificate, bool) { + host := req.URL.Host + if intercept.TestHooksEnabled && req.Host != "" { + host = req.Host + } + origin, err := canonicalHTTPSOrigin(&url.URL{Scheme: req.URL.Scheme, Host: host}) + if err != nil { + return ClientCertificate{}, false + } + cert, ok := clientCertificateCache.Load(origin) + if !ok { + return ClientCertificate{}, false + } + return cert.(ClientCertificate), true +} + // AddCredentials populates the request header with the user's credentials // as specified by the GOAUTH environment variable. // It returns whether any matching credentials were found. @@ -44,12 +80,16 @@ func AddCredentials(client *http.Client, req *http.Request, res *http.Response, // First fetch must have failed; re-invoke GOAUTH commands with url. runGoAuth(client, res, url) } - return loadCredential(req, req.URL.String()) + found := loadCredential(req, req.URL.String()) + if _, ok := ClientCertificateForRequest(req); ok { + found = true + } + return found } // runGoAuth executes authentication commands specified by the GOAUTH -// environment variable handling 'off', 'netrc', and 'git' methods specially, -// and storing retrieved credentials for future access. +// environment variable handling 'off', 'netrc', 'git', and 'mtls' methods +// specially, and storing retrieved credentials for future access. func runGoAuth(client *http.Client, res *http.Response, url string) { var cmdErrs []error // store GOAUTH command errors to log later. goAuthCmds := strings.Split(cfg.GOAUTH, ";") @@ -113,6 +153,16 @@ func runGoAuth(client *http.Client, res *http.Response, url string) { } else { storeCredential(prefix, header) } + case "mtls": + words, err := quoted.Split(command) + if err != nil { + base.Fatalf("go: cannot parse GOAUTH=mtls command %q: %v", command, err) + } + cert, err := parseClientCertificate(words) + if err != nil { + base.Fatalf("go: GOAUTH=mtls: %v", err) + } + clientCertificateCache.Store(cert.Origin, cert) default: credentials, err := runAuthCommand(command, url, res) if err != nil { @@ -129,8 +179,13 @@ func runGoAuth(client *http.Client, res *http.Response, url string) { // If no GOAUTH command provided a credential for the given url // and an error occurred, log the error. if cfg.BuildX && url != "" { - req := &http.Request{Header: make(http.Header)} - if ok := loadCredential(req, url); !ok && len(cmdErrs) > 0 { + req, err := http.NewRequest("GET", url, nil) + hasCredential := err == nil && loadCredential(req, url) + if err == nil { + _, hasClientCertificate := ClientCertificateForRequest(req) + hasCredential = hasCredential || hasClientCertificate + } + if !hasCredential && len(cmdErrs) > 0 { log.Printf("GOAUTH encountered errors for %s:", url) for _, err := range cmdErrs { log.Printf(" %v", err) @@ -139,6 +194,52 @@ func runGoAuth(client *http.Client, res *http.Response, url string) { } } +func parseClientCertificate(words []string) (ClientCertificate, error) { + if len(words) != 3 && len(words) != 4 { + return ClientCertificate{}, fmt.Errorf("usage: mtls https-origin cert-file [key-file]") + } + u, err := url.ParseRequestURI(words[1]) + if err != nil { + return ClientCertificate{}, fmt.Errorf("invalid HTTPS origin %q: %v", words[1], err) + } + origin, err := canonicalHTTPSOrigin(u) + if err != nil { + return ClientCertificate{}, err + } + if !filepath.IsAbs(words[2]) { + return ClientCertificate{}, fmt.Errorf("certificate file must be an absolute path") + } + keyFile := words[2] + if len(words) == 4 { + keyFile = words[3] + if !filepath.IsAbs(keyFile) { + return ClientCertificate{}, fmt.Errorf("key file must be an absolute path") + } + } + return ClientCertificate{Origin: origin, CertFile: words[2], KeyFile: keyFile}, nil +} + +func canonicalHTTPSOrigin(u *url.URL) (string, error) { + if u.Scheme != "https" || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.ForceQuery || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("origin must be an HTTPS URL without user information, a non-root path, query, or fragment") + } + host := strings.TrimSuffix(u.Hostname(), ".") + if host == "" { + return "", fmt.Errorf("HTTPS origin is missing a hostname") + } + for i := 0; i < len(host); i++ { + if host[i] >= 0x80 { + return "", fmt.Errorf("HTTPS origin hostname must use ASCII or Punycode") + } + } + host = strings.ToLower(host) + port := u.Port() + if port == "" { + port = "443" + } + return "https://" + net.JoinHostPort(host, port), nil +} + // loadCredential retrieves cached credentials for the given url and adds // them to the request headers. func loadCredential(req *http.Request, rawURL string) bool { diff --git a/src/cmd/go/internal/auth/auth_test.go b/src/cmd/go/internal/auth/auth_test.go index 64565b59d8d33b..169a6aaf97a3dd 100644 --- a/src/cmd/go/internal/auth/auth_test.go +++ b/src/cmd/go/internal/auth/auth_test.go @@ -5,6 +5,9 @@ package auth import ( + "cmd/go/internal/cfg" + "cmd/go/internal/web/intercept" + "cmd/internal/quoted" "net/http" "reflect" "testing" @@ -103,3 +106,151 @@ func TestCredentialCacheURLQuery(t *testing.T) { t.Fatalf("got %v, want %v", got.Header, want.Header) } } + +func TestParseClientCertificate(t *testing.T) { + dir := t.TempDir() + certFile := dir + "/client cert.pem" + keyFile := dir + "/client key.pem" + + for _, test := range []struct { + name string + words []string + want ClientCertificate + }{ + { + name: "combined PEM", + words: []string{"mtls", "https://REGISTRY.example.com./", certFile}, + want: ClientCertificate{Origin: "https://registry.example.com:443", CertFile: certFile, KeyFile: certFile}, + }, + { + name: "separate key and port", + words: []string{"mtls", "https://registry.example.com:8443", certFile, keyFile}, + want: ClientCertificate{Origin: "https://registry.example.com:8443", CertFile: certFile, KeyFile: keyFile}, + }, + } { + t.Run(test.name, func(t *testing.T) { + got, err := parseClientCertificate(test.words) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("parseClientCertificate() = %#v, want %#v", got, test.want) + } + }) + } + + for _, words := range [][]string{ + {"mtls"}, + {"mtls", "http://registry.example.com", certFile}, + {"mtls", "https://user@registry.example.com", certFile}, + {"mtls", "https://registry.example.com/path", certFile}, + {"mtls", "https://registry.example.com?query", certFile}, + {"mtls", "https://registry.example.com?", certFile}, + {"mtls", "https://\u0130.example.com", certFile}, + {"mtls", "https://registry.example.com", "relative-cert.pem"}, + {"mtls", "https://registry.example.com", certFile, "relative-key.pem"}, + } { + if _, err := parseClientCertificate(words); err == nil { + t.Errorf("parseClientCertificate(%q) succeeded, want error", words) + } + } +} + +func TestClientCertificateForRequest(t *testing.T) { + cert := ClientCertificate{ + Origin: "https://registry.example.com:443", + CertFile: "/client-cert.pem", + KeyFile: "/client-key.pem", + } + clientCertificateCache.Store(cert.Origin, cert) + defer clientCertificateCache.Delete(cert.Origin) + lookalikeCert := ClientCertificate{ + Origin: "https://i.example:443", + CertFile: "/lookalike-client-cert.pem", + KeyFile: "/lookalike-client-key.pem", + } + clientCertificateCache.Store(lookalikeCert.Origin, lookalikeCert) + defer clientCertificateCache.Delete(lookalikeCert.Origin) + + for _, test := range []struct { + name string + url string + host string + testHooks bool + want bool + }{ + {name: "exact origin", url: "https://registry.example.com/module", want: true}, + {name: "case and trailing dot", url: "https://REGISTRY.example.com./module", want: true}, + {name: "explicit default port", url: "https://registry.example.com:443/module", want: true}, + {name: "different port", url: "https://registry.example.com:8443/module", want: false}, + {name: "subdomain", url: "https://sub.registry.example.com/module", want: false}, + {name: "different scheme", url: "http://registry.example.com/module", want: false}, + {name: "IDNA lookalike", url: "https://\u0130.example/module", want: false}, + {name: "intercepted logical Host", url: "https://127.0.0.1/module", host: "registry.example.com", testHooks: true, want: true}, + {name: "Host ignored without test hooks", url: "https://127.0.0.1/module", host: "registry.example.com", want: false}, + } { + t.Run(test.name, func(t *testing.T) { + if test.testHooks != intercept.TestHooksEnabled { + defer func(saved bool) { intercept.TestHooksEnabled = saved }(intercept.TestHooksEnabled) + intercept.TestHooksEnabled = test.testHooks + } + req, err := http.NewRequest("GET", test.url, nil) + if err != nil { + t.Fatal(err) + } + req.Host = test.host + got, ok := ClientCertificateForRequest(req) + if ok != test.want { + t.Fatalf("ClientCertificateForRequest() found = %t, want %t", ok, test.want) + } + if ok && !reflect.DeepEqual(got, cert) { + t.Fatalf("ClientCertificateForRequest() = %#v, want %#v", got, cert) + } + }) + } +} + +func TestRunGoAuthMTLS(t *testing.T) { + oldGOAUTH := cfg.GOAUTH + defer func() { cfg.GOAUTH = oldGOAUTH }() + + dir := t.TempDir() + first := dir + "/first client.pem" + second := dir + "/second client.pem" + origin := "https://registry.example.com:443" + defer clientCertificateCache.Delete(origin) + + // The first GOAUTH method has priority when multiple methods configure + // the same origin. + firstCommand, err := quoted.Join([]string{"mtls", "https://registry.example.com", first}) + if err != nil { + t.Fatal(err) + } + secondCommand, err := quoted.Join([]string{"mtls", "https://registry.example.com", second}) + if err != nil { + t.Fatal(err) + } + cfg.GOAUTH = firstCommand + "; " + secondCommand + runGoAuth(http.DefaultClient, nil, "") + + req, err := http.NewRequest("GET", "https://registry.example.com/module", nil) + if err != nil { + t.Fatal(err) + } + got, ok := ClientCertificateForRequest(req) + if !ok { + t.Fatal("ClientCertificateForRequest did not find GOAUTH=mtls configuration") + } + want := ClientCertificate{Origin: origin, CertFile: first, KeyFile: first} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ClientCertificateForRequest() = %#v, want %#v", got, want) + } + + req, err = http.NewRequest("GET", "https://registry.example.com/module", nil) + if err != nil { + t.Fatal(err) + } + if !AddCredentials(http.DefaultClient, req, nil, "") { + t.Error("AddCredentials did not report the matching client certificate") + } +} diff --git a/src/cmd/go/internal/help/helpdoc.go b/src/cmd/go/internal/help/helpdoc.go index b305457a23d104..2646558efc0e0f 100644 --- a/src/cmd/go/internal/help/helpdoc.go +++ b/src/cmd/go/internal/help/helpdoc.go @@ -1093,6 +1093,18 @@ git dir Runs 'git credential fill' in dir and uses its credentials. The go command will run 'git credential approve/reject' to update the credential helper's cache. +mtls https-origin cert-file [key-file] + Uses the client certificate and private key in the named files to + authenticate HTTPS requests to https-origin. The origin must not contain + user information, a non-root path, a query, or a fragment. If its port is + omitted, port 443 is used. Its hostname must use ASCII or Punycode. The file + paths must be absolute. If key-file is omitted, cert-file must contain both + the certificate and private key. The files must contain PEM-encoded data, + and the private key must not be encrypted. + The files are read only when a request is made to the matching origin. + This method configures client authentication only; server certificates are + verified using the go command's usual trust configuration. + This method cannot be used with GOINSECURE or an HTTPS proxy. command Executes the given command (a space-separated argument list) and attaches the provided headers to HTTPS requests. diff --git a/src/cmd/go/internal/vcweb/vcstest/vcstest.go b/src/cmd/go/internal/vcweb/vcstest/vcstest.go index 224cfd791931ca..41fd92be48eb36 100644 --- a/src/cmd/go/internal/vcweb/vcstest/vcstest.go +++ b/src/cmd/go/internal/vcweb/vcstest/vcstest.go @@ -11,6 +11,9 @@ import ( "cmd/go/internal/vcs" "cmd/go/internal/vcweb" "cmd/go/internal/web/intercept" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" "crypto/x509" "encoding/pem" @@ -18,12 +21,15 @@ import ( "internal/testenv" "io" "log" + "math/big" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" + "strings" "testing" + "time" ) var Hosts = []string{ @@ -84,8 +90,9 @@ func NewServer() (srv *Server, err error) { } }() - srvHTTPS := httptest.NewUnstartedServer(handler) + srvHTTPS := httptest.NewUnstartedServer(requireClientCertificate(handler)) srvHTTPS.Config.ErrorLog = testLogger() + srvHTTPS.TLS = &tls.Config{ClientAuth: tls.RequestClientCert} srvHTTPS.StartTLS() httpsURL, err := url.Parse(srvHTTPS.URL) if err != nil { @@ -120,6 +127,16 @@ func NewServer() (srv *Server, err error) { return srv, nil } +func requireClientCertificate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if strings.HasPrefix(req.URL.Path, "/auth/mtls") && (req.TLS == nil || len(req.TLS.PeerCertificates) == 0) { + http.Error(w, "client certificate required", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, req) + }) +} + func testLogger() *log.Logger { return log.New(httpLogger{}, "vcweb: ", 0) } @@ -163,6 +180,43 @@ func (srv *Server) WriteCertificateFile() (string, error) { return filename, nil } +// WriteClientCertificateFiles writes a client certificate and private key for +// authenticating to the test server. +func (srv *Server) WriteClientCertificateFiles() (certFile, keyFile string, err error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", "", err + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return "", "", err + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return "", "", err + } + + certFile = filepath.Join(srv.workDir, "client-cert.pem") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + if err := os.WriteFile(certFile, certPEM, 0644); err != nil { + return "", "", err + } + keyFile = filepath.Join(srv.workDir, "client-key.pem") + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(keyFile, keyPEM, 0600); err != nil { + return "", "", err + } + return certFile, keyFile, nil +} + // TLSClient returns an http.Client that can talk to the httptest.Server // whose certificate is written to the given file path. func TLSClient(certFile string) (*http.Client, error) { diff --git a/src/cmd/go/internal/web/http.go b/src/cmd/go/internal/web/http.go index 12109412ca7d43..9ee588c55f9199 100644 --- a/src/cmd/go/internal/web/http.go +++ b/src/cmd/go/internal/web/http.go @@ -22,6 +22,7 @@ import ( urlpkg "net/url" "os" "strings" + "sync" "time" "cmd/go/internal/auth" @@ -36,7 +37,7 @@ const userAgent = "GoCommand/1 (+https://go.dev/cmd/go)" // impatientInsecureHTTPClient is used with GOINSECURE, // when we're connecting to https servers that might not be there // or might be using self-signed certificates. -var impatientInsecureHTTPClient = &http.Client{ +var impatientInsecureHTTPClient = mtlsHTTPClient(&http.Client{ CheckRedirect: checkRedirect, Timeout: 5 * time.Second, Transport: &http.Transport{ @@ -45,9 +46,114 @@ var impatientInsecureHTTPClient = &http.Client{ InsecureSkipVerify: true, }, }, +}, true) + +var securityPreservingDefaultClient = securityPreservingHTTPClient(mtlsHTTPClient(http.DefaultClient, false)) + +type clientCertificateLookup func(*http.Request) (auth.ClientCertificate, bool) + +// mtlsHTTPClient returns a client that uses origin-scoped client certificates +// configured by GOAUTH. Certificate files are not read until the first request +// to a matching origin. +func mtlsHTTPClient(original *http.Client, insecure bool) *http.Client { + return mtlsHTTPClientWithLookup(original, insecure, auth.ClientCertificateForRequest) +} + +func mtlsHTTPClientWithLookup(original *http.Client, insecure bool, lookup clientCertificateLookup) *http.Client { + c := new(http.Client) + *c = *original + base := original.Transport + if base == nil { + base = http.DefaultTransport + } + c.Transport = &mtlsTransport{base: base, insecure: insecure, lookup: lookup} + return c } -var securityPreservingDefaultClient = securityPreservingHTTPClient(http.DefaultClient) +type mtlsTransport struct { + base http.RoundTripper + insecure bool + lookup clientCertificateLookup + states sync.Map // auth.ClientCertificate → *mtlsTransportState +} + +type mtlsTransportState struct { + mu sync.Mutex + once sync.Once + transport http.RoundTripper + err error +} + +func (t *mtlsTransport) RoundTrip(req *http.Request) (*http.Response, error) { + cert, ok := t.lookup(req) + if !ok { + return t.base.RoundTrip(req) + } + if t.insecure { + return nil, fmt.Errorf("GOAUTH=mtls cannot be used with GOINSECURE for %s", cert.Origin) + } + + value, _ := t.states.LoadOrStore(cert, new(mtlsTransportState)) + state := value.(*mtlsTransportState) + state.once.Do(func() { + state.init(t.base, cert) + }) + if state.err != nil { + return nil, state.err + } + return state.transport.RoundTrip(req) +} + +func (s *mtlsTransportState) init(roundTripper http.RoundTripper, certConfig auth.ClientCertificate) { + s.mu.Lock() + defer s.mu.Unlock() + + base, ok := roundTripper.(*http.Transport) + if !ok { + s.err = fmt.Errorf("GOAUTH=mtls requires an *http.Transport, but the HTTP client uses %T", roundTripper) + return + } + + cert, err := tls.LoadX509KeyPair(certConfig.CertFile, certConfig.KeyFile) + if err != nil { + s.err = fmt.Errorf("loading GOAUTH=mtls client certificate for %s: %w", certConfig.Origin, err) + return + } + + transport := base.Clone() + if proxy := transport.Proxy; proxy != nil { + transport.Proxy = func(req *http.Request) (*urlpkg.URL, error) { + u, err := proxy(req) + if err == nil && u != nil && strings.EqualFold(u.Scheme, "https") { + return nil, fmt.Errorf("GOAUTH=mtls does not support HTTPS proxy %s", u.Redacted()) + } + return u, err + } + } + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = new(tls.Config) + } else { + transport.TLSClientConfig = transport.TLSClientConfig.Clone() + } + transport.TLSClientConfig.Certificates = []tls.Certificate{cert} + transport.TLSClientConfig.GetClientCertificate = nil + s.transport = transport +} + +func (t *mtlsTransport) CloseIdleConnections() { + if closer, ok := t.base.(interface{ CloseIdleConnections() }); ok { + closer.CloseIdleConnections() + } + t.states.Range(func(_, value any) bool { + state := value.(*mtlsTransportState) + state.mu.Lock() + defer state.mu.Unlock() + if closer, ok := state.transport.(interface{ CloseIdleConnections() }); ok { + closer.CloseIdleConnections() + } + return true + }) +} // securityPreservingHTTPClient returns a client that is like the original // but rejects redirects to plain-HTTP URLs if the original URL was secure. @@ -128,7 +234,7 @@ func get(security SecurityMode, url *urlpkg.URL) (*Response, error) { if security == Insecure && url.Scheme == "https" { client = impatientInsecureHTTPClient } else if intercepted && t.Client != nil { - client = securityPreservingHTTPClient(t.Client) + client = securityPreservingHTTPClient(mtlsHTTPClient(t.Client, false)) } else { client = securityPreservingDefaultClient } diff --git a/src/cmd/go/internal/web/http_test.go b/src/cmd/go/internal/web/http_test.go index 84a41d6dcb2c9a..b7204c23ae26a7 100644 --- a/src/cmd/go/internal/web/http_test.go +++ b/src/cmd/go/internal/web/http_test.go @@ -6,11 +6,25 @@ package web import ( "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/pem" "io" + "math/big" + "net" "net/http" "net/http/httptest" "net/url" + "os" + "strings" "testing" + "time" + + "cmd/go/internal/auth" ) func TestUserAgent(t *testing.T) { @@ -76,3 +90,280 @@ func TestGoGet1(t *testing.T) { t.Errorf("http status != 200: %v", res.Status) } } + +func TestMTLSClient(t *testing.T) { + for _, test := range []struct { + name string + combined bool + }{ + {name: "separate certificate and key files", combined: false}, + {name: "combined certificate and key file", combined: true}, + } { + t.Run(test.name, func(t *testing.T) { + receivedCertificate := make(chan bool, 1) + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedCertificate <- len(r.TLS.PeerCertificates) > 0 + })) + clientCert, clientCAs := newClientCertificate(t) + ts.TLS = &tls.Config{ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs} + ts.StartTLS() + defer ts.Close() + + var certFile, keyFile string + if test.combined { + certFile = writeCombinedCertificateFile(t, clientCert) + keyFile = certFile + } else { + certFile, keyFile = writeCertificateFiles(t, clientCert) + } + u, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + cert := auth.ClientCertificate{Origin: u.Scheme + "://" + u.Host, CertFile: certFile, KeyFile: keyFile} + client := mtlsHTTPClientWithLookup(ts.Client(), false, lookupClientCertificate(cert)) + res, err := client.Get(ts.URL) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if !<-receivedCertificate { + t.Error("server did not receive a client certificate") + } + }) + } +} + +func TestMTLSClientLazyForOtherHost(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer ts.Close() + + cert := auth.ClientCertificate{ + Origin: "https://mtls.example.com:443", + CertFile: "missing-client-cert.pem", + KeyFile: "missing-client-key.pem", + } + client := mtlsHTTPClientWithLookup(ts.Client(), false, lookupClientCertificate(cert)) + res, err := client.Get(ts.URL) + if err != nil { + t.Fatalf("request to an unrelated host unexpectedly loaded the certificate: %v", err) + } + res.Body.Close() +} + +func TestMTLSClientMissingCertificate(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer ts.Close() + + u, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + cert := auth.ClientCertificate{ + Origin: u.Scheme + "://" + u.Host, + CertFile: "missing-client-cert.pem", + KeyFile: "missing-client-key.pem", + } + client := mtlsHTTPClientWithLookup(ts.Client(), false, lookupClientCertificate(cert)) + _, err = client.Get(ts.URL) + if err == nil || !strings.Contains(err.Error(), "loading GOAUTH=mtls client certificate") { + t.Fatalf("client.Get error = %v, want missing GOAUTH=mtls certificate error", err) + } +} + +func TestMTLSClientRedirectDoesNotForwardCertificate(t *testing.T) { + destinationReceivedCertificate := make(chan bool, 1) + destination := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + destinationReceivedCertificate <- len(r.TLS.PeerCertificates) > 0 + })) + destination.TLS = &tls.Config{ClientAuth: tls.RequestClientCert} + destination.StartTLS() + defer destination.Close() + + destinationURL, err := url.Parse(destination.URL) + if err != nil { + t.Fatal(err) + } + destinationURL.Host = "other.example.com:" + destinationURL.Port() + + source := httptest.NewUnstartedServer(http.RedirectHandler(destinationURL.String(), http.StatusFound)) + source.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert} + source.StartTLS() + defer source.Close() + + certFile, keyFile := writeCertificateFiles(t, source.TLS.Certificates[0]) + sourceURL, err := url.Parse(source.URL) + if err != nil { + t.Fatal(err) + } + sourceURL.Host = "mtls.example.com:" + sourceURL.Port() + baseClient := source.Client() + transport := baseClient.Transport.(*http.Transport).Clone() + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + _, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort("127.0.0.1", port)) + } + baseClient.Transport = transport + cert := auth.ClientCertificate{Origin: sourceURL.Scheme + "://" + sourceURL.Host, CertFile: certFile, KeyFile: keyFile} + client := mtlsHTTPClientWithLookup(baseClient, false, lookupClientCertificate(cert)) + res, err := client.Get(sourceURL.String()) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if <-destinationReceivedCertificate { + t.Error("redirect destination received the client certificate") + } +} + +func TestMTLSClientRejectsHTTPSProxy(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer ts.Close() + + certFile, keyFile := writeCertificateFiles(t, ts.TLS.Certificates[0]) + u, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + baseClient := ts.Client() + transport := baseClient.Transport.(*http.Transport).Clone() + transport.Proxy = func(*http.Request) (*url.URL, error) { + return url.Parse("https://proxy.example.com") + } + baseClient.Transport = transport + cert := auth.ClientCertificate{Origin: u.Scheme + "://" + u.Host, CertFile: certFile, KeyFile: keyFile} + client := mtlsHTTPClientWithLookup(baseClient, false, lookupClientCertificate(cert)) + _, err = client.Get(ts.URL) + if err == nil || !strings.Contains(err.Error(), "GOAUTH=mtls does not support HTTPS proxy") { + t.Fatalf("client.Get error = %v, want HTTPS proxy error", err) + } +} + +func TestMTLSClientRejectsGOINSECURE(t *testing.T) { + cert := auth.ClientCertificate{ + Origin: "https://mtls.example.com:443", + CertFile: "missing-client-cert.pem", + KeyFile: "missing-client-key.pem", + } + client := mtlsHTTPClientWithLookup(http.DefaultClient, true, lookupClientCertificate(cert)) + _, err := client.Get("https://mtls.example.com") + if err == nil || !strings.Contains(err.Error(), "GOAUTH=mtls cannot be used with GOINSECURE") { + t.Fatalf("client.Get error = %v, want GOINSECURE error", err) + } +} + +func lookupClientCertificate(cert auth.ClientCertificate) clientCertificateLookup { + return func(req *http.Request) (auth.ClientCertificate, bool) { + host := req.URL.Host + if req.Host != "" { + host = req.Host + } + u := &url.URL{Scheme: req.URL.Scheme, Host: host} + port := u.Port() + if port == "" { + port = "443" + } + origin := u.Scheme + "://" + net.JoinHostPort(strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")), port) + return cert, origin == cert.Origin + } +} + +func newClientCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) { + t.Helper() + + now := time.Now() + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + ca, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatal(err) + } + + clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + clientTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + clientDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, ca, &clientKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + + pool := x509.NewCertPool() + pool.AddCert(ca) + return tls.Certificate{Certificate: [][]byte{clientDER, caDER}, PrivateKey: clientKey}, pool +} + +func writeCertificateFiles(t *testing.T, cert tls.Certificate) (certFile, keyFile string) { + t.Helper() + + var certPEM bytes.Buffer + for _, der := range cert.Certificate { + if err := pem.Encode(&certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { + t.Fatal(err) + } + } + key, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey) + if err != nil { + t.Fatal(err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}) + + dir := t.TempDir() + certFile = dir + "/client-cert.pem" + keyFile = dir + "/client-key.pem" + if err := os.WriteFile(certFile, certPEM.Bytes(), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyFile, keyPEM, 0600); err != nil { + t.Fatal(err) + } + return certFile, keyFile +} + +func writeCombinedCertificateFile(t *testing.T, cert tls.Certificate) string { + t.Helper() + + var combined bytes.Buffer + for _, der := range cert.Certificate { + if err := pem.Encode(&combined, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { + t.Fatal(err) + } + } + key, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey) + if err != nil { + t.Fatal(err) + } + if err := pem.Encode(&combined, &pem.Block{Type: "PRIVATE KEY", Bytes: key}); err != nil { + t.Fatal(err) + } + + file := t.TempDir() + "/client.pem" + if err := os.WriteFile(file, combined.Bytes(), 0600); err != nil { + t.Fatal(err) + } + return file +} diff --git a/src/cmd/go/script_test.go b/src/cmd/go/script_test.go index e9b386ec660cd0..071fcd8488dce5 100644 --- a/src/cmd/go/script_test.go +++ b/src/cmd/go/script_test.go @@ -28,6 +28,7 @@ import ( "cmd/go/internal/cfg" "cmd/go/internal/gover" "cmd/go/internal/vcweb/vcstest" + "cmd/internal/quoted" "cmd/internal/script" "cmd/internal/script/scripttest" @@ -60,6 +61,10 @@ func TestScript(t *testing.T) { if err != nil { t.Fatal(err) } + clientCertFile, clientKeyFile, err := srv.WriteClientCertificateFiles() + if err != nil { + t.Fatal(err) + } StartProxy() @@ -90,7 +95,7 @@ func TestScript(t *testing.T) { t.Cleanup(cancel) } - env, err := scriptEnv(srv, certFile) + env, err := scriptEnv(srv, certFile, clientCertFile, clientKeyFile) if err != nil { t.Fatal(err) } @@ -213,7 +218,7 @@ func initScriptDirs(t testing.TB, s *script.State) (telemetryDir string) { return telemetryDir } -func scriptEnv(srv *vcstest.Server, srvCertFile string) ([]string, error) { +func scriptEnv(srv *vcstest.Server, srvCertFile, clientCertFile, clientKeyFile string) ([]string, error) { httpURL, err := url.Parse(srv.HTTP.URL) if err != nil { return nil, err @@ -222,6 +227,10 @@ func scriptEnv(srv *vcstest.Server, srvCertFile string) ([]string, error) { if err != nil { return nil, err } + mtlsGOAUTH, err := quoted.Join([]string{"mtls", "https://vcs-test.golang.org", clientCertFile, clientKeyFile}) + if err != nil { + return nil, err + } env := []string{ pathEnvName() + "=" + testBin + string(filepath.ListSeparator) + os.Getenv(pathEnvName()), homeEnvName() + "=/no-home", @@ -245,6 +254,7 @@ func scriptEnv(srv *vcstest.Server, srvCertFile string) ([]string, error) { "TESTGO_VCSTEST_HOST=" + httpURL.Host, "TESTGO_VCSTEST_TLS_HOST=" + httpsURL.Host, "TESTGO_VCSTEST_CERT=" + srvCertFile, + "TESTGO_VCSTEST_MTLS_GOAUTH=" + mtlsGOAUTH, "TESTGONETWORK=panic", // cleared by the [net] condition "GOSUMDB=" + testSumDBVerifierKey, "TESTGO_SUMDB=" + testSumDBName, diff --git a/src/cmd/go/testdata/script/goauth_mtls.txt b/src/cmd/go/testdata/script/goauth_mtls.txt new file mode 100644 index 00000000000000..006ad10447b22c --- /dev/null +++ b/src/cmd/go/testdata/script/goauth_mtls.txt @@ -0,0 +1,40 @@ +# GOAUTH=mtls certificate files are loaded only when the configured origin is +# contacted. A request to another HTTPS origin must not load missing files. +env GOAUTH='mtls https://mtls.example.com '$WORK'/missing-client-cert.pem '$WORK'/missing-client-key.pem; netrc' +env GOPROXY=direct +env GOSUMDB=off +env NETRC=$WORK/netrc +cp go.mod.orig go.mod +go get vcs-test.golang.org/auth/or401 + +# A server that requires a client certificate rejects an unauthenticated fetch. +env GOAUTH=off +cp go.mod.orig go.mod +! go get vcs-test.golang.org/auth/mtls +stderr 'client certificate required' + +# The same fetch succeeds with the origin-scoped client certificate. +env GOAUTH=$TESTGO_VCSTEST_MTLS_GOAUTH +cp go.mod.orig go.mod +go get vcs-test.golang.org/auth/mtls +go list -m all +stdout vcs-test.golang.org/auth/mtls + +# GOAUTH=mtls also authenticates requests made directly to an HTTPS GOPROXY. +go clean -modcache +env GOPROXY=https://vcs-test.golang.org/auth/mtls +env GOAUTH=off +! go mod download vcs-test.golang.org/auth/mtls@v0.0.0-20190405155051-52df474c8a8b +stderr 'client certificate required' + +env GOAUTH=$TESTGO_VCSTEST_MTLS_GOAUTH +go mod download vcs-test.golang.org/auth/mtls@v0.0.0-20190405155051-52df474c8a8b + +-- go.mod.orig -- +module private.example.com + +go 1.24 +-- $WORK/netrc -- +machine vcs-test.golang.org + login aladdin + password opensesame diff --git a/src/cmd/go/testdata/vcstest/auth/mtls.txt b/src/cmd/go/testdata/vcstest/auth/mtls.txt new file mode 100644 index 00000000000000..3610d8c814e879 --- /dev/null +++ b/src/cmd/go/testdata/vcstest/auth/mtls.txt @@ -0,0 +1,22 @@ +handle dir + +modzip vcs-test.golang.org/auth/mtls/@v/v0.0.0-20190405155051-52df474c8a8b.zip vcs-test.golang.org/auth/mtls@v0.0.0-20190405155051-52df474c8a8b .moddir + +-- index.html -- + + + +-- vcs-test.golang.org/auth/mtls/@v/list -- +v0.0.0-20190405155051-52df474c8a8b +-- vcs-test.golang.org/auth/mtls/@v/v0.0.0-20190405155051-52df474c8a8b.info -- +{"Version":"v0.0.0-20190405155051-52df474c8a8b","Time":"2019-04-05T15:50:51Z"} +-- vcs-test.golang.org/auth/mtls/@v/v0.0.0-20190405155051-52df474c8a8b.mod -- +module vcs-test.golang.org/auth/mtls + +go 1.13 +-- .moddir/go.mod -- +module vcs-test.golang.org/auth/mtls + +go 1.13 +-- .moddir/mtls.go -- +package mtls