Skip to content

Commit 94835b5

Browse files
committed
✨ Support passwords in ssh_async
Add password authentication for the `asyncssh` backend, including passwords passed directly and passwords resolved from authinfo secure storage. Reject password authentication for the `openssh` backend, which cannot inject passwords programmatically. Use `sshpass` for `gotocomputer` only when a stored password and `sshpass` are available. Otherwise fall back to manual password entry.
1 parent c8cb6c0 commit 94835b5

4 files changed

Lines changed: 176 additions & 13 deletions

File tree

.github/workflows/setup_ssh.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ ssh-keyscan -H localhost >> "${HOME}/.ssh/known_hosts"
77

88
# The permissions on the GitHub runner are 777 which will cause SSH to refuse the keys and cause authentication to fail
99
chmod 755 "${HOME}"
10+
11+
# set up ssh by password
12+
# runner is default user name in github CI, we set password to "password"
13+
echo "runner:password" | sudo chpasswd

src/aiida/transports/plugins/async_backend.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,18 @@ class _AsyncSSH(_AsynchronousSSHBackend):
226226
Note: This class is not part of the public API and should not be used directly.
227227
"""
228228

229-
def __init__(self, machine: str, logger: logging.LoggerAdapter, bash_command: str):
229+
def __init__(
230+
self,
231+
machine: str,
232+
logger: logging.LoggerAdapter,
233+
bash_command: str,
234+
connection_options: asyncssh.SSHClientConnectionOptions | None = None,
235+
):
230236
super().__init__(machine, logger, bash_command)
237+
self._connection_options = connection_options
231238

232239
async def open(self):
233-
self._conn = await asyncssh.connect(self.machine)
240+
self._conn = await asyncssh.connect(self.machine, options=self._connection_options)
234241
self._sftp = await self._conn.start_sftp_client()
235242

236243
async def close(self):

src/aiida/transports/plugins/ssh_async.py

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@
1313
import asyncio
1414
import glob
1515
import os
16+
import shutil
1617
import subprocess
1718
from pathlib import Path, PurePath
19+
from typing import TYPE_CHECKING
1820

1921
import click
22+
from asyncssh import SSHClientConnectionOptions
2023

2124
from aiida.common.escaping import escape_for_bash
2225
from aiida.common.exceptions import InvalidOperation
@@ -31,6 +34,38 @@
3134
__all__ = ('AsyncSshTransport',)
3235

3336

37+
if TYPE_CHECKING:
38+
from aiida.orm.implementation.authinfos import BackendAuthInfo
39+
40+
41+
def validate_os_supports_secure_storage(ctx, param, value: str):
42+
"""Validate the secure storage is available when configuring a password.
43+
44+
:param ctx: the click context
45+
:param param: the click parameter
46+
:param value: the password value to validate
47+
:return: the unchanged password value
48+
:raises OSError: if the system secure storage cannot be accessed
49+
"""
50+
if not value:
51+
return value
52+
53+
import keyringrs # type: ignore[import-untyped]
54+
55+
try:
56+
entry = keyringrs.Entry('aiida.transports.ssh_async', 'VALIDATE_OS_SUPPORTS_SECURE_STORAGE')
57+
entry.set_password('DUMMY')
58+
entry.delete_credential()
59+
except Exception as exception:
60+
msg = (
61+
'Could not access secure storage on your system for storing the password. '
62+
'Cannot use password authentication without secure storage.'
63+
)
64+
raise OSError(msg) from exception
65+
66+
return value
67+
68+
3469
def validate_script(ctx, param, value: str):
3570
if value == 'None':
3671
return value
@@ -73,6 +108,18 @@ class AsyncSshTransport(AsyncTransport):
73108
'non_interactive_default': True,
74109
},
75110
),
111+
(
112+
'password',
113+
{
114+
'type': str,
115+
'default': '',
116+
'prompt': 'Password',
117+
'hide_input': True,
118+
'help': 'Login password for the remote machine.',
119+
'non_interactive_default': True,
120+
'callback': validate_os_supports_secure_storage,
121+
},
122+
),
76123
(
77124
'max_io_allowed',
78125
{
@@ -125,7 +172,9 @@ def _get_host_suggestion_string(cls, computer):
125172
# TODO: an issue is open: https://github.com/aiidateam/aiida-core/issues/6726
126173
return computer.hostname
127174

128-
def __init__(self, *args, **kwargs):
175+
def __init__(self, *args, secure_storage: 'BackendAuthInfo.SecureStorage | None' = None, **kwargs):
176+
from aiida.orm.authinfos import Password
177+
129178
super().__init__(*args, **kwargs)
130179
# the machine is passed as `machine=computer.hostname` in the codebase
131180
# 'machine' is immutable.
@@ -142,6 +191,37 @@ def __init__(self, *args, **kwargs):
142191
# for backward compatibility
143192
self.auth_script = kwargs.pop('script_before', 'None')
144193

194+
self._secure_storage: BackendAuthInfo.SecureStorage | None = secure_storage
195+
self._password: str | Password | None = kwargs.pop('password', None)
196+
if self._password == Password.REDACTED.value:
197+
# `AuthInfo.get_auth_params` redacts a stored password with the plain marker string
198+
self._password = Password.REDACTED
199+
200+
if self._password and kwargs.get('backend') == 'openssh':
201+
msg = (
202+
'Password authentication is not supported by the `openssh` backend. '
203+
'Use the default `asyncssh` backend or configure key-based authentication.'
204+
)
205+
raise ValueError(msg)
206+
207+
if self._password == Password.REDACTED:
208+
if self._secure_storage is None:
209+
msg = 'No secure storage manager was provided for the redacted password.'
210+
raise ValueError(msg)
211+
if (connection_password := self._secure_storage.get_password()) is None:
212+
msg = f'No registered password has been found in secure storage for host `{self.machine}`.'
213+
raise ValueError(msg)
214+
else:
215+
connection_password = self._password
216+
217+
connection_options = None
218+
if connection_password:
219+
connection_options = SSHClientConnectionOptions(
220+
password=connection_password,
221+
preferred_auth='password',
222+
public_key_auth=False,
223+
)
224+
145225
if kwargs.get('backend') == 'openssh':
146226
from .async_backend import _OpenSSH
147227

@@ -150,7 +230,12 @@ def __init__(self, *args, **kwargs):
150230
# default backend is asyncssh
151231
from .async_backend import _AsyncSSH
152232

153-
self.async_backend = _AsyncSSH(self.machine, self.logger, self._bash_command_str) # type: ignore[assignment]
233+
self.async_backend = _AsyncSSH(
234+
self.machine,
235+
self.logger,
236+
self._bash_command_str,
237+
connection_options=connection_options,
238+
) # type: ignore[assignment]
154239

155240
@property
156241
def max_io_allowed(self):
@@ -1300,6 +1385,21 @@ def gotocomputer_command(self, remotedir: TransportPath | None = None):
13001385
13011386
:type remotedir: :class:`Path <pathlib.Path>`, :class:`PurePosixPath <pathlib.PurePosixPath>`, or `str`
13021387
"""
1388+
from aiida.orm.authinfos import Password
1389+
13031390
connect_string = self._gotocomputer_string(remotedir=remotedir)
13041391
cmd = f'ssh -t {self.machine} {connect_string}'
1392+
1393+
if self._password == Password.REDACTED:
1394+
if shutil.which('sshpass') is None:
1395+
self.logger.info('Command line tool `sshpass` was not found. Continue with manual password entry.')
1396+
elif self._secure_storage is None or self._secure_storage.get_password() is None:
1397+
self.logger.info(
1398+
f'No registered password has been found in secure storage for host `{self.machine}`. '
1399+
'Continue with manual password entry.'
1400+
)
1401+
else:
1402+
cmd_stdout_password = self._secure_storage.get_cmd_stdout_password()
1403+
cmd = f'sshpass -p "$({cmd_stdout_password})" {cmd}'
1404+
13051405
return cmd

tests/transports/test_all_plugins.py

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
from aiida.common.warnings import AiidaDeprecationWarning
2828
from aiida.plugins import SchedulerFactory, TransportFactory
2929
from aiida.transports import Transport
30+
from aiida.transports.plugins import ssh_async
31+
from aiida.transports.plugins.local import LocalTransport
3032

3133
# TODO : test for copy with pattern
3234
# TODO : test for copy with/without patterns, overwriting folder
@@ -64,23 +66,40 @@ def tmp_path_local(tmp_path_factory):
6466
('core.ssh', None),
6567
('core.ssh_async', 'asyncssh'),
6668
('core.ssh_async', 'openssh'),
69+
('core.ssh_async', 'password-passed'),
70+
('core.ssh_async', 'password-from-keychain'),
6771
],
6872
)
69-
def custom_transport(request, tmp_path_factory, monkeypatch) -> Transport:
73+
def custom_transport(request, aiida_localhost) -> Transport:
7074
"""Fixture that parametrizes over all the registered implementations of the transport plugins."""
71-
plugin = TransportFactory(request.param[0])
75+
plugin_name, use_case = request.param
76+
plugin = TransportFactory(plugin_name)
77+
auth_info = None
7278

73-
if request.param[0] == 'core.ssh':
79+
if plugin_name == 'core.ssh':
7480
kwargs = {'machine': 'localhost', 'timeout': 30, 'load_system_host_keys': True, 'key_policy': 'AutoAddPolicy'}
75-
elif request.param[0] == 'core.ssh_async':
76-
kwargs = {
77-
'machine': 'localhost',
78-
'backend': request.param[1],
79-
}
81+
elif plugin_name == 'core.ssh_async':
82+
auth_info = aiida_localhost.configure()
83+
kwargs = {'machine': 'localhost', 'secure_storage': auth_info.secure_storage}
84+
if use_case in {'asyncssh', 'openssh'}:
85+
kwargs['backend'] = use_case
86+
elif use_case == 'password-passed':
87+
kwargs['backend'] = 'asyncssh'
88+
kwargs['password'] = 'password'
89+
elif use_case == 'password-from-keychain':
90+
from aiida.orm.authinfos import Password
91+
92+
auth_info.secure_storage.set_password('password')
93+
kwargs['backend'] = 'asyncssh'
94+
kwargs['password'] = Password.REDACTED
8095
else:
8196
kwargs = {}
8297

83-
return plugin(**kwargs)
98+
try:
99+
yield plugin(**kwargs)
100+
finally:
101+
if plugin_name == 'core.ssh_async' and use_case == 'password-from-keychain' and auth_info is not None:
102+
auth_info.secure_storage.delete_password()
84103

85104

86105
def test_is_open(custom_transport):
@@ -1580,3 +1599,36 @@ def test_glob(custom_transport, tmp_path_local):
15801599
g_list = transport.glob(str(tmp_path_local) + '/folder2/aiida.pdos*')
15811600
paths = [str(tmp_path_local.joinpath('folder2/aiida.pdos_atm#2(Al)_wfc#2(p)'))]
15821601
assert sorted(paths) == sorted(g_list)
1602+
1603+
1604+
def test_gotocomputer_command(custom_transport):
1605+
"""Test the generated go-to-computer command."""
1606+
goto_computer_cmd = custom_transport.gotocomputer_command(Path('/path'))
1607+
1608+
if isinstance(custom_transport, LocalTransport):
1609+
assert 'bash' in goto_computer_cmd
1610+
else:
1611+
assert 'ssh' in goto_computer_cmd
1612+
1613+
1614+
def test_openssh_backend_rejects_password():
1615+
"""A password cannot be used with the ``openssh`` backend, which has no way to inject it."""
1616+
with pytest.raises(ValueError, match=r'Password authentication is not supported by the `openssh` backend'):
1617+
TransportFactory('core.ssh_async')(machine='localhost', backend='openssh', password='secret')
1618+
1619+
1620+
def test_validate_os_supports_secure_storage(monkeypatch):
1621+
"""Validate password configuration fails if secure storage is unavailable."""
1622+
import keyringrs
1623+
1624+
class FailingEntry:
1625+
def __init__(self, *args, **kwargs):
1626+
pass
1627+
1628+
def set_password(self, _: str):
1629+
raise RuntimeError('Could not access secure storage.')
1630+
1631+
monkeypatch.setattr(keyringrs, 'Entry', FailingEntry)
1632+
1633+
with pytest.raises(OSError, match=r'.*Cannot use password authentication without secure storage.*'):
1634+
ssh_async.validate_os_supports_secure_storage(None, None, 'pw')

0 commit comments

Comments
 (0)