├── .coveragerc ├── .gitignore ├── .travis.yml ├── README.md ├── tests ├── __init__.py ├── conftest.py ├── test_aia.py ├── test_basic.py ├── test_conftest.py └── utils.py └── validator.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = 4 | validator 5 | tests/ 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | *.py[co] 3 | .coverage 4 | htmlcov/ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: trusty 3 | 4 | language: python 5 | cache: 6 | directories: 7 | - $HOME/.cache/pip/ 8 | 9 | python: 10 | - 2.7 11 | - 3.4 12 | - 3.5 13 | - 3.6 14 | 15 | install: 16 | - pip install pytest coverage requests cryptography flake8 17 | 18 | script: 19 | - coverage run -m pytest 20 | - coverage report 21 | - flake8 validator.py tests/ 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ``x509-validator`` 2 | 3 | **WARNING:** This has never received any sort of security review, don't use it. 4 | 5 | This library is a pure-Python implementation of X.509 certificate path building 6 | and validation, built on top of ``pyca/cryptography``. 7 | 8 | ## Usage 9 | 10 | ```python 11 | from cryptography import x509 12 | 13 | from validator import X509Validator, ValidationContext 14 | 15 | validator = X509Validator([list-of-trusted-x509-certificates]) 16 | validator.validate( 17 | leaf_certificate 18 | ValidationContext( 19 | name=x509.DNSName(hostname), 20 | extra_certs=[list-of-intermediate-x509-certificates], 21 | extended_key_usage=x509.ExtendedKeyUsageOIDs.SERVER_AUTH, 22 | ) 23 | ) 24 | ``` 25 | 26 | Will return the built chain on success, or raise an `x509.ValidationError` on 27 | failure. 28 | 29 | ## Work in progress 30 | 31 | See the issue tracker for things that are currently known to be unimplemented 32 | (seek existential assistance for things that are not known to be 33 | unimplemented). 34 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex/x509-validator/a6df5949640a6e0176dda25c6c9cf90f45c31047/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, unicode_literals 2 | 3 | import base64 4 | import datetime 5 | import hashlib 6 | import threading 7 | import wsgiref.simple_server 8 | from collections import defaultdict 9 | 10 | from cryptography import x509 11 | from cryptography.hazmat.backends import default_backend 12 | from cryptography.hazmat.primitives import hashes, serialization 13 | from cryptography.hazmat.primitives.asymmetric import dsa, ec, rsa 14 | 15 | import pytest 16 | 17 | from validator import ( 18 | ANY_EXTENDED_KEY_USAGE_OID, X509Validator, ValidationContext, 19 | ValidationError 20 | ) 21 | 22 | from .utils import create_ca_issuer, create_extension 23 | 24 | 25 | class KeyCache(object): 26 | def __init__(self, keys): 27 | self._inuse_keys = defaultdict(list) 28 | self._free_keys = defaultdict(list, keys) 29 | 30 | @classmethod 31 | def _from_dump(cls, cached_entries): 32 | keys = defaultdict(list) 33 | for entry in cached_entries: 34 | params = tuple(entry["params"]) 35 | for key in entry["keys"]: 36 | key = serialization.load_der_private_key( 37 | base64.b64decode(key), 38 | password=None, 39 | backend=default_backend(), 40 | ) 41 | keys[params].append(key) 42 | return cls(keys) 43 | 44 | def _dump(self): 45 | cache_entries = [] 46 | for (params, keys) in self._free_keys.items(): 47 | cache_entries.append({ 48 | "params": params, 49 | "keys": [ 50 | base64.b64encode(key.private_bytes( 51 | serialization.Encoding.DER, 52 | serialization.PrivateFormat.PKCS8, 53 | serialization.NoEncryption() 54 | )).decode("ascii") 55 | for key in keys 56 | ], 57 | }) 58 | return cache_entries 59 | 60 | def _generate_key(self, params, create_key): 61 | if self._free_keys[params]: 62 | key = self._free_keys[params].pop() 63 | else: 64 | key = create_key() 65 | self._inuse_keys[params].append(key) 66 | return key 67 | 68 | def generate_rsa_key(self, public_exponent=65537, key_size=2048): 69 | return self._generate_key( 70 | ("rsa", public_exponent, key_size), 71 | lambda: rsa.generate_private_key( 72 | public_exponent, key_size, backend=default_backend() 73 | ) 74 | ) 75 | 76 | def generate_ec_key(self, curve): 77 | return self._generate_key( 78 | ("ecdsa", curve.name), 79 | lambda: ec.generate_private_key(curve, backend=default_backend()), 80 | ) 81 | 82 | def generate_dsa_key(self, key_size=2048): 83 | return self._generate_key( 84 | ("dsa", key_size), 85 | lambda: dsa.generate_private_key( 86 | key_size, backend=default_backend() 87 | ) 88 | ) 89 | 90 | def _reset(self): 91 | for params, keys in self._inuse_keys.items(): 92 | self._free_keys[params].extend(keys) 93 | self._inuse_keys.clear() 94 | 95 | 96 | @pytest.fixture(scope="session") 97 | def key_cache(request): 98 | keys = request.config.cache.get("x509-validator/keys", []) 99 | key_cache = KeyCache._from_dump(keys) 100 | try: 101 | yield key_cache 102 | finally: 103 | request.config.cache.set("x509-validator/keys", key_cache._dump()) 104 | 105 | 106 | class CertificatePair(object): 107 | def __init__(self, cert, key): 108 | self.cert = cert 109 | self.key = key 110 | 111 | 112 | class CAWorkspace(object): 113 | def __init__(self, key_cache): 114 | self._key_cache = key_cache 115 | self._roots = [] 116 | 117 | def _build_validator(self): 118 | return X509Validator(self._roots) 119 | 120 | def _build_validation_context(self, name=x509.DNSName("example.com"), 121 | extra_certs=[], extended_key_usage=None): 122 | if extended_key_usage is None: 123 | extended_key_usage = ANY_EXTENDED_KEY_USAGE_OID 124 | return ValidationContext( 125 | name=name, 126 | extra_certs=[c.cert for c in extra_certs], 127 | extended_key_usage=extended_key_usage, 128 | ) 129 | 130 | def assert_doesnt_validate(self, cert, **kwargs): 131 | validator = self._build_validator() 132 | ctx = self._build_validation_context(**kwargs) 133 | with pytest.raises(ValidationError): 134 | validator.validate(cert.cert, ctx) 135 | 136 | def assert_validates(self, cert, expected_chain, **kwargs): 137 | validator = self._build_validator() 138 | chain = validator.validate( 139 | cert.cert, self._build_validation_context(**kwargs) 140 | ) 141 | assert chain == [c.cert for c in expected_chain] 142 | 143 | def _issue_new_cert(self, key=None, names=[x509.DNSName("example.com")], 144 | issuer=None, not_valid_before=None, 145 | not_valid_after=None, signature_hash_algorithm=None, 146 | key_usage=None, 147 | extended_key_usages=[ANY_EXTENDED_KEY_USAGE_OID], 148 | ca_issuers=None, 149 | extra_extensions=[]): 150 | 151 | if key is None: 152 | key = self._key_cache.generate_rsa_key() 153 | 154 | subject_name = x509.Name([]) 155 | 156 | if issuer is not None: 157 | issuer_name = issuer.cert.subject 158 | ca_key = issuer.key 159 | else: 160 | issuer_name = subject_name 161 | ca_key = key 162 | 163 | if not_valid_before is None: 164 | not_valid_before = datetime.datetime.utcnow() 165 | if not_valid_after is None: 166 | not_valid_after = ( 167 | datetime.datetime.utcnow() + datetime.timedelta(hours=1) 168 | ) 169 | 170 | if signature_hash_algorithm is None: 171 | signature_hash_algorithm = hashes.SHA256() 172 | 173 | builder = x509.CertificateBuilder().serial_number( 174 | 1 175 | ).public_key( 176 | key.public_key() 177 | ).not_valid_before( 178 | not_valid_before 179 | ).not_valid_after( 180 | not_valid_after 181 | ).subject_name( 182 | subject_name 183 | ).issuer_name( 184 | issuer_name 185 | ) 186 | if names is not None: 187 | builder = builder.add_extension( 188 | x509.SubjectAlternativeName(names), 189 | critical=False, 190 | ) 191 | if key_usage is not None: 192 | builder = builder.add_extension(key_usage, critical=False) 193 | if extended_key_usages is not None: 194 | builder = builder.add_extension( 195 | x509.ExtendedKeyUsage(extended_key_usages), 196 | critical=False, 197 | ) 198 | if ca_issuers is not None: 199 | builder = builder.add_extension( 200 | x509.AuthorityInformationAccess(ca_issuers), 201 | critical=False, 202 | ) 203 | for ext in extra_extensions: 204 | builder = builder.add_extension(ext.value, critical=ext.critical) 205 | cert = builder.sign( 206 | ca_key, signature_hash_algorithm, default_backend() 207 | ) 208 | return CertificatePair(cert, key) 209 | 210 | def _issue_new_ca(self, issuer=None, path_length=None, **kwargs): 211 | kwargs.setdefault( 212 | "key_usage", 213 | x509.KeyUsage( 214 | key_cert_sign=True, 215 | 216 | digital_signature=False, 217 | content_commitment=False, 218 | key_encipherment=False, 219 | data_encipherment=False, 220 | key_agreement=False, 221 | encipher_only=False, 222 | decipher_only=False, 223 | crl_sign=False, 224 | ) 225 | ) 226 | return self._issue_new_cert( 227 | issuer=issuer, 228 | extra_extensions=[ 229 | create_extension( 230 | x509.BasicConstraints( 231 | ca=True, path_length=path_length 232 | ), 233 | critical=True 234 | ) 235 | ] + kwargs.pop("extra_extensions", []), 236 | **kwargs 237 | ) 238 | 239 | def add_trusted_root(self, cert): 240 | self._roots.append(cert.cert) 241 | 242 | def issue_new_trusted_root(self, **kwargs): 243 | certpair = self._issue_new_ca(**kwargs) 244 | self.add_trusted_root(certpair) 245 | return certpair 246 | 247 | def issue_new_ca(self, ca, **kwargs): 248 | return self._issue_new_ca(issuer=ca, **kwargs) 249 | 250 | def issue_new_leaf(self, ca, **kwargs): 251 | return self._issue_new_cert(issuer=ca, **kwargs) 252 | 253 | def issue_new_self_signed(self, **kwargs): 254 | return self._issue_new_cert(**kwargs) 255 | 256 | 257 | @pytest.fixture 258 | def ca_workspace(key_cache): 259 | workspace = CAWorkspace(key_cache) 260 | try: 261 | yield workspace 262 | finally: 263 | key_cache._reset() 264 | 265 | 266 | class WSGIApplication(object): 267 | def __init__(self): 268 | self.urls = {} 269 | 270 | def __call__(self, environ, start_response): 271 | try: 272 | contents = self.urls[environ["PATH_INFO"]] 273 | except KeyError: 274 | start_response(str("404 Not Found"), []) 275 | return [] 276 | start_response( 277 | str("200 OK"), 278 | [(str("Content-Type"), str("application/pkix-cert"))], 279 | ) 280 | return [contents] 281 | 282 | 283 | class Server(object): 284 | def __init__(self, wsgi_app, server_address): 285 | self.wsgi_app = wsgi_app 286 | self.server_address = server_address 287 | 288 | @property 289 | def base_url(self): 290 | (host, port) = self.server_address 291 | return "http://{}:{}".format(host, port) 292 | 293 | def create_aia_url(self, cert): 294 | if isinstance(cert, CertificatePair): 295 | data = cert.cert.public_bytes(serialization.Encoding.DER) 296 | else: 297 | data = cert 298 | url = "/{}.crt".format(hashlib.sha256(data).hexdigest()) 299 | self.wsgi_app.urls[url] = data 300 | return create_ca_issuer("{}{}".format(self.base_url, url)) 301 | 302 | 303 | @pytest.fixture 304 | def server(): 305 | wsgi_app = WSGIApplication() 306 | httpd = wsgiref.simple_server.make_server("localhost", 0, wsgi_app) 307 | t = threading.Thread( 308 | # The default poll_interval means that shutdown takes half a second 309 | target=httpd.serve_forever, kwargs={"poll_interval": 0} 310 | ) 311 | t.start() 312 | yield Server(wsgi_app, httpd.server_address) 313 | httpd.shutdown() 314 | t.join() 315 | -------------------------------------------------------------------------------- /tests/test_aia.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, unicode_literals 2 | 3 | from cryptography import x509 4 | 5 | from .utils import create_ca_issuer 6 | 7 | 8 | def test_follows_aia(ca_workspace, server): 9 | root = ca_workspace.issue_new_trusted_root() 10 | intermediate = ca_workspace.issue_new_ca(root) 11 | intermediate_url = server.create_aia_url(intermediate) 12 | 13 | cert = ca_workspace.issue_new_leaf( 14 | intermediate, ca_issuers=[intermediate_url] 15 | ) 16 | 17 | ca_workspace.assert_validates(cert, [cert, intermediate, root]) 18 | 19 | 20 | def test_non_http_aia(ca_workspace): 21 | root = ca_workspace.issue_new_trusted_root() 22 | intermediate = ca_workspace.issue_new_ca(root) 23 | cert = ca_workspace.issue_new_leaf( 24 | intermediate, ca_issuers=[create_ca_issuer("ldap://nonsense")] 25 | ) 26 | 27 | ca_workspace.assert_doesnt_validate(cert) 28 | 29 | 30 | def test_multiple_aia(ca_workspace, server): 31 | root = ca_workspace.issue_new_trusted_root() 32 | intermediate = ca_workspace.issue_new_ca(root) 33 | intermediate_url = server.create_aia_url(intermediate) 34 | cert = ca_workspace.issue_new_leaf( 35 | intermediate, ca_issuers=[ 36 | x509.AccessDescription( 37 | x509.AuthorityInformationAccessOID.OCSP, 38 | x509.UniformResourceIdentifier("http://example.com") 39 | ), 40 | intermediate_url 41 | ] 42 | ) 43 | 44 | ca_workspace.assert_validates(cert, [cert, intermediate, root]) 45 | 46 | 47 | def test_aia_invalid_cert(ca_workspace, server): 48 | root = ca_workspace.issue_new_trusted_root() 49 | intermediate = ca_workspace.issue_new_ca(root) 50 | 51 | aia_url = server.create_aia_url(b"gibberish - definitely not a cert") 52 | cert = ca_workspace.issue_new_leaf(intermediate, ca_issuers=[aia_url]) 53 | 54 | ca_workspace.assert_doesnt_validate(cert) 55 | ca_workspace.assert_validates( 56 | cert, [cert, intermediate, root], extra_certs=[intermediate] 57 | ) 58 | 59 | 60 | def test_aia_404(ca_workspace, server): 61 | root = ca_workspace.issue_new_trusted_root() 62 | intermediate = ca_workspace.issue_new_ca(root) 63 | 64 | aia_url = create_ca_issuer("{}/not-a-real-url".format(server.base_url)) 65 | cert = ca_workspace.issue_new_leaf(intermediate, ca_issuers=[aia_url]) 66 | 67 | ca_workspace.assert_doesnt_validate(cert) 68 | ca_workspace.assert_validates( 69 | cert, [cert, intermediate, root], extra_certs=[intermediate] 70 | ) 71 | 72 | 73 | def test_aia_bad_address(ca_workspace): 74 | root = ca_workspace.issue_new_trusted_root() 75 | intermediate = ca_workspace.issue_new_ca(root) 76 | 77 | aia_url = create_ca_issuer("http://host.invalid/") 78 | cert = ca_workspace.issue_new_leaf(intermediate, ca_issuers=[aia_url]) 79 | 80 | ca_workspace.assert_doesnt_validate(cert) 81 | ca_workspace.assert_validates( 82 | cert, [cert, intermediate, root], extra_certs=[intermediate] 83 | ) 84 | 85 | 86 | def test_aia_to_untrusted(ca_workspace, server): 87 | root = ca_workspace.issue_new_trusted_root() 88 | intermediate1 = ca_workspace.issue_new_ca(root) 89 | intermediate2 = ca_workspace.issue_new_ca(root) 90 | 91 | aia_url = server.create_aia_url(intermediate2) 92 | # Create a child of intermediate1, with an AIA to intermediate2 93 | cert = ca_workspace.issue_new_leaf(intermediate1, ca_issuers=[aia_url]) 94 | 95 | ca_workspace.assert_doesnt_validate(cert) 96 | ca_workspace.assert_validates( 97 | cert, [cert, intermediate1, root], extra_certs=[intermediate1] 98 | ) 99 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, unicode_literals 2 | 3 | import datetime 4 | import ipaddress 5 | 6 | from cryptography import x509 7 | from cryptography.hazmat.primitives import hashes 8 | from cryptography.hazmat.primitives.asymmetric import ec 9 | 10 | import pytest 11 | 12 | from .utils import create_extension, relative_datetime 13 | 14 | 15 | def test_empty_trust_store(ca_workspace): 16 | cert = ca_workspace.issue_new_self_signed() 17 | ca_workspace.assert_doesnt_validate(cert) 18 | 19 | 20 | def test_simple_issuance(ca_workspace): 21 | root = ca_workspace.issue_new_trusted_root() 22 | cert = ca_workspace.issue_new_leaf(root) 23 | 24 | ca_workspace.assert_validates(cert, [cert, root]) 25 | 26 | 27 | def test_untrusted_issuer(ca_workspace): 28 | ca_workspace.issue_new_trusted_root() 29 | root = ca_workspace.issue_new_self_signed() 30 | cert = ca_workspace.issue_new_leaf(root) 31 | 32 | ca_workspace.assert_doesnt_validate(cert) 33 | 34 | 35 | def test_intermediate(ca_workspace): 36 | root = ca_workspace.issue_new_trusted_root() 37 | intermediate = ca_workspace.issue_new_ca(root) 38 | cert = ca_workspace.issue_new_leaf(intermediate) 39 | 40 | ca_workspace.assert_validates( 41 | cert, [cert, intermediate, root], extra_certs=[intermediate] 42 | ) 43 | 44 | 45 | def test_ca_true_required(ca_workspace): 46 | root = ca_workspace.issue_new_trusted_root() 47 | cert1 = ca_workspace.issue_new_leaf(root) 48 | cert2 = ca_workspace.issue_new_leaf(root, extra_extensions=[ 49 | create_extension( 50 | x509.BasicConstraints(ca=False, path_length=None), 51 | critical=True, 52 | ) 53 | ]) 54 | untrusted1 = ca_workspace.issue_new_leaf(cert1) 55 | untrusted2 = ca_workspace.issue_new_leaf(cert2) 56 | 57 | ca_workspace.assert_validates(cert1, [cert1, root]) 58 | ca_workspace.assert_validates(cert2, [cert2, root]) 59 | ca_workspace.assert_doesnt_validate(untrusted1, extra_certs=[cert1]) 60 | ca_workspace.assert_doesnt_validate(untrusted2, extra_certs=[cert2]) 61 | 62 | root = ca_workspace.issue_new_self_signed() 63 | ca_workspace.add_trusted_root(root) 64 | leaf = ca_workspace.issue_new_leaf(root) 65 | ca_workspace.assert_doesnt_validate(leaf, extra_certs=[root]) 66 | 67 | 68 | def test_pathlen(ca_workspace): 69 | root = ca_workspace.issue_new_trusted_root(path_length=0) 70 | intermediate = ca_workspace.issue_new_ca(root) 71 | direct = ca_workspace.issue_new_leaf(root) 72 | cert = ca_workspace.issue_new_leaf(intermediate) 73 | 74 | ca_workspace.assert_validates(direct, [direct, root]) 75 | ca_workspace.assert_doesnt_validate(cert, extra_certs=[intermediate]) 76 | 77 | root = ca_workspace.issue_new_trusted_root(path_length=1) 78 | direct1 = ca_workspace.issue_new_leaf(root) 79 | intermediate1 = ca_workspace.issue_new_ca(root) 80 | direct2 = ca_workspace.issue_new_leaf(intermediate1) 81 | intermediate2 = ca_workspace.issue_new_ca(intermediate) 82 | cert = ca_workspace.issue_new_leaf(intermediate2) 83 | 84 | ca_workspace.assert_validates(direct1, [direct1, root]) 85 | ca_workspace.assert_validates( 86 | direct2, [direct2, intermediate1, root], extra_certs=[intermediate1] 87 | ) 88 | ca_workspace.assert_doesnt_validate( 89 | cert, extra_certs=[intermediate1, intermediate2] 90 | ) 91 | 92 | 93 | def test_conflicting_pathlen(ca_workspace): 94 | root = ca_workspace.issue_new_trusted_root(path_length=1) 95 | intermediate1 = ca_workspace.issue_new_ca(root, path_length=2) 96 | intermediate2 = ca_workspace.issue_new_ca(intermediate1) 97 | leaf = ca_workspace.issue_new_leaf(intermediate2) 98 | 99 | ca_workspace.assert_doesnt_validate( 100 | leaf, extra_certs=[intermediate1, intermediate2] 101 | ) 102 | 103 | 104 | def test_leaf_validity(ca_workspace): 105 | root = ca_workspace.issue_new_trusted_root() 106 | expired = ca_workspace.issue_new_leaf( 107 | root, 108 | not_valid_before=relative_datetime(-datetime.timedelta(days=2)), 109 | not_valid_after=relative_datetime(-datetime.timedelta(days=1)), 110 | ) 111 | not_yet_valid = ca_workspace.issue_new_leaf( 112 | root, 113 | not_valid_before=relative_datetime(datetime.timedelta(days=1)), 114 | not_valid_after=relative_datetime(datetime.timedelta(days=2)), 115 | ) 116 | 117 | ca_workspace.assert_doesnt_validate(expired) 118 | ca_workspace.assert_doesnt_validate(not_yet_valid) 119 | 120 | 121 | def test_root_validity(ca_workspace): 122 | expired_root = ca_workspace.issue_new_trusted_root( 123 | not_valid_before=relative_datetime(-datetime.timedelta(days=2)), 124 | not_valid_after=relative_datetime(-datetime.timedelta(days=1)), 125 | ) 126 | not_yet_valid_root = ca_workspace.issue_new_trusted_root( 127 | not_valid_before=relative_datetime(datetime.timedelta(days=1)), 128 | not_valid_after=relative_datetime(datetime.timedelta(days=2)), 129 | ) 130 | 131 | expired_root_leaf = ca_workspace.issue_new_leaf(expired_root) 132 | not_yet_valid_root_leaf = ca_workspace.issue_new_leaf(not_yet_valid_root) 133 | 134 | ca_workspace.assert_doesnt_validate(expired_root_leaf) 135 | ca_workspace.assert_doesnt_validate(not_yet_valid_root_leaf) 136 | 137 | 138 | def test_rsa_key_too_small(ca_workspace, key_cache): 139 | root = ca_workspace.issue_new_trusted_root() 140 | leaf = ca_workspace.issue_new_leaf( 141 | root, key=key_cache.generate_rsa_key(key_size=1024) 142 | ) 143 | 144 | ca_workspace.assert_doesnt_validate(leaf) 145 | 146 | 147 | def test_unsupported_signature_hash(ca_workspace, key_cache): 148 | root = ca_workspace.issue_new_trusted_root() 149 | md5_leaf = ca_workspace.issue_new_leaf( 150 | root, signature_hash_algorithm=hashes.MD5() 151 | ) 152 | sha1_leaf = ca_workspace.issue_new_leaf( 153 | root, signature_hash_algorithm=hashes.SHA1() 154 | ) 155 | 156 | ca_workspace.assert_doesnt_validate(md5_leaf) 157 | ca_workspace.assert_doesnt_validate(sha1_leaf) 158 | 159 | root = ca_workspace.issue_new_trusted_root( 160 | key=key_cache.generate_ec_key(ec.SECP256R1()) 161 | ) 162 | sha1_leaf = ca_workspace.issue_new_leaf( 163 | root, signature_hash_algorithm=hashes.SHA1() 164 | ) 165 | 166 | ca_workspace.assert_doesnt_validate(sha1_leaf) 167 | 168 | 169 | def test_maximum_chain_depth(ca_workspace): 170 | root = ca_workspace.issue_new_trusted_root() 171 | intermediates = [] 172 | ca = root 173 | for _ in range(16): 174 | ca = ca_workspace.issue_new_ca(ca) 175 | intermediates.append(ca) 176 | leaf = ca_workspace.issue_new_leaf(ca) 177 | 178 | ca_workspace.assert_doesnt_validate(leaf, extra_certs=intermediates) 179 | 180 | 181 | def test_unsupported_critical_extension_leaf(ca_workspace): 182 | root = ca_workspace.issue_new_trusted_root() 183 | leaf = ca_workspace.issue_new_leaf(root, extra_extensions=[ 184 | create_extension( 185 | x509.UnrecognizedExtension( 186 | oid=x509.ObjectIdentifier("1.0"), value=b"" 187 | ), 188 | critical=True 189 | ) 190 | ]) 191 | 192 | ca_workspace.assert_doesnt_validate(leaf) 193 | 194 | 195 | def test_unsupported_critical_extension_intermediate(ca_workspace): 196 | root = ca_workspace.issue_new_trusted_root() 197 | intermediate = ca_workspace.issue_new_ca( 198 | root, 199 | extra_extensions=[ 200 | create_extension( 201 | x509.UnrecognizedExtension( 202 | oid=x509.ObjectIdentifier("1.0"), value=b"" 203 | ), 204 | critical=True 205 | ) 206 | ] 207 | ) 208 | leaf = ca_workspace.issue_new_leaf(intermediate) 209 | 210 | ca_workspace.assert_doesnt_validate(leaf, extra_certs=[intermediate]) 211 | 212 | 213 | def test_name_validation(ca_workspace): 214 | root = ca_workspace.issue_new_trusted_root() 215 | cert = ca_workspace.issue_new_leaf(root) 216 | 217 | ca_workspace.assert_validates( 218 | cert, [cert, root], name=x509.DNSName("example.com") 219 | ) 220 | ca_workspace.assert_doesnt_validate( 221 | cert, name=x509.DNSName("google.com") 222 | ) 223 | ca_workspace.assert_doesnt_validate( 224 | cert, name=x509.DNSName("sub.example.com") 225 | ) 226 | ca_workspace.assert_doesnt_validate( 227 | cert, name=x509.IPAddress(ipaddress.IPv4Network("127.0.0.1")) 228 | ) 229 | 230 | wildcard_cert = ca_workspace.issue_new_leaf( 231 | root, names=[x509.DNSName("*.example.com")] 232 | ) 233 | ca_workspace.assert_validates( 234 | wildcard_cert, [wildcard_cert, root], 235 | name=x509.DNSName("sub.example.com") 236 | ) 237 | ca_workspace.assert_doesnt_validate( 238 | wildcard_cert, name=x509.DNSName("example.com") 239 | ) 240 | ca_workspace.assert_doesnt_validate( 241 | wildcard_cert, name=x509.DNSName("sub.sub.example.com") 242 | ) 243 | ca_workspace.assert_doesnt_validate( 244 | wildcard_cert, name=x509.DNSName("google.com") 245 | ) 246 | 247 | empty_san_cert = ca_workspace.issue_new_leaf(root, names=[]) 248 | ca_workspace.assert_doesnt_validate( 249 | empty_san_cert, name=x509.DNSName("example.com") 250 | ) 251 | 252 | no_san_cert = ca_workspace.issue_new_leaf(root, names=None) 253 | ca_workspace.assert_doesnt_validate( 254 | no_san_cert, name=x509.DNSName("example.com") 255 | ) 256 | 257 | dns_san_cert = ca_workspace.issue_new_leaf( 258 | root, names=[x509.IPAddress(ipaddress.IPv4Address("127.0.0.1"))] 259 | ) 260 | ca_workspace.assert_doesnt_validate( 261 | dns_san_cert, name=x509.DNSName("example.com") 262 | ) 263 | 264 | many_san_types_cert = ca_workspace.issue_new_leaf( 265 | root, names=[ 266 | x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")), 267 | x509.DNSName("example.com"), 268 | ] 269 | ) 270 | ca_workspace.assert_validates( 271 | many_san_types_cert, 272 | [many_san_types_cert, root], 273 | name=x509.DNSName("example.com") 274 | ) 275 | 276 | 277 | @pytest.mark.parametrize(("trusted", "name"), [ 278 | (False, "example.com"), 279 | (True, "sub.example.com"), 280 | (True, "sub.sub.example.com"), 281 | (False, "subsub.example.com"), 282 | (False, "sub.subsub.example.com"), 283 | (False, "google.com"), 284 | (False, "subsub.google.com"), 285 | (True, "sub.google.com"), 286 | (True, "sub.sub.google.com"), 287 | (True, "sub.sub.google.com"), 288 | (False, "mozilla.org"), 289 | ]) 290 | def test_name_constraints(ca_workspace, trusted, name): 291 | root = ca_workspace.issue_new_trusted_root(extra_extensions=[ 292 | create_extension( 293 | x509.NameConstraints( 294 | permitted_subtrees=[ 295 | x509.DNSName(".example.com"), 296 | x509.DNSName("sub.google.com"), 297 | x509.IPAddress(ipaddress.IPv4Network("10.10.0.0/24")), 298 | ], 299 | excluded_subtrees=[ 300 | x509.DNSName("subsub.example.com"), 301 | ], 302 | ), 303 | critical=False, 304 | ) 305 | ]) 306 | 307 | cert = ca_workspace.issue_new_leaf(root, names=[x509.DNSName(name)]) 308 | if trusted: 309 | ca_workspace.assert_validates( 310 | cert, [cert, root], name=x509.DNSName(name) 311 | ) 312 | else: 313 | ca_workspace.assert_doesnt_validate(cert, name=x509.DNSName(name)) 314 | 315 | 316 | def test_name_constraints_excluded(ca_workspace): 317 | root = ca_workspace.issue_new_trusted_root(extra_extensions=[ 318 | create_extension( 319 | x509.NameConstraints( 320 | permitted_subtrees=[], 321 | excluded_subtrees=[ 322 | x509.DNSName("example.com"), 323 | ], 324 | ), 325 | critical=False, 326 | ) 327 | ]) 328 | example_cert = ca_workspace.issue_new_leaf( 329 | root, names=[x509.DNSName("example.com")] 330 | ) 331 | example_sub_cert = ca_workspace.issue_new_leaf( 332 | root, names=[x509.DNSName("sub.example.com")] 333 | ) 334 | google_cert = ca_workspace.issue_new_leaf( 335 | root, names=[x509.DNSName("google.com")] 336 | ) 337 | 338 | ca_workspace.assert_doesnt_validate( 339 | example_cert, name=x509.DNSName("example.com") 340 | ) 341 | ca_workspace.assert_doesnt_validate( 342 | example_sub_cert, name=x509.DNSName("sub.example.com") 343 | ) 344 | ca_workspace.assert_validates( 345 | google_cert, [google_cert, root], name=x509.DNSName("google.com") 346 | ) 347 | 348 | 349 | def test_p256_chain(ca_workspace, key_cache): 350 | root = ca_workspace.issue_new_trusted_root( 351 | key=key_cache.generate_ec_key(ec.SECP256R1()) 352 | ) 353 | leaf = ca_workspace.issue_new_leaf( 354 | root, key=key_cache.generate_ec_key(ec.SECP256R1()) 355 | ) 356 | 357 | ca_workspace.assert_validates(leaf, [leaf, root]) 358 | 359 | 360 | def test_mixed_chain(ca_workspace, key_cache): 361 | root = ca_workspace.issue_new_trusted_root() 362 | leaf = ca_workspace.issue_new_leaf( 363 | root, key=key_cache.generate_ec_key(ec.SECP256R1()) 364 | ) 365 | 366 | ca_workspace.assert_validates(leaf, [leaf, root]) 367 | 368 | root = ca_workspace.issue_new_trusted_root( 369 | key=key_cache.generate_ec_key(ec.SECP256R1()) 370 | ) 371 | leaf = ca_workspace.issue_new_leaf(root) 372 | 373 | ca_workspace.assert_validates(leaf, [leaf, root]) 374 | 375 | 376 | def test_untrusted_issuer_p256(ca_workspace, key_cache): 377 | ca_workspace.issue_new_trusted_root( 378 | key=key_cache.generate_ec_key(ec.SECP256R1()) 379 | ) 380 | root = ca_workspace.issue_new_self_signed( 381 | key=key_cache.generate_ec_key(ec.SECP256R1()) 382 | ) 383 | cert = ca_workspace.issue_new_leaf( 384 | root, key=key_cache.generate_ec_key(ec.SECP256R1()) 385 | ) 386 | 387 | ca_workspace.assert_doesnt_validate(cert) 388 | 389 | 390 | def test_unsupported_curve(ca_workspace, key_cache): 391 | root = ca_workspace.issue_new_trusted_root() 392 | cert = ca_workspace.issue_new_leaf( 393 | root, key=key_cache.generate_ec_key(ec.SECP192R1()) 394 | ) 395 | 396 | ca_workspace.assert_doesnt_validate(cert) 397 | 398 | 399 | def test_p384(ca_workspace, key_cache): 400 | root = ca_workspace.issue_new_trusted_root() 401 | cert = ca_workspace.issue_new_leaf( 402 | root, key=key_cache.generate_ec_key(ec.SECP384R1()) 403 | ) 404 | 405 | ca_workspace.assert_validates(cert, [cert, root]) 406 | 407 | 408 | def test_dsa_unsupported(ca_workspace, key_cache): 409 | root = ca_workspace.issue_new_trusted_root() 410 | cert = ca_workspace.issue_new_leaf( 411 | root, key=key_cache.generate_dsa_key() 412 | ) 413 | 414 | ca_workspace.assert_doesnt_validate(cert) 415 | 416 | 417 | def test_extended_key_usage(ca_workspace): 418 | root = ca_workspace.issue_new_trusted_root() 419 | cert = ca_workspace.issue_new_leaf( 420 | root, extended_key_usages=[x509.ExtendedKeyUsageOID.CLIENT_AUTH], 421 | ) 422 | 423 | ca_workspace.assert_doesnt_validate( 424 | cert, extended_key_usage=x509.ExtendedKeyUsageOID.SERVER_AUTH 425 | ) 426 | 427 | root = ca_workspace.issue_new_trusted_root( 428 | extended_key_usages=[x509.ExtendedKeyUsageOID.CLIENT_AUTH] 429 | ) 430 | cert = ca_workspace.issue_new_leaf(root) 431 | ca_workspace.assert_doesnt_validate( 432 | cert, extended_key_usage=x509.ExtendedKeyUsageOID.SERVER_AUTH 433 | ) 434 | 435 | root = ca_workspace.issue_new_trusted_root() 436 | intermediate = ca_workspace.issue_new_ca( 437 | root, extended_key_usages=[x509.ExtendedKeyUsageOID.CLIENT_AUTH] 438 | ) 439 | cert = ca_workspace.issue_new_leaf(intermediate) 440 | 441 | ca_workspace.assert_doesnt_validate( 442 | cert, 443 | extra_certs=[intermediate], 444 | extended_key_usage=x509.ExtendedKeyUsageOID.SERVER_AUTH, 445 | ) 446 | 447 | 448 | def test_extended_key_usage_any(ca_workspace): 449 | root = ca_workspace.issue_new_trusted_root() 450 | cert = ca_workspace.issue_new_leaf(root) 451 | 452 | ca_workspace.assert_validates( 453 | cert, [cert, root], 454 | extended_key_usage=[x509.ExtendedKeyUsageOID.SERVER_AUTH] 455 | ) 456 | 457 | 458 | def test_missing_extended_key_usage(ca_workspace): 459 | root = ca_workspace.issue_new_trusted_root(extended_key_usages=None) 460 | cert = ca_workspace.issue_new_leaf(root) 461 | 462 | ca_workspace.assert_validates( 463 | cert, [cert, root], 464 | extended_key_usage=[x509.ExtendedKeyUsageOID.SERVER_AUTH] 465 | ) 466 | 467 | 468 | def test_key_usage_ca(ca_workspace): 469 | root = ca_workspace.issue_new_trusted_root(key_usage=None) 470 | cert = ca_workspace.issue_new_leaf(root) 471 | 472 | ca_workspace.assert_doesnt_validate(cert) 473 | 474 | root = ca_workspace.issue_new_trusted_root( 475 | key_usage=x509.KeyUsage( 476 | digital_signature=False, 477 | content_commitment=False, 478 | key_encipherment=False, 479 | data_encipherment=False, 480 | key_agreement=False, 481 | key_cert_sign=False, 482 | encipher_only=False, 483 | decipher_only=False, 484 | crl_sign=True 485 | ) 486 | ) 487 | cert = ca_workspace.issue_new_leaf(root) 488 | 489 | ca_workspace.assert_doesnt_validate(cert) 490 | 491 | root = ca_workspace.issue_new_trusted_root() 492 | intermediate = ca_workspace.issue_new_ca(root, key_usage=None) 493 | cert = ca_workspace.issue_new_leaf(intermediate) 494 | 495 | ca_workspace.assert_doesnt_validate(cert, extra_certs=[intermediate]) 496 | 497 | root = ca_workspace.issue_new_trusted_root() 498 | intermediate = ca_workspace.issue_new_ca( 499 | root, 500 | key_usage=x509.KeyUsage( 501 | digital_signature=False, 502 | content_commitment=False, 503 | key_encipherment=False, 504 | data_encipherment=False, 505 | key_agreement=False, 506 | key_cert_sign=False, 507 | encipher_only=False, 508 | decipher_only=False, 509 | crl_sign=True 510 | ) 511 | ) 512 | cert = ca_workspace.issue_new_leaf(intermediate) 513 | 514 | ca_workspace.assert_doesnt_validate(cert, extra_certs=[intermediate]) 515 | -------------------------------------------------------------------------------- /tests/test_conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, unicode_literals 2 | 3 | from cryptography.hazmat.primitives.asymmetric import dsa, ec, rsa 4 | 5 | from .conftest import KeyCache 6 | 7 | 8 | def test_keycache_generates_rsa_key(): 9 | k = KeyCache([]) 10 | assert isinstance(k.generate_rsa_key(key_size=512), rsa.RSAPrivateKey) 11 | 12 | 13 | def test_keycache_generates_ec_key(): 14 | k = KeyCache([]) 15 | assert isinstance( 16 | k.generate_ec_key(ec.SECP256R1()), ec.EllipticCurvePrivateKey 17 | ) 18 | 19 | 20 | def test_keycache_generates_dsa_key(): 21 | k = KeyCache([]) 22 | assert isinstance(k.generate_dsa_key(key_size=1024), dsa.DSAPrivateKey) 23 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, unicode_literals 2 | 3 | import datetime 4 | 5 | from cryptography import x509 6 | 7 | 8 | def create_extension(value, critical): 9 | return x509.Extension(value.oid, critical, value) 10 | 11 | 12 | def relative_datetime(td): 13 | return datetime.datetime.utcnow() + td 14 | 15 | 16 | def create_ca_issuer(url): 17 | return x509.AccessDescription( 18 | x509.AuthorityInformationAccessOID.CA_ISSUERS, 19 | x509.UniformResourceIdentifier(url) 20 | ) 21 | -------------------------------------------------------------------------------- /validator.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, unicode_literals 2 | 3 | import datetime 4 | 5 | from cryptography import x509 6 | from cryptography.exceptions import InvalidSignature 7 | from cryptography.hazmat.backends import default_backend 8 | from cryptography.hazmat.primitives.asymmetric import ec, rsa, padding 9 | 10 | import requests 11 | 12 | 13 | # TODO: https://github.com/pyca/cryptography/issues/3745 14 | ANY_EXTENDED_KEY_USAGE_OID = x509.ObjectIdentifier("2.5.29.37.0") 15 | 16 | 17 | class ValidationError(Exception): 18 | pass 19 | 20 | 21 | def _build_name_mapping(roots): 22 | mapping = {} 23 | for root in roots: 24 | mapping.setdefault(root.subject, []).append(root) 25 | return mapping 26 | 27 | 28 | def _hostname_matches(hostname, cert_hostname): 29 | hostname_prefix, hostname_rest = hostname.split(".", 1) 30 | cert_hostname_prefix, cert_hostname_rest = cert_hostname.split(".", 1) 31 | return ( 32 | ( 33 | cert_hostname_prefix == "*" or 34 | cert_hostname_prefix == hostname_prefix 35 | ) and 36 | cert_hostname_rest == hostname_rest 37 | ) 38 | 39 | 40 | def _name_constraint_matches(hostname, name_constraint): 41 | if not isinstance(name_constraint, x509.DNSName): 42 | return False 43 | constraint_hostname = name_constraint.value 44 | 45 | if constraint_hostname.startswith("."): 46 | return hostname.endswith(constraint_hostname) 47 | else: 48 | return ( 49 | hostname == constraint_hostname or 50 | hostname.endswith("." + constraint_hostname) 51 | ) 52 | 53 | 54 | class ValidationContext(object): 55 | def __init__(self, name, extended_key_usage, extra_certs=[]): 56 | self.name = name 57 | self.extended_key_usage = extended_key_usage 58 | self.extra_certs = extra_certs 59 | self._extra_certs_by_name = _build_name_mapping(extra_certs) 60 | self.timestamp = datetime.datetime.utcnow() 61 | 62 | 63 | _MAX_CHAIN_DEPTH = 8 64 | _SUPPORTED_EXTENSIONS = {x509.ExtensionOID.BASIC_CONSTRAINTS} 65 | _SUPPORTED_CURVES = {ec.SECP256R1, ec.SECP384R1} 66 | 67 | 68 | class X509Validator(object): 69 | def __init__(self, roots): 70 | self._roots = roots 71 | self._roots_by_name = _build_name_mapping(roots) 72 | 73 | self._http_session = requests.session() 74 | 75 | def validate(self, cert, ctx): 76 | if not self._is_valid_cert(cert, ctx): 77 | raise ValidationError 78 | 79 | if not self._is_name_correct(cert, ctx.name): 80 | raise ValidationError 81 | 82 | for chain in self._build_chain_from(cert, ctx, depth=0): 83 | return chain 84 | raise ValidationError 85 | 86 | def _find_potential_issuers(self, cert, ctx): 87 | for issuer in ctx._extra_certs_by_name.get(cert.issuer, []): 88 | yield issuer 89 | for issuer in self._roots_by_name.get(cert.issuer, []): 90 | yield issuer 91 | for issuer in self._follow_aia(cert): 92 | yield issuer 93 | 94 | def _follow_aia(self, cert): 95 | try: 96 | aia = cert.extensions.get_extension_for_class( 97 | x509.AuthorityInformationAccess 98 | ).value 99 | except x509.ExtensionNotFound: 100 | return 101 | 102 | for loc in aia: 103 | am = loc.access_method 104 | if ( 105 | am == x509.AuthorityInformationAccessOID.CA_ISSUERS and 106 | isinstance(loc.access_location, x509.UniformResourceIdentifier) 107 | ): 108 | location = loc.access_location.value 109 | if location.startswith("http://"): 110 | # TODO: filtering out addresses that shouldn't be 111 | # accessible (e.g. 169.254.169.254), timeouts, disabling 112 | # AIA, ... 113 | try: 114 | response = self._http_session.get(location) 115 | except requests.ConnectionError: 116 | continue 117 | if response.status_code != 200: 118 | continue 119 | try: 120 | yield x509.load_der_x509_certificate( 121 | response.content, default_backend() 122 | ) 123 | except ValueError: 124 | pass 125 | 126 | def _is_name_correct(self, cert, name): 127 | if not isinstance(name, x509.DNSName): 128 | raise ValidationError 129 | hostname = name.value 130 | try: 131 | san = cert.extensions.get_extension_for_class( 132 | x509.SubjectAlternativeName 133 | ).value 134 | except x509.ExtensionNotFound: 135 | return False 136 | 137 | for entry in san: 138 | # TODO: support other name types 139 | if not isinstance(entry, x509.DNSName): 140 | continue 141 | if _hostname_matches(hostname, entry.value): 142 | return True 143 | return False 144 | 145 | def _check_name_constraints(self, cert, name): 146 | try: 147 | nc = cert.extensions.get_extension_for_class( 148 | x509.NameConstraints 149 | ).value 150 | except x509.ExtensionNotFound: 151 | return True 152 | 153 | assert isinstance(name, x509.DNSName) 154 | if nc.permitted_subtrees: 155 | for constraint in nc.permitted_subtrees: 156 | if _name_constraint_matches(name.value, constraint): 157 | break 158 | else: 159 | return False 160 | 161 | for constraint in nc.excluded_subtrees: 162 | if _name_constraint_matches(name.value, constraint): 163 | return False 164 | 165 | return True 166 | 167 | def _is_valid_cert(self, cert, ctx): 168 | try: 169 | eku = cert.extensions.get_extension_for_class( 170 | x509.ExtendedKeyUsage 171 | ).value 172 | except x509.ExtensionNotFound: 173 | # No EKU extension means "anything is permitted" 174 | pass 175 | else: 176 | if ( 177 | ctx.extended_key_usage not in eku and 178 | ANY_EXTENDED_KEY_USAGE_OID not in eku 179 | ): 180 | return False 181 | 182 | return ( 183 | cert.not_valid_before <= ctx.timestamp <= cert.not_valid_after and 184 | self._is_valid_public_key(cert.public_key()) and 185 | all( 186 | ext.oid in _SUPPORTED_EXTENSIONS 187 | for ext in cert.extensions if ext.critical 188 | ) 189 | ) 190 | 191 | def _is_valid_public_key(self, key): 192 | return ( 193 | (isinstance(key, rsa.RSAPublicKey) and key.key_size >= 2048) or 194 | ( 195 | isinstance(key, ec.EllipticCurvePublicKey) and 196 | type(key.curve) in _SUPPORTED_CURVES 197 | ) 198 | ) 199 | 200 | def _is_valid_issuer(self, cert, issuer, depth, ctx): 201 | if not self._is_valid_cert(issuer, ctx): 202 | return False 203 | 204 | try: 205 | basic_constraints = issuer.extensions.get_extension_for_class( 206 | x509.BasicConstraints 207 | ).value 208 | except x509.ExtensionNotFound: 209 | return False 210 | if not basic_constraints.ca: 211 | return False 212 | if ( 213 | basic_constraints.path_length is not None and 214 | basic_constraints.path_length < depth 215 | ): 216 | return False 217 | 218 | try: 219 | ku = issuer.extensions.get_extension_for_class(x509.KeyUsage).value 220 | except x509.ExtensionNotFound: 221 | return False 222 | 223 | if not ku.key_cert_sign: 224 | return False 225 | 226 | if not self._check_name_constraints(issuer, ctx.name): 227 | return False 228 | 229 | public_key = issuer.public_key() 230 | if isinstance(public_key, rsa.RSAPublicKey): 231 | if cert.signature_algorithm_oid not in [ 232 | x509.SignatureAlgorithmOID.RSA_WITH_SHA256 233 | ]: 234 | return False 235 | 236 | try: 237 | public_key.verify( 238 | cert.signature, 239 | cert.tbs_certificate_bytes, 240 | padding.PKCS1v15(), 241 | cert.signature_hash_algorithm, 242 | ) 243 | except InvalidSignature: 244 | return False 245 | else: 246 | # Always true because of the `_is_valid_public_key` check. 247 | assert isinstance(public_key, ec.EllipticCurvePublicKey) 248 | if cert.signature_algorithm_oid not in [ 249 | x509.SignatureAlgorithmOID.ECDSA_WITH_SHA256 250 | ]: 251 | return False 252 | 253 | try: 254 | public_key.verify( 255 | cert.signature, 256 | cert.tbs_certificate_bytes, 257 | ec.ECDSA(cert.signature_hash_algorithm), 258 | ) 259 | except InvalidSignature: 260 | return False 261 | return True 262 | 263 | def _build_chain_from(self, cert, ctx, depth): 264 | if depth > _MAX_CHAIN_DEPTH: 265 | return 266 | if cert in self._roots: 267 | yield [cert] 268 | for issuer in self._find_potential_issuers(cert, ctx): 269 | if self._is_valid_issuer(cert, issuer, depth, ctx): 270 | chains = self._build_chain_from(issuer, ctx, depth=depth+1) 271 | for chain in chains: 272 | yield [cert] + chain 273 | --------------------------------------------------------------------------------