From c7df565dd496496a8cb668c506c846583869306e Mon Sep 17 00:00:00 2001 From: Han-Wen Nienhuys Date: Thu, 6 Jun 2013 10:44:12 -0400 Subject: [PATCH] go.crypto/ssh: implement keyboard-interactive auth (RFC 4256), both on client and server-side. R=dave, agl CC=gobot, golang-dev https://golang.org/cl/9853050 --- ssh/client_auth.go | 117 ++++++++++++++++++++++++++++++++++++++++ ssh/client_auth_test.go | 60 +++++++++++++++++++++ ssh/common.go | 15 ++++++ ssh/messages.go | 16 ++++++ ssh/server.go | 76 ++++++++++++++++++++++++++ 5 files changed, 284 insertions(+) diff --git a/ssh/client_auth.go b/ssh/client_auth.go index 89d1794..ebb74a2 100644 --- a/ssh/client_auth.go +++ b/ssh/client_auth.go @@ -375,3 +375,120 @@ func (kr *agentKeyring) Sign(i int, rand io.Reader, data []byte) (sig []byte, er } return sig, nil } + +// ClientKeyboardInteractive should prompt the user for the given +// questions. +type ClientKeyboardInteractive interface { + // Challenge should print the questions, optionally disabling + // echoing (eg. for passwords), and return all the answers. + // Challenge may be called multiple times in a single + // session. After successful authentication, the server may + // send a challenge with no questions, for which the user and + // instruction messages should be printed. RFC 4256 section + // 3.3 details how the UI should behave for both CLI and + // GUI environments. + Challenge(user, instruction string, questions []string, echos []bool) ([]string, error) +} + +// ClientAuthKeyboardInteractive returns a ClientAuth using a +// prompt/response sequence controlled by the server. +func ClientAuthKeyboardInteractive(impl ClientKeyboardInteractive) ClientAuth { + return &keyboardInteractiveAuth{impl} +} + +type keyboardInteractiveAuth struct { + ClientKeyboardInteractive +} + +func (c *keyboardInteractiveAuth) method() string { + return "keyboard-interactive" +} + +func (c *keyboardInteractiveAuth) auth(session []byte, user string, t *transport, rand io.Reader) (bool, []string, error) { + type initiateMsg struct { + User string + Service string + Method string + Language string + Submethods string + } + + if err := t.writePacket(marshal(msgUserAuthRequest, initiateMsg{ + User: user, + Service: serviceSSH, + Method: "keyboard-interactive", + })); err != nil { + return false, nil, err + } + + for { + packet, err := t.readPacket() + if err != nil { + return false, nil, err + } + + // like handleAuthResponse, but with less options. + switch packet[0] { + case msgUserAuthInfoRequest: + // OK + case msgUserAuthFailure: + var msg userAuthFailureMsg + if err := unmarshal(&msg, packet, msgUserAuthFailure); err != nil { + return false, nil, err + } + return false, msg.Methods, nil + case msgUserAuthSuccess: + return true, nil, nil + default: + return false, nil, UnexpectedMessageError{msgUserAuthInfoRequest, packet[0]} + } + + var msg userAuthInfoRequestMsg + if err := unmarshal(&msg, packet, packet[0]); err != nil { + return false, nil, err + } + + // Manually unpack the prompt/echo pairs. + rest := msg.Prompts + var prompts []string + var echos []bool + for i := 0; i < int(msg.NumPrompts); i++ { + prompt, r, ok := parseString(rest) + if !ok || len(r) == 0 { + return false, nil, errors.New("ssh: prompt format error") + } + prompts = append(prompts, string(prompt)) + echos = append(echos, r[0] != 0) + rest = r[1:] + } + + if len(rest) != 0 { + return false, nil, fmt.Errorf("ssh: junk following message %q", rest) + } + + answers, err := c.Challenge(msg.User, msg.Instruction, prompts, echos) + if err != nil { + return false, nil, err + } + + if len(answers) != len(prompts) { + return false, nil, errors.New("ssh: not enough answers from keyboard-interactive callback") + } + responseLength := 1 + 4 + for _, a := range answers { + responseLength += stringLength(len(a)) + } + serialized := make([]byte, responseLength) + p := serialized + p[0] = msgUserAuthInfoResponse + p = p[1:] + p = marshalUint32(p, uint32(len(answers))) + for _, a := range answers { + p = marshalString(p, []byte(a)) + } + + if err := t.writePacket(serialized); err != nil { + return false, nil, err + } + } +} diff --git a/ssh/client_auth_test.go b/ssh/client_auth_test.go index 1613e75..d339ffe 100644 --- a/ssh/client_auth_test.go +++ b/ssh/client_auth_test.go @@ -112,6 +112,16 @@ func (p password) Password(user string) (string, error) { return string(p), nil } +type keyboardInteractive map[string]string + +func (cr *keyboardInteractive) Challenge(user string, instruction string, questions []string, echos []bool) ([]string, error) { + var answers []string + for _, q := range questions { + answers = append(answers, (*cr)[q]) + } + return answers, nil +} + // reused internally by tests var ( rsakey *rsa.PrivateKey @@ -128,6 +138,18 @@ var ( algoname := algoName(key) return user == "testuser" && algo == algoname && bytes.Equal(pubkey, expected) }, + KeyboardInteractiveCallback: func(conn *ServerConn, user string, client ClientKeyboardInteractive) bool { + ans, err := client.Challenge("user", + "instruction", + []string{"question1", "question2"}, + []bool{true, true}) + if err != nil { + return false + } + ok := user == "testuser" && ans[0] == "answer1" && ans[1] == "answer2" + client.Challenge("user", "motd", nil, nil) + return ok + }, } ) @@ -221,6 +243,44 @@ func TestClientAuthWrongPassword(t *testing.T) { c.Close() } +func TestClientAuthKeyboardInteractive(t *testing.T) { + answers := keyboardInteractive(map[string]string{ + "question1": "answer1", + "question2": "answer2", + }) + config := &ClientConfig{ + User: "testuser", + Auth: []ClientAuth{ + ClientAuthKeyboardInteractive(&answers), + }, + } + + c, err := Dial("tcp", newMockAuthServer(t), config) + if err != nil { + t.Fatalf("unable to dial remote side: %s", err) + } + c.Close() +} + +func TestClientAuthWrongKeyboardInteractive(t *testing.T) { + answers := keyboardInteractive(map[string]string{ + "question1": "answer1", + "question2": "WRONG", + }) + config := &ClientConfig{ + User: "testuser", + Auth: []ClientAuth{ + ClientAuthKeyboardInteractive(&answers), + }, + } + + c, err := Dial("tcp", newMockAuthServer(t), config) + if err == nil { + c.Close() + t.Fatalf("wrong answers should not have authenticated with KeyboardInteractive") + } +} + // the mock server will only authenticate ssh-rsa keys func TestClientAuthInvalidPublickey(t *testing.T) { kc := new(keychain) diff --git a/ssh/common.go b/ssh/common.go index c06e3e4..1f967b0 100644 --- a/ssh/common.go +++ b/ssh/common.go @@ -329,6 +329,21 @@ func appendInt(buf []byte, n int) []byte { return appendU32(buf, uint32(n)) } +func appendString(buf []byte, s string) []byte { + buf = appendU32(buf, uint32(len(s))) + buf = append(buf, s...) + return buf +} + +func appendBool(buf []byte, b bool) []byte { + if b { + buf = append(buf, 1) + } else { + buf = append(buf, 0) + } + return buf +} + // newCond is a helper to hide the fact that there is no usable zero // value for sync.Cond. func newCond() *sync.Cond { return sync.NewCond(new(sync.Mutex)) } diff --git a/ssh/messages.go b/ssh/messages.go index 95811ef..7a0f980 100644 --- a/ssh/messages.go +++ b/ssh/messages.go @@ -25,19 +25,26 @@ const ( msgKexInit = 20 msgNewKeys = 21 + // Diffie-Helman msgKexDHInit = 30 msgKexDHReply = 31 + // Standard authentication messages msgUserAuthRequest = 50 msgUserAuthFailure = 51 msgUserAuthSuccess = 52 msgUserAuthBanner = 53 msgUserAuthPubKeyOk = 60 + // Method specific messages + msgUserAuthInfoRequest = 60 + msgUserAuthInfoResponse = 61 + msgGlobalRequest = 80 msgRequestSuccess = 81 msgRequestFailure = 82 + // Channel manipulation msgChannelOpen = 90 msgChannelOpenConfirm = 91 msgChannelOpenFailure = 92 @@ -117,6 +124,15 @@ type userAuthFailureMsg struct { PartialSuccess bool } +// See RFC 4256, section 3.2 +type userAuthInfoRequestMsg struct { + User string + Instruction string + DeprecatedLanguage string + NumPrompts uint32 + Prompts []byte `ssh:"rest"` +} + // See RFC 4254, section 5.1. type channelOpenMsg struct { ChanType string diff --git a/ssh/server.go b/ssh/server.go index c214b8a..7ecc694 100644 --- a/ssh/server.go +++ b/ssh/server.go @@ -42,6 +42,15 @@ type ServerConfig struct { // valid for the given user. PublicKeyCallback func(conn *ServerConn, user, algo string, pubkey []byte) bool + // KeyboardInteractiveCallback, if non-nil, is called when + // keyboard-interactive authentication is selected (RFC + // 4256). The client object's Challenge function should be + // used to query the user. The callback may offer multiple + // Challenge rounds. To avoid information leaks, the client + // should be presented a challenge even if the user is + // unknown. + KeyboardInteractiveCallback func(conn *ServerConn, user string, client ClientKeyboardInteractive) bool + // Cryptographic-related configuration. Crypto CryptoConfig } @@ -415,6 +424,15 @@ userAuthLoop: if s.config.PasswordCallback(s, userAuthReq.User, string(password)) { break userAuthLoop } + case "keyboard-interactive": + if s.config.KeyboardInteractiveCallback == nil { + break + } + + s.User = userAuthReq.User + if s.config.KeyboardInteractiveCallback(s, s.User, &sshClientKeyboardInteractive{s}) { + break userAuthLoop + } case "publickey": if s.config.PublicKeyCallback == nil { break @@ -498,6 +516,9 @@ userAuthLoop: if s.config.PublicKeyCallback != nil { failureMsg.Methods = append(failureMsg.Methods, "publickey") } + if s.config.KeyboardInteractiveCallback != nil { + failureMsg.Methods = append(failureMsg.Methods, "keyboard-interactive") + } if len(failureMsg.Methods) == 0 { return errors.New("ssh: no authentication methods configured but NoClientAuth is also false") @@ -516,6 +537,61 @@ userAuthLoop: return nil } +// sshClientKeyboardInteractive implements a ClientKeyboardInteractive by +// asking the client on the other side of a ServerConn. +type sshClientKeyboardInteractive struct { + *ServerConn +} + +func (c *sshClientKeyboardInteractive) Challenge(user, instruction string, questions []string, echos []bool) (answers []string, err error) { + if len(questions) != len(echos) { + return nil, errors.New("ssh: echos and questions must have equal length") + } + + var prompts []byte + for i := range questions { + prompts = appendString(prompts, questions[i]) + prompts = appendBool(prompts, echos[i]) + } + + if err := c.writePacket(marshal(msgUserAuthInfoRequest, userAuthInfoRequestMsg{ + Instruction: instruction, + NumPrompts: uint32(len(questions)), + Prompts: prompts, + })); err != nil { + return nil, err + } + + packet, err := c.readPacket() + if err != nil { + return nil, err + } + if packet[0] != msgUserAuthInfoResponse { + return nil, UnexpectedMessageError{msgUserAuthInfoResponse, packet[0]} + } + packet = packet[1:] + + n, packet, ok := parseUint32(packet) + if !ok || int(n) != len(questions) { + return nil, &ParseError{msgUserAuthInfoResponse} + } + + for i := uint32(0); i < n; i++ { + ans, rest, ok := parseString(packet) + if !ok { + return nil, &ParseError{msgUserAuthInfoResponse} + } + + answers = append(answers, string(ans)) + packet = rest + } + if len(packet) != 0 { + return nil, errors.New("ssh: junk at end of message") + } + + return answers, nil +} + const defaultWindowSize = 32768 // Accept reads and processes messages on a ServerConn. It must be called