Skip to content

Commit db1dea6

Browse files
committed
PYTHON-1042 - Support client certificate passphrase
1 parent d7abe6e commit db1dea6

File tree

7 files changed

+130
-17
lines changed

7 files changed

+130
-17
lines changed

doc/examples/tls.rst

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ Or, in the URI::
8282
Specifying a certificate revocation list
8383
........................................
8484

85-
Python2 2.7.9+ (pypy 2.5.1+) and python3 3.4+ provide support for certificate
86-
revocation lists. The `ssl_crlfile` option takes a path to a CRL file. It can
87-
be passed as a keyword argument::
85+
Python 2.7.9+ (pypy 2.5.1+) and 3.4+ provide support for certificate revocation
86+
lists. The `ssl_crlfile` option takes a path to a CRL file. It can be passed as
87+
a keyword argument::
8888

8989
>>> client = pymongo.MongoClient('example.com',
9090
... ssl=True,
@@ -113,4 +113,14 @@ the `ssl_keyfile` option::
113113
... ssl_certfile='/path/to/client.pem',
114114
... ssl_keyfile='/path/to/key.pem')
115115

116+
Python 2.7.9+ (pypy 2.5.1+) and 3.3+ support providing a password or passphrase
117+
to decrypt encrypted private keys. Use the `ssl_pem_passphrase` option::
118+
119+
>>> client = pymongo.MongoClient('example.com',
120+
... ssl=True,
121+
... ssl_certfile='/path/to/client.pem',
122+
... ssl_keyfile='/path/to/key.pem',
123+
... ssl_pem_passphrase=<passphrase>)
124+
125+
116126
These options can also be passed as part of the MongoDB URI.

pymongo/client_options.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def _parse_ssl_options(options):
7070

7171
certfile = options.get('ssl_certfile')
7272
keyfile = options.get('ssl_keyfile')
73+
passphrase = options.get('ssl_pem_passphrase')
7374
ca_certs = options.get('ssl_ca_certs')
7475
cert_reqs = options.get('ssl_cert_reqs')
7576
match_hostname = options.get('ssl_match_hostname', True)
@@ -88,7 +89,8 @@ def _parse_ssl_options(options):
8889
use_ssl = True
8990

9091
if use_ssl is True:
91-
ctx = get_ssl_context(certfile, keyfile, ca_certs, cert_reqs, crlfile)
92+
ctx = get_ssl_context(
93+
certfile, keyfile, passphrase, ca_certs, cert_reqs, crlfile)
9294
return ctx, match_hostname
9395
return None, match_hostname
9496

pymongo/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ def validate_ok_for_update(update):
421421
'ssl': validate_boolean_or_string,
422422
'ssl_keyfile': validate_readable,
423423
'ssl_certfile': validate_readable,
424+
'ssl_pem_passphrase': validate_string_or_none,
424425
'ssl_cert_reqs': validate_cert_reqs,
425426
'ssl_ca_certs': validate_readable,
426427
'ssl_match_hostname': validate_boolean_or_string,

pymongo/mongo_client.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,13 +195,17 @@ def __init__(
195195
196196
- `ssl`: If ``True``, create the connection to the server using SSL.
197197
Defaults to ``False``.
198+
- `ssl_certfile`: The certificate file used to identify the local
199+
connection against mongod. Implies ``ssl=True``. Defaults to
200+
``None``.
198201
- `ssl_keyfile`: The private keyfile used to identify the local
199202
connection against mongod. If included with the ``certfile`` then
200203
only the ``ssl_certfile`` is needed. Implies ``ssl=True``.
201204
Defaults to ``None``.
202-
- `ssl_certfile`: The certificate file used to identify the local
203-
connection against mongod. Implies ``ssl=True``. Defaults to
204-
``None``.
205+
- `ssl_pem_passphrase`: The password or passphrase for decrypting
206+
the private key in ``ssl_certfile`` or ``ssl_keyfile``. Only
207+
necessary if the private key is encrypted. Only supported by python
208+
2.7.9+ (pypy 2.5.1+) and 3.3+. Defaults to ``None``.
205209
- `ssl_cert_reqs`: Specifies whether a certificate is required from
206210
the other side of the connection, and whether it will be validated
207211
if provided. It must be one of the three values ``ssl.CERT_NONE``
@@ -219,8 +223,8 @@ def __init__(
219223
certificates passed from the other end of the connection.
220224
Implies ``ssl=True``. Defaults to ``None``.
221225
- `ssl_crlfile`: The path to a PEM or DER formatted certificate
222-
revocation list. Only supported by python 2.9.7+ and python 3.4+.
223-
Defaults to ``None``.
226+
revocation list. Only supported by python 2.7.9+ (pypy 2.5.1+)
227+
and 3.4+. Defaults to ``None``.
224228
- `ssl_match_hostname`: If ``True`` (the default), and
225229
`ssl_cert_reqs` is not ``ssl.CERT_NONE``, enables hostname
226230
verification using the :func:`~ssl.match_hostname` function from

pymongo/ssl_support.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def _load_wincerts():
8888
# parameter.
8989
def get_ssl_context(*args):
9090
"""Create and return an SSLContext object."""
91-
certfile, keyfile, ca_certs, cert_reqs, crlfile = args
91+
certfile, keyfile, passphrase, ca_certs, cert_reqs, crlfile = args
9292
# Note PROTOCOL_SSLv23 is about the most misleading name imaginable.
9393
# This configures the server and client to negotiate the
9494
# highest protocol version they both support. A very good thing.
@@ -102,12 +102,23 @@ def get_ssl_context(*args):
102102
ctx.options |= getattr(ssl, "OP_NO_SSLv2", 0)
103103
ctx.options |= getattr(ssl, "OP_NO_SSLv3", 0)
104104
if certfile is not None:
105-
ctx.load_cert_chain(certfile, keyfile)
105+
if passphrase is not None:
106+
vi = sys.version_info
107+
# Since python just added a new parameter to an existing method
108+
# this seems to be about the best we can do.
109+
if (vi[0] == 2 and vi < (2, 7, 9) or
110+
vi[0] == 3 and vi < (3, 3)):
111+
raise ConfigurationError(
112+
"Support for ssl_pem_passphrase requires "
113+
"python 2.7.9+ (pypy 2.5.1+) or 3.3+")
114+
ctx.load_cert_chain(certfile, keyfile, passphrase)
115+
else:
116+
ctx.load_cert_chain(certfile, keyfile)
106117
if crlfile is not None:
107118
if not hasattr(ctx, "verify_flags"):
108119
raise ConfigurationError(
109120
"Support for ssl_crlfile requires "
110-
"python2 2.7.9+ (pypy 2.5.1+) or python3 3.4+")
121+
"python 2.7.9+ (pypy 2.5.1+) or 3.4+")
111122
# Match the server's behavior.
112123
ctx.verify_flags = ssl.VERIFY_CRL_CHECK_LEAF
113124
ctx.load_verify_locations(crlfile)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
-----BEGIN ENCRYPTED PRIVATE KEY-----
2+
MIIFHzBJBgkqhkiG9w0BBQ0wPDAbBgkqhkiG9w0BBQwwDgQIXVpiOphzP48CAggA
3+
MB0GCWCGSAFlAwQBAgQQCrPjZvdtfdmb5FnflM2QaQSCBNDwm1fRI4HsJ988MS6P
4+
7xRkA8fscD/krgMsnG3g47NjgM+CtYRbUfKNMrgidajQVC74P7z1pQ+uHCz9Py7U
5+
5Ge0uFFqkPyQl2RcnVjCdsx6ElCfdDLEnizVL1ERHMYTq0AXnYXyPN45Qpq9UfQG
6+
DPtoOAWpe6FSrQ0UFYZ3hTJIXK8A2J7phkPzrFz9CYtzaLv4ShD0osAOK0hN1hSp
7+
k258lb1oGifGc3BXv1atiXZI7R8tFhY5GlG/F6BksFHslOc01/Iix+rb04jV6aKB
8+
L1yyoUqjwO0Qt365FfUw/ZODwkW7n5gAT1hJ/qJc0Tkh92pv7ISnMpT5h40k4Q9L
9+
tkCm4aOymhSNWLjkBrgIVbLPgUo59bNZAgPrDz2Wjwt7rU3t5TyXPiScDYfxQyhj
10+
dhI7NkpC/nMGc/VRir8+zUSRvR6xIlkHM8glpVHWOU8gIRDv1RYQFhhecEFgoSMO
11+
oP54SK3a6A0BzrFZIRqMeVDXl1DXuA9tdgF7N2CIMdKTtAgOohTcTRESIk5ysFNm
12+
dY15ptDtj3woGsBffMgfa7AWxDq/97Gfp0leMdV7DTLHQWM/hjsmDaz7E5jRt36Z
13+
Qql2/SdUX3A91u4lvcCDgzcMe6YY1jJ3oPsuQRoMeZrj1jivR2TY0LUo474mxq3Y
14+
IxyP8wrGEhDDc3Tfq2TeFwyS2u1cxYu1JocRi1qxtbG/cdK340KXvdVJKl5mosys
15+
qUW0MdTyEAzzf77k14AqSOyXeVarttY4ZLMQd2qbW4kgBP7ZZ+Jz0YXPz4Pc0fiG
16+
ONeF8Q1LVVkneURHmvGXEoP+6rRk59neVtW1An5hxZkmeL9VPUm+q+cXAwLEJIxN
17+
tEzC6liIhzpMbyUFht6HbCaHvl6U4Zap56SqMfPXl6V6L3MDQC0uenQoPHmprYGP
18+
WcM1/F7BviQclHuyhdmkE/dbutJ8Dj7gKdv6qVYm4zS5BYcPfzqR/v4t4pXX+ubS
19+
Ng5GPZkrBCG4+D97/zSS4w6KPImbOsrQh6D0aJZKoRHcEgmEBaN0tDcwZQsSXQRs
20+
wB/ATl2sgebIRkNxABTqUyG3hnMMFNxoGPicv1P73Ai29056KrA5kq3Xz6PfOgI7
21+
ZwfMgvwKNPewyzQ6cIoJHKCZellBeAm8B6SPI6fes4nLBvTJi6khjpK489nnZqYz
22+
MDIzu1eFPQLAVM2XB9ABTdZJJVM8f2OrjUMuXAiqj3eKV9A0lVUd7YfCjPmat3+3
23+
JLlvJb7ePnVR7/O/4AgQatfIUyVRuqovR6s+j2KHu3iVEMUZvpEuQZpIzj+jptfC
24+
fSbaFoTjSGii97YKQ5OoZqnZiy3gkprY/SCkXa7Jlx7cb8h5pktbqEpAjlLd1MMd
25+
mES8HUiJpYSrseLCNJkbU5dKK1DqNj1UgAKRtU55HGYRGtpxx9zqPBqTiBkW7FwR
26+
qaBYhvM4STPj3cD0JqCE9TuJDG5/7jSKKKm342j1VyPN2VUXinreNDUFE2WePRdV
27+
OhyIa5zB4xuH5rLfqb3MJlxeuM7sMX/6Nor0+F7XfVn3HHEGGdjkclaJlFonmRTk
28+
+/xpROQ/iPCVWr85adnfkgBzIZOYDKFHqpXS0G4W0p09+eDfQWuHw1Nu5D3Gb9Y6
29+
CrGuA90yQPSULpuE+GBjv8Od+Q==
30+
-----END ENCRYPTED PRIVATE KEY-----
31+
-----BEGIN CERTIFICATE-----
32+
MIIDjTCCAnWgAwIBAgIDAmB0MA0GCSqGSIb3DQEBBQUAMHUxGTAXBgNVBAMTEFB5
33+
dGhvbiBEcml2ZXIgQ0ExEDAOBgNVBAsTB0RyaXZlcnMxEDAOBgNVBAoTB01vbmdv
34+
REIxEjAQBgNVBAcTCVBhbG8gQWx0bzETMBEGA1UECBMKQ2FsaWZvcm5pYTELMAkG
35+
A1UEBhMCVVMwHhcNMTYwNTI0MDAyMTQ4WhcNMzYwNTI0MDAyMTQ4WjBkMQ8wDQYD
36+
VQQDEwZjbGllbnQxEDAOBgNVBAsTB0RyaXZlcnMxCTAHBgNVBAoTADESMBAGA1UE
37+
BxMJUGFsbyBBbHRvMRMwEQYDVQQIEwpDYWxpZm9ybmlhMQswCQYDVQQGEwJVUzCC
38+
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALuWTvDI4CRfGVGkvRnGfwEp
39+
j2U/fwMvZoJW3LT0w2nQRUsa/hrf74Ggm9FbhaaM9bxA6eS5vWEaaJX9gfEEqOaj
40+
FlF8O++7+8+CrxsBWRK6yNT8nkB9rg3VCv5gqXJr9rpRhWAeq3QuFhunACrk+Mt4
41+
5KL+ueYerJyhJwx87LiwGAMxasdr+U8j/s5kc+SIhwQxWXYVOSgdweJd4FlEGYvZ
42+
0WR/oheEWp1ayZzf4cE8K2aD0YC6CS2ErFke6W4ZsdqtwGRjLwz71SMG0tSEpOjg
43+
NkNJBqNju3FU9AtFJFS7t9wPzMG/2LIioSXKe+RW4/SBjhc0LM7xgDM9oFYqZ3sC
44+
AwEAAaM3MDUwCwYDVR0PBAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMCMBEGA1Ud
45+
EQQKMAiCBmNsaWVudDANBgkqhkiG9w0BAQUFAAOCAQEAMJgtYqm4cgOCmFiacwYy
46+
ZtpWJHLk1zFwBLrNdQu9GKlYhC+903yV/VypnRjOd0McdopMZ+4aQut4CE01PLty
47+
FEZ6UrpqXbEFniGh5PKaouhqrX896iBwPRn5eeFKf40sFgdNReJ3KDyGt6kqTWn6
48+
rY13wgopMy0A3NfCqmBHHXYRBRl9GS0O2bOOLMR49o7iBW+Ga638/Z3+4xx8T9Mh
49+
+ejauV8EWZFKfg43soXuGHLDfDl3BMqH4iP12y+e8NtBEMRl6RPbZcO3X9Zkavba
50+
rmlcdR/01UK9/FiWP2rPNNxk2ypqZDxkPzT8sCDBQXtRgemxTlxgeKryDgXlF1iD
51+
Pw==
52+
-----END CERTIFICATE-----

test/test_ssl.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
CERT_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)),
4444
'certificates')
4545
CLIENT_PEM = os.path.join(CERT_PATH, 'client.pem')
46+
CLIENT_ENCRYPTED_PEM = os.path.join(CERT_PATH, 'client_encrypted.pem')
4647
CA_PEM = os.path.join(CERT_PATH, 'ca.pem')
4748
CRL_PEM = os.path.join(CERT_PATH, 'crl.pem')
4849
SIMPLE_SSL = False
@@ -224,6 +225,38 @@ def test_simple_ssl(self):
224225
self.assertTrue(db.test.find_one()['ssl'])
225226
client.drop_database('pymongo_ssl_test')
226227

228+
def test_ssl_pem_passphrase(self):
229+
# Expects the server to be running with server.pem and ca.pem
230+
#
231+
# --sslPEMKeyFile=/path/to/pymongo/test/certificates/server.pem
232+
# --sslCAFile=/path/to/pymongo/test/certificates/ca.pem
233+
if not CERT_SSL:
234+
raise SkipTest("No mongod available over SSL with certs")
235+
236+
vi = sys.version_info
237+
if vi[0] == 2 and vi < (2, 7, 9) or vi[0] == 3 and vi < (3, 3):
238+
self.assertRaises(
239+
ConfigurationError,
240+
MongoClient,
241+
'server',
242+
ssl=True,
243+
ssl_certfile=CLIENT_ENCRYPTED_PEM,
244+
ssl_pem_passphrase="clientpassword",
245+
ssl_ca_certs=CA_PEM,
246+
serverSelectionTimeoutMS=100)
247+
else:
248+
connected(MongoClient('server',
249+
ssl=True,
250+
ssl_certfile=CLIENT_ENCRYPTED_PEM,
251+
ssl_pem_passphrase="clientpassword",
252+
ssl_ca_certs=CA_PEM,
253+
serverSelectionTimeoutMS=100))
254+
255+
uri_fmt = ("mongodb://server/?ssl=true"
256+
"&ssl_certfile=%s&ssl_pem_passphrase=clientpassword"
257+
"&ssl_ca_certs=%s&serverSelectionTimeoutMS=100")
258+
connected(MongoClient(uri_fmt % (CLIENT_ENCRYPTED_PEM, CA_PEM)))
259+
227260
def test_cert_ssl(self):
228261
# Expects the server to be running with server.pem and ca.pem.
229262
#
@@ -515,7 +548,7 @@ def test_validation_with_system_ca_certs(self):
515548
os.environ.pop('SSL_CERT_FILE')
516549

517550
def test_system_certs_config_error(self):
518-
ctx = get_ssl_context(None, None, None, ssl.CERT_NONE, None)
551+
ctx = get_ssl_context(None, None, None, None, ssl.CERT_NONE, None)
519552
if ((sys.platform != "win32"
520553
and hasattr(ctx, "set_default_verify_paths"))
521554
or hasattr(ctx, "load_default_certs")):
@@ -547,11 +580,11 @@ def test_certifi_support(self):
547580
# Force the test on Windows, regardless of environment.
548581
ssl_support.HAVE_WINCERTSTORE = False
549582
try:
550-
ctx = get_ssl_context(None, None, CA_PEM, ssl.CERT_REQUIRED, None)
583+
ctx = get_ssl_context(None, None, None, CA_PEM, ssl.CERT_REQUIRED, None)
551584
ssl_sock = ctx.wrap_socket(socket.socket())
552585
self.assertEqual(ssl_sock.ca_certs, CA_PEM)
553586

554-
ctx = get_ssl_context(None, None, None, None, None)
587+
ctx = get_ssl_context(None, None, None, None, None, None)
555588
ssl_sock = ctx.wrap_socket(socket.socket())
556589
self.assertEqual(ssl_sock.ca_certs, ssl_support.certifi.where())
557590
finally:
@@ -568,11 +601,11 @@ def test_wincertstore(self):
568601
if not ssl_support.HAVE_WINCERTSTORE:
569602
raise SkipTest("Need wincertstore to test wincertstore.")
570603

571-
ctx = get_ssl_context(None, None, CA_PEM, ssl.CERT_REQUIRED, None)
604+
ctx = get_ssl_context(None, None, None, CA_PEM, ssl.CERT_REQUIRED, None)
572605
ssl_sock = ctx.wrap_socket(socket.socket())
573606
self.assertEqual(ssl_sock.ca_certs, CA_PEM)
574607

575-
ctx = get_ssl_context(None, None, None, None, None)
608+
ctx = get_ssl_context(None, None, None, None, None, None)
576609
ssl_sock = ctx.wrap_socket(socket.socket())
577610
self.assertEqual(ssl_sock.ca_certs, ssl_support._WINCERTS.name)
578611

0 commit comments

Comments
 (0)