diff --git a/doc/source/main/Anchor.rst b/doc/source/main/Anchor.rst new file mode 100644 index 0000000000..0ee8ff504e --- /dev/null +++ b/doc/source/main/Anchor.rst @@ -0,0 +1,22 @@ +====== +Anchor +====== +Anchor (see https://wiki.openstack.org/wiki/Security/Projects/Anchor) is +an ephemeral PKI system built to enable cryptographic trust in OpenStack +services. In the context of Octavia it can be used to sign the certificates +which secure the amphora - controller communication. + +Basic Setup +----------- +# Download/Install/Start Anchor from https://github.com/openstack/anchor +# Change the listening port in config.py to 9999 +# I found it useful to run anchor in an additional devstack screen +# Set in octavia.conf +## [controller_worker] cert_generator to anchor +## [haproxy_amphora] server_ca = /opt/stack/anchor/CA/root-ca.crt (Anchor CA) +# Restart o-cw o-hm o-hk + +Benefit +------- +In bigger cloud installations Anchor can be a gateway to a more secure +certificate management system than our default local signing. diff --git a/doc/source/main/glossary.rst b/doc/source/main/glossary.rst index cef5e00e9d..3e2ced3d18 100644 --- a/doc/source/main/glossary.rst +++ b/doc/source/main/glossary.rst @@ -28,6 +28,12 @@ description of these terms. back-end amphora corresponding with the driver. This communication happens over the LB network. + Anchor + Is an OpenStack project for an ephemeral PKI system (see + https://wiki.openstack.org/wiki/Security/Projects/Anchor). In Octavia + we can use Anchor to sign the certificates we use to authenticate/secure + controller <-> amphora communication. + Apolocation Term used to describe when two or more amphorae are not colocated on the same physical hardware (which is often essential in HA topologies). diff --git a/etc/octavia.conf b/etc/octavia.conf index 2c233ba0fc..0edb077c00 100644 --- a/etc/octavia.conf +++ b/etc/octavia.conf @@ -73,6 +73,12 @@ # barbican_cert_manager # cert_manager=local_cert_manager +[anchor] +# Use OpenStack anchor to sign the amphora REST API certificates +# url = http://localhost:9999/v1/sign/default +# username = myusername +# password = simplepassword + [networking] # Network to communicate with amphora # lb_network_name = @@ -137,6 +143,7 @@ # # Certificate Generator options are local_cert_generator # barbican_cert_generator +# anchor_cert_generator # cert_generator = local_cert_generator [task_flow] diff --git a/octavia/certificates/generator/anchor.py b/octavia/certificates/generator/anchor.py new file mode 100644 index 0000000000..f46dc77552 --- /dev/null +++ b/octavia/certificates/generator/anchor.py @@ -0,0 +1,68 @@ +# Copyright (c) 2015 Hewlett Packard Enterprise Development Company LP +# 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 oslo_config import cfg +from oslo_log import log as logging +import requests + +from octavia.certificates.generator import local +from octavia.common import exceptions +from octavia.i18n import _LE + + +LOG = logging.getLogger(__name__) + +CONF = cfg.CONF +CONF.import_group('anchor', 'octavia.common.config') + + +class AnchorException(exceptions.CertificateGenerationException): + pass + + +class AnchorCertGenerator(local.LocalCertGenerator): + """Cert Generator Interface that signs certs with Anchor.""" + + @classmethod + def sign_cert(cls, csr, validity=None, **kwargs): + """Signs a certificate using Anchor based on the specified CSR + + :param csr: A Certificate Signing Request + :param validity: Will be ignored for now + :param kwargs: Will be ignored for now + + :return: Signed certificate + :raises Exception: if certificate signing fails + """ + LOG.debug("Signing a certificate request using Anchor") + + try: + LOG.debug('Certificate: %s', csr) + r = requests.post(CONF.anchor.url, data={ + 'user': CONF.anchor.username, + 'secret': CONF.anchor.password, + 'encoding': 'pem', + 'csr': csr}) + + if r.status_code != 200: + LOG.debug('Anchor returned: %s', r.content) + raise AnchorException("Anchor returned Status Code : " + + str(r.status_code)) + + return r.content + + except Exception as e: + LOG.error(_LE("Unable to sign certificate.")) + raise exceptions.CertificateGenerationException(msg=e) diff --git a/octavia/certificates/generator/local.py b/octavia/certificates/generator/local.py index d38bcd99ea..ac4c68aeb8 100644 --- a/octavia/certificates/generator/local.py +++ b/octavia/certificates/generator/local.py @@ -177,7 +177,10 @@ class LocalCertGenerator(cert_gen.CertGenerator): csr = x509.CertificateSigningRequestBuilder().subject_name( x509.Name([ x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, cn), - ])).sign(pk, hashes.SHA256(), backends.default_backend()) + ])).add_extension( + x509.BasicConstraints( + ca=False, path_length=None), critical=True, + ).sign(pk, hashes.SHA256(), backends.default_backend()) return csr.public_bytes(serialization.Encoding.PEM) @classmethod diff --git a/octavia/common/config.py b/octavia/common/config.py index f2755755f7..423e04f32d 100644 --- a/octavia/common/config.py +++ b/octavia/common/config.py @@ -286,7 +286,19 @@ house_keeping_opts = [ default=10, help=_('Number of threads performing amphora certificate' ' rotation')) +] +anchor_opts = [ + cfg.StrOpt('url', + default='http://localhost:9999/v1/sign/default', + help=_('Anchor URL')), + cfg.StrOpt('username', + default='myusername', + help=_('Anchor username')), + cfg.StrOpt('password', + default='simplepassword', + help=_('Anchor password'), + secret=True) ] # Register the configuration options @@ -299,6 +311,7 @@ cfg.CONF.register_opts(controller_worker_opts, group='controller_worker') cfg.CONF.register_opts(task_flow_opts, group='task_flow') cfg.CONF.register_opts(oslo_messaging_opts, group='oslo_messaging') cfg.CONF.register_opts(house_keeping_opts, group='house_keeping') +cfg.CONF.register_opts(anchor_opts, group='anchor') cfg.CONF.register_cli_opts(core_cli_opts) cfg.CONF.register_opts(certificate_opts, group='certificates') cfg.CONF.register_cli_opts(healthmanager_opts, group='health_manager') @@ -306,6 +319,7 @@ cfg.CONF.import_group('keystone_authtoken', 'keystonemiddleware.auth_token') cfg.CONF.register_opts(keystone_authtoken_v3_opts, group='keystone_authtoken_v3') + # Ensure that the control exchange is set correctly messaging.set_transport_defaults(control_exchange='octavia') _SQL_CONNECTION_DEFAULT = 'sqlite://' diff --git a/octavia/tests/unit/certificates/generator/local_csr.py b/octavia/tests/unit/certificates/generator/local_csr.py new file mode 100644 index 0000000000..2e0155ed08 --- /dev/null +++ b/octavia/tests/unit/certificates/generator/local_csr.py @@ -0,0 +1,115 @@ +# Copyright 2015 Hewlett Packard Enterprise Development Company LP +# +# 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 cryptography.hazmat import backends +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +from cryptography import x509 +import mock + +import octavia.tests.unit.base as base + + +class BaseLocalCSRTestCase(base.TestCase): + def setUp(self): + self.signing_digest = "sha256" + + # Set up CSR data + csr_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=backends.default_backend() + ) + csr = x509.CertificateSigningRequestBuilder().subject_name( + x509.Name([ + x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, u"test"), + ])).sign(csr_key, hashes.SHA256(), backends.default_backend()) + self.certificate_signing_request = csr.public_bytes( + serialization.Encoding.PEM) + + # Set up keys + self.ca_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=backends.default_backend() + ) + + self.ca_private_key_passphrase = b"Testing" + self.ca_private_key = self.ca_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.BestAvailableEncryption( + self.ca_private_key_passphrase), + ) + + super(BaseLocalCSRTestCase, self).setUp() + + def test_generate_csr(self): + cn = 'test_cn' + # Attempt to generate a CSR + csr = self.cert_generator._generate_csr( + cn=cn, + private_key=self.ca_private_key, + passphrase=self.ca_private_key_passphrase + ) + + # Attempt to load the generated CSR + csro = x509.load_pem_x509_csr(data=csr, + backend=backends.default_backend()) + + # Make sure the CN is correct + self.assertEqual(cn, csro.subject.get_attributes_for_oid( + x509.oid.NameOID.COMMON_NAME)[0].value) + + def test_generate_private_key(self): + bit_length = 1024 + # Attempt to generate a private key + pk = self.cert_generator._generate_private_key( + bit_length=bit_length + ) + + # Attempt to load the generated private key + pko = serialization.load_pem_private_key( + data=pk, password=None, backend=backends.default_backend()) + + # Make sure the bit_length is what we set + self.assertEqual(pko.key_size, bit_length) + + def test_generate_private_key_with_passphrase(self): + bit_length = 2048 + # Attempt to generate a private key + pk = self.cert_generator._generate_private_key( + bit_length=bit_length, + passphrase=self.ca_private_key_passphrase + ) + + # Attempt to load the generated private key + pko = serialization.load_pem_private_key( + data=pk, password=self.ca_private_key_passphrase, + backend=backends.default_backend()) + + # Make sure the bit_length is what we set + self.assertEqual(pko.key_size, bit_length) + + def test_generate_cert_key_pair_mock(self): + cn = 'test_cn' + + with mock.patch.object(self.cert_generator, 'sign_cert') as m: + # Attempt to generate a cert/key pair + self.cert_generator.generate_cert_key_pair( + cn=cn, + validity=2 * 365 * 24 * 60 * 60, + ) + self.assertTrue(m.called) \ No newline at end of file diff --git a/octavia/tests/unit/certificates/generator/test_anchor.py b/octavia/tests/unit/certificates/generator/test_anchor.py new file mode 100644 index 0000000000..05913fc553 --- /dev/null +++ b/octavia/tests/unit/certificates/generator/test_anchor.py @@ -0,0 +1,48 @@ +# Copyright 2015 Hewlett Packard Enterprise Development Company LP +# +# 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 oslo_config import cfg +import requests_mock +import six + +from octavia.certificates.generator import anchor +from octavia.common import exceptions +from octavia.tests.unit.certificates.generator import local_csr + + +CONF = cfg.CONF +CONF.import_group('anchor', 'octavia.common.config') + + +class TestAnchorGenerator(local_csr.BaseLocalCSRTestCase): + def setUp(self): + super(TestAnchorGenerator, self).setUp() + self.cert_generator = anchor.AnchorCertGenerator + + @requests_mock.mock() + def test_sign_cert(self, m): + + m.post(CONF.anchor.url, content=six.b('test')) + + # Attempt to sign a cert + signed_cert = self.cert_generator.sign_cert( + csr=self.certificate_signing_request + ) + self.assertEqual("test", signed_cert.decode('ascii')) + self.assertTrue(m.called) + + m.post(CONF.anchor.url, status_code=400) + self.assertRaises(exceptions.CertificateGenerationException, + self.cert_generator.sign_cert, + self.certificate_signing_request) diff --git a/octavia/tests/unit/certificates/generator/test_local.py b/octavia/tests/unit/certificates/generator/test_local.py index f6ba660512..cd536dc92e 100644 --- a/octavia/tests/unit/certificates/generator/test_local.py +++ b/octavia/tests/unit/certificates/generator/test_local.py @@ -15,46 +15,20 @@ import datetime from cryptography import exceptions as crypto_exceptions from cryptography.hazmat import backends -from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import serialization from cryptography import x509 import octavia.certificates.generator.local as local_cert_gen -import octavia.tests.unit.base as base +from octavia.tests.unit.certificates.generator import local_csr -class TestLocalGenerator(base.TestCase): +class TestLocalGenerator(local_csr.BaseLocalCSRTestCase): def setUp(self): + super(TestLocalGenerator, self).setUp() self.signing_digest = "sha256" - # Set up CSR data - csr_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - backend=backends.default_backend() - ) - csr = x509.CertificateSigningRequestBuilder().subject_name( - x509.Name([ - x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, u"test"), - ])).sign(csr_key, hashes.SHA256(), backends.default_backend()) - self.certificate_signing_request = csr.public_bytes( - serialization.Encoding.PEM) - - # Set up CA data - ca_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - backend=backends.default_backend() - ) - - self.ca_private_key_passphrase = b"Testing" - self.ca_private_key = ca_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.BestAvailableEncryption( - self.ca_private_key_passphrase), - ) + # Setup CA data ca_cert = x509.CertificateBuilder() valid_from_datetime = datetime.datetime.utcnow() @@ -75,19 +49,19 @@ class TestLocalGenerator(base.TestCase): ]) ca_cert = ca_cert.subject_name(subject_name) ca_cert = ca_cert.issuer_name(subject_name) - ca_cert = ca_cert.public_key(ca_key.public_key()) - signed_cert = ca_cert.sign(private_key=ca_key, + ca_cert = ca_cert.public_key(self.ca_key.public_key()) + signed_cert = ca_cert.sign(private_key=self.ca_key, algorithm=hashes.SHA256(), backend=backends.default_backend()) self.ca_certificate = signed_cert.public_bytes( encoding=serialization.Encoding.PEM) - super(TestLocalGenerator, self).setUp() + self.cert_generator = local_cert_gen.LocalCertGenerator def test_sign_cert(self): # Attempt sign a cert - signed_cert = local_cert_gen.LocalCertGenerator.sign_cert( + signed_cert = self.cert_generator.sign_cert( csr=self.certificate_signing_request, validity=2 * 365 * 24 * 60 * 60, ca_cert=self.ca_certificate, @@ -128,7 +102,7 @@ class TestLocalGenerator(base.TestCase): def test_sign_cert_invalid_algorithm(self): self.assertRaises( crypto_exceptions.UnsupportedAlgorithm, - local_cert_gen.LocalCertGenerator.sign_cert, + self.cert_generator.sign_cert, csr=self.certificate_signing_request, validity=2 * 365 * 24 * 60 * 60, ca_cert=self.ca_certificate, @@ -137,58 +111,12 @@ class TestLocalGenerator(base.TestCase): ca_digest='not_an_algorithm' ) - def test_generate_private_key(self): - bit_length = 1024 - # Attempt to generate a private key - pk = local_cert_gen.LocalCertGenerator._generate_private_key( - bit_length=bit_length - ) - - # Attempt to load the generated private key - pko = serialization.load_pem_private_key( - data=pk, password=None, backend=backends.default_backend()) - - # Make sure the bit_length is what we set - self.assertEqual(pko.key_size, bit_length) - - def test_generate_private_key_with_passphrase(self): - bit_length = 2048 - # Attempt to generate a private key - pk = local_cert_gen.LocalCertGenerator._generate_private_key( - bit_length=bit_length, - passphrase=self.ca_private_key_passphrase - ) - - # Attempt to load the generated private key - pko = serialization.load_pem_private_key( - data=pk, password=self.ca_private_key_passphrase, - backend=backends.default_backend()) - - # Make sure the bit_length is what we set - self.assertEqual(pko.key_size, bit_length) - - def test_generate_csr(self): - cn = 'test_cn' - # Attempt to generate a CSR - csr = local_cert_gen.LocalCertGenerator._generate_csr( - cn=cn, - private_key=self.ca_private_key, - passphrase=self.ca_private_key_passphrase - ) - - # Attempt to load the generated CSR - csro = x509.load_pem_x509_csr(data=csr, - backend=backends.default_backend()) - - # Make sure the CN is correct - self.assertEqual(cn, csro.subject.get_attributes_for_oid( - x509.oid.NameOID.COMMON_NAME)[0].value) - def test_generate_cert_key_pair(self): cn = 'test_cn' bit_length = 512 + # Attempt to generate a cert/key pair - cert_object = local_cert_gen.LocalCertGenerator.generate_cert_key_pair( + cert_object = self.cert_generator.generate_cert_key_pair( cn=cn, validity=2 * 365 * 24 * 60 * 60, bit_length=bit_length, @@ -207,4 +135,4 @@ class TestLocalGenerator(base.TestCase): data=cert_object.private_key, password=cert_object.private_key_passphrase, backend=backends.default_backend()) - self.assertIsNotNone(key) + self.assertIsNotNone(key) \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 7ea64ca2f4..ec41d4c095 100644 --- a/setup.cfg +++ b/setup.cfg @@ -59,6 +59,7 @@ octavia.network.drivers = octavia.cert_generator = local_cert_generator = octavia.certificates.generator.local:LocalCertGenerator barbican_cert_generator = octavia.certificates.generator.barbican:BarbicanCertGenerator + anchor_cert_generator = octavia.certificates.generator.anchor:AnchorCertGenerator octavia.cert_manager = local_cert_manager = octavia.certificates.manager.local:LocalCertManager barbican_cert_manager = octavia.certificates.manager.barbican:BarbicanCertManager