#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

import datetime
import os
from unittest import mock

from cryptography.hazmat.backends import default_backend
from cryptography import x509

from cursive import certificate_utils
from cursive import exception
from cursive.tests import base


class TestCertificateUtils(base.TestCase):
    """Test methods for the certificate verification context and utilities"""

    def setUp(self):
        super(TestCertificateUtils, self).setUp()

        self.cert_path = os.path.join(
            os.path.dirname(os.path.realpath(__file__)),
            'data'
        )

    def tearDown(self):
        super(TestCertificateUtils, self).tearDown()

    def load_certificate(self, cert_name):
        # Load the raw certificate file data.
        path = os.path.join(self.cert_path, cert_name)
        with open(path, 'rb') as cert_file:
            data = cert_file.read()

        # Convert the raw certificate data into a certificate object, first
        # as a PEM-encoded certificate and, if that fails, then as a
        # DER-encoded certificate. If both fail, the certificate cannot be
        # loaded.
        try:
            return x509.load_pem_x509_certificate(data, default_backend())
        except Exception:
            try:
                return x509.load_der_x509_certificate(data, default_backend())
            except Exception:
                raise exception.SignatureVerificationError(
                    "Failed to load certificate: %s" % path
                )

    def load_certificates(self, cert_names):
        certs = list()
        for cert_name in cert_names:
            cert = self.load_certificate(cert_name)
            certs.append(cert)
        return certs

    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_is_within_valid_dates(self, mock_utcnow):
        # Verify a certificate is valid at a time within its valid date range
        cert = self.load_certificate('self_signed_cert.pem')
        mock_utcnow.return_value = datetime.datetime(2017, 1, 1)
        result = certificate_utils.is_within_valid_dates(cert)
        self.assertEqual(True, result)

    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_is_before_valid_dates(self, mock_utcnow):
        # Verify a certificate is invalid at a time before its valid date range
        cert = self.load_certificate('self_signed_cert.pem')
        mock_utcnow.return_value = datetime.datetime(2000, 1, 1)
        result = certificate_utils.is_within_valid_dates(cert)
        self.assertEqual(False, result)

    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_is_after_valid_dates(self, mock_utcnow):
        # Verify a certificate is invalid at a time after its valid date range
        cert = self.load_certificate('self_signed_cert.pem')
        mock_utcnow.return_value = datetime.datetime(2100, 1, 1)
        result = certificate_utils.is_within_valid_dates(cert)
        self.assertEqual(False, result)

    def test_is_issuer(self):
        # Test issuer and subject name matching for a self-signed certificate.
        cert = self.load_certificate('self_signed_cert.pem')
        result = certificate_utils.is_issuer(cert, cert)
        self.assertEqual(True, result)

    def test_is_not_issuer(self):
        # Test issuer and subject name mismatching.
        cert = self.load_certificate('self_signed_cert.pem')
        alt = self.load_certificate('orphaned_cert.pem')
        result = certificate_utils.is_issuer(cert, alt)
        self.assertEqual(False, result)

    def test_is_issuer_with_invalid_certs(self):
        # Test issuer check with invalid certificates
        cert = self.load_certificate('self_signed_cert.pem')
        result = certificate_utils.is_issuer(cert, None)
        self.assertEqual(False, result)
        result = certificate_utils.is_issuer(None, cert)
        self.assertEqual(False, result)

    def test_can_sign_certificates(self):
        # Test that a well-formatted certificate can sign
        cert = self.load_certificate('self_signed_cert.pem')
        result = certificate_utils.can_sign_certificates(cert, 'test-ID')
        self.assertEqual(True, result)

    def test_cannot_sign_certificates_without_basic_constraints(self):
        # Verify a certificate without basic constraints cannot sign
        cert = self.load_certificate(
            'self_signed_cert_missing_ca_constraint.pem'
        )
        result = certificate_utils.can_sign_certificates(cert, 'test-ID')
        self.assertEqual(False, result)

    def test_cannot_sign_certificates_with_invalid_basic_constraints(self):
        # Verify a certificate with invalid basic constraints cannot sign
        cert = self.load_certificate(
            'self_signed_cert_invalid_ca_constraint.pem'
        )
        result = certificate_utils.can_sign_certificates(cert, 'test-ID')
        self.assertEqual(False, result)

    def test_cannot_sign_certificates_without_key_usage(self):
        # Verify a certificate without key usage cannot sign
        cert = self.load_certificate('self_signed_cert_missing_key_usage.pem')
        result = certificate_utils.can_sign_certificates(cert, 'test-ID')
        self.assertEqual(False, result)

    def test_cannot_sign_certificates_with_invalid_key_usage(self):
        # Verify a certificate with invalid key usage cannot sign
        cert = self.load_certificate('self_signed_cert_invalid_key_usage.pem')
        result = certificate_utils.can_sign_certificates(cert, 'test-ID')
        self.assertEqual(False, result)

    def test_verify_signing_certificate(self):
        signing_certificate = self.load_certificate('self_signed_cert.pem')
        signed_certificate = self.load_certificate('signed_cert.pem')

        certificate_utils.verify_certificate_signature(
            signing_certificate,
            signed_certificate
        )

    @mock.patch('cursive.signature_utils.get_certificate')
    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_verify_valid_certificate(self, mock_utcnow, mock_get_cert):
        mock_utcnow.return_value = datetime.datetime(2017, 1, 1)
        certs = self.load_certificates(
            ['self_signed_cert.pem', 'self_signed_cert.der',
             'signed_cert.pem']
        )
        mock_get_cert.side_effect = certs
        cert_uuid = '3'
        trusted_cert_uuids = ['1', '2']
        certificate_utils.verify_certificate(
            None, cert_uuid, trusted_cert_uuids
        )

    @mock.patch('cursive.signature_utils.get_certificate')
    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_verify_invalid_certificate(self, mock_utcnow, mock_get_cert):
        mock_utcnow.return_value = datetime.datetime(2017, 1, 1)
        certs = self.load_certificates(
            ['self_signed_cert.pem', 'self_signed_cert.der',
             'orphaned_cert.pem']
        )
        mock_get_cert.side_effect = certs
        cert_uuid = '3'
        trusted_cert_uuids = ['1', '2']
        self.assertRaisesRegex(
            exception.SignatureVerificationError,
            "Certificate chain building failed. Could not locate the "
            "signing certificate for the base certificate in the set of "
            "trusted certificates.",
            certificate_utils.verify_certificate,
            None,
            cert_uuid,
            trusted_cert_uuids
        )

    @mock.patch('cursive.signature_utils.get_certificate')
    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_verify_valid_certificate_with_no_root(self, mock_utcnow,
                                                   mock_get_cert):
        mock_utcnow.return_value = datetime.datetime(2017, 1, 1)

        # Test verifying a valid certificate against an empty list of trusted
        # certificates.
        certs = self.load_certificates(['signed_cert.pem'])
        mock_get_cert.side_effect = certs
        cert_uuid = '3'
        trusted_cert_uuids = []
        self.assertRaisesRegex(
            exception.SignatureVerificationError,
            "Certificate chain building failed. Could not locate the "
            "signing certificate for the base certificate in the set of "
            "trusted certificates.",
            certificate_utils.verify_certificate,
            None,
            cert_uuid,
            trusted_cert_uuids
        )

    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_context_init(self, mock_utcnow):
        # Test constructing a context object with a valid set of certificates
        mock_utcnow.return_value = datetime.datetime(2017, 1, 1)
        certs = self.load_certificates(
            ['self_signed_cert.pem', 'self_signed_cert.der']
        )
        cert_tuples = [('1', certs[0]), ('2', certs[1])]
        context = certificate_utils.CertificateVerificationContext(
            cert_tuples
        )
        self.assertEqual(2, len(context._signing_certificates))
        for t in cert_tuples:
            path, cert = t
            self.assertIn(cert, [x[1] for x in context._signing_certificates])

    @mock.patch('cursive.certificate_utils.LOG')
    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_context_init_with_invalid_certificate(self, mock_utcnow,
                                                   mock_log):
        # Test constructing a context object with an invalid certificate
        mock_utcnow.return_value = datetime.datetime(2017, 1, 1)
        alt_cert_tuples = [('path', None)]
        context = certificate_utils.CertificateVerificationContext(
            alt_cert_tuples
        )
        self.assertEqual(0, len(context._signing_certificates))
        self.assertEqual(1, mock_log.error.call_count)

    @mock.patch('cursive.certificate_utils.LOG')
    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_context_init_with_non_signing_certificate(self, mock_utcnow,
                                                       mock_log):
        # Test constructing a context object with an non-signing certificate
        mock_utcnow.return_value = datetime.datetime(2017, 1, 1)
        non_signing_cert = self.load_certificate(
            'self_signed_cert_missing_key_usage.pem'
        )
        alt_cert_tuples = [('path', non_signing_cert)]
        context = certificate_utils.CertificateVerificationContext(
            alt_cert_tuples
        )
        self.assertEqual(0, len(context._signing_certificates))
        self.assertEqual(1, mock_log.warning.call_count)

    @mock.patch('cursive.certificate_utils.LOG')
    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_context_init_with_out_of_date_certificate(self, mock_utcnow,
                                                       mock_log):
        # Test constructing a context object with out-of-date certificates
        mock_utcnow.return_value = datetime.datetime(2100, 1, 1)
        certs = self.load_certificates(
            ['self_signed_cert.pem', 'self_signed_cert.der']
        )
        cert_tuples = [('1', certs[0]), ('2', certs[1])]
        context = certificate_utils.CertificateVerificationContext(cert_tuples)
        self.assertEqual(0, len(context._signing_certificates))
        self.assertEqual(2, mock_log.warning.call_count)

    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_context_update_with_valid_certificate(self, mock_utcnow):
        # Test updating the context with a valid certificate
        mock_utcnow.return_value = datetime.datetime(2017, 1, 1)
        certs = self.load_certificates(
            ['self_signed_cert.pem', 'self_signed_cert.der']
        )
        cert_tuples = [('1', certs[0]), ('2', certs[1])]
        context = certificate_utils.CertificateVerificationContext(cert_tuples)
        cert = self.load_certificate('orphaned_cert.pem')
        context.update(cert)
        self.assertEqual(cert, context._signed_certificate)

    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_context_update_with_date_invalid_certificate(self, mock_utcnow):
        # Test updating the context with an out-of-date certificate
        mock_utcnow.return_value = datetime.datetime(2017, 1, 1)
        certs = self.load_certificates(
            ['self_signed_cert.pem', 'self_signed_cert.der']
        )
        cert_tuples = [('1', certs[0]), ('2', certs[1])]
        context = certificate_utils.CertificateVerificationContext(cert_tuples)
        cert = self.load_certificate('orphaned_cert.pem')
        mock_utcnow.return_value = datetime.datetime(2100, 1, 1)
        self.assertRaisesRegex(
            exception.SignatureVerificationError,
            "The certificate is outside its valid date range.",
            context.update,
            cert
        )

    def test_context_update_with_invalid_certificate(self):
        # Test updating the context with an invalid certificate
        certs = self.load_certificates(
            ['self_signed_cert.pem', 'self_signed_cert.der']
        )
        cert_tuples = [('1', certs[0]), ('2', certs[1])]
        context = certificate_utils.CertificateVerificationContext(
            cert_tuples
        )

        self.assertRaisesRegex(
            exception.SignatureVerificationError,
            "The certificate must be an x509.Certificate object.",
            context.update,
            None
        )

    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_context_verify(self, mock_utcnow):
        mock_utcnow.return_value = datetime.datetime(2017, 1, 1)
        certs = self.load_certificates(
            ['self_signed_cert.pem', 'self_signed_cert.der']
        )
        cert_tuples = [('1', certs[0]), ('2', certs[1])]

        # Test verification with a two-link certificate chain.
        context = certificate_utils.CertificateVerificationContext(
            cert_tuples
        )
        cert = self.load_certificate('signed_cert.pem')
        context.update(cert)
        context.verify()

        # Test verification with a single-link certificate chain.
        context = certificate_utils.CertificateVerificationContext(
            cert_tuples
        )
        context.update(certs[0])
        context.verify()

    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_context_verify_disable_checks(self, mock_utcnow):
        mock_utcnow.return_value = datetime.datetime(2017, 1, 1)
        certs = self.load_certificates(
            ['self_signed_cert.pem', 'self_signed_cert.der']
        )
        cert_tuples = [('1', certs[0]), ('2', certs[1])]

        # Test verification with a two-link certificate chain.
        context = certificate_utils.CertificateVerificationContext(
            cert_tuples,
            enforce_valid_dates=False,
            enforce_signing_extensions=False,
            enforce_path_length=False
        )
        cert = self.load_certificate('signed_cert.pem')
        context.update(cert)
        context.verify()

        # Test verification with a single-link certificate chain.
        context = certificate_utils.CertificateVerificationContext(
            cert_tuples,
            enforce_valid_dates=False,
            enforce_signing_extensions=False,
            enforce_path_length=False
        )
        context.update(certs[0])
        context.verify()

    @mock.patch('oslo_utils.timeutils.utcnow')
    def test_context_verify_invalid_chain_length(self, mock_utcnow):
        mock_utcnow.return_value = datetime.datetime(2017, 11, 1)
        certs = self.load_certificates(
            ['grandparent_cert.pem', 'parent_cert.pem', 'child_cert.pem']
        )
        cert_tuples = [
            ('1', certs[0]),
            ('2', certs[1]),
            ('3', certs[2])
        ]
        cert = self.load_certificate('grandchild_cert.pem')

        context = certificate_utils.CertificateVerificationContext(
            cert_tuples
        )
        context.update(cert)
        self.assertRaisesRegex(
            exception.SignatureVerificationError,
            "Certificate validation failed. The signing certificate '1' is "
            "not configured to support certificate chains of sufficient "
            "length.",
            context.verify
        )

        context = certificate_utils.CertificateVerificationContext(
            cert_tuples,
            enforce_path_length=False
        )
        context.update(cert)
        context.verify()
