Repository: Netflix/lemur

Issue:
ACME plugin should store account credentials per authority
Currently the ACME plugin, either reads the account details from `ACME_PRIVATE_KEY` and `ACME_REGR` configuration variables, or always creates a new account for each request.

This can lead to rate limit exhaustion if you try to create a lot of certificates at once, and also makes it impossible, to revoke a ACME certificate, since the revocation needs to be done using the same account as the certificate was issued for.

When creating a client, it tries to read the the private key and the registration resource from the authority
https://github.com/Netflix/lemur/blob/772894c414baca916c44ce1f91fb3fe6b33c88f1/lemur/plugins/lemur_acme/plugin.py#L237-L240

But since they aren't stored if a new account was created the first time around, this is futile.

After a new registration is done, it should store the credentials in the authority
https://github.com/Netflix/lemur/blob/772894c414baca916c44ce1f91fb3fe6b33c88f1/lemur/plugins/lemur_acme/plugin.py#L253-L265


Discussion:

Resolving patch:
diff --git a/lemur/authorities/service.py b/lemur/authorities/service.py
--- a/lemur/authorities/service.py
+++ b/lemur/authorities/service.py
@@ -39,6 +39,22 @@ def update(authority_id, description, owner, active, roles):
     return database.update(authority)
 
 
+def update_options(authority_id, options):
+    """
+    Update an authority with new options.
+
+    :param authority_id:
+    :param options: the new options to be saved into the authority
+    :return:
+    """
+
+    authority = get(authority_id)
+
+    authority.options = options
+
+    return database.update(authority)
+
+
 def mint(**kwargs):
     """
     Creates the authority based on the plugin provided.
diff --git a/lemur/plugins/lemur_acme/plugin.py b/lemur/plugins/lemur_acme/plugin.py
--- a/lemur/plugins/lemur_acme/plugin.py
+++ b/lemur/plugins/lemur_acme/plugin.py
@@ -32,6 +32,7 @@
 from lemur.plugins import lemur_acme as acme
 from lemur.plugins.bases import IssuerPlugin
 from lemur.plugins.lemur_acme import cloudflare, dyn, route53, ultradns, powerdns
+from lemur.authorities import service as authorities_service
 from retrying import retry
 
 
@@ -240,6 +241,7 @@ def setup_acme_client(self, authority):
         existing_regr = options.get("acme_regr", current_app.config.get("ACME_REGR"))
 
         if existing_key and existing_regr:
+            current_app.logger.debug("Reusing existing ACME account")
             # Reuse the same account for each certificate issuance
             key = jose.JWK.json_loads(existing_key)
             regr = messages.RegistrationResource.json_loads(existing_regr)
@@ -253,6 +255,7 @@ def setup_acme_client(self, authority):
             # Create an account for each certificate issuance
             key = jose.JWKRSA(key=generate_private_key("RSA2048"))
 
+            current_app.logger.debug("Creating a new ACME account")
             current_app.logger.debug(
                 "Connecting with directory at {0}".format(directory_url)
             )
@@ -262,6 +265,27 @@ def setup_acme_client(self, authority):
             registration = client.new_account_and_tos(
                 messages.NewRegistration.from_data(email=email)
             )
+
+            # if store_account is checked, add the private_key and registration resources to the options
+            if options['store_account']:
+                new_options = json.loads(authority.options)
+                # the key returned by fields_to_partial_json is missing the key type, so we add it manually
+                key_dict = key.fields_to_partial_json()
+                key_dict["kty"] = "RSA"
+                acme_private_key = {
+                    "name": "acme_private_key",
+                    "value": json.dumps(key_dict)
+                }
+                new_options.append(acme_private_key)
+
+                acme_regr = {
+                    "name": "acme_regr",
+                    "value": json.dumps({"body": {}, "uri": registration.uri})
+                }
+                new_options.append(acme_regr)
+
+                authorities_service.update_options(authority.id, options=json.dumps(new_options))
+
             current_app.logger.debug("Connected: {0}".format(registration.uri))
 
         return client, registration
@@ -447,6 +471,13 @@ class ACMEIssuerPlugin(IssuerPlugin):
             "validation": "/^-----BEGIN CERTIFICATE-----/",
             "helpMessage": "Certificate to use",
         },
+        {
+            "name": "store_account",
+            "type": "bool",
+            "required": False,
+            "helpMessage": "Disable to create a new account for each ACME request",
+            "default": False,
+        }
     ]
 
     def __init__(self, *args, **kwargs):


Test patch:
diff --git a/lemur/plugins/lemur_acme/tests/test_acme.py b/lemur/plugins/lemur_acme/tests/test_acme.py
--- a/lemur/plugins/lemur_acme/tests/test_acme.py
+++ b/lemur/plugins/lemur_acme/tests/test_acme.py
@@ -1,8 +1,10 @@
 import unittest
 from unittest.mock import patch, Mock
 
+import josepy as jose
 from cryptography.x509 import DNSName
 from lemur.plugins.lemur_acme import plugin
+from lemur.common.utils import generate_private_key
 from mock import MagicMock
 
 
@@ -165,11 +167,65 @@ def test_setup_acme_client_fail(self):
         with self.assertRaises(Exception):
             self.acme.setup_acme_client(mock_authority)
 
+    @patch("lemur.plugins.lemur_acme.plugin.jose.JWK.json_loads")
     @patch("lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2")
     @patch("lemur.plugins.lemur_acme.plugin.current_app")
-    def test_setup_acme_client_success(self, mock_current_app, mock_acme):
+    def test_setup_acme_client_success_load_account_from_authority(self, mock_current_app, mock_acme, mock_key_json_load):
         mock_authority = Mock()
-        mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}]'
+        mock_authority.id = 2
+        mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \
+                                 '{"name": "store_account", "value": true},' \
+                                 '{"name": "acme_private_key", "value": "{\\"n\\": \\"PwIOkViO\\", \\"kty\\": \\"RSA\\"}"}, ' \
+                                 '{"name": "acme_regr", "value": "{\\"body\\": {}, \\"uri\\": \\"http://test.com\\"}"}]'
+        mock_client = Mock()
+        mock_acme.return_value = mock_client
+        mock_current_app.config = {}
+
+        mock_key_json_load.return_value = jose.JWKRSA(key=generate_private_key("RSA2048"))
+
+        result_client, result_registration = self.acme.setup_acme_client(mock_authority)
+
+        mock_acme.new_account_and_tos.assert_not_called()
+        assert result_client
+        assert not result_registration
+
+    @patch("lemur.plugins.lemur_acme.plugin.jose.JWKRSA.fields_to_partial_json")
+    @patch("lemur.plugins.lemur_acme.plugin.authorities_service")
+    @patch("lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2")
+    @patch("lemur.plugins.lemur_acme.plugin.current_app")
+    def test_setup_acme_client_success_store_new_account(self, mock_current_app, mock_acme, mock_authorities_service,
+                                                         mock_key_generation):
+        mock_authority = Mock()
+        mock_authority.id = 2
+        mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \
+                                 '{"name": "store_account", "value": true}]'
+        mock_client = Mock()
+        mock_registration = Mock()
+        mock_registration.uri = "http://test.com"
+        mock_client.register = mock_registration
+        mock_client.agree_to_tos = Mock(return_value=True)
+        mock_client.new_account_and_tos.return_value = mock_registration
+        mock_acme.return_value = mock_client
+        mock_current_app.config = {}
+
+        mock_key_generation.return_value = {"n": "PwIOkViO"}
+
+        mock_authorities_service.update_options = Mock(return_value=True)
+
+        self.acme.setup_acme_client(mock_authority)
+
+        mock_authorities_service.update_options.assert_called_with(2, options='[{"name": "mock_name", "value": "mock_value"}, '
+        '{"name": "store_account", "value": true}, '
+        '{"name": "acme_private_key", "value": "{\\"n\\": \\"PwIOkViO\\", \\"kty\\": \\"RSA\\"}"}, '
+        '{"name": "acme_regr", "value": "{\\"body\\": {}, \\"uri\\": \\"http://test.com\\"}"}]')
+
+    @patch("lemur.plugins.lemur_acme.plugin.authorities_service")
+    @patch("lemur.plugins.lemur_acme.plugin.BackwardsCompatibleClientV2")
+    @patch("lemur.plugins.lemur_acme.plugin.current_app")
+    def test_setup_acme_client_success(self, mock_current_app, mock_acme, mock_authorities_service):
+        mock_authority = Mock()
+        mock_authority.options = '[{"name": "mock_name", "value": "mock_value"}, ' \
+                                 '{"name": "store_account", "value": false}]'
         mock_client = Mock()
         mock_registration = Mock()
         mock_registration.uri = "http://test.com"
@@ -178,6 +234,7 @@ def test_setup_acme_client_success(self, mock_current_app, mock_acme):
         mock_acme.return_value = mock_client
         mock_current_app.config = {}
         result_client, result_registration = self.acme.setup_acme_client(mock_authority)
+        mock_authorities_service.update_options.assert_not_called()
         assert result_client
         assert result_registration