diff --git a/CHANGES.md b/CHANGES.md index 09d2d4c15..84ebe181f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -39,6 +39,7 @@ ## Bug Fixes * [GH-524](https://github.com/apache/mina-sshd/issues/524) Performance improvements +* [GH-533](https://github.com/apache/mina-sshd/issues/533) Fix multi-step authentication ## New Features diff --git a/sshd-core/src/main/java/org/apache/sshd/client/auth/pubkey/UserAuthPublicKey.java b/sshd-core/src/main/java/org/apache/sshd/client/auth/pubkey/UserAuthPublicKey.java index 376dfc048..6bd9424a5 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/auth/pubkey/UserAuthPublicKey.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/auth/pubkey/UserAuthPublicKey.java @@ -473,9 +473,12 @@ protected byte[] appendSignature( @Override public void signalAuthMethodSuccess(ClientSession session, String service, Buffer buffer) throws Exception { + PublicKeyIdentity successfulKey = current; + KeyPair identity = (successfulKey == null) ? null : successfulKey.getKeyIdentity(); + current = null; PublicKeyAuthenticationReporter reporter = session.getPublicKeyAuthenticationReporter(); if (reporter != null) { - reporter.signalAuthenticationSuccess(session, service, (current == null) ? null : current.getKeyIdentity()); + reporter.signalAuthenticationSuccess(session, service, identity); } } @@ -483,9 +486,30 @@ public void signalAuthMethodSuccess(ClientSession session, String service, Buffe public void signalAuthMethodFailure( ClientSession session, String service, boolean partial, List serverMethods, Buffer buffer) throws Exception { + PublicKeyIdentity keyUsed = current; + if (partial) { + // Actually a pubkey success, but we must continue with either this or another authentication method. + // + // Prevent re-use of this key if this instance of UserAuthPublicKey is used again. See OpenBSD sshd_config, + // AuthenticationMethods: "If the publickey method is listed more than once, sshd(8) verifies that keys + // that have been used successfully are not reused for subsequent authentications. For example, + // "publickey,publickey" requires successful authentication using two different public keys." + // + // https://man.openbsd.org/sshd_config#AuthenticationMethods + // + // If the successful key was an RSA key, and we succeeded with an rsa-sha2-512 signature, we might otherwise + // re-try that same key with an rsa-sha2-256 and ssh-rsa signature, which would be wrong. We have to + // continue with the next available key from the iterator. + // + // Note that if a server imposes an order on the keys used in such a case (say, it requires successful + // pubkey authentication first with key A, then with key B), it is the user's responsibility to ensure that + // the iterator has the keys in that order, for instance by specifying them in that order in "IdentityFile" + // directives in the host entry in the client-side ~/.ssh/config. + current = null; + } PublicKeyAuthenticationReporter reporter = session.getPublicKeyAuthenticationReporter(); if (reporter != null) { - KeyPair identity = (current == null) ? null : current.getKeyIdentity(); + KeyPair identity = (keyUsed == null) ? null : keyUsed.getKeyIdentity(); reporter.signalAuthenticationFailure(session, service, identity, partial, serverMethods); } } diff --git a/sshd-core/src/main/java/org/apache/sshd/client/session/ClientUserAuthService.java b/sshd-core/src/main/java/org/apache/sshd/client/session/ClientUserAuthService.java index 8ba615803..ffcfe5b30 100644 --- a/sshd-core/src/main/java/org/apache/sshd/client/session/ClientUserAuthService.java +++ b/sshd-core/src/main/java/org/apache/sshd/client/session/ClientUserAuthService.java @@ -30,6 +30,7 @@ import org.apache.sshd.client.auth.UserAuth; import org.apache.sshd.client.auth.UserAuthFactory; import org.apache.sshd.client.auth.keyboard.UserInteraction; +import org.apache.sshd.client.auth.pubkey.UserAuthPublicKey; import org.apache.sshd.client.future.AuthFuture; import org.apache.sshd.client.future.DefaultAuthFuture; import org.apache.sshd.common.NamedResource; @@ -66,8 +67,9 @@ public class ClientUserAuthService extends AbstractCloseable implements Service, private final Map properties = new ConcurrentHashMap<>(); private String service; - private UserAuth userAuth; + private UserAuth currentUserAuth; private int currentMethod; + private UserAuth pubkeyAuth; private final Object initLock = new Object(); private boolean started; @@ -150,6 +152,7 @@ public AuthFuture auth(String service) throws IOException { // start from scratch serverMethods = null; + pubkeyAuth = null; currentMethod = 0; clearUserAuth(); @@ -284,15 +287,17 @@ protected void processUserAuth(int cmd, Buffer buffer, AuthFuture authFuture) th if (cmd == SshConstants.SSH_MSG_USERAUTH_SUCCESS) { if (log.isDebugEnabled()) { log.debug("processUserAuth({}) SSH_MSG_USERAUTH_SUCCESS Succeeded with {}", - session, (userAuth == null) ? "" : userAuth.getName()); + session, (currentUserAuth == null) ? "" : currentUserAuth.getName()); } - if (userAuth != null) { + if (currentUserAuth != null) { try { - userAuth.signalAuthMethodSuccess(session, service, buffer); + currentUserAuth.signalAuthMethodSuccess(session, service, buffer); } finally { clearUserAuth(); } + } else { + destroyPubkeyAuth(); } session.setAuthenticated(); ((ClientSessionImpl) session).switchToNextService(); @@ -309,43 +314,82 @@ protected void processUserAuth(int cmd, Buffer buffer, AuthFuture authFuture) th return; } if (cmd == SshConstants.SSH_MSG_USERAUTH_FAILURE) { - String mths = buffer.getString(); + String methods = buffer.getString(); boolean partial = buffer.getBoolean(); if (log.isDebugEnabled()) { log.debug("processUserAuth({}) Received SSH_MSG_USERAUTH_FAILURE - partial={}, methods={}", - session, partial, mths); + session, partial, methods); } - if (partial || (serverMethods == null)) { - serverMethods = Arrays.asList(GenericUtils.split(mths, ',')); - currentMethod = 0; - if (userAuth != null) { - try { - userAuth.signalAuthMethodFailure(session, service, partial, Collections.unmodifiableList(serverMethods), - buffer); - } finally { - clearUserAuth(); + List allowedMethods; + if (GenericUtils.isEmpty(methods)) { + if (serverMethods == null) { + // RFC 4252 section 5.2 says that in the SSH_MSG_USERAUTH_FAILURE response + // to a 'none' request a server MAY return a list of methods. Here it didn't, + // so we just assume all methods that the client knows are fine. + // + // https://datatracker.ietf.org/doc/html/rfc4252#section-5.2 + allowedMethods = new ArrayList<>(clientMethods); + } else if (partial) { + // Don't reset to an empty list; keep going with the previous methods. Sending + // a partial success without methods that may continue makes no sense and would + // be a server bug. + // + // currentUserAuth should always be set here! + if (log.isDebugEnabled()) { + log.debug( + "processUserAuth({}) : potential bug in {} server: SSH_MSG_USERAUTH_FAILURE with partial success after {} authentication, but without continuation methods", + session, session.getServerVersion(), + currentUserAuth != null ? currentUserAuth.getName() : "UNKNOWN"); } + allowedMethods = serverMethods; + } else { + allowedMethods = new ArrayList<>(); } + } else { + allowedMethods = Arrays.asList(GenericUtils.split(methods, ',')); } + if (currentUserAuth != null) { + try { + currentUserAuth.signalAuthMethodFailure(session, service, partial, + Collections.unmodifiableList(allowedMethods), buffer); + } catch (Exception e) { + clearUserAuth(); + throw e; + } + + // Check if the current method is still allowed. + if (allowedMethods.indexOf(currentUserAuth.getName()) < 0) { + if (currentUserAuth == pubkeyAuth) { + // Don't destroy it yet, we might still need it later on + currentUserAuth = null; + } else { + destroyUserAuth(); + } + } + } + if (partial || (serverMethods == null)) { + currentMethod = 0; + } + serverMethods = allowedMethods; tryNext(cmd, authFuture); return; } - if (userAuth == null) { + if (currentUserAuth == null) { throw new IllegalStateException("Received unknown packet: " + SshConstants.getCommandMessageName(cmd)); } if (log.isDebugEnabled()) { log.debug("processUserAuth({}) delegate processing of {} to {}", - session, SshConstants.getCommandMessageName(cmd), userAuth.getName()); + session, SshConstants.getCommandMessageName(cmd), currentUserAuth.getName()); } buffer.rpos(buffer.rpos() - 1); - if (!userAuth.process(buffer)) { + if (!currentUserAuth.process(buffer)) { tryNext(cmd, authFuture); } else { - authFuture.setCancellable(userAuth.isCancellable()); + authFuture.setCancellable(currentUserAuth.isCancellable()); } } @@ -353,21 +397,28 @@ protected void tryNext(int cmd, AuthFuture authFuture) throws Exception { ClientSession session = getClientSession(); // Loop until we find something to try for (boolean debugEnabled = log.isDebugEnabled();; debugEnabled = log.isDebugEnabled()) { - if (userAuth == null) { + if (currentUserAuth == null) { if (debugEnabled) { - log.debug("tryNext({}) starting authentication mechanisms: client={}, server={}", - session, clientMethods, serverMethods); + log.debug("tryNext({}) starting authentication mechanisms: client={}, client index={}, server={}", session, + clientMethods, currentMethod, serverMethods); } - } else if (!userAuth.process(null)) { + } else if (!currentUserAuth.process(null)) { if (debugEnabled) { - log.debug("tryNext({}) no initial request sent by method={}", session, userAuth.getName()); + log.debug("tryNext({}) no initial request sent by method={}", session, currentUserAuth.getName()); + } + if (currentUserAuth == pubkeyAuth) { + // Don't destroy it yet. It might re-appear later if the server requires multiple methods. + // It doesn't have any more keys, but we don't want to re-create it from scratch and re-try + // all the keys already tried again. + currentUserAuth = null; + } else { + destroyUserAuth(); } - clearUserAuth(); currentMethod++; } else { if (debugEnabled) { log.debug("tryNext({}) successfully processed initial buffer by method={}", - session, userAuth.getName()); + session, currentUserAuth.getName()); } return; } @@ -385,7 +436,7 @@ protected void tryNext(int cmd, AuthFuture authFuture) throws Exception { log.debug("tryNext({}) exhausted all methods - client={}, server={}", session, clientMethods, serverMethods); } - + clearUserAuth(); // also wake up anyone sitting in waitFor authFuture.setException(new SshException(SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE, "No more authentication methods available")); @@ -398,30 +449,58 @@ protected void tryNext(int cmd, AuthFuture authFuture) throws Exception { clearUserAuth(); return; } - userAuth = UserAuthMethodFactory.createUserAuth(session, authFactories, method); - if (userAuth == null) { - throw new UnsupportedOperationException("Failed to find a user-auth factory for method=" + method); + if (UserAuthPublicKey.NAME.equals(method) && pubkeyAuth != null) { + currentUserAuth = pubkeyAuth; + } else { + currentUserAuth = UserAuthMethodFactory.createUserAuth(session, authFactories, method); + if (currentUserAuth == null) { + throw new UnsupportedOperationException("Failed to find a user-auth factory for method=" + method); + } } - if (debugEnabled) { log.debug("tryNext({}) attempting method={}", session, method); } - - userAuth.init(session, service); - authFuture.setCancellable(userAuth.isCancellable()); + if (currentUserAuth != pubkeyAuth) { + currentUserAuth.init(session, service); + } + if (UserAuthPublicKey.NAME.equals(currentUserAuth.getName())) { + pubkeyAuth = currentUserAuth; + } + authFuture.setCancellable(currentUserAuth.isCancellable()); if (authFuture.isCanceled()) { authFuture.getCancellation().setCanceled(); clearUserAuth(); + return; } } } private void clearUserAuth() { - if (userAuth != null) { + if (currentUserAuth == pubkeyAuth) { + pubkeyAuth = null; + destroyUserAuth(); + } else { + destroyUserAuth(); + destroyPubkeyAuth(); + } + } + + private void destroyUserAuth() { + if (currentUserAuth != null) { + try { + currentUserAuth.destroy(); + } finally { + currentUserAuth = null; + } + } + } + + private void destroyPubkeyAuth() { + if (pubkeyAuth != null) { try { - userAuth.destroy(); + pubkeyAuth.destroy(); } finally { - userAuth = null; + pubkeyAuth = null; } } } diff --git a/sshd-core/src/test/java/org/apache/sshd/client/auth/pubkey/MultiAuthTest.java b/sshd-core/src/test/java/org/apache/sshd/client/auth/pubkey/MultiAuthTest.java new file mode 100644 index 000000000..aa62348fb --- /dev/null +++ b/sshd-core/src/test/java/org/apache/sshd/client/auth/pubkey/MultiAuthTest.java @@ -0,0 +1,319 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sshd.client.auth.pubkey; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.util.ArrayList; +import java.util.List; + +import org.apache.sshd.client.SshClient; +import org.apache.sshd.client.auth.password.PasswordAuthenticationReporter; +import org.apache.sshd.client.auth.password.PasswordIdentityProvider; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.AttributeRepository.AttributeKey; +import org.apache.sshd.common.auth.UserAuthMethodFactory; +import org.apache.sshd.common.config.keys.KeyUtils; +import org.apache.sshd.common.util.GenericUtils; +import org.apache.sshd.core.CoreModuleProperties; +import org.apache.sshd.server.SshServer; +import org.apache.sshd.server.auth.AsyncAuthException; +import org.apache.sshd.server.auth.hostbased.RejectAllHostBasedAuthenticator; +import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator; +import org.apache.sshd.server.session.ServerSession; +import org.apache.sshd.util.test.BaseTestSupport; +import org.apache.sshd.util.test.CoreTestSupportUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class MultiAuthTest extends BaseTestSupport { + + private static final String USER_NAME = "foo"; + private static final String PASSWORD = "pass"; + + private SshServer sshd; + private SshClient client; + private int port; + + private KeyPair ecKeyUser; + private KeyPair rsaKeyUser; + + public MultiAuthTest() { + super(); + } + + private static class PubkeyAuth implements PublickeyAuthenticator { + + private static final AttributeKey SUCCESSFUL_AUTH_COUNT = new AttributeKey<>(); + + private final List knownKeys; + + PubkeyAuth(PublicKey... keys) { + knownKeys = GenericUtils.asList(keys); + } + + @Override + public boolean authenticate(String username, PublicKey key, ServerSession session) throws AsyncAuthException { + if (!USER_NAME.equals(username)) { + return false; + } + Integer count = session.getAttribute(SUCCESSFUL_AUTH_COUNT); + int successfulAuths = count == null ? 0 : count.intValue(); + // Server-side interfaces are poor. We should get the "hasSignature" flag. + // We know our client will send two auth requests per key (pre-auth without signature, then auth with + // signature). + int index = successfulAuths / 2; + if (index < knownKeys.size()) { + if (KeyUtils.compareKeys(key, knownKeys.get(index))) { + session.setAttribute(SUCCESSFUL_AUTH_COUNT, Integer.valueOf(successfulAuths + 1)); + return true; + } + } + return false; + } + } + + private static KeyPair getKeyPair(String algorithm, int size) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance(algorithm); + generator.initialize(size); + return generator.generateKeyPair(); + } + + @Before + public void setupClientAndServer() throws Exception { + sshd = CoreTestSupportUtils.setupTestServer(MultiAuthTest.class); + sshd.setHostBasedAuthenticator(RejectAllHostBasedAuthenticator.INSTANCE); + // Generate two user keys + rsaKeyUser = getKeyPair(KeyUtils.RSA_ALGORITHM, 2048); + ecKeyUser = getKeyPair(KeyUtils.EC_ALGORITHM, 256); + sshd.setPublickeyAuthenticator(new PubkeyAuth(rsaKeyUser.getPublic(), ecKeyUser.getPublic())); + sshd.setPasswordAuthenticator((username, password, session) -> { + return USER_NAME.equals(username) && PASSWORD.equals(password); + }); + sshd.start(); + port = sshd.getPort(); + client = CoreTestSupportUtils.setupTestClient(MultiAuthTest.class); + client.setUserAuthFactoriesNames(UserAuthMethodFactory.PUBLIC_KEY, UserAuthMethodFactory.PASSWORD); + client.start(); + } + + @After + public void teardownClientAndServer() throws Exception { + if (sshd != null) { + try { + sshd.stop(true); + } finally { + sshd = null; + } + } + if (client != null) { + try { + client.stop(); + } finally { + client = null; + } + } + } + + @Test + public void testConnect() throws Exception { + CoreModuleProperties.AUTH_METHODS.set(sshd, "publickey,password,publickey"); + StringBuilder sb = new StringBuilder(); + try (ClientSession session = createClientSession(USER_NAME, client, port)) { + session.setKeyIdentityProvider(ctx -> { + List result = new ArrayList<>(); + result.add(rsaKeyUser); + result.add(ecKeyUser); + return result; + }); + session.setPasswordIdentityProvider(PasswordIdentityProvider.wrapPasswords(PASSWORD)); + session.setPublicKeyAuthenticationReporter(new PubkeyReporter(sb)); + session.setPasswordAuthenticationReporter(new PasswordReporter(sb)); + session.auth().verify(AUTH_TIMEOUT); + } catch (Exception e) { + throw new RuntimeException(e.getMessage() + '\n' + sb.toString(), e); + } + String expected = "publickey TRY RSA rsa-sha2-512\n" // + + "publickey PARTIAL RSA\n" // + + "password TRY pass\n" // + + "password PARTIAL pass\n" // + + "publickey TRY EC ecdsa-sha2-nistp256\n" // + + "publickey SUCCESS EC\n"; + assertEquals(expected, sb.toString()); + } + + @Test + public void testConnect2() throws Exception { + CoreModuleProperties.AUTH_METHODS.set(sshd, "publickey,publickey"); + StringBuilder sb = new StringBuilder(); + try (ClientSession session = createClientSession(USER_NAME, client, port)) { + session.setKeyIdentityProvider(ctx -> { + List result = new ArrayList<>(); + result.add(rsaKeyUser); + result.add(ecKeyUser); + return result; + }); + session.setPublicKeyAuthenticationReporter(new PubkeyReporter(sb)); + session.setPasswordAuthenticationReporter(new PasswordReporter(sb)); + session.auth().verify(AUTH_TIMEOUT); + } catch (Exception e) { + throw new RuntimeException(e.getMessage() + '\n' + sb.toString(), e); + } + String expected = "publickey TRY RSA rsa-sha2-512\n" // + + "publickey PARTIAL RSA\n" // + + "publickey TRY EC ecdsa-sha2-nistp256\n" // + + "publickey SUCCESS EC\n"; + assertEquals(expected, sb.toString()); + } + + @Test + public void testConnect3() throws Exception { + CoreModuleProperties.AUTH_METHODS.set(sshd, "publickey password"); + StringBuilder sb = new StringBuilder(); + try (ClientSession session = createClientSession(USER_NAME, client, port)) { + session.setKeyIdentityProvider(ctx -> { + List result = new ArrayList<>(); + result.add(rsaKeyUser); + result.add(ecKeyUser); + return result; + }); + session.setPublicKeyAuthenticationReporter(new PubkeyReporter(sb)); + session.setPasswordAuthenticationReporter(new PasswordReporter(sb)); + session.auth().verify(AUTH_TIMEOUT); + } catch (Exception e) { + throw new RuntimeException(e.getMessage() + '\n' + sb.toString(), e); + } + String expected = "publickey TRY RSA rsa-sha2-512\n" // + + "publickey SUCCESS RSA\n"; + assertEquals(expected, sb.toString()); + } + + @Test + public void testConnect4() throws Exception { + CoreModuleProperties.AUTH_METHODS.set(sshd, "password,publickey"); + StringBuilder sb = new StringBuilder(); + try (ClientSession session = createClientSession(USER_NAME, client, port)) { + session.setKeyIdentityProvider(ctx -> { + List result = new ArrayList<>(); + result.add(rsaKeyUser); + result.add(ecKeyUser); + return result; + }); + session.setPasswordIdentityProvider(PasswordIdentityProvider.wrapPasswords(PASSWORD)); + session.setPublicKeyAuthenticationReporter(new PubkeyReporter(sb)); + session.setPasswordAuthenticationReporter(new PasswordReporter(sb)); + session.auth().verify(AUTH_TIMEOUT); + } catch (Exception e) { + throw new RuntimeException(e.getMessage() + '\n' + sb.toString(), e); + } + String expected = "password TRY pass\n" // + + "password PARTIAL pass\n" // + + "publickey TRY RSA rsa-sha2-512\n" // + + "publickey SUCCESS RSA\n"; + assertEquals(expected, sb.toString()); + } + + @Test + public void testConnect5() throws Exception { + CoreModuleProperties.AUTH_METHODS.set(sshd, "password,publickey,publickey"); + StringBuilder sb = new StringBuilder(); + try (ClientSession session = createClientSession(USER_NAME, client, port)) { + session.setKeyIdentityProvider(ctx -> { + List result = new ArrayList<>(); + result.add(rsaKeyUser); + result.add(ecKeyUser); + return result; + }); + session.setPasswordIdentityProvider(PasswordIdentityProvider.wrapPasswords(PASSWORD)); + session.setPublicKeyAuthenticationReporter(new PubkeyReporter(sb)); + session.setPasswordAuthenticationReporter(new PasswordReporter(sb)); + session.auth().verify(AUTH_TIMEOUT); + } catch (Exception e) { + throw new RuntimeException(e.getMessage() + '\n' + sb.toString(), e); + } + String expected = "password TRY pass\n" // + + "password PARTIAL pass\n" // + + "publickey TRY RSA rsa-sha2-512\n" // + + "publickey PARTIAL RSA\n" // + + "publickey TRY EC ecdsa-sha2-nistp256\n" // + + "publickey SUCCESS EC\n"; + assertEquals(expected, sb.toString()); + } + + private static class PubkeyReporter implements PublicKeyAuthenticationReporter { + + private final StringBuilder out; + + PubkeyReporter(StringBuilder sink) { + out = sink; + } + + @Override + public void signalAuthenticationAttempt(ClientSession session, String service, KeyPair identity, String signature) + throws Exception { + out.append("publickey TRY ").append(identity == null ? "null" : identity.getPublic().getAlgorithm()).append(' ') + .append(signature == null ? "null" : signature).append('\n'); + } + + @Override + public void signalAuthenticationSuccess(ClientSession session, String service, KeyPair identity) throws Exception { + out.append("publickey SUCCESS ").append(identity == null ? "null" : identity.getPublic().getAlgorithm()) + .append('\n'); + } + + @Override + public void signalAuthenticationFailure( + ClientSession session, String service, KeyPair identity, boolean partial, + List serverMethods) throws Exception { + out.append("publickey ").append(partial ? "PARTIAL " : "FAILURE ") + .append(identity == null ? "null" : identity.getPublic().getAlgorithm()).append('\n'); + } + } + + private static class PasswordReporter implements PasswordAuthenticationReporter { + + private final StringBuilder out; + + PasswordReporter(StringBuilder sink) { + out = sink; + } + + @Override + public void signalAuthenticationAttempt( + ClientSession session, String service, String oldPassword, boolean modified, + String newPassword) throws Exception { + out.append("password TRY " + oldPassword).append('\n'); + } + + @Override + public void signalAuthenticationSuccess(ClientSession session, String service, String password) throws Exception { + out.append("password SUCCESS " + password).append('\n'); + } + + @Override + public void signalAuthenticationFailure( + ClientSession session, String service, String password, boolean partial, + List serverMethods) throws Exception { + out.append("password ").append(partial ? "PARTIAL " : "FAILURE ").append(password == null ? "null" : password) + .append('\n'); + } + } +}