Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/cmd/go/alldocs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

115 changes: 108 additions & 7 deletions src/cmd/go/internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -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, ";")
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
151 changes: 151 additions & 0 deletions src/cmd/go/internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
package auth

import (
"cmd/go/internal/cfg"
"cmd/go/internal/web/intercept"
"cmd/internal/quoted"
"net/http"
"reflect"
"testing"
Expand Down Expand Up @@ -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")
}
}
12 changes: 12 additions & 0 deletions src/cmd/go/internal/help/helpdoc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading