commit
734c845828
@ -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)
|
@ -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)
|
@ -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)
|
Loading…
Reference in new issue