Merge remote-tracking branch 'github/letsencrypt/master' into bugs/273

Conflicts:
	letsencrypt/client/plugins/standalone/tests/authenticator_test.py
This commit is contained in:
Jakub Warmuz
2015-05-01 19:18:19 +00:00
41 changed files with 1833 additions and 1055 deletions
+5
View File
@@ -0,0 +1,5 @@
:mod:`letsencrypt.client.account`
---------------------------------
.. automodule:: letsencrypt.client.account
:members:
+4 -14
View File
@@ -1,22 +1,12 @@
"""ACME protocol implementation. """ACME protocol implementation.
.. warning:: This module is an implementation of the draft `ACME This module is an implementation of the `ACME protocol`_. Latest
protocol version 00`_, and not the latest (as of time of writing), supported version: `v02`_.
"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`: https://github.com/letsencrypt/acme-spec
.. _`ACME protocol version 00`: .. _`v02`:
https://github.com/letsencrypt/acme-spec/blob/v00/draft-barnes-acme.md https://github.com/letsencrypt/acme-spec/commit/d328fea2d507deb9822793c512830d827a4150c4
.. _`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
""" """
+3 -5
View File
@@ -216,7 +216,7 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable):
value = getattr(self, slot) value = getattr(self, slot)
if field.omit(value): if field.omit(value):
logging.debug('Ommiting empty field "%s" (%s)', slot, value) logging.debug('Omitting empty field "%s" (%s)', slot, value)
else: else:
try: try:
jobj[field.json_name] = field.encode(value) jobj[field.json_name] = field.encode(value)
@@ -372,17 +372,15 @@ class TypedJSONObjectWithFields(JSONObjectWithFields):
raise errors.DeserializationError("missing type field") raise errors.DeserializationError("missing type field")
try: try:
type_cls = cls.TYPES[typ] return cls.TYPES[typ]
except KeyError: except KeyError:
raise errors.UnrecognizedTypeError(typ, jobj) raise errors.UnrecognizedTypeError(typ, jobj)
return type_cls
def to_partial_json(self): def to_partial_json(self):
"""Get JSON serializable object. """Get JSON serializable object.
:returns: Serializable JSON object representing ACME typed object. :returns: Serializable JSON object representing ACME typed object.
:meth:`validate` will almost certianly not work, due to reasons :meth:`validate` will almost certainly not work, due to reasons
explained in :class:`letsencrypt.acme.interfaces.IJSONSerializable`. explained in :class:`letsencrypt.acme.interfaces.IJSONSerializable`.
:rtype: dict :rtype: dict
+22 -1
View File
@@ -1,4 +1,25 @@
"""ACME protocol messages.""" """ACME protocol v00 messages.
.. warning:: This module is an implementation of the draft `ACME
protocol version 00`_, and not the "RESTified" `ACME protocol version
01`_ or later. It should work with `older Node.js implementation`_,
but will definitely not work with Boulder_. It is kept for reference
purposes only.
.. _`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
.. _`older Node.js implementation`:
https://github.com/letsencrypt/node-acme/commit/f42aa5b7fad4cd2fc289653c4ab14f18052367b3
"""
import jsonschema import jsonschema
from letsencrypt.acme import challenges from letsencrypt.acme import challenges
+16 -16
View File
@@ -1,4 +1,4 @@
"""ACME protocol v02 messages.""" """ACME protocol messages."""
from letsencrypt.acme import challenges from letsencrypt.acme import challenges
from letsencrypt.acme import fields from letsencrypt.acme import fields
from letsencrypt.acme import jose from letsencrypt.acme import jose
@@ -10,7 +10,6 @@ class Error(jose.JSONObjectWithFields, Exception):
https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00
""" """
ERROR_TYPE_NAMESPACE = 'urn:acme:error:' ERROR_TYPE_NAMESPACE = 'urn:acme:error:'
ERROR_TYPE_DESCRIPTIONS = { ERROR_TYPE_DESCRIPTIONS = {
'malformed': 'The request message was malformed', 'malformed': 'The request message was malformed',
@@ -19,7 +18,7 @@ class Error(jose.JSONObjectWithFields, Exception):
'badCSR': 'The CSR is unacceptable (e.g., due to a short key)', 'badCSR': 'The CSR is unacceptable (e.g., due to a short key)',
} }
# TODO: Boulder omits 'type' and 'instance', spec requires # TODO: Boulder omits 'type' and 'instance', spec requires, boulder#128
typ = jose.Field('type', omitempty=True) typ = jose.Field('type', omitempty=True)
title = jose.Field('title', omitempty=True) title = jose.Field('title', omitempty=True)
detail = jose.Field('detail') detail = jose.Field('detail')
@@ -73,6 +72,9 @@ class _Constant(jose.JSONDeSerializable):
def __eq__(self, other): def __eq__(self, other):
return isinstance(other, type(self)) and other.name == self.name return isinstance(other, type(self)) and other.name == self.name
def __ne__(self, other):
return not self.__eq__(other)
class Status(_Constant): class Status(_Constant):
"""ACME "status" field.""" """ACME "status" field."""
@@ -130,10 +132,9 @@ class Registration(ResourceBody):
"""Registration Resource Body. """Registration Resource Body.
:ivar letsencrypt.acme.jose.jwk.JWK key: Public key. :ivar letsencrypt.acme.jose.jwk.JWK key: Public key.
:ivar tuple contact: :ivar tuple contact: Contact information following ACME spec
""" """
# on new-reg key server ignores 'key' and populates it based on # on new-reg key server ignores 'key' and populates it based on
# JWS.signature.combined.jwk # JWS.signature.combined.jwk
key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json)
@@ -163,20 +164,17 @@ class ChallengeBody(ResourceBody):
.. todo:: .. todo::
Confusingly, this has a similar name to `.challenges.Challenge`, Confusingly, this has a similar name to `.challenges.Challenge`,
as well as `.achallenges.AnnotatedChallenge` or as well as `.achallenges.AnnotatedChallenge`. Please use names
`.achallenges.Indexed`... Once `messages2` and `network2` is such as ``challb`` to distinguish instances of this class from
integrated with the rest of the client, this class functionality ``achall``.
will be merged with `.challenges.Challenge`. Meanwhile,
separation allows the ``master`` to be still interoperable with
Node.js server (protocol v00). For the time being use names such
as ``challb`` to distinguish instances of this class from
``achall`` or ``ichall``.
:ivar letsencrypt.acme.challenges.Challenge: Wrapped challenge.
Conveniently, all challenge fields are proxied, i.e. you can
call ``challb.x`` to get ``challb.chall.x`` contents.
:ivar letsencrypt.acme.messages2.Status status: :ivar letsencrypt.acme.messages2.Status status:
:ivar datetime.datetime validated: :ivar datetime.datetime validated:
""" """
__slots__ = ('chall',) __slots__ = ('chall',)
uri = jose.Field('uri') uri = jose.Field('uri')
status = jose.Field('status', decoder=Status.from_json) status = jose.Field('status', decoder=Status.from_json)
@@ -193,6 +191,9 @@ class ChallengeBody(ResourceBody):
jobj_fields['chall'] = challenges.Challenge.from_json(jobj) jobj_fields['chall'] = challenges.Challenge.from_json(jobj)
return jobj_fields return jobj_fields
def __getattr__(self, name):
return getattr(self.chall, name)
class AuthorizationResource(Resource): class AuthorizationResource(Resource):
"""Authorization Resource. """Authorization Resource.
@@ -208,7 +209,7 @@ class Authorization(ResourceBody):
"""Authorization Resource Body. """Authorization Resource Body.
:ivar letsencrypt.acme.messages2.Identifier identifier: :ivar letsencrypt.acme.messages2.Identifier identifier:
:ivar list challenges: `list` of `Challenge` :ivar list challenges: `list` of `.ChallengeBody`
:ivar tuple combinations: Challenge combinations (`tuple` of `tuple` :ivar tuple combinations: Challenge combinations (`tuple` of `tuple`
of `int`, as opposed to `list` of `list` from the spec). of `int`, as opposed to `list` of `list` from the spec).
:ivar letsencrypt.acme.jose.jwk.JWK key: Public key. :ivar letsencrypt.acme.jose.jwk.JWK key: Public key.
@@ -217,7 +218,6 @@ class Authorization(ResourceBody):
:ivar datetime.datetime expires: :ivar datetime.datetime expires:
""" """
identifier = jose.Field('identifier', decoder=Identifier.from_json) identifier = jose.Field('identifier', decoder=Identifier.from_json)
challenges = jose.Field('challenges', omitempty=True) challenges = jose.Field('challenges', omitempty=True)
combinations = jose.Field('combinations', omitempty=True) combinations = jose.Field('combinations', omitempty=True)
+7
View File
@@ -79,6 +79,13 @@ class ConstantTest(unittest.TestCase):
self.assertEqual('MockConstant(a)', repr(self.const_a)) self.assertEqual('MockConstant(a)', repr(self.const_a))
self.assertEqual('MockConstant(b)', repr(self.const_b)) self.assertEqual('MockConstant(b)', repr(self.const_b))
def test_equality(self):
const_a_prime = self.MockConstant('a')
self.assertFalse(self.const_a == self.const_b)
self.assertTrue(self.const_a == const_a_prime)
self.assertTrue(self.const_a != self.const_b)
self.assertFalse(self.const_a != const_a_prime)
class RegistrationTest(unittest.TestCase): class RegistrationTest(unittest.TestCase):
"""Tests for letsencrypt.acme.messages2.Registration.""" """Tests for letsencrypt.acme.messages2.Registration."""
+231
View File
@@ -0,0 +1,231 @@
"""Creates ACME accounts for server."""
import logging
import os
import re
import configobj
import zope.component
from letsencrypt.acme import messages2
from letsencrypt.client import crypto_util
from letsencrypt.client import errors
from letsencrypt.client import interfaces
from letsencrypt.client import le_util
from letsencrypt.client.display import util as display_util
class Account(object):
"""ACME protocol registration.
:ivar config: Client configuration object
:type config: :class:`~letsencrypt.client.interfaces.IConfig`
:ivar key: Account/Authorized Key
:type key: :class:`~letsencrypt.client.le_util.Key`
:ivar str email: Client's email address
:ivar str phone: Client's phone number
:ivar regr: Registration Resource
:type regr: :class:`~letsencrypt.acme.messages2.RegistrationResource`
"""
# Just make sure we don't get pwned
# Make sure that it also doesn't start with a period or have two consecutive
# periods <- this needs to be done in addition to the regex
EMAIL_REGEX = re.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$")
def __init__(self, config, key, email=None, phone=None, regr=None):
le_util.make_or_verify_dir(
config.accounts_dir, 0o700, os.geteuid())
self.key = key
self.config = config
if email is not None and self.safe_email(email):
self.email = email
else:
self.email = None
self.phone = phone
self.regr = regr
@property
def uri(self):
"""URI link for new registrations."""
if self.regr is not None:
return self.regr.uri
else:
return None
@property
def new_authzr_uri(self): # pylint: disable=missing-docstring
if self.regr is not None:
return self.regr.new_authzr_uri
else:
return None
@property
def terms_of_service(self): # pylint: disable=missing-docstring
if self.regr is not None:
return self.regr.terms_of_service
else:
return None
@property
def recovery_token(self): # pylint: disable=missing-docstring
if self.regr is not None and self.regr.body is not None:
return self.regr.body.recovery_token
else:
return None
def save(self):
"""Save account to disk."""
le_util.make_or_verify_dir(
self.config.accounts_dir, 0o700, os.geteuid())
acc_config = configobj.ConfigObj()
acc_config.filename = os.path.join(
self.config.accounts_dir, self._get_config_filename(self.email))
acc_config.initial_comment = [
"DO NOT EDIT THIS FILE",
"Account information for %s under %s" % (
self._get_config_filename(self.email), self.config.server),
]
acc_config["key"] = self.key.file
acc_config["phone"] = self.phone
if self.regr is not None:
acc_config["RegistrationResource"] = {}
acc_config["RegistrationResource"]["uri"] = self.uri
acc_config["RegistrationResource"]["new_authzr_uri"] = (
self.new_authzr_uri)
acc_config["RegistrationResource"]["terms_of_service"] = (
self.terms_of_service)
regr_dict = self.regr.body.to_json()
acc_config["RegistrationResource"]["body"] = regr_dict
acc_config.write()
@classmethod
def _get_config_filename(cls, email):
return email if email is not None and email else "default"
@classmethod
def from_existing_account(cls, config, email=None):
"""Populate an account from an existing email."""
config_fp = os.path.join(
config.accounts_dir, cls._get_config_filename(email))
return cls._from_config_fp(config, config_fp)
@classmethod
def _from_config_fp(cls, config, config_fp):
try:
acc_config = configobj.ConfigObj(
infile=config_fp, file_error=True, create_empty=False)
except IOError:
raise errors.LetsEncryptClientError(
"Account for %s does not exist" % os.path.basename(config_fp))
if os.path.basename(config_fp) != "default":
email = os.path.basename(config_fp)
else:
email = None
phone = acc_config["phone"] if acc_config["phone"] != "None" else None
with open(acc_config["key"]) as key_file:
key = le_util.Key(acc_config["key"], key_file.read())
if "RegistrationResource" in acc_config:
acc_config_rr = acc_config["RegistrationResource"]
regr = messages2.RegistrationResource(
uri=acc_config_rr["uri"],
new_authzr_uri=acc_config_rr["new_authzr_uri"],
terms_of_service=acc_config_rr["terms_of_service"],
body=messages2.Registration.from_json(acc_config_rr["body"]))
else:
regr = None
return cls(config, key, email, phone, regr)
@classmethod
def get_accounts(cls, config):
"""Return all current accounts.
:param config: Configuration
:type config: :class:`letsencrypt.client.interfaces.IConfig`
"""
try:
filenames = os.listdir(config.accounts_dir)
except OSError:
return []
accounts = []
for name in filenames:
# Not some directory ie. keys
config_fp = os.path.join(config.accounts_dir, name)
if os.path.isfile(config_fp):
accounts.append(cls._from_config_fp(config, config_fp))
return accounts
@classmethod
def from_prompts(cls, config):
"""Generate an account from prompted user input.
:param config: Configuration
:type config: :class:`letsencrypt.client.interfaces.IConfig`
:returns: Account or None
:rtype: :class:`letsencrypt.client.account.Account`
"""
while True:
code, email = zope.component.getUtility(interfaces.IDisplay).input(
"Enter email address (optional, press Enter to skip)")
if code == display_util.OK:
try:
return cls.from_email(config, email)
except errors.LetsEncryptClientError:
continue
else:
return None
@classmethod
def from_email(cls, config, email):
"""Generate a new account from an email address.
:param config: Configuration
:type config: :class:`letsencrypt.client.interfaces.IConfig`
:param str email: Email address
:raises letsencrypt.client.errors.LetsEncryptClientError: If invalid
email address is given.
"""
if not email or cls.safe_email(email):
email = email if email else None
le_util.make_or_verify_dir(
config.account_keys_dir, 0o700, os.geteuid())
key = crypto_util.init_save_key(
config.rsa_key_size, config.account_keys_dir,
cls._get_config_filename(email))
return cls(config, key, email)
raise errors.LetsEncryptClientError("Invalid email address.")
@classmethod
def safe_email(cls, email):
"""Scrub email address before using it."""
if cls.EMAIL_REGEX.match(email):
return not email.startswith(".") and ".." not in email
else:
logging.warn("Invalid email address.")
return False
+20 -30
View File
@@ -1,19 +1,20 @@
"""Client annotated ACME challenges. """Client annotated ACME challenges.
Please use names such as ``achall`` and ``ichall`` (respectively ``achalls`` Please use names such as ``achall`` to distiguish from variables "of type"
and ``ichalls`` for collections) to distiguish from variables "of type" :class:`letsencrypt.acme.challenges.Challenge` (denoted by ``chall``)
:class:`letsencrypt.acme.challenges.Challenge` (denoted by ``chall``):: and :class:`.ChallengeBody` (denoted by ``challb``)::
from letsencrypt.acme import challenges from letsencrypt.acme import challenges
from letsencrypt.acme import messages2
from letsencrypt.client import achallenges from letsencrypt.client import achallenges
chall = challenges.DNS(token='foo') chall = challenges.DNS(token='foo')
achall = achallenges.DNS(chall=chall, domain='example.com') challb = messages2.ChallengeBody(chall=chall)
ichall = achallenges.Indexed(achall=achall, index=0) achall = achallenges.DNS(chall=challb, domain='example.com')
Note, that all annotated challenges act as a proxy objects:: Note, that all annotated challenges act as a proxy objects::
ichall.token == achall.token == chall.token achall.token == challb.token
""" """
from letsencrypt.acme import challenges from letsencrypt.acme import challenges
@@ -28,19 +29,22 @@ from letsencrypt.client import crypto_util
class AnnotatedChallenge(jose_util.ImmutableMap): class AnnotatedChallenge(jose_util.ImmutableMap):
"""Client annotated challenge. """Client annotated challenge.
Wraps around :class:`~letsencrypt.acme.challenges.Challenge` and Wraps around server provided challenge and annotates with data
annotates with data usfeul for the client. useful for the client.
:ivar challb: Wrapped `~.ChallengeBody`.
""" """
__slots__ = ('challb',)
acme_type = NotImplemented acme_type = NotImplemented
def __getattr__(self, name): def __getattr__(self, name):
return getattr(self.chall, name) return getattr(self.challb, name)
class DVSNI(AnnotatedChallenge): class DVSNI(AnnotatedChallenge):
"""Client annotated "dvsni" ACME challenge.""" """Client annotated "dvsni" ACME challenge."""
__slots__ = ('chall', 'domain', 'key') __slots__ = ('challb', 'domain', 'key')
acme_type = challenges.DVSNI acme_type = challenges.DVSNI
def gen_cert_and_response(self, s=None): # pylint: disable=invalid-name def gen_cert_and_response(self, s=None): # pylint: disable=invalid-name
@@ -54,49 +58,35 @@ class DVSNI(AnnotatedChallenge):
""" """
response = challenges.DVSNIResponse(s=s) response = challenges.DVSNIResponse(s=s)
cert_pem = crypto_util.make_ss_cert(self.key.pem, [ cert_pem = crypto_util.make_ss_cert(self.key.pem, [
self.nonce_domain, self.domain, response.z_domain(self.chall)]) self.nonce_domain, self.domain, response.z_domain(self.challb)])
return cert_pem, response return cert_pem, response
class SimpleHTTPS(AnnotatedChallenge): class SimpleHTTPS(AnnotatedChallenge):
"""Client annotated "simpleHttps" ACME challenge.""" """Client annotated "simpleHttps" ACME challenge."""
__slots__ = ('chall', 'domain', 'key') __slots__ = ('challb', 'domain', 'key')
acme_type = challenges.SimpleHTTPS acme_type = challenges.SimpleHTTPS
class DNS(AnnotatedChallenge): class DNS(AnnotatedChallenge):
"""Client annotated "dns" ACME challenge.""" """Client annotated "dns" ACME challenge."""
__slots__ = ('chall', 'domain') __slots__ = ('challb', 'domain')
acme_type = challenges.DNS acme_type = challenges.DNS
class RecoveryContact(AnnotatedChallenge): class RecoveryContact(AnnotatedChallenge):
"""Client annotated "recoveryContact" ACME challenge.""" """Client annotated "recoveryContact" ACME challenge."""
__slots__ = ('chall', 'domain') __slots__ = ('challb', 'domain')
acme_type = challenges.RecoveryContact acme_type = challenges.RecoveryContact
class RecoveryToken(AnnotatedChallenge): class RecoveryToken(AnnotatedChallenge):
"""Client annotated "recoveryToken" ACME challenge.""" """Client annotated "recoveryToken" ACME challenge."""
__slots__ = ('chall', 'domain') __slots__ = ('challb', 'domain')
acme_type = challenges.RecoveryToken acme_type = challenges.RecoveryToken
class ProofOfPossession(AnnotatedChallenge): class ProofOfPossession(AnnotatedChallenge):
"""Client annotated "proofOfPossession" ACME challenge.""" """Client annotated "proofOfPossession" ACME challenge."""
__slots__ = ('chall', 'domain') __slots__ = ('challb', 'domain')
acme_type = challenges.ProofOfPossession acme_type = challenges.ProofOfPossession
class Indexed(jose_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)
+268 -218
View File
@@ -1,19 +1,17 @@
"""ACME AuthHandler.""" """ACME AuthHandler."""
import itertools
import logging import logging
import sys import time
import Crypto.PublicKey.RSA
from letsencrypt.acme import challenges from letsencrypt.acme import challenges
from letsencrypt.acme import jose from letsencrypt.acme import messages2
from letsencrypt.acme import messages
from letsencrypt.client import achallenges from letsencrypt.client import achallenges
from letsencrypt.client import constants from letsencrypt.client import constants
from letsencrypt.client import errors from letsencrypt.client import errors
class AuthHandler(object): # pylint: disable=too-many-instance-attributes class AuthHandler(object):
"""ACME Authorization Handler for a client. """ACME Authorization Handler for a client.
:ivar dv_auth: Authenticator capable of solving :ivar dv_auth: Authenticator capable of solving
@@ -26,186 +24,212 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
:ivar network: Network object for sending and receiving authorization :ivar network: Network object for sending and receiving authorization
messages messages
:type network: :class:`letsencrypt.client.network.Network` :type network: :class:`letsencrypt.client.network2.Network`
:ivar list domains: list of str domains to get authorization :ivar account: Client's Account
:ivar dict authkey: Authorized Keys for each domain. :type account: :class:`letsencrypt.client.account.Account`
values are of type :class:`letsencrypt.client.le_util.Key`
:ivar dict responses: keys: domain, values: list of responses :ivar dict authzr: ACME Authorization Resource dict where keys are domains
(:class:`letsencrypt.acme.challenges.ChallengeResponse`. and values are :class:`letsencrypt.acme.messages2.AuthorizationResource`
:ivar dict msgs: ACME Challenge messages with domain as a key. :ivar list dv_c: DV challenges in the form of
:ivar dict paths: optimal path for authorization. eg. paths[domain] :class:`letsencrypt.client.achallenges.AnnotatedChallenge`
:ivar dict dv_c: Keys - domain, Values are DV challenges in the form of :ivar list cont_c: Continuity challenges in the
:class:`letsencrypt.client.achallenges.Indexed` form of :class:`letsencrypt.client.achallenges.AnnotatedChallenge`
:ivar dict cont_c: Keys - domain, Values are Continuity challenges in the
form of :class:`letsencrypt.client.achallenges.Indexed`
""" """
def __init__(self, dv_auth, cont_auth, network): def __init__(self, dv_auth, cont_auth, network, account):
self.dv_auth = dv_auth self.dv_auth = dv_auth
self.cont_auth = cont_auth self.cont_auth = cont_auth
self.network = network self.network = network
self.domains = [] self.account = account
self.authkey = dict() self.authzr = dict()
self.responses = dict()
self.msgs = dict()
self.paths = dict()
self.dv_c = dict() # List must be used to keep responses straight.
self.cont_c = dict() self.dv_c = []
self.cont_c = []
def add_chall_msg(self, domain, msg, authkey): def get_authorizations(self, domains, best_effort=False):
"""Add a challenge message to the AuthHandler.
:param str domain: domain for authorization
:param msg: ACME "challenge" message
:type msg: :class:`letsencrypt.acme.message.Challenge`
:param authkey: authorized key for the challenge
:type authkey: :class:`letsencrypt.client.le_util.Key`
"""
if domain in self.domains:
raise errors.LetsEncryptAuthHandlerError(
"Multiple ACMEChallengeMessages for the same domain "
"is not supported.")
self.domains.append(domain)
self.responses[domain] = [None] * len(msg.challenges)
self.msgs[domain] = msg
self.authkey[domain] = authkey
def get_authorizations(self):
"""Retrieve all authorizations for challenges. """Retrieve all authorizations for challenges.
:raises LetsEncryptAuthHandlerError: If unable to retrieve all :param set domains: Domains for authorization
:param bool best_effort: Whether or not all authorizations are required
(this is useful in renewal)
:returns: tuple of lists of authorization resources. Takes the form of
(`completed`, `failed`)
rtype: tuple
:raises AuthorizationError: If unable to retrieve all
authorizations authorizations
""" """
progress = True for domain in domains:
while self.msgs and progress: self.authzr[domain] = self.network.request_domain_challenges(
progress = False domain, self.account.new_authzr_uri)
self._satisfy_challenges()
delete_list = [] self._choose_challenges(domains)
for dom in self.domains: # While there are still challenges remaining...
if self._path_satisfied(dom): while self.dv_c or self.cont_c:
self.acme_authorization(dom) cont_resp, dv_resp = self._solve_challenges()
delete_list.append(dom) logging.info("Waiting for verification...")
# This avoids modifying while iterating over the list # Send all Responses - this modifies dv_c and cont_c
if delete_list: self._respond(cont_resp, dv_resp, best_effort)
self._cleanup_state(delete_list)
progress = True
if not progress: # Just make sure all decisions are complete.
raise errors.LetsEncryptAuthHandlerError( self.verify_authzr_complete()
"Unable to solve challenges for requested names.") # Only return valid authorizations
return [authzr for authzr in self.authzr.values()
if authzr.body.status == messages2.STATUS_VALID]
def acme_authorization(self, domain): def _choose_challenges(self, domains):
"""Handle ACME "authorization" phase. """Retrieve necessary challenges to satisfy server."""
:param str domain: domain that is requesting authorization
:returns: ACME "authorization" message.
:rtype: :class:`letsencrypt.acme.messages.Authorization`
"""
try:
auth = self.network.send_and_receive_expected(
messages.AuthorizationRequest.create(
session_id=self.msgs[domain].session_id,
nonce=self.msgs[domain].nonce,
responses=self.responses[domain],
name=domain,
key=jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
self.authkey[domain].pem))),
messages.Authorization)
logging.info("Received Authorization for %s", domain)
return auth
except errors.LetsEncryptClientError as err:
logging.fatal(str(err))
logging.fatal(
"Failed Authorization procedure - cleaning up challenges")
sys.exit(1)
finally:
self._cleanup_challenges(domain)
def _satisfy_challenges(self):
"""Attempt to satisfy all saved challenge messages.
.. todo:: It might be worth it to try different challenges to
find one that doesn't throw an exception
.. todo:: separate into more functions
"""
logging.info("Performing the following challenges:") logging.info("Performing the following challenges:")
for dom in self.domains: for dom in domains:
self.paths[dom] = gen_challenge_path( path = gen_challenge_path(
self.msgs[dom].challenges, self.authzr[dom].body.challenges,
self._get_chall_pref(dom), self._get_chall_pref(dom),
self.msgs[dom].combinations) self.authzr[dom].body.combinations)
self.dv_c[dom], self.cont_c[dom] = self._challenge_factory( dom_cont_c, dom_dv_c = self._challenge_factory(
dom, self.paths[dom]) dom, path)
self.dv_c.extend(dom_dv_c)
# Flatten challs for authenticator functions and remove index self.cont_c.extend(dom_cont_c)
# Order is important here as we will not expose the outside
# Authenticator to our own indices.
flat_cont = []
flat_dv = []
for dom in self.domains:
flat_cont.extend(ichall.achall for ichall in self.cont_c[dom])
flat_dv.extend(ichall.achall for ichall in self.dv_c[dom])
def _solve_challenges(self):
"""Get Responses for challenges from authenticators."""
cont_resp = [] cont_resp = []
dv_resp = [] dv_resp = []
try: try:
if flat_cont: if self.cont_c:
cont_resp = self.cont_auth.perform(flat_cont) cont_resp = self.cont_auth.perform(self.cont_c)
if flat_dv: if self.dv_c:
dv_resp = self.dv_auth.perform(flat_dv) dv_resp = self.dv_auth.perform(self.dv_c)
# This will catch both specific types of errors. # This will catch both specific types of errors.
except errors.LetsEncryptAuthHandlerError as err: except errors.AuthorizationError:
logging.critical("Failure in setting up challenges:") logging.critical("Failure in setting up challenges.")
logging.critical(str(err))
logging.info("Attempting to clean up outstanding challenges...") logging.info("Attempting to clean up outstanding challenges...")
for dom in self.domains: self._cleanup_challenges()
self._cleanup_challenges(dom) raise
raise errors.LetsEncryptAuthHandlerError( assert len(cont_resp) == len(self.cont_c)
"Unable to perform challenges") assert len(dv_resp) == len(self.dv_c)
logging.info("Ready for verification...") return cont_resp, dv_resp
# Assemble Responses def _respond(self, cont_resp, dv_resp, best_effort):
if cont_resp: """Send/Receive confirmation of all challenges.
self._assign_responses(cont_resp, self.cont_c)
if dv_resp:
self._assign_responses(dv_resp, self.dv_c)
def _assign_responses(self, flat_list, ichall_dict): .. note:: This method also cleans up the auth_handler state.
"""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 'continuity' and 'dv' Indexed challenges, or their
:class:`letsencrypt.client.achallenges.Indexed` list
""" """
flat_index = 0 # TODO: chall_update is a dirty hack to get around acme-spec #105
for dom in self.domains: chall_update = dict()
for ichall in ichall_dict[dom]: active_achalls = []
self.responses[dom][ichall.index] = flat_list[flat_index] active_achalls.extend(
flat_index += 1 self._send_responses(self.dv_c, dv_resp, chall_update))
active_achalls.extend(
self._send_responses(self.cont_c, cont_resp, chall_update))
def _path_satisfied(self, dom): # Check for updated status...
"""Returns whether a path has been completely satisfied.""" self._poll_challenges(chall_update, best_effort)
# Make sure that there are no 'None' or 'False' entries along path. # This removes challenges from self.dv_c and self.cont_c
return all(self.responses[dom][i] for i in self.paths[dom]) self._cleanup_challenges(active_achalls)
def _send_responses(self, achalls, resps, chall_update):
"""Send responses and make sure errors are handled.
:param dict chall_update: parameter that is updated to hold
authzr -> list of outstanding solved annotated challenges
"""
active_achalls = []
for achall, resp in itertools.izip(achalls, resps):
# Don't send challenges for None and False authenticator responses
if resp:
self.network.answer_challenge(achall.challb, resp)
active_achalls.append(achall)
if achall.domain in chall_update:
chall_update[achall.domain].append(achall)
else:
chall_update[achall.domain] = [achall]
return active_achalls
def _poll_challenges(
self, chall_update, best_effort, min_sleep=3, max_rounds=15):
"""Wait for all challenge results to be determined."""
dom_to_check = set(chall_update.keys())
comp_domains = set()
rounds = 0
while dom_to_check and rounds < max_rounds:
# TODO: Use retry-after...
time.sleep(min_sleep)
for domain in dom_to_check:
comp_challs, failed_challs = self._handle_check(
domain, chall_update[domain])
if len(comp_challs) == len(chall_update[domain]):
comp_domains.add(domain)
elif not failed_challs:
for chall in comp_challs:
chall_update[domain].remove(chall)
# We failed some challenges... damage control
else:
# Right now... just assume a loss and carry on...
if best_effort:
comp_domains.add(domain)
else:
raise errors.AuthorizationError(
"Failed Authorization procedure for %s" % domain)
dom_to_check -= comp_domains
comp_domains.clear()
rounds += 1
def _handle_check(self, domain, achalls):
"""Returns tuple of ('completed', 'failed')."""
completed = []
failed = []
self.authzr[domain], _ = self.network.poll(self.authzr[domain])
if self.authzr[domain].body.status == messages2.STATUS_VALID:
return achalls, []
# Note: if the whole authorization is invalid, the individual failed
# challenges will be determined here...
for achall in achalls:
status = self._get_chall_status(self.authzr[domain], achall)
# This does nothing for challenges that have yet to be decided yet.
if status == messages2.STATUS_VALID:
completed.append(achall)
elif status == messages2.STATUS_INVALID:
failed.append(achall)
return completed, failed
def _get_chall_status(self, authzr, achall): # pylint: disable=no-self-use
"""Get the status of the challenge.
.. warning:: This assumes only one instance of type of challenge in
each challenge resource.
:param authzr: Authorization Resource
:type authzr: :class:`letsencrypt.acme.messages2.AuthorizationResource`
:param achall: Annotated challenge for which to get status
:type achall: :class:`letsencrypt.client.achallenges.AnnotatedChallenge`
"""
for authzr_challb in authzr.body.challenges:
if type(authzr_challb.chall) is type(achall.challb.chall):
return authzr_challb.status
raise errors.AuthorizationError(
"Target challenge not found in authorization resource")
def _get_chall_pref(self, domain): def _get_chall_pref(self, domain):
"""Return list of challenge preferences. """Return list of challenge preferences.
@@ -213,45 +237,49 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
:param str domain: domain for which you are requesting preferences :param str domain: domain for which you are requesting preferences
""" """
# Make sure to make a copy...
chall_prefs = [] chall_prefs = []
chall_prefs.extend(self.cont_auth.get_chall_pref(domain)) chall_prefs.extend(self.cont_auth.get_chall_pref(domain))
chall_prefs.extend(self.dv_auth.get_chall_pref(domain)) chall_prefs.extend(self.dv_auth.get_chall_pref(domain))
return chall_prefs return chall_prefs
def _cleanup_challenges(self, domain): def _cleanup_challenges(self, achall_list=None):
"""Cleanup configuration challenges """Cleanup challenges.
:param str domain: domain for which to clean up challenges If achall_list is not provided, cleanup all achallenges.
""" """
logging.info("Cleaning up challenges for %s", domain) logging.info("Cleaning up challenges")
# 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.achall for ichall in self.dv_c[domain]]
cont_list = [ichall.achall for ichall in self.cont_c[domain]]
if dv_list:
self.dv_auth.cleanup(dv_list)
if cont_list:
self.cont_auth.cleanup(cont_list)
def _cleanup_state(self, delete_list): if achall_list is None:
"""Cleanup state after an authorization is received. dv_c = self.dv_c
cont_c = self.cont_c
else:
dv_c = [achall for achall in achall_list
if isinstance(achall.chall, challenges.DVChallenge)]
cont_c = [achall for achall in achall_list if isinstance(
achall.chall, challenges.ContinuityChallenge)]
:param list delete_list: list of domains in str form if dv_c:
self.dv_auth.cleanup(dv_c)
for achall in dv_c:
self.dv_c.remove(achall)
if cont_c:
self.cont_auth.cleanup(cont_c)
for achall in cont_c:
self.cont_c.remove(achall)
def verify_authzr_complete(self):
"""Verifies that all authorizations have been decided.
:returns: Whether all authzr are complete
:rtype: bool
""" """
for domain in delete_list: for authzr in self.authzr.values():
del self.msgs[domain] if (authzr.body.status != messages2.STATUS_VALID and
del self.responses[domain] authzr.body.status != messages2.STATUS_INVALID):
del self.paths[domain] raise errors.AuthorizationError("Incomplete authorizations")
del self.authkey[domain]
del self.cont_c[domain]
del self.dv_c[domain]
self.domains.remove(domain)
def _challenge_factory(self, domain, path): def _challenge_factory(self, domain, path):
"""Construct Namedtuple Challenges """Construct Namedtuple Challenges
@@ -274,54 +302,75 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
cont_chall = [] cont_chall = []
for index in path: for index in path:
chall = self.msgs[domain].challenges[index] challb = self.authzr[domain].body.challenges[index]
chall = challb.chall
achall = challb_to_achall(challb, self.account.key, domain)
if isinstance(chall, challenges.ContinuityChallenge):
cont_chall.append(achall)
elif isinstance(chall, challenges.DVChallenge):
dv_chall.append(achall)
return cont_chall, dv_chall
def challb_to_achall(challb, key, domain):
"""Converts a ChallengeBody object to an AnnotatedChallenge.
:param challb: ChallengeBody
:type challb: :class:`letsencrypt.acme.messages2.ChallengeBody`
:param key: Key
:type key: :class:`letsencrypt.client.le_util.Key`
:param str domain: Domain of the challb
:returns: Appropriate AnnotatedChallenge
:rtype: :class:`letsencrypt.client.achallenges.AnnotatedChallenge`
"""
chall = challb.chall
if isinstance(chall, challenges.DVSNI): if isinstance(chall, challenges.DVSNI):
logging.info(" DVSNI challenge for %s.", domain) logging.info(" DVSNI challenge for %s.", domain)
achall = achallenges.DVSNI( return achallenges.DVSNI(
chall=chall, domain=domain, key=self.authkey[domain]) challb=challb, domain=domain, key=key)
elif isinstance(chall, challenges.SimpleHTTPS): elif isinstance(chall, challenges.SimpleHTTPS):
logging.info(" SimpleHTTPS challenge for %s.", domain) logging.info(" SimpleHTTPS challenge for %s.", domain)
achall = achallenges.SimpleHTTPS( return achallenges.SimpleHTTPS(
chall=chall, domain=domain, key=self.authkey[domain]) challb=challb, domain=domain, key=key)
elif isinstance(chall, challenges.DNS): elif isinstance(chall, challenges.DNS):
logging.info(" DNS challenge for %s.", domain) logging.info(" DNS challenge for %s.", domain)
achall = achallenges.DNS(chall=chall, domain=domain) return achallenges.DNS(challb=challb, domain=domain)
elif isinstance(chall, challenges.RecoveryToken): elif isinstance(chall, challenges.RecoveryToken):
logging.info(" Recovery Token Challenge for %s.", domain) logging.info(" Recovery Token Challenge for %s.", domain)
achall = achallenges.RecoveryToken(chall=chall, domain=domain) return achallenges.RecoveryToken(challb=challb, domain=domain)
elif isinstance(chall, challenges.RecoveryContact): elif isinstance(chall, challenges.RecoveryContact):
logging.info(" Recovery Contact Challenge for %s.", domain) logging.info(" Recovery Contact Challenge for %s.", domain)
achall = achallenges.RecoveryContact(chall=chall, domain=domain) return achallenges.RecoveryContact(
challb=challb, domain=domain)
elif isinstance(chall, challenges.ProofOfPossession): elif isinstance(chall, challenges.ProofOfPossession):
logging.info(" Proof-of-Possession Challenge for %s", domain) logging.info(" Proof-of-Possession Challenge for %s", domain)
achall = achallenges.ProofOfPossession( return achallenges.ProofOfPossession(
chall=chall, domain=domain) challb=challb, domain=domain)
else: else:
raise errors.LetsEncryptClientError( raise errors.LetsEncryptClientError(
"Received unsupported challenge of type: %s", chall.typ) "Received unsupported challenge of type: %s",
chall.typ)
ichall = achallenges.Indexed(achall=achall, index=index)
if isinstance(chall, challenges.ContinuityChallenge):
cont_chall.append(ichall)
elif isinstance(chall, challenges.DVChallenge):
dv_chall.append(ichall)
return dv_chall, cont_chall
def gen_challenge_path(challs, preferences, combinations): def gen_challenge_path(challbs, preferences, combinations):
"""Generate a plan to get authority over the identity. """Generate a plan to get authority over the identity.
.. todo:: This can be possibly be rewritten to use resolved_combinations. .. todo:: This can be possibly be rewritten to use resolved_combinations.
:param tuple challs: A tuple of challenges :param tuple challbs: A tuple of challenges
(:class:`letsencrypt.acme.challenges.Challenge`) from (:class:`letsencrypt.acme.messages2.Challenge`) from
:class:`letsencrypt.acme.messages.Challenge` server message to :class:`letsencrypt.acme.messages2.AuthorizationResource` to be
be fulfilled by the client in order to prove possession of the fulfilled by the client in order to prove possession of the
identifier. identifier.
:param list preferences: List of challenge preferences for domain :param list preferences: List of challenge preferences for domain
@@ -334,18 +383,18 @@ def gen_challenge_path(challs, preferences, combinations):
:returns: tuple of indices from ``challenges``. :returns: tuple of indices from ``challenges``.
:rtype: tuple :rtype: tuple
:raises letsencrypt.client.errors.LetsEncryptAuthHandlerError: If a :raises letsencrypt.client.errors.AuthorizationError: If a
path cannot be created that satisfies the CA given the preferences and path cannot be created that satisfies the CA given the preferences and
combinations. combinations.
""" """
if combinations: if combinations:
return _find_smart_path(challs, preferences, combinations) return _find_smart_path(challbs, preferences, combinations)
else: else:
return _find_dumb_path(challs, preferences) return _find_dumb_path(challbs, preferences)
def _find_smart_path(challs, preferences, combinations): def _find_smart_path(challbs, preferences, combinations):
"""Find challenge path with server hints. """Find challenge path with server hints.
Can be called if combinations is included. Function uses a simple Can be called if combinations is included. Function uses a simple
@@ -367,8 +416,8 @@ def _find_smart_path(challs, preferences, combinations):
combo_total = 0 combo_total = 0
for combo in combinations: for combo in combinations:
for challenge_index in combo: for challenge_index in combo:
combo_total += chall_cost.get(challs[ combo_total += chall_cost.get(challbs[
challenge_index].__class__, max_cost) challenge_index].chall.__class__, max_cost)
if combo_total < best_combo_cost: if combo_total < best_combo_cost:
best_combo = combo best_combo = combo
@@ -380,12 +429,12 @@ def _find_smart_path(challs, preferences, combinations):
msg = ("Client does not support any combination of challenges that " msg = ("Client does not support any combination of challenges that "
"will satisfy the CA.") "will satisfy the CA.")
logging.fatal(msg) logging.fatal(msg)
raise errors.LetsEncryptAuthHandlerError(msg) raise errors.AuthorizationError(msg)
return best_combo return best_combo
def _find_dumb_path(challs, preferences): def _find_dumb_path(challbs, preferences):
"""Find challenge path without server hints. """Find challenge path without server hints.
Should be called if the combinations hint is not included by the Should be called if the combinations hint is not included by the
@@ -398,11 +447,11 @@ def _find_dumb_path(challs, preferences):
path = [] path = []
satisfied = set() satisfied = set()
for pref_c in preferences: for pref_c in preferences:
for i, offered_chall in enumerate(challs): for i, offered_challb in enumerate(challbs):
if (isinstance(offered_chall, pref_c) and if (isinstance(offered_challb.chall, pref_c) and
is_preferred(offered_chall, satisfied)): is_preferred(offered_challb, satisfied)):
path.append(i) path.append(i)
satisfied.add(offered_chall) satisfied.add(offered_challb)
return path return path
@@ -422,11 +471,12 @@ def mutually_exclusive(obj1, obj2, groups, different=False):
return True return True
def is_preferred(offered_chall, satisfied, def is_preferred(offered_challb, satisfied,
exclusive_groups=constants.EXCLUSIVE_CHALLENGES): exclusive_groups=constants.EXCLUSIVE_CHALLENGES):
"""Return whether or not the challenge is preferred in path.""" """Return whether or not the challenge is preferred in path."""
for chall in satisfied: for challb in satisfied:
if not mutually_exclusive( if not mutually_exclusive(
offered_chall, chall, exclusive_groups, different=True): offered_challb.chall, challb.chall, exclusive_groups,
different=True):
return False return False
return True return True
+110 -123
View File
@@ -1,20 +1,22 @@
"""ACME protocol client class and helper functions.""" """ACME protocol client class and helper functions."""
import logging import logging
import os import os
import sys import pkg_resources
import Crypto.PublicKey.RSA
import M2Crypto import M2Crypto
import zope.component
from letsencrypt.acme import jose from letsencrypt.acme import jose
from letsencrypt.acme import messages from letsencrypt.acme.jose import jwk
from letsencrypt.client import account
from letsencrypt.client import auth_handler from letsencrypt.client import auth_handler
from letsencrypt.client import continuity_auth from letsencrypt.client import continuity_auth
from letsencrypt.client import crypto_util from letsencrypt.client import crypto_util
from letsencrypt.client import errors from letsencrypt.client import errors
from letsencrypt.client import interfaces
from letsencrypt.client import le_util from letsencrypt.client import le_util
from letsencrypt.client import network from letsencrypt.client import network2
from letsencrypt.client import reverter from letsencrypt.client import reverter
from letsencrypt.client import revoker from letsencrypt.client import revoker
@@ -27,10 +29,10 @@ class Client(object):
"""ACME protocol client. """ACME protocol client.
:ivar network: Network object for sending and receiving messages :ivar network: Network object for sending and receiving messages
:type network: :class:`letsencrypt.client.network.Network` :type network: :class:`letsencrypt.client.network2.Network`
:ivar authkey: Authorization Key :ivar account: Account object used for registration
:type authkey: :class:`letsencrypt.client.le_util.Key` :type account: :class:`letsencrypt.client.account.Account`
:ivar auth_handler: Object that supports the IAuthenticator interface. :ivar auth_handler: Object that supports the IAuthenticator interface.
auth_handler contains both a dv_authenticator and a auth_handler contains both a dv_authenticator and a
@@ -45,7 +47,7 @@ class Client(object):
""" """
def __init__(self, config, authkey, dv_auth, installer): def __init__(self, config, account_, dv_auth, installer):
"""Initialize a client. """Initialize a client.
:param dv_auth: IAuthenticator that can solve the :param dv_auth: IAuthenticator that can solve the
@@ -55,22 +57,53 @@ class Client(object):
:type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator`
""" """
self.network = network.Network(config.server) self.account = account_
self.authkey = authkey
self.installer = installer self.installer = installer
# TODO: Allow for other alg types besides RS256
self.network = network2.Network(
config.server_url, jwk.JWKRSA.load(self.account.key.pem))
self.config = config self.config = config
if dv_auth is not None: if dv_auth is not None:
cont_auth = continuity_auth.ContinuityAuthenticator(config) cont_auth = continuity_auth.ContinuityAuthenticator(config)
self.auth_handler = auth_handler.AuthHandler( self.auth_handler = auth_handler.AuthHandler(
dv_auth, cont_auth, self.network) dv_auth, cont_auth, self.network, self.account)
else: else:
self.auth_handler = None self.auth_handler = None
def register(self):
"""New Registration with the ACME server."""
self.account = self.network.register_from_account(self.account)
if self.account.terms_of_service:
if not self.config.tos:
# TODO: Replace with self.account.terms_of_service
eula = pkg_resources.resource_string("letsencrypt", "EULA")
agree = zope.component.getUtility(interfaces.IDisplay).yesno(
eula, "Agree", "Cancel")
else:
agree = True
if agree:
self.account.regr = self.network.agree_to_tos(self.account.regr)
else:
# What is the proper response here...
raise errors.LetsEncryptClientError("Must agree to TOS")
self.account.save()
def obtain_certificate(self, domains, csr=None): def obtain_certificate(self, domains, csr=None):
"""Obtains a certificate from the ACME server. """Obtains a certificate from the ACME server.
:param str domains: list of domains to get a certificate :meth:`.register` must be called before :meth:`.obtain_certificate`
.. todo:: This function currently uses the account key for the cert.
This should be changed to an independent key once renewal is sorted
out.
:param set domains: domains to get a certificate
:param csr: CSR must contain requested domains, the key used to generate :param csr: CSR must contain requested domains, the key used to generate
this CSR can be different than self.authkey this CSR can be different than self.authkey
@@ -81,68 +114,43 @@ class Client(object):
""" """
if self.auth_handler is None: if self.auth_handler is None:
logging.warning("Unable to obtain a certificate, because client " msg = ("Unable to obtain certificate because authenticator is "
"does not have a valid auth handler.") "not set.")
logging.warning(msg)
# Request Challenges raise errors.LetsEncryptClientError(msg)
for name in domains: if self.account.regr is None:
self.auth_handler.add_chall_msg( raise errors.LetsEncryptClientError(
name, self.acme_challenge(name), self.authkey) "Please register with the ACME server first.")
# Perform Challenges/Get Authorizations # Perform Challenges/Get Authorizations
self.auth_handler.get_authorizations() authzr = self.auth_handler.get_authorizations(domains)
# Create CSR from names # Create CSR from names
if csr is None: if csr is None:
csr = init_csr(self.authkey, domains, self.config.cert_dir) csr = crypto_util.init_save_csr(
self.account.key, domains, self.config.cert_dir)
# Retrieve certificate # Retrieve certificate
certificate_msg = self.acme_certificate(csr.data) certr = self.network.request_issuance(
jose.ComparableX509(
M2Crypto.X509.load_request_der_string(csr.data)),
authzr)
# Save Certificate # Save Certificate
cert_file, chain_file = self.save_certificate( cert_file, chain_file = self.save_certificate(
certificate_msg, self.config.cert_path, self.config.chain_path) certr, self.config.cert_path, self.config.chain_path)
revoker.Revoker.store_cert_key( revoker.Revoker.store_cert_key(
cert_file, self.authkey.file, self.config) cert_file, self.account.key.file, self.config)
return cert_file, chain_file return cert_file, chain_file
def acme_challenge(self, domain): def save_certificate(self, certr, cert_path, chain_path):
"""Handle ACME "challenge" phase.
:returns: ACME "challenge" message.
:rtype: :class:`letsencrypt.acme.messages.Challenge`
"""
return self.network.send_and_receive_expected(
messages.ChallengeRequest(identifier=domain),
messages.Challenge)
def acme_certificate(self, csr_der):
"""Handle ACME "certificate" phase.
:param str csr_der: CSR in DER format.
:returns: ACME "certificate" message.
:rtype: :class:`letsencrypt.acme.message.Certificate`
"""
logging.info("Preparing and sending CSR...")
return self.network.send_and_receive_expected(
messages.CertificateRequest.create(
csr=jose.ComparableX509(
M2Crypto.X509.load_request_der_string(csr_der)),
key=jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
self.authkey.pem))),
messages.Certificate)
def save_certificate(self, certificate_msg, cert_path, chain_path):
# pylint: disable=no-self-use # pylint: disable=no-self-use
"""Saves the certificate received from the ACME server. """Saves the certificate received from the ACME server.
:param certificate_msg: ACME "certificate" message from server. :param certr: ACME "certificate" resource.
:type certificate_msg: :class:`letsencrypt.acme.messages.Certificate` :type certr: :class:`letsencrypt.acme.messages.Certificate`
:param str cert_path: Path to attempt to save the cert file :param str cert_path: Path to attempt to save the cert file
:param str chain_path: Path to attempt to save the chain file :param str chain_path: Path to attempt to save the chain file
@@ -153,25 +161,36 @@ class Client(object):
:raises IOError: If unable to find room to write the cert files :raises IOError: If unable to find room to write the cert files
""" """
# try finally close
cert_chain_abspath = None cert_chain_abspath = None
cert_fd, cert_file = le_util.unique_file(cert_path, 0o644) cert_file, act_cert_path = le_util.unique_file(cert_path, 0o644)
cert_fd.write(certificate_msg.certificate.as_pem()) # TODO: Except
cert_fd.close() cert_pem = certr.body.as_pem()
logging.info( try:
"Server issued certificate; certificate written to %s", cert_file) cert_file.write(cert_pem)
finally:
cert_file.close()
logging.info("Server issued certificate; certificate written to %s",
act_cert_path)
if certificate_msg.chain: if certr.cert_chain_uri:
chain_fd, chain_fn = le_util.unique_file(chain_path, 0o644) # TODO: Except
for cert in certificate_msg.chain: chain_cert = self.network.fetch_chain(certr.cert_chain_uri)
chain_fd.write(cert.to_pem()) if chain_cert:
chain_fd.close() chain_file, act_chain_path = le_util.unique_file(
chain_path, 0o644)
chain_pem = chain_cert.to_pem()
try:
chain_file.write(chain_pem)
finally:
chain_file.close()
logging.info("Cert chain written to %s", chain_fn) logging.info("Cert chain written to %s", act_chain_path)
# This expects a valid chain file # This expects a valid chain file
cert_chain_abspath = os.path.abspath(chain_fn) cert_chain_abspath = os.path.abspath(act_chain_path)
return os.path.abspath(cert_file), cert_chain_abspath return os.path.abspath(act_cert_path), cert_chain_abspath
def deploy_certificate(self, domains, privkey, cert_file, chain_file=None): def deploy_certificate(self, domains, privkey, cert_file, chain_file=None):
"""Install certificate """Install certificate
@@ -295,60 +314,6 @@ def validate_key_csr(privkey, csr=None):
"The key and CSR do not match") "The key and CSR do not match")
def init_key(key_size, key_dir):
"""Initializes privkey.
Inits key and CSR using provided files or generating new files
if necessary. Both will be saved in PEM format on the
filesystem. The CSR is placed into DER format to allow
the namedtuple to easily work with the protocol.
:param str key_dir: Key save directory.
"""
try:
key_pem = crypto_util.make_key(key_size)
except ValueError as err:
logging.fatal(str(err))
sys.exit(1)
# Save file
le_util.make_or_verify_dir(key_dir, 0o700)
key_f, key_filename = le_util.unique_file(
os.path.join(key_dir, "key-letsencrypt.pem"), 0o600)
key_f.write(key_pem)
key_f.close()
logging.info("Generating key (%d bits): %s", key_size, key_filename)
return le_util.Key(key_filename, key_pem)
def init_csr(privkey, names, cert_dir):
"""Initialize a CSR with the given private key.
:param privkey: Key to include in the CSR
:type privkey: :class:`letsencrypt.client.le_util.Key`
:param list names: `str` names to include in the CSR
:param str cert_dir: Certificate save directory.
"""
csr_pem, csr_der = crypto_util.make_csr(privkey.pem, names)
# Save CSR
le_util.make_or_verify_dir(cert_dir, 0o755)
csr_f, csr_filename = le_util.unique_file(
os.path.join(cert_dir, "csr-letsencrypt.pem"), 0o644)
csr_f.write(csr_pem)
csr_f.close()
logging.info("Creating CSR: %s", csr_filename)
return le_util.CSR(csr_filename, csr_der, "der")
def list_available_authenticators(avail_auths): def list_available_authenticators(avail_auths):
"""Return a pretty-printed list of authenticators. """Return a pretty-printed list of authenticators.
@@ -417,6 +382,28 @@ def determine_authenticator(all_auths, config):
return auth return auth
def determine_account(config):
"""Determine which account to use.
Will create an account if necessary.
:param config: Configuration object
:type config: :class:`letsencrypt.client.interfaces.IConfig`
:returns: Account
:rtype: :class:`letsencrypt.client.account.Account`
"""
accounts = account.Account.get_accounts(config)
if len(accounts) == 1:
return accounts[0]
elif len(accounts) > 1:
return display_ops.choose_account(accounts)
return account.Account.from_prompts(config)
def determine_installer(config): def determine_installer(config):
"""Returns a valid installer if one exists. """Returns a valid installer if one exists.
+24 -1
View File
@@ -28,6 +28,8 @@ class NamespaceConfig(object):
zope.interface.implements(interfaces.IConfig) zope.interface.implements(interfaces.IConfig)
def __init__(self, namespace): def __init__(self, namespace):
assert not namespace.server.startswith('https://')
assert not namespace.server.startswith('http://')
self.namespace = namespace self.namespace = namespace
def __getattr__(self, name): def __getattr__(self, name):
@@ -42,11 +44,32 @@ class NamespaceConfig(object):
def in_progress_dir(self): # pylint: disable=missing-docstring def in_progress_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.work_dir, constants.IN_PROGRESS_DIR) return os.path.join(self.namespace.work_dir, constants.IN_PROGRESS_DIR)
@property
def server_path(self):
"""File path based on ``server``."""
return self.namespace.server.replace('/', os.path.sep)
@property
def server_url(self):
"""Full server URL (including HTTPS scheme)."""
return 'https://' + self.namespace.server
@property @property
def cert_key_backup(self): # pylint: disable=missing-docstring def cert_key_backup(self): # pylint: disable=missing-docstring
return os.path.join( return os.path.join(
self.namespace.work_dir, constants.CERT_KEY_BACKUP_DIR, self.namespace.work_dir, constants.CERT_KEY_BACKUP_DIR,
self.namespace.server.partition(":")[0]) self.server_path)
@property
def accounts_dir(self): #pylint: disable=missing-docstring
return os.path.join(
self.namespace.config_dir, constants.ACCOUNTS_DIR, self.server_path)
@property
def account_keys_dir(self): #pylint: disable=missing-docstring
return os.path.join(
self.namespace.config_dir, constants.ACCOUNTS_DIR,
self.server_path, constants.ACCOUNT_KEYS_DIR)
# TODO: This should probably include the server name # TODO: This should probably include the server name
@property @property
+9
View File
@@ -50,6 +50,9 @@ DVSNI_CHALLENGE_PORT = 443
"""Port to perform DVSNI challenge.""" """Port to perform DVSNI challenge."""
CONFIG_DIRS_MODE = 0o755
"""Directory mode for ``.IConfig.config_dir`` et al."""
TEMP_CHECKPOINT_DIR = "temp_checkpoint" TEMP_CHECKPOINT_DIR = "temp_checkpoint"
"""Temporary checkpoint directory (relative to IConfig.work_dir).""" """Temporary checkpoint directory (relative to IConfig.work_dir)."""
@@ -61,6 +64,12 @@ CERT_KEY_BACKUP_DIR = "keys-certs"
"""Directory where all certificates and keys are stored (relative to """Directory where all certificates and keys are stored (relative to
IConfig.work_dir. Used for easy revocation.""" IConfig.work_dir. Used for easy revocation."""
ACCOUNTS_DIR = "accounts"
"""Directory where all accounts are saved."""
ACCOUNT_KEYS_DIR = "keys"
"""Directory where account keys are saved. Relative to ACCOUNTS_DIR."""
REC_TOKEN_DIR = "recovery_tokens" REC_TOKEN_DIR = "recovery_tokens"
"""Directory where all recovery tokens are saved (relative to """Directory where all recovery tokens are saved (relative to
IConfig.work_dir).""" IConfig.work_dir)."""
+70
View File
@@ -4,6 +4,8 @@
is capable of handling the signatures. is capable of handling the signatures.
""" """
import logging
import os
import time import time
import Crypto.Hash.SHA256 import Crypto.Hash.SHA256
@@ -12,7 +14,75 @@ import Crypto.Signature.PKCS1_v1_5
import M2Crypto import M2Crypto
from letsencrypt.client import le_util
# High level functions
def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"):
"""Initializes and saves a privkey.
Inits key and saves it in PEM format on the filesystem.
.. note:: keyname is the attempted filename, it may be different if a file
already exists at the path.
:param int key_size: RSA key size in bits
:param str key_dir: Key save directory.
:param str keyname: Filename of key
:returns: Key
:rtype: :class:`letsencrypt.client.le_util.Key`
:raises ValueError: If unable to generate the key given key_size.
"""
try:
key_pem = make_key(key_size)
except ValueError as err:
logging.fatal(str(err))
raise err
# Save file
le_util.make_or_verify_dir(key_dir, 0o700, os.geteuid())
key_f, key_path = le_util.unique_file(
os.path.join(key_dir, keyname), 0o600)
key_f.write(key_pem)
key_f.close()
logging.info("Generating key (%d bits): %s", key_size, key_path)
return le_util.Key(key_path, key_pem)
def init_save_csr(privkey, names, cert_dir, csrname="csr-letsencrypt.pem"):
"""Initialize a CSR with the given private key.
:param privkey: Key to include in the CSR
:type privkey: :class:`letsencrypt.client.le_util.Key`
:param set names: `str` names to include in the CSR
:param str cert_dir: Certificate save directory.
:returns: CSR
:rtype: :class:`letsencrypt.client.le_util.CSR`
"""
csr_pem, csr_der = make_csr(privkey.pem, names)
# Save CSR
le_util.make_or_verify_dir(cert_dir, 0o755)
csr_f, csr_filename = le_util.unique_file(
os.path.join(cert_dir, csrname), 0o644)
csr_f.write(csr_pem)
csr_f.close()
logging.info("Creating CSR: %s", csr_filename)
return le_util.CSR(csr_filename, csr_der, "der")
# Lower level functions
def make_csr(key_str, domains): def make_csr(key_str, domains):
"""Generate a CSR. """Generate a CSR.
+22
View File
@@ -43,6 +43,28 @@ def choose_authenticator(auths, errs):
return return
def choose_account(accounts):
"""Choose an account.
:param list accounts: Containing at least one
:class:`~letsencrypt.client.account.Account`
"""
# Note this will get more complicated once we start recording authorizations
labels = [
"%s | %s" % (acc.email.ljust(display_util.WIDTH - 39),
acc.phone if acc.phone is not None else "")
for acc in accounts
]
code, index = util(interfaces.IDisplay).menu(
"Please choose an account", labels)
if code == display_util.OK:
return accounts[index]
else:
return None
def choose_names(installer): def choose_names(installer):
"""Display screen to select domains to validate. """Display screen to select domains to validate.
+9 -5
View File
@@ -133,19 +133,21 @@ class NcursesDisplay(object):
message, self.height, self.width, message, self.height, self.width,
yes_label=yes_label, no_label=no_label) yes_label=yes_label, no_label=no_label)
def checklist(self, message, tags): def checklist(self, message, tags, default_status=True):
"""Displays a checklist. """Displays a checklist.
:param message: Message to display before choices :param message: Message to display before choices
:param list tags: where each is of type :class:`str` :param list tags: where each is of type :class:`str` len(tags) > 0
len(tags) > 0 :param bool default_status: If True, items are in a selected state by
default.
:returns: tuple of the form (code, list_tags) where :returns: tuple of the form (code, list_tags) where
`code` - int display exit code `code` - int display exit code
`list_tags` - list of str tags selected by the user `list_tags` - list of str tags selected by the user
""" """
choices = [(tag, "", False) for tag in tags] choices = [(tag, "", default_status) for tag in tags]
return self.dialog.checklist( return self.dialog.checklist(
message, width=self.width, height=self.height, choices=choices) message, width=self.width, height=self.height, choices=choices)
@@ -257,11 +259,13 @@ class FileDisplay(object):
ans.startswith(no_label[0].upper())): ans.startswith(no_label[0].upper())):
return False return False
def checklist(self, message, tags): def checklist(self, message, tags, default_status=True):
# pylint: disable=unused-argument
"""Display a checklist. """Display a checklist.
:param str message: Message to display to user :param str message: Message to display to user
:param list tags: `str` tags to select, len(tags) > 0 :param list tags: `str` tags to select, len(tags) > 0
:param bool default_status: Not used for FileDisplay
:returns: tuple of (`code`, `tags`) where :returns: tuple of (`code`, `tags`) where
`code` - str display exit code `code` - str display exit code
+5 -5
View File
@@ -18,15 +18,15 @@ class LetsEncryptReverterError(LetsEncryptClientError):
# Auth Handler Errors # Auth Handler Errors
class LetsEncryptAuthHandlerError(LetsEncryptClientError): class AuthorizationError(LetsEncryptClientError):
"""Let's Encrypt Auth Handler error.""" """Authorization error."""
class LetsEncryptContAuthError(LetsEncryptAuthHandlerError): class LetsEncryptContAuthError(AuthorizationError):
"""Let's Encrypt Client Authenticator error.""" """Let's Encrypt Continuity Authenticator error."""
class LetsEncryptDvAuthError(LetsEncryptAuthHandlerError): class LetsEncryptDvAuthError(AuthorizationError):
"""Let's Encrypt DV Authenticator error.""" """Let's Encrypt DV Authenticator error."""
+10 -4
View File
@@ -95,6 +95,8 @@ class IConfig(zope.interface.Interface):
"be trusted in order to avoid further modifications to the client.") "be trusted in order to avoid further modifications to the client.")
authenticator = zope.interface.Attribute( authenticator = zope.interface.Attribute(
"Authenticator to use for responding to challenges.") "Authenticator to use for responding to challenges.")
email = zope.interface.Attribute(
"Email used for registration and recovery contact.")
rsa_key_size = zope.interface.Attribute("Size of the RSA key.") rsa_key_size = zope.interface.Attribute("Size of the RSA key.")
config_dir = zope.interface.Attribute("Configuration directory.") config_dir = zope.interface.Attribute("Configuration directory.")
@@ -107,6 +109,10 @@ class IConfig(zope.interface.Interface):
cert_key_backup = zope.interface.Attribute( cert_key_backup = zope.interface.Attribute(
"Directory where all certificates and keys are stored. " "Directory where all certificates and keys are stored. "
"Used for easy revocation.") "Used for easy revocation.")
accounts_dir = zope.interface.Attribute(
"Directory where all account information is stored.")
account_keys_dir = zope.interface.Attribute(
"Directory where all account keys are stored.")
rec_token_dir = zope.interface.Attribute( rec_token_dir = zope.interface.Attribute(
"Directory where all recovery tokens are saved.") "Directory where all recovery tokens are saved.")
key_dir = zope.interface.Attribute("Keys storage.") key_dir = zope.interface.Attribute("Keys storage.")
@@ -289,13 +295,13 @@ class IDisplay(zope.interface.Interface):
""" """
def checklist(message, choices): def checklist(message, tags, default_state):
"""Allow for multiple selections from a menu. """Allow for multiple selections from a menu.
:param str message: message to display to the user :param str message: message to display to the user
:param list tags: where each is of type :class:`str` len(tags) > 0
:param tags: tags :param bool default_status: If True, items are in a selected state by
:type tags: :class:`list` of :class:`str` default.
""" """
+70 -25
View File
@@ -2,7 +2,6 @@
import datetime import datetime
import heapq import heapq
import httplib import httplib
import itertools
import logging import logging
import time import time
@@ -74,7 +73,6 @@ class Network(object):
""" """
response_ct = response.headers.get('Content-Type') response_ct = response.headers.get('Content-Type')
try: try:
# TODO: response.json() is called twice, once here, and # TODO: response.json() is called twice, once here, and
# once in _get and _post clients # once in _get and _post clients
@@ -88,8 +86,9 @@ class Network(object):
logging.debug( logging.debug(
'Ignoring wrong Content-Type (%r) for JSON Error', 'Ignoring wrong Content-Type (%r) for JSON Error',
response_ct) response_ct)
try: try:
logging.error("Error: %s", jobj)
logging.error("Response from server: %s", response.content)
raise messages2.Error.from_json(jobj) raise messages2.Error.from_json(jobj)
except jose.DeserializationError as error: except jose.DeserializationError as error:
# Couldn't deserialize JSON object # Couldn't deserialize JSON object
@@ -167,7 +166,7 @@ class Network(object):
'contact'].default): 'contact'].default):
"""Register. """Register.
:param contact: Contact list, as accpeted by `.RegistrationResource` :param contact: Contact list, as accepted by `.Registration`
:type contact: `tuple` :type contact: `tuple`
:returns: Registration Resource. :returns: Registration Resource.
@@ -187,6 +186,24 @@ class Network(object):
return regr return regr
def register_from_account(self, account):
"""Register with server.
:param account: Account
:type account: :class:`letsencrypt.client.account.Account`
:returns: Updated account
:rtype: :class:`letsencrypt.client.account.Account`
"""
details = (
"mailto:" + account.email if account.email is not None else None,
"tel:" + account.phone if account.phone is not None else None,
)
account.regr = self.register(contact=tuple(
det for det in details if det is not None))
return account
def update_registration(self, regr): def update_registration(self, regr):
"""Update registration. """Update registration.
@@ -213,6 +230,21 @@ class Network(object):
raise errors.UnexpectedUpdate(regr) raise errors.UnexpectedUpdate(regr)
return updated_regr return updated_regr
def agree_to_tos(self, regr):
"""Agree to the terms-of-service.
Agree to the terms-of-service in a Registration Resource.
:param regr: Registration Resource.
:type regr: `.RegistrationResource`
:returns: Updated Registration Resource.
:rtype: `.RegistrationResource`
"""
return self.update_registration(
regr.update(body=regr.body.update(agreement=regr.terms_of_service)))
def _authzr_from_response(self, response, identifier, def _authzr_from_response(self, response, identifier,
uri=None, new_cert_uri=None): uri=None, new_cert_uri=None):
if new_cert_uri is None: if new_cert_uri is None:
@@ -230,25 +262,24 @@ class Network(object):
raise errors.UnexpectedUpdate(authzr) raise errors.UnexpectedUpdate(authzr)
return authzr return authzr
def request_challenges(self, identifier, regr): def request_challenges(self, identifier, new_authzr_uri):
"""Request challenges. """Request challenges.
:param identifier: Identifier to be challenged. :param identifier: Identifier to be challenged.
:type identifier: `.messages2.Identifier` :type identifier: `.messages2.Identifier`
:param regr: Registration Resource. :param str new_authzr_uri: new-authorization URI
:type regr: `.RegistrationResource`
:returns: Authorization Resource. :returns: Authorization Resource.
:rtype: `.AuthorizationResource` :rtype: `.AuthorizationResource`
""" """
new_authz = messages2.Authorization(identifier=identifier) new_authz = messages2.Authorization(identifier=identifier)
response = self._post(regr.new_authzr_uri, self._wrap_in_jws(new_authz)) response = self._post(new_authzr_uri, self._wrap_in_jws(new_authz))
assert response.status_code == httplib.CREATED # TODO: handle errors assert response.status_code == httplib.CREATED # TODO: handle errors
return self._authzr_from_response(response, identifier) return self._authzr_from_response(response, identifier)
def request_domain_challenges(self, domain, regr): def request_domain_challenges(self, domain, new_authz_uri):
"""Request challenges for domain names. """Request challenges for domain names.
This is simply a convenience function that wraps around This is simply a convenience function that wraps around
@@ -256,10 +287,14 @@ class Network(object):
generic identifiers. generic identifiers.
:param str domain: Domain name to be challenged. :param str domain: Domain name to be challenged.
:param str new_authzr_uri: new-authorization URI
:returns: Authorization Resource.
:rtype: `.AuthorizationResource`
""" """
return self.request_challenges(messages2.Identifier( return self.request_challenges(messages2.Identifier(
typ=messages2.IDENTIFIER_FQDN, value=domain), regr) typ=messages2.IDENTIFIER_FQDN, value=domain), new_authz_uri)
def answer_challenge(self, challb, response): def answer_challenge(self, challb, response):
"""Answer challenge. """Answer challenge.
@@ -280,7 +315,11 @@ class Network(object):
try: try:
authzr_uri = response.links['up']['url'] authzr_uri = response.links['up']['url']
except KeyError: except KeyError:
raise errors.NetworkError('"up" Link header missing') # TODO: Right now Boulder responds with the authorization resource
# instead of a challenge resource... this can be uncommented
# once the error is fixed (boulder#130).
return None
# raise errors.NetworkError('"up" Link header missing')
challr = messages2.ChallengeResource( challr = messages2.ChallengeResource(
authzr_uri=authzr_uri, authzr_uri=authzr_uri,
body=messages2.ChallengeBody.from_json(response.json())) body=messages2.ChallengeBody.from_json(response.json()))
@@ -289,17 +328,6 @@ class Network(object):
raise errors.UnexpectedUpdate(challr.uri) raise errors.UnexpectedUpdate(challr.uri)
return challr return challr
def answer_challenges(self, challbs, responses):
"""Answer multiple challenges.
.. note:: This is a convenience function to make integration
with old proto code easier and shall probably be removed
once restification is over.
"""
return [self.answer_challenge(challb, response)
for challb, response in itertools.izip(challbs, responses)]
@classmethod @classmethod
def retry_after(cls, response, default): def retry_after(cls, response, default):
"""Compute next `poll` time based on response ``Retry-After`` header. """Compute next `poll` time based on response ``Retry-After`` header.
@@ -342,7 +370,6 @@ class Network(object):
updated_authzr = self._authzr_from_response( updated_authzr = self._authzr_from_response(
response, authzr.body.identifier, authzr.uri, authzr.new_cert_uri) response, authzr.body.identifier, authzr.uri, authzr.new_cert_uri)
# TODO: check and raise UnexpectedUpdate # TODO: check and raise UnexpectedUpdate
return updated_authzr, response return updated_authzr, response
def request_issuance(self, csr, authzrs): def request_issuance(self, csr, authzrs):
@@ -358,6 +385,7 @@ class Network(object):
""" """
assert authzrs, "Authorizations list is empty" assert authzrs, "Authorizations list is empty"
logging.debug("Requesting issuance...")
# TODO: assert len(authzrs) == number of SANs # TODO: assert len(authzrs) == number of SANs
req = messages2.CertificateRequest( req = messages2.CertificateRequest(
@@ -408,7 +436,7 @@ class Network(object):
:rtype: `tuple` :rtype: `tuple`
""" """
# priority queue with datetime (based od Retry-After) as key, # priority queue with datetime (based on Retry-After) as key,
# and original Authorization Resource as value # and original Authorization Resource as value
waiting = [(datetime.datetime.now(), authzr) for authzr in authzrs] waiting = [(datetime.datetime.now(), authzr) for authzr in authzrs]
# mapping between original Authorization Resource and the most # mapping between original Authorization Resource and the most
@@ -438,6 +466,15 @@ class Network(object):
return self.request_issuance(csr, updated_authzrs), updated_authzrs return self.request_issuance(csr, updated_authzrs), updated_authzrs
def _get_cert(self, uri): def _get_cert(self, uri):
"""Returns certificate from URI.
:param str uri: URI of certificate
:returns: tuple of the form
(response, :class:`letsencrypt.acme.jose.ComparableX509`)
:rtype: tuple
"""
content_type = self.DER_CONTENT_TYPE # TODO: make it a param content_type = self.DER_CONTENT_TYPE # TODO: make it a param
response = self._get(uri, headers={'Accept': content_type}, response = self._get(uri, headers={'Accept': content_type},
content_type=content_type) content_type=content_type)
@@ -489,14 +526,22 @@ class Network(object):
""" """
if certr.cert_chain_uri is not None: if certr.cert_chain_uri is not None:
return self._get_cert(certr.cert_chain_uri) return self._get_cert(certr.cert_chain_uri)[1]
else:
return None
def revoke(self, certr, when=messages2.Revocation.NOW): def revoke(self, certr, when=messages2.Revocation.NOW):
"""Revoke certificate. """Revoke certificate.
:param certr: Certificate Resource
:type certr: `.CertificateResource`
:param when: When should the revocation take place? Takes :param when: When should the revocation take place? Takes
the same values as `.messages2.Revocation.revoke`. the same values as `.messages2.Revocation.revoke`.
:raises letsencrypt.client.errors.NetworkError: If revocation is
unsuccessful.
""" """
rev = messages2.Revocation(revoke=when, authorizations=tuple( rev = messages2.Revocation(revoke=when, authorizations=tuple(
authzr.uri for authzr in certr.authzrs)) authzr.uri for authzr in certr.authzrs))
@@ -934,9 +934,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
""" """
uid = os.geteuid() uid = os.geteuid()
le_util.make_or_verify_dir(self.config.config_dir, 0o755, uid) le_util.make_or_verify_dir(
le_util.make_or_verify_dir(self.config.work_dir, 0o755, uid) self.config.config_dir, constants.CONFIG_DIRS_MODE, uid)
le_util.make_or_verify_dir(self.config.backup_dir, 0o755, uid) le_util.make_or_verify_dir(
self.config.work_dir, constants.CONFIG_DIRS_MODE, uid)
le_util.make_or_verify_dir(
self.config.backup_dir, constants.CONFIG_DIRS_MODE, uid)
def get_version(self): def get_version(self):
"""Return version of Apache Server. """Return version of Apache Server.
@@ -1006,13 +1009,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
apache_dvsni.add_chall(achall, i) apache_dvsni.add_chall(achall, i)
sni_response = apache_dvsni.perform() sni_response = apache_dvsni.perform()
if sni_response:
# Must restart in order to activate the challenges. # Must restart in order to activate the challenges.
# Handled here because we may be able to load up other challenge types # Handled here because we may be able to load up other challenge
# types
self.restart() self.restart()
# Go through all of the challenges and assign them to the proper place # Go through all of the challenges and assign them to the proper
# in the responses return value. All responses must be in the same order # place in the responses return value. All responses must be in the
# as the original challenges. # same order as the original challenges.
for i, resp in enumerate(sni_response): for i, resp in enumerate(sni_response):
responses[apache_dvsni.indices[i]] = resp responses[apache_dvsni.indices[i]] = resp
@@ -18,6 +18,8 @@ from letsencrypt.client.plugins.apache import parser
from letsencrypt.client.plugins.apache.tests import util from letsencrypt.client.plugins.apache.tests import util
from letsencrypt.client.tests import acme_util
class TwoVhost80Test(util.ApacheTest): class TwoVhost80Test(util.ApacheTest):
"""Test two standard well configured HTTP vhosts.""" """Test two standard well configured HTTP vhosts."""
@@ -157,14 +159,18 @@ class TwoVhost80Test(util.ApacheTest):
# Note: As more challenges are offered this will have to be expanded # Note: As more challenges are offered this will have to be expanded
auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem) auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem)
achall1 = achallenges.DVSNI( achall1 = achallenges.DVSNI(
chall=challenges.DVSNI( challb=acme_util.chall_to_challb(
challenges.DVSNI(
r="jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", r="jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q",
nonce="37bc5eb75d3e00a19b4f6355845e5a18"), nonce="37bc5eb75d3e00a19b4f6355845e5a18"),
"pending"),
domain="encryption-example.demo", key=auth_key) domain="encryption-example.demo", key=auth_key)
achall2 = achallenges.DVSNI( achall2 = achallenges.DVSNI(
chall=challenges.DVSNI( challb=acme_util.chall_to_challb(
challenges.DVSNI(
r="uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", r="uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU",
nonce="59ed014cac95f77057b1d7a1b2c596ba"), nonce="59ed014cac95f77057b1d7a1b2c596ba"),
"pending"),
domain="letsencrypt.demo", key=auth_key) domain="letsencrypt.demo", key=auth_key)
dvsni_ret_val = [ dvsni_ret_val = [
@@ -14,6 +14,8 @@ from letsencrypt.client.plugins.apache.obj import Addr
from letsencrypt.client.plugins.apache.tests import util from letsencrypt.client.plugins.apache.tests import util
from letsencrypt.client.tests import acme_util
class DvsniPerformTest(util.ApacheTest): class DvsniPerformTest(util.ApacheTest):
"""Test the ApacheDVSNI challenge.""" """Test the ApacheDVSNI challenge."""
@@ -39,18 +41,22 @@ class DvsniPerformTest(util.ApacheTest):
auth_key = le_util.Key(rsa256_file, rsa256_pem) auth_key = le_util.Key(rsa256_file, rsa256_pem)
self.achalls = [ self.achalls = [
achallenges.DVSNI( achallenges.DVSNI(
chall=challenges.DVSNI( challb=acme_util.chall_to_challb(
r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9\xf1" challenges.DVSNI(
"\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4", 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", nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18",
), domain="encryption-example.demo", key=auth_key), ), "pending"),
domain="encryption-example.demo", key=auth_key),
achallenges.DVSNI( achallenges.DVSNI(
chall=challenges.DVSNI( challb=acme_util.chall_to_challb(
challenges.DVSNI(
r="\xba\xa9\xda?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y\x80" r="\xba\xa9\xda?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y\x80"
"\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945", "\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945",
nonce="Y\xed\x01L\xac\x95\xf7pW\xb1\xd7" nonce="Y\xed\x01L\xac\x95\xf7pW\xb1\xd7\xa1\xb2\xc5"
"\xa1\xb2\xc5\x96\xba", "\x96\xba",
), domain="letsencrypt.demo", key=auth_key), ), "pending"),
domain="letsencrypt.demo", key=auth_key),
] ]
def tearDown(self): def tearDown(self):
@@ -349,9 +349,12 @@ class NginxConfigurator(object):
""" """
uid = os.geteuid() uid = os.geteuid()
le_util.make_or_verify_dir(self.config.work_dir, 0o755, uid) le_util.make_or_verify_dir(
le_util.make_or_verify_dir(self.config.backup_dir, 0o755, uid) self.config.work_dir, constants.CONFIG_DIRS_MODE, uid)
le_util.make_or_verify_dir(self.config.config_dir, 0o755, uid) le_util.make_or_verify_dir(
self.config.backup_dir, constants.CONFIG_DIRS_MODE, uid)
le_util.make_or_verify_dir(
self.config.config_dir, constants.CONFIG_DIRS_MODE, uid)
def get_version(self): def get_version(self):
"""Return version of Nginx Server. """Return version of Nginx Server.
@@ -5,6 +5,7 @@ import unittest
import mock import mock
from letsencrypt.acme import challenges from letsencrypt.acme import challenges
from letsencrypt.acme import messages2
from letsencrypt.client import achallenges from letsencrypt.client import achallenges
from letsencrypt.client import errors from letsencrypt.client import errors
@@ -166,15 +167,21 @@ class NginxConfiguratorTest(util.NginxTest):
# Note: As more challenges are offered this will have to be expanded # Note: As more challenges are offered this will have to be expanded
auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem) auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem)
achall1 = achallenges.DVSNI( achall1 = achallenges.DVSNI(
challb=messages2.ChallengeBody(
chall=challenges.DVSNI( chall=challenges.DVSNI(
r="foo", r="foo",
nonce="bar"), nonce="bar"),
domain="localhost", key=auth_key) uri="https://ca.org/chall0_uri",
status=messages2.Status("pending"),
), domain="localhost", key=auth_key)
achall2 = achallenges.DVSNI( achall2 = achallenges.DVSNI(
challb=messages2.ChallengeBody(
chall=challenges.DVSNI( chall=challenges.DVSNI(
r="abc", r="abc",
nonce="def"), nonce="def"),
domain="example.com", key=auth_key) uri="https://ca.org/chall1_uri",
status=messages2.Status("pending"),
), domain="example.com", key=auth_key)
dvsni_ret_val = [ dvsni_ret_val = [
challenges.DVSNIResponse(s="irrelevant"), challenges.DVSNIResponse(s="irrelevant"),
@@ -6,6 +6,7 @@ import shutil
import mock import mock
from letsencrypt.acme import challenges from letsencrypt.acme import challenges
from letsencrypt.acme import messages2
from letsencrypt.client import achallenges from letsencrypt.client import achallenges
from letsencrypt.client import le_util from letsencrypt.client import le_util
@@ -35,16 +36,24 @@ class DvsniPerformTest(util.NginxTest):
self.achalls = [ self.achalls = [
achallenges.DVSNI( achallenges.DVSNI(
challb=messages2.ChallengeBody(
chall=challenges.DVSNI( chall=challenges.DVSNI(
r="foo", r="foo",
nonce="bar", nonce="bar",
),
uri="https://letsencrypt-ca.org/chall0_uri",
status=messages2.Status("pending"),
), domain="www.example.com", key=auth_key), ), domain="www.example.com", key=auth_key),
achallenges.DVSNI( achallenges.DVSNI(
challb=messages2.ChallengeBody(
chall=challenges.DVSNI( chall=challenges.DVSNI(
r="\xba\xa9\xda?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y\x80" r="\xba\xa9\xda?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y\x80"
"\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945", "\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945",
nonce="Y\xed\x01L\xac\x95\xf7pW\xb1\xd7" nonce="Y\xed\x01L\xac\x95\xf7pW\xb1\xd7"
"\xa1\xb2\xc5\x96\xba", "\xa1\xb2\xc5\x96\xba",
),
uri="https://letsencrypt-ca.org/chall1_uri",
status=messages2.Status("pending"),
), domain="blah", key=auth_key), ), domain="blah", key=auth_key),
] ]
@@ -15,6 +15,8 @@ from letsencrypt.acme import challenges
from letsencrypt.client import achallenges from letsencrypt.client import achallenges
from letsencrypt.client import le_util from letsencrypt.client import le_util
from letsencrypt.client.tests import acme_util
KEY = le_util.Key("foo", pkg_resources.resource_string( KEY = le_util.Key("foo", pkg_resources.resource_string(
"letsencrypt.acme.jose", os.path.join("testdata", "rsa512_key.pem"))) "letsencrypt.acme.jose", os.path.join("testdata", "rsa512_key.pem")))
@@ -71,7 +73,7 @@ class SNICallbackTest(unittest.TestCase):
StandaloneAuthenticator StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None) self.authenticator = StandaloneAuthenticator(None)
self.cert = achallenges.DVSNI( self.cert = achallenges.DVSNI(
chall=challenges.DVSNI(r="x"*32, nonce="abcdef"), challb=acme_util.DVSNI_P,
domain="example.com", key=KEY).gen_cert_and_response()[0] domain="example.com", key=KEY).gen_cert_and_response()[0]
self.authenticator.private_key = PRIVATE_KEY self.authenticator.private_key = PRIVATE_KEY
self.authenticator.tasks = {"abcdef.acme.invalid": self.cert} self.authenticator.tasks = {"abcdef.acme.invalid": self.cert}
@@ -298,10 +300,12 @@ class PerformTest(unittest.TestCase):
self.authenticator = StandaloneAuthenticator(None) self.authenticator = StandaloneAuthenticator(None)
self.achall1 = achallenges.DVSNI( self.achall1 = achallenges.DVSNI(
chall=challenges.DVSNI(r="whee", nonce="foo"), challb=acme_util.chall_to_challb(
challenges.DVSNI(r="whee", nonce="foo"), "pending"),
domain="foo.example.com", key=KEY) domain="foo.example.com", key=KEY)
self.achall2 = achallenges.DVSNI( self.achall2 = achallenges.DVSNI(
chall=challenges.DVSNI(r="whee", nonce="bar"), challb=acme_util.chall_to_challb(
challenges.DVSNI(r="whee", nonce="bar"), "pending"),
domain="bar.example.com", key=KEY) domain="bar.example.com", key=KEY)
bad_achall = ("This", "Represents", "A Non-DVSNI", "Challenge") bad_achall = ("This", "Represents", "A Non-DVSNI", "Challenge")
self.achalls = [self.achall1, self.achall2, bad_achall] self.achalls = [self.achall1, self.achall2, bad_achall]
@@ -346,12 +350,12 @@ class PerformTest(unittest.TestCase):
def test_perform_with_pending_tasks(self): def test_perform_with_pending_tasks(self):
self.authenticator.tasks = {"foononce.acme.invalid": "cert_data"} self.authenticator.tasks = {"foononce.acme.invalid": "cert_data"}
extra_achall = achallenges.DVSNI(chall="a", domain="b", key="c") extra_achall = acme_util.DVSNI_P
self.assertRaises( self.assertRaises(
ValueError, self.authenticator.perform, [extra_achall]) ValueError, self.authenticator.perform, [extra_achall])
def test_perform_without_challenge_list(self): def test_perform_without_challenge_list(self):
extra_achall = achallenges.DVSNI(chall="a", domain="b", key="c") extra_achall = acme_util.DVSNI_P
# This is wrong because a challenge must be specified. # This is wrong because a challenge must be specified.
self.assertRaises(ValueError, self.authenticator.perform, []) self.assertRaises(ValueError, self.authenticator.perform, [])
# This is wrong because it must be a list, not a bare challenge. # This is wrong because it must be a list, not a bare challenge.
@@ -458,7 +462,8 @@ class DoChildProcessTest(unittest.TestCase):
StandaloneAuthenticator StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None) self.authenticator = StandaloneAuthenticator(None)
self.cert = achallenges.DVSNI( self.cert = achallenges.DVSNI(
chall=challenges.DVSNI(r="x"*32, nonce="abcdef"), challb=acme_util.chall_to_challb(
challenges.DVSNI(r=("x" * 32), nonce="abcdef"), "pending"),
domain="example.com", key=KEY).gen_cert_and_response()[0] domain="example.com", key=KEY).gen_cert_and_response()[0]
self.authenticator.private_key = PRIVATE_KEY self.authenticator.private_key = PRIVATE_KEY
self.authenticator.tasks = {"abcdef.acme.invalid": self.cert} self.authenticator.tasks = {"abcdef.acme.invalid": self.cert}
@@ -546,7 +551,8 @@ class CleanupTest(unittest.TestCase):
StandaloneAuthenticator StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None) self.authenticator = StandaloneAuthenticator(None)
self.achall = achallenges.DVSNI( self.achall = achallenges.DVSNI(
chall=challenges.DVSNI(r="whee", nonce="foononce"), challb=acme_util.chall_to_challb(
challenges.DVSNI(r="whee", nonce="foononce"), "pending"),
domain="foo.example.com", key="key") domain="foo.example.com", key="key")
self.authenticator.tasks = {self.achall.nonce_domain: "stuff"} self.authenticator.tasks = {self.achall.nonce_domain: "stuff"}
self.authenticator.child_pid = 12345 self.authenticator.child_pid = 12345
@@ -566,7 +572,8 @@ class CleanupTest(unittest.TestCase):
def test_bad_cleanup(self): def test_bad_cleanup(self):
self.assertRaises( self.assertRaises(
ValueError, self.authenticator.cleanup, [achallenges.DVSNI( ValueError, self.authenticator.cleanup, [achallenges.DVSNI(
chall=challenges.DVSNI(r="whee", nonce="badnonce"), challb=acme_util.chall_to_challb(
challenges.DVSNI(r="whee", nonce="badnonce"), "pending"),
domain="bad.example.com", key="key")]) domain="bad.example.com", key="key")])
+6 -2
View File
@@ -6,9 +6,11 @@ import time
import zope.component import zope.component
from letsencrypt.client import constants
from letsencrypt.client import errors from letsencrypt.client import errors
from letsencrypt.client import interfaces from letsencrypt.client import interfaces
from letsencrypt.client import le_util from letsencrypt.client import le_util
from letsencrypt.client.display import util as display_util from letsencrypt.client.display import util as display_util
@@ -164,7 +166,8 @@ class Reverter(object):
unable to add checkpoint unable to add checkpoint
""" """
le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) le_util.make_or_verify_dir(
cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid())
op_fd, existing_filepaths = self._read_and_append( op_fd, existing_filepaths = self._read_and_append(
os.path.join(cp_dir, "FILEPATHS")) os.path.join(cp_dir, "FILEPATHS"))
@@ -305,7 +308,8 @@ class Reverter(object):
else: else:
cp_dir = self.config.in_progress_dir cp_dir = self.config.in_progress_dir
le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) le_util.make_or_verify_dir(
cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid())
# Append all new files (that aren't already registered) # Append all new files (that aren't already registered)
new_fd = None new_fd = None
+211
View File
@@ -0,0 +1,211 @@
"""Tests for letsencrypt.client.account."""
import logging
import mock
import os
import pkg_resources
import shutil
import tempfile
import unittest
from letsencrypt.acme import messages2
from letsencrypt.client import configuration
from letsencrypt.client import errors
from letsencrypt.client import le_util
from letsencrypt.client.display import util as display_util
class AccountTest(unittest.TestCase):
"""Tests letsencrypt.client.account.Account."""
def setUp(self):
from letsencrypt.client.account import Account
logging.disable(logging.CRITICAL)
self.accounts_dir = tempfile.mkdtemp("accounts")
self.account_keys_dir = os.path.join(self.accounts_dir, "keys")
os.makedirs(self.account_keys_dir, 0o700)
self.config = mock.MagicMock(
spec=configuration.NamespaceConfig, accounts_dir=self.accounts_dir,
account_keys_dir=self.account_keys_dir, rsa_key_size=2048,
server="letsencrypt-demo.org")
key_file = pkg_resources.resource_filename(
"letsencrypt.acme.jose", os.path.join("testdata", "rsa512_key.pem"))
key_pem = pkg_resources.resource_string(
"letsencrypt.acme.jose", os.path.join("testdata", "rsa512_key.pem"))
self.key = le_util.Key(key_file, key_pem)
self.email = "client@letsencrypt.org"
self.regr = messages2.RegistrationResource(
uri="uri",
new_authzr_uri="new_authzr_uri",
terms_of_service="terms_of_service",
body=messages2.Registration(
recovery_token="recovery_token", agreement="agreement")
)
self.test_account = Account(
self.config, self.key, self.email, None, self.regr)
def tearDown(self):
shutil.rmtree(self.accounts_dir)
logging.disable(logging.NOTSET)
@mock.patch("letsencrypt.client.account.zope.component.getUtility")
@mock.patch("letsencrypt.client.account.crypto_util.init_save_key")
def test_prompts(self, mock_key, mock_util):
from letsencrypt.client.account import Account
mock_util().input.return_value = (display_util.OK, self.email)
mock_key.return_value = self.key
acc = Account.from_prompts(self.config)
self.assertEqual(acc.email, self.email)
self.assertEqual(acc.key, self.key)
self.assertEqual(acc.config, self.config)
@mock.patch("letsencrypt.client.account.zope.component.getUtility")
@mock.patch("letsencrypt.client.account.Account.from_email")
def test_prompts_bad_email(self, mock_from_email, mock_util):
from letsencrypt.client.account import Account
mock_from_email.side_effect = (errors.LetsEncryptClientError, "acc")
mock_util().input.return_value = (display_util.OK, self.email)
self.assertEqual(Account.from_prompts(self.config), "acc")
@mock.patch("letsencrypt.client.account.zope.component.getUtility")
@mock.patch("letsencrypt.client.account.crypto_util.init_save_key")
def test_prompts_empty_email(self, mock_key, mock_util):
from letsencrypt.client.account import Account
mock_util().input.return_value = (display_util.OK, "")
acc = Account.from_prompts(self.config)
self.assertTrue(acc.email is None)
# _get_config_filename | pylint: disable=protected-access
mock_key.assert_called_once_with(
mock.ANY, mock.ANY, acc._get_config_filename(None))
@mock.patch("letsencrypt.client.account.zope.component.getUtility")
def test_prompts_cancel(self, mock_util):
from letsencrypt.client.account import Account
mock_util().input.return_value = (display_util.CANCEL, "")
self.assertTrue(Account.from_prompts(self.config) is None)
def test_from_email(self):
from letsencrypt.client.account import Account
self.assertRaises(errors.LetsEncryptClientError,
Account.from_email, self.config, "not_valid...email")
def test_save_from_existing_account(self):
from letsencrypt.client.account import Account
self.test_account.save()
acc = Account.from_existing_account(self.config, self.email)
self.assertEqual(acc.key, self.test_account.key)
self.assertEqual(acc.email, self.test_account.email)
self.assertEqual(acc.phone, self.test_account.phone)
self.assertEqual(acc.regr, self.test_account.regr)
def test_properties(self):
self.assertEqual(self.test_account.uri, "uri")
self.assertEqual(self.test_account.new_authzr_uri, "new_authzr_uri")
self.assertEqual(self.test_account.terms_of_service, "terms_of_service")
self.assertEqual(self.test_account.recovery_token, "recovery_token")
def test_partial_properties(self):
from letsencrypt.client.account import Account
partial = Account(self.config, self.key)
self.assertTrue(partial.uri is None)
self.assertTrue(partial.new_authzr_uri is None)
self.assertTrue(partial.terms_of_service is None)
self.assertTrue(partial.recovery_token is None)
def test_partial_account_default(self):
from letsencrypt.client.account import Account
partial = Account(self.config, self.key)
partial.save()
acc = Account.from_existing_account(self.config)
self.assertEqual(partial.key, acc.key)
self.assertEqual(partial.email, acc.email)
self.assertEqual(partial.phone, acc.phone)
self.assertEqual(partial.regr, acc.regr)
def test_get_accounts(self):
from letsencrypt.client.account import Account
accs = Account.get_accounts(self.config)
self.assertFalse(accs)
self.test_account.save()
accs = Account.get_accounts(self.config)
self.assertEqual(len(accs), 1)
self.assertEqual(accs[0].email, self.test_account.email)
acc2 = Account(self.config, self.key, "testing_email@gmail.com")
acc2.save()
accs = Account.get_accounts(self.config)
self.assertEqual(len(accs), 2)
def test_get_accounts_no_accounts(self):
from letsencrypt.client.account import Account
self.assertEqual(Account.get_accounts(
mock.Mock(accounts_dir="non-existant")), [])
def test_failed_existing_account(self):
from letsencrypt.client.account import Account
self.assertRaises(
errors.LetsEncryptClientError,
Account.from_existing_account,
self.config, "non-existant@email.org")
class SafeEmailTest(unittest.TestCase):
"""Test safe_email."""
def setUp(self):
logging.disable(logging.CRITICAL)
def tearDown(self):
logging.disable(logging.NOTSET)
@classmethod
def _call(cls, addr):
from letsencrypt.client.account import Account
return Account.safe_email(addr)
def test_valid_emails(self):
addrs = [
"letsencrypt@letsencrypt.org",
"tbd.ade@gmail.com",
"abc_def.jdk@hotmail.museum",
]
for addr in addrs:
self.assertTrue(self._call(addr), "%s failed." % addr)
def test_invalid_emails(self):
addrs = [
"letsencrypt@letsencrypt..org",
".tbd.ade@gmail.com",
"~/abc_def.jdk@hotmail.museum",
]
for addr in addrs:
self.assertFalse(self._call(addr), "%s failed." % addr)
if __name__ == "__main__":
unittest.main()
+4 -20
View File
@@ -5,24 +5,25 @@ import re
import unittest import unittest
import M2Crypto import M2Crypto
import mock
from letsencrypt.acme import challenges from letsencrypt.acme import challenges
from letsencrypt.client import le_util from letsencrypt.client import le_util
from letsencrypt.client.tests import acme_util
class DVSNITest(unittest.TestCase): class DVSNITest(unittest.TestCase):
"""Tests for letsencrypt.client.achallenges.DVSNI.""" """Tests for letsencrypt.client.achallenges.DVSNI."""
def setUp(self): def setUp(self):
self.chall = challenges.DVSNI(r="r_value", nonce="12345ABCDE") self.chall = acme_util.chall_to_challb(
challenges.DVSNI(r="r_value", nonce="12345ABCDE"), "pending")
self.response = challenges.DVSNIResponse() self.response = challenges.DVSNIResponse()
key = le_util.Key("path", pkg_resources.resource_string( key = le_util.Key("path", pkg_resources.resource_string(
"letsencrypt.acme.jose", "letsencrypt.acme.jose",
os.path.join("testdata", "rsa512_key.pem"))) os.path.join("testdata", "rsa512_key.pem")))
from letsencrypt.client.achallenges import DVSNI from letsencrypt.client.achallenges import DVSNI
self.achall = DVSNI(chall=self.chall, domain="example.com", key=key) self.achall = DVSNI(challb=self.chall, domain="example.com", key=key)
def test_proxy(self): def test_proxy(self):
self.assertEqual(self.chall.r, self.achall.r) self.assertEqual(self.chall.r, self.achall.r)
@@ -42,22 +43,5 @@ class DVSNITest(unittest.TestCase):
) )
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__": if __name__ == "__main__":
unittest.main() unittest.main()
+80 -4
View File
@@ -1,4 +1,6 @@
"""Class helps construct valid ACME messages for testing.""" """Class helps construct valid ACME messages for testing."""
import datetime
import itertools
import os import os
import pkg_resources import pkg_resources
@@ -6,6 +8,7 @@ import Crypto.PublicKey.RSA
from letsencrypt.acme import challenges from letsencrypt.acme import challenges
from letsencrypt.acme import jose from letsencrypt.acme import jose
from letsencrypt.acme import messages2
KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
@@ -51,13 +54,13 @@ CONT_CHALLENGES = [chall for chall in CHALLENGES
if isinstance(chall, challenges.ContinuityChallenge)] if isinstance(chall, challenges.ContinuityChallenge)]
def gen_combos(challs): def gen_combos(challbs):
"""Generate natural combinations for challs.""" """Generate natural combinations for challbs."""
dv_chall = [] dv_chall = []
cont_chall = [] cont_chall = []
for i, chall in enumerate(challs): # pylint: disable=redefined-outer-name for i, challb in enumerate(challbs): # pylint: disable=redefined-outer-name
if isinstance(chall, challenges.DVChallenge): if isinstance(challb.chall, challenges.DVChallenge):
dv_chall.append(i) dv_chall.append(i)
else: else:
cont_chall.append(i) cont_chall.append(i)
@@ -65,3 +68,76 @@ def gen_combos(challs):
# Gen combos for 1 of each type, lowest index first (makes testing easier) # Gen combos for 1 of each type, lowest index first (makes testing easier)
return tuple((i, j) if i < j else (j, i) return tuple((i, j) if i < j else (j, i)
for i in dv_chall for j in cont_chall) for i in dv_chall for j in cont_chall)
def chall_to_challb(chall, status): # pylint: disable=redefined-outer-name
"""Return ChallengeBody from Challenge."""
kwargs = {
"chall": chall,
"uri": chall.typ + "_uri",
"status": status,
}
if status == messages2.STATUS_VALID:
kwargs.update({"validated": datetime.datetime.now()})
return messages2.ChallengeBody(**kwargs) # pylint: disable=star-args
# Pending ChallengeBody objects
DVSNI_P = chall_to_challb(DVSNI, messages2.STATUS_PENDING)
SIMPLE_HTTPS_P = chall_to_challb(SIMPLE_HTTPS, messages2.STATUS_PENDING)
DNS_P = chall_to_challb(DNS, messages2.STATUS_PENDING)
RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, messages2.STATUS_PENDING)
RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, messages2.STATUS_PENDING)
POP_P = chall_to_challb(POP, messages2.STATUS_PENDING)
CHALLENGES_P = [SIMPLE_HTTPS_P, DVSNI_P, DNS_P,
RECOVERY_CONTACT_P, RECOVERY_TOKEN_P, POP_P]
DV_CHALLENGES_P = [challb for challb in CHALLENGES_P
if isinstance(challb.chall, challenges.DVChallenge)]
CONT_CHALLENGES_P = [
challb for challb in CHALLENGES_P
if isinstance(challb.chall, challenges.ContinuityChallenge)
]
def gen_authzr(authz_status, domain, challs, statuses, combos=True):
"""Generate an authorization resource.
:param authz_status: Status object
:type authz_status: :class:`letsencrypt.acme.messages2.Status`
:param list challs: Challenge objects
:param list statuses: status of each challenge object
:param bool combos: Whether or not to add combinations
"""
# pylint: disable=redefined-outer-name
challbs = tuple(
chall_to_challb(chall, status)
for chall, status in itertools.izip(challs, statuses)
)
authz_kwargs = {
"identifier": messages2.Identifier(
typ=messages2.IDENTIFIER_FQDN, value=domain),
"challenges": challbs,
}
if combos:
authz_kwargs.update({"combinations": gen_combos(challbs)})
if authz_status == messages2.STATUS_VALID:
now = datetime.datetime.now()
authz_kwargs.update({
"status": authz_status,
"expires": datetime.datetime(now.year, now.month + 1, now.day),
})
else:
authz_kwargs.update({
"status": authz_status,
})
# pylint: disable=star-args
return messages2.AuthorizationResource(
uri="https://trusted.ca/new-authz-resource",
new_cert_uri="https://trusted.ca/new-cert",
body=messages2.Authorization(**authz_kwargs)
)
+243 -456
View File
@@ -1,14 +1,16 @@
"""Tests for letsencrypt.client.auth_handler.""" """Tests for letsencrypt.client.auth_handler."""
import functools
import logging import logging
import unittest import unittest
import mock import mock
from letsencrypt.acme import challenges from letsencrypt.acme import challenges
from letsencrypt.acme import messages from letsencrypt.acme import messages2
from letsencrypt.client import achallenges
from letsencrypt.client import errors from letsencrypt.client import errors
from letsencrypt.client import le_util
from letsencrypt.client import network2
from letsencrypt.client.tests import acme_util from letsencrypt.client.tests import acme_util
@@ -23,8 +25,52 @@ TRANSLATE = {
} }
class SatisfyChallengesTest(unittest.TestCase): class ChallengeFactoryTest(unittest.TestCase):
"""verify_identities test.""" # pylint: disable=protected-access
def setUp(self):
from letsencrypt.client.auth_handler import AuthHandler
# Account is mocked...
self.handler = AuthHandler(
None, None, None, mock.Mock(key="mock_key"))
self.dom = "test"
self.handler.authzr[self.dom] = acme_util.gen_authzr(
messages2.STATUS_PENDING, self.dom, acme_util.CHALLENGES,
[messages2.STATUS_PENDING]*6, False)
def test_all(self):
cont_c, dv_c = self.handler._challenge_factory(self.dom, range(0, 6))
self.assertEqual(
[achall.chall for achall in cont_c], acme_util.CONT_CHALLENGES)
self.assertEqual(
[achall.chall for achall in dv_c], acme_util.DV_CHALLENGES)
def test_one_dv_one_cont(self):
cont_c, dv_c = self.handler._challenge_factory(self.dom, [1, 4])
self.assertEqual(
[achall.chall for achall in cont_c], [acme_util.RECOVERY_TOKEN])
self.assertEqual([achall.chall for achall in dv_c], [acme_util.DVSNI])
def test_unrecognized(self):
self.handler.authzr["failure.com"] = acme_util.gen_authzr(
messages2.STATUS_PENDING, "failure.com",
[mock.Mock(chall="chall", typ="unrecognized")],
[messages2.STATUS_PENDING])
self.assertRaises(errors.LetsEncryptClientError,
self.handler._challenge_factory, "failure.com", [0])
class GetAuthorizationsTest(unittest.TestCase):
"""get_authorizations test.
This tests everything except for all functions under _poll_challenges.
"""
def setUp(self): def setUp(self):
from letsencrypt.client.auth_handler import AuthHandler from letsencrypt.client.auth_handler import AuthHandler
@@ -39,297 +85,76 @@ class SatisfyChallengesTest(unittest.TestCase):
self.mock_cont_auth.perform.side_effect = gen_auth_resp self.mock_cont_auth.perform.side_effect = gen_auth_resp
self.mock_dv_auth.perform.side_effect = gen_auth_resp self.mock_dv_auth.perform.side_effect = gen_auth_resp
self.mock_account = mock.Mock(key=le_util.Key("file_path", "PEM"))
self.mock_net = mock.MagicMock(spec=network2.Network)
self.handler = AuthHandler( self.handler = AuthHandler(
self.mock_dv_auth, self.mock_cont_auth, None) self.mock_dv_auth, self.mock_cont_auth,
self.mock_net, self.mock_account)
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
def tearDown(self): def tearDown(self):
logging.disable(logging.NOTSET) logging.disable(logging.NOTSET)
def test_name1_dvsni1(self): @mock.patch("letsencrypt.client.auth_handler.AuthHandler._poll_challenges")
dom = "0" def test_name1_dvsni1(self, mock_poll):
msg = messages.Challenge( self.mock_net.request_domain_challenges.side_effect = functools.partial(
session_id=dom, nonce="nonce0", combinations=[], gen_dom_authzr, challs=acme_util.DV_CHALLENGES)
challenges=[acme_util.DVSNI])
self.handler.add_chall_msg(dom, msg, "dummy_key")
self.handler._satisfy_challenges() # pylint: disable=protected-access mock_poll.side_effect = self._validate_all
self.assertEqual(len(self.handler.responses), 1) authzr = self.handler.get_authorizations(["0"])
self.assertEqual(len(self.handler.responses[dom]), 1)
self.assertEqual("DVSNI0", self.handler.responses[dom][0]) self.assertEqual(self.mock_net.answer_challenge.call_count, 1)
self.assertEqual(len(self.handler.dv_c), 1)
self.assertEqual(len(self.handler.cont_c), 1)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.cont_c[dom]), 0)
def test_name1_rectok1(self): self.assertEqual(mock_poll.call_count, 1)
dom = "0" chall_update = mock_poll.call_args[0][0]
msg = messages.Challenge( self.assertEqual(chall_update.keys(), ["0"])
session_id=dom, nonce="nonce0", combinations=[], self.assertEqual(len(chall_update.values()), 1)
challenges=[acme_util.RECOVERY_TOKEN])
self.handler.add_chall_msg(dom, msg, "dummy_key")
self.handler._satisfy_challenges() # pylint: disable=protected-access
self.assertEqual(len(self.handler.responses), 1)
self.assertEqual(len(self.handler.responses[dom]), 1)
# Test if statement for dv_auth perform
self.assertEqual(self.mock_cont_auth.perform.call_count, 1)
self.assertEqual(self.mock_dv_auth.perform.call_count, 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.cont_c), 1)
# Assert 1 auth challenge, 0 dv
self.assertEqual(len(self.handler.dv_c[dom]), 0)
self.assertEqual(len(self.handler.cont_c[dom]), 1)
def test_name5_dvsni5(self):
for i in xrange(5):
self.handler.add_chall_msg(
str(i),
messages.Challenge(session_id=str(i), nonce="nonce%d" % i,
challenges=[acme_util.DVSNI],
combinations=[]),
"dummy_key")
self.handler._satisfy_challenges() # pylint: disable=protected-access
self.assertEqual(len(self.handler.responses), 5)
self.assertEqual(len(self.handler.dv_c), 5)
self.assertEqual(len(self.handler.cont_c), 5)
# Each message contains 1 auth, 0 client
# Test proper call count for methods
self.assertEqual(self.mock_cont_auth.perform.call_count, 0)
self.assertEqual(self.mock_dv_auth.perform.call_count, 1)
for i in xrange(5):
dom = str(i)
self.assertEqual(len(self.handler.responses[dom]), 1)
self.assertEqual(self.handler.responses[dom][0], "DVSNI%d" % i)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.cont_c[dom]), 0)
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"
self.handler.add_chall_msg(
dom,
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([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(acme_util.DV_CHALLENGES))
self.assertEqual(len(self.handler.dv_c), 1)
self.assertEqual(len(self.handler.cont_c), 1)
# Test if statement for cont_auth perform
self.assertEqual(self.mock_cont_auth.perform.call_count, 0)
self.assertEqual(self.mock_dv_auth.perform.call_count, 1)
self.assertEqual(self.mock_dv_auth.cleanup.call_count, 1)
self.assertEqual(self.mock_cont_auth.cleanup.call_count, 0)
# Test if list first element is DVSNI, use typ because it is an achall
self.assertEqual( self.assertEqual(
self.handler.responses[dom], self.mock_dv_auth.cleanup.call_args[0][0][0].typ, "dvsni")
self._get_exp_response(dom, path, acme_util.DV_CHALLENGES))
self.assertEqual(len(self.handler.dv_c[dom]), 1) self.assertEqual(len(authzr), 1)
self.assertEqual(len(self.handler.cont_c[dom]), 0)
self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall,
achallenges.SimpleHTTPS))
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") @mock.patch("letsencrypt.client.auth_handler.AuthHandler._poll_challenges")
def test_name1_all(self, mock_chall_path): def test_name3_dvsni3_rectok_3(self, mock_poll):
dom = "0" self.mock_net.request_domain_challenges.side_effect = functools.partial(
gen_dom_authzr, challs=acme_util.CHALLENGES)
combos = acme_util.gen_combos(acme_util.CHALLENGES) mock_poll.side_effect = self._validate_all
self.handler.add_chall_msg(
dom,
messages.Challenge(
session_id=dom, nonce="nonce0", challenges=acme_util.CHALLENGES,
combinations=combos),
"dummy_key")
path = gen_path([acme_util.SIMPLE_HTTPS, acme_util.RECOVERY_TOKEN], authzr = self.handler.get_authorizations(["0", "1", "2"])
acme_util.CHALLENGES)
mock_chall_path.return_value = path
self.handler._satisfy_challenges() # pylint: disable=protected-access self.assertEqual(self.mock_net.answer_challenge.call_count, 6)
self.assertEqual(len(self.handler.responses), 1) # Check poll call
self.assertEqual( self.assertEqual(mock_poll.call_count, 1)
len(self.handler.responses[dom]), len(acme_util.CHALLENGES)) chall_update = mock_poll.call_args[0][0]
self.assertEqual(len(self.handler.dv_c), 1) self.assertEqual(len(chall_update.keys()), 3)
self.assertEqual(len(self.handler.cont_c), 1) self.assertTrue("0" in chall_update.keys())
self.assertEqual(len(self.handler.dv_c[dom]), 1) self.assertEqual(len(chall_update["0"]), 2)
self.assertEqual(len(self.handler.cont_c[dom]), 1) self.assertTrue("1" in chall_update.keys())
self.assertEqual(len(chall_update["1"]), 2)
self.assertTrue("2" in chall_update.keys())
self.assertEqual(len(chall_update["2"]), 2)
self.assertEqual( self.assertEqual(self.mock_dv_auth.cleanup.call_count, 1)
self.handler.responses[dom], self.assertEqual(self.mock_cont_auth.cleanup.call_count, 1)
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.cont_c[dom][0].achall,
achallenges.RecoveryToken))
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") self.assertEqual(len(authzr), 3)
def test_name5_all(self, mock_chall_path):
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=acme_util.CHALLENGES, combinations=combos),
"dummy_key")
path = gen_path([acme_util.DVSNI, acme_util.RECOVERY_CONTACT], def test_perform_failure(self):
acme_util.CHALLENGES) self.mock_net.request_domain_challenges.side_effect = functools.partial(
mock_chall_path.return_value = path gen_dom_authzr, challs=acme_util.CHALLENGES)
self.mock_dv_auth.perform.side_effect = errors.AuthorizationError
self.handler._satisfy_challenges() # pylint: disable=protected-access
self.assertEqual(len(self.handler.responses), 5)
for i in xrange(5):
self.assertEqual(
len(self.handler.responses[str(i)]), len(acme_util.CHALLENGES))
self.assertEqual(len(self.handler.dv_c), 5)
self.assertEqual(len(self.handler.cont_c), 5)
for i in xrange(5):
dom = str(i)
self.assertEqual(
self.handler.responses[dom],
self._get_exp_response(dom, path, acme_util.CHALLENGES))
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.cont_c[dom]), 1)
self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall,
achallenges.DVSNI))
self.assertTrue(isinstance(self.handler.cont_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 = [[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):
dom = str(i)
paths.append(gen_path(chosen_chall[i], challenge_list[i]))
self.handler.add_chall_msg(
dom,
messages.Challenge(
session_id=dom, nonce="nonce%d" % i,
challenges=challenge_list[i], combinations=[]),
"dummy_key")
mock_chall_path.side_effect = paths
self.handler._satisfy_challenges() # pylint: disable=protected-access
self.assertEqual(len(self.handler.responses), 5)
self.assertEqual(len(self.handler.dv_c), 5)
self.assertEqual(len(self.handler.cont_c), 5)
for i in xrange(5):
dom = str(i)
resp = self._get_exp_response(i, paths[i], challenge_list[i])
self.assertEqual(self.handler.responses[dom], resp)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(
len(self.handler.cont_c[dom]), len(chosen_chall[i]) - 1)
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.cont_c["2"][0].achall,
achallenges.ProofOfPossession))
self.assertTrue(isinstance(
self.handler.cont_c["4"][0].achall, achallenges.RecoveryToken))
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
def test_perform_exception_cleanup(self, mock_chall_path):
"""3 Challenge messages... fail perform... clean up."""
# pylint: disable=protected-access
self.mock_dv_auth.perform.side_effect = errors.LetsEncryptDvsniError
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=acme_util.CHALLENGES, combinations=combos),
"dummy_key")
mock_chall_path.side_effect = [
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
self.assertRaises(errors.LetsEncryptAuthHandlerError,
self.handler._satisfy_challenges)
# Verify cleanup is actually run correctly
self.assertEqual(self.mock_dv_auth.cleanup.call_count, 2)
self.assertEqual(self.mock_cont_auth.cleanup.call_count, 2)
dv_cleanup_args = self.mock_dv_auth.cleanup.call_args_list
cont_cleanup_args = self.mock_cont_auth.cleanup.call_args_list
# Check DV cleanup
for i in xrange(2):
dv_chall_list = dv_cleanup_args[i][0][0]
self.assertEqual(len(dv_chall_list), 1)
self.assertTrue(
isinstance(dv_chall_list[0], achallenges.DVSNI))
# Check Auth cleanup
for i in xrange(2):
cont_chall_list = cont_cleanup_args[i][0][0]
self.assertEqual(len(cont_chall_list), 1)
self.assertTrue(
isinstance(cont_chall_list[0], achallenges.ProofOfPossession))
self.assertRaises(errors.AuthorizationError,
self.handler.get_authorizations, ["0"])
def _get_exp_response(self, domain, path, challs): def _get_exp_response(self, domain, path, challs):
# pylint: disable=no-self-use # pylint: disable=no-self-use
@@ -339,179 +164,132 @@ class SatisfyChallengesTest(unittest.TestCase):
return exp_resp return exp_resp
def _validate_all(self, unused_1, unused_2):
# pylint: disable=protected-access for dom in self.handler.authzr.keys():
class GetAuthorizationsTest(unittest.TestCase): azr = self.handler.authzr[dom]
def setUp(self): self.handler.authzr[dom] = acme_util.gen_authzr(
from letsencrypt.client.auth_handler import AuthHandler messages2.STATUS_VALID,
self.mock_dv_auth = mock.MagicMock(name="ApacheConfigurator")
self.mock_cont_auth = mock.MagicMock(name="ContinuityAuthenticator")
self.mock_sat_chall = mock.MagicMock(name="_satisfy_challenges")
self.mock_acme_auth = mock.MagicMock(name="acme_authorization")
self.iteration = 0
self.handler = AuthHandler(
self.mock_dv_auth, self.mock_cont_auth, None)
self.handler._satisfy_challenges = self.mock_sat_chall
self.handler.acme_authorization = self.mock_acme_auth
def test_solved3_at_once(self):
# Set 3 DVSNI challenges
for i in xrange(3):
self.handler.add_chall_msg(
str(i),
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
self.handler.get_authorizations()
self.assertEqual(self.mock_sat_chall.call_count, 1)
self.assertEqual(self.mock_acme_auth.call_count, 3)
exp_call_list = [mock.call("0"), mock.call("1"), mock.call("2")]
self.assertEqual(
self.mock_acme_auth.call_args_list, exp_call_list)
self._test_finished()
def _sat_solved_at_once(self):
for i in xrange(3):
dom = str(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])
self.handler.dv_c[dom], self.handler.cont_c[dom] = dv_c, c_c
def test_progress_failure(self):
self.handler.add_chall_msg(
"0",
messages.Challenge(
session_id="0", nonce="nonce0", challenges=acme_util.CHALLENGES,
combinations=[]),
"dummy_key")
# Don't do anything to satisfy challenges
self.mock_sat_chall.side_effect = self._sat_failure
self.assertRaises(
errors.LetsEncryptAuthHandlerError, self.handler.get_authorizations)
# Check to make sure program didn't loop
self.assertEqual(self.mock_sat_chall.call_count, 1)
def _sat_failure(self):
dom = "0"
self.handler.paths[dom] = gen_path(
[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.cont_c[dom] = dv_c, c_c
def test_incremental_progress(self):
for dom, challs in [("0", acme_util.CHALLENGES),
("1", acme_util.DV_CHALLENGES)]:
self.handler.add_chall_msg(
dom, dom,
messages.Challenge(session_id=dom, nonce="nonce", [challb.chall for challb in azr.body.challenges],
combinations=[], challenges=challs), [messages2.STATUS_VALID]*len(azr.body.challenges),
"dummy_key") azr.body.combinations)
self.mock_sat_chall.side_effect = self._sat_incremental
self.handler.get_authorizations()
self._test_finished()
self.assertEqual(self.mock_acme_auth.call_args_list,
[mock.call("1"), mock.call("0")])
def _sat_incremental(self):
# Exact responses don't matter, just path/response match
if self.iteration == 0:
# Only solve one of "0" required challs
self.handler.responses["0"][1] = "onecomplete"
self.handler.responses["0"][3] = None
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
dv_c, c_c = self.handler._challenge_factory("0", [1, 3])
self.handler.dv_c["0"], self.handler.cont_c["0"] = dv_c, c_c
dv_c, c_c = self.handler._challenge_factory("1", [2])
self.handler.dv_c["1"], self.handler.cont_c["1"] = dv_c, c_c
self.iteration += 1
elif self.iteration == 1:
# Quick check to make sure it was actually completed.
self.assertEqual(
self.mock_acme_auth.call_args_list, [mock.call("1")])
self.handler.responses["0"][1] = "now_finish"
self.handler.responses["0"][3] = "finally!"
else:
raise errors.LetsEncryptAuthHandlerError(
"Failed incremental test: too many invocations")
def _test_finished(self):
self.assertFalse(self.handler.msgs)
self.assertFalse(self.handler.dv_c)
self.assertFalse(self.handler.responses)
self.assertFalse(self.handler.paths)
self.assertFalse(self.handler.domains)
class PollChallengesTest(unittest.TestCase):
# pylint: disable=protected-access # pylint: disable=protected-access
class PathSatisfiedTest(unittest.TestCase): """Test poll challenges."""
def setUp(self): def setUp(self):
from letsencrypt.client.auth_handler import challb_to_achall
from letsencrypt.client.auth_handler import AuthHandler from letsencrypt.client.auth_handler import AuthHandler
self.handler = AuthHandler(None, None, None)
def test_satisfied_true(self): # Account and network are mocked...
dom = ["0", "1", "2", "3", "4"] self.mock_net = mock.MagicMock()
self.handler.paths[dom[0]] = [1, 2] self.handler = AuthHandler(
self.handler.responses[dom[0]] = [None, "sat", "sat2", None] None, None, self.mock_net, mock.Mock(key="mock_key"))
self.handler.paths[dom[1]] = [0] self.doms = ["0", "1", "2"]
self.handler.responses[dom[1]] = ["sat", None, None, False] self.handler.authzr[self.doms[0]] = acme_util.gen_authzr(
messages2.STATUS_PENDING, self.doms[0],
acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False)
self.handler.paths[dom[2]] = [0] self.handler.authzr[self.doms[1]] = acme_util.gen_authzr(
self.handler.responses[dom[2]] = ["sat"] messages2.STATUS_PENDING, self.doms[1],
acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False)
self.handler.paths[dom[3]] = [] self.handler.authzr[self.doms[2]] = acme_util.gen_authzr(
self.handler.responses[dom[3]] = [] messages2.STATUS_PENDING, self.doms[2],
acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False)
self.handler.paths[dom[4]] = [] self.chall_update = {}
self.handler.responses[dom[4]] = ["respond... sure"] for dom in self.doms:
self.chall_update[dom] = [
challb_to_achall(challb, "dummy_key", dom)
for challb in self.handler.authzr[dom].body.challenges]
for i in xrange(5): @mock.patch("letsencrypt.client.auth_handler.time")
self.assertTrue(self.handler._path_satisfied(dom[i])) def test_poll_challenges(self, unused_mock_time):
self.mock_net.poll.side_effect = self._mock_poll_solve_one_valid
self.handler._poll_challenges(self.chall_update, False)
def test_not_satisfied(self): for authzr in self.handler.authzr.values():
dom = ["0", "1", "2", "3"] self.assertEqual(authzr.body.status, messages2.STATUS_VALID)
self.handler.paths[dom[0]] = [1, 2]
self.handler.responses[dom[0]] = ["sat1", None, "sat2", None]
self.handler.paths[dom[1]] = [0] @mock.patch("letsencrypt.client.auth_handler.time")
self.handler.responses[dom[1]] = [None, None, None, None] def test_poll_challenges_failure_best_effort(self, unused_mock_time):
self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid
self.handler._poll_challenges(self.chall_update, True)
self.handler.paths[dom[2]] = [0] for authzr in self.handler.authzr.values():
self.handler.responses[dom[2]] = [None] self.assertEqual(authzr.body.status, messages2.STATUS_PENDING)
self.handler.paths[dom[3]] = [0] @mock.patch("letsencrypt.client.auth_handler.time")
self.handler.responses[dom[3]] = [False] def test_poll_challenges_failure(self, unused_mock_time):
self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid
self.assertRaises(errors.AuthorizationError,
self.handler._poll_challenges,
self.chall_update, False)
for i in xrange(3): @mock.patch("letsencrypt.client.auth_handler.time")
self.assertFalse(self.handler._path_satisfied(dom[i])) def test_unable_to_find_challenge_status(self, unused_mock_time):
from letsencrypt.client.auth_handler import challb_to_achall
self.mock_net.poll.side_effect = self._mock_poll_solve_one_valid
self.chall_update[self.doms[0]].append(
challb_to_achall(acme_util.RECOVERY_CONTACT_P, "key", self.doms[0]))
self.assertRaises(
errors.AuthorizationError,
self.handler._poll_challenges, self.chall_update, False)
def test_verify_authzr_failure(self):
self.assertRaises(
errors.AuthorizationError, self.handler.verify_authzr_complete)
def _mock_poll_solve_one_valid(self, authzr):
# Pending here because my dummy script won't change the full status.
# Basically it didn't raise an error and it stopped earlier than
# Making all challenges invalid which would make mock_poll_solve_one
# change authzr to invalid
return self._mock_poll_solve_one_chall(authzr, messages2.STATUS_VALID)
def _mock_poll_solve_one_invalid(self, authzr):
return self._mock_poll_solve_one_chall(authzr, messages2.STATUS_INVALID)
def _mock_poll_solve_one_chall(self, authzr, desired_status):
# pylint: disable=no-self-use
"""Dummy method that solves one chall at a time to desired_status.
When all are solved.. it changes authzr.status to desired_status
"""
new_challbs = authzr.body.challenges
for challb in authzr.body.challenges:
if challb.status != desired_status:
new_challbs = tuple(
challb_temp if challb_temp != challb
else acme_util.chall_to_challb(challb.chall, desired_status)
for challb_temp in authzr.body.challenges
)
break
if all(test_challb.status == desired_status
for test_challb in new_challbs):
status_ = desired_status
else:
status_ = authzr.body.status
new_authzr = messages2.AuthorizationResource(
uri=authzr.uri,
new_cert_uri=authzr.new_cert_uri,
body=messages2.Authorization(
identifier=authzr.body.identifier,
challenges=new_challbs,
combinations=authzr.body.combinations,
key=authzr.body.key,
contact=authzr.body.contact,
status=status_,
),
)
return (new_authzr, "response")
class GenChallengePathTest(unittest.TestCase): class GenChallengePathTest(unittest.TestCase):
"""Tests for letsencrypt.client.auth_handler.gen_challenge_path. """Tests for letsencrypt.client.auth_handler.gen_challenge_path.
@@ -526,42 +304,42 @@ class GenChallengePathTest(unittest.TestCase):
logging.disable(logging.NOTSET) logging.disable(logging.NOTSET)
@classmethod @classmethod
def _call(cls, challs, preferences, combinations): def _call(cls, challbs, preferences, combinations):
from letsencrypt.client.auth_handler import gen_challenge_path from letsencrypt.client.auth_handler import gen_challenge_path
return gen_challenge_path(challs, preferences, combinations) return gen_challenge_path(challbs, preferences, combinations)
def test_common_case(self): def test_common_case(self):
"""Given DVSNI and SimpleHTTPS with appropriate combos.""" """Given DVSNI and SimpleHTTPS with appropriate combos."""
challs = (acme_util.DVSNI, acme_util.SIMPLE_HTTPS) challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTPS_P)
prefs = [challenges.DVSNI] prefs = [challenges.DVSNI]
combos = ((0,), (1,)) combos = ((0,), (1,))
# Smart then trivial dumb path test # Smart then trivial dumb path test
self.assertEqual(self._call(challs, prefs, combos), (0,)) self.assertEqual(self._call(challbs, prefs, combos), (0,))
self.assertTrue(self._call(challs, prefs, None)) self.assertTrue(self._call(challbs, prefs, None))
# Rearrange order... # Rearrange order...
self.assertEqual(self._call(challs[::-1], prefs, combos), (1,)) self.assertEqual(self._call(challbs[::-1], prefs, combos), (1,))
self.assertTrue(self._call(challs[::-1], prefs, None)) self.assertTrue(self._call(challbs[::-1], prefs, None))
def test_common_case_with_continuity(self): def test_common_case_with_continuity(self):
challs = (acme_util.RECOVERY_TOKEN, challbs = (acme_util.RECOVERY_TOKEN_P,
acme_util.RECOVERY_CONTACT, acme_util.RECOVERY_CONTACT_P,
acme_util.DVSNI, acme_util.DVSNI_P,
acme_util.SIMPLE_HTTPS) acme_util.SIMPLE_HTTPS_P)
prefs = [challenges.RecoveryToken, challenges.DVSNI] prefs = [challenges.RecoveryToken, challenges.DVSNI]
combos = acme_util.gen_combos(challs) combos = acme_util.gen_combos(challbs)
self.assertEqual(self._call(challs, prefs, combos), (0, 2)) self.assertEqual(self._call(challbs, prefs, combos), (0, 2))
# dumb_path() trivial test # dumb_path() trivial test
self.assertTrue(self._call(challs, prefs, None)) self.assertTrue(self._call(challbs, prefs, None))
def test_full_cont_server(self): def test_full_cont_server(self):
challs = (acme_util.RECOVERY_TOKEN, challbs = (acme_util.RECOVERY_TOKEN_P,
acme_util.RECOVERY_CONTACT, acme_util.RECOVERY_CONTACT_P,
acme_util.POP, acme_util.POP_P,
acme_util.DVSNI, acme_util.DVSNI_P,
acme_util.SIMPLE_HTTPS, acme_util.SIMPLE_HTTPS_P,
acme_util.DNS) acme_util.DNS_P)
# Typical webserver client that can do everything except DNS # Typical webserver client that can do everything except DNS
# Attempted to make the order realistic # Attempted to make the order realistic
prefs = [challenges.RecoveryToken, prefs = [challenges.RecoveryToken,
@@ -569,19 +347,19 @@ class GenChallengePathTest(unittest.TestCase):
challenges.SimpleHTTPS, challenges.SimpleHTTPS,
challenges.DVSNI, challenges.DVSNI,
challenges.RecoveryContact] challenges.RecoveryContact]
combos = acme_util.gen_combos(challs) combos = acme_util.gen_combos(challbs)
self.assertEqual(self._call(challs, prefs, combos), (0, 4)) self.assertEqual(self._call(challbs, prefs, combos), (0, 4))
# Dumb path trivial test # Dumb path trivial test
self.assertTrue(self._call(challs, prefs, None)) self.assertTrue(self._call(challbs, prefs, None))
def test_not_supported(self): def test_not_supported(self):
challs = (acme_util.POP, acme_util.DVSNI) challbs = (acme_util.POP_P, acme_util.DVSNI_P)
prefs = [challenges.DVSNI] prefs = [challenges.DVSNI]
combos = ((0, 1),) combos = ((0, 1),)
self.assertRaises(errors.LetsEncryptAuthHandlerError, self.assertRaises(errors.AuthorizationError,
self._call, challs, prefs, combos) self._call, challbs, prefs, combos)
class MutuallyExclusiveTest(unittest.TestCase): class MutuallyExclusiveTest(unittest.TestCase):
@@ -640,15 +418,16 @@ class IsPreferredTest(unittest.TestCase):
])) ]))
def test_empty_satisfied(self): def test_empty_satisfied(self):
self.assertTrue(self._call(acme_util.DNS, frozenset())) self.assertTrue(self._call(acme_util.DNS_P, frozenset()))
def test_mutually_exclusvie(self): def test_mutually_exclusvie(self):
self.assertFalse( self.assertFalse(
self._call(acme_util.DVSNI, frozenset([acme_util.SIMPLE_HTTPS]))) self._call(
acme_util.DVSNI_P, frozenset([acme_util.SIMPLE_HTTPS_P])))
def test_mutually_exclusive_same_type(self): def test_mutually_exclusive_same_type(self):
self.assertTrue( self.assertTrue(
self._call(acme_util.DVSNI, frozenset([acme_util.DVSNI]))) self._call(acme_util.DVSNI_P, frozenset([acme_util.DVSNI_P])))
def gen_auth_resp(chall_list): def gen_auth_resp(chall_list):
@@ -657,6 +436,13 @@ def gen_auth_resp(chall_list):
for chall in chall_list] for chall in chall_list]
def gen_dom_authzr(domain, unused_new_authzr_uri, challs):
"""Generates new authzr for domains."""
return acme_util.gen_authzr(
messages2.STATUS_PENDING, domain, challs,
[messages2.STATUS_PENDING]*len(challs))
def gen_path(required, challs): def gen_path(required, challs):
"""Generate a combination by picking ``required`` from ``challs``. """Generate a combination by picking ``required`` from ``challs``.
@@ -670,5 +456,6 @@ def gen_path(required, challs):
""" """
return [challs.index(chall) for chall in required] return [challs.index(chall) for chall in required]
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+47
View File
@@ -1,10 +1,57 @@
"""letsencrypt.client.client.py tests.""" """letsencrypt.client.client.py tests."""
import os
import unittest import unittest
import shutil
import tempfile
import mock import mock
from letsencrypt.client import account
from letsencrypt.client import configuration from letsencrypt.client import configuration
from letsencrypt.client import errors from letsencrypt.client import errors
from letsencrypt.client import le_util
class DetermineAccountTest(unittest.TestCase):
def setUp(self):
self.accounts_dir = tempfile.mkdtemp("accounts")
account_keys_dir = os.path.join(self.accounts_dir, "keys")
os.makedirs(account_keys_dir, 0o700)
self.config = mock.MagicMock(
spec=configuration.NamespaceConfig, accounts_dir=self.accounts_dir,
account_keys_dir=account_keys_dir, rsa_key_size=2048,
server="letsencrypt-demo.org")
def tearDown(self):
shutil.rmtree(self.accounts_dir)
@mock.patch("letsencrypt.client.client.account.Account.from_prompts")
@mock.patch("letsencrypt.client.client.display_ops.choose_account")
def determine_account(self, mock_op, mock_prompt):
"""Test determine account"""
from letsencrypt.client import client
key = le_util.Key("file", "pem")
test_acc = account.Account(self.config, key, "email1@gmail.com")
mock_op.return_value = test_acc
# Test 0
mock_prompt.return_value = None
self.assertTrue(client.determine_account(self.config) is None)
# Test 1
test_acc.save()
acc = client.determine_account(self.config)
self.assertEqual(acc.email, test_acc.email)
# Test multiple
self.assertFalse(mock_op.called)
acc2 = account.Account(self.config, key)
acc2.save()
chosen_acc = client.determine_account(self.config)
self.assertTrue(mock_op.called)
self.assertTrue(chosen_acc.email, test_acc.email)
class DetermineAuthenticatorTest(unittest.TestCase): class DetermineAuthenticatorTest(unittest.TestCase):
+20 -2
View File
@@ -1,4 +1,5 @@
"""Tests for letsencrypt.client.configuration.""" """Tests for letsencrypt.client.configuration."""
import os
import unittest import unittest
import mock import mock
@@ -10,24 +11,41 @@ class NamespaceConfigTest(unittest.TestCase):
def setUp(self): def setUp(self):
from letsencrypt.client.configuration import NamespaceConfig from letsencrypt.client.configuration import NamespaceConfig
namespace = mock.MagicMock( namespace = mock.MagicMock(
work_dir='/tmp/foo', foo='bar', server='acme-server.org:443') config_dir='/tmp/config', work_dir='/tmp/foo', foo='bar',
server='acme-server.org:443/new')
self.config = NamespaceConfig(namespace) self.config = NamespaceConfig(namespace)
def test_proxy_getattr(self): def test_proxy_getattr(self):
self.assertEqual(self.config.foo, 'bar') self.assertEqual(self.config.foo, 'bar')
self.assertEqual(self.config.work_dir, '/tmp/foo') self.assertEqual(self.config.work_dir, '/tmp/foo')
def test_server_path(self):
self.assertEqual(['acme-server.org:443', 'new'],
self.config.server_path.split(os.path.sep))
def test_server_url(self):
self.assertEqual(
self.config.server_url, 'https://acme-server.org:443/new')
@mock.patch('letsencrypt.client.configuration.constants') @mock.patch('letsencrypt.client.configuration.constants')
def test_dynamic_dirs(self, constants): def test_dynamic_dirs(self, constants):
constants.TEMP_CHECKPOINT_DIR = 't' constants.TEMP_CHECKPOINT_DIR = 't'
constants.IN_PROGRESS_DIR = '../p' constants.IN_PROGRESS_DIR = '../p'
constants.CERT_KEY_BACKUP_DIR = 'c/' constants.CERT_KEY_BACKUP_DIR = 'c/'
constants.REC_TOKEN_DIR = '/r' constants.REC_TOKEN_DIR = '/r'
constants.ACCOUNTS_DIR = 'acc'
constants.ACCOUNT_KEYS_DIR = 'keys'
self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t') self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t')
self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p') self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p')
self.assertEqual( self.assertEqual(
self.config.cert_key_backup, '/tmp/foo/c/acme-server.org') self.config.cert_key_backup, '/tmp/foo/c/acme-server.org:443/new')
self.assertEqual(self.config.rec_token_dir, '/r') self.assertEqual(self.config.rec_token_dir, '/r')
self.assertEqual(
self.config.accounts_dir, '/tmp/config/acc/acme-server.org:443/new')
self.assertEqual(
self.config.account_keys_dir,
'/tmp/config/acc/acme-server.org:443/new/keys')
if __name__ == '__main__': if __name__ == '__main__':
@@ -21,14 +21,14 @@ class PerformTest(unittest.TestCase):
name="rec_token_perform", side_effect=gen_client_resp) name="rec_token_perform", side_effect=gen_client_resp)
def test_rec_token1(self): def test_rec_token1(self):
token = achallenges.RecoveryToken(chall=None, domain="0") token = achallenges.RecoveryToken(challb=None, domain="0")
responses = self.auth.perform([token]) responses = self.auth.perform([token])
self.assertEqual(responses, ["RecoveryToken0"]) self.assertEqual(responses, ["RecoveryToken0"])
def test_rec_token5(self): def test_rec_token5(self):
tokens = [] tokens = []
for i in xrange(5): for i in xrange(5):
tokens.append(achallenges.RecoveryToken(chall=None, domain=str(i))) tokens.append(achallenges.RecoveryToken(challb=None, domain=str(i)))
responses = self.auth.perform(tokens) responses = self.auth.perform(tokens)
@@ -39,7 +39,7 @@ class PerformTest(unittest.TestCase):
def test_unexpected(self): def test_unexpected(self):
self.assertRaises( self.assertRaises(
errors.LetsEncryptContAuthError, self.auth.perform, [ errors.LetsEncryptContAuthError, self.auth.perform, [
achallenges.DVSNI(chall=None, domain="0", key="invalid_key")]) achallenges.DVSNI(challb=None, domain="0", key="invalid_key")])
def test_chall_pref(self): def test_chall_pref(self):
self.assertEqual( self.assertEqual(
@@ -58,8 +58,8 @@ class CleanupTest(unittest.TestCase):
self.auth.rec_token.cleanup = self.mock_cleanup self.auth.rec_token.cleanup = self.mock_cleanup
def test_rec_token2(self): def test_rec_token2(self):
token1 = achallenges.RecoveryToken(chall=None, domain="0") token1 = achallenges.RecoveryToken(challb=None, domain="0")
token2 = achallenges.RecoveryToken(chall=None, domain="1") token2 = achallenges.RecoveryToken(challb=None, domain="1")
self.auth.cleanup([token1, token2]) self.auth.cleanup([token1, token2])
@@ -67,8 +67,8 @@ class CleanupTest(unittest.TestCase):
[mock.call(token1), mock.call(token2)]) [mock.call(token1), mock.call(token2)])
def test_unexpected(self): def test_unexpected(self):
token = achallenges.RecoveryToken(chall=None, domain="0") token = achallenges.RecoveryToken(challb=None, domain="0")
unexpected = achallenges.DVSNI(chall=None, domain="0", key="dummy_key") unexpected = achallenges.DVSNI(challb=None, domain="0", key="dummy_key")
self.assertRaises(errors.LetsEncryptContAuthError, self.assertRaises(errors.LetsEncryptContAuthError,
self.auth.cleanup, [token, unexpected]) self.auth.cleanup, [token, unexpected])
@@ -1,9 +1,13 @@
"""Tests for letsencrypt.client.crypto_util.""" """Tests for letsencrypt.client.crypto_util."""
import logging
import os import os
import pkg_resources import pkg_resources
import shutil
import tempfile
import unittest import unittest
import M2Crypto import M2Crypto
import mock
RSA256_KEY = pkg_resources.resource_string( RSA256_KEY = pkg_resources.resource_string(
@@ -12,6 +16,57 @@ RSA512_KEY = pkg_resources.resource_string(
'letsencrypt.acme.jose', os.path.join('testdata', 'rsa512_key.pem')) 'letsencrypt.acme.jose', os.path.join('testdata', 'rsa512_key.pem'))
class InitSaveKeyTest(unittest.TestCase):
"""Tests for letsencrypt.client.crypto_util.init_save_key."""
def setUp(self):
logging.disable(logging.CRITICAL)
self.key_dir = tempfile.mkdtemp('key_dir')
def tearDown(self):
logging.disable(logging.NOTSET)
shutil.rmtree(self.key_dir)
@classmethod
def _call(cls, key_size, key_dir):
from letsencrypt.client.crypto_util import init_save_key
return init_save_key(key_size, key_dir, 'key-letsencrypt.pem')
@mock.patch('letsencrypt.client.crypto_util.make_key')
def test_success(self, mock_make):
mock_make.return_value = 'key_pem'
key = self._call(1024, self.key_dir)
self.assertEqual(key.pem, 'key_pem')
self.assertTrue('key-letsencrypt.pem' in key.file)
@mock.patch('letsencrypt.client.crypto_util.make_key')
def test_key_failure(self, mock_make):
mock_make.side_effect = ValueError
self.assertRaises(ValueError, self._call, 431, self.key_dir)
class InitSaveCSRTest(unittest.TestCase):
"""Tests for letsencrypt.client.crypto_util.init_save_csr."""
def setUp(self):
self.csr_dir = tempfile.mkdtemp('csr_dir')
def tearDown(self):
shutil.rmtree(self.csr_dir)
@mock.patch('letsencrypt.client.crypto_util.make_csr')
@mock.patch('letsencrypt.client.crypto_util.le_util.make_or_verify_dir')
def test_it(self, unused_mock_verify, mock_csr):
from letsencrypt.client.crypto_util import init_save_csr
mock_csr.return_value = ('csr_pem', 'csr_der')
csr = init_save_csr(
mock.Mock(pem='dummy_key'), 'example.com', self.csr_dir,
'csr-letsencrypt.pem')
self.assertEqual(csr.data, 'csr_der')
self.assertTrue('csr-letsencrypt.pem' in csr.file)
class ValidCSRTest(unittest.TestCase): class ValidCSRTest(unittest.TestCase):
"""Tests for letsencrypt.client.crypto_util.valid_csr.""" """Tests for letsencrypt.client.crypto_util.valid_csr."""
+46 -2
View File
@@ -1,13 +1,16 @@
"""Test letsencrypt.client.display.ops.""" """Test letsencrypt.client.display.ops."""
import os
import sys import sys
import tempfile
import unittest import unittest
import mock import mock
import zope.component import zope.component
from letsencrypt.client import account
from letsencrypt.client import le_util
from letsencrypt.client.display import util as display_util from letsencrypt.client.display import util as display_util
class ChooseAuthenticatorTest(unittest.TestCase): class ChooseAuthenticatorTest(unittest.TestCase):
"""Test choose_authenticator function.""" """Test choose_authenticator function."""
def setUp(self): def setUp(self):
@@ -50,10 +53,51 @@ class ChooseAuthenticatorTest(unittest.TestCase):
@mock.patch("letsencrypt.client.display.ops.util") @mock.patch("letsencrypt.client.display.ops.util")
def test_no_choice(self, mock_util): def test_no_choice(self, mock_util):
mock_util().menu.return_value = (display_util.CANCEL, 0) mock_util().menu.return_value = (display_util.CANCEL, 0)
self.assertTrue(self._call(self.auths, {}) is None) self.assertTrue(self._call(self.auths, {}) is None)
class ChooseAccountTest(unittest.TestCase):
"""Test choose_account."""
def setUp(self):
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
self.accounts_dir = tempfile.mkdtemp("accounts")
self.account_keys_dir = os.path.join(self.accounts_dir, "keys")
os.makedirs(self.account_keys_dir, 0o700)
self.config = mock.MagicMock(
accounts_dir=self.accounts_dir,
account_keys_dir=self.account_keys_dir,
server="letsencrypt-demo.org")
self.key = le_util.Key("keypath", "pem")
self.acc1 = account.Account(self.config, self.key, "email1@g.com")
self.acc2 = account.Account(
self.config, self.key, "email2@g.com", "phone")
self.acc1.save()
self.acc2.save()
@classmethod
def _call(cls, accounts):
from letsencrypt.client.display import ops
return ops.choose_account(accounts)
@mock.patch("letsencrypt.client.display.ops.util")
def test_one(self, mock_util):
mock_util().menu.return_value = (display_util.OK, 0)
self.assertEqual(self._call([self.acc1]), self.acc1)
@mock.patch("letsencrypt.client.display.ops.util")
def test_two(self, mock_util):
mock_util().menu.return_value = (display_util.OK, 1)
self.assertEqual(self._call([self.acc1, self.acc2]), self.acc2)
@mock.patch("letsencrypt.client.display.ops.util")
def test_cancel(self, mock_util):
mock_util().menu.return_value = (display_util.CANCEL, 1)
self.assertTrue(self._call([self.acc1, self.acc2]) is None)
class GenHttpsNamesTest(unittest.TestCase): class GenHttpsNamesTest(unittest.TestCase):
"""Test _gen_https_names.""" """Test _gen_https_names."""
def setUp(self): def setUp(self):
@@ -128,9 +128,9 @@ class NcursesDisplayTest(DisplayT):
self.displayer.checklist("message", self.tags) self.displayer.checklist("message", self.tags)
choices = [ choices = [
(self.tags[0], "", False), (self.tags[0], "", True),
(self.tags[1], "", False), (self.tags[1], "", True),
(self.tags[2], "", False) (self.tags[2], "", True),
] ]
mock_checklist.assert_called_with( mock_checklist.assert_called_with(
"message", width=display_util.WIDTH, height=display_util.HEIGHT, "message", width=display_util.WIDTH, height=display_util.HEIGHT,
+41 -14
View File
@@ -9,12 +9,13 @@ import M2Crypto
import mock import mock
import requests import requests
from letsencrypt.client import errors
from letsencrypt.acme import challenges from letsencrypt.acme import challenges
from letsencrypt.acme import jose from letsencrypt.acme import jose
from letsencrypt.acme import messages2 from letsencrypt.acme import messages2
from letsencrypt.client import account
from letsencrypt.client import errors
CERT = jose.ComparableX509(M2Crypto.X509.load_cert_string( CERT = jose.ComparableX509(M2Crypto.X509.load_cert_string(
pkg_resources.resource_string( pkg_resources.resource_string(
@@ -196,6 +197,30 @@ class NetworkTest(unittest.TestCase):
self.assertRaises( self.assertRaises(
errors.NetworkError, self.net.register, self.regr.body) errors.NetworkError, self.net.register, self.regr.body)
def test_register_from_account(self):
self.net.register = mock.Mock()
acc = account.Account(
mock.Mock(accounts_dir='mock_dir'), 'key',
email='cert-admin@example.com', phone='+12025551212')
self.net.register_from_account(acc)
self.net.register.assert_called_with(contact=self.contact)
def test_register_from_account_partial_info(self):
self.net.register = mock.Mock()
acc = account.Account(
mock.Mock(accounts_dir='mock_dir'), 'key',
email='cert-admin@example.com')
acc2 = account.Account(mock.Mock(accounts_dir='mock_dir'), 'key')
self.net.register_from_account(acc)
self.net.register.assert_called_with(
contact=('mailto:cert-admin@example.com',))
self.net.register_from_account(acc2)
self.net.register.assert_called_with(contact=())
def test_update_registration(self): def test_update_registration(self):
self.response.headers['Location'] = self.regr.uri self.response.headers['Location'] = self.regr.uri
self.response.json.return_value = self.regr.body.to_json() self.response.json.return_value = self.regr.body.to_json()
@@ -208,6 +233,12 @@ class NetworkTest(unittest.TestCase):
self.assertRaises( self.assertRaises(
errors.UnexpectedUpdate, self.net.update_registration, self.regr) errors.UnexpectedUpdate, self.net.update_registration, self.regr)
def test_agree_to_tos(self):
self.net.update_registration = mock.Mock()
self.net.agree_to_tos(self.regr)
regr = self.net.update_registration.call_args[0][0]
self.assertEqual(self.regr.terms_of_service, regr.body.agreement)
def test_request_challenges(self): def test_request_challenges(self):
self.response.status_code = httplib.CREATED self.response.status_code = httplib.CREATED
self.response.headers['Location'] = self.authzr.uri self.response.headers['Location'] = self.authzr.uri
@@ -217,7 +248,7 @@ class NetworkTest(unittest.TestCase):
} }
self._mock_post_get() self._mock_post_get()
self.net.request_challenges(self.identifier, self.regr) self.net.request_challenges(self.identifier, self.authzr.uri)
# TODO: test POST call arguments # TODO: test POST call arguments
# TODO: split here and separate test # TODO: split here and separate test
@@ -255,16 +286,11 @@ class NetworkTest(unittest.TestCase):
def test_answer_challenge_missing_next(self): def test_answer_challenge_missing_next(self):
self._mock_post_get() self._mock_post_get()
self.assertRaises(errors.NetworkError, self.net.answer_challenge, self.assertTrue(self.net.answer_challenge(
self.challr.body, challenges.DNSResponse()) self.challr.body, challenges.DNSResponse()) is None)
# TODO: boulder#130, acme-spec#110
def test_answer_challenges(self): # self.assertRaises(errors.NetworkError, self.net.answer_challenge,
self.net.answer_challenge = mock.MagicMock() # self.challr.body, challenges.DNSResponse())
self.assertEqual(
[self.net.answer_challenge(
self.challr.body, challenges.DNSResponse())],
self.net.answer_challenges(
[self.challr.body], [challenges.DNSResponse()]))
def test_retry_after_date(self): def test_retry_after_date(self):
self.response.headers['Retry-After'] = 'Fri, 31 Dec 1999 23:59:59 GMT' self.response.headers['Retry-After'] = 'Fri, 31 Dec 1999 23:59:59 GMT'
@@ -435,7 +461,8 @@ class NetworkTest(unittest.TestCase):
def test_fetch_chain(self): def test_fetch_chain(self):
# pylint: disable=protected-access # pylint: disable=protected-access
self.net._get_cert = mock.MagicMock() self.net._get_cert = mock.MagicMock()
self.assertEqual(self.net._get_cert(self.certr.cert_chain_uri), self.net._get_cert.return_value = ("response", "certificate")
self.assertEqual(self.net._get_cert(self.certr.cert_chain_uri)[1],
self.net.fetch_chain(self.certr)) self.net.fetch_chain(self.certr))
def test_fetch_chain_no_up_link(self): def test_fetch_chain_no_up_link(self):
@@ -39,22 +39,24 @@ class RecoveryTokenTest(unittest.TestCase):
self.assertFalse(self.rec_token.requires_human("example3.com")) self.assertFalse(self.rec_token.requires_human("example3.com"))
self.rec_token.cleanup(achallenges.RecoveryToken( self.rec_token.cleanup(achallenges.RecoveryToken(
chall=None, domain="example3.com")) challb=challenges.RecoveryToken(), domain="example3.com"))
self.assertTrue(self.rec_token.requires_human("example3.com")) self.assertTrue(self.rec_token.requires_human("example3.com"))
# Shouldn't throw an error # Shouldn't throw an error
self.rec_token.cleanup(achallenges.RecoveryToken( self.rec_token.cleanup(achallenges.RecoveryToken(
chall=None, domain="example4.com")) challb=None, domain="example4.com"))
# SHOULD throw an error (OSError other than nonexistent file) # SHOULD throw an error (OSError other than nonexistent file)
self.assertRaises( self.assertRaises(
OSError, self.rec_token.cleanup, OSError, self.rec_token.cleanup,
achallenges.RecoveryToken(chall=None, domain="a"+"r"*10000+".com")) achallenges.RecoveryToken(
challb=None, domain=("a" + "r" * 10000 + ".com")))
def test_perform_stored(self): def test_perform_stored(self):
self.rec_token.store_token("example4.com", 444) self.rec_token.store_token("example4.com", 444)
response = self.rec_token.perform( response = self.rec_token.perform(
achallenges.RecoveryToken(chall=None, domain="example4.com")) achallenges.RecoveryToken(
challb=challenges.RecoveryToken(), domain="example4.com"))
self.assertEqual( self.assertEqual(
response, challenges.RecoveryTokenResponse(token="444")) response, challenges.RecoveryTokenResponse(token="444"))
@@ -63,12 +65,14 @@ class RecoveryTokenTest(unittest.TestCase):
def test_perform_not_stored(self, mock_input): def test_perform_not_stored(self, mock_input):
mock_input().input.side_effect = [(0, "555"), (1, "000")] mock_input().input.side_effect = [(0, "555"), (1, "000")]
response = self.rec_token.perform( response = self.rec_token.perform(
achallenges.RecoveryToken(chall=None, domain="example5.com")) achallenges.RecoveryToken(
challb=challenges.RecoveryToken(), domain="example5.com"))
self.assertEqual( self.assertEqual(
response, challenges.RecoveryTokenResponse(token="555")) response, challenges.RecoveryTokenResponse(token="555"))
response = self.rec_token.perform( response = self.rec_token.perform(
achallenges.RecoveryToken(chall=None, domain="example6.com")) achallenges.RecoveryToken(
challb=challenges.RecoveryToken(), domain="example6.com"))
self.assertTrue(response is None) self.assertTrue(response is None)
+41 -22
View File
@@ -16,12 +16,15 @@ import zope.interface.verify
import letsencrypt import letsencrypt
from letsencrypt.client import account
from letsencrypt.client import configuration from letsencrypt.client import configuration
from letsencrypt.client import constants
from letsencrypt.client import client from letsencrypt.client import client
from letsencrypt.client import errors from letsencrypt.client import errors
from letsencrypt.client import interfaces from letsencrypt.client import interfaces
from letsencrypt.client import le_util from letsencrypt.client import le_util
from letsencrypt.client import log from letsencrypt.client import log
from letsencrypt.client.display import util as display_util from letsencrypt.client.display import util as display_util
from letsencrypt.client.display import ops as display_ops from letsencrypt.client.display import ops as display_ops
@@ -59,7 +62,8 @@ def create_parser():
config_help = lambda name: interfaces.IConfig[name].__doc__ config_help = lambda name: interfaces.IConfig[name].__doc__
add("-d", "--domains", metavar="DOMAIN", nargs="+") add("-d", "--domains", metavar="DOMAIN", nargs="+")
add("-s", "--server", default="letsencrypt-demo.org:443", add("-s", "--server",
default="www.letsencrypt-demo.org/acme/new-reg",
help=config_help("server")) help=config_help("server"))
# TODO: we should generate the list of choices from the set of # TODO: we should generate the list of choices from the set of
@@ -70,6 +74,8 @@ def create_parser():
add("-k", "--authkey", type=read_file, add("-k", "--authkey", type=read_file,
help="Path to the authorized key file") help="Path to the authorized key file")
add("-m", "--email", type=str,
help="Email address used for account registration.")
add("-B", "--rsa-key-size", type=int, default=2048, metavar="N", add("-B", "--rsa-key-size", type=int, default=2048, metavar="N",
help=config_help("rsa_key_size")) help=config_help("rsa_key_size"))
@@ -93,7 +99,7 @@ def create_parser():
add("--no-confirm", dest="no_confirm", action="store_true", add("--no-confirm", dest="no_confirm", action="store_true",
help="Turn off confirmation screens, currently used for --revoke") help="Turn off confirmation screens, currently used for --revoke")
add("-e", "--agree-tos", dest="eula", action="store_true", add("-e", "--agree-tos", dest="tos", action="store_true",
help="Skip the end user license agreement screen.") help="Skip the end user license agreement screen.")
add("-t", "--text", dest="use_curses", action="store_false", add("-t", "--text", dest="use_curses", action="store_false",
help="Use the text output instead of the curses UI.") help="Use the text output instead of the curses UI.")
@@ -163,15 +169,37 @@ def main(): # pylint: disable=too-many-branches, too-many-statements
sys.exit() sys.exit()
if args.revoke or args.rev_cert is not None or args.rev_key is not None: if args.revoke or args.rev_cert is not None or args.rev_key is not None:
client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key) # This depends on the renewal config and cannot be completed yet.
zope.component.getUtility(interfaces.IDisplay).notification(
"Revocation is not available with the new Boulder server yet.")
# client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key)
sys.exit() sys.exit()
if args.rollback > 0: if args.rollback > 0:
client.rollback(args.rollback, config) client.rollback(args.rollback, config)
sys.exit() sys.exit()
if not args.eula: le_util.make_or_verify_dir(
display_eula() config.config_dir, constants.CONFIG_DIRS_MODE, os.geteuid())
# Prepare for init of Client
if args.email is None:
acc = client.determine_account(config)
else:
try:
# The way to get the default would be args.email = ""
# First try existing account
acc = account.Account.from_existing_account(config, args.email)
except errors.LetsEncryptClientError:
try:
# Try to make an account based on the email address
acc = account.Account.from_email(config, args.email)
except errors.LetsEncryptClientError:
sys.exit(1)
if acc is None:
sys.exit(0)
all_auths = init_auths(config) all_auths = init_auths(config)
logging.debug('Initialized authenticators: %s', all_auths.keys()) logging.debug('Initialized authenticators: %s', all_auths.keys())
@@ -200,16 +228,10 @@ def main(): # pylint: disable=too-many-branches, too-many-statements
if not doms: if not doms:
sys.exit(0) sys.exit(0)
# Prepare for init of Client acme = client.Client(config, acc, auth, installer)
if args.authkey is None:
authkey = client.init_key(args.rsa_key_size, config.key_dir)
else:
authkey = le_util.Key(args.authkey[0], args.authkey[1])
acme = client.Client(config, authkey, auth, installer)
# Validate the key and csr # Validate the key and csr
client.validate_key_csr(authkey) client.validate_key_csr(acc.key)
# This more closely mimics the capabilities of the CLI # This more closely mimics the capabilities of the CLI
# It should be possible for reconfig only, install-only, no-install # It should be possible for reconfig only, install-only, no-install
@@ -217,21 +239,18 @@ def main(): # pylint: disable=too-many-branches, too-many-statements
# but this code should be safe on all environments. # but this code should be safe on all environments.
cert_file = None cert_file = None
if auth is not None: if auth is not None:
if acc.regr is None:
try:
acme.register()
except errors.LetsEncryptClientError:
sys.exit(0)
cert_file, chain_file = acme.obtain_certificate(doms) cert_file, chain_file = acme.obtain_certificate(doms)
if installer is not None and cert_file is not None: if installer is not None and cert_file is not None:
acme.deploy_certificate(doms, authkey, cert_file, chain_file) acme.deploy_certificate(doms, acc.key, cert_file, chain_file)
if installer is not None: if installer is not None:
acme.enhance_config(doms, args.redirect) acme.enhance_config(doms, args.redirect)
def display_eula():
"""Displays the end user agreement."""
eula = pkg_resources.resource_string("letsencrypt", "EULA")
if not zope.component.getUtility(interfaces.IDisplay).yesno(
eula, "Agree", "Cancel"):
sys.exit(0)
def read_file(filename): def read_file(filename):
"""Returns the given file's contents with universal new line support. """Returns the given file's contents with universal new line support.
+1
View File
@@ -30,6 +30,7 @@ changes = read_file(os.path.join(here, 'CHANGES.rst'))
install_requires = [ install_requires = [
'argparse', 'argparse',
'ConfArgParse', 'ConfArgParse',
'configobj',
'jsonschema', 'jsonschema',
'mock', 'mock',
'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304)
+1 -1
View File
@@ -19,7 +19,7 @@ setenv =
basepython = python2.7 basepython = python2.7
commands = commands =
pip install -e .[testing] pip install -e .[testing]
python setup.py nosetests --with-coverage --cover-min-percentage=87 python setup.py nosetests --with-coverage --cover-min-percentage=89
[testenv:lint] [testenv:lint]
# recent versions of pylint do not support Python 2.6 (#97, #187) # recent versions of pylint do not support Python 2.6 (#97, #187)