ssh: reject incomplete gssapi-with-mic configurations

Make the runtime gssapi-with-mic guard match the existing
configuration and method advertisement checks.

An incomplete GSSAPIWithMICConfig can be treated as unavailable when
building the advertised auth method list, while still remaining
reachable from the runtime auth dispatcher. Treat incomplete
configurations as not configured.

This change introduces a single internal completeness check for
GSSAPIWithMICConfig and uses it for the startup authentication
validation, the runtime gssapi-with-mic dispatch guard, and the
advertised authentication method list.

The change also adds a regression test. The test configures a server
with a normal PasswordCallback, a GSSAPIWithMICConfig with Server set,
and AllowLogin intentionally unset. It then uses a custom client auth
method that explicitly sends a USERAUTH_REQUEST with Method set to
gssapi-with-mic even though the server does not advertise that method,
and verifies that authentication fails cleanly with
"ssh: gssapi-with-mic auth not configured".

No golang/go issue reference is available yet.

Change-Id: I9a0c965d3a56192bd68309aa41e2c1f91952036c
GitHub-Last-Rev: 0267bda8e15e7c258ba3b92cd54f0941534c5fc9
GitHub-Pull-Request: golang/crypto#345
Reviewed-on: https://go-review.googlesource.com/c/crypto/+/773460
Reviewed-by: Mark Freeman <markfreeman@google.com>
Reviewed-by: Nicola Murino <nicola.murino@gmail.com>
Reviewed-by: David Chase <drchase@google.com>
Reviewed-by: Filippo Valsorda <filippo@golang.org>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/ssh/client_auth_test.go b/ssh/client_auth_test.go
index b994b78..2e48342 100644
--- a/ssh/client_auth_test.go
+++ b/ssh/client_auth_test.go
@@ -1654,6 +1654,74 @@
 	}
 }
 
+type maliciousGSSAPIWithMICAuthMethod struct{}
+
+func (maliciousGSSAPIWithMICAuthMethod) method() string {
+	// Reuse an advertised auth method name so the client attempts this
+	// AuthMethod even when gssapi-with-mic is not advertised.
+	return "password"
+}
+
+func (maliciousGSSAPIWithMICAuthMethod) auth(session []byte, user string, c packetConn, rand io.Reader, _ map[string][]byte) (authResult, []string, error) {
+	req := &userAuthRequestMsg{
+		User:    user,
+		Service: serviceSSH,
+		Method:  "gssapi-with-mic",
+	}
+	req.Payload = appendU32(req.Payload, 1)
+	req.Payload = appendString(req.Payload, string(krb5OID))
+	if err := c.writePacket(Marshal(req)); err != nil {
+		return authFailure, nil, err
+	}
+
+	packet, err := c.readPacket()
+	if err != nil {
+		return authFailure, nil, err
+	}
+	switch packet[0] {
+	case msgUserAuthFailure:
+		var msg userAuthFailureMsg
+		if err := Unmarshal(packet, &msg); err != nil {
+			return authFailure, nil, err
+		}
+		if msg.PartialSuccess {
+			return authPartialSuccess, msg.Methods, nil
+		}
+		return authFailure, msg.Methods, nil
+	default:
+		return authFailure, nil, unexpectedMessageError(msgUserAuthFailure, packet[0])
+	}
+}
+
+func TestGSSAPIWithMICPartialConfigRejectedAtRuntime(t *testing.T) {
+	config := &ClientConfig{
+		User: "testuser",
+		Auth: []AuthMethod{
+			maliciousGSSAPIWithMICAuthMethod{},
+		},
+		HostKeyCallback: InsecureIgnoreHostKey(),
+	}
+
+	clientErr, serverErrs := tryAuthBothSides(t, config, &GSSAPIWithMICConfig{
+		Server: &FakeServer{},
+	})
+
+	if clientErr == nil || !strings.Contains(clientErr.Error(), "ssh: unable to authenticate") {
+		t.Fatalf("client got %v, want authentication failure", clientErr)
+	}
+
+	found := false
+	for _, err := range serverErrs {
+		if err != nil && strings.Contains(err.Error(), "ssh: gssapi-with-mic auth not configured") {
+			found = true
+			break
+		}
+	}
+	if !found {
+		t.Fatalf("server auth errors = %v, want gssapi-with-mic auth not configured", serverErrs)
+	}
+}
+
 func TestCompatibleAlgoAndSignatures(t *testing.T) {
 	type testcase struct {
 		algo       string
diff --git a/ssh/server.go b/ssh/server.go
index 9292f0b..3c0fcc9 100644
--- a/ssh/server.go
+++ b/ssh/server.go
@@ -54,6 +54,9 @@
 	ExtraData map[any]any
 }
 
+// GSSAPIWithMICConfig includes the server callbacks for gssapi-with-mic
+// authentication. If either field is nil, gssapi-with-mic is considered not
+// configured.
 type GSSAPIWithMICConfig struct {
 	// AllowLogin, must be set, is called when gssapi-with-mic
 	// authentication is selected (RFC 4462 section 3). The srcName is from the
@@ -68,6 +71,10 @@
 	Server GSSAPIServer
 }
 
+func gssapiWithMICConfigured(config *GSSAPIWithMICConfig) bool {
+	return config != nil && config.AllowLogin != nil && config.Server != nil
+}
+
 // SendAuthBanner implements [ServerPreAuthConn].
 func (s *connection) SendAuthBanner(msg string) error {
 	return s.transport.writePacket(Marshal(&userAuthBannerMsg{
@@ -382,8 +389,7 @@
 	}
 
 	if !config.NoClientAuth && config.PasswordCallback == nil && config.PublicKeyCallback == nil &&
-		config.KeyboardInteractiveCallback == nil && (config.GSSAPIWithMICConfig == nil ||
-		config.GSSAPIWithMICConfig.AllowLogin == nil || config.GSSAPIWithMICConfig.Server == nil) {
+		config.KeyboardInteractiveCallback == nil && !gssapiWithMICConfigured(config.GSSAPIWithMICConfig) {
 		return nil, errors.New("ssh: no authentication methods configured but NoClientAuth is also false")
 	}
 
@@ -869,7 +875,7 @@
 				}
 			}
 		case "gssapi-with-mic":
-			if authConfig.GSSAPIWithMICConfig == nil {
+			if !gssapiWithMICConfigured(authConfig.GSSAPIWithMICConfig) {
 				authErr = errors.New("ssh: gssapi-with-mic auth not configured")
 				break
 			}
@@ -1002,8 +1008,7 @@
 		if authConfig.KeyboardInteractiveCallback != nil {
 			failureMsg.Methods = append(failureMsg.Methods, "keyboard-interactive")
 		}
-		if authConfig.GSSAPIWithMICConfig != nil && authConfig.GSSAPIWithMICConfig.Server != nil &&
-			authConfig.GSSAPIWithMICConfig.AllowLogin != nil {
+		if gssapiWithMICConfigured(authConfig.GSSAPIWithMICConfig) {
 			failureMsg.Methods = append(failureMsg.Methods, "gssapi-with-mic")
 		}