1313import asyncio
1414import glob
1515import os
16+ import shutil
1617import subprocess
1718from pathlib import Path , PurePath
19+ from typing import TYPE_CHECKING
1820
1921import click
22+ from asyncssh import SSHClientConnectionOptions
2023
2124from aiida .common .escaping import escape_for_bash
2225from aiida .common .exceptions import InvalidOperation
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+
3469def 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
0 commit comments