Repository: certbot/certbot

Issue:
Disambiguate certbot certonly/run behavior on existing ECDSA certificates
This issue concerns the ECDSA certificate generation feature provided by #8254. It is about how Certbot should behave when `certonly` or `run` command are run to generate a certificate for an existing lineage (either explicitly with `--cert-name` file or implicitly because of the set of domains required), as described in the design document: https://docs.google.com/document/d/18pr4F4SVim-bSR6izNV5ZYwolnvitTvmP6gqElDdZPU/edit?usp=sharing

Indeed since the default type of key used is RSA if `--key-type` is not set, we do not want Certbot to silently switch a ECDSA certificate for a RSA, and should in fact expect explicit user approval: passing explicitly the relevant CLI flags.

This will also fix the inverted situation with existing RSA certificates, when/if Certbot switches to ECDSA keys by default.

So, for both `certbot certonly` and `certbot run`:
1) If `--cert-name` is set, `--key-type` is not, a certificate exists for that lineage, and its type is not the same than the target type computed by Certbot, then an error should be raised, asking the user to set `--key-type` explicitly.
2) If neither `--cert-name` and `--key-type` are set, a certificate with an identical/subset of the requested domains exists, and its type is not the same than the computed target type, an error should be raised, asking the user to set `--key-type` and `--cert-name` explicitly.


Discussion:
Block on #8254
> 2. If neither --cert-name and --key-type are set, a certificate with an identical/subset of the requested domains exists, and its type is not the same than the computed target type, an error should be raised, asking the user to set --key-type and --cert-name explicitly.

Is an explicit use of `--cert-name` really necessary in that case? Certbot already has identified the existing lineage. Currently, it isn't required in those specific situations. It asks if you really want to re-issue it or just try to reinstall or it asks to expand an existing certificate, all without the explicit use of `--cert-name`. What's the added benefit when `--key-type` comes into play?
Partially due to things like reactions to my post [here](https://github.com/certbot/certbot/issues/2163#issuecomment-548528167), I think there will be a significant number of Certbot users who want to maintain both ECDSA and RSA certificates for their domains and this check removes any ambiguity about whether the user wants to issue a cert of a different key type along side their existing one or change the key type for that cert name.

The doc that Adrien linked above proposes adding an interactive prompt about this rather than erroring out in the future.
Unblock #8254 from #8431

Resolving patch:
diff --git a/certbot/certbot/_internal/main.py b/certbot/certbot/_internal/main.py
--- a/certbot/certbot/_internal/main.py
+++ b/certbot/certbot/_internal/main.py
@@ -11,7 +11,7 @@
 import zope.component
 
 from acme import errors as acme_errors
-from acme.magic_typing import Union, Iterable, Optional  # pylint: disable=unused-import
+from acme.magic_typing import Union, Iterable, Optional, List, Tuple  # pylint: disable=unused-import
 import certbot
 from certbot import crypto_util
 from certbot import errors
@@ -130,7 +130,33 @@ def _get_and_save_cert(le_client, config, domains=None, certname=None, lineage=N
     return lineage
 
 
-def _handle_subset_cert_request(config, domains, cert):
+def _handle_unexpected_key_type_migration(config, cert):
+    # type: (configuration.NamespaceConfig, storage.RenewableCert) -> None
+    """
+    This function ensures that the user will not implicitly migrate an existing key
+    from one type to another in the situation where a certificate for that lineage
+    already exist and they have not provided explicitly --key-type and --cert-name.
+    :param config: Current configuration provided by the client
+    :param cert: Matching certificate that could be renewed
+    """
+    if not cli.set_by_cli("key_type") or not cli.set_by_cli("certname"):
+
+        new_key_type = config.key_type.upper()
+        cur_key_type = cert.private_key_type.upper()
+
+        if new_key_type != cur_key_type:
+            msg = ('Are you trying to change the key type of the certificate named {0} '
+                   'from {1} to {2}? Please provide both --cert-name and --key-type on '
+                   'the command line confirm the change you are trying to make.')
+            msg = msg.format(cert.lineagename, cur_key_type, new_key_type)
+            raise errors.Error(msg)
+
+
+def _handle_subset_cert_request(config,  # type: configuration.NamespaceConfig
+                                domains,  # type: List[str]
+                                cert  # type: storage.RenewableCert
+                                ):
+    # type: (...) -> Tuple[str, Optional[storage.RenewableCert]]
     """Figure out what to do if a previous cert had a subset of the names now requested
 
     :param config: Configuration object
@@ -147,6 +173,8 @@ def _handle_subset_cert_request(config, domains, cert):
     :rtype: `tuple` of `str`
 
     """
+    _handle_unexpected_key_type_migration(config, cert)
+
     existing = ", ".join(cert.names())
     question = (
         "You have an existing certificate that contains a portion of "
@@ -177,7 +205,10 @@ def _handle_subset_cert_request(config, domains, cert):
     raise errors.Error(USER_CANCELLED)
 
 
-def _handle_identical_cert_request(config, lineage):
+def _handle_identical_cert_request(config,  # type: configuration.NamespaceConfig
+                                   lineage,  # type: storage.RenewableCert
+                                   ):
+    # type: (...) -> Tuple[str, Optional[storage.RenewableCert]]
     """Figure out what to do if a lineage has the same names as a previously obtained one
 
     :param config: Configuration object
@@ -191,6 +222,8 @@ def _handle_identical_cert_request(config, lineage):
     :rtype: `tuple` of `str`
 
     """
+    _handle_unexpected_key_type_migration(config, lineage)
+
     if not lineage.ensure_deployed():
         return "reinstall", lineage
     if renewal.should_renew(config, lineage):
@@ -266,6 +299,7 @@ def _find_lineage_for_domains(config, domains):
         return _handle_subset_cert_request(config, domains, subset_names_cert)
     return None, None
 
+
 def _find_cert(config, domains, certname):
     """Finds an existing certificate object given domains and/or a certificate name.
 
@@ -289,7 +323,12 @@ def _find_cert(config, domains, certname):
         logger.info("Keeping the existing certificate")
     return (action != "reinstall"), lineage
 
-def _find_lineage_for_domains_and_certname(config, domains, certname):
+
+def _find_lineage_for_domains_and_certname(config,  # type: configuration.NamespaceConfig
+                                           domains,  # type: List[str]
+                                           certname  # type: str
+                                           ):
+    # type: (...) -> Tuple[str, Optional[storage.RenewableCert]]
     """Find appropriate lineage based on given domains and/or certname.
 
     :param config: Configuration object
@@ -316,8 +355,9 @@ def _find_lineage_for_domains_and_certname(config, domains, certname):
     if lineage:
         if domains:
             if set(cert_manager.domains_for_certname(config, certname)) != set(domains):
+                _handle_unexpected_key_type_migration(config, lineage)
                 _ask_user_to_confirm_new_names(config, domains, certname,
-                    lineage.names()) # raises if no
+                                               lineage.names())  # raises if no
                 return "renew", lineage
         # unnecessarily specified domains or no domains specified
         return _handle_identical_cert_request(config, lineage)
@@ -386,6 +426,7 @@ def _ask_user_to_confirm_new_names(config, new_domains, certname, old_domains):
     if not obj.yesno(msg, "Update cert", "Cancel", default=True):
         raise errors.ConfigurationError("Specified mismatched cert name and domains.")
 
+
 def _find_domains_or_certname(config, installer, question=None):
     """Retrieve domains and certname from config or user input.
 


Test patch:
diff --git a/certbot-ci/certbot_integration_tests/certbot_tests/test_main.py b/certbot-ci/certbot_integration_tests/certbot_tests/test_main.py
--- a/certbot-ci/certbot_integration_tests/certbot_tests/test_main.py
+++ b/certbot-ci/certbot_integration_tests/certbot_tests/test_main.py
@@ -499,6 +499,16 @@ def test_renew_with_ec_keys(context):
     assert_elliptic_key(key2, SECP384R1)
     assert 280 < os.stat(key2).st_size < 320  # ec keys of 384 bits are ~310 bytes
 
+    # We expect here that the command will fail because without --key-type specified,
+    # Certbot must error out to prevent changing an existing certificate key type,
+    # without explicit user consent (by specifying both --cert-name and --key-type).
+    with pytest.raises(subprocess.CalledProcessError):
+        context.certbot([
+            'certonly',
+            '--force-renewal',
+            '-d', certname
+        ])
+
 
 def test_ocsp_must_staple(context):
     """Test that OCSP Must-Staple is correctly set in the generated certificate."""
diff --git a/certbot/tests/main_test.py b/certbot/tests/main_test.py
--- a/certbot/tests/main_test.py
+++ b/certbot/tests/main_test.py
@@ -49,14 +49,51 @@
 SS_CERT_PATH = test_util.vector_path('cert_2048.pem')
 
 
-class TestHandleIdenticalCerts(unittest.TestCase):
-    """Test for certbot._internal.main._handle_identical_cert_request"""
-    def test_handle_identical_cert_request_pending(self):
+class TestHandleCerts(unittest.TestCase):
+    """Test for certbot._internal.main._handle_* methods"""
+    @mock.patch("certbot._internal.main._handle_unexpected_key_type_migration")
+    def test_handle_identical_cert_request_pending(self, mock_handle_migration):
         mock_lineage = mock.Mock()
         mock_lineage.ensure_deployed.return_value = False
         # pylint: disable=protected-access
         ret = main._handle_identical_cert_request(mock.Mock(), mock_lineage)
         self.assertEqual(ret, ("reinstall", mock_lineage))
+        self.assertTrue(mock_handle_migration.called)
+
+    @mock.patch("certbot._internal.main._handle_unexpected_key_type_migration")
+    def test_handle_subset_cert_request(self, mock_handle_migration):
+        mock_config = mock.Mock()
+        mock_config.expand = True
+        mock_lineage = mock.Mock()
+        mock_lineage.names.return_value = ["dummy1", "dummy2"]
+        ret = main._handle_subset_cert_request(mock_config, ["dummy1"], mock_lineage)
+        self.assertEqual(ret, ("renew", mock_lineage))
+        self.assertTrue(mock_handle_migration.called)
+
+    @mock.patch("certbot._internal.main.cli.set_by_cli")
+    def test_handle_unexpected_key_type_migration(self, mock_set):
+        config = mock.Mock()
+        config.key_type = "rsa"
+        cert = mock.Mock()
+        cert.private_key_type = "ecdsa"
+
+        mock_set.return_value = True
+        main._handle_unexpected_key_type_migration(config, cert)
+
+        mock_set.return_value = False
+        with self.assertRaises(errors.Error) as raised:
+            main._handle_unexpected_key_type_migration(config, cert)
+        self.assertTrue("Please provide both --cert-name and --key-type" in str(raised.exception))
+
+        mock_set.side_effect = lambda var: var != "certname"
+        with self.assertRaises(errors.Error) as raised:
+            main._handle_unexpected_key_type_migration(config, cert)
+        self.assertTrue("Please provide both --cert-name and --key-type" in str(raised.exception))
+
+        mock_set.side_effect = lambda var: var != "key_type"
+        with self.assertRaises(errors.Error) as raised:
+            main._handle_unexpected_key_type_migration(config, cert)
+        self.assertTrue("Please provide both --cert-name and --key-type" in str(raised.exception))
 
 
 class RunTest(test_util.ConfigTestCase):
@@ -163,9 +200,10 @@ def _assert_no_pause(self, message, pause=True):  # pylint: disable=unused-argum
     @mock.patch('certbot._internal.cert_manager.lineage_for_certname')
     @mock.patch('certbot._internal.cert_manager.domains_for_certname')
     @mock.patch('certbot._internal.renewal.renew_cert')
+    @mock.patch('certbot._internal.main._handle_unexpected_key_type_migration')
     @mock.patch('certbot._internal.main._report_new_cert')
     def test_find_lineage_for_domains_and_certname(self, mock_report_cert,
-        mock_renew_cert, mock_domains, mock_lineage):
+        mock_handle_type, mock_renew_cert, mock_domains, mock_lineage):
         domains = ['example.com', 'test.org']
         mock_domains.return_value = domains
         mock_lineage.names.return_value = domains
@@ -175,6 +213,7 @@ def test_find_lineage_for_domains_and_certname(self, mock_report_cert,
         self.assertTrue(mock_domains.call_count == 1)
         self.assertTrue(mock_renew_cert.call_count == 1)
         self.assertTrue(mock_report_cert.call_count == 1)
+        self.assertTrue(mock_handle_type.call_count == 1)
 
         # user confirms updating lineage with new domains
         self._call(('certonly --webroot -d example.com -d test.com '
@@ -183,11 +222,12 @@ def test_find_lineage_for_domains_and_certname(self, mock_report_cert,
         self.assertTrue(mock_domains.call_count == 2)
         self.assertTrue(mock_renew_cert.call_count == 2)
         self.assertTrue(mock_report_cert.call_count == 2)
+        self.assertTrue(mock_handle_type.call_count == 2)
 
         # error in _ask_user_to_confirm_new_names
         self.mock_get_utility().yesno.return_value = False
         self.assertRaises(errors.ConfigurationError, self._call,
-            ('certonly --webroot -d example.com -d test.com --cert-name example.com').split())
+            'certonly --webroot -d example.com -d test.com --cert-name example.com'.split())
 
     @mock.patch('certbot._internal.cert_manager.domains_for_certname')
     @mock.patch('certbot.display.ops.choose_names')
@@ -982,6 +1022,7 @@ def _test_renewal_common(self, due_for_renewal, extra_args, log_out=None,
         mock_lineage.should_autorenew.return_value = due_for_renewal
         mock_lineage.has_pending_deployment.return_value = False
         mock_lineage.names.return_value = ['isnot.org']
+        mock_lineage.private_key_type = 'RSA'
         mock_certr = mock.MagicMock()
         mock_key = mock.MagicMock(pem='pem_key')
         mock_client = mock.MagicMock()