Repository: certbot/certbot

Issue:
Certbot 2.x `renew` incorrectly changes key type to ECDSA if key_type is absent from renewal conf
See https://community.letsencrypt.org/t/existing-rsa-key-cert-renewed-as-ecdsa-on-cerbot-upgrade/195317.

Reproduction:

    certbot certonly -d example.com --key-type rsa
    sed -i .bak 's/key_type = rsa//' /etc/letsencrypt/renewal/example.com.conf
    certbot renew --cert-name example.com --force-renewal

The key type changes to ECDSA, but it shouldn't.

I think what's happened is that the key preservation relies on https://github.com/certbot/certbot/pull/9217, which is not available in Certbot <v1.25.0.

Upgrading from an older version causes the `key_type` not to be reconstituted correctly at the next renewal.

The unexpected key migration code only affects the `run` and `certonly` verbs, `renew` is untested and not working correctly with respect to key type preservation.


Discussion:

Resolving patch:
diff --git a/certbot/certbot/_internal/renewal.py b/certbot/certbot/_internal/renewal.py
--- a/certbot/certbot/_internal/renewal.py
+++ b/certbot/certbot/_internal/renewal.py
@@ -87,6 +87,14 @@ def reconstitute(config: configuration.NamespaceConfig,
         logger.error("Renewal configuration file %s does not specify "
                        "an authenticator. Skipping.", full_path)
         return None
+
+    # Prior to Certbot v1.25.0, the default value of key_type (rsa) was not persisted to the
+    # renewal params. If the option is absent, it means the certificate was an RSA key.
+    # Restoring the option here is necessary to preserve the certificate key_type if
+    # the user has upgraded directly from Certbot <v1.25.0 to >=v2.0.0, where the default
+    # key_type was changed to ECDSA. See https://github.com/certbot/certbot/issues/9635.
+    renewalparams["key_type"] = renewalparams.get("key_type", "rsa")
+
     # Now restore specific values along with their data types, if
     # those elements are present.
     renewalparams = _remove_deprecated_config_elements(renewalparams)


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
@@ -936,3 +936,24 @@ def test_preferred_chain(context: IntegrationTestsContext) -> None:
         with open(conf_path, 'r') as f:
             assert f'preferred_chain = {requested}' in f.read(), \
                    'Expected preferred_chain to be set in renewal config'
+
+
+def test_ancient_rsa_key_type_preserved(context: IntegrationTestsContext) -> None:
+    certname = context.get_domain('newname')
+    context.certbot(['certonly', '-d', certname, '--key-type', 'rsa'])
+    assert_saved_lineage_option(context.config_dir, certname, 'key_type', 'rsa')
+
+    # Remove `key_type = rsa` from the renewal config to emulate a <v1.25.0 Certbot certificate.
+    conf_path = join(context.config_dir, 'renewal', f'{certname}.conf')
+    conf_contents: str = ''
+    with open(conf_path) as f:
+        conf_contents = f.read()
+    conf_contents = conf_contents.replace('key_type = rsa', '')
+    with open(conf_path, 'w') as f:
+        f.write(conf_contents)
+
+    context.certbot(['renew', '--cert-name', certname, '--force-renewal'])
+
+    assert_saved_lineage_option(context.config_dir, certname, 'key_type', 'rsa')
+    key2 = join(context.config_dir, 'archive/{0}/privkey2.pem'.format(certname))
+    assert_rsa_key(key2, 2048)
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
@@ -1472,13 +1472,13 @@ def test_renew_verb(self):
         self._test_renewal_common(True, [], args=args, should_renew=True)
 
     def test_reuse_key(self):
-        test_util.make_lineage(self.config.config_dir, 'sample-renewal.conf')
+        test_util.make_lineage(self.config.config_dir, 'sample-renewal.conf', ec=False)
         args = ["renew", "--dry-run", "--reuse-key"]
         self._test_renewal_common(True, [], args=args, should_renew=True, reuse_key=True)
 
     @mock.patch('certbot._internal.storage.RenewableCert.save_successor')
     def test_reuse_key_no_dry_run(self, unused_save_successor):
-        test_util.make_lineage(self.config.config_dir, 'sample-renewal.conf')
+        test_util.make_lineage(self.config.config_dir, 'sample-renewal.conf', ec=False)
         args = ["renew", "--reuse-key"]
         self._test_renewal_common(True, [], args=args, should_renew=True, reuse_key=True)
 
diff --git a/certbot/tests/renewal_test.py b/certbot/tests/renewal_test.py
--- a/certbot/tests/renewal_test.py
+++ b/certbot/tests/renewal_test.py
@@ -177,6 +177,17 @@ def test_remove_deprecated_config_elements(self, mock_set_by_cli, unused_mock_ge
         # value in the renewal conf file
         assert isinstance(lineage_config.manual_public_ip_logging_ok, mock.MagicMock)
 
+    @mock.patch('certbot._internal.renewal.cli.set_by_cli')
+    def test_absent_key_type_restored(self, mock_set_by_cli):
+        mock_set_by_cli.return_value = False
+
+        rc_path = test_util.make_lineage(self.config.config_dir, 'sample-renewal.conf', ec=False)
+
+        from certbot._internal import renewal
+        lineage_config = copy.deepcopy(self.config)
+        renewal.reconstitute(lineage_config, rc_path)
+        assert lineage_config.key_type == 'rsa'
+
 
 class RestoreRequiredConfigElementsTest(test_util.ConfigTestCase):
     """Tests for certbot._internal.renewal.restore_required_config_elements."""