Skip to content

Commit

Permalink
ssh: add server side multi-step authentication
Browse files Browse the repository at this point in the history
Add support for sending back partial success to the client while
handling authentication in the server. This is implemented by a special
error that can be returned by any of the authentication methods, which
contains the authentication methods to offer next.

This patch is based on CL 399075 with some minor changes and the
addition of test cases.

Fixes golang/go#17889
Fixes golang/go#61447
Fixes golang/go#64974

Change-Id: I05c8f913bb407d22c2e41c4cbe965e36ab4739b0
  • Loading branch information
drakkan committed Mar 7, 2024
1 parent 7067223 commit 65e4acc
Show file tree
Hide file tree
Showing 2 changed files with 327 additions and 44 deletions.
175 changes: 131 additions & 44 deletions ssh/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,54 @@ func (l ServerAuthError) Error() string {
return "[" + strings.Join(errs, ", ") + "]"
}

// ServerAuthCallbacks defines server-side authentication callbacks.
type ServerAuthCallbacks struct {
// PasswordCallback, if non-nil, is called when a user attempts to
// authenticate using a password.
PasswordCallback func(conn ConnMetadata, password []byte) (*Permissions, error)

// PublicKeyCallback, if non-nil, is called when a client offers a public
// key for authentication. It must return a nil error if the given public
// key can be used to authenticate the given user. For example, see
// CertChecker.Authenticate. A call to this function does not guarantee that
// the key offered is in fact used to authenticate. To record any data
// depending on the public key, store it inside a Permissions.Extensions
// entry.
PublicKeyCallback func(conn ConnMetadata, key PublicKey) (*Permissions, error)

// 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 ConnMetadata, client KeyboardInteractiveChallenge) (*Permissions, error)

// GSSAPIWithMICConfig includes gssapi server and callback, which if both
// non-nil, is used when gssapi-with-mic authentication is selected (RFC
// 4462 section 3).
GSSAPIWithMICConfig *GSSAPIWithMICConfig

// NoClientAuthCallback, if non-nil, is called when a user
// attempts to authenticate with auth method "none".
// NoClientAuth must also be set to true for this be used, or
// this func is unused.
NoClientAuthCallback func(ConnMetadata) (*Permissions, error)
}

// PartialSuccessError might be returned from any of the authentication methods
// to indicate that the authentication is in progress, but more steps must be
// done. It should contain the authentication methods to offer in further
// authentications.
type PartialSuccessError struct {
// Next defines the authentication callbacks that are allowed after a
// partial success error.
Next ServerAuthCallbacks
}

func (p *PartialSuccessError) Error() string {
return "ssh: authenticated with partial success"
}

// ErrNoAuth is the error value returned if no
// authentication method has been passed yet. This happens as a normal
// part of the authentication loop, since the client first tries
Expand All @@ -441,6 +489,11 @@ func (s *connection) serverAuthenticate(config *ServerConfig) (*Permissions, err
authFailures := 0
var authErrs []error
var displayedBanner bool
var noClientAuthAllowed bool
// Wrap authentication methods in a PartialSuccess struct to easily put a
// newer set of authentication methods later, when a PartialSuccess is
// returned.
var authConfig PartialSuccessError

userAuthLoop:
for {
Expand Down Expand Up @@ -471,6 +524,19 @@ userAuthLoop:
return nil, errors.New("ssh: client attempted to negotiate for unknown service: " + userAuthReq.Service)
}

if s.user == "" || s.user != userAuthReq.User {
// Set the initial authentication configuration if we have no user
// or if the user changes.
authConfig = PartialSuccessError{
Next: ServerAuthCallbacks{
PasswordCallback: config.PasswordCallback,
PublicKeyCallback: config.PublicKeyCallback,
KeyboardInteractiveCallback: config.KeyboardInteractiveCallback,
GSSAPIWithMICConfig: config.GSSAPIWithMICConfig,
},
}
noClientAuthAllowed = config.NoClientAuth
}
s.user = userAuthReq.User

if !displayedBanner && config.BannerCallback != nil {
Expand All @@ -491,7 +557,7 @@ userAuthLoop:

switch userAuthReq.Method {
case "none":
if config.NoClientAuth {
if noClientAuthAllowed {
if config.NoClientAuthCallback != nil {
perms, authErr = config.NoClientAuthCallback(s)
} else {
Expand All @@ -504,7 +570,7 @@ userAuthLoop:
authFailures--
}
case "password":
if config.PasswordCallback == nil {
if authConfig.Next.PasswordCallback == nil {
authErr = errors.New("ssh: password auth not configured")
break
}
Expand All @@ -518,17 +584,17 @@ userAuthLoop:
return nil, parseError(msgUserAuthRequest)
}

perms, authErr = config.PasswordCallback(s, password)
perms, authErr = authConfig.Next.PasswordCallback(s, password)
case "keyboard-interactive":
if config.KeyboardInteractiveCallback == nil {
if authConfig.Next.KeyboardInteractiveCallback == nil {
authErr = errors.New("ssh: keyboard-interactive auth not configured")
break
}

prompter := &sshClientKeyboardInteractive{s}
perms, authErr = config.KeyboardInteractiveCallback(s, prompter.Challenge)
perms, authErr = authConfig.Next.KeyboardInteractiveCallback(s, prompter.Challenge)
case "publickey":
if config.PublicKeyCallback == nil {
if authConfig.Next.PublicKeyCallback == nil {
authErr = errors.New("ssh: publickey auth not configured")
break
}
Expand Down Expand Up @@ -560,13 +626,16 @@ userAuthLoop:

candidate, ok := cache.get(s.user, pubKeyData)
if !ok {
var partialSuccess *PartialSuccessError
candidate.user = s.user
candidate.pubKeyData = pubKeyData
candidate.perms, candidate.result = config.PublicKeyCallback(s, pubKey)
if candidate.result == nil && candidate.perms != nil && candidate.perms.CriticalOptions != nil && candidate.perms.CriticalOptions[sourceAddressCriticalOption] != "" {
candidate.result = checkSourceAddress(
candidate.perms, candidate.result = authConfig.Next.PublicKeyCallback(s, pubKey)
if (candidate.result == nil || errors.As(candidate.result, &partialSuccess)) && candidate.perms != nil && candidate.perms.CriticalOptions != nil && candidate.perms.CriticalOptions[sourceAddressCriticalOption] != "" {
if err := checkSourceAddress(
s.RemoteAddr(),
candidate.perms.CriticalOptions[sourceAddressCriticalOption])
candidate.perms.CriticalOptions[sourceAddressCriticalOption]); err != nil {
candidate.result = err
}
}
cache.add(candidate)
}
Expand All @@ -578,8 +647,8 @@ userAuthLoop:
if len(payload) > 0 {
return nil, parseError(msgUserAuthRequest)
}

if candidate.result == nil {
var partialSuccess *PartialSuccessError
if candidate.result == nil || errors.As(candidate.result, &partialSuccess) {
okMsg := userAuthPubKeyOkMsg{
Algo: algo,
PubKey: pubKeyData,
Expand Down Expand Up @@ -629,11 +698,11 @@ userAuthLoop:
perms = candidate.perms
}
case "gssapi-with-mic":
if config.GSSAPIWithMICConfig == nil {
if authConfig.Next.GSSAPIWithMICConfig == nil {
authErr = errors.New("ssh: gssapi-with-mic auth not configured")
break
}
gssapiConfig := config.GSSAPIWithMICConfig
gssapiConfig := authConfig.Next.GSSAPIWithMICConfig
userAuthRequestGSSAPI, err := parseGSSAPIPayload(userAuthReq.Payload)
if err != nil {
return nil, parseError(msgUserAuthRequest)
Expand Down Expand Up @@ -689,44 +758,62 @@ userAuthLoop:
break userAuthLoop
}

authFailures++
if config.MaxAuthTries > 0 && authFailures >= config.MaxAuthTries {
// If we have hit the max attempts, don't bother sending the
// final SSH_MSG_USERAUTH_FAILURE message, since there are
// no more authentication methods which can be attempted,
// and this message may cause the client to re-attempt
// authentication while we send the disconnect message.
// Continue, and trigger the disconnect at the start of
// the loop.
//
// The SSH specification is somewhat confusing about this,
// RFC 4252 Section 5.1 requires each authentication failure
// be responded to with a respective SSH_MSG_USERAUTH_FAILURE
// message, but Section 4 says the server should disconnect
// after some number of attempts, but it isn't explicit which
// message should take precedence (i.e. should there be a failure
// message than a disconnect message, or if we are going to
// disconnect, should we only send that message.)
//
// Either way, OpenSSH disconnects immediately after the last
// failed authnetication attempt, and given they are typically
// considered the golden implementation it seems reasonable
// to match that behavior.
continue
var failureMsg userAuthFailureMsg

var partialSuccess *PartialSuccessError
if errors.As(authErr, &partialSuccess) {
// In case a partial success is returned, the server may send
// a new set of authentication methods.
authConfig = *partialSuccess

// Do not allow none authentication in further rounds.
noClientAuthAllowed = false

// Reset pubkey cache, as the new PublicKeyCallback might
// accept an other set of public keys.
cache = pubKeyCache{}

// Send back a partial success message to the user.
failureMsg.PartialSuccess = true
} else {
authFailures++
if config.MaxAuthTries > 0 && authFailures >= config.MaxAuthTries {
// If we have hit the max attempts, don't bother sending the
// final SSH_MSG_USERAUTH_FAILURE message, since there are
// no more authentication methods which can be attempted,
// and this message may cause the client to re-attempt
// authentication while we send the disconnect message.
// Continue, and trigger the disconnect at the start of
// the loop.
//
// The SSH specification is somewhat confusing about this,
// RFC 4252 Section 5.1 requires each authentication failure
// be responded to with a respective SSH_MSG_USERAUTH_FAILURE
// message, but Section 4 says the server should disconnect
// after some number of attempts, but it isn't explicit which
// message should take precedence (i.e. should there be a failure
// message than a disconnect message, or if we are going to
// disconnect, should we only send that message.)
//
// Either way, OpenSSH disconnects immediately after the last
// failed authnetication attempt, and given they are typically
// considered the golden implementation it seems reasonable
// to match that behavior.
continue
}
}

var failureMsg userAuthFailureMsg
if config.PasswordCallback != nil {
if authConfig.Next.PasswordCallback != nil {
failureMsg.Methods = append(failureMsg.Methods, "password")
}
if config.PublicKeyCallback != nil {
if authConfig.Next.PublicKeyCallback != nil {
failureMsg.Methods = append(failureMsg.Methods, "publickey")
}
if config.KeyboardInteractiveCallback != nil {
if authConfig.Next.KeyboardInteractiveCallback != nil {
failureMsg.Methods = append(failureMsg.Methods, "keyboard-interactive")
}
if config.GSSAPIWithMICConfig != nil && config.GSSAPIWithMICConfig.Server != nil &&
config.GSSAPIWithMICConfig.AllowLogin != nil {
if authConfig.Next.GSSAPIWithMICConfig != nil && authConfig.Next.GSSAPIWithMICConfig.Server != nil &&
authConfig.Next.GSSAPIWithMICConfig.AllowLogin != nil {
failureMsg.Methods = append(failureMsg.Methods, "gssapi-with-mic")
}

Expand Down
Loading

0 comments on commit 65e4acc

Please sign in to comment.