Merge "Allow configurable signing backends"
This commit is contained in:
commit
fceba34856
34
README.md
34
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
|
||||
===============================
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -12,6 +12,7 @@ Contents:
|
||||
|
||||
configuration
|
||||
api
|
||||
signing_backends
|
||||
validators
|
||||
|
||||
Indices and tables
|
||||
|
65
docs/signing_backends.rst
Normal file
65
docs/signing_backends.rst
Normal 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.
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user