diff --git a/go.mod b/go.mod index c1fc3815f..a0450d5ea 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,9 @@ module github.com/go-git/go-git/v5 // go-git supports the last 3 stable Go versions. -go 1.20 +go 1.21 + +toolchain go1.22.6 // Use the v6-exp branch across go-git dependencies (gcfg and go-billy). replace ( @@ -11,6 +13,7 @@ replace ( require ( dario.cat/mergo v1.0.0 + github.com/Microsoft/go-winio v0.6.1 github.com/ProtonMail/go-crypto v1.0.0 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/elazarl/goproxy v0.0.0-20240618083138-03be62527ccb @@ -24,9 +27,7 @@ require ( github.com/kevinburke/ssh_config v1.2.0 github.com/pjbgf/sha1cd v0.3.0 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 - github.com/skeema/knownhosts v1.3.0 github.com/stretchr/testify v1.9.0 - github.com/xanzy/ssh-agent v0.3.3 golang.org/x/crypto v0.26.0 golang.org/x/net v0.28.0 golang.org/x/sys v0.24.0 @@ -35,7 +36,6 @@ require ( ) require ( - github.com/Microsoft/go-winio v0.6.1 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect diff --git a/go.sum b/go.sum index af0d15b9b..4e6f5849c 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,5 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= @@ -58,20 +57,13 @@ github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDN github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= -github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= -github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= @@ -82,7 +74,6 @@ golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -95,13 +86,9 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -118,7 +105,6 @@ golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= diff --git a/plumbing/transport/ssh/auth_method.go b/plumbing/transport/ssh/auth_method.go index f9c598e6f..7bd91a763 100644 --- a/plumbing/transport/ssh/auth_method.go +++ b/plumbing/transport/ssh/auth_method.go @@ -3,14 +3,16 @@ package ssh import ( "errors" "fmt" + "net" "os" "os/user" "path/filepath" "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/ssh/knownhosts" + "github.com/go-git/go-git/v5/plumbing/transport/ssh/sshagent" + "github.com/go-git/go-git/v5/utils/trace" - "github.com/skeema/knownhosts" - sshagent "github.com/xanzy/ssh-agent" "golang.org/x/crypto/ssh" ) @@ -54,6 +56,7 @@ func (a *KeyboardInteractive) String() string { } func (a *KeyboardInteractive) ClientConfig() (*ssh.ClientConfig, error) { + trace.SSH.Printf("ssh: %s user=%s", KeyboardInteractiveName, a.User) return a.SetHostKeyCallback(&ssh.ClientConfig{ User: a.User, Auth: []ssh.AuthMethod{ @@ -78,6 +81,7 @@ func (a *Password) String() string { } func (a *Password) ClientConfig() (*ssh.ClientConfig, error) { + trace.SSH.Printf("ssh: %s user=%s", PasswordName, a.User) return a.SetHostKeyCallback(&ssh.ClientConfig{ User: a.User, Auth: []ssh.AuthMethod{ssh.Password(a.Password)}, @@ -101,6 +105,7 @@ func (a *PasswordCallback) String() string { } func (a *PasswordCallback) ClientConfig() (*ssh.ClientConfig, error) { + trace.SSH.Printf("ssh: %s user=%s", PasswordCallbackName, a.User) return a.SetHostKeyCallback(&ssh.ClientConfig{ User: a.User, Auth: []ssh.AuthMethod{ssh.PasswordCallback(a.Callback)}, @@ -150,6 +155,9 @@ func (a *PublicKeys) String() string { } func (a *PublicKeys) ClientConfig() (*ssh.ClientConfig, error) { + trace.SSH.Printf("ssh: %s user=%s signer=\"%s %s\"", PublicKeysName, a.User, + a.Signer.PublicKey().Type(), + ssh.FingerprintSHA256(a.Signer.PublicKey())) return a.SetHostKeyCallback(&ssh.ClientConfig{ User: a.User, Auth: []ssh.AuthMethod{ssh.PublicKeys(a.Signer)}, @@ -160,8 +168,10 @@ func username() (string, error) { var username string if user, err := user.Current(); err == nil { username = user.Username + trace.SSH.Printf("ssh: Falling back to current user name %q", username) } else { username = os.Getenv("USER") + trace.SSH.Printf("ssh: Falling back to environment variable USER %q", username) } if username == "" { @@ -211,9 +221,10 @@ func (a *PublicKeysCallback) String() string { } func (a *PublicKeysCallback) ClientConfig() (*ssh.ClientConfig, error) { + trace.SSH.Printf("ssh: %s user=%s", PublicKeysCallbackName, a.User) return a.SetHostKeyCallback(&ssh.ClientConfig{ User: a.User, - Auth: []ssh.AuthMethod{ssh.PublicKeysCallback(a.Callback)}, + Auth: []ssh.AuthMethod{tracePublicKeysCallback(a.Callback)}, }) } @@ -236,16 +247,17 @@ func NewKnownHostsCallback(files ...string) (ssh.HostKeyCallback, error) { func newKnownHostsDb(files ...string) (*knownhosts.HostKeyDB, error) { var err error - if len(files) == 0 { if files, err = getDefaultKnownHostsFiles(); err != nil { return nil, err } } + trace.SSH.Printf("ssh: known_hosts sources %s", files) if files, err = filterKnownHostsFiles(files...); err != nil { return nil, err } + trace.SSH.Printf("ssh: filtered known_hosts sources %s", files) return knownhosts.NewDB(files...) } @@ -253,6 +265,7 @@ func newKnownHostsDb(files ...string) (*knownhosts.HostKeyDB, error) { func getDefaultKnownHostsFiles() ([]string, error) { files := filepath.SplitList(os.Getenv("SSH_KNOWN_HOSTS")) if len(files) != 0 { + trace.SSH.Printf("ssh: loading known_hosts from SSH_KNOWN_HOSTS") return files, nil } @@ -309,6 +322,32 @@ func (m *HostKeyCallbackHelper) SetHostKeyCallback(cfg *ssh.ClientConfig) (*ssh. m.HostKeyCallback = db.HostKeyCallback() } - cfg.HostKeyCallback = m.HostKeyCallback + cfg.HostKeyCallback = m.traceHostKeyCallback return cfg, nil } + +func (m *HostKeyCallbackHelper) traceHostKeyCallback(hostname string, remote net.Addr, key ssh.PublicKey) error { + trace.SSH.Printf( + `ssh: hostkey callback hostname=%s remote=%s key(type print)="%s %s"`, + hostname, remote, key.Type(), ssh.FingerprintSHA256(key)) + return m.HostKeyCallback(hostname, remote, key) +} + +func tracePublicKeysCallback(getSigners func() ([]ssh.Signer, error)) ssh.AuthMethod { + signers, err := getSigners() + if err != nil { + trace.SSH.Printf("ssh: error calling getSigners: %v", err) + } + if len(signers) == 0 { + trace.SSH.Printf("ssh: no signers found") + } + for _, s := range signers { + trace.SSH.Printf("ssh: found key: %s %s", s.PublicKey().Type(), + ssh.FingerprintSHA256(s.PublicKey())) + } + + cb := func() ([]ssh.Signer, error) { + return signers, err + } + return ssh.PublicKeysCallback(cb) +} diff --git a/plumbing/transport/ssh/common.go b/plumbing/transport/ssh/common.go index 98ab46f28..a07231b0f 100644 --- a/plumbing/transport/ssh/common.go +++ b/plumbing/transport/ssh/common.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/utils/trace" "github.com/kevinburke/ssh_config" "golang.org/x/crypto/ssh" @@ -39,6 +40,7 @@ func NewClient(config *ssh.ClientConfig) transport.Transport { // DefaultAuthBuilder is the function used to create a default AuthMethod, when // the user doesn't provide any. var DefaultAuthBuilder = func(user string) (AuthMethod, error) { + trace.SSH.Printf("ssh: Using default auth builder (user: %s)", user) return NewSSHAgentAuth(user) } @@ -150,6 +152,8 @@ func (c *command) connect() error { config.HostKeyAlgorithms = db.HostKeyAlgorithms(hostWithPort) } + trace.SSH.Printf("ssh: host key algorithms %s", config.HostKeyAlgorithms) + overrideConfig(c.config, config) c.client, err = dial("tcp", hostWithPort, c.endpoint.Proxy, config) @@ -187,6 +191,8 @@ func dial(network, addr string, proxyOpts transport.ProxyOptions, config *ssh.Cl if err != nil { return nil, err } + + trace.SSH.Printf("ssh: using proxyURL=%s", proxyUrl) dialer, err := proxy.FromURL(proxyUrl, proxy.Direct) if err != nil { return nil, err diff --git a/plumbing/transport/ssh/knownhosts/knownhosts.go b/plumbing/transport/ssh/knownhosts/knownhosts.go new file mode 100644 index 000000000..6b283ade1 --- /dev/null +++ b/plumbing/transport/ssh/knownhosts/knownhosts.go @@ -0,0 +1,468 @@ +// Copyright 2024 Skeema LLC and the Skeema Knownhosts authors + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Originally from: https://github.com/skeema/knownhosts/blob/main/knownhosts.go + +// Package knownhosts is a thin wrapper around golang.org/x/crypto/ssh/knownhosts, +// adding the ability to obtain the list of host key algorithms for a known host. +package knownhosts + +import ( + "bufio" + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "os" + "sort" + "strings" + + "github.com/go-git/go-git/v5/utils/trace" + "golang.org/x/crypto/ssh" + xknownhosts "golang.org/x/crypto/ssh/knownhosts" +) + +// HostKeyDB wraps logic in golang.org/x/crypto/ssh/knownhosts with additional +// behaviors, such as the ability to perform host key/algorithm lookups from +// known_hosts entries. +type HostKeyDB struct { + callback ssh.HostKeyCallback + isCert map[string]bool // keyed by "filename:line" + isWildcard map[string]bool // keyed by "filename:line" +} + +// NewDB creates a HostKeyDB from the given OpenSSH known_hosts file(s). It +// reads and parses the provided files one additional time (beyond logic in +// golang.org/x/crypto/ssh/knownhosts) in order to: +// +// - Handle CA lines properly and return ssh.CertAlgo* values when calling the +// HostKeyAlgorithms method, for use in ssh.ClientConfig.HostKeyAlgorithms +// - Allow * wildcards in hostnames to match on non-standard ports, providing +// a workaround for https://github.com/golang/go/issues/52056 in order to +// align with OpenSSH's wildcard behavior +// +// When supplying multiple files, their order does not matter. +func NewDB(files ...string) (*HostKeyDB, error) { + cb, err := xknownhosts.New(files...) + if err != nil { + return nil, err + } + hkdb := &HostKeyDB{ + callback: cb, + isCert: make(map[string]bool), + isWildcard: make(map[string]bool), + } + + // Re-read each file a single time, looking for @cert-authority lines. The + // logic for reading the file is designed to mimic hostKeyDB.Read from + // golang.org/x/crypto/ssh/knownhosts + for _, filename := range files { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + scanner := bufio.NewScanner(f) + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := scanner.Bytes() + line = bytes.TrimSpace(line) + // Does the line start with "@cert-authority" followed by whitespace? + if len(line) > 15 && bytes.HasPrefix(line, []byte("@cert-authority")) && (line[15] == ' ' || line[15] == '\t') { + mapKey := fmt.Sprintf("%s:%d", filename, lineNum) + hkdb.isCert[mapKey] = true + line = bytes.TrimSpace(line[16:]) + } + // truncate line to just the host pattern field + if i := bytes.IndexAny(line, "\t "); i >= 0 { + line = line[:i] + } + // Does the host pattern contain a * wildcard and no specific port? + if i := bytes.IndexRune(line, '*'); i >= 0 && !bytes.Contains(line[i:], []byte("]:")) { + mapKey := fmt.Sprintf("%s:%d", filename, lineNum) + hkdb.isWildcard[mapKey] = true + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("knownhosts: %s:%d: %w", filename, lineNum, err) + } + } + return hkdb, nil +} + +// HostKeyCallback returns an ssh.HostKeyCallback. This can be used directly in +// ssh.ClientConfig.HostKeyCallback, as shown in the example for NewDB. +// Alternatively, you can wrap it with an outer callback to potentially handle +// appending a new entry to the known_hosts file; see example in WriteKnownHost. +func (hkdb *HostKeyDB) HostKeyCallback() ssh.HostKeyCallback { + // Either NewDB found no wildcard host patterns, or hkdb was created from + // HostKeyCallback.ToDB in which case we didn't scan known_hosts for them: + // return the callback (which came from x/crypto/ssh/knownhosts) as-is + if len(hkdb.isWildcard) == 0 { + return hkdb.callback + } + + // If we scanned for wildcards and found at least one, return a wrapped + // callback with extra behavior: if the host lookup found no matches, and the + // host arg had a non-standard port, re-do the lookup on standard port 22. If + // that second call returns a *xknownhosts.KeyError, filter down any resulting + // Want keys to known wildcard entries. + f := func(hostname string, remote net.Addr, key ssh.PublicKey) error { + trace.SSH.Printf( + `ssh: wildcard knownhosts for hostname=%s pubkey="%s %s"`, + hostname, key.Type(), ssh.FingerprintSHA256(key)) + + callbackErr := hkdb.callback(hostname, remote, key) + if callbackErr == nil || IsHostKeyChanged(callbackErr) { // hostname has known_host entries as-is + return callbackErr + } + justHost, port, splitErr := net.SplitHostPort(hostname) + if splitErr != nil || port == "" || port == "22" { // hostname already using standard port + return callbackErr + } + // If we reach here, the port was non-standard and no known_host entries + // were found for the non-standard port. Try again with standard port. + if tcpAddr, ok := remote.(*net.TCPAddr); ok && tcpAddr.Port != 22 { + remote = &net.TCPAddr{ + IP: tcpAddr.IP, + Port: 22, + Zone: tcpAddr.Zone, + } + } + callbackErr = hkdb.callback(justHost+":22", remote, key) + var keyErr *xknownhosts.KeyError + if errors.As(callbackErr, &keyErr) && len(keyErr.Want) > 0 { + wildcardKeys := make([]xknownhosts.KnownKey, 0, len(keyErr.Want)) + for _, wantKey := range keyErr.Want { + if hkdb.isWildcard[fmt.Sprintf("%s:%d", wantKey.Filename, wantKey.Line)] { + wildcardKeys = append(wildcardKeys, wantKey) + } + } + callbackErr = &xknownhosts.KeyError{ + Want: wildcardKeys, + } + } + return callbackErr + } + return ssh.HostKeyCallback(f) +} + +// PublicKey wraps ssh.PublicKey with an additional field, to identify +// whether the key corresponds to a certificate authority. +type PublicKey struct { + ssh.PublicKey + Cert bool +} + +// HostKeys returns a slice of known host public keys for the supplied host:port +// found in the known_hosts file(s), or an empty slice if the host is not +// already known. For hosts that have multiple known_hosts entries (for +// different key types), the result will be sorted by known_hosts filename and +// line number. +// If hkdb was originally created by calling NewDB, the Cert boolean field of +// each result entry reports whether the key corresponded to a @cert-authority +// line. If hkdb was NOT obtained from NewDB, then Cert will always be false. +func (hkdb *HostKeyDB) HostKeys(hostWithPort string) (keys []PublicKey) { + var keyErr *xknownhosts.KeyError + placeholderAddr := &net.TCPAddr{IP: []byte{0, 0, 0, 0}} + placeholderPubKey := &fakePublicKey{} + var kkeys []xknownhosts.KnownKey + callback := hkdb.HostKeyCallback() + if hkcbErr := callback(hostWithPort, placeholderAddr, placeholderPubKey); errors.As(hkcbErr, &keyErr) { + kkeys = append(kkeys, keyErr.Want...) + knownKeyLess := func(i, j int) bool { + if kkeys[i].Filename < kkeys[j].Filename { + return true + } + return (kkeys[i].Filename == kkeys[j].Filename && kkeys[i].Line < kkeys[j].Line) + } + sort.Slice(kkeys, knownKeyLess) + keys = make([]PublicKey, len(kkeys)) + for n := range kkeys { + keys[n] = PublicKey{ + PublicKey: kkeys[n].Key, + } + if len(hkdb.isCert) > 0 { + keys[n].Cert = hkdb.isCert[fmt.Sprintf("%s:%d", kkeys[n].Filename, kkeys[n].Line)] + } + } + } + return keys +} + +// HostKeyAlgorithms returns a slice of host key algorithms for the supplied +// host:port found in the known_hosts file(s), or an empty slice if the host +// is not already known. The result may be used in ssh.ClientConfig's +// HostKeyAlgorithms field, either as-is or after filtering (if you wish to +// ignore or prefer particular algorithms). For hosts that have multiple +// known_hosts entries (of different key types), the result will be sorted by +// known_hosts filename and line number. +// If hkdb was originally created by calling NewDB, any @cert-authority lines +// in the known_hosts file will properly be converted to the corresponding +// ssh.CertAlgo* values. +func (hkdb *HostKeyDB) HostKeyAlgorithms(hostWithPort string) (algos []string) { + // We ensure that algos never contains duplicates. This is done for robustness + // even though currently golang.org/x/crypto/ssh/knownhosts never exposes + // multiple keys of the same type. This way our behavior here is unaffected + // even if https://github.com/golang/go/issues/28870 is implemented, for + // example by https://github.com/golang/crypto/pull/254. + hostKeys := hkdb.HostKeys(hostWithPort) + seen := make(map[string]struct{}, len(hostKeys)) + addAlgo := func(typ string, cert bool) { + if cert { + typ = keyTypeToCertAlgo(typ) + } + if _, already := seen[typ]; !already { + algos = append(algos, typ) + seen[typ] = struct{}{} + } + } + for _, key := range hostKeys { + typ := key.Type() + if typ == ssh.KeyAlgoRSA { + // KeyAlgoRSASHA256 and KeyAlgoRSASHA512 are only public key algorithms, + // not public key formats, so they can't appear as a PublicKey.Type. + // The corresponding PublicKey.Type is KeyAlgoRSA. See RFC 8332, Section 2. + addAlgo(ssh.KeyAlgoRSASHA512, key.Cert) + addAlgo(ssh.KeyAlgoRSASHA256, key.Cert) + } + addAlgo(typ, key.Cert) + } + return algos +} + +func keyTypeToCertAlgo(keyType string) string { + switch keyType { + case ssh.KeyAlgoRSA: + return ssh.CertAlgoRSAv01 + case ssh.KeyAlgoRSASHA256: + return ssh.CertAlgoRSASHA256v01 + case ssh.KeyAlgoRSASHA512: + return ssh.CertAlgoRSASHA512v01 + case ssh.KeyAlgoDSA: + return ssh.CertAlgoDSAv01 + case ssh.KeyAlgoECDSA256: + return ssh.CertAlgoECDSA256v01 + case ssh.KeyAlgoSKECDSA256: + return ssh.CertAlgoSKECDSA256v01 + case ssh.KeyAlgoECDSA384: + return ssh.CertAlgoECDSA384v01 + case ssh.KeyAlgoECDSA521: + return ssh.CertAlgoECDSA521v01 + case ssh.KeyAlgoED25519: + return ssh.CertAlgoED25519v01 + case ssh.KeyAlgoSKED25519: + return ssh.CertAlgoSKED25519v01 + } + return "" +} + +// HostKeyCallback wraps ssh.HostKeyCallback with additional methods to +// perform host key and algorithm lookups from the known_hosts entries. It is +// otherwise identical to ssh.HostKeyCallback, and does not introduce any file- +// parsing behavior beyond what is in golang.org/x/crypto/ssh/knownhosts. +// +// In most situations, use HostKeyDB and its constructor NewDB instead of using +// the HostKeyCallback type. The HostKeyCallback type is only provided for +// backwards compatibility with older versions of this package, as well as for +// very strict situations where any extra known_hosts file-parsing is +// undesirable. +// +// Methods of HostKeyCallback do not provide any special treatment for +// @cert-authority lines, which will (incorrectly) look like normal non-CA host +// keys. Additionally, HostKeyCallback lacks the fix for applying * wildcard +// known_host entries to all ports, like OpenSSH's behavior. +type HostKeyCallback ssh.HostKeyCallback + +// New creates a HostKeyCallback from the given OpenSSH known_hosts file(s). The +// returned value may be used in ssh.ClientConfig.HostKeyCallback by casting it +// to ssh.HostKeyCallback, or using its HostKeyCallback method. Otherwise, it +// operates the same as the New function in golang.org/x/crypto/ssh/knownhosts. +// When supplying multiple files, their order does not matter. +// +// In most situations, you should avoid this function, as the returned value +// lacks several enhanced behaviors. See doc comment for HostKeyCallback for +// more information. Instead, most callers should use NewDB to create a +// HostKeyDB, which includes these enhancements. +func New(files ...string) (HostKeyCallback, error) { + cb, err := xknownhosts.New(files...) + return HostKeyCallback(cb), err +} + +// HostKeyCallback simply casts the receiver back to ssh.HostKeyCallback, for +// use in ssh.ClientConfig.HostKeyCallback. +func (hkcb HostKeyCallback) HostKeyCallback() ssh.HostKeyCallback { + return ssh.HostKeyCallback(hkcb) +} + +// ToDB converts the receiver into a HostKeyDB. However, the returned HostKeyDB +// lacks the enhanced behaviors described in the doc comment for NewDB: proper +// CA support, and wildcard matching on nonstandard ports. +// +// It is generally preferable to create a HostKeyDB by using NewDB. The ToDB +// method is only provided for situations in which the calling code needs to +// make the extra NewDB behaviors optional / user-configurable, perhaps for +// reasons of performance or code trust (since NewDB reads the known_host file +// an extra time, which may be undesirable in some strict situations). This way, +// callers can conditionally create a non-enhanced HostKeyDB by using New and +// ToDB. See code example. +func (hkcb HostKeyCallback) ToDB() *HostKeyDB { + // This intentionally leaves the isCert and isWildcard map fields as nil, as + // there is no way to retroactively populate them from just a HostKeyCallback. + // Methods of HostKeyDB will skip any related enhanced behaviors accordingly. + return &HostKeyDB{callback: ssh.HostKeyCallback(hkcb)} +} + +// HostKeys returns a slice of known host public keys for the supplied host:port +// found in the known_hosts file(s), or an empty slice if the host is not +// already known. For hosts that have multiple known_hosts entries (for +// different key types), the result will be sorted by known_hosts filename and +// line number. +// In the returned values, there is no way to distinguish between CA keys +// (known_hosts lines beginning with @cert-authority) and regular keys. To do +// so, see NewDB and HostKeyDB.HostKeys instead. +func (hkcb HostKeyCallback) HostKeys(hostWithPort string) []ssh.PublicKey { + annotatedKeys := hkcb.ToDB().HostKeys(hostWithPort) + rawKeys := make([]ssh.PublicKey, len(annotatedKeys)) + for n, ak := range annotatedKeys { + rawKeys[n] = ak.PublicKey + } + return rawKeys +} + +// HostKeyAlgorithms returns a slice of host key algorithms for the supplied +// host:port found in the known_hosts file(s), or an empty slice if the host +// is not already known. The result may be used in ssh.ClientConfig's +// HostKeyAlgorithms field, either as-is or after filtering (if you wish to +// ignore or prefer particular algorithms). For hosts that have multiple +// known_hosts entries (for different key types), the result will be sorted by +// known_hosts filename and line number. +// The returned values will not include ssh.CertAlgo* values. If any +// known_hosts lines had @cert-authority prefixes, their original key algo will +// be returned instead. For proper CA support, see NewDB and +// HostKeyDB.HostKeyAlgorithms instead. +func (hkcb HostKeyCallback) HostKeyAlgorithms(hostWithPort string) (algos []string) { + return hkcb.ToDB().HostKeyAlgorithms(hostWithPort) +} + +// HostKeyAlgorithms is a convenience function for performing host key algorithm +// lookups on an ssh.HostKeyCallback directly. It is intended for use in code +// paths that stay with the New method of golang.org/x/crypto/ssh/knownhosts +// rather than this package's New or NewDB methods. +// The returned values will not include ssh.CertAlgo* values. If any +// known_hosts lines had @cert-authority prefixes, their original key algo will +// be returned instead. For proper CA support, see NewDB and +// HostKeyDB.HostKeyAlgorithms instead. +func HostKeyAlgorithms(cb ssh.HostKeyCallback, hostWithPort string) []string { + return HostKeyCallback(cb).HostKeyAlgorithms(hostWithPort) +} + +// IsHostKeyChanged returns a boolean indicating whether the error indicates +// the host key has changed. It is intended to be called on the error returned +// from invoking a host key callback, to check whether an SSH host is known. +func IsHostKeyChanged(err error) bool { + var keyErr *xknownhosts.KeyError + return errors.As(err, &keyErr) && len(keyErr.Want) > 0 +} + +// IsHostUnknown returns a boolean indicating whether the error represents an +// unknown host. It is intended to be called on the error returned from invoking +// a host key callback to check whether an SSH host is known. +func IsHostUnknown(err error) bool { + var keyErr *xknownhosts.KeyError + return errors.As(err, &keyErr) && len(keyErr.Want) == 0 +} + +// Normalize normalizes an address into the form used in known_hosts. This +// implementation includes a fix for https://github.com/golang/go/issues/53463 +// and will omit brackets around ipv6 addresses on standard port 22. +func Normalize(address string) string { + host, port, err := net.SplitHostPort(address) + if err != nil { + host = address + port = "22" + } + entry := host + if port != "22" { + entry = "[" + entry + "]:" + port + } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + entry = entry[1 : len(entry)-1] + } + return entry +} + +// Line returns a line to append to the known_hosts files. This implementation +// uses the local patched implementation of Normalize in order to solve +// https://github.com/golang/go/issues/53463. +func Line(addresses []string, key ssh.PublicKey) string { + var trimmed []string + for _, a := range addresses { + trimmed = append(trimmed, Normalize(a)) + } + + return strings.Join([]string{ + strings.Join(trimmed, ","), + key.Type(), + base64.StdEncoding.EncodeToString(key.Marshal()), + }, " ") +} + +// WriteKnownHost writes a known_hosts line to w for the supplied hostname, +// remote, and key. This is useful when writing a custom hostkey callback which +// wraps a callback obtained from this package to provide additional known_hosts +// management functionality. The hostname, remote, and key typically correspond +// to the callback's args. This function does not support writing +// @cert-authority lines. +func WriteKnownHost(w io.Writer, hostname string, remote net.Addr, key ssh.PublicKey) error { + // Always include hostname; only also include remote if it isn't a zero value + // and doesn't normalize to the same string as hostname. + hostnameNormalized := Normalize(hostname) + if strings.ContainsAny(hostnameNormalized, "\t ") { + return fmt.Errorf("knownhosts: hostname '%s' contains spaces", hostnameNormalized) + } + addresses := []string{hostnameNormalized} + remoteStrNormalized := Normalize(remote.String()) + if remoteStrNormalized != "[0.0.0.0]:0" && remoteStrNormalized != hostnameNormalized && + !strings.ContainsAny(remoteStrNormalized, "\t ") { + addresses = append(addresses, remoteStrNormalized) + } + line := Line(addresses, key) + "\n" + _, err := w.Write([]byte(line)) + return err +} + +// WriteKnownHostCA writes a @cert-authority line to w for the supplied host +// name/pattern and key. +func WriteKnownHostCA(w io.Writer, hostPattern string, key ssh.PublicKey) error { + encodedKey := base64.StdEncoding.EncodeToString(key.Marshal()) + _, err := fmt.Fprintf(w, "@cert-authority %s %s %s\n", hostPattern, key.Type(), encodedKey) + return err +} + +// fakePublicKey is used as part of the work-around for +// https://github.com/golang/go/issues/29286 +type fakePublicKey struct{} + +func (fakePublicKey) Type() string { + return "fake-public-key" +} +func (fakePublicKey) Marshal() []byte { + return []byte("fake public key") +} +func (fakePublicKey) Verify(_ []byte, _ *ssh.Signature) error { + return errors.New("Verify called on placeholder key") +} diff --git a/plumbing/transport/ssh/knownhosts/knownhosts_test.go b/plumbing/transport/ssh/knownhosts/knownhosts_test.go new file mode 100644 index 000000000..99ca9aff2 --- /dev/null +++ b/plumbing/transport/ssh/knownhosts/knownhosts_test.go @@ -0,0 +1,558 @@ +// Copyright 2024 Skeema LLC and the Skeema Knownhosts authors + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Originally from: https://github.com/skeema/knownhosts/blob/main/knownhosts_test.go + +package knownhosts + +import ( + "bytes" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "net" + "os" + "path/filepath" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestNewDB(t *testing.T) { + khPath := getTestKnownHosts(t) + + // Valid path should return a non-nil HostKeyDB and no error + if kh, err := NewDB(khPath); kh == nil || err != nil { + t.Errorf("Unexpected return from NewDB on valid known_hosts path: %v, %v", kh, err) + } else { + // Confirm return value of HostKeyCallback is an ssh.HostKeyCallback + _ = ssh.ClientConfig{ + HostKeyCallback: kh.HostKeyCallback(), + } + } + + // Append a @cert-authority line to the valid known_hosts file + // Valid path should still return a non-nil HostKeyDB and no error + appendCertTestKnownHosts(t, khPath, "*", ssh.KeyAlgoECDSA256) + if kh, err := NewDB(khPath); kh == nil || err != nil { + t.Errorf("Unexpected return from NewDB on valid known_hosts path containing a cert: %v, %v", kh, err) + } + + // Write a second valid known_hosts file + // Supplying both valid paths should still return a non-nil HostKeyDB and no + // error + appendCertTestKnownHosts(t, khPath+"2", "*.certy.test", ssh.KeyAlgoED25519) + if kh, err := NewDB(khPath+"2", khPath); kh == nil || err != nil { + t.Errorf("Unexpected return from NewDB on two valid known_hosts paths: %v, %v", kh, err) + } + + // Invalid path should return an error, with or without other valid paths + if _, err := NewDB(khPath + "_does_not_exist"); err == nil { + t.Error("Expected error from NewDB with invalid path, but error was nil") + } + if _, err := NewDB(khPath, khPath+"_does_not_exist"); err == nil { + t.Error("Expected error from NewDB with mix of valid and invalid paths, but error was nil") + } +} + +func TestNew(t *testing.T) { + khPath := getTestKnownHosts(t) + + // Valid path should return a callback and no error; callback should be usable + // in ssh.ClientConfig.HostKeyCallback + if kh, err := New(khPath); err != nil { + t.Errorf("Unexpected error from New on valid known_hosts path: %v", err) + } else { + // Confirm kh can be converted to an ssh.HostKeyCallback + _ = ssh.ClientConfig{ + HostKeyCallback: ssh.HostKeyCallback(kh), + } + // Confirm return value of HostKeyCallback is an ssh.HostKeyCallback + _ = ssh.ClientConfig{ + HostKeyCallback: kh.HostKeyCallback(), + } + } + + // Invalid path should return an error, with or without other valid paths + if _, err := New(khPath + "_does_not_exist"); err == nil { + t.Error("Expected error from New with invalid path, but error was nil") + } + if _, err := New(khPath, khPath+"_does_not_exist"); err == nil { + t.Error("Expected error from New with mix of valid and invalid paths, but error was nil") + } +} + +func TestHostKeys(t *testing.T) { + khPath := getTestKnownHosts(t) + kh, err := New(khPath) + if err != nil { + t.Fatalf("Unexpected error from New: %v", err) + } + + expectedKeyTypes := map[string][]string{ + "only-rsa.example.test:22": {"ssh-rsa"}, + "only-ecdsa.example.test:22": {"ecdsa-sha2-nistp256"}, + "only-ed25519.example.test:22": {"ssh-ed25519"}, + "multi.example.test:2233": {"ssh-rsa", "ecdsa-sha2-nistp256", "ssh-ed25519"}, + "192.168.1.102:2222": {"ecdsa-sha2-nistp256", "ssh-ed25519"}, + "unknown-host.example.test": {}, // host not in file + "multi.example.test:22": {}, // different port than entry in file + "192.168.1.102": {}, // different port than entry in file + } + for host, expected := range expectedKeyTypes { + actual := kh.HostKeys(host) + if len(actual) != len(expected) { + t.Errorf("Unexpected number of keys returned by HostKeys(%q): expected %d, found %d", host, len(expected), len(actual)) + continue + } + for n := range expected { + if actualType := actual[n].Type(); expected[n] != actualType { + t.Errorf("Unexpected key returned by HostKeys(%q): expected key[%d] to be type %v, found %v", host, n, expected, actualType) + break + } + } + } +} + +func TestHostKeyAlgorithms(t *testing.T) { + khPath := getTestKnownHosts(t) + kh, err := New(khPath) + if err != nil { + t.Fatalf("Unexpected error from New: %v", err) + } + + expectedAlgorithms := map[string][]string{ + "only-rsa.example.test:22": {"rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"}, + "only-ecdsa.example.test:22": {"ecdsa-sha2-nistp256"}, + "only-ed25519.example.test:22": {"ssh-ed25519"}, + "multi.example.test:2233": {"rsa-sha2-512", "rsa-sha2-256", "ssh-rsa", "ecdsa-sha2-nistp256", "ssh-ed25519"}, + "192.168.1.102:2222": {"ecdsa-sha2-nistp256", "ssh-ed25519"}, + "unknown-host.example.test": {}, // host not in file + "multi.example.test:22": {}, // different port than entry in file + "192.168.1.102": {}, // different port than entry in file + } + for host, expected := range expectedAlgorithms { + actual := kh.HostKeyAlgorithms(host) + actual2 := HostKeyAlgorithms(kh.HostKeyCallback(), host) + if len(actual) != len(expected) || len(actual2) != len(expected) { + t.Errorf("Unexpected number of algorithms returned by HostKeyAlgorithms(%q): expected %d, found %d", host, len(expected), len(actual)) + continue + } + for n := range expected { + if expected[n] != actual[n] || expected[n] != actual2[n] { + t.Errorf("Unexpected algorithms returned by HostKeyAlgorithms(%q): expected %v, found %v", host, expected, actual) + break + } + } + } +} + +func TestWithCertLines(t *testing.T) { + khPath := getTestKnownHosts(t) + khPath2 := khPath + "2" + appendCertTestKnownHosts(t, khPath, "*.certy.test", ssh.KeyAlgoRSA) + appendCertTestKnownHosts(t, khPath2, "*", ssh.KeyAlgoECDSA256) + appendCertTestKnownHosts(t, khPath2, "*.certy.test", ssh.KeyAlgoED25519) + + // Test behavior of HostKeyCallback type, which doesn't properly handle + // @cert-authority lines but shouldn't error on them. It should just return + // them as regular keys / algorithms. + cbOnly, err := New(khPath2, khPath) + if err != nil { + t.Fatalf("Unexpected error from New: %v", err) + } + algos := cbOnly.HostKeyAlgorithms("only-ed25519.example.test:22") + // algos should return ssh.KeyAlgoED25519 (as per previous test) but now also + // ssh.KeyAlgoECDSA256 due to the cert entry on *. They should always be in + // that order due to matching the file and line order from NewDB. + if len(algos) != 2 || algos[0] != ssh.KeyAlgoED25519 || algos[1] != ssh.KeyAlgoECDSA256 { + t.Errorf("Unexpected return from HostKeyCallback.HostKeyAlgorithms: %v", algos) + } + + // Now test behavior of HostKeyDB type, which should properly support + // @cert-authority lines as being different from other lines + kh, err := NewDB(khPath2, khPath) + if err != nil { + t.Fatalf("Unexpected error from NewDB: %v", err) + } + testCases := []struct { + host string + expectedKeyTypes []string + expectedIsCert []bool + expectedAlgos []string + }{ + { + host: "only-ed25519.example.test:22", + expectedKeyTypes: []string{ssh.KeyAlgoED25519, ssh.KeyAlgoECDSA256}, + expectedIsCert: []bool{false, true}, + expectedAlgos: []string{ssh.KeyAlgoED25519, ssh.CertAlgoECDSA256v01}, + }, + { + host: "only-rsa.example.test:22", + expectedKeyTypes: []string{ssh.KeyAlgoRSA, ssh.KeyAlgoECDSA256}, + expectedIsCert: []bool{false, true}, + expectedAlgos: []string{ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256, ssh.KeyAlgoRSA, ssh.CertAlgoECDSA256v01}, + }, + { + host: "whatever.test:22", // only matches the * entry + expectedKeyTypes: []string{ssh.KeyAlgoECDSA256}, + expectedIsCert: []bool{true}, + expectedAlgos: []string{ssh.CertAlgoECDSA256v01}, + }, + { + host: "whatever.test:22022", // only matches the * entry + expectedKeyTypes: []string{ssh.KeyAlgoECDSA256}, + expectedIsCert: []bool{true}, + expectedAlgos: []string{ssh.CertAlgoECDSA256v01}, + }, + { + host: "asdf.certy.test:22", + expectedKeyTypes: []string{ssh.KeyAlgoRSA, ssh.KeyAlgoECDSA256, ssh.KeyAlgoED25519}, + expectedIsCert: []bool{true, true, true}, + expectedAlgos: []string{ssh.CertAlgoRSASHA512v01, ssh.CertAlgoRSASHA256v01, ssh.CertAlgoRSAv01, ssh.CertAlgoECDSA256v01, ssh.CertAlgoED25519v01}, + }, + { + host: "oddport.certy.test:2345", + expectedKeyTypes: []string{ssh.KeyAlgoRSA, ssh.KeyAlgoECDSA256, ssh.KeyAlgoED25519}, + expectedIsCert: []bool{true, true, true}, + expectedAlgos: []string{ssh.CertAlgoRSASHA512v01, ssh.CertAlgoRSASHA256v01, ssh.CertAlgoRSAv01, ssh.CertAlgoECDSA256v01, ssh.CertAlgoED25519v01}, + }, + } + for _, tc := range testCases { + annotatedKeys := kh.HostKeys(tc.host) + if len(annotatedKeys) != len(tc.expectedKeyTypes) { + t.Errorf("Unexpected return from HostKeys(%q): %v", tc.host, annotatedKeys) + } else { + for n := range annotatedKeys { + if annotatedKeys[n].Type() != tc.expectedKeyTypes[n] || annotatedKeys[n].Cert != tc.expectedIsCert[n] { + t.Errorf("Unexpected return from HostKeys(%q) at index %d: %v", tc.host, n, annotatedKeys) + break + } + } + } + algos := kh.HostKeyAlgorithms(tc.host) + if len(algos) != len(tc.expectedAlgos) { + t.Errorf("Unexpected return from HostKeyAlgorithms(%q): %v", tc.host, algos) + } else { + for n := range algos { + if algos[n] != tc.expectedAlgos[n] { + t.Errorf("Unexpected return from HostKeyAlgorithms(%q) at index %d: %v", tc.host, n, algos) + break + } + } + } + } +} + +func TestIsHostKeyChanged(t *testing.T) { + khPath := getTestKnownHosts(t) + kh, err := New(khPath) + if err != nil { + t.Fatalf("Unexpected error from New: %v", err) + } + noAddr, _ := net.ResolveTCPAddr("tcp", "0.0.0.0:0") + pubKey := generatePubKeyEd25519(t) + + // Unknown host: should return false + if err := kh("unknown.example.test:22", noAddr, pubKey); IsHostKeyChanged(err) { + t.Error("IsHostKeyChanged unexpectedly returned true for unknown host") + } + + // Known host, wrong key: should return true + if err := kh("multi.example.test:2233", noAddr, pubKey); !IsHostKeyChanged(err) { + t.Error("IsHostKeyChanged unexpectedly returned false for known host with different host key") + } + + // Append the key for a known host that doesn't already have that key type, + // re-init the known_hosts, and check again: should return false + f, err := os.OpenFile(khPath, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatalf("Unable to open %s for writing: %v", khPath, err) + } + if err := WriteKnownHost(f, "only-ecdsa.example.test:22", noAddr, pubKey); err != nil { + t.Fatalf("Unable to write known host line: %v", err) + } + f.Close() + if kh, err = New(khPath); err != nil { + t.Fatalf("Unexpected error from New: %v", err) + } + if err := kh("only-ecdsa.example.test:22", noAddr, pubKey); IsHostKeyChanged(err) { + t.Error("IsHostKeyChanged unexpectedly returned true for valid known host") + } +} + +func TestIsHostUnknown(t *testing.T) { + khPath := getTestKnownHosts(t) + kh, err := New(khPath) + if err != nil { + t.Fatalf("Unexpected error from New: %v", err) + } + noAddr, _ := net.ResolveTCPAddr("tcp", "0.0.0.0:0") + pubKey := generatePubKeyEd25519(t) + + // Unknown host: should return true + if err := kh("unknown.example.test:22", noAddr, pubKey); !IsHostUnknown(err) { + t.Error("IsHostUnknown unexpectedly returned false for unknown host") + } + + // Known host, wrong key: should return false + if err := kh("multi.example.test:2233", noAddr, pubKey); IsHostUnknown(err) { + t.Error("IsHostUnknown unexpectedly returned true for known host with different host key") + } + + // Append the key for an unknown host, re-init the known_hosts, and check + // again: should return false + f, err := os.OpenFile(khPath, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatalf("Unable to open %s for writing: %v", khPath, err) + } + if err := WriteKnownHost(f, "newhost.example.test:22", noAddr, pubKey); err != nil { + t.Fatalf("Unable to write known host line: %v", err) + } + f.Close() + if kh, err = New(khPath); err != nil { + t.Fatalf("Unexpected error from New: %v", err) + } + if err := kh("newhost.example.test:22", noAddr, pubKey); IsHostUnknown(err) { + t.Error("IsHostUnknown unexpectedly returned true for valid known host") + } +} + +func TestNormalize(t *testing.T) { + for in, want := range map[string]string{ + "127.0.0.1": "127.0.0.1", + "127.0.0.1:22": "127.0.0.1", + "[127.0.0.1]:22": "127.0.0.1", + "[127.0.0.1]:23": "[127.0.0.1]:23", + "127.0.0.1:23": "[127.0.0.1]:23", + "[a.b.c]:22": "a.b.c", + "abcd::abcd:abcd:abcd": "abcd::abcd:abcd:abcd", + "[abcd::abcd:abcd:abcd]": "abcd::abcd:abcd:abcd", + "[abcd::abcd:abcd:abcd]:22": "abcd::abcd:abcd:abcd", + "[abcd::abcd:abcd:abcd]:23": "[abcd::abcd:abcd:abcd]:23", + } { + got := Normalize(in) + if got != want { + t.Errorf("Normalize(%q) = %q, want %q", in, got, want) + } + } +} + +func TestLine(t *testing.T) { + edKeyStr := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF9Wn63tLEhSWl9Ye+4x2GnruH8cq0LIh2vum/fUHrFQ" + edKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(edKeyStr)) + if err != nil { + t.Fatalf("Unable to parse authorized key: %v", err) + } + for in, want := range map[string]string{ + "server.org": "server.org " + edKeyStr, + "server.org:22": "server.org " + edKeyStr, + "server.org:23": "[server.org]:23 " + edKeyStr, + "[c629:1ec4:102:304:102:304:102:304]:22": "c629:1ec4:102:304:102:304:102:304 " + edKeyStr, + "[c629:1ec4:102:304:102:304:102:304]:23": "[c629:1ec4:102:304:102:304:102:304]:23 " + edKeyStr, + } { + if got := Line([]string{in}, edKey); got != want { + t.Errorf("Line(%q) = %q, want %q", in, got, want) + } + } +} + +func TestWriteKnownHost(t *testing.T) { + edKeyStr := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF9Wn63tLEhSWl9Ye+4x2GnruH8cq0LIh2vum/fUHrFQ" + edKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(edKeyStr)) + if err != nil { + t.Fatalf("Unable to parse authorized key: %v", err) + } + for _, m := range []struct { + hostname string + remoteAddr string + want string + err string + }{ + {hostname: "::1", remoteAddr: "[::1]:22", want: "::1 " + edKeyStr + "\n"}, + {hostname: "127.0.0.1", remoteAddr: "127.0.0.1:22", want: "127.0.0.1 " + edKeyStr + "\n"}, + {hostname: "ipv4.test", remoteAddr: "192.168.0.1:23", want: "ipv4.test,[192.168.0.1]:23 " + edKeyStr + "\n"}, + {hostname: "ipv6.test", remoteAddr: "[ff01::1234]:23", want: "ipv6.test,[ff01::1234]:23 " + edKeyStr + "\n"}, + {hostname: "normal.zone", remoteAddr: "[fe80::1%en0]:22", want: "normal.zone,fe80::1%en0 " + edKeyStr + "\n"}, + {hostname: "spaces.zone", remoteAddr: "[fe80::1%Ethernet 1]:22", want: "spaces.zone " + edKeyStr + "\n"}, + {hostname: "spaces.zone", remoteAddr: "[fe80::1%Ethernet\t2]:23", want: "spaces.zone " + edKeyStr + "\n"}, + {hostname: "[fe80::1%Ethernet 1]:22", err: "knownhosts: hostname 'fe80::1%Ethernet 1' contains spaces"}, + {hostname: "[fe80::1%Ethernet\t2]:23", err: "knownhosts: hostname '[fe80::1%Ethernet\t2]:23' contains spaces"}, + } { + remote, err := net.ResolveTCPAddr("tcp", m.remoteAddr) + if err != nil { + t.Fatalf("Unable to resolve tcp addr: %v", err) + } + var got bytes.Buffer + err = WriteKnownHost(&got, m.hostname, remote, edKey) + if m.err != "" { + if err == nil || err.Error() != m.err { + t.Errorf("WriteKnownHost(%q) expected error %v, found %v", m.hostname, m.err, err) + } + continue + } + if err != nil { + t.Fatalf("Unable to write known host: %v", err) + } + if got.String() != m.want { + t.Errorf("WriteKnownHost(%q) = %q, want %q", m.hostname, got.String(), m.want) + } + } +} + +func TestFakePublicKey(t *testing.T) { + fpk := fakePublicKey{} + if err := fpk.Verify(nil, nil); err == nil { + t.Error("Expected fakePublicKey.Verify() to always return an error, but it did not") + } + if certAlgo := keyTypeToCertAlgo(fpk.Type()); certAlgo != "" { + t.Errorf("Expected keyTypeToCertAlgo on a fakePublicKey to return an empty string, but instead found %q", certAlgo) + } +} + +var testKnownHostsContents []byte + +// getTestKnownHosts returns a path to a test known_hosts file. The file path +// will differ between test functions, but the contents are always the same, +// containing keys generated upon the first invocation. The file is removed +// upon test completion. +func getTestKnownHosts(t *testing.T) string { + // Re-use previously memoized result + if len(testKnownHostsContents) > 0 { + dir := t.TempDir() + khPath := filepath.Join(dir, "known_hosts") + if err := os.WriteFile(khPath, testKnownHostsContents, 0600); err != nil { + t.Fatalf("Unable to write to %s: %v", khPath, err) + } + return khPath + } + + khPath := writeTestKnownHosts(t) + if contents, err := os.ReadFile(khPath); err == nil { + testKnownHostsContents = contents + } + return khPath +} + +// writeTestKnownHosts generates the test known_hosts file and returns the +// file path to it. The generated file contains several hosts with a mix of +// key types; each known host has between 1 and 4 different known host keys. +// If generating or writing the file fails, the test fails. +func writeTestKnownHosts(t *testing.T) string { + t.Helper() + hosts := map[string][]ssh.PublicKey{ + "only-rsa.example.test:22": {generatePubKeyRSA(t)}, + "only-ecdsa.example.test:22": {generatePubKeyECDSA(t)}, + "only-ed25519.example.test:22": {generatePubKeyEd25519(t)}, + "multi.example.test:2233": {generatePubKeyRSA(t), generatePubKeyECDSA(t), generatePubKeyEd25519(t), generatePubKeyEd25519(t)}, + "192.168.1.102:2222": {generatePubKeyECDSA(t), generatePubKeyEd25519(t)}, + "[fe80::abc:abc:abcd:abcd]:22": {generatePubKeyEd25519(t), generatePubKeyRSA(t)}, + } + + dir := t.TempDir() + khPath := filepath.Join(dir, "known_hosts") + f, err := os.OpenFile(khPath, os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + t.Fatalf("Unable to open %s for writing: %v", khPath, err) + } + defer f.Close() + noAddr, _ := net.ResolveTCPAddr("tcp", "0.0.0.0:0") + for host, keys := range hosts { + for _, k := range keys { + if err := WriteKnownHost(f, host, noAddr, k); err != nil { + t.Fatalf("Unable to write known host line: %v", err) + } + } + } + return khPath +} + +var testCertKeys = make(map[string]ssh.PublicKey) // key string format is "hostpattern keytype" + +// appendCertTestKnownHosts adds a @cert-authority line to the file at the +// supplied path, creating it if it does not exist yet. The keyType must be one +// of ssh.KeyAlgoRSA, ssh.KeyAlgoECDSA256, or ssh.KeyAlgoED25519; while all +// valid algos are supported by this package, the test logic hasn't been +// written for other algos here yet. Generated keys are memoized to avoid +// slow test performance. +func appendCertTestKnownHosts(t *testing.T, filePath, hostPattern, keyType string) { + t.Helper() + + var pubKey ssh.PublicKey + var ok bool + cacheKey := hostPattern + " " + keyType + if pubKey, ok = testCertKeys[cacheKey]; !ok { + switch keyType { + case ssh.KeyAlgoRSA: + pubKey = generatePubKeyRSA(t) + case ssh.KeyAlgoECDSA256: + pubKey = generatePubKeyECDSA(t) + case ssh.KeyAlgoED25519: + pubKey = generatePubKeyEd25519(t) + default: + t.Fatalf("test logic does not support generating key of type %s yet", keyType) + } + testCertKeys[cacheKey] = pubKey + } + + f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) + if err != nil { + t.Fatalf("Unable to open %s for writing: %v", filePath, err) + } + defer f.Close() + if err := WriteKnownHostCA(f, hostPattern, pubKey); err != nil { + t.Fatalf("Unable to append @cert-authority line to %s: %v", filePath, err) + } +} + +func generatePubKeyRSA(t *testing.T) ssh.PublicKey { + t.Helper() + privKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + t.Fatalf("Unable to generate RSA key: %v", err) + } + pub, err := ssh.NewPublicKey(&privKey.PublicKey) + if err != nil { + t.Fatalf("Unable to convert public key: %v", err) + } + return pub +} + +func generatePubKeyECDSA(t *testing.T) ssh.PublicKey { + t.Helper() + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("Unable to generate ECDSA key: %v", err) + } + pub, err := ssh.NewPublicKey(privKey.Public()) + if err != nil { + t.Fatalf("Unable to convert public key: %v", err) + } + return pub +} + +func generatePubKeyEd25519(t *testing.T) ssh.PublicKey { + t.Helper() + rawPub, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("Unable to generate ed25519 key: %v", err) + } + pub, err := ssh.NewPublicKey(rawPub) + if err != nil { + t.Fatalf("Unable to convert public key: %v", err) + } + return pub +} diff --git a/plumbing/transport/ssh/sshagent/pageant_windows.go b/plumbing/transport/ssh/sshagent/pageant_windows.go new file mode 100644 index 000000000..a05a2d116 --- /dev/null +++ b/plumbing/transport/ssh/sshagent/pageant_windows.go @@ -0,0 +1,152 @@ +// +// Copyright (c) 2014 David Mzareulyan +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software +// and associated documentation files (the "Software"), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, publish, distribute, +// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +// is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial +// portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +// Originally from: https://github.com/xanzy/ssh-agent/blob/main/pageant_windows.go +// MIT LICENSE: https://github.com/davidmz/go-pageant/blob/master/LICENSE.txt + +//go:build windows +// +build windows + +package sshagent + +// see https://github.com/Yasushi/putty/blob/master/windows/winpgntc.c#L155 +// see https://github.com/paramiko/paramiko/blob/master/paramiko/win_pageant.py + +import ( + "encoding/binary" + "errors" + "fmt" + "sync" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Maximum size of message can be sent to pageant +const MaxMessageLen = 8192 + +var ( + ErrPageantNotFound = errors.New("pageant process not found") + ErrSendMessage = errors.New("error sending message") + + ErrMessageTooLong = errors.New("message too long") + ErrInvalidMessageFormat = errors.New("invalid message format") + ErrResponseTooLong = errors.New("response too long") +) + +const ( + agentCopydataID = 0x804e50ba + wmCopydata = 74 +) + +type copyData struct { + dwData uintptr + cbData uint32 + lpData unsafe.Pointer +} + +var ( + lock sync.Mutex + + user32dll = windows.NewLazySystemDLL("user32.dll") + winFindWindow = winAPI(user32dll, "FindWindowW") + winSendMessage = winAPI(user32dll, "SendMessageW") + + kernel32dll = windows.NewLazySystemDLL("kernel32.dll") + winGetCurrentThreadID = winAPI(kernel32dll, "GetCurrentThreadId") +) + +func winAPI(dll *windows.LazyDLL, funcName string) func(...uintptr) (uintptr, uintptr, error) { + proc := dll.NewProc(funcName) + return func(a ...uintptr) (uintptr, uintptr, error) { return proc.Call(a...) } +} + +// Query sends message msg to Pageant and returns response or error. +// 'msg' is raw agent request with length prefix +// Response is raw agent response with length prefix +func query(msg []byte) ([]byte, error) { + if len(msg) > MaxMessageLen { + return nil, ErrMessageTooLong + } + + msgLen := binary.BigEndian.Uint32(msg[:4]) + if len(msg) != int(msgLen)+4 { + return nil, ErrInvalidMessageFormat + } + + lock.Lock() + defer lock.Unlock() + + paWin := pageantWindow() + + if paWin == 0 { + return nil, ErrPageantNotFound + } + + thID, _, _ := winGetCurrentThreadID() + mapName := fmt.Sprintf("PageantRequest%08x", thID) + pMapName, _ := syscall.UTF16PtrFromString(mapName) + + mmap, err := syscall.CreateFileMapping(syscall.InvalidHandle, nil, syscall.PAGE_READWRITE, 0, MaxMessageLen+4, pMapName) + if err != nil { + return nil, err + } + defer syscall.CloseHandle(mmap) + + ptr, err := syscall.MapViewOfFile(mmap, syscall.FILE_MAP_WRITE, 0, 0, 0) + if err != nil { + return nil, err + } + defer syscall.UnmapViewOfFile(ptr) + + mmSlice := (*(*[MaxMessageLen]byte)(unsafe.Pointer(ptr)))[:] + + copy(mmSlice, msg) + + mapNameBytesZ := append([]byte(mapName), 0) + + cds := copyData{ + dwData: agentCopydataID, + cbData: uint32(len(mapNameBytesZ)), + lpData: unsafe.Pointer(&(mapNameBytesZ[0])), + } + + resp, _, _ := winSendMessage(paWin, wmCopydata, 0, uintptr(unsafe.Pointer(&cds))) + + if resp == 0 { + return nil, ErrSendMessage + } + + respLen := binary.BigEndian.Uint32(mmSlice[:4]) + if respLen > MaxMessageLen-4 { + return nil, ErrResponseTooLong + } + + respData := make([]byte, respLen+4) + copy(respData, mmSlice) + + return respData, nil +} + +func pageantWindow() uintptr { + nameP, _ := syscall.UTF16PtrFromString("Pageant") + h, _, _ := winFindWindow(uintptr(unsafe.Pointer(nameP)), uintptr(unsafe.Pointer(nameP))) + return h +} diff --git a/plumbing/transport/ssh/sshagent/sshagent.go b/plumbing/transport/ssh/sshagent/sshagent.go new file mode 100644 index 000000000..6741d831d --- /dev/null +++ b/plumbing/transport/ssh/sshagent/sshagent.go @@ -0,0 +1,54 @@ +// +// Copyright 2015, Sander van Harmelen +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// Originally from: https://github.com/xanzy/ssh-agent/blob/main/sshagent.go + +//go:build !windows +// +build !windows + +package sshagent + +import ( + "errors" + "fmt" + "net" + "os" + + "github.com/go-git/go-git/v5/utils/trace" + "golang.org/x/crypto/ssh/agent" +) + +// New returns a new agent.Agent that uses a unix socket +func New() (agent.Agent, net.Conn, error) { + if !Available() { + return nil, nil, errors.New("SSH agent requested but SSH_AUTH_SOCK not-specified") + } + + sshAuthSock := os.Getenv("SSH_AUTH_SOCK") + + trace.SSH.Printf("ssh: net.Dial unix sock %s", sshAuthSock) + conn, err := net.Dial("unix", sshAuthSock) + if err != nil { + return nil, nil, fmt.Errorf("error connecting to SSH_AUTH_SOCK: %v", err) + } + + return agent.NewClient(conn), conn, nil +} + +// Available returns true is a auth socket is defined +func Available() bool { + return os.Getenv("SSH_AUTH_SOCK") != "" +} diff --git a/plumbing/transport/ssh/sshagent/sshagent_windows.go b/plumbing/transport/ssh/sshagent/sshagent_windows.go new file mode 100644 index 000000000..ac801e4a8 --- /dev/null +++ b/plumbing/transport/ssh/sshagent/sshagent_windows.go @@ -0,0 +1,110 @@ +// +// Copyright (c) 2014 David Mzareulyan +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software +// and associated documentation files (the "Software"), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, publish, distribute, +// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +// is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial +// portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +// Originally from: https://github.com/xanzy/ssh-agent/blob/main/sshagent_windows.go +// MIT LICENSE: https://github.com/davidmz/go-pageant/blob/master/LICENSE.txt + +//go:build windows +// +build windows + +package sshagent + +import ( + "errors" + "io" + "net" + "sync" + + "github.com/Microsoft/go-winio" + "github.com/go-git/go-git/v5/utils/trace" + "golang.org/x/crypto/ssh/agent" +) + +const ( + sshAgentPipe = `\\.\pipe\openssh-ssh-agent` +) + +// Available returns true if Pageant is running +func Available() bool { + if pageantWindow() != 0 { + return true + } + + conn, err := winio.DialPipe(sshAgentPipe, nil) + if err != nil { + return false + } + conn.Close() + return true +} + +// New returns a new agent.Agent and the (custom) connection it uses +// to communicate with a running pagent.exe instance (see README.md) +func New() (agent.Agent, net.Conn, error) { + if pageantWindow() != 0 { + return agent.NewClient(&conn{}), nil, nil + } + trace.SSH.Printf("ssh: winio.DialPipe %s", sshAgentPipe) + conn, err := winio.DialPipe(sshAgentPipe, nil) + if err != nil { + return nil, nil, errors.New( + "SSH agent requested, but could not detect Pageant or Windows native SSH agent", + ) + } + return agent.NewClient(conn), nil, nil +} + +type conn struct { + sync.Mutex + buf []byte +} + +func (c *conn) Close() { + c.Lock() + defer c.Unlock() + c.buf = nil +} + +func (c *conn) Write(p []byte) (int, error) { + c.Lock() + defer c.Unlock() + + resp, err := query(p) + if err != nil { + return 0, err + } + + c.buf = append(c.buf, resp...) + + return len(p), nil +} + +func (c *conn) Read(p []byte) (int, error) { + c.Lock() + defer c.Unlock() + + if len(c.buf) == 0 { + return 0, io.EOF + } + + n := copy(p, c.buf) + c.buf = c.buf[n:] + + return n, nil +} diff --git a/utils/trace/trace.go b/utils/trace/trace.go index 3e15c5b9f..0d5fa806e 100644 --- a/utils/trace/trace.go +++ b/utils/trace/trace.go @@ -4,15 +4,34 @@ import ( "fmt" "log" "os" + "strings" "sync/atomic" ) +func init() { + var target Target + for k, v := range envToTarget { + if strings.EqualFold(os.Getenv(k), "true") { + target |= v + } + } + SetTarget(target) +} + var ( // logger is the logger to use for tracing. logger = newLogger() // current is the targets that are enabled for tracing. current atomic.Int32 + + // envToTarget maps what environment variables can be used + // to enable specific trace targets. + envToTarget = map[string]Target{ + "GIT_TRACE": General, + "GIT_TRACE_PACKET": Packet, + "GIT_TRACE_SSH": SSH, + } ) func newLogger() *log.Logger { @@ -28,6 +47,10 @@ const ( // Packet traces git packets. Packet + + // SSH traces SSH handshake operations. This does not have + // a direct translation to an upstream trace option. + SSH ) // SetTarget sets the tracing targets.