Skip to content

Commit 6f79b5a

Browse files
drakkanpeterverraedt
authored andcommitted
ssh: add server side multi-step authentication
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 Co-authored-by: Peter Verraedt <[email protected]> Change-Id: I05c8f913bb407d22c2e41c4cbe965e36ab4739b0 Reviewed-on: https://go-review.googlesource.com/c/crypto/+/516355 Reviewed-by: Andrew Lytvynov <[email protected]> Reviewed-by: Than McIntosh <[email protected]> Reviewed-by: Dmitri Shuralyov <[email protected]> LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Filippo Valsorda <[email protected]> Auto-Submit: Filippo Valsorda <[email protected]>
1 parent 8d0d405 commit 6f79b5a

File tree

2 files changed

+530
-50
lines changed

2 files changed

+530
-50
lines changed

ssh/server.go

+118-50
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,35 @@ func (l ServerAuthError) Error() string {
426426
return "[" + strings.Join(errs, ", ") + "]"
427427
}
428428

429+
// ServerAuthCallbacks defines server-side authentication callbacks.
430+
type ServerAuthCallbacks struct {
431+
// PasswordCallback behaves like [ServerConfig.PasswordCallback].
432+
PasswordCallback func(conn ConnMetadata, password []byte) (*Permissions, error)
433+
434+
// PublicKeyCallback behaves like [ServerConfig.PublicKeyCallback].
435+
PublicKeyCallback func(conn ConnMetadata, key PublicKey) (*Permissions, error)
436+
437+
// KeyboardInteractiveCallback behaves like [ServerConfig.KeyboardInteractiveCallback].
438+
KeyboardInteractiveCallback func(conn ConnMetadata, client KeyboardInteractiveChallenge) (*Permissions, error)
439+
440+
// GSSAPIWithMICConfig behaves like [ServerConfig.GSSAPIWithMICConfig].
441+
GSSAPIWithMICConfig *GSSAPIWithMICConfig
442+
}
443+
444+
// PartialSuccessError can be returned by any of the [ServerConfig]
445+
// authentication callbacks to indicate to the client that authentication has
446+
// partially succeeded, but further steps are required.
447+
type PartialSuccessError struct {
448+
// Next defines the authentication callbacks to apply to further steps. The
449+
// available methods communicated to the client are based on the non-nil
450+
// ServerAuthCallbacks fields.
451+
Next ServerAuthCallbacks
452+
}
453+
454+
func (p *PartialSuccessError) Error() string {
455+
return "ssh: authenticated with partial success"
456+
}
457+
429458
// ErrNoAuth is the error value returned if no
430459
// authentication method has been passed yet. This happens as a normal
431460
// part of the authentication loop, since the client first tries
@@ -441,6 +470,15 @@ func (s *connection) serverAuthenticate(config *ServerConfig) (*Permissions, err
441470
authFailures := 0
442471
var authErrs []error
443472
var displayedBanner bool
473+
partialSuccessReturned := false
474+
// Set the initial authentication callbacks from the config. They can be
475+
// changed if a PartialSuccessError is returned.
476+
authConfig := ServerAuthCallbacks{
477+
PasswordCallback: config.PasswordCallback,
478+
PublicKeyCallback: config.PublicKeyCallback,
479+
KeyboardInteractiveCallback: config.KeyboardInteractiveCallback,
480+
GSSAPIWithMICConfig: config.GSSAPIWithMICConfig,
481+
}
444482

445483
userAuthLoop:
446484
for {
@@ -471,6 +509,11 @@ userAuthLoop:
471509
return nil, errors.New("ssh: client attempted to negotiate for unknown service: " + userAuthReq.Service)
472510
}
473511

512+
if s.user != userAuthReq.User && partialSuccessReturned {
513+
return nil, fmt.Errorf("ssh: client changed the user after a partial success authentication, previous user %q, current user %q",
514+
s.user, userAuthReq.User)
515+
}
516+
474517
s.user = userAuthReq.User
475518

476519
if !displayedBanner && config.BannerCallback != nil {
@@ -491,20 +534,17 @@ userAuthLoop:
491534

492535
switch userAuthReq.Method {
493536
case "none":
494-
if config.NoClientAuth {
537+
// We don't allow none authentication after a partial success
538+
// response.
539+
if config.NoClientAuth && !partialSuccessReturned {
495540
if config.NoClientAuthCallback != nil {
496541
perms, authErr = config.NoClientAuthCallback(s)
497542
} else {
498543
authErr = nil
499544
}
500545
}
501-
502-
// allow initial attempt of 'none' without penalty
503-
if authFailures == 0 {
504-
authFailures--
505-
}
506546
case "password":
507-
if config.PasswordCallback == nil {
547+
if authConfig.PasswordCallback == nil {
508548
authErr = errors.New("ssh: password auth not configured")
509549
break
510550
}
@@ -518,17 +558,17 @@ userAuthLoop:
518558
return nil, parseError(msgUserAuthRequest)
519559
}
520560

521-
perms, authErr = config.PasswordCallback(s, password)
561+
perms, authErr = authConfig.PasswordCallback(s, password)
522562
case "keyboard-interactive":
523-
if config.KeyboardInteractiveCallback == nil {
563+
if authConfig.KeyboardInteractiveCallback == nil {
524564
authErr = errors.New("ssh: keyboard-interactive auth not configured")
525565
break
526566
}
527567

528568
prompter := &sshClientKeyboardInteractive{s}
529-
perms, authErr = config.KeyboardInteractiveCallback(s, prompter.Challenge)
569+
perms, authErr = authConfig.KeyboardInteractiveCallback(s, prompter.Challenge)
530570
case "publickey":
531-
if config.PublicKeyCallback == nil {
571+
if authConfig.PublicKeyCallback == nil {
532572
authErr = errors.New("ssh: publickey auth not configured")
533573
break
534574
}
@@ -562,11 +602,18 @@ userAuthLoop:
562602
if !ok {
563603
candidate.user = s.user
564604
candidate.pubKeyData = pubKeyData
565-
candidate.perms, candidate.result = config.PublicKeyCallback(s, pubKey)
566-
if candidate.result == nil && candidate.perms != nil && candidate.perms.CriticalOptions != nil && candidate.perms.CriticalOptions[sourceAddressCriticalOption] != "" {
567-
candidate.result = checkSourceAddress(
605+
candidate.perms, candidate.result = authConfig.PublicKeyCallback(s, pubKey)
606+
_, isPartialSuccessError := candidate.result.(*PartialSuccessError)
607+
608+
if (candidate.result == nil || isPartialSuccessError) &&
609+
candidate.perms != nil &&
610+
candidate.perms.CriticalOptions != nil &&
611+
candidate.perms.CriticalOptions[sourceAddressCriticalOption] != "" {
612+
if err := checkSourceAddress(
568613
s.RemoteAddr(),
569-
candidate.perms.CriticalOptions[sourceAddressCriticalOption])
614+
candidate.perms.CriticalOptions[sourceAddressCriticalOption]); err != nil {
615+
candidate.result = err
616+
}
570617
}
571618
cache.add(candidate)
572619
}
@@ -578,8 +625,8 @@ userAuthLoop:
578625
if len(payload) > 0 {
579626
return nil, parseError(msgUserAuthRequest)
580627
}
581-
582-
if candidate.result == nil {
628+
_, isPartialSuccessError := candidate.result.(*PartialSuccessError)
629+
if candidate.result == nil || isPartialSuccessError {
583630
okMsg := userAuthPubKeyOkMsg{
584631
Algo: algo,
585632
PubKey: pubKeyData,
@@ -629,11 +676,11 @@ userAuthLoop:
629676
perms = candidate.perms
630677
}
631678
case "gssapi-with-mic":
632-
if config.GSSAPIWithMICConfig == nil {
679+
if authConfig.GSSAPIWithMICConfig == nil {
633680
authErr = errors.New("ssh: gssapi-with-mic auth not configured")
634681
break
635682
}
636-
gssapiConfig := config.GSSAPIWithMICConfig
683+
gssapiConfig := authConfig.GSSAPIWithMICConfig
637684
userAuthRequestGSSAPI, err := parseGSSAPIPayload(userAuthReq.Payload)
638685
if err != nil {
639686
return nil, parseError(msgUserAuthRequest)
@@ -689,49 +736,70 @@ userAuthLoop:
689736
break userAuthLoop
690737
}
691738

692-
authFailures++
693-
if config.MaxAuthTries > 0 && authFailures >= config.MaxAuthTries {
694-
// If we have hit the max attempts, don't bother sending the
695-
// final SSH_MSG_USERAUTH_FAILURE message, since there are
696-
// no more authentication methods which can be attempted,
697-
// and this message may cause the client to re-attempt
698-
// authentication while we send the disconnect message.
699-
// Continue, and trigger the disconnect at the start of
700-
// the loop.
701-
//
702-
// The SSH specification is somewhat confusing about this,
703-
// RFC 4252 Section 5.1 requires each authentication failure
704-
// be responded to with a respective SSH_MSG_USERAUTH_FAILURE
705-
// message, but Section 4 says the server should disconnect
706-
// after some number of attempts, but it isn't explicit which
707-
// message should take precedence (i.e. should there be a failure
708-
// message than a disconnect message, or if we are going to
709-
// disconnect, should we only send that message.)
710-
//
711-
// Either way, OpenSSH disconnects immediately after the last
712-
// failed authnetication attempt, and given they are typically
713-
// considered the golden implementation it seems reasonable
714-
// to match that behavior.
715-
continue
739+
var failureMsg userAuthFailureMsg
740+
741+
if partialSuccess, ok := authErr.(*PartialSuccessError); ok {
742+
// After a partial success error we don't allow changing the user
743+
// name and execute the NoClientAuthCallback.
744+
partialSuccessReturned = true
745+
746+
// In case a partial success is returned, the server may send
747+
// a new set of authentication methods.
748+
authConfig = partialSuccess.Next
749+
750+
// Reset pubkey cache, as the new PublicKeyCallback might
751+
// accept a different set of public keys.
752+
cache = pubKeyCache{}
753+
754+
// Send back a partial success message to the user.
755+
failureMsg.PartialSuccess = true
756+
} else {
757+
// Allow initial attempt of 'none' without penalty.
758+
if authFailures > 0 || userAuthReq.Method != "none" {
759+
authFailures++
760+
}
761+
if config.MaxAuthTries > 0 && authFailures >= config.MaxAuthTries {
762+
// If we have hit the max attempts, don't bother sending the
763+
// final SSH_MSG_USERAUTH_FAILURE message, since there are
764+
// no more authentication methods which can be attempted,
765+
// and this message may cause the client to re-attempt
766+
// authentication while we send the disconnect message.
767+
// Continue, and trigger the disconnect at the start of
768+
// the loop.
769+
//
770+
// The SSH specification is somewhat confusing about this,
771+
// RFC 4252 Section 5.1 requires each authentication failure
772+
// be responded to with a respective SSH_MSG_USERAUTH_FAILURE
773+
// message, but Section 4 says the server should disconnect
774+
// after some number of attempts, but it isn't explicit which
775+
// message should take precedence (i.e. should there be a failure
776+
// message than a disconnect message, or if we are going to
777+
// disconnect, should we only send that message.)
778+
//
779+
// Either way, OpenSSH disconnects immediately after the last
780+
// failed authnetication attempt, and given they are typically
781+
// considered the golden implementation it seems reasonable
782+
// to match that behavior.
783+
continue
784+
}
716785
}
717786

718-
var failureMsg userAuthFailureMsg
719-
if config.PasswordCallback != nil {
787+
if authConfig.PasswordCallback != nil {
720788
failureMsg.Methods = append(failureMsg.Methods, "password")
721789
}
722-
if config.PublicKeyCallback != nil {
790+
if authConfig.PublicKeyCallback != nil {
723791
failureMsg.Methods = append(failureMsg.Methods, "publickey")
724792
}
725-
if config.KeyboardInteractiveCallback != nil {
793+
if authConfig.KeyboardInteractiveCallback != nil {
726794
failureMsg.Methods = append(failureMsg.Methods, "keyboard-interactive")
727795
}
728-
if config.GSSAPIWithMICConfig != nil && config.GSSAPIWithMICConfig.Server != nil &&
729-
config.GSSAPIWithMICConfig.AllowLogin != nil {
796+
if authConfig.GSSAPIWithMICConfig != nil && authConfig.GSSAPIWithMICConfig.Server != nil &&
797+
authConfig.GSSAPIWithMICConfig.AllowLogin != nil {
730798
failureMsg.Methods = append(failureMsg.Methods, "gssapi-with-mic")
731799
}
732800

733801
if len(failureMsg.Methods) == 0 {
734-
return nil, errors.New("ssh: no authentication methods configured but NoClientAuth is also false")
802+
return nil, errors.New("ssh: no authentication methods available")
735803
}
736804

737805
if err := s.transport.writePacket(Marshal(&failureMsg)); err != nil {

0 commit comments

Comments
 (0)