Hello community, here is the log from the commit of package python-acme for openSUSE:Factory checked in at 2018-12-18 14:57:50 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-acme (Old) and /work/SRC/openSUSE:Factory/.python-acme.new.28833 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-acme" Tue Dec 18 14:57:50 2018 rev:25 rq:658303 version:0.29.1 Changes: -------- --- /work/SRC/openSUSE:Factory/python-acme/python-acme.changes 2018-11-18 23:33:18.069401286 +0100 +++ /work/SRC/openSUSE:Factory/.python-acme.new.28833/python-acme.changes 2018-12-18 14:58:16.590261822 +0100 @@ -1,0 +2,23 @@ +Sat Dec 15 07:02:55 UTC 2018 - Thomas Bechtold <[email protected]> + +- update to 0.29.1: + * Release 0.29.1 + * Release 0.29.0 + * WIP External Account Binding (#6059) + * Implement POST-as-GET requests (#6522) + * ignore erroneously no-member lint error + * Revert acme/acme/client.py + * Bump version to 0.29.0 + * remove unused six imports + * Remove module-level ignore::ResourceWarnings + * bring requests back down to 2.4.1 in setup and oldest constraints + * Requests no longer vendorizes urllib3 + * Use a newer version of requests because of the upcoming Callable import + Deprecation in Python 3.8 that warns in Python 3.7 + * Cover is run on 2.7, so mark 3-only lines as no cover + * Ignore ResourceWarnings in various modules in a 2-compatible way. + * ignore ResourceWarnings in acme tests + * s/assertEquals/assertEqual +- Adjust Requires + +------------------------------------------------------------------- Old: ---- acme-0.28.0.tar.gz acme-0.28.0.tar.gz.asc New: ---- acme-0.29.1.tar.gz acme-0.29.1.tar.gz.asc ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-acme.spec ++++++ --- /var/tmp/diff_new_pack.7DSWPl/_old 2018-12-18 14:58:17.166260952 +0100 +++ /var/tmp/diff_new_pack.7DSWPl/_new 2018-12-18 14:58:17.166260952 +0100 @@ -19,7 +19,7 @@ %{?!python_module:%define python_module() python-%{**} python3-%{**}} %define libname acme Name: python-%{libname} -Version: 0.28.0 +Version: 0.29.1 Release: 0 Summary: Python library for the ACME protocol License: Apache-2.0 @@ -45,7 +45,6 @@ BuildRequires: fdupes BuildRequires: python-rpm-macros Requires: python-cryptography >= 0.8 -Requires: python-dnspython >= 1.12 Requires: python-josepy >= 1.0.0 Requires: python-ndg-httpsclient Requires: python-pyOpenSSL >= 0.13 ++++++ acme-0.28.0.tar.gz -> acme-0.29.1.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/acme-0.28.0/PKG-INFO new/acme-0.29.1/PKG-INFO --- old/acme-0.28.0/PKG-INFO 2018-11-07 22:15:05.000000000 +0100 +++ new/acme-0.29.1/PKG-INFO 2018-12-06 00:48:05.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: acme -Version: 0.28.0 +Version: 0.29.1 Summary: ACME protocol implementation in Python Home-page: https://github.com/letsencrypt/letsencrypt Author: Certbot Project diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/acme-0.28.0/acme/client.py new/acme-0.29.1/acme/client.py --- old/acme-0.28.0/acme/client.py 2018-11-07 22:14:56.000000000 +0100 +++ new/acme-0.29.1/acme/client.py 2018-12-06 00:47:58.000000000 +0100 @@ -33,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 @@ -199,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. @@ -236,6 +221,7 @@ raise errors.ClientError( 'Successful revocation must return HTTP OK status') + class Client(ClientBase): """ACME client for a v1 API. @@ -388,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. @@ -651,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. @@ -681,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 @@ -716,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() @@ -739,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 @@ -768,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 @@ -880,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. @@ -943,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, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/acme-0.28.0/acme/client_test.py new/acme-0.29.1/acme/client_test.py --- old/acme-0.28.0/acme/client_test.py 2018-11-07 22:14:56.000000000 +0100 +++ new/acme-0.29.1/acme/client_test.py 2018-12-06 00:47:58.000000000 +0100 @@ -1,4 +1,5 @@ """Tests for acme.client.""" +# pylint: disable=too-many-lines import copy import datetime import json @@ -283,6 +284,37 @@ 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.""" @@ -665,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 @@ -730,9 +762,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): @@ -821,6 +854,47 @@ 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): # pylint: disable=missing-docstring diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/acme-0.28.0/acme/crypto_util_test.py new/acme-0.29.1/acme/crypto_util_test.py --- old/acme-0.28.0/acme/crypto_util_test.py 2018-11-07 22:14:56.000000000 +0100 +++ new/acme-0.29.1/acme/crypto_util_test.py 2018-12-06 00:47:58.000000000 +0100 @@ -209,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, @@ -227,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 -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/acme-0.28.0/acme/messages.py new/acme-0.29.1/acme/messages.py --- old/acme-0.28.0/acme/messages.py 2018-11-07 22:14:56.000000000 +0100 +++ new/acme-0.29.1/acme/messages.py 2018-12-06 00:47:58.000000000 +0100 @@ -1,6 +1,7 @@ """ACME protocol messages.""" import collections import six +import json import josepy as jose @@ -8,6 +9,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 +29,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( @@ -176,6 +179,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()) @@ -258,6 +262,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. @@ -275,12 +297,13 @@ 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: @@ -288,6 +311,10 @@ if email is not None: 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): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/acme-0.28.0/acme/messages_test.py new/acme-0.29.1/acme/messages_test.py --- old/acme-0.28.0/acme/messages_test.py 2018-11-07 22:14:56.000000000 +0100 +++ new/acme-0.29.1/acme/messages_test.py 2018-12-06 00:47:58.000000000 +0100 @@ -174,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.""" @@ -205,6 +223,22 @@ 'mailto:[email protected]', )) + 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='[email protected]', external_account_binding=eab) + self.assertEqual(reg.contact, ( + 'mailto:[email protected]', + )) + self.assertEqual(sorted(reg.external_account_binding.keys()), + sorted(['protected', 'payload', 'signature'])) + def test_phones(self): self.assertEqual(('1234',), self.reg.phones) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/acme-0.28.0/acme.egg-info/PKG-INFO new/acme-0.29.1/acme.egg-info/PKG-INFO --- old/acme-0.28.0/acme.egg-info/PKG-INFO 2018-11-07 22:15:05.000000000 +0100 +++ new/acme-0.29.1/acme.egg-info/PKG-INFO 2018-12-06 00:48:05.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: acme -Version: 0.28.0 +Version: 0.29.1 Summary: ACME protocol implementation in Python Home-page: https://github.com/letsencrypt/letsencrypt Author: Certbot Project diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/acme-0.28.0/setup.py new/acme-0.29.1/setup.py --- old/acme-0.28.0/setup.py 2018-11-07 22:14:57.000000000 +0100 +++ new/acme-0.29.1/setup.py 2018-12-06 00:47:59.000000000 +0100 @@ -3,7 +3,7 @@ from setuptools.command.test import test as TestCommand import sys -version = '0.28.0' +version = '0.29.1' # Please update tox.ini when modifying dependency version requirements install_requires = [
