Repository: certbot/certbot

Issue:
--reuse-key ambiguity
If you run
```
sudo certbot -d example.org --standalone certonly
```
Certbot will obtain a certificate using a 2048 bit RSA key.

If you then run
```
sudo certbot -d example.org --standalone certonly --force-renewal --rsa-key-size 4096 --reuse-key
```
the current 2048 bit RSA key is reused in the new certificate and the resulting renewal configuration file contains the following renewal parameters
```
reuse_key = True
rsa_key_size = 4096
```
I don't think this is the correct behavior. We had a brief [conversation about this in Mattermost](https://opensource.eff.org/eff-open-source/pl/4qo67zuj7brcfbjaxsecqy3our), where most people thought erroring out was the correct behavior.

This probably seems right, however, I then think we need a way to specify that a new key should be created and that key should be used for subsequent renewals. Peng suggested maybe using a separate flag for this?

I think we should fix this, however, I think this is technically a breaking change and should be done a little cautiously.


Discussion:
+1 to @m0namon's suggestion of erroring out with a helpful message.
We've made a lot of changes to Certbot since this issue was opened. If you still have this issue with an up-to-date version of Certbot, can you please add a comment letting us know? This helps us to better see what issues are still affecting our users. If there is no activity in the next 30 days, this issue will be automatically closed.

>This probably seems right, however, I then think we need a way to specify that a new key should be created and that key should be used for subsequent renewals. Peng suggested maybe using a separate flag for this?

I've been [looking at `--reuse-key` scenarios](https://docs.google.com/spreadsheets/d/1B1XbsCsgvBOP0TlYhFFXNaQoJTVe_7UEfI_WphaeMp8/edit?usp=sharing) and it's mainly this question that's standing out.

Would it make sense to do a similar thing as we do for "unexpected key type migration"? That is:

- For interactive mode, an interactive prompt (1: Create a new key, or 2: Keep using the existing key). Both options would retain `reuse_key`.
- For non-interactive mode, error out: user needs to provide `--cert-name` in order to confirm the creation of a new key.



One problem I have is I think `--reuse-key` is ambiguous, even with `--cert-name`. When modifying an existing lineage with any subcommand which of the following does `--reuse-key` mean:

1) Reuse the existing key
2) Generate a new key and then reuse that key on subsequent renewals

I think currently it means (1) and I'm not sure the presence of `--cert-name` should change its behavior to (2). What's stopping a user from meaning (1) but still using `--cert-name`?

The details of the interactions with various other settings would need to be worked out, but the approach that I think I'd currently like the best would be to introduce a flag like `--generate-and-reuse-key`. After introducing this flag, `--reuse-key` would always mean (1) and the new flag would always mean (2). What do you think about something like this?
Just to build on the potential alternatives, what about `--new-key` that always means "generate a new key" for commands that issue a new cert, `--reuse-key`that always means "reuse key during renewal", and use both flags to say "generate a new key and reuse it during renewals"? 
For sure `renew --reuse-key` is ambiguous. Certbot can always error in that case, directing them to `certonly/run`.

>One problem I have is I think --reuse-key is ambiguous, even with --cert-name. When modifying an existing lineage with any subcommand which of the following does --reuse-key mean:
> 1.    Reuse the existing key
> 2.  Generate a new key and then reuse that key on subsequent renewals

I'm not feeling the `{certonly,run} --reuse-key` ambiguity. 

Maybe I am alone with this, but I've always understood these verbs as generating a certificate from scratch, without looking at or inheriting the parameters of any previous lineages. It just happens to have the same certificate name.

From that perspective, it seems consistent that (2) is the unambiguous meaning. 

The old lineage is only relevant in that we are adding a guardrail to prevent the user accidentally changing key types (or key parameters under `reuse_key`), as they overwrite the old lineage with a fresh one.

I guess I'm holding onto the idea that we could avoid a new flag, especially since it only applies to a rare case (reuse_key + key param change + non-interactive).

What do you think?
But certonly and run are not always generating a new certificate from scratch: if a certificate already exists for this lineage and is not due to removal, certbot will skip the entire process, making this an equivalent, at least at first sight, to the renew verb.

So I think there is indeed an ambiguity with these verbs and so the interpretation of what `--reuse-key`will do.

It seems that a dedicated flag, either on the specific combination of key for new cert + reuse on renew, or to do a combination of two flags (have you seen my proposition above ?) is the only way to build a clear and unique meaning for each flag. 
>making this an equivalent, at least at first sight, to the renew verb.

Initially I was disagreeing here, because renewal via `{certonly,run}` does not reconstitute renewal params (including `reuse_key`). In that sense, it is not equivalent to the `renew` verb.

But, thinking on this some more, Certbot already applies key reuse if doing a renewal via `{certonly,run}`.

If that's the behavior we want to keep for 2.0, then yes, I agree that a new flag like `--new-key` makes sense. 
I think I like the `--new-key` flag better than my `--generate-and-reuse-key` flag. Thanks for the suggestion Adrien.

I see what you mean about `--reuse-key` with certonly/run Alex. I think there is a bit of awkwardness in the design Adrien and I propose because if we're running certonly/run with `--reuse-key` and **not** modifying an existing lineage, we either need to also require `--new-key` or accept a bit of inconsistency here. (I personally prefer the latter.)

With that said, I think I still personally prefer this design because it's how `--reuse-key` currently works with certonly/run and it allows us to resolve the `renew --reuse-key` ambiguity without resorting to telling people to renew using certonly/run which is something we were trying to avoid.

If we go with this `--new-key` design, I also want to suggest that we may not need an interactive prompt here initially. I think it's a pretty uncommon scenario so I think it may be OK to just error out in the case of `reuse_key`, key params being explicitly set on the CLI which conflict with the existing key, and no `--new-key`. We could always add an interactive prompt later if people find rerunning things after fixing their command too cumbersome.

What do you think?

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
@@ -326,12 +326,57 @@ def _avoid_invalidating_lineage(config: configuration.NamespaceConfig,
                     "unless you use the --break-my-certs flag!")
 
 
+def _avoid_reuse_key_conflicts(config: configuration.NamespaceConfig,
+                               lineage: storage.RenewableCert) -> None:
+    """Don't allow combining --reuse-key with any flags that would conflict
+    with key reuse (--key-type, --rsa-key-size, --elliptic-curve), unless
+    --new-key is also set.
+    """
+    # If --no-reuse-key is set, no conflict
+    if cli.set_by_cli("reuse_key") and not config.reuse_key:
+        return
+
+    # If reuse_key is not set on the lineage and --reuse-key is not
+    # set on the CLI, no conflict.
+    if not lineage.reuse_key and not config.reuse_key:
+        return
+
+    # If --new-key is set, no conflict
+    if config.new_key:
+        return
+
+    kt = config.key_type.lower()
+
+    # The remaining cases where conflicts are present:
+    # - --key-type is set on the CLI and doesn't match the stored private key
+    # - It's an RSA key and --rsa-key-size is set and doesn't match
+    # - It's an ECDSA key and --eliptic-curve is set and doesn't match
+    potential_conflicts = [
+        ("--key-type",
+         lambda: kt != lineage.private_key_type.lower()),
+        ("--rsa-key-type",
+         lambda: kt == "rsa" and config.rsa_key_size != lineage.rsa_key_size),
+        ("--elliptic-curve",
+         lambda: kt == "ecdsa" and lineage.elliptic_curve and \
+                 config.elliptic_curve.lower() != lineage.elliptic_curve.lower())
+    ]
+
+    for conflict in potential_conflicts:
+        if conflict[1]():
+            raise errors.Error(
+                f"Unable to change the {conflict[0]} of this certificate because --reuse-key "
+                "is set. To stop reusing the private key, specify --no-reuse-key. "
+                "To change the private key this one time and then reuse it in future, "
+                "add --new-key.")
+
+
 def renew_cert(config: configuration.NamespaceConfig, domains: Optional[List[str]],
                le_client: client.Client, lineage: 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)
+    _avoid_reuse_key_conflicts(config, lineage)
     if not domains:
         domains = lineage.names()
     # The private key is the existing lineage private key if reuse_key is set.
diff --git a/certbot/certbot/_internal/storage.py b/certbot/certbot/_internal/storage.py
--- a/certbot/certbot/_internal/storage.py
+++ b/certbot/certbot/_internal/storage.py
@@ -12,10 +12,12 @@
 from typing import Mapping
 from typing import Optional
 from typing import Tuple
+from typing import Union
 
 import configobj
 from cryptography.hazmat.backends import default_backend
 from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
+from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
 from cryptography.hazmat.primitives.serialization import load_pem_private_key
 import parsedatetime
 import pkg_resources
@@ -569,6 +571,12 @@ def is_test_cert(self) -> bool:
             return util.is_staging(self.server)
         return False
 
+    @property
+    def reuse_key(self) -> bool:
+        """Returns whether this certificate is configured to reuse its private key"""
+        return "reuse_key" in self.configuration["renewalparams"] and \
+               self.configuration["renewalparams"].as_bool("reuse_key")
+
     def _check_symlinks(self) -> None:
         """Raises an exception if a symlink doesn't exist"""
         for kind in ALL_FOUR:
@@ -1115,22 +1123,47 @@ def new_lineage(cls, lineagename: str, cert: bytes, privkey: bytes, chain: bytes
             target, values)
         return cls(new_config.filename, cli_config)
 
-    @property
-    def private_key_type(self) -> str:
-        """
-        :returns: The type of algorithm for the private, RSA or ECDSA
-        :rtype: str
-        """
+    def _private_key(self) -> Union[RSAPrivateKey, EllipticCurvePrivateKey]:
         with open(self.configuration["privkey"], "rb") as priv_key_file:
             key = load_pem_private_key(
                 data=priv_key_file.read(),
                 password=None,
                 backend=default_backend()
             )
+            return key
+
+    @property
+    def private_key_type(self) -> str:
+        """
+        :returns: The type of algorithm for the private, RSA or ECDSA
+        :rtype: str
+        """
+        key = self._private_key()
         if isinstance(key, RSAPrivateKey):
             return "RSA"
-        else:
-            return "ECDSA"
+        return "ECDSA"
+
+    @property
+    def rsa_key_size(self) -> Optional[int]:
+        """
+        :returns: If the private key is an RSA key, its size.
+        :rtype: int
+        """
+        key = self._private_key()
+        if isinstance(key, RSAPrivateKey):
+            return key.key_size
+        return None
+
+    @property
+    def elliptic_curve(self) -> Optional[str]:
+        """
+        :returns: If the private key is an elliptic key, the name of its curve.
+        :rtype: str
+        """
+        key = self._private_key()
+        if isinstance(key, EllipticCurvePrivateKey):
+            return key.curve.name
+        return None
 
     def save_successor(self, prior_version: int, new_cert: bytes, new_privkey: bytes,
                        new_chain: bytes, cli_config: configuration.NamespaceConfig) -> int:


Test patch:
diff --git a/certbot-ci/certbot_integration_tests/certbot_tests/assertions.py b/certbot-ci/certbot_integration_tests/certbot_tests/assertions.py
--- a/certbot-ci/certbot_integration_tests/certbot_tests/assertions.py
+++ b/certbot-ci/certbot_integration_tests/certbot_tests/assertions.py
@@ -33,8 +33,8 @@ def assert_elliptic_key(key: str, curve: Type[EllipticCurve]) -> None:
 
     key = load_pem_private_key(data=privkey1, password=None, backend=default_backend())
 
-    assert isinstance(key, EllipticCurvePrivateKey)
-    assert isinstance(key.curve, curve)
+    assert isinstance(key, EllipticCurvePrivateKey), f"should be an EC key but was {type(key)}"
+    assert isinstance(key.curve, curve), f"should have curve {curve} but was {key.curve}"
 
 
 def assert_rsa_key(key: str, key_size: Optional[int] = None) -> None:
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,19 @@ def private_key(generation: int) -> Tuple[str, str]:
     assert_saved_lineage_option(context.config_dir, certname, 'reuse_key', 'True')
     assert_elliptic_key(privkey4_path, SECP256R1)
 
+    # certonly: it should not be possible to change a key parameter without --new-key
+    with pytest.raises(subprocess.CalledProcessError) as error:
+        context.certbot(['certonly', '-d', certname, '--reuse-key',
+                         '--elliptic-curve', 'secp384r1'])
+    assert 'Unable to change the --elliptic-curve' in error.value.stderr
+
+    # certonly: not specifying --key-type should keep the existing key type (non-interactively).
+    # TODO: when ECDSA is made default key type, the key types must be inverted
+    context.certbot(['certonly', '-d', certname, '--no-reuse-key'])
+    privkey5, privkey5_path = private_key(5)
+    assert_elliptic_key(privkey5_path, SECP256R1)
+    assert privkey4 != privkey5
+
 
 def test_incorrect_key_type(context: IntegrationTestsContext) -> None:
     with pytest.raises(subprocess.CalledProcessError):
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
@@ -1192,6 +1192,7 @@ def _test_renewal_common(self, due_for_renewal, extra_args, log_out=None,
         mock_lineage.has_pending_deployment.return_value = False
         mock_lineage.names.return_value = ['isnot.org']
         mock_lineage.private_key_type = 'RSA'
+        mock_lineage.rsa_key_size = 2048
         mock_certr = mock.MagicMock()
         mock_key = mock.MagicMock(pem='pem_key')
         mock_client = mock.MagicMock()
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
@@ -55,7 +55,8 @@ 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):
+    @mock.patch('certbot._internal.renewal._avoid_reuse_key_conflicts')
+    def test_reuse_key_renewal_params(self, unused_mock_avoid_reuse_conflicts):
         self.config.rsa_key_size = 'INVALID_VALUE'
         self.config.reuse_key = True
         self.config.dry_run = True
@@ -75,7 +76,8 @@ def test_reuse_key_renewal_params(self):
 
         assert self.config.rsa_key_size == 2048
 
-    def test_reuse_ec_key_renewal_params(self):
+    @mock.patch('certbot._internal.renewal._avoid_reuse_key_conflicts')
+    def test_reuse_ec_key_renewal_params(self, unused_mock_avoid_reuse_conflicts):
         self.config.elliptic_curve = 'INVALID_CURVE'
         self.config.reuse_key = True
         self.config.dry_run = True
@@ -99,7 +101,9 @@ def test_reuse_ec_key_renewal_params(self):
 
         assert self.config.elliptic_curve == 'secp256r1'
 
-    def test_new_key(self):
+    @mock.patch('certbot._internal.renewal.cli.set_by_cli')
+    def test_new_key(self, mock_set_by_cli):
+        mock_set_by_cli.return_value = False
         # When renewing with both reuse_key and new_key, the key should be regenerated,
         # the key type, key parameters and reuse_key should be kept.
         self.config.reuse_key = True
@@ -125,6 +129,38 @@ def test_new_key(self):
         # None is passed as the existing key, i.e. the key is not actually being reused.
         le_client.obtain_certificate.assert_called_with(mock.ANY, None)
 
+    @mock.patch('certbot._internal.renewal.hooks.renew_hook')
+    @mock.patch('certbot._internal.renewal.cli.set_by_cli')
+    def test_reuse_key_conflicts(self, mock_set_by_cli, unused_mock_renew_hook):
+        mock_set_by_cli.return_value = False
+
+        # When renewing with reuse_key and a conflicting key parameter (size, curve)
+        # an error should be raised ...
+        self.config.reuse_key = True
+        self.config.key_type = "rsa"
+        self.config.rsa_key_size = 4096
+        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)
+        lineage.configuration["renewalparams"]["reuse_key"] = True
+
+        le_client = mock.MagicMock()
+        le_client.obtain_certificate.return_value = (None, None, None, None)
+
+        from certbot._internal import renewal
+
+        with self.assertRaisesRegex(errors.Error, "Unable to change the --rsa-key-type"):
+            renewal.renew_cert(self.config, None, le_client, lineage)
+
+        # ... unless --no-reuse-key is set
+        mock_set_by_cli.side_effect = lambda var: var == "reuse_key"
+        self.config.reuse_key = False
+        renewal.renew_cert(self.config, None, le_client, lineage)
+
     @test_util.patch_display_util()
     @mock.patch('certbot._internal.renewal.cli.set_by_cli')
     def test_remove_deprecated_config_elements(self, mock_set_by_cli, unused_mock_get_utility):