Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@
import ch.cyberduck.core.LoginOptions;
import ch.cyberduck.core.PasswordStoreFactory;
import ch.cyberduck.core.exception.ConnectionCanceledException;
import ch.cyberduck.core.exception.LoginCanceledException;
import ch.cyberduck.core.exception.LoginFailureException;
import ch.cyberduck.core.preferences.PreferencesFactory;
import ch.cyberduck.core.ssl.X509KeyManager;

import org.apache.commons.cli.CommandLine;
import org.apache.commons.lang3.StringUtils;
Expand All @@ -44,7 +44,7 @@ public TerminalLoginService(final CommandLine input) {
}

@Override
public void validate(final Host bookmark, final LoginCallback prompt, final LoginOptions options) throws ConnectionCanceledException, LoginFailureException {
public void validate(final Host bookmark, final X509KeyManager keys, final LoginCallback prompt, final LoginOptions options) throws ConnectionCanceledException, LoginFailureException {
final Credentials credentials = bookmark.getCredentials();
if(input.hasOption(TerminalOptionsBuilder.Params.anonymous.name())) {
credentials.setUsername(PreferencesFactory.get().getProperty("connection.login.anon.name"));
Expand All @@ -61,6 +61,6 @@ public void validate(final Host bookmark, final LoginCallback prompt, final Logi
if(StringUtils.isNotBlank(credentials.getUsername()) && StringUtils.isNotBlank(credentials.getPassword())) {
return;
}
super.validate(bookmark, prompt, options);
super.validate(bookmark, keys, prompt, options);
}
}
12 changes: 12 additions & 0 deletions core/src/main/java/ch/cyberduck/core/Host.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public class Host implements Serializable, Comparable<Host>, PreferencesReader {
* The credentials to authenticate with for the CDN
*/
private final Credentials cloudfront = new Credentials();
/**
* Proxy configuration
*/
private Host jumphost;
/**
* The protocol identifier.
*/
Expand Down Expand Up @@ -357,6 +361,14 @@ public Host withCredentials(final Credentials credentials) {
return this;
}

public Host getJumphost() {
return jumphost;
}

public void setJumphost(final Host jumphost) {
this.jumphost = jumphost;
}

/**
* @return Credentials to modify CDN configuration
*/
Expand Down
65 changes: 40 additions & 25 deletions core/src/main/java/ch/cyberduck/core/KeychainLoginService.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import ch.cyberduck.core.exception.LocalAccessDeniedException;
import ch.cyberduck.core.exception.LoginCanceledException;
import ch.cyberduck.core.exception.LoginFailureException;
import ch.cyberduck.core.ssl.X509KeyManager;
import ch.cyberduck.core.threading.CancelCallback;

import org.apache.commons.lang3.StringUtils;
Expand All @@ -35,84 +36,95 @@ public class KeychainLoginService implements LoginService {

private final HostPasswordStore keychain;

public KeychainLoginService() {
this(PasswordStoreFactory.get());
}

public KeychainLoginService(final HostPasswordStore keychain) {
this.keychain = keychain;
}

@Override
public void validate(final Host bookmark, final LoginCallback prompt, final LoginOptions options) throws ConnectionCanceledException, LoginFailureException {
log.debug("Validate login credentials for {}", bookmark);
final Credentials credentials = bookmark.getCredentials();
public void validate(final Host host, final X509KeyManager keys, final LoginCallback prompt, final LoginOptions options) throws ConnectionCanceledException, LoginFailureException {
log.debug("Validate login credentials for {}", host);
final Host jumphost = host.getJumphost();
if(null != jumphost) {
this.validate(jumphost, keys, prompt, new LoginOptions(jumphost.getProtocol()));
}
final Credentials credentials = host.getCredentials();
if(credentials.isPublicKeyAuthentication()) {
if(!credentials.getIdentity().attributes().getPermission().isReadable()) {
log.warn("Prompt to select identity file not readable {}", credentials.getIdentity());
credentials.setIdentity(prompt.select(credentials.getIdentity()));
}
}
if(options.keychain) {
log.debug("Lookup credentials in keychain for {}", bookmark);
log.debug("Lookup credentials in keychain for {}", host);
if(options.password) {
if(StringUtils.isBlank(credentials.getPassword())) {
final String password = keychain.findLoginPassword(bookmark);
final String password = keychain.findLoginPassword(host);
if(StringUtils.isNotBlank(password)) {
log.info("Fetched password from keychain for {}", bookmark);
log.info("Fetched password from keychain for {}", host);
// No need to reinsert found password to the keychain.
credentials.setPassword(password).setSaved(false);
}
}
}
if(options.token) {
if(StringUtils.isBlank(credentials.getToken())) {
final String token = keychain.findLoginToken(bookmark);
final String token = keychain.findLoginToken(host);
if(StringUtils.isNotBlank(token)) {
log.info("Fetched token from keychain for {}", bookmark);
log.info("Fetched token from keychain for {}", host);
// No need to reinsert found token to the keychain.
credentials.setToken(token).setSaved(false);
}
}
}
if(options.publickey) {
final String passphrase = keychain.findPrivateKeyPassphrase(bookmark);
final String passphrase = keychain.findPrivateKeyPassphrase(host);
if(StringUtils.isNotBlank(passphrase)) {
log.info("Fetched private key passphrase from keychain for {}", bookmark);
log.info("Fetched private key passphrase from keychain for {}", host);
// No need to reinsert found token to the keychain.
credentials.setIdentityPassphrase(passphrase).setSaved(false);
}
}
if(options.oauth) {
final OAuthTokens tokens = keychain.findOAuthTokens(bookmark);
final OAuthTokens tokens = keychain.findOAuthTokens(host);
if(tokens.validate()) {
log.info("Fetched OAuth tokens {} from keychain for {}", tokens, bookmark);
log.info("Fetched OAuth tokens {} from keychain for {}", tokens, host);
// No need to reinsert found token to the keychain.
credentials.setOauth(tokens).setSaved(tokens.isExpired());
}
}
if(options.certificate) {
final String alias = host.getCredentials().getCertificate();
if(StringUtils.isNotBlank(alias)) {
if(keys != null) {
if(null == keys.getPrivateKey(alias)) {
log.warn("No private key found for alias {} in keychain", alias);
throw new LoginFailureException(LocaleFactory.localizedString("Provide additional login credentials", "Credentials"));
}
}
}
}
Comment on lines +95 to +105
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new certificate validation logic (lines 95-105) lacks test coverage. This is a critical code path that validates private key availability for certificate-based authentication and throws a LoginFailureException when validation fails. Consider adding test cases that cover:

  1. Successful validation when a valid private key exists for the given alias
  2. Failure scenario when getPrivateKey() returns null
  3. Edge case when keys parameter is null
  4. Edge case when alias is blank/null

Copilot uses AI. Check for mistakes.
}
if(!credentials.validate(bookmark.getProtocol(), options)) {
if(!credentials.validate(host.getProtocol(), options)) {
log.warn("Failed validation of credentials {} with options {}", credentials, options);
final CredentialsConfigurator configurator = CredentialsConfiguratorFactory.get(bookmark.getProtocol());
final CredentialsConfigurator configurator = CredentialsConfiguratorFactory.get(host.getProtocol());
log.debug("Auto configure credentials with {}", configurator);
final Credentials configuration = configurator.configure(bookmark);
if(configuration.validate(bookmark.getProtocol(), options)) {
bookmark.setCredentials(configuration);
log.info("Auto configured credentials {} for {}", configuration, bookmark);
final Credentials configuration = configurator.configure(host);
if(configuration.validate(host.getProtocol(), options)) {
host.setCredentials(configuration);
log.info("Auto configured credentials {} for {}", configuration, host);
return;
}
final StringAppender message = new StringAppender();
if(options.password) {
message.append(MessageFormat.format(LocaleFactory.localizedString(
"Login {0} with username and password", "Credentials"), BookmarkNameProvider.toString(bookmark)));
"Login {0} with username and password", "Credentials"), BookmarkNameProvider.toString(host)));
}
if(options.publickey) {
message.append(LocaleFactory.localizedString(
"Select the private key in PEM or PuTTY format", "Credentials"));
}
message.append(LocaleFactory.localizedString("No login credentials could be found in the Keychain", "Credentials"));
this.prompt(bookmark, message.toString(), prompt, options);
this.prompt(host, message.toString(), prompt, options);
}
log.debug("Validated credentials {} with options {}", credentials, options);
}
Expand Down Expand Up @@ -205,9 +217,12 @@ public boolean authenticate(final Session<?> session, final ProgressListener lis
public void save(final Host bookmark) {
final Credentials credentials = bookmark.getCredentials();
if(credentials.isSaved()) {
// Write credentials to keychain
// Write credentials to the password store
try {
keychain.save(bookmark);
if(bookmark.getJumphost() != null) {
keychain.save(bookmark.getJumphost());
}
}
catch(LocalAccessDeniedException e) {
log.error("Failure saving credentials for {} in keychain. {}", bookmark, e);
Expand Down
34 changes: 20 additions & 14 deletions core/src/main/java/ch/cyberduck/core/LoginConnectionService.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import ch.cyberduck.core.proxy.ProxyFactory;
import ch.cyberduck.core.proxy.ProxyFinder;
import ch.cyberduck.core.proxy.ProxyHostUrlProvider;
import ch.cyberduck.core.ssl.X509KeyManager;
import ch.cyberduck.core.threading.CancelCallback;

import org.apache.commons.lang3.StringUtils;
Expand Down Expand Up @@ -88,12 +89,17 @@ public boolean check(final Session<?> session, final CancelCallback callback) th
}
if(session.isConnected()) {
log.debug("Skip opening connection for session {}", session);
// Connection already open
// Connection is already open
return false;
}
// Obtain password from keychain or prompt
final Host jumphost = JumpHostConfiguratorFactory.get(bookmark.getProtocol()).getJumphost(bookmark.getHostname());
if(null != jumphost) {
log.debug("Configure with jump host {}", jumphost);
bookmark.setJumphost(jumphost);
}
// Get password from the password store or prompt
synchronized(login) {
login.validate(bookmark, prompt, new LoginOptions(bookmark.getProtocol()));
login.validate(bookmark, session.getFeature(X509KeyManager.class), prompt, new LoginOptions(bookmark.getProtocol()));
}
this.connect(session, callback);
return true;
Expand All @@ -102,7 +108,7 @@ public boolean check(final Session<?> session, final CancelCallback callback) th
@Override
public void close(final Session<?> session) throws BackgroundException {
listener.message(MessageFormat.format(LocaleFactory.localizedString("Disconnecting {0}", "Status"),
session.getHost().getHostname()));
session.getHost().getHostname()));
// Close the underlying socket first
session.interrupt();
}
Expand Down Expand Up @@ -130,24 +136,24 @@ public void connect(final Session<?> session, final CancelCallback cancel) throw
}
}
listener.message(MessageFormat.format(LocaleFactory.localizedString("Opening {0} connection to {1}", "Status"),
bookmark.getProtocol().getName(), hostname));
bookmark.getProtocol().getName(), hostname));
// The IP address could successfully be determined
session.open(proxy, key, prompt, cancel);
listener.message(MessageFormat.format(LocaleFactory.localizedString("{0} connection opened", "Status"),
bookmark.getProtocol().getName()));
bookmark.getProtocol().getName()));
// Update last accessed timestamp
bookmark.setTimestamp(new Date());
// Warning about insecure connection prior authenticating
if(session.alert(prompt)) {
// Warning if credentials are sent plaintext.
prompt.warn(bookmark, MessageFormat.format(LocaleFactory.localizedString("Unsecured {0} connection", "Credentials"),
bookmark.getProtocol().getName()),
MessageFormat.format("{0} {1}.", MessageFormat.format(LocaleFactory.localizedString("{0} will be sent in plaintext.", "Credentials"),
bookmark.getProtocol().getPasswordPlaceholder()),
LocaleFactory.localizedString("Please contact your web hosting service provider for assistance", "Support")),
LocaleFactory.localizedString("Continue", "Credentials"),
LocaleFactory.localizedString("Disconnect", "Credentials"),
String.format("connection.unsecure.%s", bookmark.getHostname()));
bookmark.getProtocol().getName()),
MessageFormat.format("{0} {1}.", MessageFormat.format(LocaleFactory.localizedString("{0} will be sent in plaintext.", "Credentials"),
bookmark.getProtocol().getPasswordPlaceholder()),
LocaleFactory.localizedString("Please contact your web hosting service provider for assistance", "Support")),
LocaleFactory.localizedString("Continue", "Credentials"),
LocaleFactory.localizedString("Disconnect", "Credentials"),
String.format("connection.unsecure.%s", bookmark.getHostname()));
}
// Login
try {
Expand All @@ -162,7 +168,7 @@ public void connect(final Session<?> session, final CancelCallback cancel) throw
private void authenticate(final Session<?> session, final CancelCallback callback) throws BackgroundException {
if(!login.authenticate(session, listener, prompt, callback)) {
if(session.isConnected()) {
// Next attempt with updated credentials but cancel when prompt is dismissed
// Next attempt with updated credentials but cancel when the prompt is dismissed
this.authenticate(session, callback);
}
else {
Expand Down
7 changes: 4 additions & 3 deletions core/src/main/java/ch/cyberduck/core/LoginService.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,18 @@
import ch.cyberduck.core.exception.ConnectionCanceledException;
import ch.cyberduck.core.exception.LoginCanceledException;
import ch.cyberduck.core.exception.LoginFailureException;
import ch.cyberduck.core.ssl.X509KeyManager;
import ch.cyberduck.core.threading.CancelCallback;

public interface LoginService {
/**
* Obtain password from password store or prompt user for input
*
* @param bookmark Credentials
* @param pompt Login prompt
* @param options Login mechanism features
* @param prompt Login prompt
* @param options Login mechanism features
*/
void validate(Host bookmark, LoginCallback pompt, LoginOptions options) throws ConnectionCanceledException, LoginFailureException;
void validate(Host bookmark, X509KeyManager keys, LoginCallback prompt, LoginOptions options) throws ConnectionCanceledException, LoginFailureException;

/**
* Login and prompt on failure
Expand Down
10 changes: 10 additions & 0 deletions core/src/main/java/ch/cyberduck/core/ssl/X509KeyManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
*/

import java.security.Principal;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.List;

Expand All @@ -40,4 +41,13 @@ public interface X509KeyManager extends javax.net.ssl.X509KeyManager {
* @param issuers Acceptable CA issuer subject names or null if it does not matter which issuers are used
*/
X509Certificate getCertificate(String alias, String[] keyTypes, Principal[] issuers);

/**
* Find private key for certificate to use for authentication with mutual TLS
*
* @param alias Certificate alias
* @return Null when not found
*/
@Override
PrivateKey getPrivateKey(String alias);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import ch.cyberduck.core.exception.LoginCanceledException;
import ch.cyberduck.core.preferences.PreferencesFactory;
import ch.cyberduck.core.proxy.DisabledProxyFinder;
import ch.cyberduck.core.ssl.DefaultX509KeyManager;
import ch.cyberduck.core.threading.CancelCallback;

import org.junit.Test;
Expand Down Expand Up @@ -51,7 +52,8 @@ else if(1 == i) {
@Test(expected = LoginCanceledException.class)
public void testCancel() throws Exception {
LoginService l = new KeychainLoginService(new DisabledPasswordStore());
l.validate(new Host(new TestProtocol(), "h"), new DisabledLoginCallback(), new LoginOptions());
l.validate(new Host(new TestProtocol(), "h"),
new DefaultX509KeyManager(), new DisabledLoginCallback(), new LoginOptions());
}

@Test
Expand All @@ -68,7 +70,7 @@ public String findLoginPassword(final Host bookmark) {
final Credentials credentials = new Credentials();
credentials.setUsername("u");
final Host host = new Host(new TestProtocol(), "test.cyberduck.ch", credentials);
l.validate(host, new DisabledLoginCallback(), new LoginOptions(host.getProtocol()));
l.validate(host, new DefaultX509KeyManager(), new DisabledLoginCallback(), new LoginOptions(host.getProtocol()));
assertTrue(keychain.get());
assertFalse(host.getCredentials().isSaved());
assertEquals("P", host.getCredentials().getPassword());
Expand Down
Loading