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 @@
ignore=_version.py
[DESIGN]
max-args=12
max-args=13
max-nested-blocks=6
max-module-lines=1500
......
......@@ -41,6 +41,7 @@ from .exceptions import (
CertificateRevokedError,
NotACertificateSigningRequest,
Found,
RetryLater,
)
__all__ = ('CertificateAuthority', 'UserCertificateAuthority', 'Extension')
......@@ -92,6 +93,7 @@ class CertificateAuthority(object):
crt_life_time=31 * 3, # Approximately 3 months
ca_life_period=4, # Approximately a year
crl_renew_period=0.33, # Approximately a month
crt_no_renewal_period=10,
crl_base_url=None,
digest_list=utils.DEFAULT_DIGEST_LIST,
auto_sign_csr_amount=0,
......@@ -126,6 +128,10 @@ class CertificateAuthority(object):
Number of crt_life_time periods for which a revocation list is
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)
The CRL distribution URL to include in signed certificates.
None to not declare a CRL distribution point in generated certificates.
......@@ -190,6 +196,7 @@ class CertificateAuthority(object):
crt_life_time * crl_renew_period * .5,
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._loadCAKeyPairList()
self._renewCAIfNeeded()
......@@ -792,6 +799,11 @@ class CertificateAuthority(object):
_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(
csr_id=self.appendCertificateSigningRequest(
csr_pem,
......@@ -1057,7 +1069,14 @@ class UserCertificateAuthority(CertificateAuthority):
# Now that the database is restored, use a CertificateAuthority to
# 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
crt_pem = self._storage.getCertificateByKeyIdentifier(key_id)
# pylint: enable=protected-access
......
......@@ -273,11 +273,19 @@ class CLICaucaseClient(object):
if renewal_deadline < old_crt.not_valid_after:
self._print(crt_path, 'did not reach renew threshold, not renewing')
continue
try:
new_key_pem, new_crt_pem = self._client.renewCertificate(
old_crt=old_crt,
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:
with open(crt_path, 'wb') as crt_file:
crt_file.write(new_key_pem)
......@@ -936,11 +944,23 @@ def updater(argv=None, until=utils.until):
crt = utils.load_certificate(crt_pem, ca_crt_list, None)
if crt.not_valid_after - threshold <= now:
print('Renewing', args.crt)
try:
new_key_pem, new_crt_pem = client.renewCertificate(
old_crt=crt,
old_key=utils.load_privatekey(key_pem),
key_len=args.key_len,
)
except exceptions.RetryLater as e:
# XXX: remember retry-after value for this certificate ?
retry_after = e.when
if isinstance(retry_after, datetime.timedelta):
retry_after += now
print('Renewal rejected')
next_deadline = min(
next_deadline,
retry_after,
)
else:
if key_path is None:
with open(args.crt, 'wb') as crt_file:
crt_file.write(new_key_pem)
......@@ -955,12 +975,21 @@ def updater(argv=None, until=utils.until):
) as key_file:
key_file.write(new_key_pem)
crt_file.write(new_crt_pem)
crt = utils.load_certificate(utils.getCert(args.crt), ca_crt_list, None)
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,
now + min_sleep,
......
......@@ -31,6 +31,7 @@ from urlparse import urlparse
from cryptography import x509
from cryptography.hazmat.backends import default_backend
import cryptography.exceptions
from . import exceptions
from . import utils
from . import version
......@@ -279,9 +280,8 @@ class CaucaseClient(object):
[ANONYMOUS] Request certificate renewal.
"""
new_key = utils.generatePrivateKey(key_len=key_len)
return (
utils.dump_privatekey(new_key),
self._http(
try:
new_cert = self._http(
'PUT',
'/crt/renew',
json.dumps(
......@@ -306,7 +306,32 @@ class CaucaseClient(object):
),
).encode('utf-8'),
{'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):
......
......@@ -53,3 +53,9 @@ class NotACertificateSigningRequest(CertificateAuthorityException):
class NotJSON(CertificateAuthorityException):
"""Provided value does not decode properly as JSON"""
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(
'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(
'--lock-auto-approve-count',
action='store_true',
......@@ -641,6 +658,7 @@ def main(
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,
crt_no_renewal_period=args.user_no_renewal_period,
)
# Certificate Authority for Services: server and client certificates, the
# final produce of caucase.
......@@ -660,6 +678,7 @@ def main(
crt_life_time=args.service_crt_validity,
auto_sign_csr_amount=args.service_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
# able to restrict the validity scope of produced CA certificate, so that it
......@@ -700,6 +719,7 @@ def main(
# So it should be safe and more practical to give it a long life.
ca_life_period=40, # approx. 10 years
crt_life_time=args.service_crt_validity,
crt_no_renewal_period=0,
)
if os.path.exists(args.cors_key_store):
with open(args.cors_key_store, 'rb') as cors_key_file:
......
......@@ -600,6 +600,8 @@ class CaucaseTest(unittest.TestCase):
#'--threshold', '31',
#'--key-len', '2048',
'--cors-key-store', self._server_cors_store,
'--service-no-renewal-period', '0',
'--user-no-renewal-period', '0',
) + argv,
'until': until,
'log_file': self.caucase_test_output,
......
......@@ -23,6 +23,7 @@ Caucase - Certificate Authority for Users, Certificate Authority for SErvices
"""
from __future__ import absolute_import
from Cookie import SimpleCookie, CookieError
import datetime
from functools import partial
import httplib
import json
......@@ -177,6 +178,20 @@ class InsufficientStorage(ApplicationError):
# constant...
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_CREATED = _getStatus(httplib.CREATED)
STATUS_NO_CONTENT = _getStatus(httplib.NO_CONTENT)
......@@ -584,6 +599,8 @@ class Application(object):
raise InsufficientStorage
except exceptions.NotJSON:
raise BadRequest(b'Invalid json payload')
except exceptions.RetryLater as e:
raise RetryLater(e.when)
except exceptions.CertificateAuthorityException as e:
raise BadRequest(str(e))
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