diff --git a/.gitignore b/.gitignore index ac2842e27..b63e40d1c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ tags tests/letstest/letest-*/ tests/letstest/*.pem tests/letstest/venv/ + +.venv diff --git a/acme/acme/client.py b/acme/acme/client.py index 26109352b..19f566b0d 100644 --- a/acme/acme/client.py +++ b/acme/acme/client.py @@ -132,12 +132,24 @@ class Client(object): # pylint: disable=too-many-instance-attributes """ update = regr.body if update is None else update - updated_regr = self._send_recv_regr( - regr, body=messages.UpdateRegistration(**dict(update))) + body = messages.UpdateRegistration(**dict(update)) + updated_regr = self._send_recv_regr(regr, body=body) if updated_regr != regr: raise errors.UnexpectedUpdate(regr) return updated_regr + def deactivate_registration(self, regr): + """Deactivate registration. + + :param messages.RegistrationResource regr: The Registration Resource + to be deactivated. + + :returns: The Registration resource that was deactivated. + :rtype: `.RegistrationResource` + + """ + return self.update_registration(regr, update={'status': 'deactivated'}) + def query_registration(self, regr): """Query server about registration. diff --git a/acme/acme/client_test.py b/acme/acme/client_test.py index 4822a1ae6..62555939c 100644 --- a/acme/acme/client_test.py +++ b/acme/acme/client_test.py @@ -124,6 +124,20 @@ class ClientTest(unittest.TestCase): self.assertRaises( errors.UnexpectedUpdate, self.client.update_registration, self.regr) + 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_registration(self.regr)) + + def test_deactivate_account_bad_registration_returned(self): + self.response.headers['Location'] = self.regr.uri + self.response.json.return_value = "some wrong registration thing" + self.assertRaises( + errors.UnexpectedUpdate, + self.client.deactivate_registration, + self.regr) + def test_query_registration(self): self.response.json.return_value = self.regr.body.to_json() self.assertEqual(self.regr, self.client.query_registration(self.regr)) diff --git a/acme/acme/messages.py b/acme/acme/messages.py index 29d719684..54cd25c94 100644 --- a/acme/acme/messages.py +++ b/acme/acme/messages.py @@ -250,6 +250,7 @@ class Registration(ResourceBody): agreement = jose.Field('agreement', omitempty=True) authorizations = jose.Field('authorizations', omitempty=True) certificates = jose.Field('certificates', omitempty=True) + status = jose.Field('status', omitempty=True) class Authorizations(jose.JSONObjectWithFields): """Authorizations granted to Account in the process of registration. diff --git a/certbot/account.py b/certbot/account.py index 71951d8e0..877b87015 100644 --- a/certbot/account.py +++ b/certbot/account.py @@ -3,6 +3,7 @@ import datetime import hashlib import logging import os +import shutil import socket from cryptography.hazmat.primitives import serialization @@ -197,6 +198,18 @@ class AccountFileStorage(interfaces.AccountStorage): """ self._save(account, regr_only=True) + def delete(self, account_id): + """Delete registration info from disk + + :param account_id: id of account which should be deleted + + """ + account_dir_path = self._account_dir_path(account_id) + if not os.path.isdir(account_dir_path): + raise errors.AccountNotFound( + "Account at %s does not exist" % account_dir_path) + shutil.rmtree(account_dir_path) + def _save(self, account, regr_only): account_dir_path = self._account_dir_path(account.id) util.make_or_verify_dir(account_dir_path, 0o700, os.geteuid(), diff --git a/certbot/cli.py b/certbot/cli.py index 4aeac6d34..7d146e33b 100644 --- a/certbot/cli.py +++ b/certbot/cli.py @@ -366,6 +366,10 @@ VERB_HELP = [ "short": "Register for account with Let's Encrypt / other ACME server", "opts": "Options for account registration & modification" }), + ("unregister", { + "short": "Irrevocably deactivate your account", + "opts": "Options for account deactivation." + }), ("install", { "short": "Install an arbitrary cert in a server", "opts": "Options for modifying how a cert is deployed" @@ -414,6 +418,7 @@ class HelpfulArgumentParser(object): "install": main.install, "plugins": main.plugins_cmd, "register": main.register, + "unregister": main.unregister, "renew": main.renew, "revoke": main.revoke, "rollback": main.rollback, @@ -871,7 +876,9 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis help="With the register verb, indicates that details associated " "with an existing registration, such as the e-mail address, " "should be updated, rather than registering a new account.") - helpful.add(["register", "automation"], "-m", "--email", help=config_help("email")) + helpful.add( + ["register", "unregister", "automation"], "-m", "--email", + help=config_help("email")) helpful.add( ["automation", "certonly", "run"], "--keep-until-expiring", "--keep", "--reinstall", @@ -913,7 +920,7 @@ def prepare_and_parse_args(plugins, args, detect_defaults=False): # pylint: dis "automation", "--agree-tos", dest="tos", action="store_true", help="Agree to the ACME Subscriber Agreement (default: Ask)") helpful.add( - "automation", "--account", metavar="ACCOUNT_ID", + ["unregister", "automation"], "--account", metavar="ACCOUNT_ID", help="Account ID to use") helpful.add( "automation", "--duplicate", dest="duplicate", action="store_true", diff --git a/certbot/client.py b/certbot/client.py index b40a169e6..f76688b70 100644 --- a/certbot/client.py +++ b/certbot/client.py @@ -172,7 +172,7 @@ def perform_registration(acme, config): class Client(object): - """ACME protocol client. + """Certbot's client. :ivar .IConfig config: Client configuration. :ivar .Account account: Account registered with `register`. diff --git a/certbot/main.py b/certbot/main.py index b95049284..f8cb411b5 100644 --- a/certbot/main.py +++ b/certbot/main.py @@ -406,6 +406,35 @@ def _init_le_client(config, authenticator, installer): return client.Client(config, acc, authenticator, installer, acme=acme) +def unregister(config, unused_plugins): + """Deactivate account on server""" + account_storage = account.AccountFileStorage(config) + accounts = account_storage.find_all() + reporter_util = zope.component.getUtility(interfaces.IReporter) + + if not accounts: + return "Could not find existing account to deactivate." + yesno = zope.component.getUtility(interfaces.IDisplay).yesno + prompt = ("Are you sure you would like to irrevocably deactivate " + "your account?") + wants_deactivate = yesno(prompt, yes_label='Deactivate', no_label='Abort', + default=True) + + if not wants_deactivate: + return "Deactivation aborted." + + acc, acme = _determine_account(config) + acme_client = client.Client(config, acc, None, None, acme=acme) + + # delete on boulder + acme_client.acme.deactivate_registration(acc.regr) + account_files = account.AccountFileStorage(config) + # delete local account files + account_files.delete(config.account) + + reporter_util.add_message("Account deactivated.", reporter_util.MEDIUM_PRIORITY) + + def register(config, unused_plugins): """Create or modify accounts on the server.""" @@ -413,6 +442,8 @@ def register(config, unused_plugins): # exist or not. account_storage = account.AccountFileStorage(config) accounts = account_storage.find_all() + reporter_util = zope.component.getUtility(interfaces.IReporter) + add_msg = lambda m: reporter_util.add_message(m, reporter_util.MEDIUM_PRIORITY) # registering a new account if not config.update_registration: @@ -443,9 +474,7 @@ def register(config, unused_plugins): acc.regr = acme_client.acme.update_registration(acc.regr.update( body=acc.regr.body.update(contact=('mailto:' + config.email,)))) account_storage.save_regr(acc) - reporter_util = zope.component.getUtility(interfaces.IReporter) - msg = "Your e-mail address was updated to {0}.".format(config.email) - reporter_util.add_message(msg, reporter_util.MEDIUM_PRIORITY) + add_msg("Your e-mail address was updated to {0}.".format(config.email)) def install(config, plugins): diff --git a/certbot/tests/account_test.py b/certbot/tests/account_test.py index 1c50025d7..d045afe23 100644 --- a/certbot/tests/account_test.py +++ b/certbot/tests/account_test.py @@ -190,6 +190,14 @@ class AccountFileStorageTest(unittest.TestCase): self.assertRaises( errors.AccountStorageError, self.storage.save, self.acc) + def test_delete(self): + self.storage.save(self.acc) + self.storage.delete(self.acc.id) + self.assertRaises(errors.AccountNotFound, self.storage.load, self.acc.id) + + def test_delete_no_account(self): + self.assertRaises(errors.AccountNotFound, self.storage.delete, self.acc.id) + if __name__ == "__main__": unittest.main() # pragma: no cover diff --git a/certbot/tests/main_test.py b/certbot/tests/main_test.py index 087a3615d..3348a7574 100644 --- a/certbot/tests/main_test.py +++ b/certbot/tests/main_test.py @@ -1,4 +1,5 @@ """Tests for certbot.main.""" +# pylint: disable=too-many-lines from __future__ import print_function import itertools @@ -1164,6 +1165,68 @@ class MainTest(unittest.TestCase): # pylint: disable=too-many-public-methods email in mock_utility().add_message.call_args[0][0]) +class UnregisterTest(unittest.TestCase): + def setUp(self): + self.patchers = { + '_determine_account': mock.patch('certbot.main._determine_account'), + 'account': mock.patch('certbot.main.account'), + 'client': mock.patch('certbot.main.client'), + 'get_utility': test_util.patch_get_utility()} + self.mocks = dict((k, v.start()) for k, v in self.patchers.items()) + + def tearDown(self): + for patch in self.patchers.values(): + patch.stop() + + def test_abort_unregister(self): + self.mocks['account'].AccountFileStorage.return_value = mock.Mock() + + util_mock = self.mocks['get_utility'].return_value + util_mock.yesno.return_value = False + + config = mock.Mock() + unused_plugins = mock.Mock() + + res = main.unregister(config, unused_plugins) + self.assertEqual(res, "Deactivation aborted.") + + def test_unregister(self): + mocked_storage = mock.MagicMock() + mocked_storage.find_all.return_value = ["an account"] + + self.mocks['account'].AccountFileStorage.return_value = mocked_storage + self.mocks['_determine_account'].return_value = (mock.MagicMock(), "foo") + + acme_client = mock.MagicMock() + self.mocks['client'].Client.return_value = acme_client + + config = mock.MagicMock() + unused_plugins = mock.MagicMock() + + res = main.unregister(config, unused_plugins) + + self.assertTrue(res is None) + self.assertTrue(acme_client.acme.deactivate_registration.called) + m = "Account deactivated." + self.assertTrue(m in self.mocks['get_utility']().add_message.call_args[0][0]) + + def test_unregister_no_account(self): + mocked_storage = mock.MagicMock() + mocked_storage.find_all.return_value = [] + self.mocks['account'].AccountFileStorage.return_value = mocked_storage + + acme_client = mock.MagicMock() + self.mocks['client'].Client.return_value = acme_client + + config = mock.MagicMock() + unused_plugins = mock.MagicMock() + + res = main.unregister(config, unused_plugins) + m = "Could not find existing account to deactivate." + self.assertEqual(res, m) + self.assertFalse(acme_client.acme.deactivate_registration.called) + + class TestHandleException(unittest.TestCase): """Test main._handle_exception""" @mock.patch('certbot.main.sys') diff --git a/tests/boulder-integration.sh b/tests/boulder-integration.sh index 30fc17f81..790e5a263 100755 --- a/tests/boulder-integration.sh +++ b/tests/boulder-integration.sh @@ -9,7 +9,7 @@ # Note: this script is called by Boulder integration test suite! . ./tests/integration/_common.sh -export PATH="/usr/sbin:$PATH" # /usr/sbin/nginx +export PATH="$PATH:/usr/sbin" # /usr/sbin/nginx export GOPATH="${GOPATH:-/tmp/go}" export PATH="$GOPATH/bin:$PATH" @@ -161,7 +161,8 @@ common revoke --cert-path "$root/conf/live/le.wtf/cert.pem" common revoke --cert-path "$root/conf/live/le1.wtf/cert.pem" # revoke by cert key common revoke --cert-path "$root/conf/live/le2.wtf/cert.pem" \ - --key-path "$root/conf/live/le2.wtf/privkey.pem" + --key-path "$root/conf/live/le2.wtf/privkey.pem" + # Get new certs to test revoke with a reason, by account and by cert key common --domains le1.wtf common revoke --cert-path "$root/conf/live/le1.wtf/cert.pem" \ @@ -170,6 +171,9 @@ common --domains le2.wtf common revoke --cert-path "$root/conf/live/le2.wtf/cert.pem" \ --key-path "$root/conf/live/le2.wtf/privkey.pem" \ --reason keyCompromise + +common unregister + if type nginx; then . ./certbot-nginx/tests/boulder-integration.sh