Repository: certbot/certbot

Issue:
Remove e-mail address from registration
The subcommand `certbot update_account` allows users to update the email address in their ACME account. Currently, Certbot does not allow users to remove all email addresses from a registration. See the code for [update_account](https://github.com/certbot/certbot/blob/df1ca726f975b4f2cf96ada29785a4d35027e5f9/certbot/certbot/_internal/main.py#L684-L727).

We should allow users to do this, probably by reusing the `--register-unsafely-without-email` flag since it is usually a bad idea to have a Let's Encrypt account with no contact information.

This issue relies on https://github.com/certbot/certbot/issues/5129 being resolved which you may want to do in a PR before this one.

EDIT: My original description of this issue was very sparse and vague so I rewrote it to provide more information.
Remove e-mail address from registration
The subcommand `certbot update_account` allows users to update the email address in their ACME account. Currently, Certbot does not allow users to remove all email addresses from a registration. See the code for [update_account](https://github.com/certbot/certbot/blob/df1ca726f975b4f2cf96ada29785a4d35027e5f9/certbot/certbot/_internal/main.py#L684-L727).

We should allow users to do this, probably by reusing the `--register-unsafely-without-email` flag since it is usually a bad idea to have a Let's Encrypt account with no contact information.

This issue relies on https://github.com/certbot/certbot/issues/5129 being resolved which you may want to do in a PR before this one.

EDIT: My original description of this issue was very sparse and vague so I rewrote it to provide more information.


Discussion:
Could you provide a little bit more guidance like which files to edit? Is it just `certbot/cli.py`?
You'd at least have to modify [certbot.main.register](https://github.com/certbot/certbot/blob/bb6a22b9853cfab2a06cb020ddf57dd5a6ae8eba/certbot/main.py#L455) and potentially the code called there so updating a registration without an email address works.
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 further activity, this issue will be automatically closed.

This is still an issue.
@bmw Is this a duplicate of https://github.com/certbot/certbot/issues/5129 or is 5129 for `acme` and 3162 is for the end user `certbot` tool? 

_Edit: Just noticed 5129 references this issue, so probably not a dupe! Sorry!_
Thanks for the ping @cpu. I remembered seeing you comment on a similar issue somewhere in my inbox which I hadn't got to yet.

These issues could be combined, but you're right that it was previously my intention for this issue to track the Certbot work and #5129 to track the `acme` work.
Hi, I was looking for a first issue, but I thought I should double check before working on this.

Is this issue still valid and correctly described? The code in [certbot.internal.client](https://github.com/certbot/certbot/blob/master/certbot/certbot/_internal/client.py#L154) allows this with the --register-unsafely-without-email (though it hasn't been updated since 2015). However, the cli interface at [cerbot.internal.cli.__init__](https://github.com/certbot/certbot/blob/master/certbot/certbot/_internal/cli/__init__.py#L171)  (just updated in february of 2020) specifies that flag should be provided if you wish to register without email. That made me think the current behavior might be the desired one.
Hi @aeturnum :wave:

Thanks for taking a look at our open issues.

I updated https://github.com/certbot/certbot/issues/3162#issue-160015326 to provide more information here.
Could you provide a little bit more guidance like which files to edit? Is it just `certbot/cli.py`?
You'd at least have to modify [certbot.main.register](https://github.com/certbot/certbot/blob/bb6a22b9853cfab2a06cb020ddf57dd5a6ae8eba/certbot/main.py#L455) and potentially the code called there so updating a registration without an email address works.
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 further activity, this issue will be automatically closed.

This is still an issue.
@bmw Is this a duplicate of https://github.com/certbot/certbot/issues/5129 or is 5129 for `acme` and 3162 is for the end user `certbot` tool? 

_Edit: Just noticed 5129 references this issue, so probably not a dupe! Sorry!_
Thanks for the ping @cpu. I remembered seeing you comment on a similar issue somewhere in my inbox which I hadn't got to yet.

These issues could be combined, but you're right that it was previously my intention for this issue to track the Certbot work and #5129 to track the `acme` work.
Hi, I was looking for a first issue, but I thought I should double check before working on this.

Is this issue still valid and correctly described? The code in [certbot.internal.client](https://github.com/certbot/certbot/blob/master/certbot/certbot/_internal/client.py#L154) allows this with the --register-unsafely-without-email (though it hasn't been updated since 2015). However, the cli interface at [cerbot.internal.cli.__init__](https://github.com/certbot/certbot/blob/master/certbot/certbot/_internal/cli/__init__.py#L171)  (just updated in february of 2020) specifies that flag should be provided if you wish to register without email. That made me think the current behavior might be the desired one.
Hi @aeturnum :wave:

Thanks for taking a look at our open issues.

I updated https://github.com/certbot/certbot/issues/3162#issue-160015326 to provide more information here.

Resolving patch:
diff --git a/acme/acme/messages.py b/acme/acme/messages.py
--- a/acme/acme/messages.py
+++ b/acme/acme/messages.py
@@ -315,6 +315,9 @@ class Registration(ResourceBody):
     # on new-reg key server ignores 'key' and populates it based on
     # JWS.signature.combined.jwk
     key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json)
+    # Contact field implements special behavior to allow messages that clear existing
+    # contacts while not expecting the `contact` field when loading from json.
+    # This is implemented in the constructor and *_json methods.
     contact = jose.Field('contact', omitempty=True, default=())
     agreement = jose.Field('agreement', omitempty=True)
     status = jose.Field('status', omitempty=True)
@@ -327,24 +330,73 @@ class Registration(ResourceBody):
 
     @classmethod
     def from_data(cls, phone=None, email=None, external_account_binding=None, **kwargs):
-        """Create registration resource from contact details."""
+        """
+        Create registration resource from contact details.
+
+        The `contact` keyword being passed to a Registration object is meaningful, so
+        this function represents empty iterables in its kwargs by passing on an empty
+        `tuple`.
+        """
+
+        # Note if `contact` was in kwargs.
+        contact_provided = 'contact' in kwargs
+
+        # Pop `contact` from kwargs and add formatted email or phone numbers
         details = list(kwargs.pop('contact', ()))
         if phone is not None:
             details.append(cls.phone_prefix + phone)
         if email is not None:
             details.extend([cls.email_prefix + mail for mail in email.split(',')])
-        kwargs['contact'] = tuple(details)
+
+        # Insert formatted contact information back into kwargs
+        # or insert an empty tuple if `contact` provided.
+        if details or contact_provided:
+            kwargs['contact'] = tuple(details)
 
         if external_account_binding:
             kwargs['external_account_binding'] = external_account_binding
 
         return cls(**kwargs)
 
+    def __init__(self, **kwargs):
+        """Note if the user provides a value for the `contact` member."""
+        if 'contact' in kwargs:
+            # Avoid the __setattr__ used by jose.TypedJSONObjectWithFields
+            object.__setattr__(self, '_add_contact', True)
+        super(Registration, self).__init__(**kwargs)
+
     def _filter_contact(self, prefix):
         return tuple(
             detail[len(prefix):] for detail in self.contact  # pylint: disable=not-an-iterable
             if detail.startswith(prefix))
 
+    def _add_contact_if_appropriate(self, jobj):
+        """
+        The `contact` member of Registration objects should not be required when
+        de-serializing (as it would be if the Fields' `omitempty` flag were `False`), but
+        it should be included in serializations if it was provided.
+
+        :param jobj: Dictionary containing this Registrations' data
+        :type jobj: dict
+
+        :returns: Dictionary containing Registrations data to transmit to the server
+        :rtype: dict
+        """
+        if getattr(self, '_add_contact', False):
+            jobj['contact'] = self.encode('contact')
+
+        return jobj
+
+    def to_partial_json(self):
+        """Modify josepy.JSONDeserializable.to_partial_json()"""
+        jobj = super(Registration, self).to_partial_json()
+        return self._add_contact_if_appropriate(jobj)
+
+    def fields_to_partial_json(self):
+        """Modify josepy.JSONObjectWithFields.fields_to_partial_json()"""
+        jobj = super(Registration, self).fields_to_partial_json()
+        return self._add_contact_if_appropriate(jobj)
+
     @property
     def phones(self):
         """All phones found in the ``contact`` field."""
diff --git a/certbot/certbot/_internal/main.py b/certbot/certbot/_internal/main.py
--- a/certbot/certbot/_internal/main.py
+++ b/certbot/certbot/_internal/main.py
@@ -11,7 +11,7 @@
 import zope.component
 
 from acme import errors as acme_errors
-from acme.magic_typing import Union
+from acme.magic_typing import Union, Iterable, Optional  # pylint: disable=unused-import
 import certbot
 from certbot import crypto_util
 from certbot import errors
@@ -590,7 +590,7 @@ def _init_le_client(config, authenticator, installer):
     :type config: interfaces.IConfig
 
     :param authenticator: Acme authentication handler
-    :type authenticator: interfaces.IAuthenticator
+    :type authenticator: Optional[interfaces.IAuthenticator]
     :param installer: Installer object
     :type installer: interfaces.IInstaller
 
@@ -703,17 +703,17 @@ def update_account(config, unused_plugins):
 
     if not accounts:
         return "Could not find an existing account to update."
-    if config.email is None:
-        if config.register_unsafely_without_email:
-            return ("--register-unsafely-without-email provided, however, a "
-                    "new e-mail address must\ncurrently be provided when "
-                    "updating a registration.")
+    if config.email is None and not config.register_unsafely_without_email:
         config.email = display_ops.get_email(optional=False)
 
     acc, acme = _determine_account(config)
     cb_client = client.Client(config, acc, None, None, acme=acme)
+    # Empty list of contacts in case the user is removing all emails
+
+    acc_contacts = () # type: Iterable[str]
+    if config.email:
+        acc_contacts = ['mailto:' + email for email in config.email.split(',')]
     # We rely on an exception to interrupt this process if it didn't work.
-    acc_contacts = ['mailto:' + email for email in config.email.split(',')]
     prev_regr_uri = acc.regr.uri
     acc.regr = cb_client.acme.update_registration(acc.regr.update(
         body=acc.regr.body.update(contact=acc_contacts)))
@@ -722,8 +722,13 @@ def update_account(config, unused_plugins):
     # so that we can also continue to use the account object with acmev1.
     acc.regr = acc.regr.update(uri=prev_regr_uri)
     account_storage.update_regr(acc, cb_client.acme)
-    eff.prepare_subscription(config, acc)
-    add_msg("Your e-mail address was updated to {0}.".format(config.email))
+
+    if config.email is None:
+        add_msg("Any contact information associated with this account has been removed.")
+    else:
+        eff.prepare_subscription(config, acc)
+        add_msg("Your e-mail address was updated to {0}.".format(config.email))
+
     return None
 
 


Test patch:
diff --git a/acme/tests/messages_test.py b/acme/tests/messages_test.py
--- a/acme/tests/messages_test.py
+++ b/acme/tests/messages_test.py
@@ -254,6 +254,19 @@ def test_from_json_hashable(self):
         from acme.messages import Registration
         hash(Registration.from_json(self.jobj_from))
 
+    def test_default_not_transmitted(self):
+        from acme.messages import NewRegistration
+        empty_new_reg = NewRegistration()
+        new_reg_with_contact = NewRegistration(contact=())
+
+        self.assertEqual(empty_new_reg.contact, ())
+        self.assertEqual(new_reg_with_contact.contact, ())
+
+        self.assertTrue('contact' not in empty_new_reg.to_partial_json())
+        self.assertTrue('contact' not in empty_new_reg.fields_to_partial_json())
+        self.assertTrue('contact' in new_reg_with_contact.to_partial_json())
+        self.assertTrue('contact' in new_reg_with_contact.fields_to_partial_json())
+
 
 class UpdateRegistrationTest(unittest.TestCase):
     """Tests for acme.messages.UpdateRegistration."""
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
@@ -1404,6 +1404,43 @@ def test_update_account_no_existing_accounts(self):
                  "user@example.org"])
             self.assertTrue("Could not find an existing account" in x[0])
 
+    @mock.patch('certbot._internal.main._determine_account')
+    @mock.patch('certbot._internal.eff.prepare_subscription')
+    @mock.patch('certbot._internal.main.account')
+    def test_update_account_remove_email(self, mocked_account_module, mock_prepare, mock_det_acc):
+        # Mock account storage and the account object returned
+        mocked_storage = mock.MagicMock()
+        mocked_account = mock.MagicMock()
+
+        mocked_account_module.AccountFileStorage.return_value = mocked_storage
+        mocked_storage.find_all.return_value = [mocked_account]
+        mock_det_acc.return_value = (mocked_account, "foo")
+
+        # Mock registration body to verify calls are made
+        mock_regr_body = mock.MagicMock()
+
+        # mocked_account.regr is overwritten in update, requiring an odd mock setup
+        mocked_account.regr.body = mock_regr_body
+
+        x = self._call(
+            ["update_account", "--register-unsafely-without-email"])
+
+
+        # When update succeeds, the return value of update_account() is None
+        self.assertTrue(x[0] is None)
+        # and we got supposedly did update the registration from
+        # the server
+        client_mock = x[3]
+        self.assertTrue(client_mock.Client().acme.update_registration.called)
+
+        self.assertTrue(mock_regr_body.update.called)
+        self.assertTrue('contact' in mock_regr_body.update.call_args[1])
+        self.assertEqual(mock_regr_body.update.call_args[1]['contact'], ())
+        # and we saved the updated registration on disk
+        self.assertTrue(mocked_storage.update_regr.called)
+        # ensure we didn't try to subscribe (no email to subscribe with)
+        self.assertFalse(mock_prepare.called)
+
     @mock.patch('certbot._internal.main.display_ops.get_email')
     @test_util.patch_get_utility()
     def test_update_account_with_email(self, mock_utility, mock_email):