Skip to content

Commit 26314d1

Browse files
committed
Add high level LDAPObject.set_tls_options()
The new high level function ``set_tls_options`` deals with most common quirks and issues when setting TLS/SSL related options. Signed-off-by: Christian Heimes <[email protected]>
1 parent 605a34b commit 26314d1

File tree

5 files changed

+239
-0
lines changed

5 files changed

+239
-0
lines changed

Doc/reference/ldap.rst

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,9 @@ SASL options
253253
TLS options
254254
:::::::::::
255255

256+
The method :py:meth:`LDAPObject.set_tls_options` provides a high-level API
257+
to configure TLS options.
258+
256259
.. warning::
257260

258261
libldap does not materialize all TLS settings immediately. You must use
@@ -1316,6 +1319,51 @@ Connection-specific LDAP options
13161319
specified by *option* to *invalue*.
13171320

13181321

1322+
.. py:method:: LDAPObject.set_tls_options(cacertfile=None, cacertdir=None, require_cert=None, protocol_min=None, cipher_suite=None, certfile=None, keyfile=None, crlfile=None, crlcheck=None, start_tls=True) -> None
1323+
1324+
The method provides a high-level API to set TLS related options. It
1325+
avoids most common pitfalls and some catches errors early, e.g.
1326+
missing :py:const:`OPT_X_TLS_NEWCTX`. The method is available for OpenSSL
1327+
and GnuTLS backends. It raises :py:exc:`ValueError` for unsupported
1328+
backends, when libldap does not have TLS support, or TLS layer is already
1329+
installed.
1330+
1331+
*cacertfile* is a path to a PEM bundle file containing root CA certs.
1332+
Raises :py:exc:`OSError` when file is not found.
1333+
1334+
*cacertdir* is a path to a directory that contains hashed CA cert files.
1335+
Raises :py:exc:`OSError` when the directory does not exist.
1336+
1337+
*require_cert* set the cert validation strategy. Value must be one of
1338+
:py:const:`OPT_X_TLS_NEVER`, :py:const:`OPT_X_TLS_DEMAND`,
1339+
or :py:const:`OPT_X_TLS_HARD`. Hard and demand have the same meaning.
1340+
Raises :py:exc:`ValueError` for unsupported values.
1341+
1342+
*protocol_min* sets the minimum TLS protocol version. Value must one of
1343+
``0x303`` (TLS 1.2) or ``0x304`` (TLS 1.3). Raises :py:exc:`ValueError`
1344+
for unsupported values.
1345+
1346+
*cipher_suite* cipher suite string, see OpenSSL documentation for more
1347+
details.
1348+
1349+
*certfile* and *keyfile* set paths to certificate and key for client
1350+
cert authentication. Raises :py:exc:`ValueError` when only one option
1351+
is given and :py:exc:`OSError` when any file does not exist.
1352+
1353+
*crlfile* is path to a CRL file. Raises :py:exc:`OSError` when file is
1354+
not found.
1355+
1356+
*crlcheck* sets the CRL verification strategy. Value must be one of
1357+
:py:const:`OPT_X_TLS_CRL_NONE`, :py:const:`OPT_X_TLS_CRL_PEER`, or
1358+
:py:const:`OPT_X_TLS_CRL_ALL`. Raises :py:exc:`ValueError` for unsupported
1359+
values.
1360+
1361+
When *start_tls* is set then :py:meth:`LDAPObject.start_tls_s` is
1362+
automatically called for ``ldap://`` URIs. The argument is ignored
1363+
for ``ldaps://`` URIs.
1364+
1365+
.. versionadded:: 3.3
1366+
13191367
Object attributes
13201368
-----------------
13211369

Doc/spelling_wordlist.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ attrtype
1111
authzId
1212
automagically
1313
backend
14+
backends
1415
behaviour
1516
BER
1617
bindname
@@ -56,6 +57,7 @@ filterstr
5657
filterStr
5758
formatOID
5859
func
60+
GnuTLS
5961
GPG
6062
Heimdal
6163
hostport
@@ -141,6 +143,7 @@ subtree
141143
syncrepl
142144
syntaxes
143145
timelimit
146+
TLS
144147
tracebacks
145148
tuple
146149
tuples

Lib/ldap/ldapobject.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
See https://www.python-ldap.org/ for details.
55
"""
66
from os import strerror
7+
import os.path
78

89
from ldap.pkginfo import __version__, __author__, __license__
910

@@ -697,6 +698,127 @@ def set_option(self,option,invalue):
697698
invalue = RequestControlTuples(invalue)
698699
return self._ldap_call(self._l.set_option,option,invalue)
699700

701+
def set_tls_options(self, cacertfile=None, cacertdir=None,
702+
require_cert=None, protocol_min=None,
703+
cipher_suite=None, certfile=None, keyfile=None,
704+
crlfile=None, crlcheck=None, start_tls=True):
705+
"""Set TLS/SSL options
706+
707+
:param cacertfile: path to a PEM bundle file containing root CA certs
708+
:param cacertdir: path to a directory with hashed CA certificates
709+
:param require_cert: cert validation strategy, one of
710+
ldap.OPT_X_TLS_NEVER, OPT_X_TLS_DEMAND, OPT_X_TLS_HARD. Hard and
711+
demand have the same meaning for client side sockets.
712+
:param protocol_min: minimum protocol version, one of 0x303 (TLS 1.2)
713+
or 0x304 (TLS 1.3).
714+
:param cipher_suite: cipher suite string
715+
:param certfile: path to cert file for client cert authentication
716+
:param keyfile: path to key file for client cert authentication
717+
:param crlfile: path to a CRL file
718+
:param crlcheck: CRL verification strategy, one of
719+
ldap.OPT_X_TLS_CRL_NONE, ldap.OPT_X_TLS_CRL_PEER, or
720+
ldap.OPT_X_TLS_CRL_ALL
721+
:param start_tls: automatically perform StartTLS for ldap:// connections
722+
"""
723+
if not hasattr(ldap, "OPT_X_TLS_NEWCTX"):
724+
raise ValueError("libldap does not have TLS support")
725+
# OpenSSL and GnuTLS support these options
726+
tls_pkg = self.get_option(ldap.OPT_X_TLS_PACKAGE)
727+
if tls_pkg not in {"OpenSSL", "GnuTLS"}:
728+
raise ValueError("Unsupport TLS package '{}'.".format(tls_pkg))
729+
# block ldapi ('in' because libldap supports multiple URIs)
730+
if "ldapi://" in self._uri:
731+
raise ValueError("IPC (ldapi) does not support TLS.")
732+
if self._ldap_call(self._l.tls_inplace):
733+
raise ValueError("TLS connection already established")
734+
735+
def _checkfile(option, filename):
736+
# check that the file exists and is readable.
737+
# libldap doesn't verify paths until it establishes a connection
738+
with open(filename, "rb"):
739+
pass
740+
741+
if cacertfile is not None:
742+
_checkfile("certfile", certfile)
743+
self.set_option(ldap.OPT_X_TLS_CACERTFILE, cacertfile)
744+
745+
if cacertdir is not None:
746+
if not os.path.isdir(cacertdir):
747+
raise OSError(
748+
"'{}' does not exist or is not a directory".format(cacertdir)
749+
)
750+
self.set_option(ldap.OPT_X_TLS_CACERTDIR, cacertdir)
751+
752+
if require_cert is not None:
753+
supported = {
754+
ldap.OPT_X_TLS_NEVER,
755+
# ALLOW is a server-side setting
756+
# ldap.OPT_X_TLS_ALLOW,
757+
ldap.OPT_X_TLS_DEMAND,
758+
ldap.OPT_X_TLS_HARD
759+
}
760+
if require_cert not in supported:
761+
raise ValueError("Unsupported value for require_cert")
762+
self.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, require_cert)
763+
764+
if protocol_min is not None:
765+
# let's not support TLS 1.0 and 1.1
766+
supported = {0x303, 0x304}
767+
if protocol_min not in supported:
768+
raise ValueError("Unsupported value for protocol_min")
769+
self.set_option(ldap.OPT_X_TLS_PROTOCOL_MIN, protocol_min)
770+
771+
if cipher_suite is not None:
772+
self.set_option(ldap.OPT_X_TLS_CIPHER_SUITE, cipher_suite)
773+
774+
if certfile is not None:
775+
if keyfile is None:
776+
raise ValueError("certfile option requires keyfile option")
777+
_checkfile("certfile", certfile)
778+
self.set_option(ldap.OPT_X_TLS_CERTFILE, certfile)
779+
780+
if keyfile is not None:
781+
if certfile is None:
782+
raise ValueError("keyfile option requires certfile option")
783+
_checkfile("keyfile", keyfile)
784+
self.set_option(ldap.OPT_X_TLS_KEYFILE, keyfile)
785+
786+
if crlfile is not None:
787+
_checkfile("crlfile", crlfile)
788+
self.set_option(ldap.OPT_X_TLS_CRLFILE, crlfile)
789+
790+
if crlcheck is not None:
791+
# no check for crlfile. OpenSSL supports CRLs in CACERTDIR, too.
792+
supported = {
793+
ldap.OPT_X_TLS_CRL_NONE,
794+
ldap.OPT_X_TLS_CRL_PEER,
795+
ldap.OPT_X_TLS_CRL_ALL
796+
}
797+
if crlcheck not in supported:
798+
raise ValueError("Unsupported value for crlcheck")
799+
self.set_option(ldap.OPT_X_TLS_CRLCHECK, crlcheck)
800+
801+
# materialize settings
802+
# 0 means client-side socket
803+
try:
804+
self.set_option(ldap.OPT_X_TLS_NEWCTX, 0)
805+
except ValueError as e:
806+
# libldap doesn't return better error message here, global debug log
807+
# may contain more information.
808+
raise ValueError(
809+
"libldap or {} does not support one or more options: {}".format(
810+
tls_pkg, e
811+
)
812+
)
813+
814+
# Cannot use OPT_X_TLS with OPT_X_TLS_HARD to enforce StartTLS.
815+
# libldap ldap_int_open_connection() calls ldap_int_tls_start() when
816+
# mode is HARD, but it does not send LDAP_EXOP_START_TLS first.
817+
if start_tls and "ldap://" in self._uri:
818+
if self.protocol_version != ldap.VERSION3:
819+
self.protocol_version = ldap.VERSION3
820+
self.start_tls_s()
821+
700822
def search_subschemasubentry_s(self,dn=None):
701823
"""
702824
Returns the distinguished name of the sub schema sub entry

Modules/LDAPObject.c

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,15 @@ l_ldap_start_tls_s(LDAPObject *self, PyObject *args)
13601360
return Py_None;
13611361
}
13621362

1363+
static PyObject *
1364+
l_ldap_tls_inplace(LDAPObject *self)
1365+
{
1366+
if (not_valid(self))
1367+
return NULL;
1368+
1369+
return PyBool_FromLong(ldap_tls_inplace(self->ldap));
1370+
}
1371+
13631372
#endif
13641373

13651374
/* ldap_set_option */
@@ -1525,6 +1534,7 @@ static PyMethodDef methods[] = {
15251534
{"search_ext", (PyCFunction)l_ldap_search_ext, METH_VARARGS},
15261535
#ifdef HAVE_TLS
15271536
{"start_tls_s", (PyCFunction)l_ldap_start_tls_s, METH_VARARGS},
1537+
{"tls_inplace", (PyCFunction)l_ldap_tls_inplace, METH_NOARGS},
15281538
#endif
15291539
{"whoami_s", (PyCFunction)l_ldap_whoami_s, METH_VARARGS},
15301540
{"passwd", (PyCFunction)l_ldap_passwd, METH_VARARGS},

Tests/t_ldapobject.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,62 @@ def test_multiple_starttls(self):
371371
l.simple_bind_s(self.server.root_dn, self.server.root_pw)
372372
self.assertEqual(l.whoami_s(), 'dn:' + self.server.root_dn)
373373

374+
def assert_option_equal(self, conn, option, value):
375+
self.assertEqual(conn.get_option(option), value)
376+
377+
@requires_tls()
378+
def test_set_tls_options_ldap(self):
379+
# just any directory will do
380+
certdir = os.path.dirname(__file__)
381+
conn = self.ldap_object_class(self.server.ldap_uri)
382+
conn.set_tls_options(
383+
cacertfile=self.server.cafile,
384+
# just any directory
385+
cacertdir=certdir,
386+
require_cert=ldap.OPT_X_TLS_DEMAND,
387+
protocol_min=0x303,
388+
# libldap on Travis CI doesn't like cipher_suite
389+
# cipher_suite="ALL",
390+
certfile=self.server.clientcert,
391+
keyfile=self.server.clientkey,
392+
# libldap on TravisCI doesn't like CRL options
393+
# crlfile=None,
394+
# crlcheck=ldap.OPT_X_TLS_CRL_PEER,
395+
start_tls=False
396+
)
397+
self.assert_option_equal(
398+
conn, ldap.OPT_X_TLS_CACERTFILE, self.server.cafile
399+
)
400+
self.assert_option_equal(
401+
conn, ldap.OPT_X_TLS_CACERTDIR, certdir
402+
)
403+
self.assert_option_equal(
404+
conn, ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND
405+
)
406+
# cipher_suite depends on OpenSSL version and system settings
407+
self.assert_option_equal(
408+
conn, ldap.OPT_X_TLS_PROTOCOL_MIN, 0x303
409+
)
410+
self.assert_option_equal(
411+
conn, ldap.OPT_X_TLS_CERTFILE, self.server.clientcert
412+
)
413+
self.assert_option_equal(
414+
conn, ldap.OPT_X_TLS_KEYFILE, self.server.clientkey,
415+
)
416+
# self.assert_option_equal(
417+
# conn, ldap.OPT_X_TLS_CRLFILE, crlfile
418+
# )
419+
# self.assert_option_equal(
420+
# conn, ldap.OPT_X_TLS_CRLCHECK, ldap.OPT_X_TLS_CRL_PEER
421+
# )
422+
423+
# run again, this time with default start_tls.
424+
conn.set_tls_options()
425+
# second call should fail
426+
with self.assertRaises(ValueError) as e:
427+
conn.set_tls_options()
428+
self.assertIn("TLS connection already established", str(e.exception))
429+
374430
def test_dse(self):
375431
dse = self._ldap_conn.read_rootdse_s()
376432
self.assertIsInstance(dse, dict)

0 commit comments

Comments
 (0)