Skip to content

Commit 6d4f3d2

Browse files
committed
Add private key password option to ssl adapters
- It is now possible to use password protected private keys in both builtin and openssl ssl-adapters - Added also positive and negative unit test cases - With reference to #1583
1 parent 587d1fa commit 6d4f3d2

File tree

8 files changed

+217
-8
lines changed

8 files changed

+217
-8
lines changed

cheroot/ssl/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ def __init__(
2020
private_key,
2121
certificate_chain=None,
2222
ciphers=None,
23+
private_key_password=None,
2324
):
24-
"""Set up certificates, private key ciphers and reset context."""
25+
"""Set up certificates, private key, ciphers and reset context."""
2526
self.certificate = certificate
2627
self.private_key = private_key
2728
self.certificate_chain = certificate_chain
2829
self.ciphers = ciphers
30+
self.private_key_password = private_key_password
2931
self.context = None
3032

3133
@abstractmethod

cheroot/ssl/__init__.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ class Adapter(ABC):
66
private_key: Any
77
certificate_chain: Any
88
ciphers: Any
9+
private_key_password: str | bytes | None
910
context: Any
1011
@abstractmethod
1112
def __init__(
@@ -14,6 +15,7 @@ class Adapter(ABC):
1415
private_key,
1516
certificate_chain: Any | None = ...,
1617
ciphers: Any | None = ...,
18+
private_key_password: str | bytes | None = ...,
1719
): ...
1820
@abstractmethod
1921
def bind(self, sock): ...

cheroot/ssl/builtin.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,19 @@ def _loopback_for_cert_thread(context, server):
7676
ssl_sock.send(b'0000')
7777

7878

79-
def _loopback_for_cert(certificate, private_key, certificate_chain):
79+
def _loopback_for_cert(
80+
certificate,
81+
private_key,
82+
certificate_chain,
83+
private_key_password=None,
84+
):
8085
"""Create a loopback connection to parse a cert with a private key."""
8186
context = ssl.create_default_context(cafile=certificate_chain)
82-
context.load_cert_chain(certificate, private_key)
87+
context.load_cert_chain(
88+
certificate,
89+
private_key,
90+
password=private_key_password,
91+
)
8392
context.check_hostname = False
8493
context.verify_mode = ssl.CERT_NONE
8594

@@ -112,15 +121,25 @@ def _loopback_for_cert(certificate, private_key, certificate_chain):
112121
server.close()
113122

114123

115-
def _parse_cert(certificate, private_key, certificate_chain):
124+
def _parse_cert(
125+
certificate,
126+
private_key,
127+
certificate_chain,
128+
private_key_password=None,
129+
):
116130
"""Parse a certificate."""
117131
# loopback_for_cert uses socket.socketpair which was only
118132
# introduced in Python 3.0 for *nix and 3.5 for Windows
119133
# and requires OS support (AttributeError, OSError)
120134
# it also requires a private key either in its own file
121135
# or combined with the cert (SSLError)
122136
with suppress(AttributeError, ssl.SSLError, OSError):
123-
return _loopback_for_cert(certificate, private_key, certificate_chain)
137+
return _loopback_for_cert(
138+
certificate,
139+
private_key,
140+
certificate_chain,
141+
private_key_password=private_key_password,
142+
)
124143

125144
# KLUDGE: using an undocumented, private, test method to parse a cert
126145
# unfortunately, it is the only built-in way without a connection
@@ -153,6 +172,9 @@ class BuiltinSSLAdapter(Adapter):
153172
ciphers = None
154173
"""The ciphers list of SSL."""
155174

175+
private_key_password = None
176+
"""Optional passphrase for password protected private key."""
177+
156178
# from mod_ssl/pkg.sslmod/ssl_engine_vars.c ssl_var_lookup_ssl_cert
157179
CERT_KEY_TO_ENV = {
158180
'version': 'M_VERSION',
@@ -208,6 +230,7 @@ def __init__(
208230
private_key,
209231
certificate_chain=None,
210232
ciphers=None,
233+
private_key_password=None,
211234
):
212235
"""Set up context in addition to base class properties if available."""
213236
if ssl is None:
@@ -218,19 +241,29 @@ def __init__(
218241
private_key,
219242
certificate_chain,
220243
ciphers,
244+
private_key_password,
221245
)
222246

223247
self.context = ssl.create_default_context(
224248
purpose=ssl.Purpose.CLIENT_AUTH,
225249
cafile=certificate_chain,
226250
)
227-
self.context.load_cert_chain(certificate, private_key)
251+
self.context.load_cert_chain(
252+
certificate,
253+
private_key,
254+
password=private_key_password,
255+
)
228256
if self.ciphers is not None:
229257
self.context.set_ciphers(ciphers)
230258

231259
self._server_env = self._make_env_cert_dict(
232260
'SSL_SERVER',
233-
_parse_cert(certificate, private_key, self.certificate_chain),
261+
_parse_cert(
262+
certificate,
263+
private_key,
264+
self.certificate_chain,
265+
private_key_password=private_key_password,
266+
),
234267
)
235268
if not self._server_env:
236269
return

cheroot/ssl/builtin.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class BuiltinSSLAdapter(Adapter):
1313
private_key,
1414
certificate_chain: Any | None = ...,
1515
ciphers: Any | None = ...,
16+
private_key_password: str | bytes | None = ...,
1617
) -> None: ...
1718
@property
1819
def context(self): ...

cheroot/ssl/pyopenssl.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,12 +293,16 @@ class pyOpenSSLAdapter(Adapter):
293293
ciphers = None
294294
"""The ciphers list of TLS."""
295295

296+
private_key_password = None
297+
"""Optional passphrase for password protected private key."""
298+
296299
def __init__(
297300
self,
298301
certificate,
299302
private_key,
300303
certificate_chain=None,
301304
ciphers=None,
305+
private_key_password=None,
302306
):
303307
"""Initialize OpenSSL Adapter instance."""
304308
if SSL is None:
@@ -309,6 +313,7 @@ def __init__(
309313
private_key,
310314
certificate_chain,
311315
ciphers,
316+
private_key_password,
312317
)
313318

314319
self._environ = None
@@ -328,13 +333,28 @@ def wrap(self, sock):
328333
# closing so we can't reliably access protocol/client cert for the env
329334
return sock, self._environ.copy()
330335

336+
def _password_callback(
337+
self,
338+
_password_max_length,
339+
_verify_twice,
340+
password,
341+
):
342+
"""Pass a passphrase to password protected private key."""
343+
if not password:
344+
return b''
345+
if not isinstance(password, bytes):
346+
return password.encode('utf-8')
347+
return password
348+
331349
def get_context(self):
332350
"""Return an ``SSL.Context`` from self attributes.
333351
334352
Ref: :py:class:`SSL.Context <pyopenssl:OpenSSL.SSL.Context>`
335353
"""
336354
# See https://code.activestate.com/recipes/442473/
337355
c = SSL.Context(SSL.SSLv23_METHOD)
356+
if self.private_key_password is not None:
357+
c.set_passwd_cb(self._password_callback, self.private_key_password)
338358
c.use_privatekey_file(self.private_key)
339359
if self.certificate_chain:
340360
c.load_verify_locations(self.certificate_chain)

cheroot/ssl/pyopenssl.pyi

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,16 @@ class pyOpenSSLAdapter(Adapter):
3131
private_key,
3232
certificate_chain: Any | None = ...,
3333
ciphers: Any | None = ...,
34+
private_key_password: str | bytes | None = ...,
3435
) -> None: ...
3536
def bind(self, sock): ...
3637
def wrap(self, sock): ...
38+
def _password_callback(
39+
self,
40+
_password_max_length: int,
41+
_verify_twice: bool,
42+
password: bytes | str,
43+
) -> bytes: ...
3744
def get_environ(self): ...
3845
def makefile(self, sock, mode: str = ..., bufsize: int = ...): ...
3946
def get_context(self) -> SSL.Context: ...

cheroot/test/test_ssl.py

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,29 @@
44
import http.client
55
import json
66
import os
7+
import secrets
78
import ssl
9+
import string
810
import subprocess
911
import sys
1012
import threading
1113
import time
1214
import traceback
15+
import uuid
16+
from pathlib import Path
1317

1418
import pytest
1519

1620
import OpenSSL.SSL
1721
import requests
1822
import trustme
23+
from cryptography.hazmat.backends import default_backend
24+
from cryptography.hazmat.primitives.serialization import (
25+
BestAvailableEncryption,
26+
Encoding,
27+
PrivateFormat,
28+
load_pem_private_key,
29+
)
1930

2031
from .._compat import (
2132
IS_ABOVE_OPENSSL10,
@@ -29,7 +40,7 @@
2940
ntob,
3041
ntou,
3142
)
32-
from ..server import HTTPServer, get_ssl_adapter_class
43+
from ..server import Gateway, HTTPServer, get_ssl_adapter_class
3344
from ..testing import (
3445
ANY_INTERFACE_IPV4,
3546
ANY_INTERFACE_IPV6,
@@ -118,6 +129,14 @@ def make_tls_http_server(bind_addr, ssl_adapter, request):
118129
return httpserver
119130

120131

132+
@pytest.fixture
133+
def private_key_password():
134+
"""Provide random 10 character password for private key."""
135+
return ''.join(
136+
secrets.choice(string.ascii_letters + string.digits) for _ in range(10)
137+
)
138+
139+
121140
@pytest.fixture
122141
def tls_http_server(request):
123142
"""Provision a server creator as a fixture."""
@@ -158,6 +177,36 @@ def tls_certificate_private_key_pem_path(tls_certificate):
158177
yield cert_key_pem
159178

160179

180+
@pytest.fixture
181+
def tls_certificate_passwd_private_key_pem_path(
182+
tls_certificate,
183+
private_key_password,
184+
):
185+
"""Provide a certificate private key PEM file path via fixture."""
186+
key_as_bytes = tls_certificate.private_key_pem.bytes()
187+
private_key_object = load_pem_private_key(
188+
key_as_bytes,
189+
password=None,
190+
backend=default_backend(),
191+
)
192+
encrypted_key_as_bytes = private_key_object.private_bytes(
193+
encoding=Encoding.PEM,
194+
format=PrivateFormat.PKCS8,
195+
encryption_algorithm=BestAvailableEncryption(
196+
password=private_key_password.encode('utf-8'),
197+
),
198+
)
199+
200+
with tls_certificate.private_key_pem.tempfile() as tf:
201+
keyfile_temp_path = Path(tf)
202+
keyfile_temp_path.write_bytes(encrypted_key_as_bytes)
203+
204+
yield keyfile_temp_path
205+
206+
# removes the temporary file in teardown
207+
Path.unlink(keyfile_temp_path, missing_ok=True)
208+
209+
161210
def _thread_except_hook(exceptions, args):
162211
"""Append uncaught exception ``args`` in threads to ``exceptions``."""
163212
if issubclass(args.exc_type, SystemExit):
@@ -726,3 +775,96 @@ def test_http_over_https_error(
726775
'The underlying error is {underlying_error!r}'.format(**locals())
727776
)
728777
assert expected_error_text in err_text
778+
779+
780+
@pytest.mark.parametrize(
781+
'adapter_type',
782+
(
783+
'builtin',
784+
'pyopenssl',
785+
)
786+
* 2,
787+
)
788+
def test_ssl_adapters_with_private_key_password(
789+
private_key_password,
790+
tls_certificate_chain_pem_path,
791+
tls_certificate_passwd_private_key_pem_path,
792+
adapter_type,
793+
):
794+
"""Check that server starts using ssl adapter with password-protected private key."""
795+
httpserver = HTTPServer(
796+
bind_addr=(ANY_INTERFACE_IPV4, EPHEMERAL_PORT),
797+
gateway=Gateway,
798+
)
799+
800+
tls_adapter_cls = get_ssl_adapter_class(name=adapter_type)
801+
tls_adapter = tls_adapter_cls(
802+
certificate=tls_certificate_chain_pem_path,
803+
private_key=tls_certificate_passwd_private_key_pem_path,
804+
private_key_password=secrets.choice(
805+
[
806+
private_key_password,
807+
private_key_password.encode('utf-8'),
808+
],
809+
),
810+
)
811+
812+
httpserver.ssl_adapter = tls_adapter
813+
httpserver.prepare()
814+
815+
assert httpserver.ready
816+
assert httpserver.requests._threads
817+
for thr in httpserver.requests._threads:
818+
assert thr.ready
819+
820+
httpserver.stop()
821+
822+
823+
@pytest.mark.parametrize(
824+
'adapter_type',
825+
('builtin',),
826+
)
827+
def test_builtin_adapter_with_false_key_password(
828+
tls_certificate_chain_pem_path,
829+
tls_certificate_passwd_private_key_pem_path,
830+
adapter_type,
831+
):
832+
"""Check that builtin ssl-adapter initialization fails when wrong private key password given."""
833+
tls_adapter_cls = get_ssl_adapter_class(name=adapter_type)
834+
with pytest.raises(ssl.SSLError, match=r'\[SSL\] PEM.+'):
835+
tls_adapter_cls(
836+
certificate=tls_certificate_chain_pem_path,
837+
private_key=tls_certificate_passwd_private_key_pem_path,
838+
private_key_password=str(uuid.uuid4()),
839+
)
840+
841+
842+
@pytest.mark.parametrize(
843+
'adapter_type',
844+
('pyopenssl',),
845+
)
846+
def test_openssl_adapter_with_false_key_password(
847+
tls_certificate_chain_pem_path,
848+
tls_certificate_passwd_private_key_pem_path,
849+
adapter_type,
850+
):
851+
"""Check that server init fails when wrong private key password given."""
852+
httpserver = HTTPServer(
853+
bind_addr=(ANY_INTERFACE_IPV4, EPHEMERAL_PORT),
854+
gateway=Gateway,
855+
)
856+
857+
tls_adapter_cls = get_ssl_adapter_class(name=adapter_type)
858+
tls_adapter = tls_adapter_cls(
859+
certificate=tls_certificate_chain_pem_path,
860+
private_key=tls_certificate_passwd_private_key_pem_path,
861+
private_key_password=str(uuid.uuid4()),
862+
)
863+
864+
httpserver.ssl_adapter = tls_adapter
865+
866+
with pytest.raises(OpenSSL.SSL.Error, match=r'.+bad decrypt.+'):
867+
httpserver.prepare()
868+
869+
assert not httpserver.requests._threads
870+
assert not httpserver.ready

0 commit comments

Comments
 (0)