From 306f0709813e5d5a90717ceab0f3348424564b78 Mon Sep 17 00:00:00 2001 From: Vincent Pelletier Date: Fri, 3 Nov 2017 16:12:32 +0900 Subject: [PATCH] http: New caucased-manage command. For offline database administration: restoring backups, importing and exporting CA key pairs. --- README.rst | 9 +++++---- caucase/http.py | 301 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------- caucase/test.py | 58 +++++++++++++++++++++++++++++++++++++++------------------- setup.py | 1 + 4 files changed, 288 insertions(+), 81 deletions(-) diff --git a/README.rst b/README.rst index ab1e3dc..0a95a57 100644 --- a/README.rst +++ b/README.rst @@ -243,7 +243,7 @@ users until a disaster happens. Restoration procedure --------------------- -See `--restore-backup`. +See `caucased-manage --restore-backup`. To restore, one of the trusted users must voluntarily compromise their own private key, providing it to the administrator in charge of the restoration @@ -257,14 +257,15 @@ their access only via different credentials. - key holders manifest themselves -- admin picks a key holder, requests them to provide their eixsting private key +- admin picks a key holder, requests them to provide their existing private key and to generate a new key and accompanying csr - key holder provide requested items - admin initiates restoration with `--restore-backup` and provides key holder - with the csr_id so they can fetch their new certificate using caucase - protocol + with replacement certificate + +- admin starts caucased, service is back online. Backup file format ------------------ diff --git a/caucase/http.py b/caucase/http.py index 85a07d4..9894d04 100644 --- a/caucase/http.py +++ b/caucase/http.py @@ -18,9 +18,11 @@ """ Caucase - Certificate Authority for Users, Certificate Authority for SErvices """ -from __future__ import absolute_import +from __future__ import absolute_import, print_function import argparse +from collections import defaultdict import datetime +from getpass import getpass import glob import os import signal @@ -34,6 +36,8 @@ from urlparse import urlparse from wsgiref.simple_server import make_server, WSGIServer from cryptography import x509 from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +import pem from . import utils from .wsgi import Application from .ca import CertificateAuthority, UserCertificateAuthority @@ -44,6 +48,15 @@ _cryptography_backend = default_backend() BACKUP_SUFFIX = '.sql.caucased' +def getBytePass(prompt): + """ + Like getpass, but resurns a bytes instance. + """ + result = getpass(prompt) + if not isinstance(result, bytes): + result = result.encode(sys.stdin.encoding) + return result + def _createKey(path): """ Open a key file, setting it to minimum permission if it gets created. @@ -385,16 +398,6 @@ def main(argv=None): type=float, help='Number of days between backups. default: %(default)s' ) - backup_group.add_argument( - '--restore-backup', - nargs=4, - metavar=('BACKUP_PATH', 'KEY_PATH', 'CSR_PATH', 'CRT_PATH'), - help='Restore the file at BACKUP_PATH, decyphering it with the key ' - 'at KEY_PATH, revoking corresponding certificate and issuing a new ' - 'one in CRT_PATH using the public key in CSR_PATH. ' - 'If database is not empty, nothing is done. ' - 'Then process will exit and must be restarted wihtout this option.', - ) args = parser.parse_args(argv) # pylint: disable=unused-argument @@ -415,57 +418,25 @@ def main(argv=None): hostname = parsed_base_url.hostname http_port = parsed_base_url.port cau_crt_life_time = args.user_crt_validity - cau_db_kw = { - 'table_prefix': 'cau', - 'max_csr_amount': args.user_max_csr, - # Effectively disables certificate expiration - 'crt_keep_time': cau_crt_life_time, - 'crt_read_keep_time': cau_crt_life_time, - 'enforce_unique_key_id': True, - } - cau_kw = { - 'ca_subject_dict': { - 'CN': u'Caucase CAU' + ( - u'' if base_url is None else u' at ' + base_url + '/cau' - ), - }, - 'ca_key_size': args.key_len, - 'crt_life_time': cau_crt_life_time, - 'auto_sign_csr_amount': args.user_auto_approve_count, - 'lock_auto_sign_csr_amount': args.lock_auto_approve_count, - } - if args.restore_backup: - ( - backup_path, - backup_key_path, - backup_csr_path, - backup_crt_path, - ) = args.restore_backup - try: - _, key_pem, _ = utils.getKeyPair(backup_key_path) - except ValueError: - # maybe user extracted their private key ? - key_pem = utils.getKey(backup_key_path) - with open(backup_path) as backup_file: - with open(backup_crt_path, 'a') as new_crt_file: - new_crt_file.write( - UserCertificateAuthority.restoreBackup( - db_class=SQLite3Storage, - db_path=args.db, - read=backup_file.read, - key_pem=key_pem, - csr_pem=utils.getCertRequest(backup_csr_path), - db_kw=cau_db_kw, - kw=cau_kw, - ), - ) - return cau = UserCertificateAuthority( storage=SQLite3Storage( db_path=args.db, - **cau_db_kw + table_prefix='cau', + max_csr_amount=args.user_max_csr, + # Effectively disables certificate expiration + crt_keep_time=cau_crt_life_time, + crt_read_keep_time=cau_crt_life_time, + enforce_unique_key_id=True, ), - **cau_kw + ca_subject_dict={ + 'CN': u'Caucase CAU' + ( + u'' if base_url is None else u' at ' + base_url + '/cau' + ), + }, + ca_key_size=args.key_len, + crt_life_time=cau_crt_life_time, + auto_sign_csr_amount=args.user_auto_approve_count, + lock_auto_sign_csr_amount=args.lock_auto_approve_count, ) cas = CertificateAuthority( storage=SQLite3Storage( @@ -566,3 +537,217 @@ def main(argv=None): ) except utils.SleepInterrupt: pass + +def manage(argv=None): + """ + caucased database management tool. + """ + parser = argparse.ArgumentParser( + description='caucased caucased database management tool', + ) + parser.add_argument( + '--db', + default='caucase.sqlite', + help='Path to the SQLite database. default: %(default)s', + ) + parser.add_argument( + '--user-crt-validity', + default=3 * 31, + type=float, + metavar='DAYS', + help='Number of days an issued certificate is valid for. Useful with ' + '--restore-backup as a new user certificate must be produced. ' + 'default: %(default)s', + ) + + parser.add_argument( + '--restore-backup', + nargs=4, + metavar=('BACKUP_PATH', 'KEY_PATH', 'CSR_PATH', 'CRT_PATH'), + help='Restore the file at BACKUP_PATH, decyphering it with the key ' + 'at KEY_PATH, revoking corresponding certificate and issuing a new ' + 'one in CRT_PATH using the public key in CSR_PATH. ' + 'Fails if database exists.', + ) + parser.add_argument( + '--import-ca', + default=[], + metavar='PEM_FILE', + action='append', + type=argparse.FileType('r'), + help='Import key pairs as initial service CA certificate. ' + 'May be provided multiple times to import multiple key pairs. ' + 'Keys and certificates may be in separate files. ' + 'If there are multiple keys or certificates, all will be imported. ' + 'Will fail if there is any certificate without a key, or vice-versa, ' + 'or if any certificate is not suitable for use as a CA certificate. ' + 'Caucase-initiated CA renewal, which will happen when latest provided ' + 'has less than 3 times --service-crt-validity validity period left, ' + 'will copy that CA\'s extensions to produce the new certificate. ' + 'Passphrase will be prompted for each protected key.', + ) + parser.add_argument( + '--import-bad-ca', + action='store_true', + default=False, + help='Do not check sanity of imported CA certificates. Useful when ' + 'migrating a custom CA where clients do very customised checks. Do not ' + 'use this unless you are certain you need it and it is safe for your ' + 'use-case.', + ) + parser.add_argument( + '--export-ca', + metavar='PEM_FILE', + type=argparse.FileType('w'), + help='Export all CA certificates in a PEM file. Passphrase will be ' + 'prompted to protect all keys.', + ) + args = parser.parse_args(argv) + db_path = args.db + if args.restore_backup: + ( + backup_path, + backup_key_path, + backup_csr_path, + backup_crt_path, + ) = args.restore_backup + try: + _, key_pem, _ = utils.getKeyPair(backup_key_path) + except ValueError: + # maybe user extracted their private key ? + key_pem = utils.getKey(backup_key_path) + cau_crt_life_time = args.user_crt_validity + with open(backup_path) as backup_file: + with open(backup_crt_path, 'a') as new_crt_file: + new_crt_file.write( + UserCertificateAuthority.restoreBackup( + db_class=SQLite3Storage, + db_path=db_path, + read=backup_file.read, + key_pem=key_pem, + csr_pem=utils.getCertRequest(backup_csr_path), + db_kw={ + 'table_prefix': 'cau', + # max_csr_amount: not needed, renewal ignores quota + # Effectively disables certificate expiration + 'crt_keep_time': cau_crt_life_time, + 'crt_read_keep_time': cau_crt_life_time, + 'enforce_unique_key_id': True, + }, + kw={ + # Disable CA cert renewal + 'ca_key_size': None, + 'crt_life_time': cau_crt_life_time, + }, + ), + ) + if args.import_ca: + import_ca_dict = defaultdict( + (lambda: {'crt': None, 'key': None, 'from': []}), + ) + for ca_file in args.import_ca: + for index, component in enumerate(pem.parse(ca_file.read())): + name = '%r, block %i' % (ca_file.name, index) + if isinstance(component, pem.Certificate): + component_name = 'crt' + component_value = x509.load_pem_x509_certificate( + component.as_bytes(), + _cryptography_backend, + ) + elif isinstance(component, pem.Key): + password = None + while True: + component_name = 'key' + try: + component_value = serialization.load_pem_private_key( + component.as_bytes(), + password, + _cryptography_backend, + ) + except TypeError: + password = getBytePass('Passphrase for key at %s: ' % (name, )) + else: + break + else: + raise TypeError('%s is of unsupported type %r' % ( + name, + type(component), + )) + import_ca = import_ca_dict[ + x509.SubjectKeyIdentifier.from_public_key( + component_value.public_key(), + ).digest + ] + import_ca[component_name] = component_value + import_ca['from'].append(name) + now = utils.datetime2timestamp(datetime.datetime.utcnow()) + imported = 0 + cas_db = SQLite3Storage( + db_path, + table_prefix='cas', + ) + for identifier, ca_pair in import_ca_dict.iteritems(): + found_from = ', '.join(ca_pair['from']) + crt = ca_pair['crt'] + if crt is None: + print('No certificate correspond to ' + found_from + ', skipping') + continue + expiration = utils.datetime2timestamp(crt.not_valid_after) + if expiration < now: + print('Skipping expired certificate from ' + found_from) + del import_ca_dict[identifier] + continue + if not args.import_bad_ca: + try: + basic_contraints = crt.extensions.get_extension_for_class( + x509.BasicConstraints, + ) + key_usage = crt.extensions.get_extension_for_class( + x509.KeyUsage, + ).value + except x509.ExtensionNotFound: + failed = True + else: + failed = ( + not basic_contraints.value.ca or not basic_contraints.critical + or not key_usage.key_cert_sign or not key_usage.crl_sign + ) + if failed: + print('Skipping non-CA certificate from ' + found_from) + continue + key = ca_pair['key'] + if key is None: + print('No private key correspond to ' + found_form + ', skipping') + continue + imported += 1 + cas_db.appendCAKeyPair( + expiration, + { + 'key_pem': utils.dump_privatekey(key), + 'crt_pem': utils.dump_certificate(crt), + }, + ) + if not imported: + raise ValueError('No CA certificate imported') + print('Imported %i CA certificates' % imported) + if args.export_ca is not None: + encryption_algorithm = serialization.BestAvailableEncryption( + getBytePass('CA export passphrase: ') + ) + write = args.export_ca.write + for key_pair in SQLite3Storage( + db_path, + table_prefix='cas', + ).getCAKeyPairList(): + write( + key_pair['crt_pem'] + serialization.load_pem_private_key( + key_pair['key_pem'], + None, + _cryptography_backend, + ).private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=encryption_algorithm, + ), + ) + args.export_ca.close() diff --git a/caucase/test.py b/caucase/test.py index b6a58ab..5c76392 100644 --- a/caucase/test.py +++ b/caucase/test.py @@ -136,34 +136,26 @@ class CaucaseTest(unittest.TestCase): new_key_path, ): """ - Start caucased in its special --restore-backup mode. It will exit once - done. + Start caucased-manage --restore-backup . Returns its exit status. """ - server = multiprocessing.Process( - target=http.main, - kwargs={ - 'argv': ( + try: + http.manage( + argv=( '--db', self._server_db, - '--server-key', self._server_key, - '--netloc', self._server_netloc, - #'--threshold', '31', - #'--key-len', '2048', - '--backup-directory', self._server_backup_path, '--restore-backup', backup_path, key_path, new_csr_path, new_key_path, ), - } - ) - server.daemon = True - server.start() - # Must exit after a (short) while - if not retry(lambda: not server.is_alive(), try_count=400): - raise AssertionError('Backup restoration took too long') - return server.exitcode + ) + except SystemExit, e: + return e.code + except: + return 1 + return 0 + def _startServer(self, *argv): """ @@ -1553,5 +1545,33 @@ class CaucaseTest(unittest.TestCase): 0, ) + def testCAImportExport(self): + """ + Exercise CA export and import code. + """ + exported_ca = os.path.join(self._server_dir, 'exported.ca.pem') + getBytePass_orig = http.getBytePass + http.getBytePass = lambda x: 'test' + try: + self.assertFalse(os.path.exists(exported_ca), exported_ca) + http.manage( + argv=( + '--db', self._server_db, + '--export-ca', exported_ca, + ), + ) + self.assertTrue(os.path.exists(exported_ca), exported_ca) + server_db2 = self._server_db + '2' + self.assertFalse(os.path.exists(server_db2), server_db2) + http.manage( + argv=( + '--db', server_db2, + '--import-ca', exported_ca, + ), + ) + self.assertTrue(os.path.exists(server_db2), server_db2) + finally: + http.getBytePass = getBytePass_orig + if __name__ == '__main__': unittest.main() diff --git a/setup.py b/setup.py index c2f0ed3..c70e105 100644 --- a/setup.py +++ b/setup.py @@ -58,6 +58,7 @@ setup( 'caucase-rerequest = caucase.cli:rerequest', 'caucase-key-id = caucase.cli:key_id', 'caucased = caucase.http:main', + 'caucased-manage = caucase.http:manage', ] }, test_suite='caucase.test', -- libgit2 0.24.0