Stream-parse CRLs for caching file locations.

AT-AT needs to maintain a key-value CRL cache where each key is the DER
byte-string of the issuer and the value is a dictionary of the CRL file
path and expiration. This way when it checks a client certificate, it
can load the correct CRL by comparing the issuers. This is preferable to
loading all of the CRLs in-memory. However, it still requires that AT-AT
load and parse each CRL when the application boots. Because of the size
of the CRLs and their parsed, in-memory size, this leads to the
application spiking to use nearly 900MB of memory (resting usage is
around 50MB).

This change introduces a small function to ad-hoc parse the CRL and
obtain the information in the CRL we need: the issuer and the
expiration. It does this by reading the CRL byte-by-byte until it
reaches the ASN1 sequence that corresponds to the issuer, and then looks
ahead to find the nextUpdate field (i.e., the expiration date). The
CRLCache class uses this function to build its cache and JSON-serializes
the cache to disk. If another AT-AT application process finds the
serialized version, it will load that copy instead of rebuilding it. It
also entails a change to the function signature for the init method of
CRLCache: now it expects the CRL directory as its second argument,
instead of a list of locations.

The Python script invoked by `script/sync-crls` will rebuild the
location cache each time it's run. This means that when the Kubernetes
CronJob for CRLs runs, it will refresh the cache each time. When a new
application container boots, it will get the refreshed cache.

This also adds a nightly CircleCI job to sync the CRLs and test that the
ad-hoc parsing function returns the same result as a proper parsing
using the Python cryptography library. This provides extra insurance
that the function is returning correct results on real data.
This commit is contained in:
dandds
2019-10-31 08:44:44 -04:00
parent 56f0119814
commit 0b5acde4c4
10 changed files with 277 additions and 51 deletions

17
tests/check_crl_parse.py Normal file
View File

@@ -0,0 +1,17 @@
import os
import pytest
from atst.domain.authnid.crl.util import scan_for_issuer_and_next_update
from tests.utils import parse_for_issuer_and_next_update
CRL_DIR = "crls"
_CRLS = ["{}/{}".format(CRL_DIR, file_) for file_ in os.listdir(CRL_DIR)]
@pytest.mark.parametrize("crl_path", _CRLS)
def test_crl_scan_against_parse(crl_path):
parsed_der = parse_for_issuer_and_next_update(crl_path)
scanned_der = scan_for_issuer_and_next_update(crl_path)
assert parsed_der == scanned_der

View File

@@ -5,6 +5,7 @@ import os
import shutil
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import Encoding
from OpenSSL import crypto
from atst.domain.authnid.crl import (
CRLCache,
@@ -12,9 +13,17 @@ from atst.domain.authnid.crl import (
CRLInvalidException,
NoOpCRLCache,
)
from atst.domain.authnid.crl.util import (
scan_for_issuer_and_next_update,
build_crl_locations_cache,
serialize_crl_locations_cache,
load_crl_locations_cache,
CRLParseError,
JSON_CACHE,
)
from tests.mocks import FIXTURE_EMAIL_ADDRESS, DOD_CN
from tests.utils import FakeLogger
from tests.utils import FakeLogger, parse_for_issuer_and_next_update
class MockX509Store:
@@ -34,7 +43,9 @@ class MockX509Store:
def test_can_build_crl_list(crl_file, ca_key, ca_file, make_crl, tmpdir):
crl = make_crl(ca_key)
cache = CRLCache(ca_file, crl_locations=[crl_file], store_class=MockX509Store)
dir_ = os.path.dirname(crl_file)
serialize_crl_locations_cache(dir_)
cache = CRLCache(ca_file, dir_, store_class=MockX509Store)
issuer_der = crl.issuer.public_bytes(default_backend())
assert len(cache.crl_cache.keys()) == 1
assert issuer_der in cache.crl_cache
@@ -42,26 +53,16 @@ def test_can_build_crl_list(crl_file, ca_key, ca_file, make_crl, tmpdir):
assert cache.crl_cache[issuer_der]["expires"] == crl.next_update
def test_can_build_trusted_root_list():
def test_can_build_trusted_root_list(app):
location = "ssl/server-certs/ca-chain.pem"
cache = CRLCache(
root_location=location, crl_locations=[], store_class=MockX509Store
location, app.config["CRL_STORAGE_CONTAINER"], store_class=MockX509Store
)
with open(location) as f:
content = f.read()
assert len(cache.certificate_authorities.keys()) == content.count("BEGIN CERT")
def test_can_build_crl_list_with_missing_crls():
location = "ssl/client-certs/client-ca.der.crl"
cache = CRLCache(
"ssl/client-certs/client-ca.crt",
crl_locations=["tests/fixtures/sample.pdf"],
store_class=MockX509Store,
)
assert len(cache.crl_cache.keys()) == 0
def test_crl_validation_on_login(
app,
client,
@@ -78,8 +79,9 @@ def test_crl_validation_on_login(
crl = make_crl(ca_key, expired_serials=[bad_cert.serial_number])
serialize_pki_object_to_disk(crl, crl_file, encoding=Encoding.DER)
crl_dir = os.path.dirname(crl_file)
cache = CRLCache(ca_file, crl_locations=[crl_file])
cache = CRLCache(ca_file, crl_dir)
assert cache.crl_check(good_cert.public_bytes(Encoding.PEM).decode())
with pytest.raises(CRLRevocationException):
cache.crl_check(bad_cert.public_bytes(Encoding.PEM).decode())
@@ -94,7 +96,8 @@ def test_can_dynamically_update_crls(
make_crl,
serialize_pki_object_to_disk,
):
cache = CRLCache(ca_file, crl_locations=[crl_file])
crl_dir = os.path.dirname(crl_file)
cache = CRLCache(ca_file, crl_dir)
client_cert = make_x509(rsa_key(), signer_key=ca_key, cn="chewbacca")
client_pem = client_cert.public_bytes(Encoding.PEM)
assert cache.crl_check(client_pem)
@@ -107,8 +110,10 @@ def test_can_dynamically_update_crls(
assert cache.crl_check(client_pem)
def test_throws_error_for_missing_issuer():
cache = CRLCache("ssl/server-certs/ca-chain.pem", crl_locations=[])
def test_throws_error_for_missing_issuer(app):
cache = CRLCache(
"ssl/server-certs/ca-chain.pem", app.config["CRL_STORAGE_CONTAINER"]
)
# this cert is self-signed, and so the application does not have a
# corresponding CRL for it
cert = open("tests/fixtures/{}.crt".format(FIXTURE_EMAIL_ADDRESS), "rb").read()
@@ -123,10 +128,7 @@ def test_throws_error_for_missing_issuer():
def test_multistep_certificate_chain():
cache = CRLCache(
"tests/fixtures/chain/ca-chain.pem",
crl_locations=["tests/fixtures/chain/intermediate.crl"],
)
cache = CRLCache("tests/fixtures/chain/ca-chain.pem", "tests/fixtures/chain/")
cert = open("tests/fixtures/chain/client.crt", "rb").read()
assert cache.crl_check(cert)
@@ -144,7 +146,8 @@ def test_expired_crl_raises_CRLInvalidException_with_failover_config_false(
):
client_cert = make_x509(rsa_key(), signer_key=ca_key, cn="chewbacca")
client_pem = client_cert.public_bytes(Encoding.PEM)
crl_cache = CRLCache(ca_file, crl_locations=[expired_crl_file])
crl_dir = os.path.dirname(expired_crl_file)
crl_cache = CRLCache(ca_file, crl_dir)
with pytest.raises(CRLInvalidException):
crl_cache.crl_check(client_pem)
@@ -154,7 +157,8 @@ def test_expired_crl_passes_with_failover_config_true(
):
client_cert = make_x509(rsa_key(), signer_key=ca_key, cn="chewbacca")
client_pem = client_cert.public_bytes(Encoding.PEM)
crl_cache = CRLCache(ca_file, crl_locations=[expired_crl_file])
crl_dir = os.path.dirname(expired_crl_file)
crl_cache = CRLCache(ca_file, crl_dir)
assert crl_cache.crl_check(client_pem)
@@ -173,7 +177,54 @@ def test_updates_expired_certs(
def _crl_update_func():
shutil.copyfile(crl_file, expired_crl_file)
crl_cache = CRLCache(
ca_file, crl_locations=[expired_crl_file], crl_update_func=_crl_update_func
)
crl_dir = os.path.dirname(expired_crl_file)
crl_cache = CRLCache(ca_file, crl_dir, crl_update_func=_crl_update_func)
crl_cache.crl_check(client_pem)
def test_scan_for_issuer_and_next_update(crl_file):
parsed = parse_for_issuer_and_next_update(crl_file)
scanned = scan_for_issuer_and_next_update(crl_file)
assert parsed == scanned
@pytest.fixture
def bad_crl(tmpdir):
bad_file = tmpdir.join("bad.crl")
with open(bad_file, "wb") as bad:
bad.write(b"definitely not a crl")
return bad_file
def test_scan_for_issuer_and_next_update_with_bad_data(bad_crl):
with pytest.raises(CRLParseError):
scan_for_issuer_and_next_update(bad_crl)
def test_build_crl_locations_cache(crl_file):
issuer_der, next_update = parse_for_issuer_and_next_update(crl_file)
cache = build_crl_locations_cache([crl_file])
assert cache == {issuer_der: {"location": crl_file, "expires": next_update}}
def test_build_crl_locations_cache_with_bad_data(crl_file, bad_crl):
logger = FakeLogger()
issuer_der, next_update = parse_for_issuer_and_next_update(crl_file)
cache = build_crl_locations_cache([crl_file, bad_crl], logger=logger)
assert cache == {issuer_der: {"location": crl_file, "expires": next_update}}
assert logger.messages
assert str(bad_crl) in logger.messages[0]
def test_serialize_crl_locations_cache(crl_file, bad_crl):
dir_ = os.path.dirname(crl_file)
serialize_crl_locations_cache(dir_)
assert os.path.isfile("{}/{}".format(dir_, JSON_CACHE))
def test_load_crl_locations_cache(crl_file, bad_crl):
dir_ = os.path.dirname(crl_file)
serialize_crl_locations_cache(dir_)
cache = load_crl_locations_cache(dir_)
assert isinstance(cache, dict)

0
tests/fixtures/crl/.keep vendored Normal file
View File

View File

@@ -1,3 +1,4 @@
import os
from urllib.parse import urlparse
import pytest
@@ -147,7 +148,8 @@ def swap_crl_cache(
else:
crl = make_crl(ca_key)
serialize_pki_object_to_disk(crl, crl_file, encoding=Encoding.DER)
app.crl_cache = CRLCache(ca_file, crl_locations=[crl_file])
crl_dir = os.path.dirname(crl_file)
app.crl_cache = CRLCache(ca_file, crl_dir)
yield _swap_crl_cache
@@ -172,7 +174,8 @@ def test_crl_validation_on_login(
crl = make_crl(ca_key, expired_serials=[bad_cert.serial_number])
serialize_pki_object_to_disk(crl, crl_file, encoding=Encoding.DER)
cache = CRLCache(ca_file, crl_locations=[crl_file])
crl_dir = os.path.dirname(crl_file)
cache = CRLCache(ca_file, crl_dir)
swap_crl_cache(cache)
# bad cert is on the test CRL

View File

@@ -1,6 +1,7 @@
from flask import template_rendered
from contextlib import contextmanager
from unittest.mock import Mock
from OpenSSL import crypto
from atst.utils.notification_sender import NotificationSender
@@ -43,3 +44,10 @@ class FakeLogger:
FakeNotificationSender = lambda: Mock(spec=NotificationSender)
def parse_for_issuer_and_next_update(crl):
with open(crl, "rb") as crl_file:
parsed = crypto.load_crl(crypto.FILETYPE_ASN1, crl_file.read())
next_update = parsed.to_cryptography().next_update
return (parsed.get_issuer().der(), next_update)