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