Skip to content

Commit 2a16422

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 4a8dc43 commit 2a16422

File tree

8 files changed

+276
-8
lines changed

8 files changed

+276
-8
lines changed

cheroot/ssl/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ def __init__(
2020
private_key,
2121
certificate_chain=None,
2222
ciphers=None,
23+
*,
24+
private_key_password=None,
2325
):
24-
"""Set up certificates, private key ciphers and reset context."""
26+
"""Set up certificates, private key, ciphers and reset context."""
2527
self.certificate = certificate
2628
self.private_key = private_key
2729
self.certificate_chain = certificate_chain
2830
self.ciphers = ciphers
31+
self.private_key_password = private_key_password
2932
self.context = None
3033

3134
@abstractmethod

cheroot/ssl/__init__.pyi

Lines changed: 3 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,8 @@ class Adapter(ABC):
1415
private_key,
1516
certificate_chain: Any | None = ...,
1617
ciphers: Any | None = ...,
18+
*,
19+
private_key_password: str | bytes | None = ...,
1720
): ...
1821
@abstractmethod
1922
def bind(self, sock): ...

cheroot/ssl/builtin.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,20 @@ 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+
*,
84+
private_key_password=None,
85+
):
8086
"""Create a loopback connection to parse a cert with a private key."""
8187
context = ssl.create_default_context(cafile=certificate_chain)
82-
context.load_cert_chain(certificate, private_key)
88+
context.load_cert_chain(
89+
certificate,
90+
private_key,
91+
password=private_key_password,
92+
)
8393
context.check_hostname = False
8494
context.verify_mode = ssl.CERT_NONE
8595

@@ -112,15 +122,26 @@ def _loopback_for_cert(certificate, private_key, certificate_chain):
112122
server.close()
113123

114124

115-
def _parse_cert(certificate, private_key, certificate_chain):
125+
def _parse_cert(
126+
certificate,
127+
private_key,
128+
certificate_chain,
129+
*,
130+
private_key_password=None,
131+
):
116132
"""Parse a certificate."""
117133
# loopback_for_cert uses socket.socketpair which was only
118134
# introduced in Python 3.0 for *nix and 3.5 for Windows
119135
# and requires OS support (AttributeError, OSError)
120136
# it also requires a private key either in its own file
121137
# or combined with the cert (SSLError)
122138
with suppress(AttributeError, ssl.SSLError, OSError):
123-
return _loopback_for_cert(certificate, private_key, certificate_chain)
139+
return _loopback_for_cert(
140+
certificate,
141+
private_key,
142+
certificate_chain,
143+
private_key_password=private_key_password,
144+
)
124145

125146
# KLUDGE: using an undocumented, private, test method to parse a cert
126147
# unfortunately, it is the only built-in way without a connection
@@ -153,6 +174,9 @@ class BuiltinSSLAdapter(Adapter):
153174
ciphers = None
154175
"""The ciphers list of SSL."""
155176

177+
private_key_password = None
178+
"""Optional passphrase for password protected private key."""
179+
156180
# from mod_ssl/pkg.sslmod/ssl_engine_vars.c ssl_var_lookup_ssl_cert
157181
CERT_KEY_TO_ENV = {
158182
'version': 'M_VERSION',
@@ -208,6 +232,8 @@ def __init__(
208232
private_key,
209233
certificate_chain=None,
210234
ciphers=None,
235+
*,
236+
private_key_password=None,
211237
):
212238
"""Set up context in addition to base class properties if available."""
213239
if ssl is None:
@@ -218,19 +244,29 @@ def __init__(
218244
private_key,
219245
certificate_chain,
220246
ciphers,
247+
private_key_password=private_key_password,
221248
)
222249

223250
self.context = ssl.create_default_context(
224251
purpose=ssl.Purpose.CLIENT_AUTH,
225252
cafile=certificate_chain,
226253
)
227-
self.context.load_cert_chain(certificate, private_key)
254+
self.context.load_cert_chain(
255+
certificate,
256+
private_key,
257+
password=private_key_password,
258+
)
228259
if self.ciphers is not None:
229260
self.context.set_ciphers(ciphers)
230261

231262
self._server_env = self._make_env_cert_dict(
232263
'SSL_SERVER',
233-
_parse_cert(certificate, private_key, self.certificate_chain),
264+
_parse_cert(
265+
certificate,
266+
private_key,
267+
self.certificate_chain,
268+
private_key_password=private_key_password,
269+
),
234270
)
235271
if not self._server_env:
236272
return

cheroot/ssl/builtin.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ class BuiltinSSLAdapter(Adapter):
1313
private_key,
1414
certificate_chain: Any | None = ...,
1515
ciphers: Any | None = ...,
16+
*,
17+
private_key_password: str | bytes | None = ...,
1618
) -> None: ...
1719
@property
1820
def context(self): ...

cheroot/ssl/pyopenssl.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
import sys
5555
import threading
5656
import time
57+
from warnings import warn as _warn
5758

5859

5960
try:
@@ -293,12 +294,17 @@ class pyOpenSSLAdapter(Adapter):
293294
ciphers = None
294295
"""The ciphers list of TLS."""
295296

297+
private_key_password = None
298+
"""Optional passphrase for password protected private key."""
299+
296300
def __init__(
297301
self,
298302
certificate,
299303
private_key,
300304
certificate_chain=None,
301305
ciphers=None,
306+
*,
307+
private_key_password=None,
302308
):
303309
"""Initialize OpenSSL Adapter instance."""
304310
if SSL is None:
@@ -309,6 +315,7 @@ def __init__(
309315
private_key,
310316
certificate_chain,
311317
ciphers,
318+
private_key_password=private_key_password,
312319
)
313320

314321
self._environ = None
@@ -328,13 +335,39 @@ def wrap(self, sock):
328335
# closing so we can't reliably access protocol/client cert for the env
329336
return sock, self._environ.copy()
330337

338+
def _password_callback(
339+
self,
340+
password_max_length,
341+
_verify_twice,
342+
password,
343+
/,
344+
):
345+
"""Pass a passphrase to password protected private key."""
346+
b_password = b'' # returning a falsy value communicates an error
347+
if isinstance(password, str):
348+
b_password = password.encode('utf-8')
349+
elif isinstance(password, bytes):
350+
b_password = password
351+
352+
password_length = len(b_password)
353+
if password_length > password_max_length:
354+
_warn(
355+
f'User-provided password is {password_length} bytes long and will '
356+
f'be truncated since it exceeds the maximum of {password_max_length}.',
357+
UserWarning,
358+
stacklevel=1,
359+
)
360+
361+
return b_password
362+
331363
def get_context(self):
332364
"""Return an ``SSL.Context`` from self attributes.
333365
334366
Ref: :py:class:`SSL.Context <pyopenssl:OpenSSL.SSL.Context>`
335367
"""
336368
# See https://code.activestate.com/recipes/442473/
337369
c = SSL.Context(SSL.SSLv23_METHOD)
370+
c.set_passwd_cb(self._password_callback, self.private_key_password)
338371
c.use_privatekey_file(self.private_key)
339372
if self.certificate_chain:
340373
c.load_verify_locations(self.certificate_chain)

cheroot/ssl/pyopenssl.pyi

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,18 @@ class pyOpenSSLAdapter(Adapter):
3131
private_key,
3232
certificate_chain: Any | None = ...,
3333
ciphers: Any | None = ...,
34+
*,
35+
private_key_password: str | bytes | None = ...,
3436
) -> None: ...
3537
def bind(self, sock): ...
3638
def wrap(self, sock): ...
39+
def _password_callback(
40+
self,
41+
password_max_length: int,
42+
_verify_twice: bool,
43+
password: bytes | str | None,
44+
/,
45+
) -> bytes: ...
3746
def get_environ(self): ...
3847
def makefile(self, sock, mode: str = ..., bufsize: int = ...): ...
3948
def get_context(self) -> SSL.Context: ...

0 commit comments

Comments
 (0)