Skip to content

Commit 67a0983

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 d9ded15 commit 67a0983

File tree

5 files changed

+245
-0
lines changed

5 files changed

+245
-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
@@ -1339,6 +1342,51 @@ Connection-specific LDAP options
13391342
specified by *option* to *invalue*.
13401343

13411344

1345+
.. 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
1346+
1347+
The method provides a high-level API to set TLS related options. It
1348+
avoids most common pitfalls and some catches errors early, e.g.
1349+
missing :py:const:`OPT_X_TLS_NEWCTX`. The method is available for OpenSSL
1350+
and GnuTLS backends. It raises :py:exc:`ValueError` for unsupported
1351+
backends, when libldap does not have TLS support, or TLS layer is already
1352+
installed.
1353+
1354+
*cacertfile* is a path to a PEM bundle file containing root CA certs.
1355+
Raises :py:exc:`OSError` when file is not found.
1356+
1357+
*cacertdir* is a path to a directory that contains hashed CA cert files.
1358+
Raises :py:exc:`OSError` when the directory does not exist.
1359+
1360+
*require_cert* set the cert validation strategy. Value must be one of
1361+
:py:const:`OPT_X_TLS_NEVER`, :py:const:`OPT_X_TLS_DEMAND`,
1362+
or :py:const:`OPT_X_TLS_HARD`. Hard and demand have the same meaning.
1363+
Raises :py:exc:`ValueError` for unsupported values.
1364+
1365+
*protocol_min* sets the minimum TLS protocol version. Value must one of
1366+
``0x303`` (TLS 1.2) or ``0x304`` (TLS 1.3). Raises :py:exc:`ValueError`
1367+
for unsupported values.
1368+
1369+
*cipher_suite* cipher suite string, see OpenSSL documentation for more
1370+
details.
1371+
1372+
*certfile* and *keyfile* set paths to certificate and key for client
1373+
cert authentication. Raises :py:exc:`ValueError` when only one option
1374+
is given and :py:exc:`OSError` when any file does not exist.
1375+
1376+
*crlfile* is path to a CRL file. Raises :py:exc:`OSError` when file is
1377+
not found.
1378+
1379+
*crlcheck* sets the CRL verification strategy. Value must be one of
1380+
:py:const:`OPT_X_TLS_CRL_NONE`, :py:const:`OPT_X_TLS_CRL_PEER`, or
1381+
:py:const:`OPT_X_TLS_CRL_ALL`. Raises :py:exc:`ValueError` for unsupported
1382+
values.
1383+
1384+
When *start_tls* is set then :py:meth:`LDAPObject.start_tls_s` is
1385+
automatically called for ``ldap://`` URIs. The argument is ignored
1386+
for ``ldaps://`` URIs.
1387+
1388+
.. versionadded:: 3.3
1389+
13421390
Object attributes
13431391
-----------------
13441392

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
@@ -144,6 +146,7 @@ subtree
144146
syncrepl
145147
syntaxes
146148
timelimit
149+
TLS
147150
tracebacks
148151
tuple
149152
tuples

Lib/ldap/ldapobject.py

Lines changed: 128 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,133 @@ 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+
726+
# OpenSSL and GnuTLS support these options
727+
tls_pkg = self.get_option(ldap.OPT_X_TLS_PACKAGE)
728+
if tls_pkg not in {"OpenSSL", "GnuTLS"}:
729+
raise ValueError("Unsupport TLS package '{}'.".format(tls_pkg))
730+
731+
# block ldapi ('in' because libldap supports multiple URIs)
732+
if "ldapi://" in self._uri:
733+
raise ValueError("IPC (ldapi) does not support TLS.")
734+
735+
# Check that TLS layer is not inplace yet
736+
if self._ldap_call(self._l.tls_inplace):
737+
raise ValueError("TLS connection already established")
738+
739+
def _checkfile(option, filename):
740+
# check that the file exists and is readable.
741+
# libldap doesn't verify paths until it establishes a connection
742+
if not os.access(filename, os.R_OK):
743+
raise OSError(
744+
f"{option} '{filename}' does not exist or is not readable"
745+
)
746+
747+
if cacertfile is not None:
748+
_checkfile("certfile", certfile)
749+
self.set_option(ldap.OPT_X_TLS_CACERTFILE, cacertfile)
750+
751+
if cacertdir is not None:
752+
if not os.path.isdir(cacertdir):
753+
raise OSError(
754+
"'{}' does not exist or is not a directory".format(cacertdir)
755+
)
756+
self.set_option(ldap.OPT_X_TLS_CACERTDIR, cacertdir)
757+
758+
if require_cert is not None:
759+
supported = {
760+
ldap.OPT_X_TLS_NEVER,
761+
# ALLOW is a server-side setting
762+
# ldap.OPT_X_TLS_ALLOW,
763+
ldap.OPT_X_TLS_DEMAND,
764+
ldap.OPT_X_TLS_HARD
765+
}
766+
if require_cert not in supported:
767+
raise ValueError("Unsupported value for require_cert")
768+
self.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, require_cert)
769+
770+
if protocol_min is not None:
771+
# let's not support TLS 1.0 and 1.1
772+
supported = {0x303, 0x304}
773+
if protocol_min not in supported:
774+
raise ValueError("Unsupported value for protocol_min")
775+
self.set_option(ldap.OPT_X_TLS_PROTOCOL_MIN, protocol_min)
776+
777+
if cipher_suite is not None:
778+
self.set_option(ldap.OPT_X_TLS_CIPHER_SUITE, cipher_suite)
779+
780+
if certfile is not None:
781+
if keyfile is None:
782+
raise ValueError("certfile option requires keyfile option")
783+
_checkfile("certfile", certfile)
784+
self.set_option(ldap.OPT_X_TLS_CERTFILE, certfile)
785+
786+
if keyfile is not None:
787+
if certfile is None:
788+
raise ValueError("keyfile option requires certfile option")
789+
_checkfile("keyfile", keyfile)
790+
self.set_option(ldap.OPT_X_TLS_KEYFILE, keyfile)
791+
792+
if crlfile is not None:
793+
_checkfile("crlfile", crlfile)
794+
self.set_option(ldap.OPT_X_TLS_CRLFILE, crlfile)
795+
796+
if crlcheck is not None:
797+
# no check for crlfile. OpenSSL supports CRLs in CACERTDIR, too.
798+
supported = {
799+
ldap.OPT_X_TLS_CRL_NONE,
800+
ldap.OPT_X_TLS_CRL_PEER,
801+
ldap.OPT_X_TLS_CRL_ALL
802+
}
803+
if crlcheck not in supported:
804+
raise ValueError("Unsupported value for crlcheck")
805+
self.set_option(ldap.OPT_X_TLS_CRLCHECK, crlcheck)
806+
807+
# materialize settings
808+
# 0 means client-side socket
809+
try:
810+
self.set_option(ldap.OPT_X_TLS_NEWCTX, 0)
811+
except ValueError as e:
812+
# libldap doesn't return better error message here, global debug log
813+
# may contain more information.
814+
raise ValueError(
815+
"libldap or {} does not support one or more options: {}".format(
816+
tls_pkg, e
817+
)
818+
)
819+
820+
# Cannot use OPT_X_TLS with OPT_X_TLS_HARD to enforce StartTLS.
821+
# libldap ldap_int_open_connection() calls ldap_int_tls_start() when
822+
# mode is HARD, but it does not send LDAP_EXOP_START_TLS first.
823+
if start_tls and "ldap://" in self._uri:
824+
if self.protocol_version != ldap.VERSION3:
825+
self.protocol_version = ldap.VERSION3
826+
self.start_tls_s()
827+
700828
def search_subschemasubentry_s(self,dn=None):
701829
"""
702830
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
@@ -418,6 +418,62 @@ def test_multiple_starttls(self):
418418
l.simple_bind_s(self.server.root_dn, self.server.root_pw)
419419
self.assertEqual(l.whoami_s(), 'dn:' + self.server.root_dn)
420420

421+
def assert_option_equal(self, conn, option, value):
422+
self.assertEqual(conn.get_option(option), value)
423+
424+
@requires_tls()
425+
def test_set_tls_options_ldap(self):
426+
# just any directory will do
427+
certdir = os.path.dirname(__file__)
428+
conn = self.ldap_object_class(self.server.ldap_uri)
429+
conn.set_tls_options(
430+
cacertfile=self.server.cafile,
431+
# just any directory
432+
cacertdir=certdir,
433+
require_cert=ldap.OPT_X_TLS_DEMAND,
434+
protocol_min=0x303,
435+
# libldap on Travis CI doesn't like cipher_suite
436+
# cipher_suite="ALL",
437+
certfile=self.server.clientcert,
438+
keyfile=self.server.clientkey,
439+
# libldap on TravisCI doesn't like CRL options
440+
# crlfile=None,
441+
# crlcheck=ldap.OPT_X_TLS_CRL_PEER,
442+
start_tls=False
443+
)
444+
self.assert_option_equal(
445+
conn, ldap.OPT_X_TLS_CACERTFILE, self.server.cafile
446+
)
447+
self.assert_option_equal(
448+
conn, ldap.OPT_X_TLS_CACERTDIR, certdir
449+
)
450+
self.assert_option_equal(
451+
conn, ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND
452+
)
453+
# cipher_suite depends on OpenSSL version and system settings
454+
self.assert_option_equal(
455+
conn, ldap.OPT_X_TLS_PROTOCOL_MIN, 0x303
456+
)
457+
self.assert_option_equal(
458+
conn, ldap.OPT_X_TLS_CERTFILE, self.server.clientcert
459+
)
460+
self.assert_option_equal(
461+
conn, ldap.OPT_X_TLS_KEYFILE, self.server.clientkey,
462+
)
463+
# self.assert_option_equal(
464+
# conn, ldap.OPT_X_TLS_CRLFILE, crlfile
465+
# )
466+
# self.assert_option_equal(
467+
# conn, ldap.OPT_X_TLS_CRLCHECK, ldap.OPT_X_TLS_CRL_PEER
468+
# )
469+
470+
# run again, this time with default start_tls.
471+
conn.set_tls_options()
472+
# second call should fail
473+
with self.assertRaises(ValueError) as e:
474+
conn.set_tls_options()
475+
self.assertIn("TLS connection already established", str(e.exception))
476+
421477
def test_dse(self):
422478
dse = self._ldap_conn.read_rootdse_s()
423479
self.assertIsInstance(dse, dict)

0 commit comments

Comments
 (0)