Repository: certbot/certbot

Issue:
Providing different alternate chain selection methods
In the near future, Let's Encrypt intends to update the default and alternate chains that we provide. In short, we'll be offering two chains that users can choose between:
```
Subscriber Cert <-- R3 <-- ISRG Root X1 <-- DST Root CA X3 (self-signed)
Subscriber Cert <-- R3 <-- ISRG Root X1 (self-signed)
```

These chains come with three intertwined issues, the third of which is relevant here:
1) The second chain (which terminates at ISRG Root X1) doesn't work on older Android devices, which don't trust ISRG Root X1. This issue is solved by offering the other chain as the default.
2) The first/default chain (which terminates at DST Root CA X3) doesn't work with older versions of openssl, because that root will expire and old openssl will fail to realize it could have stopped at ISRG Root X1. (This is the same problem as the AddTrust expiration from May of last year.) This issue is solved by offering the shorter chain as an alternate.
3) The alternate chain contains no unique names, so certbot users cannot select it using the current `--preferred-chain` mechanism.

Today, certbot's `--preferred-chain` selection mechanism looks at every certificate in the chain, to see if any of them have a name which matches that supplied by the user. Unfortunately, there is no name which exists in the alternate chain above which does not exist in the default chain above, so there is no name which a user could provide to cause certbot to select the alternate chain.

I'd like to update certbot to have a different preferred chain mechanism which can distinguish between these chains. There are a few options I can think of, and probably more that I haven't:
* Only care about the name of the root certificate, not any other certificates in the chain
* Distinguish between chains by qualitative criteria, like "pick the shortest"
* Distinguish between certificates by their fingerprint, rather than their name

In addition, there are multiple ways (and again others I haven't yet thought of) that changes like the above could be implemented:
* Change the semantics of the existing `--preferred-chain` flag (probably backwards-incompatible, but would anyone actually be broken)
* Add a new flag with the new semantics
* Add a new flag which changes the semantics of the existing `--preferred-chain` flag

I'm happy to make this contribution / write a PR for this, but I wanted to make sure there was a place to have a design discussion first. I don't want to just toss a PR out there and then have it be aiming towards a solution that you don't like.


Discussion:
Hm. My main preference here is to make what's happening as clear and predictable as possible. I think based on that, I'd go with the fingerprint of the chain. And then we could add a `--preferred-chain-fingerprint` flag, which could be an alternate option to `--preferred-chain`.

EDIT: To expand on this, basically, my assumption is that anyone using this flag will be following specific instructions from somewhere else to get the chain that they want, such as a post on the community forum that explains everything you've explained above in greater detail. In which case they can simply copy and paste the flag and a fingerprint for their use case.
I [proposed earlier](https://community.letsencrypt.org/t/alternate-chain-selection/141327/10?u=_az) to keep the existing semantics but to introduce a tiebreaker by ranking root "ISRG Root X1" higher than intermediate "ISRG Root X1".

If that is too complicated, my vote would be to change the semantics to only look at the root issuer. I agree that changing it now would not break anything.
I'm not personally a huge fan of the the "certificate fingerprint" idea (because it puts significant onus on the user to figure out how to find or compute the correct fingerprint), but I made sure to include it because that's the approach being taken by another client (uacme). But it definitely meets all of the uniqueness criteria, and I'm totally willing to implement it if that's the direction you want to go.

I personally like the "only check (or maybe just prioritize) matches with the root" idea, but I don't feel like I'm in a position to know whether changing the semantics of the existing flag in that way would be bad for the certbot userbase.
As long as it fits what I described above (having clear, predictable behavior), I'm good with that option as well.
Hi @aarongable, we are happy to accept a change to match against "a root issuer" rather than "an issuer". 

Thinking about how this (technically backwards-incompatible) will affect users, the [only advice, now outdated](https://community.letsencrypt.org/t/certbot-users-preparing-for-the-isrg-root-transition-january-11-2021/138059) we have given will be unaffected. 

Resolving patch:
diff --git a/certbot/certbot/crypto_util.py b/certbot/certbot/crypto_util.py
--- a/certbot/certbot/crypto_util.py
+++ b/certbot/certbot/crypto_util.py
@@ -573,8 +573,9 @@ def get_serial_from_cert(cert_path):
 
 
 def find_chain_with_issuer(fullchains, issuer_cn, warn_on_no_match=False):
-    """Chooses the first certificate chain from fullchains which contains an
-    Issuer Subject Common Name matching issuer_cn.
+    """Chooses the first certificate chain from fullchains whose topmost
+    intermediate has an Issuer Common Name matching issuer_cn (in other words
+    the first chain which chains to a root whose name matches issuer_cn).
 
     :param fullchains: The list of fullchains in PEM chain format.
     :type fullchains: `list` of `str`
@@ -585,14 +586,11 @@ def find_chain_with_issuer(fullchains, issuer_cn, warn_on_no_match=False):
     :rtype: `str`
     """
     for chain in fullchains:
-        certs = [x509.load_pem_x509_certificate(cert, default_backend()) \
-                 for cert in CERT_PEM_REGEX.findall(chain.encode())]
-        # Iterate the fullchain beginning from the leaf. For each certificate encountered,
-        # match against Issuer Subject CN.
-        for cert in certs:
-            cert_issuer_cn = cert.issuer.get_attributes_for_oid(x509.NameOID.COMMON_NAME)
-            if cert_issuer_cn and cert_issuer_cn[0].value == issuer_cn:
-                return chain
+        certs = CERT_PEM_REGEX.findall(chain.encode())
+        top_cert = x509.load_pem_x509_certificate(certs[-1], default_backend())
+        top_issuer_cn = top_cert.issuer.get_attributes_for_oid(x509.NameOID.COMMON_NAME)
+        if top_issuer_cn and top_issuer_cn[0].value == issuer_cn:
+            return chain
 
     # Nothing matched, return whatever was first in the list.
     if warn_on_no_match:
diff --git a/certbot/certbot/interfaces.py b/certbot/certbot/interfaces.py
--- a/certbot/certbot/interfaces.py
+++ b/certbot/certbot/interfaces.py
@@ -262,9 +262,9 @@ class IConfig(zope.interface.Interface):
         " with \"renew\" verb should be disabled.")
 
     preferred_chain = zope.interface.Attribute(
-        "If the CA offers multiple certificate chains, prefer the chain with "
-        "an issuer matching this Subject Common Name. If no match, the default "
-        "offered chain will be used."
+        "If the CA offers multiple certificate chains, prefer the chain whose "
+        "topmost certificate was issued from this Subject Common Name. "
+        "If no match, the default offered chain will be used."
     )
 
 


Test patch:
diff --git a/certbot/tests/crypto_util_test.py b/certbot/tests/crypto_util_test.py
--- a/certbot/tests/crypto_util_test.py
+++ b/certbot/tests/crypto_util_test.py
@@ -471,6 +471,19 @@ def test_positive_match(self):
         matched = self._call(fullchains, "Pebble Root CA 0cc6f0")
         self.assertEqual(matched, fullchains[1])
 
+    @mock.patch('certbot.crypto_util.logger.info')
+    def test_intermediate_match(self, mock_info):
+        """Don't pick a chain where only an intermediate matches"""
+        fullchains = self._all_fullchains()
+        # Make the second chain actually only contain "Pebble Root CA 0cc6f0"
+        # as an intermediate, not as the root. This wouldn't be a valid chain
+        # (the CERT_ISSUER cert didn't issue the CERT_ALT_ISSUER cert), but the
+        # function under test here doesn't care about that.
+        fullchains[1] = fullchains[1] + CERT_ISSUER.decode()
+        matched = self._call(fullchains, "Pebble Root CA 0cc6f0")
+        self.assertEqual(matched, fullchains[0])
+        mock_info.assert_not_called()
+
     @mock.patch('certbot.crypto_util.logger.info')
     def test_no_match(self, mock_info):
         fullchains = self._all_fullchains()