diff --git a/.pylintrc b/.pylintrc
index 2970f2bc9..fe4d471ac 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -192,7 +192,7 @@ additional-builtins=
[SIMILARITIES]
# Minimum lines number of a similarity.
-min-similarity-lines=4
+min-similarity-lines=6
# Ignore comments when computing similarities.
ignore-comments=yes
diff --git a/docs/api/acme.rst b/docs/api/acme.rst
new file mode 100644
index 000000000..04c33917a
--- /dev/null
+++ b/docs/api/acme.rst
@@ -0,0 +1,51 @@
+:mod:`letsencrypt.acme`
+=======================
+
+.. automodule:: letsencrypt.acme
+ :members:
+
+
+Interfaces
+----------
+
+.. automodule:: letsencrypt.acme.interfaces
+ :members:
+
+Messages
+--------
+
+.. automodule:: letsencrypt.acme.messages
+ :members:
+
+
+Challenges
+----------
+
+.. automodule:: letsencrypt.acme.challenges
+ :members:
+
+
+Other ACME objects
+------------------
+.. automodule:: letsencrypt.acme.other
+ :members:
+
+
+Errors
+------
+
+.. automodule:: letsencrypt.acme.errors
+ :members:
+
+
+ :members:
+
+
+Utilities
+---------
+
+.. automodule:: letsencrypt.acme.util
+ :members:
+
+.. automodule:: letsencrypt.acme.jose
+ :members:
diff --git a/docs/api/acme/errors.rst b/docs/api/acme/errors.rst
deleted file mode 100644
index 53132bd15..000000000
--- a/docs/api/acme/errors.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-:mod:`letsencrypt.acme.errors`
-------------------------------
-
-.. automodule:: letsencrypt.acme.errors
- :members:
diff --git a/docs/api/acme/interfaces.rst b/docs/api/acme/interfaces.rst
deleted file mode 100644
index 5ed652834..000000000
--- a/docs/api/acme/interfaces.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-:mod:`letsencrypt.acme.interfaces`
-----------------------------------
-
-.. automodule:: letsencrypt.acme.interfaces
- :members:
diff --git a/docs/api/acme/jose.rst b/docs/api/acme/jose.rst
deleted file mode 100644
index d82dc1f15..000000000
--- a/docs/api/acme/jose.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-:mod:`letsencrypt.acme.jose`
-----------------------------
-
-.. automodule:: letsencrypt.acme.jose
- :members:
diff --git a/docs/api/acme/messages.rst b/docs/api/acme/messages.rst
deleted file mode 100644
index d231f9c52..000000000
--- a/docs/api/acme/messages.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-:mod:`letsencrypt.acme.messages`
---------------------------------
-
-.. automodule:: letsencrypt.acme.messages
- :members:
diff --git a/docs/api/acme/other.rst b/docs/api/acme/other.rst
deleted file mode 100644
index 8372e3028..000000000
--- a/docs/api/acme/other.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-:mod:`letsencrypt.acme.other`
------------------------------
-
-.. automodule:: letsencrypt.acme.other
- :members:
diff --git a/docs/api/acme/util.rst b/docs/api/acme/util.rst
deleted file mode 100644
index 960cf8882..000000000
--- a/docs/api/acme/util.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-:mod:`letsencrypt.acme.util`
-----------------------------
-
-.. automodule:: letsencrypt.acme.util
- :members:
diff --git a/docs/api/client/achallenges.rst b/docs/api/client/achallenges.rst
new file mode 100644
index 000000000..46a13ee8b
--- /dev/null
+++ b/docs/api/client/achallenges.rst
@@ -0,0 +1,5 @@
+:mod:`letsencrypt.client.achallenges`
+-------------------------------------
+
+.. automodule:: letsencrypt.client.achallenges
+ :members:
diff --git a/docs/api/client/challenge_util.rst b/docs/api/client/challenge_util.rst
deleted file mode 100644
index 3866230a5..000000000
--- a/docs/api/client/challenge_util.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-:mod:`letsencrypt.client.challenge_util`
-----------------------------------------
-
-.. automodule:: letsencrypt.client.challenge_util
- :members:
diff --git a/docs/conf.py b/docs/conf.py
index 2f25c9a7b..2a29b9dd3 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -54,6 +54,9 @@ extensions = [
'repoze.sphinx.autointerface',
]
+autodoc_member_order = 'bysource'
+autodoc_default_flags = ['show-inheritance']
+
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
diff --git a/letsencrypt/acme/__init__.py b/letsencrypt/acme/__init__.py
index 69418608b..95744bbd5 100644
--- a/letsencrypt/acme/__init__.py
+++ b/letsencrypt/acme/__init__.py
@@ -1 +1,22 @@
-"""ACME protocol implementation."""
+"""ACME protocol implementation.
+
+.. warning:: This module is an implementation of the draft `ACME
+ protocol version 00`_, and not the latest (as of time of writing),
+ "RESTified" `ACME protocol version 01`_. It should work with the
+ server from the `Node.js implementation`_, but will not work with
+ Boulder_.
+
+
+.. _`ACME protocol`: https://github.com/letsencrypt/acme-spec
+
+.. _`ACME protocol version 00`:
+ https://github.com/letsencrypt/acme-spec/blob/v00/draft-barnes-acme.md
+
+.. _`ACME protocol version 01`:
+ https://github.com/letsencrypt/acme-spec/blob/v01/draft-barnes-acme.md
+
+.. _Boulder: https://github.com/letsencrypt/boulder
+
+.. _`Node.js implementation`: https://github.com/letsencrypt/node-acme
+
+"""
diff --git a/letsencrypt/acme/challenges.py b/letsencrypt/acme/challenges.py
new file mode 100644
index 000000000..4bbeb4cd2
--- /dev/null
+++ b/letsencrypt/acme/challenges.py
@@ -0,0 +1,361 @@
+"""ACME Identifier Validation Challenges."""
+import binascii
+import functools
+import hashlib
+
+import Crypto.Random
+
+from letsencrypt.acme import jose
+from letsencrypt.acme import other
+from letsencrypt.acme import util
+
+
+# pylint: disable=too-few-public-methods
+
+
+class Challenge(util.TypedACMEObject):
+ # _fields_to_json | pylint: disable=abstract-method
+ """ACME challenge."""
+ TYPES = {}
+
+
+class ClientChallenge(Challenge): # pylint: disable=abstract-method
+ """Client validation challenges."""
+
+
+class DVChallenge(Challenge): # pylint: disable=abstract-method
+ """Domain validation challenges."""
+
+
+class ChallengeResponse(util.TypedACMEObject):
+ # _fields_to_json | pylint: disable=abstract-method
+ """ACME challenge response."""
+ TYPES = {}
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ if jobj is None:
+ # if the client chooses not to respond to a given
+ # challenge, then the corresponding entry in the response
+ # array is set to None (null)
+ return None
+ return super(ChallengeResponse, cls).from_valid_json(jobj)
+
+
+@Challenge.register
+class SimpleHTTPS(DVChallenge):
+ """ACME "simpleHttps" challenge."""
+ acme_type = "simpleHttps"
+ __slots__ = ("token",)
+
+ def _fields_to_json(self):
+ return {"token": self.token}
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ return cls(token=jobj["token"])
+
+
+@ChallengeResponse.register
+class SimpleHTTPSResponse(ChallengeResponse):
+ """ACME "simpleHttps" challenge response."""
+ acme_type = "simpleHttps"
+ __slots__ = ("path",)
+
+ URI_TEMPLATE = "https://{domain}/.well-known/acme-challenge/{path}"
+ """URI template for HTTPS server provisioned resource."""
+
+ def uri(self, domain):
+ """Create an URI to the provisioned resource.
+
+ Forms an URI to the HTTPS server provisioned resource (containing
+ :attr:`~SimpleHTTPS.token`) by populating the :attr:`URI_TEMPLATE`.
+
+ :param str domain: Domain name being verified.
+
+ """
+ return self.URI_TEMPLATE.format(domain=domain, path=self.path)
+
+ def _fields_to_json(self):
+ return {"path": self.path}
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ return cls(path=jobj["path"])
+
+
+@Challenge.register
+class DVSNI(DVChallenge):
+ """ACME "dvsni" challenge.
+
+ :ivar str r: Random data, **not** base64-encoded.
+ :ivar str nonce: Random data, **not** hex-encoded.
+
+ """
+ acme_type = "dvsni"
+ __slots__ = ("r", "nonce")
+
+ 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."""
+
+ @property
+ def nonce_domain(self):
+ """Domain name used in SNI."""
+ return binascii.hexlify(self.nonce) + self.DOMAIN_SUFFIX
+
+ def _fields_to_json(self):
+ return {
+ "r": jose.b64encode(self.r),
+ "nonce": binascii.hexlify(self.nonce),
+ }
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ return cls(r=util.decode_b64jose(jobj["r"], cls.R_SIZE),
+ nonce=util.decode_hex16(jobj["nonce"], cls.NONCE_SIZE))
+
+
+@ChallengeResponse.register
+class DVSNIResponse(ChallengeResponse):
+ """ACME "dvsni" challenge response.
+
+ :param str s: Random data, **not** base64-encoded.
+
+ """
+ acme_type = "dvsni"
+ __slots__ = ("s",)
+
+ DOMAIN_SUFFIX = DVSNI.DOMAIN_SUFFIX
+ """Domain name suffix."""
+
+ S_SIZE = 32
+ """Required size of the :attr:`s` in bytes."""
+
+ def __init__(self, s=None, *args, **kwargs):
+ s = Crypto.Random.get_random_bytes(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):
+ """Domain name for certificate subjectAltName."""
+ return self.z(chall) + self.DOMAIN_SUFFIX
+
+ def _fields_to_json(self):
+ return {"s": jose.b64encode(self.s)}
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ return cls(s=util.decode_b64jose(jobj["s"], cls.S_SIZE))
+
+
+@Challenge.register
+class RecoveryContact(ClientChallenge):
+ """ACME "recoveryContact" challenge."""
+ acme_type = "recoveryContact"
+ __slots__ = ("activation_url", "success_url", "contact")
+
+ def _fields_to_json(self):
+ fields = {}
+ add = functools.partial(_extend_if_not_none, fields)
+ add(self.activation_url, "activationURL")
+ add(self.success_url, "successURL")
+ add(self.contact, "contact")
+ return fields
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ return cls(activation_url=jobj.get("activationURL"),
+ success_url=jobj.get("successURL"),
+ contact=jobj.get("contact"))
+
+
+@ChallengeResponse.register
+class RecoveryContactResponse(ChallengeResponse):
+ """ACME "recoveryContact" challenge response."""
+ acme_type = "recoveryContact"
+ __slots__ = ("token",)
+
+ def _fields_to_json(self):
+ fields = {}
+ if self.token is not None:
+ fields["token"] = self.token
+ return fields
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ return cls(token=jobj.get("token"))
+
+
+@Challenge.register
+class RecoveryToken(ClientChallenge):
+ """ACME "recoveryToken" challenge."""
+ acme_type = "recoveryToken"
+ __slots__ = ()
+
+ def _fields_to_json(self):
+ return {}
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ return cls()
+
+
+@ChallengeResponse.register
+class RecoveryTokenResponse(ChallengeResponse):
+ """ACME "recoveryToken" challenge response."""
+ acme_type = "recoveryToken"
+ __slots__ = ("token",)
+
+ def _fields_to_json(self):
+ fields = {}
+ if self.token is not None:
+ fields["token"] = self.token
+ return fields
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ return cls(token=jobj.get("token"))
+
+
+def _extend_if_not_empty(dikt, param, name):
+ if param:
+ dikt[name] = param
+
+def _extend_if_not_none(dikt, param, name):
+ if param is not None:
+ dikt[name] = param
+
+
+@Challenge.register
+class ProofOfPossession(ClientChallenge):
+ """ACME "proofOfPossession" challenge.
+
+ :ivar str nonce: Random data, **not** base64-encoded.
+ :ivar hints: Various clues for the client (:class:`Hints`).
+
+ """
+ acme_type = "proofOfPossession"
+ __slots__ = ("alg", "nonce", "hints")
+
+ NONCE_SIZE = 16
+
+ class Hints(util.ACMEObject):
+ """Hints for "proofOfPossession" challenge.
+
+ :ivar jwk: JSON Web Key (:class:`letsencrypt.acme.other.JWK`)
+ :ivar list certs: List of :class:`M2Crypto.X509.X509` cetificates.
+
+ """
+ __slots__ = (
+ "jwk", "cert_fingerprints", "certs", "subject_key_identifiers",
+ "serial_numbers", "issuers", "authorized_for")
+
+ def to_json(self):
+ fields = {"jwk": self.jwk}
+ add = functools.partial(_extend_if_not_empty, fields)
+ add(self.cert_fingerprints, "certFingerprints")
+ add([util.encode_cert(cert) for cert in self.certs], "certs")
+ add(self.subject_key_identifiers, "subjectKeyIdentifiers")
+ add(self.serial_numbers, "serialNumbers")
+ add(self.issuers, "issuers")
+ add(self.authorized_for, "authorizedFor")
+ return fields
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ return cls(
+ jwk=other.JWK.from_valid_json(jobj["jwk"]),
+ cert_fingerprints=jobj.get("certFingerprints", []),
+ certs=[util.decode_cert(cert)
+ for cert in jobj.get("certs", [])],
+ subject_key_identifiers=jobj.get("subjectKeyIdentifiers", []),
+ serial_numbers=jobj.get("serialNumbers", []),
+ issuers=jobj.get("issuers", []),
+ authorized_for=jobj.get("authorizedFor", []))
+
+ def _fields_to_json(self):
+ return {
+ "alg": self.alg,
+ "nonce": jose.b64encode(self.nonce),
+ "hints": self.hints,
+ }
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ return cls(alg=jobj["alg"],
+ nonce=util.decode_b64jose(jobj["nonce"], cls.NONCE_SIZE),
+ hints=cls.Hints.from_valid_json(jobj["hints"]))
+
+
+@ChallengeResponse.register
+class ProofOfPossessionResponse(ChallengeResponse):
+ """ACME "proofOfPossession" challenge response.
+
+ :ivar str nonce: Random data, **not** base64-encoded.
+ :ivar signature: :class:`~letsencrypt.acme.other.Signature` of this message.
+
+ """
+ acme_type = "proofOfPossession"
+ __slots__ = ("nonce", "signature")
+
+ NONCE_SIZE = ProofOfPossession.NONCE_SIZE
+
+ def verify(self):
+ """Verify the challenge."""
+ return self.signature.verify(self.nonce)
+
+ def _fields_to_json(self):
+ return {
+ "nonce": jose.b64encode(self.nonce),
+ "signature": self.signature,
+ }
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ return cls(nonce=util.decode_b64jose(jobj["nonce"], cls.NONCE_SIZE),
+ signature=other.Signature.from_valid_json(jobj["signature"]))
+
+
+@Challenge.register
+class DNS(DVChallenge):
+ """ACME "dns" challenge."""
+ acme_type = "dns"
+ __slots__ = ("token",)
+
+ def _fields_to_json(self):
+ return {"token": self.token}
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ return cls(token=jobj["token"])
+
+
+@ChallengeResponse.register
+class DNSResponse(ChallengeResponse):
+ """ACME "dns" challenge response."""
+ acme_type = "dns"
+ __slots__ = ()
+
+ def _fields_to_json(self):
+ return {}
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ return cls()
diff --git a/letsencrypt/acme/challenges_test.py b/letsencrypt/acme/challenges_test.py
new file mode 100644
index 000000000..53b3ff3f1
--- /dev/null
+++ b/letsencrypt/acme/challenges_test.py
@@ -0,0 +1,411 @@
+"""Tests for letsencrypt.acme.challenges."""
+import os
+import pkg_resources
+import unittest
+
+import Crypto.PublicKey.RSA
+import M2Crypto
+
+from letsencrypt.acme import errors
+from letsencrypt.acme import jose
+from letsencrypt.acme import other
+from letsencrypt.acme import util
+
+
+CERT = util.ComparableX509(M2Crypto.X509.load_cert(
+ pkg_resources.resource_filename(
+ 'letsencrypt.client.tests', 'testdata/cert.pem')))
+KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
+ 'letsencrypt.client.tests', os.path.join('testdata', 'rsa256_key.pem')))
+
+
+class SimpleHTTPSTest(unittest.TestCase):
+
+ def setUp(self):
+ from letsencrypt.acme.challenges import SimpleHTTPS
+ self.msg = SimpleHTTPS(
+ token='evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA')
+ self.jmsg = {
+ 'type': 'simpleHttps',
+ 'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA',
+ }
+
+ def test_to_json(self):
+ self.assertEqual(self.jmsg, self.msg.to_json())
+
+ def test_from_json(self):
+ from letsencrypt.acme.challenges import SimpleHTTPS
+ self.assertEqual(self.msg, SimpleHTTPS.from_valid_json(self.jmsg))
+
+
+class SimpleHTTPSResponseTest(unittest.TestCase):
+
+ def setUp(self):
+ from letsencrypt.acme.challenges import SimpleHTTPSResponse
+ self.msg = SimpleHTTPSResponse(path='6tbIMBC5Anhl5bOlWT5ZFA')
+ self.jmsg = {
+ 'type': 'simpleHttps',
+ 'path': '6tbIMBC5Anhl5bOlWT5ZFA',
+ }
+
+ def test_uri(self):
+ self.assertEqual('https://example.com/.well-known/acme-challenge/'
+ '6tbIMBC5Anhl5bOlWT5ZFA', self.msg.uri('example.com'))
+
+ def test_to_json(self):
+ self.assertEqual(self.jmsg, self.msg.to_json())
+
+ def test_from_json(self):
+ from letsencrypt.acme.challenges import SimpleHTTPSResponse
+ self.assertEqual(
+ self.msg, SimpleHTTPSResponse.from_valid_json(self.jmsg))
+
+
+class DVSNITest(unittest.TestCase):
+
+ def setUp(self):
+ from letsencrypt.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.')
+ self.jmsg = {
+ 'type': 'dvsni',
+ 'r': 'Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI',
+ 'nonce': 'a82d5ff8ef740d12881f6d3c2277ab2e',
+ }
+
+ def test_nonce_domain(self):
+ self.assertEqual('a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid',
+ self.msg.nonce_domain)
+
+ def test_to_json(self):
+ self.assertEqual(self.jmsg, self.msg.to_json())
+
+ def test_from_json(self):
+ from letsencrypt.acme.challenges import DVSNI
+ self.assertEqual(self.msg, DVSNI.from_valid_json(self.jmsg))
+
+ def test_from_json_invalid_r_length(self):
+ from letsencrypt.acme.challenges import DVSNI
+ self.jmsg['r'] = 'abcd'
+ self.assertRaises(
+ errors.ValidationError, DVSNI.from_valid_json, self.jmsg)
+
+ def test_from_json_invalid_nonce_length(self):
+ from letsencrypt.acme.challenges import DVSNI
+ self.jmsg['nonce'] = 'abcd'
+ self.assertRaises(
+ errors.ValidationError, DVSNI.from_valid_json, self.jmsg)
+
+
+class DVSNIResponseTest(unittest.TestCase):
+
+ def setUp(self):
+ from letsencrypt.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 = {
+ 'type': 'dvsni',
+ 's': '9dbjsl3gTAtOnEtKFEmhS6Mj-ajNjDcOmRkp3Lfzm3c',
+ }
+
+ def test_z_and_domain(self):
+ from letsencrypt.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))
+ self.assertEqual(
+ '{0}.acme.invalid'.format(z), self.msg.z_domain(challenge))
+
+ def test_to_json(self):
+ self.assertEqual(self.jmsg, self.msg.to_json())
+
+ def test_from_json(self):
+ from letsencrypt.acme.challenges import DVSNIResponse
+ self.assertEqual(self.msg, DVSNIResponse.from_valid_json(self.jmsg))
+
+
+class RecoveryContactTest(unittest.TestCase):
+
+ def setUp(self):
+ from letsencrypt.acme.challenges import RecoveryContact
+ self.msg = RecoveryContact(
+ activation_url='https://example.ca/sendrecovery/a5bd99383fb0',
+ success_url='https://example.ca/confirmrecovery/bb1b9928932',
+ contact='c********n@example.com')
+ self.jmsg = {
+ 'type': 'recoveryContact',
+ 'activationURL' : 'https://example.ca/sendrecovery/a5bd99383fb0',
+ 'successURL' : 'https://example.ca/confirmrecovery/bb1b9928932',
+ 'contact' : 'c********n@example.com',
+ }
+
+ def test_to_json(self):
+ self.assertEqual(self.jmsg, self.msg.to_json())
+
+ def test_from_json(self):
+ from letsencrypt.acme.challenges import RecoveryContact
+ self.assertEqual(self.msg, RecoveryContact.from_valid_json(self.jmsg))
+
+ def test_json_without_optionals(self):
+ del self.jmsg['activationURL']
+ del self.jmsg['successURL']
+ del self.jmsg['contact']
+
+ from letsencrypt.acme.challenges import RecoveryContact
+ msg = RecoveryContact.from_valid_json(self.jmsg)
+
+ self.assertTrue(msg.activation_url is None)
+ self.assertTrue(msg.success_url is None)
+ self.assertTrue(msg.contact is None)
+ self.assertEqual(self.jmsg, msg.to_json())
+
+
+class RecoveryContactResponseTest(unittest.TestCase):
+
+ def setUp(self):
+ from letsencrypt.acme.challenges import RecoveryContactResponse
+ self.msg = RecoveryContactResponse(token='23029d88d9e123e')
+ self.jmsg = {'type': 'recoveryContact', 'token': '23029d88d9e123e'}
+
+ def test_to_json(self):
+ self.assertEqual(self.jmsg, self.msg.to_json())
+
+ def test_from_json(self):
+ from letsencrypt.acme.challenges import RecoveryContactResponse
+ self.assertEqual(
+ self.msg, RecoveryContactResponse.from_valid_json(self.jmsg))
+
+ def test_json_without_optionals(self):
+ del self.jmsg['token']
+
+ from letsencrypt.acme.challenges import RecoveryContactResponse
+ msg = RecoveryContactResponse.from_valid_json(self.jmsg)
+
+ self.assertTrue(msg.token is None)
+ self.assertEqual(self.jmsg, msg.to_json())
+
+
+class RecoveryTokenTest(unittest.TestCase):
+
+ def setUp(self):
+ from letsencrypt.acme.challenges import RecoveryToken
+ self.msg = RecoveryToken()
+ self.jmsg = {'type': 'recoveryToken'}
+
+ def test_to_json(self):
+ self.assertEqual(self.jmsg, self.msg.to_json())
+
+ def test_from_json(self):
+ from letsencrypt.acme.challenges import RecoveryToken
+ self.assertEqual(self.msg, RecoveryToken.from_valid_json(self.jmsg))
+
+
+class RecoveryTokenResponseTest(unittest.TestCase):
+
+ def setUp(self):
+ from letsencrypt.acme.challenges import RecoveryTokenResponse
+ self.msg = RecoveryTokenResponse(token='23029d88d9e123e')
+ self.jmsg = {'type': 'recoveryToken', 'token': '23029d88d9e123e'}
+
+ def test_to_json(self):
+ self.assertEqual(self.jmsg, self.msg.to_json())
+
+ def test_from_json(self):
+ from letsencrypt.acme.challenges import RecoveryTokenResponse
+ self.assertEqual(
+ self.msg, RecoveryTokenResponse.from_valid_json(self.jmsg))
+
+ def test_json_without_optionals(self):
+ del self.jmsg['token']
+
+ from letsencrypt.acme.challenges import RecoveryTokenResponse
+ msg = RecoveryTokenResponse.from_valid_json(self.jmsg)
+
+ self.assertTrue(msg.token is None)
+ self.assertEqual(self.jmsg, msg.to_json())
+
+
+class ProofOfPossessionHintsTest(unittest.TestCase):
+
+ def setUp(self):
+ jwk = other.JWK(key=KEY.publickey())
+ issuers = [
+ 'C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA',
+ 'O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure',
+ ]
+ cert_fingerprints = [
+ '93416768eb85e33adc4277f4c9acd63e7418fcfe',
+ '16d95b7b63f1972b980b14c20291f3c0d1855d95',
+ '48b46570d9fc6358108af43ad1649484def0debf',
+ ]
+ subject_key_identifiers = ['d0083162dcc4c8a23ecb8aecbd86120e56fd24e5']
+ authorized_for = ['www.example.com', 'example.net']
+ serial_numbers = [34234239832, 23993939911, 17]
+
+ from letsencrypt.acme.challenges import ProofOfPossession
+ self.msg = ProofOfPossession.Hints(
+ jwk=jwk, issuers=issuers, cert_fingerprints=cert_fingerprints,
+ certs=[CERT], subject_key_identifiers=subject_key_identifiers,
+ authorized_for=authorized_for, serial_numbers=serial_numbers)
+
+ self.jmsg_to = {
+ 'jwk': jwk,
+ 'certFingerprints': cert_fingerprints,
+ 'certs': [jose.b64encode(CERT.as_der())],
+ 'subjectKeyIdentifiers': subject_key_identifiers,
+ 'serialNumbers': serial_numbers,
+ 'issuers': issuers,
+ 'authorizedFor': authorized_for,
+ }
+ self.jmsg_from = self.jmsg_to.copy()
+ self.jmsg_from.update({'jwk': jwk.to_json()})
+
+ def test_to_json(self):
+ self.assertEqual(self.jmsg_to, self.msg.to_json())
+
+ def test_from_json(self):
+ from letsencrypt.acme.challenges import ProofOfPossession
+ self.assertEqual(
+ self.msg, ProofOfPossession.Hints.from_valid_json(self.jmsg_from))
+
+ def test_json_without_optionals(self):
+ for optional in ['certFingerprints', 'certs', 'subjectKeyIdentifiers',
+ 'serialNumbers', 'issuers', 'authorizedFor']:
+ del self.jmsg_from[optional]
+ del self.jmsg_to[optional]
+
+ from letsencrypt.acme.challenges import ProofOfPossession
+ msg = ProofOfPossession.Hints.from_valid_json(self.jmsg_from)
+
+ self.assertEqual(msg.cert_fingerprints, [])
+ self.assertEqual(msg.certs, [])
+ self.assertEqual(msg.subject_key_identifiers, [])
+ self.assertEqual(msg.serial_numbers, [])
+ self.assertEqual(msg.issuers, [])
+ self.assertEqual(msg.authorized_for, [])
+
+ self.assertEqual(self.jmsg_to, msg.to_json())
+
+
+class ProofOfPossessionTest(unittest.TestCase):
+
+ def setUp(self):
+ from letsencrypt.acme.challenges import ProofOfPossession
+ hints = ProofOfPossession.Hints(
+ jwk=other.JWK(key=KEY.publickey()), cert_fingerprints=[], certs=[],
+ serial_numbers=[], subject_key_identifiers=[], issuers=[],
+ authorized_for=[])
+ self.msg = ProofOfPossession(
+ alg='RS256', nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ',
+ hints=hints)
+
+ self.jmsg_to = {
+ 'type': 'proofOfPossession',
+ 'alg': 'RS256',
+ 'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
+ 'hints': hints,
+ }
+ self.jmsg_from = {
+ 'type': 'proofOfPossession',
+ 'alg': 'RS256',
+ 'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
+ 'hints': hints.to_json(),
+ }
+ self.jmsg_from['hints']['jwk'] = self.jmsg_from[
+ 'hints']['jwk'].to_json()
+
+ def test_to_json(self):
+ self.assertEqual(self.jmsg_to, self.msg.to_json())
+
+ def test_from_json(self):
+ from letsencrypt.acme.challenges import ProofOfPossession
+ self.assertEqual(
+ self.msg, ProofOfPossession.from_valid_json(self.jmsg_from))
+
+
+class ProofOfPossessionResponseTest(unittest.TestCase):
+
+ def setUp(self):
+ # acme-spec uses a confusing example in which both signature
+ # nonce and challenge nonce are the same, don't make the same
+ # mistake here...
+ signature = other.Signature(
+ alg='RS256', jwk=other.JWK(key=KEY.publickey()),
+ 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',
+ )
+
+ from letsencrypt.acme.challenges import ProofOfPossessionResponse
+ self.msg = ProofOfPossessionResponse(
+ nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ',
+ signature=signature)
+
+ self.jmsg_to = {
+ 'type': 'proofOfPossession',
+ 'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
+ 'signature': signature,
+ }
+ self.jmsg_from = {
+ 'type': 'proofOfPossession',
+ 'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
+ 'signature': signature.to_json(),
+ }
+ self.jmsg_from['signature']['jwk'] = self.jmsg_from[
+ 'signature']['jwk'].to_json()
+
+
+ def test_verify(self):
+ self.assertTrue(self.msg.verify())
+
+ def test_to_json(self):
+ self.assertEqual(self.jmsg_to, self.msg.to_json())
+
+ def test_from_json(self):
+ from letsencrypt.acme.challenges import ProofOfPossessionResponse
+ self.assertEqual(
+ self.msg, ProofOfPossessionResponse.from_valid_json(self.jmsg_from))
+
+
+class DNSTest(unittest.TestCase):
+
+ def setUp(self):
+ from letsencrypt.acme.challenges import DNS
+ self.msg = DNS(token='17817c66b60ce2e4012dfad92657527a')
+ self.jmsg = {'type': 'dns', 'token': '17817c66b60ce2e4012dfad92657527a'}
+
+ def test_to_json(self):
+ self.assertEqual(self.jmsg, self.msg.to_json())
+
+ def test_from_json(self):
+ from letsencrypt.acme.challenges import DNS
+ self.assertEqual(self.msg, DNS.from_valid_json(self.jmsg))
+
+
+class DNSResponseTest(unittest.TestCase):
+
+ def setUp(self):
+ from letsencrypt.acme.challenges import DNSResponse
+ self.msg = DNSResponse()
+ self.jmsg = {'type': 'dns'}
+
+ def test_to_json(self):
+ self.assertEqual(self.jmsg, self.msg.to_json())
+
+ def test_from_json(self):
+ from letsencrypt.acme.challenges import DNSResponse
+ self.assertEqual(self.msg, DNSResponse.from_valid_json(self.jmsg))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/letsencrypt/acme/errors.py b/letsencrypt/acme/errors.py
index a70271894..c88881412 100644
--- a/letsencrypt/acme/errors.py
+++ b/letsencrypt/acme/errors.py
@@ -4,10 +4,10 @@ class Error(Exception):
"""Generic ACME error."""
class ValidationError(Error):
- """ACME message validation error."""
+ """ACME object validation error."""
-class UnrecognizedMessageTypeError(ValidationError):
- """Unrecognized ACME message type error."""
+class UnrecognizedTypeError(ValidationError):
+ """Unrecognized ACME object type error."""
class SchemaValidationError(ValidationError):
- """JSON schema ACME message validation error."""
+ """JSON schema ACME object validation error."""
diff --git a/letsencrypt/acme/interfaces.py b/letsencrypt/acme/interfaces.py
index 0d9e56495..e49956b4b 100644
--- a/letsencrypt/acme/interfaces.py
+++ b/letsencrypt/acme/interfaces.py
@@ -1,7 +1,20 @@
-"""ACME interfaces."""
+"""ACME interfaces.
+
+Separation between :class:`IJSONSerializable` and :class:`IJSONDeserializable`
+is necessary because we want to use ``cls.from_valid_json``
+classmethod on class and ``cls().to_json()`` on object, i.e. class
+instance. ``cls.to_json()`` doesn't make much sense. Therefore a class
+definition that requires both must call
+``zope.interface.implements(IJSONSerializable)`` and
+``zope.interface.classImplements(IJSONDeSerializable)`` (note the
+difference btween `implements` and `classImplements`) and
+:class:`letsencrypt.acme.util.ACMEObject` definition is an example.
+
+"""
import zope.interface
# pylint: disable=no-self-argument,no-method-argument,no-init,inherit-non-class
+# pylint: disable=too-few-public-methods
class IJSONSerializable(zope.interface.Interface):
@@ -11,12 +24,46 @@ class IJSONSerializable(zope.interface.Interface):
def to_json():
"""Prepare JSON serializable object.
- :returns: JSON object ready to be serialized. Note, however, that
- this might return other
- :class:`letsencrypt.acme.interfaces.IJSONSerializable`
- objects, that haven't been serialized yet, which is fine as
- long as :func:`letsencrypt.acme.util.dump_ijsonserializable`
- is used.
- :rtype: dict
+ Note, however, that this method might return other
+ :class:`letsencrypt.acme.interfaces.IJSONSerializable`
+ objects that haven't been serialized yet, which is fine as
+ long as :func:`letsencrypt.acme.util.dump_ijsonserializable`
+ is used. For example::
+
+ class Foo(object):
+ zope.interface.implements(IJSONSerializable)
+
+ def to_json(self):
+ return 'foo'
+
+ class Bar(object):
+ zope.interface.implements(IJSONSerializable)
+
+ def to_json(self):
+ return [Foo(), Foo()]
+
+ bar = Bar()
+ assert isinstance(bar.to_json()[0], Foo)
+ assert isinstance(bar.to_json()[1], Foo)
+ assert json.dumps(
+ bar, default=dump_ijsonserializable) == ['foo', 'foo']
+
+ :returns: JSON object ready to be serialized.
+
+ """
+
+class IJSONDeserializable(zope.interface.Interface):
+ """JSON deserializable class."""
+
+ def from_valid_json(jobj):
+ """Deserialize valid JSON object.
+
+ :param jobj: JSON object validated against JSON schema (found in
+ schemata/ directory).
+
+ :raises letsencrypt.acme.errors.ValidationError: It might be the
+ case that ``jobj`` validates against schema, but still is not
+ valid (e.g. unparseable X509 certificate, or wrong padding in
+ JOSE base64 encoded string).
"""
diff --git a/letsencrypt/acme/jose.py b/letsencrypt/acme/jose.py
index 6d2097ba5..81c1abbf7 100644
--- a/letsencrypt/acme/jose.py
+++ b/letsencrypt/acme/jose.py
@@ -1,53 +1,5 @@
"""JOSE."""
import base64
-import binascii
-
-import Crypto.PublicKey.RSA
-
-from letsencrypt.acme import util
-
-
-def _leading_zeros(arg):
- if len(arg) % 2:
- return '0' + arg
- return arg
-
-
-class JWK(util.JSONDeSerializable, util.ImmutableMap):
- # pylint: disable=too-few-public-methods
- """JSON Web Key.
-
- .. todo:: Currently works for RSA public keys only.
-
- """
- __slots__ = ('key',)
- schema = util.load_schema('jwk')
-
- @classmethod
- def _encode_param(cls, param):
- """Encode numeric key parameter."""
- return b64encode(binascii.unhexlify(
- _leading_zeros(hex(param)[2:].rstrip('L'))))
-
- @classmethod
- def _decode_param(cls, param):
- """Decode numeric key parameter."""
- return long(binascii.hexlify(b64decode(param)), 16)
-
- def to_json(self):
- """Serialize to JSON."""
- return {
- 'kty': 'RSA', # TODO
- 'n': self._encode_param(self.key.n),
- 'e': self._encode_param(self.key.e),
- }
-
- @classmethod
- def _from_valid_json(cls, jobj):
- assert 'RSA' == jobj['kty'] # TODO
- return cls(key=Crypto.PublicKey.RSA.construct(
- (cls._decode_param(jobj['n']), cls._decode_param(jobj['e']))))
-
# https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-37#appendix-C
#
diff --git a/letsencrypt/acme/jose_test.py b/letsencrypt/acme/jose_test.py
index a1a872704..42cf8051c 100644
--- a/letsencrypt/acme/jose_test.py
+++ b/letsencrypt/acme/jose_test.py
@@ -1,54 +1,6 @@
"""Tests for letsencrypt.acme.jose."""
-import pkg_resources
import unittest
-import Crypto.PublicKey.RSA
-
-
-RSA256_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
- 'letsencrypt.client.tests', 'testdata/rsa256_key.pem'))
-RSA512_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
- 'letsencrypt.client.tests', 'testdata/rsa512_key.pem'))
-
-
-class JWKTest(unittest.TestCase):
- """Tests fro letsencrypt.acme.jose.JWK."""
-
- def setUp(self):
- from letsencrypt.acme.jose import JWK
- self.jwk256 = JWK(key=RSA256_KEY.publickey())
- self.jwk256json = {
- 'kty': 'RSA',
- 'e': 'AQAB',
- 'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5'
- '80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q',
- }
- self.jwk512 = JWK(key=RSA512_KEY.publickey())
- self.jwk512json = {
- 'kty': 'RSA',
- 'e': 'AQAB',
- 'n': '9LYRcVE3Nr-qleecEcX8JwVDnjeG1X7ucsCasuuZM0e09c'
- 'mYuUzxIkMjO_9x4AVcvXXRXPEV-LzWWkfkTlzRMw',
- }
-
- def test_equals(self):
- self.assertEqual(self.jwk256, self.jwk256)
- self.assertEqual(self.jwk512, self.jwk512)
-
- def test_not_equals(self):
- self.assertNotEqual(self.jwk256, self.jwk512)
- self.assertNotEqual(self.jwk512, self.jwk256)
-
- def test_to_json(self):
- self.assertEqual(self.jwk256.to_json(), self.jwk256json)
- self.assertEqual(self.jwk512.to_json(), self.jwk512json)
-
- def test_from_json(self):
- from letsencrypt.acme.jose import JWK
- self.assertEqual(self.jwk256, JWK.from_json(self.jwk256json))
- # TODO: fix schemata to allow RSA512
- #self.assertEqual(self.jwk512, JWK.from_json(self.jwk512json))
-
# https://en.wikipedia.org/wiki/Base64#Examples
B64_PADDING_EXAMPLES = {
diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py
index a345be9f9..64f7a0350 100644
--- a/letsencrypt/acme/messages.py
+++ b/letsencrypt/acme/messages.py
@@ -1,58 +1,28 @@
"""ACME protocol messages."""
-import M2Crypto
-import zope.interface
+import json
+import jsonschema
+
+from letsencrypt.acme import challenges
from letsencrypt.acme import errors
-from letsencrypt.acme import interfaces
from letsencrypt.acme import jose
from letsencrypt.acme import other
from letsencrypt.acme import util
-class Message(util.JSONDeSerializable, util.ImmutableMap):
- """ACME message.
+class Message(util.TypedACMEObject):
+ # _fields_to_json | pylint: disable=abstract-method
+ """ACME message."""
+ TYPES = {}
- Messages are considered immutable.
+ schema = NotImplemented
+ """JSON schema the object is tested against in :meth:`from_json`.
+
+ Subclasses must overrride it with a value that is acceptable by
+ :func:`jsonschema.validate`, most probably using
+ :func:`letsencrypt.acme.util.load_schema`.
"""
- zope.interface.implements(interfaces.IJSONSerializable)
-
- acme_type = NotImplemented
- """ACME message "type" field. Subclasses must override."""
-
- TYPES = {}
- """Message types registered for JSON deserialization"""
-
- @classmethod
- def register(cls, msg_cls):
- """Register class for JSON deserialization."""
- cls.TYPES[msg_cls.acme_type] = msg_cls
- return msg_cls
-
- def to_json(self):
- """Get JSON serializable object.
-
- :returns: Serializable JSON object representing ACME message.
- :meth:`validate` will almost certainly not work, due to reasons
- explained in :class:`letsencrypt.acme.interfaces.IJSONSerializable`.
- :rtype: dict
-
- """
- jobj = self._fields_to_json()
- jobj["type"] = self.acme_type
- return jobj
-
- def _fields_to_json(self):
- """Prepare ACME message fields for JSON serialiazation.
-
- Subclasses must override this method.
-
- :returns: Serializable JSON object containg all ACME message fields
- apart from "type".
- :rtype: dict
-
- """
- raise NotImplementedError()
@classmethod
def get_msg_cls(cls, jobj):
@@ -72,37 +42,61 @@ class Message(util.JSONDeSerializable, util.ImmutableMap):
raise errors.ValidationError("missing type field")
try:
- msg_cls = cls.TYPES[msg_type]
+ return cls.TYPES[msg_type]
except KeyError:
- raise errors.UnrecognizedMessageTypeError(msg_type)
-
- return msg_cls
+ raise errors.UnrecognizedTypeError(msg_type)
@classmethod
- def from_json(cls, jobj, validate=True):
- """Deserialize validated ACME message from JSON string.
+ def from_json(cls, jobj):
+ """Deserialize from (possibly invalid) JSON object.
- :param str jobj: JSON object.
- :param bool validate: Validate against schema before deserializing.
- Useful if :class:`JWK` is part of already validated json object.
+ Note that the input ``jobj`` has not been sanitized in any way.
- :raises letsencrypt.acme.errors.ValidationError: if validation
- was unsuccessful
+ :param jobj: JSON object.
- :returns: Valid ACME message.
- :rtype: subclass of :class:`Message`
+ :raises letsencrypt.acme.errors.SchemaValidationError: if the input
+ JSON object could not be validated against JSON schema specified
+ in :attr:`schema`.
+ :raises letsencrypt.acme.errors.ValidationError: for any other generic
+ error in decoding.
+
+ :returns: instance of the class
"""
msg_cls = cls.get_msg_cls(jobj)
- if validate:
- msg_cls.validate_json(jobj)
- # pylint: disable=protected-access
- return msg_cls._from_valid_json(jobj)
+
+ try:
+ jsonschema.validate(jobj, msg_cls.schema)
+ except jsonschema.ValidationError as error:
+ raise errors.SchemaValidationError(error)
+
+ return cls.from_valid_json(jobj)
+
+ @classmethod
+ def json_loads(cls, json_string):
+ """Load JSON string."""
+ return cls.from_json(json.loads(json_string))
+
+ def json_dumps(self, *args, **kwargs):
+ """Dump to JSON string using proper serializer.
+
+ :returns: JSON serialized string.
+ :rtype: str
+
+ """
+ return json.dumps(
+ self, *args, default=util.dump_ijsonserializable, **kwargs)
@Message.register # pylint: disable=too-few-public-methods
class Challenge(Message):
- """ACME "challenge" message."""
+ """ACME "challenge" message.
+
+ :ivar str nonce: Random data, **not** base64-encoded.
+ :ivar list challenges: List of
+ :class:`~letsencrypt.acme.challenges.Challenge` objects.
+
+ """
acme_type = "challenge"
schema = util.load_schema(acme_type)
__slots__ = ("session_id", "nonce", "challenges", "combinations")
@@ -117,21 +111,29 @@ class Challenge(Message):
fields["combinations"] = self.combinations
return fields
+ @property
+ def resolved_combinations(self):
+ """Combinations with challenges instead of indices."""
+ return [[self.challenges[idx] for idx in combo]
+ for combo in self.combinations]
+
@classmethod
- def _from_valid_json(cls, jobj):
+ def from_valid_json(cls, jobj):
+ # TODO: can challenges contain two challenges of the same type?
+ # TODO: can challenges contain duplicates?
+ # TODO: check "combinations" indices are in valid range
+ # TODO: turn "combinations" elements into sets?
+ # TODO: turn "combinations" into set?
return cls(session_id=jobj["sessionID"],
- nonce=jose.b64decode(jobj["nonce"]),
- challenges=jobj["challenges"],
+ nonce=util.decode_b64jose(jobj["nonce"]),
+ challenges=[challenges.Challenge.from_valid_json(chall)
+ for chall in jobj["challenges"]],
combinations=jobj.get("combinations", []))
@Message.register # pylint: disable=too-few-public-methods
class ChallengeRequest(Message):
- """ACME "challengeRequest" message.
-
- :ivar str identifier: Domain name.
-
- """
+ """ACME "challengeRequest" message."""
acme_type = "challengeRequest"
schema = util.load_schema(acme_type)
__slots__ = ("identifier",)
@@ -142,13 +144,17 @@ class ChallengeRequest(Message):
}
@classmethod
- def _from_valid_json(cls, jobj):
+ def from_valid_json(cls, jobj):
return cls(identifier=jobj["identifier"])
@Message.register # pylint: disable=too-few-public-methods
class Authorization(Message):
- """ACME "authorization" message."""
+ """ACME "authorization" message.
+
+ :ivar jwk: :class:`letsencrypt.acme.other.JWK`
+
+ """
acme_type = "authorization"
schema = util.load_schema(acme_type)
__slots__ = ("recovery_token", "identifier", "jwk")
@@ -164,10 +170,10 @@ class Authorization(Message):
return fields
@classmethod
- def _from_valid_json(cls, jobj):
+ def from_valid_json(cls, jobj):
jwk = jobj.get("jwk")
if jwk is not None:
- jwk = jose.JWK.from_json(jwk, validate=False)
+ jwk = other.JWK.from_valid_json(jwk)
return cls(recovery_token=jobj.get("recoveryToken"),
identifier=jobj.get("identifier"), jwk=jwk)
@@ -176,11 +182,11 @@ class Authorization(Message):
class AuthorizationRequest(Message):
"""ACME "authorizationRequest" message.
- :ivar str session_id: "sessionID" from the server challenge
- :ivar str nonce: Nonce from the server challenge
- :ivar list responses: List of completed challenges
+ :ivar str nonce: Random data from the corresponding
+ :attr:`Challenge.nonce`, **not** base64-encoded.
+ :ivar list responses: List of completed challenges (
+ :class:`letsencrypt.acme.challenges.ChallengeResponse`).
:ivar signature: Signature (:class:`letsencrypt.acme.other.Signature`).
- :ivar contact: TODO
"""
acme_type = "authorizationRequest"
@@ -236,13 +242,14 @@ class AuthorizationRequest(Message):
return fields
@classmethod
- def _from_valid_json(cls, jobj):
- return cls(session_id=jobj["sessionID"],
- nonce=jose.b64decode(jobj["nonce"]),
- responses=jobj["responses"],
- signature=other.Signature.from_json(
- jobj["signature"], validate=False),
- contact=jobj.get("contact", []))
+ def from_valid_json(cls, jobj):
+ return cls(
+ session_id=jobj["sessionID"],
+ nonce=util.decode_b64jose(jobj["nonce"]),
+ responses=[challenges.ChallengeResponse.from_valid_json(chall)
+ for chall in jobj["responses"]],
+ signature=other.Signature.from_valid_json(jobj["signature"]),
+ contact=jobj.get("contact", []))
@Message.register # pylint: disable=too-few-public-methods
@@ -261,26 +268,17 @@ class Certificate(Message):
__slots__ = ("certificate", "chain", "refresh")
def _fields_to_json(self):
- fields = {"certificate": self._encode_cert(self.certificate)}
+ fields = {"certificate": util.encode_cert(self.certificate)}
if self.chain:
- fields["chain"] = [self._encode_cert(cert) for cert in self.chain]
+ fields["chain"] = [util.encode_cert(cert) for cert in self.chain]
if self.refresh is not None:
fields["refresh"] = self.refresh
return fields
@classmethod
- def _decode_cert(cls, b64der):
- return util.ComparableX509(M2Crypto.X509.load_cert_der_string(
- jose.b64decode(b64der)))
-
- @classmethod
- def _encode_cert(cls, cert):
- return jose.b64encode(cert.as_der())
-
- @classmethod
- def _from_valid_json(cls, jobj):
- return cls(certificate=cls._decode_cert(jobj["certificate"]),
- chain=[cls._decode_cert(cert) for cert in
+ def from_valid_json(cls, jobj):
+ return cls(certificate=util.decode_cert(jobj["certificate"]),
+ chain=[util.decode_cert(cert) for cert in
jobj.get("chain", [])],
refresh=jobj.get("refresh"))
@@ -328,26 +326,16 @@ class CertificateRequest(Message):
"""
return self.signature.verify(self.csr.as_der())
- @classmethod
- def _decode_csr(cls, b64der):
- return util.ComparableX509(M2Crypto.X509.load_request_der_string(
- jose.b64decode(b64der)))
-
- @classmethod
- def _encode_csr(cls, csr):
- return jose.b64encode(csr.as_der())
-
def _fields_to_json(self):
return {
- "csr": self._encode_csr(self.csr),
+ "csr": util.encode_csr(self.csr),
"signature": self.signature,
}
@classmethod
- def _from_valid_json(cls, jobj):
- return cls(csr=cls._decode_csr(jobj["csr"]),
- signature=other.Signature.from_json(
- jobj["signature"], validate=False))
+ def from_valid_json(cls, jobj):
+ return cls(csr=util.decode_csr(jobj["csr"]),
+ signature=other.Signature.from_valid_json(jobj["signature"]))
@Message.register # pylint: disable=too-few-public-methods
@@ -366,7 +354,7 @@ class Defer(Message):
return fields
@classmethod
- def _from_valid_json(cls, jobj):
+ def from_valid_json(cls, jobj):
return cls(token=jobj["token"], interval=jobj.get("interval"),
message=jobj.get("message"))
@@ -396,7 +384,7 @@ class Error(Message):
return fields
@classmethod
- def _from_valid_json(cls, jobj):
+ def from_valid_json(cls, jobj):
return cls(error=jobj["error"], message=jobj.get("message"),
more_info=jobj.get("moreInfo"))
@@ -412,7 +400,7 @@ class Revocation(Message):
return {}
@classmethod
- def _from_valid_json(cls, jobj):
+ def from_valid_json(cls, jobj):
return cls()
@@ -459,26 +447,16 @@ class RevocationRequest(Message):
"""
return self.signature.verify(self.certificate.as_der())
- @classmethod
- def _decode_cert(cls, b64der):
- return util.ComparableX509(M2Crypto.X509.load_cert_der_string(
- jose.b64decode(b64der)))
-
- @classmethod
- def _encode_cert(cls, cert):
- return jose.b64encode(cert.as_der())
-
def _fields_to_json(self):
return {
- "certificate": self._encode_cert(self.certificate),
+ "certificate": util.encode_cert(self.certificate),
"signature": self.signature,
}
@classmethod
- def _from_valid_json(cls, jobj):
- return cls(certificate=cls._decode_cert(jobj["certificate"]),
- signature=other.Signature.from_json(
- jobj["signature"], validate=False))
+ def from_valid_json(cls, jobj):
+ return cls(certificate=util.decode_cert(jobj["certificate"]),
+ signature=other.Signature.from_valid_json(jobj["signature"]))
@Message.register # pylint: disable=too-few-public-methods
@@ -496,5 +474,5 @@ class StatusRequest(Message):
return {"token": self.token}
@classmethod
- def _from_valid_json(cls, jobj):
+ def from_valid_json(cls, jobj):
return cls(token=jobj["token"])
diff --git a/letsencrypt/acme/messages_test.py b/letsencrypt/acme/messages_test.py
index 018854225..ab9f4f64e 100644
--- a/letsencrypt/acme/messages_test.py
+++ b/letsencrypt/acme/messages_test.py
@@ -3,9 +3,9 @@ import pkg_resources
import unittest
import Crypto.PublicKey.RSA
-import M2Crypto.X509
-import mock
+import M2Crypto
+from letsencrypt.acme import challenges
from letsencrypt.acme import errors
from letsencrypt.acme import jose
from letsencrypt.acme import other
@@ -28,7 +28,13 @@ class MessageTest(unittest.TestCase):
def setUp(self):
# pylint: disable=missing-docstring,too-few-public-methods
from letsencrypt.acme.messages import Message
- class TestMessage(Message):
+
+ class MockParentMessage(Message):
+ # pylint: disable=abstract-method
+ TYPES = {}
+
+ @MockParentMessage.register
+ class MockMessage(MockParentMessage):
acme_type = 'test'
schema = {
'type': 'object',
@@ -37,62 +43,54 @@ class MessageTest(unittest.TestCase):
'name': {'type': 'string'},
},
}
+ __slots__ = ('price', 'name')
@classmethod
- def _from_valid_json(cls, jobj):
- return jobj
+ def from_valid_json(cls, jobj):
+ return cls(price=jobj.get('price'), name=jobj.get('name'))
def _fields_to_json(self):
- return {'foo': 'bar'}
+ # pylint: disable=no-member
+ return {'price': self.price, 'name': self.name}
- self.msg_cls = TestMessage
-
- def test_to_json(self):
- self.assertEqual(self.msg_cls().to_json(), {
- 'type': 'test',
- 'foo': 'bar',
- })
-
- def test_fields_to_json_not_implemented(self):
- from letsencrypt.acme.messages import Message
- # pylint: disable=protected-access
- self.assertRaises(NotImplementedError, Message()._fields_to_json)
-
- @classmethod
- def _from_json(cls, jobj, validate=True):
- from letsencrypt.acme.messages import Message
- return Message.from_json(jobj, validate)
+ self.parent_cls = MockParentMessage
+ self.msg = MockMessage(price=123, name='foo')
def test_from_json_non_dict_fails(self):
- self.assertRaises(errors.ValidationError, self._from_json, [])
+ self.assertRaises(errors.ValidationError, self.parent_cls.from_json, [])
def test_from_json_dict_no_type_fails(self):
- self.assertRaises(errors.ValidationError, self._from_json, {})
+ self.assertRaises(errors.ValidationError, self.parent_cls.from_json, {})
- def test_from_json_unknown_type_fails(self):
- self.assertRaises(errors.UnrecognizedMessageTypeError,
- self._from_json, {'type': 'bar'})
+ def test_from_json_unrecognized_type(self):
+ self.assertRaises(errors.UnrecognizedTypeError,
+ self.parent_cls.from_json, {'type': 'foo'})
- @mock.patch('letsencrypt.acme.messages.Message.TYPES')
- def test_from_json_validate_errors(self, types):
- types.__getitem__.side_effect = lambda x: {'foo': self.msg_cls}[x]
+ def test_from_json_validates(self):
self.assertRaises(errors.SchemaValidationError,
- self._from_json, {'type': 'foo', 'price': 'asd'})
+ self.parent_cls.from_json,
+ {'type': 'test', 'price': 'asd'})
- @mock.patch('letsencrypt.acme.messages.Message.TYPES')
- def test_from_json_valid_returns_cls(self, types):
- types.__getitem__.side_effect = lambda x: {'foo': self.msg_cls}[x]
- self.assertEqual(self._from_json({'type': 'foo'}, validate=False),
- {'type': 'foo'})
+ def test_from_json(self):
+ self.assertEqual(self.msg, self.parent_cls.from_json(
+ {'type': 'test', 'name': 'foo', 'price': 123}))
+
+ def test_json_loads(self):
+ self.assertEqual(self.msg, self.parent_cls.json_loads(
+ '{"type": "test", "name": "foo", "price": 123}'))
+
+ def test_json_dumps(self):
+ self.assertEqual(self.msg.json_dumps(sort_keys=True),
+ '{"name": "foo", "price": 123, "type": "test"}')
class ChallengeTest(unittest.TestCase):
def setUp(self):
- challenges = [
- {'type': 'simpleHttps', 'token': 'IlirfxKKXAsHtmzK29Pj8A'},
- {'type': 'dns', 'token': 'DGyRejmCefe7v4NfDGDKfA'},
- {'type': 'recoveryToken'},
+ challs = [
+ challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'),
+ challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'),
+ challenges.RecoveryToken(),
]
combinations = [[0, 2], [1, 2]]
@@ -100,31 +98,52 @@ class ChallengeTest(unittest.TestCase):
self.msg = Challenge(
session_id='aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
nonce='\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9',
- challenges=challenges, combinations=combinations)
+ challenges=challs, combinations=combinations)
- self.jmsg = {
+ self.jmsg_to = {
'type': 'challenge',
'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
- 'challenges': challenges,
+ 'challenges': challs,
'combinations': combinations,
}
+ self.jmsg_from = {
+ 'type': 'challenge',
+ 'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
+ 'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
+ 'challenges': [chall.to_json() for chall in challs],
+ 'combinations': combinations,
+ }
+
+ def test_resolved_combinations(self):
+ self.assertEqual(self.msg.resolved_combinations, [
+ [
+ challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'),
+ challenges.RecoveryToken()
+ ],
+ [
+ challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'),
+ challenges.RecoveryToken(),
+ ]
+ ])
+
def test_to_json(self):
- self.assertEqual(self.msg.to_json(), self.jmsg)
+ self.assertEqual(self.msg.to_json(), self.jmsg_to)
def test_from_json(self):
from letsencrypt.acme.messages import Challenge
- self.assertEqual(Challenge.from_json(self.jmsg), self.msg)
+ self.assertEqual(Challenge.from_json(self.jmsg_from), self.msg)
def test_json_without_optionals(self):
- del self.jmsg['combinations']
+ del self.jmsg_from['combinations']
+ del self.jmsg_to['combinations']
from letsencrypt.acme.messages import Challenge
- msg = Challenge.from_json(self.jmsg)
+ msg = Challenge.from_json(self.jmsg_from)
self.assertEqual(msg.combinations, [])
- self.assertEqual(msg.to_json(), self.jmsg)
+ self.assertEqual(msg.to_json(), self.jmsg_to)
class ChallengeRequestTest(unittest.TestCase):
@@ -149,7 +168,7 @@ class ChallengeRequestTest(unittest.TestCase):
class AuthorizationTest(unittest.TestCase):
def setUp(self):
- jwk = jose.JWK(key=KEY.publickey())
+ jwk = other.JWK(key=KEY.publickey())
from letsencrypt.acme.messages import Authorization
self.msg = Authorization(recovery_token='tok', jwk=jwk,
@@ -189,13 +208,13 @@ class AuthorizationRequestTest(unittest.TestCase):
def setUp(self):
self.responses = [
- {'type': 'simpleHttps', 'path': 'Hf5GrX4Q7EBax9hc2jJnfw'},
+ challenges.SimpleHTTPSResponse(path='Hf5GrX4Q7EBax9hc2jJnfw'),
None, # null
- {'type': 'recoveryToken', 'token': '23029d88d9e123e'},
+ challenges.RecoveryTokenResponse(token='23029d88d9e123e'),
]
self.contact = ["mailto:cert-admin@example.com", "tel:+12025551212"]
signature = other.Signature(
- alg='RS256', jwk=jose.JWK(key=KEY.publickey()),
+ alg='RS256', jwk=other.JWK(key=KEY.publickey()),
sig='-v\xd8\xc2\xa3\xba0\xd6\x92\x16\xb5.\xbe\xa1[\x04\xbe'
'\x1b\xa1X\xd2)\x18\x94\x8f\xd7\xd0\xc0\xbbcI`W\xdf v'
'\xe4\xed\xe8\x03J\xe8\xc8\xc8W\x94\x94cj(\xe7\xaa$'
@@ -223,7 +242,8 @@ class AuthorizationRequestTest(unittest.TestCase):
'type': 'authorizationRequest',
'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
- 'responses': self.responses,
+ 'responses': [None if response is None else response.to_json()
+ for response in self.responses],
'signature': signature.to_json(),
'contact': self.contact,
}
@@ -300,7 +320,7 @@ class CertificateRequestTest(unittest.TestCase):
def setUp(self):
signature = other.Signature(
- alg='RS256', jwk=jose.JWK(key=KEY.publickey()),
+ alg='RS256', jwk=other.JWK(key=KEY.publickey()),
sig='\x15\xed\x84\xaa:\xf2DO\x0e9 \xbcg\xf8\xc0\xcf\x87\x9a'
'\x95\xeb\xffT[\x84[\xec\x85\x7f\x8eK\xe9\xc2\x12\xc8Q'
'\xafo\xc6h\x07\xba\xa6\xdf\xd1\xa7"$\xba=Z\x13n\x14\x0b'
@@ -408,10 +428,7 @@ class RevocationTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.messages import Revocation
self.msg = Revocation()
-
- self.jmsg = {
- 'type': 'revocation',
- }
+ self.jmsg = {'type': 'revocation'}
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
@@ -427,7 +444,7 @@ class RevocationRequestTest(unittest.TestCase):
self.sig_nonce = '\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9'
signature = other.Signature(
- alg='RS256', jwk=jose.JWK(key=KEY.publickey()),
+ alg='RS256', jwk=other.JWK(key=KEY.publickey()),
sig='eJ\xfe\x12"U\x87\x8b\xbf/ ,\xdeP\xb2\xdc1\xb00\xe5\x1dB'
'\xfch<\xc6\x9eH@!\x1c\x16\xb2\x0b_\xc4\xddP\x89\xc8\xce?'
'\x16g\x069I\xb9\xb3\x91\xb9\x0e$3\x9f\x87\x8e\x82\xca\xc5'
diff --git a/letsencrypt/acme/other.py b/letsencrypt/acme/other.py
index 1fe0d9463..c21611103 100644
--- a/letsencrypt/acme/other.py
+++ b/letsencrypt/acme/other.py
@@ -1,15 +1,59 @@
-"""JSON objects in ACME protocol other than messages."""
+"""Other ACME objects."""
+import binascii
import logging
-from Crypto import Random
+import Crypto.Random
import Crypto.Hash.SHA256
+import Crypto.PublicKey.RSA
import Crypto.Signature.PKCS1_v1_5
+from letsencrypt.acme import errors
from letsencrypt.acme import jose
from letsencrypt.acme import util
-class Signature(util.JSONDeSerializable, util.ImmutableMap):
+class JWK(util.ACMEObject):
+ # pylint: disable=too-few-public-methods
+ """JSON Web Key.
+
+ .. todo:: Currently works for RSA public keys only.
+
+ """
+ __slots__ = ('key',)
+
+ @classmethod
+ def _encode_param(cls, data):
+ def _leading_zeros(arg):
+ if len(arg) % 2:
+ return '0' + arg
+ return arg
+
+ return jose.b64encode(binascii.unhexlify(
+ _leading_zeros(hex(data)[2:].rstrip('L'))))
+
+ @classmethod
+ def _decode_param(cls, data):
+ try:
+ return long(binascii.hexlify(util.decode_b64jose(data)), 16)
+ except ValueError: # invalid literal for long() with base 16
+ raise errors.ValidationError(data)
+
+ def to_json(self):
+ return {
+ 'kty': 'RSA', # TODO
+ 'n': self._encode_param(self.key.n),
+ 'e': self._encode_param(self.key.e),
+ }
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ assert 'RSA' == jobj['kty'] # TODO
+ return cls(key=Crypto.PublicKey.RSA.construct(
+ (cls._decode_param(jobj['n']),
+ cls._decode_param(jobj['e']))))
+
+
+class Signature(util.ACMEObject):
"""ACME signature.
:ivar str alg: Signature algorithm.
@@ -17,19 +61,18 @@ class Signature(util.JSONDeSerializable, util.ImmutableMap):
:ivar str nonce: Nonce.
:ivar jwk: JWK.
- :type jwk: :class:`letsencrypt.acme.jose.JWK`
+ :type jwk: :class:`JWK`
.. todo:: Currently works for RSA keys only.
"""
__slots__ = ('alg', 'sig', 'nonce', 'jwk')
- schema = util.load_schema('signature')
- NONCE_LEN = 16
- """Size of nonce in bytes, as specified in the ACME protocol."""
+ NONCE_SIZE = 16
+ """Minimum size of nonce in bytes."""
@classmethod
- def from_msg(cls, msg, key, nonce=None):
+ def from_msg(cls, msg, key, nonce=None, nonce_size=None):
"""Create signature with nonce prepended to the message.
.. todo:: Protect against crypto unicode errors... is this sufficient?
@@ -40,13 +83,15 @@ class Signature(util.JSONDeSerializable, util.ImmutableMap):
:param key: Key used for signing.
:type key: :class:`Crypto.PublicKey.RSA`
- :param nonce: Nonce to be used. If None, nonce of
- :const:`NONCE_LEN` size will be randomly generated.
- :type nonce: str or None
+ :param str 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`.
"""
+ nonce_size = cls.NONCE_SIZE if nonce_size is None else nonce_size
if nonce is None:
- nonce = Random.get_random_bytes(cls.NONCE_LEN)
+ nonce = Crypto.Random.get_random_bytes(nonce_size)
msg_with_nonce = nonce + msg
hashed = Crypto.Hash.SHA256.new(msg_with_nonce)
@@ -55,7 +100,7 @@ class Signature(util.JSONDeSerializable, util.ImmutableMap):
logging.debug('%s signed as %s', msg_with_nonce, sig)
return cls(alg='RS256', sig=sig, nonce=nonce,
- jwk=jose.JWK(key=key.publickey()))
+ jwk=JWK(key=key.publickey()))
def verify(self, msg):
"""Verify the signature.
@@ -68,7 +113,6 @@ class Signature(util.JSONDeSerializable, util.ImmutableMap):
hashed, self.sig)
def to_json(self):
- """Prepare JSON serializable object."""
return {
'alg': self.alg,
'sig': jose.b64encode(self.sig),
@@ -77,7 +121,9 @@ class Signature(util.JSONDeSerializable, util.ImmutableMap):
}
@classmethod
- def _from_valid_json(cls, jobj):
- return cls(alg=jobj['alg'], sig=jose.b64decode(jobj['sig']),
- nonce=jose.b64decode(jobj['nonce']),
- jwk=jose.JWK.from_json(jobj['jwk'], validate=False))
+ def from_valid_json(cls, jobj):
+ assert jobj['alg'] == 'RS256' # TODO: support other algorithms
+ return cls(alg=jobj['alg'], sig=util.decode_b64jose(jobj['sig']),
+ nonce=util.decode_b64jose(
+ jobj['nonce'], cls.NONCE_SIZE, minimum=True),
+ jwk=JWK.from_valid_json(jobj['jwk']))
diff --git a/letsencrypt/acme/other_test.py b/letsencrypt/acme/other_test.py
index 292fbd886..273f19116 100644
--- a/letsencrypt/acme/other_test.py
+++ b/letsencrypt/acme/other_test.py
@@ -4,14 +4,63 @@ import unittest
import Crypto.PublicKey.RSA
-from letsencrypt.acme import jose
+from letsencrypt.acme import errors
RSA256_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
'letsencrypt.client.tests', 'testdata/rsa256_key.pem'))
+RSA512_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
+ 'letsencrypt.client.tests', 'testdata/rsa512_key.pem'))
-class SigatureTest(unittest.TestCase):
+class JWKTest(unittest.TestCase):
+ """Tests fro letsencrypt.acme.other.JWK."""
+
+ def setUp(self):
+ from letsencrypt.acme.other import JWK
+ self.jwk256 = JWK(key=RSA256_KEY.publickey())
+ self.jwk256json = {
+ 'kty': 'RSA',
+ 'e': 'AQAB',
+ 'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5'
+ '80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q',
+ }
+ self.jwk512 = JWK(key=RSA512_KEY.publickey())
+ self.jwk512json = {
+ 'kty': 'RSA',
+ 'e': 'AQAB',
+ 'n': '9LYRcVE3Nr-qleecEcX8JwVDnjeG1X7ucsCasuuZM0e09c'
+ 'mYuUzxIkMjO_9x4AVcvXXRXPEV-LzWWkfkTlzRMw',
+ }
+
+ def test_equals(self):
+ self.assertEqual(self.jwk256, self.jwk256)
+ self.assertEqual(self.jwk512, self.jwk512)
+
+ def test_not_equals(self):
+ self.assertNotEqual(self.jwk256, self.jwk512)
+ self.assertNotEqual(self.jwk512, self.jwk256)
+
+ def test_to_json(self):
+ self.assertEqual(self.jwk256.to_json(), self.jwk256json)
+ self.assertEqual(self.jwk512.to_json(), self.jwk512json)
+
+ def test_from_json(self):
+ from letsencrypt.acme.other import JWK
+ self.assertEqual(self.jwk256, JWK.from_valid_json(self.jwk256json))
+ # TODO: fix schemata to allow RSA512
+ #self.assertEqual(self.jwk512, JWK.from_json(self.jwk512json))
+
+ def test_from_json_non_schema_errors(self):
+ # valid against schema, but still failing
+ from letsencrypt.acme.other import JWK
+ self.assertRaises(errors.ValidationError, JWK.from_valid_json,
+ {'kty': 'RSA', 'e': 'AQAB', 'n': ''})
+ self.assertRaises(errors.ValidationError, JWK.from_valid_json,
+ {'kty': 'RSA', 'e': 'AQAB', 'n': '1'})
+
+
+class SignatureTest(unittest.TestCase):
# pylint: disable=too-many-instance-attributes
"""Tests for letsencrypt.acme.sig.Signature."""
@@ -23,7 +72,9 @@ class SigatureTest(unittest.TestCase):
'\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.jwk = jose.JWK(key=RSA256_KEY.publickey())
+
+ from letsencrypt.acme.other import JWK
+ self.jwk = JWK(key=RSA256_KEY.publickey())
b64sig = ('SUPYKucUnhlTt8_sMxLiigOYdf_wlOLXPI-o7aRLTsOquVjDd6r'
'AX9AFJHk-bCMQPJbSzXKjG6H1IWbvxjS2Ew')
@@ -78,9 +129,16 @@ class SigatureTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.other import Signature
- # pylint: disable=protected-access
self.assertEqual(
- self.signature, Signature._from_valid_json(self.jsig_from))
+ self.signature, Signature.from_valid_json(self.jsig_from))
+
+ def test_from_json_non_schema_errors(self):
+ from letsencrypt.acme.other import Signature
+ jwk = self.jwk.to_json()
+ self.assertRaises(errors.ValidationError, Signature.from_valid_json, {
+ 'alg': 'RS256', 'sig': 'x', 'nonce': '', 'jwk': jwk})
+ self.assertRaises(errors.ValidationError, Signature.from_valid_json, {
+ 'alg': 'RS256', 'sig': '', 'nonce': 'x', 'jwk': jwk})
if __name__ == '__main__':
diff --git a/letsencrypt/acme/util.py b/letsencrypt/acme/util.py
index 8906e584a..cc00dc2bb 100644
--- a/letsencrypt/acme/util.py
+++ b/letsencrypt/acme/util.py
@@ -1,12 +1,14 @@
"""ACME utilities."""
+import binascii
import json
import pkg_resources
-import jsonschema
+import M2Crypto
import zope.interface
from letsencrypt.acme import errors
from letsencrypt.acme import interfaces
+from letsencrypt.acme import jose
class ComparableX509(object): # pylint: disable=too-few-public-methods
@@ -34,78 +36,14 @@ def load_schema(name):
__name__, "schemata/%s.json" % name)))
-class JSONDeSerializable(object):
- """JSON (de)serializable object."""
- zope.interface.implements(interfaces.IJSONSerializable)
-
- schema = NotImplemented
-
- @classmethod
- def validate_json(cls, jobj):
- """Validate JSON object against schema.
-
- :raises letsencrypt.acme.errors.SchemaValidationError: if object
- couldn't be validated.
-
- """
- try:
- jsonschema.validate(jobj, cls.schema)
- except jsonschema.ValidationError as error:
- raise errors.SchemaValidationError(error)
-
- @classmethod
- def from_json(cls, jobj, validate=True):
- """Deserialize from JSON.
-
- Note that the input ``jobj`` has not been sanitized in any way.
-
- :param jobj: JSON object.
- :param bool validate: Validate against schema before deserializing.
- Useful if :class:`JWK` is part of already validated json object.
-
- :raises letsencrypt.acme.errors.SchemaValidationError: if ``validate``
- was ``True`` and object couldn't be validated.
-
- :returns: instance of the class
-
- """
- if validate:
- cls.validate_json(jobj)
- return cls._from_valid_json(jobj)
-
- @classmethod
- def _from_valid_json(cls, jobj):
- """Deserializa from valid JSON object.
-
- :param jobj: JSON object that has been validated against schema.
-
- """
- raise NotImplementedError()
-
- @classmethod
- def json_loads(cls, json_string, validate=True):
- """Load JSON string."""
- return cls.from_json(json.loads(json_string), validate)
-
- def to_json(self):
- """Prepare JSON serializable object."""
- raise NotImplementedError()
-
- def json_dumps(self):
- """Dump to JSON string using proper serializer.
-
- :returns: JSON serialized string.
- :rtype: str
-
- """
- return json.dumps(self, default=dump_ijsonserializable)
-
-
def dump_ijsonserializable(python_object):
"""Serialize IJSONSerializable to JSON.
This is meant to be passed to :func:`json.dumps` as ``default``
- argument.
+ argument in order to facilitate recursive calls to
+ :meth:`~letsencrypt.acme.interfaces.IJSONSerializable.to_json`.
+ Please see :meth:`letsencrypt.acme.interfaces.IJSONSerializable.to_json`
+ for an example.
"""
# providedBy | pylint: disable=no-member
@@ -145,3 +83,165 @@ class ImmutableMap(object): # pylint: disable=too-few-public-methods
return '{0}({1})'.format(self.__class__.__name__, ', '.join(
'{0}={1!r}'.format(slot, getattr(self, slot))
for slot in self.__slots__))
+
+
+class ACMEObject(ImmutableMap): # pylint: disable=too-few-public-methods
+ """ACME object."""
+ zope.interface.implements(interfaces.IJSONSerializable)
+ zope.interface.classImplements(interfaces.IJSONDeserializable)
+
+ def to_json(self): # pragma: no cover
+ """Serialize to JSON."""
+ raise NotImplementedError()
+
+ @classmethod
+ def from_valid_json(cls, jobj): # pragma: no cover
+ """Deserialize from valid JSON object."""
+ raise NotImplementedError()
+
+
+def decode_b64jose(value, size=None, minimum=False):
+ """Decode ACME object JOSE Base64 encoded field.
+
+ :param str value: Encoded field value.
+ :param int size: If specified, this function will check if data size
+ (after decoding) matches.
+ :param bool minimum: If ``True``, then ``size`` is the minimum required
+ size, otherwise ``size`` must be exact.
+
+ :raises letsencrypt.acme.errors.ValidationError: if anything goes wrong
+ :returns: Decoded value.
+
+ """
+ try:
+ decoded = jose.b64decode(value)
+ except TypeError:
+ raise errors.ValidationError()
+
+ if size is not None and ((not minimum and len(decoded) != size)
+ or (minimum and len(decoded) < size)):
+ raise errors.ValidationError()
+
+ return decoded
+
+
+def decode_hex16(value, size=None, minimum=False):
+ """Decode ACME object hex16-encoded field.
+
+ :param str value: Encoded field value.
+ :param int size: If specified, this function will check if data size
+ (after decoding) matches.
+ :param bool minimum: If ``True``, then ``size`` is the minimum required
+ size, otherwise ``size`` must be exact.
+
+ """
+ # binascii.hexlify.__doc__: "The resulting string is therefore twice
+ # as long as the length of data."
+ if size is not None and ((not minimum and len(value) != size * 2)
+ or (minimum and len(value) < size * 2)):
+ raise errors.ValidationError()
+ try:
+ return binascii.unhexlify(value)
+ except TypeError as error: # odd-length string (binascci.unhexlify.__doc__)
+ raise errors.ValidationError(error)
+
+
+def encode_cert(cert):
+ """Encode ACME object X509 certificate field."""
+ return jose.b64encode(cert.as_der())
+
+
+def decode_cert(b64der):
+ """Decode ACME object X509 certificate field.
+
+ :param str b64der: Input data that's meant to be valid base64
+ DER-encoded certificate.
+
+ :raises letsencrypt.acme.errors.ValidationError: if anything goes wrong
+
+ :returns: Decoded certificate.
+ :rtype: :class:`M2Crypto.X509.X509` wrapped in :class:`ComparableX509`.
+
+ """
+ try:
+ return ComparableX509(M2Crypto.X509.load_cert_der_string(
+ decode_b64jose(b64der)))
+ except M2Crypto.X509.X509Error:
+ raise errors.ValidationError()
+
+
+def encode_csr(csr):
+ """Encode ACME object CSR field."""
+ return encode_cert(csr)
+
+
+def decode_csr(b64der):
+ """Decode ACME object CSR field.
+
+ :param str b64der: Input data that's meant to be valid base64
+ DER-encoded CSR.
+
+ :raises letsencrypt.acme.errors.ValidationError: if anything goes wrong
+
+ :returns: Decoded certificate.
+ :rtype: :class:`M2Crypto.X509.X509` wrapped in :class:`ComparableX509`.
+
+ """
+ try:
+ return ComparableX509(M2Crypto.X509.load_request_der_string(
+ decode_b64jose(b64der)))
+ except M2Crypto.X509.X509Error:
+ raise errors.ValidationError()
+
+
+class TypedACMEObject(ACMEObject):
+ """ACME object with type (immutable)."""
+
+ acme_type = NotImplemented
+ """ACME "type" field. Subclasses must override."""
+
+ TYPES = NotImplemented
+ """Types registered for JSON deserialization"""
+
+ @classmethod
+ def register(cls, msg_cls):
+ """Register class for JSON deserialization."""
+ cls.TYPES[msg_cls.acme_type] = msg_cls
+ return msg_cls
+
+ def to_json(self):
+ """Get JSON serializable object.
+
+ :returns: Serializable JSON object representing ACME typed object.
+ :rtype: dict
+
+ """
+ jobj = self._fields_to_json()
+ jobj["type"] = self.acme_type
+ return jobj
+
+ def _fields_to_json(self): # pragma: no cover
+ """Prepare ACME object fields for JSON serialiazation.
+
+ Subclasses must override this method.
+
+ :returns: Serializable JSON object containg all ACME object fields
+ apart from "type".
+ :rtype: dict
+
+ """
+ raise NotImplementedError()
+
+ @classmethod
+ def from_valid_json(cls, jobj):
+ """Deserialize ACME object from valid JSON object.
+
+ :raises letsencrypt.acme.errors.UnrecognizedTypeError: if type
+ of the ACME object has not been registered.
+
+ """
+ try:
+ msg_cls = cls.TYPES[jobj["type"]]
+ except KeyError:
+ raise errors.UnrecognizedTypeError(jobj["type"])
+ return msg_cls.from_valid_json(jobj)
diff --git a/letsencrypt/acme/util_test.py b/letsencrypt/acme/util_test.py
index cf71963e8..0b500a2c7 100644
--- a/letsencrypt/acme/util_test.py
+++ b/letsencrypt/acme/util_test.py
@@ -1,87 +1,33 @@
"""Tests for letsencrypt.acme.util."""
import functools
import json
+import os
+import pkg_resources
import unittest
+import M2Crypto
import zope.interface
from letsencrypt.acme import errors
from letsencrypt.acme import interfaces
-class MockJSONSerialiazable(object):
- # pylint: disable=missing-docstring,too-few-public-methods,no-self-use
- zope.interface.implements(interfaces.IJSONSerializable)
-
- def to_json(self):
- return [3, 2, 1]
-
-
-class JSONDeSerializableTest(unittest.TestCase):
- """Tests for letsencrypt.acme.util.JSONDeSerializable."""
-
- def setUp(self):
- from letsencrypt.acme.util import JSONDeSerializable
-
- class Tester(JSONDeSerializable):
- # pylint: disable=missing-docstring,no-self-use,
- # pylint: disable=too-few-public-methods
- zope.interface.implements(interfaces.IJSONSerializable)
-
- schema = {'type': 'integer'}
-
- def __init__(self, jobj):
- self.jobj = jobj
-
- @classmethod
- def _from_valid_json(cls, jobj):
- return cls(jobj)
-
- def to_json(self):
- return {'foo': MockJSONSerialiazable()}
-
- self.tester_cls = Tester
-
- def test_validate_invalid_json(self):
- self.assertRaises(errors.SchemaValidationError,
- self.tester_cls.validate_json, 'bang!')
-
- def test_validate_valid_json(self):
- self.tester_cls.validate_json(5)
-
- def test_from_json(self):
- self.assertEqual(5, self.tester_cls.from_json(5, validate=True).jobj)
-
- def test_from_json_no_validation(self):
- self.assertEqual(['1', 2], self.tester_cls.from_json(
- ['1', 2], validate=False).jobj)
-
- def test_from_valid_json_raises_error(self):
- from letsencrypt.acme.util import JSONDeSerializable
- # pylint: disable=protected-access
- self.assertRaises(
- NotImplementedError, JSONDeSerializable._from_valid_json, 'foo')
-
- def test_json_loads(self):
- tester = self.tester_cls.json_loads('5', validate=True)
- self.assertEqual(tester.jobj, 5)
-
- def test_json_loads_no_validation(self):
- self.assertEqual(
- 'foo', self.tester_cls.json_loads('"foo"', validate=False).jobj)
-
- def test_to_json_raises_error(self):
- from letsencrypt.acme.util import JSONDeSerializable
- self.assertRaises(NotImplementedError, JSONDeSerializable().to_json)
-
- def test_json_dumps(self):
- self.assertEqual(
- self.tester_cls('foo').json_dumps(), '{"foo": [3, 2, 1]}')
+CERT = M2Crypto.X509.load_cert(pkg_resources.resource_filename(
+ 'letsencrypt.client.tests', os.path.join('testdata', 'cert.pem')))
+CSR = M2Crypto.X509.load_request(pkg_resources.resource_filename(
+ 'letsencrypt.client.tests', os.path.join('testdata', 'csr.pem')))
class DumpIJSONSerializableTest(unittest.TestCase):
"""Tests for letsencrypt.acme.util.dump_ijsonserializable."""
+ class MockJSONSerialiazable(object):
+ # pylint: disable=missing-docstring,too-few-public-methods,no-self-use
+ zope.interface.implements(interfaces.IJSONSerializable)
+
+ def to_json(self):
+ return [3, 2, 1]
+
@classmethod
def _call(cls, obj):
from letsencrypt.acme.util import dump_ijsonserializable
@@ -91,7 +37,7 @@ class DumpIJSONSerializableTest(unittest.TestCase):
self.assertEqual('5', self._call(5))
def test_ijsonserializable(self):
- self.assertEqual('[3, 2, 1]', self._call(MockJSONSerialiazable()))
+ self.assertEqual('[3, 2, 1]', self._call(self.MockJSONSerialiazable()))
def test_raises_type_error(self):
self.assertRaises(TypeError, self._call, object())
@@ -163,5 +109,132 @@ class ImmutableMapTest(unittest.TestCase):
self.assertEqual("B(x='foo', y='bar')", repr(self.B(x='foo', y='bar')))
+class EncodersAndDecodersTest(unittest.TestCase):
+ """Tests for encoders and decoders from letsencrypt.acme.util"""
+ # pylint: disable=protected-access
+
+ def setUp(self):
+ self.b64_cert = (
+ 'MIIB3jCCAYigAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhM'
+ 'CVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKz'
+ 'ApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxF'
+ 'DASBgNVBAMMC2V4YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIx'
+ 'ODIyMzQ0NVowdzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRI'
+ 'wEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTW'
+ 'ljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4YW1wbGUuY29tMFwwD'
+ 'QYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR7R_drnBSQ_zfx1vQLHUbFLh1'
+ 'AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c-pVE6K-EdE_twuUCAwE'
+ 'AATANBgkqhkiG9w0BAQsFAANBAC24z0IdwIVKSlntksllvr6zJepBH5fMnd'
+ 'fk3XJp10jT6VE-14KNtjh02a56GoraAvJAT5_H67E8GvJ_ocNnB_o'
+ )
+ self.b64_csr = (
+ 'MIIBXTCCAQcCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2F'
+ 'uMRIwEAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECw'
+ 'wWVW5pdmVyc2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAwwLZXhhbXBsZS5jb'
+ '20wXDANBgkqhkiG9w0BAQEFAANLADBIAkEArHVztFHtH92ucFJD_N_HW9As'
+ 'dRsUuHUBBBDlHwNlRd3fp580rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3'
+ 'C5QIDAQABoCkwJwYJKoZIhvcNAQkOMRowGDAWBgNVHREEDzANggtleGFtcG'
+ 'xlLmNvbTANBgkqhkiG9w0BAQsFAANBAHJH_O6BtC9aGzEVCMGOZ7z9iIRHW'
+ 'Szr9x_bOzn7hLwsbXPAgO1QxEwL-X-4g20Gn9XBE1N9W6HCIEut2d8wACg'
+ )
+
+ def test_decode_b64_jose_padding_error(self):
+ from letsencrypt.acme.util import decode_b64jose
+ self.assertRaises(errors.ValidationError, decode_b64jose, 'x')
+
+ def test_decode_b64_jose_size(self):
+ from letsencrypt.acme.util import decode_b64jose
+ self.assertEqual('foo', decode_b64jose('Zm9v', size=3))
+ self.assertRaises(
+ errors.ValidationError, decode_b64jose, 'Zm9v', size=2)
+ self.assertRaises(
+ errors.ValidationError, decode_b64jose, 'Zm9v', size=4)
+
+ def test_decode_b64_jose_minimum_size(self):
+ from letsencrypt.acme.util import decode_b64jose
+ self.assertEqual('foo', decode_b64jose('Zm9v', size=3, minimum=True))
+ self.assertEqual('foo', decode_b64jose('Zm9v', size=2, minimum=True))
+ self.assertRaises(errors.ValidationError, decode_b64jose,
+ 'Zm9v', size=4, minimum=True)
+
+ def test_decode_hex16(self):
+ from letsencrypt.acme.util import decode_hex16
+ self.assertEqual('foo', decode_hex16('666f6f'))
+
+ def test_decode_hex16_minimum_size(self):
+ from letsencrypt.acme.util import decode_hex16
+ self.assertEqual('foo', decode_hex16('666f6f', size=3, minimum=True))
+ self.assertEqual('foo', decode_hex16('666f6f', size=2, minimum=True))
+ self.assertRaises(errors.ValidationError, decode_hex16,
+ '666f6f', size=4, minimum=True)
+
+ def test_decode_hex16_odd_length(self):
+ from letsencrypt.acme.util import decode_hex16
+ self.assertRaises(errors.ValidationError, decode_hex16, 'x')
+
+ def test_encode_cert(self):
+ from letsencrypt.acme.util import encode_cert
+ self.assertEqual(self.b64_cert, encode_cert(CERT))
+
+ def test_decode_cert(self):
+ from letsencrypt.acme.util import ComparableX509
+ from letsencrypt.acme.util import decode_cert
+ cert = decode_cert(self.b64_cert)
+ self.assertTrue(isinstance(cert, ComparableX509))
+ self.assertEqual(cert, CERT)
+ self.assertRaises(errors.ValidationError, decode_cert, '')
+
+ def test_encode_csr(self):
+ from letsencrypt.acme.util import encode_csr
+ self.assertEqual(self.b64_csr, encode_csr(CSR))
+
+ def test_decode_csr(self):
+ from letsencrypt.acme.util import ComparableX509
+ from letsencrypt.acme.util import decode_csr
+ csr = decode_csr(self.b64_csr)
+ self.assertTrue(isinstance(csr, ComparableX509))
+ self.assertEqual(csr, CSR)
+ self.assertRaises(errors.ValidationError, decode_csr, '')
+
+
+class TypedACMEObjectTest(unittest.TestCase):
+
+ def setUp(self):
+ from letsencrypt.acme.util import TypedACMEObject
+
+ # pylint: disable=missing-docstring,abstract-method
+ # pylint: disable=too-few-public-methods
+
+ class MockParentTypedACMEObject(TypedACMEObject):
+ TYPES = {}
+
+ @MockParentTypedACMEObject.register
+ class MockTypedACMEObject(MockParentTypedACMEObject):
+ acme_type = 'test'
+
+ @classmethod
+ def from_valid_json(cls, unused_obj):
+ return '!'
+
+ def _fields_to_json(self):
+ return {'foo': 'bar'}
+
+ self.parent_cls = MockParentTypedACMEObject
+ self.msg = MockTypedACMEObject()
+
+ def test_to_json(self):
+ self.assertEqual(self.msg.to_json(), {
+ 'type': 'test',
+ 'foo': 'bar',
+ })
+
+ def test_from_json_unknown_type_fails(self):
+ self.assertRaises(errors.UnrecognizedTypeError,
+ self.parent_cls.from_valid_json, {'type': 'bar'})
+
+ def test_from_json_returns_obj(self):
+ self.assertEqual(self.parent_cls.from_valid_json({'type': 'test'}), '!')
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/letsencrypt/client/achallenges.py b/letsencrypt/client/achallenges.py
new file mode 100644
index 000000000..835bd1e8d
--- /dev/null
+++ b/letsencrypt/client/achallenges.py
@@ -0,0 +1,102 @@
+"""Client annotated ACME challenges.
+
+Please use names such as ``achall`` and ``ichall`` (respectively ``achalls``
+and ``ichalls`` for collections) to distiguish from variables "of type"
+:class:`letsencrypt.acme.challenges.Challenge` (denoted by ``chall``)::
+
+ from letsencrypt.acme import challenges
+ from letsencrypt.client import achallenges
+
+ chall = challenges.DNS(token='foo')
+ achall = achallenges.DNS(chall=chall, domain='example.com')
+ ichall = achallenges.Indexed(achall=achall, index=0)
+
+Note, that all annotated challenges act as a proxy objects::
+
+ ichall.token == achall.token == chall.token
+
+"""
+from letsencrypt.acme import challenges
+from letsencrypt.acme import util as acme_util
+
+from letsencrypt.client import crypto_util
+
+
+# pylint: disable=too-few-public-methods
+
+
+class AnnotatedChallenge(acme_util.ImmutableMap):
+ """Client annotated challenge.
+
+ Wraps around :class:`~letsencrypt.acme.challenges.Challenge` and
+ annotates with data usfeul for the client.
+
+ """
+ acme_type = NotImplemented
+
+ def __getattr__(self, name):
+ return getattr(self.chall, name)
+
+
+class DVSNI(AnnotatedChallenge):
+ """Client annotated "dvsni" ACME challenge."""
+ __slots__ = ('chall', 'domain', 'key')
+ acme_type = challenges.DVSNI
+
+ def gen_cert_and_response(self, s=None): # pylint: disable=invalid-name
+ """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:`letsencrypt.acme.challenges.DVSNIResponse`.
+ :rtype: tuple
+
+ """
+ response = challenges.DVSNIResponse(s=s)
+ cert_pem = crypto_util.make_ss_cert(self.key.pem, [
+ self.nonce_domain, self.domain, response.z_domain(self.chall)])
+ return cert_pem, response
+
+
+class SimpleHTTPS(AnnotatedChallenge):
+ """Client annotated "simpleHttps" ACME challenge."""
+ __slots__ = ('chall', 'domain', 'key')
+ acme_type = challenges.SimpleHTTPS
+
+
+class DNS(AnnotatedChallenge):
+ """Client annotated "dns" ACME challenge."""
+ __slots__ = ('chall', 'domain')
+ acme_type = challenges.DNS
+
+
+class RecoveryContact(AnnotatedChallenge):
+ """Client annotated "recoveryContact" ACME challenge."""
+ __slots__ = ('chall', 'domain')
+ acme_type = challenges.RecoveryContact
+
+
+class RecoveryToken(AnnotatedChallenge):
+ """Client annotated "recoveryToken" ACME challenge."""
+ __slots__ = ('chall', 'domain')
+ acme_type = challenges.RecoveryToken
+
+
+class ProofOfPossession(AnnotatedChallenge):
+ """Client annotated "proofOfPossession" ACME challenge."""
+ __slots__ = ('chall', 'domain')
+ acme_type = challenges.ProofOfPossession
+
+
+class Indexed(acme_util.ImmutableMap):
+ """Indexed and annotated ACME challenge.
+
+ Wraps around :class:`AnnotatedChallenge` and annotates with an
+ ``index`` in order to maintain the proper position of the response
+ within a larger challenge list.
+
+ """
+ __slots__ = ('achall', 'index')
+
+ def __getattr__(self, name):
+ return getattr(self.achall, name)
diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py
index af71ff5f7..93db689f8 100644
--- a/letsencrypt/client/apache/configurator.py
+++ b/letsencrypt/client/apache/configurator.py
@@ -9,8 +9,10 @@ import sys
import zope.interface
+from letsencrypt.acme import challenges
+
+from letsencrypt.client import achallenges
from letsencrypt.client import augeas_configurator
-from letsencrypt.client import challenge_util
from letsencrypt.client import constants
from letsencrypt.client import errors
from letsencrypt.client import interfaces
@@ -971,34 +973,26 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
###########################################################################
def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use
"""Return list of challenge preferences."""
- return ["dvsni"]
+ return [challenges.DVSNI]
- def perform(self, chall_list):
+ def perform(self, achalls):
"""Perform the configuration related challenge.
This function currently assumes all challenges will be fulfilled.
If this turns out not to be the case in the future. Cleanup and
outstanding challenges will have to be designed better.
- :param list chall_list: List of challenges to be
- fulfilled by configurator.
-
- :returns: list of responses. All responses are returned in the same
- order as received by the perform function. A None response
- indicates the challenge was not perfromed.
- :rtype: list
-
"""
- self._chall_out += len(chall_list)
- responses = [None] * len(chall_list)
+ self._chall_out += len(achalls)
+ responses = [None] * len(achalls)
apache_dvsni = dvsni.ApacheDvsni(self)
- for i, chall in enumerate(chall_list):
- if isinstance(chall, challenge_util.DvsniChall):
+ for i, achall in enumerate(achalls):
+ if isinstance(achall, achallenges.DVSNI):
# Currently also have dvsni hold associated index
# of the challenge. This helps to put all of the responses back
# together when they are all complete.
- apache_dvsni.add_chall(chall, i)
+ apache_dvsni.add_chall(achall, i)
sni_response = apache_dvsni.perform()
# Must restart in order to activate the challenges.
@@ -1013,9 +1007,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
return responses
- def cleanup(self, chall_list):
+ def cleanup(self, achalls):
"""Revert all challenges."""
- self._chall_out -= len(chall_list)
+ self._chall_out -= len(achalls)
# If all of the challenges have been finished, clean up everything
if self._chall_out <= 0:
diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py
index 9b4cd957a..b980fdb36 100644
--- a/letsencrypt/client/apache/dvsni.py
+++ b/letsencrypt/client/apache/dvsni.py
@@ -2,9 +2,6 @@
import logging
import os
-from letsencrypt.client import challenge_util
-from letsencrypt.client import constants
-
from letsencrypt.client.apache import parser
@@ -15,18 +12,14 @@ class ApacheDvsni(object):
:type configurator:
:class:`letsencrypt.client.apache.configurator.ApacheConfigurator`
- :ivar dvsni_chall: Data required for challenges.
- where DvsniChall tuples have the following fields
- `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`)
- `key` (:class:`letsencrypt.client.le_util.Key`)
- :type dvsni_chall: `list` of
- :class:`letsencrypt.client.challenge_util.DvsniChall`
+ :ivar list achalls: Annotated :class:`~letsencrypt.client.achallenges.DVSNI`
+ challenges.
:param list indicies: Meant to hold indices of challenges in a
larger array. ApacheDvsni is capable of solving many challenges
at once which causes an indexing issue within ApacheConfigurator
who must return all responses in order. Imagine ApacheConfigurator
- maintaining state about where all of the SimpleHttps Challenges,
+ maintaining state about where all of the SimpleHTTPS Challenges,
Dvsni Challenges belong in the response array. This is an optional
utility.
@@ -35,28 +28,28 @@ class ApacheDvsni(object):
"""
def __init__(self, configurator):
self.configurator = configurator
- self.dvsni_chall = []
+ self.achalls = []
self.indices = []
self.challenge_conf = os.path.join(
configurator.config.config_dir, "le_dvsni_cert_challenge.conf")
# self.completed = 0
- def add_chall(self, chall, idx=None):
+ def add_chall(self, achall, idx=None):
"""Add challenge to DVSNI object to perform at once.
- :param chall: DVSNI challenge info
- :type chall: :class:`letsencrypt.client.challenge_util.DvsniChall`
+ :param achall: Annotated DVSNI challenge.
+ :type achall: :class:`letsencrypt.client.achallenges.DVSNI`
:param int idx: index to challenge in a larger array
"""
- self.dvsni_chall.append(chall)
+ self.achalls.append(achall)
if idx is not None:
self.indices.append(idx)
def perform(self):
"""Peform a DVSNI challenge."""
- if not self.dvsni_chall:
+ if not self.achalls:
return None
# Save any changes to the configuration as a precaution
# About to make temporary changes to the config
@@ -64,12 +57,12 @@ class ApacheDvsni(object):
addresses = []
default_addr = "*:443"
- for chall in self.dvsni_chall:
- vhost = self.configurator.choose_vhost(chall.domain)
+ for achall in self.achalls:
+ vhost = self.configurator.choose_vhost(achall.domain)
if vhost is None:
logging.error(
"No vhost exists with servername or alias of: %s",
- chall.domain)
+ achall.domain)
logging.error("No _default_:443 vhost exists")
logging.error("Please specify servernames in the Apache config")
return None
@@ -87,9 +80,8 @@ class ApacheDvsni(object):
responses = []
# Create all of the challenge certs
- for chall in self.dvsni_chall:
- s_b64 = self._setup_challenge_cert(chall)
- responses.append({"type": "dvsni", "s": s_b64})
+ for achall in self.achalls:
+ responses.append(self._setup_challenge_cert(achall))
# Setup the configuration
self._mod_config(addresses)
@@ -99,20 +91,20 @@ class ApacheDvsni(object):
return responses
- def _setup_challenge_cert(self, chall):
+ def _setup_challenge_cert(self, achall, s=None):
+ # pylint: disable=invalid-name
"""Generate and write out challenge certificate."""
- cert_path = self.get_cert_file(chall.nonce)
+ cert_path = self.get_cert_file(achall)
# Register the path before you write out the file
self.configurator.reverter.register_file_creation(True, cert_path)
- cert_pem, s_b64 = challenge_util.dvsni_gen_cert(
- chall.domain, chall.r_b64, chall.nonce, chall.key)
+ cert_pem, response = achall.gen_cert_and_response(s)
# Write out challenge cert
with open(cert_path, 'w') as cert_chall_fd:
cert_chall_fd.write(cert_pem)
- return s_b64
+ return response
def _mod_config(self, ll_addrs):
"""Modifies Apache config files to include challenge vhosts.
@@ -126,9 +118,7 @@ class ApacheDvsni(object):
# TODO: Use ip address of existing vhost instead of relying on FQDN
config_text = "\n"
for idx, lis in enumerate(ll_addrs):
- config_text += self._get_config_text(
- self.dvsni_chall[idx].nonce, lis,
- self.dvsni_chall[idx].key.file)
+ config_text += self._get_config_text(self.achalls[idx], lis)
config_text += "\n"
self._conf_include_check(self.configurator.parser.loc["default"])
@@ -154,13 +144,14 @@ class ApacheDvsni(object):
parser.get_aug_path(main_config),
"Include", self.challenge_conf)
- def _get_config_text(self, nonce, ip_addrs, dvsni_key_file):
+ def _get_config_text(self, achall, ip_addrs):
"""Chocolate virtual server configuration text
- :param str nonce: hex form of nonce
+ :param achall: Annotated DVSNI challenge.
+ :type achall: :class:`letsencrypt.client.achallenges.DVSNI`
+
:param list ip_addrs: addresses of challenged domain
:class:`list` of type :class:`letsencrypt.client.apache.obj.Addr`
- :param str dvsni_key_file: Path to key file
:returns: virtual host configuration text
:rtype: str
@@ -170,26 +161,28 @@ class ApacheDvsni(object):
document_root = os.path.join(
self.configurator.config.config_dir, "dvsni_page/")
return ("\n"
- "ServerName " + nonce + constants.DVSNI_DOMAIN_SUFFIX + "\n"
+ "ServerName " + achall.nonce_domain + "\n"
"UseCanonicalName on\n"
"SSLStrictSNIVHostCheck on\n"
"\n"
"LimitRequestBody 1048576\n"
"\n"
"Include " + self.configurator.parser.loc["ssl_options"] + "\n"
- "SSLCertificateFile " + self.get_cert_file(nonce) + "\n"
- "SSLCertificateKeyFile " + dvsni_key_file + "\n"
+ "SSLCertificateFile " + self.get_cert_file(achall) + "\n"
+ "SSLCertificateKeyFile " + achall.key.file + "\n"
"\n"
"DocumentRoot " + document_root + "\n"
"\n\n")
- def get_cert_file(self, nonce):
+ def get_cert_file(self, achall):
"""Returns standardized name for challenge certificate.
- :param str nonce: hex form of nonce
+ :param achall: Annotated DVSNI challenge.
+ :type achall: :class:`letsencrypt.client.achallenges.DVSNI`
:returns: certificate file name
:rtype: str
"""
- return os.path.join(self.configurator.config.work_dir, nonce + ".crt")
+ return os.path.join(
+ self.configurator.config.work_dir, achall.nonce_domain + ".crt")
diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py
index 3a2b28648..4e3b5f68f 100644
--- a/letsencrypt/client/auth_handler.py
+++ b/letsencrypt/client/auth_handler.py
@@ -4,9 +4,10 @@ import sys
import Crypto.PublicKey.RSA
+from letsencrypt.acme import challenges
from letsencrypt.acme import messages
-from letsencrypt.client import challenge_util
+from letsencrypt.client import achallenges
from letsencrypt.client import constants
from letsencrypt.client import errors
@@ -29,13 +30,14 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
:ivar list domains: list of str domains to get authorization
:ivar dict authkey: Authorized Keys for each domain.
values are of type :class:`letsencrypt.client.le_util.Key`
- :ivar dict responses: keys: domain, values: list of dict responses
- :ivar dict msgs: ACME Challenge messages with domain as a key
+ :ivar dict responses: keys: domain, values: list of responses
+ (:class:`letsencrypt.acme.challenges.ChallengeResponse`.
+ :ivar dict msgs: ACME Challenge messages with domain as a key.
:ivar dict paths: optimal path for authorization. eg. paths[domain]
:ivar dict dv_c: Keys - domain, Values are DV challenges in the form of
- :class:`letsencrypt.client.challenge_util.IndexedChall`
+ :class:`letsencrypt.client.achallenges.Indexed`
:ivar dict client_c: Keys - domain, Values are Client challenges in the form
- of :class:`letsencrypt.client.challenge_util.IndexedChall`
+ of :class:`letsencrypt.client.achallenges.Indexed`
"""
def __init__(self, dv_auth, client_auth, network):
@@ -69,7 +71,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
"Multiple ACMEChallengeMessages for the same domain "
"is not supported.")
self.domains.append(domain)
- self.responses[domain] = ["null"] * len(msg.challenges)
+ self.responses[domain] = [None] * len(msg.challenges)
self.msgs[domain] = msg
self.authkey[domain] = authkey
@@ -155,8 +157,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
flat_dv = []
for dom in self.domains:
- flat_client.extend(ichall.chall for ichall in self.client_c[dom])
- flat_dv.extend(ichall.chall for ichall in self.dv_c[dom])
+ flat_client.extend(ichall.achall for ichall in self.client_c[dom])
+ flat_dv.extend(ichall.achall for ichall in self.dv_c[dom])
client_resp = []
dv_resp = []
@@ -185,12 +187,12 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
self._assign_responses(dv_resp, self.dv_c)
def _assign_responses(self, flat_list, ichall_dict):
- """Assign responses from flat_list back to the IndexedChall dicts.
+ """Assign responses from flat_list back to the Indexed dicts.
:param list flat_list: flat_list of responses from an IAuthenticator
:param dict ichall_dict: Master dict mapping all domains to a list of
- their associated 'client' and 'dv' IndexedChallenges, or their
- :class:`letsencrypt.client.challenge_util.IndexedChall` list
+ their associated 'client' and 'dv' Indexed challenges, or their
+ :class:`letsencrypt.client.achallenges.Indexed` list
"""
flat_index = 0
@@ -201,9 +203,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
def _path_satisfied(self, dom):
"""Returns whether a path has been completely satisfied."""
- return all(
- None != self.responses[dom][i] and "null" != self.responses[dom][i]
- for i in self.paths[dom])
+ return all(self.responses[dom][i] is not None for i in self.paths[dom])
def _get_chall_pref(self, domain):
"""Return list of challenge preferences.
@@ -226,8 +226,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
# These are indexed challenges... give just the challenges to the auth
# Chose to make these lists instead of a generator to make it easier to
# work with...
- dv_list = [ichall.chall for ichall in self.dv_c[domain]]
- client_list = [ichall.chall for ichall in self.client_c[domain]]
+ dv_list = [ichall.achall for ichall in self.dv_c[domain]]
+ client_list = [ichall.achall for ichall in self.client_c[domain]]
if dv_list:
self.dv_auth.cleanup(dv_list)
if client_list:
@@ -259,156 +259,99 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
:param list path: List of indices from `challenges`.
:returns: dv_chall, list of
- :class:`letsencrypt.client.challenge_util.IndexedChall`
+ :class:`letsencrypt.client.achallenges.Indexed`
client_chall, list of
- :class:`letsencrypt.client.challenge_util.IndexedChall`
+ :class:`letsencrypt.client.achallenges.Indexed`
:rtype: tuple
:raises errors.LetsEncryptClientError: If Challenge type is not
recognized
"""
- challenges = self.msgs[domain].challenges
-
dv_chall = []
client_chall = []
for index in path:
- chall = challenges[index]
+ chall = self.msgs[domain].challenges[index]
- # Authenticator Challenges
- if chall["type"] in constants.DV_CHALLENGES:
- dv_chall.append(challenge_util.IndexedChall(
- self._construct_dv_chall(chall, domain), index))
+ if isinstance(chall, challenges.DVSNI):
+ logging.info(" DVSNI challenge for %s.", domain)
+ achall = achallenges.DVSNI(
+ chall=chall, domain=domain, key=self.authkey[domain])
+ elif isinstance(chall, challenges.SimpleHTTPS):
+ logging.info(" SimpleHTTPS challenge for %s.", domain)
+ achall = achallenges.SimpleHTTPS(
+ chall=chall, domain=domain, key=self.authkey[domain])
+ elif isinstance(chall, challenges.DNS):
+ logging.info(" DNS challenge for %s.", domain)
+ achall = achallenges.DNS(chall=chall, domain=domain)
- # Client Challenges
- elif chall["type"] in constants.CLIENT_CHALLENGES:
- client_chall.append(challenge_util.IndexedChall(
- self._construct_client_chall(chall, domain), index))
+ elif isinstance(chall, challenges.RecoveryToken):
+ logging.info(" Recovery Token Challenge for %s.", domain)
+ achall = achallenges.RecoveryToken(chall=chall, domain=domain)
+ elif isinstance(chall, challenges.RecoveryContact):
+ logging.info(" Recovery Contact Challenge for %s.", domain)
+ achall = achallenges.RecoveryContact(chall=chall, domain=domain)
+ elif isinstance(chall, challenges.ProofOfPossession):
+ logging.info(" Proof-of-Possession Challenge for %s", domain)
+ achall = achallenges.ProofOfPossession(
+ chall=chall, domain=domain)
else:
raise errors.LetsEncryptClientError(
- "Received unrecognized challenge of type: "
- "%s" % chall["type"])
+ "Received unsupported challenge of type: "
+ "%s" % chall.acme_type)
+
+ ichall = achallenges.Indexed(achall=achall, index=index)
+
+ if isinstance(chall, challenges.ClientChallenge):
+ client_chall.append(ichall)
+ elif isinstance(chall, challenges.DVChallenge):
+ dv_chall.append(ichall)
return dv_chall, client_chall
- def _construct_dv_chall(self, chall, domain):
- """Construct Auth Type Challenges.
- :param dict chall: Single challenge
- :param str domain: challenge's domain
-
- :returns: challenge_util named tuple Chall object
- :rtype: `collections.namedtuple`
-
- :raises errors.LetsEncryptClientError: If unimplemented challenge exists
-
- """
- if chall["type"] == "dvsni":
- logging.info(" DVSNI challenge for name %s.", domain)
- return challenge_util.DvsniChall(
- domain, str(chall["r"]), str(chall["nonce"]),
- self.authkey[domain])
-
- elif chall["type"] == "simpleHttps":
- logging.info(" SimpleHTTPS challenge for name %s.", domain)
- return challenge_util.SimpleHttpsChall(
- domain, str(chall["token"]), self.authkey[domain])
-
- elif chall["type"] == "dns":
- logging.info(" DNS challenge for name %s.", domain)
- return challenge_util.DnsChall(domain, str(chall["token"]))
-
- else:
- raise errors.LetsEncryptClientError(
- "Unimplemented Auth Challenge: %s" % chall["type"])
-
- def _construct_client_chall(self, chall, domain): # pylint: disable=no-self-use
- """Construct Client Type Challenges.
-
- :param dict chall: Single challenge
- :param str domain: challenge's domain
-
- :returns: challenge_util named tuple Chall object
- :rtype: `collections.namedtuple`
-
- :raises errors.LetsEncryptClientError: If unimplemented challenge exists
-
- """
- if chall["type"] == "recoveryToken":
- logging.info(" Recovery Token Challenge for name: %s.", domain)
- return challenge_util.RecTokenChall(domain)
-
- elif chall["type"] == "recoveryContact":
- logging.info(" Recovery Contact Challenge for name: %s.", domain)
- return challenge_util.RecContactChall(
- domain,
- chall.get("activationURL", None),
- chall.get("successURL", None),
- chall.get("contact", None))
-
- elif chall["type"] == "proofOfPossession":
- logging.info(" Proof-of-Possession Challenge for name: "
- "%s", domain)
- return challenge_util.PopChall(
- domain, chall["alg"], chall["nonce"], chall["hints"])
-
- else:
- raise errors.LetsEncryptClientError(
- "Unimplemented Client Challenge: %s" % chall["type"])
-
-
-def gen_challenge_path(challenges, preferences, combos=None):
+def gen_challenge_path(challs, preferences, combinations):
"""Generate a plan to get authority over the identity.
.. todo:: Make sure that the challenges are feasible...
Example: Do you have the recovery key?
- :param list challenges: A list of challenges from ACME "challenge"
- server message to be fulfilled by the client in order to prove
- possession of the identifier.
+ :param list challs: A list of challenges
+ (:class:`letsencrypt.acme.challenges.Challenge`) from
+ :class:`letsencrypt.acme.messages.Challenge` server message to
+ be fulfilled by the client in order to prove possession of the
+ identifier.
:param list preferences: List of challenge preferences for domain
+ (:class:`letsencrypt.acme.challenges.Challege` subclasses)
- :param combos: A collection of sets of challenges from ACME
- "challenge" server message ("combinations"), each of which would
+ :param list combinations: A collection of sets of challenges from
+ :class:`letsencrypt.acme.messages.Challenge`, each of which would
be sufficient to prove possession of the identifier.
- :type combos: list or None
- :returns: List of indices from `challenges`.
+ :returns: List of indices from ``challenges``.
:rtype: list
"""
- if combos:
- return _find_smart_path(challenges, preferences, combos)
+ if combinations:
+ return _find_smart_path(challs, preferences, combinations)
else:
- return _find_dumb_path(challenges, preferences)
+ return _find_dumb_path(challs, preferences)
-def _find_smart_path(challenges, preferences, combos):
+def _find_smart_path(challs, preferences, combinations):
"""Find challenge path with server hints.
Can be called if combinations is included. Function uses a simple
ranking system to choose the combo with the lowest cost.
- :param list challenges: A list of challenges from ACME "challenge"
- server message to be fulfilled by the client in order to prove
- possession of the identifier.
-
- :param combos: A collection of sets of challenges from ACME
- "challenge" server message ("combinations"), each of which would
- be sufficient to prove possession of the identifier.
- :type combos: list or None
-
- :returns: List of indices from `challenges`.
- :rtype: list
-
"""
chall_cost = {}
max_cost = 0
- for i, chall in enumerate(preferences):
- chall_cost[chall] = i
+ for i, chall_cls in enumerate(preferences):
+ chall_cost[chall_cls] = i
max_cost += i
best_combo = []
@@ -416,10 +359,10 @@ def _find_smart_path(challenges, preferences, combos):
best_combo_cost = max_cost + 1
combo_total = 0
- for combo in combos:
+ for combo in combinations:
for challenge_index in combo:
- combo_total += chall_cost.get(challenges[
- challenge_index]["type"], max_cost)
+ combo_total += chall_cost.get(challs[
+ challenge_index].__class__, max_cost)
if combo_total < best_combo_cost:
best_combo = combo
best_combo_cost = combo_total
@@ -433,47 +376,48 @@ def _find_smart_path(challenges, preferences, combos):
return best_combo
-def _find_dumb_path(challenges, preferences):
+def _find_dumb_path(challs, preferences):
"""Find challenge path without server hints.
Should be called if the combinations hint is not included by the
server. This function returns the best path that does not contain
multiple mutually exclusive challenges.
- :param list challenges: A list of challenges from ACME "challenge"
- server message to be fulfilled by the client in order to prove
- possession of the identifier.
-
- :param list preferences: A list of preferences representing the
- challenge type found within the ACME spec. Each challenge type
- can only be listed once.
-
- :returns: List of indices from `challenges`.
- :rtype: list
-
"""
- # Add logic for a crappy server
- # Choose a DV
- path = []
assert len(preferences) == len(set(preferences))
+
+ path = []
+ satisfied = set()
for pref_c in preferences:
- for i, offered_challenge in enumerate(challenges):
- if (pref_c == offered_challenge["type"] and
- is_preferred(offered_challenge["type"], path)):
- path.append((i, offered_challenge["type"]))
-
- return [i for (i, _) in path]
+ for i, offered_chall in enumerate(challs):
+ if (isinstance(offered_chall, pref_c) and
+ is_preferred(offered_chall, satisfied)):
+ path.append(i)
+ satisfied.add(offered_chall)
+ return path
-def is_preferred(offered_challenge_type, path):
- """Return whether or not the challenge is preferred in path."""
- for _, challenge_type in path:
- for mutually_exclusive in constants.EXCLUSIVE_CHALLENGES:
- # Second part is in case we eventually allow multiple names
- # to be challenges at the same time
- if (challenge_type in mutually_exclusive and
- offered_challenge_type in mutually_exclusive and
- challenge_type != offered_challenge_type):
+def mutually_exclusive(obj1, obj2, groups, different=False):
+ """Are two objects mutually exclusive?"""
+ for group in groups:
+ obj1_present = False
+ obj2_present = False
+
+ for obj_cls in group:
+ obj1_present |= isinstance(obj1, obj_cls)
+ obj2_present |= isinstance(obj2, obj_cls)
+
+ if obj1_present and obj2_present and (
+ not different or not isinstance(obj1, obj2.__class__)):
return False
-
+ return True
+
+
+def is_preferred(offered_chall, satisfied,
+ exclusive_groups=constants.EXCLUSIVE_CHALLENGES):
+ """Return whether or not the challenge is preferred in path."""
+ for chall in satisfied:
+ if not mutually_exclusive(
+ offered_chall, chall, exclusive_groups, different=True):
+ return False
return True
diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py
deleted file mode 100644
index 7ff9dd660..000000000
--- a/letsencrypt/client/challenge_util.py
+++ /dev/null
@@ -1,74 +0,0 @@
-"""Challenge specific utility functions."""
-import collections
-import hashlib
-
-from Crypto import Random
-
-from letsencrypt.acme import jose
-
-from letsencrypt.client import constants
-from letsencrypt.client import crypto_util
-
-
-# Authenticator Challenges
-DvsniChall = collections.namedtuple("DvsniChall", "domain, r_b64, nonce, key")
-SimpleHttpsChall = collections.namedtuple(
- "SimpleHttpsChall", "domain, token, key")
-DnsChall = collections.namedtuple("DnsChall", "domain, token")
-
-# Client Challenges
-RecContactChall = collections.namedtuple(
- "RecContactChall", "domain, a_url, s_url, contact")
-RecTokenChall = collections.namedtuple("RecTokenChall", "domain")
-PopChall = collections.namedtuple("PopChall", "domain, alg, nonce, hints")
-
-# Helper Challenge Wrapper - Can be used to maintain the proper position of
-# the response within a larger challenge list
-IndexedChall = collections.namedtuple("IndexedChall", "chall, index")
-
-
-# DVSNI Challenge functions
-def dvsni_gen_cert(name, r_b64, nonce, key):
- """Generate a DVSNI cert and save it to filepath.
-
- :param str name: domain to validate
- :param str r_b64: jose base64 encoded dvsni r value
- :param str nonce: hex value of nonce
-
- :param key: Key to perform challenge
- :type key: :class:`letsencrypt.client.le_util.Key`
-
- :returns: tuple of (cert_pem, s) where
- cert_pem is the certificate in pem form
- s is the dvsni s value, jose base64 encoded
- :rtype: tuple
-
- """
- # Generate S
- dvsni_s = Random.get_random_bytes(constants.S_SIZE)
- dvsni_r = jose.b64decode(r_b64)
-
- # Generate extension
- ext = _dvsni_gen_ext(dvsni_r, dvsni_s)
-
- cert_pem = crypto_util.make_ss_cert(
- key.pem, [nonce + constants.DVSNI_DOMAIN_SUFFIX, name, ext])
-
- return cert_pem, jose.b64encode(dvsni_s)
-
-
-def _dvsni_gen_ext(dvsni_r, dvsni_s):
- """Generates z extension to be placed in certificate extension.
-
- :param bytearray dvsni_r: DVSNI r value
- :param bytearray dvsni_s: DVSNI s value
-
- :returns: z + :const:`~letsencrypt.client.constants.DVSNI_DOMAIN_SUFFIX`
- :rtype: str
-
- """
- z_base = hashlib.new("sha256")
- z_base.update(dvsni_r)
- z_base.update(dvsni_s)
-
- return z_base.hexdigest() + constants.DVSNI_DOMAIN_SUFFIX
diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/client_authenticator.py
index 7229239dc..3cef97355 100644
--- a/letsencrypt/client/client_authenticator.py
+++ b/letsencrypt/client/client_authenticator.py
@@ -1,7 +1,9 @@
"""Client Authenticator"""
import zope.interface
-from letsencrypt.client import challenge_util
+from letsencrypt.acme import challenges
+
+from letsencrypt.client import achallenges
from letsencrypt.client import errors
from letsencrypt.client import interfaces
from letsencrypt.client import recovery_token
@@ -30,22 +32,22 @@ class ClientAuthenticator(object):
def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use
"""Return list of challenge preferences."""
- return ["recoveryToken"]
+ return [challenges.RecoveryToken]
- def perform(self, chall_list):
+ def perform(self, achalls):
"""Perform client specific challenges for IAuthenticator"""
responses = []
- for chall in chall_list:
- if isinstance(chall, challenge_util.RecTokenChall):
- responses.append(self.rec_token.perform(chall))
+ for achall in achalls:
+ if isinstance(achall, achallenges.RecoveryToken):
+ responses.append(self.rec_token.perform(achall))
else:
raise errors.LetsEncryptClientAuthError("Unexpected Challenge")
return responses
- def cleanup(self, chall_list):
+ def cleanup(self, achalls):
"""Cleanup call for IAuthenticator."""
- for chall in chall_list:
- if isinstance(chall, challenge_util.RecTokenChall):
- self.rec_token.cleanup(chall)
+ for achall in achalls:
+ if isinstance(achall, achallenges.RecoveryToken):
+ self.rec_token.cleanup(achall)
else:
raise errors.LetsEncryptClientAuthError("Unexpected Challenge")
diff --git a/letsencrypt/client/constants.py b/letsencrypt/client/constants.py
index 5a1715788..3e27d88ac 100644
--- a/letsencrypt/client/constants.py
+++ b/letsencrypt/client/constants.py
@@ -1,26 +1,21 @@
"""Let's Encrypt constants."""
import pkg_resources
+from letsencrypt.acme import challenges
+
S_SIZE = 32
"""Size (in bytes) of secret base64-encoded octet string "s" used in
-challanges."""
+challenges."""
NONCE_SIZE = 16
"""Size of nonce used in JWS objects (in bytes)."""
-EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])]
+EXCLUSIVE_CHALLENGES = frozenset([frozenset([
+ challenges.DVSNI, challenges.SimpleHTTPS])])
"""Mutually exclusive challenges."""
-DV_CHALLENGES = frozenset(["dvsni", "simpleHttps", "dns"])
-"""Challenges that must be solved by a
-:class:`letsencrypt.client.interfaces.IAuthenticator` object."""
-
-CLIENT_CHALLENGES = frozenset(
- ["recoveryToken", "recoveryContact", "proofOfPossession"])
-"""Challenges that are handled by the Let's Encrypt client."""
-
ENHANCEMENTS = ["redirect", "http-header", "ocsp-stapling", "spdy"]
"""List of possible :class:`letsencrypt.client.interfaces.IInstaller`
@@ -48,9 +43,6 @@ APACHE_REWRITE_HTTPS_ARGS = [
DVSNI_CHALLENGE_PORT = 443
"""Port to perform DVSNI challenge."""
-DVSNI_DOMAIN_SUFFIX = ".acme.invalid"
-"""Suffix appended to domains in DVSNI validation."""
-
TEMP_CHECKPOINT_DIR = "temp_checkpoint"
"""Temporary checkpoint directory (relative to IConfig.work_dir)."""
diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py
index f0afae5f5..6779d4e1e 100644
--- a/letsencrypt/client/interfaces.py
+++ b/letsencrypt/client/interfaces.py
@@ -30,43 +30,43 @@ class IAuthenticator(zope.interface.Interface):
:param str domain: Domain for which challenge preferences are sought.
- :returns: list of strings with the most preferred challenges first.
- If a type is not specified, it means the Authenticator cannot
- perform the challenge.
+ :returns: List of challege types (subclasses of
+ :class:`letsencrypt.acme.challenges.Challenge`) with the most
+ preferred challenges first. If a type is not specified, it means the
+ Authenticator cannot perform the challenge.
:rtype: list
"""
- def perform(chall_list):
+ def perform(achalls):
"""Perform the given challenge.
- :param list chall_list: List of namedtuple types defined in
- :mod:`letsencrypt.client.challenge_util` (``DvsniChall``, etc.).
+ :param list achalls: Non-empty (guaranteed) list of
+ :class:`~letsencrypt.client.achallenges.AnnotatedChallenge`
+ instances, such that it contains types found within
+ :func:`get_chall_pref` only.
- - chall_list will never be empty
- - chall_list will only contain types found within
- :func:`get_chall_pref`
-
- :returns: ACME Challenge responses or if it cannot be completed then:
+ :returns: List of ACME
+ :class:`~letsencrypt.acme.challenges.ChallengeResponse` instances
+ or if the :class:`~letsencrypt.acme.challenges.Challenge` cannot
+ be fulfilled then:
``None``
- Authenticator can perform challenge, but can't at this time
+ Authenticator can perform challenge, but not at this time.
``False``
- Authenticator will never be able to perform (error)
+ Authenticator will never be able to perform (error).
- :rtype: :class:`list` of :class:`dict`
+ :rtype: :class:`list` of
+ :class:`letsencrypt.acme.challenges.ChallengeResponse`
"""
- def cleanup(chall_list):
+ def cleanup(achalls):
"""Revert changes and shutdown after challenges complete.
- :param list chall_list: List of namedtuple types defined in
- :mod:`letsencrypt.client.challenge_util` (``DvsniChall``, etc.)
-
- - Only challenges given previously in the perform function will be
- found in chall_list.
- - chall_list will never be empty
+ :param list achalls: Non-empty (guaranteed) list of
+ :class:`~letsencrypt.client.achallenges.AnnotatedChallenge`
+ instances, a subset of those previously passed to :func:`perform`.
"""
diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py
index bdba746b0..b61a8a2f8 100644
--- a/letsencrypt/client/network.py
+++ b/letsencrypt/client/network.py
@@ -5,6 +5,7 @@ import time
import requests
+from letsencrypt.acme import errors as acme_errors
from letsencrypt.acme import messages
from letsencrypt.client import errors
@@ -36,8 +37,8 @@ class Network(object):
:returns: Server response message.
:rtype: :class:`letsencrypt.acme.messages.Message`
- :raises TypeError: if `msg` is not JSON serializable
- :raises jsonschema.ValidationError: if not valid ACME message
+ :raises letsencrypt.acme.errors.ValidationError: if `msg` is not
+ valid serializable ACME JSON message.
:raises errors.LetsEncryptClientError: in case of connection error
or if response from server is not a valid ACME message.
@@ -53,7 +54,12 @@ class Network(object):
raise errors.LetsEncryptClientError(
'Sending ACME message to server has failed: %s' % error)
- return messages.Message.from_json(response.json(), validate=True)
+ json_string = response.json()
+ try:
+ return messages.Message.from_json(json_string)
+ except acme_errors.ValidationError as error:
+ logging.error(json_string)
+ raise # TODO
def send_and_receive_expected(self, msg, expected):
"""Send ACME message to server and return expected message.
diff --git a/letsencrypt/client/recovery_token.py b/letsencrypt/client/recovery_token.py
index 4d556eb51..f0c7d5839 100644
--- a/letsencrypt/client/recovery_token.py
+++ b/letsencrypt/client/recovery_token.py
@@ -4,6 +4,8 @@ import os
import zope.component
+from letsencrypt.acme import challenges
+
from letsencrypt.client import le_util
from letsencrypt.client import interfaces
@@ -21,7 +23,7 @@ class RecoveryToken(object):
"""Perform the Recovery Token Challenge.
:param chall: Recovery Token Challenge
- :type chall: :class:`letsencrypt.client.challenge_util.RecTokenChall`
+ :type chall: :class:`letsencrypt.client.achallenges.RecoveryToken`
:returns: response
:rtype: dict
@@ -30,13 +32,13 @@ class RecoveryToken(object):
token_fp = os.path.join(self.token_dir, chall.domain)
if os.path.isfile(token_fp):
with open(token_fp) as token_fd:
- return self.generate_response(token_fd.read())
+ 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 self.generate_response(token)
+ return challenges.RecoveryTokenResponse(token=token)
return None
@@ -44,7 +46,7 @@ class RecoveryToken(object):
"""Cleanup the saved recovery token if it exists.
:param chall: Recovery Token Challenge
- :type chall: :class:`letsencrypt.client.challenge_util.RecTokenChall`
+ :type chall: :class:`letsencrypt.client.achallenges.RecoveryToken`
"""
try:
@@ -53,13 +55,6 @@ class RecoveryToken(object):
if err.errno != errno.ENOENT:
raise
- def generate_response(self, token): # pylint: disable=no-self-use
- """Generate json response."""
- return {
- "type": "recoveryToken",
- "token": token,
- }
-
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))
diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/standalone_authenticator.py
index 963c3083b..bf08a39ec 100755
--- a/letsencrypt/client/standalone_authenticator.py
+++ b/letsencrypt/client/standalone_authenticator.py
@@ -12,7 +12,9 @@ import OpenSSL.SSL
import zope.component
import zope.interface
-from letsencrypt.client import challenge_util
+from letsencrypt.acme import challenges
+
+from letsencrypt.client import achallenges
from letsencrypt.client import constants
from letsencrypt.client import interfaces
@@ -324,9 +326,9 @@ class StandaloneAuthenticator(object):
:returns: A list containing only 'dvsni'.
"""
- return ["dvsni"]
+ return [challenges.DVSNI]
- def perform(self, chall_list):
+ def perform(self, achalls):
"""Perform the challenge.
.. warning::
@@ -336,13 +338,6 @@ class StandaloneAuthenticator(object):
validations for multiple independent sets of domains, a separate
StandaloneAuthenticator should be instantiated.
- :param list chall_list: List of namedtuple types defined in
- :mod:`letsencrypt.client.challenge_util` (``DvsniChall``, etc.)
-
- :returns: ACME Challenge DVSNI responses following IAuthenticator
- interface.
- :rtype: :class:`list` of :class`dict`
-
"""
if self.child_pid or self.tasks:
# We should not be willing to continue with perform
@@ -350,17 +345,15 @@ class StandaloneAuthenticator(object):
raise ValueError(".perform() was called with pending tasks!")
results_if_success = []
results_if_failure = []
- if not chall_list or not isinstance(chall_list, list):
+ if not achalls or not isinstance(achalls, list):
raise ValueError(".perform() was called without challenge list")
- for chall in chall_list:
- if isinstance(chall, challenge_util.DvsniChall):
+ for achall in achalls:
+ if isinstance(achall, achallenges.DVSNI):
# We will attempt to do it
- name, r_b64 = chall.domain, chall.r_b64
- nonce, key = chall.nonce, chall.key
- cert, s_b64 = challenge_util.dvsni_gen_cert(
- name, r_b64, nonce, key)
- self.tasks[nonce + constants.DVSNI_DOMAIN_SUFFIX] = cert
- results_if_success.append({"type": "dvsni", "s": s_b64})
+ key = achall.key # TODO: bug; one key per start_listener
+ cert_pem, response = achall.gen_cert_and_response()
+ self.tasks[achall.nonce_domain] = cert_pem
+ results_if_success.append(response)
results_if_failure.append(None)
else:
# We will not attempt to do this challenge because it
@@ -384,7 +377,7 @@ class StandaloneAuthenticator(object):
# rather than returning a list of None objects.
return results_if_failure
- def cleanup(self, chall_list):
+ def cleanup(self, achalls):
"""Clean up.
If some challenges are removed from the list, the authenticator
@@ -394,11 +387,10 @@ class StandaloneAuthenticator(object):
"""
# Remove this from pending tasks list
- for chall in chall_list:
- assert isinstance(chall, challenge_util.DvsniChall)
- nonce = chall.nonce
- if nonce + constants.DVSNI_DOMAIN_SUFFIX in self.tasks:
- del self.tasks[nonce + constants.DVSNI_DOMAIN_SUFFIX]
+ for achall in achalls:
+ assert isinstance(achall, achallenges.DVSNI)
+ if achall.nonce_domain in self.tasks:
+ del self.tasks[achall.nonce_domain]
else:
# Could not find the challenge to remove!
raise ValueError("could not find the challenge to remove")
diff --git a/letsencrypt/client/tests/achallenges_test.py b/letsencrypt/client/tests/achallenges_test.py
new file mode 100644
index 000000000..1ed307bd9
--- /dev/null
+++ b/letsencrypt/client/tests/achallenges_test.py
@@ -0,0 +1,62 @@
+"""Tests for letsencrypt.client.achallenges."""
+import os
+import pkg_resources
+import re
+import unittest
+
+import M2Crypto
+import mock
+
+from letsencrypt.acme import challenges
+from letsencrypt.client import le_util
+
+
+class DVSNITest(unittest.TestCase):
+ """Tests for letsencrypt.client.achallenges.DVSNI."""
+
+ def setUp(self):
+ self.chall = challenges.DVSNI(r="r_value", nonce="12345ABCDE")
+ self.response = challenges.DVSNIResponse()
+ key = le_util.Key("path", pkg_resources.resource_string(
+ __name__, os.path.join("testdata", "rsa256_key.pem")))
+
+ from letsencrypt.client.achallenges import DVSNI
+ self.achall = DVSNI(chall=self.chall, domain="example.com", key=key)
+
+ def test_proxy(self):
+ self.assertEqual(self.chall.r, self.achall.r)
+ self.assertEqual(self.chall.nonce, self.achall.nonce)
+
+ def test_gen_cert_and_response(self):
+ cert_pem, _ = self.achall.gen_cert_and_response(s=self.response.s)
+
+ cert = M2Crypto.X509.load_cert_string(cert_pem)
+ self.assertEqual(cert.get_subject().CN, self.chall.nonce_domain)
+
+ sans = cert.get_ext("subjectAltName").get_value()
+ self.assertEqual(
+ set([self.chall.nonce_domain, "example.com",
+ self.response.z_domain(self.chall)]),
+ set(re.findall(r"DNS:([^, $]*)", sans)),
+ )
+
+
+class IndexedTest(unittest.TestCase):
+ """Tests for letsencrypt.client.achallenges.Indexed."""
+
+ def setUp(self):
+ from letsencrypt.client.achallenges import Indexed
+ self.achall = mock.MagicMock()
+ self.ichall = Indexed(achall=self.achall, index=0)
+
+ def test_attributes(self):
+ self.assertEqual(self.achall, self.ichall.achall)
+ self.assertEqual(0, self.ichall.index)
+
+ def test_proxy(self):
+ self.assertEqual(self.achall.foo, self.ichall.foo)
+
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py
index 86bdbb282..233436361 100644
--- a/letsencrypt/client/tests/acme_util.py
+++ b/letsencrypt/client/tests/acme_util.py
@@ -1,79 +1,53 @@
"""Class helps construct valid ACME messages for testing."""
-from letsencrypt.client import constants
+import os
+import pkg_resources
+
+import Crypto.PublicKey.RSA
+
+from letsencrypt.acme import challenges
+from letsencrypt.acme import other
-CHALLENGES = {
- "simpleHttps":
- {
- "type": "simpleHttps",
- "token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA"
- },
- "dvsni":
- {
- "type": "dvsni",
- "r": "Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI",
- "nonce": "a82d5ff8ef740d12881f6d3c2277ab2e"
- },
- "dns":
- {
- "type": "dns",
- "token": "17817c66b60ce2e4012dfad92657527a"
- },
- "recoveryContact":
- {
- "type": "recoveryContact",
- "activationURL": "https://example.ca/sendrecovery/a5bd99383fb0",
- "successURL": "https://example.ca/confirmrecovery/bb1b9928932",
- "contact": "c********n@example.com"
- },
- "recoveryToken":
- {
- "type": "recoveryToken"
- },
- "proofOfPossession":
- {
- "type": "proofOfPossession",
- "alg": "RS256",
- "nonce": "eET5udtV7aoX8Xl8gYiZIA",
- "hints": {
- "jwk": {
- "kty": "RSA",
- "e": "AQAB",
- "n": "KxITJ0rNlfDMAtfDr8eAw...fSSoehDFNZKQKzTZPtQ"
- },
- "certFingerprints": [
- "93416768eb85e33adc4277f4c9acd63e7418fcfe",
- "16d95b7b63f1972b980b14c20291f3c0d1855d95",
- "48b46570d9fc6358108af43ad1649484def0debf"
- ],
- "subjectKeyIdentifiers":
- ["d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"],
- "serialNumbers": [34234239832, 23993939911, 17],
- "issuers": [
- "C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA",
- "O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure"
- ],
- "authorizedFor": ["www.example.com", "example.net"]
- }
- }
-}
+KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
+ "letsencrypt.client.tests", os.path.join("testdata", "rsa256_key.pem")))
+# Challenges
+SIMPLE_HTTPS = challenges.SimpleHTTPS(
+ token="evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA")
+DVSNI = challenges.DVSNI(
+ r="O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6\xbf'\xb3"
+ "\xed\x9a9nX\x0f'\\m\xe7\x12", nonce="a82d5ff8ef740d12881f6d3c2277ab2e")
+DNS = challenges.DNS(token="17817c66b60ce2e4012dfad92657527a")
+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="xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ",
+ hints=challenges.ProofOfPossession.Hints(
+ jwk=other.JWK(key=KEY.publickey()),
+ cert_fingerprints=[
+ "93416768eb85e33adc4277f4c9acd63e7418fcfe",
+ "16d95b7b63f1972b980b14c20291f3c0d1855d95",
+ "48b46570d9fc6358108af43ad1649484def0debf"
+ ],
+ certs=[], # TODO
+ subject_key_identifiers=["d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"],
+ serial_numbers=[34234239832, 23993939911, 17],
+ issuers=[
+ "C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA",
+ "O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure",
+ ],
+ authorized_for=["www.example.com", "example.net"],
+ )
+)
-def get_dv_challenges():
- """Returns all auth challenges."""
- return [chall for typ, chall in CHALLENGES.iteritems()
- if typ in constants.DV_CHALLENGES]
-
-
-def get_client_challenges():
- """Returns all client challenges."""
- return [chall for typ, chall in CHALLENGES.iteritems()
- if typ in constants.CLIENT_CHALLENGES]
-
-
-def get_challenges():
- """Returns all challenges."""
- return [chall for chall in CHALLENGES.itervalues()]
+CHALLENGES = [SIMPLE_HTTPS, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP]
+DV_CHALLENGES = [chall for chall in CHALLENGES
+ if isinstance(chall, challenges.DVChallenge)]
+CLIENT_CHALLENGES = [chall for chall in CHALLENGES
+ if isinstance(chall, challenges.ClientChallenge)]
def gen_combos(challs):
@@ -81,8 +55,8 @@ def gen_combos(challs):
dv_chall = []
renewal_chall = []
- for i, chall in enumerate(challs):
- if chall["type"] in constants.DV_CHALLENGES:
+ for i, chall in enumerate(challs): # pylint: disable=redefined-outer-name
+ if isinstance(chall, challenges.DVChallenge):
dv_chall.append(i)
else:
renewal_chall.append(i)
diff --git a/letsencrypt/client/tests/apache/configurator_test.py b/letsencrypt/client/tests/apache/configurator_test.py
index a67c0088a..1bb4207a3 100644
--- a/letsencrypt/client/tests/apache/configurator_test.py
+++ b/letsencrypt/client/tests/apache/configurator_test.py
@@ -6,7 +6,9 @@ import unittest
import mock
-from letsencrypt.client import challenge_util
+from letsencrypt.acme import challenges
+
+from letsencrypt.client import achallenges
from letsencrypt.client import errors
from letsencrypt.client import le_util
@@ -140,24 +142,24 @@ class TwoVhost80Test(util.ApacheTest):
# Only tests functionality specific to configurator.perform
# Note: As more challenges are offered this will have to be expanded
auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem)
- chall1 = challenge_util.DvsniChall(
- "encryption-example.demo",
- "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q",
- "37bc5eb75d3e00a19b4f6355845e5a18",
- auth_key)
- chall2 = challenge_util.DvsniChall(
- "letsencrypt.demo",
- "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU",
- "59ed014cac95f77057b1d7a1b2c596ba",
- auth_key)
+ achall1 = achallenges.DVSNI(
+ chall=challenges.DVSNI(
+ r="jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q",
+ nonce="37bc5eb75d3e00a19b4f6355845e5a18"),
+ domain="encryption-example.demo", key=auth_key)
+ achall2 = achallenges.DVSNI(
+ chall=challenges.DVSNI(
+ r="uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU",
+ nonce="59ed014cac95f77057b1d7a1b2c596ba"),
+ domain="letsencrypt.demo", key=auth_key)
dvsni_ret_val = [
- {"type": "dvsni", "s": "randomS1"},
- {"type": "dvsni", "s": "randomS2"}
+ challenges.DVSNIResponse(s="randomS1"),
+ challenges.DVSNIResponse(s="randomS2"),
]
mock_dvsni_perform.return_value = dvsni_ret_val
- responses = self.config.perform([chall1, chall2])
+ responses = self.config.perform([achall1, achall2])
self.assertEqual(mock_dvsni_perform.call_count, 1)
self.assertEqual(responses, dvsni_ret_val)
diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/tests/apache/dvsni_test.py
index f44e603dc..384e426bb 100644
--- a/letsencrypt/client/tests/apache/dvsni_test.py
+++ b/letsencrypt/client/tests/apache/dvsni_test.py
@@ -5,8 +5,9 @@ import shutil
import mock
-from letsencrypt.client import challenge_util
-from letsencrypt.client import constants
+from letsencrypt.acme import challenges
+
+from letsencrypt.client import achallenges
from letsencrypt.client import le_util
from letsencrypt.client.apache.obj import Addr
@@ -36,17 +37,21 @@ class DvsniPerformTest(util.ApacheTest):
"letsencrypt.client.tests", 'testdata/rsa256_key.pem')
auth_key = le_util.Key(rsa256_file, rsa256_pem)
- self.challs = []
- self.challs.append(challenge_util.DvsniChall(
- "encryption-example.demo",
- "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q",
- "37bc5eb75d3e00a19b4f6355845e5a18",
- auth_key))
- self.challs.append(challenge_util.DvsniChall(
- "letsencrypt.demo",
- "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU",
- "59ed014cac95f77057b1d7a1b2c596ba",
- auth_key))
+ self.achalls = [
+ achallenges.DVSNI(
+ chall=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",
+ ), domain="encryption-example.demo", key=auth_key),
+ achallenges.DVSNI(
+ chall=challenges.DVSNI(
+ r="\xba\xa9\xda? 80 char...
dv_c, c_c = self.handler._challenge_factory(dom, [0])
@@ -387,11 +391,11 @@ class GetAuthorizationsTest(unittest.TestCase):
self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c
def test_progress_failure(self):
- challenges = acme_util.get_challenges()
self.handler.add_chall_msg(
"0",
- messages.Challenge(session_id="0", nonce="nonce0",
- challenges=challenges, combinations=[]),
+ messages.Challenge(
+ session_id="0", nonce="nonce0", challenges=acme_util.CHALLENGES,
+ combinations=[]),
"dummy_key")
# Don't do anything to satisfy challenges
@@ -406,21 +410,19 @@ class GetAuthorizationsTest(unittest.TestCase):
def _sat_failure(self):
dom = "0"
self.handler.paths[dom] = gen_path(
- ["dns", "recoveryToken"], self.handler.msgs[dom].challenges)
+ [acme_util.DNS, acme_util.RECOVERY_TOKEN],
+ self.handler.msgs[dom].challenges)
dv_c, c_c = self.handler._challenge_factory(
dom, self.handler.paths[dom])
self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c
def test_incremental_progress(self):
- challs = []
- challs.append(acme_util.get_challenges())
- challs.append(acme_util.get_dv_challenges())
- for i in xrange(2):
- dom = str(i)
+ for dom, challs in [("0", acme_util.CHALLENGES),
+ ("1", acme_util.DV_CHALLENGES)]:
self.handler.add_chall_msg(
dom,
- messages.Challenge(session_id=dom, nonce="nonce%d" % i,
- challenges=challs[i], combinations=[]),
+ messages.Challenge(session_id=dom, nonce="nonce",
+ combinations=[], challenges=challs),
"dummy_key")
self.mock_sat_chall.side_effect = self._sat_incremental
@@ -437,7 +439,7 @@ class GetAuthorizationsTest(unittest.TestCase):
# Only solve one of "0" required challs
self.handler.responses["0"][1] = "onecomplete"
self.handler.responses["0"][3] = None
- self.handler.responses["1"] = ["null", "null", "goodresp"]
+ self.handler.responses["1"] = [None, None, "goodresp"]
self.handler.paths["0"] = [1, 3]
self.handler.paths["1"] = [2]
# This is probably overkill... but set it anyway
@@ -476,10 +478,10 @@ class PathSatisfiedTest(unittest.TestCase):
def test_satisfied_true(self):
dom = ["0", "1", "2", "3", "4"]
self.handler.paths[dom[0]] = [1, 2]
- self.handler.responses[dom[0]] = ["null", "sat", "sat2", "null"]
+ self.handler.responses[dom[0]] = [None, "sat", "sat2", None]
self.handler.paths[dom[1]] = [0]
- self.handler.responses[dom[1]] = ["sat", None, None, "null"]
+ self.handler.responses[dom[1]] = ["sat", None, None, None]
self.handler.paths[dom[2]] = [0]
self.handler.responses[dom[2]] = ["sat"]
@@ -494,46 +496,105 @@ class PathSatisfiedTest(unittest.TestCase):
self.assertTrue(self.handler._path_satisfied(dom[i]))
def test_not_satisfied(self):
- dom = ["0", "1", "2", "3", "4"]
+ dom = ["0", "1", "2"]
self.handler.paths[dom[0]] = [1, 2]
- self.handler.responses[dom[0]] = ["sat1", "null", "sat2", "null"]
+ self.handler.responses[dom[0]] = ["sat1", None, "sat2", None]
self.handler.paths[dom[1]] = [0]
- self.handler.responses[dom[1]] = [None, "null", "null", "null"]
+ self.handler.responses[dom[1]] = [None, None, None, None]
self.handler.paths[dom[2]] = [0]
self.handler.responses[dom[2]] = [None]
- self.handler.paths[dom[3]] = [0]
- self.handler.responses[dom[3]] = ["null"]
-
- for i in xrange(4):
+ for i in xrange(3):
self.assertFalse(self.handler._path_satisfied(dom[i]))
+class MutuallyExclusiveTest(unittest.TestCase):
+ """Tests for letsencrypt.client.auth_handler.mutually_exclusive."""
+
+ # pylint: disable=invalid-name,missing-docstring,too-few-public-methods
+ class A(object):
+ pass
+
+ class B(object):
+ pass
+
+ class C(object):
+ pass
+
+ class D(C):
+ pass
+
+ @classmethod
+ def _call(cls, chall1, chall2, different=False):
+ from letsencrypt.client.auth_handler import mutually_exclusive
+ return mutually_exclusive(chall1, chall2, groups=frozenset([
+ frozenset([cls.A, cls.B]), frozenset([cls.A, cls.C]),
+ ]), different=different)
+
+ def test_group_members(self):
+ self.assertFalse(self._call(self.A(), self.B()))
+ self.assertFalse(self._call(self.A(), self.C()))
+
+ def test_cross_group(self):
+ self.assertTrue(self._call(self.B(), self.C()))
+
+ def test_same_type(self):
+ self.assertFalse(self._call(self.A(), self.A(), different=False))
+ self.assertTrue(self._call(self.A(), self.A(), different=True))
+
+ # in particular...
+ obj = self.A()
+ self.assertFalse(self._call(obj, obj, different=False))
+ self.assertTrue(self._call(obj, obj, different=True))
+
+ def test_subclass(self):
+ self.assertFalse(self._call(self.A(), self.D()))
+ self.assertFalse(self._call(self.D(), self.A()))
+
+
+class IsPreferredTest(unittest.TestCase):
+ """Tests for letsencrypt.client.auth_handler.is_preferred."""
+
+ @classmethod
+ def _call(cls, chall, satisfied):
+ from letsencrypt.client.auth_handler import is_preferred
+ return is_preferred(chall, satisfied, exclusive_groups=frozenset([
+ frozenset([challenges.DVSNI, challenges.SimpleHTTPS]),
+ frozenset([challenges.DNS, challenges.SimpleHTTPS]),
+ ]))
+
+ def test_empty_satisfied(self):
+ self.assertTrue(self._call(acme_util.DNS, frozenset()))
+
+ def test_mutually_exclusvie(self):
+ self.assertFalse(
+ self._call(acme_util.DVSNI, frozenset([acme_util.SIMPLE_HTTPS])))
+
+ def test_mutually_exclusive_same_type(self):
+ self.assertTrue(
+ self._call(acme_util.DVSNI, frozenset([acme_util.DVSNI])))
+
+
def gen_auth_resp(chall_list):
"""Generate a dummy authorization response."""
return ["%s%s" % (chall.__class__.__name__, chall.domain)
for chall in chall_list]
-def gen_path(str_list, challenges):
- """Generate a path for challenge messages
+def gen_path(required, challs):
+ """Generate a combination by picking ``required`` from ``challs``.
- :param list str_list: challenge message types (:class:`str`)
- :param dict challenges: ACME challenge messages
+ :param required: Required types of challenges (subclasses of
+ :class:`~letsencrypt.acme.challenges.Challenge`).
+ :param challs: Sequence of ACME challenge messages, corresponding to
+ :attr:`letsencrypt.acme.messages.Challenge.challenges`.
:return: :class:`list` of :class:`int`
"""
- path = []
- for i, chall in enumerate(challenges):
- for str_chall in str_list:
- if chall["type"] == str_chall:
- path.append(i)
- continue
- return path
-
+ return [challs.index(chall) for chall in required]
if __name__ == "__main__":
unittest.main()
diff --git a/letsencrypt/client/tests/challenge_util_test.py b/letsencrypt/client/tests/challenge_util_test.py
deleted file mode 100644
index c7848a213..000000000
--- a/letsencrypt/client/tests/challenge_util_test.py
+++ /dev/null
@@ -1,57 +0,0 @@
-"""Tests for challenge_util."""
-import os
-import pkg_resources
-import re
-import unittest
-
-import M2Crypto
-
-from letsencrypt.acme import jose
-
-from letsencrypt.client import challenge_util
-from letsencrypt.client import constants
-from letsencrypt.client import le_util
-
-
-class DvsniGenCertTest(unittest.TestCase):
- # pylint: disable=too-few-public-methods
- """Tests for letsencrypt.client.challenge_util.dvsni_gen_cert."""
-
- def test_standard(self):
- """Basic test for straightline code."""
- domain = "example.com"
- dvsni_r = "r_value"
- r_b64 = jose.b64encode(dvsni_r)
- pem = pkg_resources.resource_string(
- __name__, os.path.join("testdata", "rsa256_key.pem"))
- key = le_util.Key("path", pem)
- nonce = "12345ABCDE"
- cert_pem, s_b64 = self._call(domain, r_b64, nonce, key)
-
- # pylint: disable=protected-access
- ext = challenge_util._dvsni_gen_ext(
- dvsni_r, jose.b64decode(s_b64))
- self._standard_check_cert(cert_pem, domain, nonce, ext)
-
- def _standard_check_cert(self, pem, domain, nonce, ext):
- """Check the certificate fields."""
- dns_regex = r"DNS:([^, $]*)"
- cert = M2Crypto.X509.load_cert_string(pem)
- self.assertEqual(
- cert.get_subject().CN, nonce + constants.DVSNI_DOMAIN_SUFFIX)
-
- sans = cert.get_ext("subjectAltName").get_value()
-
- exp_sans = set([nonce + constants.DVSNI_DOMAIN_SUFFIX, domain, ext])
- act_sans = set(re.findall(dns_regex, sans))
-
- self.assertEqual(exp_sans, act_sans)
-
- @classmethod
- def _call(cls, name, r_b64, nonce, key):
- from letsencrypt.client.challenge_util import dvsni_gen_cert
- return dvsni_gen_cert(name, r_b64, nonce, key)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/letsencrypt/client/tests/client_authenticator_test.py b/letsencrypt/client/tests/client_authenticator_test.py
index c79f26e0a..7db1956d5 100644
--- a/letsencrypt/client/tests/client_authenticator_test.py
+++ b/letsencrypt/client/tests/client_authenticator_test.py
@@ -3,7 +3,9 @@ import unittest
import mock
-from letsencrypt.client import challenge_util
+from letsencrypt.acme import challenges
+
+from letsencrypt.client import achallenges
from letsencrypt.client import errors
@@ -19,31 +21,29 @@ class PerformTest(unittest.TestCase):
name="rec_token_perform", side_effect=gen_client_resp)
def test_rec_token1(self):
- token = challenge_util.RecTokenChall("0")
+ token = achallenges.RecoveryToken(chall=None, domain="0")
responses = self.auth.perform([token])
- self.assertEqual(responses, ["RecTokenChall0"])
+ self.assertEqual(responses, ["RecoveryToken0"])
def test_rec_token5(self):
tokens = []
for i in xrange(5):
- tokens.append(challenge_util.RecTokenChall(str(i)))
+ tokens.append(achallenges.RecoveryToken(chall=None, domain=str(i)))
responses = self.auth.perform(tokens)
self.assertEqual(len(responses), 5)
for i in xrange(5):
- self.assertEqual(responses[i], "RecTokenChall%d" % i)
+ self.assertEqual(responses[i], "RecoveryToken%d" % i)
def test_unexpected(self):
- unexpected = challenge_util.DvsniChall(
- "0", "rb64", "123", "invalid_key")
-
self.assertRaises(
- errors.LetsEncryptClientAuthError, self.auth.perform, [unexpected])
+ errors.LetsEncryptClientAuthError, self.auth.perform, [
+ achallenges.DVSNI(chall=None, domain="0", key="invalid_key")])
def test_chall_pref(self):
self.assertEqual(
- self.auth.get_chall_pref("example.com"), ["recoveryToken"])
+ self.auth.get_chall_pref("example.com"), [challenges.RecoveryToken])
class CleanupTest(unittest.TestCase):
@@ -58,8 +58,8 @@ class CleanupTest(unittest.TestCase):
self.auth.rec_token.cleanup = self.mock_cleanup
def test_rec_token2(self):
- token1 = challenge_util.RecTokenChall("0")
- token2 = challenge_util.RecTokenChall("1")
+ token1 = achallenges.RecoveryToken(chall=None, domain="0")
+ token2 = achallenges.RecoveryToken(chall=None, domain="1")
self.auth.cleanup([token1, token2])
@@ -67,8 +67,8 @@ class CleanupTest(unittest.TestCase):
[mock.call(token1), mock.call(token2)])
def test_unexpected(self):
- token = challenge_util.RecTokenChall("0")
- unexpected = challenge_util.DvsniChall("0", "rb64", "123", "dummy_key")
+ token = achallenges.RecoveryToken(chall=None, domain="0")
+ unexpected = achallenges.DVSNI(chall=None, domain="0", key="dummy_key")
self.assertRaises(errors.LetsEncryptClientAuthError,
self.auth.cleanup, [token, unexpected])
diff --git a/letsencrypt/client/tests/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py
index 0a49137d8..01ba78d72 100644
--- a/letsencrypt/client/tests/recovery_token_test.py
+++ b/letsencrypt/client/tests/recovery_token_test.py
@@ -6,7 +6,9 @@ import tempfile
import mock
-from letsencrypt.client import challenge_util
+from letsencrypt.acme import challenges
+
+from letsencrypt.client import achallenges
class RecoveryTokenTest(unittest.TestCase):
@@ -36,34 +38,37 @@ class RecoveryTokenTest(unittest.TestCase):
self.rec_token.store_token("example3.com", 333)
self.assertFalse(self.rec_token.requires_human("example3.com"))
- self.rec_token.cleanup(challenge_util.RecTokenChall("example3.com"))
+ self.rec_token.cleanup(achallenges.RecoveryToken(
+ chall=None, domain="example3.com"))
self.assertTrue(self.rec_token.requires_human("example3.com"))
# Shouldn't throw an error
- self.rec_token.cleanup(challenge_util.RecTokenChall("example4.com"))
+ self.rec_token.cleanup(achallenges.RecoveryToken(
+ chall=None, domain="example4.com"))
# SHOULD throw an error (OSError other than nonexistent file)
self.assertRaises(
OSError, self.rec_token.cleanup,
- challenge_util.RecTokenChall("a"+"r"*10000+".com"))
+ achallenges.RecoveryToken(chall=None, domain="a"+"r"*10000+".com"))
def test_perform_stored(self):
self.rec_token.store_token("example4.com", 444)
response = self.rec_token.perform(
- challenge_util.RecTokenChall("example4.com"))
+ achallenges.RecoveryToken(chall=None, domain="example4.com"))
- self.assertEqual(response, {"type": "recoveryToken", "token": "444"})
+ self.assertEqual(
+ response, challenges.RecoveryTokenResponse(token="444"))
@mock.patch("letsencrypt.client.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(
- challenge_util.RecTokenChall("example5.com"))
-
- self.assertEqual(response, {"type": "recoveryToken", "token": "555"})
+ achallenges.RecoveryToken(chall=None, domain="example5.com"))
+ self.assertEqual(
+ response, challenges.RecoveryTokenResponse(token="555"))
response = self.rec_token.perform(
- challenge_util.RecTokenChall("example6.com"))
+ achallenges.RecoveryToken(chall=None, domain="example6.com"))
self.assertTrue(response is None)
diff --git a/letsencrypt/client/tests/standalone_authenticator_test.py b/letsencrypt/client/tests/standalone_authenticator_test.py
index b8e9baff9..9adf6a167 100644
--- a/letsencrypt/client/tests/standalone_authenticator_test.py
+++ b/letsencrypt/client/tests/standalone_authenticator_test.py
@@ -10,9 +10,9 @@ import mock
import OpenSSL.crypto
import OpenSSL.SSL
-from letsencrypt.acme import jose
+from letsencrypt.acme import challenges
-from letsencrypt.client import challenge_util
+from letsencrypt.client import achallenges
from letsencrypt.client import le_util
@@ -54,8 +54,8 @@ class ChallPrefTest(unittest.TestCase):
self.authenticator = StandaloneAuthenticator()
def test_chall_pref(self):
- self.assertEqual(
- self.authenticator.get_chall_pref("example.com"), ["dvsni"])
+ self.assertEqual(self.authenticator.get_chall_pref("example.com"),
+ [challenges.DVSNI])
class SNICallbackTest(unittest.TestCase):
@@ -64,11 +64,12 @@ class SNICallbackTest(unittest.TestCase):
from letsencrypt.client.standalone_authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator()
- name, r_b64 = "example.com", jose.b64encode("x" * 32)
test_key = pkg_resources.resource_string(
__name__, "testdata/rsa256_key.pem")
- nonce, key = "abcdef", le_util.Key("foo", test_key)
- self.cert = challenge_util.dvsni_gen_cert(name, r_b64, nonce, key)[0]
+ key = le_util.Key("foo", test_key)
+ self.cert = achallenges.DVSNI(
+ chall=challenges.DVSNI(r="x"*32, nonce="abcdef"),
+ domain="example.com", key=key).gen_cert_and_response()[0]
private_key = OpenSSL.crypto.load_privatekey(
OpenSSL.crypto.FILETYPE_PEM, key.pem)
self.authenticator.private_key = private_key
@@ -291,80 +292,71 @@ class PerformTest(unittest.TestCase):
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator()
- def test_perform_when_already_listening(self):
test_key = pkg_resources.resource_string(
__name__, "testdata/rsa256_key.pem")
- key = le_util.Key("something", test_key)
- chall1 = challenge_util.DvsniChall(
- "foo.example.com", "whee", "foononce", key)
+ self.key = le_util.Key("something", test_key)
+
+ self.achall1 = achallenges.DVSNI(
+ chall=challenges.DVSNI(r="whee", nonce="foo"),
+ domain="foo.example.com", key=self.key)
+ self.achall2 = achallenges.DVSNI(
+ chall=challenges.DVSNI(r="whee", nonce="bar"),
+ domain="bar.example.com", key=self.key)
+ bad_achall = ("This", "Represents", "A Non-DVSNI", "Challenge")
+ self.achalls = [self.achall1, self.achall2, bad_achall]
+
+ def test_perform_when_already_listening(self):
self.authenticator.already_listening = mock.Mock()
self.authenticator.already_listening.return_value = True
- result = self.authenticator.perform([chall1])
+ result = self.authenticator.perform([self.achall1])
self.assertEqual(result, [None])
def test_can_perform(self):
"""What happens if start_listener() returns True."""
- test_key = pkg_resources.resource_string(
- __name__, "testdata/rsa256_key.pem")
- key = le_util.Key("something", test_key)
- chall1 = challenge_util.DvsniChall(
- "foo.example.com", "whee", "foononce", key)
- chall2 = challenge_util.DvsniChall(
- "bar.example.com", "whee", "barnonce", key)
- bad_chall = ("This", "Represents", "A Non-DVSNI", "Challenge")
self.authenticator.start_listener = mock.Mock()
self.authenticator.start_listener.return_value = True
- result = self.authenticator.perform([chall1, chall2, bad_chall])
+ result = self.authenticator.perform(self.achalls)
self.assertEqual(len(self.authenticator.tasks), 2)
self.assertTrue(
- self.authenticator.tasks.has_key("foononce.acme.invalid"))
+ self.authenticator.tasks.has_key(self.achall1.nonce_domain))
self.assertTrue(
- self.authenticator.tasks.has_key("barnonce.acme.invalid"))
+ self.authenticator.tasks.has_key(self.achall2.nonce_domain))
self.assertTrue(isinstance(result, list))
self.assertEqual(len(result), 3)
- self.assertTrue(isinstance(result[0], dict))
- self.assertTrue(isinstance(result[1], dict))
+ self.assertTrue(isinstance(result[0], challenges.ChallengeResponse))
+ self.assertTrue(isinstance(result[1], challenges.ChallengeResponse))
self.assertFalse(result[2])
- self.assertTrue(result[0].has_key("s"))
- self.assertTrue(result[1].has_key("s"))
- self.authenticator.start_listener.assert_called_once_with(443, key)
+ self.authenticator.start_listener.assert_called_once_with(443, self.key)
def test_cannot_perform(self):
"""What happens if start_listener() returns False."""
- test_key = pkg_resources.resource_string(
- __name__, "testdata/rsa256_key.pem")
- key = le_util.Key("something", test_key)
- chall1 = challenge_util.DvsniChall(
- "foo.example.com", "whee", "foononce", key)
- chall2 = challenge_util.DvsniChall(
- "bar.example.com", "whee", "barnonce", key)
- bad_chall = ("This", "Represents", "A Non-DVSNI", "Challenge")
self.authenticator.start_listener = mock.Mock()
self.authenticator.start_listener.return_value = False
- result = self.authenticator.perform([chall1, chall2, bad_chall])
+ result = self.authenticator.perform(self.achalls)
self.assertEqual(len(self.authenticator.tasks), 2)
self.assertTrue(
- self.authenticator.tasks.has_key("foononce.acme.invalid"))
+ self.authenticator.tasks.has_key(self.achall1.nonce_domain))
self.assertTrue(
- self.authenticator.tasks.has_key("barnonce.acme.invalid"))
+ self.authenticator.tasks.has_key(self.achall2.nonce_domain))
self.assertTrue(isinstance(result, list))
self.assertEqual(len(result), 3)
self.assertEqual(result, [None, None, False])
- self.authenticator.start_listener.assert_called_once_with(443, key)
+ self.authenticator.start_listener.assert_called_once_with(
+ 443, self. key)
def test_perform_with_pending_tasks(self):
self.authenticator.tasks = {"foononce.acme.invalid": "cert_data"}
- extra_challenge = challenge_util.DvsniChall("a", "b", "c", "d")
+ extra_achall = achallenges.DVSNI(chall="a", domain="b", key="c")
self.assertRaises(
- ValueError, self.authenticator.perform, [extra_challenge])
+ ValueError, self.authenticator.perform, [extra_achall])
def test_perform_without_challenge_list(self):
- extra_challenge = challenge_util.DvsniChall("a", "b", "c", "d")
+ extra_achall = achallenges.DVSNI(chall="a", domain="b", key="c")
# This is wrong because a challenge must be specified.
self.assertRaises(ValueError, self.authenticator.perform, [])
# This is wrong because it must be a list, not a bare challenge.
self.assertRaises(
- ValueError, self.authenticator.perform, extra_challenge)
+ ValueError, self.authenticator.perform, extra_achall)
# This is wrong because the list must contain at least one challenge.
self.assertRaises(
ValueError, self.authenticator.perform, range(20))
@@ -461,12 +453,13 @@ class DoChildProcessTest(unittest.TestCase):
from letsencrypt.client.standalone_authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator()
- name, r_b64 = "example.com", jose.b64encode("x" * 32)
test_key = pkg_resources.resource_string(
__name__, "testdata/rsa256_key.pem")
- nonce, key = "abcdef", le_util.Key("foo", test_key)
+ key = le_util.Key("foo", test_key)
self.key = key
- self.cert = challenge_util.dvsni_gen_cert(name, r_b64, nonce, key)[0]
+ self.cert = achallenges.DVSNI(
+ chall=challenges.DVSNI(r="x"*32, nonce="abcdef"),
+ domain="example.com", key=key).gen_cert_and_response()[0]
private_key = OpenSSL.crypto.load_privatekey(
OpenSSL.crypto.FILETYPE_PEM, key.pem)
self.authenticator.private_key = private_key
@@ -553,7 +546,10 @@ class CleanupTest(unittest.TestCase):
from letsencrypt.client.standalone_authenticator import \
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator()
- self.authenticator.tasks = {"foononce.acme.invalid": "stuff"}
+ self.achall = achallenges.DVSNI(
+ chall=challenges.DVSNI(r="whee", nonce="foononce"),
+ domain="foo.example.com", key="key")
+ self.authenticator.tasks = {self.achall.nonce_domain: "stuff"}
self.authenticator.child_pid = 12345
@mock.patch("letsencrypt.client.standalone_authenticator.os.kill")
@@ -561,16 +557,17 @@ class CleanupTest(unittest.TestCase):
def test_cleanup(self, mock_sleep, mock_kill):
mock_sleep.return_value = None
mock_kill.return_value = None
- chall = challenge_util.DvsniChall(
- "foo.example.com", "whee", "foononce", "key")
- self.authenticator.cleanup([chall])
+
+ self.authenticator.cleanup([self.achall])
+
mock_kill.assert_called_once_with(12345, signal.SIGINT)
mock_sleep.assert_called_once_with(1)
def test_bad_cleanup(self):
- chall = challenge_util.DvsniChall(
- "bad.example.com", "whee", "badnonce", "key")
- self.assertRaises(ValueError, self.authenticator.cleanup, [chall])
+ self.assertRaises(
+ ValueError, self.authenticator.cleanup, [achallenges.DVSNI(
+ chall=challenges.DVSNI(r="whee", nonce="badnonce"),
+ domain="bad.example.com", key="key")])
class MoreInfoTest(unittest.TestCase):
diff --git a/linter_plugin.py b/linter_plugin.py
index d5faf33ac..ac2a01f6d 100644
--- a/linter_plugin.py
+++ b/linter_plugin.py
@@ -14,8 +14,10 @@ def _transform(cls):
# fix the "no-member" error on instances of
# letsencrypt.acme.util.ImmutableMap subclasses (instance
# attributes are initialized dynamically based on __slots__)
- if (('Message' in cls.basenames or 'ImmutableMap' in cls.basenames or
- 'util.ImmutableMap' in cls.basenames) and (cls.slots() is not None)):
+
+ # TODO: this is too broad and applies to any tested class...
+
+ if cls.slots() is not None:
for slot in cls.slots():
cls.locals[slot.value] = [nodes.EmptyNode()]
diff --git a/setup.py b/setup.py
index 1fc643304..6108a1493 100755
--- a/setup.py
+++ b/setup.py
@@ -45,7 +45,7 @@ dev_extras = [
docs_extras = [
'repoze.sphinx.autointerface',
- 'Sphinx',
+ 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags
'sphinx_rtd_theme',
]
diff --git a/tox.ini b/tox.ini
index d4af50fa5..bf609a747 100644
--- a/tox.ini
+++ b/tox.ini
@@ -17,7 +17,7 @@ setenv =
basepython = python2.7
commands =
pip install -e .[testing]
- python setup.py nosetests --with-coverage --cover-min-percentage=83
+ python setup.py nosetests --with-coverage --cover-min-percentage=85
[testenv:lint]
# recent versions of pylint do not support Python 2.6 (#97, #187)