error out when --reuse-key conflicts with other flags (#9262)

* error out when --reuse-key conflicts with other flags

* add unit test

* add integration tests

* lint
This commit is contained in:
alexzorin
2022-09-27 12:37:24 +10:00
committed by GitHub
parent c42dd567ca
commit 212c2ba990
6 changed files with 141 additions and 13 deletions
@@ -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()) key = load_pem_private_key(data=privkey1, password=None, backend=default_backend())
assert isinstance(key, EllipticCurvePrivateKey) assert isinstance(key, EllipticCurvePrivateKey), f"should be an EC key but was {type(key)}"
assert isinstance(key.curve, curve) 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: def assert_rsa_key(key: str, key_size: Optional[int] = None) -> None:
@@ -507,6 +507,19 @@ def test_new_key(context: IntegrationTestsContext) -> None:
assert_saved_lineage_option(context.config_dir, certname, 'reuse_key', 'True') assert_saved_lineage_option(context.config_dir, certname, 'reuse_key', 'True')
assert_elliptic_key(privkey4_path, SECP256R1) 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: def test_incorrect_key_type(context: IntegrationTestsContext) -> None:
with pytest.raises(subprocess.CalledProcessError): with pytest.raises(subprocess.CalledProcessError):
+45
View File
@@ -324,12 +324,57 @@ def _avoid_invalidating_lineage(config: configuration.NamespaceConfig,
"unless you use the --break-my-certs flag!") "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]], def renew_cert(config: configuration.NamespaceConfig, domains: Optional[List[str]],
le_client: client.Client, lineage: storage.RenewableCert) -> None: le_client: client.Client, lineage: storage.RenewableCert) -> None:
"""Renew a certificate lineage.""" """Renew a certificate lineage."""
renewal_params = lineage.configuration["renewalparams"] renewal_params = lineage.configuration["renewalparams"]
original_server = renewal_params.get("server", cli.flag_default("server")) original_server = renewal_params.get("server", cli.flag_default("server"))
_avoid_invalidating_lineage(config, lineage, original_server) _avoid_invalidating_lineage(config, lineage, original_server)
_avoid_reuse_key_conflicts(config, lineage)
if not domains: if not domains:
domains = lineage.names() domains = lineage.names()
# The private key is the existing lineage private key if reuse_key is set. # The private key is the existing lineage private key if reuse_key is set.
+41 -8
View File
@@ -12,10 +12,12 @@ from typing import List
from typing import Mapping from typing import Mapping
from typing import Optional from typing import Optional
from typing import Tuple from typing import Tuple
from typing import Union
import configobj import configobj
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey 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 from cryptography.hazmat.primitives.serialization import load_pem_private_key
import parsedatetime import parsedatetime
import pkg_resources import pkg_resources
@@ -569,6 +571,12 @@ class RenewableCert(interfaces.RenewableCert):
return util.is_staging(self.server) return util.is_staging(self.server)
return False 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: def _check_symlinks(self) -> None:
"""Raises an exception if a symlink doesn't exist""" """Raises an exception if a symlink doesn't exist"""
for kind in ALL_FOUR: for kind in ALL_FOUR:
@@ -1115,22 +1123,47 @@ class RenewableCert(interfaces.RenewableCert):
target, values) target, values)
return cls(new_config.filename, cli_config) return cls(new_config.filename, cli_config)
@property def _private_key(self) -> Union[RSAPrivateKey, EllipticCurvePrivateKey]:
def private_key_type(self) -> str:
"""
:returns: The type of algorithm for the private, RSA or ECDSA
:rtype: str
"""
with open(self.configuration["privkey"], "rb") as priv_key_file: with open(self.configuration["privkey"], "rb") as priv_key_file:
key = load_pem_private_key( key = load_pem_private_key(
data=priv_key_file.read(), data=priv_key_file.read(),
password=None, password=None,
backend=default_backend() 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): if isinstance(key, RSAPrivateKey):
return "RSA" 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, def save_successor(self, prior_version: int, new_cert: bytes, new_privkey: bytes,
new_chain: bytes, cli_config: configuration.NamespaceConfig) -> int: new_chain: bytes, cli_config: configuration.NamespaceConfig) -> int:
+1
View File
@@ -1209,6 +1209,7 @@ class MainTest(test_util.ConfigTestCase):
mock_lineage.has_pending_deployment.return_value = False mock_lineage.has_pending_deployment.return_value = False
mock_lineage.names.return_value = ['isnot.org'] mock_lineage.names.return_value = ['isnot.org']
mock_lineage.private_key_type = 'RSA' mock_lineage.private_key_type = 'RSA'
mock_lineage.rsa_key_size = 2048
mock_certr = mock.MagicMock() mock_certr = mock.MagicMock()
mock_key = mock.MagicMock(pem='pem_key') mock_key = mock.MagicMock(pem='pem_key')
mock_client = mock.MagicMock() mock_client = mock.MagicMock()
+39 -3
View File
@@ -55,7 +55,8 @@ class RenewalTest(test_util.ConfigTestCase):
self.assertEqual(self.config.webroot_map, {}) self.assertEqual(self.config.webroot_map, {})
self.assertEqual(self.config.webroot_path, ['/var/www/test']) 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.rsa_key_size = 'INVALID_VALUE'
self.config.reuse_key = True self.config.reuse_key = True
self.config.dry_run = True self.config.dry_run = True
@@ -75,7 +76,8 @@ class RenewalTest(test_util.ConfigTestCase):
assert self.config.rsa_key_size == 2048 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.elliptic_curve = 'INVALID_CURVE'
self.config.reuse_key = True self.config.reuse_key = True
self.config.dry_run = True self.config.dry_run = True
@@ -99,7 +101,9 @@ class RenewalTest(test_util.ConfigTestCase):
assert self.config.elliptic_curve == 'secp256r1' 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, # 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. # the key type, key parameters and reuse_key should be kept.
self.config.reuse_key = True self.config.reuse_key = True
@@ -125,6 +129,38 @@ class RenewalTest(test_util.ConfigTestCase):
# None is passed as the existing key, i.e. the key is not actually being reused. # 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) 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() @test_util.patch_display_util()
@mock.patch('certbot._internal.renewal.cli.set_by_cli') @mock.patch('certbot._internal.renewal.cli.set_by_cli')
def test_remove_deprecated_config_elements(self, mock_set_by_cli, unused_mock_get_utility): def test_remove_deprecated_config_elements(self, mock_set_by_cli, unused_mock_get_utility):