Skip to content

Commit aad4418

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 <cheimes@redhat.com>
1 parent 39ea8e5 commit aad4418

File tree

5 files changed

+237
-0
lines changed

5 files changed

+237
-0
lines changed

Doc/reference/ldap.rst

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,9 @@ SASL options
241241
TLS options
242242
:::::::::::
243243

244+
The method :py:meth:`LDAPObject.set_tls_options` provides a high-level API
245+
to configure TLS options.
246+
244247
.. warning::
245248

246249
libldap does not materialize all TLS settings immediately. You must use
@@ -1299,6 +1302,51 @@ Connection-specific LDAP options
12991302
specified by *option* to *invalue*.
13001303

13011304

1305+
.. 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
1306+
1307+
The method provides a high-level API to set TLS related options. It
1308+
avoids most common pitfalls and some catches errors early, e.g.
1309+
missing :py:const:`OPT_X_TLS_NEWCTX`. The method is available for OpenSSL
1310+
and GnuTLS backends. It raises :py:exc:`ValueError` for unsupported
1311+
backends, when libldap does not have TLS support, or TLS layer is already
1312+
installed.
1313+
1314+
*cacertfile* is a path to a PEM bundle file containing root CA certs.
1315+
Raises :py:exc:`OSError` when file is not found.
1316+
1317+
*cacertdir* is a path to a directory that contains hashed CA cert files.
1318+
Raises :py:exc:`OSError` when the directory does not exist.
1319+
1320+
*require_cert* set the cert validation strategy. Value must be one of
1321+
:py:const:`OPT_X_TLS_NEVER`, :py:const:`OPT_X_TLS_DEMAND`,
1322+
or :py:const:`OPT_X_TLS_HARD`. Hard and demand have the same meaning.
1323+
Raises :py:exc:`ValueError` for unsupported values.
1324+
1325+
*protocol_min* sets the minimum TLS protocol version. Value must one of
1326+
``0x303`` (TLS 1.2) or ``0x304`` (TLS 1.3). Raises :py:exc:`ValueError`
1327+
for unsupported values.
1328+
1329+
*cipher_suite* cipher suite string, see OpenSSL documentation for more
1330+
details.
1331+
1332+
*certfile* and *keyfile* set paths to certificate and key for client
1333+
cert authentication. Raises :py:exc:`ValueError` when only one option
1334+
is given and :py:exc:`OSError` when any file does not exist.
1335+
1336+
*crlfile* is path to a CRL file. Raises :py:exc:`OSError` when file is
1337+
not found.
1338+
1339+
*crlcheck* sets the CRL verification strategy. Value must be one of
1340+
:py:const:`OPT_X_TLS_CRL_NONE`, :py:const:`OPT_X_TLS_CRL_PEER`, or
1341+
:py:const:`OPT_X_TLS_CRL_ALL`. Raises :py:exc:`ValueError` for unsupported
1342+
values.
1343+
1344+
When *start_tls* is set then :py:meth:`LDAPObject.start_tls_s` is
1345+
automatically called for ``ldap://`` URIs. The argument is ignored
1346+
for ``ldaps://`` URIs.
1347+
1348+
.. versionadded:: 3.3
1349+
13021350
Object attributes
13031351
-----------------
13041352

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
Heimdal
6062
hostport
6163
hrefTarget
@@ -139,6 +141,7 @@ subtree
139141
syncrepl
140142
syntaxes
141143
timelimit
144+
TLS
142145
tracebacks
143146
tuple
144147
tuples

Lib/ldap/ldapobject.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import unicode_literals
88

99
from os import strerror
10+
import os.path
1011

1112
from ldap.pkginfo import __version__, __author__, __license__
1213

@@ -934,6 +935,125 @@ def set_option(self,option,invalue):
934935
invalue = RequestControlTuples(invalue)
935936
return self._ldap_call(self._l.set_option,option,invalue)
936937

938+
def set_tls_options(self, cacertfile=None, cacertdir=None,
939+
require_cert=None, protocol_min=None,
940+
cipher_suite=None, certfile=None, keyfile=None,
941+
crlfile=None, crlcheck=None, start_tls=True):
942+
"""Set TLS/SSL options
943+
944+
:param cacertfile: path to a PEM bundle file containing root CA certs
945+
:param cacertdir: path to a directory with hashed CA certificates
946+
:param require_cert: cert validation strategy, one of
947+
ldap.OPT_X_TLS_NEVER, OPT_X_TLS_DEMAND, OPT_X_TLS_HARD. Hard and
948+
demand have the same meaning for client side sockets.
949+
:param protocol_min: minimum protocol version, one of 0x303 (TLS 1.2)
950+
or 0x304 (TLS 1.3).
951+
:param cipher_suite: cipher suite string
952+
:param certfile: path to cert file for client cert authentication
953+
:param keyfile: path to key file for client cert authentication
954+
:param crlfile: path to a CRL file
955+
:param crlcheck: CRL verification strategy, one of
956+
ldap.OPT_X_TLS_CRL_NONE, ldap.OPT_X_TLS_CRL_PEER, or
957+
ldap.OPT_X_TLS_CRL_ALL
958+
:param start_tls: automatically perform StartTLS for ldap:// connections
959+
"""
960+
if not hasattr(ldap, "OPT_X_TLS_NEWCTX"):
961+
raise ValueError("libldap does not have TLS support")
962+
# OpenSSL and GnuTLS support these options
963+
tls_pkg = self.get_option(ldap.OPT_X_TLS_PACKAGE)
964+
if tls_pkg not in {"OpenSSL", "GnuTLS"}:
965+
raise ValueError("Unsupport TLS package '{}'.".format(tls_pkg))
966+
# libldap supports multiple URIs,
967+
if "ldapi://" in self._uri:
968+
raise ValueError("IPC (ldapi) does not support TLS.")
969+
if self._ldap_call(self._l.tls_inplace):
970+
raise ValueError("TLS layer is already installed")
971+
972+
def _checkfile(option, filename):
973+
# check that the file exists and is readable.
974+
# libldap doesn't verify paths until it establishes a connection
975+
with open(filename, "rb"):
976+
pass
977+
978+
if cacertfile is not None:
979+
_checkfile("certfile", certfile)
980+
self.set_option(ldap.OPT_X_TLS_CACERTFILE, cacertfile)
981+
982+
if cacertdir is not None:
983+
if not os.path.isdir(cacertdir):
984+
raise OSError(
985+
"'{}' does not exist or is not a directory".format(cacertdir)
986+
)
987+
self.set_option(ldap.OPT_X_TLS_CACERTDIR, cacertdir)
988+
989+
if require_cert is not None:
990+
supported = {
991+
ldap.OPT_X_TLS_NEVER,
992+
# ALLOW is a server-side setting
993+
# ldap.OPT_X_TLS_ALLOW,
994+
ldap.OPT_X_TLS_DEMAND,
995+
ldap.OPT_X_TLS_HARD
996+
}
997+
if require_cert not in supported:
998+
raise ValueError("Unsupported value for require_cert")
999+
self.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, require_cert)
1000+
1001+
if protocol_min is not None:
1002+
# let's not support TLS 1.0 and 1.1
1003+
supported = {0x303, 0x304}
1004+
if protocol_min not in supported:
1005+
raise ValueError("Unsupported value for protocol_min")
1006+
self.set_option(ldap.OPT_X_TLS_PROTOCOL_MIN, protocol_min)
1007+
1008+
if cipher_suite is not None:
1009+
self.set_option(ldap.OPT_X_TLS_CIPHER_SUITE, cipher_suite)
1010+
1011+
if certfile is not None:
1012+
if keyfile is None:
1013+
raise ValueError("certfile option requires keyfile option")
1014+
_checkfile("certfile", certfile)
1015+
self.set_option(ldap.OPT_X_TLS_CERTFILE, certfile)
1016+
1017+
if keyfile is not None:
1018+
if certfile is None:
1019+
raise ValueError("keyfile option requires certfile option")
1020+
_checkfile("keyfile", keyfile)
1021+
self.set_option(ldap.OPT_X_TLS_KEYFILE, keyfile)
1022+
1023+
if crlfile is not None:
1024+
_checkfile("crlfile", crlfile)
1025+
self.set_option(ldap.OPT_X_TLS_CRLFILE, crlfile)
1026+
1027+
if crlcheck is not None:
1028+
# no check for crlfile, CRLs can be provided by CACERTDIR, too.
1029+
supported = {
1030+
ldap.OPT_X_TLS_CRL_NONE,
1031+
ldap.OPT_X_TLS_CRL_PEER,
1032+
ldap.OPT_X_TLS_CRL_ALL
1033+
}
1034+
if crlcheck not in supported:
1035+
raise ValueError("Unsupported value for crlcheck")
1036+
self.set_option(ldap.OPT_X_TLS_CRLCHECK, crlcheck)
1037+
1038+
# materialize settings
1039+
# 0 means client-side socket
1040+
try:
1041+
self.set_option(ldap.OPT_X_TLS_NEWCTX, 0)
1042+
except ValueError as e:
1043+
raise ValueError(
1044+
"libldap or {} does not support one or more options: {}".format(
1045+
tls_pkg, e
1046+
)
1047+
)
1048+
1049+
# Cannot use OPT_X_TLS with OPT_X_TLS_HARD to enforce StartTLS.
1050+
# libldap ldap_int_open_connection() calls ldap_int_tls_start() when
1051+
# mode is HARD, but it does not send LDAP_EXOP_START_TLS first.
1052+
if start_tls and "ldap://" in self._uri:
1053+
if self.protocol_version != ldap.VERSION3:
1054+
self.protocol_version = ldap.VERSION3
1055+
self.start_tls_s()
1056+
9371057
def search_subschemasubentry_s(self,dn=None):
9381058
"""
9391059
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
@@ -623,6 +623,62 @@ def test_multiple_starttls(self):
623623
l.simple_bind_s(self.server.root_dn, self.server.root_pw)
624624
self.assertEqual(l.whoami_s(), 'dn:' + self.server.root_dn)
625625

626+
def assert_option_equal(self, conn, option, value):
627+
self.assertEqual(conn.get_option(option), value)
628+
629+
@requires_tls()
630+
def test_set_tls_options_ldap(self):
631+
# just any directory will do
632+
certdir = os.path.dirname(__file__)
633+
conn = self.ldap_object_class(self.server.ldap_uri)
634+
conn.set_tls_options(
635+
cacertfile=self.server.cafile,
636+
# just any directory
637+
cacertdir=certdir,
638+
require_cert=ldap.OPT_X_TLS_DEMAND,
639+
protocol_min=0x303,
640+
# libldap on Travis CI doesn't like cipher_suite
641+
# cipher_suite="ALL",
642+
certfile=self.server.clientcert,
643+
keyfile=self.server.clientkey,
644+
# libldap on TravisCI doesn't like CRL options
645+
# crlfile=None,
646+
# crlcheck=ldap.OPT_X_TLS_CRL_PEER,
647+
start_tls=False
648+
)
649+
self.assert_option_equal(
650+
conn, ldap.OPT_X_TLS_CACERTFILE, self.server.cafile
651+
)
652+
self.assert_option_equal(
653+
conn, ldap.OPT_X_TLS_CACERTDIR, certdir
654+
)
655+
self.assert_option_equal(
656+
conn, ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND
657+
)
658+
# cipher_suite depends on OpenSSL version and system settings
659+
self.assert_option_equal(
660+
conn, ldap.OPT_X_TLS_PROTOCOL_MIN, 0x303
661+
)
662+
self.assert_option_equal(
663+
conn, ldap.OPT_X_TLS_CERTFILE, self.server.clientcert
664+
)
665+
self.assert_option_equal(
666+
conn, ldap.OPT_X_TLS_KEYFILE, self.server.clientkey,
667+
)
668+
# self.assert_option_equal(
669+
# conn, ldap.OPT_X_TLS_CRLFILE, crlfile
670+
# )
671+
# self.assert_option_equal(
672+
# conn, ldap.OPT_X_TLS_CRLCHECK, ldap.OPT_X_TLS_CRL_PEER
673+
# )
674+
675+
# run again, this time with default start_tls.
676+
conn.set_tls_options()
677+
# second call should fail
678+
with self.assertRaises(ValueError) as e:
679+
conn.set_tls_options()
680+
self.assertIn("TLS layer is already installed", str(e.exception))
681+
626682
def test_dse(self):
627683
dse = self._ldap_conn.read_rootdse_s()
628684
self.assertIsInstance(dse, dict)

0 commit comments

Comments
 (0)