Skip to content

Commit 529dd97

Browse files
committed
Fix manual prompt in pyopenssl adapter for private key password
- If pyopenssl adapter was used with password protected private key, the manual entry option was not given, only a fail due to invalid password. The password callback was triggered also in the case where the private_key_password was None. - Added Callable type as possible private_key_password argument
1 parent 0a6088c commit 529dd97

8 files changed

Lines changed: 98 additions & 19 deletions

File tree

cheroot/ssl/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import socket as _socket
44
from abc import ABC, abstractmethod
5+
from getpass import getpass as _ask_for_password_interactively
56
from warnings import warn as _warn
67

78
from .. import errors as _errors
@@ -113,3 +114,8 @@ def get_environ(self):
113114
def makefile(self, sock, mode='r', bufsize=-1):
114115
"""Return socket file object."""
115116
raise NotImplementedError # pragma: no cover
117+
118+
def _prompt_for_tls_password(self) -> str:
119+
"""Define interactive prompt for encrypted private key password."""
120+
prompt = 'Enter PEM pass phrase: '
121+
return _ask_for_password_interactively(prompt)

cheroot/ssl/__init__.pyi

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ class Adapter(ABC):
66
private_key: _t.Any
77
certificate_chain: _t.Any
88
ciphers: _t.Any
9-
private_key_password: str | bytes | None
9+
private_key_password: _t.Callable[[], bytes | str] | bytes | str | None
1010
context: _t.Any
11+
1112
@abstractmethod
1213
def __init__(
1314
self,
@@ -16,7 +17,10 @@ class Adapter(ABC):
1617
certificate_chain: _t.Any | None = ...,
1718
ciphers: _t.Any | None = ...,
1819
*,
19-
private_key_password: str | bytes | None = ...,
20+
private_key_password: _t.Callable[[], bytes | str]
21+
| bytes
22+
| str
23+
| None = ...,
2024
): ...
2125
def bind(self, sock): ...
2226
@abstractmethod
@@ -25,3 +29,4 @@ class Adapter(ABC):
2529
def get_environ(self): ...
2630
@abstractmethod
2731
def makefile(self, sock, mode: str = ..., bufsize: int = ...): ...
32+
def _prompt_for_tls_password(self) -> str: ...

cheroot/ssl/builtin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,9 @@ def __init__(
247247
private_key_password=private_key_password,
248248
)
249249

250+
if private_key_password is None:
251+
private_key_password = self._prompt_for_tls_password
252+
250253
self.context = ssl.create_default_context(
251254
purpose=ssl.Purpose.CLIENT_AUTH,
252255
cafile=certificate_chain,

cheroot/ssl/builtin.pyi

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ class BuiltinSSLAdapter(Adapter):
1414
certificate_chain: _t.Any | None = ...,
1515
ciphers: _t.Any | None = ...,
1616
*,
17-
private_key_password: str | bytes | None = ...,
17+
private_key_password: _t.Callable[[], bytes | str]
18+
| bytes
19+
| str
20+
| None = ...,
1821
) -> None: ...
1922
@property
2023
def context(self): ...

cheroot/ssl/pyopenssl.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,11 +352,20 @@ def wrap(self, sock):
352352
def _password_callback(
353353
self,
354354
password_max_length,
355-
_verify_twice,
356-
password,
355+
verify_twice,
356+
password_or_callback,
357357
/,
358358
):
359359
"""Pass a passphrase to password protected private key."""
360+
if callable(password_or_callback):
361+
password = password_or_callback()
362+
if verify_twice and password != password_or_callback():
363+
raise ValueError(
364+
'Verification failed: entered passwords do not match',
365+
) from None
366+
else:
367+
password = password_or_callback
368+
360369
b_password = b'' # returning a falsy value communicates an error
361370
if isinstance(password, str):
362371
b_password = password.encode('utf-8')
@@ -381,6 +390,8 @@ def get_context(self):
381390
"""
382391
# See https://code.activestate.com/recipes/442473/
383392
c = SSL.Context(SSL.SSLv23_METHOD)
393+
if self.private_key_password is None:
394+
self.private_key_password = self._prompt_for_tls_password
384395
c.set_passwd_cb(self._password_callback, self.private_key_password)
385396
c.use_privatekey_file(self.private_key)
386397
if self.certificate_chain:

cheroot/ssl/pyopenssl.pyi

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,20 @@ class pyOpenSSLAdapter(Adapter):
3232
certificate_chain: _t.Any | None = ...,
3333
ciphers: _t.Any | None = ...,
3434
*,
35-
private_key_password: str | bytes | None = ...,
35+
private_key_password: _t.Callable[[], bytes | str]
36+
| bytes
37+
| str
38+
| None = ...,
3639
) -> None: ...
3740
def wrap(self, sock): ...
3841
def _password_callback(
3942
self,
4043
password_max_length: int,
41-
_verify_twice: bool,
42-
password: bytes | str | None,
44+
verify_twice: bool,
45+
password_or_callback: _t.Callable[[], bytes | str]
46+
| bytes
47+
| str
48+
| None,
4349
/,
4450
) -> bytes: ...
4551
def get_environ(self): ...

cheroot/test/test_ssl.py

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,18 @@ def make_tls_http_server(bind_addr, ssl_adapter, request):
149149
return httpserver
150150

151151

152+
def get_key_password():
153+
"""Return a predefined password string.
154+
155+
It is to be used for decrypting private keys.
156+
"""
157+
return 'криївка'
158+
159+
152160
@pytest.fixture(scope='session')
153161
def private_key_password():
154162
"""Provide hardcoded password for private key."""
155-
return 'криївка'
163+
return get_key_password()
156164

157165

158166
@pytest.fixture
@@ -900,9 +908,17 @@ def test_http_over_https_ssl_handshake(
900908
ids=('encrypted-key', 'unencrypted-key'),
901909
)
902910
@pytest.mark.parametrize(
903-
'password_as_bytes',
904-
(True, False),
905-
ids=('with-bytes-password', 'with-str-password'),
911+
'transform_password_arg',
912+
(
913+
lambda pass_factory: pass_factory().encode('utf-8'),
914+
lambda pass_factory: pass_factory(),
915+
lambda pass_factory: pass_factory,
916+
),
917+
ids=(
918+
'with-bytes-password',
919+
'with-str-password',
920+
'with-callable-password-provider',
921+
),
906922
)
907923
# pylint: disable-next=too-many-positional-arguments
908924
def test_ssl_adapters_with_private_key_password(
@@ -915,25 +931,21 @@ def test_ssl_adapters_with_private_key_password(
915931
tls_certificate_private_key_pem_path,
916932
adapter_type,
917933
encrypted_key,
918-
password_as_bytes,
934+
transform_password_arg,
919935
):
920936
"""Check server decrypts private TLS keys with password as bytes or str."""
921937
key_file = (
922938
tls_certificate_passwd_private_key_pem_path
923939
if encrypted_key
924940
else tls_certificate_private_key_pem_path
925941
)
926-
key_pass = (
927-
private_key_password.encode('utf-8')
928-
if password_as_bytes
929-
else private_key_password
930-
)
942+
private_key_password = transform_password_arg(get_key_password)
931943

932944
tls_adapter_cls = get_ssl_adapter_class(name=adapter_type)
933945
tls_adapter = tls_adapter_cls(
934946
certificate=tls_certificate_chain_pem_path,
935947
private_key=key_file,
936-
private_key_password=key_pass,
948+
private_key_password=private_key_password,
937949
)
938950

939951
interface, _host, port = _get_conn_data(
@@ -1015,6 +1027,32 @@ def test_openssl_adapter_with_false_key_password(
10151027
)
10161028

10171029

1030+
@pytest.mark.parametrize(
1031+
'adapter_type',
1032+
('pyopenssl', 'builtin'),
1033+
)
1034+
def test_openssl_adapter_with_none_key_password(
1035+
tls_certificate_chain_pem_path,
1036+
tls_certificate_passwd_private_key_pem_path,
1037+
private_key_password,
1038+
adapter_type,
1039+
monkeypatch,
1040+
):
1041+
"""Check that TLS-adapters prompt for password when set as ``None``."""
1042+
tls_adapter_cls = get_ssl_adapter_class(name=adapter_type)
1043+
monkeypatch.setattr(
1044+
sys.modules['cheroot.ssl'],
1045+
'_ask_for_password_interactively',
1046+
lambda prompt: private_key_password,
1047+
)
1048+
tls_adapter = tls_adapter_cls(
1049+
certificate=tls_certificate_chain_pem_path,
1050+
private_key=tls_certificate_passwd_private_key_pem_path,
1051+
)
1052+
1053+
assert tls_adapter.context is not None
1054+
1055+
10181056
@pytest.fixture
10191057
def dummy_adapter(monkeypatch):
10201058
"""Provide a dummy SSL adapter instance."""
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Fixed prompting for the encrypted private key password interactively,
2+
when the password in not set in the :py:attr:`private_key_password attribute
3+
<cheroot.ssl.pyopenssl.pyOpenSSLAdapter.private_key_password>` in the
4+
:py:class:`pyOpenSSL TLS adapter <cheroot.ssl.pyopenssl.pyOpenSSLAdapter>`.
5+
Also improved the private key password to accept the :py:class:`~typing.Callable` type.
6+
7+
-- by :user:`jatalahd`

0 commit comments

Comments
 (0)