Commit 2c1f9099 authored by Vincent Pelletier's avatar Vincent Pelletier

WIP all: Refuse to renew too-young certificates.

Makes it harder for a compromised certificate to escape revocation by
renewing itself faster than it can be identified and revoked.

TODO:
- fix tests
- coverage
- maybe just refuse to renew any cert more than once, to prevent
  "lineage forks" without introducing such new deadline ? (probably not
  a good idea, losing one's certificate happens and should not cause
  such punishment)
- only enable for CAU certificates ?
- distinguish issuance tracking between renewal and user issuance ?
- auto-revoke certificates issued by renewal, but not those issued by user
  cert ?
- 10 days is way too long. above an hour it will get in the way, and
  revoking multiple should not take too long... if there was a way to
  recognise serials (cf. previous commit)
parent 5fe1e86b
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
ignore=_version.py ignore=_version.py
[DESIGN] [DESIGN]
max-args=12 max-args=13
max-nested-blocks=6 max-nested-blocks=6
max-module-lines=1500 max-module-lines=1500
......
...@@ -41,6 +41,7 @@ from .exceptions import ( ...@@ -41,6 +41,7 @@ from .exceptions import (
CertificateRevokedError, CertificateRevokedError,
NotACertificateSigningRequest, NotACertificateSigningRequest,
Found, Found,
RetryLater,
) )
__all__ = ('CertificateAuthority', 'UserCertificateAuthority', 'Extension') __all__ = ('CertificateAuthority', 'UserCertificateAuthority', 'Extension')
...@@ -92,6 +93,7 @@ class CertificateAuthority(object): ...@@ -92,6 +93,7 @@ class CertificateAuthority(object):
crt_life_time=31 * 3, # Approximately 3 months crt_life_time=31 * 3, # Approximately 3 months
ca_life_period=4, # Approximately a year ca_life_period=4, # Approximately a year
crl_renew_period=0.33, # Approximately a month crl_renew_period=0.33, # Approximately a month
crt_no_renewal_period=10,
crl_base_url=None, crl_base_url=None,
digest_list=utils.DEFAULT_DIGEST_LIST, digest_list=utils.DEFAULT_DIGEST_LIST,
auto_sign_csr_amount=0, auto_sign_csr_amount=0,
...@@ -126,6 +128,10 @@ class CertificateAuthority(object): ...@@ -126,6 +128,10 @@ class CertificateAuthority(object):
Number of crt_life_time periods for which a revocation list is Number of crt_life_time periods for which a revocation list is
valid for. valid for.
crt_no_renewal_period (float)
Number of days during which a certificate cannot be renewed after its
issuance, to prevent revocation escaping.
crl_base_url (str) crl_base_url (str)
The CRL distribution URL to include in signed certificates. The CRL distribution URL to include in signed certificates.
None to not declare a CRL distribution point in generated certificates. None to not declare a CRL distribution point in generated certificates.
...@@ -190,6 +196,7 @@ class CertificateAuthority(object): ...@@ -190,6 +196,7 @@ class CertificateAuthority(object):
crt_life_time * crl_renew_period * .5, crt_life_time * crl_renew_period * .5,
0, 0,
) )
self._crt_no_renewal_period = datetime.timedelta(crt_no_renewal_period, 0)
self._ca_life_time = datetime.timedelta(crt_life_time * ca_life_period, 0) self._ca_life_time = datetime.timedelta(crt_life_time * ca_life_period, 0)
self._loadCAKeyPairList() self._loadCAKeyPairList()
self._renewCAIfNeeded() self._renewCAIfNeeded()
...@@ -792,6 +799,11 @@ class CertificateAuthority(object): ...@@ -792,6 +799,11 @@ class CertificateAuthority(object):
_cryptography_backend, _cryptography_backend,
), ),
) )
can_renew_after = crt.not_valid_before + self._crt_no_renewal_period
if can_renew_after > datetime.datetime.utcnow():
# Prevent revocation evasion by renewing certificates faster than they
# can be revoked.
raise RetryLater(can_renew_after)
return self._createCertificate( return self._createCertificate(
csr_id=self.appendCertificateSigningRequest( csr_id=self.appendCertificateSigningRequest(
csr_pem, csr_pem,
...@@ -1057,7 +1069,14 @@ class UserCertificateAuthority(CertificateAuthority): ...@@ -1057,7 +1069,14 @@ class UserCertificateAuthority(CertificateAuthority):
# Now that the database is restored, use a CertificateAuthority to # Now that the database is restored, use a CertificateAuthority to
# renew & revoke given private key. # renew & revoke given private key.
self = cls(storage=db_class(db_path=db_path, **dict(db_kw)), **dict(kw)) self = cls(
crt_no_renewal_period=0,
storage=db_class(
db_path=db_path,
**dict(db_kw)
),
**dict(kw)
)
# pylint: disable=protected-access # pylint: disable=protected-access
crt_pem = self._storage.getCertificateByKeyIdentifier(key_id) crt_pem = self._storage.getCertificateByKeyIdentifier(key_id)
# pylint: enable=protected-access # pylint: enable=protected-access
......
...@@ -273,11 +273,19 @@ class CLICaucaseClient(object): ...@@ -273,11 +273,19 @@ class CLICaucaseClient(object):
if renewal_deadline < old_crt.not_valid_after: if renewal_deadline < old_crt.not_valid_after:
self._print(crt_path, 'did not reach renew threshold, not renewing') self._print(crt_path, 'did not reach renew threshold, not renewing')
continue continue
new_key_pem, new_crt_pem = self._client.renewCertificate( try:
old_crt=old_crt, new_key_pem, new_crt_pem = self._client.renewCertificate(
old_key=utils.load_privatekey(old_key_pem), old_crt=old_crt,
key_len=key_len, old_key=utils.load_privatekey(old_key_pem),
) key_len=key_len,
)
except exceptions.RetryLater as e:
print(
crt_path,
b'renewal was rejected by server, retry after',
e.when,
)
continue
if key_path is None: if key_path is None:
with open(crt_path, 'wb') as crt_file: with open(crt_path, 'wb') as crt_file:
crt_file.write(new_key_pem) crt_file.write(new_key_pem)
...@@ -936,31 +944,52 @@ def updater(argv=None, until=utils.until): ...@@ -936,31 +944,52 @@ def updater(argv=None, until=utils.until):
crt = utils.load_certificate(crt_pem, ca_crt_list, None) crt = utils.load_certificate(crt_pem, ca_crt_list, None)
if crt.not_valid_after - threshold <= now: if crt.not_valid_after - threshold <= now:
print('Renewing', args.crt) print('Renewing', args.crt)
new_key_pem, new_crt_pem = client.renewCertificate( try:
old_crt=crt, new_key_pem, new_crt_pem = client.renewCertificate(
old_key=utils.load_privatekey(key_pem), old_crt=crt,
key_len=args.key_len, old_key=utils.load_privatekey(key_pem),
) key_len=args.key_len,
if key_path is None: )
with open(args.crt, 'wb') as crt_file: except exceptions.RetryLater as e:
crt_file.write(new_key_pem) # XXX: remember retry-after value for this certificate ?
crt_file.write(new_crt_pem) retry_after = e.when
if isinstance(retry_after, datetime.timedelta):
retry_after += now
print('Renewal rejected')
next_deadline = min(
next_deadline,
retry_after,
)
else: else:
with open( if key_path is None:
args.crt, with open(args.crt, 'wb') as crt_file:
'wb', crt_file.write(new_key_pem)
) as crt_file, open( crt_file.write(new_crt_pem)
key_path, else:
'wb', with open(
) as key_file: args.crt,
key_file.write(new_key_pem) 'wb',
crt_file.write(new_crt_pem) ) as crt_file, open(
crt = utils.load_certificate(utils.getCert(args.crt), ca_crt_list, None) key_path,
updated = True 'wb',
next_deadline = min( ) as key_file:
next_deadline, key_file.write(new_key_pem)
crt.not_valid_after - threshold, crt_file.write(new_crt_pem)
) crt = utils.load_certificate(
utils.getCert(args.crt),
ca_crt_list,
None,
)
updated = True
next_deadline = min(
next_deadline,
crt.not_valid_after - threshold,
)
else:
next_deadline = min(
next_deadline,
crt.not_valid_after - threshold,
)
next_deadline = max( next_deadline = max(
next_deadline, next_deadline,
now + min_sleep, now + min_sleep,
......
...@@ -31,6 +31,7 @@ from urlparse import urlparse ...@@ -31,6 +31,7 @@ from urlparse import urlparse
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
import cryptography.exceptions import cryptography.exceptions
from . import exceptions
from . import utils from . import utils
from . import version from . import version
...@@ -279,9 +280,8 @@ class CaucaseClient(object): ...@@ -279,9 +280,8 @@ class CaucaseClient(object):
[ANONYMOUS] Request certificate renewal. [ANONYMOUS] Request certificate renewal.
""" """
new_key = utils.generatePrivateKey(key_len=key_len) new_key = utils.generatePrivateKey(key_len=key_len)
return ( try:
utils.dump_privatekey(new_key), new_cert = self._http(
self._http(
'PUT', 'PUT',
'/crt/renew', '/crt/renew',
json.dumps( json.dumps(
...@@ -306,7 +306,32 @@ class CaucaseClient(object): ...@@ -306,7 +306,32 @@ class CaucaseClient(object):
), ),
).encode('utf-8'), ).encode('utf-8'),
{'Content-Type': 'application/json'}, {'Content-Type': 'application/json'},
), )
except CaucaseError as e:
# pylint: disable=unbalanced-tuple-unpacking
status, header_list, _ = e.args
# pylint: enable=unbalanced-tuple-unpacking
if status != httplib.FORBIDDEN:
raise
retry_after_list = [
value
for key, value in header_list
if key == 'retry-after'
]
if len(retry_after_list) != 1:
raise
retry_after, = retry_after_list
if retry_after.strip().isdigit():
raise exceptions.RetryLater(datetime.timedelta(0, int(retry_after, 10)))
retry_after = utils.IMFfixdate2timestamp(retry_after)
if retry_after is None:
raise e
raise exceptions.RetryLater(
datetime.datetime.fromtimestamp(retry_after),
)
return (
utils.dump_privatekey(new_key),
new_cert,
) )
def revokeCertificate(self, crt, key=None): def revokeCertificate(self, crt, key=None):
......
...@@ -53,3 +53,9 @@ class NotACertificateSigningRequest(CertificateAuthorityException): ...@@ -53,3 +53,9 @@ class NotACertificateSigningRequest(CertificateAuthorityException):
class NotJSON(CertificateAuthorityException): class NotJSON(CertificateAuthorityException):
"""Provided value does not decode properly as JSON""" """Provided value does not decode properly as JSON"""
pass pass
class RetryLater(CertificateAuthorityException):
"""Action cannot be performed yet (certificate too young, ...)"""
def __init__(self, when):
super(RetryLater, self).__init__(when)
self.when = when
...@@ -556,6 +556,23 @@ def main( ...@@ -556,6 +556,23 @@ def main(
'submission. default: %(default)s', 'submission. default: %(default)s',
) )
service_group.add_argument(
'--service-no-renewal-period',
default=10,
type=int,
metavar='DAYS',
help='Number of days during which a certificate cannot be renewed after '
'its issuance, to prevent revocation escaping. default: %(default)s',
)
user_group.add_argument(
'--user-no-renewal-period',
default=10,
type=int,
metavar='DAYS',
help='Number of days during which a certificate cannot be renewed after '
'its issuance, to prevent revocation escaping. default: %(default)s',
)
parser.add_argument( parser.add_argument(
'--lock-auto-approve-count', '--lock-auto-approve-count',
action='store_true', action='store_true',
...@@ -641,6 +658,7 @@ def main( ...@@ -641,6 +658,7 @@ def main(
crt_life_time=cau_crt_life_time, crt_life_time=cau_crt_life_time,
auto_sign_csr_amount=args.user_auto_approve_count, auto_sign_csr_amount=args.user_auto_approve_count,
lock_auto_sign_csr_amount=args.lock_auto_approve_count, lock_auto_sign_csr_amount=args.lock_auto_approve_count,
crt_no_renewal_period=args.user_no_renewal_period,
) )
# Certificate Authority for Services: server and client certificates, the # Certificate Authority for Services: server and client certificates, the
# final produce of caucase. # final produce of caucase.
...@@ -660,6 +678,7 @@ def main( ...@@ -660,6 +678,7 @@ def main(
crt_life_time=args.service_crt_validity, crt_life_time=args.service_crt_validity,
auto_sign_csr_amount=args.service_auto_approve_count, auto_sign_csr_amount=args.service_auto_approve_count,
lock_auto_sign_csr_amount=args.lock_auto_approve_count, lock_auto_sign_csr_amount=args.lock_auto_approve_count,
crt_no_renewal_period=args.service_no_renewal_period,
) )
# Certificate Authority for caucased https service. Distinct from CAS to be # Certificate Authority for caucased https service. Distinct from CAS to be
# able to restrict the validity scope of produced CA certificate, so that it # able to restrict the validity scope of produced CA certificate, so that it
...@@ -700,6 +719,7 @@ def main( ...@@ -700,6 +719,7 @@ def main(
# So it should be safe and more practical to give it a long life. # So it should be safe and more practical to give it a long life.
ca_life_period=40, # approx. 10 years ca_life_period=40, # approx. 10 years
crt_life_time=args.service_crt_validity, crt_life_time=args.service_crt_validity,
crt_no_renewal_period=0,
) )
if os.path.exists(args.cors_key_store): if os.path.exists(args.cors_key_store):
with open(args.cors_key_store, 'rb') as cors_key_file: with open(args.cors_key_store, 'rb') as cors_key_file:
......
...@@ -600,6 +600,8 @@ class CaucaseTest(unittest.TestCase): ...@@ -600,6 +600,8 @@ class CaucaseTest(unittest.TestCase):
#'--threshold', '31', #'--threshold', '31',
#'--key-len', '2048', #'--key-len', '2048',
'--cors-key-store', self._server_cors_store, '--cors-key-store', self._server_cors_store,
'--service-no-renewal-period', '0',
'--user-no-renewal-period', '0',
) + argv, ) + argv,
'until': until, 'until': until,
'log_file': self.caucase_test_output, 'log_file': self.caucase_test_output,
......
...@@ -23,6 +23,7 @@ Caucase - Certificate Authority for Users, Certificate Authority for SErvices ...@@ -23,6 +23,7 @@ Caucase - Certificate Authority for Users, Certificate Authority for SErvices
""" """
from __future__ import absolute_import from __future__ import absolute_import
from Cookie import SimpleCookie, CookieError from Cookie import SimpleCookie, CookieError
import datetime
from functools import partial from functools import partial
import httplib import httplib
import json import json
...@@ -177,6 +178,20 @@ class InsufficientStorage(ApplicationError): ...@@ -177,6 +178,20 @@ class InsufficientStorage(ApplicationError):
# constant... # constant...
status = '%i Insufficient Storage' % (httplib.INSUFFICIENT_STORAGE, ) status = '%i Insufficient Storage' % (httplib.INSUFFICIENT_STORAGE, )
class RetryLater(Forbidden):
"""
HTTP service unavailable error with "Retry-After" header.
"""
def __init__(self, when, *args, **kw):
super(RetryLater, self).__init__(when, *args, **kw)
if isinstance(when, datetime.timedelta):
when = '%i' % (when.total_seconds(), )
else:
when = utils.timestamp2IMFfixdate(utils.datetime2timestamp(when))
self._response_headers = [
('Retry-After', when),
]
STATUS_OK = _getStatus(httplib.OK) STATUS_OK = _getStatus(httplib.OK)
STATUS_CREATED = _getStatus(httplib.CREATED) STATUS_CREATED = _getStatus(httplib.CREATED)
STATUS_NO_CONTENT = _getStatus(httplib.NO_CONTENT) STATUS_NO_CONTENT = _getStatus(httplib.NO_CONTENT)
...@@ -584,6 +599,8 @@ class Application(object): ...@@ -584,6 +599,8 @@ class Application(object):
raise InsufficientStorage raise InsufficientStorage
except exceptions.NotJSON: except exceptions.NotJSON:
raise BadRequest(b'Invalid json payload') raise BadRequest(b'Invalid json payload')
except exceptions.RetryLater as e:
raise RetryLater(e.when)
except exceptions.CertificateAuthorityException as e: except exceptions.CertificateAuthorityException as e:
raise BadRequest(str(e)) raise BadRequest(str(e))
except Exception: except Exception:
......
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