Skip to content

Commit 39cccaa

Browse files
authored
Merge pull request #798 from jatalahd/fix_openssl_private_key_none_passwd
Fix manual prompt in pyopenssl adapter for private key password
2 parents e3a56e5 + 75196e3 commit 39cccaa

File tree

8 files changed

+145
-19
lines changed

8 files changed

+145
-19
lines changed

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+
"""Prompt for encrypted private key password interactively."""
120+
prompt = 'Enter PEM pass phrase: '
121+
return _ask_for_password_interactively(prompt)

cheroot/ssl/__init__.pyi

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import collections.abc as _c
12
import typing as _t
23
from abc import ABC, abstractmethod
34

@@ -6,8 +7,9 @@ class Adapter(ABC):
67
private_key: _t.Any
78
certificate_chain: _t.Any
89
ciphers: _t.Any
9-
private_key_password: str | bytes | None
10+
private_key_password: _c.Callable[[], bytes | str] | bytes | str | None
1011
context: _t.Any
12+
1113
@abstractmethod
1214
def __init__(
1315
self,
@@ -16,7 +18,10 @@ class Adapter(ABC):
1618
certificate_chain: _t.Any | None = ...,
1719
ciphers: _t.Any | None = ...,
1820
*,
19-
private_key_password: str | bytes | None = ...,
21+
private_key_password: _c.Callable[[], bytes | str]
22+
| bytes
23+
| str
24+
| None = ...,
2025
): ...
2126
def bind(self, sock): ...
2227
@abstractmethod
@@ -25,3 +30,4 @@ class Adapter(ABC):
2530
def get_environ(self): ...
2631
@abstractmethod
2732
def makefile(self, sock, mode: str = ..., bufsize: int = ...): ...
33+
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: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import collections.abc as _c
12
import typing as _t
23

34
from . import Adapter
@@ -14,7 +15,10 @@ class BuiltinSSLAdapter(Adapter):
1415
certificate_chain: _t.Any | None = ...,
1516
ciphers: _t.Any | None = ...,
1617
*,
17-
private_key_password: str | bytes | None = ...,
18+
private_key_password: _c.Callable[[], bytes | str]
19+
| bytes
20+
| str
21+
| None = ...,
1822
) -> None: ...
1923
@property
2024
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: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import collections.abc as _c
12
import typing as _t
23

34
from OpenSSL import SSL
@@ -32,14 +33,20 @@ class pyOpenSSLAdapter(Adapter):
3233
certificate_chain: _t.Any | None = ...,
3334
ciphers: _t.Any | None = ...,
3435
*,
35-
private_key_password: str | bytes | None = ...,
36+
private_key_password: _c.Callable[[], bytes | str]
37+
| bytes
38+
| str
39+
| None = ...,
3640
) -> None: ...
3741
def wrap(self, sock): ...
3842
def _password_callback(
3943
self,
4044
password_max_length: int,
41-
_verify_twice: bool,
42-
password: bytes | str | None,
45+
verify_twice: bool,
46+
password_or_callback: _c.Callable[[], bytes | str]
47+
| bytes
48+
| str
49+
| None,
4350
/,
4451
) -> bytes: ...
4552
def get_environ(self): ...

cheroot/test/test_ssl.py

Lines changed: 93 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,76 @@ 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_ssl_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+
mocker,
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+
mocker.patch(
1044+
'cheroot.ssl._ask_for_password_interactively',
1045+
return_value=private_key_password,
1046+
)
1047+
tls_adapter = tls_adapter_cls(
1048+
certificate=tls_certificate_chain_pem_path,
1049+
private_key=tls_certificate_passwd_private_key_pem_path,
1050+
)
1051+
1052+
assert tls_adapter.context is not None
1053+
1054+
1055+
class PasswordCallbackHelper:
1056+
"""Collects helper methods for mocking password callback."""
1057+
1058+
def __init__(self, adapter: Adapter):
1059+
"""Initialize helper variables."""
1060+
self.counter = 0
1061+
self.callback = adapter._password_callback
1062+
1063+
def get_password(self):
1064+
"""Provide correct password on first call, wrong on other calls."""
1065+
self.counter += 1
1066+
return get_key_password() * self.counter
1067+
1068+
def verify_twice_callback(self, max_length, _verify_twice, userdata):
1069+
"""Establish a mock callback for testing two-factor password prompt."""
1070+
return self.callback(self, max_length, True, userdata)
1071+
1072+
1073+
@pytest.mark.parametrize('adapter_type', ('pyopenssl',))
1074+
def test_openssl_adapter_verify_twice_callback(
1075+
tls_certificate_chain_pem_path,
1076+
tls_certificate_passwd_private_key_pem_path,
1077+
adapter_type,
1078+
mocker,
1079+
):
1080+
"""Check that two-time password verification fails with correct error."""
1081+
tls_adapter_cls = get_ssl_adapter_class(name=adapter_type)
1082+
helper = PasswordCallbackHelper(tls_adapter_cls)
1083+
1084+
mocker.patch(
1085+
'cheroot.ssl.pyopenssl.pyOpenSSLAdapter._password_callback',
1086+
side_effect=helper.verify_twice_callback,
1087+
)
1088+
1089+
with pytest.raises(
1090+
ValueError,
1091+
match='Verification failed: entered passwords do not match',
1092+
):
1093+
tls_adapter_cls(
1094+
certificate=tls_certificate_chain_pem_path,
1095+
private_key=tls_certificate_passwd_private_key_pem_path,
1096+
private_key_password=helper.get_password,
1097+
)
1098+
1099+
10181100
@pytest.fixture
10191101
def dummy_adapter(monkeypatch):
10201102
"""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:`~collections.abc.Callable` type.
6+
7+
-- by :user:`jatalahd`

0 commit comments

Comments
 (0)