44# Copyright 2020 ACSONE SA/NV (<http://acsone.eu>)
55# @author Simone Orsi <simahawk@gmail.com>
66# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
7+ import base64
78import errno
89import logging
910import os
2021 _logger .debug (err )
2122
2223
24+ def normalize_key_input (value ):
25+ """Normalize key input to string content.
26+
27+ Accepts:
28+ - str: file path or direct key content
29+ - bytes: key content as bytes
30+ - file-like object: readable object with key content
31+
32+ Returns:
33+ str: the key content
34+ """
35+ if value is None :
36+ return None
37+
38+ # Handle file-like objects (have read method)
39+ if hasattr (value , "read" ):
40+ content = value .read ()
41+ if hasattr (value , "seek" ):
42+ value .seek (0 ) # Reset for potential reuse
43+ if isinstance (content , bytes ):
44+ return content .decode ("utf-8" )
45+ return content
46+
47+ # Handle bytes
48+ if isinstance (value , bytes ):
49+ return value .decode ("utf-8" )
50+
51+ # Handle string (path or content)
52+ if isinstance (value , str ):
53+ value = value .strip ()
54+
55+ # Check if it looks like a file path (not key content)
56+ is_path = value .startswith (("/" , "~" , "./" , "../" )) or (
57+ not value .startswith ("-----" ) # Not PEM format
58+ and not value .startswith ("ssh-" ) # Not SSH public key
59+ and len (value ) < 500 # Paths are short
60+ and "\n " not in value # Keys have newlines
61+ )
62+
63+ if is_path :
64+ expanded_path = os .path .expanduser (value )
65+ if not os .path .isabs (expanded_path ):
66+ # Relative paths from home directory
67+ expanded_path = os .path .join (os .path .expanduser ("~" ), expanded_path )
68+
69+ if os .path .exists (expanded_path ):
70+ with open (expanded_path , "r" ) as f :
71+ return f .read ()
72+ # If path doesn't exist but looks like a path, raise error
73+ if value .startswith (("/" , "~" , "./" , "../" )):
74+ raise FileNotFoundError (f"Key file not found: { expanded_path } " )
75+
76+ # It's direct content
77+ return value
78+
79+ raise TypeError (f"Unsupported key input type: { type (value )} " )
80+
81+
2382def sftp_mkdirs (client , path , mode = 511 ):
2483 try :
2584 client .mkdir (path , mode )
@@ -31,7 +90,18 @@ def sftp_mkdirs(client, path, mode=511):
3190 raise # pragma: no cover
3291
3392
34- def load_ssh_key (ssh_key_buffer ):
93+ def load_ssh_key (ssh_key_input ):
94+ """Load SSH private key from various input types.
95+
96+ Args:
97+ ssh_key_input: str (path or content), bytes, or file-like object
98+
99+ Returns:
100+ paramiko private key object
101+ """
102+ key_content = normalize_key_input (ssh_key_input )
103+ ssh_key_buffer = StringIO (key_content )
104+
35105 # Build list of supported key classes.
36106 # Conditionally including DSSKey for backward compatibility with older
37107 # versions of paramiko
@@ -51,18 +121,208 @@ def load_ssh_key(ssh_key_buffer):
51121 raise Exception ("Invalid ssh private key" )
52122
53123
124+ def parse_hostkey (hostkey_input , hostname = None ):
125+ """Parse a host key from various input types.
126+
127+ Args:
128+ hostkey_input: str (path or content), bytes, or file-like object
129+ hostname: If provided, search for this host in known_hosts format
130+
131+ Returns:
132+ paramiko key object
133+ """
134+ hostkey_str = normalize_key_input (hostkey_input )
135+ if not hostkey_str :
136+ return None
137+
138+ lines = hostkey_str .strip ().split ("\n " )
139+
140+ for line in lines :
141+ line = line .strip ()
142+ if not line or line .startswith ("#" ):
143+ continue
144+
145+ parts = line .split ()
146+
147+ # known_hosts format: hostname key-type key-data [comment]
148+ # direct format: key-type key-data [comment]
149+ if len (parts ) >= 3 and not parts [0 ].startswith ("ssh-" ):
150+ # known_hosts format
151+ host_field , key_type , key_data = parts [0 ], parts [1 ], parts [2 ]
152+ # Check if hostname matches (supports comma-separated hosts)
153+ if hostname :
154+ hosts = host_field .split ("," )
155+ if not any (
156+ h == hostname or h .startswith (f"[{ hostname } ]" ) for h in hosts
157+ ):
158+ continue
159+ elif len (parts ) >= 2 :
160+ # direct format: key-type key-data
161+ key_type , key_data = parts [0 ], parts [1 ]
162+ else :
163+ continue
164+
165+ try :
166+ key_bytes = base64 .b64decode (key_data )
167+ except Exception :
168+ continue
169+
170+ try :
171+ if key_type == "ssh-rsa" :
172+ return paramiko .RSAKey (data = key_bytes )
173+ elif key_type == "ssh-ed25519" :
174+ return paramiko .Ed25519Key (data = key_bytes )
175+ elif key_type .startswith ("ecdsa-" ):
176+ return paramiko .ECDSAKey (data = key_bytes )
177+ elif key_type == "ssh-dss" and hasattr (paramiko , "DSSKey" ):
178+ return paramiko .DSSKey (data = key_bytes )
179+ except paramiko .SSHException :
180+ continue
181+
182+ raise ValueError (f"No valid host key found for { hostname or 'server' } " )
183+
184+
185+ def _log_verbose (backend , message , * args ):
186+ """Log message only if verbose logging is enabled."""
187+ if backend .sftp_verbose_logging :
188+ _logger .info (message , * args )
189+
190+
54191@contextmanager
55192def sftp (backend ):
56- transport = paramiko .Transport ((backend .sftp_server , backend .sftp_port ))
57- if backend .sftp_auth_method == "pwd" :
58- transport .connect (username = backend .sftp_login , password = backend .sftp_password )
59- elif backend .sftp_auth_method == "ssh_key" :
60- ssh_key_buffer = StringIO (backend .sftp_ssh_private_key )
61- private_key = load_ssh_key (ssh_key_buffer )
62- transport .connect (username = backend .sftp_login , pkey = private_key )
193+ _log_verbose (
194+ backend ,
195+ "SFTP: Connecting to %s:%s as %s (auth=%s, legacy=%s, verify_hostkey=%s)" ,
196+ backend .sftp_server ,
197+ backend .sftp_port ,
198+ backend .sftp_login ,
199+ backend .sftp_auth_method ,
200+ backend .sftp_legacy_algorithms ,
201+ backend .sftp_verify_hostkey ,
202+ )
203+
204+ # Enable paramiko debug logging if verbose mode
205+ if backend .sftp_verbose_logging :
206+ logging .getLogger ("paramiko" ).setLevel (logging .DEBUG )
207+
208+ # For legacy servers, disable newer rsa-sha2-* algorithms
209+ # so paramiko falls back to ssh-rsa (SHA-1) for signing
210+ disabled_algorithms = None
211+ if backend .sftp_legacy_algorithms :
212+ disabled_algorithms = {
213+ "pubkeys" : ["rsa-sha2-256" , "rsa-sha2-512" ],
214+ }
215+ _log_verbose (backend , "SFTP: Disabling algorithms: %s" , disabled_algorithms )
216+
217+ transport = paramiko .Transport (
218+ (backend .sftp_server , backend .sftp_port ),
219+ disabled_algorithms = disabled_algorithms ,
220+ )
221+
222+ # Configure legacy algorithms if enabled (for older servers like banks)
223+ if backend .sftp_legacy_algorithms :
224+ security_options = transport .get_security_options ()
225+ _log_verbose (backend , "SFTP: Original key_types: %s" , security_options .key_types )
226+ _log_verbose (backend , "SFTP: Original kex: %s" , security_options .kex )
227+ # Force ssh-rsa at the beginning for both host key AND public key auth
228+ security_options .key_types = ("ssh-rsa" ,) + tuple (
229+ k for k in security_options .key_types if k != "ssh-rsa"
230+ )
231+ _log_verbose (backend , "SFTP: Modified key_types: %s" , security_options .key_types )
232+
233+ # Prepare hostkey verification if enabled
234+ hostkey = None
235+ if backend .sftp_verify_hostkey and backend .sftp_hostkey :
236+ _log_verbose (backend , "SFTP: Parsing hostkey for %s" , backend .sftp_server )
237+ hostkey = parse_hostkey (backend .sftp_hostkey , hostname = backend .sftp_server )
238+ _log_verbose (backend , "SFTP: Hostkey parsed: %s" , type (hostkey ).__name__ )
239+
240+ # Start transport (key exchange) separately to inspect server capabilities
241+ try :
242+ _log_verbose (backend , "SFTP: Starting key exchange..." )
243+ transport .start_client ()
244+
245+ # Log server information AFTER key exchange
246+ _log_verbose (backend , "SFTP: Server version: %s" , transport .remote_version )
247+ if hasattr (transport , "remote_cipher" ):
248+ _log_verbose (backend , "SFTP: Remote cipher: %s" , transport .remote_cipher )
249+ if hasattr (transport , "local_cipher" ):
250+ _log_verbose (backend , "SFTP: Local cipher: %s" , transport .local_cipher )
251+
252+ # Get the server's host key
253+ server_key = transport .get_remote_server_key ()
254+ _log_verbose (
255+ backend ,
256+ "SFTP: Server host key: %s (fingerprint: %s)" ,
257+ server_key .get_name (),
258+ server_key .get_fingerprint ().hex (),
259+ )
260+
261+ # Verify hostkey if enabled
262+ if hostkey :
263+ if server_key .get_name () != hostkey .get_name ():
264+ raise paramiko .SSHException (
265+ f"Host key type mismatch: expected { hostkey .get_name ()} , "
266+ f"got { server_key .get_name ()} "
267+ )
268+ if server_key .asbytes () != hostkey .asbytes ():
269+ raise paramiko .SSHException (
270+ "Host key verification failed! "
271+ "Server key does not match expected key."
272+ )
273+ _log_verbose (backend , "SFTP: Host key verified successfully" )
274+
275+ # Now authenticate
276+ if backend .sftp_auth_method == "pwd" :
277+ _log_verbose (backend , "SFTP: Authenticating with password..." )
278+ transport .auth_password (
279+ username = backend .sftp_login ,
280+ password = backend .sftp_password ,
281+ )
282+ elif backend .sftp_auth_method == "ssh_key" :
283+ _log_verbose (
284+ backend ,
285+ "SFTP: Loading private key from: %s" ,
286+ backend .sftp_ssh_private_key [:50 ] + "..."
287+ if len (backend .sftp_ssh_private_key or "" ) > 50
288+ else backend .sftp_ssh_private_key ,
289+ )
290+ private_key = load_ssh_key (backend .sftp_ssh_private_key )
291+ _log_verbose (
292+ backend ,
293+ "SFTP: Private key loaded: %s (fingerprint: %s)" ,
294+ type (private_key ).__name__ ,
295+ private_key .get_fingerprint ().hex (),
296+ )
297+ _log_verbose (backend , "SFTP: Authenticating with public key..." )
298+ transport .auth_publickey (
299+ username = backend .sftp_login ,
300+ key = private_key ,
301+ )
302+ _log_verbose (backend , "SFTP: Authentication successful!" )
303+ except paramiko .AuthenticationException as e :
304+ _logger .error ("SFTP: Authentication failed: %s" , e )
305+ # Try to get info about what the server accepts
306+ try :
307+ transport .auth_none (backend .sftp_login )
308+ except paramiko .BadAuthenticationType as auth_err :
309+ _logger .error (
310+ "SFTP: Server accepts auth methods: %s" , auth_err .allowed_types
311+ )
312+ except Exception :
313+ pass
314+ transport .close ()
315+ raise
316+ except Exception as e :
317+ _logger .error ("SFTP: Connection failed: %s: %s" , type (e ).__name__ , e )
318+ transport .close ()
319+ raise
320+
63321 client = paramiko .SFTPClient .from_transport (transport )
322+ _log_verbose (backend , "SFTP: SFTP client created successfully" )
64323 yield client
65324 transport .close ()
325+ _log_verbose (backend , "SFTP: Connection closed" )
66326
67327
68328class SFTPStorageBackendAdapter (Component ):
0 commit comments