Skip to content

Commit 9c9b678

Browse files
authored
Pkey from memory (#329)
* Added private key auth from memory implementation for native client * Updated gitignore * Updated parallel clients to use in-memory pkey data * Added tests * Bump requirements * Updated changelog * Updated documentation
1 parent 3215d05 commit 9c9b678

14 files changed

+134
-24
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,5 @@ doc/_build
4747

4848
tests/unit_test_cert_key-cert.pub
4949
tests/embedded_server/principals
50+
tests/embedded_server/sshd_config_*
51+
tests/embedded_server/*.pid

Changelog.rst

+13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
Change Log
22
============
33

4+
2.8.0
5+
+++++
6+
7+
Changes
8+
--------
9+
10+
* All clients now support private key data as bytes in ``pkey`` parameter for authentication from in-memory private key
11+
data - #317. See `documentation <https://parallel-ssh.readthedocs.io/en/latest/advanced.html#in-memory-private-keys>`_
12+
for examples.
13+
* Parallel clients now read a provided private key path only once and use in-memory data for authentication to avoid
14+
reading same file multiple times, if a path is provided.
15+
16+
417
2.7.1
518
+++++
619

doc/advanced.rst

+18
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,24 @@ A private key can also be provided programmatically.
2424
Where ``my_key`` is a private key file under `.ssh` in the user's home directory.
2525

2626

27+
In-Memory Private Keys
28+
========================
29+
30+
Private key data can also be provided as bytes for authentication from in-memory private keys.
31+
32+
.. code-block:: python
33+
34+
from pssh.clients import ParallelSSHClient
35+
36+
pkey_data = b"""-----BEGIN RSA PRIVATE KEY-----
37+
<key data>
38+
-----END RSA PRIVATE KEY-----
39+
"""
40+
client = ParallelSSHClient(hosts, pkey=pkey_data)
41+
42+
Private key data provided this way *must* be in bytes. This is supported by all parallel and single host clients.
43+
44+
2745
Native Clients
2846
***************
2947

pssh/clients/base/single.py

+19-6
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from ssh2.exceptions import AgentConnectionError, AgentListIdentitiesError, \
2929
AgentAuthenticationError, AgentGetIdentityError
3030

31-
from ..common import _validate_pkey_path
31+
from ..common import _validate_pkey
3232
from ...constants import DEFAULT_RETRIES, RETRY_DELAY
3333
from ..reader import ConcurrentRWBuffer
3434
from ...exceptions import UnknownHostError, AuthenticationError, \
@@ -182,12 +182,15 @@ def __init__(self, host,
182182
self.session = None
183183
self._host = proxy_host if proxy_host else host
184184
self._port = proxy_port if proxy_port else self.port
185-
self.pkey = _validate_pkey_path(pkey, self.host)
185+
self.pkey = _validate_pkey(pkey)
186186
self.identity_auth = identity_auth
187187
self._keepalive_greenlet = None
188188
self.ipv6_only = ipv6_only
189189
self._init()
190190

191+
def _pkey_from_memory(self, pkey_data):
192+
raise NotImplementedError
193+
191194
def _init(self):
192195
self._connect(self._host, self._port)
193196
self._init_session()
@@ -309,7 +312,7 @@ def _identity_auth(self):
309312
"Trying to authenticate with identity file %s",
310313
identity_file)
311314
try:
312-
self._pkey_auth(identity_file, password=self.password)
315+
self._pkey_file_auth(identity_file, password=self.password)
313316
except Exception as ex:
314317
logger.debug(
315318
"Authentication with identity file %s failed with %s, "
@@ -331,8 +334,8 @@ def _keepalive(self):
331334
def auth(self):
332335
if self.pkey is not None:
333336
logger.debug(
334-
"Proceeding with private key file authentication")
335-
return self._pkey_auth(self.pkey, password=self.password)
337+
"Proceeding with private key authentication")
338+
return self._pkey_auth(self.pkey)
336339
if self.allow_agent:
337340
try:
338341
self._agent_auth()
@@ -364,7 +367,17 @@ def _agent_auth(self):
364367
def _password_auth(self):
365368
raise NotImplementedError
366369

367-
def _pkey_auth(self, pkey_file, password=None):
370+
def _pkey_auth(self, pkey):
371+
_pkey = pkey
372+
if isinstance(pkey, str):
373+
logger.debug("Private key is provided as str, loading from private key file path")
374+
with open(pkey, 'rb') as fh:
375+
_pkey = fh.read()
376+
elif isinstance(pkey, bytes):
377+
logger.debug("Private key is provided in bytes, using as private key data")
378+
return self._pkey_from_memory(_pkey)
379+
380+
def _pkey_file_auth(self, pkey_file, password=None):
368381
raise NotImplementedError
369382

370383
def _open_session(self):

pssh/clients/common.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from ..exceptions import PKeyFileError
2121

2222

23-
def _validate_pkey_path(pkey, host=None):
23+
def _validate_pkey_path(pkey):
2424
if pkey is None:
2525
return
2626
pkey = os.path.normpath(os.path.expanduser(pkey))
@@ -31,3 +31,11 @@ def _validate_pkey_path(pkey, host=None):
3131
ex = PKeyFileError(msg, pkey)
3232
raise ex
3333
return pkey
34+
35+
36+
def _validate_pkey(pkey):
37+
if pkey is None:
38+
return
39+
if isinstance(pkey, str):
40+
return _validate_pkey_path(pkey)
41+
return pkey

pssh/clients/native/parallel.py

+13-6
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import logging
1919

2020
from .single import SSHClient
21-
from ..common import _validate_pkey_path
21+
from ..common import _validate_pkey
2222
from ..base.parallel import BaseParallelSSHClient
2323
from ...constants import DEFAULT_RETRIES, RETRY_DELAY
2424
from ...exceptions import HostArgumentError
@@ -50,9 +50,11 @@ def __init__(self, hosts, user=None, password=None, port=22, pkey=None,
5050
:param port: (Optional) Port number to use for SSH connection. Defaults
5151
to 22.
5252
:type port: int
53-
:param pkey: Private key file path to use. Path must be either absolute
53+
:param pkey: Private key file path or private key data to use.
54+
Paths must be str type and either absolute
5455
path or relative to user home directory like ``~/<path>``.
55-
:type pkey: str
56+
Bytes type input is used as private key data for authentication.
57+
:type pkey: str or bytes
5658
:param num_retries: (Optional) Number of connection and authentication
5759
attempts before the client gives up. Defaults to 3.
5860
:type num_retries: int
@@ -127,10 +129,10 @@ def __init__(self, hosts, user=None, password=None, port=22, pkey=None,
127129
identity_auth=identity_auth,
128130
ipv6_only=ipv6_only,
129131
)
130-
self.pkey = _validate_pkey_path(pkey)
132+
self.pkey = _validate_pkey(pkey)
131133
self.proxy_host = proxy_host
132134
self.proxy_port = proxy_port
133-
self.proxy_pkey = _validate_pkey_path(proxy_pkey)
135+
self.proxy_pkey = _validate_pkey(proxy_pkey)
134136
self.proxy_user = proxy_user
135137
self.proxy_password = proxy_password
136138
self.forward_ssh_agent = forward_ssh_agent
@@ -235,9 +237,14 @@ def _make_ssh_client(self, host_i, host):
235237
or self._host_clients[(host_i, host)] is None:
236238
_user, _port, _password, _pkey, proxy_host, proxy_port, proxy_user, \
237239
proxy_password, proxy_pkey = self._get_host_config_values(host_i, host)
240+
if isinstance(self.pkey, str):
241+
with open(_pkey, 'rb') as fh:
242+
_pkey_data = fh.read()
243+
else:
244+
_pkey_data = _pkey
238245
_client = SSHClient(
239246
host, user=_user, password=_password, port=_port,
240-
pkey=_pkey, num_retries=self.num_retries,
247+
pkey=_pkey_data, num_retries=self.num_retries,
241248
timeout=self.timeout,
242249
allow_agent=self.allow_agent, retry_delay=self.retry_delay,
243250
proxy_host=proxy_host,

pssh/clients/native/single.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ def __init__(self, host,
7575
:param pkey: Private key file path to use for authentication. Path must
7676
be either absolute path or relative to user home directory
7777
like ``~/<path>``.
78-
:type pkey: str
78+
Bytes type input is used as private key data for authentication.
79+
:type pkey: str or bytes
7980
:param num_retries: (Optional) Number of connection and authentication
8081
attempts before the client gives up. Defaults to 3.
8182
:type num_retries: int
@@ -239,12 +240,19 @@ def _keepalive(self):
239240
def _agent_auth(self):
240241
self.session.agent_auth(self.user)
241242

242-
def _pkey_auth(self, pkey_file, password=None):
243+
def _pkey_file_auth(self, pkey_file, password=None):
243244
self.session.userauth_publickey_fromfile(
244245
self.user,
245246
pkey_file,
246247
passphrase=password if password is not None else b'')
247248

249+
def _pkey_from_memory(self, pkey_data):
250+
self.session.userauth_publickey_frommemory(
251+
self.user,
252+
pkey_data,
253+
passphrase=self.password if self.password is not None else b'',
254+
)
255+
248256
def _password_auth(self):
249257
self.session.userauth_password(self.user, self.password)
250258

pssh/clients/ssh/parallel.py

+10-4
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import logging
1919

2020
from .single import SSHClient
21-
from ..common import _validate_pkey_path
21+
from ..common import _validate_pkey_path, _validate_pkey
2222
from ..base.parallel import BaseParallelSSHClient
2323
from ...constants import DEFAULT_RETRIES, RETRY_DELAY
2424

@@ -54,7 +54,8 @@ def __init__(self, hosts, user=None, password=None, port=22, pkey=None,
5454
:type port: int
5555
:param pkey: Private key file path to use. Path must be either absolute
5656
path or relative to user home directory like ``~/<path>``.
57-
:type pkey: str
57+
Bytes type input is used as private key data for authentication.
58+
:type pkey: str or bytes
5859
:param cert_file: Public key signed certificate file to use for
5960
authentication. The corresponding private key must also be provided
6061
via ``pkey`` parameter.
@@ -141,7 +142,7 @@ def __init__(self, hosts, user=None, password=None, port=22, pkey=None,
141142
identity_auth=identity_auth,
142143
ipv6_only=ipv6_only,
143144
)
144-
self.pkey = _validate_pkey_path(pkey)
145+
self.pkey = _validate_pkey(pkey)
145146
self.cert_file = _validate_pkey_path(cert_file)
146147
self.forward_ssh_agent = forward_ssh_agent
147148
self.gssapi_auth = gssapi_auth
@@ -235,9 +236,14 @@ def _make_ssh_client(self, host_i, host):
235236
or self._host_clients[(host_i, host)] is None:
236237
_user, _port, _password, _pkey, _, _, _, _, _ = \
237238
self._get_host_config_values(host_i, host)
239+
if isinstance(self.pkey, str):
240+
with open(_pkey, 'rb') as fh:
241+
_pkey_data = fh.read()
242+
else:
243+
_pkey_data = _pkey
238244
_client = SSHClient(
239245
host, user=_user, password=_password, port=_port,
240-
pkey=_pkey,
246+
pkey=_pkey_data,
241247
cert_file=self.cert_file,
242248
num_retries=self.num_retries,
243249
timeout=self.timeout,

pssh/clients/ssh/single.py

+15-4
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
from gevent import sleep, spawn, Timeout as GTimeout, joinall
2121
from ssh import options
2222
from ssh.session import Session, SSH_READ_PENDING, SSH_WRITE_PENDING
23-
from ssh.key import import_privkey_file, import_cert_file, copy_cert_to_privkey
23+
from ssh.key import import_privkey_file, import_cert_file, copy_cert_to_privkey,\
24+
import_privkey_base64
2425
from ssh.exceptions import EOF
2526
from ssh.error_codes import SSH_AGAIN
2627

@@ -62,7 +63,8 @@ def __init__(self, host,
6263
:param pkey: Private key file path to use for authentication. Path must
6364
be either absolute path or relative to user home directory
6465
like ``~/<path>``.
65-
:type pkey: str
66+
Bytes type input is used as private key data for authentication.
67+
:type pkey: str or bytes
6668
:param cert_file: Public key signed certificate file to use for
6769
authentication. The corresponding private key must also be provided
6870
via ``pkey`` parameter.
@@ -106,7 +108,7 @@ def __init__(self, host,
106108
:raises: :py:class:`pssh.exceptions.PKeyFileError` on errors finding
107109
provided private key.
108110
"""
109-
self.cert_file = _validate_pkey_path(cert_file, host)
111+
self.cert_file = _validate_pkey_path(cert_file)
110112
self.gssapi_auth = gssapi_auth
111113
self.gssapi_server_identity = gssapi_server_identity
112114
self.gssapi_client_identity = gssapi_client_identity
@@ -175,13 +177,22 @@ def auth(self):
175177
def _password_auth(self):
176178
self.session.userauth_password(self.user, self.password)
177179

178-
def _pkey_auth(self, pkey_file, password=None):
180+
def _pkey_file_auth(self, pkey_file, password=None):
179181
pkey = import_privkey_file(pkey_file, passphrase=password if password is not None else '')
182+
return self._pkey_obj_auth(pkey)
183+
184+
def _pkey_obj_auth(self, pkey):
180185
if self.cert_file is not None:
181186
logger.debug("Certificate file set - trying certificate authentication")
182187
self._import_cert_file(pkey)
183188
self.session.userauth_publickey(pkey)
184189

190+
def _pkey_from_memory(self, pkey_data):
191+
_pkey = import_privkey_base64(
192+
pkey_data,
193+
passphrase=self.password if self.password is not None else b'')
194+
return self._pkey_obj_auth(_pkey)
195+
185196
def _import_cert_file(self, pkey):
186197
cert_key = import_cert_file(self.cert_file)
187198
self.session.userauth_try_publickey(cert_key)

requirements.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
gevent>=1.3.0
2-
ssh2-python>=0.22.0
2+
ssh2-python>=0.27.0
33
ssh-python>=0.9.0

tests/native/test_parallel_client.py

+6
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ def test_connect_auth(self):
8787
client = ParallelSSHClient([self.host], pkey=self.user_key, port=self.port, num_retries=1)
8888
joinall(client.connect_auth(), raise_error=True)
8989

90+
def test_pkey_from_memory(self):
91+
with open(self.user_key, 'rb') as fh:
92+
key = fh.read()
93+
client = ParallelSSHClient([self.host], pkey=key, port=self.port, num_retries=1)
94+
joinall(client.connect_auth(), raise_error=True)
95+
9096
def test_client_shells(self):
9197
shells = self.client.open_shell()
9298
self.client.run_shell_commands(shells, self.cmd)

tests/native/test_single_client.py

+6
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ def test_scp_fail(self):
140140
finally:
141141
os.rmdir('adir')
142142

143+
def test_pkey_from_memory(self):
144+
with open(self.user_key, 'rb') as fh:
145+
key_data = fh.read()
146+
SSHClient(self.host, port=self.port,
147+
pkey=key_data, num_retries=1, timeout=1)
148+
143149
def test_execute(self):
144150
host_out = self.client.run_command(self.cmd)
145151
output = list(host_out.stdout)

tests/ssh/test_parallel_client.py

+6
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ def _session(timeout=1):
9595
client._host_clients[(0, self.host)].open_session = _session
9696
self.assertRaises(Timeout, client.run_command, self.cmd)
9797

98+
def test_pkey_from_memory(self):
99+
with open(self.user_key, 'rb') as fh:
100+
key = fh.read()
101+
client = ParallelSSHClient([self.host], pkey=key, port=self.port, num_retries=1)
102+
joinall(client.connect_auth(), raise_error=True)
103+
98104
def test_join_timeout(self):
99105
client = ParallelSSHClient([self.host], port=self.port,
100106
pkey=self.user_key)

tests/ssh/test_single_client.py

+6
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ def _session(timeout=2):
5353
client.open_session = _session
5454
self.assertRaises(GTimeout, client.run_command, self.cmd)
5555

56+
def test_pkey_from_memory(self):
57+
with open(self.user_key, 'rb') as fh:
58+
key_data = fh.read()
59+
SSHClient(self.host, port=self.port,
60+
pkey=key_data, num_retries=1, timeout=1)
61+
5662
def test_execute(self):
5763
host_out = self.client.run_command(self.cmd)
5864
output = list(host_out.stdout)

0 commit comments

Comments
 (0)