"""
SSL peer certificate checking routines

Copyright (c) 2004-2007 Open Source Applications Foundation.
All rights reserved.

Copyright 2008 Heikki Toivonen. All rights reserved.
"""

__all__ = [
    "SSLVerificationError",
    "NoCertificate",
    "WrongCertificate",
    "WrongHost",
    "Checker",
]

import re
import socket

from M2Crypto import X509, m2  # noqa
from typing import Optional, Union  # noqa

try:
    from re import Pattern
except ImportError:
    from typing import Pattern


class SSLVerificationError(Exception):
    pass


class NoCertificate(SSLVerificationError):
    pass


class WrongCertificate(SSLVerificationError):
    pass


class WrongHost(SSLVerificationError):
    def __init__(
        self,
        expectedHost: str,
        actualHost: Union[str, bytes],
        fieldName: str = "commonName",
    ) -> None:
        """
        This exception will be raised if the certificate returned by the
        peer was issued for a different host than we tried to connect to.
        This could be due to a server misconfiguration or an active attack.

        :param expectedHost: The name of the host we expected to find in the
                             certificate.
        :param actualHost:   The name of the host we actually found in the
                             certificate.
        :param fieldName:    The field name where we noticed the error. This
                             should be either 'commonName' or 'subjectAltName'.
        """
        if fieldName not in ("commonName", "subjectAltName"):
            raise ValueError(
                "Unknown fieldName, should be either commonName " + "or subjectAltName"
            )

        SSLVerificationError.__init__(self)
        self.expectedHost = expectedHost
        self.actualHost = actualHost
        self.fieldName = fieldName

    def __str__(self) -> str:
        actual = (
            self.actualHost.decode()
            if isinstance(self.actualHost, bytes)
            else self.actualHost
        )
        return "Peer certificate %s does not match host, expected %s, got %s" % (
            self.fieldName,
            self.expectedHost,
            actual,
        )


class Checker:

    # COMPATIBILITY: re.Pattern is available only from Python 3.7+
    numericIpMatch: Pattern[str] = re.compile(r"^[0-9]+(\.[0-9]+)*$")

    def __init__(
        self,
        host: Optional[str] = None,
        peerCertHash: Optional[bytes] = None,
        peerCertDigest: str = "sha256",
    ) -> None:
        self.host = host
        self.fingerprint = peerCertHash
        self.digest: str = peerCertDigest

    def __call__(
        self, peerCert: Optional[X509.X509], host: Optional[str] = None
    ) -> bool:
        if peerCert is None:
            raise NoCertificate("peer did not return certificate")

        if host is not None:
            self.host = host

        if self.fingerprint:
            if self.digest not in ("sha256"):
                raise ValueError('unsupported digest "%s"' % self.digest)

            if self.digest == "sha256":
                expected_len = 64
            else:
                raise ValueError("Unexpected digest {0}".format(self.digest))

            if len(self.fingerprint) != expected_len:
                raise WrongCertificate(
                    (
                        "peer certificate fingerprint length does not match\n"
                        + "fingerprint: {0}\nexpected = {1}\n"
                        + "observed = {2}"
                    ).format(
                        self.fingerprint,
                        expected_len,
                        len(self.fingerprint),
                    )
                )

            expected_fingerprint = (
                self.fingerprint.decode()
                if isinstance(self.fingerprint, bytes)
                else self.fingerprint
            )
            observed_fingerprint = peerCert.get_fingerprint(md=self.digest)
            if observed_fingerprint != expected_fingerprint:
                raise WrongCertificate(
                    (
                        "peer certificate fingerprint does not match\n"
                        + "expected = {0},\n"
                        + "observed = {1}"
                    ).format(expected_fingerprint, observed_fingerprint)
                )

        if self.host:
            hostValidationPassed = False
            self.useSubjectAltNameOnly = False

            # subjectAltName=DNS:somehost[, ...]*
            try:
                subjectAltName = peerCert.get_ext("subjectAltName").get_value()
                if self._splitSubjectAltName(self.host, subjectAltName):
                    hostValidationPassed = True
                elif self.useSubjectAltNameOnly:
                    raise WrongHost(
                        expectedHost=self.host,
                        actualHost=subjectAltName,
                        fieldName="subjectAltName",
                    )
            except LookupError:
                pass

            # commonName=somehost[, ...]*
            if not hostValidationPassed:
                hasCommonName = False
                commonNames = ""
                for entry in peerCert.get_subject().get_entries_by_nid(
                    m2.NID_commonName
                ):
                    hasCommonName = True
                    commonName = entry.get_data().as_text()
                    if not commonNames:
                        commonNames = commonName
                    else:
                        commonNames += "," + commonName
                    if self._match(self.host, commonName):
                        hostValidationPassed = True
                        break

                if not hasCommonName:
                    raise WrongCertificate("no commonName in peer certificate")

                if not hostValidationPassed:
                    raise WrongHost(
                        expectedHost=self.host,
                        actualHost=commonNames,
                        fieldName="commonName",
                    )

        return True

    def _splitSubjectAltName(
        self,
        host: Union[str, bytes],
        subjectAltName: Union[str, bytes],
    ) -> bool:
        """
        >>> check = Checker()
        >>> check._splitSubjectAltName(host='my.example.com',
        ...     subjectAltName='DNS:my.example.com')
        True
        >>> check._splitSubjectAltName(host='my.example.com',
        ...     subjectAltName='DNS:*.example.com')
        True
        >>> check._splitSubjectAltName(host='my.example.com',
        ...     subjectAltName='DNS:m*.example.com')
        True
        >>> check._splitSubjectAltName(host='my.example.com',
        ...     subjectAltName='DNS:m*ample.com')
        False
        >>> check.useSubjectAltNameOnly
        True
        >>> check._splitSubjectAltName(host='my.example.com',
        ...     subjectAltName='DNS:m*ample.com, othername:<unsupported>')
        False
        >>> check._splitSubjectAltName(host='my.example.com',
        ...     subjectAltName='DNS:m*ample.com, DNS:my.example.org')
        False
        >>> check._splitSubjectAltName(host='my.example.com',
        ...     subjectAltName='DNS:m*ample.com, DNS:my.example.com')
        True
        >>> check._splitSubjectAltName(host='my.example.com',
        ...     subjectAltName='DNS:my.example.com, DNS:my.example.org')
        True
        >>> check.useSubjectAltNameOnly
        True
        >>> check._splitSubjectAltName(host='my.example.com',
        ...     subjectAltName='')
        False
        >>> check._splitSubjectAltName(host='my.example.com',
        ...     subjectAltName='othername:<unsupported>')
        False
        >>> check.useSubjectAltNameOnly
        False
        """
        host_str = host.decode() if isinstance(host, bytes) else host
        san_str = (
            subjectAltName.decode()
            if isinstance(subjectAltName, bytes)
            else subjectAltName
        )

        self.useSubjectAltNameOnly = False
        for certHost in san_str.split(","):
            certHost = certHost.lower().strip()
            if certHost[:4] == "dns:":
                self.useSubjectAltNameOnly = True
                if self._match(host_str, certHost[4:]):
                    return True
            elif certHost[:11] == "ip address:":
                self.useSubjectAltNameOnly = True
                if self._matchIPAddress(host_str, certHost[11:]):
                    return True
        return False

    def _match(self, host: str, certHost: str) -> bool:
        """
        >>> check = Checker()
        >>> check._match(host='my.example.com', certHost='my.example.com')
        True
        >>> check._match(host='my.example.com', certHost='*.example.com')
        True
        >>> check._match(host='my.example.com', certHost='m*.example.com')
        True
        >>> check._match(host='my.example.com', certHost='m*.EXAMPLE.com')
        True
        >>> check._match(host='my.example.com', certHost='m*ample.com')
        False
        >>> check._match(host='my.example.com', certHost='*.*.com')
        False
        >>> check._match(host='1.2.3.4', certHost='1.2.3.4')
        True
        >>> check._match(host='1.2.3.4', certHost='*.2.3.4')
        False
        >>> check._match(host='1234', certHost='1234')
        True
        """
        # XXX See RFC 2818 and 3280 for matching rules, this is may not
        # XXX yet be complete.

        host = host.lower()
        certHost = certHost.lower()

        if host == certHost:
            return True

        if certHost.count("*") > 1:
            # Not sure about this, but being conservative
            return False

        if self.numericIpMatch.match(host) or self.numericIpMatch.match(
            certHost.replace("*", "")
        ):
            # Not sure if * allowed in numeric IP, but think not.
            return False

        if certHost.find("\\") > -1:
            # Not sure about this, maybe some encoding might have these.
            # But being conservative for now, because regex below relies
            # on this.
            return False

        # Massage certHost so that it can be used in regex
        certHost = certHost.replace(".", "\\.")
        certHost = certHost.replace("*", "[^\\.]*")
        if re.compile("^%s$" % certHost).match(host):
            return True

        return False

    def _matchIPAddress(
        self, host: Union[str, bytes], certHost: Union[str, bytes]
    ) -> bool:
        """
        >>> check = Checker()
        >>> check._matchIPAddress(host='my.example.com',
        ...     certHost='my.example.com')
        False
        >>> check._matchIPAddress(host='1.2.3.4', certHost='1.2.3.4')
        True
        >>> check._matchIPAddress(host='1.2.3.4', certHost='*.2.3.4')
        False
        >>> check._matchIPAddress(host='1.2.3.4', certHost='1.2.3.40')
        False
        >>> check._matchIPAddress(host='::1', certHost='::1')
        True
        >>> check._matchIPAddress(host='::1', certHost='0:0:0:0:0:0:0:1')
        True
        >>> check._matchIPAddress(host='::1', certHost='::2')
        False
        """
        try:
            canonical = socket.getaddrinfo(
                host,
                0,
                0,
                socket.SOCK_STREAM,
                0,
                socket.AI_NUMERICHOST,
            )
            certCanonical = socket.getaddrinfo(
                certHost,
                0,
                0,
                socket.SOCK_STREAM,
                0,
                socket.AI_NUMERICHOST,
            )
        except:
            return False
        return canonical == certCanonical


if __name__ == "__main__":
    import doctest

    doctest.testmod()
