diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100755 index 0000000..6447d67 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,3 @@ +languages: + JavaScript: false + Python: true diff --git a/.travis.yml b/.travis.yml index 8ddbc81..98b8359 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,6 @@ addons: apt: packages: - expect-dev # provides unbuffer utility - - unixodbc-dev language: python @@ -15,7 +14,8 @@ virtualenv: install: - pip install coveralls - pip install codecov --user - - pip install -r test_requirements.txt + - pip install codeclimate-test-reporter + - pip install -r requirements.txt - pip install . # command to run tests @@ -25,3 +25,4 @@ script: after_success: - coveralls - codecov + - codeclimate-test-reporter diff --git a/README.rst b/README.rst index 1e2a7cc..d434b19 100644 --- a/README.rst +++ b/README.rst @@ -1,2 +1,30 @@ -# cfssl-py -Python Library for CloudFlare CFSSL +| |Build Status| | |Coveralls Status| | |Codecov Status| | |Code Climate| + +Python CFSSL Library +==================== + +This library allows you to interact with a remote CFSSL using Python. + +Installation +------------ + +Setup +----- + +Usage +----- + +Known Issues / Road Map +----------------------- + +- Installation, setup, usage - in ReadMe +- Add a Certificate Request data structure + +.. |Build Status| image:: https://api.travis-ci.org/laslabs/Python-CFSSL.svg?branch=master + :target: https://travis-ci.org/laslabs/Python-CFSSL +.. |Coveralls Status| image:: https://coveralls.io/repos/laslabs/Python-CFSSL/badge.svg?branch=master + :target: https://coveralls.io/r/laslabs/Python-CFSSL?branch=master +.. |Codecov Status| image:: https://codecov.io/gh/laslabs/Python-CFSSL/branch/master/graph/badge.svg + :target: https://codecov.io/gh/laslabs/Python-CFSSL +.. |Code Climate| image:: https://codeclimate.com/github/laslabs/Python-CFSSL/badges/gpa.svg + :target: https://codeclimate.com/github/laslabs/Python-CFSSL diff --git a/cfssl/__init__.py b/cfssl/__init__.py new file mode 100644 index 0000000..1edfa7d --- /dev/null +++ b/cfssl/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 LasLabs Inc. +# License MIT (https://opensource.org/licenses/MIT). + +from .cfssl import CFSSL diff --git a/cfssl/cfssl.py b/cfssl/cfssl.py new file mode 100644 index 0000000..9cbd4bd --- /dev/null +++ b/cfssl/cfssl.py @@ -0,0 +1,381 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 LasLabs Inc. +# License MIT (https://opensource.org/licenses/MIT). + +import requests + +from .exceptions import CFSSLException, CFSSLRemoteException + + +class CFSSL(object): + """ It provides Python bindings to a remote CFSSL server via HTTP(S). + + Additional documentation regarding the API endpoints is available at + https://github.com/cloudflare/cfssl/tree/master/doc/api + """ + + def __init__(self, host, port, ssl=True): + ssl = 'https' if ssl else 'http' + self.uri_base = '%s://%s:%d' % (ssl, host, port) + + def auth_sign(self, token, request, datetime=None, remote_address=None): + """ It provides returns a signed certificate. + + Args: + token: (str) The authentication token. + request: (mixed) Signing request document (e.g. as + documented in endpoint_sign.txt, but not JSON encoded). + datetime: (datetime.datetime) Authentication timestamp. + remote_address: (str) An address used in making the request. + Returns: + (str) A PEM-encoded certificate that has been signed by the + server. + """ + data = self._clean_mapping({ + 'token': token, + 'request': request, + 'datetime': datetime, + 'remote_address': remote_address, + }) + return self.call('authsign', 'POST', data=data) + + def bundle(self, certificate, private_key=None, + flavor='ubiquitous', domain=None, ip=None): + """ It builds and returns certificate bundles. + + Args: + certificate: (str) The PEM-encoded certificate to be bundled. + + If the ``certificate`` parameter is present, the following four + arguments are valid: + private_key: (str) The PEM-encoded private key to be included with + the bundle. This is valid only if the server is not running in + ``keyless`` mode. + flavor: (str) One of ``ubiquitous``, ``force``, or ``optimal``, + with a default value of ``ubiquitous``. A ubiquitous bundle is + one that has a higher probability of being verified everywhere, + even by clients using outdated or unusual trust stores. Force will + cause the endpoint to use the bundle provided in the + ``certificate`` parameter, and will only verify that the bundle + is a valid (verifiable) chain. + domain: (str) The domain name to verify as the hostname of the + certificate. + ip: (str) The IP address to verify against the certificate IP + SANs. + + If only the ``domain`` parameter is present, the following + parameter is valid: + + ip: (str) The IP address of the remote host; this will fetch the + certificate from the IP, and verify that it is valid for the + domain name. + + Returns: + (dict) Object repesenting the bundle, with the following keys: + * bundle contains the concatenated list of PEM certificates + forming the certificate chain; this forms the actual + bundle. The remaining parameters are additional metadata + supporting the bundle. + * crl_support is true if CRL information is contained in the + certificate. + * crt contains the original certificate the bundle is built + from. + * expires contains the expiration date of the certificate. + * hostnames contains the SAN hostnames for the certificate. + * issuer contains the X.509 issuer information for the + certificate. + * key contains the private key for the certificate, if one + was presented. + * key_size contains the size of the key in bits for the + certificate. It will be present even if the private key wasn't + provided because this can be determined from the public key. + * key_type contains a textual description of the key type, + e.g. '2048-bit RSA'. + * ocsp contains the OCSP URLs for the certificate, if present. + * ocsp_support will be true if the certificate supports OCSP + revocation checking. + * signature contains the signature type used in the + certificate, e.g. 'SHA1WithRSA'. + * status contains a number of elements: + * code is bit-encoded error code. 1st bit indicates whether + there is a expiring certificate in the bundle. 2nd bit indicates + whether there is a ubiquity issue with the bundle. + * expiring_SKIs contains the SKIs (subject key identifiers) + for any certificates that might expire soon (within 30 + days). + * messages is a list of human-readable warnings on bundle + ubiquity and certificate expiration. For example, an expiration + warning can be "The expiring cert is #1 in the chain", + indicating the leaf certificate is expiring. Ubiquity warnings + include SHA-1 deprecation warning (if the bundle triggers + any major browser's SHA-1 deprecation policy), SHA-2 + compatibility warning (if the bundle contains signatures using + ECDSA SHA-2 hash algorithms, it will be rejected by Windows XP + SP2), compatibility warning (if the bundle contains ECDSA + certificates, it will be rejected by Windows XP, Android 2.2 and + Android 2.3 etc) and root trust warning (if the bundle cannot be + trusted by some major OSes or browsers). + * rebundled indicates whether the server had to rebundle the + certificate. The server will rebundle the uploaded + certificate as needed; for example, if the certificate + contains none of the required intermediates or a better set + of intermediates was found. In this case, the server will + mark rebundled as true. + * untrusted_root_stores contains the names of any major + OSes and browsers that doesn't trust the bundle. The names + are used to construct the root trust warnings in the messages + list + * subject contains the X.509 subject identifier from the + certificate. + """ + data = self._clean_mapping({ + 'certificate': certificate, + 'domain': domain, + 'private_key': private_key, + 'flavor': flavor, + 'ip': ip, + }) + return self.call('bundle', 'POST', data=data) + + def info(self, label, profile=None): + """ It returns information about the CA, including the cert. + + Args: + label: (str) A string specifying the signer. + profile: (str) a string specifying the signing profile for the + signer. Signing profile specifies what key usages should be + used and how long the expiry should be set. + Returns: + (dict) Mapping with three keys: + * certificate: (str) a PEM-encoded certificate of the signer. + * usage: (list) a string array of key usages from the signing + profile. + * expiry: (str) the expiry string from the signing profile. + """ + data = self._clean_mapping({ + 'label': label, + 'profile': profile, + }) + return self.call('info', 'POST', data=data) + + def init_ca(self, hosts, names, common_name=None, key=None, ca=None): + """ It initializes a new certificate authority. + + Args: + hosts: (list) Of SANs (subject alternative names) for the + requested CA certificate. + names: (list) the certificate subject for the requested CA + certificate. + common_name: (str) the common name for the certificate subject in + the requested CA certificate. + key: the key algorithm and size for the newly generated private key, + default to ECDSA-256. + ca: the CA configuration of the requested CA, including CA pathlen + and CA default expiry. + Returns: + (dict) Mapping with two keys: + * private key: (str) a PEM-encoded CA private key. + * certificate: (str) a PEM-encoded self-signed CA certificate. + """ + data = self._clean_mapping({ + 'hosts': hosts, + 'names': names, + 'CN': common_name, + 'key': key, + 'ca': ca, + }) + return self.call('init_ca', 'POST', data=data) + + def new_key(self, hosts, names, common_name=None, key=None, ca=None): + """ It generates and returns a new private key + CSR. + + Args: + hosts: (list) Of SANs (subject alternative names) for the + requested CA certificate. + names: (list) the certificate subject for the requested CA + certificate. + CN: (str) the common name for the certificate subject in the + requestedrequested CA certificate. + key: the key algorithm and size for the newly generated private key, + default to ECDSA-256. + ca: the CA configuration of the requested CA, including CA pathlen + and CA default expiry. + Returns: + (dict) Mapping with three keys: + * private key: (str) a PEM-encoded CA private key. + * certificate: (str) a PEM-encoded self-signed CA certificate. + * sums: (dict) Mapping holding both MD5 and SHA1 digests for the + certificate request + """ + data = self._clean_mapping({ + 'hosts': hosts, + 'names': names, + 'CN': common_name, + 'key': key, + 'ca': ca, + }) + return self.call('newkey', 'POST', data=data) + + def new_cert(self, request, label=None, profile=None, bundle=None): + """ It generates and returns a new private key and certificate. + + Args: + request: (dict) Specifying the certificate request. + label: (str) Specifying which signer to be appointed to sign + the CSR, useful when interacting with cfssl server that stands + in front of a remote multi-root CA signer. + profile: (str) Specifying the signing profile for the signer. + bundle: (bool) Specifying whether to include an "optimal" + certificate bundle along with the certificate. + Returns: + (dict) mapping with these keys: + * private key: a PEM-encoded private key. + * certificate_request: a PEM-encoded certificate request. + * certificate: a PEM-encoded certificate, signed by the server. + * sums: a JSON object holding both MD5 and SHA1 digests for the + certificate request and the certificate. + * bundle: See the result of endpoint_bundle.txt (only included + if the bundle parameter was set). + """ + data = self._clean_mapping({ + 'request': request, + 'label': label, + 'profile': profile, + 'bundle': bundle, + }) + return self.call('newcert', 'POST', data=data) + + def revoke(self, serial, authority_key_id, reason): + """ It provides certificate revocation. + + Args: + serial: (str) Specifying the serial number of a certificate. + authority_key_id: (str) Specifying the authority key identifier + of the certificate to be revoked; this is used to distinguish + which private key was used to sign the certificate. + reason: (str) Identifying why the certificate was revoked; see, + for example, ReasonStringToCode in the ocsp package or section + 4.2.1.13 of RFC 5280. The "reasons" used here are the ReasonFlag + names in said RFC. + """ + data = self._clean_mapping({ + 'serial': serial, + 'authority_key_id': authority_key_id, + 'reason': reason, + }) + return self.call('revoke', 'POST', data=data) + + def scan(self, host, ip=None, timeout=None, family=None, scanner=None): + """ It scans servers to determine the quality of their TLS setup. + + Args: + host: the hostname (optionally including port) to scan. + ip: IP Address to override DNS lookup of host. + timeout: The amount of time allotted for the scan to complete + (default: 1 minute). + family: regular expression specifying scan famil(ies) to run. + scanner: regular expression specifying scanner(s) to run. + Returns: + (dict) Mapping with keys for each scan family. Each of these + objects contains keys for each scanner run in that family + pointing to objects possibly containing the following keys: + * grade: (str) Describing the exit status of the scan. Can be: + * "Good": host performing the expected state-of-the-art. + * "Warning": host with non-ideal configuration, + possibly maintaining support for legacy clients. + * "Bad": host with serious misconfiguration or vulnerability + * "Skipped": indicates that the scan was not performed for some + reason. + * error: (str) Any error encountered during the scan process. + * output: (dict) Arbitrary data retrieved during the scan. + """ + data = self._clean_mapping({ + 'host': host, + 'ip': ip, + 'timeout': timeout, + 'family': family, + 'scanner': scanner, + }) + return self.call('scan', params=data) + + def scan_info(self): + """ It lists options available for scanning. + + Returns: + (dict) Mapping with keys for each scan family. For each family, + there exists a `description` containing a string describing + the family and a `scanners` object mapping each of the family's + scanners to an object containing a `description` string. + """ + return self.call('scaninfo') + + def sign(self, certificate_request, hosts=None, subject=None, + serial_sequence=None, label=None, profile=None): + """ It signs and returns a certificate. + + Args: + certificate_request: (str) the CSR bytes to be signed in PEM. + hosts: (iter) of SAN (subject alternative .names) + which overrides the ones in the CSR + subject: (str) The certificate subject which overrides + the ones in the CSR. + serial_sequence: (str) Specify the prefix which the generated + certificate serial should have. + label: (str) Specifying which signer to be appointed to sign + the CSR, useful when interacting with a remote multi-root CA + signer. + profile: (str) Specifying the signing profile for the signer, + useful when interacting with a remote multi-root CA signer. + Returns: + (str) A PEM-encoded certificate that has been signed by the + server. + """ + data = self._clean_mapping({ + 'certificate_request': certificate_request, + 'hosts': hosts, + 'subject': subject, + 'serial_sequence': serial_sequence, + 'label': label, + 'profile': profile, + }) + result = self.call('sign', 'POST', data=data) + return result['certificate'] + + def call(self, endpoint, method='GET', params=None, data=None): + """ It calls the remote endpoint and returns the result, if success. + + Args: + endpoint: (str) CFSSL endpoint to call (e.g. ``newcert``). + method: (str) HTTP method to utilize for the Request. + params: (dict|bytes) Data to be sent in the query string + for the Request. + data: (dict|bytes|file) Data to send in the body of the + Request. + Returns: + (mixed) Data contained in ``result`` key of the API response. + Raises: + CFSSLRemoteException: In the event of a ``False`` in the + ``success`` key of the API response. + """ + endpoint = '%s/api/v1/cfssl/%s' % (self.uri_base, endpoint) + response = requests.request( + method=method, + url=endpoint, + params=params, + data=data, + ) + response = response.json() + if not response['success']: + raise CFSSLRemoteException( + '\n'.join([ + 'Errors:', + '\n'.join(response.get('errors', [])), + 'Messages:' + '\n'.join(response.get('messages', [])), + ]) + ) + return response['result'] + + def _clean_mapping(self, mapping): + """ It removes false entries from mapping """ + return {k:v for k, v in mapping.iteritems() if v} diff --git a/cfssl/exceptions.py b/cfssl/exceptions.py new file mode 100644 index 0000000..c1f77cf --- /dev/null +++ b/cfssl/exceptions.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 LasLabs Inc. +# License MIT (https://opensource.org/licenses/MIT). + +import requests + + +class CFSSLException(EnvironmentError): + """ This exception is raised from errors in the CFSSL Library. """ + + +class CFSSLRemoteException(CFSSLException): + """ This exception is raised to indicate issues returned from API. """ diff --git a/cfssl/tests/__init__.py b/cfssl/tests/__init__.py new file mode 100644 index 0000000..da36d8a --- /dev/null +++ b/cfssl/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 LasLabs Inc. +# License MIT (https://opensource.org/licenses/MIT). diff --git a/cfssl/tests/test_cfssl.py b/cfssl/tests/test_cfssl.py new file mode 100644 index 0000000..9d3606b --- /dev/null +++ b/cfssl/tests/test_cfssl.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 LasLabs Inc. +# License MIT (https://opensource.org/licenses/MIT). + +import mock +import unittest + +from ..cfssl import CFSSL, CFSSLRemoteException, requests + + +class TestCFSSL(unittest.TestCase): + + def setUp(self): + super(TestCFSSL, self).setUp() + self.cfssl = CFSSL('test', 1) + + def test_uri_base_https(self): + """ It should have an HTTP URI by default """ + self.assertIn('https://', self.cfssl.uri_base) + + def test_uri_base_http(self): + """ It should have an HTTP URI if someone decides to be crazy """ + cfssl = CFSSL('test', 1, False) + self.assertIn('http://', cfssl.uri_base) + + @mock.patch.object(CFSSL, 'call') + def test_auth_sign(self, call): + """ It should call with proper args """ + expect = { + 'token': 'token', + 'request': 'request', + } + self.cfssl.auth_sign(**expect) + call.assert_called_once_with( + 'authsign', 'POST', data=expect + ) + + @mock.patch.object(CFSSL, 'call') + def test_bundle(self, call): + """ It should call with proper args """ + expect = { + 'certificate': 'certificate', + 'flavor': 'flavor', + } + self.cfssl.bundle(**expect) + call.assert_called_once_with( + 'bundle', 'POST', data=expect + ) + + @mock.patch.object(CFSSL, 'call') + def test_info(self, call): + """ It should call with proper args """ + expect = { + 'label': 'label', + } + self.cfssl.info(**expect) + call.assert_called_once_with( + 'info', 'POST', data=expect + ) + + @mock.patch.object(CFSSL, 'call') + def test_init_ca(self, call): + """ It should call with proper args """ + expect = { + 'hosts': 'hosts', + 'names': 'names', + 'common_name': 'cn' + } + self.cfssl.init_ca(**expect) + expect['CN'] = 'cn' + del expect['common_name'] + call.assert_called_once_with( + 'init_ca', 'POST', data=expect + ) + + @mock.patch.object(CFSSL, 'call') + def test_new_key(self, call): + """ It should call with proper args """ + expect = { + 'hosts': 'hosts', + 'names': 'names', + 'common_name': 'cn' + } + self.cfssl.new_key(**expect) + expect['CN'] = 'cn' + del expect['common_name'] + call.assert_called_once_with( + 'newkey', 'POST', data=expect + ) + + @mock.patch.object(CFSSL, 'call') + def test_new_cert(self, call): + """ It should call with proper args """ + expect = { + 'request': 'request', + 'label': 'label', + } + self.cfssl.new_cert(**expect) + call.assert_called_once_with( + 'newcert', 'POST', data=expect + ) + + @mock.patch.object(CFSSL, 'call') + def test_revoke(self, call): + """ It should call with proper args """ + expect = { + 'serial': 'Ben-S', + 'authority_key_id': 'REVOKE!', + 'reason': 'The derphead lost it', + } + self.cfssl.revoke(**expect) + call.assert_called_once_with( + 'revoke', 'POST', data=expect + ) + + @mock.patch.object(CFSSL, 'call') + def test_scan(self, call): + """ It should call with proper args """ + expect = { + 'host': 'host', + } + self.cfssl.scan(**expect) + call.assert_called_once_with( + 'scan', params=expect + ) + + @mock.patch.object(CFSSL, 'call') + def test_scan_info(self, call): + """ It should call with proper args """ + self.cfssl.scan_info() + call.assert_called_once_with('scaninfo') + + @mock.patch.object(CFSSL, 'call') + def test_sign(self, call): + """ It should call with proper args """ + expect = { + 'certificate_request': 'certificate_request', + } + self.cfssl.sign(**expect) + call.assert_called_once_with( + 'sign', 'POST', data=expect + ) + + @mock.patch.object(requests, 'request') + def test_call_request(self, requests): + """ It should call requests with proper args """ + self.cfssl.call('endpoint', 'method', 'params', 'data') + requests.assert_called_once_with( + method='method', + url='https://test:1/api/v1/cfssl/endpoint', + params='params', + data='data', + ) + + @mock.patch.object(requests, 'request') + def test_call_error(self, requests): + """ It should raise on non-success response """ + requests().json.return_value = {'success': False} + with self.assertRaises(CFSSLRemoteException): + self.cfssl.call('None') + + @mock.patch.object(requests, 'request') + def test_call_success(self, requests): + """ It should reteurn result on success response """ + requests().json.return_value = {'success': True, + 'result': 'result'} + res = self.cfssl.call(None) + self.assertEqual(res, 'result') + +if __name__ == '__main__': + unittest.main() diff --git a/setup.py b/setup.py index 89d9ce8..aee20c0 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ 'author_email': 'support@laslabs.com', 'description': 'This library will allow you to interact with CFSSL ' 'using Python.', - 'url': 'https://github.com/laslabs/cfssl-py', + 'url': 'https://github.com/laslabs/Python-CFSSL', 'license': 'MIT', 'classifiers': [ 'Development Status :: 4 - Beta', diff --git a/tests.py b/tests.py index 632499a..de321eb 100644 --- a/tests.py +++ b/tests.py @@ -3,12 +3,7 @@ # License MIT (https://opensource.org/licenses/MIT). from setuptools import Command - -try: - from xmlrunner import XMLTestRunner - from unittest import TestLoader -except ImportError: - pass +from unittest import TestLoader, TextTestRunner class FailTestException(Exception): @@ -22,8 +17,6 @@ class Tests(Command): MODULE_NAMES = [ 'cfssl', ] - TEST_RESULTS = '_results' - COVERAGE_RESULTS = 'coverage.xml' user_options = [] # < For Command API compatibility def initialize_options(self, ): @@ -35,7 +28,7 @@ def finalize_options(self, ): def run(self, ): loader = TestLoader() tests = loader.discover('.', 'test_*.py') - t = XMLTestRunner(verbosity=1, output=self.TEST_RESULTS) + t = TextTestRunner(verbosity=1) res = t.run(tests) if not res.wasSuccessful(): raise FailTestException()