Skip to content

Commit afc39cd

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 bfa7f97 commit afc39cd

File tree

8 files changed

+103
-12
lines changed

8 files changed

+103
-12
lines changed

cheroot/ssl/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Implementation of the SSL adapter base interface."""
22

33
from abc import ABC, abstractmethod
4+
from getpass import getpass as _ask_for_password_interactively
45
from warnings import warn as _warn
56

67

@@ -48,6 +49,11 @@ def bind(self, sock):
4849
)
4950
return sock
5051

52+
def prompt_for_tls_password(self) -> str:
53+
"""Define interactive prompt for encrypted private key password."""
54+
prompt = 'Enter PEM pass phrase:'
55+
return _ask_for_password_interactively(prompt)
56+
5157
@abstractmethod
5258
def wrap(self, sock):
5359
"""Wrap the given socket and return WSGI environ entries."""

cheroot/ssl/__init__.pyi

Lines changed: 3 additions & 1 deletion
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: str | bytes | _t.Callable[[], bytes | str] | None
1010
context: _t.Any
11+
1112
@abstractmethod
1213
def __init__(
1314
self,
@@ -19,6 +20,7 @@ class Adapter(ABC):
1920
private_key_password: str | bytes | None = ...,
2021
): ...
2122
def bind(self, sock): ...
23+
def prompt_for_tls_password(self): ...
2224
@abstractmethod
2325
def wrap(self, sock): ...
2426
@abstractmethod

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: str
18+
| bytes
19+
| _t.Callable[[], bytes | 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
@@ -347,11 +347,20 @@ def wrap(self, sock):
347347
def _password_callback(
348348
self,
349349
password_max_length,
350-
_verify_twice,
351-
password,
350+
verify_twice,
351+
password_or_callback,
352352
/,
353353
):
354354
"""Pass a passphrase to password protected private key."""
355+
if callable(password_or_callback):
356+
password = password_or_callback()
357+
if verify_twice and password != password_or_callback():
358+
raise ValueError(
359+
'Verification failed: entered passwords do not match',
360+
) from None
361+
else:
362+
password = password_or_callback
363+
355364
b_password = b'' # returning a falsy value communicates an error
356365
if isinstance(password, str):
357366
b_password = password.encode('utf-8')
@@ -376,6 +385,8 @@ def get_context(self):
376385
"""
377386
# See https://code.activestate.com/recipes/442473/
378387
c = SSL.Context(SSL.SSLv23_METHOD)
388+
if self.private_key_password is None:
389+
self.private_key_password = self.prompt_for_tls_password
379390
c.set_passwd_cb(self._password_callback, self.private_key_password)
380391
c.use_privatekey_file(self.private_key)
381392
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: str
36+
| bytes
37+
| _t.Callable[[], bytes | 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: str
46+
| bytes
47+
| _t.Callable[[], bytes | str]
48+
| None,
4349
/,
4450
) -> bytes: ...
4551
def get_environ(self): ...

cheroot/test/test_ssl.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
load_pem_private_key,
2727
)
2828

29+
import cheroot.ssl as _cheroot_ssl
2930
from cheroot.ssl import Adapter
3031

3132
from .._compat import (
@@ -149,10 +150,15 @@ def make_tls_http_server(bind_addr, ssl_adapter, request):
149150
return httpserver
150151

151152

153+
def get_key_password():
154+
"""Provide hardcoded password for private key as callable."""
155+
return 'криївка'
156+
157+
152158
@pytest.fixture(scope='session')
153159
def private_key_password():
154160
"""Provide hardcoded password for private key."""
155-
return 'криївка'
161+
return get_key_password()
156162

157163

158164
@pytest.fixture
@@ -810,8 +816,8 @@ def test_http_over_https_error(
810816
)
811817
@pytest.mark.parametrize(
812818
'password_as_bytes',
813-
(True, False),
814-
ids=('with-bytes-password', 'with-str-password'),
819+
(True, False, None),
820+
ids=('with-bytes-password', 'with-str-password', 'with-callable-password'),
815821
)
816822
# pylint: disable-next=too-many-positional-arguments
817823
def test_ssl_adapters_with_private_key_password(
@@ -833,9 +839,13 @@ def test_ssl_adapters_with_private_key_password(
833839
else tls_certificate_private_key_pem_path
834840
)
835841
key_pass = (
836-
private_key_password.encode('utf-8')
842+
private_key_password.encode('utf-8') # use bytes
837843
if password_as_bytes
838-
else private_key_password
844+
else (
845+
get_key_password # use callable
846+
if password_as_bytes is None
847+
else private_key_password # use str
848+
)
839849
)
840850

841851
tls_adapter_cls = get_ssl_adapter_class(name=adapter_type)
@@ -924,6 +934,50 @@ def test_openssl_adapter_with_false_key_password(
924934
)
925935

926936

937+
@pytest.mark.parametrize(
938+
'adapter_type',
939+
('pyopenssl', 'builtin'),
940+
)
941+
# pylint: disable-next=too-many-positional-arguments
942+
def test_openssl_adapter_with_none_key_password(
943+
http_request_timeout,
944+
tls_certificate_chain_pem_path,
945+
tls_certificate_passwd_private_key_pem_path,
946+
tls_ca_certificate_pem_path,
947+
tls_http_server,
948+
private_key_password,
949+
adapter_type,
950+
monkeypatch,
951+
):
952+
"""Check that openssl ssl-adapter prompts password when set as None."""
953+
tls_adapter_cls = get_ssl_adapter_class(name=adapter_type)
954+
monkeypatch.setattr(
955+
_cheroot_ssl,
956+
'_ask_for_password_interactively',
957+
lambda prompt: private_key_password,
958+
)
959+
tls_adapter = tls_adapter_cls(
960+
certificate=tls_certificate_chain_pem_path,
961+
private_key=tls_certificate_passwd_private_key_pem_path,
962+
)
963+
964+
interface, _host, port = _get_conn_data(
965+
tls_http_server(
966+
(ANY_INTERFACE_IPV4, EPHEMERAL_PORT),
967+
tls_adapter,
968+
).bind_addr,
969+
)
970+
971+
resp = requests.get(
972+
f'https://{interface!s}:{port!s}/',
973+
timeout=http_request_timeout,
974+
verify=tls_ca_certificate_pem_path,
975+
)
976+
977+
assert resp.status_code == 200
978+
assert resp.text == 'Hello world!'
979+
980+
927981
@pytest.fixture
928982
def dummy_adapter(monkeypatch):
929983
"""Provide a dummy SSL adapter instance."""
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
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>` in the
4+
:py:class:`pyOpenSSL TLS adapter <cheroot.ssl.pyopenssl.pyOpenSSLAdapter>`.
5+
Also improved the private key password to accept Callable type.
6+
-- by :user:`jatalahd`

0 commit comments

Comments
 (0)