commit 329216fef650ed57158ad65d458ebc6975abb350
Author: Stanislav Král <stanislav.kral3@gmail.com>
Date:   Thu Apr 22 00:39:51 2021 +0200

    Re #8585 - Fixed an issue where required extensions based on cert. usages were not present in the generated certificate
    
    Created an utility that converts usages to extension configuration lines
    Defined which extension field flags are required for certain usages

diff --git a/src/services/certificate_service.py b/src/services/certificate_service.py
index 8e645b8..a7d9863 100644
--- a/src/services/certificate_service.py
+++ b/src/services/certificate_service.py
@@ -4,7 +4,7 @@ from injector import inject
 
 from src.config.configuration import Configuration
 from src.constants import ROOT_CA_ID, INTERMEDIATE_CA_ID, CA_ID, CERTIFICATE_ID, CERTIFICATE_STATES, \
-    CERTIFICATE_REVOCATION_REASONS
+    CERTIFICATE_REVOCATION_REASONS, SSL_ID, SIGNATURE_ID, AUTHENTICATION_ID
 from src.dao.certificate_repository import CertificateRepository
 from src.exceptions.certificate_not_found_exception import CertificateNotFoundException
 from src.exceptions.database_exception import DatabaseException
@@ -16,6 +16,10 @@ from src.services.cryptography import CryptographyService
 
 import time
 
+from src.utils.usages_to_extensions import usages_to_extension_lines, ExtensionFieldFlags, CRITICAL, KEY_CERT_SIGN, \
+    CRL_SIGN, CA, DIGITAL_SIGNATURE, KEY_ENCIPHERMENT, KEY_AGREEMENT, SERVER_AUTH, NON_REPUDIATION, TIME_STAMPING, \
+    CLIENT_AUTH
+
 VALID_FROM_TO_DATE_FORMAT = "%d.%m.%Y %H:%M:%S"
 CA_EXTENSIONS = "basicConstraints=critical,CA:TRUE"
 CRL_EXTENSION = "crlDistributionPoints=URI:"
@@ -23,6 +27,13 @@ OCSP_EXTENSION = "authorityInfoAccess=OCSP;URI:"
 STATUS_REVOKED = "revoked"
 STATUS_VALID = "valid"
 
+# define which flags are required for various usages
+REQUIRED_USAGE_EXTENSION_FLAGS = {
+    CA_ID: ExtensionFieldFlags({CRITICAL, KEY_CERT_SIGN, CRL_SIGN}, {}, {CRITICAL, CA}),
+    SSL_ID: ExtensionFieldFlags({DIGITAL_SIGNATURE, KEY_ENCIPHERMENT, KEY_AGREEMENT}, {SERVER_AUTH}, {}),
+    SIGNATURE_ID: ExtensionFieldFlags({DIGITAL_SIGNATURE, NON_REPUDIATION}, {TIME_STAMPING}, {}),
+    AUTHENTICATION_ID: ExtensionFieldFlags({DIGITAL_SIGNATURE}, {CLIENT_AUTH}, {})}
+
 
 class CertificateService:
 
@@ -52,11 +63,15 @@ class CertificateService:
 
         cert_id = self.certificate_repository.get_next_id()
 
+        # specify CA usage
+        usages[CA_ID] = True
+
+        # generate extension configuration lines based on the specified usages
+        extensions = extensions + "\n" + "\n".join(usages_to_extension_lines(usages, REQUIRED_USAGE_EXTENSION_FLAGS))
+
         # create a new self signed  certificate
         cert_pem = self.cryptography_service.create_sscrt(subject, key.private_key, key_pass=key.password,
                                                           extensions=extensions, config=config, days=days, sn=cert_id)
-        # specify CA usage
-        usages[CA_ID] = True
 
         # wrap into Certificate class
         certificate = self.__create_wrapper(cert_pem, key.private_key_id, usages, 0,
@@ -111,7 +126,12 @@ class CertificateService:
         if usages is None:
             usages = {}
 
-        extensions = extensions + "\n" + CA_EXTENSIONS
+        # specify CA usage
+        usages[CA_ID] = True
+
+        # generate extension configuration lines based on the specified usages
+        extensions = extensions + "\n" + "\n".join(usages_to_extension_lines(usages, REQUIRED_USAGE_EXTENSION_FLAGS))
+
         # Add CRL and OCSP distribution point to certificate extensions
         cert_id = self.certificate_repository.get_next_id()
         extensions = extensions + "\n" + CRL_EXTENSION + " " + self.__get_crl_endpoint(issuer_cert.certificate_id)
@@ -125,9 +145,6 @@ class CertificateService:
                                                         days=days,
                                                         sn=cert_id)
 
-        # specify CA usage
-        usages[CA_ID] = True
-
         # wrap into Certificate class
         self.__create_wrapper(cert_pem, subject_key.private_key_id, usages,
                               issuer_cert.certificate_id, INTERMEDIATE_CA_ID)
@@ -139,9 +156,6 @@ class CertificateService:
         not_before_formatted = time.strftime(VALID_FROM_TO_DATE_FORMAT, not_before)
         not_after_formatted = time.strftime(VALID_FROM_TO_DATE_FORMAT, not_after)
 
-        # specify CA usage
-        usages[CA_ID] = True
-
         # create a certificate wrapper
         certificate = Certificate(-1, subject.common_name, not_before_formatted, not_after_formatted, cert_pem,
                                   subject_key.private_key_id, INTERMEDIATE_CA_ID, issuer_cert.certificate_id, usages)
@@ -174,6 +188,9 @@ class CertificateService:
         # get the next certificate ID in order to be able to specify the serial number
         cert_id = self.certificate_repository.get_next_id()
 
+        # generate extension configuration lines based on the specified usages
+        extensions = extensions + "\n" + "\n".join(usages_to_extension_lines(usages, REQUIRED_USAGE_EXTENSION_FLAGS))
+
         # Add CRL and OCSP distribution point to certificate extensions
         extensions = extensions + "\n" + CRL_EXTENSION + " " + self.__get_crl_endpoint(issuer_cert.certificate_id)
         extensions = extensions + "\n" + OCSP_EXTENSION + " " + self.__get_ocsp_endpoint(issuer_cert.certificate_id)
diff --git a/src/services/cryptography.py b/src/services/cryptography.py
index 25663f6..ab9b91e 100644
--- a/src/services/cryptography.py
+++ b/src/services/cryptography.py
@@ -111,7 +111,7 @@ class CryptographyService:
 
     def create_sscrt(self, subject, key, config="", extensions="", key_pass=None, days=30, sn: int = None):
         """
-        Creates a root CA
+        Creates a self signed certificate
 
         :param subject: an instance of <Subject> representing the subject to be added to the certificate
         :param key: private key of the CA to be used
@@ -132,7 +132,6 @@ class CryptographyService:
         # file instead of an extension file. Therefore the following code creates
         # the most basic configuration file with sscrt_ext section, that is later
         # reference in openssl req command using -extensions option.
-        extensions += "\n" + CA_EXTENSIONS
         if len(config) == 0:
             config += MINIMAL_CONFIG_FILE
         config += "\n[ " + SSCRT_SECTION + " ]" + "\n" + extensions
diff --git a/src/utils/usages_to_extensions.py b/src/utils/usages_to_extensions.py
new file mode 100644
index 0000000..4af1793
--- /dev/null
+++ b/src/utils/usages_to_extensions.py
@@ -0,0 +1,105 @@
+from functools import cmp_to_key
+
+# basic constraints
+from typing import Dict
+
+BASIC_CONSTRAINTS_KEY = "basicConstraints"
+
+CRITICAL = "critical"
+CA = "CA:TRUE"
+
+# key usages
+KEY_USAGES_KEY = "keyUsage"
+
+DIGITAL_SIGNATURE = "digitalSignature"
+NON_REPUDIATION = "nonRepudiation"
+KEY_ENCIPHERMENT = "keyEncipherment"
+DATA_ENCIPHERMENT = "dataEncipherment"
+KEY_AGREEMENT = "keyAgreement"
+KEY_CERT_SIGN = "keyCertSign"
+CRL_SIGN = "cRLSign"
+ENCIPHER_ONLY = "encipherOnly"
+DECIPHER_ONLY = "decipherOnly"
+
+# extended key usages
+EXTENDED_KEY_USAGE_KEY = "extendedKeyUsage"
+
+SERVER_AUTH = "serverAuth"
+CLIENT_AUTH = "clientAuth"
+CODE_SIGNING = "codeSigning"
+EMAIL_PROTECTION = "emailProtection"
+TIME_STAMPING = "timeStamping"
+OCSP_SIGNING = "OCSPSigning"
+
+
+class ExtensionFieldFlags:
+    def __init__(self, key_usages_flags, extended_key_usages_flags, basic_constraints_flags):
+        self.key_usages_flags = key_usages_flags
+        self.extended_key_usages_flags = extended_key_usages_flags
+        self.basic_constraints_flags = basic_constraints_flags
+
+
+def __compare_with_critical_prioritized(item1, item2):
+    # compare in such way, that the CRITICAL str is always considered to be less than any other string
+    if item1 == CRITICAL:
+        # first item is CRITICAL, return -1
+        return -1
+    elif item2 == CRITICAL:
+        # second item is CRITICAL, return 2
+        return 1
+    elif item1 == item2:
+        # items are same, return 0
+        return 0
+    else:
+        # none of the items is CRITICAL and they are not equal, compare via < str overloaded operator
+        return -1 if item1 < item2 else 1
+
+
+def usages_to_extension_lines(usages, required_extension_flags:  Dict[int, ExtensionFieldFlags]):
+    """
+    Converts usages dictionary to a configuration lines to be put in the extensions section
+
+    :param usages: a dictionary containing usages to be converted into ext. configuration lines
+    :param required_extension_flags: an object containing an information about which flags are required to be present
+    in the extension configuration lines to be generated
+    :return: a list of configuration lines
+    """
+    # initialize sets that will represent values of all required fields (keyUsages, extendedKeyUsage, basicConstraints)
+    key_usages = set()
+    extended_key_usages = set()
+    basic_constraints = set()
+
+    # iterate over given usages
+    for usage, value in usages.items():
+        # check whether the usage is set to true
+        if value:
+            # load required extension fields
+            key_usage = required_extension_flags[usage]
+
+            # append the required keyUsage flags
+            key_usages = key_usages.union(key_usage.key_usages_flags)
+
+            # append the required extendedKeyUsage flags
+            extended_key_usages = extended_key_usages.union(key_usage.extended_key_usages_flags)
+
+            # append the required basicConstraints flags
+            basic_constraints = basic_constraints.union(key_usage.basic_constraints_flags)
+
+    lines = []
+
+    # sort flags so their order is consistent and therefore testable
+    # after sorting the flags generate an extension line joining the flags with "," character
+    if len(basic_constraints) > 0:
+        # "basicConstraints" (maybe) require the "critical" flag to be at the head of the list if present
+        lines.append(
+            f"""{BASIC_CONSTRAINTS_KEY}={",".join(sorted(basic_constraints, key=cmp_to_key(__compare_with_critical_prioritized)))}""")
+
+    if len(key_usages) > 0:
+        # "keyUsages" (maybe) require the "critical" flag to be at the head of the list if present
+        lines.append(
+            f"""{KEY_USAGES_KEY}={",".join(sorted(key_usages, key=cmp_to_key(__compare_with_critical_prioritized)))}""")
+
+    if len(extended_key_usages) > 0:
+        lines.append(f"""{EXTENDED_KEY_USAGE_KEY}={",".join(sorted(extended_key_usages))}""")
+
+    return lines
diff --git a/tests/unit_tests/services/cryptography/self_signed_cert_test.py b/tests/unit_tests/services/cryptography/self_signed_cert_test.py
index e9d1ac1..52629f3 100644
--- a/tests/unit_tests/services/cryptography/self_signed_cert_test.py
+++ b/tests/unit_tests/services/cryptography/self_signed_cert_test.py
@@ -41,6 +41,8 @@ def test_create_sscrt(service):
     assert "Certificate Sign, CRL Sign" in cert_printed
     assert "X509v3 Key Usage: critical" in cert_printed
     assert "CA:TRUE" in cert_printed
+    assert "X509v3 Key Usage: critical" in cert_printed
+    assert "Certificate Sign, CRL Sign" in cert_printed
     assert "X509v3 CRL Distribution Points:" in cert_printed
     assert "URI:http://localhost/api/crl/0" in cert_printed
     assert "Authority Information Access:" in cert_printed
@@ -73,6 +75,8 @@ def test_create_sscrt_extensions_no_config(service):
     assert "Certificate Sign, CRL Sign" in cert_printed
     assert "X509v3 Key Usage: critical" in cert_printed
     assert "CA:TRUE" in cert_printed
+    assert "X509v3 Key Usage: critical" in cert_printed
+    assert "Certificate Sign, CRL Sign" in cert_printed
     assert "X509v3 CRL Distribution Points:" in cert_printed
     assert "URI:http://localhost/api/crl/0" in cert_printed
     assert "Authority Information Access:" in cert_printed
diff --git a/tests/unit_tests/utils/__init__.py b/tests/unit_tests/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/unit_tests/utils/usages_to_extensions_test.py b/tests/unit_tests/utils/usages_to_extensions_test.py
new file mode 100644
index 0000000..1af0b8b
--- /dev/null
+++ b/tests/unit_tests/utils/usages_to_extensions_test.py
@@ -0,0 +1,26 @@
+from src.constants import CA_ID, SSL_ID, SIGNATURE_ID, AUTHENTICATION_ID, DICT_USAGES
+from src.services.certificate_service import REQUIRED_USAGE_EXTENSION_FLAGS
+from src.utils.usages_to_extensions import usages_to_extension_lines
+
+
+def test_usages_to_extensions():
+    usages = DICT_USAGES.copy()
+    usages[CA_ID] = True
+    usages[SSL_ID] = True
+
+    expected = """basicConstraints=critical,CA:TRUE
+keyUsage=critical,cRLSign,digitalSignature,keyAgreement,keyCertSign,keyEncipherment
+extendedKeyUsage=serverAuth"""
+
+    assert "\n".join(usages_to_extension_lines(usages, REQUIRED_USAGE_EXTENSION_FLAGS)) == expected
+
+
+def test_usages_to_extensions_2():
+    usages = DICT_USAGES.copy()
+    usages[SIGNATURE_ID] = True
+    usages[AUTHENTICATION_ID] = True
+
+    expected = """keyUsage=digitalSignature,nonRepudiation
+extendedKeyUsage=clientAuth,timeStamping"""
+
+    assert "\n".join(usages_to_extension_lines(usages, REQUIRED_USAGE_EXTENSION_FLAGS)) == expected
