diff -Nru python-acme-0.22.2/acme/challenges.py python-acme-0.31.0/acme/challenges.py --- python-acme-0.22.2/acme/challenges.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/acme/challenges.py 2019-02-07 21:20:29.000000000 +0000 @@ -4,11 +4,13 @@ import hashlib import logging import socket +import warnings from cryptography.hazmat.primitives import hashes # type: ignore import josepy as jose import OpenSSL import requests +import six from acme import errors from acme import crypto_util @@ -139,16 +141,16 @@ return True +@six.add_metaclass(abc.ABCMeta) class KeyAuthorizationChallenge(_TokenChallenge): # pylint: disable=abstract-class-little-used,too-many-ancestors """Challenge based on Key Authorization. :param response_cls: Subclass of `KeyAuthorizationChallengeResponse` that will be used to generate `response`. - + :param str typ: type of the challenge """ - __metaclass__ = abc.ABCMeta - + typ = NotImplemented response_cls = NotImplemented thumbprint_hash_function = ( KeyAuthorizationChallengeResponse.thumbprint_hash_function) @@ -477,7 +479,7 @@ try: cert = self.probe_cert(domain=domain, **kwargs) except errors.Error as error: - logger.debug(error, exc_info=True) + logger.debug(str(error), exc_info=True) return False return self.verify_cert(cert) @@ -492,6 +494,11 @@ # boulder#962, ietf-wg-acme#22 #n = jose.Field("n", encoder=int, decoder=int) + def __init__(self, *args, **kwargs): + warnings.warn("TLS-SNI-01 is deprecated, and will stop working soon.", + DeprecationWarning, stacklevel=2) + super(TLSSNI01, self).__init__(*args, **kwargs) + def validation(self, account_key, **kwargs): """Generate validation. @@ -506,6 +513,33 @@ return self.response(account_key).gen_cert(key=kwargs.get('cert_key')) +@ChallengeResponse.register +class TLSALPN01Response(KeyAuthorizationChallengeResponse): + """ACME TLS-ALPN-01 challenge response. + + This class only allows initiating a TLS-ALPN-01 challenge returned from the + CA. Full support for responding to TLS-ALPN-01 challenges by generating and + serving the expected response certificate is not currently provided. + """ + typ = "tls-alpn-01" + + +@Challenge.register # pylint: disable=too-many-ancestors +class TLSALPN01(KeyAuthorizationChallenge): + """ACME tls-alpn-01 challenge. + + This class simply allows parsing the TLS-ALPN-01 challenge returned from + the CA. Full TLS-ALPN-01 support is not currently provided. + + """ + typ = "tls-alpn-01" + response_cls = TLSALPN01Response + + def validation(self, account_key, **kwargs): + """Generate validation for the challenge.""" + raise NotImplementedError() + + @Challenge.register # pylint: disable=too-many-ancestors class DNS(_TokenChallenge): """ACME "dns" challenge.""" diff -Nru python-acme-0.22.2/acme/challenges_test.py python-acme-0.31.0/acme/challenges_test.py --- python-acme-0.22.2/acme/challenges_test.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/acme/challenges_test.py 2019-02-07 21:20:29.000000000 +0000 @@ -1,5 +1,6 @@ """Tests for acme.challenges.""" import unittest +import warnings import josepy as jose import mock @@ -360,20 +361,29 @@ class TLSSNI01Test(unittest.TestCase): def setUp(self): - from acme.challenges import TLSSNI01 - self.msg = TLSSNI01( - token=jose.b64decode('a82d5ff8ef740d12881f6d3c2277ab2e')) self.jmsg = { 'type': 'tls-sni-01', 'token': 'a82d5ff8ef740d12881f6d3c2277ab2e', } + def _msg(self): + from acme.challenges import TLSSNI01 + with warnings.catch_warnings(record=True) as warn: + warnings.simplefilter("always") + msg = TLSSNI01( + token=jose.b64decode('a82d5ff8ef740d12881f6d3c2277ab2e')) + assert warn is not None # using a raw assert for mypy + self.assertTrue(len(warn) == 1) + self.assertTrue(issubclass(warn[-1].category, DeprecationWarning)) + self.assertTrue('deprecated' in str(warn[-1].message)) + return msg + def test_to_partial_json(self): - self.assertEqual(self.jmsg, self.msg.to_partial_json()) + self.assertEqual(self.jmsg, self._msg().to_partial_json()) def test_from_json(self): from acme.challenges import TLSSNI01 - self.assertEqual(self.msg, TLSSNI01.from_json(self.jmsg)) + self.assertEqual(self._msg(), TLSSNI01.from_json(self.jmsg)) def test_from_json_hashable(self): from acme.challenges import TLSSNI01 @@ -388,10 +398,69 @@ @mock.patch('acme.challenges.TLSSNI01Response.gen_cert') def test_validation(self, mock_gen_cert): mock_gen_cert.return_value = ('cert', 'key') - self.assertEqual(('cert', 'key'), self.msg.validation( + self.assertEqual(('cert', 'key'), self._msg().validation( KEY, cert_key=mock.sentinel.cert_key)) mock_gen_cert.assert_called_once_with(key=mock.sentinel.cert_key) +class TLSALPN01ResponseTest(unittest.TestCase): + # pylint: disable=too-many-instance-attributes + + def setUp(self): + from acme.challenges import TLSALPN01Response + self.msg = TLSALPN01Response(key_authorization=u'foo') + self.jmsg = { + 'resource': 'challenge', + 'type': 'tls-alpn-01', + 'keyAuthorization': u'foo', + } + + from acme.challenges import TLSALPN01 + self.chall = TLSALPN01(token=(b'x' * 16)) + self.response = self.chall.response(KEY) + + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) + + def test_from_json(self): + from acme.challenges import TLSALPN01Response + self.assertEqual(self.msg, TLSALPN01Response.from_json(self.jmsg)) + + def test_from_json_hashable(self): + from acme.challenges import TLSALPN01Response + hash(TLSALPN01Response.from_json(self.jmsg)) + + +class TLSALPN01Test(unittest.TestCase): + + def setUp(self): + from acme.challenges import TLSALPN01 + self.msg = TLSALPN01( + token=jose.b64decode('a82d5ff8ef740d12881f6d3c2277ab2e')) + self.jmsg = { + 'type': 'tls-alpn-01', + 'token': 'a82d5ff8ef740d12881f6d3c2277ab2e', + } + + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) + + def test_from_json(self): + from acme.challenges import TLSALPN01 + self.assertEqual(self.msg, TLSALPN01.from_json(self.jmsg)) + + def test_from_json_hashable(self): + from acme.challenges import TLSALPN01 + hash(TLSALPN01.from_json(self.jmsg)) + + def test_from_json_invalid_token_length(self): + from acme.challenges import TLSALPN01 + self.jmsg['token'] = jose.encode_b64jose(b'abcd') + self.assertRaises( + jose.DeserializationError, TLSALPN01.from_json, self.jmsg) + + def test_validation(self): + self.assertRaises(NotImplementedError, self.msg.validation, KEY) + class DNSTest(unittest.TestCase): diff -Nru python-acme-0.22.2/acme/client.py python-acme-0.31.0/acme/client.py --- python-acme-0.22.2/acme/client.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/acme/client.py 2019-02-07 21:20:29.000000000 +0000 @@ -9,17 +9,20 @@ import six from six.moves import http_client # pylint: disable=import-error - import josepy as jose import OpenSSL import re +from requests_toolbelt.adapters.source import SourceAddressAdapter import requests +from requests.adapters import HTTPAdapter import sys from acme import crypto_util from acme import errors from acme import jws from acme import messages +# pylint: disable=unused-import, no-name-in-module +from acme.magic_typing import Dict, List, Set, Text logger = logging.getLogger(__name__) @@ -30,6 +33,7 @@ # https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning if sys.version_info < (2, 7, 9): # pragma: no cover try: + # pylint: disable=no-member requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() # type: ignore except AttributeError: import urllib3.contrib.pyopenssl # pylint: disable=import-error @@ -47,7 +51,6 @@ :ivar .ClientNetwork net: Client network. :ivar int acme_version: ACME protocol version. 1 or 2. """ - def __init__(self, directory, net, acme_version): """Initialize. @@ -87,6 +90,8 @@ """ kwargs.setdefault('acme_version', self.acme_version) + if hasattr(self.directory, 'newNonce'): + kwargs.setdefault('new_nonce_url', getattr(self.directory, 'newNonce')) return self.net.post(*args, **kwargs) def update_registration(self, regr, update=None): @@ -195,22 +200,6 @@ return datetime.datetime.now() + datetime.timedelta(seconds=seconds) - def poll(self, authzr): - """Poll Authorization Resource for status. - - :param authzr: Authorization Resource - :type authzr: `.AuthorizationResource` - - :returns: Updated Authorization Resource and HTTP response. - - :rtype: (`.AuthorizationResource`, `requests.Response`) - - """ - response = self.net.get(authzr.uri) - updated_authzr = self._authzr_from_response( - response, authzr.body.identifier, authzr.uri) - return updated_authzr, response - def _revoke(self, cert, rsn, url): """Revoke certificate. @@ -232,6 +221,7 @@ raise errors.ClientError( 'Successful revocation must return HTTP OK status') + class Client(ClientBase): """ACME client for a v1 API. @@ -384,6 +374,22 @@ body=jose.ComparableX509(OpenSSL.crypto.load_certificate( OpenSSL.crypto.FILETYPE_ASN1, response.content))) + def poll(self, authzr): + """Poll Authorization Resource for status. + + :param authzr: Authorization Resource + :type authzr: `.AuthorizationResource` + + :returns: Updated Authorization Resource and HTTP response. + + :rtype: (`.AuthorizationResource`, `requests.Response`) + + """ + response = self.net.get(authzr.uri) + updated_authzr = self._authzr_from_response( + response, authzr.body.identifier, authzr.uri) + return updated_authzr, response + def poll_and_request_issuance( self, csr, authzrs, mintime=5, max_attempts=10): """Poll and request issuance. @@ -415,7 +421,7 @@ """ # pylint: disable=too-many-locals assert max_attempts > 0 - attempts = collections.defaultdict(int) + attempts = collections.defaultdict(int) # type: Dict[messages.AuthorizationResource, int] exhausted = set() # priority queue with datetime.datetime (based on Retry-After) as key, @@ -529,7 +535,7 @@ :rtype: `list` of `OpenSSL.crypto.X509` wrapped in `.ComparableX509` """ - chain = [] + chain = [] # type: List[jose.ComparableX509] uri = certr.cert_chain_uri while uri is not None and len(chain) < max_length: response, cert = self._get_cert(uri) @@ -575,16 +581,57 @@ :param .NewRegistration new_account: + :raises .ConflictError: in case the account already exists + :returns: Registration Resource. :rtype: `.RegistrationResource` """ response = self._post(self.directory['newAccount'], new_account) + # if account already exists + if response.status_code == 200 and 'Location' in response.headers: + raise errors.ConflictError(response.headers.get('Location')) # "Instance of 'Field' has no key/contact member" bug: # pylint: disable=no-member regr = self._regr_from_response(response) self.net.account = regr return regr + def query_registration(self, regr): + """Query server about registration. + + :param messages.RegistrationResource: Existing Registration + Resource. + + """ + self.net.account = regr + updated_regr = super(ClientV2, self).query_registration(regr) + self.net.account = updated_regr + return updated_regr + + def update_registration(self, regr, update=None): + """Update registration. + + :param messages.RegistrationResource regr: Registration Resource. + :param messages.Registration update: Updated body of the + resource. If not provided, body will be taken from `regr`. + + :returns: Updated Registration Resource. + :rtype: `.RegistrationResource` + + """ + # https://github.com/certbot/certbot/issues/6155 + new_regr = self._get_v2_account(regr) + return super(ClientV2, self).update_registration(new_regr, update) + + def _get_v2_account(self, regr): + self.net.account = None + only_existing_reg = regr.body.update(only_return_existing=True) + response = self._post(self.directory['newAccount'], only_existing_reg) + updated_uri = response.headers['Location'] + new_regr = regr.update(uri=updated_uri) + self.net.account = new_regr + return new_regr + def new_order(self, csr_pem): """Request a new Order object from the server. @@ -606,13 +653,29 @@ body = messages.Order.from_json(response.json()) authorizations = [] for url in body.authorizations: - authorizations.append(self._authzr_from_response(self.net.get(url), uri=url)) + authorizations.append(self._authzr_from_response(self._post_as_get(url), uri=url)) return messages.OrderResource( body=body, uri=response.headers.get('Location'), authorizations=authorizations, csr_pem=csr_pem) + def poll(self, authzr): + """Poll Authorization Resource for status. + + :param authzr: Authorization Resource + :type authzr: `.AuthorizationResource` + + :returns: Updated Authorization Resource and HTTP response. + + :rtype: (`.AuthorizationResource`, `requests.Response`) + + """ + response = self._post_as_get(authzr.uri) + updated_authzr = self._authzr_from_response( + response, authzr.body.identifier, authzr.uri) + return updated_authzr, response + def poll_and_finalize(self, orderr, deadline=None): """Poll authorizations and finalize the order. @@ -636,7 +699,7 @@ responses = [] for url in orderr.body.authorizations: while datetime.datetime.now() < deadline: - authzr = self._authzr_from_response(self.net.get(url), uri=url) + authzr = self._authzr_from_response(self._post_as_get(url), uri=url) if authzr.body.status != messages.STATUS_PENDING: responses.append(authzr) break @@ -671,12 +734,12 @@ self._post(orderr.body.finalize, wrapped_csr) while datetime.datetime.now() < deadline: time.sleep(1) - response = self.net.get(orderr.uri) + response = self._post_as_get(orderr.uri) body = messages.Order.from_json(response.json()) if body.error is not None: raise errors.IssuanceError(body.error) if body.certificate is not None: - certificate_response = self.net.get(body.certificate, + certificate_response = self._post_as_get(body.certificate, content_type=DER_CONTENT_TYPE).text return orderr.update(body=body, fullchain_pem=certificate_response) raise errors.TimeoutError() @@ -694,6 +757,39 @@ """ return self._revoke(cert, rsn, self.directory['revokeCert']) + def external_account_required(self): + """Checks if ACME server requires External Account Binding authentication.""" + if hasattr(self.directory, 'meta') and self.directory.meta.external_account_required: + return True + else: + return False + + def _post_as_get(self, *args, **kwargs): + """ + Send GET request using the POST-as-GET protocol if needed. + The request will be first issued using POST-as-GET for ACME v2. If the ACME CA servers do + not support this yet and return an error, request will be retried using GET. + For ACME v1, only GET request will be tried, as POST-as-GET is not supported. + :param args: + :param kwargs: + :return: + """ + if self.acme_version >= 2: + # We add an empty payload for POST-as-GET requests + new_args = args[:1] + (None,) + args[1:] + try: + return self._post(*new_args, **kwargs) # pylint: disable=star-args + except messages.Error as error: + if error.code == 'malformed': + logger.debug('Error during a POST-as-GET request, ' + 'your ACME CA may not support it:\n%s', error) + logger.debug('Retrying request with GET.') + else: # pragma: no cover + raise + + # If POST-as-GET is not supported yet, we use a GET instead. + return self.net.get(*args, **kwargs) + class BackwardsCompatibleClientV2(object): """ACME client wrapper that tends towards V2-style calls, but @@ -723,12 +819,7 @@ self.client = ClientV2(directory, net=net) def __getattr__(self, name): - if name in vars(self.client): - return getattr(self.client, name) - elif name in dir(ClientBase): - return getattr(self.client, name) - else: - raise AttributeError() + return getattr(self.client, name) def new_account_and_tos(self, regr, check_tos_cb=None): """Combined register and agree_tos for V1, new_account for V2 @@ -835,6 +926,15 @@ else: return 1 + def external_account_required(self): + """Checks if the server requires an external account for ACMEv2 servers. + + Always return False for ACMEv1 servers, as it doesn't use External Account Binding.""" + if self.acme_version == 1: + return False + else: + return self.client.external_account_required() + class ClientNetwork(object): # pylint: disable=too-many-instance-attributes """Wrapper around requests that signs POSTs for authentication. @@ -856,18 +956,28 @@ :param bool verify_ssl: Whether to verify certificates on SSL connections. :param str user_agent: String to send as User-Agent header. :param float timeout: Timeout for requests. + :param source_address: Optional source address to bind to when making requests. + :type source_address: str or tuple(str, int) """ def __init__(self, key, account=None, alg=jose.RS256, verify_ssl=True, - user_agent='acme-python', timeout=DEFAULT_NETWORK_TIMEOUT): + user_agent='acme-python', timeout=DEFAULT_NETWORK_TIMEOUT, + source_address=None): # pylint: disable=too-many-arguments self.key = key self.account = account self.alg = alg self.verify_ssl = verify_ssl - self._nonces = set() + self._nonces = set() # type: Set[Text] self.user_agent = user_agent self.session = requests.Session() self._default_timeout = timeout + adapter = HTTPAdapter() + + if source_address is not None: + adapter = SourceAddressAdapter(source_address) + + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) def __del__(self): # Try to close the session, but don't show exceptions to the @@ -888,7 +998,7 @@ :rtype: `josepy.JWS` """ - jobj = obj.json_dumps(indent=2).encode() + jobj = obj.json_dumps(indent=2).encode() if obj else b'' logger.debug('JWS payload:\n%s', jobj) kwargs = { "alg": self.alg, @@ -897,6 +1007,7 @@ if acme_version == 2: kwargs["url"] = url # newAccount and revokeCert work without the kid + # newAccount must not have kid if self.account is not None: kwargs["kid"] = self.account["uri"] kwargs["key"] = self.key @@ -1017,7 +1128,7 @@ if response.headers.get("Content-Type") == DER_CONTENT_TYPE: debug_content = base64.b64encode(response.content) else: - debug_content = response.content + debug_content = response.content.decode("utf-8") logger.debug('Received response:\nHTTP %d\n%s\n\n%s', response.status_code, "\n".join(["{0}: {1}".format(k, v) @@ -1052,10 +1163,15 @@ else: raise errors.MissingNonce(response) - def _get_nonce(self, url): + def _get_nonce(self, url, new_nonce_url): if not self._nonces: logger.debug('Requesting fresh nonce') - self._add_nonce(self.head(url)) + if new_nonce_url is None: + response = self.head(url) + else: + # request a new nonce from the acme newNonce endpoint + response = self._check_response(self.head(new_nonce_url), content_type=None) + self._add_nonce(response) return self._nonces.pop() def post(self, *args, **kwargs): @@ -1076,8 +1192,13 @@ def _post_once(self, url, obj, content_type=JOSE_CONTENT_TYPE, acme_version=1, **kwargs): - data = self._wrap_in_jws(obj, self._get_nonce(url), url, acme_version) + try: + new_nonce_url = kwargs.pop('new_nonce_url') + except KeyError: + new_nonce_url = None + data = self._wrap_in_jws(obj, self._get_nonce(url, new_nonce_url), url, acme_version) kwargs.setdefault('headers', {'Content-Type': content_type}) response = self._send_request('POST', url, data=data, **kwargs) + response = self._check_response(response, content_type=content_type) self._add_nonce(response) - return self._check_response(response, content_type=content_type) + return response diff -Nru python-acme-0.22.2/acme/client_test.py python-acme-0.31.0/acme/client_test.py --- python-acme-0.22.2/acme/client_test.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/acme/client_test.py 2019-02-07 21:20:29.000000000 +0000 @@ -1,4 +1,5 @@ """Tests for acme.client.""" +# pylint: disable=too-many-lines import copy import datetime import json @@ -17,6 +18,7 @@ from acme import messages from acme import messages_test from acme import test_util +from acme.magic_typing import Dict # pylint: disable=unused-import, no-name-in-module CERT_DER = test_util.load_vector('cert.der') @@ -61,7 +63,8 @@ self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212') reg = messages.Registration( contact=self.contact, key=KEY.public_key()) - self.new_reg = messages.NewRegistration(**dict(reg)) + the_arg = dict(reg) # type: Dict + self.new_reg = messages.NewRegistration(**the_arg) # pylint: disable=star-args self.regr = messages.RegistrationResource( body=reg, uri='https://www.letsencrypt-demo.org/acme/reg/1') @@ -132,12 +135,18 @@ client = self._init() self.assertEqual(client.acme_version, 2) + def test_query_registration_client_v2(self): + self.response.json.return_value = DIRECTORY_V2.to_json() + client = self._init() + self.response.json.return_value = self.regr.body.to_json() + self.assertEqual(self.regr, client.query_registration(self.regr)) + def test_forwarding(self): self.response.json.return_value = DIRECTORY_V1.to_json() client = self._init() self.assertEqual(client.directory, client.client.directory) self.assertEqual(client.key, KEY) - self.assertEqual(client.update_registration, client.client.update_registration) + self.assertEqual(client.deactivate_registration, client.client.deactivate_registration) self.assertRaises(AttributeError, client.__getattr__, 'nonexistent') self.assertRaises(AttributeError, client.__getattr__, 'new_account_and_tos') self.assertRaises(AttributeError, client.__getattr__, 'new_account') @@ -268,6 +277,44 @@ client.revoke(messages_test.CERT, self.rsn) mock_client().revoke.assert_called_once_with(messages_test.CERT, self.rsn) + def test_update_registration(self): + self.response.json.return_value = DIRECTORY_V1.to_json() + with mock.patch('acme.client.Client') as mock_client: + client = self._init() + client.update_registration(mock.sentinel.regr, None) + mock_client().update_registration.assert_called_once_with(mock.sentinel.regr, None) + + # newNonce present means it will pick acme_version 2 + def test_external_account_required_true(self): + self.response.json.return_value = messages.Directory({ + 'newNonce': 'http://letsencrypt-test.com/acme/new-nonce', + 'meta': messages.Directory.Meta(external_account_required=True), + }).to_json() + + client = self._init() + + self.assertTrue(client.external_account_required()) + + # newNonce present means it will pick acme_version 2 + def test_external_account_required_false(self): + self.response.json.return_value = messages.Directory({ + 'newNonce': 'http://letsencrypt-test.com/acme/new-nonce', + 'meta': messages.Directory.Meta(external_account_required=False), + }).to_json() + + client = self._init() + + self.assertFalse(client.external_account_required()) + + def test_external_account_required_false_v1(self): + self.response.json.return_value = messages.Directory({ + 'meta': messages.Directory.Meta(external_account_required=False), + }).to_json() + + client = self._init() + + self.assertFalse(client.external_account_required()) + class ClientTest(ClientTestBase): """Tests for acme.client.Client.""" @@ -650,7 +697,7 @@ def test_revocation_payload(self): obj = messages.Revocation(certificate=self.certr.body, reason=self.rsn) self.assertTrue('reason' in obj.to_partial_json().keys()) - self.assertEquals(self.rsn, obj.to_partial_json()['reason']) + self.assertEqual(self.rsn, obj.to_partial_json()['reason']) def test_revoke_bad_status_raises_error(self): self.response.status_code = http_client.METHOD_NOT_ALLOWED @@ -660,6 +707,7 @@ self.certr, self.rsn) + class ClientV2Test(ClientTestBase): """Tests for acme.client.ClientV2.""" @@ -697,6 +745,11 @@ self.assertEqual(self.regr, self.client.new_account(self.new_reg)) + def test_new_account_conflict(self): + self.response.status_code = http_client.OK + self.response.headers['Location'] = self.regr.uri + self.assertRaises(errors.ConflictError, self.client.new_account, self.new_reg) + def test_new_order(self): order_response = copy.deepcopy(self.response) order_response.status_code = http_client.CREATED @@ -710,9 +763,10 @@ authz_response2 = self.response authz_response2.json.return_value = self.authz2.to_json() authz_response2.headers['Location'] = self.authzr2.uri - self.net.get.side_effect = (authz_response, authz_response2) - self.assertEqual(self.client.new_order(CSR_SAN_PEM), self.orderr) + with mock.patch('acme.client.ClientV2._post_as_get') as mock_post_as_get: + mock_post_as_get.side_effect = (authz_response, authz_response2) + self.assertEqual(self.client.new_order(CSR_SAN_PEM), self.orderr) @mock.patch('acme.client.datetime') def test_poll_and_finalize(self, mock_datetime): @@ -785,7 +839,62 @@ def test_revoke(self): self.client.revoke(messages_test.CERT, self.rsn) self.net.post.assert_called_once_with( - self.directory["revokeCert"], mock.ANY, acme_version=2) + self.directory["revokeCert"], mock.ANY, acme_version=2, + new_nonce_url=DIRECTORY_V2['newNonce']) + + def test_update_registration(self): + # "Instance of 'Field' has no to_json/update member" bug: + # pylint: disable=no-member + self.response.headers['Location'] = self.regr.uri + self.response.json.return_value = self.regr.body.to_json() + self.assertEqual(self.regr, self.client.update_registration(self.regr)) + self.assertNotEqual(self.client.net.account, None) + self.assertEqual(self.client.net.post.call_count, 2) + self.assertTrue(DIRECTORY_V2.newAccount in self.net.post.call_args_list[0][0]) + + self.response.json.return_value = self.regr.body.update( + contact=()).to_json() + + def test_external_account_required_true(self): + self.client.directory = messages.Directory({ + 'meta': messages.Directory.Meta(external_account_required=True) + }) + + self.assertTrue(self.client.external_account_required()) + + def test_external_account_required_false(self): + self.client.directory = messages.Directory({ + 'meta': messages.Directory.Meta(external_account_required=False) + }) + + self.assertFalse(self.client.external_account_required()) + + def test_external_account_required_default(self): + self.assertFalse(self.client.external_account_required()) + + def test_post_as_get(self): + with mock.patch('acme.client.ClientV2._authzr_from_response') as mock_client: + mock_client.return_value = self.authzr2 + + self.client.poll(self.authzr2) # pylint: disable=protected-access + + self.client.net.post.assert_called_once_with( + self.authzr2.uri, None, acme_version=2, + new_nonce_url='https://www.letsencrypt-demo.org/acme/new-nonce') + self.client.net.get.assert_not_called() + + class FakeError(messages.Error): # pylint: disable=too-many-ancestors + """Fake error to reproduce a malformed request ACME error""" + def __init__(self): # pylint: disable=super-init-not-called + pass + @property + def code(self): + return 'malformed' + self.client.net.post.side_effect = FakeError() + + self.client.poll(self.authzr2) # pylint: disable=protected-access + + self.client.net.get.assert_called_once_with(self.authzr2.uri) class MockJSONDeSerializable(jose.JSONDeSerializable): @@ -842,7 +951,6 @@ self.assertEqual(jws.signature.combined.kid, u'acct-uri') self.assertEqual(jws.signature.combined.url, u'url') - def test_check_response_not_ok_jobj_no_error(self): self.response.ok = False self.response.json.return_value = {} @@ -1005,8 +1113,8 @@ # Requests Library Exceptions except requests.exceptions.ConnectionError as z: #pragma: no cover - self.assertEqual("('Connection aborted.', " - "error(111, 'Connection refused'))", str(z)) + self.assertTrue("'Connection aborted.'" in str(z) or "[WinError 10061]" in str(z)) + class ClientNetworkWithMockedResponseTest(unittest.TestCase): """Tests for acme.client.ClientNetwork which mock out response.""" @@ -1019,7 +1127,10 @@ self.response = mock.MagicMock(ok=True, status_code=http_client.OK) self.response.headers = {} self.response.links = {} - self.checked_response = mock.MagicMock() + self.response.checked = False + self.acmev1_nonce_response = mock.MagicMock(ok=False, + status_code=http_client.METHOD_NOT_ALLOWED) + self.acmev1_nonce_response.headers = {} self.obj = mock.MagicMock() self.wrapped_obj = mock.MagicMock() self.content_type = mock.sentinel.content_type @@ -1031,13 +1142,21 @@ def send_request(*args, **kwargs): # pylint: disable=unused-argument,missing-docstring + self.assertFalse("new_nonce_url" in kwargs) + method = args[0] + uri = args[1] + if method == 'HEAD' and uri != "new_nonce_uri": + response = self.acmev1_nonce_response + else: + response = self.response + if self.available_nonces: - self.response.headers = { + response.headers = { self.net.REPLAY_NONCE_HEADER: self.available_nonces.pop().decode()} else: - self.response.headers = {} - return self.response + response.headers = {} + return response # pylint: disable=protected-access self.net._send_request = self.send_request = mock.MagicMock( @@ -1049,28 +1168,39 @@ # pylint: disable=missing-docstring self.assertEqual(self.response, response) self.assertEqual(self.content_type, content_type) - return self.checked_response + self.assertTrue(self.response.ok) + self.response.checked = True + return self.response def test_head(self): - self.assertEqual(self.response, self.net.head( + self.assertEqual(self.acmev1_nonce_response, self.net.head( 'http://example.com/', 'foo', bar='baz')) self.send_request.assert_called_once_with( 'HEAD', 'http://example.com/', 'foo', bar='baz') + def test_head_v2(self): + self.assertEqual(self.response, self.net.head( + 'new_nonce_uri', 'foo', bar='baz')) + self.send_request.assert_called_once_with( + 'HEAD', 'new_nonce_uri', 'foo', bar='baz') + def test_get(self): - self.assertEqual(self.checked_response, self.net.get( + self.assertEqual(self.response, self.net.get( 'http://example.com/', content_type=self.content_type, bar='baz')) + self.assertTrue(self.response.checked) self.send_request.assert_called_once_with( 'GET', 'http://example.com/', bar='baz') def test_post_no_content_type(self): self.content_type = self.net.JOSE_CONTENT_TYPE - self.assertEqual(self.checked_response, self.net.post('uri', self.obj)) + self.assertEqual(self.response, self.net.post('uri', self.obj)) + self.assertTrue(self.response.checked) def test_post(self): # pylint: disable=protected-access - self.assertEqual(self.checked_response, self.net.post( + self.assertEqual(self.response, self.net.post( 'uri', self.obj, content_type=self.content_type)) + self.assertTrue(self.response.checked) self.net._wrap_in_jws.assert_called_once_with( self.obj, jose.b64decode(self.all_nonces.pop()), "uri", 1) @@ -1102,7 +1232,7 @@ def test_post_not_retried(self): check_response = mock.MagicMock() check_response.side_effect = [messages.Error.with_code('malformed'), - self.checked_response] + self.response] # pylint: disable=protected-access self.net._check_response = check_response @@ -1110,13 +1240,12 @@ self.obj, content_type=self.content_type) def test_post_successful_retry(self): - check_response = mock.MagicMock() - check_response.side_effect = [messages.Error.with_code('badNonce'), - self.checked_response] + post_once = mock.MagicMock() + post_once.side_effect = [messages.Error.with_code('badNonce'), + self.response] # pylint: disable=protected-access - self.net._check_response = check_response - self.assertEqual(self.checked_response, self.net.post( + self.assertEqual(self.response, self.net.post( 'uri', self.obj, content_type=self.content_type)) def test_head_get_post_error_passthrough(self): @@ -1127,6 +1256,51 @@ self.assertRaises(requests.exceptions.RequestException, self.net.post, 'uri', obj=self.obj) + def test_post_bad_nonce_head(self): + # pylint: disable=protected-access + # regression test for https://github.com/certbot/certbot/issues/6092 + bad_response = mock.MagicMock(ok=False, status_code=http_client.SERVICE_UNAVAILABLE) + self.net._send_request = mock.MagicMock() + self.net._send_request.return_value = bad_response + self.content_type = None + check_response = mock.MagicMock() + self.net._check_response = check_response + self.assertRaises(errors.ClientError, self.net.post, 'uri', + self.obj, content_type=self.content_type, acme_version=2, + new_nonce_url='new_nonce_uri') + self.assertEqual(check_response.call_count, 1) + + def test_new_nonce_uri_removed(self): + self.content_type = None + self.net.post('uri', self.obj, content_type=None, + acme_version=2, new_nonce_url='new_nonce_uri') + + +class ClientNetworkSourceAddressBindingTest(unittest.TestCase): + """Tests that if ClientNetwork has a source IP set manually, the underlying library has + used the provided source address.""" + + def setUp(self): + self.source_address = "8.8.8.8" + + def test_source_address_set(self): + from acme.client import ClientNetwork + net = ClientNetwork(key=None, alg=None, source_address=self.source_address) + for adapter in net.session.adapters.values(): + self.assertTrue(self.source_address in adapter.source_address) + + def test_behavior_assumption(self): + """This is a test that guardrails the HTTPAdapter behavior so that if the default for + a Session() changes, the assumptions here aren't violated silently.""" + from acme.client import ClientNetwork + # Source address not specified, so the default adapter type should be bound -- this + # test should fail if the default adapter type is changed by requests + net = ClientNetwork(key=None, alg=None) + session = requests.Session() + for scheme in session.adapters.keys(): + client_network_adapter = net.session.adapters.get(scheme) + default_adapter = session.adapters.get(scheme) + self.assertEqual(client_network_adapter.__class__, default_adapter.__class__) if __name__ == '__main__': unittest.main() # pragma: no cover diff -Nru python-acme-0.22.2/acme/crypto_util.py python-acme-0.31.0/acme/crypto_util.py --- python-acme-0.22.2/acme/crypto_util.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/acme/crypto_util.py 2019-02-07 21:20:29.000000000 +0000 @@ -6,11 +6,14 @@ import re import socket -import OpenSSL +from OpenSSL import crypto +from OpenSSL import SSL # type: ignore # https://github.com/python/typeshed/issues/2052 import josepy as jose - from acme import errors +# pylint: disable=unused-import, no-name-in-module +from acme.magic_typing import Callable, Union, Tuple, Optional +# pylint: enable=unused-import, no-name-in-module logger = logging.getLogger(__name__) @@ -25,7 +28,7 @@ # https://www.openssl.org/docs/ssl/SSLv23_method.html). _serve_sni # should be changed to use "set_options" to disable SSLv2 and SSLv3, # in case it's used for things other than probing/serving! -_DEFAULT_TLSSNI01_SSL_METHOD = OpenSSL.SSL.SSLv23_METHOD # type: ignore +_DEFAULT_TLSSNI01_SSL_METHOD = SSL.SSLv23_METHOD # type: ignore class SSLSocket(object): # pylint: disable=too-few-public-methods @@ -64,9 +67,9 @@ logger.debug("Server name (%s) not recognized, dropping SSL", server_name) return - new_context = OpenSSL.SSL.Context(self.method) - new_context.set_options(OpenSSL.SSL.OP_NO_SSLv2) - new_context.set_options(OpenSSL.SSL.OP_NO_SSLv3) + new_context = SSL.Context(self.method) + new_context.set_options(SSL.OP_NO_SSLv2) + new_context.set_options(SSL.OP_NO_SSLv3) new_context.use_privatekey(key) new_context.use_certificate(cert) connection.set_context(new_context) @@ -89,18 +92,18 @@ def accept(self): # pylint: disable=missing-docstring sock, addr = self.sock.accept() - context = OpenSSL.SSL.Context(self.method) - context.set_options(OpenSSL.SSL.OP_NO_SSLv2) - context.set_options(OpenSSL.SSL.OP_NO_SSLv3) + context = SSL.Context(self.method) + context.set_options(SSL.OP_NO_SSLv2) + context.set_options(SSL.OP_NO_SSLv3) context.set_tlsext_servername_callback(self._pick_certificate_cb) - ssl_sock = self.FakeConnection(OpenSSL.SSL.Connection(context, sock)) + ssl_sock = self.FakeConnection(SSL.Connection(context, sock)) ssl_sock.set_accept_state() logger.debug("Performing handshake with %s", addr) try: ssl_sock.do_handshake() - except OpenSSL.SSL.Error as error: + except SSL.Error as error: # _pick_certificate_cb might have returned without # creating SSL context (wrong server name) raise socket.error(error) @@ -128,30 +131,33 @@ :rtype: OpenSSL.crypto.X509 """ - context = OpenSSL.SSL.Context(method) + context = SSL.Context(method) context.set_timeout(timeout) socket_kwargs = {'source_address': source_address} - host_protocol_agnostic = None if host == '::' or host == '0' else host - try: # pylint: disable=star-args - logger.debug("Attempting to connect to %s:%d%s.", host_protocol_agnostic, port, - " from {0}:{1}".format(source_address[0], source_address[1]) if \ - socket_kwargs else "") - sock = socket.create_connection((host_protocol_agnostic, port), **socket_kwargs) + logger.debug( + "Attempting to connect to %s:%d%s.", host, port, + " from {0}:{1}".format( + source_address[0], + source_address[1] + ) if socket_kwargs else "" + ) + socket_tuple = (host, port) # type: Tuple[str, int] + sock = socket.create_connection(socket_tuple, **socket_kwargs) # type: ignore except socket.error as error: raise errors.Error(error) with contextlib.closing(sock) as client: - client_ssl = OpenSSL.SSL.Connection(context, client) + client_ssl = SSL.Connection(context, client) client_ssl.set_connect_state() client_ssl.set_tlsext_host_name(name) # pyOpenSSL>=0.13 try: client_ssl.do_handshake() client_ssl.shutdown() - except OpenSSL.SSL.Error as error: + except SSL.Error as error: raise errors.Error(error) return client_ssl.get_peer_certificate() @@ -164,18 +170,18 @@ OCSP Must Staple: https://tools.ietf.org/html/rfc7633). :returns: buffer PEM-encoded Certificate Signing Request. """ - private_key = OpenSSL.crypto.load_privatekey( - OpenSSL.crypto.FILETYPE_PEM, private_key_pem) - csr = OpenSSL.crypto.X509Req() + private_key = crypto.load_privatekey( + crypto.FILETYPE_PEM, private_key_pem) + csr = crypto.X509Req() extensions = [ - OpenSSL.crypto.X509Extension( + crypto.X509Extension( b'subjectAltName', critical=False, value=', '.join('DNS:' + d for d in domains).encode('ascii') ), ] if must_staple: - extensions.append(OpenSSL.crypto.X509Extension( + extensions.append(crypto.X509Extension( b"1.3.6.1.5.5.7.1.24", critical=False, value=b"DER:30:03:02:01:05")) @@ -183,8 +189,8 @@ csr.set_pubkey(private_key) csr.set_version(2) csr.sign(private_key, 'sha256') - return OpenSSL.crypto.dump_certificate_request( - OpenSSL.crypto.FILETYPE_PEM, csr) + return crypto.dump_certificate_request( + crypto.FILETYPE_PEM, csr) def _pyopenssl_cert_or_req_all_names(loaded_cert_or_req): common_name = loaded_cert_or_req.get_subject().CN @@ -221,11 +227,12 @@ parts_separator = ", " prefix = "DNS" + part_separator - if isinstance(cert_or_req, OpenSSL.crypto.X509): - func = OpenSSL.crypto.dump_certificate + if isinstance(cert_or_req, crypto.X509): + # pylint: disable=line-too-long + func = crypto.dump_certificate # type: Union[Callable[[int, crypto.X509Req], bytes], Callable[[int, crypto.X509], bytes]] else: - func = OpenSSL.crypto.dump_certificate_request - text = func(OpenSSL.crypto.FILETYPE_TEXT, cert_or_req).decode("utf-8") + func = crypto.dump_certificate_request + text = func(crypto.FILETYPE_TEXT, cert_or_req).decode("utf-8") # WARNING: this function does not support multiple SANs extensions. # Multiple X509v3 extensions of the same type is disallowed by RFC 5280. match = re.search(r"X509v3 Subject Alternative Name:(?: critical)?\s*(.*)", text) @@ -252,12 +259,12 @@ """ assert domains, "Must provide one or more hostnames for the cert." - cert = OpenSSL.crypto.X509() + cert = crypto.X509() cert.set_serial_number(int(binascii.hexlify(os.urandom(16)), 16)) cert.set_version(2) extensions = [ - OpenSSL.crypto.X509Extension( + crypto.X509Extension( b"basicConstraints", True, b"CA:TRUE, pathlen:0"), ] @@ -266,7 +273,7 @@ cert.set_issuer(cert.get_subject()) if force_san or len(domains) > 1: - extensions.append(OpenSSL.crypto.X509Extension( + extensions.append(crypto.X509Extension( b"subjectAltName", critical=False, value=b", ".join(b"DNS:" + d.encode() for d in domains) @@ -281,7 +288,7 @@ cert.sign(key, "sha256") return cert -def dump_pyopenssl_chain(chain, filetype=OpenSSL.crypto.FILETYPE_PEM): +def dump_pyopenssl_chain(chain, filetype=crypto.FILETYPE_PEM): """Dump certificate chain into a bundle. :param list chain: List of `OpenSSL.crypto.X509` (or wrapped in @@ -298,7 +305,7 @@ if isinstance(cert, jose.ComparableX509): # pylint: disable=protected-access cert = cert.wrapped - return OpenSSL.crypto.dump_certificate(filetype, cert) + return crypto.dump_certificate(filetype, cert) # assumes that OpenSSL.crypto.dump_certificate includes ending # newline character diff -Nru python-acme-0.22.2/acme/crypto_util_test.py python-acme-0.31.0/acme/crypto_util_test.py --- python-acme-0.22.2/acme/crypto_util_test.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/acme/crypto_util_test.py 2019-02-07 21:20:29.000000000 +0000 @@ -13,6 +13,7 @@ from acme import errors from acme import test_util +from acme.magic_typing import List # pylint: disable=unused-import, no-name-in-module class SSLSocketAndProbeSNITest(unittest.TestCase): @@ -41,28 +42,38 @@ self.server_thread = threading.Thread( # pylint: disable=no-member target=self.server.handle_request) - self.server_thread.start() - time.sleep(1) # TODO: avoid race conditions in other way def tearDown(self): - self.server_thread.join() + if self.server_thread.is_alive(): + # The thread may have already terminated. + self.server_thread.join() # pragma: no cover def _probe(self, name): from acme.crypto_util import probe_sni return jose.ComparableX509(probe_sni( name, host='127.0.0.1', port=self.port)) + def _start_server(self): + self.server_thread.start() + time.sleep(1) # TODO: avoid race conditions in other way + def test_probe_ok(self): + self._start_server() self.assertEqual(self.cert, self._probe(b'foo')) def test_probe_not_recognized_name(self): + self._start_server() self.assertRaises(errors.Error, self._probe, b'bar') - # TODO: py33/py34 tox hangs forever on do_handshake in second probe - #def probe_connection_error(self): - # self._probe(b'foo') - # #time.sleep(1) # TODO: avoid race conditions in other way - # self.assertRaises(errors.Error, self._probe, b'bar') + def test_probe_connection_error(self): + # pylint has a hard time with six + self.server.server_close() # pylint: disable=no-member + original_timeout = socket.getdefaulttimeout() + try: + socket.setdefaulttimeout(1) + self.assertRaises(errors.Error, self._probe, b'bar') + finally: + socket.setdefaulttimeout(original_timeout) class PyOpenSSLCertOrReqAllNamesTest(unittest.TestCase): @@ -165,7 +176,7 @@ def setUp(self): self.cert_count = 5 - self.serial_num = [] + self.serial_num = [] # type: List[int] self.key = OpenSSL.crypto.PKey() self.key.generate_key(OpenSSL.crypto.TYPE_RSA, 2048) @@ -198,8 +209,8 @@ # have a get_extensions() method, so we skip this test if the method # isn't available. if hasattr(csr, 'get_extensions'): - self.assertEquals(len(csr.get_extensions()), 1) - self.assertEquals(csr.get_extensions()[0].get_data(), + self.assertEqual(len(csr.get_extensions()), 1) + self.assertEqual(csr.get_extensions()[0].get_data(), OpenSSL.crypto.X509Extension( b'subjectAltName', critical=False, @@ -216,7 +227,7 @@ # have a get_extensions() method, so we skip this test if the method # isn't available. if hasattr(csr, 'get_extensions'): - self.assertEquals(len(csr.get_extensions()), 2) + self.assertEqual(len(csr.get_extensions()), 2) # NOTE: Ideally we would filter by the TLS Feature OID, but # OpenSSL.crypto.X509Extension doesn't give us the extension's raw OID, # and the shortname field is just "UNDEF" diff -Nru python-acme-0.22.2/acme/errors.py python-acme-0.31.0/acme/errors.py --- python-acme-0.22.2/acme/errors.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/acme/errors.py 2019-02-07 21:20:29.000000000 +0000 @@ -110,6 +110,8 @@ In the version of ACME implemented by Boulder, this is used to find an account if you only have the private key, but don't know the account URL. + + Also used in V2 of the ACME client for the same purpose. """ def __init__(self, location): self.location = location diff -Nru python-acme-0.22.2/acme/__init__.py python-acme-0.31.0/acme/__init__.py --- python-acme-0.22.2/acme/__init__.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/acme/__init__.py 2019-02-07 21:20:29.000000000 +0000 @@ -1,12 +1,22 @@ """ACME protocol implementation. -This module is an implementation of the `ACME protocol`_. Latest -supported version: `draft-ietf-acme-01`_. - +This module is an implementation of the `ACME protocol`_. .. _`ACME protocol`: https://ietf-wg-acme.github.io/acme -.. _`draft-ietf-acme-01`: - https://github.com/ietf-wg-acme/acme/tree/draft-ietf-acme-acme-01 - """ +import sys + +# This code exists to keep backwards compatibility with people using acme.jose +# before it became the standalone josepy package. +# +# It is based on +# https://github.com/requests/requests/blob/1278ecdf71a312dc2268f3bfc0aabfab3c006dcf/requests/packages.py + +import josepy as jose + +for mod in list(sys.modules): + # This traversal is apparently necessary such that the identities are + # preserved (acme.jose.* is josepy.*) + if mod == 'josepy' or mod.startswith('josepy.'): + sys.modules['acme.' + mod.replace('josepy', 'jose', 1)] = sys.modules[mod] diff -Nru python-acme-0.22.2/acme/jose_test.py python-acme-0.31.0/acme/jose_test.py --- python-acme-0.22.2/acme/jose_test.py 1970-01-01 00:00:00.000000000 +0000 +++ python-acme-0.31.0/acme/jose_test.py 2019-02-07 21:20:29.000000000 +0000 @@ -0,0 +1,53 @@ +"""Tests for acme.jose shim.""" +import importlib +import unittest + +class JoseTest(unittest.TestCase): + """Tests for acme.jose shim.""" + + def _test_it(self, submodule, attribute): + if submodule: + acme_jose_path = 'acme.jose.' + submodule + josepy_path = 'josepy.' + submodule + else: + acme_jose_path = 'acme.jose' + josepy_path = 'josepy' + acme_jose_mod = importlib.import_module(acme_jose_path) + josepy_mod = importlib.import_module(josepy_path) + + self.assertIs(acme_jose_mod, josepy_mod) + self.assertIs(getattr(acme_jose_mod, attribute), getattr(josepy_mod, attribute)) + + # We use the imports below with eval, but pylint doesn't + # understand that. + # pylint: disable=eval-used,unused-variable + import acme + import josepy + acme_jose_mod = eval(acme_jose_path) + josepy_mod = eval(josepy_path) + self.assertIs(acme_jose_mod, josepy_mod) + self.assertIs(getattr(acme_jose_mod, attribute), getattr(josepy_mod, attribute)) + + def test_top_level(self): + self._test_it('', 'RS512') + + def test_submodules(self): + # This test ensures that the modules in josepy that were + # available at the time it was moved into its own package are + # available under acme.jose. Backwards compatibility with new + # modules or testing code is not maintained. + mods_and_attrs = [('b64', 'b64decode',), + ('errors', 'Error',), + ('interfaces', 'JSONDeSerializable',), + ('json_util', 'Field',), + ('jwa', 'HS256',), + ('jwk', 'JWK',), + ('jws', 'JWS',), + ('util', 'ImmutableMap',),] + + for mod, attr in mods_and_attrs: + self._test_it(mod, attr) + + +if __name__ == '__main__': + unittest.main() # pragma: no cover diff -Nru python-acme-0.22.2/acme/magic_typing.py python-acme-0.31.0/acme/magic_typing.py --- python-acme-0.22.2/acme/magic_typing.py 1970-01-01 00:00:00.000000000 +0000 +++ python-acme-0.31.0/acme/magic_typing.py 2019-02-07 21:20:29.000000000 +0000 @@ -0,0 +1,16 @@ +"""Shim class to not have to depend on typing module in prod.""" +import sys + +class TypingClass(object): + """Ignore import errors by getting anything""" + def __getattr__(self, name): + return None + +try: + # mypy doesn't respect modifying sys.modules + from typing import * # pylint: disable=wildcard-import, unused-wildcard-import + # pylint: disable=unused-import + from typing import Collection, IO # type: ignore + # pylint: enable=unused-import +except ImportError: + sys.modules[__name__] = TypingClass() diff -Nru python-acme-0.22.2/acme/magic_typing_test.py python-acme-0.31.0/acme/magic_typing_test.py --- python-acme-0.22.2/acme/magic_typing_test.py 1970-01-01 00:00:00.000000000 +0000 +++ python-acme-0.31.0/acme/magic_typing_test.py 2019-02-07 21:20:29.000000000 +0000 @@ -0,0 +1,41 @@ +"""Tests for acme.magic_typing.""" +import sys +import unittest + +import mock + + +class MagicTypingTest(unittest.TestCase): + """Tests for acme.magic_typing.""" + def test_import_success(self): + try: + import typing as temp_typing + except ImportError: # pragma: no cover + temp_typing = None # pragma: no cover + typing_class_mock = mock.MagicMock() + text_mock = mock.MagicMock() + typing_class_mock.Text = text_mock + sys.modules['typing'] = typing_class_mock + if 'acme.magic_typing' in sys.modules: + del sys.modules['acme.magic_typing'] # pragma: no cover + from acme.magic_typing import Text # pylint: disable=no-name-in-module + self.assertEqual(Text, text_mock) + del sys.modules['acme.magic_typing'] + sys.modules['typing'] = temp_typing + + def test_import_failure(self): + try: + import typing as temp_typing + except ImportError: # pragma: no cover + temp_typing = None # pragma: no cover + sys.modules['typing'] = None + if 'acme.magic_typing' in sys.modules: + del sys.modules['acme.magic_typing'] # pragma: no cover + from acme.magic_typing import Text # pylint: disable=no-name-in-module + self.assertTrue(Text is None) + del sys.modules['acme.magic_typing'] + sys.modules['typing'] = temp_typing + + +if __name__ == '__main__': + unittest.main() # pragma: no cover diff -Nru python-acme-0.22.2/acme/messages.py python-acme-0.31.0/acme/messages.py --- python-acme-0.22.2/acme/messages.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/acme/messages.py 2019-02-07 21:20:29.000000000 +0000 @@ -1,6 +1,10 @@ """ACME protocol messages.""" -import collections import six +import json +try: + from collections.abc import Hashable # pylint: disable=no-name-in-module +except ImportError: + from collections import Hashable import josepy as jose @@ -8,6 +12,7 @@ from acme import errors from acme import fields from acme import util +from acme import jws OLD_ERROR_PREFIX = "urn:acme:error:" ERROR_PREFIX = "urn:ietf:params:acme:error:" @@ -27,6 +32,7 @@ 'tls': 'The server experienced a TLS error during domain verification', 'unauthorized': 'The client lacks sufficient authorization', 'unknownHost': 'The server could not resolve a domain name', + 'externalAccountRequired': 'The server requires external account binding', } ERROR_TYPE_DESCRIPTIONS = dict( @@ -104,7 +110,7 @@ if part is not None).decode() -class _Constant(jose.JSONDeSerializable, collections.Hashable): # type: ignore +class _Constant(jose.JSONDeSerializable, Hashable): # type: ignore """ACME constant.""" __slots__ = ('name',) POSSIBLE_NAMES = NotImplemented @@ -145,6 +151,7 @@ STATUS_VALID = Status('valid') STATUS_INVALID = Status('invalid') STATUS_REVOKED = Status('revoked') +STATUS_READY = Status('ready') class IdentifierType(_Constant): @@ -175,6 +182,7 @@ _terms_of_service_v2 = jose.Field('termsOfService', omitempty=True) website = jose.Field('website', omitempty=True) caa_identities = jose.Field('caaIdentities', omitempty=True) + external_account_required = jose.Field('externalAccountRequired', omitempty=True) def __init__(self, **kwargs): kwargs = dict((self._internal_name(k), v) for k, v in kwargs.items()) @@ -257,6 +265,24 @@ """ACME Resource Body.""" +class ExternalAccountBinding(object): + """ACME External Account Binding""" + + @classmethod + def from_data(cls, account_public_key, kid, hmac_key, directory): + """Create External Account Binding Resource from contact details, kid and hmac.""" + + key_json = json.dumps(account_public_key.to_partial_json()).encode() + decoded_hmac_key = jose.b64.b64decode(hmac_key) + url = directory["newAccount"] + + eab = jws.JWS.sign(key_json, jose.jwk.JWKOct(key=decoded_hmac_key), + jose.jwa.HS256, None, + url, kid) + + return eab.to_partial_json() + + class Registration(ResourceBody): """Registration Resource Body. @@ -273,19 +299,25 @@ agreement = jose.Field('agreement', omitempty=True) status = jose.Field('status', omitempty=True) terms_of_service_agreed = jose.Field('termsOfServiceAgreed', omitempty=True) + only_return_existing = jose.Field('onlyReturnExisting', omitempty=True) + external_account_binding = jose.Field('externalAccountBinding', omitempty=True) phone_prefix = 'tel:' email_prefix = 'mailto:' @classmethod - def from_data(cls, phone=None, email=None, **kwargs): + def from_data(cls, phone=None, email=None, external_account_binding=None, **kwargs): """Create registration resource from contact details.""" details = list(kwargs.pop('contact', ())) if phone is not None: details.append(cls.phone_prefix + phone) if email is not None: - details.append(cls.email_prefix + email) + details.extend([cls.email_prefix + mail for mail in email.split(',')]) kwargs['contact'] = tuple(details) + + if external_account_binding: + kwargs['external_account_binding'] = external_account_binding + return cls(**kwargs) def _filter_contact(self, prefix): @@ -435,6 +467,7 @@ # be absent'... then acme-spec gives example with 'expires' # present... That's confusing! expires = fields.RFC3339Field('expires', omitempty=True) + wildcard = jose.Field('wildcard', omitempty=True) @challenges.decoder def challenges(value): # pylint: disable=missing-docstring,no-self-argument @@ -520,7 +553,7 @@ """ identifiers = jose.Field('identifiers', omitempty=True) status = jose.Field('status', decoder=Status.from_json, - omitempty=True, default=STATUS_PENDING) + omitempty=True) authorizations = jose.Field('authorizations', omitempty=True) certificate = jose.Field('certificate', omitempty=True) finalize = jose.Field('finalize', omitempty=True) @@ -550,4 +583,3 @@ class NewOrder(Order): """New order.""" resource_type = 'new-order' - resource = fields.Resource(resource_type) diff -Nru python-acme-0.22.2/acme/messages_test.py python-acme-0.31.0/acme/messages_test.py --- python-acme-0.22.2/acme/messages_test.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/acme/messages_test.py 2019-02-07 21:20:29.000000000 +0000 @@ -6,6 +6,7 @@ from acme import challenges from acme import test_util +from acme.magic_typing import Dict # pylint: disable=unused-import, no-name-in-module CERT = test_util.load_comparable_cert('cert.der') @@ -85,7 +86,7 @@ from acme.messages import _Constant class MockConstant(_Constant): # pylint: disable=missing-docstring - POSSIBLE_NAMES = {} + POSSIBLE_NAMES = {} # type: Dict self.MockConstant = MockConstant # pylint: disable=invalid-name self.const_a = MockConstant('a') @@ -173,6 +174,24 @@ self.assertTrue(result) +class ExternalAccountBindingTest(unittest.TestCase): + def setUp(self): + from acme.messages import Directory + self.key = jose.jwk.JWKRSA(key=KEY.public_key()) + self.kid = "kid-for-testing" + self.hmac_key = "hmac-key-for-testing" + self.dir = Directory({ + 'newAccount': 'http://url/acme/new-account', + }) + + def test_from_data(self): + from acme.messages import ExternalAccountBinding + eab = ExternalAccountBinding.from_data(self.key, self.kid, self.hmac_key, self.dir) + + self.assertEqual(len(eab), 3) + self.assertEqual(sorted(eab.keys()), sorted(['protected', 'payload', 'signature'])) + + class RegistrationTest(unittest.TestCase): """Tests for acme.messages.Registration.""" @@ -204,6 +223,22 @@ 'mailto:admin@foo.com', )) + def test_new_registration_from_data_with_eab(self): + from acme.messages import NewRegistration, ExternalAccountBinding, Directory + key = jose.jwk.JWKRSA(key=KEY.public_key()) + kid = "kid-for-testing" + hmac_key = "hmac-key-for-testing" + directory = Directory({ + 'newAccount': 'http://url/acme/new-account', + }) + eab = ExternalAccountBinding.from_data(key, kid, hmac_key, directory) + reg = NewRegistration.from_data(email='admin@foo.com', external_account_binding=eab) + self.assertEqual(reg.contact, ( + 'mailto:admin@foo.com', + )) + self.assertEqual(sorted(reg.external_account_binding.keys()), + sorted(['protected', 'payload', 'signature'])) + def test_phones(self): self.assertEqual(('1234',), self.reg.phones) @@ -423,6 +458,19 @@ 'authorizations': None, }) +class NewOrderTest(unittest.TestCase): + """Tests for acme.messages.NewOrder.""" + + def setUp(self): + from acme.messages import NewOrder + self.reg = NewOrder( + identifiers=mock.sentinel.identifiers) + + def test_to_partial_json(self): + self.assertEqual(self.reg.to_json(), { + 'identifiers': mock.sentinel.identifiers, + }) + if __name__ == '__main__': unittest.main() # pragma: no cover diff -Nru python-acme-0.22.2/acme/standalone.py python-acme-0.31.0/acme/standalone.py --- python-acme-0.22.2/acme/standalone.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/acme/standalone.py 2019-02-07 21:20:29.000000000 +0000 @@ -16,6 +16,7 @@ from acme import challenges from acme import crypto_util +from acme.magic_typing import List # pylint: disable=unused-import, no-name-in-module logger = logging.getLogger(__name__) @@ -66,8 +67,8 @@ def __init__(self, ServerClass, server_address, *remaining_args, **kwargs): port = server_address[1] - self.threads = [] - self.servers = [] + self.threads = [] # type: List[threading.Thread] + self.servers = [] # type: List[ACMEServerMixin] # Must try True first. # Ubuntu, for example, will fail to bind to IPv4 if we've already bound @@ -82,9 +83,22 @@ new_address = (server_address[0],) + (port,) + server_address[2:] new_args = (new_address,) + remaining_args server = ServerClass(*new_args, **kwargs) # pylint: disable=star-args - except socket.error: - logger.debug("Failed to bind to %s:%s using %s", new_address[0], + logger.debug( + "Successfully bound to %s:%s using %s", new_address[0], new_address[1], "IPv6" if ip_version else "IPv4") + except socket.error: + if self.servers: + # Already bound using IPv6. + logger.debug( + "Certbot wasn't able to bind to %s:%s using %s, this " + + "is often expected due to the dual stack nature of " + + "IPv6 socket implementations.", + new_address[0], new_address[1], + "IPv6" if ip_version else "IPv4") + else: + logger.debug( + "Failed to bind to %s:%s using %s", new_address[0], + new_address[1], "IPv6" if ip_version else "IPv4") else: self.servers.append(server) # If two servers are set up and port 0 was passed in, ensure we always @@ -189,7 +203,7 @@ def __init__(self, *args, **kwargs): self.simple_http_resources = kwargs.pop("simple_http_resources", set()) - socketserver.BaseRequestHandler.__init__(self, *args, **kwargs) + BaseHTTPServer.BaseHTTPRequestHandler.__init__(self, *args, **kwargs) def log_message(self, format, *args): # pylint: disable=redefined-builtin """Log arbitrary message.""" @@ -262,7 +276,7 @@ certs = {} - _, hosts, _ = next(os.walk('.')) + _, hosts, _ = next(os.walk('.')) # type: ignore # https://github.com/python/mypy/issues/465 for host in hosts: with open(os.path.join(host, "cert.pem")) as cert_file: cert_contents = cert_file.read() diff -Nru python-acme-0.22.2/acme/standalone_test.py python-acme-0.31.0/acme/standalone_test.py --- python-acme-0.22.2/acme/standalone_test.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/acme/standalone_test.py 2019-02-07 21:20:29.000000000 +0000 @@ -4,10 +4,10 @@ import socket import threading import tempfile -import time import unittest from six.moves import http_client # pylint: disable=import-error +from six.moves import queue # pylint: disable=import-error from six.moves import socketserver # type: ignore # pylint: disable=import-error import josepy as jose @@ -16,8 +16,8 @@ from acme import challenges from acme import crypto_util -from acme import errors from acme import test_util +from acme.magic_typing import Set # pylint: disable=unused-import, no-name-in-module class TLSServerTest(unittest.TestCase): @@ -48,7 +48,7 @@ test_util.load_cert('rsa2048_cert.pem'), )} from acme.standalone import TLSSNI01Server - self.server = TLSSNI01Server(("", 0), certs=self.certs) + self.server = TLSSNI01Server(('localhost', 0), certs=self.certs) # pylint: disable=no-member self.thread = threading.Thread(target=self.server.serve_forever) self.thread.start() @@ -72,7 +72,7 @@ def setUp(self): self.account_key = jose.JWK.load( test_util.load_vector('rsa1024_key.pem')) - self.resources = set() + self.resources = set() # type: Set from acme.standalone import HTTP01Server self.server = HTTP01Server(('', 0), resources=self.resources) @@ -133,8 +133,11 @@ self.address_family = socket.AF_INET socketserver.TCPServer.__init__(self, *args, **kwargs) if ipv6: + # NB: On Windows, socket.IPPROTO_IPV6 constant may be missing. + # We use the corresponding value (41) instead. + level = getattr(socket, "IPPROTO_IPV6", 41) # pylint: disable=no-member - self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + self.socket.setsockopt(level, socket.IPV6_V6ONLY, 1) try: self.server_bind() self.server_activate() @@ -147,15 +150,15 @@ mock_bind.side_effect = socket.error from acme.standalone import BaseDualNetworkedServers self.assertRaises(socket.error, BaseDualNetworkedServers, - BaseDualNetworkedServersTest.SingleProtocolServer, - ("", 0), - socketserver.BaseRequestHandler) + BaseDualNetworkedServersTest.SingleProtocolServer, + ('', 0), + socketserver.BaseRequestHandler) def test_ports_equal(self): from acme.standalone import BaseDualNetworkedServers servers = BaseDualNetworkedServers( BaseDualNetworkedServersTest.SingleProtocolServer, - ("", 0), + ('', 0), socketserver.BaseRequestHandler) socknames = servers.getsocknames() prev_port = None @@ -177,7 +180,7 @@ test_util.load_cert('rsa2048_cert.pem'), )} from acme.standalone import TLSSNI01DualNetworkedServers - self.servers = TLSSNI01DualNetworkedServers(("", 0), certs=self.certs) + self.servers = TLSSNI01DualNetworkedServers(('localhost', 0), certs=self.certs) self.servers.serve_forever() def tearDown(self): @@ -201,7 +204,7 @@ def setUp(self): self.account_key = jose.JWK.load( test_util.load_vector('rsa1024_key.pem')) - self.resources = set() + self.resources = set() # type: Set from acme.standalone import HTTP01DualNetworkedServers self.servers = HTTP01DualNetworkedServers(('', 0), resources=self.resources) @@ -245,6 +248,7 @@ self.assertFalse(self._test_http01(add=False)) +@test_util.broken_on_windows class TestSimpleTLSSNI01Server(unittest.TestCase): """Tests for acme.standalone.simple_tls_sni_01_server.""" @@ -260,10 +264,9 @@ os.path.join(localhost_dir, 'key.pem')) from acme.standalone import simple_tls_sni_01_server - self.port = 1234 self.thread = threading.Thread( target=simple_tls_sni_01_server, kwargs={ - 'cli_args': ('xxx', '--port', str(self.port)), + 'cli_args': ('filename',), 'forever': False, }, ) @@ -275,25 +278,20 @@ self.thread.join() shutil.rmtree(self.test_cwd) - def test_it(self): - max_attempts = 5 - for attempt in range(max_attempts): - try: - cert = crypto_util.probe_sni( - b'localhost', b'0.0.0.0', self.port) - except errors.Error: - self.assertTrue(attempt + 1 < max_attempts, "Timeout!") - time.sleep(1) # wait until thread starts - else: - self.assertEqual(jose.ComparableX509(cert), - test_util.load_comparable_cert( - 'rsa2048_cert.pem')) - break - - if attempt == 0: - # the first attempt is always meant to fail, so we can test - # the socket failure code-path for probe_sni, as well - self.thread.start() + @mock.patch('acme.standalone.logger') + def test_it(self, mock_logger): + # Use a Queue because mock objects aren't thread safe. + q = queue.Queue() # type: queue.Queue[int] + # Add port number to the queue. + mock_logger.info.side_effect = lambda *args: q.put(args[-1]) + self.thread.start() + + # After the timeout, an exception is raised if the queue is empty. + port = q.get(timeout=5) + cert = crypto_util.probe_sni(b'localhost', b'0.0.0.0', port) + self.assertEqual(jose.ComparableX509(cert), + test_util.load_comparable_cert( + 'rsa2048_cert.pem')) if __name__ == "__main__": diff -Nru python-acme-0.22.2/acme/test_util.py python-acme-0.31.0/acme/test_util.py --- python-acme-0.22.2/acme/test_util.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/acme/test_util.py 2019-02-07 21:20:29.000000000 +0000 @@ -4,13 +4,14 @@ """ import os +import sys import pkg_resources import unittest from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization import josepy as jose -import OpenSSL +from OpenSSL import crypto def vector_path(*names): @@ -39,8 +40,8 @@ def load_cert(*names): """Load certificate.""" loader = _guess_loader( - names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1) - return OpenSSL.crypto.load_certificate(loader, load_vector(*names)) + names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1) + return crypto.load_certificate(loader, load_vector(*names)) def load_comparable_cert(*names): @@ -51,8 +52,8 @@ def load_csr(*names): """Load certificate request.""" loader = _guess_loader( - names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1) - return OpenSSL.crypto.load_certificate_request(loader, load_vector(*names)) + names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1) + return crypto.load_certificate_request(loader, load_vector(*names)) def load_comparable_csr(*names): @@ -71,8 +72,8 @@ def load_pyopenssl_private_key(*names): """Load pyOpenSSL private key.""" loader = _guess_loader( - names[-1], OpenSSL.crypto.FILETYPE_PEM, OpenSSL.crypto.FILETYPE_ASN1) - return OpenSSL.crypto.load_privatekey(loader, load_vector(*names)) + names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1) + return crypto.load_privatekey(loader, load_vector(*names)) def skip_unless(condition, reason): # pragma: no cover @@ -94,3 +95,11 @@ return lambda cls: cls else: return lambda cls: None + +def broken_on_windows(function): + """Decorator to skip temporarily a broken test on Windows.""" + reason = 'Test is broken and ignored on windows but should be fixed.' + return unittest.skipIf( + sys.platform == 'win32' + and os.environ.get('SKIP_BROKEN_TESTS_ON_WINDOWS', 'true') == 'true', + reason)(function) diff -Nru python-acme-0.22.2/acme.egg-info/PKG-INFO python-acme-0.31.0/acme.egg-info/PKG-INFO --- python-acme-0.22.2/acme.egg-info/PKG-INFO 2018-03-20 00:33:44.000000000 +0000 +++ python-acme-0.31.0/acme.egg-info/PKG-INFO 2019-02-07 21:20:40.000000000 +0000 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: acme -Version: 0.22.2 +Version: 0.31.0 Summary: ACME protocol implementation in Python Home-page: https://github.com/letsencrypt/letsencrypt Author: Certbot Project @@ -8,7 +8,7 @@ License: Apache License 2.0 Description: UNKNOWN Platform: UNKNOWN -Classifier: Development Status :: 3 - Alpha +Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: Apache Software License Classifier: Programming Language :: Python @@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 Classifier: Topic :: Internet :: WWW/HTTP Classifier: Topic :: Security Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.* diff -Nru python-acme-0.22.2/acme.egg-info/requires.txt python-acme-0.31.0/acme.egg-info/requires.txt --- python-acme-0.22.2/acme.egg-info/requires.txt 2018-03-20 00:33:44.000000000 +0000 +++ python-acme-0.31.0/acme.egg-info/requires.txt 2019-02-07 21:20:40.000000000 +0000 @@ -1,10 +1,11 @@ -cryptography>=0.8 +cryptography>=1.2.3 josepy>=1.0.0 mock -PyOpenSSL>=0.13 +PyOpenSSL>=0.13.1 pyrfc3339 pytz -requests[security]>=2.4.1 +requests[security]>=2.6.0 +requests-toolbelt>=0.3.0 setuptools six>=1.9.0 diff -Nru python-acme-0.22.2/acme.egg-info/SOURCES.txt python-acme-0.31.0/acme.egg-info/SOURCES.txt --- python-acme-0.22.2/acme.egg-info/SOURCES.txt 2018-03-20 00:33:44.000000000 +0000 +++ python-acme-0.31.0/acme.egg-info/SOURCES.txt 2019-02-07 21:20:40.000000000 +0000 @@ -1,6 +1,7 @@ LICENSE.txt MANIFEST.in README.rst +pytest.ini setup.cfg setup.py acme/__init__.py @@ -14,8 +15,11 @@ acme/errors_test.py acme/fields.py acme/fields_test.py +acme/jose_test.py acme/jws.py acme/jws_test.py +acme/magic_typing.py +acme/magic_typing_test.py acme/messages.py acme/messages_test.py acme/standalone.py @@ -66,7 +70,6 @@ docs/api/messages.rst docs/api/standalone.rst docs/man/jws.rst -examples/example_client.py examples/standalone/README examples/standalone/localhost/cert.pem examples/standalone/localhost/key.pem \ No newline at end of file diff -Nru python-acme-0.22.2/debian/changelog python-acme-0.31.0/debian/changelog --- python-acme-0.22.2/debian/changelog 2018-06-16 02:05:49.000000000 +0000 +++ python-acme-0.31.0/debian/changelog 2019-09-07 06:15:04.000000000 +0000 @@ -1,9 +1,72 @@ -python-acme (0.22.2-1ubuntu0.1) bionic; urgency=medium +python-acme (0.31.0-2~ubuntu18.04.1) bionic; urgency=medium - * Add ready status type to be compatible with the new Let's Encrypt ACMEv2 - endpoint (LP: #1777205). + * Backport packaging to build on Ubuntu Bionic (LP: #1836823) - -- Simon Quigley Fri, 15 Jun 2018 21:05:49 -0500 + -- James Hebden Sat, 07 Sep 2019 16:15:04 +1000 + +python-acme (0.31.0-2) unstable; urgency=medium + + * Backport POST-as-GET support (Closes: #928452) + + -- Harlan Lieberman-Berg Sat, 04 May 2019 21:32:00 -0400 + +python-acme (0.31.0-1) unstable; urgency=medium + + * Bump dependency on josepy to >= 1.1.0 + * Add Breaks on python-acme against certbot << 0.20 + * New upstream version 0.31.0 + * Add dep on python-idna required by security extra. + * Bump S-V; no changes needed. + + -- Harlan Lieberman-Berg Sat, 09 Feb 2019 19:07:59 -0500 + +python-acme (0.28.0-1) unstable; urgency=medium + + * New upstream version 0.28.0 + + -- Harlan Lieberman-Berg Wed, 07 Nov 2018 18:05:59 -0500 + +python-acme (0.27.0-1) unstable; urgency=medium + + * New upstream release. + * Bump S-V; no changes needed. + + -- Harlan Lieberman-Berg Wed, 05 Sep 2018 20:12:58 -0400 + +python-acme (0.26.0-1) unstable; urgency=medium + + * New upstream version 0.26.0 + * Bump S-V; add Rules-Require-Root: no + + -- Harlan Lieberman-Berg Thu, 12 Jul 2018 22:07:01 -0400 + +python-acme (0.25.1-1) unstable; urgency=medium + + * New upstream version 0.25.1 + + -- Harlan Lieberman-Berg Wed, 13 Jun 2018 22:28:55 -0400 + +python-acme (0.25.0-1) unstable; urgency=medium + + * New upstream version 0.25.0 + * Add new dependency on requests-toolbelt + * Drop unnecessary X-Python-Version fields + * Add pytest as build-time dep only. + + -- Harlan Lieberman-Berg Mon, 11 Jun 2018 21:54:41 -0400 + +python-acme (0.24.0-2) unstable; urgency=medium + + * Update team email address. (Closes: #895863) + + -- Harlan Lieberman-Berg Fri, 04 May 2018 20:33:30 -0400 + +python-acme (0.24.0-1) unstable; urgency=medium + + * New upstream release. + * Bump S-V; no changes needed. + + -- Harlan Lieberman-Berg Thu, 03 May 2018 19:30:10 -0400 python-acme (0.22.2-1) unstable; urgency=medium diff -Nru python-acme-0.22.2/debian/control python-acme-0.31.0/debian/control --- python-acme-0.22.2/debian/control 2018-06-16 02:05:49.000000000 +0000 +++ python-acme-0.31.0/debian/control 2019-09-07 06:15:04.000000000 +0000 @@ -2,7 +2,7 @@ Section: python Priority: optional Maintainer: Ubuntu Developers -XSBC-Original-Maintainer: Debian Let's Encrypt +XSBC-Original-Maintainer: Debian Let's Encrypt Uploaders: Harlan Lieberman-Berg , Francois Marier Build-Depends: debhelper (>= 11~), @@ -10,11 +10,14 @@ python-all (>= 2.7), python-cryptography (>= 1.3.4), python-docutils, - python-josepy, + python-idna (>= 2.5), python-idna (<< 2.8~), + python-josepy (>= 1.1.0~), python-mock, python-ndg-httpsclient, python-openssl (>= 0.15), + python-pytest, python-requests (>= 2.4.1), + python-requests-toolbelt (>= 0.3.0), python-rfc3339, python-setuptools (>= 11.3), python-six (>= 1.9), @@ -22,23 +25,25 @@ python3 (>= 3.4), python3-cryptography (>= 1.3.4), python3-docutils, - python3-josepy, + python3-idna (>= 2.5), python3-idna (<< 2.8), + python3-josepy (>= 1.1.0~), python3-mock, python3-ndg-httpsclient, python3-openssl (>= 0.15), + python3-pytest, python3-requests (>= 2.4.1), + python3-requests-toolbelt (>= 0.3.0), python3-rfc3339, python3-setuptools (>= 11.3), python3-six (>= 1.9), python3-sphinx (>= 1.3.1-1~), python3-sphinx-rtd-theme, python3-tz -Standards-Version: 4.1.3 +Standards-Version: 4.3.0 Homepage: https://letsencrypt.org/ Vcs-Git: https://salsa.debian.org/letsencrypt-team/certbot/acme.git Vcs-Browser: https://salsa.debian.org/letsencrypt-team/certbot/acme -X-Python-Version: >= 2.7 -X-Python3-Version: >= 3.4 +Rules-Requires-Root: no Testsuite: autopkgtest-pkg-python Package: python-acme @@ -47,6 +52,7 @@ python-openssl (>= 0.15), ${misc:Depends}, ${python:Depends} +Breaks: certbot (<< 0.20.0-1) Suggests: python-acme-doc Description: ACME protocol library for Python 2 This is a library used by the Let's Encrypt client for the ACME diff -Nru python-acme-0.22.2/debian/patches/0001-post-as-get.patch python-acme-0.31.0/debian/patches/0001-post-as-get.patch --- python-acme-0.22.2/debian/patches/0001-post-as-get.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-acme-0.31.0/debian/patches/0001-post-as-get.patch 2019-09-07 06:15:04.000000000 +0000 @@ -0,0 +1,66 @@ +From b0d960f102c998d8231c0ee48952b488f10864ac Mon Sep 17 00:00:00 2001 +From: Adrien Ferrand +Date: Wed, 1 May 2019 00:37:23 +0200 +Subject: [PATCH] Send a POST-as-GET request to query registration in ACME v2 + (#6993) + +* Send a post-as-get request to query registration + +* Add comments. Add again a line. + +* Prepare code for future PR about post-as-get +--- +diff --git a/acme/client.py b/acme/client.py +index a41787756f..5a8fd88ae9 100644 +--- a/acme/client.py ++++ b/acme/client.py +@@ -123,15 +123,6 @@ def deactivate_registration(self, regr): + """ + return self.update_registration(regr, update={'status': 'deactivated'}) + +- def query_registration(self, regr): +- """Query server about registration. +- +- :param messages.RegistrationResource: Existing Registration +- Resource. +- +- """ +- return self._send_recv_regr(regr, messages.UpdateRegistration()) +- + def _authzr_from_response(self, response, identifier=None, uri=None): + authzr = messages.AuthorizationResource( + body=messages.Authorization.from_json(response.json()), +@@ -276,6 +267,15 @@ def register(self, new_reg=None): + # pylint: disable=no-member + return self._regr_from_response(response) + ++ def query_registration(self, regr): ++ """Query server about registration. ++ ++ :param messages.RegistrationResource: Existing Registration ++ Resource. ++ ++ """ ++ return self._send_recv_regr(regr, messages.UpdateRegistration()) ++ + def agree_to_tos(self, regr): + """Agree to the terms-of-service. + +@@ -603,10 +603,13 @@ def query_registration(self, regr): + Resource. + + """ +- self.net.account = regr +- updated_regr = super(ClientV2, self).query_registration(regr) +- self.net.account = updated_regr +- return updated_regr ++ self.net.account = regr # See certbot/certbot#6258 ++ # ACME v2 requires to use a POST-as-GET request (POST an empty JWS) here. ++ # This is done by passing None instead of an empty UpdateRegistration to _post(). ++ response = self._post(regr.uri, None) ++ self.net.account = self._regr_from_response(response, uri=regr.uri, ++ terms_of_service=regr.terms_of_service) ++ return self.net.account + + def update_registration(self, regr, update=None): + """Update registration. diff -Nru python-acme-0.22.2/debian/patches/0002-post-as-get.patch python-acme-0.31.0/debian/patches/0002-post-as-get.patch --- python-acme-0.22.2/debian/patches/0002-post-as-get.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-acme-0.31.0/debian/patches/0002-post-as-get.patch 2019-09-07 06:15:04.000000000 +0000 @@ -0,0 +1,24 @@ +From a0a8292ff26a2d062e75b865d9b9b10977dc1f80 Mon Sep 17 00:00:00 2001 +From: Adrien Ferrand +Date: Wed, 13 Feb 2019 00:36:27 +0100 +Subject: [PATCH] Correct the Content-Type used in the POST-as-GET request to + retrieve a cert (#6757) + +--- + acme/client.py | 3 +-- + 1 file changed, 1 insertion(+), 2 deletions(-) + +Index: python-acme/acme/client.py +=================================================================== +--- python-acme.orig/acme/client.py ++++ python-acme/acme/client.py +@@ -742,8 +742,7 @@ class ClientV2(ClientBase): + if body.error is not None: + raise errors.IssuanceError(body.error) + if body.certificate is not None: +- certificate_response = self._post_as_get(body.certificate, +- content_type=DER_CONTENT_TYPE).text ++ certificate_response = self._post_as_get(body.certificate).text + return orderr.update(body=body, fullchain_pem=certificate_response) + raise errors.TimeoutError() + diff -Nru python-acme-0.22.2/debian/patches/0003-remove-keyauth-from-jws.patch python-acme-0.31.0/debian/patches/0003-remove-keyauth-from-jws.patch --- python-acme-0.22.2/debian/patches/0003-remove-keyauth-from-jws.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-acme-0.31.0/debian/patches/0003-remove-keyauth-from-jws.patch 2019-09-07 06:15:04.000000000 +0000 @@ -0,0 +1,219 @@ +From 339d034d6a5a57d296607795a4706203f81d7059 Mon Sep 17 00:00:00 2001 +From: Adrien Ferrand +Date: Wed, 27 Feb 2019 18:21:47 +0100 +Subject: [PATCH] Remove keyAuthorization field from the challenge response JWS + token (#6758) + +Fixes #6755. + +POSTing the `keyAuthorization` in a JWS token when answering an ACME challenge, has been deprecated for some time now. Indeed, this is superfluous as the request is already authentified by the JWS signature. + +Boulder still accepts to see this field in the JWS token, and ignore it. Pebble in non strict mode also. But Pebble in strict mode refuses the request, to prepare complete removal of this field in ACME v2. + +Certbot still sends the `keyAuthorization` field. This PR removes it, and makes Certbot compliant with current ACME v2 protocol, and so Pebble in strict mode. + +See also [letsencrypt/pebble#192](https://github.com/letsencrypt/pebble/issues/192) for implementation details server side. + +* New implementation, with a fallback. + +* Update acme/acme/client.py + +Co-Authored-By: adferrand + +* Fix an instance parameter + +* Update comment + +* Add unit tests on keyAuthorization dump + +* Update acme/client.py + +Co-Authored-By: adferrand + +* Restrict the magic of setting a variable in immutable object in one place. Make a soon to be removed method private. +--- + acme/challenges.py | 20 ++++++++++++++++++++ + acme/challenges_test.py | 12 ++++++++++++ + acme/client.py | 26 ++++++++++++++++++++------ + acme/client_test.py | 28 ++++++++++++++++++++++++++++ + 4 files changed, 87 insertions(+), 6 deletions(-) + +Index: python-acme/acme/challenges.py +=================================================================== +--- python-acme.orig/acme/challenges.py ++++ python-acme/acme/challenges.py +@@ -108,6 +108,10 @@ class KeyAuthorizationChallengeResponse( + 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. + +@@ -140,6 +144,22 @@ class KeyAuthorizationChallengeResponse( + + 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) ++ ++ return jobj ++ + + @six.add_metaclass(abc.ABCMeta) + class KeyAuthorizationChallenge(_TokenChallenge): +Index: python-acme/acme/challenges_test.py +=================================================================== +--- python-acme.orig/acme/challenges_test.py ++++ python-acme/acme/challenges_test.py +@@ -94,6 +94,9 @@ class DNS01ResponseTest(unittest.TestCas + self.response = self.chall.response(KEY) + + 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): +@@ -165,6 +168,9 @@ class HTTP01ResponseTest(unittest.TestCa + self.response = self.chall.response(KEY) + + 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): +@@ -285,6 +291,9 @@ class TLSSNI01ResponseTest(unittest.Test + self.assertEqual(self.z_domain, self.response.z_domain) + + 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): +@@ -419,6 +428,9 @@ class TLSALPN01ResponseTest(unittest.Tes + self.response = self.chall.response(KEY) + + 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): +Index: python-acme/acme/client.py +=================================================================== +--- python-acme.orig/acme/client.py ++++ python-acme/acme/client.py +@@ -17,6 +17,7 @@ import requests + from requests.adapters import HTTPAdapter + import sys + ++from acme import challenges + from acme import crypto_util + from acme import errors + from acme import jws +@@ -146,7 +147,23 @@ class ClientBase(object): # pylint: dis + :raises .UnexpectedUpdate: + + """ +- response = self._post(challb.uri, response) ++ # 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 + try: + authzr_uri = response.links['up']['url'] + except KeyError: +@@ -784,7 +801,7 @@ class ClientV2(ClientBase): + except messages.Error as error: + if error.code == 'malformed': + logger.debug('Error during a POST-as-GET request, ' +- 'your ACME CA may not support it:\n%s', error) ++ 'your ACME CA server may not support it:\n%s', error) + logger.debug('Retrying request with GET.') + else: # pragma: no cover + raise +@@ -1194,10 +1211,7 @@ class ClientNetwork(object): # pylint: + + def _post_once(self, url, obj, content_type=JOSE_CONTENT_TYPE, + acme_version=1, **kwargs): +- try: +- new_nonce_url = kwargs.pop('new_nonce_url') +- except KeyError: +- new_nonce_url = None ++ new_nonce_url = kwargs.pop('new_nonce_url', None) + data = self._wrap_in_jws(obj, self._get_nonce(url, new_nonce_url), url, acme_version) + kwargs.setdefault('headers', {'Content-Type': content_type}) + response = self._send_request('POST', url, data=data, **kwargs) +Index: python-acme/acme/client_test.py +=================================================================== +--- python-acme.orig/acme/client_test.py ++++ python-acme/acme/client_test.py +@@ -463,6 +463,34 @@ 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 -Nru python-acme-0.22.2/debian/patches/add-ready-status-type.patch python-acme-0.31.0/debian/patches/add-ready-status-type.patch --- python-acme-0.22.2/debian/patches/add-ready-status-type.patch 2018-06-16 02:05:49.000000000 +0000 +++ python-acme-0.31.0/debian/patches/add-ready-status-type.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,16 +0,0 @@ -Description: Add ready status type -Author: ohemorange -Origin: upstream -Bug-Ubuntu: https://bugs.launchpad.net/bugs/1777205 -Applied-Upstream: commit:5940ee9 -Last-Update: 2018-06-15 ---- a/acme/messages.py -+++ b/acme/messages.py -@@ -145,6 +145,7 @@ STATUS_PROCESSING = Status('processing') - STATUS_VALID = Status('valid') - STATUS_INVALID = Status('invalid') - STATUS_REVOKED = Status('revoked') -+STATUS_READY = Status('ready') - - - class IdentifierType(_Constant): diff -Nru python-acme-0.22.2/debian/patches/series python-acme-0.31.0/debian/patches/series --- python-acme-0.22.2/debian/patches/series 2018-06-16 02:05:49.000000000 +0000 +++ python-acme-0.31.0/debian/patches/series 2019-09-07 06:15:04.000000000 +0000 @@ -1 +1,3 @@ -add-ready-status-type.patch +0001-post-as-get.patch -p1 +0002-post-as-get.patch +0003-remove-keyauth-from-jws.patch diff -Nru python-acme-0.22.2/docs/index.rst python-acme-0.31.0/docs/index.rst --- python-acme-0.22.2/docs/index.rst 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/docs/index.rst 2019-02-07 21:20:29.000000000 +0000 @@ -16,13 +16,6 @@ .. automodule:: acme :members: - -Example client: - -.. include:: ../examples/example_client.py - :code: python - - Indices and tables ================== diff -Nru python-acme-0.22.2/examples/example_client.py python-acme-0.31.0/examples/example_client.py --- python-acme-0.22.2/examples/example_client.py 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/examples/example_client.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,47 +0,0 @@ -"""Example script showing how to use acme client API.""" -import logging -import os -import pkg_resources - -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.asymmetric import rsa -import josepy as jose -import OpenSSL - -from acme import client -from acme import messages - - -logging.basicConfig(level=logging.DEBUG) - - -DIRECTORY_URL = 'https://acme-staging.api.letsencrypt.org/directory' -BITS = 2048 # minimum for Boulder -DOMAIN = 'example1.com' # example.com is ignored by Boulder - -# generate_private_key requires cryptography>=0.5 -key = jose.JWKRSA(key=rsa.generate_private_key( - public_exponent=65537, - key_size=BITS, - backend=default_backend())) -acme = client.Client(DIRECTORY_URL, key) - -regr = acme.register() -logging.info('Auto-accepting TOS: %s', regr.terms_of_service) -acme.agree_to_tos(regr) -logging.debug(regr) - -authzr = acme.request_challenges( - identifier=messages.Identifier(typ=messages.IDENTIFIER_FQDN, value=DOMAIN)) -logging.debug(authzr) - -authzr, authzr_response = acme.poll(authzr) - -csr = OpenSSL.crypto.load_certificate_request( - OpenSSL.crypto.FILETYPE_ASN1, pkg_resources.resource_string( - 'acme', os.path.join('testdata', 'csr.der'))) -try: - acme.request_issuance(jose.util.ComparableX509(csr), (authzr,)) -except messages.Error as error: - print ("This script is doomed to fail as no authorization " - "challenges are ever solved. Error from server: {0}".format(error)) diff -Nru python-acme-0.22.2/MANIFEST.in python-acme-0.31.0/MANIFEST.in --- python-acme-0.22.2/MANIFEST.in 2018-03-20 00:33:21.000000000 +0000 +++ python-acme-0.31.0/MANIFEST.in 2019-02-07 21:20:29.000000000 +0000 @@ -1,5 +1,6 @@ include LICENSE.txt include README.rst +include pytest.ini recursive-include docs * recursive-include examples * recursive-include acme/testdata * diff -Nru python-acme-0.22.2/PKG-INFO python-acme-0.31.0/PKG-INFO --- python-acme-0.22.2/PKG-INFO 2018-03-20 00:33:44.000000000 +0000 +++ python-acme-0.31.0/PKG-INFO 2019-02-07 21:20:40.000000000 +0000 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: acme -Version: 0.22.2 +Version: 0.31.0 Summary: ACME protocol implementation in Python Home-page: https://github.com/letsencrypt/letsencrypt Author: Certbot Project @@ -8,7 +8,7 @@ License: Apache License 2.0 Description: UNKNOWN Platform: UNKNOWN -Classifier: Development Status :: 3 - Alpha +Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: Apache Software License Classifier: Programming Language :: Python @@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 Classifier: Topic :: Internet :: WWW/HTTP Classifier: Topic :: Security Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.* diff -Nru python-acme-0.22.2/pytest.ini python-acme-0.31.0/pytest.ini --- python-acme-0.22.2/pytest.ini 1970-01-01 00:00:00.000000000 +0000 +++ python-acme-0.31.0/pytest.ini 2019-02-07 21:20:29.000000000 +0000 @@ -0,0 +1,2 @@ +[pytest] +norecursedirs = .* build dist CVS _darcs {arch} *.egg diff -Nru python-acme-0.22.2/setup.py python-acme-0.31.0/setup.py --- python-acme-0.22.2/setup.py 2018-03-20 00:33:22.000000000 +0000 +++ python-acme-0.31.0/setup.py 2019-02-07 21:20:31.000000000 +0000 @@ -1,24 +1,24 @@ -import sys - from setuptools import setup from setuptools import find_packages +from setuptools.command.test import test as TestCommand +import sys - -version = '0.22.2' +version = '0.31.0' # Please update tox.ini when modifying dependency version requirements install_requires = [ # load_pem_private/public_key (>=0.6) # rsa_recover_prime_factors (>=0.8) - 'cryptography>=0.8', + 'cryptography>=1.2.3', # formerly known as acme.jose: 'josepy>=1.0.0', # Connection.set_tlsext_host_name (>=0.13) 'mock', - 'PyOpenSSL>=0.13', + 'PyOpenSSL>=0.13.1', 'pyrfc3339', 'pytz', - 'requests[security]>=2.4.1', # security extras added in 2.4.1 + 'requests[security]>=2.6.0', # security extras added in 2.4.1 + 'requests-toolbelt>=0.3.0', 'setuptools', 'six>=1.9.0', # needed for python_2_unicode_compatible ] @@ -34,6 +34,19 @@ 'sphinx_rtd_theme', ] +class PyTest(TestCommand): + user_options = [] + + def initialize_options(self): + TestCommand.initialize_options(self) + self.pytest_args = '' + + def run_tests(self): + import shlex + # import here, cause outside the eggs aren't loaded + import pytest + errno = pytest.main(shlex.split(self.pytest_args)) + sys.exit(errno) setup( name='acme', @@ -45,7 +58,7 @@ license='Apache License 2.0', python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', 'Programming Language :: Python', @@ -55,6 +68,7 @@ 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Security', ], @@ -66,5 +80,7 @@ 'dev': dev_extras, 'docs': docs_extras, }, + tests_require=["pytest"], test_suite='acme', + cmdclass={"test": PyTest}, )