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:
parent
f1ed12e2cf
commit
8e19fc9e9c
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
|
Reporting bugs and contributing
|
||||||
===============================
|
===============================
|
||||||
|
|
||||||
|
|
|
@ -204,6 +204,8 @@ def load_config():
|
||||||
logger.info("using config: {}".format(config_path))
|
logger.info("using config: {}".format(config_path))
|
||||||
jsonloader.conf.load_file_data(config_path)
|
jsonloader.conf.load_file_data(config_path)
|
||||||
|
|
||||||
|
jsonloader.conf.load_extensions()
|
||||||
|
|
||||||
|
|
||||||
def setup_app(config):
|
def setup_app(config):
|
||||||
# initial logging, will be re-configured later
|
# initial logging, will be re-configured later
|
||||||
|
|
|
@ -20,6 +20,7 @@ import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pecan
|
import pecan
|
||||||
|
from webob import exc as http_status
|
||||||
|
|
||||||
from anchor import jsonloader
|
from anchor import jsonloader
|
||||||
from anchor import validators
|
from anchor import validators
|
||||||
|
@ -36,6 +37,10 @@ logger = logging.getLogger(__name__)
|
||||||
VALID_ENCODINGS = ['pem']
|
VALID_ENCODINGS = ['pem']
|
||||||
|
|
||||||
|
|
||||||
|
class SigningError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def parse_csr(csr, encoding):
|
def parse_csr(csr, encoding):
|
||||||
"""Loads the user provided CSR into the backend X509 library.
|
"""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")
|
pecan.abort(400, "CSR failed validation")
|
||||||
|
|
||||||
|
|
||||||
def sign(ra_name, csr):
|
def certificate_fingerprint(cert_pem, hash_name):
|
||||||
"""Generate an X.509 certificate and sign it.
|
"""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
|
:param csr: X509 certificate signing request
|
||||||
|
:return: signed certificate in PEM format
|
||||||
"""
|
"""
|
||||||
ca_conf = jsonloader.signing_ca_for_registration_authority(ra_name)
|
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:
|
try:
|
||||||
ca = certificate.X509Certificate.from_file(
|
ca = certificate.X509Certificate.from_file(
|
||||||
ca_conf['cert_path'])
|
ca_conf['cert_path'])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Cannot load the signing CA: %s", e)
|
raise SigningError("Cannot load the signing CA: %s" % (e,))
|
||||||
pecan.abort(500, "certificate signing error")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
key = utils.get_private_key_from_file(ca_conf['key_path'])
|
key = utils.get_private_key_from_file(ca_conf['key_path'])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Cannot load the signing CA key: %s", e)
|
raise SigningError("Cannot load the signing CA key: %s" % (e,))
|
||||||
pecan.abort(500, "certificate signing error")
|
|
||||||
|
|
||||||
new_cert = certificate.X509Certificate()
|
new_cert = certificate.X509Certificate()
|
||||||
new_cert.set_version(2)
|
new_cert.set_version(2)
|
||||||
|
@ -169,16 +209,6 @@ def sign(ra_name, csr):
|
||||||
|
|
||||||
new_cert.sign(key, ca_conf['signing_hash'])
|
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()
|
cert_pem = new_cert.as_pem()
|
||||||
|
|
||||||
with open(path, "w") as f:
|
|
||||||
f.write(cert_pem)
|
|
||||||
|
|
||||||
return cert_pem
|
return cert_pem
|
||||||
|
|
|
@ -51,7 +51,7 @@ class SignInstanceController(rest.RestController):
|
||||||
pecan.request.POST.get('encoding'))
|
pecan.request.POST.get('encoding'))
|
||||||
certificate_ops.validate_csr(ra_name, auth_result, csr, pecan.request)
|
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):
|
class SignController(rest.RestController):
|
||||||
|
|
|
@ -19,6 +19,8 @@ from __future__ import absolute_import
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import stevedore
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -55,6 +57,13 @@ class AnchorConf():
|
||||||
'''Load a config from string data.'''
|
'''Load a config from string data.'''
|
||||||
self._config = json.loads(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
|
@property
|
||||||
def config(self):
|
def config(self):
|
||||||
'''Property to return the config dictionary
|
'''Property to return the config dictionary
|
||||||
|
|
|
@ -106,6 +106,7 @@ which uses local files. An example configuration looks like this.
|
||||||
{
|
{
|
||||||
"signing_ca": {
|
"signing_ca": {
|
||||||
"local": {
|
"local": {
|
||||||
|
"backend": "anchor",
|
||||||
"cert_path": "CA/root-ca.crt",
|
"cert_path": "CA/root-ca.crt",
|
||||||
"key_path": "CA/root-ca-unwrapped.key",
|
"key_path": "CA/root-ca-unwrapped.key",
|
||||||
"output_path": "certs",
|
"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
|
Anchor allows the use of configurable signing backend. While it provides a
|
||||||
the CA certificate and its private key. The location where the local copies of
|
default implementation (based on cryptography.io and OpenSSL), other
|
||||||
issued certificates is held is defiend by ``output_path``. The ``signing_hash``
|
implementations may be configured. The backend is configured by setting the
|
||||||
defines the hash used to sign the results. The validity of issued certificates
|
``backend`` value to the name of the right entry point. Backend implementations
|
||||||
(in hours) is set by ``valid_hours``.
|
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
|
Virtual registration authority
|
||||||
|
|
|
@ -12,6 +12,7 @@ Contents:
|
||||||
|
|
||||||
configuration
|
configuration
|
||||||
api
|
api
|
||||||
|
signing_backends
|
||||||
validators
|
validators
|
||||||
|
|
||||||
Indices and tables
|
Indices and tables
|
||||||
|
|
|
@ -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.12
|
netaddr>=0.7.12
|
||||||
ldap3>=0.9.8.2 # LGPLv3
|
ldap3>=0.9.8.2 # LGPLv3
|
||||||
requests>=2.5.2
|
requests>=2.5.2
|
||||||
|
stevedore>=1.5.0 # Apache-2.0
|
||||||
|
|
|
@ -21,6 +21,10 @@ classifier =
|
||||||
Programming Language :: Python :: 3.4
|
Programming Language :: Python :: 3.4
|
||||||
Topic :: Security
|
Topic :: Security
|
||||||
|
|
||||||
|
[entry_points]
|
||||||
|
anchor.signing_backends =
|
||||||
|
anchor = anchor.certificate_ops:sign
|
||||||
|
|
||||||
[files]
|
[files]
|
||||||
packages =
|
packages =
|
||||||
anchor
|
anchor
|
||||||
|
|
|
@ -42,6 +42,7 @@ class DefaultConfigMixin(object):
|
||||||
}
|
}
|
||||||
self.sample_conf_ca = {
|
self.sample_conf_ca = {
|
||||||
"default_ca": {
|
"default_ca": {
|
||||||
|
"backend": "anchor",
|
||||||
"cert_path": "tests/CA/root-ca.crt",
|
"cert_path": "tests/CA/root-ca.crt",
|
||||||
"key_path": "tests/CA/root-ca-unwrapped.key",
|
"key_path": "tests/CA/root-ca-unwrapped.key",
|
||||||
"output_path": "certs",
|
"output_path": "certs",
|
||||||
|
|
|
@ -21,6 +21,7 @@ import mock
|
||||||
from webob import exc as http_status
|
from webob import exc as http_status
|
||||||
|
|
||||||
from anchor import certificate_ops
|
from anchor import certificate_ops
|
||||||
|
from anchor import jsonloader
|
||||||
from anchor.X509 import name as x509_name
|
from anchor.X509 import name as x509_name
|
||||||
import tests
|
import tests
|
||||||
|
|
||||||
|
@ -141,6 +142,7 @@ class CertificateOpsTests(tests.DefaultConfigMixin, unittest.TestCase):
|
||||||
def test_ca_cert_read_failure(self):
|
def test_ca_cert_read_failure(self):
|
||||||
"""Test CA certificate read failure."""
|
"""Test CA certificate read failure."""
|
||||||
csr_obj = certificate_ops.parse_csr(self.csr, 'pem')
|
csr_obj = certificate_ops.parse_csr(self.csr, 'pem')
|
||||||
|
jsonloader.conf.load_extensions()
|
||||||
config = "anchor.jsonloader.conf._config"
|
config = "anchor.jsonloader.conf._config"
|
||||||
ca_conf = self.sample_conf_ca['default_ca']
|
ca_conf = self.sample_conf_ca['default_ca']
|
||||||
ca_conf['cert_path'] = '/xxx/not/a/valid/path'
|
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 mock.patch.dict(config, data):
|
||||||
with self.assertRaises(http_status.HTTPException) as cm:
|
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)
|
self.assertEqual(cm.exception.code, 500)
|
||||||
|
|
||||||
def test_ca_key_read_failure(self):
|
def test_ca_key_read_failure(self):
|
||||||
"""Test CA key read failure."""
|
"""Test CA key read failure."""
|
||||||
csr_obj = certificate_ops.parse_csr(self.csr, 'pem')
|
csr_obj = certificate_ops.parse_csr(self.csr, 'pem')
|
||||||
|
jsonloader.conf.load_extensions()
|
||||||
config = "anchor.jsonloader.conf._config"
|
config = "anchor.jsonloader.conf._config"
|
||||||
self.sample_conf_ca['default_ca']['cert_path'] = 'tests/CA/root-ca.crt'
|
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'
|
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 mock.patch.dict(config, data):
|
||||||
with self.assertRaises(http_status.HTTPException) as cm:
|
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)
|
self.assertEqual(cm.exception.code, 500)
|
||||||
|
|
|
@ -76,6 +76,7 @@ class TestFunctional(tests.DefaultConfigMixin, unittest.TestCase):
|
||||||
|
|
||||||
# Load config from json test config
|
# Load config from json test config
|
||||||
jsonloader.conf.load_str_data(json.dumps(self.sample_conf))
|
jsonloader.conf.load_str_data(json.dumps(self.sample_conf))
|
||||||
|
jsonloader.conf.load_extensions()
|
||||||
self.conf = jsonloader.conf._config
|
self.conf = jsonloader.conf._config
|
||||||
ca_conf = self.conf["signing_ca"]["default_ca"]
|
ca_conf = self.conf["signing_ca"]["default_ca"]
|
||||||
ca_conf["output_path"] = tempfile.mkdtemp()
|
ca_conf["output_path"] = tempfile.mkdtemp()
|
||||||
|
|
Loading…
Reference in New Issue