x/crypto/ssh: wrapping ssh.KeyboardInteractive() into ssh.RetryableAuthMethod() fails to handle early auth errors with Windows OpenSSH server · Issue #67855 · golang/go (original) (raw)

Go version

go version go1.22.3 linux/amd64

Output of go env in your module/workspace:

GO111MODULE='' GOARCH='amd64' GOBIN='' GOCACHE='/home/xxx/.cache/go-build' GOENV='/home/xxx/.config/go/env' GOEXE='' GOEXPERIMENT='' GOFLAGS='' GOHOSTARCH='amd64' GOHOSTOS='linux' GOINSECURE='' GOMODCACHE='/home/xxx/go/pkg/mod' GONOPROXY='' GONOSUMDB='' GOOS='linux' GOPATH='/home/xxx/go' GOPRIVATE='' GOPROXY='https://proxy.golang.org,direct' GOROOT='/usr/local/lib/go' GOSUMDB='sum.golang.org' GOTMPDIR='' GOTOOLCHAIN='auto' GOTOOLDIR='/usr/local/lib/go/pkg/tool/linux_amd64' GOVCS='' GOVERSION='go1.22.3' GCCGO='gccgo' GOAMD64='v1' AR='ar' CC='gcc' CXX='g++' CGO_ENABLED='1' GOMOD='/home/xxx/go/src/github.com/samiponkanen/crypto/go.mod' GOWORK='' CGO_CFLAGS='-O2 -g' CGO_CPPFLAGS='' CGO_CXXFLAGS='-O2 -g' CGO_FFLAGS='-O2 -g' CGO_LDFLAGS='-O2 -g' PKG_CONFIG='pkg-config' GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build2434574384=/tmp/go-build -gno-record-gcc-switches'

What did you do?

It seems that Windows OpenSSH server behaves incorrectly w.r.t keyboard-interactive authentication:

$ ssh -vvv -o "PubkeyAuthentication no" -o "PasswordAuthentication no" user@10.1.102.148
OpenSSH_9.1p1, OpenSSL 3.0.2 15 Mar 2022
...
debug1: Local version string SSH-2.0-OpenSSH_9.1
debug1: Remote protocol version 2.0, remote software version OpenSSH_for_Windows_8.1
debug1: compat_banner: match: OpenSSH_for_Windows_8.1 pat OpenSSH* compat 0x04000000
...
debug3: receive packet: type 6
debug2: service_accept: ssh-userauth
debug1: SSH2_MSG_SERVICE_ACCEPT received
debug3: send packet: type 50
debug3: receive packet: type 51
debug1: Authentications that can continue: publickey,password,keyboard-interactive
debug3: start over, passed a different list publickey,password,keyboard-interactive
debug3: preferred keyboard-interactive
debug3: authmethod_lookup keyboard-interactive
debug3: remaining preferred: 
debug3: authmethod_is_enabled keyboard-interactive
debug1: Next authentication method: keyboard-interactive
debug2: userauth_kbdint
debug3: send packet: type 50
debug2: we sent a keyboard-interactive packet, wait for reply
debug3: receive packet: type 51
debug1: Authentications that can continue: publickey,password,keyboard-interactive
debug3: userauth_kbdint: disable: no info_req_seen
debug2: we did not send a packet, disable method
debug1: No more authentication methods to try.
user@10.1.102.148: Permission denied (publickey,password,keyboard-interactive).

When trying to connect to such host using a golang client that uses ssh.KeyboardInteractive() wrapped a into ssh.RetryableAuthMethod(), then ssh.RetryableAuthMethod() will retry ssh.KeyboardInteractive() even if the failure happens so early that password is never prompted from the user.

What did you see happen?

package main

import (
    "log"
    "net"
    "os"
    "strings"

    "golang.org/x/crypto/ssh"
)

func main() {
    exit := func(v interface{}) {
        l := log.New(os.Stderr, "", 0)
        l.Printf("%v\n", v)
        os.Exit(-1)
    }

    args := os.Args[1:]
    if len(args) != 1 {
        exit("missing destination")
    }
    idx := strings.LastIndex(args[0], "@")
    if idx == -1 {
        exit("destination does not contain username")
    }
    user := args[0][:idx]
    dst := args[0][idx+1:]
    host, port, err := net.SplitHostPort(dst)
    if err != nil || port == "" {
        host = dst
        port = "22"
    }
    dst = net.JoinHostPort(host, port)

    cfg := &ssh.ClientConfig{
        User: user,
        Auth: []ssh.AuthMethod{
            ssh.RetryableAuthMethod(ssh.KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
                log.Printf("KeyboardInteractive()")
                return []string{"notaverysecretpassword"}, nil
            }), 6),
            ssh.RetryableAuthMethod(ssh.PasswordCallback(func() (secret string, err error) {
                log.Printf("PasswordCallback()")
                return "notaverysecretpassword", nil
            }), 6),
        },
        HostKeyCallback: ssh.InsecureIgnoreHostKey(),
    }

    log.Printf("connecting to %s@%s", user, dst)
    conn, err := ssh.Dial("tcp", dst, cfg)
    if err != nil {
        exit(err)
    }
    conn.Close()
}

Running this test client against a Windows OpenSSH server (and assuming MaxAuthTries is 6) reveals that neither KeyboardInteractive nor PasswordCallback is called:

$ ./testclient user@10.1.102.148
2024/06/06 12:02:05 connecting to user@10.1.102.148:22
ssh: handshake failed: ssh: disconnect, reason 2: Too many authentication failures

What did you expect to see?

Expected result is that PasswordCallback gets called:

$ ./testclient user@10.1.102.148
2024/06/06 12:02:42 connecting to user@10.1.102.148:22
2024/06/06 12:02:42 PasswordCallback()
2024/06/06 12:02:42 PasswordCallback()
2024/06/06 12:02:42 PasswordCallback()
2024/06/06 12:02:43 PasswordCallback()
2024/06/06 12:02:43 PasswordCallback()
ssh: handshake failed: ssh: disconnect, reason 2: Too many authentication failures