From 1bb6763595dfa6e1b331ed5a3bebc3ca7bc08599 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 10 Jul 2015 21:42:37 +0000 Subject: [PATCH 01/13] acme: Update DVSNI to v03. --- acme/acme/challenges.py | 60 ++++++++++---------------------- acme/acme/challenges_test.py | 56 ++++++++++++----------------- acme/acme/jose/json_util.py | 16 +++++++++ acme/acme/jose/json_util_test.py | 12 +++++++ 4 files changed, 69 insertions(+), 75 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 8024728fa..8d8d21f9d 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -1,9 +1,7 @@ """ACME Identifier Validation Challenges.""" -import binascii import functools import hashlib import logging -import os import requests @@ -163,8 +161,7 @@ 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 str token: Random data, **not** base64-encoded. """ typ = "dvsni" @@ -172,25 +169,15 @@ class DVSNI(DVChallenge): DOMAIN_SUFFIX = ".acme.invalid" """Domain name suffix.""" - R_SIZE = 32 - """Required size of the :attr:`r` in bytes.""" - - NONCE_SIZE = 16 - """Required size of the :attr:`nonce` in bytes.""" + TOKEN_SIZE = 128 / 8 # Based on the entropy value from the spec + """Minimum size of the :attr:`token` in bytes.""" PORT = 443 """Port to perform DVSNI challenge.""" - r = jose.Field("r", encoder=jose.b64encode, # pylint: disable=invalid-name - decoder=functools.partial(jose.decode_b64jose, size=R_SIZE)) - nonce = jose.Field("nonce", encoder=binascii.hexlify, - decoder=functools.partial(functools.partial( - jose.decode_hex16, size=NONCE_SIZE))) - - @property - def nonce_domain(self): - """Domain name used in SNI.""" - return binascii.hexlify(self.nonce) + self.DOMAIN_SUFFIX + token = jose.Field( + "token", encoder=jose.b64encode, decoder=functools.partial( + jose.decode_b64jose, size=TOKEN_SIZE, minimum=True)) @ChallengeResponse.register @@ -205,31 +192,22 @@ class DVSNIResponse(ChallengeResponse): DOMAIN_SUFFIX = DVSNI.DOMAIN_SUFFIX """Domain name suffix.""" - S_SIZE = 32 - """Required size of the :attr:`s` in bytes.""" + validation = jose.Field("validation", decoder=jose.JWS.from_json) - s = jose.Field("s", encoder=jose.b64encode, # pylint: disable=invalid-name - decoder=functools.partial(jose.decode_b64jose, size=S_SIZE)) + @property + def z(self): # pylint: disable=invalid-name + """The ``z`` parameter.""" + # Instance of 'Field' has no 'signature' member + # pylint: disable=no-member + return hashlib.sha256(self.validation.signature.encode( + "signature")).hexdigest() - def __init__(self, s=None, *args, **kwargs): - s = os.urandom(self.S_SIZE) if s is None else s - super(DVSNIResponse, self).__init__(s=s, *args, **kwargs) - - def z(self, chall): # pylint: disable=invalid-name - """Compute the parameter ``z``. - - :param challenge: Corresponding challenge. - :type challenge: :class:`DVSNI` - - """ - z = hashlib.new("sha256") # pylint: disable=invalid-name - z.update(chall.r) - z.update(self.s) - return z.hexdigest() - - def z_domain(self, chall): + @property + def z_domain(self): """Domain name for certificate subjectAltName.""" - return self.z(chall) + self.DOMAIN_SUFFIX + z = self.z # pylint: disable=invalid-name + return "{0}.{1}{2}".format(z[:32], z[32:], self.DOMAIN_SUFFIX) + @Challenge.register class RecoveryContact(ContinuityChallenge): diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index a1214c2f9..eae9eedea 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -145,19 +145,12 @@ 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.') + token=jose.b64decode('a82d5ff8ef740d12881f6d3c2277ab2e')) self.jmsg = { 'type': 'dvsni', - 'r': 'Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI', - 'nonce': 'a82d5ff8ef740d12881f6d3c2277ab2e', + 'token': 'a82d5ff8ef740d12881f6d3c2277ab2e', } - def test_nonce_domain(self): - self.assertEqual('a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid', - self.msg.nonce_domain) - def test_to_partial_json(self): self.assertEqual(self.jmsg, self.msg.to_partial_json()) @@ -169,15 +162,9 @@ class DVSNITest(unittest.TestCase): from acme.challenges import DVSNI hash(DVSNI.from_json(self.jmsg)) - def test_from_json_invalid_r_length(self): + def test_from_json_invalid_token_length(self): from acme.challenges import DVSNI - self.jmsg['r'] = 'abcd' - self.assertRaises( - jose.DeserializationError, DVSNI.from_json, self.jmsg) - - def test_from_json_invalid_nonce_length(self): - from acme.challenges import DVSNI - self.jmsg['nonce'] = 'abcd' + self.jmsg['token'] = jose.b64encode('abcd') self.assertRaises( jose.DeserializationError, DVSNI.from_json, self.jmsg) @@ -186,37 +173,38 @@ 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') - self.jmsg = { + self.validation = jose.JWS.sign( + payload='foo', key=jose.JWKRSA(key=KEY), alg=jose.RS256) + self.msg = DVSNIResponse(validation=self.validation) + self.jmsg_to = { 'type': 'dvsni', - 's': '9dbjsl3gTAtOnEtKFEmhS6Mj-ajNjDcOmRkp3Lfzm3c', + 'validation': self.validation, + } + self.jmsg_from = { + 'type': 'dvsni', + 'validation': self.validation.to_json(), } 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')) # pylint: disable=invalid-name - z = '38e612b0397cc2624a07d351d7ef50e46134c0213d9ed52f7d7c611acaeed41b' - self.assertEqual(z, self.msg.z(challenge)) + z = '94b209f1b27afe1cb40f27c9ce7c1b4d75786fe6e380524c0bb80009f0105e4b' + label1 = '94b209f1b27afe1cb40f27c9ce7c1b4d' + label2 = '75786fe6e380524c0bb80009f0105e4b' + assert z == label1 + label2 + self.assertEqual(z, self.msg.z) self.assertEqual( - '{0}.acme.invalid'.format(z), self.msg.z_domain(challenge)) + '{0}.{1}.acme.invalid'.format(label1, label2), self.msg.z_domain) def test_to_partial_json(self): - self.assertEqual(self.jmsg, self.msg.to_partial_json()) + self.assertEqual(self.jmsg_to, self.msg.to_partial_json()) def test_from_json(self): from acme.challenges import DVSNIResponse - self.assertEqual(self.msg, DVSNIResponse.from_json(self.jmsg)) + self.assertEqual(self.msg, DVSNIResponse.from_json(self.jmsg_from)) def test_from_json_hashable(self): from acme.challenges import DVSNIResponse - hash(DVSNIResponse.from_json(self.jmsg)) + hash(DVSNIResponse.from_json(self.jmsg_from)) class RecoveryContactTest(unittest.TestCase): diff --git a/acme/acme/jose/json_util.py b/acme/acme/jose/json_util.py index fe3831296..be852b675 100644 --- a/acme/acme/jose/json_util.py +++ b/acme/acme/jose/json_util.py @@ -218,6 +218,22 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable): super(JSONObjectWithFields, self).__init__( **(dict(self._defaults(), **kwargs))) + def encode(self, name): + """Encode a single field. + + :param str name: Name of the field to be encoded. + + :raises erors.SerializationError: if field cannot be serialized + :raises errors.Error: if field could not be found + + """ + try: + field = self._fields[name] + except KeyError: + raise errors.Error("Field not found: {0}".format(name)) + + return field.encode(getattr(self, name)) + def fields_to_partial_json(self): """Serialize fields to JSON.""" jobj = {} diff --git a/acme/acme/jose/json_util_test.py b/acme/acme/jose/json_util_test.py index 9e2a87858..3eaa80b84 100644 --- a/acme/acme/jose/json_util_test.py +++ b/acme/acme/jose/json_util_test.py @@ -159,6 +159,18 @@ class JSONObjectWithFieldsTest(unittest.TestCase): def test_init_defaults(self): self.assertEqual(self.mock, self.MockJSONObjectWithFields(y=2, z=3)) + def test_encode(self): + self.assertEqual(10, self.MockJSONObjectWithFields( + x=5, y=0, z=0).encode("x")) + + def test_encode_wrong_field(self): + self.assertRaises(errors.Error, self.mock.encode, 'foo') + + def test_encode_serialization_error_passthrough(self): + self.assertRaises( + errors.SerializationError, + self.MockJSONObjectWithFields(y=500, z=None).encode, "y") + def test_fields_to_partial_json_omits_empty(self): self.assertEqual(self.mock.fields_to_partial_json(), {'y': 2, 'Z': 3}) From 735bd924bf525a25571a7fdea30a9211f0b076fb Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 17 Jul 2015 17:44:20 +0000 Subject: [PATCH 02/13] Update letsencrypt to DVSNI v03 (fixes #597). --- letsencrypt/achallenges.py | 42 +++++++--- letsencrypt/auth_handler.py | 16 ++-- letsencrypt/crypto_util.py | 11 ++- letsencrypt/plugins/common.py | 11 +-- letsencrypt/plugins/common_test.py | 29 +++---- .../plugins/standalone/authenticator.py | 47 ++++++----- .../standalone/tests/authenticator_test.py | 80 +++++++++---------- letsencrypt/tests/achallenges_test.py | 28 +++---- letsencrypt/tests/acme_util.py | 3 +- letsencrypt/tests/auth_handler_test.py | 6 +- letsencrypt/tests/continuity_auth_test.py | 6 +- 11 files changed, 133 insertions(+), 146 deletions(-) diff --git a/letsencrypt/achallenges.py b/letsencrypt/achallenges.py index a81ae05a2..ced57395a 100644 --- a/letsencrypt/achallenges.py +++ b/letsencrypt/achallenges.py @@ -18,7 +18,7 @@ Note, that all annotated challenges act as a proxy objects:: """ from acme import challenges -from acme.jose import util as jose_util +from acme import jose from letsencrypt import crypto_util @@ -26,7 +26,7 @@ from letsencrypt import crypto_util # pylint: disable=too-few-public-methods -class AnnotatedChallenge(jose_util.ImmutableMap): +class AnnotatedChallenge(jose.ImmutableMap): """Client annotated challenge. Wraps around server provided challenge and annotates with data @@ -43,23 +43,41 @@ class AnnotatedChallenge(jose_util.ImmutableMap): class DVSNI(AnnotatedChallenge): - """Client annotated "dvsni" ACME challenge.""" - __slots__ = ('challb', 'domain', 'key') + """Client annotated "dvsni" ACME challenge. + + :ivar .Account account: + + """ + __slots__ = ('challb', 'domain', 'account') acme_type = challenges.DVSNI - def gen_cert_and_response(self, s=None): # pylint: disable=invalid-name + def gen_cert_and_response(self, key_pem=None, bits=2048, alg=jose.RS256): """Generate a DVSNI cert and save it to filepath. - :returns: ``(cert_pem, response)`` tuple, where ``cert_pem`` is the PEM - encoded certificate and ``response`` is an instance - :class:`acme.challenges.DVSNIResponse`. + :param bytes key_pem: Private PEM-encoded key used for + certificate generation. If none provided, a fresh key will + be generated. + :param int bits: Number of bits for fresh key generation. + :param .JWAAlgorithm alg: + + :returns: ``(response, cert_pem, key_pem)`` tuple, where + ``response`` is an instance of + `acme.challenges.DVSNIResponse`, ``cert_pem`` is the + PEM-encoded certificate and ``key_pem`` is PEM-encoded + private key. :rtype: tuple """ - response = challenges.DVSNIResponse(s=s) - cert_pem = crypto_util.make_ss_cert(self.key, [ - self.domain, self.nonce_domain, response.z_domain(self.challb)]) - return cert_pem, response + key_pem = crypto_util.make_key(bits) if key_pem is None else key_pem + response = challenges.DVSNIResponse(validation=jose.JWS.sign( + payload=self.challb.chall.json_dumps().encode('utf-8'), + alg=alg, + key=self.account.key, + include_jwk=False, + )) + cert_pem = crypto_util.make_ss_cert( + key_pem, ["some CN", response.z_domain], force_san=True) + return response, cert_pem, key_pem class SimpleHTTP(AnnotatedChallenge): diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index bd6e89cc3..9c985b751 100644 --- a/letsencrypt/auth_handler.py +++ b/letsencrypt/auth_handler.py @@ -317,7 +317,7 @@ class AuthHandler(object): challb = self.authzr[domain].body.challenges[index] chall = challb.chall - achall = challb_to_achall(challb, self.account.key, domain) + achall = challb_to_achall(challb, self.account, domain) if isinstance(chall, challenges.ContinuityChallenge): cont_chall.append(achall) @@ -327,15 +327,11 @@ class AuthHandler(object): return cont_chall, dv_chall -def challb_to_achall(challb, key, domain): +def challb_to_achall(challb, account, domain): """Converts a ChallengeBody object to an AnnotatedChallenge. - :param challb: ChallengeBody - :type challb: :class:`acme.messages.ChallengeBody` - - :param key: Key - :type key: :class:`letsencrypt.le_util.Key` - + :param .ChallengeBody challb: ChallengeBody + :param .Account account: :param str domain: Domain of the challb :returns: Appropriate AnnotatedChallenge @@ -347,10 +343,10 @@ def challb_to_achall(challb, key, domain): if isinstance(chall, challenges.DVSNI): return achallenges.DVSNI( - challb=challb, domain=domain, key=key) + challb=challb, domain=domain, account=account) elif isinstance(chall, challenges.SimpleHTTP): return achallenges.SimpleHTTP( - challb=challb, domain=domain, key=key) + challb=challb, domain=domain, key=account.key) elif isinstance(chall, challenges.DNS): return achallenges.DNS(challb=challb, domain=domain) elif isinstance(chall, challenges.RecoveryToken): diff --git a/letsencrypt/crypto_util.py b/letsencrypt/crypto_util.py index edfd2eccf..8850fc357 100644 --- a/letsencrypt/crypto_util.py +++ b/letsencrypt/crypto_util.py @@ -216,10 +216,13 @@ def pyopenssl_load_certificate(data): def make_ss_cert(key, domains, not_before=None, - validity=(7 * 24 * 60 * 60)): + validity=(7 * 24 * 60 * 60), force_san=False): """Returns new self-signed cert in PEM form. - Uses key and contains all domains. + If more than one domain is provided, all of the domains are put into + ``subjectAltName`` X.509 extension and first domain is set as the + subject CN. If only one domain is provided no ``subjectAltName`` + extension is used, unless `force_san` is ``True``. """ if isinstance(key, jose.JWK): @@ -243,7 +246,7 @@ def make_ss_cert(key, domains, not_before=None, # TODO: what to put into cert.get_subject()? cert.set_issuer(cert.get_subject()) - if len(domains) > 1: + if force_san or len(domains) > 1: extensions.append(OpenSSL.crypto.X509Extension( "subjectAltName", critical=False, @@ -312,7 +315,7 @@ def _get_sans_from_cert_or_req( def get_sans_from_cert(cert, typ=OpenSSL.crypto.FILETYPE_PEM): """Get a list of Subject Alternative Names from a certificate. - :param str csr: Certificate (encoded). + :param str cert: Certificate (encoded). :param typ: `OpenSSL.crypto.FILETYPE_PEM` or `OpenSSL.crypto.FILETYPE_ASN1` :returns: A list of Subject Alternative Names. diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py index 104e8d9c4..c781fbe28 100644 --- a/letsencrypt/plugins/common.py +++ b/letsencrypt/plugins/common.py @@ -4,7 +4,6 @@ import pkg_resources import shutil import tempfile -from cryptography.hazmat.primitives import serialization import zope.interface from acme.jose import util as jose_util @@ -173,17 +172,11 @@ class Dvsni(object): self.configurator.reverter.register_file_creation(True, key_path) self.configurator.reverter.register_file_creation(True, cert_path) - cert_pem, response = achall.gen_cert_and_response(s) + response, cert_pem, key_pem = achall.gen_cert_and_response(s) - # Write out challenge cert + # Write out challenge cert and key with open(cert_path, "wb") as cert_chall_fd: cert_chall_fd.write(cert_pem) - - # Write out challenge key - key_pem = achall.key.key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption()) with le_util.safe_open(key_path, 'wb', chmod=0o400) as key_file: key_file.write(key_pem) diff --git a/letsencrypt/plugins/common_test.py b/letsencrypt/plugins/common_test.py index b68ab8369..4ecf638dd 100644 --- a/letsencrypt/plugins/common_test.py +++ b/letsencrypt/plugins/common_test.py @@ -120,21 +120,12 @@ class DvsniTest(unittest.TestCase): achalls = [ achallenges.DVSNI( challb=acme_util.chall_to_challb( - challenges.DVSNI( - r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9" - "\xf1\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4", - nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18", - ), "pending"), - domain="encryption-example.demo", key=auth_key), + challenges.DVSNI(token=b'dvsni1'), "pending"), + domain="encryption-example.demo", account=mock.Mock(key=auth_key)), achallenges.DVSNI( challb=acme_util.chall_to_challb( - challenges.DVSNI( - r="\xba\xa9\xda? Date: Sun, 19 Jul 2015 12:59:10 +0000 Subject: [PATCH 03/13] Remove obsolete comment. --- acme/acme/fields.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/acme/acme/fields.py b/acme/acme/fields.py index 6943563d4..5c60b05b6 100644 --- a/acme/acme/fields.py +++ b/acme/acme/fields.py @@ -31,8 +31,6 @@ class Resource(jose.Field): def __init__(self, resource_type, *args, **kwargs): self.resource_type = resource_type super(Resource, self).__init__( - # TODO: omitempty used only to trick - # JSONObjectWithFieldsMeta._defaults..., server implementation 'resource', default=resource_type, *args, **kwargs) def decode(self, value): From e0651ad0509c672b460f3c3dd8eb683e82e693b3 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 25 Jul 2015 11:53:37 +0000 Subject: [PATCH 04/13] Remove Recovery Token. --- acme/acme/challenges.py | 17 ----- acme/acme/challenges_test.py | 52 --------------- acme/acme/client_test.py | 2 +- acme/acme/messages.py | 2 - acme/acme/messages_test.py | 8 +-- letsencrypt/account.py | 12 ++-- letsencrypt/achallenges.py | 6 -- letsencrypt/auth_handler.py | 2 - letsencrypt/configuration.py | 6 -- letsencrypt/constants.py | 4 -- letsencrypt/continuity_auth.py | 18 ++--- letsencrypt/interfaces.py | 2 - letsencrypt/recovery_token.py | 72 -------------------- letsencrypt/tests/account_test.py | 2 - letsencrypt/tests/acme_util.py | 7 +- letsencrypt/tests/auth_handler_test.py | 22 +++---- letsencrypt/tests/configuration_test.py | 2 - letsencrypt/tests/continuity_auth_test.py | 51 ++------------- letsencrypt/tests/le_util_test.py | 31 +++++++++ letsencrypt/tests/recovery_token_test.py | 80 ----------------------- 20 files changed, 60 insertions(+), 338 deletions(-) delete mode 100644 letsencrypt/recovery_token.py delete mode 100644 letsencrypt/tests/recovery_token_test.py diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index f3608a954..41ede797e 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -359,23 +359,6 @@ class RecoveryContactResponse(ChallengeResponse): token = jose.Field("token", omitempty=True) -@Challenge.register -class RecoveryToken(ContinuityChallenge): - """ACME "recoveryToken" challenge.""" - typ = "recoveryToken" - - -@ChallengeResponse.register -class RecoveryTokenResponse(ChallengeResponse): - """ACME "recoveryToken" challenge response. - - :ivar unicode token: - - """ - typ = "recoveryToken" - token = jose.Field("token", omitempty=True) - - @Challenge.register class ProofOfPossession(ContinuityChallenge): """ACME "proofOfPossession" challenge. diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index f3f89bf0b..a9d557e8d 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -368,58 +368,6 @@ class RecoveryContactResponseTest(unittest.TestCase): self.assertEqual(self.jmsg, msg.to_partial_json()) -class RecoveryTokenTest(unittest.TestCase): - - def setUp(self): - from acme.challenges import RecoveryToken - self.msg = RecoveryToken() - self.jmsg = {'type': 'recoveryToken'} - - def test_to_partial_json(self): - self.assertEqual(self.jmsg, self.msg.to_partial_json()) - - def test_from_json(self): - from acme.challenges import RecoveryToken - self.assertEqual(self.msg, RecoveryToken.from_json(self.jmsg)) - - def test_from_json_hashable(self): - from acme.challenges import RecoveryToken - hash(RecoveryToken.from_json(self.jmsg)) - - -class RecoveryTokenResponseTest(unittest.TestCase): - - def setUp(self): - from acme.challenges import RecoveryTokenResponse - self.msg = RecoveryTokenResponse(token='23029d88d9e123e') - self.jmsg = { - 'resource': 'challenge', - 'type': 'recoveryToken', - 'token': '23029d88d9e123e' - } - - def test_to_partial_json(self): - self.assertEqual(self.jmsg, self.msg.to_partial_json()) - - def test_from_json(self): - from acme.challenges import RecoveryTokenResponse - self.assertEqual( - self.msg, RecoveryTokenResponse.from_json(self.jmsg)) - - def test_from_json_hashable(self): - from acme.challenges import RecoveryTokenResponse - hash(RecoveryTokenResponse.from_json(self.jmsg)) - - def test_json_without_optionals(self): - del self.jmsg['token'] - - from acme.challenges import RecoveryTokenResponse - msg = RecoveryTokenResponse.from_json(self.jmsg) - - self.assertTrue(msg.token is None) - self.assertEqual(self.jmsg, msg.to_partial_json()) - - class ProofOfPossessionHintsTest(unittest.TestCase): def setUp(self): diff --git a/acme/acme/client_test.py b/acme/acme/client_test.py index 97e0b9662..28226fe5a 100644 --- a/acme/acme/client_test.py +++ b/acme/acme/client_test.py @@ -44,7 +44,7 @@ class ClientTest(unittest.TestCase): # Registration self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212') reg = messages.Registration( - contact=self.contact, key=KEY.public_key(), recovery_token='t') + contact=self.contact, key=KEY.public_key()) self.new_reg = messages.NewRegistration(**dict(reg)) self.regr = messages.RegistrationResource( body=reg, uri='https://www.letsencrypt-demo.org/acme/reg/1', diff --git a/acme/acme/messages.py b/acme/acme/messages.py index 39594cbda..33157899e 100644 --- a/acme/acme/messages.py +++ b/acme/acme/messages.py @@ -156,7 +156,6 @@ class Registration(ResourceBody): :ivar acme.jose.jwk.JWK key: Public key. :ivar tuple contact: Contact information following ACME spec, `tuple` of `unicode`. - :ivar unicode recovery_token: :ivar unicode agreement: """ @@ -164,7 +163,6 @@ class Registration(ResourceBody): # JWS.signature.combined.jwk key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) contact = jose.Field('contact', omitempty=True, default=()) - recovery_token = jose.Field('recoveryToken', omitempty=True) agreement = jose.Field('agreement', omitempty=True) phone_prefix = 'tel:' diff --git a/acme/acme/messages_test.py b/acme/acme/messages_test.py index 2ed0dd669..810db3e91 100644 --- a/acme/acme/messages_test.py +++ b/acme/acme/messages_test.py @@ -101,18 +101,14 @@ class RegistrationTest(unittest.TestCase): 'mailto:admin@foo.com', 'tel:1234', ) - recovery_token = 'XYZ' agreement = 'https://letsencrypt.org/terms' from acme.messages import Registration - self.reg = Registration( - key=key, contact=contact, recovery_token=recovery_token, - agreement=agreement) + self.reg = Registration(key=key, contact=contact, agreement=agreement) self.reg_none = Registration() self.jobj_to = { 'contact': contact, - 'recoveryToken': recovery_token, 'agreement': agreement, 'key': key, } @@ -232,7 +228,7 @@ class AuthorizationTest(unittest.TestCase): ChallengeBody(uri='http://challb2', status=STATUS_VALID, chall=challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA')), ChallengeBody(uri='http://challb3', status=STATUS_VALID, - chall=challenges.RecoveryToken()), + chall=challenges.RecoveryContact()), ) combinations = ((0, 2), (1, 2)) diff --git a/letsencrypt/account.py b/letsencrypt/account.py index e962c993d..22f625bca 100644 --- a/letsencrypt/account.py +++ b/letsencrypt/account.py @@ -94,15 +94,11 @@ def report_new_account(acc, config): config.config_dir), reporter.MEDIUM_PRIORITY, True) - assert acc.regr.body.recovery_token is not None - recovery_msg = ("If you lose your account credentials, you can recover " - "them using the token \"{0}\". You must write that down " - "and put it in a safe place.".format( - acc.regr.body.recovery_token)) if acc.regr.body.emails: - recovery_msg += (" Another recovery method will be e-mails sent to " - "{0}.".format(", ".join(acc.regr.body.emails))) - reporter.add_message(recovery_msg, reporter.HIGH_PRIORITY, True) + recovery_msg = ("If you lose your account credentials, you can " + "recover through e-mails sent to {0}.".format( + ", ".join(acc.regr.body.emails))) + reporter.add_message(recovery_msg, reporter.HIGH_PRIORITY, True) class AccountMemoryStorage(interfaces.AccountStorage): diff --git a/letsencrypt/achallenges.py b/letsencrypt/achallenges.py index 12720a16b..7bdf4affe 100644 --- a/letsencrypt/achallenges.py +++ b/letsencrypt/achallenges.py @@ -99,12 +99,6 @@ class RecoveryContact(AnnotatedChallenge): acme_type = challenges.RecoveryContact -class RecoveryToken(AnnotatedChallenge): - """Client annotated "recoveryToken" ACME challenge.""" - __slots__ = ('challb', 'domain') - acme_type = challenges.RecoveryToken - - class ProofOfPossession(AnnotatedChallenge): """Client annotated "proofOfPossession" ACME challenge.""" __slots__ = ('challb', 'domain') diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index 9c985b751..0cf1da7e4 100644 --- a/letsencrypt/auth_handler.py +++ b/letsencrypt/auth_handler.py @@ -349,8 +349,6 @@ def challb_to_achall(challb, account, domain): challb=challb, domain=domain, key=account.key) elif isinstance(chall, challenges.DNS): return achallenges.DNS(challb=challb, domain=domain) - elif isinstance(chall, challenges.RecoveryToken): - return achallenges.RecoveryToken(challb=challb, domain=domain) elif isinstance(chall, challenges.RecoveryContact): return achallenges.RecoveryContact( challb=challb, domain=domain) diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index d68b46e52..c7c780535 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -22,7 +22,6 @@ class NamespaceConfig(object): - `cert_key_backup` - `in_progress_dir` - `key_dir` - - `rec_token_dir` - `renewer_config_file` - `temp_checkpoint_dir` @@ -71,11 +70,6 @@ class NamespaceConfig(object): def key_dir(self): # pylint: disable=missing-docstring return os.path.join(self.namespace.config_dir, constants.KEY_DIR) - # TODO: This should probably include the server name - @property - def rec_token_dir(self): # pylint: disable=missing-docstring - return os.path.join(self.namespace.work_dir, constants.REC_TOKEN_DIR) - @property def temp_checkpoint_dir(self): # pylint: disable=missing-docstring return os.path.join( diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py index 07d1965fb..1847abd7c 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -88,10 +88,6 @@ LIVE_DIR = "live" TEMP_CHECKPOINT_DIR = "temp_checkpoint" """Temporary checkpoint directory (relative to `IConfig.work_dir`).""" -REC_TOKEN_DIR = "recovery_tokens" -"""Directory where all recovery tokens are saved (relative to -`IConfig.work_dir`).""" - RENEWAL_CONFIGS_DIR = "configs" """Renewal configs directory, relative to `IConfig.config_dir`.""" diff --git a/letsencrypt/continuity_auth.py b/letsencrypt/continuity_auth.py index 2eb1c22bf..52d0cee8e 100644 --- a/letsencrypt/continuity_auth.py +++ b/letsencrypt/continuity_auth.py @@ -7,16 +7,12 @@ from letsencrypt import achallenges from letsencrypt import errors from letsencrypt import interfaces from letsencrypt import proof_of_possession -from letsencrypt import recovery_token class ContinuityAuthenticator(object): """IAuthenticator for :const:`~acme.challenges.ContinuityChallenge` class challenges. - :ivar rec_token: Performs "recoveryToken" challenges. - :type rec_token: :class:`letsencrypt.recovery_token.RecoveryToken` - :ivar proof_of_pos: Performs "proofOfPossession" challenges. :type proof_of_pos: :class:`letsencrypt.proof_of_possession.Proof_of_Possession` @@ -25,7 +21,7 @@ class ContinuityAuthenticator(object): zope.interface.implements(interfaces.IAuthenticator) # This will have an installer soon for get_key/cert purposes - def __init__(self, config, installer): + def __init__(self, config, installer): # pylint: disable=unused-argument """Initialize Client Authenticator. :param config: Configuration. @@ -35,13 +31,11 @@ class ContinuityAuthenticator(object): :type installer: :class:`letsencrypt.interfaces.IInstaller` """ - self.rec_token = recovery_token.RecoveryToken( - config.server, config.rec_token_dir) self.proof_of_pos = proof_of_possession.ProofOfPossession(installer) def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use """Return list of challenge preferences.""" - return [challenges.ProofOfPossession, challenges.RecoveryToken] + return [challenges.ProofOfPossession] def perform(self, achalls): """Perform client specific challenges for IAuthenticator""" @@ -49,16 +43,12 @@ class ContinuityAuthenticator(object): for achall in achalls: if isinstance(achall, achallenges.ProofOfPossession): responses.append(self.proof_of_pos.perform(achall)) - elif isinstance(achall, achallenges.RecoveryToken): - responses.append(self.rec_token.perform(achall)) else: raise errors.ContAuthError("Unexpected Challenge") return responses - def cleanup(self, achalls): + def cleanup(self, achalls): # pylint: disable=no-self-use """Cleanup call for IAuthenticator.""" for achall in achalls: - if isinstance(achall, achallenges.RecoveryToken): - self.rec_token.cleanup(achall) - elif not isinstance(achall, achallenges.ProofOfPossession): + if not isinstance(achall, achallenges.ProofOfPossession): raise errors.ContAuthError("Unexpected Challenge") diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index b07e64894..359609304 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -206,8 +206,6 @@ class IConfig(zope.interface.Interface): in_progress_dir = zope.interface.Attribute( "Directory used before a permanent checkpoint is finalized.") key_dir = zope.interface.Attribute("Keys storage.") - rec_token_dir = zope.interface.Attribute( - "Directory where all recovery tokens are saved.") temp_checkpoint_dir = zope.interface.Attribute( "Temporary checkpoint directory.") diff --git a/letsencrypt/recovery_token.py b/letsencrypt/recovery_token.py deleted file mode 100644 index c5796d581..000000000 --- a/letsencrypt/recovery_token.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Recovery Token Identifier Validation Challenge.""" -import errno -import os - -import zope.component - -from acme import challenges - -from letsencrypt import le_util -from letsencrypt import interfaces - - -class RecoveryToken(object): - """Recovery Token Identifier Validation Challenge. - - Based on draft-barnes-acme, section 6.4. - - """ - def __init__(self, server, direc): - self.token_dir = os.path.join(direc, server) - - def perform(self, chall): - """Perform the Recovery Token Challenge. - - :param chall: Recovery Token Challenge - :type chall: :class:`letsencrypt.achallenges.RecoveryToken` - - :returns: response - :rtype: dict - - """ - token_fp = os.path.join(self.token_dir, chall.domain) - if os.path.isfile(token_fp): - with open(token_fp) as token_fd: - return challenges.RecoveryTokenResponse(token=token_fd.read()) - - cancel, token = zope.component.getUtility( - interfaces.IDisplay).input( - "%s - Input Recovery Token: " % chall.domain) - if cancel != 1: - return challenges.RecoveryTokenResponse(token=token) - - return None - - def cleanup(self, chall): - """Cleanup the saved recovery token if it exists. - - :param chall: Recovery Token Challenge - :type chall: :class:`letsencrypt.achallenges.RecoveryToken` - - """ - try: - le_util.safely_remove(os.path.join(self.token_dir, chall.domain)) - except OSError as err: - if err.errno != errno.ENOENT: - raise - - def requires_human(self, domain): - """Indicates whether or not domain can be auto solved.""" - return not os.path.isfile(os.path.join(self.token_dir, domain)) - - def store_token(self, domain, token): - """Store token for later automatic use. - - :param str domain: domain associated with the token - :param str token: token from authorization - - """ - le_util.make_or_verify_dir(self.token_dir, 0o700, os.geteuid()) - - with open(os.path.join(self.token_dir, domain), "w") as token_fd: - token_fd.write(str(token)) diff --git a/letsencrypt/tests/account_test.py b/letsencrypt/tests/account_test.py index e19940fe8..cd98e1e20 100644 --- a/letsencrypt/tests/account_test.py +++ b/letsencrypt/tests/account_test.py @@ -63,7 +63,6 @@ class ReportNewAccountTest(unittest.TestCase): def setUp(self): self.config = mock.MagicMock(config_dir="/etc/letsencrypt") reg = messages.Registration.from_data(email="rhino@jungle.io") - reg = reg.update(recovery_token="ECCENTRIC INVISIBILITY RHINOCEROS") self.acc = mock.MagicMock(regr=messages.RegistrationResource( uri=None, new_authzr_uri=None, body=reg)) @@ -81,7 +80,6 @@ class ReportNewAccountTest(unittest.TestCase): self._call() call_list = mock_zope().add_message.call_args_list self.assertTrue(self.config.config_dir in call_list[0][0][0]) - self.assertTrue(self.acc.regr.body.recovery_token in call_list[1][0][0]) self.assertTrue( ", ".join(self.acc.regr.body.emails) in call_list[1][0][0]) diff --git a/letsencrypt/tests/acme_util.py b/letsencrypt/tests/acme_util.py index 6b2cb88eb..33bf605e0 100644 --- a/letsencrypt/tests/acme_util.py +++ b/letsencrypt/tests/acme_util.py @@ -21,7 +21,6 @@ RECOVERY_CONTACT = challenges.RecoveryContact( activation_url="https://example.ca/sendrecovery/a5bd99383fb0", success_url="https://example.ca/confirmrecovery/bb1b9928932", contact="c********n@example.com") -RECOVERY_TOKEN = challenges.RecoveryToken() POP = challenges.ProofOfPossession( alg="RS256", nonce=jose.b64decode("eET5udtV7aoX8Xl8gYiZIA"), hints=challenges.ProofOfPossession.Hints( @@ -42,7 +41,7 @@ POP = challenges.ProofOfPossession( ) ) -CHALLENGES = [SIMPLE_HTTP, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP] +CHALLENGES = [SIMPLE_HTTP, DVSNI, DNS, RECOVERY_CONTACT, POP] DV_CHALLENGES = [chall for chall in CHALLENGES if isinstance(chall, challenges.DVChallenge)] CONT_CHALLENGES = [chall for chall in CHALLENGES @@ -84,11 +83,9 @@ DVSNI_P = chall_to_challb(DVSNI, messages.STATUS_PENDING) SIMPLE_HTTP_P = chall_to_challb(SIMPLE_HTTP, messages.STATUS_PENDING) DNS_P = chall_to_challb(DNS, messages.STATUS_PENDING) RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, messages.STATUS_PENDING) -RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, messages.STATUS_PENDING) POP_P = chall_to_challb(POP, messages.STATUS_PENDING) -CHALLENGES_P = [SIMPLE_HTTP_P, DVSNI_P, DNS_P, - RECOVERY_CONTACT_P, RECOVERY_TOKEN_P, POP_P] +CHALLENGES_P = [SIMPLE_HTTP_P, DVSNI_P, DNS_P, RECOVERY_CONTACT_P, POP_P] DV_CHALLENGES_P = [challb for challb in CHALLENGES_P if isinstance(challb.chall, challenges.DVChallenge)] CONT_CHALLENGES_P = [ diff --git a/letsencrypt/tests/auth_handler_test.py b/letsencrypt/tests/auth_handler_test.py index 204a314a3..4e1ee85e6 100644 --- a/letsencrypt/tests/auth_handler_test.py +++ b/letsencrypt/tests/auth_handler_test.py @@ -19,7 +19,6 @@ TRANSLATE = { "dvsni": "DVSNI", "simpleHttp": "SimpleHTTP", "dns": "DNS", - "recoveryToken": "RecoveryToken", "recoveryContact": "RecoveryContact", "proofOfPossession": "ProofOfPossession", } @@ -41,7 +40,8 @@ class ChallengeFactoryTest(unittest.TestCase): [messages.STATUS_PENDING]*6, False) def test_all(self): - cont_c, dv_c = self.handler._challenge_factory(self.dom, range(0, 6)) + cont_c, dv_c = self.handler._challenge_factory( + self.dom, range(0, len(acme_util.CHALLENGES))) self.assertEqual( [achall.chall for achall in cont_c], acme_util.CONT_CHALLENGES) @@ -49,10 +49,10 @@ class ChallengeFactoryTest(unittest.TestCase): [achall.chall for achall in dv_c], acme_util.DV_CHALLENGES) def test_one_dv_one_cont(self): - cont_c, dv_c = self.handler._challenge_factory(self.dom, [1, 4]) + cont_c, dv_c = self.handler._challenge_factory(self.dom, [1, 3]) self.assertEqual( - [achall.chall for achall in cont_c], [acme_util.RECOVERY_TOKEN]) + [achall.chall for achall in cont_c], [acme_util.RECOVERY_CONTACT]) self.assertEqual([achall.chall for achall in dv_c], [acme_util.DVSNI]) def test_unrecognized(self): @@ -80,7 +80,7 @@ class GetAuthorizationsTest(unittest.TestCase): self.mock_dv_auth.get_chall_pref.return_value = [challenges.DVSNI] self.mock_cont_auth.get_chall_pref.return_value = [ - challenges.RecoveryToken] + challenges.RecoveryContact] self.mock_cont_auth.perform.side_effect = gen_auth_resp self.mock_dv_auth.perform.side_effect = gen_auth_resp @@ -313,11 +313,11 @@ class GenChallengePathTest(unittest.TestCase): self.assertTrue(self._call(challbs[::-1], prefs, None)) def test_common_case_with_continuity(self): - challbs = (acme_util.RECOVERY_TOKEN_P, + challbs = (acme_util.POP_P, acme_util.RECOVERY_CONTACT_P, acme_util.DVSNI_P, acme_util.SIMPLE_HTTP_P) - prefs = [challenges.RecoveryToken, challenges.DVSNI] + prefs = [challenges.ProofOfPossession, challenges.DVSNI] combos = acme_util.gen_combos(challbs) self.assertEqual(self._call(challbs, prefs, combos), (0, 2)) @@ -325,21 +325,19 @@ class GenChallengePathTest(unittest.TestCase): self.assertTrue(self._call(challbs, prefs, None)) def test_full_cont_server(self): - challbs = (acme_util.RECOVERY_TOKEN_P, - acme_util.RECOVERY_CONTACT_P, + challbs = (acme_util.RECOVERY_CONTACT_P, acme_util.POP_P, acme_util.DVSNI_P, acme_util.SIMPLE_HTTP_P, acme_util.DNS_P) # Typical webserver client that can do everything except DNS # Attempted to make the order realistic - prefs = [challenges.RecoveryToken, - challenges.ProofOfPossession, + prefs = [challenges.ProofOfPossession, challenges.SimpleHTTP, challenges.DVSNI, challenges.RecoveryContact] combos = acme_util.gen_combos(challbs) - self.assertEqual(self._call(challbs, prefs, combos), (0, 4)) + self.assertEqual(self._call(challbs, prefs, combos), (1, 3)) # Dumb path trivial test self.assertTrue(self._call(challbs, prefs, None)) diff --git a/letsencrypt/tests/configuration_test.py b/letsencrypt/tests/configuration_test.py index 82e82d520..498147c6d 100644 --- a/letsencrypt/tests/configuration_test.py +++ b/letsencrypt/tests/configuration_test.py @@ -36,7 +36,6 @@ class NamespaceConfigTest(unittest.TestCase): constants.CERT_DIR = 'certs' constants.IN_PROGRESS_DIR = '../p' constants.KEY_DIR = 'keys' - constants.REC_TOKEN_DIR = '/r' constants.TEMP_CHECKPOINT_DIR = 't' self.assertEqual( @@ -47,7 +46,6 @@ class NamespaceConfigTest(unittest.TestCase): self.config.cert_key_backup, '/tmp/foo/c/acme-server.org:443/new') self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p') self.assertEqual(self.config.key_dir, '/tmp/config/keys') - self.assertEqual(self.config.rec_token_dir, '/r') self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t') diff --git a/letsencrypt/tests/continuity_auth_test.py b/letsencrypt/tests/continuity_auth_test.py index a8875ec74..67d27d04d 100644 --- a/letsencrypt/tests/continuity_auth_test.py +++ b/letsencrypt/tests/continuity_auth_test.py @@ -17,44 +17,19 @@ class PerformTest(unittest.TestCase): self.auth = ContinuityAuthenticator( mock.MagicMock(server="demo_server.org"), None) - self.auth.rec_token.perform = mock.MagicMock( - name="rec_token_perform", side_effect=gen_client_resp) self.auth.proof_of_pos.perform = mock.MagicMock( name="proof_of_pos_perform", side_effect=gen_client_resp) - def test_rec_token1(self): - token = achallenges.RecoveryToken(challb=None, domain="0") - responses = self.auth.perform([token]) - self.assertEqual(responses, ["RecoveryToken0"]) - - def test_rec_token5(self): - tokens = [] - for i in xrange(5): - tokens.append(achallenges.RecoveryToken(challb=None, domain=str(i))) - - responses = self.auth.perform(tokens) - - self.assertEqual(len(responses), 5) - for i in xrange(5): - self.assertEqual(responses[i], "RecoveryToken%d" % i) - - def test_pop_and_rec_token(self): + def test_pop(self): achalls = [] for i in xrange(4): - if i % 2 == 0: - achalls.append(achallenges.RecoveryToken(challb=None, - domain=str(i))) - else: - achalls.append(achallenges.ProofOfPossession(challb=None, - domain=str(i))) + achalls.append(achallenges.ProofOfPossession( + challb=None, domain=str(i))) responses = self.auth.perform(achalls) self.assertEqual(len(responses), 4) for i in xrange(4): - if i % 2 == 0: - self.assertEqual(responses[i], "RecoveryToken%d" % i) - else: - self.assertEqual(responses[i], "ProofOfPossession%d" % i) + self.assertEqual(responses[i], "ProofOfPossession%d" % i) def test_unexpected(self): self.assertRaises( @@ -65,7 +40,7 @@ class PerformTest(unittest.TestCase): def test_chall_pref(self): self.assertEqual( self.auth.get_chall_pref("example.com"), - [challenges.ProofOfPossession, challenges.RecoveryToken]) + [challenges.ProofOfPossession]) class CleanupTest(unittest.TestCase): @@ -76,25 +51,11 @@ class CleanupTest(unittest.TestCase): self.auth = ContinuityAuthenticator( mock.MagicMock(server="demo_server.org"), None) - self.mock_cleanup = mock.MagicMock(name="rec_token_cleanup") - self.auth.rec_token.cleanup = self.mock_cleanup - - def test_rec_token2(self): - token1 = achallenges.RecoveryToken(challb=None, domain="0") - token2 = achallenges.RecoveryToken(challb=None, domain="1") - - self.auth.cleanup([token1, token2]) - - self.assertEqual(self.mock_cleanup.call_args_list, - [mock.call(token1), mock.call(token2)]) def test_unexpected(self): - token = achallenges.RecoveryToken(challb=None, domain="0") unexpected = achallenges.DVSNI( challb=None, domain="0", account=mock.Mock("dummy_key")) - - self.assertRaises( - errors.ContAuthError, self.auth.cleanup, [token, unexpected]) + self.assertRaises(errors.ContAuthError, self.auth.cleanup, [unexpected]) def gen_client_resp(chall): diff --git a/letsencrypt/tests/le_util_test.py b/letsencrypt/tests/le_util_test.py index 1ecc1ea16..00867f5b2 100644 --- a/letsencrypt/tests/le_util_test.py +++ b/letsencrypt/tests/le_util_test.py @@ -166,6 +166,37 @@ class UniqueLineageNameTest(unittest.TestCase): self.assertRaises(OSError, self._call, "wow") +class SafelyRemoveTest(unittest.TestCase): + """Tests for letsencrypt.le_util.safely_remove.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.path = os.path.join(self.tmp, "foo") + + def tearDown(self): + shutil.rmtree(self.tmp) + + def _call(self): + from letsencrypt.le_util import safely_remove + return safely_remove(self.path) + + def test_exists(self): + with open(self.path, "w"): + pass # just create the file + self._call() + self.assertFalse(os.path.exists(self.path)) + + def test_missing(self): + self._call() + # no error, yay! + self.assertFalse(os.path.exists(self.path)) + + @mock.patch("letsencrypt.le_util.os.remove") + def test_other_error_passthrough(self, mock_remove): + mock_remove.side_effect = OSError + self.assertRaises(OSError, self._call) + + class SafeEmailTest(unittest.TestCase): """Test safe_email.""" @classmethod diff --git a/letsencrypt/tests/recovery_token_test.py b/letsencrypt/tests/recovery_token_test.py deleted file mode 100644 index 8e767d3cf..000000000 --- a/letsencrypt/tests/recovery_token_test.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Tests for recovery_token.py.""" -import os -import unittest -import shutil -import tempfile - -import mock - -from acme import challenges - -from letsencrypt import achallenges - - -class RecoveryTokenTest(unittest.TestCase): - def setUp(self): - from letsencrypt.recovery_token import RecoveryToken - server = "demo_server" - self.base_dir = tempfile.mkdtemp("tokens") - self.token_dir = os.path.join(self.base_dir, server) - self.rec_token = RecoveryToken(server, self.base_dir) - - def tearDown(self): - shutil.rmtree(self.base_dir) - - def test_store_token(self): - self.rec_token.store_token("example.com", 111) - path = os.path.join(self.token_dir, "example.com") - self.assertTrue(os.path.isfile(path)) - with open(path) as token_fd: - self.assertEqual(token_fd.read(), "111") - - def test_requires_human(self): - self.rec_token.store_token("example2.com", 222) - self.assertFalse(self.rec_token.requires_human("example2.com")) - self.assertTrue(self.rec_token.requires_human("example3.com")) - - def test_cleanup(self): - self.rec_token.store_token("example3.com", 333) - self.assertFalse(self.rec_token.requires_human("example3.com")) - - self.rec_token.cleanup(achallenges.RecoveryToken( - challb=challenges.RecoveryToken(), domain="example3.com")) - self.assertTrue(self.rec_token.requires_human("example3.com")) - - # Shouldn't throw an error - self.rec_token.cleanup(achallenges.RecoveryToken( - challb=None, domain="example4.com")) - - # SHOULD throw an error (OSError other than nonexistent file) - self.assertRaises( - OSError, self.rec_token.cleanup, - achallenges.RecoveryToken( - challb=None, domain=("a" + "r" * 10000 + ".com"))) - - def test_perform_stored(self): - self.rec_token.store_token("example4.com", 444) - response = self.rec_token.perform( - achallenges.RecoveryToken( - challb=challenges.RecoveryToken(), domain="example4.com")) - - self.assertEqual( - response, challenges.RecoveryTokenResponse(token="444")) - - @mock.patch("letsencrypt.recovery_token.zope.component.getUtility") - def test_perform_not_stored(self, mock_input): - mock_input().input.side_effect = [(0, "555"), (1, "000")] - response = self.rec_token.perform( - achallenges.RecoveryToken( - challb=challenges.RecoveryToken(), domain="example5.com")) - self.assertEqual( - response, challenges.RecoveryTokenResponse(token="555")) - - response = self.rec_token.perform( - achallenges.RecoveryToken( - challb=challenges.RecoveryToken(), domain="example6.com")) - self.assertTrue(response is None) - - -if __name__ == "__main__": - unittest.main() # pragma: no cover From de3b48640b5c537d3ad2440618598a92164f9a62 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 25 Jul 2015 15:48:14 +0000 Subject: [PATCH 05/13] Doc fixes. --- acme/acme/challenges.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 41ede797e..a9cae6a41 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -179,7 +179,7 @@ class DVSNI(DVChallenge): """Generate response. :param .JWK account_key: Private account key. - :rtype: .JWS + :rtype: .DVSNIResponse """ return DVSNIResponse(validation=jose.JWS.sign( @@ -287,9 +287,6 @@ class DVSNIResponse(ChallengeResponse): :param .challenges.DVSNI chall: Corresponding challenge. :param str domain: Domain name being validated. - :param public_key: Public key for the key pair - being authorized. If ``None`` key verification is not - performed! :type account_public_key: `~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey` or From a55991055ef16cb621e160d89bade402bb8de1e2 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 25 Jul 2015 18:17:53 +0000 Subject: [PATCH 06/13] Human meaningful exception message for decoding fields with minimum length. --- acme/acme/jose/json_util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/acme/acme/jose/json_util.py b/acme/acme/jose/json_util.py index 0608d3906..51d55ebd9 100644 --- a/acme/acme/jose/json_util.py +++ b/acme/acme/jose/json_util.py @@ -326,7 +326,8 @@ def decode_b64jose(data, size=None, minimum=False): if size is not None and ((not minimum and len(decoded) != size) or (minimum and len(decoded) < size)): - raise errors.DeserializationError() + raise errors.DeserializationError( + "Expected at least or exactly {0} bytes".format(size)) return decoded From ca5823ffd8e422b535a9f8c96789f0ad134977c7 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 29 Jul 2015 20:58:54 +0000 Subject: [PATCH 07/13] acme: progress with v03 Simple HTTP challenge. --- acme/acme/challenges.py | 106 ++++++++++++++++++++++++++++++----- acme/acme/challenges_test.py | 76 +++++++++++++++++++------ acme/acme/fields.py | 25 +++++++++ acme/acme/fields_test.py | 20 +++++++ 4 files changed, 197 insertions(+), 30 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index a9cae6a41..abcbf6d1d 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -50,7 +50,13 @@ class SimpleHTTP(DVChallenge): """ typ = "simpleHttp" - token = jose.Field("token") + + TOKEN_SIZE = 128 / 8 # Based on the entropy value from the spec + """Minimum size of the :attr:`token` in bytes.""" + + token = jose.Field( + "token", encoder=jose.encode_b64jose, decoder=functools.partial( + jose.decode_b64jose, size=TOKEN_SIZE, minimum=True)) @ChallengeResponse.register @@ -73,7 +79,7 @@ class SimpleHTTPResponse(ChallengeResponse): MAX_PATH_LEN = 25 """Maximum allowed `path` length.""" - CONTENT_TYPE = "text/plain" + CONTENT_TYPE = "application/jose+json" @property def good_path(self): @@ -110,7 +116,62 @@ class SimpleHTTPResponse(ChallengeResponse): return self._URI_TEMPLATE.format( scheme=self.scheme, domain=domain, path=self.path) - def simple_verify(self, chall, domain, port=None): + def gen_resource(self, chall): + """Generate provisioned resource. + + :param .SimpleHTTP chall: + :rtype: SimpleHTTPProvisionedResource + + """ + return SimpleHTTPProvisionedResource( + token=chall.token, path=self.path, tls=self.tls) + + def gen_validation(self, chall, account_key, alg=jose.RS256, **kwargs): + """Generate validation. + + :param .SimpleHTTP chall: + :param .JWK account_key: Private account key. + :param .JWA alg: + + :returns: `.SimpleHTTPProvisionedResource` signed in `.JWS` + :rtype: .JWS + + """ + return jose.JWS.sign( + payload=self.gen_resource(chall).json_dumps().encode('utf-8'), + key=account_key, alg=alg, **kwargs) + + def check_validation(self, validation, chall, account_public_key): + """Check validation. + + :param .JWS validation: + :param .SimpleHTTP chall: + :type account_public_key: + `~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey` + or + `~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey` + or + `~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey` + wrapped in `.ComparableKey + + :rtype: bool + + """ + if not validation.verify(key=account_public_key): + return False + + try: + resource = SimpleHTTPProvisionedResource.json_loads( + validation.payload.decode('utf-8')) + except jose.DeserializationError as error: + logger.debug(error) + return False + + return (resource.token == chall.token and + resource.path == self.path and + resource.tls == self.tls) + + def simple_verify(self, chall, domain, account_public_key, port=None): """Simple verify. According to the ACME specification, "the ACME server MUST @@ -119,6 +180,16 @@ class SimpleHTTPResponse(ChallengeResponse): :param .SimpleHTTP chall: Corresponding challenge. :param unicode domain: Domain name being verified. + :param account_public_key: Public key for the key pair + being authorized. If ``None`` key verification is not + performed! + :type account_public_key: + `~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey` + or + `~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey` + or + `~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey` + wrapped in `.ComparableKey :param int port: Port used in the validation. :returns: ``True`` iff validation is successful, ``False`` @@ -144,16 +215,25 @@ class SimpleHTTPResponse(ChallengeResponse): logger.debug( "Received %s. Headers: %s", http_response, http_response.headers) - good_token = http_response.text == chall.token - if not good_token: - logger.error( - "Unable to verify %s! Expected: %r, returned: %r.", - uri, chall.token, http_response.text) - # TODO: spec contradicts itself, c.f. - # https://github.com/letsencrypt/acme-spec/pull/156/files#r33136438 - good_ct = self.CONTENT_TYPE == http_response.headers.get( - "Content-Type", self.CONTENT_TYPE) - return self.good_path and good_ct and good_token + if self.CONTENT_TYPE != http_response.headers.get( + "Content-Type", self.CONTENT_TYPE): + return False + + try: + validation = jose.JWS.json_loads(http_response.text) + except jose.DeserializationError as error: + logger.debug(error) + return False + + return self.check_validation(validation, chall, account_public_key) + + +class SimpleHTTPProvisionedResource(jose.JSONObjectWithFields): + """SimpleHTTP provisioned resource.""" + typ = fields.Fixed("type", SimpleHTTP.typ) + token = SimpleHTTP._fields["token"] + path = SimpleHTTPResponse._fields["path"] + tls = SimpleHTTPResponse._fields["tls"] @Challenge.register diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index a9d557e8d..dd9760903 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -22,10 +22,11 @@ class SimpleHTTPTest(unittest.TestCase): def setUp(self): from acme.challenges import SimpleHTTP self.msg = SimpleHTTP( - token='evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA') + token=jose.decode_b64jose( + 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA')) self.jmsg = { 'type': 'simpleHttp', - 'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA', + 'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA', } def test_to_partial_json(self): @@ -62,11 +63,27 @@ class SimpleHTTPResponseTest(unittest.TestCase): } from acme.challenges import SimpleHTTP - self.chall = SimpleHTTP(token="foo") + self.chall = SimpleHTTP(token=(r"x" * 16)) self.resp_http = SimpleHTTPResponse(path="bar", tls=False) self.resp_https = SimpleHTTPResponse(path="bar", tls=True) self.good_headers = {'Content-Type': SimpleHTTPResponse.CONTENT_TYPE} + def test_to_partial_json(self): + self.assertEqual(self.jmsg_http, self.msg_http.to_partial_json()) + self.assertEqual(self.jmsg_https, self.msg_https.to_partial_json()) + + def test_from_json(self): + from acme.challenges import SimpleHTTPResponse + self.assertEqual( + self.msg_http, SimpleHTTPResponse.from_json(self.jmsg_http)) + self.assertEqual( + self.msg_https, SimpleHTTPResponse.from_json(self.jmsg_https)) + + def test_from_json_hashable(self): + from acme.challenges import SimpleHTTPResponse + hash(SimpleHTTPResponse.from_json(self.jmsg_http)) + hash(SimpleHTTPResponse.from_json(self.jmsg_https)) + def test_good_path(self): self.assertTrue(self.msg_http.good_path) self.assertTrue(self.msg_https.good_path) @@ -89,21 +106,45 @@ class SimpleHTTPResponseTest(unittest.TestCase): 'https://example.com/.well-known/acme-challenge/' '6tbIMBC5Anhl5bOlWT5ZFA', self.msg_https.uri('example.com')) - def test_to_partial_json(self): - self.assertEqual(self.jmsg_http, self.msg_http.to_partial_json()) - self.assertEqual(self.jmsg_https, self.msg_https.to_partial_json()) + def test_gen_check_validation(self): + account_key = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem')) + self.assertTrue(self.resp_http.check_validation( + validation=self.resp_http.gen_validation(self.chall, account_key), + chall=self.chall, account_public_key=account_key.public_key())) - def test_from_json(self): - from acme.challenges import SimpleHTTPResponse - self.assertEqual( - self.msg_http, SimpleHTTPResponse.from_json(self.jmsg_http)) - self.assertEqual( - self.msg_https, SimpleHTTPResponse.from_json(self.jmsg_https)) + def test_gen_check_validation_wrong_key(self): + key1 = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem')) + key2 = jose.JWKRSA.load(test_util.load_vector('rsa1024_key.pem')) + self.assertFalse(self.resp_http.check_validation( + validation=self.resp_http.gen_validation(self.chall, key1), + chall=self.chall, account_public_key=key2.public_key())) - def test_from_json_hashable(self): - from acme.challenges import SimpleHTTPResponse - hash(SimpleHTTPResponse.from_json(self.jmsg_http)) - hash(SimpleHTTPResponse.from_json(self.jmsg_https)) + def test_check_validation_wrong_payload(self): + account_key = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem')) + validations = tuple( + jose.JWS.sign(payload=payload, alg=jose.RS256, key=account_key) + for payload in (b'', b'{}', self.chall.json_dumps().encode('utf-8'), + self.resp_http.json_dumps().encode('utf-8')) + ) + for validation in validations: + self.assertFalse(self.resp_http.check_validation( + validation=validation, chall=self.chall, + account_public_key=account_key.public_key())) + + def test_check_validation_wrong_fields(self): + resource = self.resp_http.gen_resource(self.chall) + account_key = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem')) + validations = tuple( + jose.JWS.sign(payload=bad_resource.json_dumps().encode('utf-8'), + alg=jose.RS256, key=account_key) + for bad_resource in (resource.update(tls=True), + resource.update(path='xxx'), + resource.update(token=r'x'*20)) + ) + for validation in validations: + self.assertFalse(self.resp_http.check_validation( + validation=validation, chall=self.chall, + account_public_key=account_key.public_key())) @mock.patch("acme.challenges.requests.get") def test_simple_verify_good_token(self, mock_get): @@ -132,7 +173,8 @@ 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.resp_http.simple_verify( + self.chall, domain="local", account_public_key=None, port=4430) self.assertEqual("local:4430", urllib_parse.urlparse( mock_get.mock_calls[0][1][0]).netloc) diff --git a/acme/acme/fields.py b/acme/acme/fields.py index 5c60b05b6..1e4d3a822 100644 --- a/acme/acme/fields.py +++ b/acme/acme/fields.py @@ -1,9 +1,34 @@ """ACME JSON fields.""" +import logging + import pyrfc3339 from acme import jose +logger = logging.getLogger(__name__) + + +class Fixed(jose.Field): + """Fixed field.""" + + def __init__(self, json_name, value): + self.value = value + super(Fixed, self).__init__( + json_name=json_name, default=value, omitempty=False) + + def decode(self, value): + if value != self.value: + raise jose.DeserializationError('Expected {0!r}'.format(self.value)) + return self.value + + def encode(self, value): + if value != self.value: + logger.warn('Overriding fixed field ({0}) with {1}'.format( + self.json_name, value)) + return value + + class RFC3339Field(jose.Field): """RFC3339 field encoder/decoder. diff --git a/acme/acme/fields_test.py b/acme/acme/fields_test.py index e95c450ce..de852b6fa 100644 --- a/acme/acme/fields_test.py +++ b/acme/acme/fields_test.py @@ -7,6 +7,26 @@ import pytz from acme import jose +class FixedTest(unittest.TestCase): + """Tests for acme.fields.Fixed.""" + + def setUp(self): + from acme.fields import Fixed + self.field = Fixed('name', 'x') + + def test_decode(self): + self.assertEqual('x', self.field.decode('x')) + + def test_decode_bad(self): + self.assertRaises(jose.DeserializationError, self.field.decode, 'y') + + def test_encode(self): + self.assertEqual('x', self.field.encode('x')) + + def test_encode_override(self): + self.assertEqual('y', self.field.encode('y')) + + class RFC3339FieldTest(unittest.TestCase): """Tests for acme.fields.RFC3339Field.""" From ceed8a71c10c23876d480712dccb3baccd974674 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 31 Jul 2015 21:19:07 +0000 Subject: [PATCH 08/13] DeserializationError: more meaningful message --- acme/acme/jose/errors.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/acme/acme/jose/errors.py b/acme/acme/jose/errors.py index 74708c4a4..74c9443e1 100644 --- a/acme/acme/jose/errors.py +++ b/acme/acme/jose/errors.py @@ -8,6 +8,10 @@ class Error(Exception): class DeserializationError(Error): """JSON deserialization error.""" + def __str__(self): + return "Deserialization error: {0}".format( + super(DeserializationError, self).__str__()) + class SerializationError(Error): """JSON serialization error.""" From 57110f4c18dde3dad287ea9de0552d026b06739a Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 31 Jul 2015 21:30:08 +0000 Subject: [PATCH 09/13] acme: simplehttp v04 --- acme/acme/challenges.py | 59 +++++++++++++++++------------------- acme/acme/challenges_test.py | 20 ++++++------ 2 files changed, 36 insertions(+), 43 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index abcbf6d1d..40de57812 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -54,46 +54,42 @@ class SimpleHTTP(DVChallenge): TOKEN_SIZE = 128 / 8 # Based on the entropy value from the spec """Minimum size of the :attr:`token` in bytes.""" + # TODO: acme-spec doesn't specify token as base64-encoded value token = jose.Field( "token", encoder=jose.encode_b64jose, decoder=functools.partial( jose.decode_b64jose, size=TOKEN_SIZE, minimum=True)) + @property + def good_token(self): # XXX: @token.decoder + """Is `token` good? + + .. todo:: acme-spec wants "It MUST NOT contain any non-ASCII + characters", but it should also warrant that it doesn't + contain ".." or "/"... + + """ + # TODO: check that path combined with uri does not go above + # URI_ROOT_PATH! + return '..' not in self.token and '/' not in self.token + @ChallengeResponse.register class SimpleHTTPResponse(ChallengeResponse): """ACME "simpleHttp" challenge response. - :ivar unicode path: - :ivar unicode tls: + :ivar bool tls: """ typ = "simpleHttp" - path = jose.Field("path") tls = jose.Field("tls", default=True, omitempty=True) URI_ROOT_PATH = ".well-known/acme-challenge" """URI root path for the server provisioned resource.""" - _URI_TEMPLATE = "{scheme}://{domain}/" + URI_ROOT_PATH + "/{path}" - - MAX_PATH_LEN = 25 - """Maximum allowed `path` length.""" + _URI_TEMPLATE = "{scheme}://{domain}/" + URI_ROOT_PATH + "/{token}" CONTENT_TYPE = "application/jose+json" - @property - def good_path(self): - """Is `path` good? - - .. todo:: acme-spec: "The value MUST be comprised entirely of - characters from the URL-safe alphabet for Base64 encoding - [RFC4648]", base64.b64decode ignores those characters - - """ - # TODO: check that path combined with uri does not go above - # URI_ROOT_PATH! - return len(self.path) <= 25 - @property def scheme(self): """URL scheme for the provisioned resource.""" @@ -104,17 +100,18 @@ class SimpleHTTPResponse(ChallengeResponse): """Port that the ACME client should be listening for validation.""" return 443 if self.tls else 80 - def uri(self, domain): + def uri(self, domain, chall): """Create an URI to the provisioned resource. Forms an URI to the HTTPS server provisioned resource (containing :attr:`~SimpleHTTP.token`). :param unicode domain: Domain name being verified. + :param challenges.SimpleHTTP chall: """ return self._URI_TEMPLATE.format( - scheme=self.scheme, domain=domain, path=self.path) + scheme=self.scheme, domain=domain, token=chall.encode("token")) def gen_resource(self, chall): """Generate provisioned resource. @@ -123,8 +120,7 @@ class SimpleHTTPResponse(ChallengeResponse): :rtype: SimpleHTTPProvisionedResource """ - return SimpleHTTPProvisionedResource( - token=chall.token, path=self.path, tls=self.tls) + return SimpleHTTPProvisionedResource(token=chall.token, tls=self.tls) def gen_validation(self, chall, account_key, alg=jose.RS256, **kwargs): """Generate validation. @@ -167,9 +163,7 @@ class SimpleHTTPResponse(ChallengeResponse): logger.debug(error) return False - return (resource.token == chall.token and - resource.path == self.path and - resource.tls == self.tls) + return resource.token == chall.token and resource.tls == self.tls def simple_verify(self, chall, domain, account_public_key, port=None): """Simple verify. @@ -205,15 +199,15 @@ class SimpleHTTPResponse(ChallengeResponse): "Using non-standard port for SimpleHTTP verification: %s", port) domain += ":{0}".format(port) - uri = self.uri(domain) + uri = self.uri(domain, chall) logger.debug("Verifying %s at %s...", chall.typ, uri) try: http_response = requests.get(uri, verify=False) except requests.exceptions.RequestException as error: logger.error("Unable to reach %s: %s", uri, error) return False - logger.debug( - "Received %s. Headers: %s", http_response, http_response.headers) + logger.debug("Received %s: %s. Headers: %s", http_response, + http_response.text, http_response.headers) if self.CONTENT_TYPE != http_response.headers.get( "Content-Type", self.CONTENT_TYPE): @@ -232,8 +226,9 @@ class SimpleHTTPProvisionedResource(jose.JSONObjectWithFields): """SimpleHTTP provisioned resource.""" typ = fields.Fixed("type", SimpleHTTP.typ) token = SimpleHTTP._fields["token"] - path = SimpleHTTPResponse._fields["path"] - tls = SimpleHTTPResponse._fields["tls"] + # If the "tls" field is not included in the response, then + # validation object MUST have its "tls" field set to "true". + tls = jose.Field("tls", omitempty=False) @Challenge.register diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index dd9760903..293e62bfe 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -46,26 +46,23 @@ class SimpleHTTPResponseTest(unittest.TestCase): def setUp(self): from acme.challenges import SimpleHTTPResponse - self.msg_http = SimpleHTTPResponse( - path='6tbIMBC5Anhl5bOlWT5ZFA', tls=False) - self.msg_https = SimpleHTTPResponse(path='6tbIMBC5Anhl5bOlWT5ZFA') + self.msg_http = SimpleHTTPResponse(tls=False) + self.msg_https = SimpleHTTPResponse(tls=True) self.jmsg_http = { 'resource': 'challenge', 'type': 'simpleHttp', - 'path': '6tbIMBC5Anhl5bOlWT5ZFA', 'tls': False, } self.jmsg_https = { 'resource': 'challenge', 'type': 'simpleHttp', - 'path': '6tbIMBC5Anhl5bOlWT5ZFA', 'tls': True, } from acme.challenges import SimpleHTTP - self.chall = SimpleHTTP(token=(r"x" * 16)) - self.resp_http = SimpleHTTPResponse(path="bar", tls=False) - self.resp_https = SimpleHTTPResponse(path="bar", tls=True) + self.chall = SimpleHTTP(token=(b"x" * 16)) + self.resp_http = SimpleHTTPResponse(tls=False) + self.resp_https = SimpleHTTPResponse(tls=True) self.good_headers = {'Content-Type': SimpleHTTPResponse.CONTENT_TYPE} def test_to_partial_json(self): @@ -101,10 +98,12 @@ class SimpleHTTPResponseTest(unittest.TestCase): def test_uri(self): self.assertEqual( 'http://example.com/.well-known/acme-challenge/' - '6tbIMBC5Anhl5bOlWT5ZFA', self.msg_http.uri('example.com')) + 'eHh4eHh4eHh4eHh4eHh4eA', self.msg_http.uri( + 'example.com', self.chall)) self.assertEqual( 'https://example.com/.well-known/acme-challenge/' - '6tbIMBC5Anhl5bOlWT5ZFA', self.msg_https.uri('example.com')) + 'eHh4eHh4eHh4eHh4eHh4eA', self.msg_https.uri( + 'example.com', self.chall)) def test_gen_check_validation(self): account_key = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem')) @@ -138,7 +137,6 @@ class SimpleHTTPResponseTest(unittest.TestCase): jose.JWS.sign(payload=bad_resource.json_dumps().encode('utf-8'), alg=jose.RS256, key=account_key) for bad_resource in (resource.update(tls=True), - resource.update(path='xxx'), resource.update(token=r'x'*20)) ) for validation in validations: From bac5a564dbeaa2e087aa794f87db0618590548c8 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 31 Jul 2015 21:31:58 +0000 Subject: [PATCH 10/13] Passing core/Boulder@370d296 integration testing. --- letsencrypt/achallenges.py | 15 ++++++++++++++- letsencrypt/auth_handler.py | 2 +- letsencrypt/plugins/manual.py | 14 ++++++++------ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/letsencrypt/achallenges.py b/letsencrypt/achallenges.py index 7bdf4affe..25874d4c1 100644 --- a/letsencrypt/achallenges.py +++ b/letsencrypt/achallenges.py @@ -17,12 +17,18 @@ Note, that all annotated challenges act as a proxy objects:: achall.token == challb.token """ +import logging +import os + import OpenSSL from acme import challenges from acme import jose +logger = logging.getLogger(__name__) + + # pylint: disable=too-few-public-methods @@ -83,9 +89,16 @@ class DVSNI(AnnotatedChallenge): class SimpleHTTP(AnnotatedChallenge): """Client annotated "simpleHttp" ACME challenge.""" - __slots__ = ('challb', 'domain', 'key') + __slots__ = ('challb', 'domain', 'account') acme_type = challenges.SimpleHTTP + def gen_response_and_validation(self, tls): + response = challenges.SimpleHTTPResponse(tls=tls) + + validation = response.gen_validation(self.chall, self.account.key) + logger.debug("Simple HTTP validation payload: %s", validation.payload) + return response, validation + class DNS(AnnotatedChallenge): """Client annotated "dns" ACME challenge.""" diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index 709e8b7b8..a8e720ce3 100644 --- a/letsencrypt/auth_handler.py +++ b/letsencrypt/auth_handler.py @@ -351,7 +351,7 @@ def challb_to_achall(challb, account, domain): challb=challb, domain=domain, account=account) elif isinstance(chall, challenges.SimpleHTTP): return achallenges.SimpleHTTP( - challb=challb, domain=domain, key=account.key) + challb=challb, domain=domain, account=account) elif isinstance(chall, challenges.DNS): return achallenges.DNS(challb=challb, domain=domain) elif isinstance(chall, challenges.RecoveryContact): diff --git a/letsencrypt/plugins/manual.py b/letsencrypt/plugins/manual.py index 83f2c0f70..29081743a 100644 --- a/letsencrypt/plugins/manual.py +++ b/letsencrypt/plugins/manual.py @@ -1,6 +1,7 @@ """Manual plugin.""" import os import logging +import pipes import shutil import signal import subprocess @@ -55,7 +56,7 @@ command on the target server (as root): HTTP_TEMPLATE = """\ mkdir -p {root}/public_html/{response.URI_ROOT_PATH} cd {root}/public_html -echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path} +echo -n {validation} > {response.URI_ROOT_PATH}/{encoded_token} # run only once per server: python -c "import BaseHTTPServer, SimpleHTTPServer; \\ SimpleHTTPServer.SimpleHTTPRequestHandler.extensions_map = {{'': '{ct}'}}; \\ @@ -67,7 +68,7 @@ s.serve_forever()" """ HTTPS_TEMPLATE = """\ mkdir -p {root}/public_html/{response.URI_ROOT_PATH} cd {root}/public_html -echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path} +echo -n {validation} > {response.URI_ROOT_PATH}/{encoded_token} # run only once per server: openssl req -new -newkey rsa:4096 -subj "/" -days 1 -nodes -x509 -keyout ../key.pem -out ../cert.pem python -c "import BaseHTTPServer, SimpleHTTPServer, ssl; \\ @@ -124,13 +125,13 @@ binary for temporary key/certificate generation.""".replace("\n", "") # same path for each challenge response would be easier for # users, but will not work if multiple domains point at the # same server: default command doesn't support virtual hosts - response = challenges.SimpleHTTPResponse( - path=jose.b64encode(os.urandom(18)), + response, validation = achall.gen_response_and_validation( tls=(not self.config.no_simple_http_tls)) - assert response.good_path # is encoded os.urandom(18) good? command = self.template.format( root=self._root, achall=achall, response=response, + validation=pipes.quote(validation.json_dumps()), + encoded_token=achall.chall.encode("token"), ct=response.CONTENT_TYPE, port=( response.port if self.config.simple_http_port is None else self.config.simple_http_port)) @@ -161,7 +162,8 @@ binary for temporary key/certificate generation.""".replace("\n", "") command=command)) if response.simple_verify( - achall.challb, achall.domain, self.config.simple_http_port): + achall.chall, achall.domain, + achall.account.key.public_key(), self.config.simple_http_port): return response else: if self.conf("test-mode") and self._httpd.poll() is not None: From 68d34391dd31c688880998f2a81d85dfef06c478 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 31 Jul 2015 22:15:56 +0000 Subject: [PATCH 11/13] Fix test_good_token --- acme/acme/challenges_test.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index 293e62bfe..3e92998a3 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -40,6 +40,11 @@ class SimpleHTTPTest(unittest.TestCase): from acme.challenges import SimpleHTTP hash(SimpleHTTP.from_json(self.jmsg)) + def test_good_token(self): + self.assertTrue(self.msg.good_token) + self.assertFalse( + self.msg.update(token=b'..').good_token) + class SimpleHTTPResponseTest(unittest.TestCase): # pylint: disable=too-many-instance-attributes @@ -81,12 +86,6 @@ class SimpleHTTPResponseTest(unittest.TestCase): hash(SimpleHTTPResponse.from_json(self.jmsg_http)) hash(SimpleHTTPResponse.from_json(self.jmsg_https)) - def test_good_path(self): - self.assertTrue(self.msg_http.good_path) - self.assertTrue(self.msg_https.good_path) - self.assertFalse( - self.msg_http.update(path=(self.msg_http.path * 10)).good_path) - def test_scheme(self): self.assertEqual('http', self.msg_http.scheme) self.assertEqual('https', self.msg_https.scheme) From eacf658003ab5c70e3ddd01f7e9fff5aec93e422 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 31 Jul 2015 22:45:48 +0000 Subject: [PATCH 12/13] py3 compat --- acme/acme/challenges.py | 7 ++++--- acme/acme/challenges_test.py | 6 +++--- acme/acme/messages_test.py | 5 +++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 40de57812..f32783830 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -70,7 +70,7 @@ class SimpleHTTP(DVChallenge): """ # TODO: check that path combined with uri does not go above # URI_ROOT_PATH! - return '..' not in self.token and '/' not in self.token + return b'..' not in self.token and b'/' not in self.token @ChallengeResponse.register @@ -134,7 +134,8 @@ class SimpleHTTPResponse(ChallengeResponse): """ return jose.JWS.sign( - payload=self.gen_resource(chall).json_dumps().encode('utf-8'), + payload=self.gen_resource(chall).json_dumps( + sort_keys=True).encode('utf-8'), key=account_key, alg=alg, **kwargs) def check_validation(self, validation, chall, account_public_key): @@ -258,7 +259,7 @@ class DVSNI(DVChallenge): """ return DVSNIResponse(validation=jose.JWS.sign( - payload=self.json_dumps().encode('utf-8'), + payload=self.json_dumps(sort_keys=True).encode('utf-8'), key=account_key, alg=alg, **kwargs)) diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index 3e92998a3..61cca498c 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -136,7 +136,7 @@ class SimpleHTTPResponseTest(unittest.TestCase): jose.JWS.sign(payload=bad_resource.json_dumps().encode('utf-8'), alg=jose.RS256, key=account_key) for bad_resource in (resource.update(tls=True), - resource.update(token=r'x'*20)) + resource.update(token=b'x'*20)) ) for validation in validations: self.assertFalse(self.resp_http.check_validation( @@ -219,11 +219,11 @@ class DVSNIResponseTest(unittest.TestCase): from acme.challenges import DVSNI self.chall = DVSNI( - token=jose.b64decode('a82d5ff8ef740d12881f6d3c2277ab2e')) + token=jose.b64decode(b'a82d5ff8ef740d12881f6d3c2277ab2e')) from acme.challenges import DVSNIResponse self.validation = jose.JWS.sign( - payload=self.chall.json_dumps().encode(), + payload=self.chall.json_dumps(sort_keys=True).encode(), key=self.key, alg=jose.RS256) self.msg = DVSNIResponse(validation=self.validation) self.jmsg_to = { diff --git a/acme/acme/messages_test.py b/acme/acme/messages_test.py index 810db3e91..051db9ae9 100644 --- a/acme/acme/messages_test.py +++ b/acme/acme/messages_test.py @@ -224,9 +224,10 @@ class AuthorizationTest(unittest.TestCase): self.challbs = ( ChallengeBody( uri='http://challb1', status=STATUS_VALID, - chall=challenges.SimpleHTTP(token='IlirfxKKXAsHtmzK29Pj8A')), + chall=challenges.SimpleHTTP(token=b'IlirfxKKXAsHtmzK29Pj8A')), ChallengeBody(uri='http://challb2', status=STATUS_VALID, - chall=challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA')), + chall=challenges.DNS( + token=b'DGyRejmCefe7v4NfDGDKfA')), ChallengeBody(uri='http://challb3', status=STATUS_VALID, chall=challenges.RecoveryContact()), ) From f96f059288cbe63403d011ca037dadc775fdc2e7 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 31 Jul 2015 22:54:25 +0000 Subject: [PATCH 13/13] Remove nonce_domain remaints from core. --- letsencrypt/plugins/common.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py index c781fbe28..2c368af45 100644 --- a/letsencrypt/plugins/common.py +++ b/letsencrypt/plugins/common.py @@ -155,13 +155,13 @@ class Dvsni(object): :rtype: str """ - return os.path.join( - self.configurator.config.work_dir, achall.nonce_domain + ".crt") + return os.path.join(self.configurator.config.work_dir, + achall.chall.encode("token") + ".crt") def get_key_path(self, achall): """Get standardized path to challenge key.""" - return os.path.join( - self.configurator.config.work_dir, achall.nonce_domain + '.pem') + return os.path.join(self.configurator.config.work_dir, + achall.chall.encode("token") + '.pem') def _setup_challenge_cert(self, achall, s=None): # pylint: disable=invalid-name