From e8ef5db4e97239e542a95309c6a66d3d3eb90d21 Mon Sep 17 00:00:00 2001 From: Anna Khmelnitsky Date: Tue, 20 Dec 2016 14:47:41 -0800 Subject: [PATCH] Client certificate management for NSXV3 authentication Client certificate authentication will replace basic authentication. A single client certificate will be generated by admin for the configuration agent (openstack, container,..). This commit focuses on certificate generation and coordination of certificate management on backend, storage and in the agent itself. Change-Id: Ib00e2c00aecb53cec63a746e9db6829a5594eb3a --- requirements.txt | 1 + .../tests/unit/v3/nsxlib_testcase.py | 4 + vmware_nsxlib/tests/unit/v3/test_cert.py | 239 +++++++++++++++++ vmware_nsxlib/tests/unit/v3/test_client.py | 22 +- vmware_nsxlib/v3/client_cert.py | 240 ++++++++++++++++++ vmware_nsxlib/v3/exceptions.py | 8 + vmware_nsxlib/v3/trust_management.py | 66 +++++ 7 files changed, 573 insertions(+), 7 deletions(-) create mode 100644 vmware_nsxlib/tests/unit/v3/test_cert.py create mode 100644 vmware_nsxlib/v3/client_cert.py create mode 100644 vmware_nsxlib/v3/trust_management.py diff --git a/requirements.txt b/requirements.txt index 7706379d..cbcafd5a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,4 @@ oslo.log>=3.11.0 # Apache-2.0 oslo.serialization>=1.10.0 # Apache-2.0 oslo.service>=1.10.0 # Apache-2.0 oslo.utils>=3.18.0 # Apache-2.0 +pyOpenSSL>=0.14 # Apache-2.0 diff --git a/vmware_nsxlib/tests/unit/v3/nsxlib_testcase.py b/vmware_nsxlib/tests/unit/v3/nsxlib_testcase.py index 6a25237a..196718fa 100644 --- a/vmware_nsxlib/tests/unit/v3/nsxlib_testcase.py +++ b/vmware_nsxlib/tests/unit/v3/nsxlib_testcase.py @@ -215,6 +215,10 @@ class NsxClientTestCase(NsxLibTestCase): mock_call = getattr(self._record, verb.lower()) mock_call.assert_called_once_with(**kwargs) + def assert_any_call(self, verb, **kwargs): + mock_call = getattr(self._record, verb.lower()) + mock_call.assert_any_call(**kwargs) + @property def recorded_calls(self): return self._record diff --git a/vmware_nsxlib/tests/unit/v3/test_cert.py b/vmware_nsxlib/tests/unit/v3/test_cert.py new file mode 100644 index 00000000..afb675ab --- /dev/null +++ b/vmware_nsxlib/tests/unit/v3/test_cert.py @@ -0,0 +1,239 @@ +# Copyright 2015 VMware, Inc. +# All Rights Reserved +# +# 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. +# + +from neutron_lib import exceptions +from OpenSSL import crypto +from oslo_serialization import jsonutils + +from vmware_nsxlib.tests.unit.v3 import mocks +from vmware_nsxlib.tests.unit.v3 import nsxlib_testcase +from vmware_nsxlib.tests.unit.v3 import test_client +from vmware_nsxlib.v3 import client +from vmware_nsxlib.v3 import client_cert +from vmware_nsxlib.v3 import exceptions as nsxlib_exc +from vmware_nsxlib.v3 import trust_management + + +class DummyStorageDriver(dict): + """Storage driver simulation - just a dictionary""" + + def store_cert(self, project_id, certificate, private_key): + self[project_id] = {} + self[project_id]['cert'] = certificate + self[project_id]['key'] = private_key + + def get_cert(self, project_id): + if project_id not in self: + return (None, None) + + return (self[project_id]['cert'], self[project_id]['key']) + + def delete_cert(self, project_id): + del(self[project_id]) + + def is_empty(self, project_id): + return project_id not in self + + +class NsxV3ClientCertificateTestCase(nsxlib_testcase.NsxClientTestCase): + + identity = 'drumknott' + cert_id = "00000000-1111-2222-3333-444444444444" + identity_id = "55555555-6666-7777-8888-999999999999" + + def _get_mocked_response(self, status_code, results): + return mocks.MockRequestsResponse( + status_code, + jsonutils.dumps({'results': results})) + + def _get_mocked_error_response(self, status_code, error_code): + return mocks.MockRequestsResponse( + status_code, + jsonutils.dumps({'httpStatus': 'go away', + 'error_code': error_code, + 'module_name': 'never mind', + 'error message': 'bad luck'})) + + def _get_mocked_trust(self, action): + + fake_responses = [] + if action == 'create': + # import cert and return its id + results = [{'id': self.cert_id}] + fake_responses.append(self._get_mocked_response(201, results)) + # and then bind this id to principal identity + fake_responses.append(self._get_mocked_response(201, [])) + + elif action == 'retry-create': + # simulate "identity already exists" failure + results = [{'id': self.cert_id}] + fake_responses.append(self._get_mocked_response(201, results)) + fake_responses.append(self._get_mocked_error_response(400, 2027)) + # after error generate code will retry identity deletion: + # first get indentities + results = [{'resource_type': 'Principal Identity', + 'id': self.identity_id, + 'name': self.identity, + 'certificate_id': self.cert_id}] + # then delete identity + fake_responses.append(self._get_mocked_response(200, results)) + # then retry identity create + fake_responses.append(self._get_mocked_response(204, [])) + + elif action == 'delete': + # get principal identities list + results = [{'resource_type': 'Principal Identity', + 'id': 'dont care', + 'name': 'willikins', + 'certificate_id': 'some other id'}, + {'resource_type': 'Principal Identity', + 'id': self.identity_id, + 'name': self.identity, + 'certificate_id': self.cert_id}] + fake_responses.append(self._get_mocked_response(200, results)) + # delete certificate + fake_responses.append(self._get_mocked_response(204, [])) + # delete identity + fake_responses.append(self._get_mocked_response(204, [])) + + mock_client = self.new_mocked_client( + client.JSONRESTClient, + url_prefix='api/v1', session_response=fake_responses) + + return trust_management.NsxLibTrustManagement(mock_client, {}) + + def test_generate_cert(self): + """Test startup without certificate + certificate generation""" + + storage_driver = DummyStorageDriver() + # Prepare fake trust management for "cert create" requests + mocked_trust = self._get_mocked_trust('create') + cert = client_cert.ClientCertificateManager(self.identity, + mocked_trust, + storage_driver) + self.assertFalse(cert.exists()) + + cert.generate(subject={}, key_size=2048, valid_for_days=333) + + # verify client cert was generated and makes sense + self.assertTrue(cert.exists()) + self.assertEqual(332, cert.expires_in_days()) + cert_pem, key_pem = cert.get_pem() + + # verify cert ans PK were stored in storage + stored_cert, stored_key = storage_driver.get_cert(self.identity) + self.assertEqual(cert_pem, stored_cert) + self.assertEqual(key_pem, stored_key) + + # verify API call to import cert on backend + cert_pem = mocked_trust.remove_newlines_from_pem(cert_pem) + base_uri = 'https://1.2.3.4/api/v1/trust-management' + uri = base_uri + '/certificates?action=import' + expected_body = {'pem_encoded': cert_pem} + test_client.assert_json_call('post', mocked_trust.client, uri, + single_call=False, + data=jsonutils.dumps(expected_body)) + + # verify API call to bind cert to identity on backend + uri = base_uri + '/principal-identities' + expected_body = {'name': self.identity, + 'certificate_id': self.cert_id} + test_client.assert_json_call('post', mocked_trust.client, uri, + single_call=False, + data=jsonutils.dumps(expected_body, + sort_keys=True)) + + # try to generate cert again and fail + self.assertRaises(nsxlib_exc.ObjectAlreadyExists, + cert.generate, {}) + + def test_generate_cert_with_retry(self): + """Test startup without certificate + certificate generation""" + + storage_driver = DummyStorageDriver() + # Prepare fake trust management for "cert create" requests + mocked_trust = self._get_mocked_trust('retry-create') + cert = client_cert.ClientCertificateManager(self.identity, + mocked_trust, + storage_driver) + self.assertFalse(cert.exists()) + cert.generate(subject={}, key_size=4096, valid_for_days=3) + + # verify client cert was generated and makes sense + self.assertTrue(cert.exists()) + + # verify cert ans PK were stored in storage + cert_pem, key_pem = cert.get_pem() + stored_cert, stored_key = storage_driver.get_cert(self.identity) + self.assertEqual(cert_pem, stored_cert) + self.assertEqual(key_pem, stored_key) + + def test_load_and_delete_existing_cert(self): + """Test startup with existing certificate + certificate deletion""" + + # prepare storage driver with existing cert and key + # this test simulates system startup + cert, key = client_cert.generate_self_signed_cert_pair(4096, 365, + 'sha256', {}) + storage_driver = DummyStorageDriver() + cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) + key_pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, key) + + storage_driver.store_cert(self.identity, cert_pem, key_pem) + # get mocked backend driver for trust management, + # prepared for get request, that preceeds delete operation + mocked_trust = self._get_mocked_trust('delete') + + cert = client_cert.ClientCertificateManager(self.identity, + mocked_trust, + storage_driver) + self.assertTrue(cert.exists()) + + cert.delete() + + self.assertFalse(cert.exists()) + self.assertTrue(storage_driver.is_empty(self.identity)) + + # verify API call to query identities in order to get cert id + base_uri = 'https://1.2.3.4/api/v1/trust-management' + uri = base_uri + '/principal-identities' + test_client.assert_json_call('get', mocked_trust.client, uri, + single_call=False) + + # verify API call to delete openstack principal identity + uri = uri + '/' + self.identity_id + test_client.assert_json_call('delete', mocked_trust.client, uri, + single_call=False) + + # verify API call to delete certificate + uri = base_uri + '/certificates/' + self.cert_id + test_client.assert_json_call('delete', mocked_trust.client, uri, + single_call=False) + + def test_bad_certificate_values(self): + bad_cert_values = [{'key_size': 1024, + 'valid_for_days': 10, + 'signature_alg': 'sha256', + 'subject': {}}, + {'key_size': 4096, + 'valid_for_days': 100, + 'signature_alg': 'sha', + 'subject': {}}] + + for args in bad_cert_values: + self.assertRaises(exceptions.InvalidInput, + client_cert.generate_self_signed_cert_pair, + **args) diff --git a/vmware_nsxlib/tests/unit/v3/test_client.py b/vmware_nsxlib/tests/unit/v3/test_client.py index dbbabdce..0d53ce23 100644 --- a/vmware_nsxlib/tests/unit/v3/test_client.py +++ b/vmware_nsxlib/tests/unit/v3/test_client.py @@ -43,24 +43,32 @@ def assert_call(verb, client_or_resource, url, verify=nsxlib_testcase.NSX_CERT, data=None, headers=DFT_ACCEPT_HEADERS, timeout=(nsxlib_testcase.NSX_HTTP_TIMEOUT, - nsxlib_testcase.NSX_HTTP_READ_TIMEOUT)): + nsxlib_testcase.NSX_HTTP_READ_TIMEOUT), + single_call=True): nsx_client = client_or_resource if getattr(nsx_client, '_client', None) is not None: nsx_client = nsx_client._client cluster = nsx_client._conn - cluster.assert_called_once( - verb, - **{'url': url, 'verify': verify, 'body': data, - 'headers': headers, 'cert': None, 'timeout': timeout}) + if single_call: + cluster.assert_called_once( + verb, + **{'url': url, 'verify': verify, 'body': data, + 'headers': headers, 'cert': None, 'timeout': timeout}) + else: + cluster.assert_any_call( + verb, + **{'url': url, 'verify': verify, 'body': data, + 'headers': headers, 'cert': None, 'timeout': timeout}) def assert_json_call(verb, client_or_resource, url, verify=nsxlib_testcase.NSX_CERT, data=None, - headers=client.JSONRESTClient._DEFAULT_HEADERS): + headers=client.JSONRESTClient._DEFAULT_HEADERS, + single_call=True): return assert_call(verb, client_or_resource, url, verify=verify, data=data, - headers=headers) + headers=headers, single_call=single_call) class NsxV3RESTClientTestCase(nsxlib_testcase.NsxClientTestCase): diff --git a/vmware_nsxlib/v3/client_cert.py b/vmware_nsxlib/v3/client_cert.py new file mode 100644 index 00000000..20c2b6d4 --- /dev/null +++ b/vmware_nsxlib/v3/client_cert.py @@ -0,0 +1,240 @@ +# Copyright 2016 VMware, Inc. +# All Rights Reserved +# +# 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 +from OpenSSL import crypto +from time import time + +from neutron_lib import exceptions +from oslo_log import log + +from vmware_nsxlib._i18n import _, _LE +from vmware_nsxlib.v3 import exceptions as nsxlib_exceptions + +LOG = log.getLogger(__name__) + +NSX_ERROR_IDENTITY_EXISTS = 2027 + + +def validate_cert_params(key_size, valid_for_days, + signature_alg, subject): + """Validate parameters for certificate""" + + expected_key_sizes = (2048, 4096) + if key_size not in expected_key_sizes: + raise exceptions.InvalidInput( + error_message=_('Invalid key size %(value)d' + '(must be one of %(list)s)') % + {'value': key_size, + 'list': expected_key_sizes}) + + expected_signature_algs = ('sha224', 'sha256') + if signature_alg not in expected_signature_algs: + raise exceptions.InvalidInput( + error_message=_('Invalid signature algorithm %(value)s' + '(must be one of %(list)s)') % + {'value': signature_alg, + 'list': expected_signature_algs}) + + +def generate_self_signed_cert_pair(key_size, valid_for_days, + signature_alg, subject): + """Generate self signed certificate and key pair""" + + validate_cert_params(key_size, valid_for_days, + signature_alg, subject) + + # generate key pair + key = crypto.PKey() + key.generate_key(crypto.TYPE_RSA, key_size) + + # generate certificate + cert = crypto.X509() + cert.get_subject().C = subject.get('country', 'US') + cert.get_subject().ST = subject.get('state', 'California') + cert.get_subject().O = subject.get('organization', 'MyOrg') + cert.get_subject().OU = subject.get('unit', 'MyUnit') + cert.get_subject().CN = subject.get('hostname', 'myorg.com') + cert.gmtime_adj_notBefore(0) + cert.gmtime_adj_notAfter(valid_for_days * 24 * 60 * 60) + cert.set_issuer(cert.get_subject()) + cert.set_pubkey(key) + cert.set_serial_number(int(time())) + cert.sign(key, signature_alg) + + return cert, key + + +class ClientCertificateManager(object): + """Manage Client Certificate for backend authentication + + There should be single client certificate associated + with certain principal identity. Certificate and PK storage + is pluggable. Storage API (similar to neutron-lbaas barbican API): + store_cert(purpose, certificate, private_key) + get_cert(purpose) + delete_cert(purpose) + """ + + def __init__(self, identity, nsx_trust_management, storage_driver): + self._cert = None + self._key = None + self._storage_driver = storage_driver + self._identity = identity + + self._nsx_trust_management = nsx_trust_management + + self._load_cert_and_key() + + def generate(self, subject, key_size=2048, valid_for_days=365, + signature_alg='sha256'): + """Generate new certificate and register it in the system + + Generate certificate with RSA key based on arguments provided, + register and associate it to principal identity on backend, + and store it in storage. If certificate already exists, fail. + """ + self._validate_empty() + + self._cert, self._key = generate_self_signed_cert_pair(key_size, + valid_for_days, + signature_alg, + subject) + + self._register_cert() + self._store_cert_and_key() + + LOG.debug("Client certificate generated successfully") + + def delete(self): + """Delete existing certificate from storage and backend""" + + if not self.exists(): + return + + ok = True + try: + # delete certificate and principal identity from backend + details = self._nsx_trust_management.get_identity_details( + self._identity) + + # TODO(annak): do not delete the identity once + # NSX supports multiple certificates per identity + # this will be required to support multiple openstack + # installations using same backend NSX + self._nsx_trust_management.delete_identity(details['id']) + if details['certificate_id']: + self._nsx_trust_management.delete_cert( + details['certificate_id']) + + except exceptions.ManagerError as e: + LOG.error(_LE("Failed to clear certificate on backend: %s"), e) + ok = False + + try: + self._storage_driver.delete_cert(self._identity) + except Exception as e: + LOG.error(_LE("Failed to clear certificate on storage: %s"), e) + ok = False + + self._cert = None + self._key = None + + if ok: + LOG.debug("Client certificate removed successfully") + + def exists(self): + """Check if certificate was created""" + + return self._cert is not None + + def get_pem(self): + """Returns certificate and key pair in PEM format""" + self._validate_exists() + + cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, self._cert) + key_pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, self._key) + + return (cert_pem, key_pem) + + def export_pem(self, filename): + """Exports certificate and key pair to file""" + if not self.exists(): + LOG.error(_LE("No certificate present - nothing to export")) + return + + cert_pem, key_pem = self.get_pem() + with open(filename, 'w') as f: + f.write(cert_pem) + f.write(key_pem) + + def expires_on(self): + """Returns certificate expiration timestamp""" + self._validate_exists() + + converted = datetime.datetime.strptime( + self._cert.get_notAfter().decode(), + "%Y%m%d%H%M%SZ") + return converted + + def expires_in_days(self): + """Returns in how many days the certificate expires""" + delta = self.expires_on() - datetime.datetime.utcnow() + return delta.days + + def _validate_empty(self): + if self.exists(): + raise nsxlib_exceptions.ObjectAlreadyExists( + object_type='Client Certificate') + + def _validate_exists(self): + if not self.exists(): + raise nsxlib_exceptions.ObjectNotGenerated( + object_type='Client Certificate') + + def _load_cert_and_key(self): + self._validate_empty() + + cert_pem, key_pem = self._storage_driver.get_cert(self._identity) + + if cert_pem is not None: + self._cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem) + self._key = crypto.load_privatekey(crypto.FILETYPE_PEM, key_pem) + + def _store_cert_and_key(self): + self._validate_exists() + + cert_pem, key_pem = self.get_pem() + self._storage_driver.store_cert(self._identity, cert_pem, key_pem) + + def _register_cert(self): + cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, self._cert) + nsx_cert_id = self._nsx_trust_management.create_cert(cert_pem) + try: + self._nsx_trust_management.create_identity(self._identity, + nsx_cert_id) + except nsxlib_exceptions.ManagerError as e: + if e.error_code != NSX_ERROR_IDENTITY_EXISTS: + raise e + + # principal identity already exists - this can happen + # due to temporary error on deletion. Worth retrying. + # TODO(annak): remove this code once + # NSX supports multiple certificates per identity + details = self._nsx_trust_management.get_identity_details( + self._identity) + self._nsx_trust_management.delete_identity(details['id']) + self._nsx_trust_management.create_identity(self._identity, + nsx_cert_id) diff --git a/vmware_nsxlib/v3/exceptions.py b/vmware_nsxlib/v3/exceptions.py index 805f9473..03cb6b9a 100644 --- a/vmware_nsxlib/v3/exceptions.py +++ b/vmware_nsxlib/v3/exceptions.py @@ -51,6 +51,14 @@ class NsxLibException(Exception): return False +class ObjectAlreadyExists(NsxLibException): + message = _("%(object_type) already exists") + + +class ObjectNotGenerated(NsxLibException): + message = _("%(object_type) was not generated") + + class ManagerError(NsxLibException): message = _("Unexpected error from backend manager (%(manager)s) " "for %(operation)s %(details)s") diff --git a/vmware_nsxlib/v3/trust_management.py b/vmware_nsxlib/v3/trust_management.py new file mode 100644 index 00000000..9699a330 --- /dev/null +++ b/vmware_nsxlib/v3/trust_management.py @@ -0,0 +1,66 @@ +# Copyright 2016 VMware, Inc. +# All Rights Reserved +# +# 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. + +from vmware_nsxlib.v3 import exceptions as nsxlib_exc +from vmware_nsxlib.v3 import utils + +BASE_SECTION = 'trust-management' +CERT_SECTION = BASE_SECTION + '/certificates' +ID_SECTION = BASE_SECTION + '/principal-identities' + + +class NsxLibTrustManagement(utils.NsxLibApiBase): + + def remove_newlines_from_pem(self, pem): + """NSX expects pem without newlines in certificate body + + BEGIN and END sections should be separated with newlines + """ + lines = pem.split(b'\n') + result = lines[0] + b'\n' + result += b''.join(lines[1:-1]) + result += b'\n' + lines[-1] + return result + + def create_cert(self, cert_pem): + resource = CERT_SECTION + '?action=import' + body = {'pem_encoded': self.remove_newlines_from_pem(cert_pem)} + + results = self.client.create(resource, body)['results'] + if len(results) > 0: + # should be only one result + return results[0]['id'] + + def delete_cert(self, cert_id): + resource = CERT_SECTION + '/' + cert_id + self.client.delete(resource) + + def create_identity(self, identity, cert_id): + body = {'name': identity, 'certificate_id': cert_id} + self.client.create(ID_SECTION, body) + + def delete_identity(self, identity): + resource = ID_SECTION + '/' + identity + self.client.delete(resource) + + def get_identity_details(self, identity): + results = self.client.get(ID_SECTION)['results'] + for result in results: + if result['name'] == identity: + return result + + raise nsxlib_exc.ResourceNotFound( + manager=self._client.nsx_api_managers, + operation="Principal identity %s not found" % identity)