Adding Account Deactivation to Certbot

The recent ACME spec included a way for users to deactivate their accounts. This post describes adding this feature to certbot. This feature touches almost every level of certbot, so it gives an instructive look at Certbot's architecture.

An account in this case means the identity associated some key pair. Accounts control identifiers (domain names).

All the code describing the ACME protocol is in the acme/ package. The messages between the client and server are defined in certbot/acme/acme/messages.py So we can add the deactivation method here. From the ACME spec we see:

6.2.2.  Account deactivation

   A client may deactivate an account by posting a signed update to the
   server with a status field of "deactivated."  Clients may wish to do
   this when the account key is compromised.

   POST /acme/reg/asdf HTTP/1.1
   Host: example.com
   Content-Type: application/jose+json

   {
     "protected": base64url({
       "alg": "ES256",
       "jwk": {...},
       "nonce": "ntuJWWSic4WVNSqeUmshgg",
       "url": "https://example.com/acme/reg/asdf"
     })
     "payload": base64url({
       "status": "deactivated"
     }),
     "signature": "earzVLd3m5M4xJzR...bVTqn7R08AKOVf3Y"
   }

   The server MUST verify that the request is signed by the account key.
   If the server accepts the deactivation request, it should reply with
   a 200 (OK) status code and the current contents of the registration
   object.

   Once an account is deactivated, the server MUST NOT accept further
   requests authorized by that account's key.  It is up to server policy
   how long to retain data related to that account, whether to revoke
   certificates issued by that account, and whether to send email to
   that account's contacts.  ACME does not provide a way to reactivate a
   deactivated account.

Adding protocol messages in messages.py

So we crack open messages.py to add this message. In the module is the UpdateRegistration class which represents messages to the reg endpoint. And deactivating an account is actually just updating the "status" field to "deactivated". So deactivation is just a kind of updating. So all that is needed is to make UpdateRegistration accept a "status" field. We do this by adding an attribute to the class like:

status = jose.Field('status', omitempty=True)

Not so bad right?

Deactivation with the client in client.py

Now we need to use this thing we added. The acme package has a notion of a client, which is a thing that speaks the acme protocol. This is done in acme/client.py. Fortunately we already have code that sends update messages, so we use this to make a new function:

class Client(object):
    ...

    def deactivate(self, regr):
        """Deactivate registration."""
        update = {'status': 'deactivated'}
        body = messages.UpdateRegistration(**dict(update))
        ...
        # code for checking the response
        ...
        return new_regr

Testing the client

We have to mock out what we response from the CA. So we'd expect the current contents of the registration to be returned. It's pretty simple

    def test_deactivate_account(self):
        self.response.headers['Location'] = self.regr.uri
        self.response.json.return_value = self.regr.body.to_json()
        self.assertEqual(self.regr, self.client.deactivate(self.regr))

Adding a deactivate command to certbot

We are done in acme/. Now we move to certbot/ where all the non-protocol specific code lives. We have to add the pieces the user will interact with on the command line so cli.py is the place to start.

cli.py is where all the command line flags are parsed, including --update-registration. Which is similar to deactivation. So we add a thing:

    helpful.add(
        "register", "--deactivate", action="store_true",
        help="Irrevocably deactivate your account. Certificates associated "
             "with your account can still be revoked, but they can not be "
             "renewed.")

Now when certbot register --deactivate is entered, the python function certbot.main.main gets called which eventually calls certbot.main.register. The register function takes a config parameter. And because we used the --deactivate flag, this parameter has config.deactivate == True. So we can add the following code.

def register(config, unused_plugins):
    ...
    if config.deactivate:
        if len(accounts) == 0:
            add_msg("Could not find existing account to deactivate.")
        else:
            yesno = zope.component.getUtility(interfaces.IDisplay).yesno
            prompt = ("Are you SURE you would like to irrevocably deactivate "
                      "your account? You will lose access to the domains "
                      "associated with it.")
            wants_deactivate = yesno(prompt, yes_label='Deactivate',
                                     no_label='Abort', cli_flag='--deactivate',
                                     default=False)
            if wants_deactivate:
                acc, acme = _determine_account(config)
                acme_client = client.Client(config, acc, None, None, acme=acme)
                acme_client.acme.deactivate(acc.regr)
                add_msg("Account deactivated.")
            else:
                add_msg("Deactivation aborted.")
        return

This song and dance needed to get the deactivate method we made earlier isn't obvious. I basically copied part of this from other places in the code.

Next, this thing we added needs to be tested. So in certbot/tests/cli_test.py we add:

    @mock.patch('certbot.main._determine_account')
    @mock.patch('certbot.main.account')
    @mock.patch('certbot.main.client')
    @mock.patch('certbot.main.zope.component.getUtility')
    def test_registration_deactivate(self, mock_utility, mocked_client,
                                     mocked_account, mocked_det):
        mocked_storage = mock.MagicMock()
        mocked_account.AccountFileStorage.return_value = mocked_storage
        mocked_storage.find_all.return_value = ["an account"]
        mocked_det.return_value = (mock.MagicMock(), "foo")
        acme_client = mock.MagicMock()
        mocked_client.Client.return_value = acme_client

        x = self._call_no_clientmock(["register", "--deactivate"])
        self.assertTrue(x[0] is None)
        self.assertTrue(acme_client.acme.deactivate.called)
        m = "Account deactivated."
        self.assertTrue(m in mock_utility().add_message.call_args[0][0])

This isn't very pretty either. certbot/tests/cli_test.py is a big monolith, there is currently an issue for this.

Comments

Comments powered by Disqus