# Copyright 2021 Offensive Security
# SPDX-license-identifier: GPL-3.0-only

import re

from kali_tweaks.utils import (
    logger,
    say,
    write_file_as_root,
)


class OpenSSLConfigParser:
    """
    A simple parser for the OpenSSL configuration file.

    The goal is NOT to implement a full-fledged parser, but rather to
    provide only what's needed for our use-case:
    * read a config file
    * get a value within a section
    * change a value within a section
    * write back the file with our modifications

    When it comes to modifying the config file, we want to make sure to
    touch ONLY the line(s) that need modification, and leave the rest of
    the files untouched. That's why Python's configparser is not really
    suitable for our use-case (I'm not even sure it could parse the file
    anyway).

    Known limitations of this implementation:
    * in case a section/value would be duplicated, we only consider
      the first one. I think it's acceptable (I'm not even sure that
      duplicate sections are allowed).
    """

    def __init__(self, path):
        self.path = path
        self.config = None

    def read(self):
        """Read a config file, raise IOError on failure."""
        with open(self.path) as f:
            self.config = f.read().splitlines()

    def write(self):
        """Write a config file, raise IOError on failure."""
        print(f"> Writing changes to {self.path}")
        write_file_as_root(self.path, self.config)

    def _get_section(self, section):
        """
        Look for a section, and return the indexes of the first
        and the last line of this section.

        If the section is not found, KeyError is raised.
        """
        start = -1
        stop = -1
        within = False

        for idx, line in enumerate(self.config):
            # Not within the section?
            if not within:
                if re.match(rf"\[ *{section} *\]", line):
                    within = True
                    start = stop = idx
                continue

            # Within the section! End of the section?
            if line.startswith("["):
                break

            # This line belongs to the section
            stop = idx

        if start >= 0 and stop >= 0:
            return start, stop

        raise KeyError(f"In file {self.path}: no section {section}.")

    def _get_value(self, start, stop, key):
        """
        Look for a key between the lines start and stop. If found,
        return the index of the line, and the value.

        If the key is not found, KeyError is raised.

        Implementation details:
        * Looking at /etc/ssl/openssl.cnf, we can see that a trailing
          comment after a value is allowed.  And we can even see that
          there's no need for a space before the '#', it can be stuck
          to the value (there's actually such a line).
        * We assume that leading spaces before a key are not allowed.
        """
        for idx in range(start, stop + 1):
            line = self.config[idx]
            grp = re.match(f"({key}) *= *([^#]*)", line)
            if not grp:
                continue
            key, val = grp.groups()
            return idx, val.strip()

        raise KeyError(
            f"In file {self.path}: no key {key} between lines {start} and {stop}."
        )

    def _set_value(self, start, stop, key, value):
        """
        Set 'key' to 'value'. The key is looked for between the
        lines 'start' and 'stop', and must exist already.

        If the key does not exist, KeyError is raised.
        """
        index, _ = self._get_value(start, stop, key)
        self.config[index] = f"{key} = {value}"

    def get_value(self, section_name, key):
        """
        Get 'key' in 'section_name', raise KeyError if not found.
        """
        start, stop = self._get_section(section_name)
        _, value = self._get_value(start, stop, key)
        logger.debug("get [%s] %s: %s", section_name, key, value)
        return value

    def set_value(self, section_name, key, value):
        """
        Set 'key' in 'section_name' to 'value', raise KeyError if
        the key is not found.
        """
        start, stop = self._get_section(section_name)
        self._set_value(start, stop, key, value)
        logger.debug("set [%s] %s: %s", section_name, key, value)

    def get_section(self, section_name):
        """
        Get 'section_name', raise KeyError if not found.
        """
        start, stop = self._get_section(section_name)
        return self.config[start : stop + 1]

    def includes(self, filepath):
        """
        Return True is the file 'filepath' is included, False otherwise.

        Implementation details:
        * Per config(5), an optional '=' is allowed between the include
          directive and the path. No idea if trailing comments are allowed
          though. Let's just assume that if they are, there's a space before
          the '#'.
        """
        for line in self.config:
            if re.match(rf"\.include *=* *{filepath}(\s|$)", line):
                return True
        return False


class OpenSSLSetting:
    def __init__(self):
        self.kali_cnf = "/etc/ssl/kali.cnf"
        self.openssl_cnf = "/etc/ssl/openssl.cnf"

    def load(self):
        mapping = {
            "kali_wide_compatibility": "compat",
            "kali_strong_security": "secure",
        }
        value = self.get_ssl_system_default()
        system_default = mapping.get(value, None)

        mapping = {
            "kali_wide_compatibility_providers": "compat",
            "kali_strong_security_providers": "secure",
        }
        value = self.get_providers()
        # Providers can be None, it's what happens with
        # openssl 3.0 and an outdated config file. In this
        # case we only consider the value of system_default.
        if value is None:
            providers = system_default
        else:
            providers = mapping.get(value, None)

        if system_default == providers:
            hardening = system_default
        else:
            hardening = None

        return {"hardening": hardening}

    def apply(self, config):
        say("Configuring OpenSSL")
        if "hardening" in config:
            value = config["hardening"]
            if value == "compat":
                value = "kali_wide_compatibility"
            elif value == "secure":
                value = "kali_strong_security"
            else:
                raise NotImplementedError(f"'{value}' not supported")
            self.set_ssl_system_default(value)
            self.set_providers(f"{value}_providers")

    def get_ssl_system_default(self):
        """
        Get the value for the setting '[ssl_sect] system_default'.

        This method raises IOError if the OpenSSL configuration file
        can't be read, and KeyError if the setting is not found.
        """
        conf = OpenSSLConfigParser(self.openssl_cnf)
        conf.read()
        value = conf.get_value("ssl_sect", "system_default")
        return value

    def set_ssl_system_default(self, value):
        """
        Set the value for the setting '[ssl_sect] system_default'.

        This method raises IOError if the OpenSSL configuration file
        can't be read or written, KeyError if the setting is not found,
        and ValueError if the value is not valid (ie. not found in kali.cnf).
        """

        print(f"> Setting ssl system default to: {value}")

        # Make sure the value corresponds to a section in kali.cnf
        conf = OpenSSLConfigParser(self.kali_cnf)
        conf.read()
        try:
            _ = conf.get_section(value)
        except KeyError as e:
            raise ValueError(
                f"Invalid value '{value}', doesn't match a section.\n{e}"
            ) from e

        # Make sure the file kali.cnf is included
        conf = OpenSSLConfigParser(self.openssl_cnf)
        conf.read()
        if not conf.includes(self.kali_cnf):
            raise KeyError(f"File {self.openssl_cnf} does not include {self.kali_cnf}.")

        # Write the value to openssl.cnf
        conf.set_value("ssl_sect", "system_default", value)
        conf.write()

    def get_providers(self):
        """
        Get the value for the setting '[openssl_init] providers'.

        This method raises IOError if the OpenSSL configuration file
        can't be read. If the setting is not found, it returns None
        and does NOT throw any exception, as this setting didn't exist
        before openssl 3.0 (ie. before Kali 2022.3).
        """
        conf = OpenSSLConfigParser(self.openssl_cnf)
        conf.read()
        try:
            value = conf.get_value("openssl_init", "providers")
        except KeyError:
            value = None
        return value

    def set_providers(self, value):
        """
        Set the value for the setting '[openssl_init] providers'.

        This method raises IOError if the OpenSSL configuration file
        can't be read or written. If the setting is not found, or the
        value appears to be invalid, it does nothing and it does NOT
        throw any exception, as this setting didn't exist before openssl
        3.0 (ie. before Kali 2022.3).
        """

        print(f"> Setting openssl providers to: {value}")

        # Make sure the value corresponds to a section in kali.cnf
        conf = OpenSSLConfigParser(self.kali_cnf)
        conf.read()
        try:
            _ = conf.get_section(value)
        except KeyError:
            return

        # Make sure the file kali.cnf is included
        conf = OpenSSLConfigParser(self.openssl_cnf)
        conf.read()
        if not conf.includes(self.kali_cnf):
            raise KeyError(f"File {self.openssl_cnf} does not include {self.kali_cnf}.")

        # Write the value to openssl.cnf
        try:
            conf.set_value("openssl_init", "providers", value)
        except KeyError:
            return
        conf.write()
