Blob Blame Raw
From 2307e77efb6a75091b9152f81a52c83b8282d61a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mat=C3=BA=C5=A1=20Hon=C4=9Bk?= <mhonek@redhat.com>
Date: Thu, 31 Jan 2019 10:44:55 +0100
Subject: [PATCH 05/12] Ticket 50217 -  Implement dsconf security section

Bug Description:
dsconf lacks options to configure security options

Fix Description:
Implementing options to configure security related attributes and handle ciphers
configuration.

Fixes: https://pagure.io/389-ds-base/issue/50217

Author: Matus Honek <mhonek@redhat.com>

Review by: firstyear, mreynolds (Thanks!)
---
 src/lib389/cli/dsconf                  |   2 +
 src/lib389/lib389/cli_conf/security.py | 244 +++++++++++++++++++++++++
 src/lib389/lib389/config.py            |  97 +++++++++-
 src/lib389/lib389/nss_ssl.py           |   7 +-
 4 files changed, 343 insertions(+), 7 deletions(-)
 create mode 100644 src/lib389/lib389/cli_conf/security.py

diff --git a/src/lib389/cli/dsconf b/src/lib389/cli/dsconf
index f81516290..c0c0b4dfe 100755
--- a/src/lib389/cli/dsconf
+++ b/src/lib389/cli/dsconf
@@ -32,6 +32,7 @@ from lib389.cli_conf import backup as cli_backup
 from lib389.cli_conf import replication as cli_replication
 from lib389.cli_conf import chaining as cli_chaining
 from lib389.cli_conf import conflicts as cli_repl_conflicts
+from lib389.cli_conf import security as cli_security
 from lib389.cli_base import disconnect_instance, connect_instance
 from lib389.cli_base.dsrc import dsrc_to_ldap, dsrc_arg_concat
 from lib389.cli_base import setup_script_logger
@@ -87,6 +88,7 @@ cli_plugin.create_parser(subparsers)
 cli_pwpolicy.create_parser(subparsers)
 cli_replication.create_parser(subparsers)
 cli_sasl.create_parser(subparsers)
+cli_security.create_parser(subparsers)
 cli_schema.create_parser(subparsers)
 cli_repl_conflicts.create_parser(subparsers)
 
diff --git a/src/lib389/lib389/cli_conf/security.py b/src/lib389/lib389/cli_conf/security.py
new file mode 100644
index 000000000..6d8c1ae0f
--- /dev/null
+++ b/src/lib389/lib389/cli_conf/security.py
@@ -0,0 +1,244 @@
+# --- BEGIN COPYRIGHT BLOCK ---
+# Copyright (C) 2019 Red Hat, Inc.
+# All rights reserved.
+#
+# License: GPL (version 3 or any later version).
+# See LICENSE for details.
+# --- END COPYRIGHT BLOCK ---
+
+from collections import OrderedDict, namedtuple
+import json
+
+from lib389.config import Config, Encryption, RSA
+from lib389.nss_ssl import NssSsl
+
+
+Props = namedtuple('Props', ['cls', 'attr', 'help', 'values'])
+
+onoff = ('on', 'off')
+protocol_versions = ('SSLv3', 'TLS1.0', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3', '')
+SECURITY_ATTRS_MAP = OrderedDict([
+    ('security', Props(Config, 'nsslapd-security',
+                       'Enable or disable security',
+                       onoff)),
+    ('listen-host', Props(Config, 'nsslapd-securelistenhost',
+                          'Host/address to listen on for LDAPS',
+                          str)),
+    ('secure-port', Props(Config, 'nsslapd-securePort',
+                          'Port for LDAPS to listen on',
+                          range(1, 65536))),
+    ('tls-client-auth', Props(Config, 'nsSSLClientAuth',
+                          'Client authentication requirement',
+                          ('off', 'allowed', 'required'))),
+    ('require-secure-authentication', Props(Config, 'nsslapd-require-secure-binds',
+                                   'Require binds over LDAPS, StartTLS, or SASL',
+                                   onoff)),
+    ('check-hostname', Props(Config, 'nsslapd-ssl-check-hostname',
+                             'Check Subject of remote certificate against the hostname',
+                             onoff)),
+    ('verify-cert-chain-on-startup', Props(Config, 'nsslapd-validate-cert',
+                                'Validate server certificate during startup',
+                                ('warn', *onoff))),
+    ('session-timeout', Props(Encryption, 'nsSSLSessionTimeout',
+                              'Secure session timeout',
+                              int)),
+    ('tls-protocol-min', Props(Encryption, 'sslVersionMin',
+                           'Secure protocol minimal allowed version',
+                           protocol_versions)),
+    ('tls-protocol-max', Props(Encryption, 'sslVersionMax',
+                           'Secure protocol maximal allowed version',
+                           protocol_versions)),
+    ('allow-insecure-ciphers', Props(Encryption, 'allowWeakCipher',
+                                'Allow weak ciphers for legacy use',
+                                onoff)),
+    ('allow-weak-dh-param', Props(Encryption, 'allowWeakDHParam',
+                                  'Allow short DH params for legacy use',
+                                  onoff)),
+])
+
+RSA_ATTRS_MAP = OrderedDict([
+    ('tls-allow-rsa-certificates', Props(RSA, 'nsSSLActivation',
+                             'Activate use of RSA certificates',
+                             onoff)),
+    ('nss-cert-name', Props(RSA, 'nsSSLPersonalitySSL',
+                          'Server certificate name in NSS DB',
+                          str)),
+    ('nss-token', Props(RSA, 'nsSSLToken',
+                    'Security token name (module of NSS DB)',
+                    str))
+])
+
+
+def _security_generic_get(inst, basedn, logs, args, attrs_map):
+    result = {}
+    for attr, props in attrs_map.items():
+        val = props.cls(inst).get_attr_val_utf8(props.attr)
+        result[props.attr] = val
+    if args.json:
+        print(json.dumps({'type': 'list', 'items': result}))
+    else:
+        print('\n'.join([f'{attr}: {value or ""}' for attr, value in result.items()]))
+
+
+def _security_generic_set(inst, basedn, logs, args, attrs_map):
+    for attr, props in attrs_map.items():
+        arg = getattr(args, attr.replace('-', '_'))
+        if arg is None:
+            continue
+        dsobj = props.cls(inst)
+        dsobj.replace(props.attr, arg)
+
+
+def _security_generic_get_parser(parent, attrs_map, help):
+    p = parent.add_parser('get', help=help)
+    p.set_defaults(func=lambda *args: _security_generic_get(*args, attrs_map))
+    return p
+
+
+def _security_generic_set_parser(parent, attrs_map, help, description):
+    p = parent.add_parser('set', help=help, description=description)
+    p.set_defaults(func=lambda *args: _security_generic_set(*args, attrs_map))
+    for opt, params in attrs_map.items():
+        p.add_argument(f'--{opt}', help=f'{params[2]} ({params[1]})')
+    return p
+
+
+def _security_ciphers_change(mode, ciphers, inst, log):
+    log = log.getChild('_security_ciphers_change')
+    if ('default' in ciphers) or ('all' in ciphers):
+        log.error(('Use ciphers\' names only. Keywords "default" and "all" are ignored. '
+                   'Please, instead specify them manually using \'set\' command.'))
+        return
+    enc = Encryption(inst)
+    if enc.change_ciphers(mode, ciphers) is False:
+        log.error('Setting new ciphers failed.')
+
+
+def _security_generic_toggle(inst, basedn, log, args, cls, attr, value, thing):
+    cls(inst).set(attr, value)
+
+
+def _security_generic_toggle_parsers(parent, cls, attr, help_pattern):
+    def add_parser(action, value):
+        p = parent.add_parser(action.lower(), help=help_pattern.format(action))
+        p.set_defaults(func=lambda *args: _security_generic_toggle(*args, cls, attr, value, action))
+        return p
+
+    return list(map(add_parser, ('Enable', 'Disable'), ('on', 'off')))
+
+
+def security_enable(inst, basedn, log, args):
+    dbpath = inst.get_cert_dir()
+    tlsdb = NssSsl(dbpath=dbpath)
+    if not tlsdb._db_exists(even_partial=True):  # we want to be very careful
+        log.info(f'Secure database does not exist. Creating a new one in {dbpath}.')
+        tlsdb.reinit()
+
+    Config(inst).set('nsslapd-security', 'on')
+
+
+def security_disable(inst, basedn, log, args):
+    Config(inst).set('nsslapd-security', 'off')
+
+
+def security_ciphers_enable(inst, basedn, log, args):
+    _security_ciphers_change('+', args.cipher, inst, log)
+
+
+def security_ciphers_disable(inst, basedn, log, args):
+    _security_ciphers_change('-', args.cipher, inst, log)
+
+
+def security_ciphers_set(inst, basedn, log, args):
+    enc = Encryption(inst)
+    enc.ciphers = args.cipher_string.split(',')
+
+
+def security_ciphers_get(inst, basedn, log, args):
+    enc = Encryption(inst)
+    if args.json:
+        print({'type': 'list', 'items': enc.ciphers})
+    else:
+        val = ','.join(enc.ciphers)
+        print(val if val != '' else '<undefined>')
+
+
+def security_ciphers_list(inst, basedn, log, args):
+    enc = Encryption(inst)
+
+    if args.enabled:
+        lst = enc.enabled_ciphers
+    elif args.supported:
+        lst = enc.supported_ciphers
+    elif args.disabled:
+        lst = set(enc.supported_ciphers) - set(enc.enabled_ciphers)
+    else:
+        lst = enc.ciphers
+
+    if args.json:
+        print(json.dumps({'type': 'list', 'items': lst}))
+    else:
+        if lst == []:
+            log.getChild('security').warn('List of ciphers is empty')
+        else:
+            print(*lst, sep='\n')
+
+
+def create_parser(subparsers):
+    security = subparsers.add_parser('security', help='Query and manipulate security options')
+    security_sub = security.add_subparsers(help='security')
+    security_set = _security_generic_set_parser(security_sub, SECURITY_ATTRS_MAP, 'Set general security options',
+        ('Use this command for setting security related options located in cn=config and cn=encryption,cn=config.'
+         '\n\nTo enable/disable security you can use enable and disable commands instead.'))
+    security_get = _security_generic_get_parser(security_sub, SECURITY_ATTRS_MAP, 'Get general security options')
+    security_enable_p = security_sub.add_parser('enable', help='Enable security', description=(
+        'If missing, create security database, then turn on security functionality. Please note this is usually not'
+        ' enought for TLS connections to work - proper setup of CA and server certificate is necessary.'))
+    security_enable_p.set_defaults(func=security_enable)
+    security_disable_p = security_sub.add_parser('disable', help='Disable security', description=(
+        'Turn off security functionality. The rest of the configuration will be left untouched.'))
+    security_disable_p.set_defaults(func=security_disable)
+
+    rsa = security_sub.add_parser('rsa', help='Query and mainpulate RSA security options')
+    rsa_sub = rsa.add_subparsers(help='rsa')
+    rsa_set = _security_generic_set_parser(rsa_sub, RSA_ATTRS_MAP, 'Set RSA security options',
+        ('Use this command for setting RSA (private key) related options located in cn=RSA,cn=encryption,cn=config.'
+         '\n\nTo enable/disable RSA you can use enable and disable commands instead.'))
+    rsa_get = _security_generic_get_parser(rsa_sub, RSA_ATTRS_MAP, 'Get RSA security options')
+    rsa_toggles = _security_generic_toggle_parsers(rsa_sub, RSA, 'nsSSLActivation', '{} RSA')
+
+    ciphers = security_sub.add_parser('ciphers', help='Manage secure ciphers')
+    ciphers_sub = ciphers.add_subparsers(help='ciphers')
+
+    ciphers_enable = ciphers_sub.add_parser('enable', help='Enable ciphers', description=(
+        'Use this command to enable specific ciphers.'))
+    ciphers_enable.set_defaults(func=security_ciphers_enable)
+    ciphers_enable.add_argument('cipher', nargs='+')
+
+    ciphers_disable = ciphers_sub.add_parser('disable', help='Disable ciphers', description=(
+        'Use this command to disable specific ciphers.'))
+    ciphers_disable.set_defaults(func=security_ciphers_disable)
+    ciphers_disable.add_argument('cipher', nargs='+')
+
+    ciphers_get = ciphers_sub.add_parser('get', help='Get ciphers attribute', description=(
+        'Use this command to get contents of nsSSL3Ciphers attribute.'))
+    ciphers_get.set_defaults(func=security_ciphers_get)
+
+    ciphers_set = ciphers_sub.add_parser('set', help='Set ciphers attribute', description=(
+        'Use this command to directly set nsSSL3Ciphers attribute. It is a comma separated list '
+        'of cipher names (prefixed with + or -), optionaly including +all or -all. The attribute '
+        'may optionally be prefixed by keyword default. Please refer to documentation of '
+        'the attribute for a more detailed description.'))
+    ciphers_set.set_defaults(func=security_ciphers_set)
+    ciphers_set.add_argument('cipher_string', metavar='cipher-string')
+
+    ciphers_list = ciphers_sub.add_parser('list', help='List ciphers', description=(
+        'List secure ciphers. Without arguments, list ciphers as configured in nsSSL3Ciphers attribute.'))
+    ciphers_list.set_defaults(func=security_ciphers_list)
+    ciphers_list_group = ciphers_list.add_mutually_exclusive_group()
+    ciphers_list_group.add_argument('--enabled', action='store_true',
+                                    help='Only enabled ciphers')
+    ciphers_list_group.add_argument('--supported', action='store_true',
+                                    help='Only supported ciphers')
+    ciphers_list_group.add_argument('--disabled', action='store_true',
+                                    help='Only supported ciphers without enabled ciphers')
diff --git a/src/lib389/lib389/config.py b/src/lib389/lib389/config.py
index b462585df..c2a34fa07 100644
--- a/src/lib389/lib389/config.py
+++ b/src/lib389/lib389/config.py
@@ -1,5 +1,5 @@
 # --- BEGIN COPYRIGHT BLOCK ---
-# Copyright (C) 2015 Red Hat, Inc.
+# Copyright (C) 2019 Red Hat, Inc.
 # All rights reserved.
 #
 # License: GPL (version 3 or any later version).
@@ -202,14 +202,16 @@ class Config(DSLdapObject):
             return DSCLE0002
         return None
 
+
 class Encryption(DSLdapObject):
     """
         Manage "cn=encryption,cn=config" tree, including:
         - ssl ciphers
         - ssl / tls levels
     """
-    def __init__(self, conn):
+    def __init__(self, conn, dn=None):
         """@param conn - a DirSrv instance """
+        assert dn is None  # compatibility with Config class
         super(Encryption, self).__init__(instance=conn)
         self._dn = 'cn=encryption,%s' % DN_CONFIG
         self._create_objectclasses = ['top', 'nsEncryptionConfig']
@@ -225,11 +227,97 @@ class Encryption(DSLdapObject):
         super(Encryption, self).create(properties=properties)
 
     def _lint_check_tls_version(self):
-        tls_min = self.get_attr_val('sslVersionMin');
+        tls_min = self.get_attr_val('sslVersionMin')
         if tls_min < ensure_bytes('TLS1.1'):
             return DSELE0001
         return None
 
+    @property
+    def ciphers(self):
+        """List of requested ciphers.
+
+        Each is represented by a string, either of:
+        - "+all" or "-all"
+        - TLS cipher RFC name, prefixed with either "+" or "-"
+
+        Optionally, first element may be a string "default".
+
+        :returns: list of str
+        """
+        val = self.get_attr_val_utf8('nsSSL3Ciphers')
+        return val.split(',') if val else []
+
+    @ciphers.setter
+    def ciphers(self, ciphers):
+        """List of requested ciphers.
+
+        :param ciphers: Ciphers to enable
+        :type ciphers: list of str
+        """
+        self.set('nsSSL3Ciphers', ','.join(ciphers))
+        self._log.info('Remeber to restart the server to apply the new cipher set.')
+        self._log.info('Some ciphers may be disabled anyway due to allowWeakCipher attribute.')
+
+    def _get_listed_ciphers(self, attr):
+        """Remove features of ciphers that come after first :: occurence."""
+        return [c[:c.index('::')] for c in self.get_attr_vals_utf8(attr)]
+
+    @property
+    def enabled_ciphers(self):
+        """List currently enabled ciphers.
+
+        :returns: list of str
+        """
+        return self._get_listed_ciphers('nsSSLEnabledCiphers')
+
+    @property
+    def supported_ciphers(self):
+        """List currently supported ciphers.
+
+        :returns: list of str
+        """
+        return self._get_listed_ciphers('nsSSLSupportedCiphers')
+
+    def _check_ciphers_supported(self, ciphers):
+        good = True
+        for c in ciphers:
+            if c not in self.supported_ciphers:
+                self._log.warn(f'Cipher {c} is not supported.')
+                good = False
+        return good
+
+    def change_ciphers(self, mode, ciphers):
+        """Enable or disable ciphers of the nsSSL3Ciphers attribute.
+
+        :param mode: '+'/'-' string to enable/disable the ciphers
+        :type mode: str
+        :param ciphers: List of ciphers to enable/disable
+        :type ciphers: list of string
+
+        :returns: False if some cipher is not supported
+        """
+        if ('default' in ciphers) or 'all' in ciphers:
+            raise NotImplementedError('Processing "default" and "all" is not implemented.')
+        if not self._check_ciphers_supported(ciphers):
+            return False
+
+        if mode == '+':
+            to_change = [c for c in ciphers if c not in self.enabled_ciphers]
+        elif mode == '-':
+            to_change = [c for c in ciphers if c in self.enabled_ciphers]
+        else:
+            raise ValueError('Incorrect mode. Use - or + sign.')
+        if len(to_change) != len(ciphers):
+            self._log.info(
+                ('Applying changes only for the following ciphers, the rest is up to date. '
+                 'If this does not seem to be correct, please make sure the effective '
+                 'set of enabled ciphers is up to date with configured ciphers '
+                 '- a server restart is needed for these to be applied.\n'
+                 f'... {to_change}'))
+        cleaned = [c for c in self.ciphers if c[1:] not in to_change]
+        self.ciphers = cleaned + list(map(lambda c: mode + c, to_change))
+
+
 class RSA(DSLdapObject):
     """
         Manage the "cn=RSA,cn=encryption,cn=config" object
@@ -237,8 +325,9 @@ class RSA(DSLdapObject):
         - Database path
         - ssl token name
     """
-    def __init__(self, conn):
+    def __init__(self, conn, dn=None):
         """@param conn - a DirSrv instance """
+        assert dn is None  # compatibility with Config class
         super(RSA, self).__init__(instance=conn)
         self._dn = 'cn=RSA,cn=encryption,%s' % DN_CONFIG
         self._create_objectclasses = ['top', 'nsEncryptionModule']
diff --git a/src/lib389/lib389/nss_ssl.py b/src/lib389/lib389/nss_ssl.py
index 7a8f2a5bd..a54095cd4 100644
--- a/src/lib389/lib389/nss_ssl.py
+++ b/src/lib389/lib389/nss_ssl.py
@@ -162,11 +162,12 @@ only.
         self.log.debug("nss output: %s", result)
         return True
 
-    def _db_exists(self):
+    def _db_exists(self, even_partial=False):
         """Check that a nss db exists at the certpath"""
 
-        if all(map(os.path.exists, self.db_files["dbm_backend"])) or \
-           all(map(os.path.exists, self.db_files["sql_backend"])):
+        fn = any if even_partial else all
+        if fn(map(os.path.exists, self.db_files["dbm_backend"])) or \
+           fn(map(os.path.exists, self.db_files["sql_backend"])):
             return True
         return False
 
-- 
2.21.0