diff --git a/.pylintrc b/.pylintrc index 88cb563f..d954b265 100644 --- a/.pylintrc +++ b/.pylintrc @@ -240,7 +240,9 @@ ignore-mixin-members=yes # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis -ignored-modules=pkg_resources,confargparse,argparse +ignored-modules=pkg_resources,confargparse,argparse,six.moves,six.moves.urllib +# import errors ignored only in 1.4.4 +# https://bitbucket.org/logilab/pylint/commits/cd000904c9e2 # List of classes names for which member attributes should not be checked # (useful for classes with attributes dynamically set). diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 8024728f..a3709553 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -35,12 +35,7 @@ class DVChallenge(Challenge): # pylint: disable=abstract-method class ChallengeResponse(interfaces.ClientRequestableResource, jose.TypedJSONObjectWithFields): # _fields_to_partial_json | pylint: disable=abstract-method - """ACME challenge response. - - :ivar str mitm_resource: ACME resource identifier used in client - HTTPS requests in order to protect against MITM. - - """ + """ACME challenge response.""" TYPES = {} resource_type = 'challenge' @@ -56,14 +51,23 @@ class ChallengeResponse(interfaces.ClientRequestableResource, @Challenge.register class SimpleHTTP(DVChallenge): - """ACME "simpleHttp" challenge.""" + """ACME "simpleHttp" challenge. + + :ivar unicode token: + + """ typ = "simpleHttp" token = jose.Field("token") @ChallengeResponse.register class SimpleHTTPResponse(ChallengeResponse): - """ACME "simpleHttp" challenge response.""" + """ACME "simpleHttp" challenge response. + + :ivar unicode path: + :ivar unicode tls: + + """ typ = "simpleHttp" path = jose.Field("path") tls = jose.Field("tls", default=True, omitempty=True) @@ -107,7 +111,7 @@ class SimpleHTTPResponse(ChallengeResponse): Forms an URI to the HTTPS server provisioned resource (containing :attr:`~SimpleHTTP.token`). - :param str domain: Domain name being verified. + :param unicode domain: Domain name being verified. """ return self._URI_TEMPLATE.format( @@ -121,7 +125,7 @@ class SimpleHTTPResponse(ChallengeResponse): ``requests.get`` is called with ``verify=False``. :param .SimpleHTTP chall: Corresponding challenge. - :param str domain: Domain name being verified. + :param unicode domain: Domain name being verified. :param int port: Port used in the validation. :returns: ``True`` iff validation is successful, ``False`` @@ -163,13 +167,13 @@ class SimpleHTTPResponse(ChallengeResponse): class DVSNI(DVChallenge): """ACME "dvsni" challenge. - :ivar str r: Random data, **not** base64-encoded. - :ivar str nonce: Random data, **not** hex-encoded. + :ivar bytes r: Random data, **not** base64-encoded. + :ivar bytes nonce: Random data, **not** hex-encoded. """ typ = "dvsni" - DOMAIN_SUFFIX = ".acme.invalid" + DOMAIN_SUFFIX = b".acme.invalid" """Domain name suffix.""" R_SIZE = 32 @@ -181,15 +185,19 @@ class DVSNI(DVChallenge): PORT = 443 """Port to perform DVSNI challenge.""" - r = jose.Field("r", encoder=jose.b64encode, # pylint: disable=invalid-name + r = jose.Field("r", encoder=jose.encode_b64jose, # pylint: disable=invalid-name decoder=functools.partial(jose.decode_b64jose, size=R_SIZE)) - nonce = jose.Field("nonce", encoder=binascii.hexlify, + nonce = jose.Field("nonce", encoder=jose.encode_hex16, decoder=functools.partial(functools.partial( jose.decode_hex16, size=NONCE_SIZE))) @property def nonce_domain(self): - """Domain name used in SNI.""" + """Domain name used in SNI. + + :rtype: bytes + + """ return binascii.hexlify(self.nonce) + self.DOMAIN_SUFFIX @@ -197,7 +205,7 @@ class DVSNI(DVChallenge): class DVSNIResponse(ChallengeResponse): """ACME "dvsni" challenge response. - :param str s: Random data, **not** base64-encoded. + :param bytes s: Random data, **not** base64-encoded. """ typ = "dvsni" @@ -208,7 +216,7 @@ class DVSNIResponse(ChallengeResponse): S_SIZE = 32 """Required size of the :attr:`s` in bytes.""" - s = jose.Field("s", encoder=jose.b64encode, # pylint: disable=invalid-name + s = jose.Field("s", encoder=jose.encode_b64jose, # pylint: disable=invalid-name decoder=functools.partial(jose.decode_b64jose, size=S_SIZE)) def __init__(self, s=None, *args, **kwargs): @@ -221,11 +229,13 @@ class DVSNIResponse(ChallengeResponse): :param challenge: Corresponding challenge. :type challenge: :class:`DVSNI` + :rtype: bytes + """ z = hashlib.new("sha256") # pylint: disable=invalid-name z.update(chall.r) z.update(self.s) - return z.hexdigest() + return z.hexdigest().encode() def z_domain(self, chall): """Domain name for certificate subjectAltName.""" @@ -233,7 +243,13 @@ class DVSNIResponse(ChallengeResponse): @Challenge.register class RecoveryContact(ContinuityChallenge): - """ACME "recoveryContact" challenge.""" + """ACME "recoveryContact" challenge. + + :ivar unicode activation_url: + :ivar unicode success_url: + :ivar unicode contact: + + """ typ = "recoveryContact" activation_url = jose.Field("activationURL", omitempty=True) @@ -243,7 +259,11 @@ class RecoveryContact(ContinuityChallenge): @ChallengeResponse.register class RecoveryContactResponse(ChallengeResponse): - """ACME "recoveryContact" challenge response.""" + """ACME "recoveryContact" challenge response. + + :ivar unicode token: + + """ typ = "recoveryContact" token = jose.Field("token", omitempty=True) @@ -256,7 +276,11 @@ class RecoveryToken(ContinuityChallenge): @ChallengeResponse.register class RecoveryTokenResponse(ChallengeResponse): - """ACME "recoveryToken" challenge response.""" + """ACME "recoveryToken" challenge response. + + :ivar unicode token: + + """ typ = "recoveryToken" token = jose.Field("token", omitempty=True) @@ -265,7 +289,8 @@ class RecoveryTokenResponse(ChallengeResponse): class ProofOfPossession(ContinuityChallenge): """ACME "proofOfPossession" challenge. - :ivar str nonce: Random data, **not** base64-encoded. + :ivar .JWAAlgorithm alg: + :ivar bytes nonce: Random data, **not** base64-encoded. :ivar hints: Various clues for the client (:class:`Hints`). """ @@ -277,8 +302,12 @@ class ProofOfPossession(ContinuityChallenge): """Hints for "proofOfPossession" challenge. :ivar jwk: JSON Web Key (:class:`acme.jose.JWK`) - :ivar list certs: List of :class:`acme.jose.ComparableX509` + :ivar tuple cert_fingerprints: `tuple` of `unicode` + :ivar tuple certs: Sequence of :class:`acme.jose.ComparableX509` certificates. + :ivar tuple subject_key_identifiers: `tuple` of `unicode` + :ivar tuple issuers: `tuple` of `unicode` + :ivar tuple authorized_for: `tuple` of `unicode` """ jwk = jose.Field("jwk", decoder=jose.JWK.from_json) @@ -301,7 +330,7 @@ class ProofOfPossession(ContinuityChallenge): alg = jose.Field("alg", decoder=jose.JWASignature.from_json) nonce = jose.Field( - "nonce", encoder=jose.b64encode, decoder=functools.partial( + "nonce", encoder=jose.encode_b64jose, decoder=functools.partial( jose.decode_b64jose, size=NONCE_SIZE)) hints = jose.Field("hints", decoder=Hints.from_json) @@ -310,8 +339,8 @@ class ProofOfPossession(ContinuityChallenge): class ProofOfPossessionResponse(ChallengeResponse): """ACME "proofOfPossession" challenge response. - :ivar str nonce: Random data, **not** base64-encoded. - :ivar signature: :class:`~acme.other.Signature` of this message. + :ivar bytes nonce: Random data, **not** base64-encoded. + :ivar acme.other.Signature signature: Sugnature of this message. """ typ = "proofOfPossession" @@ -319,7 +348,7 @@ class ProofOfPossessionResponse(ChallengeResponse): NONCE_SIZE = ProofOfPossession.NONCE_SIZE nonce = jose.Field( - "nonce", encoder=jose.b64encode, decoder=functools.partial( + "nonce", encoder=jose.encode_b64jose, decoder=functools.partial( jose.decode_b64jose, size=NONCE_SIZE)) signature = jose.Field("signature", decoder=other.Signature.from_json) @@ -331,7 +360,11 @@ class ProofOfPossessionResponse(ChallengeResponse): @Challenge.register class DNS(DVChallenge): - """ACME "dns" challenge.""" + """ACME "dns" challenge. + + :ivar unicode token: + + """ typ = "dns" token = jose.Field("token") diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index a1214c2f..3543553c 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -4,7 +4,8 @@ import unittest import mock import OpenSSL import requests -import urlparse + +from six.moves.urllib import parse as urllib_parse # pylint: disable=import-error from acme import jose from acme import other @@ -136,7 +137,7 @@ class SimpleHTTPResponseTest(unittest.TestCase): @mock.patch("acme.challenges.requests.get") def test_simple_verify_port(self, mock_get): self.resp_http.simple_verify(self.chall, "local", 4430) - self.assertEqual("local:4430", urlparse.urlparse( + self.assertEqual("local:4430", urllib_parse.urlparse( mock_get.mock_calls[0][1][0]).netloc) @@ -145,9 +146,9 @@ class DVSNITest(unittest.TestCase): def setUp(self): from acme.challenges import DVSNI self.msg = DVSNI( - r="O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6" - "\xbf'\xb3\xed\x9a9nX\x0f'\\m\xe7\x12", - nonce='\xa8-_\xf8\xeft\r\x12\x88\x1fm<"w\xab.') + r=b"O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6" + b"\xbf'\xb3\xed\x9a9nX\x0f'\\m\xe7\x12", + nonce=b'\xa8-_\xf8\xeft\r\x12\x88\x1fm<"w\xab.') self.jmsg = { 'type': 'dvsni', 'r': 'Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI', @@ -155,7 +156,7 @@ class DVSNITest(unittest.TestCase): } def test_nonce_domain(self): - self.assertEqual('a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid', + self.assertEqual(b'a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid', self.msg.nonce_domain) def test_to_partial_json(self): @@ -187,8 +188,8 @@ class DVSNIResponseTest(unittest.TestCase): def setUp(self): from acme.challenges import DVSNIResponse self.msg = DVSNIResponse( - s='\xf5\xd6\xe3\xb2]\xe0L\x0bN\x9cKJ\x14I\xa1K\xa3#\xf9\xa8' - '\xcd\x8c7\x0e\x99\x19)\xdc\xb7\xf3\x9bw') + s=b'\xf5\xd6\xe3\xb2]\xe0L\x0bN\x9cKJ\x14I\xa1K\xa3#\xf9\xa8' + b'\xcd\x8c7\x0e\x99\x19)\xdc\xb7\xf3\x9bw') self.jmsg = { 'type': 'dvsni', 's': '9dbjsl3gTAtOnEtKFEmhS6Mj-ajNjDcOmRkp3Lfzm3c', @@ -197,15 +198,14 @@ class DVSNIResponseTest(unittest.TestCase): def test_z_and_domain(self): from acme.challenges import DVSNI challenge = DVSNI( - r="O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6" - "\xbf'\xb3\xed\x9a9nX\x0f'\\m\xe7\x12", - nonce=long('439736375371401115242521957580409149254868992063' - '44333654741504362774620418661L')) + r=b"O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6" + b"\xbf'\xb3\xed\x9a9nX\x0f'\\m\xe7\x12", + nonce=int('439736375371401115242521957580409149254868992063' + '44333654741504362774620418661')) # pylint: disable=invalid-name - z = '38e612b0397cc2624a07d351d7ef50e46134c0213d9ed52f7d7c611acaeed41b' + z = b'38e612b0397cc2624a07d351d7ef50e46134c0213d9ed52f7d7c611acaeed41b' self.assertEqual(z, self.msg.z(challenge)) - self.assertEqual( - '{0}.acme.invalid'.format(z), self.msg.z_domain(challenge)) + self.assertEqual(z + b'.acme.invalid', self.msg.z_domain(challenge)) def test_to_partial_json(self): self.assertEqual(self.jmsg, self.msg.to_partial_json()) @@ -362,7 +362,7 @@ class ProofOfPossessionHintsTest(unittest.TestCase): self.jmsg_to = { 'jwk': jwk, 'certFingerprints': cert_fingerprints, - 'certs': (jose.b64encode(OpenSSL.crypto.dump_certificate( + 'certs': (jose.encode_b64jose(OpenSSL.crypto.dump_certificate( OpenSSL.crypto.FILETYPE_ASN1, CERT)),), 'subjectKeyIdentifiers': subject_key_identifiers, 'serialNumbers': serial_numbers, @@ -413,7 +413,7 @@ class ProofOfPossessionTest(unittest.TestCase): issuers=(), authorized_for=()) self.msg = ProofOfPossession( alg=jose.RS256, hints=hints, - nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ') + nonce=b'xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ') self.jmsg_to = { 'type': 'proofOfPossession', @@ -449,16 +449,16 @@ class ProofOfPossessionResponseTest(unittest.TestCase): # mistake here... signature = other.Signature( alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.public_key()), - sig='\xa7\xc1\xe7\xe82o\xbc\xcd\xd0\x1e\x010#Z|\xaf\x15\x83' - '\x94\x8f#\x9b\nQo(\x80\x15,\x08\xfcz\x1d\xfd\xfd.\xaap' - '\xfa\x06\xd1\xa2f\x8d8X2>%d\xbd%\xe1T\xdd\xaa0\x18\xde' - '\x99\x08\xf0\x0e{', - nonce='\x99\xc7Q\xb3f2\xbc\xdci\xfe\xd6\x98k\xc67\xdf', + sig=b'\xa7\xc1\xe7\xe82o\xbc\xcd\xd0\x1e\x010#Z|\xaf\x15\x83' + b'\x94\x8f#\x9b\nQo(\x80\x15,\x08\xfcz\x1d\xfd\xfd.\xaap' + b'\xfa\x06\xd1\xa2f\x8d8X2>%d\xbd%\xe1T\xdd\xaa0\x18\xde' + b'\x99\x08\xf0\x0e{', + nonce=b'\x99\xc7Q\xb3f2\xbc\xdci\xfe\xd6\x98k\xc67\xdf', ) from acme.challenges import ProofOfPossessionResponse self.msg = ProofOfPossessionResponse( - nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ', + nonce=b'xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ', signature=signature) self.jmsg_to = { diff --git a/acme/acme/client.py b/acme/acme/client.py index 33e4e4f7..11304290 100644 --- a/acme/acme/client.py +++ b/acme/acme/client.py @@ -1,13 +1,15 @@ """ACME client API.""" import datetime import heapq -import httplib import json import logging import time +from six.moves import http_client # pylint: disable=import-error + import OpenSSL import requests +import six import werkzeug from acme import errors @@ -19,7 +21,8 @@ from acme import messages logger = logging.getLogger(__name__) # https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning -requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() +if six.PY2: + requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() class Client(object): # pylint: disable=too-many-instance-attributes @@ -80,7 +83,8 @@ class Client(object): # pylint: disable=too-many-instance-attributes new_reg = messages.Registration() if new_reg is None else new_reg response = self.net.post(self.new_reg_uri, new_reg) - assert response.status_code == httplib.CREATED # TODO: handle errors + # TODO: handle errors + assert response.status_code == http_client.CREATED # "Instance of 'Field' has no key/contact member" bug: # pylint: disable=no-member @@ -162,7 +166,8 @@ class Client(object): # pylint: disable=too-many-instance-attributes """ new_authz = messages.Authorization(identifier=identifier) response = self.net.post(new_authzr_uri, new_authz) - assert response.status_code == httplib.CREATED # TODO: handle errors + # TODO: handle errors + assert response.status_code == http_client.CREATED return self._authzr_from_response(response, identifier) def request_domain_challenges(self, domain, new_authz_uri): @@ -424,7 +429,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes """ response = self.net.post(messages.Revocation.url(self.new_reg_uri), messages.Revocation(certificate=cert)) - if response.status_code != httplib.OK: + if response.status_code != http_client.OK: raise errors.ClientError( 'Successful revocation must return HTTP OK status') @@ -447,12 +452,13 @@ class ClientNetwork(object): .. todo:: Implement ``acmePath``. :param .ClientRequestableResource obj: + :param bytes nonce: :rtype: `.JWS` """ jobj = obj.to_json() jobj['resource'] = obj.resource_type - dumps = json.dumps(jobj) + dumps = json.dumps(jobj).encode() logger.debug('Serialized JSON: %s', dumps) return jws.JWS.sign( payload=dumps, key=self.key, alg=self.alg, nonce=nonce).json_dumps() @@ -555,12 +561,12 @@ class ClientNetwork(object): def _add_nonce(self, response): if self.REPLAY_NONCE_HEADER in response.headers: nonce = response.headers[self.REPLAY_NONCE_HEADER] - error = jws.Header.validate_nonce(nonce) - if error is None: - logger.debug('Storing nonce: %r', nonce) - self._nonces.add(nonce) - else: + try: + decoded_nonce = jws.Header._fields['nonce'].decode(nonce) + except jose.DeserializationError as error: raise errors.BadNonce(nonce, error) + logger.debug('Storing nonce: %r', decoded_nonce) + self._nonces.add(decoded_nonce) else: raise errors.MissingNonce(response) diff --git a/acme/acme/client_test.py b/acme/acme/client_test.py index 3e3380a1..8e731feb 100644 --- a/acme/acme/client_test.py +++ b/acme/acme/client_test.py @@ -1,9 +1,10 @@ """Tests for acme.client.""" import datetime -import httplib import json import unittest +from six.moves import http_client # pylint: disable=import-error + import mock import requests @@ -27,7 +28,7 @@ class ClientTest(unittest.TestCase): def setUp(self): self.response = mock.MagicMock( - ok=True, status_code=httplib.OK, headers={}, links={}) + ok=True, status_code=http_client.OK, headers={}, links={}) self.net = mock.MagicMock() self.net.post.return_value = self.response self.net.get.return_value = self.response @@ -73,7 +74,7 @@ class ClientTest(unittest.TestCase): def test_register(self): # "Instance of 'Field' has no to_json/update member" bug: # pylint: disable=no-member - self.response.status_code = httplib.CREATED + self.response.status_code = http_client.CREATED self.response.json.return_value = self.regr.body.to_json() self.response.headers['Location'] = self.regr.uri self.response.links.update({ @@ -91,7 +92,7 @@ class ClientTest(unittest.TestCase): errors.UnexpectedUpdate, self.client.register, self.regr.body) def test_register_missing_next(self): - self.response.status_code = httplib.CREATED + self.response.status_code = http_client.CREATED self.assertRaises( errors.ClientError, self.client.register, self.regr.body) @@ -115,7 +116,7 @@ class ClientTest(unittest.TestCase): self.assertEqual(self.regr.terms_of_service, regr.body.agreement) def test_request_challenges(self): - self.response.status_code = httplib.CREATED + self.response.status_code = http_client.CREATED self.response.headers['Location'] = self.authzr.uri self.response.json.return_value = self.authz.to_json() self.response.links = { @@ -133,7 +134,7 @@ class ClientTest(unittest.TestCase): self.identifier, self.authzr.uri) def test_request_challenges_missing_next(self): - self.response.status_code = httplib.CREATED + self.response.status_code = http_client.CREATED self.assertRaises( errors.ClientError, self.client.request_challenges, self.identifier, self.regr) @@ -345,7 +346,7 @@ class ClientTest(unittest.TestCase): self.client.new_reg_uri), mock.ANY) def test_revoke_bad_status_raises_error(self): - self.response.status_code = httplib.METHOD_NOT_ALLOWED + self.response.status_code = http_client.METHOD_NOT_ALLOWED self.assertRaises(errors.ClientError, self.client.revoke, self.certr) @@ -360,7 +361,7 @@ class ClientNetworkTest(unittest.TestCase): self.net = ClientNetwork( key=KEY, alg=jose.RS256, verify_ssl=self.verify_ssl) - self.response = mock.MagicMock(ok=True, status_code=httplib.OK) + self.response = mock.MagicMock(ok=True, status_code=http_client.OK) self.response.headers = {} self.response.links = {} @@ -380,12 +381,11 @@ class ClientNetworkTest(unittest.TestCase): pass # pragma: no cover # pylint: disable=protected-access jws_dump = self.net._wrap_in_jws( - MockClientRequestableResource('foo'), nonce='Tg') + MockClientRequestableResource('foo'), nonce=b'Tg') jws = acme_jws.JWS.json_loads(jws_dump) - self.assertEqual(json.loads(jws.payload), + self.assertEqual(json.loads(jws.payload.decode()), {'foo': 'foo', 'resource': 'mock'}) - self.assertEqual(jws.signature.combined.nonce, 'Tg') - # TODO: check that nonce is in protected header + self.assertEqual(jws.signature.combined.nonce, b'Tg') def test_check_response_not_ok_jobj_no_error(self): self.response.ok = False @@ -473,7 +473,7 @@ class ClientNetworkWithMockedResponseTest(unittest.TestCase): from acme.client import ClientNetwork self.net = ClientNetwork(key=None, alg=None) - self.response = mock.MagicMock(ok=True, status_code=httplib.OK) + self.response = mock.MagicMock(ok=True, status_code=http_client.OK) self.response.headers = {} self.response.links = {} self.checked_response = mock.MagicMock() @@ -481,13 +481,14 @@ class ClientNetworkWithMockedResponseTest(unittest.TestCase): self.wrapped_obj = mock.MagicMock() self.content_type = mock.sentinel.content_type - self.all_nonces = [jose.b64encode('Nonce'), jose.b64encode('Nonce2')] + self.all_nonces = [jose.b64encode(b'Nonce'), jose.b64encode(b'Nonce2')] self.available_nonces = self.all_nonces[:] def send_request(*args, **kwargs): # pylint: disable=unused-argument,missing-docstring if self.available_nonces: self.response.headers = { - self.net.REPLAY_NONCE_HEADER: self.available_nonces.pop()} + self.net.REPLAY_NONCE_HEADER: + self.available_nonces.pop().decode()} else: self.response.headers = {} return self.response @@ -519,21 +520,21 @@ class ClientNetworkWithMockedResponseTest(unittest.TestCase): self.assertEqual(self.checked_response, self.net.post( 'uri', self.obj, content_type=self.content_type)) self.net._wrap_in_jws.assert_called_once_with( - self.obj, self.all_nonces.pop()) + self.obj, jose.b64decode(self.all_nonces.pop())) assert not self.available_nonces self.assertRaises(errors.MissingNonce, self.net.post, 'uri', self.obj, content_type=self.content_type) self.net._wrap_in_jws.assert_called_with( - self.obj, self.all_nonces.pop()) + self.obj, jose.b64decode(self.all_nonces.pop())) def test_post_wrong_initial_nonce(self): # HEAD - self.available_nonces = ['f', jose.b64encode('good')] + self.available_nonces = [b'f', jose.b64encode(b'good')] self.assertRaises(errors.BadNonce, self.net.post, 'uri', self.obj, content_type=self.content_type) def test_post_wrong_post_response_nonce(self): - self.available_nonces = [jose.b64encode('good'), 'f'] + self.available_nonces = [jose.b64encode(b'good'), b'f'] self.assertRaises(errors.BadNonce, self.net.post, 'uri', self.obj, content_type=self.content_type) diff --git a/acme/acme/interfaces.py b/acme/acme/interfaces.py index 9899b109..39078f46 100644 --- a/acme/acme/interfaces.py +++ b/acme/acme/interfaces.py @@ -5,7 +5,7 @@ from acme import jose class ClientRequestableResource(jose.JSONDeSerializable): """Resource that can be requested by client. - :ivar str resource_type: ACME resource identifier used in client + :ivar unicode resource_type: ACME resource identifier used in client HTTPS requests in order to protect against MITM. """ diff --git a/acme/acme/jws.py b/acme/acme/jws.py index a23015d9..54bc26d9 100644 --- a/acme/acme/jws.py +++ b/acme/acme/jws.py @@ -1,5 +1,4 @@ """ACME JOSE JWS.""" -from acme import errors from acme import jose @@ -9,29 +8,15 @@ class Header(jose.Header): .. todo:: Implement ``acmePath``. """ - nonce = jose.Field('nonce', omitempty=True) - - @classmethod - def validate_nonce(cls, nonce): - """Validate nonce. - - :returns: ``None`` if ``nonce`` is valid, decoding errors otherwise. - - """ - try: - jose.b64decode(nonce) - except (ValueError, TypeError) as error: - return error - else: - return None + nonce = jose.Field('nonce', omitempty=True, encoder=jose.encode_b64jose) @nonce.decoder def nonce(value): # pylint: disable=missing-docstring,no-self-argument - error = Header.validate_nonce(value) - if error is not None: + try: + return jose.decode_b64jose(value) + except jose.DeserializationError as error: # TODO: custom error - raise errors.Error("Invalid nonce: {0}".format(error)) - return value + raise jose.DeserializationError("Invalid nonce: {0}".format(error)) class Signature(jose.Signature): diff --git a/acme/acme/jws_test.py b/acme/acme/jws_test.py index 07361581..e8f8e871 100644 --- a/acme/acme/jws_test.py +++ b/acme/acme/jws_test.py @@ -1,7 +1,6 @@ """Tests for acme.jws.""" import unittest -from acme import errors from acme import jose from acme import test_util @@ -12,8 +11,8 @@ KEY = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem')) class HeaderTest(unittest.TestCase): """Tests for acme.jws.Header.""" - good_nonce = jose.b64encode('foo') - wrong_nonce = 'F' + good_nonce = jose.encode_b64jose(b'foo') + wrong_nonce = u'F' # Following just makes sure wrong_nonce is wrong try: jose.b64decode(wrong_nonce) @@ -22,17 +21,13 @@ class HeaderTest(unittest.TestCase): else: assert False # pragma: no cover - def test_validate_nonce(self): - from acme.jws import Header - self.assertTrue(Header.validate_nonce(self.good_nonce) is None) - self.assertFalse(Header.validate_nonce(self.wrong_nonce) is None) - def test_nonce_decoder(self): from acme.jws import Header nonce_field = Header._fields['nonce'] - self.assertRaises(errors.Error, nonce_field.decode, self.wrong_nonce) - self.assertEqual(self.good_nonce, nonce_field.decode(self.good_nonce)) + self.assertRaises( + jose.DeserializationError, nonce_field.decode, self.wrong_nonce) + self.assertEqual(b'foo', nonce_field.decode(self.good_nonce)) class JWSTest(unittest.TestCase): @@ -41,13 +36,16 @@ class JWSTest(unittest.TestCase): def setUp(self): self.privkey = KEY self.pubkey = self.privkey.public_key() - self.nonce = jose.b64encode('Nonce') + self.nonce = jose.b64encode(b'Nonce') def test_it(self): from acme.jws import JWS - jws = JWS.sign(payload='foo', key=self.privkey, + jws = JWS.sign(payload=b'foo', key=self.privkey, alg=jose.RS256, nonce=self.nonce) - JWS.from_json(jws.to_json()) + self.assertEqual(jws.signature.combined.nonce, self.nonce) + # TODO: check that nonce is in protected header + + self.assertEqual(jws, JWS.from_json(jws.to_json())) if __name__ == '__main__': diff --git a/acme/acme/messages.py b/acme/acme/messages.py index cccc34cd..1ffdc48c 100644 --- a/acme/acme/messages.py +++ b/acme/acme/messages.py @@ -1,5 +1,7 @@ """ACME protocol messages.""" -import urlparse +import collections + +from six.moves.urllib import parse as urllib_parse # pylint: disable=import-error from acme import challenges from acme import fields @@ -12,6 +14,10 @@ class Error(jose.JSONObjectWithFields, Exception): https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 + :ivar unicode typ: + :ivar unicode title: + :ivar unicode detail: + """ ERROR_TYPE_NAMESPACE = 'urn:acme:error:' ERROR_TYPE_DESCRIPTIONS = { @@ -49,7 +55,11 @@ class Error(jose.JSONObjectWithFields, Exception): @property def description(self): - """Hardcoded error description based on its type.""" + """Hardcoded error description based on its type. + + :rtype: unicode + + """ return self.ERROR_TYPE_DESCRIPTIONS[self.typ] def __str__(self): @@ -59,7 +69,7 @@ class Error(jose.JSONObjectWithFields, Exception): return str(self.detail) -class _Constant(jose.JSONDeSerializable): +class _Constant(jose.JSONDeSerializable, collections.Hashable): """ACME constant.""" __slots__ = ('name',) POSSIBLE_NAMES = NotImplemented @@ -84,6 +94,9 @@ class _Constant(jose.JSONDeSerializable): def __eq__(self, other): return isinstance(other, type(self)) and other.name == self.name + def __hash__(self): + return hash((self.__class__, self.name)) + def __ne__(self, other): return not self == other @@ -108,7 +121,8 @@ IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder class Identifier(jose.JSONObjectWithFields): """ACME identifier. - :ivar acme.messages.IdentifierType typ: + :ivar IdentifierType typ: + :ivar unicode value: """ typ = jose.Field('type', decoder=IdentifierType.from_json) @@ -127,7 +141,7 @@ class Resource(jose.JSONObjectWithFields): class ResourceWithURI(Resource): """ACME Resource with URI. - :ivar str uri: Location of the resource. + :ivar unicode uri: Location of the resource. """ uri = jose.Field('uri') # no ChallengeResource.uri @@ -141,7 +155,10 @@ class Registration(interfaces.ClientRequestableResource, ResourceBody): """Registration Resource Body. :ivar acme.jose.jwk.JWK key: Public key. - :ivar tuple contact: Contact information following ACME spec + :ivar tuple contact: Contact information following ACME spec, + `tuple` of `unicode`. + :ivar unicode recovery_token: + :ivar unicode agreement: """ resource_type = 'new-reg' @@ -188,8 +205,8 @@ class RegistrationResource(interfaces.ClientRequestableResource, """Registration Resource. :ivar acme.messages.Registration body: - :ivar str new_authzr_uri: URI found in the 'next' ``Link`` header - :ivar str terms_of_service: URL for the CA TOS. + :ivar unicode new_authzr_uri: URI found in the 'next' ``Link`` header + :ivar unicode terms_of_service: URL for the CA TOS. """ resource_type = 'reg' @@ -212,6 +229,7 @@ class ChallengeBody(ResourceBody): call ``challb.x`` to get ``challb.chall.x`` contents. :ivar acme.messages.Status status: :ivar datetime.datetime validated: + :ivar Error error: """ __slots__ = ('chall',) @@ -241,7 +259,7 @@ class ChallengeResource(Resource): """Challenge Resource. :ivar acme.messages.ChallengeBody body: - :ivar str authzr_uri: URI found in the 'up' ``Link`` header. + :ivar unicode authzr_uri: URI found in the 'up' ``Link`` header. """ body = jose.Field('body', decoder=ChallengeBody.from_json) @@ -261,8 +279,6 @@ class Authorization(interfaces.ClientRequestableResource, ResourceBody): :ivar list challenges: `list` of `.ChallengeBody` :ivar tuple combinations: Challenge combinations (`tuple` of `tuple` of `int`, as opposed to `list` of `list` from the spec). - :ivar acme.jose.jwk.JWK key: Public key. - :ivar tuple contact: :ivar acme.messages.Status status: :ivar datetime.datetime expires: @@ -294,7 +310,7 @@ class AuthorizationResource(ResourceWithURI): """Authorization Resource. :ivar acme.messages.Authorization body: - :ivar str new_cert_uri: URI found in the 'next' ``Link`` header + :ivar unicode new_cert_uri: URI found in the 'next' ``Link`` header """ body = jose.Field('body', decoder=Authorization.from_json) @@ -321,7 +337,7 @@ class CertificateResource(interfaces.ClientRequestableResource, :ivar acme.jose.util.ComparableX509 body: `OpenSSL.crypto.X509` wrapped in `.ComparableX509` - :ivar str cert_chain_uri: URI found in the 'up' ``Link`` header + :ivar unicode cert_chain_uri: URI found in the 'up' ``Link`` header :ivar tuple authzrs: `tuple` of `AuthorizationResource`. """ @@ -353,4 +369,4 @@ class Revocation(interfaces.ClientRequestableResource, :param str base: New Registration Resource or server (root) URL. """ - return urlparse.urljoin(base, cls.PATH) + return urllib_parse.urljoin(base, cls.PATH) diff --git a/acme/acme/other.py b/acme/acme/other.py index cf6425b7..59bb0129 100644 --- a/acme/acme/other.py +++ b/acme/acme/other.py @@ -12,22 +12,20 @@ logger = logging.getLogger(__name__) class Signature(jose.JSONObjectWithFields): """ACME signature. - :ivar str alg: Signature algorithm. - :ivar str sig: Signature. - :ivar str nonce: Nonce. - - :ivar jwk: JWK. - :type jwk: :class:`JWK` + :ivar .JWASignature alg: Signature algorithm. + :ivar bytes sig: Signature. + :ivar bytes nonce: Nonce. + :ivar .JWK jwk: JWK. """ NONCE_SIZE = 16 """Minimum size of nonce in bytes.""" alg = jose.Field('alg', decoder=jose.JWASignature.from_json) - sig = jose.Field('sig', encoder=jose.b64encode, + sig = jose.Field('sig', encoder=jose.encode_b64jose, decoder=jose.decode_b64jose) nonce = jose.Field( - 'nonce', encoder=jose.b64encode, decoder=functools.partial( + 'nonce', encoder=jose.encode_b64jose, decoder=functools.partial( jose.decode_b64jose, size=NONCE_SIZE, minimum=True)) jwk = jose.Field('jwk', decoder=jose.JWK.from_json) @@ -35,27 +33,26 @@ class Signature(jose.JSONObjectWithFields): def from_msg(cls, msg, key, nonce=None, nonce_size=None, alg=jose.RS256): """Create signature with nonce prepended to the message. - .. todo:: Protect against crypto unicode errors... is this sufficient? - Do I need to escape? - - :param str msg: Message to be signed. + :param bytes msg: Message to be signed. :param key: Key used for signing. :type key: `cryptography.hazmat.primitives.assymetric.rsa.RSAPrivateKey` (optionally wrapped in `.ComparableRSAKey`). - :param str nonce: Nonce to be used. If None, nonce of + :param bytes nonce: Nonce to be used. If None, nonce of ``nonce_size`` will be randomly generated. :param int nonce_size: Size of the automatically generated nonce. Defaults to :const:`NONCE_SIZE`. + :param .JWASignature alg: + """ nonce_size = cls.NONCE_SIZE if nonce_size is None else nonce_size nonce = os.urandom(nonce_size) if nonce is None else nonce msg_with_nonce = nonce + msg sig = alg.sign(key, nonce + msg) - logger.debug('%s signed as %s', msg_with_nonce, sig) + logger.debug('%r signed as %r', msg_with_nonce, sig) return cls(alg=alg, sig=sig, nonce=nonce, jwk=alg.kty(key=key.public_key())) @@ -63,7 +60,7 @@ class Signature(jose.JSONObjectWithFields): def verify(self, msg): """Verify the signature. - :param str msg: Message that was used in signing. + :param bytes msg: Message that was used in signing. """ # self.alg is not Field, but JWA | pylint: disable=no-member diff --git a/acme/acme/other_test.py b/acme/acme/other_test.py index 428fca81..40fad945 100644 --- a/acme/acme/other_test.py +++ b/acme/acme/other_test.py @@ -13,12 +13,12 @@ class SignatureTest(unittest.TestCase): """Tests for acme.sig.Signature.""" def setUp(self): - self.msg = 'message' - self.sig = ('IC\xd8*\xe7\x14\x9e\x19S\xb7\xcf\xec3\x12\xe2\x8a\x03' - '\x98u\xff\xf0\x94\xe2\xd7<\x8f\xa8\xed\xa4KN\xc3\xaa' - '\xb9X\xc3w\xaa\xc0_\xd0\x05$y>l#\x10<\x96\xd2\xcdr\xa3' - '\x1b\xa1\xf5!f\xef\xc64\xb6\x13') - self.nonce = '\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9' + self.msg = b'message' + self.sig = (b'IC\xd8*\xe7\x14\x9e\x19S\xb7\xcf\xec3\x12\xe2\x8a\x03' + b'\x98u\xff\xf0\x94\xe2\xd7<\x8f\xa8\xed\xa4KN\xc3\xaa' + b'\xb9X\xc3w\xaa\xc0_\xd0\x05$y>l#\x10<\x96\xd2\xcdr\xa3' + b'\x1b\xa1\xf5!f\xef\xc64\xb6\x13') + self.nonce = b'\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9' self.alg = jose.RS256 self.jwk = jose.JWKRSA(key=KEY.public_key()) @@ -54,7 +54,7 @@ class SignatureTest(unittest.TestCase): self.assertTrue(self.signature.verify(self.msg)) def test_verify_bad_fails(self): - self.assertFalse(self.signature.verify(self.msg + 'x')) + self.assertFalse(self.signature.verify(self.msg + b'x')) @classmethod def _from_msg(cls, *args, **kwargs):