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:
5
docs/api/client/account.rst
Normal file
5
docs/api/client/account.rst
Normal file
@@ -0,0 +1,5 @@
|
||||
:mod:`letsencrypt.client.account`
|
||||
---------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.client.account
|
||||
:members:
|
||||
@@ -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
|
||||
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
231
letsencrypt/client/account.py
Normal file
231
letsencrypt/client/account.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
@@ -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")])
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
211
letsencrypt/client/tests/account_test.py
Normal file
211
letsencrypt/client/tests/account_test.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
1
setup.py
1
setup.py
@@ -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)
|
||||
|
||||
2
tox.ini
2
tox.ini
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user