Commit 306f0709 authored by Vincent Pelletier's avatar Vincent Pelletier

http: New caucased-manage command.

For offline database administration: restoring backups, importing and
exporting CA key pairs.
parent 8d759f9e
......@@ -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
------------------
......
......@@ -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()
......@@ -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()
......@@ -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',
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment