diff --git a/CHANGELOG.md b/CHANGELOG.md index ac27aa772..c1af9ffb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ Certbot adheres to [Semantic Versioning](https://semver.org/). * Updated Certbot and its plugins to improve the handling of file system permissions on Windows as a step towards adding proper Windows support to Certbot. * Updated urllib3 to 1.24.2 in certbot-auto. +* Removed the fallback introduced with 0.32.0 in `acme` to retry a challenge response + with a `keyAuthorization` if sending the response without this field caused a + `malformed` error to be received from the ACME server. +* Linode DNS plugin now supports api keys created from their new panel + at [cloud.linode.com](https://cloud.linode.com) ### Fixed @@ -26,6 +31,7 @@ Despite us having broken lockstep, we are continuing to release new versions of all Certbot components during releases for the time being, however, the only package with changes other than its version number was: +* acme * certbot * certbot-apache * certbot-dns-cloudflare diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..142b31c93 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1 @@ +This project is governed by [EFF's Public Projects Code of Conduct](https://www.eff.org/pages/eppcode). \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d740b7d89..07187eb59 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,3 +33,5 @@ started. In particular, we recommend you read these sections - [Finding issues to work on](https://certbot.eff.org/docs/contributing.html#find-issues-to-work-on) - [Coding style](https://certbot.eff.org/docs/contributing.html#coding-style) - [Submitting a pull request](https://certbot.eff.org/docs/contributing.html#submitting-a-pull-request) + - [EFF's Public Projects Code of Conduct](https://www.eff.org/pages/eppcode) + diff --git a/README.rst b/README.rst index 799507605..5f5ea17a1 100644 --- a/README.rst +++ b/README.rst @@ -28,6 +28,8 @@ Contributing If you'd like to contribute to this project please read `Developer Guide `_. +This project is governed by `EFF's Public Projects Code of Conduct `_. + .. _installation: How to run the client diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 01298d46f..78991608a 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -107,10 +107,6 @@ class KeyAuthorizationChallengeResponse(ChallengeResponse): key_authorization = jose.Field("keyAuthorization") thumbprint_hash_function = hashes.SHA256 - def __init__(self, *args, **kwargs): - super(KeyAuthorizationChallengeResponse, self).__init__(*args, **kwargs) - self._dump_authorization_key(False) - def verify(self, chall, account_public_key): """Verify the key authorization. @@ -143,20 +139,9 @@ class KeyAuthorizationChallengeResponse(ChallengeResponse): return True - def _dump_authorization_key(self, dump): - # type: (bool) -> None - """ - Set if keyAuthorization is dumped in the JSON representation of this ChallengeResponse. - NB: This method is declared as private because it will eventually be removed. - :param bool dump: True to dump the keyAuthorization, False otherwise - """ - object.__setattr__(self, '_dump_auth_key', dump) - def to_partial_json(self): jobj = super(KeyAuthorizationChallengeResponse, self).to_partial_json() - if not self._dump_auth_key: # pylint: disable=no-member - jobj.pop('keyAuthorization', None) - + jobj.pop('keyAuthorization', None) return jobj diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index f5f914005..9d3a92fa5 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -95,8 +95,6 @@ class DNS01ResponseTest(unittest.TestCase): def test_to_partial_json(self): self.assertEqual({k: v for k, v in self.jmsg.items() if k != 'keyAuthorization'}, self.msg.to_partial_json()) - self.msg._dump_authorization_key(True) # pylint: disable=protected-access - self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): from acme.challenges import DNS01Response @@ -169,8 +167,6 @@ class HTTP01ResponseTest(unittest.TestCase): def test_to_partial_json(self): self.assertEqual({k: v for k, v in self.jmsg.items() if k != 'keyAuthorization'}, self.msg.to_partial_json()) - self.msg._dump_authorization_key(True) # pylint: disable=protected-access - self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): from acme.challenges import HTTP01Response @@ -292,8 +288,6 @@ class TLSSNI01ResponseTest(unittest.TestCase): def test_to_partial_json(self): self.assertEqual({k: v for k, v in self.jmsg.items() if k != 'keyAuthorization'}, self.response.to_partial_json()) - self.response._dump_authorization_key(True) # pylint: disable=protected-access - self.assertEqual(self.jmsg, self.response.to_partial_json()) def test_from_json(self): from acme.challenges import TLSSNI01Response @@ -428,8 +422,6 @@ class TLSALPN01ResponseTest(unittest.TestCase): def test_to_partial_json(self): self.assertEqual({k: v for k, v in self.jmsg.items() if k != 'keyAuthorization'}, self.msg.to_partial_json()) - self.msg._dump_authorization_key(True) # pylint: disable=protected-access - self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): from acme.challenges import TLSALPN01Response diff --git a/acme/acme/client.py b/acme/acme/client.py index 5cad0acbe..a41787756 100644 --- a/acme/acme/client.py +++ b/acme/acme/client.py @@ -17,7 +17,6 @@ import requests from requests.adapters import HTTPAdapter from requests_toolbelt.adapters.source import SourceAddressAdapter -from acme import challenges from acme import crypto_util from acme import errors from acme import jws @@ -156,23 +155,7 @@ class ClientBase(object): # pylint: disable=too-many-instance-attributes :raises .UnexpectedUpdate: """ - # Because sending keyAuthorization in a response challenge has been removed from the ACME - # spec, it is not included in the KeyAuthorizationResponseChallenge JSON by default. - # However as a migration path, we temporarily expect a malformed error from the server, - # and fallback by resending the challenge response with the keyAuthorization field. - # TODO: Remove this fallback for Certbot 0.34.0 - try: - response = self._post(challb.uri, response) - except messages.Error as error: - if (error.code == 'malformed' - and isinstance(response, challenges.KeyAuthorizationChallengeResponse)): - logger.debug('Error while responding to a challenge without keyAuthorization ' - 'in the JWS, your ACME CA server may not support it:\n%s', error) - logger.debug('Retrying request with keyAuthorization set.') - response._dump_authorization_key(True) # pylint: disable=protected-access - response = self._post(challb.uri, response) - else: - raise + response = self._post(challb.uri, response) try: authzr_uri = response.links['up']['url'] except KeyError: diff --git a/acme/acme/client_test.py b/acme/acme/client_test.py index 5b2703701..406201751 100644 --- a/acme/acme/client_test.py +++ b/acme/acme/client_test.py @@ -461,34 +461,6 @@ class ClientTest(ClientTestBase): errors.ClientError, self.client.answer_challenge, self.challr.body, challenges.DNSResponse(validation=None)) - def test_answer_challenge_key_authorization_fallback(self): - self.response.links['up'] = {'url': self.challr.authzr_uri} - self.response.json.return_value = self.challr.body.to_json() - - def _wrapper_post(url, obj, *args, **kwargs): # pylint: disable=unused-argument - """ - Simulate an old ACME CA server, that would respond a 'malformed' - error if keyAuthorization is missing. - """ - jobj = obj.to_partial_json() - if 'keyAuthorization' not in jobj: - raise messages.Error.with_code('malformed') - return self.response - self.net.post.side_effect = _wrapper_post - - # This challenge response is of type KeyAuthorizationChallengeResponse, so the fallback - # should be triggered, and avoid an exception. - http_chall_response = challenges.HTTP01Response(key_authorization='test', - resource=mock.MagicMock()) - self.client.answer_challenge(self.challr.body, http_chall_response) - - # This challenge response is not of type KeyAuthorizationChallengeResponse, so the fallback - # should not be triggered, leading to an exception. - dns_chall_response = challenges.DNSResponse(validation=None) - self.assertRaises( - errors.Error, self.client.answer_challenge, - self.challr.body, dns_chall_response) - def test_retry_after_date(self): self.response.headers['Retry-After'] = 'Fri, 31 Dec 1999 23:59:59 GMT' self.assertEqual( diff --git a/certbot-dns-linode/certbot_dns_linode/__init__.py b/certbot-dns-linode/certbot_dns_linode/__init__.py index 0a6ccec61..107781a13 100644 --- a/certbot-dns-linode/certbot_dns_linode/__init__.py +++ b/certbot-dns-linode/certbot_dns_linode/__init__.py @@ -27,7 +27,8 @@ Credentials Use of this plugin requires a configuration file containing Linode API credentials, obtained from your Linode account's `Applications & API -Tokens page `_. +Tokens page (legacy) `_ or `Applications +& API Tokens page (new) `_. .. code-block:: ini :name: credentials.ini @@ -35,6 +36,7 @@ Tokens page `_. # Linode API credentials used by Certbot dns_linode_key = 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ64 + dns_linode_version = [|3|4] The path to this file can be provided interactively or using the ``--dns-linode-credentials`` command-line argument. Certbot records the path diff --git a/certbot-dns-linode/certbot_dns_linode/dns_linode.py b/certbot-dns-linode/certbot_dns_linode/dns_linode.py index c2097a7d6..507ad5e53 100644 --- a/certbot-dns-linode/certbot_dns_linode/dns_linode.py +++ b/certbot-dns-linode/certbot_dns_linode/dns_linode.py @@ -1,8 +1,10 @@ """DNS Authenticator for Linode.""" import logging +import re import zope.interface from lexicon.providers import linode +from lexicon.providers import linode4 from certbot import errors from certbot import interfaces @@ -12,6 +14,7 @@ from certbot.plugins import dns_common_lexicon logger = logging.getLogger(__name__) API_KEY_URL = 'https://manager.linode.com/profile/api' +API_KEY_URL_V4 = 'https://cloud.linode.com/profile/tokens' @zope.interface.implementer(interfaces.IAuthenticator) @zope.interface.provider(interfaces.IPluginFactory) @@ -41,7 +44,8 @@ class Authenticator(dns_common.DNSAuthenticator): 'credentials', 'Linode credentials INI file', { - 'key': 'API key for Linode account, obtained from {0}'.format(API_KEY_URL) + 'key': 'API key for Linode account, obtained from {0} or {1}' + .format(API_KEY_URL, API_KEY_URL_V4) } ) @@ -52,7 +56,23 @@ class Authenticator(dns_common.DNSAuthenticator): self._get_linode_client().del_txt_record(domain, validation_name, validation) def _get_linode_client(self): - return _LinodeLexiconClient(self.credentials.conf('key')) + api_key = self.credentials.conf('key') + api_version = self.credentials.conf('version') + if api_version == '': + api_version = None + + if not api_version: + api_version = 3 + + # Match for v4 api key + regex_v4 = re.compile('^[0-9a-f]{64}$') + regex_match = regex_v4.match(api_key) + if regex_match: + api_version = 4 + else: + api_version = int(api_version) + + return _LinodeLexiconClient(api_key, api_version) class _LinodeLexiconClient(dns_common_lexicon.LexiconClient): @@ -60,14 +80,26 @@ class _LinodeLexiconClient(dns_common_lexicon.LexiconClient): Encapsulates all communication with the Linode API. """ - def __init__(self, api_key): + def __init__(self, api_key, api_version): super(_LinodeLexiconClient, self).__init__() - config = dns_common_lexicon.build_lexicon_config('linode', {}, { - 'auth_token': api_key, - }) + self.api_version = api_version - self.provider = linode.Provider(config) + if api_version == 3: + config = dns_common_lexicon.build_lexicon_config('linode', {}, { + 'auth_token': api_key, + }) + + self.provider = linode.Provider(config) + elif api_version == 4: + config = dns_common_lexicon.build_lexicon_config('linode4', {}, { + 'auth_token': api_key, + }) + + self.provider = linode4.Provider(config) + else: + raise errors.PluginError('Invalid api version specified: {0}. (Supported: 3, 4)' + .format(api_version)) def _handle_general_error(self, e, domain_name): if not str(e).startswith('Domain not found'): diff --git a/certbot-dns-linode/certbot_dns_linode/dns_linode_test.py b/certbot-dns-linode/certbot_dns_linode/dns_linode_test.py index c1a4e0ec0..153f8b51d 100644 --- a/certbot-dns-linode/certbot_dns_linode/dns_linode_test.py +++ b/certbot-dns-linode/certbot_dns_linode/dns_linode_test.py @@ -4,12 +4,16 @@ import unittest import mock +from certbot import errors from certbot.compat import os from certbot.plugins import dns_test_common from certbot.plugins import dns_test_common_lexicon from certbot.tests import util as test_util +from certbot_dns_linode.dns_linode import Authenticator TOKEN = 'a-token' +TOKEN_V3 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ64' +TOKEN_V4 = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common_lexicon.BaseLexiconAuthenticatorTest): @@ -17,8 +21,6 @@ class AuthenticatorTest(test_util.TempDirTestCase, def setUp(self): super(AuthenticatorTest, self).setUp() - from certbot_dns_linode.dns_linode import Authenticator - path = os.path.join(self.tempdir, 'file.ini') dns_test_common.write({"linode_key": TOKEN}, path) @@ -31,6 +33,89 @@ class AuthenticatorTest(test_util.TempDirTestCase, # _get_linode_client | pylint: disable=protected-access self.auth._get_linode_client = mock.MagicMock(return_value=self.mock_client) + # pylint: disable=protected-access + def test_api_version_3_detection(self): + path = os.path.join(self.tempdir, 'file_3_auto.ini') + dns_test_common.write({"linode_key": TOKEN_V3}, path) + + config = mock.MagicMock(linode_credentials=path, + linode_propagation_seconds=0) + auth = Authenticator(config, "linode") + auth._setup_credentials() + client = auth._get_linode_client() + self.assertEqual(3, client.api_version) + + # pylint: disable=protected-access + def test_api_version_4_detection(self): + path = os.path.join(self.tempdir, 'file_4_auto.ini') + dns_test_common.write({"linode_key": TOKEN_V4}, path) + + config = mock.MagicMock(linode_credentials=path, + linode_propagation_seconds=0) + auth = Authenticator(config, "linode") + auth._setup_credentials() + client = auth._get_linode_client() + self.assertEqual(4, client.api_version) + + # pylint: disable=protected-access + def test_api_version_3_detection_empty_version(self): + path = os.path.join(self.tempdir, 'file_3_auto_empty.ini') + dns_test_common.write({"linode_key": TOKEN_V3, "linode_version": ""}, path) + + config = mock.MagicMock(linode_credentials=path, + linode_propagation_seconds=0) + auth = Authenticator(config, "linode") + auth._setup_credentials() + client = auth._get_linode_client() + self.assertEqual(3, client.api_version) + + # pylint: disable=protected-access + def test_api_version_4_detection_empty_version(self): + path = os.path.join(self.tempdir, 'file_4_auto_empty.ini') + dns_test_common.write({"linode_key": TOKEN_V4, "linode_version": ""}, path) + + config = mock.MagicMock(linode_credentials=path, + linode_propagation_seconds=0) + auth = Authenticator(config, "linode") + auth._setup_credentials() + client = auth._get_linode_client() + self.assertEqual(4, client.api_version) + + # pylint: disable=protected-access + def test_api_version_3_manual(self): + path = os.path.join(self.tempdir, 'file_3_manual.ini') + dns_test_common.write({"linode_key": TOKEN_V4, "linode_version": 3}, path) + + config = mock.MagicMock(linode_credentials=path, + linode_propagation_seconds=0) + auth = Authenticator(config, "linode") + auth._setup_credentials() + client = auth._get_linode_client() + self.assertEqual(3, client.api_version) + + # pylint: disable=protected-access + def test_api_version_4_manual(self): + path = os.path.join(self.tempdir, 'file_4_manual.ini') + dns_test_common.write({"linode_key": TOKEN_V3, "linode_version": 4}, path) + + config = mock.MagicMock(linode_credentials=path, + linode_propagation_seconds=0) + auth = Authenticator(config, "linode") + auth._setup_credentials() + client = auth._get_linode_client() + self.assertEqual(4, client.api_version) + + # pylint: disable=protected-access + def test_api_version_error(self): + path = os.path.join(self.tempdir, 'file_version_error.ini') + dns_test_common.write({"linode_key": TOKEN_V3, "linode_version": 5}, path) + + config = mock.MagicMock(linode_credentials=path, + linode_propagation_seconds=0) + auth = Authenticator(config, "linode") + auth._setup_credentials() + self.assertRaises(errors.PluginError, auth._get_linode_client) + class LinodeLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest): DOMAIN_NOT_FOUND = Exception('Domain not found') @@ -38,7 +123,19 @@ class LinodeLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLex def setUp(self): from certbot_dns_linode.dns_linode import _LinodeLexiconClient - self.client = _LinodeLexiconClient(TOKEN) + self.client = _LinodeLexiconClient(TOKEN, 3) + + self.provider_mock = mock.MagicMock() + self.client.provider = self.provider_mock + +class Linode4LexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest): + + DOMAIN_NOT_FOUND = Exception('Domain not found') + + def setUp(self): + from certbot_dns_linode.dns_linode import _LinodeLexiconClient + + self.client = _LinodeLexiconClient(TOKEN, 4) self.provider_mock = mock.MagicMock() self.client.provider = self.provider_mock diff --git a/certbot-dns-linode/local-oldest-requirements.txt b/certbot-dns-linode/local-oldest-requirements.txt index 2b3ba9f32..d48a789bb 100644 --- a/certbot-dns-linode/local-oldest-requirements.txt +++ b/certbot-dns-linode/local-oldest-requirements.txt @@ -1,3 +1,4 @@ # Remember to update setup.py to match the package versions below. acme[dev]==0.31.0 -e .[dev] +dns-lexicon==2.2.3 diff --git a/certbot-dns-linode/setup.py b/certbot-dns-linode/setup.py index e43ab8de9..771e09381 100644 --- a/certbot-dns-linode/setup.py +++ b/certbot-dns-linode/setup.py @@ -7,7 +7,7 @@ version = '0.34.0.dev0' install_requires = [ 'acme>=0.31.0', 'certbot>=0.34.0.dev0', - 'dns-lexicon>=2.2.1', + 'dns-lexicon>=2.2.3', 'mock', 'setuptools', 'zope.interface',