git push origin masterMerge branch 'kuba-acme.challenges'

This commit is contained in:
James Kasten 2015-03-04 23:01:39 -08:00
commit f69f9809a1
47 changed files with 2294 additions and 1265 deletions

View file

@ -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

51
docs/api/acme.rst Normal file
View file

@ -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:

View file

@ -1,5 +0,0 @@
:mod:`letsencrypt.acme.errors`
------------------------------
.. automodule:: letsencrypt.acme.errors
:members:

View file

@ -1,5 +0,0 @@
:mod:`letsencrypt.acme.interfaces`
----------------------------------
.. automodule:: letsencrypt.acme.interfaces
:members:

View file

@ -1,5 +0,0 @@
:mod:`letsencrypt.acme.jose`
----------------------------
.. automodule:: letsencrypt.acme.jose
:members:

View file

@ -1,5 +0,0 @@
:mod:`letsencrypt.acme.messages`
--------------------------------
.. automodule:: letsencrypt.acme.messages
:members:

View file

@ -1,5 +0,0 @@
:mod:`letsencrypt.acme.other`
-----------------------------
.. automodule:: letsencrypt.acme.other
:members:

View file

@ -1,5 +0,0 @@
:mod:`letsencrypt.acme.util`
----------------------------
.. automodule:: letsencrypt.acme.util
:members:

View file

@ -0,0 +1,5 @@
:mod:`letsencrypt.client.achallenges`
-------------------------------------
.. automodule:: letsencrypt.client.achallenges
:members:

View file

@ -1,5 +0,0 @@
:mod:`letsencrypt.client.challenge_util`
----------------------------------------
.. automodule:: letsencrypt.client.challenge_util
:members:

View file

@ -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']

View file

@ -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
"""

View file

@ -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()

View file

@ -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()

View file

@ -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."""

View file

@ -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).
"""

View file

@ -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
#

View file

@ -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 = {

View file

@ -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"])

View file

@ -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'

View file

@ -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']))

View file

@ -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__':

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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:

View file

@ -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 = "<IfModule mod_ssl.c>\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 += "</IfModule>\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 ("<VirtualHost " + ips + ">\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"
"</VirtualHost>\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")

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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)."""

View file

@ -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`.
"""

View file

@ -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.

View file

@ -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))

View file

@ -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")

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y\x80"
"\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945",
nonce="Y\xed\x01L\xac\x95\xf7pW\xb1\xd7"
"\xa1\xb2\xc5\x96\xba",
), domain="letsencrypt.demo", key=auth_key),
]
def tearDown(self):
shutil.rmtree(self.temp_dir)
@ -57,48 +62,40 @@ class DvsniPerformTest(util.ApacheTest):
resp = self.sni.perform()
self.assertTrue(resp is None)
@mock.patch("letsencrypt.client.challenge_util.dvsni_gen_cert")
def test_setup_challenge_cert(self, mock_dvsni_gen_cert):
def test_setup_challenge_cert(self):
# This is a helper function that can be used for handling
# open context managers more elegantly. It avoids dealing with
# __enter__ and __exit__ calls.
# http://www.voidspace.org.uk/python/mock/helpers.html#mock.mock_open
chall = self.challs[0]
m_open = mock.mock_open()
mock_dvsni_gen_cert.return_value = ("pem", "randomS1")
response = challenges.DVSNIResponse(s="randomS1")
achall = mock.MagicMock(nonce=self.achalls[0].nonce,
nonce_domain=self.achalls[0].nonce_domain)
achall.gen_cert_and_response.return_value = ("pem", response)
with mock.patch("letsencrypt.client.apache.dvsni.open",
m_open, create=True):
# pylint: disable=protected-access
s_b64 = self.sni._setup_challenge_cert(chall)
self.assertEqual(s_b64, "randomS1")
self.assertEqual(response, self.sni._setup_challenge_cert(
achall, "randomS1"))
self.assertTrue(m_open.called)
self.assertEqual(
m_open.call_args[0], (self.sni.get_cert_file(chall.nonce), 'w'))
m_open.call_args[0], (self.sni.get_cert_file(achall), 'w'))
self.assertEqual(m_open().write.call_args[0][0], "pem")
self.assertEqual(mock_dvsni_gen_cert.call_count, 1)
calls = mock_dvsni_gen_cert.call_args_list
expected_call_list = [
(chall.domain, chall.r_b64, chall.nonce, chall.key)
]
for i in xrange(len(expected_call_list)):
for j in xrange(len(expected_call_list[0])):
self.assertEqual(calls[i][0][j], expected_call_list[i][j])
def test_perform1(self):
chall = self.challs[0]
self.sni.add_chall(chall)
mock_setup_cert = mock.MagicMock(return_value="randomS1")
achall = self.achalls[0]
self.sni.add_chall(achall)
mock_setup_cert = mock.MagicMock(
return_value=challenges.DVSNIResponse(s="randomS1"))
# pylint: disable=protected-access
self.sni._setup_challenge_cert = mock_setup_cert
responses = self.sni.perform()
mock_setup_cert.assert_called_once_with(chall)
mock_setup_cert.assert_called_once_with(achall)
# Check to make sure challenge config path is included in apache config.
self.assertEqual(
@ -106,13 +103,15 @@ class DvsniPerformTest(util.ApacheTest):
"Include", self.sni.challenge_conf)),
1)
self.assertEqual(len(responses), 1)
self.assertEqual(responses[0]["s"], "randomS1")
self.assertEqual(responses[0].s, "randomS1")
def test_perform2(self):
for chall in self.challs:
self.sni.add_chall(chall)
for achall in self.achalls:
self.sni.add_chall(achall)
mock_setup_cert = mock.MagicMock(side_effect=["randomS0", "randomS1"])
mock_setup_cert = mock.MagicMock(side_effect=[
challenges.DVSNIResponse(s="randomS0"),
challenges.DVSNIResponse(s="randomS1")])
# pylint: disable=protected-access
self.sni._setup_challenge_cert = mock_setup_cert
@ -122,9 +121,9 @@ class DvsniPerformTest(util.ApacheTest):
# Make sure calls made to mocked function were correct
self.assertEqual(
mock_setup_cert.call_args_list[0], mock.call(self.challs[0]))
mock_setup_cert.call_args_list[0], mock.call(self.achalls[0]))
self.assertEqual(
mock_setup_cert.call_args_list[1], mock.call(self.challs[1]))
mock_setup_cert.call_args_list[1], mock.call(self.achalls[1]))
self.assertEqual(
len(self.sni.configurator.parser.find_dir(
@ -132,11 +131,11 @@ class DvsniPerformTest(util.ApacheTest):
1)
self.assertEqual(len(responses), 2)
for i in xrange(2):
self.assertEqual(responses[i]["s"], "randomS%d" % i)
self.assertEqual(responses[i].s, "randomS%d" % i)
def test_mod_config(self):
for chall in self.challs:
self.sni.add_chall(chall)
for achall in self.achalls:
self.sni.add_chall(achall)
v_addr1 = [Addr(("1.2.3.4", "443")), Addr(("5.6.7.8", "443"))]
v_addr2 = [Addr(("127.0.0.1", "443"))]
ll_addr = []
@ -159,14 +158,12 @@ class DvsniPerformTest(util.ApacheTest):
if vhost.addrs == set(v_addr1):
self.assertEqual(
vhost.names,
set([str(self.challs[0].nonce +
constants.DVSNI_DOMAIN_SUFFIX)]))
set([self.achalls[0].nonce_domain]))
else:
self.assertEqual(vhost.addrs, set(v_addr2))
self.assertEqual(
vhost.names,
set([str(self.challs[1].nonce +
constants.DVSNI_DOMAIN_SUFFIX)]))
set([self.achalls[1].nonce_domain]))
if __name__ == '__main__':

View file

@ -4,21 +4,22 @@ import unittest
import mock
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 errors
from letsencrypt.client.tests import acme_util
TRANSLATE = {
"dvsni": "DvsniChall",
"simpleHttps": "SimpleHttpsChall",
"dns": "DnsChall",
"recoveryToken": "RecTokenChall",
"recoveryContact": "RecContactChall",
"proofOfPossession": "PopChall",
"dvsni": "DVSNI",
"simpleHttps": "SimpleHTTPS",
"dns": "DNS",
"recoveryToken": "RecoveryToken",
"recoveryContact": "RecoveryContact",
"proofOfPossession": "ProofOfPossession",
}
@ -31,8 +32,9 @@ class SatisfyChallengesTest(unittest.TestCase):
self.mock_dv_auth = mock.MagicMock(name="ApacheConfigurator")
self.mock_client_auth = mock.MagicMock(name="ClientAuthenticator")
self.mock_dv_auth.get_chall_pref.return_value = ["dvsni"]
self.mock_client_auth.get_chall_pref.return_value = ["recoveryToken"]
self.mock_dv_auth.get_chall_pref.return_value = [challenges.DVSNI]
self.mock_client_auth.get_chall_pref.return_value = [
challenges.RecoveryToken]
self.mock_client_auth.perform.side_effect = gen_auth_resp
self.mock_dv_auth.perform.side_effect = gen_auth_resp
@ -47,9 +49,9 @@ class SatisfyChallengesTest(unittest.TestCase):
def test_name1_dvsni1(self):
dom = "0"
challenge = [acme_util.CHALLENGES["dvsni"]]
msg = messages.Challenge(session_id=dom, nonce="nonce0",
challenges=challenge, combinations=[])
msg = messages.Challenge(
session_id=dom, nonce="nonce0", combinations=[],
challenges=[acme_util.DVSNI])
self.handler.add_chall_msg(dom, msg, "dummy_key")
self.handler._satisfy_challenges() # pylint: disable=protected-access
@ -57,7 +59,7 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(len(self.handler.responses), 1)
self.assertEqual(len(self.handler.responses[dom]), 1)
self.assertEqual("DvsniChall0", self.handler.responses[dom][0])
self.assertEqual("DVSNI0", self.handler.responses[dom][0])
self.assertEqual(len(self.handler.dv_c), 1)
self.assertEqual(len(self.handler.client_c), 1)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
@ -65,9 +67,9 @@ class SatisfyChallengesTest(unittest.TestCase):
def test_name1_rectok1(self):
dom = "0"
challenge = [acme_util.CHALLENGES["recoveryToken"]]
msg = messages.Challenge(session_id=dom, nonce="nonce0",
challenges=challenge, combinations=[])
msg = messages.Challenge(
session_id=dom, nonce="nonce0", combinations=[],
challenges=[acme_util.RECOVERY_TOKEN])
self.handler.add_chall_msg(dom, msg, "dummy_key")
self.handler._satisfy_challenges() # pylint: disable=protected-access
@ -79,7 +81,7 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(self.mock_client_auth.perform.call_count, 1)
self.assertEqual(self.mock_dv_auth.perform.call_count, 0)
self.assertEqual("RecTokenChall0", self.handler.responses[dom][0])
self.assertEqual("RecoveryToken0", self.handler.responses[dom][0])
# Assert 1 domain
self.assertEqual(len(self.handler.dv_c), 1)
self.assertEqual(len(self.handler.client_c), 1)
@ -88,12 +90,12 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(len(self.handler.client_c[dom]), 1)
def test_name5_dvsni5(self):
challenge = [acme_util.CHALLENGES["dvsni"]]
for i in xrange(5):
self.handler.add_chall_msg(
str(i),
messages.Challenge(session_id=str(i), nonce="nonce%d" % i,
challenges=challenge, combinations=[]),
challenges=[acme_util.DVSNI],
combinations=[]),
"dummy_key")
self.handler._satisfy_challenges() # pylint: disable=protected-access
@ -110,30 +112,31 @@ class SatisfyChallengesTest(unittest.TestCase):
for i in xrange(5):
dom = str(i)
self.assertEqual(len(self.handler.responses[dom]), 1)
self.assertEqual(self.handler.responses[dom][0], "DvsniChall%d" % i)
self.assertEqual(self.handler.responses[dom][0], "DVSNI%d" % i)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.client_c[dom]), 0)
self.assertTrue(isinstance(self.handler.dv_c[dom][0].chall,
challenge_util.DvsniChall))
self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall,
achallenges.DVSNI))
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
def test_name1_auth(self, mock_chall_path):
dom = "0"
challenges = acme_util.get_dv_challenges()
combos = acme_util.gen_combos(challenges)
self.handler.add_chall_msg(
dom,
messages.Challenge(session_id="0", nonce="nonce0",
challenges=challenges, combinations=combos),
messages.Challenge(
session_id="0", nonce="nonce0",
challenges=acme_util.DV_CHALLENGES,
combinations=acme_util.gen_combos(acme_util.DV_CHALLENGES)),
"dummy_key")
path = gen_path(["simpleHttps"], challenges)
path = gen_path([acme_util.SIMPLE_HTTPS], acme_util.DV_CHALLENGES)
mock_chall_path.return_value = path
self.handler._satisfy_challenges() # pylint: disable=protected-access
self.assertEqual(len(self.handler.responses), 1)
self.assertEqual(len(self.handler.responses[dom]), len(challenges))
self.assertEqual(len(self.handler.responses[dom]),
len(acme_util.DV_CHALLENGES))
self.assertEqual(len(self.handler.dv_c), 1)
self.assertEqual(len(self.handler.client_c), 1)
@ -143,32 +146,34 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(
self.handler.responses[dom],
self._get_exp_response(dom, path, challenges))
self._get_exp_response(dom, path, acme_util.DV_CHALLENGES))
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.client_c[dom]), 0)
self.assertTrue(isinstance(self.handler.dv_c[dom][0].chall,
challenge_util.SimpleHttpsChall))
self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall,
achallenges.SimpleHTTPS))
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
def test_name1_all(self, mock_chall_path):
dom = "0"
challenges = acme_util.get_challenges()
combos = acme_util.gen_combos(challenges)
combos = acme_util.gen_combos(acme_util.CHALLENGES)
self.handler.add_chall_msg(
dom,
messages.Challenge(session_id=dom, nonce="nonce0",
challenges=challenges, combinations=combos),
messages.Challenge(
session_id=dom, nonce="nonce0", challenges=acme_util.CHALLENGES,
combinations=combos),
"dummy_key")
path = gen_path(["simpleHttps", "recoveryToken"], challenges)
path = gen_path([acme_util.SIMPLE_HTTPS, acme_util.RECOVERY_TOKEN],
acme_util.CHALLENGES)
mock_chall_path.return_value = path
self.handler._satisfy_challenges() # pylint: disable=protected-access
self.assertEqual(len(self.handler.responses), 1)
self.assertEqual(len(self.handler.responses[dom]), len(challenges))
self.assertEqual(
len(self.handler.responses[dom]), len(acme_util.CHALLENGES))
self.assertEqual(len(self.handler.dv_c), 1)
self.assertEqual(len(self.handler.client_c), 1)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
@ -176,25 +181,25 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(
self.handler.responses[dom],
self._get_exp_response(dom, path, challenges))
self.assertTrue(isinstance(self.handler.dv_c[dom][0].chall,
challenge_util.SimpleHttpsChall))
self.assertTrue(isinstance(self.handler.client_c[dom][0].chall,
challenge_util.RecTokenChall))
self._get_exp_response(dom, path, acme_util.CHALLENGES))
self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall,
achallenges.SimpleHTTPS))
self.assertTrue(isinstance(self.handler.client_c[dom][0].achall,
achallenges.RecoveryToken))
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
def test_name5_all(self, mock_chall_path):
challenges = acme_util.get_challenges()
combos = acme_util.gen_combos(challenges)
combos = acme_util.gen_combos(acme_util.CHALLENGES)
for i in xrange(5):
self.handler.add_chall_msg(
str(i),
messages.Challenge(
session_id=str(i), nonce="nonce%d" % i,
challenges=challenges, combinations=combos),
challenges=acme_util.CHALLENGES, combinations=combos),
"dummy_key")
path = gen_path(["dvsni", "recoveryContact"], challenges)
path = gen_path([acme_util.DVSNI, acme_util.RECOVERY_CONTACT],
acme_util.CHALLENGES)
mock_chall_path.return_value = path
self.handler._satisfy_challenges() # pylint: disable=protected-access
@ -202,7 +207,7 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(len(self.handler.responses), 5)
for i in xrange(5):
self.assertEqual(
len(self.handler.responses[str(i)]), len(challenges))
len(self.handler.responses[str(i)]), len(acme_util.CHALLENGES))
self.assertEqual(len(self.handler.dv_c), 5)
self.assertEqual(len(self.handler.client_c), 5)
@ -210,28 +215,28 @@ class SatisfyChallengesTest(unittest.TestCase):
dom = str(i)
self.assertEqual(
self.handler.responses[dom],
self._get_exp_response(dom, path, challenges))
self._get_exp_response(dom, path, acme_util.CHALLENGES))
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.client_c[dom]), 1)
self.assertTrue(isinstance(self.handler.dv_c[dom][0].chall,
challenge_util.DvsniChall))
self.assertTrue(isinstance(self.handler.client_c[dom][0].chall,
challenge_util.RecContactChall))
self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall,
achallenges.DVSNI))
self.assertTrue(isinstance(self.handler.client_c[dom][0].achall,
achallenges.RecoveryContact))
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
def test_name5_mix(self, mock_chall_path):
paths = []
chosen_chall = [["dns"],
["dvsni"],
["simpleHttps", "proofOfPossession"],
["simpleHttps"],
["dns", "recoveryToken"]]
challenge_list = [acme_util.get_dv_challenges(),
[acme_util.CHALLENGES["dvsni"]],
acme_util.get_challenges(),
acme_util.get_dv_challenges(),
acme_util.get_challenges()]
chosen_chall = [[acme_util.DNS],
[acme_util.DVSNI],
[acme_util.SIMPLE_HTTPS, acme_util.POP],
[acme_util.SIMPLE_HTTPS],
[acme_util.DNS, acme_util.RECOVERY_TOKEN]]
challenge_list = [acme_util.DV_CHALLENGES,
[acme_util.DVSNI],
acme_util.CHALLENGES,
acme_util.DV_CHALLENGES,
acme_util.CHALLENGES]
# Combos doesn't matter since I am overriding the gen_path function
for i in xrange(5):
@ -260,21 +265,21 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(
len(self.handler.client_c[dom]), len(chosen_chall[i]) - 1)
self.assertTrue(isinstance(self.handler.dv_c["0"][0].chall,
challenge_util.DnsChall))
self.assertTrue(isinstance(self.handler.dv_c["1"][0].chall,
challenge_util.DvsniChall))
self.assertTrue(isinstance(self.handler.dv_c["2"][0].chall,
challenge_util.SimpleHttpsChall))
self.assertTrue(isinstance(self.handler.dv_c["3"][0].chall,
challenge_util.SimpleHttpsChall))
self.assertTrue(isinstance(self.handler.dv_c["4"][0].chall,
challenge_util.DnsChall))
self.assertTrue(isinstance(
self.handler.dv_c["0"][0].achall, achallenges.DNS))
self.assertTrue(isinstance(
self.handler.dv_c["1"][0].achall, achallenges.DVSNI))
self.assertTrue(isinstance(
self.handler.dv_c["2"][0].achall, achallenges.SimpleHTTPS))
self.assertTrue(isinstance(
self.handler.dv_c["3"][0].achall, achallenges.SimpleHTTPS))
self.assertTrue(isinstance(
self.handler.dv_c["4"][0].achall, achallenges.DNS))
self.assertTrue(isinstance(self.handler.client_c["2"][0].chall,
challenge_util.PopChall))
self.assertTrue(isinstance(self.handler.client_c["4"][0].chall,
challenge_util.RecTokenChall))
self.assertTrue(isinstance(self.handler.client_c["2"][0].achall,
achallenges.ProofOfPossession))
self.assertTrue(isinstance(
self.handler.client_c["4"][0].achall, achallenges.RecoveryToken))
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
def test_perform_exception_cleanup(self, mock_chall_path):
@ -282,21 +287,20 @@ class SatisfyChallengesTest(unittest.TestCase):
# pylint: disable=protected-access
self.mock_dv_auth.perform.side_effect = errors.LetsEncryptDvsniError
challenges = acme_util.get_challenges()
combos = acme_util.gen_combos(challenges)
combos = acme_util.gen_combos(acme_util.CHALLENGES)
for i in xrange(3):
self.handler.add_chall_msg(
str(i),
messages.Challenge(
session_id=str(i), nonce="nonce%d" % i,
challenges=challenges, combinations=combos),
challenges=acme_util.CHALLENGES, combinations=combos),
"dummy_key")
mock_chall_path.side_effect = [
gen_path(["dvsni", "proofOfPossession"], challenges),
gen_path(["proofOfPossession"], challenges),
gen_path(["dvsni"], challenges),
gen_path([acme_util.DVSNI, acme_util.POP], acme_util.CHALLENGES),
gen_path([acme_util.POP], acme_util.CHALLENGES),
gen_path([acme_util.DVSNI], acme_util.CHALLENGES),
]
# This may change in the future... but for now catch the error
@ -316,7 +320,7 @@ class SatisfyChallengesTest(unittest.TestCase):
dv_chall_list = dv_cleanup_args[i][0][0]
self.assertEqual(len(dv_chall_list), 1)
self.assertTrue(
isinstance(dv_chall_list[0], challenge_util.DvsniChall))
isinstance(dv_chall_list[0], achallenges.DVSNI))
# Check Auth cleanup
@ -324,14 +328,14 @@ class SatisfyChallengesTest(unittest.TestCase):
client_chall_list = client_cleanup_args[i][0][0]
self.assertEqual(len(client_chall_list), 1)
self.assertTrue(
isinstance(client_chall_list[0], challenge_util.PopChall))
isinstance(client_chall_list[0], achallenges.ProofOfPossession))
def _get_exp_response(self, domain, path, challenges):
def _get_exp_response(self, domain, path, challs):
# pylint: disable=no-self-use
exp_resp = ["null"] * len(challenges)
exp_resp = [None] * len(challs)
for i in path:
exp_resp[i] = TRANSLATE[challenges[i]["type"]] + str(domain)
exp_resp[i] = TRANSLATE[challs[i].acme_type] + str(domain)
return exp_resp
@ -357,12 +361,12 @@ class GetAuthorizationsTest(unittest.TestCase):
def test_solved3_at_once(self):
# Set 3 DVSNI challenges
challenge = [acme_util.CHALLENGES["dvsni"]]
for i in xrange(3):
self.handler.add_chall_msg(
str(i),
messages.Challenge(session_id=str(i), nonce="nonce%d" % i,
challenges=challenge, combinations=[]),
messages.Challenge(
session_id=str(i), nonce="nonce%d" % i,
challenges=[acme_util.DVSNI], combinations=[]),
"dummy_key")
self.mock_sat_chall.side_effect = self._sat_solved_at_once
@ -379,7 +383,7 @@ class GetAuthorizationsTest(unittest.TestCase):
def _sat_solved_at_once(self):
for i in xrange(3):
dom = str(i)
self.handler.responses[dom] = ["DvsniChall%d" % i]
self.handler.responses[dom] = ["DVSNI%d" % i]
self.handler.paths[dom] = [0]
# Assignment was > 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()

View file

@ -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()

View file

@ -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])

View file

@ -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)

View file

@ -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):

View file

@ -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()]

View file

@ -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',
]

View file

@ -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)