diff --git a/README.md b/README.md index f7776f1..ad156ab 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,40 @@ search is done in the configured base. } } +Signing backends +================ + +Anchor allows the use of configurable signing backend. While it provides one +implementation (based on cryptography.io and OpenSSL), other implementations +may be configured. + +The resulting certificate is stored locally if the `output_path` is set to any +string. This does not depend on the configured backend. + +Backends can specify their own options - please refer to the backend +documentation for the specific list. The default backend takes the following +options: + +* `cert_path`: path where local CA certificate can be found + +* `key_path`: path to the key for that certificate + +* `signing_hash`: which hash method to use when producing signatures + +* `valid_hours`: number of hours the signed certificates are valid for + +Sample configuration for the default backend: + + "ca": { + "cert_path": "CA/root-ca.crt", + "key_path": "CA/root-ca-unwrapped.key", + "output_path": "certs", + "signing_hash": "sha256", + "valid_hours": 24 + } + +For more information, please refer to the documentation. + Reporting bugs and contributing =============================== diff --git a/anchor/app.py b/anchor/app.py index af27b7a..0568c2c 100644 --- a/anchor/app.py +++ b/anchor/app.py @@ -204,6 +204,8 @@ def load_config(): logger.info("using config: {}".format(config_path)) jsonloader.conf.load_file_data(config_path) + jsonloader.conf.load_extensions() + def setup_app(config): # initial logging, will be re-configured later diff --git a/anchor/certificate_ops.py b/anchor/certificate_ops.py index e9d8beb..9db6451 100644 --- a/anchor/certificate_ops.py +++ b/anchor/certificate_ops.py @@ -20,6 +20,7 @@ import time import uuid import pecan +from webob import exc as http_status from anchor import jsonloader from anchor import validators @@ -36,6 +37,10 @@ logger = logging.getLogger(__name__) VALID_ENCODINGS = ['pem'] +class SigningError(Exception): + pass + + def parse_csr(csr, encoding): """Loads the user provided CSR into the backend X509 library. @@ -122,26 +127,61 @@ def validate_csr(ra_name, auth_result, csr, request): pecan.abort(400, "CSR failed validation") -def sign(ra_name, csr): - """Generate an X.509 certificate and sign it. +def certificate_fingerprint(cert_pem, hash_name): + """Get certificate fingerprint.""" + cert = certificate.X509Certificate.from_buffer(cert_pem) + return cert.get_fingerprint(hash_name) + + +def dispatch_sign(ra_name, csr): + """Dispatch the sign call to the configured backend. - :param ra_name: name of the registration authority :param csr: X509 certificate signing request + :return: signed certificate in PEM format """ ca_conf = jsonloader.signing_ca_for_registration_authority(ra_name) + backend_name = ca_conf.get('backend', 'anchor') + sign_func = jsonloader.conf.get_signing_backend(backend_name) + try: + cert_pem = sign_func(csr, ca_conf) + except http_status.HTTPException: + logger.exception("Failed to sign certificate") + raise + except Exception: + logger.exception("Failed to sign the certificate") + pecan.abort(500, "certificate signing error") + if ca_conf.get('output_path') is not None: + fingerprint = certificate_fingerprint(cert_pem, 'sha256') + path = os.path.join( + ca_conf['output_path'], + '%s.crt' % fingerprint) + + logger.info("Saving certificate to: %s", path) + + with open(path, "w") as f: + f.write(cert_pem) + + return cert_pem + + +def sign(csr, ca_conf): + """Generate an X.509 certificate and sign it. + + :param csr: X509 certificate signing request + :param ca_conf: signing CA configuration + :return: signed certificate in PEM format + """ try: ca = certificate.X509Certificate.from_file( ca_conf['cert_path']) except Exception as e: - logger.exception("Cannot load the signing CA: %s", e) - pecan.abort(500, "certificate signing error") + raise SigningError("Cannot load the signing CA: %s" % (e,)) try: key = utils.get_private_key_from_file(ca_conf['key_path']) except Exception as e: - logger.exception("Cannot load the signing CA key: %s", e) - pecan.abort(500, "certificate signing error") + raise SigningError("Cannot load the signing CA key: %s" % (e,)) new_cert = certificate.X509Certificate() new_cert.set_version(2) @@ -169,16 +209,6 @@ def sign(ra_name, csr): new_cert.sign(key, ca_conf['signing_hash']) - path = os.path.join( - ca_conf['output_path'], - '%s.crt' % new_cert.get_fingerprint( - ca_conf['signing_hash'])) - - logger.info("Saving certificate to: %s", path) - cert_pem = new_cert.as_pem() - with open(path, "w") as f: - f.write(cert_pem) - return cert_pem diff --git a/anchor/controllers/__init__.py b/anchor/controllers/__init__.py index 665bec8..c164b86 100644 --- a/anchor/controllers/__init__.py +++ b/anchor/controllers/__init__.py @@ -51,7 +51,7 @@ class SignInstanceController(rest.RestController): pecan.request.POST.get('encoding')) certificate_ops.validate_csr(ra_name, auth_result, csr, pecan.request) - return certificate_ops.sign(ra_name, csr) + return certificate_ops.dispatch_sign(ra_name, csr) class SignController(rest.RestController): diff --git a/anchor/jsonloader.py b/anchor/jsonloader.py index 2ba52c7..fd8d2c9 100644 --- a/anchor/jsonloader.py +++ b/anchor/jsonloader.py @@ -19,6 +19,8 @@ from __future__ import absolute_import import json import logging +import stevedore + logger = logging.getLogger(__name__) @@ -55,6 +57,13 @@ class AnchorConf(): '''Load a config from string data.''' self._config = json.loads(data) + def load_extensions(self): + self._signing_backends = stevedore.ExtensionManager( + "anchor.signing_backends") + + def get_signing_backend(self, name): + return self._signing_backends[name].plugin + @property def config(self): '''Property to return the config dictionary diff --git a/docs/configuration.rst b/docs/configuration.rst index 6ab6460..4cdaec7 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -106,6 +106,7 @@ which uses local files. An example configuration looks like this. { "signing_ca": { "local": { + "backend": "anchor", "cert_path": "CA/root-ca.crt", "key_path": "CA/root-ca-unwrapped.key", "output_path": "certs", @@ -115,11 +116,19 @@ which uses local files. An example configuration looks like this. } } -Parameters ``cert_path`` and ``key_path`` define the location of respectively -the CA certificate and its private key. The location where the local copies of -issued certificates is held is defiend by ``output_path``. The ``signing_hash`` -defines the hash used to sign the results. The validity of issued certificates -(in hours) is set by ``valid_hours``. +Anchor allows the use of configurable signing backend. While it provides a +default implementation (based on cryptography.io and OpenSSL), other +implementations may be configured. The backend is configured by setting the +``backend`` value to the name of the right entry point. Backend implementations +need to provide only one function: ``sign(csr, config)``, taking the parsed CSR +and their own ``singing_ca`` block of the configuration as parameters and +returning signed certificate in PEM format. + +The backends are loaded using the ``stevedore`` module from the registered +entry points. The name space is ``anchor.signing_backends``. + +Each backend may take different configuration options. Please refer to +:doc:`signing backends section `. Virtual registration authority diff --git a/docs/index.rst b/docs/index.rst index 61de144..4425e03 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,6 +12,7 @@ Contents: configuration api + signing_backends validators Indices and tables diff --git a/docs/signing_backends.rst b/docs/signing_backends.rst new file mode 100644 index 0000000..99c1d5b --- /dev/null +++ b/docs/signing_backends.rst @@ -0,0 +1,65 @@ +Signing backends +================ + +Each signing backend must be registered using an entry point. They're loaded +using the ``stevedore`` module, however this should not affect the calling +behaviour. + +The signing CA configuration block allows the following common options: + +* ``backend``: name of the requested backend ("anchor" not defined) +* ``output_path``: local path where anchor saves the issued certificates + (optional, output not saved if not defined) + +Anchor provides the following backends out of the box: + +anchor +------ + +The default signing backend. It doesn't have any external service dependencies +and all signing happens inside of the Anchor process. + +A sample configuration for the ``signing_ca`` block looks like this: + +.. code:: json + + { + "local": { + "backend": "anchor", + "cert_path": "CA/root-ca.crt", + "key_path": "CA/root-ca-unwrapped.key", + "output_path": "certs", + "signing_hash": "sha256", + "valid_hours": 24 + } + } + +Valid options for this backend are: + +* ``cert_path``: path to the signing CA certificate +* ``key_path``: path to the matching key +* ``signing_hash``: hash to use when signing the issued certificate ("md5", + "sha1", "sha224, "sha256" are valid options) +* ``valid_hours``: validity period for the issued certificates, defined in + hours + +Backend development +------------------- + +Backends are simple functions which need to take 2 parameters: the CSR in PEM +format and the configuration block contents. Configuration can contain any keys +required by the backend. + +The return value must be a signed certificate in PEM format. The backend may +either throw a specific ``WebOb`` HTTP exception, or any other exception which +will result in a generic 500 response. + +For security, http exceptions from the signing backend should not expose any +specific information about the reason for failure. Internal exceptions are +preferred for this reason and their details will be logged in Anchor. + +The backend must not rely on the received CSR signature. If any modifications +are applied to the submitted CSR in Anchor, they will invalidate the signature. +Unless the backend is intended to work only with validators, and not any fixup +operations in the future, the signature field should be ignored and the request +treated as already correct/verified. diff --git a/requirements.txt b/requirements.txt index 2a0d7d9..b7103c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ Paste netaddr!=0.7.16,>=0.7.12 ldap3>=0.9.8.2 # LGPLv3 requests>=2.5.2 +stevedore>=1.5.0 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index 4bd825a..15ca13d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,6 +21,10 @@ classifier = Programming Language :: Python :: 3.4 Topic :: Security +[entry_points] +anchor.signing_backends = + anchor = anchor.certificate_ops:sign + [files] packages = anchor diff --git a/tests/__init__.py b/tests/__init__.py index 8da6b99..7b64864 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -42,6 +42,7 @@ class DefaultConfigMixin(object): } self.sample_conf_ca = { "default_ca": { + "backend": "anchor", "cert_path": "tests/CA/root-ca.crt", "key_path": "tests/CA/root-ca-unwrapped.key", "output_path": "certs", diff --git a/tests/test_certificate_ops.py b/tests/test_certificate_ops.py index 1509d11..fa326ea 100644 --- a/tests/test_certificate_ops.py +++ b/tests/test_certificate_ops.py @@ -21,6 +21,7 @@ import mock from webob import exc as http_status from anchor import certificate_ops +from anchor import jsonloader from anchor.X509 import name as x509_name import tests @@ -141,6 +142,7 @@ class CertificateOpsTests(tests.DefaultConfigMixin, unittest.TestCase): def test_ca_cert_read_failure(self): """Test CA certificate read failure.""" csr_obj = certificate_ops.parse_csr(self.csr, 'pem') + jsonloader.conf.load_extensions() config = "anchor.jsonloader.conf._config" ca_conf = self.sample_conf_ca['default_ca'] ca_conf['cert_path'] = '/xxx/not/a/valid/path' @@ -149,12 +151,13 @@ class CertificateOpsTests(tests.DefaultConfigMixin, unittest.TestCase): with mock.patch.dict(config, data): with self.assertRaises(http_status.HTTPException) as cm: - certificate_ops.sign('default_ra', csr_obj) + certificate_ops.dispatch_sign('default_ra', csr_obj) self.assertEqual(cm.exception.code, 500) def test_ca_key_read_failure(self): """Test CA key read failure.""" csr_obj = certificate_ops.parse_csr(self.csr, 'pem') + jsonloader.conf.load_extensions() config = "anchor.jsonloader.conf._config" self.sample_conf_ca['default_ca']['cert_path'] = 'tests/CA/root-ca.crt' self.sample_conf_ca['default_ca']['key_path'] = '/xxx/not/a/valid/path' @@ -162,5 +165,5 @@ class CertificateOpsTests(tests.DefaultConfigMixin, unittest.TestCase): with mock.patch.dict(config, data): with self.assertRaises(http_status.HTTPException) as cm: - certificate_ops.sign('default_ra', csr_obj) + certificate_ops.dispatch_sign('default_ra', csr_obj) self.assertEqual(cm.exception.code, 500) diff --git a/tests/test_functional.py b/tests/test_functional.py index 13816fd..8d64499 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -76,6 +76,7 @@ class TestFunctional(tests.DefaultConfigMixin, unittest.TestCase): # Load config from json test config jsonloader.conf.load_str_data(json.dumps(self.sample_conf)) + jsonloader.conf.load_extensions() self.conf = jsonloader.conf._config ca_conf = self.conf["signing_ca"]["default_ca"] ca_conf["output_path"] = tempfile.mkdtemp()