From 8c6d1ad50aa95091790be8f9d6e289f6c662b7c4 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 2 Jun 2015 13:55:16 +0000 Subject: [PATCH 01/72] letsencrypt_nginx should not depend on letsencrypt_apache. --- letsencrypt/plugins/common.py | 130 ++++++++++++++++++ letsencrypt/plugins/common_test.py | 106 ++++++++++++++ letsencrypt_apache/configurator.py | 12 +- letsencrypt_apache/dvsni.py | 57 +------- letsencrypt_apache/obj.py | 45 +----- letsencrypt_apache/tests/configurator_test.py | 7 +- letsencrypt_apache/tests/dvsni_test.py | 63 +-------- letsencrypt_apache/tests/obj_test.py | 50 +------ letsencrypt_apache/tests/util.py | 48 ++----- letsencrypt_nginx/dvsni.py | 5 +- letsencrypt_nginx/obj.py | 4 +- letsencrypt_nginx/tests/dvsni_test.py | 66 ++++----- letsencrypt_nginx/tests/util.py | 6 +- 13 files changed, 313 insertions(+), 286 deletions(-) diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py index 32bee2b49..90296c5c7 100644 --- a/letsencrypt/plugins/common.py +++ b/letsencrypt/plugins/common.py @@ -1,8 +1,14 @@ """Plugin common functions.""" +import os +import pkg_resources +import shutil +import tempfile + import zope.interface from acme.jose import util as jose_util +from letsencrypt import constants from letsencrypt import interfaces @@ -69,3 +75,127 @@ class Plugin(object): with unique plugin name prefix. """ + +# other + +class Addr(object): + r"""Represents an virtual host address. + + :param str addr: addr part of vhost address + :param str port: port number or \*, or "" + + """ + def __init__(self, tup): + self.tup = tup + + @classmethod + def fromstring(cls, str_addr): + """Initialize Addr from string.""" + tup = str_addr.partition(':') + return cls((tup[0], tup[2])) + + def __str__(self): + if self.tup[1]: + return "%s:%s" % self.tup + return self.tup[0] + + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.tup == other.tup + return False + + def __hash__(self): + return hash(self.tup) + + def get_addr(self): + """Return addr part of Addr object.""" + return self.tup[0] + + def get_port(self): + """Return port.""" + return self.tup[1] + + def get_addr_obj(self, port): + """Return new address object with same addr and new port.""" + return self.__class__((self.tup[0], port)) + + +class Dvsni(object): + """Class that perform DVSNI challenges.""" + + def __init__(self, configurator): + self.configurator = configurator + self.achalls = [] + self.indices = [] + self.challenge_conf = os.path.join( + configurator.config.config_dir, "le_dvsni_cert_challenge.conf") + # self.completed = 0 + + def add_chall(self, achall, idx=None): + """Add challenge to DVSNI object to perform at once. + + :param achall: Annotated DVSNI challenge. + :type achall: :class:`letsencrypt.achallenges.DVSNI` + + :param int idx: index to challenge in a larger array + + """ + self.achalls.append(achall) + if idx is not None: + self.indices.append(idx) + + def get_cert_file(self, achall): + """Returns standardized name for challenge certificate. + + :param achall: Annotated DVSNI challenge. + :type achall: :class:`letsencrypt.achallenges.DVSNI` + + :returns: certificate file name + :rtype: str + + """ + return os.path.join( + self.configurator.config.work_dir, achall.nonce_domain + ".crt") + + def _setup_challenge_cert(self, achall, s=None): + # pylint: disable=invalid-name + """Generate and write out challenge certificate.""" + cert_path = self.get_cert_file(achall) + # Register the path before you write out the file + self.configurator.reverter.register_file_creation(True, cert_path) + + cert_pem, response = achall.gen_cert_and_response(s) + + # Write out challenge cert + with open(cert_path, "w") as cert_chall_fd: + cert_chall_fd.write(cert_pem) + + return response + + +# test utils + +def setup_ssl_options(config_dir, mod_ssl_conf): + """Move the ssl_options into position and return the path.""" + option_path = os.path.join(config_dir, "options-ssl.conf") + shutil.copyfile(mod_ssl_conf, option_path) + return option_path + + +def dir_setup(test_dir, pkg): + """Setup the directories necessary for the configurator.""" + temp_dir = tempfile.mkdtemp("temp") + config_dir = tempfile.mkdtemp("config") + work_dir = tempfile.mkdtemp("work") + + os.chmod(temp_dir, constants.CONFIG_DIRS_MODE) + os.chmod(config_dir, constants.CONFIG_DIRS_MODE) + os.chmod(work_dir, constants.CONFIG_DIRS_MODE) + + test_configs = pkg_resources.resource_filename( + pkg, os.path.join("testdata", test_dir)) + + shutil.copytree( + test_configs, os.path.join(temp_dir, test_dir), symlinks=True) + + return temp_dir, config_dir, work_dir diff --git a/letsencrypt/plugins/common_test.py b/letsencrypt/plugins/common_test.py index 12dd18bdf..6de86f2b8 100644 --- a/letsencrypt/plugins/common_test.py +++ b/letsencrypt/plugins/common_test.py @@ -1,8 +1,16 @@ """Tests for letsencrypt.plugins.common.""" +import pkg_resources import unittest import mock +from acme import challenges + +from letsencrypt import achallenges +from letsencrypt import le_util + +from letsencrypt.tests import acme_util + class NamespaceFunctionsTest(unittest.TestCase): """Tests for letsencrypt.plugins.common.*_namespace functions.""" @@ -57,5 +65,103 @@ class PluginTest(unittest.TestCase): "--mock-foo-bar", dest="different_to_foo_bar", x=1, y=None) +class AddrTest(unittest.TestCase): + """Tests for letsencrypt.client.plugins.common.Addr.""" + + def setUp(self): + from letsencrypt.plugins.common import Addr + self.addr1 = Addr.fromstring("192.168.1.1") + self.addr2 = Addr.fromstring("192.168.1.1:*") + self.addr3 = Addr.fromstring("192.168.1.1:80") + + def test_fromstring(self): + self.assertEqual(self.addr1.get_addr(), "192.168.1.1") + self.assertEqual(self.addr1.get_port(), "") + self.assertEqual(self.addr2.get_addr(), "192.168.1.1") + self.assertEqual(self.addr2.get_port(), "*") + self.assertEqual(self.addr3.get_addr(), "192.168.1.1") + self.assertEqual(self.addr3.get_port(), "80") + + def test_str(self): + self.assertEqual(str(self.addr1), "192.168.1.1") + self.assertEqual(str(self.addr2), "192.168.1.1:*") + self.assertEqual(str(self.addr3), "192.168.1.1:80") + + def test_get_addr_obj(self): + self.assertEqual(str(self.addr1.get_addr_obj("443")), "192.168.1.1:443") + self.assertEqual(str(self.addr2.get_addr_obj("")), "192.168.1.1") + self.assertEqual(str(self.addr1.get_addr_obj("*")), "192.168.1.1:*") + + def test_eq(self): + self.assertEqual(self.addr1, self.addr2.get_addr_obj("")) + self.assertNotEqual(self.addr1, self.addr2) + self.assertFalse(self.addr1 == 3333) + + def test_set_inclusion(self): + from letsencrypt.plugins.common import Addr + set_a = set([self.addr1, self.addr2]) + addr1b = Addr.fromstring("192.168.1.1") + addr2b = Addr.fromstring("192.168.1.1:*") + set_b = set([addr1b, addr2b]) + + self.assertEqual(set_a, set_b) + + +class DvsniTest(unittest.TestCase): + """Tests for letsencrypt.plugins.common.DvsniTest.""" + + rsa256_file = pkg_resources.resource_filename( + "acme.jose", "testdata/rsa256_key.pem") + rsa256_pem = pkg_resources.resource_string( + "acme.jose", "testdata/rsa256_key.pem") + + auth_key = le_util.Key(rsa256_file, rsa256_pem) + achalls = [ + achallenges.DVSNI( + 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( + challb=acme_util.chall_to_challb( + challenges.DVSNI( + r="\xba\xa9\xda? """ - def __init__(self, configurator): - self.configurator = configurator - self.achalls = [] - self.indices = [] - self.challenge_conf = os.path.join( - configurator.config.config_dir, "le_dvsni_cert_challenge.conf") - # self.completed = 0 - - def add_chall(self, achall, idx=None): - """Add challenge to DVSNI object to perform at once. - - :param achall: Annotated DVSNI challenge. - :type achall: :class:`letsencrypt.achallenges.DVSNI` - - :param int idx: index to challenge in a larger array - - """ - self.achalls.append(achall) - if idx is not None: - self.indices.append(idx) def perform(self): """Peform a DVSNI challenge.""" @@ -107,28 +89,12 @@ class ApacheDvsni(object): return responses - def _setup_challenge_cert(self, achall, s=None): - # pylint: disable=invalid-name - """Generate and write out challenge certificate.""" - cert_path = self.get_cert_file(achall) - # Register the path before you write out the file - self.configurator.reverter.register_file_creation(True, cert_path) - - cert_pem, response = achall.gen_cert_and_response(s) - - # Write out challenge cert - with open(cert_path, "w") as cert_chall_fd: - cert_chall_fd.write(cert_pem) - - return response - def _mod_config(self, ll_addrs): """Modifies Apache config files to include challenge vhosts. Result: Apache config includes virtual servers for issued challs - :param list ll_addrs: list of list of - :class:`letsencrypt.plugins.apache.obj.Addr` to apply + :param list ll_addrs: list of list of `~.common.Addr` to apply """ # TODO: Use ip address of existing vhost instead of relying on FQDN @@ -167,7 +133,7 @@ class ApacheDvsni(object): :type achall: :class:`letsencrypt.achallenges.DVSNI` :param list ip_addrs: addresses of challenged domain - :class:`list` of type :class:`~apache.obj.Addr` + :class:`list` of type `~.common.Addr` :returns: virtual host configuration text :rtype: str @@ -186,16 +152,3 @@ class ApacheDvsni(object): ssl_options_conf_path=self.configurator.parser.loc["ssl_options"], cert_path=self.get_cert_file(achall), key_path=achall.key.file, document_root=document_root).replace("\n", os.linesep) - - def get_cert_file(self, achall): - """Returns standardized name for challenge certificate. - - :param achall: Annotated DVSNI challenge. - :type achall: :class:`letsencrypt.achallenges.DVSNI` - - :returns: certificate file name - :rtype: str - - """ - return os.path.join( - self.configurator.config.work_dir, achall.nonce_domain + ".crt") diff --git a/letsencrypt_apache/obj.py b/letsencrypt_apache/obj.py index 905e3f192..fecf46ff9 100644 --- a/letsencrypt_apache/obj.py +++ b/letsencrypt_apache/obj.py @@ -1,54 +1,13 @@ """Module contains classes used by the Apache Configurator.""" -class Addr(object): - r"""Represents an Apache VirtualHost address. - - :param str addr: addr part of vhost address - :param str port: port number or \*, or "" - - """ - def __init__(self, tup): - self.tup = tup - - @classmethod - def fromstring(cls, str_addr): - """Initialize Addr from string.""" - tup = str_addr.partition(':') - return cls((tup[0], tup[2])) - - def __str__(self): - if self.tup[1]: - return "%s:%s" % self.tup - return self.tup[0] - - def __eq__(self, other): - if isinstance(other, self.__class__): - return self.tup == other.tup - return False - - def __hash__(self): - return hash(self.tup) - - def get_addr(self): - """Return addr part of Addr object.""" - return self.tup[0] - - def get_port(self): - """Return port.""" - return self.tup[1] - - def get_addr_obj(self, port): - """Return new address object with same addr and new port.""" - return self.__class__((self.tup[0], port)) - - class VirtualHost(object): # pylint: disable=too-few-public-methods """Represents an Apache Virtualhost. :ivar str filep: file path of VH :ivar str path: Augeas path to virtual host - :ivar set addrs: Virtual Host addresses (:class:`set` of :class:`Addr`) + :ivar set addrs: Virtual Host addresses (:class:`set` of + :class:`common.Addr`) :ivar set names: Server names/aliases of vhost (:class:`list` of :class:`str`) diff --git a/letsencrypt_apache/tests/configurator_test.py b/letsencrypt_apache/tests/configurator_test.py index 11b88f9e5..c3064eb5b 100644 --- a/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt_apache/tests/configurator_test.py @@ -12,10 +12,11 @@ from letsencrypt import achallenges from letsencrypt import errors from letsencrypt import le_util +from letsencrypt.plugins import common + from letsencrypt.tests import acme_util from letsencrypt_apache import configurator -from letsencrypt_apache import obj from letsencrypt_apache import parser from letsencrypt_apache.tests import util @@ -112,7 +113,7 @@ class TwoVhost80Test(util.ApacheTest): self.vh_truth[1].filep) def test_is_name_vhost(self): - addr = obj.Addr.fromstring("*:80") + addr = common.Addr.fromstring("*:80") self.assertTrue(self.config.is_name_vhost(addr)) self.config.version = (2, 2) self.assertFalse(self.config.is_name_vhost(addr)) @@ -133,7 +134,7 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(ssl_vhost.path, "/files" + ssl_vhost.filep + "/IfModule/VirtualHost") self.assertEqual(len(ssl_vhost.addrs), 1) - self.assertEqual(set([obj.Addr.fromstring("*:443")]), ssl_vhost.addrs) + self.assertEqual(set([common.Addr.fromstring("*:443")]), ssl_vhost.addrs) self.assertEqual(ssl_vhost.names, set(["encryption-example.demo"])) self.assertTrue(ssl_vhost.ssl) self.assertFalse(ssl_vhost.enabled) diff --git a/letsencrypt_apache/tests/dvsni_test.py b/letsencrypt_apache/tests/dvsni_test.py index 321dce42c..bf43bc359 100644 --- a/letsencrypt_apache/tests/dvsni_test.py +++ b/letsencrypt_apache/tests/dvsni_test.py @@ -1,5 +1,4 @@ """Test for letsencrypt_apache.dvsni.""" -import pkg_resources import unittest import shutil @@ -7,18 +6,17 @@ import mock from acme import challenges -from letsencrypt import achallenges -from letsencrypt import le_util +from letsencrypt.plugins import common +from letsencrypt.plugins import common_test -from letsencrypt.tests import acme_util - -from letsencrypt_apache import obj from letsencrypt_apache.tests import util class DvsniPerformTest(util.ApacheTest): """Test the ApacheDVSNI challenge.""" + achalls = common_test.DvsniTest.achalls + def setUp(self): super(DvsniPerformTest, self).setUp() @@ -32,32 +30,6 @@ class DvsniPerformTest(util.ApacheTest): from letsencrypt_apache import dvsni self.sni = dvsni.ApacheDvsni(config) - rsa256_file = pkg_resources.resource_filename( - "acme.jose", "testdata/rsa256_key.pem") - rsa256_pem = pkg_resources.resource_string( - "acme.jose", "testdata/rsa256_key.pem") - - auth_key = le_util.Key(rsa256_file, rsa256_pem) - self.achalls = [ - achallenges.DVSNI( - 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( - challb=acme_util.chall_to_challb( - challenges.DVSNI( - r="\xba\xa9\xda? Date: Tue, 2 Jun 2015 17:49:13 +0000 Subject: [PATCH 02/72] Lower letsencrypt_apache coverage --- tox.cover.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.cover.sh b/tox.cover.sh index 80b6474d7..d1d030ed6 100755 --- a/tox.cover.sh +++ b/tox.cover.sh @@ -19,4 +19,4 @@ cover () { # 0, coveralls submit will be triggered (c.f. .travis.yml, # after_success) cover letsencrypt 95 && cover acme 100 && \ - cover letsencrypt_apache 78 && cover letsencrypt_nginx 96 + cover letsencrypt_apache 76 && cover letsencrypt_nginx 96 From 8cf9a152deac3ecc7dbf27e26724d2fb24f6feeb Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Thu, 4 Jun 2015 14:22:05 -0700 Subject: [PATCH 03/72] Don't suggest optional email. Email is still optional, by the same mechanism, but removing the suggestion to leave it out we will greatly increase the percentage of users that supply one, which in turn will reduce customer support requests. --- letsencrypt/account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/account.py b/letsencrypt/account.py index 3f8e3d012..93a949050 100644 --- a/letsencrypt/account.py +++ b/letsencrypt/account.py @@ -186,7 +186,7 @@ class Account(object): """ while True: code, email = zope.component.getUtility(interfaces.IDisplay).input( - "Enter email address (optional, press Enter to skip)") + "Enter email address") if code == display_util.OK: try: From 887f91bdacc79dabaa92d05af81a57a41c51c00b Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 9 Jun 2015 07:38:07 +0000 Subject: [PATCH 04/72] requirements.txt: no editable (-e) mode, no "." install. --- docs/contributing.rst | 2 +- docs/using.rst | 2 +- requirements.txt | 3 +-- tox.ini | 4 ++-- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index da28686a2..f527ba421 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -48,7 +48,7 @@ synced to ``/vagrant``, so you can get started with: vagrant ssh cd /vagrant - ./venv/bin/pip install -r requirements.txt + ./venv/bin/pip install -r requirements.txt .[dev,docs,testing] sudo ./venv/bin/letsencrypt Support for other Linux distributions coming soon. diff --git a/docs/using.rst b/docs/using.rst index 89cbc48f6..a93e2240b 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -71,7 +71,7 @@ Installation .. code-block:: shell virtualenv --no-site-packages -p python2 venv - ./venv/bin/pip install -r requirements.txt + ./venv/bin/pip install -r requirements.txt . Usage diff --git a/requirements.txt b/requirements.txt index 0f0223dab..972e87eaf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ # https://github.com/bw2/ConfigArgParse/issues/17 --e git+https://github.com/kuba/ConfigArgParse.git@python2.6#egg=ConfigArgParse --e . +git+https://github.com/kuba/ConfigArgParse.git@python2.6#egg=ConfigArgParse diff --git a/tox.ini b/tox.ini index 0367b5498..aed60f454 100644 --- a/tox.ini +++ b/tox.ini @@ -22,12 +22,12 @@ setenv = [testenv:cover] basepython = python2.7 commands = - pip install -e .[testing] + pip install -r requirements.txt -e .[testing] ./tox.cover.sh [testenv:lint] # recent versions of pylint do not support Python 2.6 (#97, #187) basepython = python2.7 commands = - pip install -e .[dev] + pip install -r requirements.txt -e .[dev] pylint --rcfile=.pylintrc letsencrypt acme letsencrypt_apache letsencrypt_nginx From fd39479810db4bcd73604253ec6e4dff955f9afb Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 10:11:49 +0000 Subject: [PATCH 05/72] Add an anti-replay nonce facility (fixes: #488). --- acme/jose/__init__.py | 6 ++- acme/jose/json_util.py | 12 ++++-- acme/jose/json_util_test.py | 41 ++++++++++++++++++ acme/jose/jws.py | 14 +++--- acme/jws.py | 59 +++++++++++++++++++++++++ acme/jws_test.py | 58 +++++++++++++++++++++++++ acme/messages2.py | 1 + letsencrypt/network2.py | 47 +++++++++++++++----- letsencrypt/tests/network2_test.py | 69 ++++++++++++++++++++++++------ 9 files changed, 275 insertions(+), 32 deletions(-) create mode 100644 acme/jws.py create mode 100644 acme/jws_test.py diff --git a/acme/jose/__init__.py b/acme/jose/__init__.py index db3258a3d..a4fe7008b 100644 --- a/acme/jose/__init__.py +++ b/acme/jose/__init__.py @@ -66,7 +66,11 @@ from acme.jose.jwk import ( JWKRSA, ) -from acme.jose.jws import JWS +from acme.jose.jws import ( + Header, + JWS, + Signature, +) from acme.jose.util import ( ComparableX509, diff --git a/acme/jose/json_util.py b/acme/jose/json_util.py index 0c91c3412..c7698ed8d 100644 --- a/acme/jose/json_util.py +++ b/acme/jose/json_util.py @@ -129,7 +129,8 @@ class JSONObjectWithFieldsMeta(abc.ABCMeta): keys are field attribute names and values are fields themselves. 2. ``cls.__slots__`` is extended by all field attribute names - (i.e. not :attr:`Field.json_name`). + (i.e. not :attr:`Field.json_name`). Original ``cls.__slots__`` + are stored in ``cls._orig_slots``. In a consequence, for a field attribute name ``some_field``, ``cls.some_field`` will be a slot descriptor and not an instance @@ -143,6 +144,7 @@ class JSONObjectWithFieldsMeta(abc.ABCMeta): some_field = some_field assert Foo.__slots__ == ('some_field', 'baz') + assert Foo._orig_slots == () assert Foo.some_field is not Field assert Foo._fields.keys() == ['some_field'] @@ -158,12 +160,16 @@ class JSONObjectWithFieldsMeta(abc.ABCMeta): def __new__(mcs, name, bases, dikt): fields = {} + + for base in bases: + fields.update(getattr(base, '_fields', {})) + # Do not reorder, this class might override fields from base classes! for key, value in dikt.items(): # not iterkeys() (in-place edit!) if isinstance(value, Field): fields[key] = dikt.pop(key) - dikt['__slots__'] = tuple( - list(dikt.get('__slots__', ())) + fields.keys()) + dikt['_orig_slots'] = dikt.get('__slots__', ()) + dikt['__slots__'] = tuple(list(dikt['_orig_slots']) + fields.keys()) dikt['_fields'] = fields return abc.ABCMeta.__new__(mcs, name, bases, dikt) diff --git a/acme/jose/json_util_test.py b/acme/jose/json_util_test.py index 5726ef2a8..a37ac08de 100644 --- a/acme/jose/json_util_test.py +++ b/acme/jose/json_util_test.py @@ -77,6 +77,47 @@ class FieldTest(unittest.TestCase): self.assertTrue(Field.default_decoder(mock_value) is mock_value) +class JSONObjectWithFieldsMetaTest(unittest.TestCase): + """Tests for acme.jose.json_util.JSONObjectWithFieldsMeta.""" + + def setUp(self): + from acme.jose.json_util import Field + from acme.jose.json_util import JSONObjectWithFieldsMeta + self.field = Field('Baz') + self.field2 = Field('Baz2') + # pylint: disable=invalid-name,missing-docstring,too-few-public-methods + # pylint: disable=blacklisted-name + class A(object): + __metaclass__ = JSONObjectWithFieldsMeta + __slots__ = ('bar',) + baz = self.field + class B(A): + pass + class C(A): + baz = self.field2 + self.a_cls = A + self.b_cls = B + self.c_cls = C + + def test_fields(self): + # pylint: disable=protected-access,no-member + self.assertEqual({'baz': self.field}, self.a_cls._fields) + self.assertEqual({'baz': self.field}, self.b_cls._fields) + + def test_fields_inheritance(self): + # pylint: disable=protected-access,no-member + self.assertEqual({'baz': self.field2}, self.c_cls._fields) + + def test_slots(self): + self.assertEqual(('bar', 'baz'), self.a_cls.__slots__) + self.assertEqual(('baz',), self.b_cls.__slots__) + + def test_orig_slots(self): + # pylint: disable=protected-access,no-member + self.assertEqual(('bar',), self.a_cls._orig_slots) + self.assertEqual((), self.b_cls._orig_slots) + + class JSONObjectWithFieldsTest(unittest.TestCase): """Tests for acme.jose.json_util.JSONObjectWithFields.""" # pylint: disable=protected-access diff --git a/acme/jose/jws.py b/acme/jose/jws.py index 06923e145..3ba60d40c 100644 --- a/acme/jose/jws.py +++ b/acme/jose/jws.py @@ -247,6 +247,8 @@ class JWS(json_util.JSONObjectWithFields): """ __slots__ = ('payload', 'signatures') + signature_cls = Signature + def verify(self, key=None): """Verify.""" return all(sig.verify(self.payload, key) for sig in self.signatures) @@ -255,13 +257,13 @@ class JWS(json_util.JSONObjectWithFields): def sign(cls, payload, **kwargs): """Sign.""" return cls(payload=payload, signatures=( - Signature.sign(payload=payload, **kwargs),)) + cls.signature_cls.sign(payload=payload, **kwargs),)) @property def signature(self): """Get a singleton signature. - :rtype: :class:`Signature` + :rtype: `signature_cls` """ assert len(self.signatures) == 1 @@ -288,8 +290,8 @@ class JWS(json_util.JSONObjectWithFields): raise errors.DeserializationError( 'Compact JWS serialization should comprise of exactly' ' 3 dot-separated components') - sig = Signature(protected=json_util.decode_b64jose(protected), - signature=json_util.decode_b64jose(signature)) + sig = cls.signature_cls(protected=json_util.decode_b64jose(protected), + signature=json_util.decode_b64jose(signature)) return cls(payload=json_util.decode_b64jose(payload), signatures=(sig,)) def to_partial_json(self, flat=True): # pylint: disable=arguments-differ @@ -312,10 +314,10 @@ class JWS(json_util.JSONObjectWithFields): raise errors.DeserializationError('Flat mixed with non-flat') elif 'signature' in jobj: # flat return cls(payload=json_util.decode_b64jose(jobj.pop('payload')), - signatures=(Signature.from_json(jobj),)) + signatures=(cls.signature_cls.from_json(jobj),)) else: return cls(payload=json_util.decode_b64jose(jobj['payload']), - signatures=tuple(Signature.from_json(sig) + signatures=tuple(cls.signature_cls.from_json(sig) for sig in jobj['signatures'])) class CLI(object): diff --git a/acme/jws.py b/acme/jws.py new file mode 100644 index 000000000..a23015d93 --- /dev/null +++ b/acme/jws.py @@ -0,0 +1,59 @@ +"""ACME JOSE JWS.""" +from acme import errors +from acme import jose + + +class Header(jose.Header): + """ACME JOSE Header. + + .. todo:: Implement ``acmePath``. + + """ + nonce = jose.Field('nonce', omitempty=True) + + @classmethod + def validate_nonce(cls, nonce): + """Validate nonce. + + :returns: ``None`` if ``nonce`` is valid, decoding errors otherwise. + + """ + try: + jose.b64decode(nonce) + except (ValueError, TypeError) as error: + return error + else: + return None + + @nonce.decoder + def nonce(value): # pylint: disable=missing-docstring,no-self-argument + error = Header.validate_nonce(value) + if error is not None: + # TODO: custom error + raise errors.Error("Invalid nonce: {0}".format(error)) + return value + + +class Signature(jose.Signature): + """ACME Signature.""" + __slots__ = jose.Signature._orig_slots # pylint: disable=no-member + + # TODO: decoder/encoder should accept cls? Otherwise, subclassing + # JSONObjectWithFields is tricky... + header_cls = Header + header = jose.Field( + 'header', omitempty=True, default=header_cls(), + decoder=header_cls.from_json) + + # TODO: decoder should check that nonce is in the protected header + + +class JWS(jose.JWS): + """ACME JWS.""" + signature_cls = Signature + __slots__ = jose.JWS._orig_slots # pylint: disable=no-member + + @classmethod + def sign(cls, payload, key, alg, nonce): # pylint: disable=arguments-differ + return super(JWS, cls).sign(payload, key=key, alg=alg, + protect=frozenset(['nonce']), nonce=nonce) diff --git a/acme/jws_test.py b/acme/jws_test.py new file mode 100644 index 000000000..f4a03f70d --- /dev/null +++ b/acme/jws_test.py @@ -0,0 +1,58 @@ +"""Tests for acme.jws.""" +import os +import pkg_resources +import unittest + +import Crypto.PublicKey.RSA + +from acme import errors +from acme import jose + + +RSA512_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( + 'acme.jose', os.path.join('testdata', 'rsa512_key.pem'))) + + +class HeaderTest(unittest.TestCase): + """Tests for acme.jws.Header.""" + + good_nonce = jose.b64encode('foo') + wrong_nonce = 'F' + # Following just makes sure wrong_nonce is wrong + try: + jose.b64decode(wrong_nonce) + except (ValueError, TypeError): + assert True + else: + assert False # pragma: no cover + + def test_validate_nonce(self): + from acme.jws import Header + self.assertTrue(Header.validate_nonce(self.good_nonce) is None) + self.assertFalse(Header.validate_nonce(self.wrong_nonce) is None) + + def test_nonce_decoder(self): + from acme.jws import Header + nonce_field = Header._fields['nonce'] + + self.assertRaises(errors.Error, nonce_field.decode, self.wrong_nonce) + self.assertEqual(self.good_nonce, nonce_field.decode(self.good_nonce)) + + +class JWSTest(unittest.TestCase): + """Tests for acme.jws.JWS.""" + + def setUp(self): + self.privkey = jose.JWKRSA(key=RSA512_KEY) + self.pubkey = self.privkey.public() + self.nonce = jose.b64encode('Nonce') + + def test_it(self): + from acme.jws import JWS + jws = JWS.sign(payload='foo', key=self.privkey, + alg=jose.RS256, nonce=self.nonce) + JWS.from_json(jws.to_json()) + + +if __name__ == '__main__': + unittest.main() # pragma: no cover diff --git a/acme/messages2.py b/acme/messages2.py index 253aaa95b..15b4521de 100644 --- a/acme/messages2.py +++ b/acme/messages2.py @@ -16,6 +16,7 @@ class Error(jose.JSONObjectWithFields, Exception): 'unauthorized': 'The client lacks sufficient authorization', 'serverInternal': 'The server experienced an internal error', 'badCSR': 'The CSR is unacceptable (e.g., due to a short key)', + 'badNonce': 'The client sent an unacceptable anti-replay nonce', } typ = jose.Field('type') diff --git a/letsencrypt/network2.py b/letsencrypt/network2.py index faf23f414..ae8aa43af 100644 --- a/letsencrypt/network2.py +++ b/letsencrypt/network2.py @@ -10,6 +10,7 @@ import requests import werkzeug from acme import jose +from acme import jws as acme_jws from acme import messages2 from letsencrypt import errors @@ -33,26 +34,32 @@ class Network(object): """ + # TODO: Move below to acme module? DER_CONTENT_TYPE = 'application/pkix-cert' JSON_CONTENT_TYPE = 'application/json' JSON_ERROR_CONTENT_TYPE = 'application/problem+json' + REPLAY_NONCE_HEADER = 'Replay-Nonce' def __init__(self, new_reg_uri, key, alg=jose.RS256, verify_ssl=True): self.new_reg_uri = new_reg_uri self.key = key self.alg = alg self.verify_ssl = verify_ssl + self._nonces = set() - def _wrap_in_jws(self, obj): + def _wrap_in_jws(self, obj, nonce): """Wrap `JSONDeSerializable` object in JWS. + .. todo:: Implement ``acmePath``. + + :param JSONDeSerializable obj: :rtype: `.JWS` """ dumps = obj.json_dumps() logging.debug('Serialized JSON: %s', dumps) - return jose.JWS.sign( - payload=dumps, key=self.key, alg=self.alg).json_dumps() + return acme_jws.JWS.sign( + payload=dumps, key=self.key, alg=self.alg, nonce=nonce).json_dumps() @classmethod def _check_response(cls, response, content_type=None): @@ -126,9 +133,27 @@ class Network(object): self._check_response(response, content_type=content_type) return response - def _post(self, uri, data, content_type=JSON_CONTENT_TYPE, **kwargs): + def _add_nonce(self, response): + if self.REPLAY_NONCE_HEADER in response.headers: + nonce = response.headers[self.REPLAY_NONCE_HEADER] + error = acme_jws.Header.validate_nonce(nonce) + if error is None: + logging.debug('Storing nonce: %r', nonce) + self._nonces.add(nonce) + else: + raise errors.NetworkError('Invalid nonce ({0}): {1}'.format( + nonce, error)) + + def _get_nonce(self, uri): + if not self._nonces: + logging.debug('Requesting fresh nonce by sending HEAD to %s', uri) + self._add_nonce(requests.head(uri)) + return self._nonces.pop() + + def _post(self, uri, obj, content_type=JSON_CONTENT_TYPE, **kwargs): """Send POST data. + :param JSONDeSerializable obj: Will be wrapped in JWS. :param str content_type: Expected ``Content-Type``, fails if not set. :raises acme.messages2.NetworkError: @@ -137,6 +162,7 @@ class Network(object): :rtype: `requests.Response` """ + data = self._wrap_in_jws(obj, self._get_nonce(uri)) logging.debug('Sending POST data to %s: %s', uri, data) kwargs.setdefault('verify', self.verify_ssl) try: @@ -145,6 +171,7 @@ class Network(object): raise errors.NetworkError(error) logging.debug('Received response %s: %r', response, response.text) + self._add_nonce(response) self._check_response(response, content_type=content_type) return response @@ -182,7 +209,7 @@ class Network(object): """ new_reg = messages2.Registration(contact=contact) - response = self._post(self.new_reg_uri, self._wrap_in_jws(new_reg)) + response = self._post(self.new_reg_uri, new_reg) assert response.status_code == httplib.CREATED # TODO: handle errors regr = self._regr_from_response(response) @@ -219,7 +246,7 @@ class Network(object): :rtype: `.RegistrationResource` """ - response = self._post(regr.uri, self._wrap_in_jws(regr.body)) + response = self._post(regr.uri, regr.body) # TODO: Boulder returns httplib.ACCEPTED #assert response.status_code == httplib.OK @@ -280,7 +307,7 @@ class Network(object): """ new_authz = messages2.Authorization(identifier=identifier) - response = self._post(new_authzr_uri, self._wrap_in_jws(new_authz)) + response = self._post(new_authzr_uri, new_authz) assert response.status_code == httplib.CREATED # TODO: handle errors return self._authzr_from_response(response, identifier) @@ -316,7 +343,7 @@ class Network(object): :raises errors.UnexpectedUpdate: """ - response = self._post(challb.uri, self._wrap_in_jws(response)) + response = self._post(challb.uri, response) try: authzr_uri = response.links['up']['url'] except KeyError: @@ -395,7 +422,7 @@ class Network(object): content_type = self.DER_CONTENT_TYPE # TODO: add 'cert_type 'argument response = self._post( authzrs[0].new_cert_uri, # TODO: acme-spec #90 - self._wrap_in_jws(req), + req, content_type=content_type, headers={'Accept': content_type}) @@ -546,7 +573,7 @@ class Network(object): """ rev = messages2.Revocation(revoke=when, authorizations=tuple( authzr.uri for authzr in certr.authzrs)) - response = self._post(certr.uri, self._wrap_in_jws(rev)) + response = self._post(certr.uri, rev) if response.status_code != httplib.OK: raise errors.NetworkError( 'Successful revocation must return HTTP OK status') diff --git a/letsencrypt/tests/network2_test.py b/letsencrypt/tests/network2_test.py index 7bffcf0f4..ed155df2e 100644 --- a/letsencrypt/tests/network2_test.py +++ b/letsencrypt/tests/network2_test.py @@ -13,6 +13,7 @@ import requests from acme import challenges from acme import jose +from acme import jws as acme_jws from acme import messages2 from letsencrypt import account @@ -40,15 +41,23 @@ class NetworkTest(unittest.TestCase): # pylint: disable=too-many-instance-attributes,too-many-public-methods def setUp(self): - from letsencrypt.network2 import Network self.verify_ssl = mock.MagicMock() + self.wrap_in_jws = mock.MagicMock(return_value=mock.sentinel.wrapped) + + from letsencrypt.network2 import Network self.net = Network( new_reg_uri='https://www.letsencrypt-demo.org/acme/new-reg', key=KEY, alg=jose.RS256, verify_ssl=self.verify_ssl) + self.nonce = jose.b64encode('Nonce') + self.net._nonces.add(self.nonce) # pylint: disable=protected-access + self.response = mock.MagicMock(ok=True, status_code=httplib.OK) self.response.headers = {} self.response.links = {} + self.post = mock.MagicMock(return_value=self.response) + self.get = mock.MagicMock(return_value=self.response) + self.identifier = messages2.Identifier( typ=messages2.IDENTIFIER_FQDN, value='example.com') @@ -89,8 +98,8 @@ class NetworkTest(unittest.TestCase): def _mock_post_get(self): # pylint: disable=protected-access - self.net._post = mock.MagicMock(return_value=self.response) - self.net._get = mock.MagicMock(return_value=self.response) + self.net._post = self.post + self.net._get = self.get def test_init(self): self.assertTrue(self.net.verify_ssl is self.verify_ssl) @@ -106,8 +115,12 @@ class NetworkTest(unittest.TestCase): def from_json(cls, value): pass # pragma: no cover # pylint: disable=protected-access - jws = self.net._wrap_in_jws(MockJSONDeSerializable('foo')) - self.assertEqual(jose.JWS.json_loads(jws).payload, '"foo"') + jws_dump = self.net._wrap_in_jws( + MockJSONDeSerializable('foo'), nonce='Tg') + jws = acme_jws.JWS.json_loads(jws_dump) + self.assertEqual(jws.payload, '"foo"') + self.assertEqual(jws.signature.combined.nonce, 'Tg') + # TODO: check that nonce is in protected header def test_check_response_not_ok_jobj_no_error(self): self.response.ok = False @@ -169,33 +182,66 @@ class NetworkTest(unittest.TestCase): self.net._check_response.assert_called_once_with( requests_mock.get('uri'), content_type='ct') + def _mock_wrap_in_jws(self): + # pylint: disable=protected-access + self.net._wrap_in_jws = self.wrap_in_jws + @mock.patch('letsencrypt.network2.requests') def test_post_requests_error_passthrough(self, requests_mock): requests_mock.exceptions = requests.exceptions requests_mock.post.side_effect = requests.exceptions.RequestException # pylint: disable=protected-access - self.assertRaises(errors.NetworkError, self.net._post, 'uri', 'data') + self._mock_wrap_in_jws() + self.assertRaises( + errors.NetworkError, self.net._post, 'uri', mock.sentinel.obj) @mock.patch('letsencrypt.network2.requests') def test_post(self, requests_mock): # pylint: disable=protected-access self.net._check_response = mock.MagicMock() - self.net._post('uri', 'data', content_type='ct') + self._mock_wrap_in_jws() + self.net._post('uri', mock.sentinel.obj, content_type='ct') self.net._check_response.assert_called_once_with( - requests_mock.post('uri', 'data'), content_type='ct') + requests_mock.post('uri', mock.sentinel.wrapped), content_type='ct') + + @mock.patch('letsencrypt.network2.requests') + def test_post_reply_nonce_handling(self, requests_mock): + # pylint: disable=protected-access + self.net._check_response = mock.MagicMock() + self._mock_wrap_in_jws() + + self.net._nonces.clear() + nonce2 = jose.b64encode('Nonce2') + requests_mock.head('uri').headers = { + self.net.REPLAY_NONCE_HEADER: nonce2} + requests_mock.post('uri').headers = { + self.net.REPLAY_NONCE_HEADER: self.nonce} + + self.net._post('uri', mock.sentinel.obj) + + requests_mock.head.assert_called_with('uri') + self.wrap_in_jws.assert_called_once_with(mock.sentinel.obj, nonce2) + self.assertEqual(self.net._nonces, set([self.nonce])) + + # wrong nonce + requests_mock.post('uri').headers = {self.net.REPLAY_NONCE_HEADER: 'F'} + self.assertRaises( + errors.NetworkError, self.net._post, 'uri', mock.sentinel.obj) @mock.patch('letsencrypt.client.network2.requests') def test_get_post_verify_ssl(self, requests_mock): # pylint: disable=protected-access + self._mock_wrap_in_jws() self.net._check_response = mock.MagicMock() for verify_ssl in [True, False]: self.net.verify_ssl = verify_ssl self.net._get('uri') - self.net._post('uri', 'data') + self.net._nonces.add('N') + self.net._post('uri', mock.sentinel.obj) requests_mock.get.assert_called_once_with('uri', verify=verify_ssl) requests_mock.post.assert_called_once_with( - 'uri', data='data', verify=verify_ssl) + 'uri', data=mock.sentinel.wrapped, verify=verify_ssl) requests_mock.reset_mock() def test_register(self): @@ -498,8 +544,7 @@ class NetworkTest(unittest.TestCase): def test_revoke(self): self._mock_post_get() self.net.revoke(self.certr, when=messages2.Revocation.NOW) - # pylint: disable=protected-access - self.net._post.assert_called_once_with(self.certr.uri, mock.ANY) + self.post.assert_called_once_with(self.certr.uri, mock.ANY) def test_revoke_bad_status_raises_error(self): self.response.status_code = httplib.METHOD_NOT_ALLOWED From 22fd9d4cd7b1a1afd3d596086b5b37974abba0e9 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 12:28:46 +0000 Subject: [PATCH 06/72] tox.cover.sh: erase coverage before tests --- tox.cover.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.cover.sh b/tox.cover.sh index 80b6474d7..9a2c3f141 100755 --- a/tox.cover.sh +++ b/tox.cover.sh @@ -15,6 +15,8 @@ cover () { "$1" --cover-min-percentage="$2" "$1" } +rm -f .coverage # --cover-erase is off, make sure stats are correct + # don't use sequential composition (;), if letsencrypt_nginx returns # 0, coveralls submit will be triggered (c.f. .travis.yml, # after_success) From 0543f040bf9f523920b076434cab23045f7a4086 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 13:06:56 +0000 Subject: [PATCH 07/72] Raise error on missing replay nonce. --- letsencrypt/network2.py | 4 ++++ letsencrypt/tests/network2_test.py | 11 +++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/letsencrypt/network2.py b/letsencrypt/network2.py index ae8aa43af..9b846da6c 100644 --- a/letsencrypt/network2.py +++ b/letsencrypt/network2.py @@ -143,6 +143,10 @@ class Network(object): else: raise errors.NetworkError('Invalid nonce ({0}): {1}'.format( nonce, error)) + else: + raise errors.NetworkError( + 'Server {0} response did not include a replay nonce'.format( + response.request.method)) def _get_nonce(self, uri): if not self._nonces: diff --git a/letsencrypt/tests/network2_test.py b/letsencrypt/tests/network2_test.py index ed155df2e..3f745ffa7 100644 --- a/letsencrypt/tests/network2_test.py +++ b/letsencrypt/tests/network2_test.py @@ -200,17 +200,22 @@ class NetworkTest(unittest.TestCase): # pylint: disable=protected-access self.net._check_response = mock.MagicMock() self._mock_wrap_in_jws() + requests_mock.post().headers = { + self.net.REPLAY_NONCE_HEADER: self.nonce} self.net._post('uri', mock.sentinel.obj, content_type='ct') self.net._check_response.assert_called_once_with( requests_mock.post('uri', mock.sentinel.wrapped), content_type='ct') @mock.patch('letsencrypt.network2.requests') - def test_post_reply_nonce_handling(self, requests_mock): + def test_post_replay_nonce_handling(self, requests_mock): # pylint: disable=protected-access self.net._check_response = mock.MagicMock() self._mock_wrap_in_jws() self.net._nonces.clear() + self.assertRaises( + errors.NetworkError, self.net._post, 'uri', mock.sentinel.obj) + nonce2 = jose.b64encode('Nonce2') requests_mock.head('uri').headers = { self.net.REPLAY_NONCE_HEADER: nonce2} @@ -238,9 +243,11 @@ class NetworkTest(unittest.TestCase): self.net.verify_ssl = verify_ssl self.net._get('uri') self.net._nonces.add('N') + requests_mock.post().headers = { + self.net.REPLAY_NONCE_HEADER: self.nonce} self.net._post('uri', mock.sentinel.obj) requests_mock.get.assert_called_once_with('uri', verify=verify_ssl) - requests_mock.post.assert_called_once_with( + requests_mock.post.assert_called_with( 'uri', data=mock.sentinel.wrapped, verify=verify_ssl) requests_mock.reset_mock() From ad5c3ff1b239d634e44286e2c58f8fd713c3dea9 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 19:13:31 +0000 Subject: [PATCH 08/72] Support M2Crypto with swig 3.0.5+ Fixes issues recognized in https://github.com/letsencrypt/lets-encrypt-preview/issues/413#issuecomment-106245456 and https://github.com/letsencrypt/lets-encrypt-preview/issues/493. --- requirements.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/requirements.txt b/requirements.txt index 0f0223dab..fa3dba412 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,9 @@ # https://github.com/bw2/ConfigArgParse/issues/17 -e git+https://github.com/kuba/ConfigArgParse.git@python2.6#egg=ConfigArgParse + +# Support swig 3.0.5 +# https://github.com/M2Crypto/M2Crypto/issues/24 +# https://github.com/M2Crypto/M2Crypto/pull/30 +git+https://github.com/M2Crypto/M2Crypto/commit/d13a3a46c8934c5f50b31d5f95b23e6e06f845c3#egg=M2Crypto + -e . From c4b495aa37085601dd7e5bcb94a66cd7d69acbcb Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 19:24:07 +0000 Subject: [PATCH 09/72] Bootstrap Fedora 22 (fixes: #493) --- bootstrap/fedora.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100755 bootstrap/fedora.sh diff --git a/bootstrap/fedora.sh b/bootstrap/fedora.sh new file mode 100755 index 000000000..0b919b4ce --- /dev/null +++ b/bootstrap/fedora.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +# Tested with: +# - Fedora 22 (x64) + +yum install -y \ + git-core \ + python \ + python-devel \ + python-virtualenv \ + python-devel \ + gcc \ + swig \ + dialog \ + augeas \ + openssl-devel \ + libffi-devel \ + ca-certificates \ From 4ed1a1c2d6b31079b47d8a76adbbd9702fa8405d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 19:25:49 +0000 Subject: [PATCH 10/72] Bootstrap Debian: one dep per line --- bootstrap/_deb_common.sh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/bootstrap/_deb_common.sh b/bootstrap/_deb_common.sh index 4e4c75b33..5348715eb 100755 --- a/bootstrap/_deb_common.sh +++ b/bootstrap/_deb_common.sh @@ -45,5 +45,15 @@ fi apt-get update apt-get install -y --no-install-recommends \ - python python-setuptools "$virtualenv" python-dev gcc swig \ - dialog libaugeas0 libssl-dev libffi-dev ca-certificates dpkg-dev + python \ + python-setuptools \ + "$virtualenv" \ + python-dev \ + gcc \ + swig \ + dialog \ + libaugeas0 \ + libssl-dev \ + libffi-dev \ + ca-certificates \ + dpkg-dev \ From 19aea3720387414747042683c0ee0df2fb80199a Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 19:26:17 +0000 Subject: [PATCH 11/72] Bootstrap Debian: remove python-setuptools dep --- bootstrap/_deb_common.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/bootstrap/_deb_common.sh b/bootstrap/_deb_common.sh index 5348715eb..7e68c7717 100755 --- a/bootstrap/_deb_common.sh +++ b/bootstrap/_deb_common.sh @@ -46,7 +46,6 @@ fi apt-get update apt-get install -y --no-install-recommends \ python \ - python-setuptools \ "$virtualenv" \ python-dev \ gcc \ From b44014b06e7eb320656e954d2812cb9bf068f9b6 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 19:28:15 +0000 Subject: [PATCH 12/72] Bootstrap README: deps rationale --- bootstrap/README | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bootstrap/README b/bootstrap/README index 847129c03..73564aaab 100644 --- a/bootstrap/README +++ b/bootstrap/README @@ -1,2 +1,7 @@ This directory contains scripts that install necessary OS-specific -prerequisite dependencies (see docs/using.rst). \ No newline at end of file +prerequisite dependencies (see docs/using.rst). + +General dependencies: +- git-core: requirements.txt git+https://* +- ca-certificates: communication with demo ACMO server at + https://www.letsencrypt-demo.org, requirements.txt git+https://* \ No newline at end of file From 2139971212b9e534d6593365fb92a51faff9d37d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 19:35:29 +0000 Subject: [PATCH 13/72] nit: add EOF newline --- bootstrap/README | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap/README b/bootstrap/README index 73564aaab..6a04ac0ba 100644 --- a/bootstrap/README +++ b/bootstrap/README @@ -4,4 +4,4 @@ prerequisite dependencies (see docs/using.rst). General dependencies: - git-core: requirements.txt git+https://* - ca-certificates: communication with demo ACMO server at - https://www.letsencrypt-demo.org, requirements.txt git+https://* \ No newline at end of file + https://www.letsencrypt-demo.org, requirements.txt git+https://* From 2be914f0d5ed2d3d27e49f679cd57dc7703c4a27 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 21:06:44 +0000 Subject: [PATCH 14/72] Bootstrap Debian: add git-core dep --- bootstrap/_deb_common.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bootstrap/_deb_common.sh b/bootstrap/_deb_common.sh index 7e68c7717..653daca53 100755 --- a/bootstrap/_deb_common.sh +++ b/bootstrap/_deb_common.sh @@ -45,9 +45,10 @@ fi apt-get update apt-get install -y --no-install-recommends \ + git-core \ python \ - "$virtualenv" \ python-dev \ + "$virtualenv" \ gcc \ swig \ dialog \ From 30545e1c545fe1798ab6cc961a764798510957e4 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 21:11:25 +0000 Subject: [PATCH 15/72] requirements.txt: fix M2Crypto URL --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fa3dba412..6c2535a9d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,6 @@ # Support swig 3.0.5 # https://github.com/M2Crypto/M2Crypto/issues/24 # https://github.com/M2Crypto/M2Crypto/pull/30 -git+https://github.com/M2Crypto/M2Crypto/commit/d13a3a46c8934c5f50b31d5f95b23e6e06f845c3#egg=M2Crypto +git+https://github.com/M2Crypto/M2Crypto.git@d13a3a46c8934c5f50b31d5f95b23e6e06f845c3#egg=M2Crypto -e . From 7f5abba83e809c23d74704dce68047852ac09109 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 16:04:46 +0000 Subject: [PATCH 16/72] Rename SimpleHTTPS to SimpleHTTP. --- acme/challenges.py | 14 +++++------ acme/challenges_test.py | 32 +++++++++++++------------- acme/messages2_test.py | 2 +- acme/messages_test.py | 6 ++--- acme/schemata/challengeobject.json | 2 +- acme/schemata/responseobject.json | 2 +- letsencrypt/achallenges.py | 6 ++--- letsencrypt/auth_handler.py | 6 ++--- letsencrypt/constants.py | 2 +- letsencrypt/tests/acme_util.py | 2 +- letsencrypt/tests/auth_handler_test.py | 10 ++++---- letsencrypt_apache/dvsni.py | 2 +- letsencrypt_nginx/dvsni.py | 2 +- 13 files changed, 44 insertions(+), 44 deletions(-) diff --git a/acme/challenges.py b/acme/challenges.py index 11a1c9a60..36c29d6c6 100644 --- a/acme/challenges.py +++ b/acme/challenges.py @@ -42,16 +42,16 @@ class ChallengeResponse(jose.TypedJSONObjectWithFields): @Challenge.register -class SimpleHTTPS(DVChallenge): - """ACME "simpleHttps" challenge.""" - typ = "simpleHttps" +class SimpleHTTP(DVChallenge): + """ACME "simpleHttp" challenge.""" + typ = "simpleHttp" token = jose.Field("token") @ChallengeResponse.register -class SimpleHTTPSResponse(ChallengeResponse): - """ACME "simpleHttps" challenge response.""" - typ = "simpleHttps" +class SimpleHTTPResponse(ChallengeResponse): + """ACME "simpleHttp" challenge response.""" + typ = "simpleHttp" path = jose.Field("path") URI_TEMPLATE = "https://{domain}/.well-known/acme-challenge/{path}" @@ -61,7 +61,7 @@ class SimpleHTTPSResponse(ChallengeResponse): """Create an URI to the provisioned resource. Forms an URI to the HTTPS server provisioned resource (containing - :attr:`~SimpleHTTPS.token`) by populating the :attr:`URI_TEMPLATE`. + :attr:`~SimpleHTTP.token`) by populating the :attr:`URI_TEMPLATE`. :param str domain: Domain name being verified. diff --git a/acme/challenges_test.py b/acme/challenges_test.py index 0669dd581..7018b8e2e 100644 --- a/acme/challenges_test.py +++ b/acme/challenges_test.py @@ -18,14 +18,14 @@ KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( 'acme.jose', os.path.join('testdata', 'rsa512_key.pem')))) -class SimpleHTTPSTest(unittest.TestCase): +class SimpleHTTPTest(unittest.TestCase): def setUp(self): - from acme.challenges import SimpleHTTPS - self.msg = SimpleHTTPS( + from acme.challenges import SimpleHTTP + self.msg = SimpleHTTP( token='evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA') self.jmsg = { - 'type': 'simpleHttps', + 'type': 'simpleHttp', 'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA', } @@ -33,21 +33,21 @@ class SimpleHTTPSTest(unittest.TestCase): self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): - from acme.challenges import SimpleHTTPS - self.assertEqual(self.msg, SimpleHTTPS.from_json(self.jmsg)) + from acme.challenges import SimpleHTTP + self.assertEqual(self.msg, SimpleHTTP.from_json(self.jmsg)) def test_from_json_hashable(self): - from acme.challenges import SimpleHTTPS - hash(SimpleHTTPS.from_json(self.jmsg)) + from acme.challenges import SimpleHTTP + hash(SimpleHTTP.from_json(self.jmsg)) -class SimpleHTTPSResponseTest(unittest.TestCase): +class SimpleHTTPResponseTest(unittest.TestCase): def setUp(self): - from acme.challenges import SimpleHTTPSResponse - self.msg = SimpleHTTPSResponse(path='6tbIMBC5Anhl5bOlWT5ZFA') + from acme.challenges import SimpleHTTPResponse + self.msg = SimpleHTTPResponse(path='6tbIMBC5Anhl5bOlWT5ZFA') self.jmsg = { - 'type': 'simpleHttps', + 'type': 'simpleHttp', 'path': '6tbIMBC5Anhl5bOlWT5ZFA', } @@ -59,13 +59,13 @@ class SimpleHTTPSResponseTest(unittest.TestCase): self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): - from acme.challenges import SimpleHTTPSResponse + from acme.challenges import SimpleHTTPResponse self.assertEqual( - self.msg, SimpleHTTPSResponse.from_json(self.jmsg)) + self.msg, SimpleHTTPResponse.from_json(self.jmsg)) def test_from_json_hashable(self): - from acme.challenges import SimpleHTTPSResponse - hash(SimpleHTTPSResponse.from_json(self.jmsg)) + from acme.challenges import SimpleHTTPResponse + hash(SimpleHTTPResponse.from_json(self.jmsg)) class DVSNITest(unittest.TestCase): diff --git a/acme/messages2_test.py b/acme/messages2_test.py index c1521e2c3..72ffc954a 100644 --- a/acme/messages2_test.py +++ b/acme/messages2_test.py @@ -183,7 +183,7 @@ class AuthorizationTest(unittest.TestCase): self.challbs = ( ChallengeBody( uri='http://challb1', status=STATUS_VALID, - chall=challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A')), + chall=challenges.SimpleHTTP(token='IlirfxKKXAsHtmzK29Pj8A')), ChallengeBody(uri='http://challb2', status=STATUS_VALID, chall=challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA')), ChallengeBody(uri='http://challb3', status=STATUS_VALID, diff --git a/acme/messages_test.py b/acme/messages_test.py index 4e0823085..baff2a21a 100644 --- a/acme/messages_test.py +++ b/acme/messages_test.py @@ -63,7 +63,7 @@ class ChallengeTest(unittest.TestCase): def setUp(self): challs = ( - challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'), + challenges.SimpleHTTP(token='IlirfxKKXAsHtmzK29Pj8A'), challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'), challenges.RecoveryToken(), ) @@ -94,7 +94,7 @@ class ChallengeTest(unittest.TestCase): def test_resolved_combinations(self): self.assertEqual(self.msg.resolved_combinations, ( ( - challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'), + challenges.SimpleHTTP(token='IlirfxKKXAsHtmzK29Pj8A'), challenges.RecoveryToken() ), ( @@ -183,7 +183,7 @@ class AuthorizationRequestTest(unittest.TestCase): def setUp(self): self.responses = ( - challenges.SimpleHTTPSResponse(path='Hf5GrX4Q7EBax9hc2jJnfw'), + challenges.SimpleHTTPResponse(path='Hf5GrX4Q7EBax9hc2jJnfw'), None, # null challenges.RecoveryTokenResponse(token='23029d88d9e123e'), ) diff --git a/acme/schemata/challengeobject.json b/acme/schemata/challengeobject.json index 5641b407e..7709f315d 100644 --- a/acme/schemata/challengeobject.json +++ b/acme/schemata/challengeobject.json @@ -7,7 +7,7 @@ "required": ["type", "token"], "properties": { "type": { - "enum" : [ "simpleHttps" ] + "enum" : [ "simpleHttp" ] }, "token": { "type": "string" diff --git a/acme/schemata/responseobject.json b/acme/schemata/responseobject.json index 5ca6babf1..5773f3a73 100644 --- a/acme/schemata/responseobject.json +++ b/acme/schemata/responseobject.json @@ -7,7 +7,7 @@ "required": ["type", "path"], "properties": { "type": { - "enum" : [ "simpleHttps" ] + "enum" : [ "simpleHttp" ] }, "path": { "type": "string" diff --git a/letsencrypt/achallenges.py b/letsencrypt/achallenges.py index 77e362f22..46ef167e0 100644 --- a/letsencrypt/achallenges.py +++ b/letsencrypt/achallenges.py @@ -62,10 +62,10 @@ class DVSNI(AnnotatedChallenge): return cert_pem, response -class SimpleHTTPS(AnnotatedChallenge): - """Client annotated "simpleHttps" ACME challenge.""" +class SimpleHTTP(AnnotatedChallenge): + """Client annotated "simpleHttp" ACME challenge.""" __slots__ = ('challb', 'domain', 'key') - acme_type = challenges.SimpleHTTPS + acme_type = challenges.SimpleHTTP class DNS(AnnotatedChallenge): diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index 37d818dbe..5f9d29e6e 100644 --- a/letsencrypt/auth_handler.py +++ b/letsencrypt/auth_handler.py @@ -336,9 +336,9 @@ def challb_to_achall(challb, key, domain): 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( + elif isinstance(chall, challenges.SimpleHTTP): + logging.info(" SimpleHTTP challenge for %s.", domain) + return achallenges.SimpleHTTP( challb=challb, domain=domain, key=key) elif isinstance(chall, challenges.DNS): logging.info(" DNS challenge for %s.", domain) diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py index dacbe9040..47539615d 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -41,7 +41,7 @@ RENEWER_DEFAULTS = dict( EXCLUSIVE_CHALLENGES = frozenset([frozenset([ - challenges.DVSNI, challenges.SimpleHTTPS])]) + challenges.DVSNI, challenges.SimpleHTTP])]) """Mutually exclusive challenges.""" diff --git a/letsencrypt/tests/acme_util.py b/letsencrypt/tests/acme_util.py index 8780e8095..51bb3cfbb 100644 --- a/letsencrypt/tests/acme_util.py +++ b/letsencrypt/tests/acme_util.py @@ -16,7 +16,7 @@ KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( "acme.jose", os.path.join("testdata", "rsa512_key.pem")))) # Challenges -SIMPLE_HTTPS = challenges.SimpleHTTPS( +SIMPLE_HTTPS = challenges.SimpleHTTP( token="evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA") DVSNI = challenges.DVSNI( r="O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6\xbf'\xb3" diff --git a/letsencrypt/tests/auth_handler_test.py b/letsencrypt/tests/auth_handler_test.py index 85bcfe8cf..d7fd2c093 100644 --- a/letsencrypt/tests/auth_handler_test.py +++ b/letsencrypt/tests/auth_handler_test.py @@ -17,7 +17,7 @@ from letsencrypt.tests import acme_util TRANSLATE = { "dvsni": "DVSNI", - "simpleHttps": "SimpleHTTPS", + "simpleHttp": "SimpleHTTP", "dns": "DNS", "recoveryToken": "RecoveryToken", "recoveryContact": "RecoveryContact", @@ -299,7 +299,7 @@ class GenChallengePathTest(unittest.TestCase): return gen_challenge_path(challbs, preferences, combinations) def test_common_case(self): - """Given DVSNI and SimpleHTTPS with appropriate combos.""" + """Given DVSNI and SimpleHTTP with appropriate combos.""" challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTPS_P) prefs = [challenges.DVSNI] combos = ((0,), (1,)) @@ -334,7 +334,7 @@ class GenChallengePathTest(unittest.TestCase): # Attempted to make the order realistic prefs = [challenges.RecoveryToken, challenges.ProofOfPossession, - challenges.SimpleHTTPS, + challenges.SimpleHTTP, challenges.DVSNI, challenges.RecoveryContact] combos = acme_util.gen_combos(challbs) @@ -403,8 +403,8 @@ class IsPreferredTest(unittest.TestCase): def _call(cls, chall, satisfied): from letsencrypt.auth_handler import is_preferred return is_preferred(chall, satisfied, exclusive_groups=frozenset([ - frozenset([challenges.DVSNI, challenges.SimpleHTTPS]), - frozenset([challenges.DNS, challenges.SimpleHTTPS]), + frozenset([challenges.DVSNI, challenges.SimpleHTTP]), + frozenset([challenges.DNS, challenges.SimpleHTTP]), ])) def test_empty_satisfied(self): diff --git a/letsencrypt_apache/dvsni.py b/letsencrypt_apache/dvsni.py index ed7a216bb..6865afe26 100644 --- a/letsencrypt_apache/dvsni.py +++ b/letsencrypt_apache/dvsni.py @@ -18,7 +18,7 @@ class ApacheDvsni(object): larger array. ApacheDvsni is capable of solving many challenges at once which causes an indexing issue within ApacheConfigurator who must return all responses in order. Imagine ApacheConfigurator - maintaining state about where all of the SimpleHTTPS Challenges, + maintaining state about where all of the SimpleHTTP Challenges, Dvsni Challenges belong in the response array. This is an optional utility. diff --git a/letsencrypt_nginx/dvsni.py b/letsencrypt_nginx/dvsni.py index 5c188099c..3792230d3 100644 --- a/letsencrypt_nginx/dvsni.py +++ b/letsencrypt_nginx/dvsni.py @@ -24,7 +24,7 @@ class NginxDvsni(ApacheDvsni): larger array. NginxDvsni is capable of solving many challenges at once which causes an indexing issue within NginxConfigurator who must return all responses in order. Imagine NginxConfigurator - maintaining state about where all of the SimpleHTTPS Challenges, + maintaining state about where all of the SimpleHTTP Challenges, Dvsni Challenges belong in the response array. This is an optional utility. From bc9373929a4c0f21cf855eaffadfeb7b465b1d2e Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 16:19:41 +0000 Subject: [PATCH 17/72] Add SimpleHTTP.tls --- acme/challenges.py | 1 + 1 file changed, 1 insertion(+) diff --git a/acme/challenges.py b/acme/challenges.py index 36c29d6c6..26f71a2e3 100644 --- a/acme/challenges.py +++ b/acme/challenges.py @@ -46,6 +46,7 @@ class SimpleHTTP(DVChallenge): """ACME "simpleHttp" challenge.""" typ = "simpleHttp" token = jose.Field("token") + tls = jose.Field("tls", default=True, omitempty=True) @ChallengeResponse.register From 8883bd76fd8beb44de6e1f7fe0addc551b4bc78e Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 18:05:00 +0000 Subject: [PATCH 18/72] Add --no-simple-http-tls. --- letsencrypt/cli.py | 3 +++ letsencrypt/interfaces.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 4b0e271f7..3bdf2bfc6 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -252,6 +252,9 @@ def create_parser(plugins): add("-t", "--text", dest="text_mode", action="store_true", help="Use the text output instead of the curses UI.") + add("--no-simple-http-tls", action="store_true", + help=config_help("no_simple_http_tls")) + testing_group = parser.add_argument_group( "testing", description="The following flags are meant for " "testing purposes only! Do NOT change them, unless you " diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index e47eea6cc..c0d44a134 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -188,6 +188,10 @@ class IConfig(zope.interface.Interface): "Port number to perform DVSNI challenge. " "Boulder in testing mode defaults to 5001.") + # TODO: not implemented + no_simple_http_tls = zope.interface.Attribute( + "Do not use TLS when solving SimpleHTTP challenges.") + class IInstaller(IPlugin): """Generic Let's Encrypt Installer Interface. From d53120f25f2ab8ffe4300dab4b47085a6b8e9da1 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 12 Jun 2015 09:21:30 +0000 Subject: [PATCH 19/72] Fix SimpleHTTP tests and omitempty bug. --- acme/challenges_test.py | 9 +++++++++ acme/jose/json_util.py | 2 +- acme/jose/json_util_test.py | 8 ++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/acme/challenges_test.py b/acme/challenges_test.py index 7018b8e2e..beeec6f73 100644 --- a/acme/challenges_test.py +++ b/acme/challenges_test.py @@ -27,8 +27,17 @@ class SimpleHTTPTest(unittest.TestCase): self.jmsg = { 'type': 'simpleHttp', 'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA', + 'tls': True, } + def test_no_tls(self): + from acme.challenges import SimpleHTTP + self.assertEqual(SimpleHTTP(token='tok', tls=False).to_json(), { + 'tls': False, + 'token': 'tok', + 'type': 'simpleHttp', + }) + def test_to_partial_json(self): self.assertEqual(self.jmsg, self.msg.to_partial_json()) diff --git a/acme/jose/json_util.py b/acme/jose/json_util.py index c7698ed8d..a08145459 100644 --- a/acme/jose/json_util.py +++ b/acme/jose/json_util.py @@ -62,7 +62,7 @@ class Field(object): definition of being empty, e.g. for some more exotic data types. """ - return not value + return not isinstance(value, bool) and not value def omit(self, value): """Omit the value in output?""" diff --git a/acme/jose/json_util_test.py b/acme/jose/json_util_test.py index a37ac08de..242e37589 100644 --- a/acme/jose/json_util_test.py +++ b/acme/jose/json_util_test.py @@ -1,4 +1,5 @@ """Tests for acme.jose.json_util.""" +import itertools import os import pkg_resources import unittest @@ -20,6 +21,13 @@ CSR = M2Crypto.X509.load_request(pkg_resources.resource_filename( class FieldTest(unittest.TestCase): """Tests for acme.jose.json_util.Field.""" + def test_no_omit_boolean(self): + from acme.jose.json_util import Field + for default, omitempty, value in itertools.product( + [True, False], [True, False], [True, False]): + self.assertFalse( + Field("foo", default=default, omitempty=omitempty).omit(value)) + def test_descriptors(self): mock_value = mock.MagicMock() From 8ba51665637be5e929969e18f19504dd344f294f Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 12 Jun 2015 11:54:12 +0000 Subject: [PATCH 20/72] Vagrant: remove explicit git-core install --- Vagrantfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/Vagrantfile b/Vagrantfile index 7eb2b4cce..1d3b48f06 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -8,8 +8,6 @@ VAGRANTFILE_API_VERSION = "2" $ubuntu_setup_script = < Date: Fri, 12 Jun 2015 12:55:33 +0000 Subject: [PATCH 21/72] Separate requirements.txt for SWIG 3.0.5+ --- docs/using.rst | 6 ++++ requirements-swig-3.0.5.txt | 67 +++++++++++++++++++++++++++++++++++++ requirements.txt | 6 ---- 3 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 requirements-swig-3.0.5.txt diff --git a/docs/using.rst b/docs/using.rst index 89cbc48f6..909c6eadc 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -31,6 +31,12 @@ In general: * ``sudo`` is required as a suggested way of running privileged process * `swig`_ is required for compiling `m2crypto`_ + + .. note:: If your operating system uses SWIG 3.0.5+, you will need + to run ``pip install -r requirements-swig-3.0.5.txt -r + requirements.txt`` instead of the standard ``pip + install -r requirements.txt``. + * `augeas`_ is required for the ``python-augeas`` bindings diff --git a/requirements-swig-3.0.5.txt b/requirements-swig-3.0.5.txt new file mode 100644 index 000000000..9ef45d950 --- /dev/null +++ b/requirements-swig-3.0.5.txt @@ -0,0 +1,67 @@ +# Support swig 3.0.5+ +# https://github.com/M2Crypto/M2Crypto/issues/24 +# https://github.com/M2Crypto/M2Crypto/pull/30 +git+https://github.com/M2Crypto/M2Crypto.git@d13a3a46c8934c5f50b31d5f95b23e6e06f845c3#egg=M2Crypto + +# This requirements file will fail on Travis CI 12.04 LTS Ubuntu build +# machine under TOX_ENV=py26 with very confusing error (full tracback +# at https://api.travis-ci.org/jobs/66529698/log.txt?deansi=true): + +#Traceback (most recent call last): +# File "setup.py", line 133, in +# include_package_data=True, +# File "/opt/python/2.6.9/lib/python2.6/distutils/core.py", line 152, in setup +# dist.run_commands() +# File "/opt/python/2.6.9/lib/python2.6/distutils/dist.py", line 975, in run_commands +# self.run_command(cmd) +# File "/opt/python/2.6.9/lib/python2.6/distutils/dist.py", line 995, in run_command +# cmd_obj.run() +# File "/home/travis/build/letsencrypt/lets-encrypt-preview/.tox/py26/lib/python2.6/site-packages/setuptools/command/test.py", line 142, in run +# self.with_project_on_sys_path(self.run_tests) +# File "/home/travis/build/letsencrypt/lets-encrypt-preview/.tox/py26/lib/python2.6/site-packages/setuptools/command/test.py", line 122, in with_project_on_sys_path +# func() +# File "/home/travis/build/letsencrypt/lets-encrypt-preview/.tox/py26/lib/python2.6/site-packages/setuptools/command/test.py", line 163, in run_tests +# testRunner=self._resolve_as_ep(self.test_runner), +# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 816, in __init__ +# self.parseArgs(argv) +# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 843, in parseArgs +# self.createTests() +# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 849, in createTests +# self.module) +# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 613, in loadTestsFromNames +# suites = [self.loadTestsFromName(name, module) for name in names] +# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 587, in loadTestsFromName +# return self.loadTestsFromModule(obj) +# File "/home/travis/build/letsencrypt/lets-encrypt-preview/.tox/py26/lib/python2.6/site-packages/setuptools/command/test.py", line 37, in loadTestsFromModule +# tests.append(self.loadTestsFromName(submodule)) +# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 584, in loadTestsFromName +# parent, obj = obj, getattr(obj, part) +#AttributeError: 'module' object has no attribute 'continuity_auth' + +# the above error happens because letsencrypt.continuity_auth cannot import M2Crypto: + +#>>> import M2Crypto +#Traceback (most recent call last): +# File "", line 1, in +# File "/root/lets-encrypt-preview/venv/lib/python2.6/site-packages/M2Crypto-0.21.1-py2.6-linux-x86_64.egg/M2Crypto/__init__.py", line 22, in +# import m2crypto +# File "/root/lets-encrypt-preview/venv/lib/python2.6/site-packages/M2Crypto-0.21.1-py2.6-linux-x86_64.egg/M2Crypto/m2crypto.py", line 26, in +# _m2crypto = swig_import_helper() +# File "/root/lets-encrypt-preview/venv/lib/python2.6/site-packages/M2Crypto-0.21.1-py2.6-linux-x86_64.egg/M2Crypto/m2crypto.py", line 22, in swig_import_helper +# _mod = imp.load_module('_m2crypto', fp, pathname, description) +#ImportError: /root/lets-encrypt-preview/venv/lib/python2.6/site-packages/M2Crypto-0.21.1-py2.6-linux-x86_64.egg/M2Crypto/_m2crypto.so: undefined symbol: SSLv2_method + +# For more info see: + +# - https://github.com/martinpaljak/M2Crypto/commit/84977c532c2444c5487db57146d81bb68dd5431d +# - http://stackoverflow.com/questions/10547332/install-m2crypto-on-a-virtualenv-without-system-packages +# - http://stackoverflow.com/questions/8206546/undefined-symbol-sslv2-method + +# In short: Python has been built without SSLv2 support, and +# github.com/M2Crypto/M2Crypto version doesn't contain necessary +# patch, but it's the only one that has a patch for newer versions of +# swig... + +# Problem seems not exists on Python 2.7. It's unlikely that the +# target distribution has swig 3.0.5+ and doesn't have Python 2.7, so +# this file should only be used in conjuction with Python 2.6. diff --git a/requirements.txt b/requirements.txt index 6c2535a9d..0f0223dab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,3 @@ # https://github.com/bw2/ConfigArgParse/issues/17 -e git+https://github.com/kuba/ConfigArgParse.git@python2.6#egg=ConfigArgParse - -# Support swig 3.0.5 -# https://github.com/M2Crypto/M2Crypto/issues/24 -# https://github.com/M2Crypto/M2Crypto/pull/30 -git+https://github.com/M2Crypto/M2Crypto.git@d13a3a46c8934c5f50b31d5f95b23e6e06f845c3#egg=M2Crypto - -e . From d585b4468014f9e2ad27a94f1256ad8ddf5a7353 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 12 Jun 2015 12:58:17 +0000 Subject: [PATCH 22/72] Nit: character upper case fixes. --- docs/using.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/using.rst b/docs/using.rst index 909c6eadc..01dda3ce6 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -30,14 +30,14 @@ are provided mainly for the :ref:`developers ` reference. In general: * ``sudo`` is required as a suggested way of running privileged process -* `swig`_ is required for compiling `m2crypto`_ +* `SWIG`_ is required for compiling `M2Crypto`_ .. note:: If your operating system uses SWIG 3.0.5+, you will need to run ``pip install -r requirements-swig-3.0.5.txt -r requirements.txt`` instead of the standard ``pip install -r requirements.txt``. -* `augeas`_ is required for the ``python-augeas`` bindings +* `Augeas`_ is required for the Python bindings Ubuntu @@ -90,6 +90,6 @@ The letsencrypt commandline tool has a builtin help: ./venv/bin/letsencrypt --help -.. _augeas: http://augeas.net/ -.. _m2crypto: https://github.com/M2Crypto/M2Crypto -.. _swig: http://www.swig.org/ +.. _Augeas: http://augeas.net/ +.. _M2Crypto: https://github.com/M2Crypto/M2Crypto +.. _SWIG: http://www.swig.org/ From 896d2be1db884fb675e1abf66d48e3b1cc1fb430 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sun, 14 Jun 2015 10:01:24 +0000 Subject: [PATCH 23/72] SimpleHTTP.tls -> SimpleHTTPResponse.tls bug, MAX_PATH_LEN, good_path, scheme --- acme/challenges.py | 34 +++++++++++++++---- acme/challenges_test.py | 45 +++++++++++++++++--------- letsencrypt/tests/acme_util.py | 8 ++--- letsencrypt/tests/auth_handler_test.py | 8 ++--- 4 files changed, 66 insertions(+), 29 deletions(-) diff --git a/acme/challenges.py b/acme/challenges.py index 26f71a2e3..05dc89fc4 100644 --- a/acme/challenges.py +++ b/acme/challenges.py @@ -46,7 +46,6 @@ class SimpleHTTP(DVChallenge): """ACME "simpleHttp" challenge.""" typ = "simpleHttp" token = jose.Field("token") - tls = jose.Field("tls", default=True, omitempty=True) @ChallengeResponse.register @@ -54,20 +53,43 @@ class SimpleHTTPResponse(ChallengeResponse): """ACME "simpleHttp" challenge response.""" typ = "simpleHttp" path = jose.Field("path") + tls = jose.Field("tls", default=True, omitempty=True) - URI_TEMPLATE = "https://{domain}/.well-known/acme-challenge/{path}" - """URI template for HTTPS server provisioned resource.""" + URI_ROOT_PATH = ".well-known/acme-challenge" + """URI root path for the server provisioned resource.""" + + _URI_TEMPLATE = "{scheme}://{domain}/" + URI_ROOT_PATH + "/{path}" + + MAX_PATH_LEN = 25 + """Maximum allowed `path` length.""" + + @property + def good_path(self): + """Is `path` good? + + .. todo:: acme-spec: "The value MUST be comprised entirely of + haracters from the URL-safe alphabet for Base64 encoding + [RFC4648]", base64.b64decode ignores those characters + + """ + return len(self.path) <= 25 + + @property + def scheme(self): + """URL scheme for the provisioned resource.""" + return "https" if self.tls else "http" def uri(self, domain): """Create an URI to the provisioned resource. - Forms an URI to the HTTPS server provisioned resource (containing - :attr:`~SimpleHTTP.token`) by populating the :attr:`URI_TEMPLATE`. + Forms an URI to the HTTPS server provisioned resource + (containing :attr:`~SimpleHTTP.token`). :param str domain: Domain name being verified. """ - return self.URI_TEMPLATE.format(domain=domain, path=self.path) + return self._URI_TEMPLATE.format( + scheme=self.scheme, domain=domain, path=self.path) @Challenge.register diff --git a/acme/challenges_test.py b/acme/challenges_test.py index beeec6f73..4c61c0e3d 100644 --- a/acme/challenges_test.py +++ b/acme/challenges_test.py @@ -27,17 +27,8 @@ class SimpleHTTPTest(unittest.TestCase): self.jmsg = { 'type': 'simpleHttp', 'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA', - 'tls': True, } - def test_no_tls(self): - from acme.challenges import SimpleHTTP - self.assertEqual(SimpleHTTP(token='tok', tls=False).to_json(), { - 'tls': False, - 'token': 'tok', - 'type': 'simpleHttp', - }) - def test_to_partial_json(self): self.assertEqual(self.jmsg, self.msg.to_partial_json()) @@ -54,27 +45,51 @@ class SimpleHTTPResponseTest(unittest.TestCase): def setUp(self): from acme.challenges import SimpleHTTPResponse - self.msg = SimpleHTTPResponse(path='6tbIMBC5Anhl5bOlWT5ZFA') - self.jmsg = { + self.msg_http = SimpleHTTPResponse( + path='6tbIMBC5Anhl5bOlWT5ZFA', tls=False) + self.msg_https = SimpleHTTPResponse(path='6tbIMBC5Anhl5bOlWT5ZFA') + self.jmsg_http = { 'type': 'simpleHttp', 'path': '6tbIMBC5Anhl5bOlWT5ZFA', + 'tls': False, + } + self.jmsg_https = { + 'type': 'simpleHttp', + 'path': '6tbIMBC5Anhl5bOlWT5ZFA', + 'tls': True, } + def test_good_path(self): + self.assertTrue(self.msg_http.good_path) + self.assertTrue(self.msg_https.good_path) + self.assertFalse( + self.msg_http.update(path=(self.msg_http.path * 10)).good_path) + + def test_scheme(self): + self.assertEqual('http', self.msg_http.scheme) + self.assertEqual('https', self.msg_https.scheme) + def test_uri(self): + self.assertEqual('http://example.com/.well-known/acme-challenge/' + '6tbIMBC5Anhl5bOlWT5ZFA', self.msg_http.uri('example.com')) self.assertEqual('https://example.com/.well-known/acme-challenge/' - '6tbIMBC5Anhl5bOlWT5ZFA', self.msg.uri('example.com')) + '6tbIMBC5Anhl5bOlWT5ZFA', self.msg_https.uri('example.com')) def test_to_partial_json(self): - self.assertEqual(self.jmsg, self.msg.to_partial_json()) + self.assertEqual(self.jmsg_http, self.msg_http.to_partial_json()) + self.assertEqual(self.jmsg_https, self.msg_https.to_partial_json()) def test_from_json(self): from acme.challenges import SimpleHTTPResponse self.assertEqual( - self.msg, SimpleHTTPResponse.from_json(self.jmsg)) + self.msg_http, SimpleHTTPResponse.from_json(self.jmsg_http)) + self.assertEqual( + self.msg_https, SimpleHTTPResponse.from_json(self.jmsg_https)) def test_from_json_hashable(self): from acme.challenges import SimpleHTTPResponse - hash(SimpleHTTPResponse.from_json(self.jmsg)) + hash(SimpleHTTPResponse.from_json(self.jmsg_http)) + hash(SimpleHTTPResponse.from_json(self.jmsg_https)) class DVSNITest(unittest.TestCase): diff --git a/letsencrypt/tests/acme_util.py b/letsencrypt/tests/acme_util.py index 51bb3cfbb..daf651059 100644 --- a/letsencrypt/tests/acme_util.py +++ b/letsencrypt/tests/acme_util.py @@ -16,7 +16,7 @@ KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( "acme.jose", os.path.join("testdata", "rsa512_key.pem")))) # Challenges -SIMPLE_HTTPS = challenges.SimpleHTTP( +SIMPLE_HTTP = challenges.SimpleHTTP( token="evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA") DVSNI = challenges.DVSNI( r="O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6\xbf'\xb3" @@ -47,7 +47,7 @@ POP = challenges.ProofOfPossession( ) ) -CHALLENGES = [SIMPLE_HTTPS, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP] +CHALLENGES = [SIMPLE_HTTP, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP] DV_CHALLENGES = [chall for chall in CHALLENGES if isinstance(chall, challenges.DVChallenge)] CONT_CHALLENGES = [chall for chall in CHALLENGES @@ -86,13 +86,13 @@ def chall_to_challb(chall, status): # pylint: disable=redefined-outer-name # Pending ChallengeBody objects DVSNI_P = chall_to_challb(DVSNI, messages2.STATUS_PENDING) -SIMPLE_HTTPS_P = chall_to_challb(SIMPLE_HTTPS, messages2.STATUS_PENDING) +SIMPLE_HTTP_P = chall_to_challb(SIMPLE_HTTP, 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, +CHALLENGES_P = [SIMPLE_HTTP_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)] diff --git a/letsencrypt/tests/auth_handler_test.py b/letsencrypt/tests/auth_handler_test.py index d7fd2c093..8cbc0e604 100644 --- a/letsencrypt/tests/auth_handler_test.py +++ b/letsencrypt/tests/auth_handler_test.py @@ -300,7 +300,7 @@ class GenChallengePathTest(unittest.TestCase): def test_common_case(self): """Given DVSNI and SimpleHTTP with appropriate combos.""" - challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTPS_P) + challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTP_P) prefs = [challenges.DVSNI] combos = ((0,), (1,)) @@ -315,7 +315,7 @@ class GenChallengePathTest(unittest.TestCase): challbs = (acme_util.RECOVERY_TOKEN_P, acme_util.RECOVERY_CONTACT_P, acme_util.DVSNI_P, - acme_util.SIMPLE_HTTPS_P) + acme_util.SIMPLE_HTTP_P) prefs = [challenges.RecoveryToken, challenges.DVSNI] combos = acme_util.gen_combos(challbs) self.assertEqual(self._call(challbs, prefs, combos), (0, 2)) @@ -328,7 +328,7 @@ class GenChallengePathTest(unittest.TestCase): acme_util.RECOVERY_CONTACT_P, acme_util.POP_P, acme_util.DVSNI_P, - acme_util.SIMPLE_HTTPS_P, + acme_util.SIMPLE_HTTP_P, acme_util.DNS_P) # Typical webserver client that can do everything except DNS # Attempted to make the order realistic @@ -413,7 +413,7 @@ class IsPreferredTest(unittest.TestCase): def test_mutually_exclusvie(self): self.assertFalse( self._call( - acme_util.DVSNI_P, frozenset([acme_util.SIMPLE_HTTPS_P]))) + acme_util.DVSNI_P, frozenset([acme_util.SIMPLE_HTTP_P]))) def test_mutually_exclusive_same_type(self): self.assertTrue( From d4b9499e2b6f35335e56d512688a23c85763c514 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sun, 14 Jun 2015 09:46:13 +0000 Subject: [PATCH 24/72] ManualAuthenticator for SimpleHTTP. Inspired by quite popular [1] letsencrypt-nosudo [2] by @diafygi. Together with #440 and #473, it allows Let's Encrypt to be used without sudo (root) on the target machine (c.f. [3]). Possibly fixes #500. [1] https://news.ycombinator.com/item?id=9707170 [2] https://github.com/diafygi/letsencrypt-nosudo [3] https://groups.google.com/a/letsencrypt.org/forum/#!topic/client-dev/JAqxSvXlln4 --- docs/api/plugins/manual.rst | 5 ++ letsencrypt/plugins/manual.py | 138 +++++++++++++++++++++++++++++ letsencrypt/plugins/manual_test.py | 59 ++++++++++++ setup.py | 1 + 4 files changed, 203 insertions(+) create mode 100644 docs/api/plugins/manual.rst create mode 100644 letsencrypt/plugins/manual.py create mode 100644 letsencrypt/plugins/manual_test.py diff --git a/docs/api/plugins/manual.rst b/docs/api/plugins/manual.rst new file mode 100644 index 000000000..4661ab7df --- /dev/null +++ b/docs/api/plugins/manual.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.plugins.manual` +--------------------------------- + +.. automodule:: letsencrypt.plugins.manual + :members: diff --git a/letsencrypt/plugins/manual.py b/letsencrypt/plugins/manual.py new file mode 100644 index 000000000..c4c7b890a --- /dev/null +++ b/letsencrypt/plugins/manual.py @@ -0,0 +1,138 @@ +"""Manual plugin.""" +import logging +import os +import sys + +import requests +import zope.component +import zope.interface + +from acme import challenges +from acme import jose + +from letsencrypt import interfaces +from letsencrypt.plugins import common + + +class ManualAuthenticator(common.Plugin): + """Manual Authenticator. + + .. todo:: Support for `~.challenges.DVSNI`. + + """ + zope.interface.implements(interfaces.IAuthenticator) + zope.interface.classProvides(interfaces.IPluginFactory) + + description = "Manual Authenticator" + + MESSAGE_TEMPLATE = """\ +Make sure your web server displays the following content at +{uri} before continuing: + +{achall.token} + +If you don't have HTTP server configured, you can run the following +command on the target server (as root): + +{command} +""" + + HTTP_TEMPLATE = """\ +mkdir -p {response.URI_ROOT_PATH} +echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path} +# run only once per server: +python -m SimpleHTTPServer 80""" + """Non-TLS command template.""" + + # https://www.piware.de/2011/01/creating-an-https-server-in-python/ + HTTPS_TEMPLATE = """\ +mkdir -p {response.URI_ROOT_PATH} # run only once per server +echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path} +# run only once per server: +openssl req -new -newkey rsa:4096 -subj "/" -days 1 -nodes -x509 -keyout key.pem -out cert.pem +python -c "import BaseHTTPServer, SimpleHTTPServer, ssl; \\ +s = BaseHTTPServer.HTTPServer(('', 443), SimpleHTTPServer.SimpleHTTPRequestHandler); \\ +s.socket = ssl.wrap_socket(s.socket, keyfile='key.pem', certfile='cert.pem'); \\ +s.serve_forever()" """ + """TLS command template. + + According to the ACME specification, "the ACME server MUST ignore + the certificate provided by the HTTPS server", so the first command + generates temporary self-signed certificate. For the same reason + ``requests.get`` in `_verify` sets ``verify=False``. Python HTTPS + server command serves the ``token`` on all URIs. + + """ + + def __init__(self, *args, **kwargs): + super(ManualAuthenticator, self).__init__(*args, **kwargs) + self.template = (self.HTTP_TEMPLATE if self.config.no_simple_http_tls + else self.HTTPS_TEMPLATE) + + def prepare(self): # pylint: disable=missing-docstring,no-self-use + pass # pragma: no cover + + def more_info(self): # pylint: disable=missing-docstring,no-self-use + return """\ +This plugin requires user's manual intervention in setting up a HTTP +server for solving SimpleHTTP challenges and thus does not need to be +run as a privilidged process. Alternatively shows instructions on how +to use Python's built-in HTTP server and, in case of HTTPS, openssl +binary for temporary key/certificate generation.""".replace("\n", "") + + def get_chall_pref(self, domain): + # pylint: disable=missing-docstring,no-self-use,unused-argument + return [challenges.SimpleHTTP] + + def perform(self, achalls): # pylint: disable=missing-docstring + responses = [] + # TODO: group achalls by the same socket.gethostbyname(_ex) + # and prompt only once per server (one "echo -n" per domain) + for achall in achalls: + responses.append(self._perform_single(achall)) + return responses + + def _perform_single(self, achall): + # same path for each challenge response would be easier for + # users, but will not work if multiple domains point at the + # same server: default command doesn't support virtual hosts + response = challenges.SimpleHTTPResponse( + path=jose.b64encode(os.urandom(18)), + tls=(not self.config.no_simple_http_tls)) + assert response.good_path # is encoded os.urandom(18) good? + + self._notify_and_wait(self.MESSAGE_TEMPLATE.format( + achall=achall, response=response, + uri=response.uri(achall.domain), + command=self.template.format(achall=achall, response=response))) + + if self._verify(achall, response): + return response + else: + return None + + def _notify_and_wait(self, message): # pylint: disable=no-self-use + # TODO: IDisplay wraps messages, breaking the command + #answer = zope.component.getUtility(interfaces.IDisplay).notification( + # message=message, height=25, pause=True) + sys.stdout.write(message) + raw_input("Press ENTER to continue") + + def _verify(self, achall, chall_response): # pylint: disable=no-self-use + uri = chall_response.uri(achall.domain) + logging.debug("Verifying %s...", uri) + try: + response = requests.get(uri, verify=False) + except requests.exceptions.ConnectionError as error: + logging.exception(error) + return False + + ret = response.text == achall.token + if not ret: + logging.error("Unable to verify %s! Expected: %r, returned: %r.", + uri, achall.token, response.text) + + return ret + + def cleanup(self, achalls): # pylint: disable=missing-docstring,no-self-use + pass # pragma: no cover diff --git a/letsencrypt/plugins/manual_test.py b/letsencrypt/plugins/manual_test.py new file mode 100644 index 000000000..c95654dec --- /dev/null +++ b/letsencrypt/plugins/manual_test.py @@ -0,0 +1,59 @@ +"""Tests for letsencrypt.plugins.manual.""" +import unittest + +import mock +import requests + +from acme import challenges + +from letsencrypt import achallenges +from letsencrypt.tests import acme_util + + +class ManualAuthenticatorTest(unittest.TestCase): + """Tests for letsencrypt.plugins.manual.ManualAuthenticator.""" + + def setUp(self): + from letsencrypt.plugins.manual import ManualAuthenticator + self.config = mock.MagicMock(no_simple_http_tls=True) + self.auth = ManualAuthenticator(config=self.config, name="manual") + self.achalls = [achallenges.SimpleHTTP( + challb=acme_util.SIMPLE_HTTP, domain="foo.com", key=None)] + + def test_more_info(self): + self.assertTrue(isinstance(self.auth.more_info(), str)) + + def test_get_chall_pref(self): + self.assertTrue(all(issubclass(pref, challenges.Challenge) + for pref in self.auth.get_chall_pref("foo.com"))) + + def test_perform_empty(self): + self.assertEqual([], self.auth.perform([])) + + @mock.patch("letsencrypt.plugins.manual.sys.stdout") + @mock.patch("letsencrypt.plugins.manual.os.urandom") + @mock.patch("letsencrypt.plugins.manual.requests.get") + @mock.patch("__builtin__.raw_input") + def test_perform(self, mock_raw_input, mock_get, mock_urandom, mock_stdout): + mock_urandom.return_value = "foo" + mock_get().text = self.achalls[0].token + + self.assertEqual( + [challenges.SimpleHTTPResponse(tls=False, path='Zm9v')], + self.auth.perform(self.achalls)) + mock_raw_input.assert_called_once() + mock_get.assert_called_with( + "http://foo.com/.well-known/acme-challenge/Zm9v", verify=False) + + message = mock_stdout.write.mock_calls[0][1][0] + self.assertTrue(self.achalls[0].token in message) + self.assertTrue('Zm9v' in message) + + mock_get().text = self.achalls[0].token + '!' + self.assertEqual([None], self.auth.perform(self.achalls)) + + mock_get.side_effect = requests.exceptions.ConnectionError + self.assertEqual([None], self.auth.perform(self.achalls)) + +if __name__ == "__main__": + unittest.main() # pragma: no cover diff --git a/setup.py b/setup.py index 145b75a69..16aafac94 100644 --- a/setup.py +++ b/setup.py @@ -120,6 +120,7 @@ setup( 'jws = letsencrypt.acme.jose.jws:CLI.run', ], 'letsencrypt.plugins': [ + 'manual = letsencrypt.plugins.manual:ManualAuthenticator', 'standalone = letsencrypt.plugins.standalone.authenticator' ':StandaloneAuthenticator', From 63d5273ed1692cd4c02af9e0e421ff362b2d06b9 Mon Sep 17 00:00:00 2001 From: PatrickHeppler Date: Thu, 18 Jun 2015 14:55:12 +0200 Subject: [PATCH 25/72] Create centos.sh --- bootstrap/centos.sh | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 bootstrap/centos.sh diff --git a/bootstrap/centos.sh b/bootstrap/centos.sh new file mode 100644 index 000000000..318a47a8a --- /dev/null +++ b/bootstrap/centos.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +# Tested with: Centos 7 on AWS EC2 t2.micro (x64) + +yum install -y \ + git \ + python \ + python-devel \ + python-virtualenv \ + python-devel \ + gcc \ + swig \ + dialog \ + augeas-libs \ + openssl-devel \ + libffi-devel \ + ca-certificates \ + python-setuptools \ + readline-devel From 8d0334d2de9f355d867210be01f74f25c6c00e2c Mon Sep 17 00:00:00 2001 From: PatrickHeppler Date: Thu, 18 Jun 2015 14:58:55 +0200 Subject: [PATCH 26/72] Update using.rst Additional informations about installing on Centos 7 --- docs/using.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/using.rst b/docs/using.rst index 89cbc48f6..ddfc67738 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -64,6 +64,11 @@ Mac OSX sudo ./bootstrap/mac.sh +Centos 7 +-------- +.. code-block:: shell + + sudo ./bootstrap/centos.sh Installation ============ @@ -73,6 +78,13 @@ Installation virtualenv --no-site-packages -p python2 venv ./venv/bin/pip install -r requirements.txt +Installation on Centos 7 +============ + +.. code-block:: shell + + virtualenv --no-site-packages -p python2 venv + env SWIG_FEATURES="-cpperraswarn -includeall -D__`uname -m`__ -I/usr/include/openssl" ./venv/bin/python setup.py install Usage ===== From 85d9047f4efb9ef6058ab4a4b55222f55ac7be16 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 18 Jun 2015 13:13:29 +0000 Subject: [PATCH 27/72] Fedora: augeas -> augeas-libs --- bootstrap/fedora.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap/fedora.sh b/bootstrap/fedora.sh index 0b919b4ce..97ea1e637 100755 --- a/bootstrap/fedora.sh +++ b/bootstrap/fedora.sh @@ -12,7 +12,7 @@ yum install -y \ gcc \ swig \ dialog \ - augeas \ + augeas-libs \ openssl-devel \ libffi-devel \ ca-certificates \ From 9b4cff8cd60c0d8e4d046ff1c05ccff2db0d0a44 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 18 Jun 2015 13:55:32 +0000 Subject: [PATCH 28/72] bootstrap: _rpm_common.sh, centos fixes --- bootstrap/_rpm_common.sh | 20 ++++++++++++++++++++ bootstrap/centos.sh | 20 +------------------- bootstrap/fedora.sh | 19 +------------------ docs/using.rst | 18 +++++++++++------- 4 files changed, 33 insertions(+), 44 deletions(-) create mode 100755 bootstrap/_rpm_common.sh mode change 100644 => 120000 bootstrap/centos.sh mode change 100755 => 120000 bootstrap/fedora.sh diff --git a/bootstrap/_rpm_common.sh b/bootstrap/_rpm_common.sh new file mode 100755 index 000000000..1209cd44a --- /dev/null +++ b/bootstrap/_rpm_common.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# Tested with: +# - Fedora 22 (x64) +# - Centos 7 (x64: on AWS EC2 t2.micro, DigitalOcean droplet) + +# "git-core" seems to be an alias for "git" in CentOS 7 (yum search fails) +yum install -y \ + git-core \ + python \ + python-devel \ + python-virtualenv \ + python-devel \ + gcc \ + swig \ + dialog \ + augeas-libs \ + openssl-devel \ + libffi-devel \ + ca-certificates \ diff --git a/bootstrap/centos.sh b/bootstrap/centos.sh deleted file mode 100644 index 318a47a8a..000000000 --- a/bootstrap/centos.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh - -# Tested with: Centos 7 on AWS EC2 t2.micro (x64) - -yum install -y \ - git \ - python \ - python-devel \ - python-virtualenv \ - python-devel \ - gcc \ - swig \ - dialog \ - augeas-libs \ - openssl-devel \ - libffi-devel \ - ca-certificates \ - python-setuptools \ - readline-devel diff --git a/bootstrap/centos.sh b/bootstrap/centos.sh new file mode 120000 index 000000000..a0db46d70 --- /dev/null +++ b/bootstrap/centos.sh @@ -0,0 +1 @@ +_rpm_common.sh \ No newline at end of file diff --git a/bootstrap/fedora.sh b/bootstrap/fedora.sh deleted file mode 100755 index 97ea1e637..000000000 --- a/bootstrap/fedora.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh - -# Tested with: -# - Fedora 22 (x64) - -yum install -y \ - git-core \ - python \ - python-devel \ - python-virtualenv \ - python-devel \ - gcc \ - swig \ - dialog \ - augeas-libs \ - openssl-devel \ - libffi-devel \ - ca-certificates \ diff --git a/bootstrap/fedora.sh b/bootstrap/fedora.sh new file mode 120000 index 000000000..a0db46d70 --- /dev/null +++ b/bootstrap/fedora.sh @@ -0,0 +1 @@ +_rpm_common.sh \ No newline at end of file diff --git a/docs/using.rst b/docs/using.rst index 0e0b493ca..ef64452e1 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -70,12 +70,23 @@ Mac OSX sudo ./bootstrap/mac.sh + Centos 7 -------- + .. code-block:: shell sudo ./bootstrap/centos.sh +For installation run this modified command (note the trailing +backslash): + +.. code-block:: shell + + SWIG_FEATURES="-includeall -D__`uname -m`__-I/usr/include/openssl" \ + ./venv/bin/pip install -r requirements.txt functools32 + + Installation ============ @@ -84,13 +95,6 @@ Installation virtualenv --no-site-packages -p python2 venv ./venv/bin/pip install -r requirements.txt -Installation on Centos 7 -============ - -.. code-block:: shell - - virtualenv --no-site-packages -p python2 venv - env SWIG_FEATURES="-cpperraswarn -includeall -D__`uname -m`__ -I/usr/include/openssl" ./venv/bin/python setup.py install Usage ===== From db6f9ecf862f0175a02cb8d46108ab2f8ec7cc9a Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 18 Jun 2015 13:59:59 +0000 Subject: [PATCH 29/72] Fedora installation instructions. --- docs/using.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/using.rst b/docs/using.rst index ef64452e1..227fd69ed 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -32,6 +32,7 @@ In general: * ``sudo`` is required as a suggested way of running privileged process * `swig`_ is required for compiling `m2crypto`_ + .. _new-swig: .. note:: If your operating system uses SWIG 3.0.5+, you will need to run ``pip install -r requirements-swig-3.0.5.txt -r requirements.txt`` instead of the standard ``pip @@ -71,6 +72,16 @@ Mac OSX sudo ./bootstrap/mac.sh +Fedora +------ + +.. code-block:: shell + + sudo ./bootstrap/fedora.sh + +.. note:: Fedora 22 uses SWIG 3.0.5+, use the :ref:`modified pip + command for installation `. + Centos 7 -------- From a873e8ea33ec4c6de298aadfe8699d555e56f3ba Mon Sep 17 00:00:00 2001 From: William Budington Date: Thu, 18 Jun 2015 17:45:04 -0700 Subject: [PATCH 30/72] functools32 required - add to setup.py --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 145b75a69..ebcc2c9b3 100644 --- a/setup.py +++ b/setup.py @@ -53,6 +53,7 @@ install_requires = [ # order of items in install_requires DOES matter and M2Crypto has # to go last, see #152 'M2Crypto', + 'functools32' ] dev_extras = [ From b1bb5a6843618dfdc1480a4328d9ff9cdf17b468 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 18 Jun 2015 18:02:51 -0700 Subject: [PATCH 31/72] Make sure cleanup_challenges happens --- letsencrypt/auth_handler.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index 5f9d29e6e..5665fe83d 100644 --- a/letsencrypt/auth_handler.py +++ b/letsencrypt/auth_handler.py @@ -134,9 +134,11 @@ class AuthHandler(object): self._send_responses(self.cont_c, cont_resp, chall_update)) # 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) + try: + self._poll_challenges(chall_update, best_effort) + finally: + # 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. From dee1b7f04921aa5b6e193f20681ef1883db09046 Mon Sep 17 00:00:00 2001 From: William Budington Date: Thu, 18 Jun 2015 18:35:35 -0700 Subject: [PATCH 32/72] Remove support for python2.6 --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 167d6ad74..9169a32d7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,7 +12,6 @@ after_success: '[ "$TOXENV" == "cover" ] && coveralls' # matrix, which allows us to clearly distinguish which component under # test has failed env: - - TOXENV=py26 - TOXENV=py27 - TOXENV=lint - TOXENV=cover From 8afc26a7362ab6aa7705125eb1da4780e7a9967d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 04:10:51 +0000 Subject: [PATCH 33/72] Fix typo --- acme/challenges.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/challenges.py b/acme/challenges.py index 05dc89fc4..9ea06645d 100644 --- a/acme/challenges.py +++ b/acme/challenges.py @@ -68,7 +68,7 @@ class SimpleHTTPResponse(ChallengeResponse): """Is `path` good? .. todo:: acme-spec: "The value MUST be comprised entirely of - haracters from the URL-safe alphabet for Base64 encoding + characters from the URL-safe alphabet for Base64 encoding [RFC4648]", base64.b64decode ignores those characters """ From b3be239061e1e2015987487b46f77382038209ad Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 08:14:25 +0000 Subject: [PATCH 34/72] Fix merge conflicts between #486 and #510 (pip install .). --- docs/using.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/using.rst b/docs/using.rst index a180a3826..bb27ad8c2 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -35,8 +35,8 @@ In general: .. _new-swig: .. note:: If your operating system uses SWIG 3.0.5+, you will need to run ``pip install -r requirements-swig-3.0.5.txt -r - requirements.txt`` instead of the standard ``pip - install -r requirements.txt``. + requirements.txt .`` instead of the standard ``pip + install -r requirements.txt .``. * `Augeas`_ is required for the Python bindings @@ -95,7 +95,7 @@ backslash): .. code-block:: shell SWIG_FEATURES="-includeall -D__`uname -m`__-I/usr/include/openssl" \ - ./venv/bin/pip install -r requirements.txt functools32 + ./venv/bin/pip install -r requirements.txt functools32 . Installation From 5f41c9f191022ff03b7d4605b000fecee60689d9 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 08:16:03 +0000 Subject: [PATCH 35/72] Dockerfile: note about missing requirements.txt --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index b6a07388c..78aa7a75b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,7 @@ COPY letsencrypt_apache /opt/letsencrypt/src/letsencrypt_apache/ COPY letsencrypt_nginx /opt/letsencrypt/src/letsencrypt_nginx/ +# requirements.txt not installed! RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv && \ /opt/letsencrypt/venv/bin/pip install -e /opt/letsencrypt/src From ed7ba282119cdb592b1e9e11d2d51bc2adbda7a8 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 07:36:45 +0000 Subject: [PATCH 36/72] Pin jsonschema (quickfix for missing functools32). https://github.com/Julian/jsonschema/issues/233 --- docs/using.rst | 2 +- setup.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/using.rst b/docs/using.rst index bb27ad8c2..e377e74ab 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -95,7 +95,7 @@ backslash): .. code-block:: shell SWIG_FEATURES="-includeall -D__`uname -m`__-I/usr/include/openssl" \ - ./venv/bin/pip install -r requirements.txt functools32 . + ./venv/bin/pip install -r requirements.txt . Installation diff --git a/setup.py b/setup.py index ebcc2c9b3..46f53244c 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ install_requires = [ 'argparse', 'ConfigArgParse', 'configobj', - 'jsonschema', + 'jsonschema<2.5.1', # https://github.com/Julian/jsonschema/issues/233 'mock', 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) 'parsedatetime', @@ -53,7 +53,6 @@ install_requires = [ # order of items in install_requires DOES matter and M2Crypto has # to go last, see #152 'M2Crypto', - 'functools32' ] dev_extras = [ From 6d2c81138e5ffd4d0102ec0595a0184d6063e109 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 07:38:05 +0000 Subject: [PATCH 37/72] Revert "Remove support for python2.6" This reverts commit dee1b7f04921aa5b6e193f20681ef1883db09046. --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 9169a32d7..167d6ad74 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,7 @@ after_success: '[ "$TOXENV" == "cover" ] && coveralls' # matrix, which allows us to clearly distinguish which component under # test has failed env: + - TOXENV=py26 - TOXENV=py27 - TOXENV=lint - TOXENV=cover From dbd024f77cc6dfba1213fb893ae2f78943ad5f6b Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 10:07:18 +0000 Subject: [PATCH 38/72] Inline docs fixes --- letsencrypt/network2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/network2.py b/letsencrypt/network2.py index 9b846da6c..f2620b4b1 100644 --- a/letsencrypt/network2.py +++ b/letsencrypt/network2.py @@ -25,7 +25,7 @@ class Network(object): .. todo:: Clean up raised error types hierarchy, document, and handle (wrap) - instances of `.DeserializationError` raised in `from_json()``. + instances of `.DeserializationError` raised in `from_json()`. :ivar str new_reg_uri: Location of new-reg :ivar key: `.JWK` (private) From f46e2aeedd69ebe62481f750eb190471b1975676 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 10:06:39 +0000 Subject: [PATCH 39/72] README: documentation link at the top. Hopefully helps to mitigate problems mentioned in https://groups.google.com/a/letsencrypt.org/forum/#!topic/client-dev/4xpVpy4EVz0 --- README.rst | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index db32889db..5b149abe5 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,9 @@ +.. notice for github users + +Official **documentation**, including `installation instructions`_, is +available at https://letsencrypt.readthedocs.org. + + About the Let's Encrypt Client ============================== @@ -47,6 +53,9 @@ server automatically!:: :target: https://quay.io/repository/letsencrypt/lets-encrypt-preview :alt: Docker Repository on Quay.io +.. _`installation instructions`: + https://letsencrypt.readthedocs.org/en/latest/using.html + .. _watch demo video: https://www.youtube.com/watch?v=Gas_sSB-5SU @@ -85,7 +94,7 @@ Current Features Links ----- -Documentation: https://letsencrypt.readthedocs.org/ +Documentation: https://letsencrypt.readthedocs.org Software project: https://github.com/letsencrypt/lets-encrypt-preview From 3382dac793fee6b06618c8aa6d27b8580ab4525e Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 10:17:15 +0000 Subject: [PATCH 40/72] README: FAQ link at the top. --- README.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.rst b/README.rst index 5b149abe5..40c054fe3 100644 --- a/README.rst +++ b/README.rst @@ -3,6 +3,10 @@ Official **documentation**, including `installation instructions`_, is available at https://letsencrypt.readthedocs.org. +Generic information about Let's Encrypt project can be found at +https://letsencrypt.org. Please read `Frequently Asked Questions (FAQ) +`_. + About the Let's Encrypt Client ============================== From a1f5ea8e8ce81e1415c6da98325a39dce1f7f9c9 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 09:55:33 +0000 Subject: [PATCH 41/72] Docs: note about pip editable mode. --- docs/contributing.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/contributing.rst b/docs/contributing.rst index f527ba421..eb9854581 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -17,6 +17,15 @@ Now you can install the development packages: ./venv/bin/pip install -r requirements.txt -e .[dev,docs,testing] +.. note:: `-e` (short for `--editable`) turns on *editable mode* in + which any source code changes in the current working + directory are "live" and no further `pip install ...` + invocations are necessary while developing. Any `pip install + .` call will re-install Let's Encrypt in non-editable mode. + + This is roughly equivalent to `python setup.py develop`. For + more info see `man pip`. + The code base, including your pull requests, **must** have 100% test statement coverage **and** be compliant with the :ref:`coding style `. From 673a6d4f3710718b7f82e48f7de55d6636dfbc04 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 10:41:04 +0000 Subject: [PATCH 42/72] Docs: move SWIG notes below installation cmd, Mac OS X note. --- docs/using.rst | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/docs/using.rst b/docs/using.rst index a180a3826..6709ad51a 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -31,13 +31,6 @@ In general: * ``sudo`` is required as a suggested way of running privileged process * `SWIG`_ is required for compiling `M2Crypto`_ - - .. _new-swig: - .. note:: If your operating system uses SWIG 3.0.5+, you will need - to run ``pip install -r requirements-swig-3.0.5.txt -r - requirements.txt`` instead of the standard ``pip - install -r requirements.txt``. - * `Augeas`_ is required for the Python bindings @@ -79,8 +72,6 @@ Fedora sudo ./bootstrap/fedora.sh -.. note:: Fedora 22 uses SWIG 3.0.5+, use the :ref:`modified pip - command for installation `. Centos 7 -------- @@ -106,6 +97,13 @@ Installation virtualenv --no-site-packages -p python2 venv ./venv/bin/pip install -r requirements.txt . +.. note:: If your operating system uses SWIG 3.0.5+, you will need to + run ``pip install -r requirements-swig-3.0.5.txt -r + requirements.txt`` instead. Known affected systems: + + * Fedora 22 + * some versions of Mac OS X + Usage ===== From 1a013eae6e021d763f3a692e10de44add751d96a Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 10:49:18 +0000 Subject: [PATCH 43/72] Docs: no support for setup.py, root, or non-Virtualenv installation. --- docs/using.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/using.rst b/docs/using.rst index 6709ad51a..0014a1192 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -97,6 +97,14 @@ Installation virtualenv --no-site-packages -p python2 venv ./venv/bin/pip install -r requirements.txt . +.. warning:: Please do **not** use ``python setup.py install``. Please + do **not** attempt the installation commands as + superuser/root and/or without Virtualenv_, e.g. ``sudo + python setup.py install``, ``sudo pip install``, ``sudo + ./venv/bin/...``. These modes of operation might corrupt + your operating system and are **not supported** by the + Let's Encrypt team! + .. note:: If your operating system uses SWIG 3.0.5+, you will need to run ``pip install -r requirements-swig-3.0.5.txt -r requirements.txt`` instead. Known affected systems: @@ -118,3 +126,4 @@ The letsencrypt commandline tool has a builtin help: .. _Augeas: http://augeas.net/ .. _M2Crypto: https://github.com/M2Crypto/M2Crypto .. _SWIG: http://www.swig.org/ +.. _Virtualenv: https://virtualenv.pypa.io From ca6b326371a60e5104e5bd53b34ff502ac0839c3 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 11:00:00 +0000 Subject: [PATCH 44/72] Docs: add "Getting the code" section. --- docs/using.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/using.rst b/docs/using.rst index 0014a1192..c829e9c80 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -20,6 +20,25 @@ And follow the instructions. Your new cert will be available in ``/etc/letsencrypt/certs``. +Getting the code +================ + +Please `install Git`_ and run the following commands: + +.. code-block:: shell + + git clone https://github.com/letsencrypt/lets-encrypt-preview + cd lets-encrypt-preview + +Alternatively you could `download the ZIP archive`_ and extract the +snapshot of our repository, but it's strongly recommended to use the +above method instead. + +.. _`install Git`: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git +.. _`download the ZIP archive`: + https://github.com/letsencrypt/lets-encrypt-preview/archive/master.zip + + Prerequisites ============= From 8292eab3f7c6803746c2ff857b82a754b275920b Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 11:01:56 +0000 Subject: [PATCH 45/72] Docs: add link to Docker docs. --- docs/using.rst | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/using.rst b/docs/using.rst index c829e9c80..51c5527f6 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -5,9 +5,9 @@ Using the Let's Encrypt client Quick start =========== -Using docker you can quickly get yourself a testing cert. From the +Using Docker_ you can quickly get yourself a testing cert. From the server that the domain your requesting a cert for resolves to, -download docker, and issue the following command +`install Docker`_, issue the following command: .. code-block:: shell @@ -16,9 +16,12 @@ download docker, and issue the following command -v "/var/lib/letsencrypt:/var/lib/letsencrypt" \ quay.io/letsencrypt/lets-encrypt-preview:latest -And follow the instructions. Your new cert will be available in +and follow the instructions. Your new cert will be available in ``/etc/letsencrypt/certs``. +.. _Docker: https://docker.com +.. _`install Docker`: https://docs.docker.com/docker/userguide/ + Getting the code ================ From b8ebb0ab161c4a27eaaffa14fe6860b44ed96e29 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 11:03:05 +0000 Subject: [PATCH 46/72] Docs: backticks nit. --- docs/using.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/using.rst b/docs/using.rst index 51c5527f6..29e8902a9 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -138,7 +138,7 @@ Installation Usage ===== -The letsencrypt commandline tool has a builtin help: +The ``letsencrypt`` commandline tool has a builtin help: .. code-block:: shell From 4040fd02045d871601c1866f25d8e2da11d78a48 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 11:21:51 +0000 Subject: [PATCH 47/72] Docs: extend usage section with "letsencrypt auth" call. --- docs/using.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/using.rst b/docs/using.rst index 29e8902a9..20f07d5d1 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -138,6 +138,12 @@ Installation Usage ===== +To get a new certificate run: + +.. code-block:: shell + + ./venv/bin/letsencrypt auth + The ``letsencrypt`` commandline tool has a builtin help: .. code-block:: shell From e176ad8f43e3342a346194875c147b66078c8306 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Mon, 15 Jun 2015 18:31:50 +0000 Subject: [PATCH 48/72] Remove old Boulder incompatibility issue --- letsencrypt/network2.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/letsencrypt/network2.py b/letsencrypt/network2.py index f2620b4b1..a20194a79 100644 --- a/letsencrypt/network2.py +++ b/letsencrypt/network2.py @@ -257,13 +257,12 @@ class Network(object): # TODO: Boulder does not set Location or Link on update # (c.f. acme-spec #94) - updated_regr = self._regr_from_response( response, uri=regr.uri, new_authzr_uri=regr.new_authzr_uri, terms_of_service=regr.terms_of_service) if updated_regr != regr: - # TODO: Boulder reregisters with new recoveryToken and new URI raise errors.UnexpectedUpdate(regr) + return updated_regr def agree_to_tos(self, regr): From 4d39699befb583820246580a4ad058828b2e81cb Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 18 Jun 2015 10:56:08 +0000 Subject: [PATCH 49/72] Remove doubled :members: from acme errors docs --- docs/pkgs/acme/index.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/pkgs/acme/index.rst b/docs/pkgs/acme/index.rst index 9cca3b795..1c73a4a42 100644 --- a/docs/pkgs/acme/index.rst +++ b/docs/pkgs/acme/index.rst @@ -51,9 +51,6 @@ Errors :members: - :members: - - Utilities --------- From 23c5a1fd90e1140af4455e32f6e2bf2e270923b9 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 19 Jun 2015 16:13:53 +0000 Subject: [PATCH 50/72] Docs: "." and functools32 adjustements --- docs/using.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/using.rst b/docs/using.rst index 20f07d5d1..96eb62b05 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -108,7 +108,7 @@ backslash): .. code-block:: shell SWIG_FEATURES="-includeall -D__`uname -m`__-I/usr/include/openssl" \ - ./venv/bin/pip install -r requirements.txt functools32 + ./venv/bin/pip install -r requirements.txt . Installation @@ -129,7 +129,7 @@ Installation .. note:: If your operating system uses SWIG 3.0.5+, you will need to run ``pip install -r requirements-swig-3.0.5.txt -r - requirements.txt`` instead. Known affected systems: + requirements.txt .`` instead. Known affected systems: * Fedora 22 * some versions of Mac OS X From 50e509604cfc70a776c682a5735572feac7dda6a Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 20 Jun 2015 10:20:54 +0000 Subject: [PATCH 51/72] Docs: remove wrong re-install comment --- docs/contributing.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index eb9854581..804cec95c 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -20,8 +20,7 @@ Now you can install the development packages: .. note:: `-e` (short for `--editable`) turns on *editable mode* in which any source code changes in the current working directory are "live" and no further `pip install ...` - invocations are necessary while developing. Any `pip install - .` call will re-install Let's Encrypt in non-editable mode. + invocations are necessary while developing. This is roughly equivalent to `python setup.py develop`. For more info see `man pip`. From 061282fa6674bb8b8fc50d61167f5ff45fbe8779 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 18 Jun 2015 20:27:40 +0000 Subject: [PATCH 52/72] Store temporary DVSNI files in IConfig.work_dir. --- letsencrypt_apache/dvsni.py | 2 +- letsencrypt_nginx/dvsni.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt_apache/dvsni.py b/letsencrypt_apache/dvsni.py index fb78cfced..c25426371 100644 --- a/letsencrypt_apache/dvsni.py +++ b/letsencrypt_apache/dvsni.py @@ -141,7 +141,7 @@ class ApacheDvsni(common.Dvsni): """ ips = " ".join(str(i) for i in ip_addrs) document_root = os.path.join( - self.configurator.config.config_dir, "dvsni_page/") + self.configurator.config.work_dir, "dvsni_page/") # TODO: Python docs is not clear how mutliline string literal # newlines are parsed on different platforms. At least on # Linux (Debian sid), when source file uses CRLF, Python still diff --git a/letsencrypt_nginx/dvsni.py b/letsencrypt_nginx/dvsni.py index 620d144f6..0697f6e1e 100644 --- a/letsencrypt_nginx/dvsni.py +++ b/letsencrypt_nginx/dvsni.py @@ -124,7 +124,7 @@ class NginxDvsni(common.Dvsni): """ document_root = os.path.join( - self.configurator.config.config_dir, "dvsni_page") + self.configurator.config.work_dir, "dvsni_page") block = [['listen', str(addr)] for addr in addrs] From 960b070c223b5901ce3b6d1ce7a27748f584eaee Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 14:51:39 +0000 Subject: [PATCH 53/72] Dummy use of network2 in revoker --- letsencrypt/revoker.py | 14 +++++++------- letsencrypt/tests/revoker_test.py | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/letsencrypt/revoker.py b/letsencrypt/revoker.py index a1ea27e71..d173a1907 100644 --- a/letsencrypt/revoker.py +++ b/letsencrypt/revoker.py @@ -16,12 +16,11 @@ import tempfile import Crypto.PublicKey.RSA import M2Crypto -from acme import messages from acme.jose import util as jose_util from letsencrypt import errors from letsencrypt import le_util -from letsencrypt import network +from letsencrypt import network2 from letsencrypt.display import util as display_util from letsencrypt.display import revocation @@ -45,7 +44,9 @@ class Revoker(object): """ def __init__(self, installer, config, no_confirm=False): - self.network = network.Network(config.server) + # XXX + self.network = network2.Network(new_reg_uri=None, key=None, alg=None) + self.installer = installer self.config = config self.no_confirm = no_confirm @@ -238,6 +239,8 @@ class Revoker(object): :returns: TODO """ + # XXX | pylint: disable=unused-variable + # These will both have to change in the future away from M2Crypto # pylint: disable=protected-access certificate = jose_util.ComparableX509(cert._cert) @@ -250,10 +253,7 @@ class Revoker(object): raise errors.LetsEncryptRevokerError( "Corrupted backup key file: %s" % cert.backup_key_path) - # TODO: Catch error associated with already revoked and proceed. - return self.network.send_and_receive_expected( - messages.RevocationRequest.create(certificate=certificate, key=key), - messages.Revocation) + return self.network.revoke(certr=None) # XXX def _remove_certs_keys(self, cert_list): # pylint: disable=no-self-use """Remove certificate and key. diff --git a/letsencrypt/tests/revoker_test.py b/letsencrypt/tests/revoker_test.py index ae04b5081..35e7d132b 100644 --- a/letsencrypt/tests/revoker_test.py +++ b/letsencrypt/tests/revoker_test.py @@ -63,7 +63,7 @@ class RevokerTest(RevokerBase): def tearDown(self): shutil.rmtree(self.backup_dir) - @mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected") + @mock.patch("letsencrypt.network2.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_key_all(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -89,7 +89,7 @@ class RevokerTest(RevokerBase): self.revoker.revoke_from_key, self.key) - @mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected") + @mock.patch("letsencrypt.network2.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_wrong_key(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -105,7 +105,7 @@ class RevokerTest(RevokerBase): # No revocation went through self.assertEqual(mock_net.call_count, 0) - @mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected") + @mock.patch("letsencrypt.network2.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_cert(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -122,7 +122,7 @@ class RevokerTest(RevokerBase): self.assertEqual(mock_net.call_count, 1) - @mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected") + @mock.patch("letsencrypt.network2.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_cert_not_found(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -141,7 +141,7 @@ class RevokerTest(RevokerBase): self.assertEqual(mock_net.call_count, 1) - @mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected") + @mock.patch("letsencrypt.network2.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_menu(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -165,7 +165,7 @@ class RevokerTest(RevokerBase): self.assertEqual(mock_display.more_info_cert.call_count, 1) @mock.patch("letsencrypt.revoker.logging") - @mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected") + @mock.patch("letsencrypt.network2.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_menu_delete_all(self, mock_display, mock_net, mock_log): mock_display().confirm_revocation.return_value = True From c5d4f91bf77612be1bfe0a972922f4cb1ab962a5 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 13 Jun 2015 13:45:50 +0000 Subject: [PATCH 54/72] Remove old messages and network --- acme/messages.py | 367 ------------------------------- acme/messages_test.py | 480 ----------------------------------------- letsencrypt/network.py | 121 ----------- setup.py | 1 - 4 files changed, 969 deletions(-) delete mode 100644 acme/messages.py delete mode 100644 acme/messages_test.py delete mode 100644 letsencrypt/network.py diff --git a/acme/messages.py b/acme/messages.py deleted file mode 100644 index 6d46f894c..000000000 --- a/acme/messages.py +++ /dev/null @@ -1,367 +0,0 @@ -"""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 acme import challenges -from acme import errors -from acme import jose -from acme import other -from acme import util - - -class Message(jose.TypedJSONObjectWithFields): - # _fields_to_partial_json | pylint: disable=abstract-method - # pylint: disable=too-few-public-methods - """ACME message.""" - TYPES = {} - type_field_name = "type" - - schema = NotImplemented - """JSON schema the object is tested against in :meth:`from_json`. - - Subclasses must overrride it with a value that is acceptable by - :func:`jsonschema.validate`, most probably using - :func:`acme.util.load_schema`. - - """ - - @classmethod - def from_json(cls, jobj): - """Deserialize from (possibly invalid) JSON object. - - Note that the input ``jobj`` has not been sanitized in any way. - - :param jobj: JSON object. - - :raises acme.errors.SchemaValidationError: if the input - JSON object could not be validated against JSON schema specified - in :attr:`schema`. - :raises acme.jose.errors.DeserializationError: for any - other generic error in decoding. - - :returns: instance of the class - - """ - msg_cls = cls.get_type_cls(jobj) - - # TODO: is that schema testing still relevant? - try: - jsonschema.validate(jobj, msg_cls.schema) - except jsonschema.ValidationError as error: - raise errors.SchemaValidationError(error) - - return super(Message, cls).from_json(jobj) - - -@Message.register # pylint: disable=too-few-public-methods -class Challenge(Message): - """ACME "challenge" message. - - :ivar str nonce: Random data, **not** base64-encoded. - :ivar list challenges: List of - :class:`~acme.challenges.Challenge` objects. - - .. todo:: - 1. can challenges contain two challenges of the same type? - 2. can challenges contain duplicates? - 3. check "combinations" indices are in valid range - 4. turn "combinations" elements into sets? - 5. turn "combinations" into set? - - """ - typ = "challenge" - schema = util.load_schema(typ) - - session_id = jose.Field("sessionID") - nonce = jose.Field("nonce", encoder=jose.b64encode, - decoder=jose.decode_b64jose) - challenges = jose.Field("challenges") - combinations = jose.Field("combinations", omitempty=True, default=()) - - @challenges.decoder - def challenges(value): # pylint: disable=missing-docstring,no-self-argument - return tuple(challenges.Challenge.from_json(chall) for chall in value) - - @property - def resolved_combinations(self): - """Combinations with challenges instead of indices.""" - return tuple(tuple(self.challenges[idx] for idx in combo) - for combo in self.combinations) - - -@Message.register # pylint: disable=too-few-public-methods -class ChallengeRequest(Message): - """ACME "challengeRequest" message.""" - typ = "challengeRequest" - schema = util.load_schema(typ) - identifier = jose.Field("identifier") - - -@Message.register # pylint: disable=too-few-public-methods -class Authorization(Message): - """ACME "authorization" message. - - :ivar jwk: :class:`acme.jose.JWK` - - """ - typ = "authorization" - schema = util.load_schema(typ) - - recovery_token = jose.Field("recoveryToken", omitempty=True) - identifier = jose.Field("identifier", omitempty=True) - jwk = jose.Field("jwk", decoder=jose.JWK.from_json, omitempty=True) - - -@Message.register -class AuthorizationRequest(Message): - """ACME "authorizationRequest" message. - - :ivar str nonce: Random data from the corresponding - :attr:`Challenge.nonce`, **not** base64-encoded. - :ivar list responses: List of completed challenges ( - :class:`acme.challenges.ChallengeResponse`). - :ivar signature: Signature (:class:`acme.other.Signature`). - - """ - typ = "authorizationRequest" - schema = util.load_schema(typ) - - session_id = jose.Field("sessionID") - nonce = jose.Field("nonce", encoder=jose.b64encode, - decoder=jose.decode_b64jose) - responses = jose.Field("responses") - signature = jose.Field("signature", decoder=other.Signature.from_json) - contact = jose.Field("contact", omitempty=True, default=()) - - @responses.decoder - def responses(value): # pylint: disable=missing-docstring,no-self-argument - return tuple(challenges.ChallengeResponse.from_json(chall) - for chall in value) - - @classmethod - def create(cls, name, key, sig_nonce=None, **kwargs): - """Create signed "authorizationRequest". - - :param str name: Hostname - - :param key: Key used for signing. - :type key: :class:`Crypto.PublicKey.RSA` - - :param str sig_nonce: Nonce used for signature. Useful for testing. - :kwargs: Any other arguments accepted by the class constructor. - - :returns: Signed "authorizationRequest" ACME message. - :rtype: :class:`AuthorizationRequest` - - """ - # pylint: disable=too-many-arguments - signature = other.Signature.from_msg( - name + kwargs["nonce"], key, sig_nonce) - return cls( - signature=signature, contact=kwargs.pop("contact", ()), **kwargs) - - def verify(self, name): - """Verify signature. - - .. warning:: Caller must check that the public key encoded in the - :attr:`signature`'s :class:`acme.jose.JWK` object - is the correct key for a given context. - - :param str name: Hostname - - :returns: True iff ``signature`` can be verified, False otherwise. - :rtype: bool - - """ - # self.signature is not Field | pylint: disable=no-member - return self.signature.verify(name + self.nonce) - - -@Message.register # pylint: disable=too-few-public-methods -class Certificate(Message): - """ACME "certificate" message. - - :ivar certificate: The certificate (:class:`M2Crypto.X509.X509` - wrapped in :class:`acme.util.ComparableX509`). - - :ivar list chain: Chain of certificates (:class:`M2Crypto.X509.X509` - wrapped in :class:`acme.util.ComparableX509` ). - - """ - typ = "certificate" - schema = util.load_schema(typ) - - certificate = jose.Field("certificate", encoder=jose.encode_cert, - decoder=jose.decode_cert) - chain = jose.Field("chain", omitempty=True, default=()) - refresh = jose.Field("refresh", omitempty=True) - - @chain.decoder - def chain(value): # pylint: disable=missing-docstring,no-self-argument - return tuple(jose.decode_cert(cert) for cert in value) - - @chain.encoder - def chain(value): # pylint: disable=missing-docstring,no-self-argument - return tuple(jose.encode_cert(cert) for cert in value) - - -@Message.register -class CertificateRequest(Message): - """ACME "certificateRequest" message. - - :ivar csr: Certificate Signing Request (:class:`M2Crypto.X509.Request` - wrapped in :class:`acme.util.ComparableX509`. - :ivar signature: Signature (:class:`acme.other.Signature`). - - """ - typ = "certificateRequest" - schema = util.load_schema(typ) - - csr = jose.Field("csr", encoder=jose.encode_csr, - decoder=jose.decode_csr) - signature = jose.Field("signature", decoder=other.Signature.from_json) - - @classmethod - def create(cls, key, sig_nonce=None, **kwargs): - """Create signed "certificateRequest". - - :param key: Key used for signing. - :type key: :class:`Crypto.PublicKey.RSA` - - :param str sig_nonce: Nonce used for signature. Useful for testing. - :kwargs: Any other arguments accepted by the class constructor. - - :returns: Signed "certificateRequest" ACME message. - :rtype: :class:`CertificateRequest` - - """ - return cls(signature=other.Signature.from_msg( - kwargs["csr"].as_der(), key, sig_nonce), **kwargs) - - def verify(self): - """Verify signature. - - .. warning:: Caller must check that the public key encoded in the - :attr:`signature`'s :class:`acme.jose.JWK` object - is the correct key for a given context. - - :returns: True iff ``signature`` can be verified, False otherwise. - :rtype: bool - - """ - # self.signature is not Field | pylint: disable=no-member - return self.signature.verify(self.csr.as_der()) - - -@Message.register # pylint: disable=too-few-public-methods -class Defer(Message): - """ACME "defer" message.""" - typ = "defer" - schema = util.load_schema(typ) - - token = jose.Field("token") - interval = jose.Field("interval", omitempty=True) - message = jose.Field("message", omitempty=True) - - -@Message.register # pylint: disable=too-few-public-methods -class Error(Message): - """ACME "error" message.""" - typ = "error" - schema = util.load_schema(typ) - - error = jose.Field("error") - message = jose.Field("message", omitempty=True) - more_info = jose.Field("moreInfo", omitempty=True) - - MESSAGE_CODES = { - "malformed": "The request message was malformed", - "unauthorized": "The client lacks sufficient authorization", - "serverInternal": "The server experienced an internal error", - "notSupported": "The request type is not supported", - "unknown": "The server does not recognize an ID/token in the request", - "badCSR": "The CSR is unacceptable (e.g., due to a short key)", - } - - -@Message.register # pylint: disable=too-few-public-methods -class Revocation(Message): - """ACME "revocation" message.""" - typ = "revocation" - schema = util.load_schema(typ) - - -@Message.register -class RevocationRequest(Message): - """ACME "revocationRequest" message. - - :ivar certificate: Certificate (:class:`M2Crypto.X509.X509` - wrapped in :class:`acme.util.ComparableX509`). - :ivar signature: Signature (:class:`acme.other.Signature`). - - """ - typ = "revocationRequest" - schema = util.load_schema(typ) - - certificate = jose.Field("certificate", decoder=jose.decode_cert, - encoder=jose.encode_cert) - signature = jose.Field("signature", decoder=other.Signature.from_json) - - @classmethod - def create(cls, key, sig_nonce=None, **kwargs): - """Create signed "revocationRequest". - - :param key: Key used for signing. - :type key: :class:`Crypto.PublicKey.RSA` - - :param str sig_nonce: Nonce used for signature. Useful for testing. - :kwargs: Any other arguments accepted by the class constructor. - - :returns: Signed "revocationRequest" ACME message. - :rtype: :class:`RevocationRequest` - - """ - return cls(signature=other.Signature.from_msg( - kwargs["certificate"].as_der(), key, sig_nonce), **kwargs) - - def verify(self): - """Verify signature. - - .. warning:: Caller must check that the public key encoded in the - :attr:`signature`'s :class:`acme.jose.JWK` object - is the correct key for a given context. - - :returns: True iff ``signature`` can be verified, False otherwise. - :rtype: bool - - """ - # self.signature is not Field | pylint: disable=no-member - return self.signature.verify(self.certificate.as_der()) - - -@Message.register # pylint: disable=too-few-public-methods -class StatusRequest(Message): - """ACME "statusRequest" message.""" - typ = "statusRequest" - schema = util.load_schema(typ) - token = jose.Field("token") diff --git a/acme/messages_test.py b/acme/messages_test.py deleted file mode 100644 index baff2a21a..000000000 --- a/acme/messages_test.py +++ /dev/null @@ -1,480 +0,0 @@ -"""Tests for acme.messages.""" -import os -import pkg_resources -import unittest - -import Crypto.PublicKey.RSA -import M2Crypto - -from acme import challenges -from acme import errors -from acme import jose -from acme import other - - -KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( - pkg_resources.resource_string( - 'acme.jose', os.path.join('testdata', 'rsa512_key.pem')))) -CERT = jose.ComparableX509(M2Crypto.X509.load_cert( - pkg_resources.resource_filename( - 'letsencrypt.tests', os.path.join('testdata', 'cert.pem')))) -CSR = jose.ComparableX509(M2Crypto.X509.load_request( - pkg_resources.resource_filename( - 'letsencrypt.tests', os.path.join('testdata', 'csr.pem')))) -CSR2 = jose.ComparableX509(M2Crypto.X509.load_request( - pkg_resources.resource_filename( - 'acme.jose', os.path.join('testdata', 'csr2.pem')))) - - -class MessageTest(unittest.TestCase): - """Tests for acme.messages.Message.""" - - def setUp(self): - # pylint: disable=missing-docstring,too-few-public-methods - from acme.messages import Message - - class MockParentMessage(Message): - # pylint: disable=abstract-method - TYPES = {} - - @MockParentMessage.register - class MockMessage(MockParentMessage): - typ = 'test' - schema = { - 'type': 'object', - 'properties': { - 'price': {'type': 'number'}, - 'name': {'type': 'string'}, - }, - } - price = jose.Field('price') - name = jose.Field('name') - - self.parent_cls = MockParentMessage - self.msg = MockMessage(price=123, name='foo') - - def test_from_json_validates(self): - self.assertRaises(errors.SchemaValidationError, - self.parent_cls.from_json, - {'type': 'test', 'price': 'asd'}) - - -class ChallengeTest(unittest.TestCase): - - def setUp(self): - challs = ( - challenges.SimpleHTTP(token='IlirfxKKXAsHtmzK29Pj8A'), - challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'), - challenges.RecoveryToken(), - ) - combinations = ((0, 2), (1, 2)) - - from acme.messages import Challenge - self.msg = Challenge( - session_id='aefoGaavieG9Wihuk2aufai3aeZ5EeW4', - nonce='\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9', - challenges=challs, combinations=combinations) - - self.jmsg_to = { - 'type': 'challenge', - 'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4', - 'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ', - 'challenges': challs, - 'combinations': combinations, - } - - self.jmsg_from = { - 'type': 'challenge', - 'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4', - 'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ', - 'challenges': [chall.to_json() for chall in challs], - 'combinations': [[0, 2], [1, 2]], # TODO array tuples - } - - def test_resolved_combinations(self): - self.assertEqual(self.msg.resolved_combinations, ( - ( - challenges.SimpleHTTP(token='IlirfxKKXAsHtmzK29Pj8A'), - challenges.RecoveryToken() - ), - ( - challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'), - challenges.RecoveryToken(), - ) - )) - - def test_to_partial_json(self): - self.assertEqual(self.msg.to_partial_json(), self.jmsg_to) - - def test_from_json(self): - from acme.messages import Challenge - self.assertEqual(Challenge.from_json(self.jmsg_from), self.msg) - - def test_json_without_optionals(self): - del self.jmsg_from['combinations'] - del self.jmsg_to['combinations'] - - from acme.messages import Challenge - msg = Challenge.from_json(self.jmsg_from) - - self.assertEqual(msg.combinations, ()) - self.assertEqual(msg.to_partial_json(), self.jmsg_to) - - -class ChallengeRequestTest(unittest.TestCase): - - def setUp(self): - from acme.messages import ChallengeRequest - self.msg = ChallengeRequest(identifier='example.com') - - self.jmsg = { - 'type': 'challengeRequest', - 'identifier': 'example.com', - } - - def test_to_partial_json(self): - self.assertEqual(self.msg.to_partial_json(), self.jmsg) - - def test_from_json(self): - from acme.messages import ChallengeRequest - self.assertEqual(ChallengeRequest.from_json(self.jmsg), self.msg) - - -class AuthorizationTest(unittest.TestCase): - - def setUp(self): - jwk = jose.JWKRSA(key=KEY.publickey()) - - from acme.messages import Authorization - self.msg = Authorization(recovery_token='tok', jwk=jwk, - identifier='example.com') - - self.jmsg = { - 'type': 'authorization', - 'recoveryToken': 'tok', - 'identifier': 'example.com', - 'jwk': jwk, - } - - def test_to_partial_json(self): - self.assertEqual(self.msg.to_partial_json(), self.jmsg) - - def test_from_json(self): - self.jmsg['jwk'] = self.jmsg['jwk'].to_partial_json() - - from acme.messages import Authorization - self.assertEqual(Authorization.from_json(self.jmsg), self.msg) - - def test_json_without_optionals(self): - del self.jmsg['recoveryToken'] - del self.jmsg['identifier'] - del self.jmsg['jwk'] - - from acme.messages import Authorization - msg = Authorization.from_json(self.jmsg) - - self.assertTrue(msg.recovery_token is None) - self.assertTrue(msg.identifier is None) - self.assertTrue(msg.jwk is None) - self.assertEqual(self.jmsg, msg.to_partial_json()) - - -class AuthorizationRequestTest(unittest.TestCase): - - def setUp(self): - self.responses = ( - challenges.SimpleHTTPResponse(path='Hf5GrX4Q7EBax9hc2jJnfw'), - None, # null - challenges.RecoveryTokenResponse(token='23029d88d9e123e'), - ) - self.contact = ("mailto:cert-admin@example.com", "tel:+12025551212") - signature = other.Signature( - alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.publickey()), - sig='-v\xd8\xc2\xa3\xba0\xd6\x92\x16\xb5.\xbe\xa1[\x04\xbe' - '\x1b\xa1X\xd2)\x18\x94\x8f\xd7\xd0\xc0\xbbcI`W\xdf v' - '\xe4\xed\xe8\x03J\xe8\xc8 Date: Thu, 11 Jun 2015 16:05:49 +0000 Subject: [PATCH 55/72] Remove old messages schemata. --- acme/schemata/authorization.json | 21 ---- acme/schemata/authorizationRequest.json | 38 ------- acme/schemata/certificate.json | 25 ----- acme/schemata/certificateRequest.json | 19 ---- acme/schemata/challenge.json | 36 ------- acme/schemata/challengeRequest.json | 15 --- acme/schemata/challengeobject.json | 130 ------------------------ acme/schemata/defer.json | 21 ---- acme/schemata/error.json | 21 ---- acme/schemata/jwk.json | 19 ---- acme/schemata/responseobject.json | 75 -------------- acme/schemata/revocation.json | 12 --- acme/schemata/revocationRequest.json | 18 ---- acme/schemata/signature.json | 71 ------------- acme/schemata/statusRequest.json | 15 --- 15 files changed, 536 deletions(-) delete mode 100644 acme/schemata/authorization.json delete mode 100644 acme/schemata/authorizationRequest.json delete mode 100644 acme/schemata/certificate.json delete mode 100644 acme/schemata/certificateRequest.json delete mode 100644 acme/schemata/challenge.json delete mode 100644 acme/schemata/challengeRequest.json delete mode 100644 acme/schemata/challengeobject.json delete mode 100644 acme/schemata/defer.json delete mode 100644 acme/schemata/error.json delete mode 100644 acme/schemata/jwk.json delete mode 100644 acme/schemata/responseobject.json delete mode 100644 acme/schemata/revocation.json delete mode 100644 acme/schemata/revocationRequest.json delete mode 100644 acme/schemata/signature.json delete mode 100644 acme/schemata/statusRequest.json diff --git a/acme/schemata/authorization.json b/acme/schemata/authorization.json deleted file mode 100644 index 122f263e1..000000000 --- a/acme/schemata/authorization.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/authorization#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Schema for an authorization message", - "type": "object", - "required": ["type"], - "properties": { - "type" : { - "enum" : [ "authorization" ] - }, - "recoveryToken" : { - "type": "string" - }, - "identifier" : { - "type": "string" - }, - "jwk": { - "$ref": "file:acme/schemata/jwk.json" - } - } -} diff --git a/acme/schemata/authorizationRequest.json b/acme/schemata/authorizationRequest.json deleted file mode 100644 index 2d4371cb8..000000000 --- a/acme/schemata/authorizationRequest.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/authorizationRequest#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Schema for an authorizationRequest message", - "type": "object", - "required": ["type", "sessionID", "nonce", "signature", "responses"], - "properties": { - "type" : { - "enum" : [ "authorizationRequest" ] - }, - "sessionID" : { - "type" : "string" - }, - "nonce" : { - "type": "string" - }, - "signature" : { - "$ref": "file:acme/schemata/signature.json" - }, - "responses": { - "type": "array", - "minItems": 1, - "items": { - "anyOf": [ - { "$ref": "file:acme/schemata/responseobject.json" }, - { "type": "null" } - ] - } - }, - "contact": { - "type": "array", - "minItems": 1, - "items": { - "type": "string" - } - } - } -} diff --git a/acme/schemata/certificate.json b/acme/schemata/certificate.json deleted file mode 100644 index 1d4e98947..000000000 --- a/acme/schemata/certificate.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/certificate#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Schema for a certificate message", - "type": "object", - "required": ["type", "certificate"], - "properties": { - "type" : { - "enum" : [ "certificate" ] - }, - "certificate" : { - "type" : "string" - }, - "chain" : { - "type": "array", - "minItems": 1, - "items": { - "type": "string" - } - }, - "refresh" : { - "type": "string" - } - } -} diff --git a/acme/schemata/certificateRequest.json b/acme/schemata/certificateRequest.json deleted file mode 100644 index ef3e18f98..000000000 --- a/acme/schemata/certificateRequest.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/certificateRequest#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Schema for a certificateRequest message", - "type": "object", - "required": ["type", "csr", "signature"], - "properties": { - "type" : { - "enum" : [ "certificateRequest" ] - }, - "csr" : { - "type" : "string" , - "pattern": "^[-_=0-9A-Za-z]+$" - }, - "signature" : { - "$ref": "file:acme/schemata/signature.json" - } - } -} diff --git a/acme/schemata/challenge.json b/acme/schemata/challenge.json deleted file mode 100644 index 978fcd4c4..000000000 --- a/acme/schemata/challenge.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/challenge#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Schema for a challenge message", - "type": "object", - "required": ["type", "sessionID", "nonce", "challenges"], - "properties": { - "type" : { - "enum" : [ "challenge" ] - }, - "sessionID" : { - "type" : "string" - }, - "nonce" : { - "type": "string" - }, - "challenges": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "file:acme/schemata/challengeobject.json" - } - }, - "combinations": { - "type": "array", - "minItems": 1, - "items": { - "type": "array", - "minItems": 1, - "items": { - "type": "integer" - } - } - } - } -} diff --git a/acme/schemata/challengeRequest.json b/acme/schemata/challengeRequest.json deleted file mode 100644 index 0762fa9c8..000000000 --- a/acme/schemata/challengeRequest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/challengeRequest#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Schema for a challengeRequest message", - "type": "object", - "required": ["type", "identifier"], - "properties": { - "type" : { - "enum" : [ "challengeRequest" ] - }, - "identifier" : { - "type": "string" - } - } -} diff --git a/acme/schemata/challengeobject.json b/acme/schemata/challengeobject.json deleted file mode 100644 index 7709f315d..000000000 --- a/acme/schemata/challengeobject.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/challengeobject#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Subschema for an individual challenge (within challenge)", - "anyOf": [ - { "type": "object", - "required": ["type", "token"], - "properties": { - "type": { - "enum" : [ "simpleHttp" ] - }, - "token": { - "type": "string" - } - } - }, - { "type": "object", - "required": ["type", "r", "nonce"], - "properties": { - "type": { - "enum" : [ "dvsni" ] - }, - "r": { - "type" : [ "string" ], - "pattern": "^[-_=0-9A-Za-z]+$" - }, - "nonce": { - "type": "string", - "pattern": "^[0-9a-f]+$" - } - } - }, - { "type": "object", - "required": ["type"], - "properties": { - "type": { - "enum" : [ "recoveryContact" ] - }, - "activationURL": { - "type" : "string" - }, - "successURL": { - "type": "string" - }, - "contact": { - "type": "string" - } - } - }, - { "type": "object", - "required": ["type"], - "properties": { - "type": { - "enum" : [ "recoveryToken" ] - } - } - }, - { "type": "object", - "required": ["type", "alg", "nonce", "hints"], - "properties": { - "type": { - "enum" : [ "proofOfPossession" ] - }, - "alg": { - "type": "string" - }, - "nonce": { - "type": "string", - "pattern": "^[-_=0-9A-Za-z]+$" - }, - "hints": { - "type": "object", - "properties": { - "jwk": { - "type": "object" - }, - "certFingerprints": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "pattern": "^[0-9a-f]+$" - } - }, - "subjectKeyIdentifiers": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "pattern": "^[0-9a-f]+$" - } - }, - "serialNumbers": { - "type": "array", - "minItems": 1, - "items": { - "type": "integer" - } - }, - "issuers": { - "type": "array", - "minItems": 1, - "items": { - "type": "string" - } - }, - "authorizedFor": { - "type": "array", - "minItems": 1, - "items": { - "type": "string" - } - } - } - } - } - }, - { "type": "object", - "required": ["type", "token"], - "properties": { - "type": { - "enum" : [ "dns" ] - }, - "token": { - "type": "string" - } - } - } - ] -} diff --git a/acme/schemata/defer.json b/acme/schemata/defer.json deleted file mode 100644 index 21edd614b..000000000 --- a/acme/schemata/defer.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/defer#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Schema for a defer message", - "type": "object", - "required": ["type", "token"], - "properties": { - "type" : { - "enum" : [ "defer" ] - }, - "token" : { - "type": "string" - }, - "interval" : { - "type": "integer" - }, - "message": { - "type": "string" - } - } -} diff --git a/acme/schemata/error.json b/acme/schemata/error.json deleted file mode 100644 index 359506b52..000000000 --- a/acme/schemata/error.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/error#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Schema for an error message", - "type": "object", - "required": ["type", "error"], - "properties": { - "type" : { - "enum" : [ "error" ] - }, - "error" : { - "enum" : [ "malformed", "unauthorized", "serverInternal", "nonSupported", "unknown", "badCSR" ] - }, - "message" : { - "type": "string" - }, - "moreInfo": { - "type": "string" - } - } -} diff --git a/acme/schemata/jwk.json b/acme/schemata/jwk.json deleted file mode 100644 index b9cca8840..000000000 --- a/acme/schemata/jwk.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/jwk#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Schema for a jwk (**kty RSA/e=65537 ONLY**)", - "type": "object", - "required": ["kty", "e", "n"], - "properties": { - "kty": { - "enum" : [ "RSA" ] - }, - "e": { - "enum" : [ "AQAB" ] - }, - "n": { - "type": "string", - "pattern": "^[-_=0-9A-Za-z]+$" - } - } -} diff --git a/acme/schemata/responseobject.json b/acme/schemata/responseobject.json deleted file mode 100644 index 5773f3a73..000000000 --- a/acme/schemata/responseobject.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/responseobject#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Subschema for an individual challenge response (within authorizationRequest)", - "anyOf": [ - { "type": "object", - "required": ["type", "path"], - "properties": { - "type": { - "enum" : [ "simpleHttp" ] - }, - "path": { - "type": "string" - } - } - }, - { "type": "object", - "required": ["type", "s"], - "properties": { - "type": { - "enum" : [ "dvsni" ] - }, - "s": { - "type" : [ "string" ], - "pattern": "^[-_=0-9A-Za-z]+$" - } - } - }, - { "type": "object", - "required": ["type"], - "properties": { - "type": { - "enum" : [ "recoveryContact" ] - }, - "token": { - "type" : "string" - } - } - }, - { "type": "object", - "required": ["type"], - "properties": { - "type": { - "enum" : [ "recoveryToken" ] - }, - "token": { - "type" : "string" - } - } - }, - { "type": "object", - "required": ["type", "nonce", "signature"], - "properties": { - "type": { - "enum" : [ "proofOfPossession" ] - }, - "nonce": { - "type": "string", - "pattern": "^[-_=0-9A-Za-z]+$" - }, - "signature": { - "$ref": "file:acme/schemata/signature.json" - } - } - }, - { "type": "object", - "required": ["type"], - "properties": { - "type": { - "enum" : [ "dns" ] - } - } - } - ] -} diff --git a/acme/schemata/revocation.json b/acme/schemata/revocation.json deleted file mode 100644 index 53455d506..000000000 --- a/acme/schemata/revocation.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/revocation#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Schema for a revocation message", - "type": "object", - "required": ["type"], - "properties": { - "type" : { - "enum" : [ "revocation" ] - } - } -} diff --git a/acme/schemata/revocationRequest.json b/acme/schemata/revocationRequest.json deleted file mode 100644 index 7559d0ee0..000000000 --- a/acme/schemata/revocationRequest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/revocationRequest#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Schema for a revocationRequest message", - "type": "object", - "required": ["type", "certificate", "signature"], - "properties": { - "type" : { - "enum" : [ "revocationRequest" ] - }, - "certificate" : { - "type" : "string" - }, - "signature" : { - "$ref": "file:acme/schemata/signature.json" - } - } -} diff --git a/acme/schemata/signature.json b/acme/schemata/signature.json deleted file mode 100644 index e70652e7c..000000000 --- a/acme/schemata/signature.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/signature#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Schema for a signature (alg RS256/e=65537 or P-256 ONLY)", - "type": "object", - "required": ["alg", "nonce", "sig", "jwk"], - "properties": { - "anyOf": [ - { - "alg" : { - "enum" : [ "RS256" ] - }, - "nonce" : { - "type" : "string" - }, - "sig" : { - "type": "string", - "pattern": "^[-_=0-9A-Za-z]+$" - }, - "jwk": { - "type": "object", - "required": ["kty", "e", "n"], - "properties": { - "kty": { - "enum" : [ "RSA" ] - }, - "e": { - "enum" : [ "AQAB" ] - }, - "n": { - "type": "string", - "pattern": "^[-_=0-9A-Za-z]+$" - } - } - } - }, - { - "alg" : { - "enum" : [ "ES256" ] - }, - "nonce" : { - "type" : "string" - }, - "sig" : { - "type": "string", - "pattern": "^[-_=0-9A-Za-z]+$" - }, - "jwk": { - "type": "object", - "required": ["kty", "crv", "x", "y"], - "properties": { - "kty": { - "enum" : [ "EC" ] - }, - "crv": { - "enum" : [ "P-256" ] - }, - "x": { - "type": "string", - "pattern": "^[-_=0-9A-Za-z]+$" - }, - "y": { - "type": "string", - "pattern": "^[-_=0-9A-Za-z]+$" - } - } - } - } - ] - } -} diff --git a/acme/schemata/statusRequest.json b/acme/schemata/statusRequest.json deleted file mode 100644 index 8e4221cbe..000000000 --- a/acme/schemata/statusRequest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "https://letsencrypt.org/schema/01/statusRequest#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Schema for a statusRequest message", - "type": "object", - "required": ["type", "token"], - "properties": { - "type" : { - "enum" : [ "statusRequest" ] - }, - "token" : { - "type": "string" - } - } -} From aa6faadb5c1d99c5d2026cac9a62193ead8ebd01 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 15:07:17 +0000 Subject: [PATCH 56/72] Add ChallangeResponseTest --- acme/challenges_test.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/acme/challenges_test.py b/acme/challenges_test.py index 4c61c0e3d..f0b025ad3 100644 --- a/acme/challenges_test.py +++ b/acme/challenges_test.py @@ -18,6 +18,13 @@ KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( 'acme.jose', os.path.join('testdata', 'rsa512_key.pem')))) +class ChallengeResponseTest(unittest.TestCase): + + def test_from_json_none(self): + from acme.challenges import ChallengeResponse + self.assertTrue(ChallengeResponse.from_json(None) is None) + + class SimpleHTTPTest(unittest.TestCase): def setUp(self): From 71a01d139ca25eae0548b35cfc5520fa5c3a808b Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 14:55:11 +0000 Subject: [PATCH 57/72] Rename network2 to network. --- docs/api/network2.rst | 5 ---- examples/restified.py | 4 +-- letsencrypt/auth_handler.py | 2 +- letsencrypt/client.py | 6 ++-- letsencrypt/{network2.py => network.py} | 0 letsencrypt/revoker.py | 4 +-- letsencrypt/tests/auth_handler_test.py | 4 +-- letsencrypt/tests/client_test.py | 6 ++-- .../{network2_test.py => network_test.py} | 28 +++++++++---------- letsencrypt/tests/revoker_test.py | 12 ++++---- 10 files changed, 33 insertions(+), 38 deletions(-) delete mode 100644 docs/api/network2.rst rename letsencrypt/{network2.py => network.py} (100%) rename letsencrypt/tests/{network2_test.py => network_test.py} (97%) diff --git a/docs/api/network2.rst b/docs/api/network2.rst deleted file mode 100644 index a73308e1b..000000000 --- a/docs/api/network2.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.network2` ---------------------------- - -.. automodule:: letsencrypt.network2 - :members: diff --git a/examples/restified.py b/examples/restified.py index c0252c1eb..07c773575 100644 --- a/examples/restified.py +++ b/examples/restified.py @@ -7,7 +7,7 @@ import M2Crypto from acme import messages2 from acme import jose -from letsencrypt import network2 +from letsencrypt import network logger = logging.getLogger() @@ -17,7 +17,7 @@ NEW_REG_URL = 'https://www.letsencrypt-demo.org/acme/new-reg' key = jose.JWKRSA.load(pkg_resources.resource_string( 'acme.jose', os.path.join('testdata', 'rsa512_key.pem'))) -net = network2.Network(NEW_REG_URL, key) +net = network.Network(NEW_REG_URL, key) regr = net.register(contact=( 'mailto:cert-admin@example.com', 'tel:+12025551212')) diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index 5665fe83d..d801613c5 100644 --- a/letsencrypt/auth_handler.py +++ b/letsencrypt/auth_handler.py @@ -24,7 +24,7 @@ class AuthHandler(object): :ivar network: Network object for sending and receiving authorization messages - :type network: :class:`letsencrypt.network2.Network` + :type network: :class:`letsencrypt.network.Network` :ivar account: Client's Account :type account: :class:`letsencrypt.account.Account` diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 17bee6069..d059a777e 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -16,7 +16,7 @@ from letsencrypt import crypto_util from letsencrypt import errors from letsencrypt import interfaces from letsencrypt import le_util -from letsencrypt import network2 +from letsencrypt import network from letsencrypt import reverter from letsencrypt import revoker from letsencrypt import storage @@ -29,7 +29,7 @@ class Client(object): """ACME protocol client. :ivar network: Network object for sending and receiving messages - :type network: :class:`letsencrypt.network2.Network` + :type network: :class:`letsencrypt.network.Network` :ivar account: Account object used for registration :type account: :class:`letsencrypt.account.Account` @@ -62,7 +62,7 @@ class Client(object): self.installer = installer # TODO: Allow for other alg types besides RS256 - self.network = network2.Network( + self.network = network.Network( config.server, jwk.JWKRSA.load(self.account.key.pem), verify_ssl=(not config.no_verify_ssl)) diff --git a/letsencrypt/network2.py b/letsencrypt/network.py similarity index 100% rename from letsencrypt/network2.py rename to letsencrypt/network.py diff --git a/letsencrypt/revoker.py b/letsencrypt/revoker.py index d173a1907..0d3bd8e79 100644 --- a/letsencrypt/revoker.py +++ b/letsencrypt/revoker.py @@ -20,7 +20,7 @@ from acme.jose import util as jose_util from letsencrypt import errors from letsencrypt import le_util -from letsencrypt import network2 +from letsencrypt import network from letsencrypt.display import util as display_util from letsencrypt.display import revocation @@ -45,7 +45,7 @@ class Revoker(object): """ def __init__(self, installer, config, no_confirm=False): # XXX - self.network = network2.Network(new_reg_uri=None, key=None, alg=None) + self.network = network.Network(new_reg_uri=None, key=None, alg=None) self.installer = installer self.config = config diff --git a/letsencrypt/tests/auth_handler_test.py b/letsencrypt/tests/auth_handler_test.py index 8cbc0e604..fddf508b2 100644 --- a/letsencrypt/tests/auth_handler_test.py +++ b/letsencrypt/tests/auth_handler_test.py @@ -10,7 +10,7 @@ from acme import messages2 from letsencrypt import errors from letsencrypt import le_util -from letsencrypt import network2 +from letsencrypt import network from letsencrypt.tests import acme_util @@ -86,7 +86,7 @@ class GetAuthorizationsTest(unittest.TestCase): 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.mock_net = mock.MagicMock(spec=network.Network) self.handler = AuthHandler( self.mock_dv_auth, self.mock_cont_auth, diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index 1fb9c2a03..79e2597ea 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -26,14 +26,14 @@ class ClientTest(unittest.TestCase): self.account = mock.MagicMock(**{"key.pem": KEY}) from letsencrypt.client import Client - with mock.patch("letsencrypt.client.network2") as network2: + with mock.patch("letsencrypt.client.network") as network: self.client = Client( config=self.config, account_=self.account, dv_auth=None, installer=None) - self.network2 = network2 + self.network = network def test_init_network_verify_ssl(self): - self.network2.Network.assert_called_once_with( + self.network.Network.assert_called_once_with( mock.ANY, mock.ANY, verify_ssl=True) @mock.patch("letsencrypt.client.zope.component.getUtility") diff --git a/letsencrypt/tests/network2_test.py b/letsencrypt/tests/network_test.py similarity index 97% rename from letsencrypt/tests/network2_test.py rename to letsencrypt/tests/network_test.py index 3f745ffa7..c0522c2fb 100644 --- a/letsencrypt/tests/network2_test.py +++ b/letsencrypt/tests/network_test.py @@ -1,4 +1,4 @@ -"""Tests for letsencrypt.network2.""" +"""Tests for letsencrypt.network.""" import datetime import httplib import os @@ -36,7 +36,7 @@ KEY2 = jose.JWKRSA.load(pkg_resources.resource_string( class NetworkTest(unittest.TestCase): - """Tests for letsencrypt.network2.Network.""" + """Tests for letsencrypt.network.Network.""" # pylint: disable=too-many-instance-attributes,too-many-public-methods @@ -44,7 +44,7 @@ class NetworkTest(unittest.TestCase): self.verify_ssl = mock.MagicMock() self.wrap_in_jws = mock.MagicMock(return_value=mock.sentinel.wrapped) - from letsencrypt.network2 import Network + from letsencrypt.network import Network self.net = Network( new_reg_uri='https://www.letsencrypt-demo.org/acme/new-reg', key=KEY, alg=jose.RS256, verify_ssl=self.verify_ssl) @@ -167,14 +167,14 @@ class NetworkTest(unittest.TestCase): # pylint: disable=protected-access self.net._check_response(self.response) - @mock.patch('letsencrypt.network2.requests') + @mock.patch('letsencrypt.network.requests') def test_get_requests_error_passthrough(self, requests_mock): requests_mock.exceptions = requests.exceptions requests_mock.get.side_effect = requests.exceptions.RequestException # pylint: disable=protected-access self.assertRaises(errors.NetworkError, self.net._get, 'uri') - @mock.patch('letsencrypt.network2.requests') + @mock.patch('letsencrypt.network.requests') def test_get(self, requests_mock): # pylint: disable=protected-access self.net._check_response = mock.MagicMock() @@ -186,7 +186,7 @@ class NetworkTest(unittest.TestCase): # pylint: disable=protected-access self.net._wrap_in_jws = self.wrap_in_jws - @mock.patch('letsencrypt.network2.requests') + @mock.patch('letsencrypt.network.requests') def test_post_requests_error_passthrough(self, requests_mock): requests_mock.exceptions = requests.exceptions requests_mock.post.side_effect = requests.exceptions.RequestException @@ -195,7 +195,7 @@ class NetworkTest(unittest.TestCase): self.assertRaises( errors.NetworkError, self.net._post, 'uri', mock.sentinel.obj) - @mock.patch('letsencrypt.network2.requests') + @mock.patch('letsencrypt.network.requests') def test_post(self, requests_mock): # pylint: disable=protected-access self.net._check_response = mock.MagicMock() @@ -206,7 +206,7 @@ class NetworkTest(unittest.TestCase): self.net._check_response.assert_called_once_with( requests_mock.post('uri', mock.sentinel.wrapped), content_type='ct') - @mock.patch('letsencrypt.network2.requests') + @mock.patch('letsencrypt.network.requests') def test_post_replay_nonce_handling(self, requests_mock): # pylint: disable=protected-access self.net._check_response = mock.MagicMock() @@ -233,7 +233,7 @@ class NetworkTest(unittest.TestCase): self.assertRaises( errors.NetworkError, self.net._post, 'uri', mock.sentinel.obj) - @mock.patch('letsencrypt.client.network2.requests') + @mock.patch('letsencrypt.client.network.requests') def test_get_post_verify_ssl(self, requests_mock): # pylint: disable=protected-access self._mock_wrap_in_jws() @@ -372,7 +372,7 @@ class NetworkTest(unittest.TestCase): datetime.datetime(1999, 12, 31, 23, 59, 59), self.net.retry_after(response=self.response, default=10)) - @mock.patch('letsencrypt.network2.datetime') + @mock.patch('letsencrypt.network.datetime') def test_retry_after_invalid(self, dt_mock): dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27) dt_mock.timedelta = datetime.timedelta @@ -382,7 +382,7 @@ class NetworkTest(unittest.TestCase): datetime.datetime(2015, 3, 27, 0, 0, 10), self.net.retry_after(response=self.response, default=10)) - @mock.patch('letsencrypt.network2.datetime') + @mock.patch('letsencrypt.network.datetime') def test_retry_after_seconds(self, dt_mock): dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27) dt_mock.timedelta = datetime.timedelta @@ -392,7 +392,7 @@ class NetworkTest(unittest.TestCase): datetime.datetime(2015, 3, 27, 0, 0, 50), self.net.retry_after(response=self.response, default=10)) - @mock.patch('letsencrypt.network2.datetime') + @mock.patch('letsencrypt.network.datetime') def test_retry_after_missing(self, dt_mock): dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27) dt_mock.timedelta = datetime.timedelta @@ -435,8 +435,8 @@ class NetworkTest(unittest.TestCase): errors.NetworkError, self.net.request_issuance, CSR, (self.authzr,)) - @mock.patch('letsencrypt.network2.datetime') - @mock.patch('letsencrypt.network2.time') + @mock.patch('letsencrypt.network.datetime') + @mock.patch('letsencrypt.network.time') def test_poll_and_request_issuance(self, time_mock, dt_mock): # clock.dt | pylint: disable=no-member clock = mock.MagicMock(dt=datetime.datetime(2015, 3, 27)) diff --git a/letsencrypt/tests/revoker_test.py b/letsencrypt/tests/revoker_test.py index 35e7d132b..cd86594fd 100644 --- a/letsencrypt/tests/revoker_test.py +++ b/letsencrypt/tests/revoker_test.py @@ -63,7 +63,7 @@ class RevokerTest(RevokerBase): def tearDown(self): shutil.rmtree(self.backup_dir) - @mock.patch("letsencrypt.network2.Network.revoke") + @mock.patch("letsencrypt.network.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_key_all(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -89,7 +89,7 @@ class RevokerTest(RevokerBase): self.revoker.revoke_from_key, self.key) - @mock.patch("letsencrypt.network2.Network.revoke") + @mock.patch("letsencrypt.network.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_wrong_key(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -105,7 +105,7 @@ class RevokerTest(RevokerBase): # No revocation went through self.assertEqual(mock_net.call_count, 0) - @mock.patch("letsencrypt.network2.Network.revoke") + @mock.patch("letsencrypt.network.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_cert(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -122,7 +122,7 @@ class RevokerTest(RevokerBase): self.assertEqual(mock_net.call_count, 1) - @mock.patch("letsencrypt.network2.Network.revoke") + @mock.patch("letsencrypt.network.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_cert_not_found(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -141,7 +141,7 @@ class RevokerTest(RevokerBase): self.assertEqual(mock_net.call_count, 1) - @mock.patch("letsencrypt.network2.Network.revoke") + @mock.patch("letsencrypt.network.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_menu(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True @@ -165,7 +165,7 @@ class RevokerTest(RevokerBase): self.assertEqual(mock_display.more_info_cert.call_count, 1) @mock.patch("letsencrypt.revoker.logging") - @mock.patch("letsencrypt.network2.Network.revoke") + @mock.patch("letsencrypt.network.Network.revoke") @mock.patch("letsencrypt.revoker.revocation") def test_revoke_by_menu_delete_all(self, mock_display, mock_net, mock_log): mock_display().confirm_revocation.return_value = True From a278d53f5200086c2436ccf56675dd718eb6c4c9 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 11 Jun 2015 15:00:18 +0000 Subject: [PATCH 58/72] Rename messages2 to messages. --- acme/{messages2.py => messages.py} | 16 ++--- acme/{messages2_test.py => messages_test.py} | 62 +++++++++---------- docs/pkgs/acme/index.rst | 9 --- examples/restified.py | 8 +-- letsencrypt/account.py | 8 +-- letsencrypt/achallenges.py | 4 +- letsencrypt/auth_handler.py | 24 +++---- letsencrypt/network.py | 48 +++++++------- letsencrypt/tests/account_test.py | 6 +- letsencrypt/tests/acme_util.py | 30 ++++----- letsencrypt/tests/auth_handler_test.py | 42 ++++++------- letsencrypt/tests/network_test.py | 34 +++++----- letsencrypt/tests/proof_of_possession_test.py | 10 +-- letsencrypt_nginx/tests/configurator_test.py | 10 +-- 14 files changed, 151 insertions(+), 160 deletions(-) rename acme/{messages2.py => messages.py} (96%) rename acme/{messages2_test.py => messages_test.py} (83%) diff --git a/acme/messages2.py b/acme/messages.py similarity index 96% rename from acme/messages2.py rename to acme/messages.py index 15b4521de..aa041caed 100644 --- a/acme/messages2.py +++ b/acme/messages.py @@ -100,7 +100,7 @@ IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder class Identifier(jose.JSONObjectWithFields): """ACME identifier. - :ivar acme.messages2.IdentifierType typ: + :ivar acme.messages.IdentifierType typ: """ typ = jose.Field('type', decoder=IdentifierType.from_json) @@ -110,7 +110,7 @@ class Identifier(jose.JSONObjectWithFields): class Resource(jose.ImmutableMap): """ACME Resource. - :ivar acme.messages2.ResourceBody body: Resource body. + :ivar acme.messages.ResourceBody body: Resource body. :ivar str uri: Location of the resource. """ @@ -124,7 +124,7 @@ class ResourceBody(jose.JSONObjectWithFields): class RegistrationResource(Resource): """Registration Resource. - :ivar acme.messages2.Registration body: + :ivar acme.messages.Registration body: :ivar str new_authzr_uri: URI found in the 'next' ``Link`` header :ivar str terms_of_service: URL for the CA TOS. @@ -150,7 +150,7 @@ class Registration(ResourceBody): class ChallengeResource(Resource, jose.JSONObjectWithFields): """Challenge Resource. - :ivar acme.messages2.ChallengeBody body: + :ivar acme.messages.ChallengeBody body: :ivar str authzr_uri: URI found in the 'up' ``Link`` header. """ @@ -175,7 +175,7 @@ class ChallengeBody(ResourceBody): :ivar 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 acme.messages2.Status status: + :ivar acme.messages.Status status: :ivar datetime.datetime validated: """ @@ -202,7 +202,7 @@ class ChallengeBody(ResourceBody): class AuthorizationResource(Resource): """Authorization Resource. - :ivar acme.messages2.Authorization body: + :ivar acme.messages.Authorization body: :ivar str new_cert_uri: URI found in the 'next' ``Link`` header """ @@ -212,13 +212,13 @@ class AuthorizationResource(Resource): class Authorization(ResourceBody): """Authorization Resource Body. - :ivar acme.messages2.Identifier identifier: + :ivar acme.messages.Identifier identifier: :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 acme.jose.jwk.JWK key: Public key. :ivar tuple contact: - :ivar acme.messages2.Status status: + :ivar acme.messages.Status status: :ivar datetime.datetime expires: """ diff --git a/acme/messages2_test.py b/acme/messages_test.py similarity index 83% rename from acme/messages2_test.py rename to acme/messages_test.py index 72ffc954a..4f86d7809 100644 --- a/acme/messages2_test.py +++ b/acme/messages_test.py @@ -1,4 +1,4 @@ -"""Tests for acme.messages2.""" +"""Tests for acme.messages.""" import datetime import os import pkg_resources @@ -17,10 +17,10 @@ KEY = jose.util.HashableRSAKey(RSA.importKey(pkg_resources.resource_string( class ErrorTest(unittest.TestCase): - """Tests for acme.messages2.Error.""" + """Tests for acme.messages.Error.""" def setUp(self): - from acme.messages2 import Error + from acme.messages import Error self.error = Error(detail='foo', typ='malformed', title='title') self.jobj = {'detail': 'foo', 'title': 'some title'} @@ -32,14 +32,14 @@ class ErrorTest(unittest.TestCase): 'malformed', self.error.from_json(self.error.to_partial_json()).typ) def test_typ_decoder_missing_prefix(self): - from acme.messages2 import Error + from acme.messages import Error self.jobj['type'] = 'malformed' self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj) self.jobj['type'] = 'not valid bare type' self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj) def test_typ_decoder_not_recognized(self): - from acme.messages2 import Error + from acme.messages import Error self.jobj['type'] = 'urn:acme:error:baz' self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj) @@ -48,7 +48,7 @@ class ErrorTest(unittest.TestCase): 'The request message was malformed', self.error.description) def test_from_json_hashable(self): - from acme.messages2 import Error + from acme.messages import Error hash(Error.from_json(self.error.to_json())) def test_str(self): @@ -59,10 +59,10 @@ class ErrorTest(unittest.TestCase): class ConstantTest(unittest.TestCase): - """Tests for acme.messages2._Constant.""" + """Tests for acme.messages._Constant.""" def setUp(self): - from acme.messages2 import _Constant + from acme.messages import _Constant class MockConstant(_Constant): # pylint: disable=missing-docstring POSSIBLE_NAMES = {} @@ -95,7 +95,7 @@ class ConstantTest(unittest.TestCase): self.assertFalse(self.const_a != const_a_prime) class RegistrationTest(unittest.TestCase): - """Tests for acme.messages2.Registration.""" + """Tests for acme.messages.Registration.""" def setUp(self): key = jose.jwk.JWKRSA(key=KEY.publickey()) @@ -103,7 +103,7 @@ class RegistrationTest(unittest.TestCase): recovery_token = 'XYZ' agreement = 'https://letsencrypt.org/terms' - from acme.messages2 import Registration + from acme.messages import Registration self.reg = Registration( key=key, contact=contact, recovery_token=recovery_token, agreement=agreement) @@ -121,31 +121,31 @@ class RegistrationTest(unittest.TestCase): self.assertEqual(self.jobj_to, self.reg.to_partial_json()) def test_from_json(self): - from acme.messages2 import Registration + from acme.messages import Registration self.assertEqual(self.reg, Registration.from_json(self.jobj_from)) def test_from_json_hashable(self): - from acme.messages2 import Registration + from acme.messages import Registration hash(Registration.from_json(self.jobj_from)) class ChallengeResourceTest(unittest.TestCase): - """Tests for acme.messages2.ChallengeResource.""" + """Tests for acme.messages.ChallengeResource.""" def test_uri(self): - from acme.messages2 import ChallengeResource + from acme.messages import ChallengeResource self.assertEqual('http://challb', ChallengeResource(body=mock.MagicMock( uri='http://challb'), authzr_uri='http://authz').uri) class ChallengeBodyTest(unittest.TestCase): - """Tests for acme.messages2.ChallengeBody.""" + """Tests for acme.messages.ChallengeBody.""" def setUp(self): self.chall = challenges.DNS(token='foo') - from acme.messages2 import ChallengeBody - from acme.messages2 import STATUS_VALID + from acme.messages import ChallengeBody + from acme.messages import STATUS_VALID self.status = STATUS_VALID self.challb = ChallengeBody( uri='http://challb', chall=self.chall, status=self.status) @@ -163,11 +163,11 @@ class ChallengeBodyTest(unittest.TestCase): self.assertEqual(self.jobj_to, self.challb.to_partial_json()) def test_from_json(self): - from acme.messages2 import ChallengeBody + from acme.messages import ChallengeBody self.assertEqual(self.challb, ChallengeBody.from_json(self.jobj_from)) def test_from_json_hashable(self): - from acme.messages2 import ChallengeBody + from acme.messages import ChallengeBody hash(ChallengeBody.from_json(self.jobj_from)) def test_proxy(self): @@ -175,11 +175,11 @@ class ChallengeBodyTest(unittest.TestCase): class AuthorizationTest(unittest.TestCase): - """Tests for acme.messages2.Authorization.""" + """Tests for acme.messages.Authorization.""" def setUp(self): - from acme.messages2 import ChallengeBody - from acme.messages2 import STATUS_VALID + from acme.messages import ChallengeBody + from acme.messages import STATUS_VALID self.challbs = ( ChallengeBody( uri='http://challb1', status=STATUS_VALID, @@ -191,9 +191,9 @@ class AuthorizationTest(unittest.TestCase): ) combinations = ((0, 2), (1, 2)) - from acme.messages2 import Authorization - from acme.messages2 import Identifier - from acme.messages2 import IDENTIFIER_FQDN + from acme.messages import Authorization + from acme.messages import Identifier + from acme.messages import IDENTIFIER_FQDN identifier = Identifier(typ=IDENTIFIER_FQDN, value='example.com') self.authz = Authorization( identifier=identifier, combinations=combinations, @@ -206,11 +206,11 @@ class AuthorizationTest(unittest.TestCase): } def test_from_json(self): - from acme.messages2 import Authorization + from acme.messages import Authorization Authorization.from_json(self.jobj_from) def test_from_json_hashable(self): - from acme.messages2 import Authorization + from acme.messages import Authorization hash(Authorization.from_json(self.jobj_from)) def test_resolved_combinations(self): @@ -221,10 +221,10 @@ class AuthorizationTest(unittest.TestCase): class RevocationTest(unittest.TestCase): - """Tests for acme.messages2.RevocationTest.""" + """Tests for acme.messages.RevocationTest.""" def setUp(self): - from acme.messages2 import Revocation + from acme.messages import Revocation self.rev_now = Revocation(authorizations=(), revoke=Revocation.NOW) self.rev_date = Revocation(authorizations=(), revoke=datetime.datetime( 2015, 3, 27, tzinfo=pytz.utc)) @@ -233,7 +233,7 @@ class RevocationTest(unittest.TestCase): 'revoke': '2015-03-27T00:00:00Z'} def test_revoke_decoder(self): - from acme.messages2 import Revocation + from acme.messages import Revocation self.assertEqual(self.rev_now, Revocation.from_json(self.jobj_now)) self.assertEqual(self.rev_date, Revocation.from_json(self.jobj_date)) @@ -242,7 +242,7 @@ class RevocationTest(unittest.TestCase): self.assertEqual(self.jobj_date, self.rev_date.to_partial_json()) def test_from_json_hashable(self): - from acme.messages2 import Revocation + from acme.messages import Revocation hash(Revocation.from_json(self.rev_now.to_json())) diff --git a/docs/pkgs/acme/index.rst b/docs/pkgs/acme/index.rst index 1c73a4a42..ea0743b1e 100644 --- a/docs/pkgs/acme/index.rst +++ b/docs/pkgs/acme/index.rst @@ -10,18 +10,9 @@ Messages -------- -v00 -~~~ - .. automodule:: acme.messages :members: -v02 -~~~ - -.. automodule:: acme.messages2 - :members: - Challenges ---------- diff --git a/examples/restified.py b/examples/restified.py index 07c773575..cfd7fa8dd 100644 --- a/examples/restified.py +++ b/examples/restified.py @@ -4,7 +4,7 @@ import pkg_resources import M2Crypto -from acme import messages2 +from acme import messages from acme import jose from letsencrypt import network @@ -27,8 +27,8 @@ net.update_registration(regr.update( logging.debug(regr) authzr = net.request_challenges( - identifier=messages2.Identifier( - typ=messages2.IDENTIFIER_FQDN, value='example1.com'), + identifier=messages.Identifier( + typ=messages.IDENTIFIER_FQDN, value='example1.com'), new_authzr_uri=regr.new_authzr_uri) logging.debug(authzr) @@ -38,5 +38,5 @@ csr = M2Crypto.X509.load_request_string(pkg_resources.resource_string( 'letsencrypt.tests', os.path.join('testdata', 'csr.pem'))) try: net.request_issuance(csr, (authzr,)) -except messages2.Error as error: +except messages.Error as error: print error.detail diff --git a/letsencrypt/account.py b/letsencrypt/account.py index 93a949050..9f351387f 100644 --- a/letsencrypt/account.py +++ b/letsencrypt/account.py @@ -6,7 +6,7 @@ import re import configobj import zope.component -from acme import messages2 +from acme import messages from letsencrypt import crypto_util from letsencrypt import errors @@ -28,7 +28,7 @@ class Account(object): :ivar str phone: Client's phone number :ivar regr: Registration Resource - :type regr: :class:`~acme.messages2.RegistrationResource` + :type regr: :class:`~acme.messages.RegistrationResource` """ @@ -141,11 +141,11 @@ class Account(object): if "RegistrationResource" in acc_config: acc_config_rr = acc_config["RegistrationResource"] - regr = messages2.RegistrationResource( + regr = messages.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"])) + body=messages.Registration.from_json(acc_config_rr["body"])) else: regr = None diff --git a/letsencrypt/achallenges.py b/letsencrypt/achallenges.py index 46ef167e0..88dcdbe11 100644 --- a/letsencrypt/achallenges.py +++ b/letsencrypt/achallenges.py @@ -5,11 +5,11 @@ Please use names such as ``achall`` to distiguish from variables "of type" and :class:`.ChallengeBody` (denoted by ``challb``):: from acme import challenges - from acme import messages2 + from acme import messages from letsencrypt import achallenges chall = challenges.DNS(token='foo') - challb = messages2.ChallengeBody(chall=chall) + challb = messages.ChallengeBody(chall=chall) achall = achallenges.DNS(chall=challb, domain='example.com') Note, that all annotated challenges act as a proxy objects:: diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index d801613c5..d895c165c 100644 --- a/letsencrypt/auth_handler.py +++ b/letsencrypt/auth_handler.py @@ -4,7 +4,7 @@ import logging import time from acme import challenges -from acme import messages2 +from acme import messages from letsencrypt import achallenges from letsencrypt import constants @@ -30,7 +30,7 @@ class AuthHandler(object): :type account: :class:`letsencrypt.account.Account` :ivar dict authzr: ACME Authorization Resource dict where keys are domains - and values are :class:`acme.messages2.AuthorizationResource` + and values are :class:`acme.messages.AuthorizationResource` :ivar list dv_c: DV challenges in the form of :class:`letsencrypt.achallenges.AnnotatedChallenge` :ivar list cont_c: Continuity challenges in the @@ -82,7 +82,7 @@ class AuthHandler(object): self.verify_authzr_complete() # Only return valid authorizations return [authzr for authzr in self.authzr.values() - if authzr.body.status == messages2.STATUS_VALID] + if authzr.body.status == messages.STATUS_VALID] def _choose_challenges(self, domains): """Retrieve necessary challenges to satisfy server.""" @@ -198,7 +198,7 @@ class AuthHandler(object): failed = [] self.authzr[domain], _ = self.network.poll(self.authzr[domain]) - if self.authzr[domain].body.status == messages2.STATUS_VALID: + if self.authzr[domain].body.status == messages.STATUS_VALID: return achalls, [] # Note: if the whole authorization is invalid, the individual failed @@ -207,9 +207,9 @@ class AuthHandler(object): 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: + if status == messages.STATUS_VALID: completed.append(achall) - elif status == messages2.STATUS_INVALID: + elif status == messages.STATUS_INVALID: failed.append(achall) return completed, failed @@ -221,7 +221,7 @@ class AuthHandler(object): each challenge resource. :param authzr: Authorization Resource - :type authzr: :class:`acme.messages2.AuthorizationResource` + :type authzr: :class:`acme.messages.AuthorizationResource` :param achall: Annotated challenge for which to get status :type achall: :class:`letsencrypt.achallenges.AnnotatedChallenge` @@ -279,8 +279,8 @@ class AuthHandler(object): """ for authzr in self.authzr.values(): - if (authzr.body.status != messages2.STATUS_VALID and - authzr.body.status != messages2.STATUS_INVALID): + if (authzr.body.status != messages.STATUS_VALID and + authzr.body.status != messages.STATUS_INVALID): raise errors.AuthorizationError("Incomplete authorizations") def _challenge_factory(self, domain, path): @@ -321,7 +321,7 @@ def challb_to_achall(challb, key, domain): """Converts a ChallengeBody object to an AnnotatedChallenge. :param challb: ChallengeBody - :type challb: :class:`acme.messages2.ChallengeBody` + :type challb: :class:`acme.messages.ChallengeBody` :param key: Key :type key: :class:`letsencrypt.le_util.Key` @@ -370,8 +370,8 @@ def gen_challenge_path(challbs, preferences, combinations): .. todo:: This can be possibly be rewritten to use resolved_combinations. :param tuple challbs: A tuple of challenges - (:class:`acme.messages2.Challenge`) from - :class:`acme.messages2.AuthorizationResource` to be + (:class:`acme.messages.Challenge`) from + :class:`acme.messages.AuthorizationResource` to be fulfilled by the client in order to prove possession of the identifier. diff --git a/letsencrypt/network.py b/letsencrypt/network.py index a20194a79..6d3be1afc 100644 --- a/letsencrypt/network.py +++ b/letsencrypt/network.py @@ -11,7 +11,7 @@ import werkzeug from acme import jose from acme import jws as acme_jws -from acme import messages2 +from acme import messages from letsencrypt import errors @@ -75,7 +75,7 @@ class Network(object): function will raise an error. Otherwise, wrong Content-Type is ignored, but logged. - :raises letsencrypt.messages2.Error: If server response body + :raises letsencrypt.messages.Error: If server response body carries HTTP Problem (draft-ietf-appsawg-http-problem-00). :raises letsencrypt.errors.NetworkError: In case of other networking errors. @@ -98,7 +98,7 @@ class Network(object): try: logging.error("Error: %s", jobj) logging.error("Response from server: %s", response.content) - raise messages2.Error.from_json(jobj) + raise messages.Error.from_json(jobj) except jose.DeserializationError as error: # Couldn't deserialize JSON object raise errors.NetworkError((response, error)) @@ -160,7 +160,7 @@ class Network(object): :param JSONDeSerializable obj: Will be wrapped in JWS. :param str content_type: Expected ``Content-Type``, fails if not set. - :raises acme.messages2.NetworkError: + :raises acme.messages.NetworkError: :returns: HTTP Response :rtype: `requests.Response` @@ -192,13 +192,13 @@ class Network(object): except KeyError: raise errors.NetworkError('"next" link missing') - return messages2.RegistrationResource( - body=messages2.Registration.from_json(response.json()), + return messages.RegistrationResource( + body=messages.Registration.from_json(response.json()), uri=response.headers.get('Location', uri), new_authzr_uri=new_authzr_uri, terms_of_service=terms_of_service) - def register(self, contact=messages2.Registration._fields[ + def register(self, contact=messages.Registration._fields[ 'contact'].default): """Register. @@ -211,7 +211,7 @@ class Network(object): :raises letsencrypt.errors.UnexpectedUpdate: """ - new_reg = messages2.Registration(contact=contact) + new_reg = messages.Registration(contact=contact) response = self._post(self.new_reg_uri, new_reg) assert response.status_code == httplib.CREATED # TODO: handle errors @@ -289,8 +289,8 @@ class Network(object): except KeyError: raise errors.NetworkError('"next" link missing') - authzr = messages2.AuthorizationResource( - body=messages2.Authorization.from_json(response.json()), + authzr = messages.AuthorizationResource( + body=messages.Authorization.from_json(response.json()), uri=response.headers.get('Location', uri), new_cert_uri=new_cert_uri) if authzr.body.identifier != identifier: @@ -301,7 +301,7 @@ class Network(object): """Request challenges. :param identifier: Identifier to be challenged. - :type identifier: `.messages2.Identifier` + :type identifier: `.messages.Identifier` :param str new_authzr_uri: new-authorization URI @@ -309,7 +309,7 @@ class Network(object): :rtype: `.AuthorizationResource` """ - new_authz = messages2.Authorization(identifier=identifier) + new_authz = messages.Authorization(identifier=identifier) response = self._post(new_authzr_uri, new_authz) assert response.status_code == httplib.CREATED # TODO: handle errors return self._authzr_from_response(response, identifier) @@ -328,8 +328,8 @@ class Network(object): :rtype: `.AuthorizationResource` """ - return self.request_challenges(messages2.Identifier( - typ=messages2.IDENTIFIER_FQDN, value=domain), new_authz_uri) + return self.request_challenges(messages.Identifier( + typ=messages.IDENTIFIER_FQDN, value=domain), new_authz_uri) def answer_challenge(self, challb, response): """Answer challenge. @@ -351,9 +351,9 @@ class Network(object): authzr_uri = response.links['up']['url'] except KeyError: raise errors.NetworkError('"up" Link header missing') - challr = messages2.ChallengeResource( + challr = messages.ChallengeResource( authzr_uri=authzr_uri, - body=messages2.ChallengeBody.from_json(response.json())) + body=messages.ChallengeBody.from_json(response.json())) # TODO: check that challr.uri == response.headers['Location']? if challr.uri != challb.uri: raise errors.UnexpectedUpdate(challr.uri) @@ -412,14 +412,14 @@ class Network(object): :param authzrs: `list` of `.AuthorizationResource` :returns: Issued certificate - :rtype: `.messages2.CertificateResource` + :rtype: `.messages.CertificateResource` """ assert authzrs, "Authorizations list is empty" logging.debug("Requesting issuance...") # TODO: assert len(authzrs) == number of SANs - req = messages2.CertificateRequest( + req = messages.CertificateRequest( csr=csr, authorizations=tuple(authzr.uri for authzr in authzrs)) content_type = self.DER_CONTENT_TYPE # TODO: add 'cert_type 'argument @@ -436,7 +436,7 @@ class Network(object): except KeyError: raise errors.NetworkError('"Location" Header missing') - return messages2.CertificateResource( + return messages.CertificateResource( uri=uri, authzrs=authzrs, cert_chain_uri=cert_chain_uri, body=jose.ComparableX509( M2Crypto.X509.load_cert_der_string(response.content))) @@ -459,7 +459,7 @@ class Network(object): ``Retry-After`` is not present in the response. :returns: ``(cert, updated_authzrs)`` `tuple` where ``cert`` is - the issued certificate (`.messages2.CertificateResource.), + the issued certificate (`.messages.CertificateResource.), and ``updated_authzrs`` is a `tuple` consisting of updated Authorization Resources (`.AuthorizationResource`) as present in the responses from server, and in the same order @@ -488,7 +488,7 @@ class Network(object): updated_authzr, response = self.poll(updated[authzr]) updated[authzr] = updated_authzr - if updated_authzr.body.status != messages2.STATUS_VALID: + if updated_authzr.body.status != messages.STATUS_VALID: # push back to the priority queue, with updated retry_after heapq.heappush(waiting, (self.retry_after( response, default=mintime), authzr)) @@ -561,20 +561,20 @@ class Network(object): else: return None - def revoke(self, certr, when=messages2.Revocation.NOW): + def revoke(self, certr, when=messages.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`. + the same values as `.messages.Revocation.revoke`. :raises letsencrypt.errors.NetworkError: If revocation is unsuccessful. """ - rev = messages2.Revocation(revoke=when, authorizations=tuple( + rev = messages.Revocation(revoke=when, authorizations=tuple( authzr.uri for authzr in certr.authzrs)) response = self._post(certr.uri, rev) if response.status_code != httplib.OK: diff --git a/letsencrypt/tests/account_test.py b/letsencrypt/tests/account_test.py index d14610252..6e9966a55 100644 --- a/letsencrypt/tests/account_test.py +++ b/letsencrypt/tests/account_test.py @@ -7,7 +7,7 @@ import shutil import tempfile import unittest -from acme import messages2 +from acme import messages from letsencrypt import configuration from letsencrypt import errors @@ -40,11 +40,11 @@ class AccountTest(unittest.TestCase): self.key = le_util.Key(key_file, key_pem) self.email = "client@letsencrypt.org" - self.regr = messages2.RegistrationResource( + self.regr = messages.RegistrationResource( uri="uri", new_authzr_uri="new_authzr_uri", terms_of_service="terms_of_service", - body=messages2.Registration( + body=messages.Registration( recovery_token="recovery_token", agreement="agreement") ) diff --git a/letsencrypt/tests/acme_util.py b/letsencrypt/tests/acme_util.py index daf651059..7ac05c1fa 100644 --- a/letsencrypt/tests/acme_util.py +++ b/letsencrypt/tests/acme_util.py @@ -8,7 +8,7 @@ import Crypto.PublicKey.RSA from acme import challenges from acme import jose -from acme import messages2 +from acme import messages KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( @@ -78,19 +78,19 @@ def chall_to_challb(chall, status): # pylint: disable=redefined-outer-name "status": status, } - if status == messages2.STATUS_VALID: + if status == messages.STATUS_VALID: kwargs.update({"validated": datetime.datetime.now()}) - return messages2.ChallengeBody(**kwargs) # pylint: disable=star-args + return messages.ChallengeBody(**kwargs) # pylint: disable=star-args # Pending ChallengeBody objects -DVSNI_P = chall_to_challb(DVSNI, messages2.STATUS_PENDING) -SIMPLE_HTTP_P = chall_to_challb(SIMPLE_HTTP, 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) +DVSNI_P = chall_to_challb(DVSNI, messages.STATUS_PENDING) +SIMPLE_HTTP_P = chall_to_challb(SIMPLE_HTTP, messages.STATUS_PENDING) +DNS_P = chall_to_challb(DNS, messages.STATUS_PENDING) +RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, messages.STATUS_PENDING) +RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, messages.STATUS_PENDING) +POP_P = chall_to_challb(POP, messages.STATUS_PENDING) CHALLENGES_P = [SIMPLE_HTTP_P, DVSNI_P, DNS_P, RECOVERY_CONTACT_P, RECOVERY_TOKEN_P, POP_P] @@ -106,7 +106,7 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True): """Generate an authorization resource. :param authz_status: Status object - :type authz_status: :class:`acme.messages2.Status` + :type authz_status: :class:`acme.messages.Status` :param list challs: Challenge objects :param list statuses: status of each challenge object :param bool combos: Whether or not to add combinations @@ -118,13 +118,13 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True): for chall, status in itertools.izip(challs, statuses) ) authz_kwargs = { - "identifier": messages2.Identifier( - typ=messages2.IDENTIFIER_FQDN, value=domain), + "identifier": messages.Identifier( + typ=messages.IDENTIFIER_FQDN, value=domain), "challenges": challbs, } if combos: authz_kwargs.update({"combinations": gen_combos(challbs)}) - if authz_status == messages2.STATUS_VALID: + if authz_status == messages.STATUS_VALID: authz_kwargs.update({ "status": authz_status, "expires": datetime.datetime.now() + datetime.timedelta(days=31), @@ -135,8 +135,8 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True): }) # pylint: disable=star-args - return messages2.AuthorizationResource( + return messages.AuthorizationResource( uri="https://trusted.ca/new-authz-resource", new_cert_uri="https://trusted.ca/new-cert", - body=messages2.Authorization(**authz_kwargs) + body=messages.Authorization(**authz_kwargs) ) diff --git a/letsencrypt/tests/auth_handler_test.py b/letsencrypt/tests/auth_handler_test.py index fddf508b2..72fba1d0b 100644 --- a/letsencrypt/tests/auth_handler_test.py +++ b/letsencrypt/tests/auth_handler_test.py @@ -6,7 +6,7 @@ import unittest import mock from acme import challenges -from acme import messages2 +from acme import messages from letsencrypt import errors from letsencrypt import le_util @@ -37,8 +37,8 @@ class ChallengeFactoryTest(unittest.TestCase): 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) + messages.STATUS_PENDING, self.dom, acme_util.CHALLENGES, + [messages.STATUS_PENDING]*6, False) def test_all(self): cont_c, dv_c = self.handler._challenge_factory(self.dom, range(0, 6)) @@ -57,9 +57,9 @@ class ChallengeFactoryTest(unittest.TestCase): def test_unrecognized(self): self.handler.authzr["failure.com"] = acme_util.gen_authzr( - messages2.STATUS_PENDING, "failure.com", + messages.STATUS_PENDING, "failure.com", [mock.Mock(chall="chall", typ="unrecognized")], - [messages2.STATUS_PENDING]) + [messages.STATUS_PENDING]) self.assertRaises(errors.LetsEncryptClientError, self.handler._challenge_factory, "failure.com", [0]) @@ -160,10 +160,10 @@ class GetAuthorizationsTest(unittest.TestCase): for dom in self.handler.authzr.keys(): azr = self.handler.authzr[dom] self.handler.authzr[dom] = acme_util.gen_authzr( - messages2.STATUS_VALID, + messages.STATUS_VALID, dom, [challb.chall for challb in azr.body.challenges], - [messages2.STATUS_VALID]*len(azr.body.challenges), + [messages.STATUS_VALID]*len(azr.body.challenges), azr.body.combinations) @@ -182,16 +182,16 @@ class PollChallengesTest(unittest.TestCase): 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) + messages.STATUS_PENDING, self.doms[0], + acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False) 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) + messages.STATUS_PENDING, self.doms[1], + acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False) 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) + messages.STATUS_PENDING, self.doms[2], + acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False) self.chall_update = {} for dom in self.doms: @@ -205,7 +205,7 @@ class PollChallengesTest(unittest.TestCase): self.handler._poll_challenges(self.chall_update, False) for authzr in self.handler.authzr.values(): - self.assertEqual(authzr.body.status, messages2.STATUS_VALID) + self.assertEqual(authzr.body.status, messages.STATUS_VALID) @mock.patch("letsencrypt.auth_handler.time") def test_poll_challenges_failure_best_effort(self, unused_mock_time): @@ -213,7 +213,7 @@ class PollChallengesTest(unittest.TestCase): self.handler._poll_challenges(self.chall_update, True) for authzr in self.handler.authzr.values(): - self.assertEqual(authzr.body.status, messages2.STATUS_PENDING) + self.assertEqual(authzr.body.status, messages.STATUS_PENDING) @mock.patch("letsencrypt.auth_handler.time") def test_poll_challenges_failure(self, unused_mock_time): @@ -241,10 +241,10 @@ class PollChallengesTest(unittest.TestCase): # 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) + return self._mock_poll_solve_one_chall(authzr, messages.STATUS_VALID) def _mock_poll_solve_one_invalid(self, authzr): - return self._mock_poll_solve_one_chall(authzr, messages2.STATUS_INVALID) + return self._mock_poll_solve_one_chall(authzr, messages.STATUS_INVALID) def _mock_poll_solve_one_chall(self, authzr, desired_status): # pylint: disable=no-self-use @@ -269,10 +269,10 @@ class PollChallengesTest(unittest.TestCase): else: status_ = authzr.body.status - new_authzr = messages2.AuthorizationResource( + new_authzr = messages.AuthorizationResource( uri=authzr.uri, new_cert_uri=authzr.new_cert_uri, - body=messages2.Authorization( + body=messages.Authorization( identifier=authzr.body.identifier, challenges=new_challbs, combinations=authzr.body.combinations, @@ -429,8 +429,8 @@ def gen_auth_resp(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)) + messages.STATUS_PENDING, domain, challs, + [messages.STATUS_PENDING]*len(challs)) if __name__ == "__main__": diff --git a/letsencrypt/tests/network_test.py b/letsencrypt/tests/network_test.py index c0522c2fb..586dc7ecb 100644 --- a/letsencrypt/tests/network_test.py +++ b/letsencrypt/tests/network_test.py @@ -14,7 +14,7 @@ import requests from acme import challenges from acme import jose from acme import jws as acme_jws -from acme import messages2 +from acme import messages from letsencrypt import account from letsencrypt import errors @@ -58,37 +58,37 @@ class NetworkTest(unittest.TestCase): self.post = mock.MagicMock(return_value=self.response) self.get = mock.MagicMock(return_value=self.response) - self.identifier = messages2.Identifier( - typ=messages2.IDENTIFIER_FQDN, value='example.com') + self.identifier = messages.Identifier( + typ=messages.IDENTIFIER_FQDN, value='example.com') self.config = mock.Mock(accounts_dir=tempfile.mkdtemp()) # Registration self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212') - reg = messages2.Registration( + reg = messages.Registration( contact=self.contact, key=KEY.public(), recovery_token='t') - self.regr = messages2.RegistrationResource( + self.regr = messages.RegistrationResource( body=reg, uri='https://www.letsencrypt-demo.org/acme/reg/1', new_authzr_uri='https://www.letsencrypt-demo.org/acme/new-reg', terms_of_service='https://www.letsencrypt-demo.org/tos') # Authorization authzr_uri = 'https://www.letsencrypt-demo.org/acme/authz/1' - challb = messages2.ChallengeBody( - uri=(authzr_uri + '/1'), status=messages2.STATUS_VALID, + challb = messages.ChallengeBody( + uri=(authzr_uri + '/1'), status=messages.STATUS_VALID, chall=challenges.DNS(token='foo')) - self.challr = messages2.ChallengeResource( + self.challr = messages.ChallengeResource( body=challb, authzr_uri=authzr_uri) - self.authz = messages2.Authorization( - identifier=messages2.Identifier( - typ=messages2.IDENTIFIER_FQDN, value='example.com'), + self.authz = messages.Authorization( + identifier=messages.Identifier( + typ=messages.IDENTIFIER_FQDN, value='example.com'), challenges=(challb,), combinations=None) - self.authzr = messages2.AuthorizationResource( + self.authzr = messages.AuthorizationResource( body=self.authz, uri=authzr_uri, new_cert_uri='https://www.letsencrypt-demo.org/acme/new-cert') # Request issuance - self.certr = messages2.CertificateResource( + self.certr = messages.CertificateResource( body=CERT, authzrs=(self.authzr,), uri='https://www.letsencrypt-demo.org/acme/cert/1', cert_chain_uri='https://www.letsencrypt-demo.org/ca') @@ -131,11 +131,11 @@ class NetworkTest(unittest.TestCase): def test_check_response_not_ok_jobj_error(self): self.response.ok = False - self.response.json.return_value = messages2.Error( + self.response.json.return_value = messages.Error( detail='foo', typ='serverInternal', title='some title').to_json() # pylint: disable=protected-access self.assertRaises( - messages2.Error, self.net._check_response, self.response) + messages.Error, self.net._check_response, self.response) def test_check_response_not_ok_no_jobj(self): self.response.ok = False @@ -462,7 +462,7 @@ class NetworkTest(unittest.TestCase): if not authzr.retries: # no more retries done = mock.MagicMock(uri=authzr.uri, times=authzr.times) - done.body.status = messages2.STATUS_VALID + done.body.status = messages.STATUS_VALID return done, [] # response (2nd result tuple element) is reduced to only @@ -550,7 +550,7 @@ class NetworkTest(unittest.TestCase): def test_revoke(self): self._mock_post_get() - self.net.revoke(self.certr, when=messages2.Revocation.NOW) + self.net.revoke(self.certr, when=messages.Revocation.NOW) self.post.assert_called_once_with(self.certr.uri, mock.ANY) def test_revoke_bad_status_raises_error(self): diff --git a/letsencrypt/tests/proof_of_possession_test.py b/letsencrypt/tests/proof_of_possession_test.py index 0a044810c..415e4caed 100644 --- a/letsencrypt/tests/proof_of_possession_test.py +++ b/letsencrypt/tests/proof_of_possession_test.py @@ -8,7 +8,7 @@ import mock from acme import challenges from acme import jose -from acme import messages2 +from acme import messages from letsencrypt import achallenges from letsencrypt import proof_of_possession @@ -48,8 +48,8 @@ class ProofOfPossessionTest(unittest.TestCase): issuers=(), authorized_for=()) chall = challenges.ProofOfPossession( alg=jose.RS256, nonce='zczv4HMLVe_0kimJ25Juig', hints=hints) - challb = messages2.ChallengeBody( - chall=chall, uri="http://example", status=messages2.STATUS_PENDING) + challb = messages.ChallengeBody( + chall=chall, uri="http://example", status=messages.STATUS_PENDING) self.achall = achallenges.ProofOfPossession( challb=challb, domain="example.com") @@ -60,8 +60,8 @@ class ProofOfPossessionTest(unittest.TestCase): issuers=(), authorized_for=()) chall = challenges.ProofOfPossession( alg=jose.HS512, nonce='zczv4HMLVe_0kimJ25Juig', hints=hints) - challb = messages2.ChallengeBody( - chall=chall, uri="http://example", status=messages2.STATUS_PENDING) + challb = messages.ChallengeBody( + chall=chall, uri="http://example", status=messages.STATUS_PENDING) self.achall = achallenges.ProofOfPossession( challb=challb, domain="example.com") self.assertEqual(self.proof_of_pos.perform(self.achall), None) diff --git a/letsencrypt_nginx/tests/configurator_test.py b/letsencrypt_nginx/tests/configurator_test.py index 82b80b9d2..94a0901b5 100644 --- a/letsencrypt_nginx/tests/configurator_test.py +++ b/letsencrypt_nginx/tests/configurator_test.py @@ -5,7 +5,7 @@ import unittest import mock from acme import challenges -from acme import messages2 +from acme import messages from letsencrypt import achallenges from letsencrypt import errors @@ -165,20 +165,20 @@ 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( - challb=messages2.ChallengeBody( + challb=messages.ChallengeBody( chall=challenges.DVSNI( r="foo", nonce="bar"), uri="https://ca.org/chall0_uri", - status=messages2.Status("pending"), + status=messages.Status("pending"), ), domain="localhost", key=auth_key) achall2 = achallenges.DVSNI( - challb=messages2.ChallengeBody( + challb=messages.ChallengeBody( chall=challenges.DVSNI( r="abc", nonce="def"), uri="https://ca.org/chall1_uri", - status=messages2.Status("pending"), + status=messages.Status("pending"), ), domain="example.com", key=auth_key) dvsni_ret_val = [ From b4d63cbbb3e2823ab6b422b3c08b70f310fe4a51 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 18 Jun 2015 10:38:20 +0000 Subject: [PATCH 59/72] Move letsencrypt.network to acme.client. --- acme/client.py | 559 +++++++++++++++++++++++++++++ acme/client_test.py | 530 ++++++++++++++++++++++++++++ acme/errors.py | 7 + acme/jose/testdata/README | 9 +- acme/jose/testdata/cert.der | Bin 0 -> 377 bytes acme/jose/testdata/csr.der | Bin 0 -> 210 bytes acme/jose/testdata/csr2.pem | 10 - docs/pkgs/acme/index.rst | 7 + letsencrypt/errors.py | 8 - letsencrypt/network.py | 568 +----------------------------- letsencrypt/tests/network_test.py | 515 +-------------------------- 11 files changed, 1115 insertions(+), 1098 deletions(-) create mode 100644 acme/client.py create mode 100644 acme/client_test.py create mode 100644 acme/jose/testdata/cert.der create mode 100644 acme/jose/testdata/csr.der delete mode 100644 acme/jose/testdata/csr2.pem diff --git a/acme/client.py b/acme/client.py new file mode 100644 index 000000000..c0eda0fa3 --- /dev/null +++ b/acme/client.py @@ -0,0 +1,559 @@ +"""ACME client API.""" +import datetime +import heapq +import httplib +import logging +import time + +import M2Crypto +import requests +import werkzeug + +from acme import errors +from acme import jose +from acme import jws +from acme import messages + + +# https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning +requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() + + +class Client(object): + """ACME client. + + .. todo:: + Clean up raised error types hierarchy, document, and handle (wrap) + instances of `.DeserializationError` raised in `from_json()`. + + :ivar str new_reg_uri: Location of new-reg + :ivar key: `.JWK` (private) + :ivar alg: `.JWASignature` + :ivar bool verify_ssl: Verify SSL certificates? + + """ + DER_CONTENT_TYPE = 'application/pkix-cert' + JSON_CONTENT_TYPE = 'application/json' + JSON_ERROR_CONTENT_TYPE = 'application/problem+json' + REPLAY_NONCE_HEADER = 'Replay-Nonce' + + def __init__(self, new_reg_uri, key, alg=jose.RS256, verify_ssl=True): + self.new_reg_uri = new_reg_uri + self.key = key + self.alg = alg + self.verify_ssl = verify_ssl + self._nonces = set() + + def _wrap_in_jws(self, obj, nonce): + """Wrap `JSONDeSerializable` object in JWS. + + .. todo:: Implement ``acmePath``. + + :param JSONDeSerializable obj: + :rtype: `.JWS` + + """ + dumps = obj.json_dumps() + logging.debug('Serialized JSON: %s', dumps) + return jws.JWS.sign( + payload=dumps, key=self.key, alg=self.alg, nonce=nonce).json_dumps() + + @classmethod + def _check_response(cls, response, content_type=None): + """Check response content and its type. + + .. note:: + Checking is not strict: wrong server response ``Content-Type`` + HTTP header is ignored if response is an expected JSON object + (c.f. Boulder #56). + + :param str content_type: Expected Content-Type response header. + If JSON is expected and not present in server response, this + function will raise an error. Otherwise, wrong Content-Type + is ignored, but logged. + + :raises .messages.Error: If server response body + carries HTTP Problem (draft-ietf-appsawg-http-problem-00). + :raises .ClientError: In case of other networking errors. + + """ + response_ct = response.headers.get('Content-Type') + try: + # TODO: response.json() is called twice, once here, and + # once in _get and _post clients + jobj = response.json() + except ValueError as error: + jobj = None + + if not response.ok: + if jobj is not None: + if response_ct != cls.JSON_ERROR_CONTENT_TYPE: + 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 messages.Error.from_json(jobj) + except jose.DeserializationError as error: + # Couldn't deserialize JSON object + raise errors.ClientError((response, error)) + else: + # response is not JSON object + raise errors.ClientError(response) + else: + if jobj is not None and response_ct != cls.JSON_CONTENT_TYPE: + logging.debug( + 'Ignoring wrong Content-Type (%r) for JSON decodable ' + 'response', response_ct) + + if content_type == cls.JSON_CONTENT_TYPE and jobj is None: + raise errors.ClientError( + 'Unexpected response Content-Type: {0}'.format(response_ct)) + + def _get(self, uri, content_type=JSON_CONTENT_TYPE, **kwargs): + """Send GET request. + + :raises .ClientError: + + :returns: HTTP Response + :rtype: `requests.Response` + + """ + logging.debug('Sending GET request to %s', uri) + kwargs.setdefault('verify', self.verify_ssl) + try: + response = requests.get(uri, **kwargs) + except requests.exceptions.RequestException as error: + raise errors.ClientError(error) + self._check_response(response, content_type=content_type) + return response + + def _add_nonce(self, response): + if self.REPLAY_NONCE_HEADER in response.headers: + nonce = response.headers[self.REPLAY_NONCE_HEADER] + error = jws.Header.validate_nonce(nonce) + if error is None: + logging.debug('Storing nonce: %r', nonce) + self._nonces.add(nonce) + else: + raise errors.ClientError('Invalid nonce ({0}): {1}'.format( + nonce, error)) + else: + raise errors.ClientError( + 'Server {0} response did not include a replay nonce'.format( + response.request.method)) + + def _get_nonce(self, uri): + if not self._nonces: + logging.debug('Requesting fresh nonce by sending HEAD to %s', uri) + self._add_nonce(requests.head(uri)) + return self._nonces.pop() + + def _post(self, uri, obj, content_type=JSON_CONTENT_TYPE, **kwargs): + """Send POST data. + + :param JSONDeSerializable obj: Will be wrapped in JWS. + :param str content_type: Expected ``Content-Type``, fails if not set. + + :raises acme.messages.ClientError: + + :returns: HTTP Response + :rtype: `requests.Response` + + """ + data = self._wrap_in_jws(obj, self._get_nonce(uri)) + logging.debug('Sending POST data to %s: %s', uri, data) + kwargs.setdefault('verify', self.verify_ssl) + try: + response = requests.post(uri, data=data, **kwargs) + except requests.exceptions.RequestException as error: + raise errors.ClientError(error) + logging.debug('Received response %s: %r', response, response.text) + + self._add_nonce(response) + self._check_response(response, content_type=content_type) + return response + + @classmethod + def _regr_from_response(cls, response, uri=None, new_authzr_uri=None, + terms_of_service=None): + terms_of_service = ( + response.links['terms-of-service']['url'] + if 'terms-of-service' in response.links else terms_of_service) + + if new_authzr_uri is None: + try: + new_authzr_uri = response.links['next']['url'] + except KeyError: + raise errors.ClientError('"next" link missing') + + return messages.RegistrationResource( + body=messages.Registration.from_json(response.json()), + uri=response.headers.get('Location', uri), + new_authzr_uri=new_authzr_uri, + terms_of_service=terms_of_service) + + def register(self, contact=messages.Registration._fields[ + 'contact'].default): + """Register. + + :param contact: Contact list, as accepted by `.Registration` + :type contact: `tuple` + + :returns: Registration Resource. + :rtype: `.RegistrationResource` + + :raises .UnexpectedUpdate: + + """ + new_reg = messages.Registration(contact=contact) + + response = self._post(self.new_reg_uri, new_reg) + assert response.status_code == httplib.CREATED # TODO: handle errors + + regr = self._regr_from_response(response) + if regr.body.key != self.key.public() or regr.body.contact != contact: + raise errors.UnexpectedUpdate(regr) + + return regr + + def update_registration(self, regr): + """Update registration. + + :pram regr: Registration Resource. + :type regr: `.RegistrationResource` + + :returns: Updated Registration Resource. + :rtype: `.RegistrationResource` + + """ + response = self._post(regr.uri, regr.body) + + # TODO: Boulder returns httplib.ACCEPTED + #assert response.status_code == httplib.OK + + # TODO: Boulder does not set Location or Link on update + # (c.f. acme-spec #94) + + updated_regr = self._regr_from_response( + response, uri=regr.uri, new_authzr_uri=regr.new_authzr_uri, + terms_of_service=regr.terms_of_service) + if updated_regr != regr: + 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): + # pylint: disable=no-self-use + if new_cert_uri is None: + try: + new_cert_uri = response.links['next']['url'] + except KeyError: + raise errors.ClientError('"next" link missing') + + authzr = messages.AuthorizationResource( + body=messages.Authorization.from_json(response.json()), + uri=response.headers.get('Location', uri), + new_cert_uri=new_cert_uri) + if authzr.body.identifier != identifier: + raise errors.UnexpectedUpdate(authzr) + return authzr + + def request_challenges(self, identifier, new_authzr_uri): + """Request challenges. + + :param identifier: Identifier to be challenged. + :type identifier: `.messages.Identifier` + + :param str new_authzr_uri: new-authorization URI + + :returns: Authorization Resource. + :rtype: `.AuthorizationResource` + + """ + new_authz = messages.Authorization(identifier=identifier) + response = self._post(new_authzr_uri, new_authz) + assert response.status_code == httplib.CREATED # TODO: handle errors + return self._authzr_from_response(response, identifier) + + def request_domain_challenges(self, domain, new_authz_uri): + """Request challenges for domain names. + + This is simply a convenience function that wraps around + `request_challenges`, but works with domain names instead of + 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(messages.Identifier( + typ=messages.IDENTIFIER_FQDN, value=domain), new_authz_uri) + + def answer_challenge(self, challb, response): + """Answer challenge. + + :param challb: Challenge Resource body. + :type challb: `.ChallengeBody` + + :param response: Corresponding Challenge response + :type response: `.challenges.ChallengeResponse` + + :returns: Challenge Resource with updated body. + :rtype: `.ChallengeResource` + + :raises errors.UnexpectedUpdate: + + """ + response = self._post(challb.uri, response) + try: + authzr_uri = response.links['up']['url'] + except KeyError: + raise errors.ClientError('"up" Link header missing') + challr = messages.ChallengeResource( + authzr_uri=authzr_uri, + body=messages.ChallengeBody.from_json(response.json())) + # TODO: check that challr.uri == response.headers['Location']? + if challr.uri != challb.uri: + raise errors.UnexpectedUpdate(challr.uri) + return challr + + @classmethod + def retry_after(cls, response, default): + """Compute next `poll` time based on response ``Retry-After`` header. + + :param response: Response from `poll`. + :type response: `requests.Response` + + :param int default: Default value (in seconds), used when + ``Retry-After`` header is not present or invalid. + + :returns: Time point when next `poll` should be performed. + :rtype: `datetime.datetime` + + """ + retry_after = response.headers.get('Retry-After', str(default)) + try: + seconds = int(retry_after) + except ValueError: + # pylint: disable=no-member + decoded = werkzeug.parse_date(retry_after) # RFC1123 + if decoded is None: + seconds = default + else: + return decoded + + return datetime.datetime.now() + datetime.timedelta(seconds=seconds) + + def poll(self, authzr): + """Poll Authorization Resource for status. + + :param authzr: Authorization Resource + :type authzr: `.AuthorizationResource` + + :returns: Updated Authorization Resource and HTTP response. + + :rtype: (`.AuthorizationResource`, `requests.Response`) + + """ + response = self._get(authzr.uri) + 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): + """Request issuance. + + :param csr: CSR + :type csr: `M2Crypto.X509.Request` wrapped in `.ComparableX509` + + :param authzrs: `list` of `.AuthorizationResource` + + :returns: Issued certificate + :rtype: `.messages.CertificateResource` + + """ + assert authzrs, "Authorizations list is empty" + logging.debug("Requesting issuance...") + + # TODO: assert len(authzrs) == number of SANs + req = messages.CertificateRequest( + csr=csr, authorizations=tuple(authzr.uri for authzr in authzrs)) + + content_type = self.DER_CONTENT_TYPE # TODO: add 'cert_type 'argument + response = self._post( + authzrs[0].new_cert_uri, # TODO: acme-spec #90 + req, + content_type=content_type, + headers={'Accept': content_type}) + + cert_chain_uri = response.links.get('up', {}).get('url') + + try: + uri = response.headers['Location'] + except KeyError: + raise errors.ClientError('"Location" Header missing') + + return messages.CertificateResource( + uri=uri, authzrs=authzrs, cert_chain_uri=cert_chain_uri, + body=jose.ComparableX509( + M2Crypto.X509.load_cert_der_string(response.content))) + + def poll_and_request_issuance(self, csr, authzrs, mintime=5): + """Poll and request issuance. + + This function polls all provided Authorization Resource URIs + until all challenges are valid, respecting ``Retry-After`` HTTP + headers, and then calls `request_issuance`. + + .. todo:: add `max_attempts` or `timeout` + + :param csr: CSR. + :type csr: `M2Crypto.X509.Request` wrapped in `.ComparableX509` + + :param authzrs: `list` of `.AuthorizationResource` + + :param int mintime: Minimum time before next attempt, used if + ``Retry-After`` is not present in the response. + + :returns: ``(cert, updated_authzrs)`` `tuple` where ``cert`` is + the issued certificate (`.messages.CertificateResource.), + and ``updated_authzrs`` is a `tuple` consisting of updated + Authorization Resources (`.AuthorizationResource`) as + present in the responses from server, and in the same order + as the input ``authzrs``. + :rtype: `tuple` + + """ + # 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 + # recently updated one + updated = dict((authzr, authzr) for authzr in authzrs) + + while waiting: + # find the smallest Retry-After, and sleep if necessary + when, authzr = heapq.heappop(waiting) + now = datetime.datetime.now() + if when > now: + seconds = (when - now).seconds + logging.debug('Sleeping for %d seconds', seconds) + time.sleep(seconds) + + # Note that we poll with the latest updated Authorization + # URI, which might have a different URI than initial one + updated_authzr, response = self.poll(updated[authzr]) + updated[authzr] = updated_authzr + + if updated_authzr.body.status != messages.STATUS_VALID: + # push back to the priority queue, with updated retry_after + heapq.heappush(waiting, (self.retry_after( + response, default=mintime), authzr)) + + updated_authzrs = tuple(updated[authzr] for authzr in authzrs) + 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:`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) + return response, jose.ComparableX509( + M2Crypto.X509.load_cert_der_string(response.content)) + + def check_cert(self, certr): + """Check for new cert. + + :param certr: Certificate Resource + :type certr: `.CertificateResource` + + :returns: Updated Certificate Resource. + :rtype: `.CertificateResource` + + """ + # TODO: acme-spec 5.1 table action should be renamed to + # "refresh cert", and this method integrated with self.refresh + response, cert = self._get_cert(certr.uri) + if 'Location' not in response.headers: + raise errors.ClientError('Location header missing') + if response.headers['Location'] != certr.uri: + raise errors.UnexpectedUpdate(response.text) + return certr.update(body=cert) + + def refresh(self, certr): + """Refresh certificate. + + :param certr: Certificate Resource + :type certr: `.CertificateResource` + + :returns: Updated Certificate Resource. + :rtype: `.CertificateResource` + + """ + # TODO: If a client sends a refresh request and the server is + # not willing to refresh the certificate, the server MUST + # respond with status code 403 (Forbidden) + return self.check_cert(certr) + + def fetch_chain(self, certr): + """Fetch chain for certificate. + + :param certr: Certificate Resource + :type certr: `.CertificateResource` + + :returns: Certificate chain, or `None` if no "up" Link was provided. + :rtype: `M2Crypto.X509.X509` wrapped in `.ComparableX509` + + """ + if certr.cert_chain_uri is not None: + return self._get_cert(certr.cert_chain_uri)[1] + else: + return None + + def revoke(self, certr, when=messages.Revocation.NOW): + """Revoke certificate. + + :param certr: Certificate Resource + :type certr: `.CertificateResource` + + :param when: When should the revocation take place? Takes + the same values as `.messages.Revocation.revoke`. + + :raises .ClientError: If revocation is unsuccessful. + + """ + rev = messages.Revocation(revoke=when, authorizations=tuple( + authzr.uri for authzr in certr.authzrs)) + response = self._post(certr.uri, rev) + if response.status_code != httplib.OK: + raise errors.ClientError( + 'Successful revocation must return HTTP OK status') diff --git a/acme/client_test.py b/acme/client_test.py new file mode 100644 index 000000000..5e4cc1720 --- /dev/null +++ b/acme/client_test.py @@ -0,0 +1,530 @@ +"""Tests for acme.client.""" +import datetime +import httplib +import os +import pkg_resources +import unittest + +import M2Crypto +import mock +import requests + +from acme import challenges +from acme import errors +from acme import jose +from acme import jws as acme_jws +from acme import messages + + +CERT = jose.ComparableX509(M2Crypto.X509.load_cert_string( + pkg_resources.resource_string( + 'acme.jose', os.path.join('testdata', 'cert.der')), + M2Crypto.X509.FORMAT_DER)) +CSR = jose.ComparableX509(M2Crypto.X509.load_request_string( + pkg_resources.resource_string( + 'acme.jose', os.path.join('testdata', 'csr.der')), + M2Crypto.X509.FORMAT_DER)) +KEY = jose.JWKRSA.load(pkg_resources.resource_string( + 'acme.jose', os.path.join('testdata', 'rsa512_key.pem'))) +KEY2 = jose.JWKRSA.load(pkg_resources.resource_string( + 'acme.jose', os.path.join('testdata', 'rsa256_key.pem'))) + + +class ClientTest(unittest.TestCase): + """Tests for acme.client.Client.""" + + # pylint: disable=too-many-instance-attributes,too-many-public-methods + + def setUp(self): + self.verify_ssl = mock.MagicMock() + self.wrap_in_jws = mock.MagicMock(return_value=mock.sentinel.wrapped) + + from acme.client import Client + self.net = Client( + new_reg_uri='https://www.letsencrypt-demo.org/acme/new-reg', + key=KEY, alg=jose.RS256, verify_ssl=self.verify_ssl) + self.nonce = jose.b64encode('Nonce') + self.net._nonces.add(self.nonce) # pylint: disable=protected-access + + self.response = mock.MagicMock(ok=True, status_code=httplib.OK) + self.response.headers = {} + self.response.links = {} + + self.post = mock.MagicMock(return_value=self.response) + self.get = mock.MagicMock(return_value=self.response) + + self.identifier = messages.Identifier( + typ=messages.IDENTIFIER_FQDN, value='example.com') + + # Registration + self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212') + reg = messages.Registration( + contact=self.contact, key=KEY.public(), recovery_token='t') + self.regr = messages.RegistrationResource( + body=reg, uri='https://www.letsencrypt-demo.org/acme/reg/1', + new_authzr_uri='https://www.letsencrypt-demo.org/acme/new-reg', + terms_of_service='https://www.letsencrypt-demo.org/tos') + + # Authorization + authzr_uri = 'https://www.letsencrypt-demo.org/acme/authz/1' + challb = messages.ChallengeBody( + uri=(authzr_uri + '/1'), status=messages.STATUS_VALID, + chall=challenges.DNS(token='foo')) + self.challr = messages.ChallengeResource( + body=challb, authzr_uri=authzr_uri) + self.authz = messages.Authorization( + identifier=messages.Identifier( + typ=messages.IDENTIFIER_FQDN, value='example.com'), + challenges=(challb,), combinations=None) + self.authzr = messages.AuthorizationResource( + body=self.authz, uri=authzr_uri, + new_cert_uri='https://www.letsencrypt-demo.org/acme/new-cert') + + # Request issuance + self.certr = messages.CertificateResource( + body=CERT, authzrs=(self.authzr,), + uri='https://www.letsencrypt-demo.org/acme/cert/1', + cert_chain_uri='https://www.letsencrypt-demo.org/ca') + + def _mock_post_get(self): + # pylint: disable=protected-access + self.net._post = self.post + self.net._get = self.get + + def test_init(self): + self.assertTrue(self.net.verify_ssl is self.verify_ssl) + + def test_wrap_in_jws(self): + class MockJSONDeSerializable(jose.JSONDeSerializable): + # pylint: disable=missing-docstring + def __init__(self, value): + self.value = value + def to_partial_json(self): + return self.value + @classmethod + def from_json(cls, value): + pass # pragma: no cover + # pylint: disable=protected-access + jws_dump = self.net._wrap_in_jws( + MockJSONDeSerializable('foo'), nonce='Tg') + jws = acme_jws.JWS.json_loads(jws_dump) + self.assertEqual(jws.payload, '"foo"') + self.assertEqual(jws.signature.combined.nonce, 'Tg') + # TODO: check that nonce is in protected header + + def test_check_response_not_ok_jobj_no_error(self): + self.response.ok = False + self.response.json.return_value = {} + # pylint: disable=protected-access + self.assertRaises( + errors.ClientError, self.net._check_response, self.response) + + def test_check_response_not_ok_jobj_error(self): + self.response.ok = False + self.response.json.return_value = messages.Error( + detail='foo', typ='serverInternal', title='some title').to_json() + # pylint: disable=protected-access + self.assertRaises( + messages.Error, self.net._check_response, self.response) + + def test_check_response_not_ok_no_jobj(self): + self.response.ok = False + self.response.json.side_effect = ValueError + # pylint: disable=protected-access + self.assertRaises( + errors.ClientError, self.net._check_response, self.response) + + def test_check_response_ok_no_jobj_ct_required(self): + self.response.json.side_effect = ValueError + for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']: + self.response.headers['Content-Type'] = response_ct + # pylint: disable=protected-access + self.assertRaises( + errors.ClientError, self.net._check_response, self.response, + content_type=self.net.JSON_CONTENT_TYPE) + + def test_check_response_ok_no_jobj_no_ct(self): + self.response.json.side_effect = ValueError + for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']: + self.response.headers['Content-Type'] = response_ct + # pylint: disable=protected-access + self.net._check_response(self.response) + + def test_check_response_jobj(self): + self.response.json.return_value = {} + for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']: + self.response.headers['Content-Type'] = response_ct + # pylint: disable=protected-access + self.net._check_response(self.response) + + @mock.patch('acme.client.requests') + def test_get_requests_error_passthrough(self, requests_mock): + requests_mock.exceptions = requests.exceptions + requests_mock.get.side_effect = requests.exceptions.RequestException + # pylint: disable=protected-access + self.assertRaises(errors.ClientError, self.net._get, 'uri') + + @mock.patch('acme.client.requests') + def test_get(self, requests_mock): + # pylint: disable=protected-access + self.net._check_response = mock.MagicMock() + self.net._get('uri', content_type='ct') + self.net._check_response.assert_called_once_with( + requests_mock.get('uri'), content_type='ct') + + def _mock_wrap_in_jws(self): + # pylint: disable=protected-access + self.net._wrap_in_jws = self.wrap_in_jws + + @mock.patch('acme.client.requests') + def test_post_requests_error_passthrough(self, requests_mock): + requests_mock.exceptions = requests.exceptions + requests_mock.post.side_effect = requests.exceptions.RequestException + # pylint: disable=protected-access + self._mock_wrap_in_jws() + self.assertRaises( + errors.ClientError, self.net._post, 'uri', mock.sentinel.obj) + + @mock.patch('acme.client.requests') + def test_post(self, requests_mock): + # pylint: disable=protected-access + self.net._check_response = mock.MagicMock() + self._mock_wrap_in_jws() + requests_mock.post().headers = { + self.net.REPLAY_NONCE_HEADER: self.nonce} + self.net._post('uri', mock.sentinel.obj, content_type='ct') + self.net._check_response.assert_called_once_with( + requests_mock.post('uri', mock.sentinel.wrapped), content_type='ct') + + @mock.patch('acme.client.requests') + def test_post_replay_nonce_handling(self, requests_mock): + # pylint: disable=protected-access + self.net._check_response = mock.MagicMock() + self._mock_wrap_in_jws() + + self.net._nonces.clear() + self.assertRaises( + errors.ClientError, self.net._post, 'uri', mock.sentinel.obj) + + nonce2 = jose.b64encode('Nonce2') + requests_mock.head('uri').headers = { + self.net.REPLAY_NONCE_HEADER: nonce2} + requests_mock.post('uri').headers = { + self.net.REPLAY_NONCE_HEADER: self.nonce} + + self.net._post('uri', mock.sentinel.obj) + + requests_mock.head.assert_called_with('uri') + self.wrap_in_jws.assert_called_once_with(mock.sentinel.obj, nonce2) + self.assertEqual(self.net._nonces, set([self.nonce])) + + # wrong nonce + requests_mock.post('uri').headers = {self.net.REPLAY_NONCE_HEADER: 'F'} + self.assertRaises( + errors.ClientError, self.net._post, 'uri', mock.sentinel.obj) + + @mock.patch('acme.client.requests') + def test_get_post_verify_ssl(self, requests_mock): + # pylint: disable=protected-access + self._mock_wrap_in_jws() + self.net._check_response = mock.MagicMock() + + for verify_ssl in [True, False]: + self.net.verify_ssl = verify_ssl + self.net._get('uri') + self.net._nonces.add('N') + requests_mock.post().headers = { + self.net.REPLAY_NONCE_HEADER: self.nonce} + self.net._post('uri', mock.sentinel.obj) + requests_mock.get.assert_called_once_with('uri', verify=verify_ssl) + requests_mock.post.assert_called_with( + 'uri', data=mock.sentinel.wrapped, verify=verify_ssl) + requests_mock.reset_mock() + + def test_register(self): + self.response.status_code = httplib.CREATED + self.response.json.return_value = self.regr.body.to_json() + self.response.headers['Location'] = self.regr.uri + self.response.links.update({ + 'next': {'url': self.regr.new_authzr_uri}, + 'terms-of-service': {'url': self.regr.terms_of_service}, + }) + + self._mock_post_get() + self.assertEqual(self.regr, self.net.register(self.contact)) + # TODO: test POST call arguments + + # TODO: split here and separate test + reg_wrong_key = self.regr.body.update(key=KEY2.public()) + self.response.json.return_value = reg_wrong_key.to_json() + self.assertRaises( + errors.UnexpectedUpdate, self.net.register, self.contact) + + def test_register_missing_next(self): + self.response.status_code = httplib.CREATED + self._mock_post_get() + self.assertRaises( + errors.ClientError, self.net.register, self.regr.body) + + def test_update_registration(self): + self.response.headers['Location'] = self.regr.uri + self.response.json.return_value = self.regr.body.to_json() + self._mock_post_get() + self.assertEqual(self.regr, self.net.update_registration(self.regr)) + + # TODO: split here and separate test + self.response.json.return_value = self.regr.body.update( + contact=()).to_json() + 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 + self.response.json.return_value = self.authz.to_json() + self.response.links = { + 'next': {'url': self.authzr.new_cert_uri}, + } + + self._mock_post_get() + self.net.request_challenges(self.identifier, self.authzr.uri) + # TODO: test POST call arguments + + # TODO: split here and separate test + self.response.json.return_value = self.authz.update( + identifier=self.identifier.update(value='foo')).to_json() + self.assertRaises(errors.UnexpectedUpdate, self.net.request_challenges, + self.identifier, self.authzr.uri) + + def test_request_challenges_missing_next(self): + self.response.status_code = httplib.CREATED + self._mock_post_get() + self.assertRaises( + errors.ClientError, self.net.request_challenges, + self.identifier, self.regr) + + def test_request_domain_challenges(self): + self.net.request_challenges = mock.MagicMock() + self.assertEqual( + self.net.request_challenges(self.identifier), + self.net.request_domain_challenges('example.com', self.regr)) + + def test_answer_challenge(self): + self.response.links['up'] = {'url': self.challr.authzr_uri} + self.response.json.return_value = self.challr.body.to_json() + + chall_response = challenges.DNSResponse() + + self._mock_post_get() + self.net.answer_challenge(self.challr.body, chall_response) + + # TODO: split here and separate test + self.assertRaises(errors.UnexpectedUpdate, self.net.answer_challenge, + self.challr.body.update(uri='foo'), chall_response) + + def test_answer_challenge_missing_next(self): + self._mock_post_get() + self.assertRaises(errors.ClientError, 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' + self.assertEqual( + datetime.datetime(1999, 12, 31, 23, 59, 59), + self.net.retry_after(response=self.response, default=10)) + + @mock.patch('acme.client.datetime') + def test_retry_after_invalid(self, dt_mock): + dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27) + dt_mock.timedelta = datetime.timedelta + + self.response.headers['Retry-After'] = 'foooo' + self.assertEqual( + datetime.datetime(2015, 3, 27, 0, 0, 10), + self.net.retry_after(response=self.response, default=10)) + + @mock.patch('acme.client.datetime') + def test_retry_after_seconds(self, dt_mock): + dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27) + dt_mock.timedelta = datetime.timedelta + + self.response.headers['Retry-After'] = '50' + self.assertEqual( + datetime.datetime(2015, 3, 27, 0, 0, 50), + self.net.retry_after(response=self.response, default=10)) + + @mock.patch('acme.client.datetime') + def test_retry_after_missing(self, dt_mock): + dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27) + dt_mock.timedelta = datetime.timedelta + + self.assertEqual( + datetime.datetime(2015, 3, 27, 0, 0, 10), + self.net.retry_after(response=self.response, default=10)) + + def test_poll(self): + self.response.json.return_value = self.authzr.body.to_json() + self._mock_post_get() + self.assertEqual((self.authzr, self.response), + self.net.poll(self.authzr)) + + # TODO: split here and separate test + self.response.json.return_value = self.authz.update( + identifier=self.identifier.update(value='foo')).to_json() + self.assertRaises(errors.UnexpectedUpdate, self.net.poll, self.authzr) + + def test_request_issuance(self): + self.response.content = CERT.as_der() + self.response.headers['Location'] = self.certr.uri + self.response.links['up'] = {'url': self.certr.cert_chain_uri} + self._mock_post_get() + self.assertEqual( + self.certr, self.net.request_issuance(CSR, (self.authzr,))) + # TODO: check POST args + + def test_request_issuance_missing_up(self): + self.response.content = CERT.as_der() + self.response.headers['Location'] = self.certr.uri + self._mock_post_get() + self.assertEqual( + self.certr.update(cert_chain_uri=None), + self.net.request_issuance(CSR, (self.authzr,))) + + def test_request_issuance_missing_location(self): + self._mock_post_get() + self.assertRaises( + errors.ClientError, self.net.request_issuance, + CSR, (self.authzr,)) + + @mock.patch('acme.client.datetime') + @mock.patch('acme.client.time') + def test_poll_and_request_issuance(self, time_mock, dt_mock): + # clock.dt | pylint: disable=no-member + clock = mock.MagicMock(dt=datetime.datetime(2015, 3, 27)) + + def sleep(seconds): + """increment clock""" + clock.dt += datetime.timedelta(seconds=seconds) + time_mock.sleep.side_effect = sleep + + def now(): + """return current clock value""" + return clock.dt + dt_mock.datetime.now.side_effect = now + dt_mock.timedelta = datetime.timedelta + + def poll(authzr): # pylint: disable=missing-docstring + # record poll start time based on the current clock value + authzr.times.append(clock.dt) + + # suppose it takes 2 seconds for server to produce the + # result, increment clock + clock.dt += datetime.timedelta(seconds=2) + + if not authzr.retries: # no more retries + done = mock.MagicMock(uri=authzr.uri, times=authzr.times) + done.body.status = messages.STATUS_VALID + return done, [] + + # response (2nd result tuple element) is reduced to only + # Retry-After header contents represented as integer + # seconds; authzr.retries is a list of Retry-After + # headers, head(retries) is peeled of as a current + # Retry-After header, and tail(retries) is persisted for + # later poll() calls + return (mock.MagicMock(retries=authzr.retries[1:], + uri=authzr.uri + '.', times=authzr.times), + authzr.retries[0]) + self.net.poll = mock.MagicMock(side_effect=poll) + + mintime = 7 + + def retry_after(response, default): # pylint: disable=missing-docstring + # check that poll_and_request_issuance correctly passes mintime + self.assertEqual(default, mintime) + return clock.dt + datetime.timedelta(seconds=response) + self.net.retry_after = mock.MagicMock(side_effect=retry_after) + + def request_issuance(csr, authzrs): # pylint: disable=missing-docstring + return csr, authzrs + self.net.request_issuance = mock.MagicMock(side_effect=request_issuance) + + csr = mock.MagicMock() + authzrs = ( + mock.MagicMock(uri='a', times=[], retries=(8, 20, 30)), + mock.MagicMock(uri='b', times=[], retries=(5,)), + ) + + cert, updated_authzrs = self.net.poll_and_request_issuance( + csr, authzrs, mintime=mintime) + self.assertTrue(cert[0] is csr) + self.assertTrue(cert[1] is updated_authzrs) + self.assertEqual(updated_authzrs[0].uri, 'a...') + self.assertEqual(updated_authzrs[1].uri, 'b.') + self.assertEqual(updated_authzrs[0].times, [ + datetime.datetime(2015, 3, 27), + # a is scheduled for 10, but b is polling [9..11), so it + # will be picked up as soon as b is finished, without + # additional sleeping + datetime.datetime(2015, 3, 27, 0, 0, 11), + datetime.datetime(2015, 3, 27, 0, 0, 33), + datetime.datetime(2015, 3, 27, 0, 1, 5), + ]) + self.assertEqual(updated_authzrs[1].times, [ + datetime.datetime(2015, 3, 27, 0, 0, 2), + datetime.datetime(2015, 3, 27, 0, 0, 9), + ]) + self.assertEqual(clock.dt, datetime.datetime(2015, 3, 27, 0, 1, 7)) + + def test_check_cert(self): + self.response.headers['Location'] = self.certr.uri + self.response.content = CERT.as_der() + self._mock_post_get() + self.assertEqual( + self.certr.update(body=CERT), self.net.check_cert(self.certr)) + + # TODO: split here and separate test + self.response.headers['Location'] = 'foo' + self.assertRaises( + errors.UnexpectedUpdate, self.net.check_cert, self.certr) + + def test_check_cert_missing_location(self): + self.response.content = CERT.as_der() + self._mock_post_get() + self.assertRaises(errors.ClientError, self.net.check_cert, self.certr) + + def test_refresh(self): + self.net.check_cert = mock.MagicMock() + self.assertEqual( + self.net.check_cert(self.certr), self.net.refresh(self.certr)) + + def test_fetch_chain(self): + # pylint: disable=protected-access + self.net._get_cert = mock.MagicMock() + 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): + self.assertTrue(self.net.fetch_chain(self.certr.update( + cert_chain_uri=None)) is None) + + def test_revoke(self): + self._mock_post_get() + self.net.revoke(self.certr, when=messages.Revocation.NOW) + self.post.assert_called_once_with(self.certr.uri, mock.ANY) + + def test_revoke_bad_status_raises_error(self): + self.response.status_code = httplib.METHOD_NOT_ALLOWED + self._mock_post_get() + self.assertRaises(errors.ClientError, self.net.revoke, self.certr) + + +if __name__ == '__main__': + unittest.main() # pragma: no cover diff --git a/acme/errors.py b/acme/errors.py index 957e781af..5046d7aee 100644 --- a/acme/errors.py +++ b/acme/errors.py @@ -1,8 +1,15 @@ """ACME errors.""" from acme.jose import errors as jose_errors + class Error(Exception): """Generic ACME error.""" class SchemaValidationError(jose_errors.DeserializationError): """JSON schema ACME object validation error.""" + +class ClientError(Error): + """Network error.""" + +class UnexpectedUpdate(ClientError): + """Unexpected update.""" diff --git a/acme/jose/testdata/README b/acme/jose/testdata/README index 72ec557e0..be3d8b2f7 100644 --- a/acme/jose/testdata/README +++ b/acme/jose/testdata/README @@ -4,7 +4,8 @@ The following command has been used to generate test keys: and for the CSR: - python -c from 'letsencrypt.crypto_util import make_csr; - import pkg_resources; open("csr2.pem", - "w").write(make_csr(pkg_resources.resource_string("letsencrypt.tests", - "testdata/rsa512_key.pem"), ["example2.com"])[0])' + openssl req -key rsa512_key.pem -new -subj '/CN=example.com' -outform DER > csr.der + +and for the certificate: + + openssl req -key rsa512_key.pem -new -subj '/CN=example.com' -x509 -outform DER > cert.der diff --git a/acme/jose/testdata/cert.der b/acme/jose/testdata/cert.der new file mode 100644 index 0000000000000000000000000000000000000000..5f1018505d81a50ed3239d829533deac5fcc2085 GIT binary patch literal 377 zcmXqLVk|XiVw7LN%*4pV#L2Ms(6oH-+lDa)ylk9WZ60mkc^MhGSs4t(3`Got*qB3E zn0dHUD-v@Ha#Hn@^K%X4#CZ)(4a|&;3``6RjLoCKTyr=Vr#=)57+D#Zy%`KVm>e0_ zlooFZd@FxGb01z;s66b16iPJW%*ddSVYv$pLpwieafaMs>~58{VWGc zu1@bVkOxUCvq%_-HDFi315zN&!fL?G$oL;EIG7z7c)I@!HO%vwut#mfG=7{>z}O6i?Kd^cJYN9>LqE5%h;CwZnWA40OQJj AMgRZ+ literal 0 HcmV?d00001 diff --git a/acme/jose/testdata/csr.der b/acme/jose/testdata/csr.der new file mode 100644 index 0000000000000000000000000000000000000000..adc29ff18463752b4b9ab26a0dd77d2621363725 GIT binary patch literal 210 zcmXqLJa16V#K>SEW+-AH#Ks)T!py^+T9KGrkdvyHoS$nDW5CPCsnzDu_MMlJk&%^w z*_*+@gUOL$O=u*A)ag|G9rW`hrfWw8)N9Mizmm^318OckeHsZ?f+1zL%^m z_ua)BZ+3d0>&un-HPz+C`j!&^w}+lGF*7nSE?_`5of~MnBZF(de`9^8<1$WWX~ABh zzuPx+%v!0Zd&ubDYwJ2|v1jw;{~Wg5`)^L5{K`Q6q!i`vv$n1I^5AE`uuA%MLEjl{ F*8oY6Puc(g literal 0 HcmV?d00001 diff --git a/acme/jose/testdata/csr2.pem b/acme/jose/testdata/csr2.pem deleted file mode 100644 index bd059a448..000000000 --- a/acme/jose/testdata/csr2.pem +++ /dev/null @@ -1,10 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIIBXzCCAQkCAQAwejELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIw -EAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECwwWVW5pdmVy -c2l0eSBvZiBNaWNoaWdhbjEVMBMGA1UEAwwMZXhhbXBsZTIuY29tMFwwDQYJKoZI -hvcNAQEBBQADSwAwSAJBAPS2EXFRNza/qpXnnBHF/CcFQ543htV+7nLAmrLrmTNH -tPXJmLlM8SJDIzv/ceAFXL110VzxFfi81lpH5E5c0TMCAwEAAaAqMCgGCSqGSIb3 -DQEJDjEbMBkwFwYDVR0RBBAwDoIMZXhhbXBsZTIuY29tMA0GCSqGSIb3DQEBCwUA -A0EAwsdL4FLIgISKV4vXFmc6QTV7CjBiP4XmPFbeN+gMFdR7QcnRyyxSpXxB0v8Z -oqYboP5LGFt9zC6/9GyjcI9/IQ== ------END CERTIFICATE REQUEST----- diff --git a/docs/pkgs/acme/index.rst b/docs/pkgs/acme/index.rst index ea0743b1e..2df2615a5 100644 --- a/docs/pkgs/acme/index.rst +++ b/docs/pkgs/acme/index.rst @@ -7,6 +7,13 @@ :members: +Client +------ + +.. automodule:: acme.client + :members: + + Messages -------- diff --git a/letsencrypt/errors.py b/letsencrypt/errors.py index f5d9f5f44..d9078dbf2 100644 --- a/letsencrypt/errors.py +++ b/letsencrypt/errors.py @@ -5,14 +5,6 @@ class LetsEncryptClientError(Exception): """Generic Let's Encrypt client error.""" -class NetworkError(LetsEncryptClientError): - """Network error.""" - - -class UnexpectedUpdate(NetworkError): - """Unexpected update.""" - - class LetsEncryptReverterError(LetsEncryptClientError): """Let's Encrypt Reverter error.""" diff --git a/letsencrypt/network.py b/letsencrypt/network.py index 6d3be1afc..0f4d9d29b 100644 --- a/letsencrypt/network.py +++ b/letsencrypt/network.py @@ -1,230 +1,15 @@ -"""Networking for ACME protocol v02.""" -import datetime -import heapq -import httplib -import logging -import time - -import M2Crypto -import requests -import werkzeug - -from acme import jose -from acme import jws as acme_jws -from acme import messages - -from letsencrypt import errors +"""Networking for ACME protocol.""" +from acme import client -# https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning -requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() - - -class Network(object): - """ACME networking. - - .. todo:: - Clean up raised error types hierarchy, document, and handle (wrap) - instances of `.DeserializationError` raised in `from_json()`. - - :ivar str new_reg_uri: Location of new-reg - :ivar key: `.JWK` (private) - :ivar alg: `.JWASignature` - :ivar bool verify_ssl: Verify SSL certificates? - - """ - - # TODO: Move below to acme module? - DER_CONTENT_TYPE = 'application/pkix-cert' - JSON_CONTENT_TYPE = 'application/json' - JSON_ERROR_CONTENT_TYPE = 'application/problem+json' - REPLAY_NONCE_HEADER = 'Replay-Nonce' - - def __init__(self, new_reg_uri, key, alg=jose.RS256, verify_ssl=True): - self.new_reg_uri = new_reg_uri - self.key = key - self.alg = alg - self.verify_ssl = verify_ssl - self._nonces = set() - - def _wrap_in_jws(self, obj, nonce): - """Wrap `JSONDeSerializable` object in JWS. - - .. todo:: Implement ``acmePath``. - - :param JSONDeSerializable obj: - :rtype: `.JWS` - - """ - dumps = obj.json_dumps() - logging.debug('Serialized JSON: %s', dumps) - return acme_jws.JWS.sign( - payload=dumps, key=self.key, alg=self.alg, nonce=nonce).json_dumps() - - @classmethod - def _check_response(cls, response, content_type=None): - """Check response content and its type. - - .. note:: - Checking is not strict: wrong server response ``Content-Type`` - HTTP header is ignored if response is an expected JSON object - (c.f. Boulder #56). - - :param str content_type: Expected Content-Type response header. - If JSON is expected and not present in server response, this - function will raise an error. Otherwise, wrong Content-Type - is ignored, but logged. - - :raises letsencrypt.messages.Error: If server response body - carries HTTP Problem (draft-ietf-appsawg-http-problem-00). - :raises letsencrypt.errors.NetworkError: In case of other - networking errors. - - """ - response_ct = response.headers.get('Content-Type') - try: - # TODO: response.json() is called twice, once here, and - # once in _get and _post clients - jobj = response.json() - except ValueError as error: - jobj = None - - if not response.ok: - if jobj is not None: - if response_ct != cls.JSON_ERROR_CONTENT_TYPE: - 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 messages.Error.from_json(jobj) - except jose.DeserializationError as error: - # Couldn't deserialize JSON object - raise errors.NetworkError((response, error)) - else: - # response is not JSON object - raise errors.NetworkError(response) - else: - if jobj is not None and response_ct != cls.JSON_CONTENT_TYPE: - logging.debug( - 'Ignoring wrong Content-Type (%r) for JSON decodable ' - 'response', response_ct) - - if content_type == cls.JSON_CONTENT_TYPE and jobj is None: - raise errors.NetworkError( - 'Unexpected response Content-Type: {0}'.format(response_ct)) - - def _get(self, uri, content_type=JSON_CONTENT_TYPE, **kwargs): - """Send GET request. - - :raises letsencrypt.errors.NetworkError: - - :returns: HTTP Response - :rtype: `requests.Response` - - """ - logging.debug('Sending GET request to %s', uri) - kwargs.setdefault('verify', self.verify_ssl) - try: - response = requests.get(uri, **kwargs) - except requests.exceptions.RequestException as error: - raise errors.NetworkError(error) - self._check_response(response, content_type=content_type) - return response - - def _add_nonce(self, response): - if self.REPLAY_NONCE_HEADER in response.headers: - nonce = response.headers[self.REPLAY_NONCE_HEADER] - error = acme_jws.Header.validate_nonce(nonce) - if error is None: - logging.debug('Storing nonce: %r', nonce) - self._nonces.add(nonce) - else: - raise errors.NetworkError('Invalid nonce ({0}): {1}'.format( - nonce, error)) - else: - raise errors.NetworkError( - 'Server {0} response did not include a replay nonce'.format( - response.request.method)) - - def _get_nonce(self, uri): - if not self._nonces: - logging.debug('Requesting fresh nonce by sending HEAD to %s', uri) - self._add_nonce(requests.head(uri)) - return self._nonces.pop() - - def _post(self, uri, obj, content_type=JSON_CONTENT_TYPE, **kwargs): - """Send POST data. - - :param JSONDeSerializable obj: Will be wrapped in JWS. - :param str content_type: Expected ``Content-Type``, fails if not set. - - :raises acme.messages.NetworkError: - - :returns: HTTP Response - :rtype: `requests.Response` - - """ - data = self._wrap_in_jws(obj, self._get_nonce(uri)) - logging.debug('Sending POST data to %s: %s', uri, data) - kwargs.setdefault('verify', self.verify_ssl) - try: - response = requests.post(uri, data=data, **kwargs) - except requests.exceptions.RequestException as error: - raise errors.NetworkError(error) - logging.debug('Received response %s: %r', response, response.text) - - self._add_nonce(response) - self._check_response(response, content_type=content_type) - return response - - @classmethod - def _regr_from_response(cls, response, uri=None, new_authzr_uri=None, - terms_of_service=None): - terms_of_service = ( - response.links['terms-of-service']['url'] - if 'terms-of-service' in response.links else terms_of_service) - - if new_authzr_uri is None: - try: - new_authzr_uri = response.links['next']['url'] - except KeyError: - raise errors.NetworkError('"next" link missing') - - return messages.RegistrationResource( - body=messages.Registration.from_json(response.json()), - uri=response.headers.get('Location', uri), - new_authzr_uri=new_authzr_uri, - terms_of_service=terms_of_service) - - def register(self, contact=messages.Registration._fields[ - 'contact'].default): - """Register. - - :param contact: Contact list, as accepted by `.Registration` - :type contact: `tuple` - - :returns: Registration Resource. - :rtype: `.RegistrationResource` - - :raises letsencrypt.errors.UnexpectedUpdate: - - """ - new_reg = messages.Registration(contact=contact) - - response = self._post(self.new_reg_uri, new_reg) - assert response.status_code == httplib.CREATED # TODO: handle errors - - regr = self._regr_from_response(response) - if regr.body.key != self.key.public() or regr.body.contact != contact: - raise errors.UnexpectedUpdate(regr) - - return regr +class Network(client.Client): + """ACME networking.""" def register_from_account(self, account): """Register with server. + .. todo:: this should probably not be a part of network... + :param account: Account :type account: :class:`letsencrypt.account.Account` @@ -239,344 +24,3 @@ class Network(object): 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. - - :pram regr: Registration Resource. - :type regr: `.RegistrationResource` - - :returns: Updated Registration Resource. - :rtype: `.RegistrationResource` - - """ - response = self._post(regr.uri, regr.body) - - # TODO: Boulder returns httplib.ACCEPTED - #assert response.status_code == httplib.OK - - # TODO: Boulder does not set Location or Link on update - # (c.f. acme-spec #94) - updated_regr = self._regr_from_response( - response, uri=regr.uri, new_authzr_uri=regr.new_authzr_uri, - terms_of_service=regr.terms_of_service) - if updated_regr != regr: - 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): - # pylint: disable=no-self-use - if new_cert_uri is None: - try: - new_cert_uri = response.links['next']['url'] - except KeyError: - raise errors.NetworkError('"next" link missing') - - authzr = messages.AuthorizationResource( - body=messages.Authorization.from_json(response.json()), - uri=response.headers.get('Location', uri), - new_cert_uri=new_cert_uri) - if authzr.body.identifier != identifier: - raise errors.UnexpectedUpdate(authzr) - return authzr - - def request_challenges(self, identifier, new_authzr_uri): - """Request challenges. - - :param identifier: Identifier to be challenged. - :type identifier: `.messages.Identifier` - - :param str new_authzr_uri: new-authorization URI - - :returns: Authorization Resource. - :rtype: `.AuthorizationResource` - - """ - new_authz = messages.Authorization(identifier=identifier) - response = self._post(new_authzr_uri, new_authz) - assert response.status_code == httplib.CREATED # TODO: handle errors - return self._authzr_from_response(response, identifier) - - def request_domain_challenges(self, domain, new_authz_uri): - """Request challenges for domain names. - - This is simply a convenience function that wraps around - `request_challenges`, but works with domain names instead of - 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(messages.Identifier( - typ=messages.IDENTIFIER_FQDN, value=domain), new_authz_uri) - - def answer_challenge(self, challb, response): - """Answer challenge. - - :param challb: Challenge Resource body. - :type challb: `.ChallengeBody` - - :param response: Corresponding Challenge response - :type response: `.challenges.ChallengeResponse` - - :returns: Challenge Resource with updated body. - :rtype: `.ChallengeResource` - - :raises errors.UnexpectedUpdate: - - """ - response = self._post(challb.uri, response) - try: - authzr_uri = response.links['up']['url'] - except KeyError: - raise errors.NetworkError('"up" Link header missing') - challr = messages.ChallengeResource( - authzr_uri=authzr_uri, - body=messages.ChallengeBody.from_json(response.json())) - # TODO: check that challr.uri == response.headers['Location']? - if challr.uri != challb.uri: - raise errors.UnexpectedUpdate(challr.uri) - return challr - - @classmethod - def retry_after(cls, response, default): - """Compute next `poll` time based on response ``Retry-After`` header. - - :param response: Response from `poll`. - :type response: `requests.Response` - - :param int default: Default value (in seconds), used when - ``Retry-After`` header is not present or invalid. - - :returns: Time point when next `poll` should be performed. - :rtype: `datetime.datetime` - - """ - retry_after = response.headers.get('Retry-After', str(default)) - try: - seconds = int(retry_after) - except ValueError: - # pylint: disable=no-member - decoded = werkzeug.parse_date(retry_after) # RFC1123 - if decoded is None: - seconds = default - else: - return decoded - - return datetime.datetime.now() + datetime.timedelta(seconds=seconds) - - def poll(self, authzr): - """Poll Authorization Resource for status. - - :param authzr: Authorization Resource - :type authzr: `.AuthorizationResource` - - :returns: Updated Authorization Resource and HTTP response. - - :rtype: (`.AuthorizationResource`, `requests.Response`) - - """ - response = self._get(authzr.uri) - 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): - """Request issuance. - - :param csr: CSR - :type csr: `M2Crypto.X509.Request` wrapped in `.ComparableX509` - - :param authzrs: `list` of `.AuthorizationResource` - - :returns: Issued certificate - :rtype: `.messages.CertificateResource` - - """ - assert authzrs, "Authorizations list is empty" - logging.debug("Requesting issuance...") - - # TODO: assert len(authzrs) == number of SANs - req = messages.CertificateRequest( - csr=csr, authorizations=tuple(authzr.uri for authzr in authzrs)) - - content_type = self.DER_CONTENT_TYPE # TODO: add 'cert_type 'argument - response = self._post( - authzrs[0].new_cert_uri, # TODO: acme-spec #90 - req, - content_type=content_type, - headers={'Accept': content_type}) - - cert_chain_uri = response.links.get('up', {}).get('url') - - try: - uri = response.headers['Location'] - except KeyError: - raise errors.NetworkError('"Location" Header missing') - - return messages.CertificateResource( - uri=uri, authzrs=authzrs, cert_chain_uri=cert_chain_uri, - body=jose.ComparableX509( - M2Crypto.X509.load_cert_der_string(response.content))) - - def poll_and_request_issuance(self, csr, authzrs, mintime=5): - """Poll and request issuance. - - This function polls all provided Authorization Resource URIs - until all challenges are valid, respecting ``Retry-After`` HTTP - headers, and then calls `request_issuance`. - - .. todo:: add `max_attempts` or `timeout` - - :param csr: CSR. - :type csr: `M2Crypto.X509.Request` wrapped in `.ComparableX509` - - :param authzrs: `list` of `.AuthorizationResource` - - :param int mintime: Minimum time before next attempt, used if - ``Retry-After`` is not present in the response. - - :returns: ``(cert, updated_authzrs)`` `tuple` where ``cert`` is - the issued certificate (`.messages.CertificateResource.), - and ``updated_authzrs`` is a `tuple` consisting of updated - Authorization Resources (`.AuthorizationResource`) as - present in the responses from server, and in the same order - as the input ``authzrs``. - :rtype: `tuple` - - """ - # 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 - # recently updated one - updated = dict((authzr, authzr) for authzr in authzrs) - - while waiting: - # find the smallest Retry-After, and sleep if necessary - when, authzr = heapq.heappop(waiting) - now = datetime.datetime.now() - if when > now: - seconds = (when - now).seconds - logging.debug('Sleeping for %d seconds', seconds) - time.sleep(seconds) - - # Note that we poll with the latest updated Authorization - # URI, which might have a different URI than initial one - updated_authzr, response = self.poll(updated[authzr]) - updated[authzr] = updated_authzr - - if updated_authzr.body.status != messages.STATUS_VALID: - # push back to the priority queue, with updated retry_after - heapq.heappush(waiting, (self.retry_after( - response, default=mintime), authzr)) - - updated_authzrs = tuple(updated[authzr] for authzr in authzrs) - 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:`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) - return response, jose.ComparableX509( - M2Crypto.X509.load_cert_der_string(response.content)) - - def check_cert(self, certr): - """Check for new cert. - - :param certr: Certificate Resource - :type certr: `.CertificateResource` - - :returns: Updated Certificate Resource. - :rtype: `.CertificateResource` - - """ - # TODO: acme-spec 5.1 table action should be renamed to - # "refresh cert", and this method integrated with self.refresh - response, cert = self._get_cert(certr.uri) - if 'Location' not in response.headers: - raise errors.NetworkError('Location header missing') - if response.headers['Location'] != certr.uri: - raise errors.UnexpectedUpdate(response.text) - return certr.update(body=cert) - - def refresh(self, certr): - """Refresh certificate. - - :param certr: Certificate Resource - :type certr: `.CertificateResource` - - :returns: Updated Certificate Resource. - :rtype: `.CertificateResource` - - """ - # TODO: If a client sends a refresh request and the server is - # not willing to refresh the certificate, the server MUST - # respond with status code 403 (Forbidden) - return self.check_cert(certr) - - def fetch_chain(self, certr): - """Fetch chain for certificate. - - :param certr: Certificate Resource - :type certr: `.CertificateResource` - - :returns: Certificate chain, or `None` if no "up" Link was provided. - :rtype: `M2Crypto.X509.X509` wrapped in `.ComparableX509` - - """ - if certr.cert_chain_uri is not None: - return self._get_cert(certr.cert_chain_uri)[1] - else: - return None - - def revoke(self, certr, when=messages.Revocation.NOW): - """Revoke certificate. - - :param certr: Certificate Resource - :type certr: `.CertificateResource` - - :param when: When should the revocation take place? Takes - the same values as `.messages.Revocation.revoke`. - - :raises letsencrypt.errors.NetworkError: If revocation is - unsuccessful. - - """ - rev = messages.Revocation(revoke=when, authorizations=tuple( - authzr.uri for authzr in certr.authzrs)) - response = self._post(certr.uri, rev) - if response.status_code != httplib.OK: - raise errors.NetworkError( - 'Successful revocation must return HTTP OK status') diff --git a/letsencrypt/tests/network_test.py b/letsencrypt/tests/network_test.py index 586dc7ecb..6acb11315 100644 --- a/letsencrypt/tests/network_test.py +++ b/letsencrypt/tests/network_test.py @@ -1,281 +1,27 @@ """Tests for letsencrypt.network.""" -import datetime -import httplib -import os -import pkg_resources import shutil import tempfile import unittest -import M2Crypto import mock -import requests - -from acme import challenges -from acme import jose -from acme import jws as acme_jws -from acme import messages from letsencrypt import account -from letsencrypt import errors - - -CERT = jose.ComparableX509(M2Crypto.X509.load_cert_string( - pkg_resources.resource_string( - __name__, os.path.join('testdata', 'cert.pem')))) -CERT2 = jose.ComparableX509(M2Crypto.X509.load_cert_string( - pkg_resources.resource_string( - __name__, os.path.join('testdata', 'cert-san.pem')))) -CSR = jose.ComparableX509(M2Crypto.X509.load_request_string( - pkg_resources.resource_string( - __name__, os.path.join('testdata', 'csr.pem')))) -KEY = jose.JWKRSA.load(pkg_resources.resource_string( - 'acme.jose', os.path.join('testdata', 'rsa512_key.pem'))) -KEY2 = jose.JWKRSA.load(pkg_resources.resource_string( - 'acme.jose', os.path.join('testdata', 'rsa256_key.pem'))) class NetworkTest(unittest.TestCase): """Tests for letsencrypt.network.Network.""" - # pylint: disable=too-many-instance-attributes,too-many-public-methods - def setUp(self): - self.verify_ssl = mock.MagicMock() - self.wrap_in_jws = mock.MagicMock(return_value=mock.sentinel.wrapped) - from letsencrypt.network import Network self.net = Network( - new_reg_uri='https://www.letsencrypt-demo.org/acme/new-reg', - key=KEY, alg=jose.RS256, verify_ssl=self.verify_ssl) - self.nonce = jose.b64encode('Nonce') - self.net._nonces.add(self.nonce) # pylint: disable=protected-access - - self.response = mock.MagicMock(ok=True, status_code=httplib.OK) - self.response.headers = {} - self.response.links = {} - - self.post = mock.MagicMock(return_value=self.response) - self.get = mock.MagicMock(return_value=self.response) - - self.identifier = messages.Identifier( - typ=messages.IDENTIFIER_FQDN, value='example.com') + new_reg_uri=None, key=None, alg=None, verify_ssl=None) self.config = mock.Mock(accounts_dir=tempfile.mkdtemp()) - - # Registration self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212') - reg = messages.Registration( - contact=self.contact, key=KEY.public(), recovery_token='t') - self.regr = messages.RegistrationResource( - body=reg, uri='https://www.letsencrypt-demo.org/acme/reg/1', - new_authzr_uri='https://www.letsencrypt-demo.org/acme/new-reg', - terms_of_service='https://www.letsencrypt-demo.org/tos') - - # Authorization - authzr_uri = 'https://www.letsencrypt-demo.org/acme/authz/1' - challb = messages.ChallengeBody( - uri=(authzr_uri + '/1'), status=messages.STATUS_VALID, - chall=challenges.DNS(token='foo')) - self.challr = messages.ChallengeResource( - body=challb, authzr_uri=authzr_uri) - self.authz = messages.Authorization( - identifier=messages.Identifier( - typ=messages.IDENTIFIER_FQDN, value='example.com'), - challenges=(challb,), combinations=None) - self.authzr = messages.AuthorizationResource( - body=self.authz, uri=authzr_uri, - new_cert_uri='https://www.letsencrypt-demo.org/acme/new-cert') - - # Request issuance - self.certr = messages.CertificateResource( - body=CERT, authzrs=(self.authzr,), - uri='https://www.letsencrypt-demo.org/acme/cert/1', - cert_chain_uri='https://www.letsencrypt-demo.org/ca') def tearDown(self): shutil.rmtree(self.config.accounts_dir) - def _mock_post_get(self): - # pylint: disable=protected-access - self.net._post = self.post - self.net._get = self.get - - def test_init(self): - self.assertTrue(self.net.verify_ssl is self.verify_ssl) - - def test_wrap_in_jws(self): - class MockJSONDeSerializable(jose.JSONDeSerializable): - # pylint: disable=missing-docstring - def __init__(self, value): - self.value = value - def to_partial_json(self): - return self.value - @classmethod - def from_json(cls, value): - pass # pragma: no cover - # pylint: disable=protected-access - jws_dump = self.net._wrap_in_jws( - MockJSONDeSerializable('foo'), nonce='Tg') - jws = acme_jws.JWS.json_loads(jws_dump) - self.assertEqual(jws.payload, '"foo"') - self.assertEqual(jws.signature.combined.nonce, 'Tg') - # TODO: check that nonce is in protected header - - def test_check_response_not_ok_jobj_no_error(self): - self.response.ok = False - self.response.json.return_value = {} - # pylint: disable=protected-access - self.assertRaises( - errors.NetworkError, self.net._check_response, self.response) - - def test_check_response_not_ok_jobj_error(self): - self.response.ok = False - self.response.json.return_value = messages.Error( - detail='foo', typ='serverInternal', title='some title').to_json() - # pylint: disable=protected-access - self.assertRaises( - messages.Error, self.net._check_response, self.response) - - def test_check_response_not_ok_no_jobj(self): - self.response.ok = False - self.response.json.side_effect = ValueError - # pylint: disable=protected-access - self.assertRaises( - errors.NetworkError, self.net._check_response, self.response) - - def test_check_response_ok_no_jobj_ct_required(self): - self.response.json.side_effect = ValueError - for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']: - self.response.headers['Content-Type'] = response_ct - # pylint: disable=protected-access - self.assertRaises( - errors.NetworkError, self.net._check_response, self.response, - content_type=self.net.JSON_CONTENT_TYPE) - - def test_check_response_ok_no_jobj_no_ct(self): - self.response.json.side_effect = ValueError - for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']: - self.response.headers['Content-Type'] = response_ct - # pylint: disable=protected-access - self.net._check_response(self.response) - - def test_check_response_jobj(self): - self.response.json.return_value = {} - for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']: - self.response.headers['Content-Type'] = response_ct - # pylint: disable=protected-access - self.net._check_response(self.response) - - @mock.patch('letsencrypt.network.requests') - def test_get_requests_error_passthrough(self, requests_mock): - requests_mock.exceptions = requests.exceptions - requests_mock.get.side_effect = requests.exceptions.RequestException - # pylint: disable=protected-access - self.assertRaises(errors.NetworkError, self.net._get, 'uri') - - @mock.patch('letsencrypt.network.requests') - def test_get(self, requests_mock): - # pylint: disable=protected-access - self.net._check_response = mock.MagicMock() - self.net._get('uri', content_type='ct') - self.net._check_response.assert_called_once_with( - requests_mock.get('uri'), content_type='ct') - - def _mock_wrap_in_jws(self): - # pylint: disable=protected-access - self.net._wrap_in_jws = self.wrap_in_jws - - @mock.patch('letsencrypt.network.requests') - def test_post_requests_error_passthrough(self, requests_mock): - requests_mock.exceptions = requests.exceptions - requests_mock.post.side_effect = requests.exceptions.RequestException - # pylint: disable=protected-access - self._mock_wrap_in_jws() - self.assertRaises( - errors.NetworkError, self.net._post, 'uri', mock.sentinel.obj) - - @mock.patch('letsencrypt.network.requests') - def test_post(self, requests_mock): - # pylint: disable=protected-access - self.net._check_response = mock.MagicMock() - self._mock_wrap_in_jws() - requests_mock.post().headers = { - self.net.REPLAY_NONCE_HEADER: self.nonce} - self.net._post('uri', mock.sentinel.obj, content_type='ct') - self.net._check_response.assert_called_once_with( - requests_mock.post('uri', mock.sentinel.wrapped), content_type='ct') - - @mock.patch('letsencrypt.network.requests') - def test_post_replay_nonce_handling(self, requests_mock): - # pylint: disable=protected-access - self.net._check_response = mock.MagicMock() - self._mock_wrap_in_jws() - - self.net._nonces.clear() - self.assertRaises( - errors.NetworkError, self.net._post, 'uri', mock.sentinel.obj) - - nonce2 = jose.b64encode('Nonce2') - requests_mock.head('uri').headers = { - self.net.REPLAY_NONCE_HEADER: nonce2} - requests_mock.post('uri').headers = { - self.net.REPLAY_NONCE_HEADER: self.nonce} - - self.net._post('uri', mock.sentinel.obj) - - requests_mock.head.assert_called_with('uri') - self.wrap_in_jws.assert_called_once_with(mock.sentinel.obj, nonce2) - self.assertEqual(self.net._nonces, set([self.nonce])) - - # wrong nonce - requests_mock.post('uri').headers = {self.net.REPLAY_NONCE_HEADER: 'F'} - self.assertRaises( - errors.NetworkError, self.net._post, 'uri', mock.sentinel.obj) - - @mock.patch('letsencrypt.client.network.requests') - def test_get_post_verify_ssl(self, requests_mock): - # pylint: disable=protected-access - self._mock_wrap_in_jws() - self.net._check_response = mock.MagicMock() - - for verify_ssl in [True, False]: - self.net.verify_ssl = verify_ssl - self.net._get('uri') - self.net._nonces.add('N') - requests_mock.post().headers = { - self.net.REPLAY_NONCE_HEADER: self.nonce} - self.net._post('uri', mock.sentinel.obj) - requests_mock.get.assert_called_once_with('uri', verify=verify_ssl) - requests_mock.post.assert_called_with( - 'uri', data=mock.sentinel.wrapped, verify=verify_ssl) - requests_mock.reset_mock() - - def test_register(self): - self.response.status_code = httplib.CREATED - self.response.json.return_value = self.regr.body.to_json() - self.response.headers['Location'] = self.regr.uri - self.response.links.update({ - 'next': {'url': self.regr.new_authzr_uri}, - 'terms-of-service': {'url': self.regr.terms_of_service}, - }) - - self._mock_post_get() - self.assertEqual(self.regr, self.net.register(self.contact)) - # TODO: test POST call arguments - - # TODO: split here and separate test - reg_wrong_key = self.regr.body.update(key=KEY2.public()) - self.response.json.return_value = reg_wrong_key.to_json() - self.assertRaises( - errors.UnexpectedUpdate, self.net.register, self.contact) - - def test_register_missing_next(self): - self.response.status_code = httplib.CREATED - self._mock_post_get() - self.assertRaises( - errors.NetworkError, self.net.register, self.regr.body) - def test_register_from_account(self): self.net.register = mock.Mock() acc = account.Account( @@ -299,265 +45,6 @@ class NetworkTest(unittest.TestCase): 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() - self._mock_post_get() - self.assertEqual(self.regr, self.net.update_registration(self.regr)) - - # TODO: split here and separate test - self.response.json.return_value = self.regr.body.update( - contact=()).to_json() - 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 - self.response.json.return_value = self.authz.to_json() - self.response.links = { - 'next': {'url': self.authzr.new_cert_uri}, - } - - self._mock_post_get() - self.net.request_challenges(self.identifier, self.authzr.uri) - # TODO: test POST call arguments - - # TODO: split here and separate test - self.response.json.return_value = self.authz.update( - identifier=self.identifier.update(value='foo')).to_json() - self.assertRaises(errors.UnexpectedUpdate, self.net.request_challenges, - self.identifier, self.authzr.uri) - - def test_request_challenges_missing_next(self): - self.response.status_code = httplib.CREATED - self._mock_post_get() - self.assertRaises( - errors.NetworkError, self.net.request_challenges, - self.identifier, self.regr) - - def test_request_domain_challenges(self): - self.net.request_challenges = mock.MagicMock() - self.assertEqual( - self.net.request_challenges(self.identifier), - self.net.request_domain_challenges('example.com', self.regr)) - - def test_answer_challenge(self): - self.response.links['up'] = {'url': self.challr.authzr_uri} - self.response.json.return_value = self.challr.body.to_json() - - chall_response = challenges.DNSResponse() - - self._mock_post_get() - self.net.answer_challenge(self.challr.body, chall_response) - - # TODO: split here and separate test - self.assertRaises(errors.UnexpectedUpdate, self.net.answer_challenge, - self.challr.body.update(uri='foo'), chall_response) - - 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_retry_after_date(self): - self.response.headers['Retry-After'] = 'Fri, 31 Dec 1999 23:59:59 GMT' - self.assertEqual( - datetime.datetime(1999, 12, 31, 23, 59, 59), - self.net.retry_after(response=self.response, default=10)) - - @mock.patch('letsencrypt.network.datetime') - def test_retry_after_invalid(self, dt_mock): - dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27) - dt_mock.timedelta = datetime.timedelta - - self.response.headers['Retry-After'] = 'foooo' - self.assertEqual( - datetime.datetime(2015, 3, 27, 0, 0, 10), - self.net.retry_after(response=self.response, default=10)) - - @mock.patch('letsencrypt.network.datetime') - def test_retry_after_seconds(self, dt_mock): - dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27) - dt_mock.timedelta = datetime.timedelta - - self.response.headers['Retry-After'] = '50' - self.assertEqual( - datetime.datetime(2015, 3, 27, 0, 0, 50), - self.net.retry_after(response=self.response, default=10)) - - @mock.patch('letsencrypt.network.datetime') - def test_retry_after_missing(self, dt_mock): - dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27) - dt_mock.timedelta = datetime.timedelta - - self.assertEqual( - datetime.datetime(2015, 3, 27, 0, 0, 10), - self.net.retry_after(response=self.response, default=10)) - - def test_poll(self): - self.response.json.return_value = self.authzr.body.to_json() - self._mock_post_get() - self.assertEqual((self.authzr, self.response), - self.net.poll(self.authzr)) - - # TODO: split here and separate test - self.response.json.return_value = self.authz.update( - identifier=self.identifier.update(value='foo')).to_json() - self.assertRaises(errors.UnexpectedUpdate, self.net.poll, self.authzr) - - def test_request_issuance(self): - self.response.content = CERT.as_der() - self.response.headers['Location'] = self.certr.uri - self.response.links['up'] = {'url': self.certr.cert_chain_uri} - self._mock_post_get() - self.assertEqual( - self.certr, self.net.request_issuance(CSR, (self.authzr,))) - # TODO: check POST args - - def test_request_issuance_missing_up(self): - self.response.content = CERT.as_der() - self.response.headers['Location'] = self.certr.uri - self._mock_post_get() - self.assertEqual( - self.certr.update(cert_chain_uri=None), - self.net.request_issuance(CSR, (self.authzr,))) - - def test_request_issuance_missing_location(self): - self._mock_post_get() - self.assertRaises( - errors.NetworkError, self.net.request_issuance, - CSR, (self.authzr,)) - - @mock.patch('letsencrypt.network.datetime') - @mock.patch('letsencrypt.network.time') - def test_poll_and_request_issuance(self, time_mock, dt_mock): - # clock.dt | pylint: disable=no-member - clock = mock.MagicMock(dt=datetime.datetime(2015, 3, 27)) - - def sleep(seconds): - """increment clock""" - clock.dt += datetime.timedelta(seconds=seconds) - time_mock.sleep.side_effect = sleep - - def now(): - """return current clock value""" - return clock.dt - dt_mock.datetime.now.side_effect = now - dt_mock.timedelta = datetime.timedelta - - def poll(authzr): # pylint: disable=missing-docstring - # record poll start time based on the current clock value - authzr.times.append(clock.dt) - - # suppose it takes 2 seconds for server to produce the - # result, increment clock - clock.dt += datetime.timedelta(seconds=2) - - if not authzr.retries: # no more retries - done = mock.MagicMock(uri=authzr.uri, times=authzr.times) - done.body.status = messages.STATUS_VALID - return done, [] - - # response (2nd result tuple element) is reduced to only - # Retry-After header contents represented as integer - # seconds; authzr.retries is a list of Retry-After - # headers, head(retries) is peeled of as a current - # Retry-After header, and tail(retries) is persisted for - # later poll() calls - return (mock.MagicMock(retries=authzr.retries[1:], - uri=authzr.uri + '.', times=authzr.times), - authzr.retries[0]) - self.net.poll = mock.MagicMock(side_effect=poll) - - mintime = 7 - - def retry_after(response, default): # pylint: disable=missing-docstring - # check that poll_and_request_issuance correctly passes mintime - self.assertEqual(default, mintime) - return clock.dt + datetime.timedelta(seconds=response) - self.net.retry_after = mock.MagicMock(side_effect=retry_after) - - def request_issuance(csr, authzrs): # pylint: disable=missing-docstring - return csr, authzrs - self.net.request_issuance = mock.MagicMock(side_effect=request_issuance) - - csr = mock.MagicMock() - authzrs = ( - mock.MagicMock(uri='a', times=[], retries=(8, 20, 30)), - mock.MagicMock(uri='b', times=[], retries=(5,)), - ) - - cert, updated_authzrs = self.net.poll_and_request_issuance( - csr, authzrs, mintime=mintime) - self.assertTrue(cert[0] is csr) - self.assertTrue(cert[1] is updated_authzrs) - self.assertEqual(updated_authzrs[0].uri, 'a...') - self.assertEqual(updated_authzrs[1].uri, 'b.') - self.assertEqual(updated_authzrs[0].times, [ - datetime.datetime(2015, 3, 27), - # a is scheduled for 10, but b is polling [9..11), so it - # will be picked up as soon as b is finished, without - # additional sleeping - datetime.datetime(2015, 3, 27, 0, 0, 11), - datetime.datetime(2015, 3, 27, 0, 0, 33), - datetime.datetime(2015, 3, 27, 0, 1, 5), - ]) - self.assertEqual(updated_authzrs[1].times, [ - datetime.datetime(2015, 3, 27, 0, 0, 2), - datetime.datetime(2015, 3, 27, 0, 0, 9), - ]) - self.assertEqual(clock.dt, datetime.datetime(2015, 3, 27, 0, 1, 7)) - - def test_check_cert(self): - self.response.headers['Location'] = self.certr.uri - self.response.content = CERT2.as_der() - self._mock_post_get() - self.assertEqual( - self.certr.update(body=CERT2), self.net.check_cert(self.certr)) - - # TODO: split here and separate test - self.response.headers['Location'] = 'foo' - self.assertRaises( - errors.UnexpectedUpdate, self.net.check_cert, self.certr) - - def test_check_cert_missing_location(self): - self.response.content = CERT2.as_der() - self._mock_post_get() - self.assertRaises(errors.NetworkError, self.net.check_cert, self.certr) - - def test_refresh(self): - self.net.check_cert = mock.MagicMock() - self.assertEqual( - self.net.check_cert(self.certr), self.net.refresh(self.certr)) - - def test_fetch_chain(self): - # pylint: disable=protected-access - self.net._get_cert = mock.MagicMock() - 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): - self.assertTrue(self.net.fetch_chain(self.certr.update( - cert_chain_uri=None)) is None) - - def test_revoke(self): - self._mock_post_get() - self.net.revoke(self.certr, when=messages.Revocation.NOW) - self.post.assert_called_once_with(self.certr.uri, mock.ANY) - - def test_revoke_bad_status_raises_error(self): - self.response.status_code = httplib.METHOD_NOT_ALLOWED - self._mock_post_get() - self.assertRaises(errors.NetworkError, self.net.revoke, self.certr) - if __name__ == '__main__': unittest.main() # pragma: no cover From 90dae9fd880b7ff07ee3f1b76d862b8419eaf22a Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 18 Jun 2015 11:07:20 +0000 Subject: [PATCH 60/72] Update restified example script and rename to acme_client.py --- examples/acme_client.py | 45 +++++++++++++++++++++++++++++++++++++++++ examples/restified.py | 42 -------------------------------------- 2 files changed, 45 insertions(+), 42 deletions(-) create mode 100644 examples/acme_client.py delete mode 100644 examples/restified.py diff --git a/examples/acme_client.py b/examples/acme_client.py new file mode 100644 index 000000000..09ff2bfc3 --- /dev/null +++ b/examples/acme_client.py @@ -0,0 +1,45 @@ +"""Example script showing how to use acme client API.""" +import logging +import os +import pkg_resources + +import Crypto.PublicKey.RSA +import M2Crypto + +from acme import client +from acme import messages +from acme import jose + + +logging.basicConfig(level=logging.DEBUG) + + +NEW_REG_URL = 'https://www.letsencrypt-demo.org/acme/new-reg' +BITS = 2048 # minimum for Boulder +DOMAIN = 'example1.com' # example.com is ignored by Boulder + +key = jose.JWKRSA.load( + Crypto.PublicKey.RSA.generate(BITS).exportKey(format="PEM")) +acme = client.Client(NEW_REG_URL, key) + +regr = acme.register(contact=()) +logging.info('Auto-accepting TOS: %s', regr.terms_of_service) +acme.update_registration(regr.update( + body=regr.body.update(agreement=regr.terms_of_service))) +logging.debug(regr) + +authzr = acme.request_challenges( + identifier=messages.Identifier(typ=messages.IDENTIFIER_FQDN, value=DOMAIN), + new_authzr_uri=regr.new_authzr_uri) +logging.debug(authzr) + +authzr, authzr_response = acme.poll(authzr) + +csr = M2Crypto.X509.load_request_string(pkg_resources.resource_string( + 'acme.jose', os.path.join('testdata', 'csr.der')), + M2Crypto.X509.FORMAT_DER) +try: + acme.request_issuance(csr, (authzr,)) +except messages.Error as error: + print ("This script is doomed to fail as no authorization " + "challenges are ever solved. Error from server: {0}".format(error)) diff --git a/examples/restified.py b/examples/restified.py deleted file mode 100644 index cfd7fa8dd..000000000 --- a/examples/restified.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging -import os -import pkg_resources - -import M2Crypto - -from acme import messages -from acme import jose - -from letsencrypt import network - - -logger = logging.getLogger() -logger.setLevel(logging.DEBUG) - -NEW_REG_URL = 'https://www.letsencrypt-demo.org/acme/new-reg' - -key = jose.JWKRSA.load(pkg_resources.resource_string( - 'acme.jose', os.path.join('testdata', 'rsa512_key.pem'))) -net = network.Network(NEW_REG_URL, key) - -regr = net.register(contact=( - 'mailto:cert-admin@example.com', 'tel:+12025551212')) -logging.info('Auto-accepting TOS: %s', regr.terms_of_service) -net.update_registration(regr.update( - body=regr.body.update(agreement=regr.terms_of_service))) -logging.debug(regr) - -authzr = net.request_challenges( - identifier=messages.Identifier( - typ=messages.IDENTIFIER_FQDN, value='example1.com'), - new_authzr_uri=regr.new_authzr_uri) -logging.debug(authzr) - -authzr, authzr_response = net.poll(authzr) - -csr = M2Crypto.X509.load_request_string(pkg_resources.resource_string( - 'letsencrypt.tests', os.path.join('testdata', 'csr.pem'))) -try: - net.request_issuance(csr, (authzr,)) -except messages.Error as error: - print error.detail From cf76593fa70f4fb5743946feab5df939a23ef153 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Mon, 22 Jun 2015 18:03:04 +0000 Subject: [PATCH 61/72] Remove constants.NETSTAT. Update docs for IConfig.server. --- letsencrypt/constants.py | 4 ---- letsencrypt/interfaces.py | 3 +-- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py index 47539615d..356b8ed14 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -81,7 +81,3 @@ ACCOUNT_KEYS_DIR = "keys" REC_TOKEN_DIR = "recovery_tokens" """Directory where all recovery tokens are saved (relative to IConfig.work_dir).""" - -NETSTAT = "/bin/netstat" -"""Location of netstat binary for checking whether a listener is already -running on the specified port (Linux-specific).""" diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index c0d44a134..d2a420d00 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -148,8 +148,7 @@ class IConfig(zope.interface.Interface): """ server = zope.interface.Attribute( - "CA hostname (and optionally :port). The server certificate must " - "be trusted in order to avoid further modifications to the client.") + "ACME new registration URI (including /acme/new-reg).") email = zope.interface.Attribute( "Email used for registration and recovery contact.") rsa_key_size = zope.interface.Attribute("Size of the RSA key.") From 1720864b443351c473a130b9a6897f0b65cb18c2 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Mon, 22 Jun 2015 19:55:47 +0000 Subject: [PATCH 62/72] acme.client: locally disable too-many-instance-attributes. --- acme/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/client.py b/acme/client.py index c0eda0fa3..629048d03 100644 --- a/acme/client.py +++ b/acme/client.py @@ -19,7 +19,7 @@ from acme import messages requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() -class Client(object): +class Client(object): # pylint: disable=too-many-instance-attributes """ACME client. .. todo:: From 52d6e9b67435ee1d597c5a11474aba566e0aaaf3 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 17 Jun 2015 16:13:26 +0000 Subject: [PATCH 63/72] acme-spec#118 revoke. --- acme/client.py | 14 +++++--------- acme/client_test.py | 5 +++-- acme/messages.py | 34 +++++++++++++++------------------- acme/messages_test.py | 31 +++++++++++++------------------ letsencrypt/revoker.py | 2 +- 5 files changed, 37 insertions(+), 49 deletions(-) diff --git a/acme/client.py b/acme/client.py index 629048d03..73c962581 100644 --- a/acme/client.py +++ b/acme/client.py @@ -539,21 +539,17 @@ class Client(object): # pylint: disable=too-many-instance-attributes else: return None - def revoke(self, certr, when=messages.Revocation.NOW): + def revoke(self, cert): """Revoke certificate. - :param certr: Certificate Resource - :type certr: `.CertificateResource` - - :param when: When should the revocation take place? Takes - the same values as `.messages.Revocation.revoke`. + :param .ComparableX509 body: `M2Crypto.X509.X509` wrapped in + `.ComparableX509` :raises .ClientError: If revocation is unsuccessful. """ - rev = messages.Revocation(revoke=when, authorizations=tuple( - authzr.uri for authzr in certr.authzrs)) - response = self._post(certr.uri, rev) + response = self._post(messages.Revocation.url(self.new_reg_uri), + messages.Revocation(certificate=cert)) if response.status_code != httplib.OK: raise errors.ClientError( 'Successful revocation must return HTTP OK status') diff --git a/acme/client_test.py b/acme/client_test.py index 5e4cc1720..dfa8d7607 100644 --- a/acme/client_test.py +++ b/acme/client_test.py @@ -517,8 +517,9 @@ class ClientTest(unittest.TestCase): def test_revoke(self): self._mock_post_get() - self.net.revoke(self.certr, when=messages.Revocation.NOW) - self.post.assert_called_once_with(self.certr.uri, mock.ANY) + self.net.revoke(self.certr.body) + self.post.assert_called_once_with(messages.Revocation.url( + self.net.new_reg_uri), mock.ANY) def test_revoke_bad_status_raises_error(self): self.response.status_code = httplib.METHOD_NOT_ALLOWED diff --git a/acme/messages.py b/acme/messages.py index aa041caed..bfc452a70 100644 --- a/acme/messages.py +++ b/acme/messages.py @@ -1,4 +1,6 @@ """ACME protocol messages.""" +import urlparse + from acme import challenges from acme import fields from acme import jose @@ -271,28 +273,22 @@ class CertificateResource(Resource): class Revocation(jose.JSONObjectWithFields): """Revocation message. - :ivar revoke: Either a `datetime.datetime` or `Revocation.NOW`. - :ivar tuple authorizations: Same as `CertificateRequest.authorizations` + :ivar .ComparableX509 certificate: `M2Crypto.X509.X509` wrapped in + `.ComparableX509` """ + certificate = jose.Field( + 'certificate', decoder=jose.decode_cert, encoder=jose.encode_cert) - NOW = 'now' - """A possible value for `revoke`, denoting that certificate should - be revoked now.""" + # TODO: acme-spec#138, this allows only one ACME server instance per domain + PATH = '/acme/revoke-cert' + """Path to revocation URL, see `url`""" - revoke = jose.Field('revoke') - authorizations = CertificateRequest._fields['authorizations'] + @classmethod + def url(cls, base): + """Get revocation URL. - @revoke.decoder - def revoke(value): # pylint: disable=missing-docstring,no-self-argument - if value == Revocation.NOW: - return value - else: - return fields.RFC3339Field.default_decoder(value) + :param str base: New Registration Resource or server (root) URL. - @revoke.encoder - def revoke(value): # pylint: disable=missing-docstring,no-self-argument - if value == Revocation.NOW: - return value - else: - return fields.RFC3339Field.default_encoder(value) + """ + return urlparse.urljoin(base, cls.PATH) diff --git a/acme/messages_test.py b/acme/messages_test.py index 4f86d7809..65c080ee7 100644 --- a/acme/messages_test.py +++ b/acme/messages_test.py @@ -1,11 +1,10 @@ """Tests for acme.messages.""" -import datetime import os import pkg_resources import unittest +import M2Crypto.X509 import mock -import pytz from Crypto.PublicKey import RSA from acme import challenges @@ -14,6 +13,9 @@ from acme import jose KEY = jose.util.HashableRSAKey(RSA.importKey(pkg_resources.resource_string( 'acme.jose', os.path.join('testdata', 'rsa512_key.pem')))) +CERT = jose.ComparableX509(M2Crypto.X509.load_cert( + format=M2Crypto.X509.FORMAT_DER, file=pkg_resources.resource_filename( + 'acme.jose', os.path.join('testdata', 'cert.der')))) class ErrorTest(unittest.TestCase): @@ -223,27 +225,20 @@ class AuthorizationTest(unittest.TestCase): class RevocationTest(unittest.TestCase): """Tests for acme.messages.RevocationTest.""" + def test_url(self): + from acme.messages import Revocation + url = 'https://letsencrypt-demo.org/acme/revoke-cert' + self.assertEqual(url, Revocation.url('https://letsencrypt-demo.org')) + self.assertEqual( + url, Revocation.url('https://letsencrypt-demo.org/acme/new-reg')) + def setUp(self): from acme.messages import Revocation - self.rev_now = Revocation(authorizations=(), revoke=Revocation.NOW) - self.rev_date = Revocation(authorizations=(), revoke=datetime.datetime( - 2015, 3, 27, tzinfo=pytz.utc)) - self.jobj_now = {'authorizations': (), 'revoke': Revocation.NOW} - self.jobj_date = {'authorizations': (), - 'revoke': '2015-03-27T00:00:00Z'} - - def test_revoke_decoder(self): - from acme.messages import Revocation - self.assertEqual(self.rev_now, Revocation.from_json(self.jobj_now)) - self.assertEqual(self.rev_date, Revocation.from_json(self.jobj_date)) - - def test_revoke_encoder(self): - self.assertEqual(self.jobj_now, self.rev_now.to_partial_json()) - self.assertEqual(self.jobj_date, self.rev_date.to_partial_json()) + self.rev = Revocation(certificate=CERT) def test_from_json_hashable(self): from acme.messages import Revocation - hash(Revocation.from_json(self.rev_now.to_json())) + hash(Revocation.from_json(self.rev.to_json())) if __name__ == '__main__': diff --git a/letsencrypt/revoker.py b/letsencrypt/revoker.py index 0d3bd8e79..402157721 100644 --- a/letsencrypt/revoker.py +++ b/letsencrypt/revoker.py @@ -253,7 +253,7 @@ class Revoker(object): raise errors.LetsEncryptRevokerError( "Corrupted backup key file: %s" % cert.backup_key_path) - return self.network.revoke(certr=None) # XXX + return self.network.revoke(cert=None) # XXX def _remove_certs_keys(self, cert_list): # pylint: disable=no-self-use """Remove certificate and key. From d970987b79c1f370ac1400ae9a31f01ee6f2722a Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Mon, 22 Jun 2015 20:30:17 +0000 Subject: [PATCH 64/72] Fix comment typo --- acme/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/client.py b/acme/client.py index 73c962581..1c0975849 100644 --- a/acme/client.py +++ b/acme/client.py @@ -542,7 +542,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes def revoke(self, cert): """Revoke certificate. - :param .ComparableX509 body: `M2Crypto.X509.X509` wrapped in + :param .ComparableX509 cert: `M2Crypto.X509.X509` wrapped in `.ComparableX509` :raises .ClientError: If revocation is unsuccessful. From e0a1e8f4e819cb1fad4d5760591264d03f2fb1ed Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 2 Jun 2015 19:08:17 +0000 Subject: [PATCH 65/72] JSONDeSerializable acme.messages.Resource. Provides API necessary to implement JSON-based account storage as described at https://github.com/letsencrypt/lets-encrypt-preview/pull/362#issuecomment-97946817 --- acme/client.py | 1 + acme/client_test.py | 32 +++++------- acme/messages.py | 118 +++++++++++++++++++++++++++++++----------- acme/messages_test.py | 99 ++++++++++++++++++++++++++++++++++- 4 files changed, 197 insertions(+), 53 deletions(-) diff --git a/acme/client.py b/acme/client.py index 629048d03..43c659bb8 100644 --- a/acme/client.py +++ b/acme/client.py @@ -466,6 +466,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes updated_authzr, response = self.poll(updated[authzr]) updated[authzr] = updated_authzr + # pylint: disable=no-member if updated_authzr.body.status != messages.STATUS_VALID: # push back to the priority queue, with updated retry_after heapq.heappush(waiting, (self.retry_after( diff --git a/acme/client_test.py b/acme/client_test.py index 5e4cc1720..7f09f8bdf 100644 --- a/acme/client_test.py +++ b/acme/client_test.py @@ -5,7 +5,6 @@ import os import pkg_resources import unittest -import M2Crypto import mock import requests @@ -14,16 +13,9 @@ from acme import errors from acme import jose from acme import jws as acme_jws from acme import messages +from acme import messages_test -CERT = jose.ComparableX509(M2Crypto.X509.load_cert_string( - pkg_resources.resource_string( - 'acme.jose', os.path.join('testdata', 'cert.der')), - M2Crypto.X509.FORMAT_DER)) -CSR = jose.ComparableX509(M2Crypto.X509.load_request_string( - pkg_resources.resource_string( - 'acme.jose', os.path.join('testdata', 'csr.der')), - M2Crypto.X509.FORMAT_DER)) KEY = jose.JWKRSA.load(pkg_resources.resource_string( 'acme.jose', os.path.join('testdata', 'rsa512_key.pem'))) KEY2 = jose.JWKRSA.load(pkg_resources.resource_string( @@ -82,7 +74,7 @@ class ClientTest(unittest.TestCase): # Request issuance self.certr = messages.CertificateResource( - body=CERT, authzrs=(self.authzr,), + body=messages_test.CERT, authzrs=(self.authzr,), uri='https://www.letsencrypt-demo.org/acme/cert/1', cert_chain_uri='https://www.letsencrypt-demo.org/ca') @@ -380,27 +372,27 @@ class ClientTest(unittest.TestCase): self.assertRaises(errors.UnexpectedUpdate, self.net.poll, self.authzr) def test_request_issuance(self): - self.response.content = CERT.as_der() + self.response.content = messages_test.CERT.as_der() self.response.headers['Location'] = self.certr.uri self.response.links['up'] = {'url': self.certr.cert_chain_uri} self._mock_post_get() - self.assertEqual( - self.certr, self.net.request_issuance(CSR, (self.authzr,))) + self.assertEqual(self.certr, self.net.request_issuance( + messages_test.CSR, (self.authzr,))) # TODO: check POST args def test_request_issuance_missing_up(self): - self.response.content = CERT.as_der() + self.response.content = messages_test.CERT.as_der() self.response.headers['Location'] = self.certr.uri self._mock_post_get() self.assertEqual( self.certr.update(cert_chain_uri=None), - self.net.request_issuance(CSR, (self.authzr,))) + self.net.request_issuance(messages_test.CSR, (self.authzr,))) def test_request_issuance_missing_location(self): self._mock_post_get() self.assertRaises( errors.ClientError, self.net.request_issuance, - CSR, (self.authzr,)) + messages_test.CSR, (self.authzr,)) @mock.patch('acme.client.datetime') @mock.patch('acme.client.time') @@ -484,10 +476,10 @@ class ClientTest(unittest.TestCase): def test_check_cert(self): self.response.headers['Location'] = self.certr.uri - self.response.content = CERT.as_der() + self.response.content = messages_test.CERT.as_der() self._mock_post_get() - self.assertEqual( - self.certr.update(body=CERT), self.net.check_cert(self.certr)) + self.assertEqual(self.certr.update(body=messages_test.CERT), + self.net.check_cert(self.certr)) # TODO: split here and separate test self.response.headers['Location'] = 'foo' @@ -495,7 +487,7 @@ class ClientTest(unittest.TestCase): errors.UnexpectedUpdate, self.net.check_cert, self.certr) def test_check_cert_missing_location(self): - self.response.content = CERT.as_der() + self.response.content = messages_test.CERT.as_der() self._mock_post_get() self.assertRaises(errors.ClientError, self.net.check_cert, self.certr) diff --git a/acme/messages.py b/acme/messages.py index aa041caed..e355e9fd0 100644 --- a/acme/messages.py +++ b/acme/messages.py @@ -51,6 +51,7 @@ class Error(jose.JSONObjectWithFields, Exception): else: return str(self.detail) + class _Constant(jose.JSONDeSerializable): """ACME constant.""" __slots__ = ('name',) @@ -107,31 +108,29 @@ class Identifier(jose.JSONObjectWithFields): value = jose.Field('value') -class Resource(jose.ImmutableMap): +class Resource(jose.JSONObjectWithFields): """ACME Resource. + :ivar str uri: Location of the resource. :ivar acme.messages.ResourceBody body: Resource body. + + """ + body = jose.Field('body') + + +class ResourceWithURI(Resource): + """ACME Resource with URI. + :ivar str uri: Location of the resource. """ - __slots__ = ('body', 'uri') + uri = jose.Field('uri') # no ChallengeResource.uri class ResourceBody(jose.JSONObjectWithFields): """ACME Resource Body.""" -class RegistrationResource(Resource): - """Registration Resource. - - :ivar acme.messages.Registration body: - :ivar str new_authzr_uri: URI found in the 'next' ``Link`` header - :ivar str terms_of_service: URL for the CA TOS. - - """ - __slots__ = ('body', 'uri', 'new_authzr_uri', 'terms_of_service') - - class Registration(ResourceBody): """Registration Resource Body. @@ -146,21 +145,59 @@ class Registration(ResourceBody): recovery_token = jose.Field('recoveryToken', omitempty=True) agreement = jose.Field('agreement', omitempty=True) + phone_prefix = 'tel:' + email_prefix = 'mailto:' -class ChallengeResource(Resource, jose.JSONObjectWithFields): - """Challenge Resource. + @classmethod + def from_data(cls, phone=None, email=None, **kwargs): + """Create registration resource from contact detauls.""" + details = list(kwargs.pop('contact', ())) + if phone is not None: + details.append(cls.phone_prefix + phone) + if email is not None: + details.append(cls.email_prefix + email) + kwargs['contact'] = tuple(details) + return cls(**kwargs) - :ivar acme.messages.ChallengeBody body: - :ivar str authzr_uri: URI found in the 'up' ``Link`` header. - - """ - __slots__ = ('body', 'authzr_uri') + def _filter_contact(self, prefix): + return tuple( + detail[len(prefix):] for detail in self.contact + if detail.startswith(prefix)) @property - def uri(self): # pylint: disable=missing-docstring,no-self-argument - # bug? 'method already defined line None' - # pylint: disable=function-redefined - return self.body.uri + def phones(self): + """All phones found in the ``contact`` field.""" + return self._filter_contact(self.phone_prefix) + + @property + def emails(self): + """All emails found in the ``contact`` field.""" + return self._filter_contact(self.email_prefix) + + @property + def phone(self): + """Phone.""" + assert len(self.phones) == 1 + return self.phones[0] + + @property + def email(self): + """Email.""" + assert len(self.emails) == 1 + return self.emails[0] + + +class RegistrationResource(ResourceWithURI): + """Registration Resource. + + :ivar acme.messages.Registration body: + :ivar str new_authzr_uri: URI found in the 'next' ``Link`` header + :ivar str terms_of_service: URL for the CA TOS. + + """ + body = jose.Field('body', decoder=Registration.from_json) + new_authzr_uri = jose.Field('new_authzr_uri') + terms_of_service = jose.Field('terms_of_service', omitempty=True) class ChallengeBody(ResourceBody): @@ -199,14 +236,21 @@ class ChallengeBody(ResourceBody): return getattr(self.chall, name) -class AuthorizationResource(Resource): - """Authorization Resource. +class ChallengeResource(Resource, jose.JSONObjectWithFields): + """Challenge Resource. - :ivar acme.messages.Authorization body: - :ivar str new_cert_uri: URI found in the 'next' ``Link`` header + :ivar acme.messages.ChallengeBody body: + :ivar str authzr_uri: URI found in the 'up' ``Link`` header. """ - __slots__ = ('body', 'uri', 'new_cert_uri') + body = jose.Field('body', decoder=ChallengeBody.from_json) + authzr_uri = jose.Field('authzr_uri') + + @property + def uri(self): # pylint: disable=missing-docstring,no-self-argument + # bug? 'method already defined line None' + # pylint: disable=function-redefined + return self.body.uri # pylint: disable=no-member class Authorization(ResourceBody): @@ -244,6 +288,17 @@ class Authorization(ResourceBody): for combo in self.combinations) +class AuthorizationResource(ResourceWithURI): + """Authorization Resource. + + :ivar acme.messages.Authorization body: + :ivar str new_cert_uri: URI found in the 'next' ``Link`` header + + """ + body = jose.Field('body', decoder=Authorization.from_json) + new_cert_uri = jose.Field('new_cert_uri') + + class CertificateRequest(jose.JSONObjectWithFields): """ACME new-cert request. @@ -256,7 +311,7 @@ class CertificateRequest(jose.JSONObjectWithFields): authorizations = jose.Field('authorizations', decoder=tuple) -class CertificateResource(Resource): +class CertificateResource(ResourceWithURI): """Certificate Resource. :ivar acme.jose.util.ComparableX509 body: @@ -265,7 +320,8 @@ class CertificateResource(Resource): :ivar tuple authzrs: `tuple` of `AuthorizationResource`. """ - __slots__ = ('body', 'uri', 'cert_chain_uri', 'authzrs') + cert_chain_uri = jose.Field('cert_chain_uri') + authzrs = jose.Field('authzrs') class Revocation(jose.JSONObjectWithFields): diff --git a/acme/messages_test.py b/acme/messages_test.py index 4f86d7809..6749f55dc 100644 --- a/acme/messages_test.py +++ b/acme/messages_test.py @@ -4,14 +4,23 @@ import os import pkg_resources import unittest +from Crypto.PublicKey import RSA +import M2Crypto import mock import pytz -from Crypto.PublicKey import RSA from acme import challenges from acme import jose +CERT = jose.ComparableX509(M2Crypto.X509.load_cert_string( + pkg_resources.resource_string( + 'acme.jose', os.path.join('testdata', 'cert.der')), + M2Crypto.X509.FORMAT_DER)) +CSR = jose.ComparableX509(M2Crypto.X509.load_request_string( + pkg_resources.resource_string( + 'acme.jose', os.path.join('testdata', 'csr.der')), + M2Crypto.X509.FORMAT_DER)) KEY = jose.util.HashableRSAKey(RSA.importKey(pkg_resources.resource_string( 'acme.jose', os.path.join('testdata', 'rsa512_key.pem')))) @@ -94,12 +103,16 @@ class ConstantTest(unittest.TestCase): self.assertTrue(self.const_a != self.const_b) self.assertFalse(self.const_a != const_a_prime) + class RegistrationTest(unittest.TestCase): """Tests for acme.messages.Registration.""" def setUp(self): key = jose.jwk.JWKRSA(key=KEY.publickey()) - contact = ('mailto:letsencrypt-client@letsencrypt.org',) + contact = ( + 'mailto:admin@foo.com', + 'tel:1234', + ) recovery_token = 'XYZ' agreement = 'https://letsencrypt.org/terms' @@ -117,6 +130,26 @@ class RegistrationTest(unittest.TestCase): self.jobj_from = self.jobj_to.copy() self.jobj_from['key'] = key.to_json() + def test_from_data(self): + from acme.messages import Registration + reg = Registration.from_data(phone='1234', email='admin@foo.com') + self.assertEqual(reg.contact, ( + 'tel:1234', + 'mailto:admin@foo.com', + )) + + def test_phones(self): + self.assertEqual(('1234',), self.reg.phones) + + def test_emails(self): + self.assertEqual(('admin@foo.com',), self.reg.emails) + + def test_phone(self): + self.assertEqual('1234', self.reg.phone) + + def test_email(self): + self.assertEqual('admin@foo.com', self.reg.email) + def test_to_partial_json(self): self.assertEqual(self.jobj_to, self.reg.to_partial_json()) @@ -129,6 +162,25 @@ class RegistrationTest(unittest.TestCase): hash(Registration.from_json(self.jobj_from)) +class RegistrationResourceTest(unittest.TestCase): + """Tests for acme.messages.RegistrationResource.""" + + def setUp(self): + from acme.messages import RegistrationResource + self.regr = RegistrationResource( + body=mock.sentinel.body, uri=mock.sentinel.uri, + new_authzr_uri=mock.sentinel.new_authzr_uri, + terms_of_service=mock.sentinel.terms_of_service) + + def test_to_partial_json(self): + self.assertEqual(self.regr.to_json(), { + 'body': mock.sentinel.body, + 'uri': mock.sentinel.uri, + 'new_authzr_uri': mock.sentinel.new_authzr_uri, + 'terms_of_service': mock.sentinel.terms_of_service, + }) + + class ChallengeResourceTest(unittest.TestCase): """Tests for acme.messages.ChallengeResource.""" @@ -220,6 +272,49 @@ class AuthorizationTest(unittest.TestCase): )) +class AuthorizationResourceTest(unittest.TestCase): + """Tests for acme.messages.AuthorizationResource.""" + + def test_json_de_serializable(self): + from acme.messages import AuthorizationResource + authzr = AuthorizationResource( + uri=mock.sentinel.uri, + body=mock.sentinel.body, + new_cert_uri=mock.sentinel.new_cert_uri, + ) + self.assertTrue(isinstance(authzr, jose.JSONDeSerializable)) + + +class CertificateRequestTest(unittest.TestCase): + """Tests for acme.messages.CertificateRequest.""" + + def setUp(self): + from acme.messages import CertificateRequest + self.req = CertificateRequest(csr=CSR, authorizations=('foo',)) + + def test_json_de_serializable(self): + self.assertTrue(isinstance(self.req, jose.JSONDeSerializable)) + from acme.messages import CertificateRequest + self.assertEqual( + self.req, CertificateRequest.from_json(self.req.to_json())) + + +class CertificateResourceTest(unittest.TestCase): + """Tests for acme.messages.CertificateResourceTest.""" + + def setUp(self): + from acme.messages import CertificateResource + self.certr = CertificateResource( + body=CERT, uri=mock.sentinel.uri, authzrs=(), + cert_chain_uri=mock.sentinel.cert_chain_uri) + + def test_json_de_serializable(self): + self.assertTrue(isinstance(self.certr, jose.JSONDeSerializable)) + from acme.messages import CertificateResource + self.assertEqual( + self.certr, CertificateResource.from_json(self.certr.to_json())) + + class RevocationTest(unittest.TestCase): """Tests for acme.messages.RevocationTest.""" From c5bf2730246bdf6fcea1747eb0b39445deb6c113 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 18 Jun 2015 08:01:28 +0000 Subject: [PATCH 66/72] setup.py: separate install_requires --- setup.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/setup.py b/setup.py index 150dfb24d..ef819f50b 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,61 @@ meta = dict(re.findall(r"""__([a-z]+)__ = "([^"]+)""", read_file(init_fn))) readme = read_file(os.path.join(here, 'README.rst')) changes = read_file(os.path.join(here, 'CHANGES.rst')) +# #358: acme, letsencrypt, letsencrypt_apache, letsencrypt_nginx, etc. +# shall be distributed separately. Please make sure to keep the +# dependecy lists up to date: this is being somewhat checked below +# using an assert statement! Separate lists are helpful for OS package +# maintainers. and will make the future migration a lot easier. +acme_install_requires = [ + 'argparse', + #'letsencrypt' # TODO: uses testdata vectors + 'mock', + 'pycrypto', + 'pyrfc3339', + 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) + 'pyasn1', # urllib3 InsecurePlatformWarning (#304) + 'pytz', + 'requests', + 'werkzeug', + 'M2Crypto', +] +letsencrypt_install_requires = [ + #'acme', + 'argparse', + 'ConfigArgParse', + 'configobj', + 'M2Crypto', + 'mock', + 'parsedatetime', + 'psutil>=2.1.0', # net_connections introduced in 2.1.0 + 'pycrypto', + # https://pyopenssl.readthedocs.org/en/latest/api/crypto.html#OpenSSL.crypto.X509Req.get_extensions + 'PyOpenSSL>=0.15', + 'pyrfc3339', + 'python-augeas', + 'python2-pythondialog>=3.2.2rc1', # Debian squeeze support, cf. #280 + 'pytz', + 'requests', + 'zope.component', + 'zope.interface', + 'M2Crypto', +] +letsencrypt_apache_install_requires = [ + #'acme', + #'letsencrypt', + 'mock', + 'python-augeas', + 'zope.component', + 'zope.interface', +] +letsencrypt_nginx_install_requires = [ + #'acme', + #'letsencrypt', + 'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary? + 'mock', + 'zope.interface', +] + install_requires = [ 'argparse', 'ConfigArgParse', @@ -54,6 +109,13 @@ install_requires = [ 'M2Crypto', ] +assert set(install_requires) == set.union(*(set(ireq) for ireq in ( + acme_install_requires, + letsencrypt_install_requires, + letsencrypt_apache_install_requires, + letsencrypt_nginx_install_requires +))), "*install_requires don't match up!" + dev_extras = [ # Pin astroid==1.3.5, pylint==1.4.2 as a workaround for #289 'astroid==1.3.5', From b6ef25e911f8304d7a2a22f615ed7a0187eb9914 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Mon, 22 Jun 2015 21:06:08 +0000 Subject: [PATCH 67/72] Fix review comments (typo, inheritance fix). --- acme/messages.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acme/messages.py b/acme/messages.py index cd5a65f79..c6d15bbf1 100644 --- a/acme/messages.py +++ b/acme/messages.py @@ -152,7 +152,7 @@ class Registration(ResourceBody): @classmethod def from_data(cls, phone=None, email=None, **kwargs): - """Create registration resource from contact detauls.""" + """Create registration resource from contact details.""" details = list(kwargs.pop('contact', ())) if phone is not None: details.append(cls.phone_prefix + phone) @@ -238,7 +238,7 @@ class ChallengeBody(ResourceBody): return getattr(self.chall, name) -class ChallengeResource(Resource, jose.JSONObjectWithFields): +class ChallengeResource(Resource): """Challenge Resource. :ivar acme.messages.ChallengeBody body: From e17bd684bb0e3d66650ad264f9c37950def75b76 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 2 Jun 2015 07:55:22 +0000 Subject: [PATCH 68/72] Debug log received response for GET/POST --- acme/client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/acme/client.py b/acme/client.py index 1c0975849..7e0e3ebd1 100644 --- a/acme/client.py +++ b/acme/client.py @@ -77,6 +77,9 @@ class Client(object): # pylint: disable=too-many-instance-attributes :raises .ClientError: In case of other networking errors. """ + logging.debug('Received response %s (headers: %s): %r', + response, response.headers, response.content) + response_ct = response.headers.get('Content-Type') try: # TODO: response.json() is called twice, once here, and @@ -169,7 +172,6 @@ class Client(object): # pylint: disable=too-many-instance-attributes response = requests.post(uri, data=data, **kwargs) except requests.exceptions.RequestException as error: raise errors.ClientError(error) - logging.debug('Received response %s: %r', response, response.text) self._add_nonce(response) self._check_response(response, content_type=content_type) From 28f5c7d6665b004290e99aad5a51fcdc73edec95 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Mon, 22 Jun 2015 10:11:59 +0000 Subject: [PATCH 69/72] logs: collate omitted empty fields --- acme/jose/json_util.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/acme/jose/json_util.py b/acme/jose/json_util.py index a08145459..f38ebc62f 100644 --- a/acme/jose/json_util.py +++ b/acme/jose/json_util.py @@ -218,11 +218,12 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable): def fields_to_partial_json(self): """Serialize fields to JSON.""" jobj = {} + omitted = set() for slot, field in self._fields.iteritems(): value = getattr(self, slot) if field.omit(value): - logging.debug('Omitting empty field "%s" (%s)', slot, value) + omitted.add((slot, value)) else: try: jobj[field.json_name] = field.encode(value) @@ -230,6 +231,10 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable): raise errors.SerializationError( 'Could not encode {0} ({1}): {2}'.format( slot, value, error)) + if omitted: + # pylint: disable=star-args + logging.debug('Omitted empty fields: %s', ', '.join( + '{0!s}={1!r}'.format(*field) for field in omitted)) return jobj def to_partial_json(self): From 8e39a3a0ef530b35ee028b0902deeca7c29f369c Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 13 Jun 2015 14:36:51 +0000 Subject: [PATCH 70/72] Collate multi-line logs, use logging.exception, other fixes. --- acme/client.py | 2 -- letsencrypt/account.py | 2 +- letsencrypt/augeas_configurator.py | 19 ++++++++----------- letsencrypt/auth_handler.py | 8 +------- letsencrypt/crypto_util.py | 2 +- letsencrypt_apache/configurator.py | 21 ++++++++------------- letsencrypt_apache/dvsni.py | 7 +++---- letsencrypt_nginx/configurator.py | 16 +++++----------- letsencrypt_nginx/dvsni.py | 4 ++-- 9 files changed, 29 insertions(+), 52 deletions(-) diff --git a/acme/client.py b/acme/client.py index 7e0e3ebd1..fe4c42799 100644 --- a/acme/client.py +++ b/acme/client.py @@ -95,8 +95,6 @@ class Client(object): # pylint: disable=too-many-instance-attributes '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 messages.Error.from_json(jobj) except jose.DeserializationError as error: # Couldn't deserialize JSON object diff --git a/letsencrypt/account.py b/letsencrypt/account.py index 9f351387f..a97e07504 100644 --- a/letsencrypt/account.py +++ b/letsencrypt/account.py @@ -227,5 +227,5 @@ class Account(object): if cls.EMAIL_REGEX.match(email): return not email.startswith(".") and ".." not in email else: - logging.warn("Invalid email address.") + logging.warn("Invalid email address: %s.", email) return False diff --git a/letsencrypt/augeas_configurator.py b/letsencrypt/augeas_configurator.py index c59d755c2..a375b2e17 100644 --- a/letsencrypt/augeas_configurator.py +++ b/letsencrypt/augeas_configurator.py @@ -52,10 +52,10 @@ class AugeasConfigurator(common.Plugin): lens_path = self.aug.get(path + "/lens") # As aug.get may return null if lens_path and lens in lens_path: - # Strip off /augeas/files and /error - logging.error("There has been an error in parsing the file: %s", - path[13:len(path) - 6]) - logging.error(self.aug.get(path + "/message")) + logging.error( + "There has been an error in parsing the file (%s): %s", + # Strip off /augeas/files and /error + path[13:len(path) - 6], self.aug.get(path + "/message")) def save(self, title=None, temporary=False): """Saves all changes to the configuration files. @@ -122,13 +122,10 @@ class AugeasConfigurator(common.Plugin): # Check for the root of save problems new_errs = self.aug.match("/augeas//error") # logging.error("During Save - %s", mod_conf) - # Only print new errors caused by recent save - for err in new_errs: - if err not in ex_errs: - logging.error( - "Unable to save file - %s", err[13:len(err) - 6]) - logging.error("Attempted Save Notes") - logging.error(self.save_notes) + logging.error("Unable to save files: %s. Attempted Save Notes: %s", + ", ".join(err[13:len(err) - 6] for err in new_errs + # Only new errors caused by recent save + if err not in ex_errs), self.save_notes) # Wrapper functions for Reverter class def recovery_routine(self): diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index d895c165c..50a66c0d0 100644 --- a/letsencrypt/auth_handler.py +++ b/letsencrypt/auth_handler.py @@ -333,28 +333,22 @@ def challb_to_achall(challb, key, domain): """ chall = challb.chall + logging.info("%s challenge for %s", chall.typ, domain) 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.SimpleHTTP): - logging.info(" SimpleHTTP challenge for %s.", domain) return achallenges.SimpleHTTP( 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) diff --git a/letsencrypt/crypto_util.py b/letsencrypt/crypto_util.py index 1eb565289..7e1bb58fb 100644 --- a/letsencrypt/crypto_util.py +++ b/letsencrypt/crypto_util.py @@ -40,7 +40,7 @@ def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"): try: key_pem = make_key(key_size) except ValueError as err: - logging.fatal(str(err)) + logging.exception(err) raise err # Save file diff --git a/letsencrypt_apache/configurator.py b/letsencrypt_apache/configurator.py index 0cff94bbd..5b0dbdea9 100644 --- a/letsencrypt_apache/configurator.py +++ b/letsencrypt_apache/configurator.py @@ -181,8 +181,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if not path["cert_path"] or not path["cert_key"]: # Throw some can't find all of the directives error" logging.warn( - "Cannot find a cert or key directive in %s", vhost.path) - logging.warn("VirtualHost was not modified") + "Cannot find a cert or key directive in %s. " + "VirtualHost was not modified", vhost.path) # Presumably break here so that the virtualhost is not modified return False @@ -408,8 +408,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Note: This could be made to also look for ip:443 combo # TODO: Need to search only open directives and IfMod mod_ssl.c if len(self.parser.find_dir(parser.case_i("Listen"), "443")) == 0: - logging.debug("No Listen 443 directive found") - logging.debug("Setting the Apache Server to Listen on port 443") + logging.debug("No Listen 443 directive found. Setting the " + "Apache Server to Listen on port 443") path = self.parser.add_dir_to_ifmodssl( parser.get_aug_path(self.parser.loc["listen"]), "Listen", "443") self.save_notes += "Added Listen 443 directive to %s\n" % path @@ -922,9 +922,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if proc.returncode != 0: # Enter recovery routine... - logging.error("Configtest failed") - logging.error(stdout) - logging.error(stderr) + logging.error("Configtest failed\n%s\n%s", stdout, stderr) return False return True @@ -1054,9 +1052,8 @@ def enable_mod(mod_name, apache_init_script, apache_enmod): stdout=open("/dev/null", "w"), stderr=open("/dev/null", "w")) apache_restart(apache_init_script) - except (OSError, subprocess.CalledProcessError) as err: - logging.error("Error enabling mod_%s", mod_name) - logging.error("Exception: %s", err) + except (OSError, subprocess.CalledProcessError): + logging.exception("Error enabling mod_%s", mod_name) sys.exit(1) @@ -1119,9 +1116,7 @@ def apache_restart(apache_init_script): if proc.returncode != 0: # Enter recovery routine... - logging.error("Apache Restart Failed!") - logging.error(stdout) - logging.error(stderr) + logging.error("Apache Restart Failed!\n%s\n%s", stdout, stderr) return False except (OSError, ValueError): diff --git a/letsencrypt_apache/dvsni.py b/letsencrypt_apache/dvsni.py index c25426371..5ff09aa50 100644 --- a/letsencrypt_apache/dvsni.py +++ b/letsencrypt_apache/dvsni.py @@ -59,10 +59,9 @@ class ApacheDvsni(common.Dvsni): vhost = self.configurator.choose_vhost(achall.domain) if vhost is None: logging.error( - "No vhost exists with servername or alias of: %s", - achall.domain) - logging.error("No _default_:443 vhost exists") - logging.error("Please specify servernames in the Apache config") + "No vhost exists with servername or alias of: %s. " + "No _default_:443 vhost exists. Please specify servernames " + "in the Apache config", achall.domain) return None # TODO - @jdkasten review this code to make sure it makes sense diff --git a/letsencrypt_nginx/configurator.py b/letsencrypt_nginx/configurator.py index f7b53f3fa..f74ad0a3a 100644 --- a/letsencrypt_nginx/configurator.py +++ b/letsencrypt_nginx/configurator.py @@ -130,9 +130,8 @@ class NginxConfigurator(common.Plugin): vhost.filep, vhost.names) except errors.LetsEncryptMisconfigurationError: logging.warn( - "Cannot find a cert or key directive in %s for %s", - vhost.filep, vhost.names) - logging.warn("VirtualHost was not modified") + "Cannot find a cert or key directive in %s for %s. " + "VirtualHost was not modified.", vhost.filep, vhost.names) # Presumably break here so that the virtualhost is not modified return False @@ -352,9 +351,7 @@ class NginxConfigurator(common.Plugin): if proc.returncode != 0: # Enter recovery routine... - logging.error("Config test failed") - logging.error(stdout) - logging.error(stderr) + logging.error("Config test failed\n%s\n%s", stdout, stderr) return False return True @@ -570,14 +567,11 @@ def nginx_restart(nginx_ctl): if nginx_proc.returncode != 0: # Enter recovery routine... - logging.error("Nginx Restart Failed!") - logging.error(stdout) - logging.error(stderr) + logging.error("Nginx Restart Failed!\n%s\n%s", stdout, stderr) return False except (OSError, ValueError): - logging.fatal( - "Nginx Restart Failed - Please Check the Configuration") + logging.fatal("Nginx Restart Failed - Please Check the Configuration") sys.exit(1) return True diff --git a/letsencrypt_nginx/dvsni.py b/letsencrypt_nginx/dvsni.py index 0697f6e1e..f6f82c5cb 100644 --- a/letsencrypt_nginx/dvsni.py +++ b/letsencrypt_nginx/dvsni.py @@ -50,9 +50,9 @@ class NginxDvsni(common.Dvsni): vhost = self.configurator.choose_vhost(achall.domain) if vhost is None: logging.error( - "No nginx vhost exists with server_name matching: %s", + "No nginx vhost exists with server_name matching: %s. " + "Please specify server_names in the Nginx config.", achall.domain) - logging.error("Please specify server_names in the Nginx config") return None for addr in vhost.addrs: From 278bd8deb2d33328b7606644da662259132dfde2 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 23 Jun 2015 07:34:17 +0000 Subject: [PATCH 71/72] Rename IConfig.csr_dir back to IConfig.cert_dir. This will be used in #504. --- letsencrypt/client.py | 2 +- letsencrypt/configuration.py | 6 +++--- letsencrypt/constants.py | 4 ++-- letsencrypt/interfaces.py | 4 +++- letsencrypt/tests/configuration_test.py | 4 ++-- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index e1e30b9be..30bf41975 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -157,7 +157,7 @@ class Client(object): cert_key = crypto_util.init_save_key( self.config.rsa_key_size, self.config.key_dir) csr = crypto_util.init_save_csr( - cert_key, domains, self.config.csr_dir) + cert_key, domains, self.config.cert_dir) # Retrieve certificate certr = self.network.request_issuance( diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index 670db0e76..d6b29bd73 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -19,7 +19,7 @@ class NamespaceConfig(object): - `accounts_dir` - `account_keys_dir` - - `csr_dir` + - `cert_dir` - `cert_key_backup` - `in_progress_dir` - `key_dir` @@ -65,8 +65,8 @@ class NamespaceConfig(object): constants.CERT_KEY_BACKUP_DIR, self.server_path) @property - def csr_dir(self): # pylint: disable=missing-docstring - return os.path.join(self.namespace.config_dir, constants.CSR_DIR) + def cert_dir(self): # pylint: disable=missing-docstring + return os.path.join(self.namespace.config_dir, constants.CERT_DIR) @property def in_progress_dir(self): # pylint: disable=missing-docstring diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py index 152bc224e..5433299fc 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -67,8 +67,8 @@ CERT_KEY_BACKUP_DIR = "keys-certs" """Directory where all certificates and keys are stored (relative to `IConfig.work_dir`). Used for easy revocation.""" -CSR_DIR = "csrs" -"""Directory (relative to `IConfig.config_dir`) where CSRs are saved.""" +CERT_DIR = "certs" +"""See `.IConfig.cert_dir`.""" IN_PROGRESS_DIR = "IN_PROGRESS" """Directory used before a permanent checkpoint is finalized (relative to diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index 78ad75f65..d10e29bcd 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -161,7 +161,9 @@ class IConfig(zope.interface.Interface): account_keys_dir = zope.interface.Attribute( "Directory where all account keys are stored.") backup_dir = zope.interface.Attribute("Configuration backups directory.") - csr_dir = zope.interface.Attribute("CSRs storage.") + cert_dir = zope.interface.Attribute( + "Directory where newly generated Certificate Signing Requests " + "(CSRs) and certificates not enrolled in the renewer are saved.") cert_key_backup = zope.interface.Attribute( "Directory where all certificates and keys are stored. " "Used for easy revocation.") diff --git a/letsencrypt/tests/configuration_test.py b/letsencrypt/tests/configuration_test.py index 38fea140a..d5e9296dd 100644 --- a/letsencrypt/tests/configuration_test.py +++ b/letsencrypt/tests/configuration_test.py @@ -34,7 +34,7 @@ class NamespaceConfigTest(unittest.TestCase): constants.ACCOUNT_KEYS_DIR = 'keys' constants.BACKUP_DIR = 'backups' constants.CERT_KEY_BACKUP_DIR = 'c/' - constants.CSR_DIR = 'csrs' + constants.CERT_DIR = 'certs' constants.IN_PROGRESS_DIR = '../p' constants.KEY_DIR = 'keys' constants.REC_TOKEN_DIR = '/r' @@ -47,7 +47,7 @@ class NamespaceConfigTest(unittest.TestCase): self.config.account_keys_dir, '/tmp/config/acc/acme-server.org:443/new/keys') self.assertEqual(self.config.backup_dir, '/tmp/foo/backups') - self.assertEqual(self.config.csr_dir, '/tmp/config/csrs') + self.assertEqual(self.config.cert_dir, '/tmp/config/certs') self.assertEqual( self.config.cert_key_backup, '/tmp/foo/c/acme-server.org:443/new') self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p') From f1e747ac1ab7db361702f07b408aa033454e0c27 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 23 Jun 2015 07:15:44 +0000 Subject: [PATCH 72/72] Revert CLI changes, blocked by #485. --- letsencrypt/cli.py | 141 +++++++++++++++++--------------------- letsencrypt/constants.py | 8 +++ letsencrypt/interfaces.py | 5 ++ 3 files changed, 74 insertions(+), 80 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 0217598b1..3bdf2bfc6 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -146,21 +146,21 @@ def install(args, config, plugins): return "Installer could not be determined" acme, doms = _common_run( args, config, acc, authenticator=None, installer=installer) - assert args.cert_path is not None # required=True in the subparser + assert args.cert_path is not None acme.deploy_certificate(doms, acc.key.file, args.cert_path, args.chain_path) acme.enhance_config(doms, args.redirect) def revoke(args, unused_config, unused_plugins): """Revoke.""" - if args.cert_path is None and args.key_path is None: - return "At least one of --cert-path or --key-path is required" + if args.rev_cert is None and args.rev_key is None: + return "At least one of --certificate or --key is required" # 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(args.installer, config, plugins, args.no_confirm, - # args.cert_path, args.key_path) + # args.rev_cert, args.rev_key) def rollback(args, config, plugins): @@ -268,6 +268,34 @@ def create_parser(plugins): "--dvsni-port", type=int, help=config_help("dvsni_port"), default=flag_default("dvsni_port")) + subparsers = parser.add_subparsers(metavar="SUBCOMMAND") + def add_subparser(name, func): # pylint: disable=missing-docstring + subparser = subparsers.add_parser( + name, help=func.__doc__.splitlines()[0], description=func.__doc__) + subparser.set_defaults(func=func) + return subparser + + add_subparser("run", run) + add_subparser("auth", auth) + add_subparser("install", install) + parser_revoke = add_subparser("revoke", revoke) + parser_rollback = add_subparser("rollback", rollback) + add_subparser("config_changes", config_changes) + + parser_plugins = add_subparser("plugins", plugins_cmd) + parser_plugins.add_argument("--init", action="store_true") + parser_plugins.add_argument("--prepare", action="store_true") + parser_plugins.add_argument( + "--authenticators", action="append_const", dest="ifaces", + const=interfaces.IAuthenticator) + parser_plugins.add_argument( + "--installers", action="append_const", dest="ifaces", + const=interfaces.IInstaller) + + parser.add_argument("--configurator") + parser.add_argument("-a", "--authenticator") + parser.add_argument("-i", "--installer") + # positional arg shadows --domains, instead of appending, and # --domains is useful, because it can be stored in config #for subparser in parser_run, parser_auth, parser_install: @@ -286,56 +314,11 @@ def create_parser(plugins): help="Automatically redirect all HTTP traffic to HTTPS for the newly " "authenticated vhost.") - _paths_parser(parser.add_argument_group("paths")) - # _plugins_parsing should be the last thing to act upon the main - # parser (--help should display plugin-specific options last) - _plugins_parsing(parser, plugins) - - _create_subparsers(parser) - - return parser - - -def _create_subparsers(parser): - subparsers = parser.add_subparsers(metavar="SUBCOMMAND") - def add_subparser(name, func): # pylint: disable=missing-docstring - subparser = subparsers.add_parser( - name, help=func.__doc__.splitlines()[0], description=func.__doc__) - subparser.set_defaults(func=func) - return subparser - - # the order of add_subparser() calls is important: it defines the - # order in which subparser names will be displayed in --help - add_subparser("run", run) - add_subparser("auth", auth) - parser_install = add_subparser("install", install) - parser_plugins = add_subparser("plugins", plugins_cmd) - parser_revoke = add_subparser("revoke", revoke) - parser_rollback = add_subparser("rollback", rollback) - add_subparser("config_changes", config_changes) - - parser_install.add_argument( - "--cert-path", required=True, help="Path to a certificate that " - "is going to be installed.") - parser_install.add_argument( - "--chain-path", help="Accompanying path to a certificate chain.") - - parser_plugins.add_argument( - "--init", action="store_true", help="Initialize plugins.") - parser_plugins.add_argument("--prepare", action="store_true", - help="Initialize and prepare plugins.") - parser_plugins.add_argument( - "--authenticators", action="append_const", dest="ifaces", - const=interfaces.IAuthenticator, - help="Limit to authenticator plugins only.") - parser_plugins.add_argument( - "--installers", action="append_const", dest="ifaces", - const=interfaces.IInstaller, help="Limit to installer plugins only.") - parser_revoke.add_argument( - "--cert-path", type=read_file, help="Revoke a specific certificate.") + "--certificate", dest="rev_cert", type=read_file, metavar="CERT_PATH", + help="Revoke a specific certificate.") parser_revoke.add_argument( - "--key-path", type=read_file, + "--key", dest="rev_key", type=read_file, metavar="KEY_PATH", help="Revoke all certs generated by the provided authorized key.") parser_rollback.add_argument( @@ -343,6 +326,16 @@ def _create_subparsers(parser): default=flag_default("rollback_checkpoints"), help="Revert configuration N number of checkpoints.") + _paths_parser(parser.add_argument_group("paths")) + + # TODO: plugin_parser should be called for every detected plugin + for name, plugin_ep in plugins.iteritems(): + plugin_ep.plugin_cls.inject_parser_options( + parser.add_argument_group( + name, description=plugin_ep.description), name) + + return parser + def _paths_parser(parser): add = parser.add_argument @@ -350,38 +343,26 @@ def _paths_parser(parser): help=config_help("config_dir")) add("--work-dir", default=flag_default("work_dir"), help=config_help("work_dir")) + add("--backup-dir", default=flag_default("backup_dir"), + help=config_help("backup_dir")) + add("--key-dir", default=flag_default("key_dir"), + help=config_help("key_dir")) + add("--cert-dir", default=flag_default("certs_dir"), + help=config_help("cert_dir")) + + add("--le-vhost-ext", default="-le-ssl.conf", + help=config_help("le_vhost_ext")) + add("--cert-path", default=flag_default("cert_path"), + help=config_help("cert_path")) + add("--chain-path", default=flag_default("chain_path"), + help=config_help("chain_path")) + + add("--renewer-config-file", default=flag_default("renewer_config_file"), + help=config_help("renewer_config_file")) return parser -def _plugins_parsing(parser, plugins): - plugins_group = parser.add_argument_group( - "plugins", description="Let's Encrypt client supports an extensible " - "plugins architecture. See '%(prog)s plugins' for a list of all " - "available plugins and their names. You can force a particular " - "plugin by setting options provided below. Futher down this help " - "message you will find plugin-specific options (prefixed by " - "--{plugin_name}.") - plugins_group.add_argument( - "-a", "--authenticator", help="Authenticator plugin name.") - plugins_group.add_argument( - "-i", "--installer", help="Installer plugin name.") - plugins_group.add_argument( - "--configurator", help="Name of the plugin that is both " - "an authenticator and an installer. Should not be used together " - "with --authenticator or --installer.") - - # things should not be reorder past/pre this comment: - # plugins_group should be displayed in --help before plugin - # specific groups (so that plugins_group.description makes sense) - - for name, plugin_ep in plugins.iteritems(): - plugin_ep.plugin_cls.inject_parser_options( - parser.add_argument_group( - "plugins: {0}".format(name), - description=plugin_ep.description), name) - - def main(args=sys.argv[1:]): """Command line argument parsing and main script execution.""" # note: arg parser internally handles --help (and exits afterwards) diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py index 5433299fc..df41fbe5b 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -17,6 +17,14 @@ CLI_DEFAULTS = dict( work_dir="/var/lib/letsencrypt", no_verify_ssl=False, dvsni_port=challenges.DVSNI.PORT, + + # TODO: blocked by #485, values ignored + backup_dir="not used", + key_dir="not used", + certs_dir="not used", + cert_path="not used", + chain_path="not used", + renewer_config_file="not used", ) """Defaults for CLI flags and `.IConfig` attributes.""" diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index d10e29bcd..a93716d7d 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -188,6 +188,11 @@ class IConfig(zope.interface.Interface): no_simple_http_tls = zope.interface.Attribute( "Do not use TLS when solving SimpleHTTP challenges.") + # TODO: the following are not used, but blocked by #485 + le_vhost_ext = zope.interface.Attribute("not used") + cert_path = zope.interface.Attribute("not used") + chain_path = zope.interface.Attribute("not used") + class IInstaller(IPlugin): """Generic Let's Encrypt Installer Interface.