Allow configurable signing backends

Make the signing backends configurable and expose the local implementation as a
default option.

Change-Id: I4d4fc649c9539d90d02b0e4d6888f79958a670da
This commit is contained in:
Stanisław Pitucha 2015-07-14 11:58:59 +10:00
parent f1ed12e2cf
commit 8e19fc9e9c
13 changed files with 185 additions and 25 deletions

View File

@ -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
===============================

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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 </signing_backends>`.
Virtual registration authority

View File

@ -12,6 +12,7 @@ Contents:
configuration
api
signing_backends
validators
Indices and tables

65
docs/signing_backends.rst Normal file
View File

@ -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.

View File

@ -9,3 +9,4 @@ Paste
netaddr>=0.7.12
ldap3>=0.9.8.2 # LGPLv3
requests>=2.5.2
stevedore>=1.5.0 # Apache-2.0

View File

@ -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

View File

@ -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",

View File

@ -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)

View File

@ -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()