Repository: cobbler/cobbler

Issue:
Cobbler uses deprecated MD5 password hashing, cannot run in FIPS mode
Trying to run Cobbler under RHEL7 with FIPS-mode enabled... cobbler logins fail due to
md5 password hashes being unsupported in FIPS.

cobbler/modules/authn_configfile.py and cobbler/utils.py are the only places where md5 is mentioned, and python2.7 hashlib supposedly provides sha256() so I tried the trivial replacement of sha256() for md5(), and used "openssl passwd -5" to generate a proper password hash...

unfortunately, my trivial changes did not work...

help?


Discussion:
@zagarjr I will investigate tomorrow. It would help knowing which version of cobbler you are using!
I'm currently working with cobbler-2.8.5 from EPEL...
@zagarjr Okay I have now setup a VM as a test environment. I never worked with CentOS or even the FIPS-mode. I will have a look and get back as soon as I got a good solution for you.

@brejoc or @watologo1 do we have something in our current SLES versions? I only found something for SLES 12 SP1.

@mcalmer Is this something a customer could open a ticket for at SUMA?
This is also relevant for 3.0.x releases as nothing has changed in this piece of code.
@SchoolGuy I think not, as we allow access only from localhost. But in general it is a good idea to use something with more bits then md5 . But to not invalidate all existing installations I would suggest to add a config variable with the hash to be used. If it is empty or not exist, fallback to md5. But it give users the option to set it to "sha256" to get something more secure.
... and if the option is changed from the default to sha256, something should be done.

For example block the accounts using md5 and ask for a password reset.
@SchoolGuy 
Will this impact the FIPS certification?


@paususe Well sadly yes as far as I know. I know what to do but I haven't had the time to work on it.

This can be fixed like stated above if we rework the authentication mechanism of cobbler and use a more modern hash algorithm. I would say including testing the changes I would need half a week to do it. 

Resolving patch:
diff --git a/cobbler/module_loader.py b/cobbler/module_loader.py
--- a/cobbler/module_loader.py
+++ b/cobbler/module_loader.py
@@ -40,9 +40,6 @@
 MODULE_CACHE = {}
 MODULES_BY_CATEGORY = {}
 
-cp = ConfigParser()
-cp.read("/etc/cobbler/modules.conf")
-
 
 def load_modules(module_path=mod_path, blacklist=None):
     logger = clogger.Logger()
@@ -110,6 +107,8 @@ def get_module_name(category, field, fallback_module_name=None):
     @raise CX if unable to find configuration file
     @return str module name
     """
+    cp = ConfigParser()
+    cp.read("/etc/cobbler/modules.conf")
 
     try:
         value = cp.get(category, field)
diff --git a/cobbler/modules/authentication/configfile.py b/cobbler/modules/authentication/configfile.py
--- a/cobbler/modules/authentication/configfile.py
+++ b/cobbler/modules/authentication/configfile.py
@@ -21,12 +21,42 @@
 02110-1301  USA
 """
 
-import os
 import hashlib
+import os
 
+from cobbler.module_loader import get_module_name
 
-def md5(key):
-    return hashlib.md5(key.encode('utf-8'))
+
+def hashfun(text):
+    """
+    Converts a str object to a hash which was configured in modules.conf of the cobbler settings.
+
+    :param text: The text to hash.
+    :type text: str
+    :return: The hash of the text. This should output the same hash when entered the same text.
+    """
+    hashfunction = get_module_name("authentication", "hash_algorithm", "sha3_512")
+    if hashfunction == "sha3_224":
+        hashalgorithm = hashlib.sha3_224(text.encode('utf-8'))
+    elif hashfunction == "sha3_384":
+        hashalgorithm = hashlib.sha3_384(text.encode('utf-8'))
+    elif hashfunction == "sha3_256":
+        hashalgorithm = hashlib.sha3_256(text.encode('utf-8'))
+    elif hashfunction == "sha3_512":
+        hashalgorithm = hashlib.sha3_512(text.encode('utf-8'))
+    elif hashfunction == "blake2b":
+        hashalgorithm = hashlib.blake2b(text.encode('utf-8'))
+    elif hashfunction == "blake2s":
+        hashalgorithm = hashlib.blake2s(text.encode('utf-8'))
+    elif hashfunction == "shake_128":
+        hashalgorithm = hashlib.shake_128(text.encode('utf-8'))
+    elif hashfunction == "shake_256":
+        hashalgorithm = hashlib.shake_256(text.encode('utf-8'))
+    else:
+        errortext = "The hashfunction (Currently: %s) must be one of the defined in /etc/cobbler/modules.conf!" \
+                    % hashfunction
+        raise ValueError(errortext)
+    return hashalgorithm.hexdigest()
 
 
 def register():
@@ -37,12 +67,10 @@ def register():
 
 
 def __parse_storage():
-
     if not os.path.exists("/etc/cobbler/users.digest"):
         return []
-    fd = open("/etc/cobbler/users.digest", encoding='utf-8')
-    data = fd.read()
-    fd.close()
+    with open("/etc/cobbler/users.digest", encoding='utf-8') as fd:
+        data = fd.read()
     results = []
     lines = data.split("\n")
     for line in lines:
@@ -57,20 +85,24 @@ def __parse_storage():
 
 def authenticate(api_handle, username, password):
     """
-    Validate a username/password combo, returning True/False
-
-    Thanks to http://trac.edgewall.org/ticket/845 for supplying
-    the algorithm info.
+    Validate a username/password combo.
+
+    Thanks to http://trac.edgewall.org/ticket/845 for supplying the algorithm info.
+    :param api_handle: Unused in this implementation.
+    :param username: The username to log in with. Must be contained in /etc/cobbler/users.digest
+    :type username: str
+    :param password: The password to log in with. Must be contained hashed in /etc/cobbler/users.digest
+    :type password: str
+    :return: A boolean which contains the information if the username/password combination is correct.
+    :rtype: bool
     """
-    # debugging only (not safe to enable)
-    # api_handle.logger.debug("backend authenticate (%s,%s)" % (username,password))
 
     userlist = __parse_storage()
-    for (user, realm, actual_blob) in userlist:
+    for (user, realm, passwordhash) in userlist:
         if user == username and realm == "Cobbler":
-            input = ":".join([user, realm, password])
-            input_blob = md5(input).hexdigest()
-            if input_blob.lower() == actual_blob.lower():
+            calculated_passwordhash = hashfun(password)
+            print("Passwordhash: %s" % passwordhash)
+            print("Calculated Passwordhash: %s" % calculated_passwordhash)
+            if calculated_passwordhash == passwordhash:
                 return True
-
     return False


Test patch:
diff --git a/tests/modules/__init__.py b/tests/modules/__init__.py
new file mode 100644
diff --git a/tests/modules/authentication/__init__.py b/tests/modules/authentication/__init__.py
new file mode 100644
diff --git a/tests/modules/authentication/configfile_test.py b/tests/modules/authentication/configfile_test.py
new file mode 100644
--- /dev/null
+++ b/tests/modules/authentication/configfile_test.py
@@ -0,0 +1,73 @@
+import pytest
+
+from cobbler.modules.authentication import configfile
+
+
+@pytest.fixture(scope="function")
+def adjust_hashfunction():
+    def _adjust_hashfunction(hashfunction):
+        modulesconf = "/etc/cobbler/modules.conf"
+        with open(modulesconf, 'r') as file:
+            data = file.readlines()
+
+        print(data[26])
+        data[26] = "hash_algorithm = %s\n" % hashfunction
+        print(data[26])
+
+        with open(modulesconf, 'w') as file:
+            file.writelines(data)
+    return _adjust_hashfunction
+
+
+class TestConfigfile:
+
+    @pytest.mark.parametrize("hashfunction,test_input,exepcted_output", [
+        ("sha3_512", "testtext", "eb17eb7a79798b31b3e625f2ff7a5cd05932254ca5f686764e9655274dde03c28ed4a7ab70b0637b5dc97e61da2ee07cc80e0c4f7d00feceb2d74cbe3a579698")
+    ])
+    def test_hashfun_positive(self, adjust_hashfunction, hashfunction, test_input, exepcted_output):
+        # Arrange
+        adjust_hashfunction(hashfunction)
+
+        # Act
+        result = configfile.hashfun(test_input)
+
+        # Assert
+        assert result == exepcted_output
+
+    @pytest.mark.parametrize("hashfunction,test_input,exepcted_output", [
+        ("md5", "testtext", "")
+    ])
+    def test_hashfun_negative(self, adjust_hashfunction, hashfunction, test_input, exepcted_output):
+        # Arrange
+        adjust_hashfunction(hashfunction)
+
+        # Act & Assert
+        with pytest.raises(ValueError):
+            configfile.hashfun(test_input)
+
+    def test_register(self):
+        assert configfile.register() is "authn"
+
+    @pytest.mark.parametrize("hashfunction, username, password", [
+        ("sha3_512", "cobbler", "cobbler")
+    ])
+    def test_authenticate_positive(self, adjust_hashfunction, hashfunction, username, password):
+        # Arrange
+        adjust_hashfunction(hashfunction)
+
+        # Act
+        result = configfile.authenticate(None, username, password)
+
+        # Assert
+        assert result
+
+    @pytest.mark.parametrize("hashfunction, username, password", [
+        ("md5", "cobbler", "cobbler")
+    ])
+    def test_authenticate_negative(self, adjust_hashfunction, hashfunction, username, password):
+        # Arrange
+        adjust_hashfunction(hashfunction)
+
+        # Act & Assert
+        with pytest.raises(ValueError):
+            configfile.authenticate(None, username, password)