Repository: certbot/certbot

Issue:
Certbot forgets key size
If you run a command like
```
sudo certbot certonly -d example.org --standalone --rsa-key-size 4096
```
it will result in a renewal configuration file containing the following renewal parameters:
```
rsa_key_size = 4096
authenticator = standalone
```
If you then run
```
sudo certbot certonly -d example.org --standalone --reuse-key --force-renewal
```
`rsa_key_size` is dropped from the renewal configuration file despite it still being a 4096 bit key.

I think we should preserve this value.


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
@@ -9,16 +9,21 @@
 import time
 import traceback
 
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.primitives.serialization import load_pem_private_key
 import OpenSSL
 import six
 import zope.component
 
 from acme.magic_typing import List
+from acme.magic_typing import Optional  # pylint: disable=unused-import
 from certbot import crypto_util
 from certbot import errors
 from certbot import interfaces
 from certbot import util
 from certbot._internal import cli
+from certbot._internal import client  # pylint: disable=unused-import
 from certbot._internal import constants
 from certbot._internal import hooks
 from certbot._internal import storage
@@ -308,7 +313,8 @@ def _avoid_invalidating_lineage(config, lineage, original_server):
 
 
 def renew_cert(config, domains, le_client, lineage):
-    "Renew a certificate lineage."
+    # type: (interfaces.IConfig, Optional[List[str]], client.Client, storage.RenewableCert) -> None
+    """Renew a certificate lineage."""
     renewal_params = lineage.configuration["renewalparams"]
     original_server = renewal_params.get("server", cli.flag_default("server"))
     _avoid_invalidating_lineage(config, lineage, original_server)
@@ -316,11 +322,14 @@ def renew_cert(config, domains, le_client, lineage):
         domains = lineage.names()
     # The private key is the existing lineage private key if reuse_key is set.
     # Otherwise, generate a fresh private key by passing None.
-    new_key = os.path.normpath(lineage.privkey) if config.reuse_key else None
+    if config.reuse_key:
+        new_key = os.path.normpath(lineage.privkey)
+        _update_renewal_params_from_key(new_key, config)
+    else:
+        new_key = None
     new_cert, new_chain, new_key, _ = le_client.obtain_certificate(domains, new_key)
     if config.dry_run:
-        logger.debug("Dry run: skipping updating lineage at %s",
-                    os.path.dirname(lineage.cert))
+        logger.debug("Dry run: skipping updating lineage at %s", os.path.dirname(lineage.cert))
     else:
         prior_version = lineage.latest_common_version()
         # TODO: Check return value of save_successor
@@ -335,6 +344,7 @@ def report(msgs, category):
     lines = ("%s (%s)" % (m, category) for m in msgs)
     return "  " + "\n  ".join(lines)
 
+
 def _renew_describe_results(config, renew_successes, renew_failures,
                             renew_skipped, parse_failures):
 
@@ -489,3 +499,13 @@ def handle_renewal_request(config):
     # Windows installer integration tests rely on handle_renewal_request behavior here.
     # If the text below changes, these tests will need to be updated accordingly.
     logger.debug("no renewal failures")
+
+
+def _update_renewal_params_from_key(key_path, config):
+    # type: (str, interfaces.IConfig) -> None
+    with open(key_path, 'rb') as file_h:
+        key = load_pem_private_key(file_h.read(), password=None, backend=default_backend())
+    if isinstance(key, rsa.RSAPrivateKey):
+        config.rsa_key_size = key.key_size
+    else:
+        raise errors.Error('Key at {0} is of an unsupported type: {1}.'.format(key_path, type(key)))


Test patch:
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
@@ -3,7 +3,7 @@
 
 try:
     import mock
-except ImportError: # pragma: no cover
+except ImportError:  # pragma: no cover
     from unittest import mock
 
 from acme import challenges
@@ -54,6 +54,26 @@ def test_webroot_params_conservation(self, mock_set_by_cli):
         self.assertEqual(self.config.webroot_map, {})
         self.assertEqual(self.config.webroot_path, ['/var/www/test'])
 
+    def test_reuse_key_renewal_params(self):
+        self.config.rsa_key_size = 'INVALID_VALUE'
+        self.config.reuse_key = True
+        self.config.dry_run = True
+        config = configuration.NamespaceConfig(self.config)
+
+        rc_path = test_util.make_lineage(
+            self.config.config_dir, 'sample-renewal.conf')
+        lineage = storage.RenewableCert(rc_path, config)
+
+        le_client = mock.MagicMock()
+        le_client.obtain_certificate.return_value = (None, None, None, None)
+
+        from certbot._internal import renewal
+
+        with mock.patch('certbot._internal.renewal.hooks.renew_hook'):
+            renewal.renew_cert(self.config, None, le_client, lineage)
+
+        assert self.config.rsa_key_size == 2048
+
 
 class RestoreRequiredConfigElementsTest(test_util.ConfigTestCase):
     """Tests for certbot._internal.renewal.restore_required_config_elements."""