Repository: pyca/cryptography

Issue:
Allow verifying an x509 cert chain without making assertions about the subject name
Thanks to all who worked on the X.509 verification support in version 42.

I am trying to use this API for verifying a signing certificate, and realizing that the API requires me to assert a subject name (DNS name or IP address) to get the validation output. The subject name is not defined/not relevant in this application.

How can I verify that a certificate is in the chain of trust without asserting on the subject name?


Discussion:
We don't yet support this use case. We intend to in the future.
I've been thinking a bit about how to do this (since I'll also likely need it at some point for `sigstore-python`). Some ideas:

1. Make the current `build_server_verifier(subject: Subject)` take an optional subject instead (maybe explicitly `None`?), which would then disable any SAN checks during chain building.
2. Add a new `build_generic_verifier(subject: Subject | None)`, which would then be an explicit path for chain building operations that are more "generic" than server authentication (presumed by `build_server_identifier`).
Well, we should not be changing the build_server_verifier function. You should never verify a server cert without checking the subject is valid.

This needs to be a new type of verifier.
That makes sense. How does `build_generic_verifier`/`GenericVerifier` sound? 

(Other meh names: `AnonymousVerifier`/`SubjectlessVerifier` for explicit non-subject checking.)
What's teh difference between that and a client cert verifier?
Hmm, not sure. My idea of a `ClientVerifier` was something that takes a nonempty `list[Subject]` to match against so it wouldn't work for the "explicitly not checking the subject" case. But maybe I'm thinking about that the wrong way.
I don't think that's how a clientverifier would work -- imagine a webserver using client certs for auth.
So in that case `ClientVerifier.verify()` would return the `Subject` for subsequent AuthZ/handling, right? In that case yeah, I think this is indistinguishable from the client cert verifier case and can be handled through it.
Yes, I think that's what you'd want, indeed.
Just to give a public update: I've offered to do the work here. I'll try and get a PR up for this tonight.

Resolving patch:
diff --git a/src/cryptography/x509/verification.py b/src/cryptography/x509/verification.py
--- a/src/cryptography/x509/verification.py
+++ b/src/cryptography/x509/verification.py
@@ -12,6 +12,8 @@
 __all__ = [
     "Store",
     "Subject",
+    "VerifiedClient",
+    "ClientVerifier",
     "ServerVerifier",
     "PolicyBuilder",
     "VerificationError",
@@ -19,6 +21,8 @@
 
 Store = rust_x509.Store
 Subject = typing.Union[DNSName, IPAddress]
+VerifiedClient = rust_x509.VerifiedClient
+ClientVerifier = rust_x509.ClientVerifier
 ServerVerifier = rust_x509.ServerVerifier
 PolicyBuilder = rust_x509.PolicyBuilder
 VerificationError = rust_x509.VerificationError


Test patch:
diff --git a/tests/x509/verification/test_limbo.py b/tests/x509/verification/test_limbo.py
--- a/tests/x509/verification/test_limbo.py
+++ b/tests/x509/verification/test_limbo.py
@@ -12,7 +12,9 @@
 from cryptography import x509
 from cryptography.x509 import load_pem_x509_certificate
 from cryptography.x509.verification import (
+    ClientVerifier,
     PolicyBuilder,
+    ServerVerifier,
     Store,
     VerificationError,
 )
@@ -78,12 +80,14 @@
 
 def _get_limbo_peer(expected_peer):
     kind = expected_peer["kind"]
-    assert kind in ("DNS", "IP")
+    assert kind in ("DNS", "IP", "RFC822")
     value = expected_peer["value"]
     if kind == "DNS":
         return x509.DNSName(value)
-    else:
+    elif kind == "IP":
         return x509.IPAddress(ipaddress.ip_address(value))
+    else:
+        return x509.RFC822Name(value)
 
 
 def _limbo_testcase(id_, testcase):
@@ -95,14 +99,7 @@ def _limbo_testcase(id_, testcase):
     if unsupported:
         pytest.skip(f"explicitly skipped features: {unsupported}")
 
-    if testcase["validation_kind"] != "SERVER":
-        pytest.skip("non-SERVER testcase")
-
     assert testcase["signature_algorithms"] == []
-    assert testcase["extended_key_usage"] == [] or testcase[
-        "extended_key_usage"
-    ] == ["serverAuth"]
-    assert testcase["expected_peer_names"] == []
 
     trusted_certs = [
         load_pem_x509_certificate(cert.encode())
@@ -115,7 +112,6 @@ def _limbo_testcase(id_, testcase):
     peer_certificate = load_pem_x509_certificate(
         testcase["peer_certificate"].encode()
     )
-    peer_name = _get_limbo_peer(testcase["expected_peer_name"])
     validation_time = testcase["validation_time"]
     validation_time = (
         datetime.datetime.fromisoformat(validation_time)
@@ -131,12 +127,33 @@ def _limbo_testcase(id_, testcase):
     if max_chain_depth is not None:
         builder = builder.max_chain_depth(max_chain_depth)
 
-    verifier = builder.build_server_verifier(peer_name)
+    verifier: ServerVerifier | ClientVerifier
+    if testcase["validation_kind"] == "SERVER":
+        assert testcase["extended_key_usage"] == [] or testcase[
+            "extended_key_usage"
+        ] == ["serverAuth"]
+        peer_name = _get_limbo_peer(testcase["expected_peer_name"])
+        verifier = builder.build_server_verifier(peer_name)
+    else:
+        assert testcase["extended_key_usage"] == ["clientAuth"]
+        verifier = builder.build_client_verifier()
 
     if should_pass:
-        built_chain = verifier.verify(
-            peer_certificate, untrusted_intermediates
-        )
+        if isinstance(verifier, ServerVerifier):
+            built_chain = verifier.verify(
+                peer_certificate, untrusted_intermediates
+            )
+        else:
+            verified_client = verifier.verify(
+                peer_certificate, untrusted_intermediates
+            )
+
+            expected_subjects = [
+                _get_limbo_peer(p) for p in testcase["expected_peer_names"]
+            ]
+            assert expected_subjects == verified_client.subjects
+
+            built_chain = verified_client.chain
 
         # Assert that the verifier returns chains in [EE, ..., TA] order.
         assert built_chain[0] == peer_certificate
diff --git a/tests/x509/verification/test_verification.py b/tests/x509/verification/test_verification.py
--- a/tests/x509/verification/test_verification.py
+++ b/tests/x509/verification/test_verification.py
@@ -105,6 +105,40 @@ def test_store_rejects_non_certificates(self):
             Store(["not a cert"])  # type: ignore[list-item]
 
 
+class TestClientVerifier:
+    def test_build_client_verifier_missing_store(self):
+        with pytest.raises(
+            ValueError, match="A client verifier must have a trust store"
+        ):
+            PolicyBuilder().build_client_verifier()
+
+    def test_verify(self):
+        # expires 2018-11-16 01:15:03 UTC
+        leaf = _load_cert(
+            os.path.join("x509", "cryptography.io.pem"),
+            x509.load_pem_x509_certificate,
+        )
+
+        store = Store([leaf])
+
+        validation_time = datetime.datetime.fromisoformat(
+            "2018-11-16T00:00:00+00:00"
+        )
+        builder = PolicyBuilder().store(store)
+        builder = builder.time(validation_time).max_chain_depth(16)
+        verifier = builder.build_client_verifier()
+
+        assert verifier.validation_time == validation_time.replace(tzinfo=None)
+        assert verifier.max_chain_depth == 16
+
+        verified_client = verifier.verify(leaf, [])
+        assert verified_client.chain == [leaf]
+
+        assert x509.DNSName("www.cryptography.io") in verified_client.subjects
+        assert x509.DNSName("cryptography.io") in verified_client.subjects
+        assert len(verified_client.subjects) == 2
+
+
 class TestServerVerifier:
     @pytest.mark.parametrize(
         ("validation_time", "valid"),