1
0
mirror of https://github.com/certbot/certbot.git synced 2026-01-21 19:01:07 +03:00

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

View File

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

View File

@@ -1,22 +1,12 @@
"""ACME protocol implementation.
.. warning:: This module is an implementation of the draft `ACME
protocol version 00`_, and not the latest (as of time of writing),
"RESTified" `ACME protocol version 01`_. It should work with the
server from the `Node.js implementation`_, but will not work with
Boulder_.
This module is an implementation of the `ACME protocol`_. Latest
supported version: `v02`_.
.. _`ACME protocol`: https://github.com/letsencrypt/acme-spec
.. _`ACME protocol version 00`:
https://github.com/letsencrypt/acme-spec/blob/v00/draft-barnes-acme.md
.. _`v02`:
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
"""

View File

@@ -216,7 +216,7 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable):
value = getattr(self, slot)
if field.omit(value):
logging.debug('Ommiting empty field "%s" (%s)', slot, value)
logging.debug('Omitting empty field "%s" (%s)', slot, value)
else:
try:
jobj[field.json_name] = field.encode(value)
@@ -372,17 +372,15 @@ class TypedJSONObjectWithFields(JSONObjectWithFields):
raise errors.DeserializationError("missing type field")
try:
type_cls = cls.TYPES[typ]
return cls.TYPES[typ]
except KeyError:
raise errors.UnrecognizedTypeError(typ, jobj)
return type_cls
def to_partial_json(self):
"""Get JSON serializable 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`.
:rtype: dict

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
from letsencrypt.acme import challenges

View File

@@ -1,4 +1,4 @@
"""ACME protocol v02 messages."""
"""ACME protocol messages."""
from letsencrypt.acme import challenges
from letsencrypt.acme import fields
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
"""
ERROR_TYPE_NAMESPACE = 'urn:acme:error:'
ERROR_TYPE_DESCRIPTIONS = {
'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)',
}
# TODO: Boulder omits 'type' and 'instance', spec requires
# TODO: Boulder omits 'type' and 'instance', spec requires, boulder#128
typ = jose.Field('type', omitempty=True)
title = jose.Field('title', omitempty=True)
detail = jose.Field('detail')
@@ -73,6 +72,9 @@ class _Constant(jose.JSONDeSerializable):
def __eq__(self, other):
return isinstance(other, type(self)) and other.name == self.name
def __ne__(self, other):
return not self.__eq__(other)
class Status(_Constant):
"""ACME "status" field."""
@@ -130,10 +132,9 @@ class Registration(ResourceBody):
"""Registration Resource Body.
: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
# JWS.signature.combined.jwk
key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json)
@@ -163,20 +164,17 @@ class ChallengeBody(ResourceBody):
.. todo::
Confusingly, this has a similar name to `.challenges.Challenge`,
as well as `.achallenges.AnnotatedChallenge` or
`.achallenges.Indexed`... Once `messages2` and `network2` is
integrated with the rest of the client, this class functionality
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``.
as well as `.achallenges.AnnotatedChallenge`. Please use names
such as ``challb`` to distinguish instances of this class from
``achall``.
: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 datetime.datetime validated:
"""
__slots__ = ('chall',)
uri = jose.Field('uri')
status = jose.Field('status', decoder=Status.from_json)
@@ -193,6 +191,9 @@ class ChallengeBody(ResourceBody):
jobj_fields['chall'] = challenges.Challenge.from_json(jobj)
return jobj_fields
def __getattr__(self, name):
return getattr(self.chall, name)
class AuthorizationResource(Resource):
"""Authorization Resource.
@@ -208,7 +209,7 @@ class Authorization(ResourceBody):
"""Authorization Resource Body.
: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`
of `int`, as opposed to `list` of `list` from the spec).
:ivar letsencrypt.acme.jose.jwk.JWK key: Public key.
@@ -217,7 +218,6 @@ class Authorization(ResourceBody):
:ivar datetime.datetime expires:
"""
identifier = jose.Field('identifier', decoder=Identifier.from_json)
challenges = jose.Field('challenges', omitempty=True)
combinations = jose.Field('combinations', omitempty=True)

View File

@@ -79,6 +79,13 @@ class ConstantTest(unittest.TestCase):
self.assertEqual('MockConstant(a)', repr(self.const_a))
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):
"""Tests for letsencrypt.acme.messages2.Registration."""

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

View File

@@ -1,19 +1,20 @@
"""Client annotated ACME challenges.
Please use names such as ``achall`` and ``ichall`` (respectively ``achalls``
and ``ichalls`` for collections) to distiguish from variables "of type"
:class:`letsencrypt.acme.challenges.Challenge` (denoted by ``chall``)::
Please use names such as ``achall`` to distiguish from variables "of type"
:class:`letsencrypt.acme.challenges.Challenge` (denoted by ``chall``)
and :class:`.ChallengeBody` (denoted by ``challb``)::
from letsencrypt.acme import challenges
from letsencrypt.acme import messages2
from letsencrypt.client import achallenges
chall = challenges.DNS(token='foo')
achall = achallenges.DNS(chall=chall, domain='example.com')
ichall = achallenges.Indexed(achall=achall, index=0)
challb = messages2.ChallengeBody(chall=chall)
achall = achallenges.DNS(chall=challb, domain='example.com')
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
@@ -28,19 +29,22 @@ from letsencrypt.client import crypto_util
class AnnotatedChallenge(jose_util.ImmutableMap):
"""Client annotated challenge.
Wraps around :class:`~letsencrypt.acme.challenges.Challenge` and
annotates with data usfeul for the client.
Wraps around server provided challenge and annotates with data
useful for the client.
:ivar challb: Wrapped `~.ChallengeBody`.
"""
__slots__ = ('challb',)
acme_type = NotImplemented
def __getattr__(self, name):
return getattr(self.chall, name)
return getattr(self.challb, name)
class DVSNI(AnnotatedChallenge):
"""Client annotated "dvsni" ACME challenge."""
__slots__ = ('chall', 'domain', 'key')
__slots__ = ('challb', 'domain', 'key')
acme_type = challenges.DVSNI
def gen_cert_and_response(self, s=None): # pylint: disable=invalid-name
@@ -54,49 +58,35 @@ class DVSNI(AnnotatedChallenge):
"""
response = challenges.DVSNIResponse(s=s)
cert_pem = crypto_util.make_ss_cert(self.key.pem, [
self.nonce_domain, self.domain, response.z_domain(self.chall)])
self.nonce_domain, self.domain, response.z_domain(self.challb)])
return cert_pem, response
class SimpleHTTPS(AnnotatedChallenge):
"""Client annotated "simpleHttps" ACME challenge."""
__slots__ = ('chall', 'domain', 'key')
__slots__ = ('challb', 'domain', 'key')
acme_type = challenges.SimpleHTTPS
class DNS(AnnotatedChallenge):
"""Client annotated "dns" ACME challenge."""
__slots__ = ('chall', 'domain')
__slots__ = ('challb', 'domain')
acme_type = challenges.DNS
class RecoveryContact(AnnotatedChallenge):
"""Client annotated "recoveryContact" ACME challenge."""
__slots__ = ('chall', 'domain')
__slots__ = ('challb', 'domain')
acme_type = challenges.RecoveryContact
class RecoveryToken(AnnotatedChallenge):
"""Client annotated "recoveryToken" ACME challenge."""
__slots__ = ('chall', 'domain')
__slots__ = ('challb', 'domain')
acme_type = challenges.RecoveryToken
class ProofOfPossession(AnnotatedChallenge):
"""Client annotated "proofOfPossession" ACME challenge."""
__slots__ = ('chall', 'domain')
__slots__ = ('challb', 'domain')
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)

View File

@@ -1,19 +1,17 @@
"""ACME AuthHandler."""
import itertools
import logging
import sys
import Crypto.PublicKey.RSA
import time
from letsencrypt.acme import challenges
from letsencrypt.acme import jose
from letsencrypt.acme import messages
from letsencrypt.acme import messages2
from letsencrypt.client import achallenges
from letsencrypt.client import constants
from letsencrypt.client import errors
class AuthHandler(object): # pylint: disable=too-many-instance-attributes
class AuthHandler(object):
"""ACME Authorization Handler for a client.
: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
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 dict authkey: Authorized Keys for each domain.
values are of type :class:`letsencrypt.client.le_util.Key`
:ivar dict responses: keys: domain, values: list of responses
(:class:`letsencrypt.acme.challenges.ChallengeResponse`.
:ivar dict msgs: ACME Challenge messages with domain as a key.
:ivar dict paths: optimal path for authorization. eg. paths[domain]
:ivar dict dv_c: Keys - domain, Values are DV challenges in the form of
:class:`letsencrypt.client.achallenges.Indexed`
:ivar dict cont_c: Keys - domain, Values are Continuity challenges in the
form of :class:`letsencrypt.client.achallenges.Indexed`
:ivar account: Client's Account
:type account: :class:`letsencrypt.client.account.Account`
:ivar dict authzr: ACME Authorization Resource dict where keys are domains
and values are :class:`letsencrypt.acme.messages2.AuthorizationResource`
:ivar list dv_c: DV challenges in the form of
:class:`letsencrypt.client.achallenges.AnnotatedChallenge`
:ivar list cont_c: Continuity challenges in the
form of :class:`letsencrypt.client.achallenges.AnnotatedChallenge`
"""
def __init__(self, dv_auth, cont_auth, network):
def __init__(self, dv_auth, cont_auth, network, account):
self.dv_auth = dv_auth
self.cont_auth = cont_auth
self.network = network
self.domains = []
self.authkey = dict()
self.responses = dict()
self.msgs = dict()
self.paths = dict()
self.account = account
self.authzr = dict()
self.dv_c = dict()
self.cont_c = dict()
# List must be used to keep responses straight.
self.dv_c = []
self.cont_c = []
def add_chall_msg(self, domain, msg, authkey):
"""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):
def get_authorizations(self, domains, best_effort=False):
"""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
"""
progress = True
while self.msgs and progress:
progress = False
self._satisfy_challenges()
for domain in domains:
self.authzr[domain] = self.network.request_domain_challenges(
domain, self.account.new_authzr_uri)
delete_list = []
self._choose_challenges(domains)
for dom in self.domains:
if self._path_satisfied(dom):
self.acme_authorization(dom)
delete_list.append(dom)
# While there are still challenges remaining...
while self.dv_c or self.cont_c:
cont_resp, dv_resp = self._solve_challenges()
logging.info("Waiting for verification...")
# This avoids modifying while iterating over the list
if delete_list:
self._cleanup_state(delete_list)
progress = True
# Send all Responses - this modifies dv_c and cont_c
self._respond(cont_resp, dv_resp, best_effort)
if not progress:
raise errors.LetsEncryptAuthHandlerError(
"Unable to solve challenges for requested names.")
# Just make sure all decisions are complete.
self.verify_authzr_complete()
# Only return valid authorizations
return [authzr for authzr in self.authzr.values()
if authzr.body.status == messages2.STATUS_VALID]
def acme_authorization(self, domain):
"""Handle ACME "authorization" phase.
: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
"""
def _choose_challenges(self, domains):
"""Retrieve necessary challenges to satisfy server."""
logging.info("Performing the following challenges:")
for dom in self.domains:
self.paths[dom] = gen_challenge_path(
self.msgs[dom].challenges,
for dom in domains:
path = gen_challenge_path(
self.authzr[dom].body.challenges,
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, self.paths[dom])
# Flatten challs for authenticator functions and remove index
# 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])
dom_cont_c, dom_dv_c = self._challenge_factory(
dom, path)
self.dv_c.extend(dom_dv_c)
self.cont_c.extend(dom_cont_c)
def _solve_challenges(self):
"""Get Responses for challenges from authenticators."""
cont_resp = []
dv_resp = []
try:
if flat_cont:
cont_resp = self.cont_auth.perform(flat_cont)
if flat_dv:
dv_resp = self.dv_auth.perform(flat_dv)
if self.cont_c:
cont_resp = self.cont_auth.perform(self.cont_c)
if self.dv_c:
dv_resp = self.dv_auth.perform(self.dv_c)
# This will catch both specific types of errors.
except errors.LetsEncryptAuthHandlerError as err:
logging.critical("Failure in setting up challenges:")
logging.critical(str(err))
except errors.AuthorizationError:
logging.critical("Failure in setting up challenges.")
logging.info("Attempting to clean up outstanding challenges...")
for dom in self.domains:
self._cleanup_challenges(dom)
self._cleanup_challenges()
raise
raise errors.LetsEncryptAuthHandlerError(
"Unable to perform challenges")
assert len(cont_resp) == len(self.cont_c)
assert len(dv_resp) == len(self.dv_c)
logging.info("Ready for verification...")
return cont_resp, dv_resp
# Assemble Responses
if cont_resp:
self._assign_responses(cont_resp, self.cont_c)
if dv_resp:
self._assign_responses(dv_resp, self.dv_c)
def _respond(self, cont_resp, dv_resp, best_effort):
"""Send/Receive confirmation of all challenges.
def _assign_responses(self, flat_list, ichall_dict):
"""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
.. note:: This method also cleans up the auth_handler state.
"""
flat_index = 0
for dom in self.domains:
for ichall in ichall_dict[dom]:
self.responses[dom][ichall.index] = flat_list[flat_index]
flat_index += 1
# TODO: chall_update is a dirty hack to get around acme-spec #105
chall_update = dict()
active_achalls = []
active_achalls.extend(
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):
"""Returns whether a path has been completely satisfied."""
# Make sure that there are no 'None' or 'False' entries along path.
return all(self.responses[dom][i] for i in self.paths[dom])
# Check for updated status...
self._poll_challenges(chall_update, best_effort)
# This removes challenges from self.dv_c and self.cont_c
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):
"""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
"""
# Make sure to make a copy...
chall_prefs = []
chall_prefs.extend(self.cont_auth.get_chall_pref(domain))
chall_prefs.extend(self.dv_auth.get_chall_pref(domain))
return chall_prefs
def _cleanup_challenges(self, domain):
"""Cleanup configuration challenges
def _cleanup_challenges(self, achall_list=None):
"""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)
# 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)
logging.info("Cleaning up challenges")
def _cleanup_state(self, delete_list):
"""Cleanup state after an authorization is received.
if achall_list is None:
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:
del self.msgs[domain]
del self.responses[domain]
del self.paths[domain]
del self.authkey[domain]
del self.cont_c[domain]
del self.dv_c[domain]
self.domains.remove(domain)
for authzr in self.authzr.values():
if (authzr.body.status != messages2.STATUS_VALID and
authzr.body.status != messages2.STATUS_INVALID):
raise errors.AuthorizationError("Incomplete authorizations")
def _challenge_factory(self, domain, path):
"""Construct Namedtuple Challenges
@@ -274,54 +302,75 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
cont_chall = []
for index in path:
chall = self.msgs[domain].challenges[index]
challb = self.authzr[domain].body.challenges[index]
chall = challb.chall
if isinstance(chall, challenges.DVSNI):
logging.info(" DVSNI challenge for %s.", domain)
achall = achallenges.DVSNI(
chall=chall, domain=domain, key=self.authkey[domain])
elif isinstance(chall, challenges.SimpleHTTPS):
logging.info(" SimpleHTTPS challenge for %s.", domain)
achall = achallenges.SimpleHTTPS(
chall=chall, domain=domain, key=self.authkey[domain])
elif isinstance(chall, challenges.DNS):
logging.info(" DNS challenge for %s.", domain)
achall = achallenges.DNS(chall=chall, domain=domain)
elif isinstance(chall, challenges.RecoveryToken):
logging.info(" Recovery Token Challenge for %s.", domain)
achall = achallenges.RecoveryToken(chall=chall, domain=domain)
elif isinstance(chall, challenges.RecoveryContact):
logging.info(" Recovery Contact Challenge for %s.", domain)
achall = achallenges.RecoveryContact(chall=chall, domain=domain)
elif isinstance(chall, challenges.ProofOfPossession):
logging.info(" Proof-of-Possession Challenge for %s", domain)
achall = achallenges.ProofOfPossession(
chall=chall, domain=domain)
else:
raise errors.LetsEncryptClientError(
"Received unsupported challenge of type: %s", chall.typ)
ichall = achallenges.Indexed(achall=achall, index=index)
achall = challb_to_achall(challb, self.account.key, domain)
if isinstance(chall, challenges.ContinuityChallenge):
cont_chall.append(ichall)
cont_chall.append(achall)
elif isinstance(chall, challenges.DVChallenge):
dv_chall.append(ichall)
dv_chall.append(achall)
return dv_chall, cont_chall
return cont_chall, dv_chall
def gen_challenge_path(challs, preferences, combinations):
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):
logging.info(" DVSNI challenge for %s.", domain)
return achallenges.DVSNI(
challb=challb, domain=domain, key=key)
elif isinstance(chall, challenges.SimpleHTTPS):
logging.info(" SimpleHTTPS challenge for %s.", domain)
return achallenges.SimpleHTTPS(
challb=challb, domain=domain, key=key)
elif isinstance(chall, challenges.DNS):
logging.info(" DNS challenge for %s.", domain)
return achallenges.DNS(challb=challb, domain=domain)
elif isinstance(chall, challenges.RecoveryToken):
logging.info(" Recovery Token Challenge for %s.", domain)
return achallenges.RecoveryToken(challb=challb, domain=domain)
elif isinstance(chall, challenges.RecoveryContact):
logging.info(" Recovery Contact Challenge for %s.", domain)
return achallenges.RecoveryContact(
challb=challb, domain=domain)
elif isinstance(chall, challenges.ProofOfPossession):
logging.info(" Proof-of-Possession Challenge for %s", domain)
return achallenges.ProofOfPossession(
challb=challb, domain=domain)
else:
raise errors.LetsEncryptClientError(
"Received unsupported challenge of type: %s",
chall.typ)
def gen_challenge_path(challbs, preferences, combinations):
"""Generate a plan to get authority over the identity.
.. todo:: This can be possibly be rewritten to use resolved_combinations.
:param tuple challs: A tuple of challenges
(:class:`letsencrypt.acme.challenges.Challenge`) from
:class:`letsencrypt.acme.messages.Challenge` server message to
be fulfilled by the client in order to prove possession of the
:param tuple challbs: A tuple of challenges
(:class:`letsencrypt.acme.messages2.Challenge`) from
:class:`letsencrypt.acme.messages2.AuthorizationResource` to be
fulfilled by the client in order to prove possession of the
identifier.
: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``.
: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
combinations.
"""
if combinations:
return _find_smart_path(challs, preferences, combinations)
return _find_smart_path(challbs, preferences, combinations)
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.
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
for combo in combinations:
for challenge_index in combo:
combo_total += chall_cost.get(challs[
challenge_index].__class__, max_cost)
combo_total += chall_cost.get(challbs[
challenge_index].chall.__class__, max_cost)
if combo_total < best_combo_cost:
best_combo = combo
@@ -380,12 +429,12 @@ def _find_smart_path(challs, preferences, combinations):
msg = ("Client does not support any combination of challenges that "
"will satisfy the CA.")
logging.fatal(msg)
raise errors.LetsEncryptAuthHandlerError(msg)
raise errors.AuthorizationError(msg)
return best_combo
def _find_dumb_path(challs, preferences):
def _find_dumb_path(challbs, preferences):
"""Find challenge path without server hints.
Should be called if the combinations hint is not included by the
@@ -398,11 +447,11 @@ def _find_dumb_path(challs, preferences):
path = []
satisfied = set()
for pref_c in preferences:
for i, offered_chall in enumerate(challs):
if (isinstance(offered_chall, pref_c) and
is_preferred(offered_chall, satisfied)):
for i, offered_challb in enumerate(challbs):
if (isinstance(offered_challb.chall, pref_c) and
is_preferred(offered_challb, satisfied)):
path.append(i)
satisfied.add(offered_chall)
satisfied.add(offered_challb)
return path
@@ -422,11 +471,12 @@ def mutually_exclusive(obj1, obj2, groups, different=False):
return True
def is_preferred(offered_chall, satisfied,
def is_preferred(offered_challb, satisfied,
exclusive_groups=constants.EXCLUSIVE_CHALLENGES):
"""Return whether or not the challenge is preferred in path."""
for chall in satisfied:
for challb in satisfied:
if not mutually_exclusive(
offered_chall, chall, exclusive_groups, different=True):
offered_challb.chall, challb.chall, exclusive_groups,
different=True):
return False
return True

View File

@@ -1,20 +1,22 @@
"""ACME protocol client class and helper functions."""
import logging
import os
import sys
import pkg_resources
import Crypto.PublicKey.RSA
import M2Crypto
import zope.component
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 continuity_auth
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 import network
from letsencrypt.client import network2
from letsencrypt.client import reverter
from letsencrypt.client import revoker
@@ -27,10 +29,10 @@ class Client(object):
"""ACME protocol client.
: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
:type authkey: :class:`letsencrypt.client.le_util.Key`
:ivar account: Account object used for registration
:type account: :class:`letsencrypt.client.account.Account`
:ivar auth_handler: Object that supports the IAuthenticator interface.
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.
:param dv_auth: IAuthenticator that can solve the
@@ -55,22 +57,53 @@ class Client(object):
:type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator`
"""
self.network = network.Network(config.server)
self.authkey = authkey
self.account = account_
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
if dv_auth is not None:
cont_auth = continuity_auth.ContinuityAuthenticator(config)
self.auth_handler = auth_handler.AuthHandler(
dv_auth, cont_auth, self.network)
dv_auth, cont_auth, self.network, self.account)
else:
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):
"""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
this CSR can be different than self.authkey
@@ -81,68 +114,43 @@ class Client(object):
"""
if self.auth_handler is None:
logging.warning("Unable to obtain a certificate, because client "
"does not have a valid auth handler.")
# Request Challenges
for name in domains:
self.auth_handler.add_chall_msg(
name, self.acme_challenge(name), self.authkey)
msg = ("Unable to obtain certificate because authenticator is "
"not set.")
logging.warning(msg)
raise errors.LetsEncryptClientError(msg)
if self.account.regr is None:
raise errors.LetsEncryptClientError(
"Please register with the ACME server first.")
# Perform Challenges/Get Authorizations
self.auth_handler.get_authorizations()
authzr = self.auth_handler.get_authorizations(domains)
# Create CSR from names
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
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
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(
cert_file, self.authkey.file, self.config)
cert_file, self.account.key.file, self.config)
return cert_file, chain_file
def acme_challenge(self, domain):
"""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):
def save_certificate(self, certr, cert_path, chain_path):
# pylint: disable=no-self-use
"""Saves the certificate received from the ACME server.
:param certificate_msg: ACME "certificate" message from server.
:type certificate_msg: :class:`letsencrypt.acme.messages.Certificate`
:param certr: ACME "certificate" resource.
:type certr: :class:`letsencrypt.acme.messages.Certificate`
:param str cert_path: Path to attempt to save the cert 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
"""
# try finally close
cert_chain_abspath = None
cert_fd, cert_file = le_util.unique_file(cert_path, 0o644)
cert_fd.write(certificate_msg.certificate.as_pem())
cert_fd.close()
logging.info(
"Server issued certificate; certificate written to %s", cert_file)
cert_file, act_cert_path = le_util.unique_file(cert_path, 0o644)
# TODO: Except
cert_pem = certr.body.as_pem()
try:
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:
chain_fd, chain_fn = le_util.unique_file(chain_path, 0o644)
for cert in certificate_msg.chain:
chain_fd.write(cert.to_pem())
chain_fd.close()
if certr.cert_chain_uri:
# TODO: Except
chain_cert = self.network.fetch_chain(certr.cert_chain_uri)
if chain_cert:
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
cert_chain_abspath = os.path.abspath(chain_fn)
# This expects a valid chain file
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):
"""Install certificate
@@ -295,60 +314,6 @@ def validate_key_csr(privkey, csr=None):
"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):
"""Return a pretty-printed list of authenticators.
@@ -417,6 +382,28 @@ def determine_authenticator(all_auths, config):
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):
"""Returns a valid installer if one exists.

View File

@@ -28,6 +28,8 @@ class NamespaceConfig(object):
zope.interface.implements(interfaces.IConfig)
def __init__(self, namespace):
assert not namespace.server.startswith('https://')
assert not namespace.server.startswith('http://')
self.namespace = namespace
def __getattr__(self, name):
@@ -42,11 +44,32 @@ class NamespaceConfig(object):
def in_progress_dir(self): # pylint: disable=missing-docstring
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
def cert_key_backup(self): # pylint: disable=missing-docstring
return os.path.join(
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
@property

View File

@@ -50,6 +50,9 @@ DVSNI_CHALLENGE_PORT = 443
"""Port to perform DVSNI challenge."""
CONFIG_DIRS_MODE = 0o755
"""Directory mode for ``.IConfig.config_dir`` et al."""
TEMP_CHECKPOINT_DIR = "temp_checkpoint"
"""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
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"
"""Directory where all recovery tokens are saved (relative to
IConfig.work_dir)."""

View File

@@ -4,6 +4,8 @@
is capable of handling the signatures.
"""
import logging
import os
import time
import Crypto.Hash.SHA256
@@ -12,7 +14,75 @@ import Crypto.Signature.PKCS1_v1_5
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):
"""Generate a CSR.

View File

@@ -43,6 +43,28 @@ def choose_authenticator(auths, errs):
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):
"""Display screen to select domains to validate.

View File

@@ -133,19 +133,21 @@ class NcursesDisplay(object):
message, self.height, self.width,
yes_label=yes_label, no_label=no_label)
def checklist(self, message, tags):
def checklist(self, message, tags, default_status=True):
"""Displays a checklist.
:param message: Message to display before choices
:param list tags: where each is of type :class:`str`
len(tags) > 0
:param list tags: where each is of type :class:`str` 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
`code` - int display exit code
`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(
message, width=self.width, height=self.height, choices=choices)
@@ -257,11 +259,13 @@ class FileDisplay(object):
ans.startswith(no_label[0].upper())):
return False
def checklist(self, message, tags):
def checklist(self, message, tags, default_status=True):
# pylint: disable=unused-argument
"""Display a checklist.
:param str message: Message to display to user
:param list tags: `str` tags to select, len(tags) > 0
:param bool default_status: Not used for FileDisplay
:returns: tuple of (`code`, `tags`) where
`code` - str display exit code

View File

@@ -18,15 +18,15 @@ class LetsEncryptReverterError(LetsEncryptClientError):
# Auth Handler Errors
class LetsEncryptAuthHandlerError(LetsEncryptClientError):
"""Let's Encrypt Auth Handler error."""
class AuthorizationError(LetsEncryptClientError):
"""Authorization error."""
class LetsEncryptContAuthError(LetsEncryptAuthHandlerError):
"""Let's Encrypt Client Authenticator error."""
class LetsEncryptContAuthError(AuthorizationError):
"""Let's Encrypt Continuity Authenticator error."""
class LetsEncryptDvAuthError(LetsEncryptAuthHandlerError):
class LetsEncryptDvAuthError(AuthorizationError):
"""Let's Encrypt DV Authenticator error."""

View File

@@ -95,6 +95,8 @@ class IConfig(zope.interface.Interface):
"be trusted in order to avoid further modifications to the client.")
authenticator = zope.interface.Attribute(
"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.")
config_dir = zope.interface.Attribute("Configuration directory.")
@@ -107,6 +109,10 @@ class IConfig(zope.interface.Interface):
cert_key_backup = zope.interface.Attribute(
"Directory where all certificates and keys are stored. "
"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(
"Directory where all recovery tokens are saved.")
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.
:param str message: message to display to the user
:param tags: tags
:type tags: :class:`list` of :class:`str`
:param list tags: where each is of type :class:`str` len(tags) > 0
:param bool default_status: If True, items are in a selected state by
default.
"""

View File

@@ -2,7 +2,6 @@
import datetime
import heapq
import httplib
import itertools
import logging
import time
@@ -74,7 +73,6 @@ class Network(object):
"""
response_ct = response.headers.get('Content-Type')
try:
# TODO: response.json() is called twice, once here, and
# once in _get and _post clients
@@ -88,8 +86,9 @@ class Network(object):
logging.debug(
'Ignoring wrong Content-Type (%r) for JSON Error',
response_ct)
try:
logging.error("Error: %s", jobj)
logging.error("Response from server: %s", response.content)
raise messages2.Error.from_json(jobj)
except jose.DeserializationError as error:
# Couldn't deserialize JSON object
@@ -167,7 +166,7 @@ class Network(object):
'contact'].default):
"""Register.
:param contact: Contact list, as accpeted by `.RegistrationResource`
:param contact: Contact list, as accepted by `.Registration`
:type contact: `tuple`
:returns: Registration Resource.
@@ -187,6 +186,24 @@ class Network(object):
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):
"""Update registration.
@@ -213,6 +230,21 @@ class Network(object):
raise errors.UnexpectedUpdate(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,
uri=None, new_cert_uri=None):
if new_cert_uri is None:
@@ -230,25 +262,24 @@ class Network(object):
raise errors.UnexpectedUpdate(authzr)
return authzr
def request_challenges(self, identifier, regr):
def request_challenges(self, identifier, new_authzr_uri):
"""Request challenges.
:param identifier: Identifier to be challenged.
:type identifier: `.messages2.Identifier`
:param regr: Registration Resource.
:type regr: `.RegistrationResource`
:param str new_authzr_uri: new-authorization URI
:returns: Authorization Resource.
:rtype: `.AuthorizationResource`
"""
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
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.
This is simply a convenience function that wraps around
@@ -256,10 +287,14 @@ class Network(object):
generic identifiers.
: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(
typ=messages2.IDENTIFIER_FQDN, value=domain), regr)
typ=messages2.IDENTIFIER_FQDN, value=domain), new_authz_uri)
def answer_challenge(self, challb, response):
"""Answer challenge.
@@ -280,7 +315,11 @@ class Network(object):
try:
authzr_uri = response.links['up']['url']
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(
authzr_uri=authzr_uri,
body=messages2.ChallengeBody.from_json(response.json()))
@@ -289,17 +328,6 @@ class Network(object):
raise errors.UnexpectedUpdate(challr.uri)
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
def retry_after(cls, response, default):
"""Compute next `poll` time based on response ``Retry-After`` header.
@@ -342,7 +370,6 @@ class Network(object):
updated_authzr = self._authzr_from_response(
response, authzr.body.identifier, authzr.uri, authzr.new_cert_uri)
# TODO: check and raise UnexpectedUpdate
return updated_authzr, response
def request_issuance(self, csr, authzrs):
@@ -358,6 +385,7 @@ class Network(object):
"""
assert authzrs, "Authorizations list is empty"
logging.debug("Requesting issuance...")
# TODO: assert len(authzrs) == number of SANs
req = messages2.CertificateRequest(
@@ -408,7 +436,7 @@ class Network(object):
: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
waiting = [(datetime.datetime.now(), authzr) for authzr in authzrs]
# mapping between original Authorization Resource and the most
@@ -438,6 +466,15 @@ class Network(object):
return self.request_issuance(csr, updated_authzrs), updated_authzrs
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
response = self._get(uri, headers={'Accept': content_type},
content_type=content_type)
@@ -489,14 +526,22 @@ class Network(object):
"""
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):
"""Revoke certificate.
:param certr: Certificate Resource
:type certr: `.CertificateResource`
:param when: When should the revocation take place? Takes
the same values as `.messages2.Revocation.revoke`.
:raises letsencrypt.client.errors.NetworkError: If revocation is
unsuccessful.
"""
rev = messages2.Revocation(revoke=when, authorizations=tuple(
authzr.uri for authzr in certr.authzrs))

View File

@@ -934,9 +934,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"""
uid = os.geteuid()
le_util.make_or_verify_dir(self.config.config_dir, 0o755, uid)
le_util.make_or_verify_dir(self.config.work_dir, 0o755, uid)
le_util.make_or_verify_dir(self.config.backup_dir, 0o755, uid)
le_util.make_or_verify_dir(
self.config.config_dir, constants.CONFIG_DIRS_MODE, 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):
"""Return version of Apache Server.
@@ -1006,15 +1009,17 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
apache_dvsni.add_chall(achall, i)
sni_response = apache_dvsni.perform()
# Must restart in order to activate the challenges.
# Handled here because we may be able to load up other challenge types
self.restart()
if sni_response:
# Must restart in order to activate the challenges.
# Handled here because we may be able to load up other challenge
# types
self.restart()
# Go through all of the challenges and assign them to the proper place
# in the responses return value. All responses must be in the same order
# as the original challenges.
for i, resp in enumerate(sni_response):
responses[apache_dvsni.indices[i]] = resp
# Go through all of the challenges and assign them to the proper
# place in the responses return value. All responses must be in the
# same order as the original challenges.
for i, resp in enumerate(sni_response):
responses[apache_dvsni.indices[i]] = resp
return responses

View File

@@ -18,6 +18,8 @@ from letsencrypt.client.plugins.apache import parser
from letsencrypt.client.plugins.apache.tests import util
from letsencrypt.client.tests import acme_util
class TwoVhost80Test(util.ApacheTest):
"""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
auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem)
achall1 = achallenges.DVSNI(
chall=challenges.DVSNI(
r="jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q",
nonce="37bc5eb75d3e00a19b4f6355845e5a18"),
challb=acme_util.chall_to_challb(
challenges.DVSNI(
r="jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q",
nonce="37bc5eb75d3e00a19b4f6355845e5a18"),
"pending"),
domain="encryption-example.demo", key=auth_key)
achall2 = achallenges.DVSNI(
chall=challenges.DVSNI(
r="uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU",
nonce="59ed014cac95f77057b1d7a1b2c596ba"),
challb=acme_util.chall_to_challb(
challenges.DVSNI(
r="uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU",
nonce="59ed014cac95f77057b1d7a1b2c596ba"),
"pending"),
domain="letsencrypt.demo", key=auth_key)
dvsni_ret_val = [

View File

@@ -14,6 +14,8 @@ from letsencrypt.client.plugins.apache.obj import Addr
from letsencrypt.client.plugins.apache.tests import util
from letsencrypt.client.tests import acme_util
class DvsniPerformTest(util.ApacheTest):
"""Test the ApacheDVSNI challenge."""
@@ -39,18 +41,22 @@ class DvsniPerformTest(util.ApacheTest):
auth_key = le_util.Key(rsa256_file, rsa256_pem)
self.achalls = [
achallenges.DVSNI(
chall=challenges.DVSNI(
r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9\xf1"
"\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4",
nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18",
), domain="encryption-example.demo", key=auth_key),
challb=acme_util.chall_to_challb(
challenges.DVSNI(
r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9"
"\xf1\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4",
nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18",
), "pending"),
domain="encryption-example.demo", key=auth_key),
achallenges.DVSNI(
chall=challenges.DVSNI(
r="\xba\xa9\xda?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y\x80"
"\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945",
nonce="Y\xed\x01L\xac\x95\xf7pW\xb1\xd7"
"\xa1\xb2\xc5\x96\xba",
), domain="letsencrypt.demo", key=auth_key),
challb=acme_util.chall_to_challb(
challenges.DVSNI(
r="\xba\xa9\xda?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y\x80"
"\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945",
nonce="Y\xed\x01L\xac\x95\xf7pW\xb1\xd7\xa1\xb2\xc5"
"\x96\xba",
), "pending"),
domain="letsencrypt.demo", key=auth_key),
]
def tearDown(self):

View File

@@ -349,9 +349,12 @@ class NginxConfigurator(object):
"""
uid = os.geteuid()
le_util.make_or_verify_dir(self.config.work_dir, 0o755, uid)
le_util.make_or_verify_dir(self.config.backup_dir, 0o755, uid)
le_util.make_or_verify_dir(self.config.config_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)
le_util.make_or_verify_dir(
self.config.config_dir, constants.CONFIG_DIRS_MODE, uid)
def get_version(self):
"""Return version of Nginx Server.

View File

@@ -5,6 +5,7 @@ import unittest
import mock
from letsencrypt.acme import challenges
from letsencrypt.acme import messages2
from letsencrypt.client import achallenges
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
auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem)
achall1 = achallenges.DVSNI(
chall=challenges.DVSNI(
r="foo",
nonce="bar"),
domain="localhost", key=auth_key)
challb=messages2.ChallengeBody(
chall=challenges.DVSNI(
r="foo",
nonce="bar"),
uri="https://ca.org/chall0_uri",
status=messages2.Status("pending"),
), domain="localhost", key=auth_key)
achall2 = achallenges.DVSNI(
chall=challenges.DVSNI(
r="abc",
nonce="def"),
domain="example.com", key=auth_key)
challb=messages2.ChallengeBody(
chall=challenges.DVSNI(
r="abc",
nonce="def"),
uri="https://ca.org/chall1_uri",
status=messages2.Status("pending"),
), domain="example.com", key=auth_key)
dvsni_ret_val = [
challenges.DVSNIResponse(s="irrelevant"),

View File

@@ -6,6 +6,7 @@ import shutil
import mock
from letsencrypt.acme import challenges
from letsencrypt.acme import messages2
from letsencrypt.client import achallenges
from letsencrypt.client import le_util
@@ -35,16 +36,24 @@ class DvsniPerformTest(util.NginxTest):
self.achalls = [
achallenges.DVSNI(
chall=challenges.DVSNI(
r="foo",
nonce="bar",
challb=messages2.ChallengeBody(
chall=challenges.DVSNI(
r="foo",
nonce="bar",
),
uri="https://letsencrypt-ca.org/chall0_uri",
status=messages2.Status("pending"),
), domain="www.example.com", key=auth_key),
achallenges.DVSNI(
chall=challenges.DVSNI(
r="\xba\xa9\xda?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y\x80"
"\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945",
nonce="Y\xed\x01L\xac\x95\xf7pW\xb1\xd7"
"\xa1\xb2\xc5\x96\xba",
challb=messages2.ChallengeBody(
chall=challenges.DVSNI(
r="\xba\xa9\xda?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y\x80"
"\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945",
nonce="Y\xed\x01L\xac\x95\xf7pW\xb1\xd7"
"\xa1\xb2\xc5\x96\xba",
),
uri="https://letsencrypt-ca.org/chall1_uri",
status=messages2.Status("pending"),
), domain="blah", key=auth_key),
]

View File

@@ -15,6 +15,8 @@ from letsencrypt.acme import challenges
from letsencrypt.client import achallenges
from letsencrypt.client import le_util
from letsencrypt.client.tests import acme_util
KEY = le_util.Key("foo", pkg_resources.resource_string(
"letsencrypt.acme.jose", os.path.join("testdata", "rsa512_key.pem")))
@@ -71,7 +73,7 @@ class SNICallbackTest(unittest.TestCase):
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None)
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]
self.authenticator.private_key = PRIVATE_KEY
self.authenticator.tasks = {"abcdef.acme.invalid": self.cert}
@@ -298,10 +300,12 @@ class PerformTest(unittest.TestCase):
self.authenticator = StandaloneAuthenticator(None)
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)
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)
bad_achall = ("This", "Represents", "A Non-DVSNI", "Challenge")
self.achalls = [self.achall1, self.achall2, bad_achall]
@@ -346,12 +350,12 @@ class PerformTest(unittest.TestCase):
def test_perform_with_pending_tasks(self):
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(
ValueError, self.authenticator.perform, [extra_achall])
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.
self.assertRaises(ValueError, self.authenticator.perform, [])
# This is wrong because it must be a list, not a bare challenge.
@@ -458,7 +462,8 @@ class DoChildProcessTest(unittest.TestCase):
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None)
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]
self.authenticator.private_key = PRIVATE_KEY
self.authenticator.tasks = {"abcdef.acme.invalid": self.cert}
@@ -546,7 +551,8 @@ class CleanupTest(unittest.TestCase):
StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(None)
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")
self.authenticator.tasks = {self.achall.nonce_domain: "stuff"}
self.authenticator.child_pid = 12345
@@ -566,7 +572,8 @@ class CleanupTest(unittest.TestCase):
def test_bad_cleanup(self):
self.assertRaises(
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")])

View File

@@ -6,9 +6,11 @@ import time
import zope.component
from letsencrypt.client import constants
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
@@ -164,7 +166,8 @@ class Reverter(object):
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(
os.path.join(cp_dir, "FILEPATHS"))
@@ -305,7 +308,8 @@ class Reverter(object):
else:
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)
new_fd = None

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

View File

@@ -5,24 +5,25 @@ import re
import unittest
import M2Crypto
import mock
from letsencrypt.acme import challenges
from letsencrypt.client import le_util
from letsencrypt.client.tests import acme_util
class DVSNITest(unittest.TestCase):
"""Tests for letsencrypt.client.achallenges.DVSNI."""
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()
key = le_util.Key("path", pkg_resources.resource_string(
"letsencrypt.acme.jose",
os.path.join("testdata", "rsa512_key.pem")))
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):
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__":
unittest.main()

View File

@@ -1,4 +1,6 @@
"""Class helps construct valid ACME messages for testing."""
import datetime
import itertools
import os
import pkg_resources
@@ -6,6 +8,7 @@ import Crypto.PublicKey.RSA
from letsencrypt.acme import challenges
from letsencrypt.acme import jose
from letsencrypt.acme import messages2
KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
@@ -51,13 +54,13 @@ CONT_CHALLENGES = [chall for chall in CHALLENGES
if isinstance(chall, challenges.ContinuityChallenge)]
def gen_combos(challs):
"""Generate natural combinations for challs."""
def gen_combos(challbs):
"""Generate natural combinations for challbs."""
dv_chall = []
cont_chall = []
for i, chall in enumerate(challs): # pylint: disable=redefined-outer-name
if isinstance(chall, challenges.DVChallenge):
for i, challb in enumerate(challbs): # pylint: disable=redefined-outer-name
if isinstance(challb.chall, challenges.DVChallenge):
dv_chall.append(i)
else:
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)
return tuple((i, j) if i < j else (j, i)
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)
)

View File

@@ -1,14 +1,16 @@
"""Tests for letsencrypt.client.auth_handler."""
import functools
import logging
import unittest
import mock
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 le_util
from letsencrypt.client import network2
from letsencrypt.client.tests import acme_util
@@ -23,8 +25,52 @@ TRANSLATE = {
}
class SatisfyChallengesTest(unittest.TestCase):
"""verify_identities test."""
class ChallengeFactoryTest(unittest.TestCase):
# 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):
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_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.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)
def tearDown(self):
logging.disable(logging.NOTSET)
def test_name1_dvsni1(self):
dom = "0"
msg = messages.Challenge(
session_id=dom, nonce="nonce0", combinations=[],
challenges=[acme_util.DVSNI])
self.handler.add_chall_msg(dom, msg, "dummy_key")
@mock.patch("letsencrypt.client.auth_handler.AuthHandler._poll_challenges")
def test_name1_dvsni1(self, mock_poll):
self.mock_net.request_domain_challenges.side_effect = functools.partial(
gen_dom_authzr, challs=acme_util.DV_CHALLENGES)
self.handler._satisfy_challenges() # pylint: disable=protected-access
mock_poll.side_effect = self._validate_all
self.assertEqual(len(self.handler.responses), 1)
self.assertEqual(len(self.handler.responses[dom]), 1)
authzr = self.handler.get_authorizations(["0"])
self.assertEqual("DVSNI0", self.handler.responses[dom][0])
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)
self.assertEqual(self.mock_net.answer_challenge.call_count, 1)
def test_name1_rectok1(self):
dom = "0"
msg = messages.Challenge(
session_id=dom, nonce="nonce0", combinations=[],
challenges=[acme_util.RECOVERY_TOKEN])
self.handler.add_chall_msg(dom, msg, "dummy_key")
self.handler._satisfy_challenges() # pylint: disable=protected-access
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(mock_poll.call_count, 1)
chall_update = mock_poll.call_args[0][0]
self.assertEqual(chall_update.keys(), ["0"])
self.assertEqual(len(chall_update.values()), 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.handler.responses[dom],
self._get_exp_response(dom, path, acme_util.DV_CHALLENGES))
self.mock_dv_auth.cleanup.call_args[0][0][0].typ, "dvsni")
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.SimpleHTTPS))
self.assertEqual(len(authzr), 1)
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
def test_name1_all(self, mock_chall_path):
dom = "0"
@mock.patch("letsencrypt.client.auth_handler.AuthHandler._poll_challenges")
def test_name3_dvsni3_rectok_3(self, mock_poll):
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)
self.handler.add_chall_msg(
dom,
messages.Challenge(
session_id=dom, nonce="nonce0", challenges=acme_util.CHALLENGES,
combinations=combos),
"dummy_key")
mock_poll.side_effect = self._validate_all
path = gen_path([acme_util.SIMPLE_HTTPS, acme_util.RECOVERY_TOKEN],
acme_util.CHALLENGES)
mock_chall_path.return_value = path
authzr = self.handler.get_authorizations(["0", "1", "2"])
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)
self.assertEqual(
len(self.handler.responses[dom]), len(acme_util.CHALLENGES))
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]), 1)
# Check poll call
self.assertEqual(mock_poll.call_count, 1)
chall_update = mock_poll.call_args[0][0]
self.assertEqual(len(chall_update.keys()), 3)
self.assertTrue("0" in chall_update.keys())
self.assertEqual(len(chall_update["0"]), 2)
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.handler.responses[dom],
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))
self.assertEqual(self.mock_dv_auth.cleanup.call_count, 1)
self.assertEqual(self.mock_cont_auth.cleanup.call_count, 1)
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
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")
self.assertEqual(len(authzr), 3)
path = gen_path([acme_util.DVSNI, acme_util.RECOVERY_CONTACT],
acme_util.CHALLENGES)
mock_chall_path.return_value = path
self.handler._satisfy_challenges() # pylint: disable=protected-access
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))
def test_perform_failure(self):
self.mock_net.request_domain_challenges.side_effect = functools.partial(
gen_dom_authzr, challs=acme_util.CHALLENGES)
self.mock_dv_auth.perform.side_effect = errors.AuthorizationError
self.assertRaises(errors.AuthorizationError,
self.handler.get_authorizations, ["0"])
def _get_exp_response(self, domain, path, challs):
# pylint: disable=no-self-use
@@ -339,179 +164,132 @@ class SatisfyChallengesTest(unittest.TestCase):
return exp_resp
# pylint: disable=protected-access
class GetAuthorizationsTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.auth_handler import AuthHandler
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(
def _validate_all(self, unused_1, unused_2):
for dom in self.handler.authzr.keys():
azr = self.handler.authzr[dom]
self.handler.authzr[dom] = acme_util.gen_authzr(
messages2.STATUS_VALID,
dom,
messages.Challenge(session_id=dom, nonce="nonce",
combinations=[], challenges=challs),
"dummy_key")
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)
[challb.chall for challb in azr.body.challenges],
[messages2.STATUS_VALID]*len(azr.body.challenges),
azr.body.combinations)
# pylint: disable=protected-access
class PathSatisfiedTest(unittest.TestCase):
class PollChallengesTest(unittest.TestCase):
# pylint: disable=protected-access
"""Test poll challenges."""
def setUp(self):
from letsencrypt.client.auth_handler import challb_to_achall
from letsencrypt.client.auth_handler import AuthHandler
self.handler = AuthHandler(None, None, None)
def test_satisfied_true(self):
dom = ["0", "1", "2", "3", "4"]
self.handler.paths[dom[0]] = [1, 2]
self.handler.responses[dom[0]] = [None, "sat", "sat2", None]
# Account and network are mocked...
self.mock_net = mock.MagicMock()
self.handler = AuthHandler(
None, None, self.mock_net, mock.Mock(key="mock_key"))
self.handler.paths[dom[1]] = [0]
self.handler.responses[dom[1]] = ["sat", None, None, False]
self.doms = ["0", "1", "2"]
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.responses[dom[2]] = ["sat"]
self.handler.authzr[self.doms[1]] = acme_util.gen_authzr(
messages2.STATUS_PENDING, self.doms[1],
acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False)
self.handler.paths[dom[3]] = []
self.handler.responses[dom[3]] = []
self.handler.authzr[self.doms[2]] = acme_util.gen_authzr(
messages2.STATUS_PENDING, self.doms[2],
acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False)
self.handler.paths[dom[4]] = []
self.handler.responses[dom[4]] = ["respond... sure"]
self.chall_update = {}
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):
self.assertTrue(self.handler._path_satisfied(dom[i]))
@mock.patch("letsencrypt.client.auth_handler.time")
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):
dom = ["0", "1", "2", "3"]
self.handler.paths[dom[0]] = [1, 2]
self.handler.responses[dom[0]] = ["sat1", None, "sat2", None]
for authzr in self.handler.authzr.values():
self.assertEqual(authzr.body.status, messages2.STATUS_VALID)
self.handler.paths[dom[1]] = [0]
self.handler.responses[dom[1]] = [None, None, None, None]
@mock.patch("letsencrypt.client.auth_handler.time")
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]
self.handler.responses[dom[2]] = [None]
for authzr in self.handler.authzr.values():
self.assertEqual(authzr.body.status, messages2.STATUS_PENDING)
self.handler.paths[dom[3]] = [0]
self.handler.responses[dom[3]] = [False]
@mock.patch("letsencrypt.client.auth_handler.time")
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):
self.assertFalse(self.handler._path_satisfied(dom[i]))
@mock.patch("letsencrypt.client.auth_handler.time")
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):
"""Tests for letsencrypt.client.auth_handler.gen_challenge_path.
@@ -526,42 +304,42 @@ class GenChallengePathTest(unittest.TestCase):
logging.disable(logging.NOTSET)
@classmethod
def _call(cls, challs, preferences, combinations):
def _call(cls, challbs, preferences, combinations):
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):
"""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]
combos = ((0,), (1,))
# Smart then trivial dumb path test
self.assertEqual(self._call(challs, prefs, combos), (0,))
self.assertTrue(self._call(challs, prefs, None))
self.assertEqual(self._call(challbs, prefs, combos), (0,))
self.assertTrue(self._call(challbs, prefs, None))
# Rearrange order...
self.assertEqual(self._call(challs[::-1], prefs, combos), (1,))
self.assertTrue(self._call(challs[::-1], prefs, None))
self.assertEqual(self._call(challbs[::-1], prefs, combos), (1,))
self.assertTrue(self._call(challbs[::-1], prefs, None))
def test_common_case_with_continuity(self):
challs = (acme_util.RECOVERY_TOKEN,
acme_util.RECOVERY_CONTACT,
acme_util.DVSNI,
acme_util.SIMPLE_HTTPS)
challbs = (acme_util.RECOVERY_TOKEN_P,
acme_util.RECOVERY_CONTACT_P,
acme_util.DVSNI_P,
acme_util.SIMPLE_HTTPS_P)
prefs = [challenges.RecoveryToken, challenges.DVSNI]
combos = acme_util.gen_combos(challs)
self.assertEqual(self._call(challs, prefs, combos), (0, 2))
combos = acme_util.gen_combos(challbs)
self.assertEqual(self._call(challbs, prefs, combos), (0, 2))
# dumb_path() trivial test
self.assertTrue(self._call(challs, prefs, None))
self.assertTrue(self._call(challbs, prefs, None))
def test_full_cont_server(self):
challs = (acme_util.RECOVERY_TOKEN,
acme_util.RECOVERY_CONTACT,
acme_util.POP,
acme_util.DVSNI,
acme_util.SIMPLE_HTTPS,
acme_util.DNS)
challbs = (acme_util.RECOVERY_TOKEN_P,
acme_util.RECOVERY_CONTACT_P,
acme_util.POP_P,
acme_util.DVSNI_P,
acme_util.SIMPLE_HTTPS_P,
acme_util.DNS_P)
# Typical webserver client that can do everything except DNS
# Attempted to make the order realistic
prefs = [challenges.RecoveryToken,
@@ -569,19 +347,19 @@ class GenChallengePathTest(unittest.TestCase):
challenges.SimpleHTTPS,
challenges.DVSNI,
challenges.RecoveryContact]
combos = acme_util.gen_combos(challs)
self.assertEqual(self._call(challs, prefs, combos), (0, 4))
combos = acme_util.gen_combos(challbs)
self.assertEqual(self._call(challbs, prefs, combos), (0, 4))
# Dumb path trivial test
self.assertTrue(self._call(challs, prefs, None))
self.assertTrue(self._call(challbs, prefs, None))
def test_not_supported(self):
challs = (acme_util.POP, acme_util.DVSNI)
challbs = (acme_util.POP_P, acme_util.DVSNI_P)
prefs = [challenges.DVSNI]
combos = ((0, 1),)
self.assertRaises(errors.LetsEncryptAuthHandlerError,
self._call, challs, prefs, combos)
self.assertRaises(errors.AuthorizationError,
self._call, challbs, prefs, combos)
class MutuallyExclusiveTest(unittest.TestCase):
@@ -640,15 +418,16 @@ class IsPreferredTest(unittest.TestCase):
]))
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):
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):
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):
@@ -657,6 +436,13 @@ def gen_auth_resp(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):
"""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]
if __name__ == "__main__":
unittest.main()

View File

@@ -1,10 +1,57 @@
"""letsencrypt.client.client.py tests."""
import os
import unittest
import shutil
import tempfile
import mock
from letsencrypt.client import account
from letsencrypt.client import configuration
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):

View File

@@ -1,4 +1,5 @@
"""Tests for letsencrypt.client.configuration."""
import os
import unittest
import mock
@@ -10,24 +11,41 @@ class NamespaceConfigTest(unittest.TestCase):
def setUp(self):
from letsencrypt.client.configuration import NamespaceConfig
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)
def test_proxy_getattr(self):
self.assertEqual(self.config.foo, 'bar')
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')
def test_dynamic_dirs(self, constants):
constants.TEMP_CHECKPOINT_DIR = 't'
constants.IN_PROGRESS_DIR = '../p'
constants.CERT_KEY_BACKUP_DIR = 'c/'
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.in_progress_dir, '/tmp/foo/../p')
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.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__':

View File

@@ -21,14 +21,14 @@ class PerformTest(unittest.TestCase):
name="rec_token_perform", side_effect=gen_client_resp)
def test_rec_token1(self):
token = achallenges.RecoveryToken(chall=None, domain="0")
token = achallenges.RecoveryToken(challb=None, domain="0")
responses = self.auth.perform([token])
self.assertEqual(responses, ["RecoveryToken0"])
def test_rec_token5(self):
tokens = []
for i in xrange(5):
tokens.append(achallenges.RecoveryToken(chall=None, domain=str(i)))
tokens.append(achallenges.RecoveryToken(challb=None, domain=str(i)))
responses = self.auth.perform(tokens)
@@ -39,7 +39,7 @@ class PerformTest(unittest.TestCase):
def test_unexpected(self):
self.assertRaises(
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):
self.assertEqual(
@@ -58,8 +58,8 @@ class CleanupTest(unittest.TestCase):
self.auth.rec_token.cleanup = self.mock_cleanup
def test_rec_token2(self):
token1 = achallenges.RecoveryToken(chall=None, domain="0")
token2 = achallenges.RecoveryToken(chall=None, domain="1")
token1 = achallenges.RecoveryToken(challb=None, domain="0")
token2 = achallenges.RecoveryToken(challb=None, domain="1")
self.auth.cleanup([token1, token2])
@@ -67,8 +67,8 @@ class CleanupTest(unittest.TestCase):
[mock.call(token1), mock.call(token2)])
def test_unexpected(self):
token = achallenges.RecoveryToken(chall=None, domain="0")
unexpected = achallenges.DVSNI(chall=None, domain="0", key="dummy_key")
token = achallenges.RecoveryToken(challb=None, domain="0")
unexpected = achallenges.DVSNI(challb=None, domain="0", key="dummy_key")
self.assertRaises(errors.LetsEncryptContAuthError,
self.auth.cleanup, [token, unexpected])

View File

@@ -1,9 +1,13 @@
"""Tests for letsencrypt.client.crypto_util."""
import logging
import os
import pkg_resources
import shutil
import tempfile
import unittest
import M2Crypto
import mock
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'))
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):
"""Tests for letsencrypt.client.crypto_util.valid_csr."""

View File

@@ -1,13 +1,16 @@
"""Test letsencrypt.client.display.ops."""
import os
import sys
import tempfile
import unittest
import mock
import zope.component
from letsencrypt.client import account
from letsencrypt.client import le_util
from letsencrypt.client.display import util as display_util
class ChooseAuthenticatorTest(unittest.TestCase):
"""Test choose_authenticator function."""
def setUp(self):
@@ -50,10 +53,51 @@ class ChooseAuthenticatorTest(unittest.TestCase):
@mock.patch("letsencrypt.client.display.ops.util")
def test_no_choice(self, mock_util):
mock_util().menu.return_value = (display_util.CANCEL, 0)
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):
"""Test _gen_https_names."""
def setUp(self):

View File

@@ -128,9 +128,9 @@ class NcursesDisplayTest(DisplayT):
self.displayer.checklist("message", self.tags)
choices = [
(self.tags[0], "", False),
(self.tags[1], "", False),
(self.tags[2], "", False)
(self.tags[0], "", True),
(self.tags[1], "", True),
(self.tags[2], "", True),
]
mock_checklist.assert_called_with(
"message", width=display_util.WIDTH, height=display_util.HEIGHT,

View File

@@ -9,12 +9,13 @@ import M2Crypto
import mock
import requests
from letsencrypt.client import errors
from letsencrypt.acme import challenges
from letsencrypt.acme import jose
from letsencrypt.acme import messages2
from letsencrypt.client import account
from letsencrypt.client import errors
CERT = jose.ComparableX509(M2Crypto.X509.load_cert_string(
pkg_resources.resource_string(
@@ -196,6 +197,30 @@ class NetworkTest(unittest.TestCase):
self.assertRaises(
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):
self.response.headers['Location'] = self.regr.uri
self.response.json.return_value = self.regr.body.to_json()
@@ -208,6 +233,12 @@ class NetworkTest(unittest.TestCase):
self.assertRaises(
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):
self.response.status_code = httplib.CREATED
self.response.headers['Location'] = self.authzr.uri
@@ -217,7 +248,7 @@ class NetworkTest(unittest.TestCase):
}
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: split here and separate test
@@ -255,16 +286,11 @@ class NetworkTest(unittest.TestCase):
def test_answer_challenge_missing_next(self):
self._mock_post_get()
self.assertRaises(errors.NetworkError, self.net.answer_challenge,
self.challr.body, challenges.DNSResponse())
def test_answer_challenges(self):
self.net.answer_challenge = mock.MagicMock()
self.assertEqual(
[self.net.answer_challenge(
self.challr.body, challenges.DNSResponse())],
self.net.answer_challenges(
[self.challr.body], [challenges.DNSResponse()]))
self.assertTrue(self.net.answer_challenge(
self.challr.body, challenges.DNSResponse()) is None)
# TODO: boulder#130, acme-spec#110
# self.assertRaises(errors.NetworkError, self.net.answer_challenge,
# self.challr.body, challenges.DNSResponse())
def test_retry_after_date(self):
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):
# pylint: disable=protected-access
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))
def test_fetch_chain_no_up_link(self):

View File

@@ -39,22 +39,24 @@ class RecoveryTokenTest(unittest.TestCase):
self.assertFalse(self.rec_token.requires_human("example3.com"))
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"))
# Shouldn't throw an error
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)
self.assertRaises(
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):
self.rec_token.store_token("example4.com", 444)
response = self.rec_token.perform(
achallenges.RecoveryToken(chall=None, domain="example4.com"))
achallenges.RecoveryToken(
challb=challenges.RecoveryToken(), domain="example4.com"))
self.assertEqual(
response, challenges.RecoveryTokenResponse(token="444"))
@@ -63,12 +65,14 @@ class RecoveryTokenTest(unittest.TestCase):
def test_perform_not_stored(self, mock_input):
mock_input().input.side_effect = [(0, "555"), (1, "000")]
response = self.rec_token.perform(
achallenges.RecoveryToken(chall=None, domain="example5.com"))
achallenges.RecoveryToken(
challb=challenges.RecoveryToken(), domain="example5.com"))
self.assertEqual(
response, challenges.RecoveryTokenResponse(token="555"))
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)

View File

@@ -16,12 +16,15 @@ import zope.interface.verify
import letsencrypt
from letsencrypt.client import account
from letsencrypt.client import configuration
from letsencrypt.client import constants
from letsencrypt.client import client
from letsencrypt.client import errors
from letsencrypt.client import interfaces
from letsencrypt.client import le_util
from letsencrypt.client import log
from letsencrypt.client.display import util as display_util
from letsencrypt.client.display import ops as display_ops
@@ -59,7 +62,8 @@ def create_parser():
config_help = lambda name: interfaces.IConfig[name].__doc__
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"))
# 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,
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",
help=config_help("rsa_key_size"))
@@ -93,7 +99,7 @@ def create_parser():
add("--no-confirm", dest="no_confirm", action="store_true",
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.")
add("-t", "--text", dest="use_curses", action="store_false",
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()
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()
if args.rollback > 0:
client.rollback(args.rollback, config)
sys.exit()
if not args.eula:
display_eula()
le_util.make_or_verify_dir(
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)
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:
sys.exit(0)
# Prepare for init of Client
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)
acme = client.Client(config, acc, auth, installer)
# 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
# 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.
cert_file = 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)
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:
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):
"""Returns the given file's contents with universal new line support.

View File

@@ -30,6 +30,7 @@ changes = read_file(os.path.join(here, 'CHANGES.rst'))
install_requires = [
'argparse',
'ConfArgParse',
'configobj',
'jsonschema',
'mock',
'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304)

View File

@@ -19,7 +19,7 @@ setenv =
basepython = python2.7
commands =
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]
# recent versions of pylint do not support Python 2.6 (#97, #187)