From db231107a2c2f4fbe89b543845f51e1a02593709 Mon Sep 17 00:00:00 2001 From: Percy Wegmann Date: Mon, 10 Feb 2025 11:43:08 -0600 Subject: [PATCH] ssh/tailssh: accept passwords and public keys Some clients don't request 'none' authentication. Instead, they immediately supply a password or public key. This change allows them to do so, but ignores the supplied credentials and authenticates using Tailscale instead. Updates #14922 Signed-off-by: Percy Wegmann --- ssh/tailssh/tailssh.go | 98 +++++++--- ssh/tailssh/tailssh_integration_test.go | 43 ++++- ssh/tailssh/tailssh_test.go | 226 ++++++++++++++++-------- ssh/tailssh/testcontainers/Dockerfile | 11 +- tempfork/sshtest/ssh/client.go | 8 + tempfork/sshtest/ssh/client_auth.go | 11 +- 6 files changed, 289 insertions(+), 108 deletions(-) diff --git a/ssh/tailssh/tailssh.go b/ssh/tailssh/tailssh.go index 638ff99b8..9aae899c3 100644 --- a/ssh/tailssh/tailssh.go +++ b/ssh/tailssh/tailssh.go @@ -51,6 +51,11 @@ sshDisableSFTP = envknob.RegisterBool("TS_SSH_DISABLE_SFTP") sshDisableForwarding = envknob.RegisterBool("TS_SSH_DISABLE_FORWARDING") sshDisablePTY = envknob.RegisterBool("TS_SSH_DISABLE_PTY") + + // errTerminal is an empty gossh.PartialSuccessError (with no 'Next' + // authentication methods that may proceed), which results in the SSH + // server immediately disconnecting the client. + errTerminal = &gossh.PartialSuccessError{} ) const ( @@ -230,8 +235,8 @@ type conn struct { finalAction *tailcfg.SSHAction // set by clientAuth info *sshConnInfo // set by setInfo - localUser *userMeta // set by doPolicyAuth - userGroupIDs []string // set by doPolicyAuth + localUser *userMeta // set by clientAuth + userGroupIDs []string // set by clientAuth acceptEnv []string // mu protects the following fields. @@ -255,46 +260,73 @@ func (c *conn) vlogf(format string, args ...any) { } // errDenied is returned by auth callbacks when a connection is denied by the -// policy. It returns a gossh.BannerError to make sure the message gets -// displayed as an auth banner. -func errDenied(message string) error { +// policy. It writes the message to an auth banner and then returns an empty +// gossh.PartialSuccessError in order to stop processing authentication +// attempts and immediately disconnect the client. +func (c *conn) errDenied(message string) error { if message == "" { message = "tailscale: access denied" } - return &gossh.BannerError{ - Message: message, + if err := c.spac.SendAuthBanner(message); err != nil { + c.logf("failed to send auth banner: %s", err) } + return errTerminal } -// bannerError creates a gossh.BannerError that will result in the given -// message being displayed to the client. If err != nil, this also logs -// message:error. The contents of err is not leaked to clients in the banner. -func (c *conn) bannerError(message string, err error) error { +// errBanner writes the given message to an auth banner and then returns an +// empty gossh.PartialSuccessError in order to stop processing authentication +// attempts and immediately disconnect the client. The contents of err is not +// leaked in the auth banner, but it is logged to the server's log. +func (c *conn) errBanner(message string, err error) error { if err != nil { c.logf("%s: %s", message, err) } - return &gossh.BannerError{ - Err: err, - Message: fmt.Sprintf("tailscale: %s", message), + if err := c.spac.SendAuthBanner("tailscale: " + message); err != nil { + c.logf("failed to send auth banner: %s", err) } + return errTerminal +} + +// errUnexpected is returned by auth callbacks that encounter an unexpected +// error, such as being unable to send an auth banner. It sends an empty +// gossh.PartialSuccessError to tell gossh.Server to stop processing +// authentication attempts and instead disconnect immediately. +func (c *conn) errUnexpected(err error) error { + c.logf("terminal error: %s", err) + return errTerminal } // clientAuth is responsible for performing client authentication. // // If policy evaluation fails, it returns an error. -// If access is denied, it returns an error. -func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) { +// If access is denied, it returns an error. This must always be an empty +// gossh.PartialSuccessError to prevent further authentication methods from +// being tried. +func (c *conn) clientAuth(cm gossh.ConnMetadata) (perms *gossh.Permissions, retErr error) { + defer func() { + if pse, ok := retErr.(*gossh.PartialSuccessError); ok { + if pse.Next.GSSAPIWithMICConfig != nil || + pse.Next.KeyboardInteractiveCallback != nil || + pse.Next.PasswordCallback != nil || + pse.Next.PublicKeyCallback != nil { + panic("clientAuth attempted to return a non-empty PartialSuccessError") + } + } else if retErr != nil { + panic(fmt.Sprintf("clientAuth attempted to return a non-PartialSuccessError error of type: %t", retErr)) + } + }() + if c.insecureSkipTailscaleAuth { return &gossh.Permissions{}, nil } if err := c.setInfo(cm); err != nil { - return nil, c.bannerError("failed to get connection info", err) + return nil, c.errBanner("failed to get connection info", err) } action, localUser, acceptEnv, err := c.evaluatePolicy() if err != nil { - return nil, c.bannerError("failed to evaluate SSH policy", err) + return nil, c.errBanner("failed to evaluate SSH policy", err) } c.action0 = action @@ -304,11 +336,11 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) { // hold and delegate URL (if necessary). lu, err := userLookup(localUser) if err != nil { - return nil, c.bannerError(fmt.Sprintf("failed to look up local user %q ", localUser), err) + return nil, c.errBanner(fmt.Sprintf("failed to look up local user %q ", localUser), err) } gids, err := lu.GroupIds() if err != nil { - return nil, c.bannerError("failed to look up local user's group IDs", err) + return nil, c.errBanner("failed to look up local user's group IDs", err) } c.userGroupIDs = gids c.localUser = lu @@ -321,7 +353,7 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) { metricTerminalAccept.Add(1) if action.Message != "" { if err := c.spac.SendAuthBanner(action.Message); err != nil { - return nil, fmt.Errorf("error sending auth welcome message: %w", err) + return nil, c.errUnexpected(fmt.Errorf("error sending auth welcome message: %w", err)) } } c.finalAction = action @@ -329,11 +361,11 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) { case action.Reject: metricTerminalReject.Add(1) c.finalAction = action - return nil, errDenied(action.Message) + return nil, c.errDenied(action.Message) case action.HoldAndDelegate != "": if action.Message != "" { if err := c.spac.SendAuthBanner(action.Message); err != nil { - return nil, fmt.Errorf("error sending hold and delegate message: %w", err) + return nil, c.errUnexpected(fmt.Errorf("error sending hold and delegate message: %w", err)) } } @@ -349,11 +381,11 @@ func (c *conn) clientAuth(cm gossh.ConnMetadata) (*gossh.Permissions, error) { action, err = c.fetchSSHAction(ctx, url) if err != nil { metricTerminalFetchError.Add(1) - return nil, c.bannerError("failed to fetch next SSH action", fmt.Errorf("fetch failed from %s: %w", url, err)) + return nil, c.errBanner("failed to fetch next SSH action", fmt.Errorf("fetch failed from %s: %w", url, err)) } default: metricTerminalMalformed.Add(1) - return nil, c.bannerError("reached Action that had neither Accept, Reject, nor HoldAndDelegate", nil) + return nil, c.errBanner("reached Action that had neither Accept, Reject, nor HoldAndDelegate", nil) } } } @@ -390,6 +422,20 @@ func (c *conn) ServerConfig(ctx ssh.Context) *gossh.ServerConfig { return perms, nil }, + PasswordCallback: func(cm gossh.ConnMetadata, pword []byte) (*gossh.Permissions, error) { + // Some clients don't request 'none' authentication. Instead, they + // immediately supply a password. We humor them by accepting the + // password, but authenticate as usual, ignoring the actual value of + // the password. + return c.clientAuth(cm) + }, + PublicKeyCallback: func(cm gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) { + // Some clients don't request 'none' authentication. Instead, they + // immediately supply a public key. We humor them by accepting the + // key, but authenticate as usual, ignoring the actual content of + // the key. + return c.clientAuth(cm) + }, } } @@ -400,7 +446,7 @@ func (srv *server) newConn() (*conn, error) { // Stop accepting new connections. // Connections in the auth phase are handled in handleConnPostSSHAuth. // Existing sessions are terminated by Shutdown. - return nil, errDenied("tailscale: server is shutting down") + return nil, errors.New("server is shutting down") } srv.mu.Unlock() c := &conn{srv: srv} diff --git a/ssh/tailssh/tailssh_integration_test.go b/ssh/tailssh/tailssh_integration_test.go index 5c4f533b1..9ab26e169 100644 --- a/ssh/tailssh/tailssh_integration_test.go +++ b/ssh/tailssh/tailssh_integration_test.go @@ -2,7 +2,6 @@ // SPDX-License-Identifier: BSD-3-Clause //go:build integrationtest -// +build integrationtest package tailssh @@ -410,6 +409,48 @@ func TestSSHAgentForwarding(t *testing.T) { } } +// TestIntegrationParamiko attempts to connect to Tailscale SSH using the +// paramiko Python library. This library does not request 'none' auth. This +// test ensures that Tailscale SSH can correctly handle clients that don't +// request 'none' auth and instead immediately authenticate with a public key +// or password. +func TestIntegrationParamiko(t *testing.T) { + debugTest.Store(true) + t.Cleanup(func() { + debugTest.Store(false) + }) + + addr := testServer(t, "testuser", true, false) + host, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("Failed to split addr %q: %s", addr, err) + } + + out, err := exec.Command("python3", "-c", fmt.Sprintf(` +import paramiko.client as pm +from paramiko.ecdsakey import ECDSAKey +client = pm.SSHClient() +client.set_missing_host_key_policy(pm.AutoAddPolicy) +client.connect('%s', port=%s, username='testuser', pkey=ECDSAKey.generate(), allow_agent=False, look_for_keys=False) +client.exec_command('pwd') +`, host, port)).CombinedOutput() + if err != nil { + t.Fatalf("failed to connect with Paramiko using public key auth: %s\n%q", err, string(out)) + } + + out, err = exec.Command("python3", "-c", fmt.Sprintf(` +import paramiko.client as pm +from paramiko.ecdsakey import ECDSAKey +client = pm.SSHClient() +client.set_missing_host_key_policy(pm.AutoAddPolicy) +client.connect('%s', port=%s, username='testuser', password='doesntmatter', allow_agent=False, look_for_keys=False) +client.exec_command('pwd') +`, host, port)).CombinedOutput() + if err != nil { + t.Fatalf("failed to connect with Paramiko using password auth: %s\n%q", err, string(out)) + } +} + func fallbackToSUAvailable() bool { if runtime.GOOS != "linux" { return false diff --git a/ssh/tailssh/tailssh_test.go b/ssh/tailssh/tailssh_test.go index d22dfe443..24f0e12a2 100644 --- a/ssh/tailssh/tailssh_test.go +++ b/ssh/tailssh/tailssh_test.go @@ -8,12 +8,15 @@ import ( "bytes" "context" + "crypto/ecdsa" "crypto/ed25519" + "crypto/elliptic" "crypto/rand" "encoding/json" "errors" "fmt" "io" + "log" "net" "net/http" "net/http/httptest" @@ -41,7 +44,7 @@ "tailscale.com/sessionrecording" "tailscale.com/tailcfg" "tailscale.com/tempfork/gliderlabs/ssh" - sshtest "tailscale.com/tempfork/sshtest/ssh" + testssh "tailscale.com/tempfork/sshtest/ssh" "tailscale.com/tsd" "tailscale.com/tstest" "tailscale.com/types/key" @@ -56,8 +59,6 @@ "tailscale.com/wgengine" ) -type _ = sshtest.Client // TODO(bradfitz,percy): sshtest; delete this line - func TestMatchRule(t *testing.T) { someAction := new(tailcfg.SSHAction) tests := []struct { @@ -510,9 +511,9 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) { defer s.Shutdown() const sshUser = "alice" - cfg := &gossh.ClientConfig{ + cfg := &testssh.ClientConfig{ User: sshUser, - HostKeyCallback: gossh.InsecureIgnoreHostKey(), + HostKeyCallback: testssh.InsecureIgnoreHostKey(), } tests := []struct { @@ -559,12 +560,12 @@ func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg) + c, chans, reqs, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), cfg) if err != nil { t.Errorf("client: %v", err) return } - client := gossh.NewClient(c, chans, reqs) + client := testssh.NewClient(c, chans, reqs) defer client.Close() session, err := client.NewSession() if err != nil { @@ -645,21 +646,21 @@ func TestMultipleRecorders(t *testing.T) { sc, dc := memnet.NewTCPConn(src, dst, 1024) const sshUser = "alice" - cfg := &gossh.ClientConfig{ + cfg := &testssh.ClientConfig{ User: sshUser, - HostKeyCallback: gossh.InsecureIgnoreHostKey(), + HostKeyCallback: testssh.InsecureIgnoreHostKey(), } var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() - c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg) + c, chans, reqs, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), cfg) if err != nil { t.Errorf("client: %v", err) return } - client := gossh.NewClient(c, chans, reqs) + client := testssh.NewClient(c, chans, reqs) defer client.Close() session, err := client.NewSession() if err != nil { @@ -736,21 +737,21 @@ func TestSSHRecordingNonInteractive(t *testing.T) { sc, dc := memnet.NewTCPConn(src, dst, 1024) const sshUser = "alice" - cfg := &gossh.ClientConfig{ + cfg := &testssh.ClientConfig{ User: sshUser, - HostKeyCallback: gossh.InsecureIgnoreHostKey(), + HostKeyCallback: testssh.InsecureIgnoreHostKey(), } var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() - c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg) + c, chans, reqs, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), cfg) if err != nil { t.Errorf("client: %v", err) return } - client := gossh.NewClient(c, chans, reqs) + client := testssh.NewClient(c, chans, reqs) defer client.Close() session, err := client.NewSession() if err != nil { @@ -886,80 +887,151 @@ func TestSSHAuthFlow(t *testing.T) { }, } s := &server{ - logf: logger.Discard, + logf: log.Printf, } defer s.Shutdown() src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22")) for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - sc, dc := memnet.NewTCPConn(src, dst, 1024) - s.lb = tc.state - sshUser := "alice" - if tc.sshUser != "" { - sshUser = tc.sshUser - } - var passwordUsed atomic.Bool - cfg := &gossh.ClientConfig{ - User: sshUser, - HostKeyCallback: gossh.InsecureIgnoreHostKey(), - Auth: []gossh.AuthMethod{ - gossh.PasswordCallback(func() (secret string, err error) { - if !tc.usesPassword { - t.Error("unexpected use of PasswordCallback") - return "", errors.New("unexpected use of PasswordCallback") - } + for _, authMethods := range [][]string{nil, {"publickey", "password"}, {"password", "publickey"}} { + t.Run(fmt.Sprintf("%s-skip-none-auth-%v", tc.name, strings.Join(authMethods, "-then-")), func(t *testing.T) { + sc, dc := memnet.NewTCPConn(src, dst, 1024) + s.lb = tc.state + sshUser := "alice" + if tc.sshUser != "" { + sshUser = tc.sshUser + } + + wantBanners := slices.Clone(tc.wantBanners) + noneAuthEnabled := len(authMethods) == 0 + + var publicKeyUsed atomic.Bool + var passwordUsed atomic.Bool + var methods []testssh.AuthMethod + + for _, authMethod := range authMethods { + switch authMethod { + case "publickey": + methods = append(methods, + testssh.PublicKeysCallback(func() (signers []testssh.Signer, err error) { + publicKeyUsed.Store(true) + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + return nil, err + } + sig, err := testssh.NewSignerFromKey(key) + if err != nil { + return nil, err + } + return []testssh.Signer{sig}, nil + })) + case "password": + methods = append(methods, testssh.PasswordCallback(func() (secret string, err error) { + passwordUsed.Store(true) + return "any-pass", nil + })) + } + } + + if noneAuthEnabled && tc.usesPassword { + methods = append(methods, testssh.PasswordCallback(func() (secret string, err error) { passwordUsed.Store(true) return "any-pass", nil - }), - }, - BannerCallback: func(message string) error { - if len(tc.wantBanners) == 0 { - t.Errorf("unexpected banner: %q", message) - } else if message != tc.wantBanners[0] { - t.Errorf("banner = %q; want %q", message, tc.wantBanners[0]) - } else { - t.Logf("banner = %q", message) - tc.wantBanners = tc.wantBanners[1:] + })) + } + + cfg := &testssh.ClientConfig{ + User: sshUser, + HostKeyCallback: testssh.InsecureIgnoreHostKey(), + SkipNoneAuth: !noneAuthEnabled, + Auth: methods, + BannerCallback: func(message string) error { + if len(wantBanners) == 0 { + t.Errorf("unexpected banner: %q", message) + } else if message != wantBanners[0] { + t.Errorf("banner = %q; want %q", message, wantBanners[0]) + } else { + t.Logf("banner = %q", message) + wantBanners = wantBanners[1:] + } + return nil + }, + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + c, chans, reqs, err := testssh.NewClientConn(sc, sc.RemoteAddr().String(), cfg) + if err != nil { + if !tc.authErr { + t.Errorf("client: %v", err) + } + return + } else if tc.authErr { + c.Close() + t.Errorf("client: expected error, got nil") + return } - return nil - }, - } - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg) - if err != nil { - if !tc.authErr { + client := testssh.NewClient(c, chans, reqs) + defer client.Close() + session, err := client.NewSession() + if err != nil { + t.Errorf("client: %v", err) + return + } + defer session.Close() + _, err = session.CombinedOutput("echo Ran echo!") + if err != nil { t.Errorf("client: %v", err) } - return - } else if tc.authErr { - c.Close() - t.Errorf("client: expected error, got nil") - return + }() + if err := s.HandleSSHConn(dc); err != nil { + t.Errorf("unexpected error: %v", err) } - client := gossh.NewClient(c, chans, reqs) - defer client.Close() - session, err := client.NewSession() - if err != nil { - t.Errorf("client: %v", err) - return + wg.Wait() + if len(wantBanners) > 0 { + t.Errorf("missing banners: %v", wantBanners) } - defer session.Close() - _, err = session.CombinedOutput("echo Ran echo!") - if err != nil { - t.Errorf("client: %v", err) + + // Check to see which callbacks were invoked. + // + // When `none` auth is enabled, the public key callback should + // never fire, and the password callback should only fire if + // authentication succeeded and the client was trying to force + // password authentication by connecting with the '-password' + // username suffix. + // + // When skipping `none` auth, the first callback should always + // fire, and the 2nd callback should fire only if + // authentication failed. + wantPublicKey := false + wantPassword := false + if noneAuthEnabled { + wantPassword = !tc.authErr && tc.usesPassword + } else { + for i, authMethod := range authMethods { + switch authMethod { + case "publickey": + wantPublicKey = i == 0 || tc.authErr + case "password": + wantPassword = i == 0 || tc.authErr + } + } } - }() - if err := s.HandleSSHConn(dc); err != nil { - t.Errorf("unexpected error: %v", err) - } - wg.Wait() - if len(tc.wantBanners) > 0 { - t.Errorf("missing banners: %v", tc.wantBanners) - } - }) + + if wantPublicKey && !publicKeyUsed.Load() { + t.Error("public key should have been attempted") + } else if !wantPublicKey && publicKeyUsed.Load() { + t.Errorf("public key should not have been attempted") + } + + if wantPassword && !passwordUsed.Load() { + t.Error("password should have been attempted") + } else if !wantPassword && passwordUsed.Load() { + t.Error("password should not have been attempted") + } + }) + } } } diff --git a/ssh/tailssh/testcontainers/Dockerfile b/ssh/tailssh/testcontainers/Dockerfile index c94c961d3..4ef1c1eb0 100644 --- a/ssh/tailssh/testcontainers/Dockerfile +++ b/ssh/tailssh/testcontainers/Dockerfile @@ -3,9 +3,12 @@ FROM ${BASE} ARG BASE -RUN echo "Install openssh, needed for scp." -RUN if echo "$BASE" | grep "ubuntu:"; then apt-get update -y && apt-get install -y openssh-client; fi -RUN if echo "$BASE" | grep "alpine:"; then apk add openssh; fi +RUN echo "Install openssh, needed for scp. Also install python3" +RUN if echo "$BASE" | grep "ubuntu:"; then apt-get update -y && apt-get install -y openssh-client python3 python3-pip; fi +RUN if echo "$BASE" | grep "alpine:"; then apk add openssh python3 py3-pip; fi + +RUN echo "Install paramiko" +RUN pip3 install paramiko==3.5.1 || pip3 install --break-system-packages paramiko==3.5.1 # Note - on Ubuntu, we do not create the user's home directory, pam_mkhomedir will do that # for us, and we want to test that PAM gets triggered by Tailscale SSH. @@ -33,6 +36,8 @@ RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSCP RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationSSH +RUN if echo "$BASE" | grep "ubuntu:"; then rm -Rf /home/testuser; fi +RUN TAILSCALED_PATH=`pwd`tailscaled ./tailssh.test -test.v -test.run TestIntegrationParamiko RUN echo "Then run tests as non-root user testuser and make sure tests still pass." RUN touch /tmp/tailscalessh.log diff --git a/tempfork/sshtest/ssh/client.go b/tempfork/sshtest/ssh/client.go index fd8c49749..5876e6421 100644 --- a/tempfork/sshtest/ssh/client.go +++ b/tempfork/sshtest/ssh/client.go @@ -239,6 +239,14 @@ type ClientConfig struct { // // A Timeout of zero means no timeout. Timeout time.Duration + + // SkipNoneAuth allows skipping the initial "none" auth request. This is unusual + // behavior, but it is allowed by [RFC4252 5.2](https://datatracker.ietf.org/doc/html/rfc4252#section-5.2), + // and some clients in the wild behave like this. One such client is the paramiko Python + // library, which is used in pgadmin4 via the sshtunnel library. + // When SkipNoneAuth is true, the client will attempt all configured + // [AuthMethod]s until one works, or it runs out. + SkipNoneAuth bool } // InsecureIgnoreHostKey returns a function that can be used for diff --git a/tempfork/sshtest/ssh/client_auth.go b/tempfork/sshtest/ssh/client_auth.go index b86dde151..af25a4f01 100644 --- a/tempfork/sshtest/ssh/client_auth.go +++ b/tempfork/sshtest/ssh/client_auth.go @@ -68,7 +68,16 @@ func (c *connection) clientAuthenticate(config *ClientConfig) error { var lastMethods []string sessionID := c.transport.getSessionID() - for auth := AuthMethod(new(noneAuth)); auth != nil; { + var auth AuthMethod + if !config.SkipNoneAuth { + auth = AuthMethod(new(noneAuth)) + } else if len(config.Auth) > 0 { + auth = config.Auth[0] + for _, a := range config.Auth { + lastMethods = append(lastMethods, a.method()) + } + } + for auth != nil { ok, methods, err := auth.auth(sessionID, config.User, c.transport, config.Rand, extensions) if err != nil { // On disconnect, return error immediately