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. ...@@ -243,7 +243,7 @@ users until a disaster happens.
Restoration procedure Restoration procedure
--------------------- ---------------------
See `--restore-backup`. See `caucased-manage --restore-backup`.
To restore, one of the trusted users must voluntarily compromise their own To restore, one of the trusted users must voluntarily compromise their own
private key, providing it to the administrator in charge of the restoration private key, providing it to the administrator in charge of the restoration
...@@ -257,14 +257,15 @@ their access only via different credentials. ...@@ -257,14 +257,15 @@ their access only via different credentials.
- key holders manifest themselves - 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 and to generate a new key and accompanying csr
- key holder provide requested items - key holder provide requested items
- admin initiates restoration with `--restore-backup` and provides key holder - admin initiates restoration with `--restore-backup` and provides key holder
with the csr_id so they can fetch their new certificate using caucase with replacement certificate
protocol
- admin starts caucased, service is back online.
Backup file format Backup file format
------------------ ------------------
......
...@@ -18,9 +18,11 @@ ...@@ -18,9 +18,11 @@
""" """
Caucase - Certificate Authority for Users, Certificate Authority for SErvices Caucase - Certificate Authority for Users, Certificate Authority for SErvices
""" """
from __future__ import absolute_import from __future__ import absolute_import, print_function
import argparse import argparse
from collections import defaultdict
import datetime import datetime
from getpass import getpass
import glob import glob
import os import os
import signal import signal
...@@ -34,6 +36,8 @@ from urlparse import urlparse ...@@ -34,6 +36,8 @@ from urlparse import urlparse
from wsgiref.simple_server import make_server, WSGIServer from wsgiref.simple_server import make_server, WSGIServer
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import pem
from . import utils from . import utils
from .wsgi import Application from .wsgi import Application
from .ca import CertificateAuthority, UserCertificateAuthority from .ca import CertificateAuthority, UserCertificateAuthority
...@@ -44,6 +48,15 @@ _cryptography_backend = default_backend() ...@@ -44,6 +48,15 @@ _cryptography_backend = default_backend()
BACKUP_SUFFIX = '.sql.caucased' 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): def _createKey(path):
""" """
Open a key file, setting it to minimum permission if it gets created. Open a key file, setting it to minimum permission if it gets created.
...@@ -385,16 +398,6 @@ def main(argv=None): ...@@ -385,16 +398,6 @@ def main(argv=None):
type=float, type=float,
help='Number of days between backups. default: %(default)s' 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) args = parser.parse_args(argv)
# pylint: disable=unused-argument # pylint: disable=unused-argument
...@@ -415,57 +418,25 @@ def main(argv=None): ...@@ -415,57 +418,25 @@ def main(argv=None):
hostname = parsed_base_url.hostname hostname = parsed_base_url.hostname
http_port = parsed_base_url.port http_port = parsed_base_url.port
cau_crt_life_time = args.user_crt_validity 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( cau = UserCertificateAuthority(
storage=SQLite3Storage( storage=SQLite3Storage(
db_path=args.db, 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( cas = CertificateAuthority(
storage=SQLite3Storage( storage=SQLite3Storage(
...@@ -566,3 +537,217 @@ def main(argv=None): ...@@ -566,3 +537,217 @@ def main(argv=None):
) )
except utils.SleepInterrupt: except utils.SleepInterrupt:
pass 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): ...@@ -136,34 +136,26 @@ class CaucaseTest(unittest.TestCase):
new_key_path, new_key_path,
): ):
""" """
Start caucased in its special --restore-backup mode. It will exit once Start caucased-manage --restore-backup .
done.
Returns its exit status. Returns its exit status.
""" """
server = multiprocessing.Process( try:
target=http.main, http.manage(
kwargs={ argv=(
'argv': (
'--db', self._server_db, '--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', '--restore-backup',
backup_path, backup_path,
key_path, key_path,
new_csr_path, new_csr_path,
new_key_path, new_key_path,
), ),
} )
) except SystemExit, e:
server.daemon = True return e.code
server.start() except:
# Must exit after a (short) while return 1
if not retry(lambda: not server.is_alive(), try_count=400): return 0
raise AssertionError('Backup restoration took too long')
return server.exitcode
def _startServer(self, *argv): def _startServer(self, *argv):
""" """
...@@ -1553,5 +1545,33 @@ class CaucaseTest(unittest.TestCase): ...@@ -1553,5 +1545,33 @@ class CaucaseTest(unittest.TestCase):
0, 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__': if __name__ == '__main__':
unittest.main() unittest.main()
...@@ -58,6 +58,7 @@ setup( ...@@ -58,6 +58,7 @@ setup(
'caucase-rerequest = caucase.cli:rerequest', 'caucase-rerequest = caucase.cli:rerequest',
'caucase-key-id = caucase.cli:key_id', 'caucase-key-id = caucase.cli:key_id',
'caucased = caucase.http:main', 'caucased = caucase.http:main',
'caucased-manage = caucase.http:manage',
] ]
}, },
test_suite='caucase.test', 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