From 6042db54c30e003eea0f36d9bc546939976cd11f Mon Sep 17 00:00:00 2001 From: Ivan Shcheklein Date: Tue, 21 Apr 2026 20:39:56 -0700 Subject: [PATCH 1/3] Fix kbdint password fallback --- dvc_ssh/client.py | 5 ++++- dvc_ssh/tests/test_client.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 dvc_ssh/tests/test_client.py diff --git a/dvc_ssh/client.py b/dvc_ssh/client.py index aacb185..32328ef 100644 --- a/dvc_ssh/client.py +++ b/dvc_ssh/client.py @@ -95,7 +95,10 @@ async def _read_private_key_interactive(self, path: "FilePath") -> "SSHKey": pass raise KeyImportError("Incorrect passphrase") - def kbdint_auth_requested(self) -> str: + def kbdint_auth_requested(self): + if self._conn._options.password is not None: + return NotImplemented + return "" async def kbdint_challenge_received( diff --git a/dvc_ssh/tests/test_client.py b/dvc_ssh/tests/test_client.py new file mode 100644 index 0000000..0bbbeab --- /dev/null +++ b/dvc_ssh/tests/test_client.py @@ -0,0 +1,21 @@ +from types import SimpleNamespace + +import pytest + +from dvc_ssh.client import InteractiveSSHClient + + +@pytest.mark.parametrize( + "password,expected", + [("secret", NotImplemented), (None, "")], +) +def test_kbdint_auth_requested(password, expected): + client = InteractiveSSHClient() + client._conn = SimpleNamespace(_options=SimpleNamespace(password=password)) + + result = client.kbdint_auth_requested() + + if expected is NotImplemented: + assert result is NotImplemented + else: + assert result == expected \ No newline at end of file From 8c0fb599649305168f1d0ee07b254d1fec4633f1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 03:41:16 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- dvc_ssh/tests/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dvc_ssh/tests/test_client.py b/dvc_ssh/tests/test_client.py index 0bbbeab..ff06078 100644 --- a/dvc_ssh/tests/test_client.py +++ b/dvc_ssh/tests/test_client.py @@ -18,4 +18,4 @@ def test_kbdint_auth_requested(password, expected): if expected is NotImplemented: assert result is NotImplemented else: - assert result == expected \ No newline at end of file + assert result == expected From 7148f99e39637a6674a1ca3f0d32db279371e3ac Mon Sep 17 00:00:00 2001 From: Ivan Shcheklein Date: Tue, 21 Apr 2026 21:25:50 -0700 Subject: [PATCH 3/3] Add functional password auth test --- dvc_ssh/__init__.py | 11 ++++++++ dvc_ssh/client.py | 2 ++ dvc_ssh/tests/docker-compose.yml | 2 ++ dvc_ssh/tests/test_client.py | 23 ++++++++++++++++ dvc_ssh/tests/test_fs.py | 46 ++++++++++++++++++++++++++++++-- 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/dvc_ssh/__init__.py b/dvc_ssh/__init__.py index 8053def..f5905ed 100644 --- a/dvc_ssh/__init__.py +++ b/dvc_ssh/__init__.py @@ -79,6 +79,17 @@ def _prepare_credentials(self, **config): if keyfile := config.get("keyfile"): login_info["client_keys"] = [os.path.expanduser(keyfile)] + # Limit auth attempts to methods backed by configured credentials. + # This avoids prompting for unrelated default keys while preserving + # keyboard-interactive fallback for password-based logins. + preferred_auth = [] + if login_info.get("client_keys") or login_info.get("passphrase") is not None: + preferred_auth.append("publickey") + if login_info.get("password") is not None: + preferred_auth.extend(("password", "keyboard-interactive")) + if preferred_auth: + login_info["preferred_auth"] = preferred_auth + login_info["timeout"] = config.get("timeout", 1800) # These two settings fine tune the asyncssh to use the diff --git a/dvc_ssh/client.py b/dvc_ssh/client.py index 32328ef..27a278a 100644 --- a/dvc_ssh/client.py +++ b/dvc_ssh/client.py @@ -96,6 +96,8 @@ async def _read_private_key_interactive(self, path: "FilePath") -> "SSHKey": raise KeyImportError("Incorrect passphrase") def kbdint_auth_requested(self): + # Let asyncssh handle password-backed keyboard-interactive auth so it + # can reuse a configured password instead of triggering our prompt path. if self._conn._options.password is not None: return NotImplemented diff --git a/dvc_ssh/tests/docker-compose.yml b/dvc_ssh/tests/docker-compose.yml index ec5bcb0..ceb1895 100644 --- a/dvc_ssh/tests/docker-compose.yml +++ b/dvc_ssh/tests/docker-compose.yml @@ -4,7 +4,9 @@ services: openssh-server: image: ghcr.io/linuxserver/openssh-server environment: + - PASSWORD_ACCESS=true - USER_NAME=user + - USER_PASSWORD=password - PUBLIC_KEY_FILE=/tmp/key ports: - 2222 diff --git a/dvc_ssh/tests/test_client.py b/dvc_ssh/tests/test_client.py index ff06078..a44d3a2 100644 --- a/dvc_ssh/tests/test_client.py +++ b/dvc_ssh/tests/test_client.py @@ -2,7 +2,10 @@ import pytest +import dvc_ssh.client +from dvc_ssh import SSHFileSystem from dvc_ssh.client import InteractiveSSHClient +from dvc_ssh.tests.cloud import TEST_SSH_USER @pytest.mark.parametrize( @@ -19,3 +22,23 @@ def test_kbdint_auth_requested(password, expected): assert result is NotImplemented else: assert result == expected + + +def test_password_auth_uses_configured_password(ssh_server, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) + + async def fail_getpass(*args, **kwargs): + raise AssertionError("_getpass should not be called") + + monkeypatch.setattr(dvc_ssh.client, "_getpass", fail_getpass) + + fs = SSHFileSystem( + host=ssh_server["host"], + port=ssh_server["port"], + user=TEST_SSH_USER, + password="password", + ) + + assert fs.fs_args["preferred_auth"] == ["password", "keyboard-interactive"] + assert fs.exists("/tmp") diff --git a/dvc_ssh/tests/test_fs.py b/dvc_ssh/tests/test_fs.py index 19c6bf5..4b0dde3 100644 --- a/dvc_ssh/tests/test_fs.py +++ b/dvc_ssh/tests/test_fs.py @@ -43,14 +43,26 @@ def test_init(): assert fs.fs_args["port"] == "1234" assert fs.fs_args["password"] == "xxx" assert fs.fs_args["passphrase"] == "yyy" + assert fs.fs_args["preferred_auth"] == [ + "publickey", + "password", + "keyboard-interactive", + ] -@pytest.mark.parametrize("option", ("password", "passphrase")) -def test_ssh_ask_password(mocker, option): +@pytest.mark.parametrize( + "option,expected_auth", + [ + ("password", ["password", "keyboard-interactive"]), + ("passphrase", ["publickey"]), + ], +) +def test_ssh_ask_password(mocker, option, expected_auth): mocker.patch("dvc_ssh.ask_password", return_value="fish") args = {f"ask_{option}": True} fs = SSHFileSystem(user="test", host="2.2.2.2", **args) assert fs.fs_args[option] == "fish" + assert fs.fs_args["preferred_auth"] == expected_auth @pytest.mark.parametrize("password", [None, "foo"]) @@ -64,6 +76,16 @@ def test_passphrase(mocker, password, passphrase): assert connect.call_args[1]["password"] == password assert connect.call_args[1]["passphrase"] == passphrase + expected_preferred_auth = [] + if passphrase is not None: + expected_preferred_auth.append("publickey") + if password is not None: + expected_preferred_auth.extend(("password", "keyboard-interactive")) + + assert connect.call_args[1].get("preferred_auth") == ( + expected_preferred_auth or None + ) + def test_ssh_user(): fs = SSHFileSystem(host="example.com", user="test") @@ -92,6 +114,26 @@ def test_ssh_keyfile(config, expected_keyfile): assert fs.fs_args.get("client_keys") == expected_keyfiles +@pytest.mark.parametrize( + "config,expected_preferred_auth", + [ + ({"host": "example.com"}, None), + ( + {"host": "example.com", "password": "secret"}, + ["password", "keyboard-interactive"], + ), + ({"host": "example.com", "keyfile": "id_test"}, ["publickey"]), + ( + {"host": "example.com", "keyfile": "id_test", "password": "secret"}, + ["publickey", "password", "keyboard-interactive"], + ), + ], +) +def test_ssh_preferred_auth(config, expected_preferred_auth): + fs = SSHFileSystem(**config) + assert fs.fs_args.get("preferred_auth") == expected_preferred_auth + + @pytest.mark.parametrize( "config,expected_gss_auth", [