diff --git a/.gitignore b/.gitignore index ae5116fcb..2e0578223 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.pyc *.egg-info +.eggs/ build/ dist/ venv/ @@ -9,3 +10,4 @@ m3 *~ .vagrant *.swp +\#*# \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 526b3d33a..167d6ad74 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,7 @@ language: python -# please keep this in sync with docs/using.rst (Ubuntu section, apt-get) -before_install: > - travis_retry sudo apt-get install python python-setuptools - python-virtualenv python-dev gcc swig dialog libaugeas0 libssl-dev - libffi-dev ca-certificates +# http://docs.travis-ci.com/user/ci-environment/#CI-environment-OS +before_install: travis_retry sudo ./bootstrap/ubuntu.sh install: "travis_retry pip install tox coveralls" script: "travis_retry tox" @@ -22,4 +19,10 @@ env: notifications: email: false - irc: "chat.freenode.net#letsencrypt" + irc: + channels: + - "chat.freenode.net#letsencrypt" + on_success: never + on_failure: always + use_notice: true + skip_join: true diff --git a/LICENSE.txt b/LICENSE.txt index 67db85882..d3c19bbd1 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,14 @@ +Let's Encrypt Preview: +Copyright (c) Internet Security Research Group +Licensed Apache Version 2.0 +Incorporating code from nginxparser +Copyright (c) 2014 Fatih Erikli +Licensed MIT + + +Text of Apache License +====================== Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -173,3 +183,23 @@ defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + + +Text of MIT License +=================== +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Vagrantfile b/Vagrantfile index a9e5494ac..b4a06ea05 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -6,10 +6,8 @@ VAGRANTFILE_API_VERSION = "2" # Setup instructions from docs/using.rst $ubuntu_setup_script = < 60, 14.04 => 1404 +version=$(lsb_release -sr | awk -F '.' '{print $1 $2}') +if [ "$distro" = "Ubuntu" -a "$version" -ge 1410 ] +then + virtualenv="virtualenv" +elif [ "$distro" = "Debian" -a "$version" -ge 80 ] +then + virtualenv="virtualenv" +else + virtualenv="python-virtualenv" +fi + +# dpkg-dev: dpkg-architecture binary necessary to compile M2Crypto, c.f. +# #276, https://github.com/martinpaljak/M2Crypto/issues/62, +# M2Crypto setup.py:add_multiarch_paths + +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 diff --git a/bootstrap/debian.sh b/bootstrap/debian.sh new file mode 120000 index 000000000..068a039cb --- /dev/null +++ b/bootstrap/debian.sh @@ -0,0 +1 @@ +_deb_common.sh \ No newline at end of file diff --git a/bootstrap/mac.sh b/bootstrap/mac.sh new file mode 100755 index 000000000..9f0f22a17 --- /dev/null +++ b/bootstrap/mac.sh @@ -0,0 +1,2 @@ +#!/bin/sh +brew install augeas swig diff --git a/bootstrap/ubuntu.sh b/bootstrap/ubuntu.sh new file mode 120000 index 000000000..068a039cb --- /dev/null +++ b/bootstrap/ubuntu.sh @@ -0,0 +1 @@ +_deb_common.sh \ No newline at end of file diff --git a/docs/api/acme/index.rst b/docs/api/acme/index.rst index 89801611e..20206183a 100644 --- a/docs/api/acme/index.rst +++ b/docs/api/acme/index.rst @@ -1,6 +1,8 @@ :mod:`letsencrypt.acme` ======================= +.. contents:: + .. automodule:: letsencrypt.acme :members: @@ -8,9 +10,18 @@ Messages -------- +v00 +~~~ + .. automodule:: letsencrypt.acme.messages :members: +v02 +~~~ + +.. automodule:: letsencrypt.acme.messages2 + :members: + Challenges ---------- @@ -21,10 +32,18 @@ Challenges Other ACME objects ------------------ + .. automodule:: letsencrypt.acme.other :members: +Fields +------ + +.. automodule:: letsencrypt.acme.fields + :members: + + Errors ------ diff --git a/docs/api/client/client_authenticator.rst b/docs/api/client/client_authenticator.rst deleted file mode 100644 index 267a0dd50..000000000 --- a/docs/api/client/client_authenticator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.client_authenticator` ----------------------------------------------- - -.. automodule:: letsencrypt.client.client_authenticator - :members: diff --git a/docs/api/client/continuity_auth.rst b/docs/api/client/continuity_auth.rst new file mode 100644 index 000000000..29f6a3ffb --- /dev/null +++ b/docs/api/client/continuity_auth.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.continuity_auth` +----------------------------------------- + +.. automodule:: letsencrypt.client.continuity_auth + :members: diff --git a/docs/api/client/network2.rst b/docs/api/client/network2.rst new file mode 100644 index 000000000..b05017551 --- /dev/null +++ b/docs/api/client/network2.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.network2` +---------------------------------- + +.. automodule:: letsencrypt.client.network2 + :members: diff --git a/docs/contributing.rst b/docs/contributing.rst index e3b81b3d4..0ed022724 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -80,6 +80,8 @@ Plugin-architecture Let's Encrypt has a plugin architecture to facilitate support for different webservers, other TLS servers, and operating systems. +The interfaces available for plugins to implement are defined in +`interfaces.py`_. The most common kind of plugin is a "Configurator", which is likely to implement the `~letsencrypt.client.interfaces.IAuthenticator` and @@ -89,6 +91,8 @@ Configurators may implement just one of those). There are also `~letsencrypt.client.interfaces.IDisplay` plugins, which implement bindings to alternative UI libraries. +.. _interfaces.py: https://github.com/letsencrypt/lets-encrypt-preview/blob/master/letsencrypt/client/interfaces.py + Authenticators -------------- @@ -98,15 +102,16 @@ the ACME server. From the protocol, there are essentially two different types of challenges. Challenges that must be solved by individual plugins in order to satisfy domain validation (subclasses of `~.DVChallenge`, i.e. `~.challenges.DVSNI`, -`~.challenges.SimpleHTTPS`, `~.challenges.DNS`) and client specific -challenges (subclasses of `~.ClientChallenge`, +`~.challenges.SimpleHTTPS`, `~.challenges.DNS`) and continuity specific +challenges (subclasses of `~.ContinuityChallenge`, i.e. `~.challenges.RecoveryToken`, `~.challenges.RecoveryContact`, -`~.challenges.ProofOfPossession`). Client specific challenges are -always handled by the `~.ClientAuthenticator`. Right now we have two -DV Authenticators, `~.ApacheConfigurator` and the -`~.StandaloneAuthenticator`. The Standalone and Apache authenticators -only solve the `~.challenges.DVSNI` challenge currently. (You can set -which challenges your authenticator can handle through the +`~.challenges.ProofOfPossession`). Continuity challenges are +always handled by the `~.ContinuityAuthenticator`, while plugins are +expected to handle `~.DVChallenge` types. +Right now, we have two authenticator plugins, the `~.ApacheConfigurator` +and the `~.StandaloneAuthenticator`. The Standalone and Apache +authenticators only solve the `~.challenges.DVSNI` challenge currently. +(You can set which challenges your authenticator can handle through the :meth:`~.IAuthenticator.get_chall_pref`. (FYI: We also have a partial implementation for a `~.DNSAuthenticator` @@ -126,26 +131,27 @@ Installers and Authenticators will oftentimes be the same class/object. Installers and Authenticators are kept separate because it should be possible to use the `~.StandaloneAuthenticator` (it sets up its own Python server to perform challenges) with a program that -cannot solve challenges itself. (I am imagining MTA installers). +cannot solve challenges itself. (Imagine MTA installers). + + +Installer Development +--------------------- + +There are a few existing classes that may be beneficial while +developing a new `~letsencrypt.client.interfaces.IInstaller`. +Installers aimed to reconfigure UNIX servers may use Augeas for +configuration parsing and can inherit from `~.AugeasConfigurator` class +to handle much of the interface. Installers that are unable to use +Augeas may still find the `~.Reverter` class helpful in handling +configuration checkpoints and rollback. Display ~~~~~~~ -We currently offer a pythondialog and "text" mode for displays. I have -rewritten the interface which should be merged within the next day -(the rewrite is in the revoker branch of the repo and should be merged -within the next day). Display plugins implement -`~letsencrypt.client.interfaces.IDisplay` interface. - - -Augeas ------- - -Some plugins, especially those designed to reconfigure UNIX servers, -can take inherit from `~.AugeasConfigurator` class in order to more -efficiently handle common operations on UNIX server configuration -files. +We currently offer a pythondialog and "text" mode for displays. Display +plugins implement the `~letsencrypt.client.interfaces.IDisplay` +interface. .. _coding-style: diff --git a/docs/using.rst b/docs/using.rst index 362b75d81..3a7940993 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -5,36 +5,46 @@ Using the Let's Encrypt client Prerequisites ============= -The demo code is supported and known to work on **Ubuntu only** (even -closely related `Debian is known to fail`_). - -Therefore, prerequisites for other platforms listed below are provided -mainly for the :ref:`developers ` reference. +The demo code is supported and known to work on **Ubuntu and +Debian**. Therefore, prerequisites for other platforms listed below +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`_ * `augeas`_ is required for the ``python-augeas`` bindings -.. _Debian is known to fail: https://github.com/letsencrypt/lets-encrypt-preview/issues/68 Ubuntu ------ .. code-block:: shell - sudo apt-get install python python-setuptools python-virtualenv python-dev \ - gcc swig dialog libaugeas0 libssl-dev libffi-dev \ - ca-certificates + sudo ./bootstrap/ubuntu.sh + + +Debian +------ + +.. code-block:: shell + + sudo ./bootstrap/debian.sh + +For squezze you will need to: + +- Use ``virtualenv --no-site-packages -p python`` instead of ``-p python2``. + + +.. _`#280`: https://github.com/letsencrypt/lets-encrypt-preview/issues/280 -.. Please keep the above command in sync with .travis.yml (before_install) Mac OSX ------- .. code-block:: shell - sudo brew install augeas swig + sudo ./bootstrap/mac.sh Installation diff --git a/examples/restified.py b/examples/restified.py new file mode 100644 index 000000000..651ecccd1 --- /dev/null +++ b/examples/restified.py @@ -0,0 +1,42 @@ +import logging +import os +import pkg_resources + +import M2Crypto + +from letsencrypt.acme import messages2 +from letsencrypt.acme import jose + +from letsencrypt.client import network2 + + +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( + 'letsencrypt.acme.jose', os.path.join('testdata', 'rsa512_key.pem'))) +net = network2.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=messages2.Identifier( + typ=messages2.IDENTIFIER_FQDN, value='example1.com'), + regr=regr) +logging.debug(authzr) + +authzr, authzr_response = net.poll(authzr) + +csr = M2Crypto.X509.load_request_string(pkg_resources.resource_string( + 'letsencrypt.client.tests', os.path.join('testdata', 'csr.pem'))) +try: + net.request_issuance(csr, (authzr,)) +except messages2.Error as error: + print error.detail diff --git a/letsencrypt/acme/challenges.py b/letsencrypt/acme/challenges.py index 9227fa1a1..0425ba2a9 100644 --- a/letsencrypt/acme/challenges.py +++ b/letsencrypt/acme/challenges.py @@ -13,12 +13,12 @@ from letsencrypt.acme import other class Challenge(jose.TypedJSONObjectWithFields): - # _fields_to_json | pylint: disable=abstract-method + # _fields_to_partial_json | pylint: disable=abstract-method """ACME challenge.""" TYPES = {} -class ClientChallenge(Challenge): # pylint: disable=abstract-method +class ContinuityChallenge(Challenge): # pylint: disable=abstract-method """Client validation challenges.""" @@ -27,7 +27,7 @@ class DVChallenge(Challenge): # pylint: disable=abstract-method class ChallengeResponse(jose.TypedJSONObjectWithFields): - # _fields_to_json | pylint: disable=abstract-method + # _fields_to_partial_json | pylint: disable=abstract-method """ACME challenge response.""" TYPES = {} @@ -139,7 +139,7 @@ class DVSNIResponse(ChallengeResponse): return self.z(chall) + self.DOMAIN_SUFFIX @Challenge.register -class RecoveryContact(ClientChallenge): +class RecoveryContact(ContinuityChallenge): """ACME "recoveryContact" challenge.""" typ = "recoveryContact" @@ -156,7 +156,7 @@ class RecoveryContactResponse(ChallengeResponse): @Challenge.register -class RecoveryToken(ClientChallenge): +class RecoveryToken(ContinuityChallenge): """ACME "recoveryToken" challenge.""" typ = "recoveryToken" @@ -169,7 +169,7 @@ class RecoveryTokenResponse(ChallengeResponse): @Challenge.register -class ProofOfPossession(ClientChallenge): +class ProofOfPossession(ContinuityChallenge): """ACME "proofOfPossession" challenge. :ivar str nonce: Random data, **not** base64-encoded. @@ -184,7 +184,8 @@ class ProofOfPossession(ClientChallenge): """Hints for "proofOfPossession" challenge. :ivar jwk: JSON Web Key (:class:`letsencrypt.acme.jose.JWK`) - :ivar list certs: List of :class:`M2Crypto.X509.X509` cetificates. + :ivar list certs: List of :class:`letsencrypt.acme.jose.ComparableX509` + certificates. """ jwk = jose.Field("jwk", decoder=jose.JWK.from_json) diff --git a/letsencrypt/acme/challenges_test.py b/letsencrypt/acme/challenges_test.py index 081560fe1..efae04740 100644 --- a/letsencrypt/acme/challenges_test.py +++ b/letsencrypt/acme/challenges_test.py @@ -13,8 +13,10 @@ from letsencrypt.acme import other CERT = jose.ComparableX509(M2Crypto.X509.load_cert( pkg_resources.resource_filename( 'letsencrypt.client.tests', 'testdata/cert.pem'))) -KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - 'letsencrypt.client.tests', os.path.join('testdata', 'rsa256_key.pem'))) +KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + 'letsencrypt.client.tests', + os.path.join('testdata', 'rsa256_key.pem')))) class SimpleHTTPSTest(unittest.TestCase): @@ -28,13 +30,17 @@ class SimpleHTTPSTest(unittest.TestCase): 'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA', } - def test_to_json(self): - self.assertEqual(self.jmsg, self.msg.to_json()) + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): from letsencrypt.acme.challenges import SimpleHTTPS self.assertEqual(self.msg, SimpleHTTPS.from_json(self.jmsg)) + def test_from_json_hashable(self): + from letsencrypt.acme.challenges import SimpleHTTPS + hash(SimpleHTTPS.from_json(self.jmsg)) + class SimpleHTTPSResponseTest(unittest.TestCase): @@ -50,14 +56,18 @@ class SimpleHTTPSResponseTest(unittest.TestCase): self.assertEqual('https://example.com/.well-known/acme-challenge/' '6tbIMBC5Anhl5bOlWT5ZFA', self.msg.uri('example.com')) - def test_to_json(self): - self.assertEqual(self.jmsg, self.msg.to_json()) + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): from letsencrypt.acme.challenges import SimpleHTTPSResponse self.assertEqual( self.msg, SimpleHTTPSResponse.from_json(self.jmsg)) + def test_from_json_hashable(self): + from letsencrypt.acme.challenges import SimpleHTTPSResponse + hash(SimpleHTTPSResponse.from_json(self.jmsg)) + class DVSNITest(unittest.TestCase): @@ -77,13 +87,17 @@ class DVSNITest(unittest.TestCase): self.assertEqual('a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid', self.msg.nonce_domain) - def test_to_json(self): - self.assertEqual(self.jmsg, self.msg.to_json()) + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): from letsencrypt.acme.challenges import DVSNI self.assertEqual(self.msg, DVSNI.from_json(self.jmsg)) + def test_from_json_hashable(self): + from letsencrypt.acme.challenges import DVSNI + hash(DVSNI.from_json(self.jmsg)) + def test_from_json_invalid_r_length(self): from letsencrypt.acme.challenges import DVSNI self.jmsg['r'] = 'abcd' @@ -122,13 +136,17 @@ class DVSNIResponseTest(unittest.TestCase): self.assertEqual( '{0}.acme.invalid'.format(z), self.msg.z_domain(challenge)) - def test_to_json(self): - self.assertEqual(self.jmsg, self.msg.to_json()) + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): from letsencrypt.acme.challenges import DVSNIResponse self.assertEqual(self.msg, DVSNIResponse.from_json(self.jmsg)) + def test_from_json_hashable(self): + from letsencrypt.acme.challenges import DVSNIResponse + hash(DVSNIResponse.from_json(self.jmsg)) + class RecoveryContactTest(unittest.TestCase): @@ -145,13 +163,17 @@ class RecoveryContactTest(unittest.TestCase): 'contact' : 'c********n@example.com', } - def test_to_json(self): - self.assertEqual(self.jmsg, self.msg.to_json()) + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): from letsencrypt.acme.challenges import RecoveryContact self.assertEqual(self.msg, RecoveryContact.from_json(self.jmsg)) + def test_from_json_hashable(self): + from letsencrypt.acme.challenges import RecoveryContact + hash(RecoveryContact.from_json(self.jmsg)) + def test_json_without_optionals(self): del self.jmsg['activationURL'] del self.jmsg['successURL'] @@ -163,7 +185,7 @@ class RecoveryContactTest(unittest.TestCase): self.assertTrue(msg.activation_url is None) self.assertTrue(msg.success_url is None) self.assertTrue(msg.contact is None) - self.assertEqual(self.jmsg, msg.to_json()) + self.assertEqual(self.jmsg, msg.to_partial_json()) class RecoveryContactResponseTest(unittest.TestCase): @@ -173,14 +195,18 @@ class RecoveryContactResponseTest(unittest.TestCase): self.msg = RecoveryContactResponse(token='23029d88d9e123e') self.jmsg = {'type': 'recoveryContact', 'token': '23029d88d9e123e'} - def test_to_json(self): - self.assertEqual(self.jmsg, self.msg.to_json()) + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): from letsencrypt.acme.challenges import RecoveryContactResponse self.assertEqual( self.msg, RecoveryContactResponse.from_json(self.jmsg)) + def test_from_json_hashable(self): + from letsencrypt.acme.challenges import RecoveryContactResponse + hash(RecoveryContactResponse.from_json(self.jmsg)) + def test_json_without_optionals(self): del self.jmsg['token'] @@ -188,7 +214,7 @@ class RecoveryContactResponseTest(unittest.TestCase): msg = RecoveryContactResponse.from_json(self.jmsg) self.assertTrue(msg.token is None) - self.assertEqual(self.jmsg, msg.to_json()) + self.assertEqual(self.jmsg, msg.to_partial_json()) class RecoveryTokenTest(unittest.TestCase): @@ -198,13 +224,17 @@ class RecoveryTokenTest(unittest.TestCase): self.msg = RecoveryToken() self.jmsg = {'type': 'recoveryToken'} - def test_to_json(self): - self.assertEqual(self.jmsg, self.msg.to_json()) + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): from letsencrypt.acme.challenges import RecoveryToken self.assertEqual(self.msg, RecoveryToken.from_json(self.jmsg)) + def test_from_json_hashable(self): + from letsencrypt.acme.challenges import RecoveryToken + hash(RecoveryToken.from_json(self.jmsg)) + class RecoveryTokenResponseTest(unittest.TestCase): @@ -213,14 +243,18 @@ class RecoveryTokenResponseTest(unittest.TestCase): self.msg = RecoveryTokenResponse(token='23029d88d9e123e') self.jmsg = {'type': 'recoveryToken', 'token': '23029d88d9e123e'} - def test_to_json(self): - self.assertEqual(self.jmsg, self.msg.to_json()) + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): from letsencrypt.acme.challenges import RecoveryTokenResponse self.assertEqual( self.msg, RecoveryTokenResponse.from_json(self.jmsg)) + def test_from_json_hashable(self): + from letsencrypt.acme.challenges import RecoveryTokenResponse + hash(RecoveryTokenResponse.from_json(self.jmsg)) + def test_json_without_optionals(self): del self.jmsg['token'] @@ -228,7 +262,7 @@ class RecoveryTokenResponseTest(unittest.TestCase): msg = RecoveryTokenResponse.from_json(self.jmsg) self.assertTrue(msg.token is None) - self.assertEqual(self.jmsg, msg.to_json()) + self.assertEqual(self.jmsg, msg.to_partial_json()) class ProofOfPossessionHintsTest(unittest.TestCase): @@ -264,16 +298,20 @@ class ProofOfPossessionHintsTest(unittest.TestCase): 'authorizedFor': authorized_for, } self.jmsg_from = self.jmsg_to.copy() - self.jmsg_from.update({'jwk': jwk.fully_serialize()}) + self.jmsg_from.update({'jwk': jwk.to_json()}) - def test_to_json(self): - self.assertEqual(self.jmsg_to, self.msg.to_json()) + def test_to_partial_json(self): + self.assertEqual(self.jmsg_to, self.msg.to_partial_json()) def test_from_json(self): from letsencrypt.acme.challenges import ProofOfPossession self.assertEqual( self.msg, ProofOfPossession.Hints.from_json(self.jmsg_from)) + def test_from_json_hashable(self): + from letsencrypt.acme.challenges import ProofOfPossession + hash(ProofOfPossession.Hints.from_json(self.jmsg_from)) + def test_json_without_optionals(self): for optional in ['certFingerprints', 'certs', 'subjectKeyIdentifiers', 'serialNumbers', 'issuers', 'authorizedFor']: @@ -290,7 +328,7 @@ class ProofOfPossessionHintsTest(unittest.TestCase): self.assertEqual(msg.issuers, ()) self.assertEqual(msg.authorized_for, ()) - self.assertEqual(self.jmsg_to, msg.to_json()) + self.assertEqual(self.jmsg_to, msg.to_partial_json()) class ProofOfPossessionTest(unittest.TestCase): @@ -313,19 +351,23 @@ class ProofOfPossessionTest(unittest.TestCase): } self.jmsg_from = { 'type': 'proofOfPossession', - 'alg': jose.RS256.fully_serialize(), + 'alg': jose.RS256.to_json(), 'nonce': 'eET5udtV7aoX8Xl8gYiZIA', - 'hints': hints.fully_serialize(), + 'hints': hints.to_json(), } - def test_to_json(self): - self.assertEqual(self.jmsg_to, self.msg.to_json()) + def test_to_partial_json(self): + self.assertEqual(self.jmsg_to, self.msg.to_partial_json()) def test_from_json(self): from letsencrypt.acme.challenges import ProofOfPossession self.assertEqual( self.msg, ProofOfPossession.from_json(self.jmsg_from)) + def test_from_json_hashable(self): + from letsencrypt.acme.challenges import ProofOfPossession + hash(ProofOfPossession.from_json(self.jmsg_from)) + class ProofOfPossessionResponseTest(unittest.TestCase): @@ -355,20 +397,24 @@ class ProofOfPossessionResponseTest(unittest.TestCase): self.jmsg_from = { 'type': 'proofOfPossession', 'nonce': 'eET5udtV7aoX8Xl8gYiZIA', - 'signature': signature.fully_serialize(), + 'signature': signature.to_json(), } def test_verify(self): self.assertTrue(self.msg.verify()) - def test_to_json(self): - self.assertEqual(self.jmsg_to, self.msg.to_json()) + def test_to_partial_json(self): + self.assertEqual(self.jmsg_to, self.msg.to_partial_json()) def test_from_json(self): from letsencrypt.acme.challenges import ProofOfPossessionResponse self.assertEqual( self.msg, ProofOfPossessionResponse.from_json(self.jmsg_from)) + def test_from_json_hashable(self): + from letsencrypt.acme.challenges import ProofOfPossessionResponse + hash(ProofOfPossessionResponse.from_json(self.jmsg_from)) + class DNSTest(unittest.TestCase): @@ -377,13 +423,17 @@ class DNSTest(unittest.TestCase): self.msg = DNS(token='17817c66b60ce2e4012dfad92657527a') self.jmsg = {'type': 'dns', 'token': '17817c66b60ce2e4012dfad92657527a'} - def test_to_json(self): - self.assertEqual(self.jmsg, self.msg.to_json()) + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): from letsencrypt.acme.challenges import DNS self.assertEqual(self.msg, DNS.from_json(self.jmsg)) + def test_from_json_hashable(self): + from letsencrypt.acme.challenges import DNS + hash(DNS.from_json(self.jmsg)) + class DNSResponseTest(unittest.TestCase): @@ -392,13 +442,17 @@ class DNSResponseTest(unittest.TestCase): self.msg = DNSResponse() self.jmsg = {'type': 'dns'} - def test_to_json(self): - self.assertEqual(self.jmsg, self.msg.to_json()) + def test_to_partial_json(self): + self.assertEqual(self.jmsg, self.msg.to_partial_json()) def test_from_json(self): from letsencrypt.acme.challenges import DNSResponse self.assertEqual(self.msg, DNSResponse.from_json(self.jmsg)) + def test_from_json_hashable(self): + from letsencrypt.acme.challenges import DNSResponse + hash(DNSResponse.from_json(self.jmsg)) + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/acme/fields.py b/letsencrypt/acme/fields.py new file mode 100644 index 000000000..f001f1cd5 --- /dev/null +++ b/letsencrypt/acme/fields.py @@ -0,0 +1,25 @@ +"""ACME JSON fields.""" +import pyrfc3339 + +from letsencrypt.acme import jose + + +class RFC3339Field(jose.Field): + """RFC3339 field encoder/decoder. + + Handles decoding/encoding between RFC3339 strings and aware (not + naive) `datetime.datetime` objects + (e.g. ``datetime.datetime.now(pytz.utc)``). + + """ + + @classmethod + def default_encoder(cls, value): + return pyrfc3339.generate(value) + + @classmethod + def default_decoder(cls, value): + try: + return pyrfc3339.parse(value) + except ValueError as error: + raise jose.DeserializationError(error) diff --git a/letsencrypt/acme/fields_test.py b/letsencrypt/acme/fields_test.py new file mode 100644 index 000000000..204849408 --- /dev/null +++ b/letsencrypt/acme/fields_test.py @@ -0,0 +1,35 @@ +"""Tests for letsencrypt.acme.fields.""" +import datetime +import unittest + +import pytz + +from letsencrypt.acme import jose + + +class RFC3339FieldTest(unittest.TestCase): + """Tests for letsencrypt.acme.fields.RFC3339Field.""" + + def setUp(self): + self.decoded = datetime.datetime(2015, 3, 27, tzinfo=pytz.utc) + self.encoded = '2015-03-27T00:00:00Z' + + def test_default_encoder(self): + from letsencrypt.acme.fields import RFC3339Field + self.assertEqual( + self.encoded, RFC3339Field.default_encoder(self.decoded)) + + def test_default_encoder_naive_fails(self): + from letsencrypt.acme.fields import RFC3339Field + self.assertRaises( + ValueError, RFC3339Field.default_encoder, datetime.datetime.now()) + + def test_default_decoder(self): + from letsencrypt.acme.fields import RFC3339Field + self.assertEqual( + self.decoded, RFC3339Field.default_decoder(self.encoded)) + + def test_default_decoder_raises_deserialization_error(self): + from letsencrypt.acme.fields import RFC3339Field + self.assertRaises( + jose.DeserializationError, RFC3339Field.default_decoder, '') diff --git a/letsencrypt/acme/jose/__init__.py b/letsencrypt/acme/jose/__init__.py index 4c7398b79..20f9ba7d3 100644 --- a/letsencrypt/acme/jose/__init__.py +++ b/letsencrypt/acme/jose/__init__.py @@ -70,5 +70,6 @@ from letsencrypt.acme.jose.jws import JWS from letsencrypt.acme.jose.util import ( ComparableX509, + HashableRSAKey, ImmutableMap, ) diff --git a/letsencrypt/acme/jose/interfaces.py b/letsencrypt/acme/jose/interfaces.py index 446a5d2b0..8e06f99f9 100644 --- a/letsencrypt/acme/jose/interfaces.py +++ b/letsencrypt/acme/jose/interfaces.py @@ -36,8 +36,8 @@ class JSONDeSerializable(object): Turning an arbitrary Python object into Python object that can be encoded into a JSON document. **Full serialization** produces a Python object composed of only basic types as required by the - :ref:`conversion table `. - **Partial serialization** (acomplished by :meth:`to_json`) + :ref:`conversion table `. **Partial + serialization** (acomplished by :meth:`to_partial_json`) produces a Python object that might also be built from other :class:`JSONDeSerializable` objects. @@ -71,15 +71,16 @@ class JSONDeSerializable(object): Interestingly, ``default`` is required to perform only partial serialization, as :func:`json.dumps` applies ``default`` - recursively. This is the idea behind making :meth:`to_json` produce - only partial serialization, while providing custom :meth:`json_dumps` - that dumps with ``default`` set to :meth:`json_dump_default`. + recursively. This is the idea behind making :meth:`to_partial_json` + produce only partial serialization, while providing custom + :meth:`json_dumps` that dumps with ``default`` set to + :meth:`json_dump_default`. To make further documentation a bit more concrete, please, consider the following imaginatory implementation example:: class Foo(JSONDeSerializable): - def to_json(self): + def to_partial_json(self): return 'foo' @classmethod @@ -87,7 +88,7 @@ class JSONDeSerializable(object): return Foo() class Bar(JSONDeSerializable): - def to_json(self): + def to_partial_json(self): return [Foo(), Foo()] @classmethod @@ -98,16 +99,16 @@ class JSONDeSerializable(object): __metaclass__ = abc.ABCMeta @abc.abstractmethod - def to_json(self): # pragma: no cover + def to_partial_json(self): # pragma: no cover """Partially serialize. Following the example, **partial serialization** means the following:: - assert isinstance(Bar().to_json()[0], Foo) - assert isinstance(Bar().to_json()[1], Foo) + assert isinstance(Bar().to_partial_json()[0], Foo) + assert isinstance(Bar().to_partial_json()[1], Foo) # in particular... - assert Bar().to_json() != ['foo', 'foo'] + assert Bar().to_partial_json() != ['foo', 'foo'] :raises letsencrypt.acme.jose.errors.SerializationError: in case of any serialization error. @@ -116,31 +117,37 @@ class JSONDeSerializable(object): """ raise NotImplementedError() - def fully_serialize(self): + def to_json(self): """Fully serialize. Again, following the example from before, **full serialization** means the following:: - assert Bar().fully_serialize() == ['foo', 'foo'] + assert Bar().to_json() == ['foo', 'foo'] :raises letsencrypt.acme.jose.errors.SerializationError: in case of any serialization error. :returns: Fully serialized object. """ - partial = self.to_json() - try_serialize = (lambda x: x.fully_serialize() - if isinstance(x, JSONDeSerializable) else x) - if isinstance(partial, basestring): # strings are sequences - return partial - if isinstance(partial, collections.Sequence): - return [try_serialize(elem) for elem in partial] - elif isinstance(partial, collections.Mapping): - return dict([(try_serialize(key), try_serialize(value)) - for key, value in partial.iteritems()]) - else: - return partial + def _serialize(obj): + if isinstance(obj, JSONDeSerializable): + return _serialize(obj.to_partial_json()) + if isinstance(obj, basestring): # strings are sequence + return obj + elif isinstance(obj, list): + return [_serialize(subobj) for subobj in obj] + elif isinstance(obj, collections.Sequence): + # default to tuple, otherwise Mapping could get + # unhashable list + return tuple(_serialize(subobj) for subobj in obj) + elif isinstance(obj, collections.Mapping): + return dict((_serialize(key), _serialize(value)) + for key, value in obj.iteritems()) + else: + return obj + + return _serialize(self) @util.abstractclassmethod def from_json(cls, unused_jobj): @@ -157,7 +164,7 @@ class JSONDeSerializable(object): """ # TypeError: Can't instantiate abstract class with - # abstract methods from_json, to_json + # abstract methods from_json, to_partial_json return cls() # pylint: disable=abstract-class-instantiated @classmethod @@ -193,6 +200,6 @@ class JSONDeSerializable(object): """ if isinstance(python_object, JSONDeSerializable): - return python_object.to_json() + return python_object.to_partial_json() else: # this branch is necessary, cannot just "return" raise TypeError(repr(python_object) + ' is not JSON serializable') diff --git a/letsencrypt/acme/jose/interfaces_test.py b/letsencrypt/acme/jose/interfaces_test.py index 2e5606bce..4c0fc6eb9 100644 --- a/letsencrypt/acme/jose/interfaces_test.py +++ b/letsencrypt/acme/jose/interfaces_test.py @@ -3,6 +3,7 @@ import unittest class JSONDeSerializableTest(unittest.TestCase): + # pylint: disable=too-many-instance-attributes def setUp(self): from letsencrypt.acme.jose.interfaces import JSONDeSerializable @@ -13,7 +14,7 @@ class JSONDeSerializableTest(unittest.TestCase): def __init__(self, v): self.v = v - def to_json(self): + def to_partial_json(self): return self.v @classmethod @@ -25,7 +26,7 @@ class JSONDeSerializableTest(unittest.TestCase): self.x = x self.y = y - def to_json(self): + def to_partial_json(self): return [self.x, self.y] @classmethod @@ -38,7 +39,7 @@ class JSONDeSerializableTest(unittest.TestCase): self.x = x self.y = y - def to_json(self): + def to_partial_json(self): return {self.x: self.y} @classmethod @@ -50,21 +51,29 @@ class JSONDeSerializableTest(unittest.TestCase): self.basic2 = Basic('foo2') self.seq = Sequence(self.basic1, self.basic2) self.mapping = Mapping(self.basic1, self.basic2) + self.nested = Basic([[self.basic1]]) + self.tuple = Basic(('foo',)) # pylint: disable=invalid-name self.Basic = Basic self.Sequence = Sequence self.Mapping = Mapping - def test_fully_serialize_sequence(self): - self.assertEqual(self.seq.fully_serialize(), ['foo1', 'foo2']) + def test_to_json_sequence(self): + self.assertEqual(self.seq.to_json(), ['foo1', 'foo2']) - def test_fully_serialize_mapping(self): - self.assertEqual(self.mapping.fully_serialize(), {'foo1': 'foo2'}) + def test_to_json_mapping(self): + self.assertEqual(self.mapping.to_json(), {'foo1': 'foo2'}) - def test_fully_serialize_other(self): + def test_to_json_other(self): mock_value = object() - self.assertTrue(self.Basic(mock_value).fully_serialize() is mock_value) + self.assertTrue(self.Basic(mock_value).to_json() is mock_value) + + def test_to_json_nested(self): + self.assertEqual(self.nested.to_json(), [['foo1']]) + + def test_to_json(self): + self.assertEqual(self.tuple.to_json(), (('foo', ))) def test_from_json_not_implemented(self): from letsencrypt.acme.jose.interfaces import JSONDeSerializable diff --git a/letsencrypt/acme/jose/json_util.py b/letsencrypt/acme/jose/json_util.py index 01eada89c..980e11179 100644 --- a/letsencrypt/acme/jose/json_util.py +++ b/letsencrypt/acme/jose/json_util.py @@ -113,7 +113,7 @@ class Field(object): @classmethod def default_encoder(cls, value): """Default (passthrough) encoder.""" - # field.to_json() is no good as encoder has to do partial + # field.to_partial_json() is no good as encoder has to do partial # serialization only return value @@ -189,7 +189,7 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable): raise errors.DeserializationError('No bar suffix!') return value[:-3] - assert Foo(bar='baz').to_json() == {'Bar': 'bazbar'} + assert Foo(bar='baz').to_partial_json() == {'Bar': 'bazbar'} assert Foo.from_json({'Bar': 'bazbar'}) == Foo(bar='baz') assert (Foo.from_json({'Bar': 'bazbar', 'Empty': '!'}) == Foo(bar='baz', empty='!')) @@ -209,7 +209,7 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable): super(JSONObjectWithFields, self).__init__( **(dict(self._defaults(), **kwargs))) - def fields_to_json(self): + def fields_to_partial_json(self): """Serialize fields to JSON.""" jobj = {} for slot, field in self._fields.iteritems(): @@ -226,8 +226,8 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable): slot, value, error)) return jobj - def to_json(self): - return self.fields_to_json() + def to_partial_json(self): + return self.fields_to_partial_json() @classmethod def _check_required(cls, jobj): @@ -378,7 +378,7 @@ class TypedJSONObjectWithFields(JSONObjectWithFields): return type_cls - def to_json(self): + def to_partial_json(self): """Get JSON serializable object. :returns: Serializable JSON object representing ACME typed object. @@ -387,7 +387,7 @@ class TypedJSONObjectWithFields(JSONObjectWithFields): :rtype: dict """ - jobj = self.fields_to_json() + jobj = self.fields_to_partial_json() jobj[self.type_field_name] = self.typ return jobj diff --git a/letsencrypt/acme/jose/json_util_test.py b/letsencrypt/acme/jose/json_util_test.py index e5bffd294..88818ed07 100644 --- a/letsencrypt/acme/jose/json_util_test.py +++ b/letsencrypt/acme/jose/json_util_test.py @@ -44,7 +44,7 @@ class FieldTest(unittest.TestCase): def test_default_encoder_is_partial(self): class MockField(interfaces.JSONDeSerializable): # pylint: disable=missing-docstring - def to_json(self): + def to_partial_json(self): return 'foo' @classmethod def from_json(cls, jobj): @@ -113,8 +113,8 @@ class JSONObjectWithFieldsTest(unittest.TestCase): def test_init_defaults(self): self.assertEqual(self.mock, self.MockJSONObjectWithFields(y=2, z=3)) - def test_fields_to_json_omits_empty(self): - self.assertEqual(self.mock.fields_to_json(), {'y': 2, 'Z': 3}) + def test_fields_to_partial_json_omits_empty(self): + self.assertEqual(self.mock.fields_to_partial_json(), {'y': 2, 'Z': 3}) def test_fields_from_json_fills_default_for_empty(self): self.assertEqual( @@ -135,9 +135,10 @@ class JSONObjectWithFieldsTest(unittest.TestCase): errors.DeserializationError, self.MockJSONObjectWithFields.fields_from_json, {'x': 0, 'Z': 0}) - def test_fields_to_json_encoder(self): - self.assertEqual(self.MockJSONObjectWithFields(x=1, y=2, z=3).to_json(), - {'x': 2, 'y': 2, 'Z': 3}) + def test_fields_to_partial_json_encoder(self): + self.assertEqual( + self.MockJSONObjectWithFields(x=1, y=2, z=3).to_partial_json(), + {'x': 2, 'y': 2, 'Z': 3}) def test_fields_from_json_decoder(self): self.assertEqual( @@ -145,10 +146,10 @@ class JSONObjectWithFieldsTest(unittest.TestCase): self.MockJSONObjectWithFields.fields_from_json( {'x': 4, 'y': 2, 'Z': 3})) - def test_fields_to_json_error_passthrough(self): + def test_fields_to_partial_json_error_passthrough(self): self.assertRaises( errors.SerializationError, self.MockJSONObjectWithFields( - x=1, y=500, z=3).to_json) + x=1, y=500, z=3).to_partial_json) def test_fields_from_json_error_passthrough(self): self.assertRaises( @@ -262,14 +263,14 @@ class TypedJSONObjectWithFieldsTest(unittest.TestCase): def fields_from_json(cls, jobj): return {'foo': jobj['foo']} - def fields_to_json(self): + def fields_to_partial_json(self): return {'foo': self.foo} self.parent_cls = MockParentTypedJSONObjectWithFields self.msg = MockTypedJSONObjectWithFields(foo='bar') - def test_to_json(self): - self.assertEqual(self.msg.to_json(), { + def test_to_partial_json(self): + self.assertEqual(self.msg.to_partial_json(), { 'type': 'test', 'foo': 'bar', }) diff --git a/letsencrypt/acme/jose/jwa.py b/letsencrypt/acme/jose/jwa.py index 984a10f41..b32e6bc66 100644 --- a/letsencrypt/acme/jose/jwa.py +++ b/letsencrypt/acme/jose/jwa.py @@ -38,7 +38,7 @@ class JWASignature(JWA): cls.SIGNATURES[signature_cls.name] = signature_cls return signature_cls - def to_json(self): + def to_partial_json(self): return self.name @classmethod diff --git a/letsencrypt/acme/jose/jwa_test.py b/letsencrypt/acme/jose/jwa_test.py index 712b50510..91f5c2114 100644 --- a/letsencrypt/acme/jose/jwa_test.py +++ b/letsencrypt/acme/jose/jwa_test.py @@ -43,9 +43,9 @@ class JWASignatureTest(unittest.TestCase): self.assertEqual('Sig1', repr(self.Sig1)) self.assertEqual('Sig2', repr(self.Sig2)) - def test_to_json(self): - self.assertEqual(self.Sig1.to_json(), 'Sig1') - self.assertEqual(self.Sig2.to_json(), 'Sig2') + def test_to_partial_json(self): + self.assertEqual(self.Sig1.to_partial_json(), 'Sig1') + self.assertEqual(self.Sig2.to_partial_json(), 'Sig2') def test_from_json(self): from letsencrypt.acme.jose.jwa import JWASignature diff --git a/letsencrypt/acme/jose/jwk.py b/letsencrypt/acme/jose/jwk.py index 1a83a5305..f79e39a33 100644 --- a/letsencrypt/acme/jose/jwk.py +++ b/letsencrypt/acme/jose/jwk.py @@ -41,7 +41,7 @@ class JWKES(JWK): # pragma: no cover """ typ = 'ES' - def fields_to_json(self): + def fields_to_partial_json(self): raise NotImplementedError() @classmethod @@ -62,7 +62,7 @@ class JWKOct(JWK): typ = 'oct' __slots__ = ('key',) - def fields_to_json(self): + def fields_to_partial_json(self): # TODO: An "alg" member SHOULD also be present to identify the # algorithm intended to be used with the key, unless the # application uses another means or convention to determine @@ -83,7 +83,11 @@ class JWKOct(JWK): @JWK.register class JWKRSA(JWK): - """RSA JWK.""" + """RSA JWK. + + :ivar key: `Crypto.PublicKey.RSA` wrapped in `.HashableRSAKey` + + """ typ = 'RSA' __slots__ = ('key',) @@ -114,18 +118,20 @@ class JWKRSA(JWK): :rtype: :class:`JWKRSA` """ - return cls(key=Crypto.PublicKey.RSA.importKey(string)) + return cls(key=util.HashableRSAKey( + Crypto.PublicKey.RSA.importKey(string))) def public(self): return type(self)(key=self.key.publickey()) @classmethod def fields_from_json(cls, jobj): - return cls(key=Crypto.PublicKey.RSA.construct( - (cls._decode_param(jobj['n']), - cls._decode_param(jobj['e'])))) + return cls(key=util.HashableRSAKey( + Crypto.PublicKey.RSA.construct( + (cls._decode_param(jobj['n']), + cls._decode_param(jobj['e']))))) - def fields_to_json(self): + def fields_to_partial_json(self): return { 'n': self._encode_param(self.key.n), 'e': self._encode_param(self.key.e), diff --git a/letsencrypt/acme/jose/jwk_test.py b/letsencrypt/acme/jose/jwk_test.py index b75d3e1ce..a37ddb467 100644 --- a/letsencrypt/acme/jose/jwk_test.py +++ b/letsencrypt/acme/jose/jwk_test.py @@ -6,6 +6,7 @@ import unittest from Crypto.PublicKey import RSA from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import util RSA256_KEY = RSA.importKey(pkg_resources.resource_string( @@ -22,13 +23,17 @@ class JWKOctTest(unittest.TestCase): self.jwk = JWKOct(key='foo') self.jobj = {'kty': 'oct', 'k': 'foo'} - def test_to_json(self): - self.assertEqual(self.jwk.to_json(), self.jobj) + def test_to_partial_json(self): + self.assertEqual(self.jwk.to_partial_json(), self.jobj) def test_from_json(self): from letsencrypt.acme.jose.jwk import JWKOct self.assertEqual(self.jwk, JWKOct.from_json(self.jobj)) + def test_from_json_hashable(self): + from letsencrypt.acme.jose.jwk import JWKOct + hash(JWKOct.from_json(self.jobj)) + def test_load(self): from letsencrypt.acme.jose.jwk import JWKOct self.assertEqual(self.jwk, JWKOct.load('foo')) @@ -42,15 +47,15 @@ class JWKRSATest(unittest.TestCase): def setUp(self): from letsencrypt.acme.jose.jwk import JWKRSA - self.jwk256 = JWKRSA(key=RSA256_KEY.publickey()) - self.jwk256_private = JWKRSA(key=RSA256_KEY) + self.jwk256 = JWKRSA(key=util.HashableRSAKey(RSA256_KEY.publickey())) + self.jwk256_private = JWKRSA(key=util.HashableRSAKey(RSA256_KEY)) self.jwk256json = { 'kty': 'RSA', 'e': 'AQAB', 'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5' '80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q', } - self.jwk512 = JWKRSA(key=RSA512_KEY.publickey()) + self.jwk512 = JWKRSA(key=util.HashableRSAKey(RSA512_KEY.publickey())) self.jwk512json = { 'kty': 'RSA', 'e': 'AQAB', @@ -68,17 +73,18 @@ class JWKRSATest(unittest.TestCase): def test_load(self): from letsencrypt.acme.jose.jwk import JWKRSA - self.assertEqual(JWKRSA(key=RSA256_KEY), JWKRSA.load( - pkg_resources.resource_string( - 'letsencrypt.client.tests', - os.path.join('testdata', 'rsa256_key.pem')))) + self.assertEqual( + JWKRSA(key=util.HashableRSAKey(RSA256_KEY)), JWKRSA.load( + pkg_resources.resource_string( + 'letsencrypt.client.tests', + os.path.join('testdata', 'rsa256_key.pem')))) def test_public(self): self.assertEqual(self.jwk256, self.jwk256_private.public()) - def test_to_json(self): - self.assertEqual(self.jwk256.to_json(), self.jwk256json) - self.assertEqual(self.jwk512.to_json(), self.jwk512json) + def test_to_partial_json(self): + self.assertEqual(self.jwk256.to_partial_json(), self.jwk256json) + self.assertEqual(self.jwk512.to_partial_json(), self.jwk512json) def test_from_json(self): from letsencrypt.acme.jose.jwk import JWK @@ -86,6 +92,10 @@ class JWKRSATest(unittest.TestCase): # TODO: fix schemata to allow RSA512 #self.assertEqual(self.jwk512, JWK.from_json(self.jwk512json)) + def test_from_json_hashable(self): + from letsencrypt.acme.jose.jwk import JWK + hash(JWK.from_json(self.jwk256json)) + def test_from_json_non_schema_errors(self): # valid against schema, but still failing from letsencrypt.acme.jose.jwk import JWK diff --git a/letsencrypt/acme/jose/jws.py b/letsencrypt/acme/jose/jws.py index 3b962aede..fc37227fd 100644 --- a/letsencrypt/acme/jose/jws.py +++ b/letsencrypt/acme/jose/jws.py @@ -46,7 +46,7 @@ class Header(json_util.JSONObjectWithFields): Parameter Names (as defined in section 4.1 of the protocol). If you need Public Header Parameter Names (4.2) or Private Header Parameter Names (4.3), you must subclass - and override :meth:`from_json` and :meth:`to_json` + and override :meth:`from_json` and :meth:`to_partial_json` appropriately. .. warning:: This class does not support any extensions through @@ -223,8 +223,8 @@ class Signature(json_util.JSONObjectWithFields): return cls(protected=protected, header=header, signature=signature) - def fields_to_json(self): - fields = super(Signature, self).fields_to_json() + def fields_to_partial_json(self): + fields = super(Signature, self).fields_to_partial_json() if not fields['header'].not_omitted(): del fields['header'] return fields @@ -294,12 +294,12 @@ class JWS(json_util.JSONObjectWithFields): signature=json_util.decode_b64jose(signature)) return cls(payload=json_util.decode_b64jose(payload), signatures=(sig,)) - def to_json(self, flat=True): # pylint: disable=arguments-differ + def to_partial_json(self, flat=True): # pylint: disable=arguments-differ assert self.signatures payload = b64.b64encode(self.payload) if flat and len(self.signatures) == 1: - ret = self.signatures[0].to_json() + ret = self.signatures[0].to_partial_json() ret['payload'] = payload return ret else: diff --git a/letsencrypt/acme/jose/jws_test.py b/letsencrypt/acme/jose/jws_test.py index 215960e15..fcae71cf4 100644 --- a/letsencrypt/acme/jose/jws_test.py +++ b/letsencrypt/acme/jose/jws_test.py @@ -72,7 +72,7 @@ class HeaderTest(unittest.TestCase): def test_x5c_decoding(self): from letsencrypt.acme.jose.jws import Header header = Header(x5c=(CERT, CERT)) - jobj = header.to_json() + jobj = header.to_partial_json() cert_b64 = base64.b64encode(CERT.as_der()) self.assertEqual(jobj, {'x5c': [cert_b64, cert_b64]}) self.assertEqual(header, Header.from_json(jobj)) @@ -152,14 +152,13 @@ class JWSTest(unittest.TestCase): self.assertRaises(errors.DeserializationError, JWS.from_compact, '.') def test_json_omitempty(self): - protected_jobj = self.protected.to_json(flat=True) - unprotected_jobj = self.unprotected.to_json(flat=True) + protected_jobj = self.protected.to_partial_json(flat=True) + unprotected_jobj = self.unprotected.to_partial_json(flat=True) self.assertTrue('protected' not in unprotected_jobj) self.assertTrue('header' not in protected_jobj) - unprotected_jobj['header'] = unprotected_jobj[ - 'header'].fully_serialize() + unprotected_jobj['header'] = unprotected_jobj['header'].to_json() from letsencrypt.acme.jose.jws import JWS self.assertEqual(JWS.from_json(protected_jobj), self.protected) @@ -173,9 +172,9 @@ class JWSTest(unittest.TestCase): 'protected': b64.b64encode(self.mixed.signature.protected), } jobj_from = jobj_to.copy() - jobj_from['header'] = jobj_from['header'].fully_serialize() + jobj_from['header'] = jobj_from['header'].to_json() - self.assertEqual(self.mixed.to_json(flat=True), jobj_to) + self.assertEqual(self.mixed.to_partial_json(flat=True), jobj_to) from letsencrypt.acme.jose.jws import JWS self.assertEqual(self.mixed, JWS.from_json(jobj_from)) @@ -185,9 +184,9 @@ class JWSTest(unittest.TestCase): 'payload': b64.b64encode('foo'), } jobj_from = jobj_to.copy() - jobj_from['signatures'] = [jobj_to['signatures'][0].fully_serialize()] + jobj_from['signatures'] = [jobj_to['signatures'][0].to_json()] - self.assertEqual(self.mixed.to_json(flat=False), jobj_to) + self.assertEqual(self.mixed.to_partial_json(flat=False), jobj_to) from letsencrypt.acme.jose.jws import JWS self.assertEqual(self.mixed, JWS.from_json(jobj_from)) @@ -196,6 +195,10 @@ class JWSTest(unittest.TestCase): self.assertRaises(errors.DeserializationError, JWS.from_json, {'signatures': (), 'signature': 'foo'}) + def test_from_json_hashable(self): + from letsencrypt.acme.jose.jws import JWS + hash(JWS.from_json(self.mixed.to_json())) + class CLITest(unittest.TestCase): diff --git a/letsencrypt/acme/jose/util.py b/letsencrypt/acme/jose/util.py index 5f516884f..2312055f7 100644 --- a/letsencrypt/acme/jose/util.py +++ b/letsencrypt/acme/jose/util.py @@ -41,6 +41,26 @@ class ComparableX509(object): # pylint: disable=too-few-public-methods return self.as_der() == other.as_der() +class HashableRSAKey(object): # pylint: disable=too-few-public-methods + """Wrapper for `Crypto.PublicKey.RSA` objects that supports hashing.""" + + def __init__(self, wrapped): + self._wrapped = wrapped + + def __getattr__(self, name): + return getattr(self._wrapped, name) + + def __eq__(self, other): + return self._wrapped == other + + def __hash__(self): + return hash((type(self), self.exportKey(format='DER'))) + + def publickey(self): + """Get wrapped public key.""" + return type(self)(self._wrapped.publickey()) + + class ImmutableMap(collections.Mapping, collections.Hashable): # pylint: disable=too-few-public-methods """Immutable key to value mapping with attribute access.""" @@ -57,6 +77,12 @@ class ImmutableMap(collections.Mapping, collections.Hashable): for slot in self.__slots__: object.__setattr__(self, slot, kwargs.pop(slot)) + def update(self, **kwargs): + """Return updated map.""" + items = dict(self) + items.update(kwargs) + return type(self)(**items) # pylint: disable=star-args + def __getitem__(self, key): try: return getattr(self, key) diff --git a/letsencrypt/acme/jose/util_test.py b/letsencrypt/acme/jose/util_test.py index 671b45472..fc75497e0 100644 --- a/letsencrypt/acme/jose/util_test.py +++ b/letsencrypt/acme/jose/util_test.py @@ -1,7 +1,36 @@ """Tests for letsencrypt.acme.jose.util.""" import functools +import os +import pkg_resources import unittest +import Crypto.PublicKey.RSA + + +class HashableRSAKeyTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.util.HashableRSAKey.""" + + def setUp(self): + from letsencrypt.acme.jose.util import HashableRSAKey + self.key = HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa256_key.pem')))) + self.key_same = HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa256_key.pem')))) + + def test_eq(self): + # if __eq__ is not defined, then two HashableRSAKeys with same + # _wrapped do not equate + self.assertEqual(self.key, self.key_same) + + def test_hash(self): + self.assertTrue(isinstance(hash(self.key), int)) + + def test_publickey(self): + from letsencrypt.acme.jose.util import HashableRSAKey + self.assertTrue(isinstance(self.key.publickey(), HashableRSAKey)) + class ImmutableMapTest(unittest.TestCase): """Tests for letsencrypt.acme.jose.util.ImmutableMap.""" @@ -25,6 +54,10 @@ class ImmutableMapTest(unittest.TestCase): self.a2 = self.A(x=3, y=4) self.b = self.B(x=1, y=2) + def test_update(self): + self.assertEqual(self.A(x=2, y=2), self.a1.update(x=2)) + self.assertEqual(self.a2, self.a1.update(x=3, y=4)) + def test_get_missing_item_raises_key_error(self): self.assertRaises(KeyError, self.a1.__getitem__, 'z') diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py index 1009398ea..412b9fb84 100644 --- a/letsencrypt/acme/messages.py +++ b/letsencrypt/acme/messages.py @@ -9,7 +9,7 @@ from letsencrypt.acme import util class Message(jose.TypedJSONObjectWithFields): - # _fields_to_json | pylint: disable=abstract-method + # _fields_to_partial_json | pylint: disable=abstract-method # pylint: disable=too-few-public-methods """ACME message.""" TYPES = {} diff --git a/letsencrypt/acme/messages2.py b/letsencrypt/acme/messages2.py new file mode 100644 index 000000000..4755f9b34 --- /dev/null +++ b/letsencrypt/acme/messages2.py @@ -0,0 +1,298 @@ +"""ACME protocol v02 messages.""" +from letsencrypt.acme import challenges +from letsencrypt.acme import fields +from letsencrypt.acme import jose + + +class Error(jose.JSONObjectWithFields, Exception): + """ACME error. + + https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 + + """ + + ERROR_TYPE_NAMESPACE = 'urn:acme:error:' + ERROR_TYPE_DESCRIPTIONS = { + 'malformed': 'The request message was malformed', + '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)', + } + + # TODO: Boulder omits 'type' and 'instance', spec requires + typ = jose.Field('type', omitempty=True) + title = jose.Field('title', omitempty=True) + detail = jose.Field('detail') + instance = jose.Field('instance', omitempty=True) + + @typ.encoder + def typ(value): # pylint: disable=missing-docstring,no-self-argument + return Error.ERROR_TYPE_NAMESPACE + value + + @typ.decoder + def typ(value): # pylint: disable=missing-docstring,no-self-argument + # pylint thinks isinstance(value, Error), so startswith is not found + # pylint: disable=no-member + if not value.startswith(Error.ERROR_TYPE_NAMESPACE): + raise jose.DeserializationError('Missing error type prefix') + + without_prefix = value[len(Error.ERROR_TYPE_NAMESPACE):] + if without_prefix not in Error.ERROR_TYPE_DESCRIPTIONS: + raise jose.DeserializationError('Error type not recognized') + + return without_prefix + + @property + def description(self): + """Hardcoded error description based on its type.""" + return self.ERROR_TYPE_DESCRIPTIONS[self.typ] + + +class _Constant(jose.JSONDeSerializable): + """ACME constant.""" + __slots__ = ('name',) + POSSIBLE_NAMES = NotImplemented + + def __init__(self, name): + self.POSSIBLE_NAMES[name] = self + self.name = name + + def to_partial_json(self): + return self.name + + @classmethod + def from_json(cls, value): + if value not in cls.POSSIBLE_NAMES: + raise jose.DeserializationError( + '{0} not recognized'.format(cls.__name__)) + return cls.POSSIBLE_NAMES[value] + + def __repr__(self): + return '{0}({1})'.format(self.__class__.__name__, self.name) + + def __eq__(self, other): + return isinstance(other, type(self)) and other.name == self.name + + +class Status(_Constant): + """ACME "status" field.""" + POSSIBLE_NAMES = {} +STATUS_UNKNOWN = Status('unknown') +STATUS_PENDING = Status('pending') +STATUS_PROCESSING = Status('processing') +STATUS_VALID = Status('valid') +STATUS_INVALID = Status('invalid') +STATUS_REVOKED = Status('revoked') + + +class IdentifierType(_Constant): + """ACME identifier type.""" + POSSIBLE_NAMES = {} +IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder + + +class Identifier(jose.JSONObjectWithFields): + """ACME identifier. + + :ivar letsencrypt.acme.messages2.IdentifierType typ: + + """ + typ = jose.Field('type', decoder=IdentifierType.from_json) + value = jose.Field('value') + + +class Resource(jose.ImmutableMap): + """ACME Resource. + + :ivar letsencrypt.acme.messages2.ResourceBody body: Resource body. + :ivar str uri: Location of the resource. + + """ + __slots__ = ('body', 'uri') + + +class ResourceBody(jose.JSONObjectWithFields): + """ACME Resource Body.""" + + +class RegistrationResource(Resource): + """Registration Resource. + + :ivar letsencrypt.acme.messages2.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. + + :ivar letsencrypt.acme.jose.jwk.JWK key: Public key. + :ivar tuple contact: + + """ + + # on new-reg key server ignores 'key' and populates it based on + # JWS.signature.combined.jwk + key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) + contact = jose.Field('contact', omitempty=True, default=()) + recovery_token = jose.Field('recoveryToken', omitempty=True) + agreement = jose.Field('agreement', omitempty=True) + + +class ChallengeResource(Resource, jose.JSONObjectWithFields): + """Challenge Resource. + + :ivar letsencrypt.acme.messages2.ChallengeBody body: + :ivar str authzr_uri: URI found in the 'up' ``Link`` header. + + """ + __slots__ = ('body', '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 + + +class ChallengeBody(ResourceBody): + """Challenge Resource Body. + + .. todo:: + Confusingly, this has a similar name to `.challenges.Challenge`, + as well as `.achallenges.AnnotatedChallenge` or + `.achallenges.Indexed`... Once `messages2` and `network2` is + integrated with the rest of the client, this class functionality + will be merged with `.challenges.Challenge`. Meanwhile, + separation allows the ``master`` to be still interoperable with + Node.js server (protocol v00). For the time being use names such + as ``challb`` to distinguish instances of this class from + ``achall`` or ``ichall``. + + :ivar letsencrypt.acme.messages2.Status status: + :ivar datetime.datetime validated: + + """ + + __slots__ = ('chall',) + uri = jose.Field('uri') + status = jose.Field('status', decoder=Status.from_json) + validated = fields.RFC3339Field('validated', omitempty=True) + + def to_partial_json(self): + jobj = super(ChallengeBody, self).to_partial_json() + jobj.update(self.chall.to_partial_json()) + return jobj + + @classmethod + def fields_from_json(cls, jobj): + jobj_fields = super(ChallengeBody, cls).fields_from_json(jobj) + jobj_fields['chall'] = challenges.Challenge.from_json(jobj) + return jobj_fields + + +class AuthorizationResource(Resource): + """Authorization Resource. + + :ivar letsencrypt.acme.messages2.Authorization body: + :ivar str new_cert_uri: URI found in the 'next' ``Link`` header + + """ + __slots__ = ('body', 'uri', 'new_cert_uri') + + +class Authorization(ResourceBody): + """Authorization Resource Body. + + :ivar letsencrypt.acme.messages2.Identifier identifier: + :ivar list challenges: `list` of `Challenge` + :ivar tuple combinations: Challenge combinations (`tuple` of `tuple` + of `int`, as opposed to `list` of `list` from the spec). + :ivar letsencrypt.acme.jose.jwk.JWK key: Public key. + :ivar tuple contact: + :ivar letsencrypt.acme.messages2.Status status: + :ivar datetime.datetime expires: + + """ + + identifier = jose.Field('identifier', decoder=Identifier.from_json) + challenges = jose.Field('challenges', omitempty=True) + combinations = jose.Field('combinations', omitempty=True) + + # TODO: acme-spec #92, #98 + key = Registration._fields['key'] + contact = Registration._fields['contact'] + + status = jose.Field('status', omitempty=True, decoder=Status.from_json) + # TODO: 'expires' is allowed for Authorization Resources in + # general, but for Key Authorization '[t]he "expires" field MUST + # be absent'... then acme-spec gives example with 'expires' + # present... That's confusing! + expires = fields.RFC3339Field('expires', omitempty=True) + + @challenges.decoder + def challenges(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(ChallengeBody.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) + + +class CertificateRequest(jose.JSONObjectWithFields): + """ACME new-cert request. + + :ivar letsencrypt.acme.jose.util.ComparableX509 csr: + `M2Crypto.X509.Request` wrapped in `.ComparableX509` + :ivar tuple authorizations: `tuple` of URIs (`str`) + + """ + csr = jose.Field('csr', decoder=jose.decode_csr, encoder=jose.encode_csr) + authorizations = jose.Field('authorizations', decoder=tuple) + + +class CertificateResource(Resource): + """Certificate Resource. + + :ivar letsencrypt.acme.jose.util.ComparableX509 body: + `M2Crypto.X509.X509` wrapped in `.ComparableX509` + :ivar str cert_chain_uri: URI found in the 'up' ``Link`` header + :ivar tuple authzrs: `tuple` of `AuthorizationResource`. + + """ + __slots__ = ('body', 'uri', 'cert_chain_uri', 'authzrs') + + +class Revocation(jose.JSONObjectWithFields): + """Revocation message. + + :ivar revoke: Either a `datetime.datetime` or `Revocation.NOW`. + :ivar tuple authorizations: Same as `CertificateRequest.authorizations` + + """ + + NOW = 'now' + """A possible value for `revoke`, denoting that certificate should + be revoked now.""" + + revoke = jose.Field('revoke') + authorizations = CertificateRequest._fields['authorizations'] + + @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) + + @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) diff --git a/letsencrypt/acme/messages2_test.py b/letsencrypt/acme/messages2_test.py new file mode 100644 index 000000000..b9695ecd6 --- /dev/null +++ b/letsencrypt/acme/messages2_test.py @@ -0,0 +1,232 @@ +"""Tests for letsencrypt.acme.messages2.""" +import datetime +import os +import pkg_resources +import unittest + +import mock +import pytz +from Crypto.PublicKey import RSA + +from letsencrypt.acme import challenges +from letsencrypt.acme import jose + + +class ErrorTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2.Error.""" + + def setUp(self): + from letsencrypt.acme.messages2 import Error + self.error = Error(detail='foo', typ='malformed') + + def test_typ_prefix(self): + self.assertEqual('malformed', self.error.typ) + self.assertEqual( + 'urn:acme:error:malformed', self.error.to_partial_json()['type']) + self.assertEqual( + 'malformed', self.error.from_json(self.error.to_partial_json()).typ) + + def test_typ_decoder_missing_prefix(self): + from letsencrypt.acme.messages2 import Error + self.assertRaises(jose.DeserializationError, Error.from_json, + {'detail': 'foo', 'type': 'malformed'}) + self.assertRaises(jose.DeserializationError, Error.from_json, + {'detail': 'foo', 'type': 'not valid bare type'}) + + def test_typ_decoder_not_recognized(self): + from letsencrypt.acme.messages2 import Error + self.assertRaises(jose.DeserializationError, Error.from_json, + {'detail': 'foo', 'type': 'urn:acme:error:baz'}) + + def test_description(self): + self.assertEqual( + 'The request message was malformed', self.error.description) + + def test_from_json_hashable(self): + from letsencrypt.acme.messages2 import Error + hash(Error.from_json(self.error.to_json())) + + +class ConstantTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2._Constant.""" + + def setUp(self): + from letsencrypt.acme.messages2 import _Constant + class MockConstant(_Constant): # pylint: disable=missing-docstring + POSSIBLE_NAMES = {} + + self.MockConstant = MockConstant # pylint: disable=invalid-name + self.const_a = MockConstant('a') + self.const_b = MockConstant('b') + + def test_to_partial_json(self): + self.assertEqual('a', self.const_a.to_partial_json()) + self.assertEqual('b', self.const_b.to_partial_json()) + + def test_from_json(self): + self.assertEqual(self.const_a, self.MockConstant.from_json('a')) + self.assertRaises( + jose.DeserializationError, self.MockConstant.from_json, 'c') + + def test_from_json_hashable(self): + hash(self.MockConstant.from_json('a')) + + def test_repr(self): + self.assertEqual('MockConstant(a)', repr(self.const_a)) + self.assertEqual('MockConstant(b)', repr(self.const_b)) + + +class RegistrationTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2.Registration.""" + + def setUp(self): + key = jose.jwk.JWKRSA(key=jose.util.HashableRSAKey( + RSA.importKey(pkg_resources.resource_string( + 'letsencrypt.client.tests', os.path.join( + 'testdata', 'rsa256_key.pem'))).publickey())) + contact = ('mailto:letsencrypt-client@letsencrypt.org',) + recovery_token = 'XYZ' + agreement = 'https://letsencrypt.org/terms' + + from letsencrypt.acme.messages2 import Registration + self.reg = Registration( + key=key, contact=contact, recovery_token=recovery_token, + agreement=agreement) + + self.jobj_to = { + 'contact': contact, + 'recoveryToken': recovery_token, + 'agreement': agreement, + 'key': key, + } + self.jobj_from = self.jobj_to.copy() + self.jobj_from['key'] = key.to_json() + + def test_to_partial_json(self): + self.assertEqual(self.jobj_to, self.reg.to_partial_json()) + + def test_from_json(self): + from letsencrypt.acme.messages2 import Registration + self.assertEqual(self.reg, Registration.from_json(self.jobj_from)) + + def test_from_json_hashable(self): + from letsencrypt.acme.messages2 import Registration + hash(Registration.from_json(self.jobj_from)) + + +class ChallengeResourceTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2.ChallengeResource.""" + + def test_uri(self): + from letsencrypt.acme.messages2 import ChallengeResource + self.assertEqual('http://challb', ChallengeResource(body=mock.MagicMock( + uri='http://challb'), authzr_uri='http://authz').uri) + + +class ChallengeBodyTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2.ChallengeBody.""" + + def setUp(self): + self.chall = challenges.DNS(token='foo') + + from letsencrypt.acme.messages2 import ChallengeBody + from letsencrypt.acme.messages2 import STATUS_VALID + self.status = STATUS_VALID + self.challb = ChallengeBody( + uri='http://challb', chall=self.chall, status=self.status) + + self.jobj_to = { + 'uri': 'http://challb', + 'status': self.status, + 'type': 'dns', + 'token': 'foo', + } + self.jobj_from = self.jobj_to.copy() + self.jobj_from['status'] = 'valid' + + def test_to_partial_json(self): + self.assertEqual(self.jobj_to, self.challb.to_partial_json()) + + def test_from_json(self): + from letsencrypt.acme.messages2 import ChallengeBody + self.assertEqual(self.challb, ChallengeBody.from_json(self.jobj_from)) + + def test_from_json_hashable(self): + from letsencrypt.acme.messages2 import ChallengeBody + hash(ChallengeBody.from_json(self.jobj_from)) + + +class AuthorizationTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2.Authorization.""" + + def setUp(self): + from letsencrypt.acme.messages2 import ChallengeBody + from letsencrypt.acme.messages2 import STATUS_VALID + self.challbs = ( + ChallengeBody( + uri='http://challb1', status=STATUS_VALID, + chall=challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A')), + ChallengeBody(uri='http://challb2', status=STATUS_VALID, + chall=challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA')), + ChallengeBody(uri='http://challb3', status=STATUS_VALID, + chall=challenges.RecoveryToken()), + ) + combinations = ((0, 2), (1, 2)) + + from letsencrypt.acme.messages2 import Authorization + from letsencrypt.acme.messages2 import Identifier + from letsencrypt.acme.messages2 import IDENTIFIER_FQDN + identifier = Identifier(typ=IDENTIFIER_FQDN, value='example.com') + self.authz = Authorization( + identifier=identifier, combinations=combinations, + challenges=self.challbs) + + self.jobj_from = { + 'identifier': identifier.to_json(), + 'challenges': [challb.to_json() for challb in self.challbs], + 'combinations': combinations, + } + + def test_from_json(self): + from letsencrypt.acme.messages2 import Authorization + Authorization.from_json(self.jobj_from) + + def test_from_json_hashable(self): + from letsencrypt.acme.messages2 import Authorization + hash(Authorization.from_json(self.jobj_from)) + + def test_resolved_combinations(self): + self.assertEqual(self.authz.resolved_combinations, ( + (self.challbs[0], self.challbs[2]), + (self.challbs[1], self.challbs[2]), + )) + + +class RevocationTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2.RevocationTest.""" + + def setUp(self): + from letsencrypt.acme.messages2 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 letsencrypt.acme.messages2 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()) + + def test_from_json_hashable(self): + from letsencrypt.acme.messages2 import Revocation + hash(Revocation.from_json(self.rev_now.to_json())) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/messages_test.py b/letsencrypt/acme/messages_test.py index bd6f4d702..46c2c74cc 100644 --- a/letsencrypt/acme/messages_test.py +++ b/letsencrypt/acme/messages_test.py @@ -11,8 +11,9 @@ from letsencrypt.acme import jose from letsencrypt.acme import other -KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - 'letsencrypt.client.tests', 'testdata/rsa256_key.pem')) +KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + 'letsencrypt.client.tests', 'testdata/rsa256_key.pem'))) CERT = jose.ComparableX509(M2Crypto.X509.load_cert( pkg_resources.resource_filename( 'letsencrypt.client.tests', 'testdata/cert.pem'))) @@ -85,7 +86,7 @@ class ChallengeTest(unittest.TestCase): 'type': 'challenge', 'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4', 'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ', - 'challenges': [chall.fully_serialize() for chall in challs], + 'challenges': [chall.to_json() for chall in challs], 'combinations': [[0, 2], [1, 2]], # TODO array tuples } @@ -101,8 +102,8 @@ class ChallengeTest(unittest.TestCase): ) )) - def test_to_json(self): - self.assertEqual(self.msg.to_json(), self.jmsg_to) + def test_to_partial_json(self): + self.assertEqual(self.msg.to_partial_json(), self.jmsg_to) def test_from_json(self): from letsencrypt.acme.messages import Challenge @@ -116,7 +117,7 @@ class ChallengeTest(unittest.TestCase): msg = Challenge.from_json(self.jmsg_from) self.assertEqual(msg.combinations, ()) - self.assertEqual(msg.to_json(), self.jmsg_to) + self.assertEqual(msg.to_partial_json(), self.jmsg_to) class ChallengeRequestTest(unittest.TestCase): @@ -130,8 +131,8 @@ class ChallengeRequestTest(unittest.TestCase): 'identifier': 'example.com', } - def test_to_json(self): - self.assertEqual(self.msg.to_json(), self.jmsg) + def test_to_partial_json(self): + self.assertEqual(self.msg.to_partial_json(), self.jmsg) def test_from_json(self): from letsencrypt.acme.messages import ChallengeRequest @@ -154,11 +155,11 @@ class AuthorizationTest(unittest.TestCase): 'jwk': jwk, } - def test_to_json(self): - self.assertEqual(self.msg.to_json(), self.jmsg) + 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_json() + self.jmsg['jwk'] = self.jmsg['jwk'].to_partial_json() from letsencrypt.acme.messages import Authorization self.assertEqual(Authorization.from_json(self.jmsg), self.msg) @@ -174,7 +175,7 @@ class AuthorizationTest(unittest.TestCase): 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_json()) + self.assertEqual(self.jmsg, msg.to_partial_json()) class AuthorizationRequestTest(unittest.TestCase): @@ -215,10 +216,9 @@ class AuthorizationRequestTest(unittest.TestCase): 'type': 'authorizationRequest', 'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4', 'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ', - 'responses': [None if response is None - else response.fully_serialize() + 'responses': [None if response is None else response.to_json() for response in self.responses], - 'signature': signature.fully_serialize(), + 'signature': signature.to_json(), # TODO: schema validation doesn't recognize tuples as # arrays :( 'contact': list(self.contact), @@ -236,8 +236,8 @@ class AuthorizationRequestTest(unittest.TestCase): def test_verify(self): self.assertTrue(self.msg.verify('example.com')) - def test_to_json(self): - self.assertEqual(self.msg.to_json(), self.jmsg_to) + def test_to_partial_json(self): + self.assertEqual(self.msg.to_partial_json(), self.jmsg_to) def test_from_json(self): from letsencrypt.acme.messages import AuthorizationRequest @@ -252,7 +252,7 @@ class AuthorizationRequestTest(unittest.TestCase): msg = AuthorizationRequest.from_json(self.jmsg_from) self.assertEqual(msg.contact, ()) - self.assertEqual(self.jmsg_to, msg.to_json()) + self.assertEqual(self.jmsg_to, msg.to_partial_json()) class CertificateTest(unittest.TestCase): @@ -274,8 +274,8 @@ class CertificateTest(unittest.TestCase): # TODO: schema validation array tuples self.jmsg_from['chain'] = list(self.jmsg_from['chain']) - def test_to_json(self): - self.assertEqual(self.msg.to_json(), self.jmsg_to) + def test_to_partial_json(self): + self.assertEqual(self.msg.to_partial_json(), self.jmsg_to) def test_from_json(self): from letsencrypt.acme.messages import Certificate @@ -292,7 +292,7 @@ class CertificateTest(unittest.TestCase): self.assertEqual(msg.chain, ()) self.assertTrue(msg.refresh is None) - self.assertEqual(self.jmsg_to, msg.to_json()) + self.assertEqual(self.jmsg_to, msg.to_partial_json()) class CertificateRequestTest(unittest.TestCase): @@ -315,8 +315,7 @@ class CertificateRequestTest(unittest.TestCase): 'signature': signature, } self.jmsg_from = self.jmsg_to.copy() - self.jmsg_from['signature'] = self.jmsg_from[ - 'signature'].fully_serialize() + self.jmsg_from['signature'] = self.jmsg_from['signature'].to_json() def test_create(self): from letsencrypt.acme.messages import CertificateRequest @@ -327,8 +326,8 @@ class CertificateRequestTest(unittest.TestCase): def test_verify(self): self.assertTrue(self.msg.verify()) - def test_to_json(self): - self.assertEqual(self.msg.to_json(), self.jmsg_to) + def test_to_partial_json(self): + self.assertEqual(self.msg.to_partial_json(), self.jmsg_to) def test_from_json(self): from letsencrypt.acme.messages import CertificateRequest @@ -350,8 +349,8 @@ class DeferTest(unittest.TestCase): 'message': 'Warming up the HSM', } - def test_to_json(self): - self.assertEqual(self.msg.to_json(), self.jmsg) + def test_to_partial_json(self): + self.assertEqual(self.msg.to_partial_json(), self.jmsg) def test_from_json(self): from letsencrypt.acme.messages import Defer @@ -366,7 +365,7 @@ class DeferTest(unittest.TestCase): self.assertTrue(msg.interval is None) self.assertTrue(msg.message is None) - self.assertEqual(self.jmsg, msg.to_json()) + self.assertEqual(self.jmsg, msg.to_partial_json()) class ErrorTest(unittest.TestCase): @@ -384,8 +383,8 @@ class ErrorTest(unittest.TestCase): 'moreInfo': 'https://ca.example.com/documentation/csr-requirements', } - def test_to_json(self): - self.assertEqual(self.msg.to_json(), self.jmsg) + def test_to_partial_json(self): + self.assertEqual(self.msg.to_partial_json(), self.jmsg) def test_from_json(self): from letsencrypt.acme.messages import Error @@ -400,7 +399,7 @@ class ErrorTest(unittest.TestCase): self.assertTrue(msg.message is None) self.assertTrue(msg.more_info is None) - self.assertEqual(self.jmsg, msg.to_json()) + self.assertEqual(self.jmsg, msg.to_partial_json()) class RevocationTest(unittest.TestCase): @@ -410,8 +409,8 @@ class RevocationTest(unittest.TestCase): self.msg = Revocation() self.jmsg = {'type': 'revocation'} - def test_to_json(self): - self.assertEqual(self.msg.to_json(), self.jmsg) + def test_to_partial_json(self): + self.assertEqual(self.msg.to_partial_json(), self.jmsg) def test_from_json(self): from letsencrypt.acme.messages import Revocation @@ -440,8 +439,7 @@ class RevocationRequestTest(unittest.TestCase): 'signature': signature, } self.jmsg_from = self.jmsg_to.copy() - self.jmsg_from['signature'] = self.jmsg_from[ - 'signature'].fully_serialize() + self.jmsg_from['signature'] = self.jmsg_from['signature'].to_json() def test_create(self): from letsencrypt.acme.messages import RevocationRequest @@ -451,8 +449,8 @@ class RevocationRequestTest(unittest.TestCase): def test_verify(self): self.assertTrue(self.msg.verify()) - def test_to_json(self): - self.assertEqual(self.msg.to_json(), self.jmsg_to) + def test_to_partial_json(self): + self.assertEqual(self.msg.to_partial_json(), self.jmsg_to) def test_from_json(self): from letsencrypt.acme.messages import RevocationRequest @@ -469,8 +467,8 @@ class StatusRequestTest(unittest.TestCase): 'token': u'O7-s9MNq1siZHlgrMzi9_A', } - def test_to_json(self): - self.assertEqual(self.msg.to_json(), self.jmsg) + def test_to_partial_json(self): + self.assertEqual(self.msg.to_partial_json(), self.jmsg) def test_from_json(self): from letsencrypt.acme.messages import StatusRequest diff --git a/letsencrypt/acme/other_test.py b/letsencrypt/acme/other_test.py index 61c37f6a3..6ca5f5dd2 100644 --- a/letsencrypt/acme/other_test.py +++ b/letsencrypt/acme/other_test.py @@ -7,10 +7,12 @@ import Crypto.PublicKey.RSA from letsencrypt.acme import jose -RSA256_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - 'letsencrypt.client.tests', 'testdata/rsa256_key.pem')) -RSA512_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - 'letsencrypt.client.tests', 'testdata/rsa512_key.pem')) +RSA256_KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + 'letsencrypt.client.tests', 'testdata/rsa256_key.pem'))) +RSA512_KEY = jose.HashableRSAKey( + Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( + 'letsencrypt.client.tests', 'testdata/rsa512_key.pem'))) class SignatureTest(unittest.TestCase): @@ -40,8 +42,8 @@ class SignatureTest(unittest.TestCase): self.jsig_from = { 'nonce': b64nonce, - 'alg': self.alg.to_json(), - 'jwk': self.jwk.to_json(), + 'alg': self.alg.to_partial_json(), + 'jwk': self.jwk.to_partial_json(), 'sig': b64sig, } @@ -76,8 +78,8 @@ class SignatureTest(unittest.TestCase): self.assertEqual(signature.jwk, self.jwk) self.assertTrue(signature.verify(self.msg)) - def test_to_json(self): - self.assertEqual(self.signature.to_json(), self.jsig_to) + def test_to_partial_json(self): + self.assertEqual(self.signature.to_partial_json(), self.jsig_to) def test_from_json(self): from letsencrypt.acme.other import Signature @@ -86,7 +88,7 @@ class SignatureTest(unittest.TestCase): def test_from_json_non_schema_errors(self): from letsencrypt.acme.other import Signature - jwk = self.jwk.to_json() + jwk = self.jwk.to_partial_json() self.assertRaises( jose.DeserializationError, Signature.from_json, { 'alg': 'RS256', 'sig': 'x', 'nonce': '', 'jwk': jwk}) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 05f3722cf..8e5020dc2 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -5,6 +5,7 @@ import sys import Crypto.PublicKey.RSA from letsencrypt.acme import challenges +from letsencrypt.acme import jose from letsencrypt.acme import messages from letsencrypt.client import achallenges @@ -16,12 +17,12 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes """ACME Authorization Handler for a client. :ivar dv_auth: Authenticator capable of solving - :const:`~letsencrypt.client.constants.DV_CHALLENGES` + :class:`~letsencrypt.acme.challenges.DVChallenge` types :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` - :ivar client_auth: Authenticator capable of solving - :const:`~letsencrypt.client_auth.constants.CLIENT_CHALLENGES` - :type client_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` + :ivar cont_auth: Authenticator capable of solving + :class:`~letsencrypt.acme.challenges.ContinuityChallenge` types + :type cont_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` :ivar network: Network object for sending and receiving authorization messages @@ -36,13 +37,13 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :ivar dict paths: optimal path for authorization. eg. paths[domain] :ivar dict dv_c: Keys - domain, Values are DV challenges in the form of :class:`letsencrypt.client.achallenges.Indexed` - :ivar dict client_c: Keys - domain, Values are Client challenges in the form - of :class:`letsencrypt.client.achallenges.Indexed` + :ivar dict cont_c: Keys - domain, Values are Continuity challenges in the + form of :class:`letsencrypt.client.achallenges.Indexed` """ - def __init__(self, dv_auth, client_auth, network): + def __init__(self, dv_auth, cont_auth, network): self.dv_auth = dv_auth - self.client_auth = client_auth + self.cont_auth = cont_auth self.network = network self.domains = [] @@ -52,7 +53,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes self.paths = dict() self.dv_c = dict() - self.client_c = dict() + self.cont_c = dict() def add_chall_msg(self, domain, msg, authkey): """Add a challenge message to the AuthHandler. @@ -76,7 +77,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes self.authkey[domain] = authkey def get_authorizations(self): - """Retreive all authorizations for challenges. + """Retrieve all authorizations for challenges. :raises LetsEncryptAuthHandlerError: If unable to retrieve all authorizations @@ -119,8 +120,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes nonce=self.msgs[domain].nonce, responses=self.responses[domain], name=domain, - key=Crypto.PublicKey.RSA.importKey( - self.authkey[domain].pem)), + key=jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + self.authkey[domain].pem))), messages.Authorization) logging.info("Received Authorization for %s", domain) return auth @@ -147,24 +148,24 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes self._get_chall_pref(dom), self.msgs[dom].combinations) - self.dv_c[dom], self.client_c[dom] = self._challenge_factory( + self.dv_c[dom], self.cont_c[dom] = self._challenge_factory( dom, self.paths[dom]) # Flatten challs for authenticator functions and remove index # Order is important here as we will not expose the outside # Authenticator to our own indices. - flat_client = [] + flat_cont = [] flat_dv = [] for dom in self.domains: - flat_client.extend(ichall.achall for ichall in self.client_c[dom]) + flat_cont.extend(ichall.achall for ichall in self.cont_c[dom]) flat_dv.extend(ichall.achall for ichall in self.dv_c[dom]) - client_resp = [] + cont_resp = [] dv_resp = [] try: - if flat_client: - client_resp = self.client_auth.perform(flat_client) + if flat_cont: + cont_resp = self.cont_auth.perform(flat_cont) if flat_dv: dv_resp = self.dv_auth.perform(flat_dv) # This will catch both specific types of errors. @@ -181,8 +182,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes logging.info("Ready for verification...") # Assemble Responses - if client_resp: - self._assign_responses(client_resp, self.client_c) + if cont_resp: + self._assign_responses(cont_resp, self.cont_c) if dv_resp: self._assign_responses(dv_resp, self.dv_c) @@ -191,7 +192,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :param list flat_list: flat_list of responses from an IAuthenticator :param dict ichall_dict: Master dict mapping all domains to a list of - their associated 'client' and 'dv' Indexed challenges, or their + their associated 'continuity' and 'dv' Indexed challenges, or their :class:`letsencrypt.client.achallenges.Indexed` list """ @@ -213,7 +214,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes """ chall_prefs = [] - chall_prefs.extend(self.client_auth.get_chall_pref(domain)) + chall_prefs.extend(self.cont_auth.get_chall_pref(domain)) chall_prefs.extend(self.dv_auth.get_chall_pref(domain)) return chall_prefs @@ -228,11 +229,11 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes # Chose to make these lists instead of a generator to make it easier to # work with... dv_list = [ichall.achall for ichall in self.dv_c[domain]] - client_list = [ichall.achall for ichall in self.client_c[domain]] + cont_list = [ichall.achall for ichall in self.cont_c[domain]] if dv_list: self.dv_auth.cleanup(dv_list) - if client_list: - self.client_auth.cleanup(client_list) + if cont_list: + self.cont_auth.cleanup(cont_list) def _cleanup_state(self, delete_list): """Cleanup state after an authorization is received. @@ -247,7 +248,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes del self.authkey[domain] - del self.client_c[domain] + del self.cont_c[domain] del self.dv_c[domain] self.domains.remove(domain) @@ -259,9 +260,9 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :param list path: List of indices from `challenges`. - :returns: dv_chall, list of + :returns: dv_chall, list of DVChallenge type :class:`letsencrypt.client.achallenges.Indexed` - client_chall, list of + cont_chall, list of ContinuityChallenge type :class:`letsencrypt.client.achallenges.Indexed` :rtype: tuple @@ -270,7 +271,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes """ dv_chall = [] - client_chall = [] + cont_chall = [] for index in path: chall = self.msgs[domain].challenges[index] @@ -304,35 +305,38 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes ichall = achallenges.Indexed(achall=achall, index=index) - if isinstance(chall, challenges.ClientChallenge): - client_chall.append(ichall) + if isinstance(chall, challenges.ContinuityChallenge): + cont_chall.append(ichall) elif isinstance(chall, challenges.DVChallenge): dv_chall.append(ichall) - return dv_chall, client_chall + return dv_chall, cont_chall def gen_challenge_path(challs, preferences, combinations): """Generate a plan to get authority over the identity. - .. todo:: Make sure that the challenges are feasible... - Example: Do you have the recovery key? + .. todo:: This can be possibly be rewritten to use resolved_combinations. - :param list challs: A list of challenges + :param tuple challs: A tuple of challenges (:class:`letsencrypt.acme.challenges.Challenge`) from :class:`letsencrypt.acme.messages.Challenge` server message to be fulfilled by the client in order to prove possession of the identifier. :param list preferences: List of challenge preferences for domain - (:class:`letsencrypt.acme.challenges.Challege` subclasses) + (:class:`letsencrypt.acme.challenges.Challenge` subclasses) - :param list combinations: A collection of sets of challenges from + :param tuple combinations: A collection of sets of challenges from :class:`letsencrypt.acme.messages.Challenge`, each of which would be sufficient to prove possession of the identifier. - :returns: List of indices from ``challenges``. - :rtype: list + :returns: tuple of indices from ``challenges``. + :rtype: tuple + + :raises letsencrypt.client.errors.LetsEncryptAuthHandlerError: If a + path cannot be created that satisfies the CA given the preferences and + combinations. """ if combinations: @@ -349,29 +353,34 @@ def _find_smart_path(challs, preferences, combinations): """ chall_cost = {} - max_cost = 0 + max_cost = 1 for i, chall_cls in enumerate(preferences): chall_cost[chall_cls] = i max_cost += i + # max_cost is now equal to sum(indices) + 1 + best_combo = [] # Set above completing all of the available challenges - best_combo_cost = max_cost + 1 + best_combo_cost = max_cost combo_total = 0 for combo in combinations: for challenge_index in combo: combo_total += chall_cost.get(challs[ challenge_index].__class__, max_cost) + if combo_total < best_combo_cost: best_combo = combo best_combo_cost = combo_total - combo_total = 0 + + combo_total = 0 if not best_combo: - logging.fatal("Client does not support any combination of " - "challenges to satisfy ACME server") - sys.exit(22) + msg = ("Client does not support any combination of challenges that " + "will satisfy the CA.") + logging.fatal(msg) + raise errors.LetsEncryptAuthHandlerError(msg) return best_combo diff --git a/letsencrypt/client/cli.py b/letsencrypt/client/cli.py index 221c51969..e38702980 100644 --- a/letsencrypt/client/cli.py +++ b/letsencrypt/client/cli.py @@ -28,6 +28,7 @@ from letsencrypt.client.display import ops as display_ops from letsencrypt.client.plugins import disco as plugins_disco from letsencrypt.client.plugins.apache import configurator as apache_configurator +from letsencrypt.client.plugins.nginx import configurator as nginx_configurator def _common_run(args, config, authenticator, installer): @@ -303,6 +304,10 @@ def create_parser(): plugin_parser( parser.add_argument_group("apache"), prefix="apache", plugin_cls=apache_configurator.ApacheConfigurator) + plugin_parser( + parser.add_argument_group("nginx"), prefix="nginx", + plugin_cls=nginx_configurator.NginxConfigurator) + return parser diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index c9723772a..2fcb45d40 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -6,11 +6,11 @@ import sys import Crypto.PublicKey.RSA import M2Crypto +from letsencrypt.acme import jose from letsencrypt.acme import messages -from letsencrypt.acme.jose import util as jose_util from letsencrypt.client import auth_handler -from letsencrypt.client import client_authenticator +from letsencrypt.client import continuity_auth from letsencrypt.client import crypto_util from letsencrypt.client import errors from letsencrypt.client import le_util @@ -33,7 +33,8 @@ class Client(object): :type authkey: :class:`letsencrypt.client.le_util.Key` :ivar auth_handler: Object that supports the IAuthenticator interface. - auth_handler contains both a dv_authenticator and a client_authenticator + auth_handler contains both a dv_authenticator and a + continuity_authenticator :type auth_handler: :class:`letsencrypt.client.auth_handler.AuthHandler` :ivar installer: Object supporting the IInstaller interface. @@ -60,9 +61,9 @@ class Client(object): self.config = config if dv_auth is not None: - client_auth = client_authenticator.ClientAuthenticator(config) + cont_auth = continuity_auth.ContinuityAuthenticator(config) self.auth_handler = auth_handler.AuthHandler( - dv_auth, client_auth, self.network) + dv_auth, cont_auth, self.network) else: self.auth_handler = None @@ -130,9 +131,10 @@ class Client(object): logging.info("Preparing and sending CSR...") return self.network.send_and_receive_expected( messages.CertificateRequest.create( - csr=jose_util.ComparableX509( + csr=jose.ComparableX509( M2Crypto.X509.load_request_der_string(csr_der)), - key=Crypto.PublicKey.RSA.importKey(self.authkey.pem)), + key=jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + self.authkey.pem))), messages.Certificate) def save_certificate(self, certificate_msg, cert_path, chain_path): diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/continuity_auth.py similarity index 83% rename from letsencrypt/client/client_authenticator.py rename to letsencrypt/client/continuity_auth.py index 3cef97355..063d3d408 100644 --- a/letsencrypt/client/client_authenticator.py +++ b/letsencrypt/client/continuity_auth.py @@ -1,4 +1,4 @@ -"""Client Authenticator""" +"""Continuity Authenticator""" import zope.interface from letsencrypt.acme import challenges @@ -9,9 +9,9 @@ from letsencrypt.client import interfaces from letsencrypt.client import recovery_token -class ClientAuthenticator(object): +class ContinuityAuthenticator(object): """IAuthenticator for - :const:`~letsencrypt.client.constants.CLIENT_CHALLENGES`. + :const:`~letsencrypt.acme.challenges.ContinuityChallenge` class challenges. :ivar rec_token: Performs "recoveryToken" challenges :type rec_token: :class:`letsencrypt.client.recovery_token.RecoveryToken` @@ -41,7 +41,7 @@ class ClientAuthenticator(object): if isinstance(achall, achallenges.RecoveryToken): responses.append(self.rec_token.perform(achall)) else: - raise errors.LetsEncryptClientAuthError("Unexpected Challenge") + raise errors.LetsEncryptContAuthError("Unexpected Challenge") return responses def cleanup(self, achalls): @@ -50,4 +50,4 @@ class ClientAuthenticator(object): if isinstance(achall, achallenges.RecoveryToken): self.rec_token.cleanup(achall) else: - raise errors.LetsEncryptClientAuthError("Unexpected Challenge") + raise errors.LetsEncryptContAuthError("Unexpected Challenge") diff --git a/letsencrypt/client/errors.py b/letsencrypt/client/errors.py index c1d6c785f..243326b14 100644 --- a/letsencrypt/client/errors.py +++ b/letsencrypt/client/errors.py @@ -5,6 +5,14 @@ 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.""" @@ -14,7 +22,7 @@ class LetsEncryptAuthHandlerError(LetsEncryptClientError): """Let's Encrypt Auth Handler error.""" -class LetsEncryptClientAuthError(LetsEncryptAuthHandlerError): +class LetsEncryptContAuthError(LetsEncryptAuthHandlerError): """Let's Encrypt Client Authenticator error.""" diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py new file mode 100644 index 000000000..c2f535096 --- /dev/null +++ b/letsencrypt/client/network2.py @@ -0,0 +1,506 @@ +"""Networking for ACME protocol v02.""" +import datetime +import heapq +import httplib +import itertools +import logging +import time + +import M2Crypto +import requests +import werkzeug + +from letsencrypt.acme import jose +from letsencrypt.acme import messages2 + +from letsencrypt.client import errors + + +# 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` + + """ + + DER_CONTENT_TYPE = 'application/pkix-cert' + JSON_CONTENT_TYPE = 'application/json' + JSON_ERROR_CONTENT_TYPE = 'application/problem+json' + + def __init__(self, new_reg_uri, key, alg=jose.RS256): + self.new_reg_uri = new_reg_uri + self.key = key + self.alg = alg + + def _wrap_in_jws(self, obj): + """Wrap `JSONDeSerializable` object in JWS. + + :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() + + @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.messages2.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: + raise messages2.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.client.errors.NetworkError: + + :returns: HTTP Response + :rtype: `requests.Response` + + """ + 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 _post(self, uri, data, content_type=JSON_CONTENT_TYPE, **kwargs): + """Send POST data. + + :param str content_type: Expected ``Content-Type``, fails if not set. + + :raises letsencrypt.acme.messages2.NetworkError: + + :returns: HTTP Response + :rtype: `requests.Response` + + """ + logging.debug('Sending POST data: %s', data) + try: + response = requests.post(uri, data=data, **kwargs) + except requests.exceptions.RequestException as error: + raise errors.NetworkError(error) + logging.debug('Received response %s: %s', response, response.text) + + 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 messages2.RegistrationResource( + body=messages2.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[ + 'contact'].default): + """Register. + + :param contact: Contact list, as accpeted by `.RegistrationResource` + :type contact: `tuple` + + :returns: Registration Resource. + :rtype: `.RegistrationResource` + + :raises letsencrypt.client.errors.UnexpectedUpdate: + + """ + new_reg = messages2.Registration(contact=contact) + + response = self._post(self.new_reg_uri, self._wrap_in_jws(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, self._wrap_in_jws(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: + # TODO: Boulder reregisters with new recoveryToken and new URI + raise errors.UnexpectedUpdate(regr) + return updated_regr + + def _authzr_from_response(self, response, identifier, + uri=None, new_cert_uri=None): + if new_cert_uri is None: + try: + new_cert_uri = response.links['next']['url'] + except KeyError: + raise errors.NetworkError('"next" link missing') + + authzr = messages2.AuthorizationResource( + body=messages2.Authorization.from_json(response.json()), + uri=response.headers.get('Location', uri), + new_cert_uri=new_cert_uri) + if (authzr.body.key != self.key.public() + or authzr.body.identifier != identifier): + raise errors.UnexpectedUpdate(authzr) + return authzr + + def request_challenges(self, identifier, regr): + """Request challenges. + + :param identifier: Identifier to be challenged. + :type identifier: `.messages2.Identifier` + + :param regr: Registration Resource. + :type regr: `.RegistrationResource` + + :returns: Authorization Resource. + :rtype: `.AuthorizationResource` + + """ + new_authz = messages2.Authorization(identifier=identifier) + response = self._post(regr.new_authzr_uri, self._wrap_in_jws(new_authz)) + assert response.status_code == httplib.CREATED # TODO: handle errors + return self._authzr_from_response(response, identifier) + + def request_domain_challenges(self, domain, regr): + """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. + + """ + return self.request_challenges(messages2.Identifier( + typ=messages2.IDENTIFIER_FQDN, value=domain), regr) + + 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, self._wrap_in_jws(response)) + try: + authzr_uri = response.links['up']['url'] + except KeyError: + raise errors.NetworkError('"up" Link header missing') + challr = messages2.ChallengeResource( + authzr_uri=authzr_uri, + body=messages2.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 + + def answer_challenges(self, challbs, responses): + """Answer multiple challenges. + + .. note:: This is a convenience function to make integration + with old proto code easier and shall probably be removed + once restification is over. + + """ + return [self.answer_challenge(challb, response) + for challb, response in itertools.izip(challbs, responses)] + + @classmethod + def retry_after(cls, response, default): + """Compute next `poll` time based on response ``Retry-After`` header. + + :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: `.messages2.CertificateResource` + + """ + assert authzrs, "Authorizations list is empty" + + # TODO: assert len(authzrs) == number of SANs + req = messages2.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 + self._wrap_in_jws(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 messages2.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 (`.messages2.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 od 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 != messages2.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): + 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) + + def revoke(self, certr, when=messages2.Revocation.NOW): + """Revoke certificate. + + :param when: When should the revocation take place? Takes + the same values as `.messages2.Revocation.revoke`. + + """ + rev = messages2.Revocation(revoke=when, authorizations=tuple( + authzr.uri for authzr in certr.authzrs)) + response = self._post(certr.uri, self._wrap_in_jws(rev)) + if response.status_code != httplib.OK: + raise errors.NetworkError( + 'Successful revocation must return HTTP OK status') diff --git a/letsencrypt/client/plugins/nginx/__init__.py b/letsencrypt/client/plugins/nginx/__init__.py new file mode 100644 index 000000000..63728924f --- /dev/null +++ b/letsencrypt/client/plugins/nginx/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.plugins.nginx.""" diff --git a/letsencrypt/client/plugins/nginx/configurator.py b/letsencrypt/client/plugins/nginx/configurator.py new file mode 100644 index 000000000..88ca820d1 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/configurator.py @@ -0,0 +1,570 @@ +"""Nginx Configuration""" +import logging +import os +import re +import shutil +import socket +import subprocess +import sys + +import zope.interface + +from letsencrypt.acme import challenges + +from letsencrypt.client import achallenges +from letsencrypt.client import errors +from letsencrypt.client import interfaces +from letsencrypt.client import le_util +from letsencrypt.client import reverter + +from letsencrypt.client.plugins.nginx import constants +from letsencrypt.client.plugins.nginx import dvsni +from letsencrypt.client.plugins.nginx import parser + + +class NginxConfigurator(object): + # pylint: disable=too-many-instance-attributes,too-many-public-methods + """Nginx configurator. + + .. todo:: Add proper support for comments in the config. Currently, + config files modified by the configurator will lose all their comments. + + :ivar config: Configuration. + :type config: :class:`~letsencrypt.client.interfaces.IConfig` + + :ivar parser: Handles low level parsing + :type parser: :class:`~letsencrypt.client.plugins.nginx.parser` + + :ivar str save_notes: Human-readable config change notes + + :ivar reverter: saves and reverts checkpoints + :type reverter: :class:`letsencrypt.client.reverter.Reverter` + + :ivar tup version: version of Nginx + + """ + zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller) + zope.interface.classProvides(interfaces.IPluginFactory) + + description = "Nginx Web Server" + + @classmethod + def add_parser_arguments(cls, add): + add("server-root", default=constants.DEFAULT_SERVER_ROOT, + help="Nginx server root directory.") + add("mod-ssl-conf", default=constants.DEFAULT_MOD_SSL_CONF, + help="Contains standard nginx SSL directives.") + add("ctl", default=constants.DEFAULT_CTL, help="Path to the " + "'nginx' binary, used for 'configtest' and retrieving nginx " + "version number.") + + def __init__(self, config, version=None): + """Initialize an Nginx Configurator. + + :param tup version: version of Nginx as a tuple (1, 4, 7) + (used mostly for unittesting) + + """ + self.config = config + + # Verify that all directories and files exist with proper permissions + if os.geteuid() == 0: + self._verify_setup() + + # Files to save + self.save_notes = "" + + # Add number of outstanding challenges + self._chall_out = 0 + + # These will be set in the prepare function + self.parser = None + self.version = version + self._enhance_func = {} # TODO: Support at least redirects + + # Set up reverter + self.reverter = reverter.Reverter(config) + self.reverter.recovery_routine() + + # This is called in determine_authenticator and determine_installer + def prepare(self): + """Prepare the authenticator/installer.""" + self.parser = parser.NginxParser( + self.config.nginx_server_root, + self.config.nginx_mod_ssl_conf) + + # Set Version + if self.version is None: + self.version = self.get_version() + + temp_install(self.config.nginx_mod_ssl_conf) + + # Entry point in main.py for installing cert + def deploy_cert(self, domain, cert, key, cert_chain=None): + # pylint: disable=unused-argument + """Deploys certificate to specified virtual host. + + .. note:: Aborts if the vhost is missing ssl_certificate or + ssl_certificate_key. + + .. note:: Nginx doesn't have a cert chain directive, so the last + parameter is always ignored. It expects the cert file to have + the concatenated chain. + + .. note:: This doesn't save the config files! + + :param str domain: domain to deploy certificate + :param str cert: certificate filename + :param str key: private key filename + :param str cert_chain: certificate chain filename + + """ + vhost = self.choose_vhost(domain) + directives = [['ssl_certificate', cert], ['ssl_certificate_key', key]] + + try: + self.parser.add_server_directives(vhost.filep, vhost.names, + directives, True) + logging.info("Deployed Certificate to VirtualHost %s for %s", + 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") + # Presumably break here so that the virtualhost is not modified + return False + + self.save_notes += ("Changed vhost at %s with addresses of %s\n" % + (vhost.filep, + ", ".join(str(addr) for addr in vhost.addrs))) + self.save_notes += "\tssl_certificate %s\n" % cert + self.save_notes += "\tssl_certificate_key %s\n" % key + + ####################### + # Vhost parsing methods + ####################### + def choose_vhost(self, target_name): + """Chooses a virtual host based on the given domain name. + + .. note:: This makes the vhost SSL-enabled if it isn't already. Follows + Nginx's server block selection rules preferring blocks that are + already SSL. + + .. todo:: This should maybe return list if no obvious answer + is presented. + + .. todo:: The special name "$hostname" corresponds to the machine's + hostname. Currently we just ignore this. + + :param str target_name: domain name + + :returns: ssl vhost associated with name + :rtype: :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + """ + vhost = None + + matches = self._get_ranked_matches(target_name) + if not matches: + # No matches at all :'( + pass + elif matches[0]['rank'] in xrange(2, 6): + # Wildcard match - need to find the longest one + rank = matches[0]['rank'] + wildcards = [x for x in matches if x['rank'] == rank] + vhost = max(wildcards, key=lambda x: len(x['name']))['vhost'] + else: + vhost = matches[0]['vhost'] + + if vhost is not None: + if not vhost.ssl: + self._make_server_ssl(vhost.filep, vhost.names) + + return vhost + + def _get_ranked_matches(self, target_name): + """Returns a ranked list of vhosts that match target_name. + + :param str target_name: The name to match + :returns: list of dicts containing the vhost, the matching name, and + the numerical rank + :rtype: list + + """ + # Nginx chooses a matching server name for a request with precedence: + # 1. exact name match + # 2. longest wildcard name starting with * + # 3. longest wildcard name ending with * + # 4. first matching regex in order of appearance in the file + matches = [] + for vhost in self.parser.get_vhosts(): + name_type, name = parser.get_best_match(target_name, vhost.names) + if name_type == 'exact': + matches.append({'vhost': vhost, + 'name': name, + 'rank': 0 if vhost.ssl else 1}) + elif name_type == 'wildcard_start': + matches.append({'vhost': vhost, + 'name': name, + 'rank': 2 if vhost.ssl else 3}) + elif name_type == 'wildcard_end': + matches.append({'vhost': vhost, + 'name': name, + 'rank': 4 if vhost.ssl else 5}) + elif name_type == 'regex': + matches.append({'vhost': vhost, + 'name': name, + 'rank': 6 if vhost.ssl else 7}) + return sorted(matches, key=lambda x: x['rank']) + + def get_all_names(self): + """Returns all names found in the Nginx Configuration. + + :returns: All ServerNames, ServerAliases, and reverse DNS entries for + virtual host addresses + :rtype: set + + """ + all_names = set() + + # Kept in same function to avoid multiple compilations of the regex + priv_ip_regex = (r"(^127\.0\.0\.1)|(^10\.)|(^172\.1[6-9]\.)|" + r"(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)") + private_ips = re.compile(priv_ip_regex) + hostname_regex = r"^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*[a-z]+$" + hostnames = re.compile(hostname_regex, re.IGNORECASE) + + for vhost in self.parser.get_vhosts(): + all_names.update(vhost.names) + + for addr in vhost.addrs: + host = addr.get_addr() + if hostnames.match(host): + # If it's a hostname, add it to the names. + all_names.add(host) + elif not private_ips.match(host): + # If it isn't a private IP, do a reverse DNS lookup + # TODO: IPv6 support + try: + socket.inet_aton(host) + all_names.add(socket.gethostbyaddr(host)[0]) + except (socket.error, socket.herror, socket.timeout): + continue + + return all_names + + def _make_server_ssl(self, filename, names): + """Makes a server SSL based on server_name and filename by adding + a 'listen 443 ssl' directive to the server block. + + .. todo:: Maybe this should create a new block instead of modifying + the existing one? + + :param str filename: The absolute filename of the config file. + :param set names: The server names of the block to add SSL in + + """ + self.parser.add_server_directives( + filename, names, + [['listen', '443 ssl'], + ['ssl_certificate', '/etc/ssl/certs/ssl-cert-snakeoil.pem'], + ['ssl_certificate_key', '/etc/ssl/private/ssl-cert-snakeoil.key'], + ['include', self.parser.loc["ssl_options"]]]) + + def get_all_certs_keys(self): + """Find all existing keys, certs from configuration. + + :returns: list of tuples with form [(cert, key, path)] + cert - str path to certificate file + key - str path to associated key file + path - File path to configuration file. + :rtype: set + + """ + return self.parser.get_all_certs_keys() + + ################################## + # enhancement methods (IInstaller) + ################################## + def supported_enhancements(self): # pylint: disable=no-self-use + """Returns currently supported enhancements.""" + return [] + + def enhance(self, domain, enhancement, options=None): + """Enhance configuration. + + :param str domain: domain to enhance + :param str enhancement: enhancement type defined in + :const:`~letsencrypt.client.constants.ENHANCEMENTS` + :param options: options for the enhancement + See :const:`~letsencrypt.client.constants.ENHANCEMENTS` + documentation for appropriate parameter. + + """ + try: + return self._enhance_func[enhancement]( + self.choose_vhost(domain), options) + except (KeyError, ValueError): + raise errors.LetsEncryptConfiguratorError( + "Unsupported enhancement: {0}".format(enhancement)) + except errors.LetsEncryptConfiguratorError: + logging.warn("Failed %s for %s", enhancement, domain) + + ###################################### + # Nginx server management (IInstaller) + ###################################### + def restart(self): + """Restarts nginx server. + + :returns: Success + :rtype: bool + + """ + return nginx_restart(self.config.nginx_ctl) + + def config_test(self): # pylint: disable=no-self-use + """Check the configuration of Nginx for errors. + + :returns: Success + :rtype: bool + + """ + try: + proc = subprocess.Popen( + [self.config.nginx_ctl, "-t"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + stdout, stderr = proc.communicate() + except (OSError, ValueError): + logging.fatal("Unable to run nginx config test") + sys.exit(1) + + if proc.returncode != 0: + # Enter recovery routine... + logging.error("Config test failed") + logging.error(stdout) + logging.error(stderr) + return False + + return True + + def _verify_setup(self): + """Verify the setup to ensure safe operating environment. + + Make sure that files/directories are setup with appropriate permissions + Aim for defensive coding... make sure all input files + have permissions of root. + + """ + uid = os.geteuid() + le_util.make_or_verify_dir(self.config.work_dir, 0o755, uid) + le_util.make_or_verify_dir(self.config.backup_dir, 0o755, uid) + le_util.make_or_verify_dir(self.config.config_dir, 0o755, uid) + + def get_version(self): + """Return version of Nginx Server. + + Version is returned as tuple. (ie. 2.4.7 = (2, 4, 7)) + + :returns: version + :rtype: tuple + + :raises errors.LetsEncryptConfiguratorError: + Unable to find Nginx version or version is unsupported + + """ + try: + proc = subprocess.Popen( + [self.config.nginx_ctl, "-V"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + text = proc.communicate()[1] # nginx prints output to stderr + except (OSError, ValueError): + raise errors.LetsEncryptConfiguratorError( + "Unable to run %s -V" % self.config.nginx_ctl) + + version_regex = re.compile(r"nginx/([0-9\.]*)", re.IGNORECASE) + version_matches = version_regex.findall(text) + + sni_regex = re.compile(r"TLS SNI support enabled", re.IGNORECASE) + sni_matches = sni_regex.findall(text) + + ssl_regex = re.compile(r" --with-http_ssl_module") + ssl_matches = ssl_regex.findall(text) + + if not version_matches: + raise errors.LetsEncryptConfiguratorError( + "Unable to find Nginx version") + if not ssl_matches: + raise errors.LetsEncryptConfiguratorError( + "Nginx build is missing SSL module (--with-http_ssl_module).") + if not sni_matches: + raise errors.LetsEncryptConfiguratorError( + "Nginx build doesn't support SNI") + + nginx_version = tuple([int(i) for i in version_matches[0].split(".")]) + + # nginx < 0.8.21 doesn't use default_server + if nginx_version < (0, 8, 21): + raise errors.LetsEncryptConfiguratorError( + "Nginx version must be 0.8.21+") + + return nginx_version + + def more_info(self): + """Human-readable string to help understand the module""" + return ( + "Configures Nginx to authenticate and install HTTPS.{0}" + "Server root: {root}{0}" + "Version: {version}".format( + os.linesep, root=self.parser.loc["root"], + version=".".join(str(i) for i in self.version)) + ) + + ################################################### + # Wrapper functions for Reverter class (IInstaller) + ################################################### + def save(self, title=None, temporary=False): + """Saves all changes to the configuration files. + + :param str title: The title of the save. If a title is given, the + configuration will be saved as a new checkpoint and put in a + timestamped directory. + + :param bool temporary: Indicates whether the changes made will + be quickly reversed in the future (ie. challenges) + + """ + save_files = set(self.parser.parsed.keys()) + + # Create Checkpoint + if temporary: + self.reverter.add_to_temp_checkpoint( + save_files, self.save_notes) + else: + self.reverter.add_to_checkpoint(save_files, + self.save_notes) + + # Change 'ext' to something else to not override existing conf files + self.parser.filedump(ext='') + if title and not temporary: + self.reverter.finalize_checkpoint(title) + + return True + + def recovery_routine(self): + """Revert all previously modified files. + + Reverts all modified files that have not been saved as a checkpoint + + """ + self.reverter.recovery_routine() + self.parser.load() + + def revert_challenge_config(self): + """Used to cleanup challenge configurations.""" + self.reverter.revert_temporary_config() + self.parser.load() + + def rollback_checkpoints(self, rollback=1): + """Rollback saved checkpoints. + + :param int rollback: Number of checkpoints to revert + + """ + self.reverter.rollback_checkpoints(rollback) + self.parser.load() + + def view_config_changes(self): + """Show all of the configuration changes that have taken place.""" + self.reverter.view_config_changes() + + ########################################################################### + # Challenges Section for IAuthenticator + ########################################################################### + def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use + """Return list of challenge preferences.""" + return [challenges.DVSNI] + + # Entry point in main.py for performing challenges + def perform(self, achalls): + """Perform the configuration related challenge. + + This function currently assumes all challenges will be fulfilled. + If this turns out not to be the case in the future. Cleanup and + outstanding challenges will have to be designed better. + + """ + self._chall_out += len(achalls) + responses = [None] * len(achalls) + nginx_dvsni = dvsni.NginxDvsni(self) + + for i, achall in enumerate(achalls): + if isinstance(achall, achallenges.DVSNI): + # Currently also have dvsni hold associated index + # of the challenge. This helps to put all of the responses back + # together when they are all complete. + nginx_dvsni.add_chall(achall, i) + + sni_response = nginx_dvsni.perform() + # Must restart in order to activate the challenges. + # Handled here because we may be able to load up other challenge types + self.restart() + + # Go through all of the challenges and assign them to the proper place + # in the responses return value. All responses must be in the same order + # as the original challenges. + for i, resp in enumerate(sni_response): + responses[nginx_dvsni.indices[i]] = resp + + return responses + + # called after challenges are performed + def cleanup(self, achalls): + """Revert all challenges.""" + self._chall_out -= len(achalls) + + # If all of the challenges have been finished, clean up everything + if self._chall_out <= 0: + self.revert_challenge_config() + self.restart() + + +def nginx_restart(nginx_ctl): + """Restarts the Nginx Server. + + :param str nginx_ctl: Path to the Nginx binary. + + """ + try: + proc = subprocess.Popen([nginx_ctl, "-s", "reload"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + stdout, stderr = proc.communicate() + + if proc.returncode != 0: + # Enter recovery routine... + logging.error("Nginx Restart Failed!") + logging.error(stdout) + logging.error(stderr) + return False + + except (OSError, ValueError): + logging.fatal( + "Nginx Restart Failed - Please Check the Configuration") + sys.exit(1) + + return True + + +def temp_install(options_ssl): + """Temporary install for convenience.""" + # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY + # THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER + # AND TAKEN OUT BEFORE RELEASE, INSTEAD + # SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM. + + # Check to make sure options-ssl.conf is installed + if not os.path.isfile(options_ssl): + shutil.copyfile(constants.MOD_SSL_CONF, options_ssl) diff --git a/letsencrypt/client/plugins/nginx/constants.py b/letsencrypt/client/plugins/nginx/constants.py new file mode 100644 index 000000000..51fff39e1 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/constants.py @@ -0,0 +1,13 @@ +"""nginx plugin constants.""" +import pkg_resources + + +DEFAULT_SERVER_ROOT = "/etc/nginx" +DEFAULT_MOD_SSL_CONF = "/etc/letsencrypt/options-ssl-nginx.conf" +DEFAULT_CTL = "nginx" + + +MOD_SSL_CONF = pkg_resources.resource_filename( + "letsencrypt.client.plugins.nginx", "options-ssl.conf") +"""Path to the Nginx mod_ssl config file found in the Let's Encrypt +distribution.""" diff --git a/letsencrypt/client/plugins/nginx/dvsni.py b/letsencrypt/client/plugins/nginx/dvsni.py new file mode 100644 index 000000000..7233d7c62 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/dvsni.py @@ -0,0 +1,63 @@ +"""NginxDVSNI""" +import logging + +from letsencrypt.client.plugins.apache.dvsni import ApacheDvsni + + +class NginxDvsni(ApacheDvsni): + """Class performs DVSNI challenges within the Nginx configurator. + + .. todo:: This is basically copied-and-pasted from the Apache equivalent. + It doesn't actually work yet. + + :ivar configurator: NginxConfigurator object + :type configurator: :class:`~nginx.configurator.NginxConfigurator` + + :ivar list achalls: Annotated :class:`~letsencrypt.client.achallenges.DVSNI` + challenges. + + :param list indices: Meant to hold indices of challenges in a + 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, + Dvsni Challenges belong in the response array. This is an optional + utility. + + :param str challenge_conf: location of the challenge config file + + """ + + def perform(self): + """Perform a DVSNI challenge on Nginx.""" + if not self.achalls: + return [] + + self.configurator.save() + + addresses = [] + for achall in self.achalls: + vhost = self.configurator.choose_vhost(achall.domain) + if vhost is None: + logging.error( + "No nginx vhost exists with servername or alias of: %s", + achall.domain) + logging.error("No default 443 nginx vhost exists") + logging.error("Please specify servernames in the Nginx config") + return None + else: + addresses.append(list(vhost.addrs)) + + responses = [] + + # Create all of the challenge certs + # for achall in self.achalls: + # responses.append(self._setup_challenge_cert(achall)) + + # Setup the configuration + # self._mod_config(addresses) + + # Save reversible changes + self.configurator.save("SNI Challenge", True) + + return responses diff --git a/letsencrypt/client/plugins/nginx/nginxparser.py b/letsencrypt/client/plugins/nginx/nginxparser.py new file mode 100644 index 000000000..18ba8b0bd --- /dev/null +++ b/letsencrypt/client/plugins/nginx/nginxparser.py @@ -0,0 +1,130 @@ +"""Very low-level nginx config parser based on pyparsing.""" +import string + +from pyparsing import ( + Literal, White, Word, alphanums, CharsNotIn, Forward, Group, + Optional, OneOrMore, ZeroOrMore, pythonStyleComment) + + +class RawNginxParser(object): + # pylint: disable=expression-not-assigned + """A class that parses nginx configuration with pyparsing.""" + + # constants + left_bracket = Literal("{").suppress() + right_bracket = Literal("}").suppress() + semicolon = Literal(";").suppress() + space = White().suppress() + key = Word(alphanums + "_/") + value = CharsNotIn("{};,") + location = CharsNotIn("{};," + string.whitespace) + # modifier for location uri [ = | ~ | ~* | ^~ ] + modifier = Literal("=") | Literal("~*") | Literal("~") | Literal("^~") + + # rules + assignment = (key + Optional(space + value) + semicolon) + block = Forward() + + block << Group( + Group(key + Optional(space + modifier) + Optional(space + location)) + + left_bracket + + Group(ZeroOrMore(Group(assignment) | block)) + + right_bracket) + + script = OneOrMore(Group(assignment) | block).ignore(pythonStyleComment) + + def __init__(self, source): + self.source = source + + def parse(self): + """Returns the parsed tree.""" + return self.script.parseString(self.source) + + def as_list(self): + """Returns the parsed tree as a list.""" + return self.parse().asList() + + +class RawNginxDumper(object): + # pylint: disable=too-few-public-methods + """A class that dumps nginx configuration from the provided tree.""" + def __init__(self, blocks, indentation=4): + self.blocks = blocks + self.indentation = indentation + + def __iter__(self, blocks=None, current_indent=0, spacer=' '): + """Iterates the dumped nginx content.""" + blocks = blocks or self.blocks + for key, values in blocks: + if current_indent: + yield spacer + indentation = spacer * current_indent + if isinstance(key, list): + yield indentation + spacer.join(key) + ' {' + for parameter in values: + if isinstance(parameter[0], list): + dumped = self.__iter__( + [parameter], + current_indent + self.indentation) + for line in dumped: + yield line + else: + dumped = spacer.join(parameter) + ';' + yield spacer * ( + current_indent + self.indentation) + dumped + + yield indentation + '}' + else: + yield spacer * current_indent + key + spacer + values + ';' + + def as_string(self): + """Return the parsed block as a string.""" + return '\n'.join(self) + + +# Shortcut functions to respect Python's serialization interface +# (like pyyaml, picker or json) + +def loads(source): + """Parses from a string. + + :param str souce: The string to parse + :returns: The parsed tree + :rtype: list + + """ + return RawNginxParser(source).as_list() + + +def load(_file): + """Parses from a file. + + :param file _file: The file to parse + :returns: The parsed tree + :rtype: list + + """ + return loads(_file.read()) + + +def dumps(blocks, indentation=4): + """Dump to a string. + + :param list block: The parsed tree + :param int indentation: The number of spaces to indent + :rtype: str + + """ + return RawNginxDumper(blocks, indentation).as_string() + + +def dump(blocks, _file, indentation=4): + """Dump to a file. + + :param list block: The parsed tree + :param file _file: The file to dump to + :param int indentation: The number of spaces to indent + :rtype: NoneType + + """ + return _file.write(dumps(blocks, indentation)) diff --git a/letsencrypt/client/plugins/nginx/obj.py b/letsencrypt/client/plugins/nginx/obj.py new file mode 100644 index 000000000..acaacb3b0 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/obj.py @@ -0,0 +1,125 @@ +"""Module contains classes used by the Nginx Configurator.""" +import re + +from letsencrypt.client.plugins.apache.obj import Addr as ApacheAddr + + +class Addr(ApacheAddr): + """Represents an Nginx address, i.e. what comes after the 'listen' + directive. + + According to http://nginx.org/en/docs/http/ngx_http_core_module.html#listen, + this may be address[:port], port, or unix:path. The latter is ignored here. + + The default value if no directive is specified is *:80 (superuser) or + *:8000 (otherwise). If no port is specified, the default is 80. If no + address is specified, listen on all addresses. + + .. todo:: Old-style nginx configs define SSL vhosts in a separate block + instead of using 'ssl' in the listen directive + + :param str addr: addr part of vhost address, may be hostname, IPv4, IPv6, + "", or "*" + :param str port: port number or "*" or "" + :param bool ssl: Whether the directive includes 'ssl' + :param bool default: Whether the directive includes 'default_server' + + """ + def __init__(self, host, port, ssl, default): + super(Addr, self).__init__((host, port)) + self.ssl = ssl + self.default = default + + @classmethod + def fromstring(cls, str_addr): + """Initialize Addr from string.""" + parts = str_addr.split(' ') + ssl = False + default = False + host = '' + port = '' + + # The first part must be the address + addr = parts.pop(0) + + # Ignore UNIX-domain sockets + if addr.startswith('unix:'): + return None + + tup = addr.partition(':') + if re.match(r'^\d+$', tup[0]): + # This is a bare port, not a hostname. E.g. listen 80 + host = '' + port = tup[0] + else: + # This is a host-port tuple. E.g. listen 127.0.0.1:* + host = tup[0] + port = tup[2] + + # The rest of the parts are options; we only care about ssl and default + while len(parts) > 0: + nextpart = parts.pop() + if nextpart == 'ssl': + ssl = True + elif nextpart == 'default_server': + default = True + + return cls(host, port, ssl, default) + + def __str__(self): + if self.tup[0] and self.tup[1]: + return "%s:%s" % self.tup + elif self.tup[0]: + return self.tup[0] + else: + return self.tup[1] + + def __eq__(self, other): + if isinstance(other, self.__class__): + return (self.tup == other.tup and + self.ssl == other.ssl and + self.default == other.default) + return False + + +class VirtualHost(object): # pylint: disable=too-few-public-methods + """Represents an Nginx Virtualhost. + + :ivar str filep: file path of VH + :ivar set addrs: Virtual Host addresses (:class:`set` of :class:`Addr`) + :ivar set names: Server names/aliases of vhost + (:class:`list` of :class:`str`) + :ivar array raw: The raw form of the parsed server block + + :ivar bool ssl: SSLEngine on in vhost + :ivar bool enabled: Virtual host is enabled + + """ + + def __init__(self, filep, addrs, ssl, enabled, names, raw): + # pylint: disable=too-many-arguments + """Initialize a VH.""" + self.filep = filep + self.addrs = addrs + self.names = names + self.ssl = ssl + self.enabled = enabled + self.raw = raw + + def __str__(self): + addr_str = ", ".join(str(addr) for addr in self.addrs) + return ("file: %s\n" + "addrs: %s\n" + "names: %s\n" + "ssl: %s\n" + "enabled: %s" % (self.filep, addr_str, + self.names, self.ssl, self.enabled)) + + def __eq__(self, other): + if isinstance(other, self.__class__): + return (self.filep == other.filep and + list(self.addrs) == list(other.addrs) and + self.names == other.names and + self.ssl == other.ssl and self.enabled == other.enabled) + + return False diff --git a/letsencrypt/client/plugins/nginx/options-ssl.conf b/letsencrypt/client/plugins/nginx/options-ssl.conf new file mode 100644 index 000000000..f0081c1fc --- /dev/null +++ b/letsencrypt/client/plugins/nginx/options-ssl.conf @@ -0,0 +1,8 @@ +ssl_session_cache shared:SSL:1m; +ssl_session_timeout 1440m; + +ssl_protocols TLSv1 TLSv1.1 TLSv1.2; +ssl_prefer_server_ciphers on; + +# Using list of ciphers from "Bulletproof SSL and TLS" +ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256 ECDHE-ECDSA-AES256-GCM-SHA384 ECDHE-ECDSA-AES128-SHA ECDHE-ECDSA-AES256-SHA ECDHE-ECDSA-AES128-SHA256 ECDHE-ECDSA-AES256-SHA384 ECDHE-RSA-AES128-GCM-SHA256 ECDHE-RSA-AES256-GCM-SHA384 ECDHE-RSA-AES128-SHA ECDHE-RSA-AES128-SHA256 ECDHE-RSA-AES256-SHA384 DHE-RSA-AES128-GCM-SHA256 DHE-RSA-AES256-GCM-SHA384 DHE-RSA-AES128-SHA DHE-RSA-AES256-SHA DHE-RSA-AES128-SHA256 DHE-RSA-AES256-SHA256 EDH-RSA-DES-CBC3-SHA"; diff --git a/letsencrypt/client/plugins/nginx/parser.py b/letsencrypt/client/plugins/nginx/parser.py new file mode 100644 index 000000000..55a0b01e8 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/parser.py @@ -0,0 +1,484 @@ +"""NginxParser is a member object of the NginxConfigurator class.""" +import glob +import logging +import os +import pyparsing +import re + +from letsencrypt.client import errors +from letsencrypt.client.plugins.nginx import obj +from letsencrypt.client.plugins.nginx.nginxparser import dump, load + + +class NginxParser(object): + """Class handles the fine details of parsing the Nginx Configuration. + + :ivar str root: Normalized abosulte path to the server root + directory. Without trailing slash. + :ivar dict parsed: Mapping of file paths to parsed trees + + """ + + def __init__(self, root, ssl_options): + self.parsed = {} + self.root = os.path.abspath(root) + self.loc = self._set_locations(ssl_options) + + # Parse nginx.conf and included files. + # TODO: Check sites-available/ as well. For now, the configurator does + # not enable sites from there. + self.load() + + def load(self): + """Loads Nginx files into a parsed tree. + + """ + self._parse_recursively(self.loc["root"]) + + def _parse_recursively(self, filepath): + """Parses nginx config files recursively by looking at 'include' + directives inside 'http' and 'server' blocks. Note that this only + reads Nginx files that potentially declare a virtual host. + + .. todo:: Can Nginx 'virtual hosts' be defined somewhere other than in + the server context? + + :param str filepath: The path to the files to parse, as a glob + + """ + filepath = self.abs_path(filepath) + trees = self._parse_files(filepath) + for tree in trees: + for entry in tree: + if _is_include_directive(entry): + # Parse the top-level included file + self._parse_recursively(entry[1]) + elif entry[0] == ['http'] or entry[0] == ['server']: + # Look for includes in the top-level 'http'/'server' context + for subentry in entry[1]: + if _is_include_directive(subentry): + self._parse_recursively(subentry[1]) + elif entry[0] == ['http'] and subentry[0] == ['server']: + # Look for includes in a 'server' context within + # an 'http' context + for server_entry in subentry[1]: + if _is_include_directive(server_entry): + self._parse_recursively(server_entry[1]) + + def abs_path(self, path): + """Converts a relative path to an absolute path relative to the root. + Does nothing for paths that are already absolute. + + :param str path: The path + :returns: The absolute path + :rtype: str + + """ + if not os.path.isabs(path): + return os.path.join(self.root, path) + else: + return path + + def get_vhosts(self): + # pylint: disable=cell-var-from-loop + """Gets list of all 'virtual hosts' found in Nginx configuration. + Technically this is a misnomer because Nginx does not have virtual + hosts, it has 'server blocks'. + + :returns: List of + :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` objects + found in configuration + :rtype: list + + """ + enabled = True # We only look at enabled vhosts for now + vhosts = [] + servers = {} + + for filename in self.parsed: + tree = self.parsed[filename] + servers[filename] = [] + srv = servers[filename] # workaround undefined loop var in lambdas + + # Find all the server blocks + _do_for_subarray(tree, lambda x: x[0] == ['server'], + lambda x: srv.append(x[1])) + + # Find 'include' statements in server blocks and append their trees + for i, server in enumerate(servers[filename]): + new_server = self._get_included_directives(server) + servers[filename][i] = new_server + + for filename in servers: + for server in servers[filename]: + # Parse the server block into a VirtualHost object + parsed_server = _parse_server(server) + vhost = obj.VirtualHost(filename, + parsed_server['addrs'], + parsed_server['ssl'], + enabled, + parsed_server['names'], + server) + vhosts.append(vhost) + + return vhosts + + def _get_included_directives(self, block): + """Returns array with the "include" directives expanded out by + concatenating the contents of the included file to the block. + + :param list block: + :rtype: list + + """ + result = list(block) # Copy the list to keep self.parsed idempotent + for directive in block: + if _is_include_directive(directive): + included_files = glob.glob( + self.abs_path(directive[1])) + for incl in included_files: + try: + result.extend(self.parsed[incl]) + except KeyError: + pass + return result + + def _parse_files(self, filepath, override=False): + """Parse files from a glob + + :param str filepath: Nginx config file path + :param bool override: Whether to parse a file that has been parsed + :returns: list of parsed tree structures + :rtype: list + + """ + files = glob.glob(filepath) + trees = [] + for item in files: + if item in self.parsed and not override: + continue + try: + with open(item) as _file: + parsed = load(_file) + self.parsed[item] = parsed + trees.append(parsed) + except IOError: + logging.warn("Could not open file: %s", item) + except pyparsing.ParseException: + logging.warn("Could not parse file: %s", item) + return trees + + def _set_locations(self, ssl_options): + """Set default location for directives. + + Locations are given as file_paths + .. todo:: Make sure that files are included + + """ + root = self._find_config_root() + default = root + + nginx_temp = os.path.join(self.root, "nginx_ports.conf") + if os.path.isfile(nginx_temp): + listen = nginx_temp + name = nginx_temp + else: + listen = default + name = default + + return {"root": root, "default": default, "listen": listen, + "name": name, "ssl_options": ssl_options} + + def _find_config_root(self): + """Find the Nginx Configuration Root file.""" + location = ['nginx.conf'] + + for name in location: + if os.path.isfile(os.path.join(self.root, name)): + return os.path.join(self.root, name) + + raise errors.LetsEncryptNoInstallationError( + "Could not find configuration root") + + def filedump(self, ext='tmp'): + """Dumps parsed configurations into files. + + :param str ext: The file extension to use for the dumped files. If + empty, this overrides the existing conf files. + + """ + for filename in self.parsed: + tree = self.parsed[filename] + if ext: + filename = filename + os.path.extsep + ext + try: + with open(filename, 'w') as _file: + dump(tree, _file) + except IOError: + logging.error("Could not open file for writing: %s", filename) + + def _has_server_names(self, entry, names): + """Checks if a server block has the given set of server_names. This + is the primary way of identifying server blocks in the configurator. + Returns false if 'entry' doesn't look like a server block at all. + + ..todo :: Doesn't match server blocks whose server_name directives are + split across multiple conf files. + + :param list entry: The block to search + :param set names: The names to match + :rtype: bool + + """ + if len(names) == 0: + # Nothing to identify blocks with + return False + + if not isinstance(entry, list): + # Can't be a server block + return False + + new_entry = self._get_included_directives(entry) + server_names = set() + for item in new_entry: + if not isinstance(item, list): + # Can't be a server block + return False + + if item[0] == 'server_name': + server_names.update(_get_servernames(item[1])) + + return server_names == names + + def add_server_directives(self, filename, names, directives, + replace=False): + """Add or replace directives in server blocks whose server_name set + is 'names'. If replace is True, this raises a misconfiguration error + if the directive does not already exist. + + ..todo :: Doesn't match server blocks whose server_name directives are + split across multiple conf files. + + :param str filename: The absolute filename of the config file + :param set names: The server_name to match + :param list directives: The directives to add + :param bool replace: Whether to only replace existing directives + + """ + if replace: + _do_for_subarray(self.parsed[filename], + lambda x: self._has_server_names(x, names), + lambda x: _replace_directives(x, directives)) + else: + _do_for_subarray(self.parsed[filename], + lambda x: self._has_server_names(x, names), + lambda x: x.extend(directives)) + + def get_all_certs_keys(self): + """Gets all certs and keys in the nginx config. + + :returns: list of tuples with form [(cert, key, path)] + cert - str path to certificate file + key - str path to associated key file + path - File path to configuration file. + :rtype: set + + """ + c_k = set() + vhosts = self.get_vhosts() + for vhost in vhosts: + tup = [None, None, vhost.filep] + if vhost.ssl: + for directive in vhost.raw: + if directive[0] == 'ssl_certificate': + tup[0] = directive[1] + elif directive[0] == 'ssl_certificate_key': + tup[1] = directive[1] + if tup[0] is not None and tup[1] is not None: + c_k.add(tuple(tup)) + return c_k + + +def _do_for_subarray(entry, condition, func): + """Executes a function for a subarray of a nested array if it matches + the given condition. + + :param list entry: The list to iterate over + :param function condition: Returns true iff func should be executed on item + :param function func: The function to call for each matching item + + """ + if isinstance(entry, list): + if condition(entry): + func(entry) + else: + for item in entry: + _do_for_subarray(item, condition, func) + + +def get_best_match(target_name, names): + """Finds the best match for target_name out of names using the Nginx + name-matching rules (exact > longest wildcard starting with * > + longest wildcard ending with * > regex). + + :param str target_name: The name to match + :param set names: The candidate server names + :returns: Tuple of (type of match, the name that matched) + :rtype: tuple + + """ + exact = [] + wildcard_start = [] + wildcard_end = [] + regex = [] + + for name in names: + if _exact_match(target_name, name): + exact.append(name) + elif _wildcard_match(target_name, name, True): + wildcard_start.append(name) + elif _wildcard_match(target_name, name, False): + wildcard_end.append(name) + elif _regex_match(target_name, name): + regex.append(name) + + if len(exact) > 0: + # There can be more than one exact match; e.g. eff.org, .eff.org + match = min(exact, key=len) + return ('exact', match) + if len(wildcard_start) > 0: + # Return the longest wildcard + match = max(wildcard_start, key=len) + return ('wildcard_start', match) + if len(wildcard_end) > 0: + # Return the longest wildcard + match = max(wildcard_end, key=len) + return ('wildcard_end', match) + if len(regex) > 0: + # Just return the first one for now + match = regex[0] + return ('regex', match) + + return (None, None) + + +def _exact_match(target_name, name): + return target_name == name or '.' + target_name == name + + +def _wildcard_match(target_name, name, start): + # Degenerate case + if name == '*': + return True + + parts = target_name.split('.') + match_parts = name.split('.') + + # If the domain ends in a wildcard, do the match procedure in reverse + if not start: + parts.reverse() + match_parts.reverse() + + if len(match_parts) == 0: + return False + + # The first part must be a wildcard or blank, e.g. '.eff.org' + first = match_parts.pop(0) + if first != '*' and first != '': + return False + + target_name = '.'.join(parts) + name = '.'.join(match_parts) + + # Ex: www.eff.org matches *.eff.org, eff.org does not match *.eff.org + return target_name.endswith('.' + name) + + +def _regex_match(target_name, name): + # Must start with a tilde + if len(name) < 2 or name[0] != '~': + return False + + # After tilde is a perl-compatible regex + try: + regex = re.compile(name[1:]) + if re.match(regex, target_name): + return True + else: + return False + except re.error: + # perl-compatible regexes are sometimes not recognized by python + return False + + +def _is_include_directive(entry): + """Checks if an nginx parsed entry is an 'include' directive. + + :param list entry: the parsed entry + :returns: Whether it's an 'include' directive + :rtype: bool + + """ + return (isinstance(entry, list) and + entry[0] == 'include' and len(entry) == 2 and + isinstance(entry[1], str)) + + +def _get_servernames(names): + """Turns a server_name string into a list of server names + + :param str names: server names + :rtype: list + + """ + whitespace_re = re.compile(r'\s+') + names = re.sub(whitespace_re, ' ', names) + return names.split(' ') + + +def _parse_server(server): + """Parses a list of server directives. + + :param list server: list of directives in a server block + :rtype: dict + + """ + parsed_server = {} + parsed_server['addrs'] = set() + parsed_server['ssl'] = False + parsed_server['names'] = set() + + for directive in server: + if directive[0] == 'listen': + addr = obj.Addr.fromstring(directive[1]) + parsed_server['addrs'].add(addr) + if not parsed_server['ssl'] and addr.ssl: + parsed_server['ssl'] = True + elif directive[0] == 'server_name': + parsed_server['names'].update( + _get_servernames(directive[1])) + + return parsed_server + + +def _replace_directives(block, directives): + """Replaces directives in a block. If the directive doesn't exist in + the entry already, raises a misconfiguration error. + + ..todo :: Find directives that are in included files. + + :param list block: The block to replace in + :param list directives: The new directives. + """ + for directive in directives: + changed = False + if len(directive) == 0: + continue + for index, line in enumerate(block): + if len(line) > 0 and line[0] == directive[0]: + block[index] = directive + changed = True + if not changed: + raise errors.LetsEncryptMisconfigurationError( + 'LetsEncrypt expected directive for %s in the Nginx config ' + 'but did not find it.' % directive[0]) diff --git a/letsencrypt/client/plugins/nginx/tests/__init__.py b/letsencrypt/client/plugins/nginx/tests/__init__.py new file mode 100644 index 000000000..157a70759 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt Nginx Tests""" diff --git a/letsencrypt/client/plugins/nginx/tests/configurator_test.py b/letsencrypt/client/plugins/nginx/tests/configurator_test.py new file mode 100644 index 000000000..0ac0fd8bc --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/configurator_test.py @@ -0,0 +1,264 @@ +"""Test for letsencrypt.client.plugins.nginx.configurator.""" +import shutil +import unittest + +import mock + +from letsencrypt.acme import challenges + +from letsencrypt.client import achallenges +from letsencrypt.client import errors +from letsencrypt.client import le_util + +from letsencrypt.client.plugins.nginx.tests import util + + +class NginxConfiguratorTest(util.NginxTest): + """Test a semi complex vhost configuration.""" + + def setUp(self): + super(NginxConfiguratorTest, self).setUp() + + self.config = util.get_nginx_configurator( + self.config_path, self.config_dir, self.work_dir, + self.ssl_options) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + shutil.rmtree(self.config_dir) + shutil.rmtree(self.work_dir) + + def test_prepare(self): + self.assertEquals((1, 6, 2), self.config.version) + self.assertEquals(5, len(self.config.parser.parsed)) + + def test_get_all_names(self): + names = self.config.get_all_names() + self.assertEqual(names, set( + ["*.www.foo.com", "somename", "another.alias", + "alias", "localhost", ".example.com", r"~^(www\.)?(example|bar)\.", + "155.225.50.69.nephoscale.net", "*.www.example.com", + "example.*", "www.example.org", "myhost"])) + + def test_supported_enhancements(self): + self.assertEqual([], self.config.supported_enhancements()) + + def test_enhance(self): + self.assertRaises(errors.LetsEncryptConfiguratorError, + self.config.enhance, + 'myhost', + 'redirect') + + def test_get_chall_pref(self): + self.assertEqual([challenges.DVSNI], + self.config.get_chall_pref('myhost')) + + def test_save(self): + filep = self.config.parser.abs_path('sites-enabled/example.com') + self.config.parser.add_server_directives( + filep, set(['.example.com', 'example.*']), + [['listen', '443 ssl']]) + self.config.save() + + # pylint: disable=protected-access + parsed = self.config.parser._parse_files(filep, override=True) + self.assertEqual([[['server'], [['listen', '69.50.225.155:9000'], + ['listen', '127.0.0.1'], + ['server_name', '.example.com'], + ['server_name', 'example.*'], + ['listen', '443 ssl']]]], + parsed[0]) + + def test_choose_vhost(self): + localhost_conf = set(['localhost', r'~^(www\.)?(example|bar)\.']) + server_conf = set(['somename', 'another.alias', 'alias']) + example_conf = set(['.example.com', 'example.*']) + foo_conf = set(['*.www.foo.com', '*.www.example.com']) + + results = {'localhost': localhost_conf, + 'alias': server_conf, + 'example.com': example_conf, + 'example.com.uk.test': example_conf, + 'www.example.com': example_conf, + 'test.www.example.com': foo_conf, + 'abc.www.foo.com': foo_conf, + 'www.bar.co.uk': localhost_conf} + bad_results = ['www.foo.com', 'example', 't.www.bar.co', + '69.255.225.155'] + + for name in results: + self.assertEqual(results[name], + self.config.choose_vhost(name).names) + for name in bad_results: + self.assertEqual(None, self.config.choose_vhost(name)) + + def test_more_info(self): + self.assertTrue('nginx.conf' in self.config.more_info()) + + def test_deploy_cert(self): + server_conf = self.config.parser.abs_path('server.conf') + nginx_conf = self.config.parser.abs_path('nginx.conf') + example_conf = self.config.parser.abs_path('sites-enabled/example.com') + + # Get the default 443 vhost + self.config.deploy_cert( + "www.example.com", + "example/cert.pem", "example/key.pem") + self.config.deploy_cert( + "another.alias", + "/etc/nginx/cert.pem", "/etc/nginx/key.pem") + self.config.save() + + self.config.parser.load() + + self.assertEqual([[['server'], + [['listen', '69.50.225.155:9000'], + ['listen', '127.0.0.1'], + ['server_name', '.example.com'], + ['server_name', 'example.*'], + ['listen', '443 ssl'], + ['ssl_certificate', 'example/cert.pem'], + ['ssl_certificate_key', 'example/key.pem'], + ['include', + self.config.parser.loc["ssl_options"]]]]], + self.config.parser.parsed[example_conf]) + self.assertEqual([['server_name', 'somename alias another.alias']], + self.config.parser.parsed[server_conf]) + self.assertEqual([['server'], + [['listen', '8000'], + ['listen', 'somename:8080'], + ['include', 'server.conf'], + [['location', '/'], + [['root', 'html'], + ['index', 'index.html index.htm']]], + ['listen', '443 ssl'], + ['ssl_certificate', '/etc/nginx/cert.pem'], + ['ssl_certificate_key', '/etc/nginx/key.pem'], + ['include', + self.config.parser.loc["ssl_options"]]]], + self.config.parser.parsed[nginx_conf][-1][-1][-1]) + + def test_get_all_certs_keys(self): + nginx_conf = self.config.parser.abs_path('nginx.conf') + example_conf = self.config.parser.abs_path('sites-enabled/example.com') + + # Get the default 443 vhost + self.config.deploy_cert( + "www.example.com", + "example/cert.pem", "example/key.pem") + self.config.deploy_cert( + "another.alias", + "/etc/nginx/cert.pem", "/etc/nginx/key.pem") + self.config.save() + + self.config.parser.load() + self.assertEqual(set([ + ('example/cert.pem', 'example/key.pem', example_conf), + ('/etc/nginx/cert.pem', '/etc/nginx/key.pem', nginx_conf), + ]), self.config.get_all_certs_keys()) + + @mock.patch("letsencrypt.client.plugins.nginx.configurator." + "dvsni.NginxDvsni.perform") + @mock.patch("letsencrypt.client.plugins.nginx.configurator." + "NginxConfigurator.restart") + def test_perform(self, mock_restart, mock_dvsni_perform): + # Only tests functionality specific to configurator.perform + # Note: As more challenges are offered this will have to be expanded + auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem) + achall1 = achallenges.DVSNI( + chall=challenges.DVSNI( + r="foo", + nonce="bar"), + domain="localhost", key=auth_key) + achall2 = achallenges.DVSNI( + chall=challenges.DVSNI( + r="abc", + nonce="def"), + domain="example.com", key=auth_key) + + dvsni_ret_val = [ + challenges.DVSNIResponse(s="irrelevant"), + challenges.DVSNIResponse(s="arbitrary"), + ] + + mock_dvsni_perform.return_value = dvsni_ret_val + responses = self.config.perform([achall1, achall2]) + + self.assertEqual(mock_dvsni_perform.call_count, 1) + self.assertEqual(responses, dvsni_ret_val) + self.assertEqual(mock_restart.call_count, 1) + + @mock.patch("letsencrypt.client.plugins.nginx.configurator." + "subprocess.Popen") + def test_get_version(self, mock_popen): + mock_popen().communicate.return_value = ( + "", "\n".join(["nginx version: nginx/1.4.2", + "built by clang 6.0 (clang-600.0.56)" + " (based on LLVM 3.5svn)", + "TLS SNI support enabled", + "configure arguments: --prefix=/usr/local/Cellar/" + "nginx/1.6.2 --with-http_ssl_module"])) + self.assertEqual(self.config.get_version(), (1, 4, 2)) + + mock_popen().communicate.return_value = ( + "", "\n".join(["nginx version: nginx/0.9", + "built by clang 6.0 (clang-600.0.56)" + " (based on LLVM 3.5svn)", + "TLS SNI support enabled", + "configure arguments: --with-http_ssl_module"])) + self.assertEqual(self.config.get_version(), (0, 9)) + + mock_popen().communicate.return_value = ( + "", "\n".join(["blah 0.0.1", + "built by clang 6.0 (clang-600.0.56)" + " (based on LLVM 3.5svn)", + "TLS SNI support enabled", + "configure arguments: --with-http_ssl_module"])) + self.assertRaises(errors.LetsEncryptConfiguratorError, + self.config.get_version) + + mock_popen().communicate.return_value = ( + "", "\n".join(["nginx version: nginx/1.4.2", + "TLS SNI support enabled"])) + self.assertRaises(errors.LetsEncryptConfiguratorError, + self.config.get_version) + + mock_popen().communicate.return_value = ( + "", "\n".join(["nginx version: nginx/1.4.2", + "built by clang 6.0 (clang-600.0.56)" + " (based on LLVM 3.5svn)", + "configure arguments: --with-http_ssl_module"])) + self.assertRaises(errors.LetsEncryptConfiguratorError, + self.config.get_version) + + mock_popen().communicate.return_value = ( + "", "\n".join(["nginx version: nginx/0.8.1", + "built by clang 6.0 (clang-600.0.56)" + " (based on LLVM 3.5svn)", + "TLS SNI support enabled", + "configure arguments: --with-http_ssl_module"])) + self.assertRaises(errors.LetsEncryptConfiguratorError, + self.config.get_version) + + mock_popen.side_effect = OSError("Can't find program") + self.assertRaises( + errors.LetsEncryptConfiguratorError, self.config.get_version) + + @mock.patch("letsencrypt.client.plugins.nginx.configurator." + "subprocess.Popen") + def test_nginx_restart(self, mock_popen): + mocked = mock_popen() + mocked.communicate.return_value = ('', '') + mocked.returncode = 0 + self.assertTrue(self.config.restart()) + + @mock.patch("letsencrypt.client.plugins.nginx.configurator." + "subprocess.Popen") + def test_config_test(self, mock_popen): + mocked = mock_popen() + mocked.communicate.return_value = ('', '') + mocked.returncode = 0 + self.assertTrue(self.config.config_test()) + +if __name__ == "__main__": + unittest.main() diff --git a/letsencrypt/client/plugins/nginx/tests/dvsni_test.py b/letsencrypt/client/plugins/nginx/tests/dvsni_test.py new file mode 100644 index 000000000..a6dfac2e2 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/dvsni_test.py @@ -0,0 +1,85 @@ +"""Test for letsencrypt.client.plugins.nginx.dvsni.""" +import pkg_resources +import unittest +import shutil + +import mock + +from letsencrypt.acme import challenges + +from letsencrypt.client import achallenges +from letsencrypt.client import le_util + +from letsencrypt.client.plugins.nginx.tests import util + + +class DvsniPerformTest(util.NginxTest): + """Test the NginxDVSNI challenge.""" + + def setUp(self): + super(DvsniPerformTest, self).setUp() + + config = util.get_nginx_configurator( + self.config_path, self.config_dir, self.work_dir, + self.ssl_options) + + rsa256_file = pkg_resources.resource_filename( + "letsencrypt.client.tests", "testdata/rsa256_key.pem") + rsa256_pem = pkg_resources.resource_string( + "letsencrypt.client.tests", "testdata/rsa256_key.pem") + + auth_key = le_util.Key(rsa256_file, rsa256_pem) + + from letsencrypt.client.plugins.nginx import dvsni + self.sni = dvsni.NginxDvsni(config) + + self.achalls = [ + achallenges.DVSNI( + chall=challenges.DVSNI( + r="foo", + nonce="bar", + ), domain="www.example.com", key=auth_key), + achallenges.DVSNI( + chall=challenges.DVSNI( + r="\xba\xa9\xda? utf8 map: it does not contain +# box-drawing and some other characters. Besides this map contains +# several koi8-u and Byelorussian letters which are not in koi8-r. +# If you need a full and standard map, use contrib/unicode2nginx/koi-utf +# map instead. + +charset_map koi8-r utf-8 { + + 80 E282AC; # euro + + 95 E280A2; # bullet + + 9A C2A0; #   + + 9E C2B7; # · + + A3 D191; # small yo + A4 D194; # small Ukrainian ye + + A6 D196; # small Ukrainian i + A7 D197; # small Ukrainian yi + + AD D291; # small Ukrainian soft g + AE D19E; # small Byelorussian short u + + B0 C2B0; # ° + + B3 D081; # capital YO + B4 D084; # capital Ukrainian YE + + B6 D086; # capital Ukrainian I + B7 D087; # capital Ukrainian YI + + B9 E28496; # numero sign + + BD D290; # capital Ukrainian soft G + BE D18E; # capital Byelorussian short U + + BF C2A9; # (C) + + C0 D18E; # small yu + C1 D0B0; # small a + C2 D0B1; # small b + C3 D186; # small ts + C4 D0B4; # small d + C5 D0B5; # small ye + C6 D184; # small f + C7 D0B3; # small g + C8 D185; # small kh + C9 D0B8; # small i + CA D0B9; # small j + CB D0BA; # small k + CC D0BB; # small l + CD D0BC; # small m + CE D0BD; # small n + CF D0BE; # small o + + D0 D0BF; # small p + D1 D18F; # small ya + D2 D180; # small r + D3 D181; # small s + D4 D182; # small t + D5 D183; # small u + D6 D0B6; # small zh + D7 D0B2; # small v + D8 D18C; # small soft sign + D9 D18B; # small y + DA D0B7; # small z + DB D188; # small sh + DC D18D; # small e + DD D189; # small shch + DE D187; # small ch + DF D18A; # small hard sign + + E0 D0AE; # capital YU + E1 D090; # capital A + E2 D091; # capital B + E3 D0A6; # capital TS + E4 D094; # capital D + E5 D095; # capital YE + E6 D0A4; # capital F + E7 D093; # capital G + E8 D0A5; # capital KH + E9 D098; # capital I + EA D099; # capital J + EB D09A; # capital K + EC D09B; # capital L + ED D09C; # capital M + EE D09D; # capital N + EF D09E; # capital O + + F0 D09F; # capital P + F1 D0AF; # capital YA + F2 D0A0; # capital R + F3 D0A1; # capital S + F4 D0A2; # capital T + F5 D0A3; # capital U + F6 D096; # capital ZH + F7 D092; # capital V + F8 D0AC; # capital soft sign + F9 D0AB; # capital Y + FA D097; # capital Z + FB D0A8; # capital SH + FC D0AD; # capital E + FD D0A9; # capital SHCH + FE D0A7; # capital CH + FF D0AA; # capital hard sign +} diff --git a/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/koi-win b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/koi-win new file mode 100644 index 000000000..c6930fc4f --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/koi-win @@ -0,0 +1,102 @@ +charset_map koi8-r windows-1251 { + + 80 88; # euro + + 95 95; # bullet + + 9A A0; #   + + 9E B7; # · + + A3 B8; # small yo + A4 BA; # small Ukrainian ye + + A6 B3; # small Ukrainian i + A7 BF; # small Ukrainian yi + + AD B4; # small Ukrainian soft g + AE A2; # small Byelorussian short u + + B0 B0; # ° + + B3 A8; # capital YO + B4 AA; # capital Ukrainian YE + + B6 B2; # capital Ukrainian I + B7 AF; # capital Ukrainian YI + + B9 B9; # numero sign + + BD A5; # capital Ukrainian soft G + BE A1; # capital Byelorussian short U + + BF A9; # (C) + + C0 FE; # small yu + C1 E0; # small a + C2 E1; # small b + C3 F6; # small ts + C4 E4; # small d + C5 E5; # small ye + C6 F4; # small f + C7 E3; # small g + C8 F5; # small kh + C9 E8; # small i + CA E9; # small j + CB EA; # small k + CC EB; # small l + CD EC; # small m + CE ED; # small n + CF EE; # small o + + D0 EF; # small p + D1 FF; # small ya + D2 F0; # small r + D3 F1; # small s + D4 F2; # small t + D5 F3; # small u + D6 E6; # small zh + D7 E2; # small v + D8 FC; # small soft sign + D9 FB; # small y + DA E7; # small z + DB F8; # small sh + DC FD; # small e + DD F9; # small shch + DE F7; # small ch + DF FA; # small hard sign + + E0 DE; # capital YU + E1 C0; # capital A + E2 C1; # capital B + E3 D6; # capital TS + E4 C4; # capital D + E5 C5; # capital YE + E6 D4; # capital F + E7 C3; # capital G + E8 D5; # capital KH + E9 C8; # capital I + EA C9; # capital J + EB CA; # capital K + EC CB; # capital L + ED CC; # capital M + EE CD; # capital N + EF CE; # capital O + + F0 CF; # capital P + F1 DF; # capital YA + F2 D0; # capital R + F3 D1; # capital S + F4 D2; # capital T + F5 D3; # capital U + F6 C6; # capital ZH + F7 C2; # capital V + F8 DC; # capital soft sign + F9 DB; # capital Y + FA C7; # capital Z + FB D8; # capital SH + FC DD; # capital E + FD D9; # capital SHCH + FE D7; # capital CH + FF DA; # capital hard sign +} diff --git a/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/mime.types b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/mime.types new file mode 100644 index 000000000..fcce4a58d --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/mime.types @@ -0,0 +1,79 @@ +types { + text/html html htm shtml; + text/css css; + text/xml xml rss; + image/gif gif; + image/jpeg jpeg jpg; + application/x-javascript js; + application/atom+xml atom; + + text/mathml mml; + text/plain txt; + text/vnd.sun.j2me.app-descriptor jad; + text/vnd.wap.wml wml; + text/x-component htc; + + image/png png; + image/tiff tif tiff; + image/vnd.wap.wbmp wbmp; + image/x-icon ico; + image/x-jng jng; + image/x-ms-bmp bmp; + image/svg+xml svg svgz; + + application/java-archive jar war ear; + application/json json; + application/mac-binhex40 hqx; + application/msword doc; + application/pdf pdf; + application/postscript ps eps ai; + application/rtf rtf; + application/vnd.ms-excel xls; + application/vnd.ms-powerpoint ppt; + application/vnd.wap.wmlc wmlc; + application/vnd.google-earth.kml+xml kml; + application/vnd.google-earth.kmz kmz; + application/x-7z-compressed 7z; + application/x-cocoa cco; + application/x-java-archive-diff jardiff; + application/x-java-jnlp-file jnlp; + application/x-makeself run; + application/x-perl pl pm; + application/x-pilot prc pdb; + application/x-rar-compressed rar; + application/x-redhat-package-manager rpm; + application/x-sea sea; + application/x-shockwave-flash swf; + application/x-stuffit sit; + application/x-tcl tcl tk; + application/x-x509-ca-cert der pem crt; + application/x-xpinstall xpi; + application/xhtml+xml xhtml; + application/zip zip; + + application/octet-stream bin exe dll; + application/octet-stream deb; + application/octet-stream dmg; + application/octet-stream eot; + application/octet-stream iso img; + application/octet-stream msi msp msm; + application/ogg ogx; + + audio/midi mid midi kar; + audio/mpeg mpga mpega mp2 mp3 m4a; + audio/ogg oga ogg spx; + audio/x-realaudio ra; + audio/webm weba; + + video/3gpp 3gpp 3gp; + video/mp4 mp4; + video/mpeg mpeg mpg mpe; + video/ogg ogv; + video/quicktime mov; + video/webm webm; + video/x-flv flv; + video/x-mng mng; + video/x-ms-asf asx asf; + video/x-ms-wmv wmv; + video/x-msvideo avi; +} diff --git a/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/naxsi-ui.conf.1.4.1 b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/naxsi-ui.conf.1.4.1 new file mode 100644 index 000000000..f4eb9d49d --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/naxsi-ui.conf.1.4.1 @@ -0,0 +1,16 @@ +[nx_extract] +username = naxsi_web +password = test +port = 8081 +rules_path = /etc/nginx/naxsi_core.rules + +[nx_intercept] +port = 8080 + +[sql] +dbtype = sqlite +username = root +password = +hostname = 127.0.0.1 +dbname = naxsi_sig + diff --git a/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/naxsi.rules b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/naxsi.rules new file mode 100644 index 000000000..fec21ea4f --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/naxsi.rules @@ -0,0 +1,13 @@ +# Sample rules file for default vhost. + +LearningMode; +SecRulesEnabled; +#SecRulesDisabled; +DeniedUrl "/RequestDenied"; + +## check rules +CheckRule "$SQL >= 8" BLOCK; +CheckRule "$RFI >= 8" BLOCK; +CheckRule "$TRAVERSAL >= 4" BLOCK; +CheckRule "$EVADE >= 4" BLOCK; +CheckRule "$XSS >= 8" BLOCK; diff --git a/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/naxsi_core.rules b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/naxsi_core.rules new file mode 100644 index 000000000..c9220209f --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/naxsi_core.rules @@ -0,0 +1,75 @@ +################################## +## INTERNAL RULES IDS:1-10 ## +################################## +#weird_request : 1 +#big_body : 2 +#no_content_type : 3 + +#MainRule "str:yesone" "msg:foobar test pattern" "mz:ARGS" "s:$SQL:42" id:1999; + +################################## +## SQL Injections IDs:1000-1099 ## +################################## +MainRule "rx:select|union|update|delete|insert|table|from|ascii|hex|unhex" "msg:sql keywords" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1000; +MainRule "str:\"" "msg:double quote" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1001; +MainRule "str:0x" "msg:0x, possible hex encoding" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:2" id:1002; +## Hardcore rules +MainRule "str:/*" "msg:mysql comment (/*)" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:8" id:1003; +MainRule "str:*/" "msg:mysql comment (*/)" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:8" id:1004; +MainRule "str:|" "msg:mysql keyword (|)" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:8" id:1005; +MainRule "rx:&&" "msg:mysql keyword (&&)" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:8" id:1006; +## end of hardcore rules +MainRule "str:--" "msg:mysql comment (--)" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1007; +MainRule "str:;" "msg:; in stuff" "mz:BODY|URL|ARGS" "s:$SQL:4" id:1008; +MainRule "str:=" "msg:equal in var, probable sql/xss" "mz:ARGS|BODY" "s:$SQL:2" id:1009; +MainRule "str:(" "msg:parenthesis, probable sql/xss" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1010; +MainRule "str:)" "msg:parenthesis, probable sql/xss" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1011; +MainRule "str:'" "msg:simple quote" "mz:ARGS|BODY|URL|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1013; +MainRule "str:\"" "msg:double quote" "mz:ARGS|BODY|URL|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1014; +MainRule "str:," "msg:, in stuff" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1015; +MainRule "str:#" "msg:mysql comment (#)" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1016; + +############################### +## OBVIOUS RFI IDs:1100-1199 ## +############################### +MainRule "str:http://" "msg:html comment tag" "mz:ARGS|BODY|$HEADERS_VAR:Cookie" "s:$RFI:8" id:1100; +MainRule "str:https://" "msg:html comment tag" "mz:ARGS|BODY|$HEADERS_VAR:Cookie" "s:$RFI:8" id:1101; +MainRule "str:ftp://" "msg:html comment tag" "mz:ARGS|BODY|$HEADERS_VAR:Cookie" "s:$RFI:8" id:1102; +MainRule "str:php://" "msg:html comment tag" "mz:ARGS|BODY|$HEADERS_VAR:Cookie" "s:$RFI:8" id:1103; + +####################################### +## Directory traversal IDs:1200-1299 ## +####################################### +MainRule "str:.." "msg:html comment tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$TRAVERSAL:4" id:1200; +MainRule "str:/etc/passwd" "msg:html comment tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$TRAVERSAL:4" id:1202; +MainRule "str:c:\\" "msg:html comment tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$TRAVERSAL:4" id:1203; +MainRule "str:cmd.exe" "msg:html comment tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$TRAVERSAL:4" id:1204; +MainRule "str:\\" "msg:html comment tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$TRAVERSAL:4" id:1205; +#MainRule "str:/" "msg:slash in args" "mz:ARGS|BODY|$HEADERS_VAR:Cookie" "s:$TRAVERSAL:2" id:1206; +######################################## +## Cross Site Scripting IDs:1300-1399 ## +######################################## +MainRule "str:<" "msg:html open tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1302; +MainRule "str:>" "msg:html close tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1303; +MainRule "str:'" "msg:simple quote" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1306; +MainRule "str:\"" "msg:double quote" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1307; +MainRule "str:(" "msg:parenthesis" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1308; +MainRule "str:)" "msg:parenthesis" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1309; +MainRule "str:[" "msg:html close comment tag" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$XSS:4" id:1310; +MainRule "str:]" "msg:html close comment tag" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$XSS:4" id:1311; +MainRule "str:~" "msg:html close comment tag" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$XSS:4" id:1312; +MainRule "str:;" "msg:semi coma" "mz:ARGS|URL|BODY" "s:$XSS:8" id:1313; +MainRule "str:`" "msg:grave accent !" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1314; +MainRule "rx:%[2|3]." "msg:double encoding !" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1315; + +#################################### +## Evading tricks IDs: 1400-1500 ## +#################################### +MainRule "str:&#" "msg: utf7/8 encoding" "mz:ARGS|BODY|URL|$HEADERS_VAR:Cookie" "s:$EVADE:4" id:1400; +MainRule "str:%U" "msg: M$ encoding" "mz:ARGS|BODY|URL|$HEADERS_VAR:Cookie" "s:$EVADE:4" id:1401; +MainRule negative "rx:multipart/form-data|application/x-www-form-urlencoded" "msg:Content is neither mulipart/x-www-form.." "mz:$HEADERS_VAR:Content-type" "s:$EVADE:4" id:1402; + +############################# +## File uploads: 1500-1600 ## +############################# +MainRule "rx:.ph*|.asp*" "msg:asp/php file upload!" "mz:FILE_EXT" "s:$UPLOAD:8" id:1500; diff --git a/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/nginx.conf b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/nginx.conf new file mode 100644 index 000000000..52219b940 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/nginx.conf @@ -0,0 +1,95 @@ +user www-data; +worker_processes 4; +pid /run/nginx.pid; + +events { + worker_connections 768; + # multi_accept on; +} + +http { + + ## + # Basic Settings + ## + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + # server_tokens off; + + # server_names_hash_bucket_size 64; + # server_name_in_redirect off; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + ## + # Logging Settings + ## + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + ## + # Gzip Settings + ## + + gzip on; + gzip_disable "msie6"; + + # gzip_vary on; + # gzip_proxied any; + # gzip_comp_level 6; + # gzip_buffers 16 8k; + # gzip_http_version 1.1; + # gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript; + + ## + # nginx-naxsi config + ## + # Uncomment it if you installed nginx-naxsi + ## + + #include /etc/nginx/naxsi_core.rules; + + ## + # nginx-passenger config + ## + # Uncomment it if you installed nginx-passenger + ## + + #passenger_root /usr; + #passenger_ruby /usr/bin/ruby; + + ## + # Virtual Host Configs + ## + + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-enabled/*; +} + + +#mail { +# # See sample authentication script at: +# # http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript +# +# # auth_http localhost/auth.php; +# # pop3_capabilities "TOP" "USER"; +# # imap_capabilities "IMAP4rev1" "UIDPLUS"; +# +# server { +# listen localhost:110; +# protocol pop3; +# proxy on; +# } +# +# server { +# listen localhost:143; +# protocol imap; +# proxy on; +# } +#} diff --git a/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/proxy_params b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/proxy_params new file mode 100644 index 000000000..df75bc5d7 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/proxy_params @@ -0,0 +1,4 @@ +proxy_set_header Host $http_host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; diff --git a/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/scgi_params b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/scgi_params new file mode 100644 index 000000000..76e858628 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/scgi_params @@ -0,0 +1,14 @@ +scgi_param REQUEST_METHOD $request_method; +scgi_param REQUEST_URI $request_uri; +scgi_param QUERY_STRING $query_string; +scgi_param CONTENT_TYPE $content_type; + +scgi_param DOCUMENT_URI $document_uri; +scgi_param DOCUMENT_ROOT $document_root; +scgi_param SCGI 1; +scgi_param SERVER_PROTOCOL $server_protocol; + +scgi_param REMOTE_ADDR $remote_addr; +scgi_param REMOTE_PORT $remote_port; +scgi_param SERVER_PORT $server_port; +scgi_param SERVER_NAME $server_name; diff --git a/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/sites-available/default b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/sites-available/default new file mode 100644 index 000000000..5d8f3ac15 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/sites-available/default @@ -0,0 +1,112 @@ +# You may add here your +# server { +# ... +# } +# statements for each of your virtual hosts to this file + +## +# You should look at the following URL's in order to grasp a solid understanding +# of Nginx configuration files in order to fully unleash the power of Nginx. +# http://wiki.nginx.org/Pitfalls +# http://wiki.nginx.org/QuickStart +# http://wiki.nginx.org/Configuration +# +# Generally, you will want to move this file somewhere, and start with a clean +# file but keep this around for reference. Or just disable in sites-enabled. +# +# Please see /usr/share/doc/nginx-doc/examples/ for more detailed examples. +## + +server { + listen 80 default_server; + listen [::]:80 default_server ipv6only=on; + + root /usr/share/nginx/html; + index index.html index.htm; + + # Make site accessible from http://localhost/ + server_name localhost; + + location / { + # First attempt to serve request as file, then + # as directory, then fall back to displaying a 404. + try_files $uri $uri/ =404; + # Uncomment to enable naxsi on this location + # include /etc/nginx/naxsi.rules + } + + # Only for nginx-naxsi used with nginx-naxsi-ui : process denied requests + #location /RequestDenied { + # proxy_pass http://127.0.0.1:8080; + #} + + #error_page 404 /404.html; + + # redirect server error pages to the static page /50x.html + # + #error_page 500 502 503 504 /50x.html; + #location = /50x.html { + # root /usr/share/nginx/html; + #} + + # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 + # + #location ~ \.php$ { + # fastcgi_split_path_info ^(.+\.php)(/.+)$; + # # NOTE: You should have "cgi.fix_pathinfo = 0;" in php.ini + # + # # With php5-cgi alone: + # fastcgi_pass 127.0.0.1:9000; + # # With php5-fpm: + # fastcgi_pass unix:/var/run/php5-fpm.sock; + # fastcgi_index index.php; + # include fastcgi_params; + #} + + # deny access to .htaccess files, if Apache's document root + # concurs with nginx's one + # + #location ~ /\.ht { + # deny all; + #} +} + + +# another virtual host using mix of IP-, name-, and port-based configuration +# +#server { +# listen 8000; +# listen somename:8080; +# server_name somename alias another.alias; +# root html; +# index index.html index.htm; +# +# location / { +# try_files $uri $uri/ =404; +# } +#} + + +# HTTPS server +# +#server { +# listen 443; +# server_name localhost; +# +# root html; +# index index.html index.htm; +# +# ssl on; +# ssl_certificate cert.pem; +# ssl_certificate_key cert.key; +# +# ssl_session_timeout 5m; +# +# ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2; +# ssl_ciphers "HIGH:!aNULL:!MD5 or HIGH:!aNULL:!MD5:!3DES"; +# ssl_prefer_server_ciphers on; +# +# location / { +# try_files $uri $uri/ =404; +# } +#} diff --git a/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/sites-enabled/default b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/sites-enabled/default new file mode 120000 index 000000000..ad35b8342 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/sites-enabled/default @@ -0,0 +1 @@ +/etc/nginx/sites-available/default \ No newline at end of file diff --git a/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/uwsgi_params b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/uwsgi_params new file mode 100644 index 000000000..3f72dbf0e --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/uwsgi_params @@ -0,0 +1,15 @@ +uwsgi_param QUERY_STRING $query_string; +uwsgi_param REQUEST_METHOD $request_method; +uwsgi_param CONTENT_TYPE $content_type; +uwsgi_param CONTENT_LENGTH $content_length; + +uwsgi_param REQUEST_URI $request_uri; +uwsgi_param PATH_INFO $document_uri; +uwsgi_param DOCUMENT_ROOT $document_root; +uwsgi_param SERVER_PROTOCOL $server_protocol; +uwsgi_param UWSGI_SCHEME $scheme; + +uwsgi_param REMOTE_ADDR $remote_addr; +uwsgi_param REMOTE_PORT $remote_port; +uwsgi_param SERVER_PORT $server_port; +uwsgi_param SERVER_NAME $server_name; diff --git a/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/win-utf b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/win-utf new file mode 100644 index 000000000..774fd9fc9 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/win-utf @@ -0,0 +1,125 @@ +# This map is not a full windows-1251 <> utf8 map: it does not +# contain Serbian and Macedonian letters. If you need a full map, +# use contrib/unicode2nginx/win-utf map instead. + +charset_map windows-1251 utf-8 { + + 82 E2809A; # single low-9 quotation mark + + 84 E2809E; # double low-9 quotation mark + 85 E280A6; # ellipsis + 86 E280A0; # dagger + 87 E280A1; # double dagger + 88 E282AC; # euro + 89 E280B0; # per mille + + 91 E28098; # left single quotation mark + 92 E28099; # right single quotation mark + 93 E2809C; # left double quotation mark + 94 E2809D; # right double quotation mark + 95 E280A2; # bullet + 96 E28093; # en dash + 97 E28094; # em dash + + 99 E284A2; # trade mark sign + + A0 C2A0; #   + A1 D18E; # capital Byelorussian short U + A2 D19E; # small Byelorussian short u + + A4 C2A4; # currency sign + A5 D290; # capital Ukrainian soft G + A6 C2A6; # borken bar + A7 C2A7; # section sign + A8 D081; # capital YO + A9 C2A9; # (C) + AA D084; # capital Ukrainian YE + AB C2AB; # left-pointing double angle quotation mark + AC C2AC; # not sign + AD C2AD; # soft hypen + AE C2AE; # (R) + AF D087; # capital Ukrainian YI + + B0 C2B0; # ° + B1 C2B1; # plus-minus sign + B2 D086; # capital Ukrainian I + B3 D196; # small Ukrainian i + B4 D291; # small Ukrainian soft g + B5 C2B5; # micro sign + B6 C2B6; # pilcrow sign + B7 C2B7; # · + B8 D191; # small yo + B9 E28496; # numero sign + BA D194; # small Ukrainian ye + BB C2BB; # right-pointing double angle quotation mark + + BF D197; # small Ukrainian yi + + C0 D090; # capital A + C1 D091; # capital B + C2 D092; # capital V + C3 D093; # capital G + C4 D094; # capital D + C5 D095; # capital YE + C6 D096; # capital ZH + C7 D097; # capital Z + C8 D098; # capital I + C9 D099; # capital J + CA D09A; # capital K + CB D09B; # capital L + CC D09C; # capital M + CD D09D; # capital N + CE D09E; # capital O + CF D09F; # capital P + + D0 D0A0; # capital R + D1 D0A1; # capital S + D2 D0A2; # capital T + D3 D0A3; # capital U + D4 D0A4; # capital F + D5 D0A5; # capital KH + D6 D0A6; # capital TS + D7 D0A7; # capital CH + D8 D0A8; # capital SH + D9 D0A9; # capital SHCH + DA D0AA; # capital hard sign + DB D0AB; # capital Y + DC D0AC; # capital soft sign + DD D0AD; # capital E + DE D0AE; # capital YU + DF D0AF; # capital YA + + E0 D0B0; # small a + E1 D0B1; # small b + E2 D0B2; # small v + E3 D0B3; # small g + E4 D0B4; # small d + E5 D0B5; # small ye + E6 D0B6; # small zh + E7 D0B7; # small z + E8 D0B8; # small i + E9 D0B9; # small j + EA D0BA; # small k + EB D0BB; # small l + EC D0BC; # small m + ED D0BD; # small n + EE D0BE; # small o + EF D0BF; # small p + + F0 D180; # small r + F1 D181; # small s + F2 D182; # small t + F3 D183; # small u + F4 D184; # small f + F5 D185; # small kh + F6 D186; # small ts + F7 D187; # small ch + F8 D188; # small sh + F9 D189; # small shch + FA D18A; # small hard sign + FB D18B; # small y + FC D18C; # small soft sign + FD D18D; # small e + FE D18E; # small yu + FF D18F; # small ya +} diff --git a/letsencrypt/client/plugins/nginx/tests/util.py b/letsencrypt/client/plugins/nginx/tests/util.py new file mode 100644 index 000000000..8acfa8ff6 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/util.py @@ -0,0 +1,76 @@ +"""Common utilities for letsencrypt.client.nginx.""" +import os +import pkg_resources +import shutil +import tempfile +import unittest + +import mock + +from letsencrypt.client.plugins.nginx import constants +from letsencrypt.client.plugins.nginx import configurator + + +class NginxTest(unittest.TestCase): # pylint: disable=too-few-public-methods + + def setUp(self): + super(NginxTest, self).setUp() + + self.temp_dir, self.config_dir, self.work_dir = dir_setup( + "testdata") + + self.ssl_options = setup_nginx_ssl_options(self.config_dir) + + self.config_path = os.path.join( + self.temp_dir, "testdata") + + self.rsa256_file = pkg_resources.resource_filename( + "letsencrypt.client.tests", "testdata/rsa256_key.pem") + self.rsa256_pem = pkg_resources.resource_string( + "letsencrypt.client.tests", "testdata/rsa256_key.pem") + + +def get_data_filename(filename): + """Gets the filename of a test data file.""" + return pkg_resources.resource_filename( + "letsencrypt.client.plugins.nginx.tests", "testdata/%s" % filename) + + +def dir_setup(test_dir="debian_nginx/two_vhost_80"): + """Setup the directories necessary for the configurator.""" + temp_dir = tempfile.mkdtemp("temp") + config_dir = tempfile.mkdtemp("config") + work_dir = tempfile.mkdtemp("work") + + test_configs = pkg_resources.resource_filename( + "letsencrypt.client.plugins.nginx.tests", test_dir) + + shutil.copytree( + test_configs, os.path.join(temp_dir, test_dir), symlinks=True) + + return temp_dir, config_dir, work_dir + + +def setup_nginx_ssl_options(config_dir): + """Move the ssl_options into position and return the path.""" + option_path = os.path.join(config_dir, "options-ssl.conf") + shutil.copyfile(constants.MOD_SSL_CONF, option_path) + return option_path + + +def get_nginx_configurator( + config_path, config_dir, work_dir, ssl_options, version=(1, 6, 2)): + """Create an Nginx Configurator with the specified options.""" + + backups = os.path.join(work_dir, "backups") + + config = configurator.NginxConfigurator( + mock.MagicMock( + nginx_server_root=config_path, nginx_mod_ssl_conf=ssl_options, + le_vhost_ext="-le-ssl.conf", backup_dir=backups, + config_dir=config_dir, work_dir=work_dir, + temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"), + in_progress_dir=os.path.join(backups, "IN_PROGRESS")), + version) + config.prepare() + return config diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index aba839f8c..5a2e2b16f 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -8,8 +8,10 @@ from letsencrypt.acme import challenges from letsencrypt.acme import jose -KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - "letsencrypt.client.tests", os.path.join("testdata", "rsa256_key.pem"))) +KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + "letsencrypt.client.tests", + os.path.join("testdata", "rsa256_key.pem")))) # Challenges SIMPLE_HTTPS = challenges.SimpleHTTPS( @@ -27,40 +29,40 @@ POP = challenges.ProofOfPossession( alg="RS256", nonce="xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ", hints=challenges.ProofOfPossession.Hints( jwk=jose.JWKRSA(key=KEY.publickey()), - cert_fingerprints=[ + cert_fingerprints=( "93416768eb85e33adc4277f4c9acd63e7418fcfe", "16d95b7b63f1972b980b14c20291f3c0d1855d95", "48b46570d9fc6358108af43ad1649484def0debf" - ], - certs=[], # TODO - subject_key_identifiers=["d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"], - serial_numbers=[34234239832, 23993939911, 17], - issuers=[ + ), + certs=(), # TODO + subject_key_identifiers=("d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"), + serial_numbers=(34234239832, 23993939911, 17), + issuers=( "C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA", "O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure", - ], - authorized_for=["www.example.com", "example.net"], + ), + authorized_for=("www.example.com", "example.net"), ) ) CHALLENGES = [SIMPLE_HTTPS, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP] DV_CHALLENGES = [chall for chall in CHALLENGES if isinstance(chall, challenges.DVChallenge)] -CLIENT_CHALLENGES = [chall for chall in CHALLENGES - if isinstance(chall, challenges.ClientChallenge)] +CONT_CHALLENGES = [chall for chall in CHALLENGES + if isinstance(chall, challenges.ContinuityChallenge)] def gen_combos(challs): """Generate natural combinations for challs.""" dv_chall = [] - renewal_chall = [] + cont_chall = [] for i, chall in enumerate(challs): # pylint: disable=redefined-outer-name if isinstance(chall, challenges.DVChallenge): dv_chall.append(i) else: - renewal_chall.append(i) + cont_chall.append(i) - # Gen combos for 1 of each type - return [[i, j] for i in xrange(len(dv_chall)) - for j in xrange(len(renewal_chall))] + # Gen combos for 1 of each type, lowest index first (makes testing easier) + return tuple((i, j) if i < j else (j, i) + for i in dv_chall for j in cont_chall) diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index 478d4c0ac..b9508709d 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -30,17 +30,17 @@ class SatisfyChallengesTest(unittest.TestCase): from letsencrypt.client.auth_handler import AuthHandler self.mock_dv_auth = mock.MagicMock(name="ApacheConfigurator") - self.mock_client_auth = mock.MagicMock(name="ClientAuthenticator") + self.mock_cont_auth = mock.MagicMock(name="ContinuityAuthenticator") self.mock_dv_auth.get_chall_pref.return_value = [challenges.DVSNI] - self.mock_client_auth.get_chall_pref.return_value = [ + self.mock_cont_auth.get_chall_pref.return_value = [ challenges.RecoveryToken] - self.mock_client_auth.perform.side_effect = gen_auth_resp + self.mock_cont_auth.perform.side_effect = gen_auth_resp self.mock_dv_auth.perform.side_effect = gen_auth_resp self.handler = AuthHandler( - self.mock_dv_auth, self.mock_client_auth, None) + self.mock_dv_auth, self.mock_cont_auth, None) logging.disable(logging.CRITICAL) @@ -61,9 +61,9 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual("DVSNI0", self.handler.responses[dom][0]) self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.cont_c), 1) self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 0) + self.assertEqual(len(self.handler.cont_c[dom]), 0) def test_name1_rectok1(self): dom = "0" @@ -78,16 +78,16 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual(len(self.handler.responses[dom]), 1) # Test if statement for dv_auth perform - self.assertEqual(self.mock_client_auth.perform.call_count, 1) + self.assertEqual(self.mock_cont_auth.perform.call_count, 1) self.assertEqual(self.mock_dv_auth.perform.call_count, 0) self.assertEqual("RecoveryToken0", self.handler.responses[dom][0]) # Assert 1 domain self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.cont_c), 1) # Assert 1 auth challenge, 0 dv self.assertEqual(len(self.handler.dv_c[dom]), 0) - self.assertEqual(len(self.handler.client_c[dom]), 1) + self.assertEqual(len(self.handler.cont_c[dom]), 1) def test_name5_dvsni5(self): for i in xrange(5): @@ -102,11 +102,11 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual(len(self.handler.responses), 5) self.assertEqual(len(self.handler.dv_c), 5) - self.assertEqual(len(self.handler.client_c), 5) + self.assertEqual(len(self.handler.cont_c), 5) # Each message contains 1 auth, 0 client # Test proper call count for methods - self.assertEqual(self.mock_client_auth.perform.call_count, 0) + self.assertEqual(self.mock_cont_auth.perform.call_count, 0) self.assertEqual(self.mock_dv_auth.perform.call_count, 1) for i in xrange(5): @@ -114,7 +114,7 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual(len(self.handler.responses[dom]), 1) self.assertEqual(self.handler.responses[dom][0], "DVSNI%d" % i) self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 0) + self.assertEqual(len(self.handler.cont_c[dom]), 0) self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, achallenges.DVSNI)) @@ -138,10 +138,10 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual(len(self.handler.responses[dom]), len(acme_util.DV_CHALLENGES)) self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.cont_c), 1) - # Test if statement for client_auth perform - self.assertEqual(self.mock_client_auth.perform.call_count, 0) + # Test if statement for cont_auth perform + self.assertEqual(self.mock_cont_auth.perform.call_count, 0) self.assertEqual(self.mock_dv_auth.perform.call_count, 1) self.assertEqual( @@ -149,7 +149,7 @@ class SatisfyChallengesTest(unittest.TestCase): self._get_exp_response(dom, path, acme_util.DV_CHALLENGES)) self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 0) + self.assertEqual(len(self.handler.cont_c[dom]), 0) self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, achallenges.SimpleHTTPS)) @@ -175,16 +175,16 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual( len(self.handler.responses[dom]), len(acme_util.CHALLENGES)) self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.cont_c), 1) self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 1) + self.assertEqual(len(self.handler.cont_c[dom]), 1) self.assertEqual( self.handler.responses[dom], self._get_exp_response(dom, path, acme_util.CHALLENGES)) self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, achallenges.SimpleHTTPS)) - self.assertTrue(isinstance(self.handler.client_c[dom][0].achall, + self.assertTrue(isinstance(self.handler.cont_c[dom][0].achall, achallenges.RecoveryToken)) @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") @@ -209,7 +209,7 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual( len(self.handler.responses[str(i)]), len(acme_util.CHALLENGES)) self.assertEqual(len(self.handler.dv_c), 5) - self.assertEqual(len(self.handler.client_c), 5) + self.assertEqual(len(self.handler.cont_c), 5) for i in xrange(5): dom = str(i) @@ -217,11 +217,11 @@ class SatisfyChallengesTest(unittest.TestCase): self.handler.responses[dom], self._get_exp_response(dom, path, acme_util.CHALLENGES)) self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 1) + self.assertEqual(len(self.handler.cont_c[dom]), 1) self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, achallenges.DVSNI)) - self.assertTrue(isinstance(self.handler.client_c[dom][0].achall, + self.assertTrue(isinstance(self.handler.cont_c[dom][0].achall, achallenges.RecoveryContact)) @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") @@ -255,7 +255,7 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual(len(self.handler.responses), 5) self.assertEqual(len(self.handler.dv_c), 5) - self.assertEqual(len(self.handler.client_c), 5) + self.assertEqual(len(self.handler.cont_c), 5) for i in xrange(5): dom = str(i) @@ -263,7 +263,7 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertEqual(self.handler.responses[dom], resp) self.assertEqual(len(self.handler.dv_c[dom]), 1) self.assertEqual( - len(self.handler.client_c[dom]), len(chosen_chall[i]) - 1) + len(self.handler.cont_c[dom]), len(chosen_chall[i]) - 1) self.assertTrue(isinstance( self.handler.dv_c["0"][0].achall, achallenges.DNS)) @@ -276,10 +276,10 @@ class SatisfyChallengesTest(unittest.TestCase): self.assertTrue(isinstance( self.handler.dv_c["4"][0].achall, achallenges.DNS)) - self.assertTrue(isinstance(self.handler.client_c["2"][0].achall, + self.assertTrue(isinstance(self.handler.cont_c["2"][0].achall, achallenges.ProofOfPossession)) self.assertTrue(isinstance( - self.handler.client_c["4"][0].achall, achallenges.RecoveryToken)) + self.handler.cont_c["4"][0].achall, achallenges.RecoveryToken)) @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") def test_perform_exception_cleanup(self, mock_chall_path): @@ -309,11 +309,11 @@ class SatisfyChallengesTest(unittest.TestCase): # Verify cleanup is actually run correctly self.assertEqual(self.mock_dv_auth.cleanup.call_count, 2) - self.assertEqual(self.mock_client_auth.cleanup.call_count, 2) + self.assertEqual(self.mock_cont_auth.cleanup.call_count, 2) dv_cleanup_args = self.mock_dv_auth.cleanup.call_args_list - client_cleanup_args = self.mock_client_auth.cleanup.call_args_list + cont_cleanup_args = self.mock_cont_auth.cleanup.call_args_list # Check DV cleanup for i in xrange(2): @@ -325,10 +325,10 @@ class SatisfyChallengesTest(unittest.TestCase): # Check Auth cleanup for i in xrange(2): - client_chall_list = client_cleanup_args[i][0][0] - self.assertEqual(len(client_chall_list), 1) + cont_chall_list = cont_cleanup_args[i][0][0] + self.assertEqual(len(cont_chall_list), 1) self.assertTrue( - isinstance(client_chall_list[0], achallenges.ProofOfPossession)) + isinstance(cont_chall_list[0], achallenges.ProofOfPossession)) def _get_exp_response(self, domain, path, challs): @@ -346,7 +346,7 @@ class GetAuthorizationsTest(unittest.TestCase): from letsencrypt.client.auth_handler import AuthHandler self.mock_dv_auth = mock.MagicMock(name="ApacheConfigurator") - self.mock_client_auth = mock.MagicMock(name="ClientAuthenticator") + self.mock_cont_auth = mock.MagicMock(name="ContinuityAuthenticator") self.mock_sat_chall = mock.MagicMock(name="_satisfy_challenges") self.mock_acme_auth = mock.MagicMock(name="acme_authorization") @@ -354,7 +354,7 @@ class GetAuthorizationsTest(unittest.TestCase): self.iteration = 0 self.handler = AuthHandler( - self.mock_dv_auth, self.mock_client_auth, None) + self.mock_dv_auth, self.mock_cont_auth, None) self.handler._satisfy_challenges = self.mock_sat_chall self.handler.acme_authorization = self.mock_acme_auth @@ -388,7 +388,7 @@ class GetAuthorizationsTest(unittest.TestCase): # Assignment was > 80 char... dv_c, c_c = self.handler._challenge_factory(dom, [0]) - self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c + self.handler.dv_c[dom], self.handler.cont_c[dom] = dv_c, c_c def test_progress_failure(self): self.handler.add_chall_msg( @@ -414,7 +414,7 @@ class GetAuthorizationsTest(unittest.TestCase): self.handler.msgs[dom].challenges) dv_c, c_c = self.handler._challenge_factory( dom, self.handler.paths[dom]) - self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c + self.handler.dv_c[dom], self.handler.cont_c[dom] = dv_c, c_c def test_incremental_progress(self): for dom, challs in [("0", acme_util.CHALLENGES), @@ -444,9 +444,9 @@ class GetAuthorizationsTest(unittest.TestCase): self.handler.paths["1"] = [2] # This is probably overkill... but set it anyway dv_c, c_c = self.handler._challenge_factory("0", [1, 3]) - self.handler.dv_c["0"], self.handler.client_c["0"] = dv_c, c_c + self.handler.dv_c["0"], self.handler.cont_c["0"] = dv_c, c_c dv_c, c_c = self.handler._challenge_factory("1", [2]) - self.handler.dv_c["1"], self.handler.client_c["1"] = dv_c, c_c + self.handler.dv_c["1"], self.handler.cont_c["1"] = dv_c, c_c self.iteration += 1 @@ -513,6 +513,77 @@ class PathSatisfiedTest(unittest.TestCase): self.assertFalse(self.handler._path_satisfied(dom[i])) +class GenChallengePathTest(unittest.TestCase): + """Tests for letsencrypt.client.auth_handler.gen_challenge_path. + + .. todo:: Add more tests for dumb_path... depending on what we want to do. + + """ + def setUp(self): + logging.disable(logging.fatal) + + def tearDown(self): + logging.disable(logging.NOTSET) + + @classmethod + def _call(cls, challs, preferences, combinations): + from letsencrypt.client.auth_handler import gen_challenge_path + return gen_challenge_path(challs, preferences, combinations) + + def test_common_case(self): + """Given DVSNI and SimpleHTTPS with appropriate combos.""" + challs = (acme_util.DVSNI, acme_util.SIMPLE_HTTPS) + prefs = [challenges.DVSNI] + combos = ((0,), (1,)) + + # Smart then trivial dumb path test + self.assertEqual(self._call(challs, prefs, combos), (0,)) + self.assertTrue(self._call(challs, prefs, None)) + # Rearrange order... + self.assertEqual(self._call(challs[::-1], prefs, combos), (1,)) + self.assertTrue(self._call(challs[::-1], prefs, None)) + + def test_common_case_with_continuity(self): + challs = (acme_util.RECOVERY_TOKEN, + acme_util.RECOVERY_CONTACT, + acme_util.DVSNI, + acme_util.SIMPLE_HTTPS) + prefs = [challenges.RecoveryToken, challenges.DVSNI] + combos = acme_util.gen_combos(challs) + self.assertEqual(self._call(challs, prefs, combos), (0, 2)) + + # dumb_path() trivial test + self.assertTrue(self._call(challs, prefs, None)) + + def test_full_cont_server(self): + challs = (acme_util.RECOVERY_TOKEN, + acme_util.RECOVERY_CONTACT, + acme_util.POP, + acme_util.DVSNI, + acme_util.SIMPLE_HTTPS, + acme_util.DNS) + # Typical webserver client that can do everything except DNS + # Attempted to make the order realistic + prefs = [challenges.RecoveryToken, + challenges.ProofOfPossession, + challenges.SimpleHTTPS, + challenges.DVSNI, + challenges.RecoveryContact] + combos = acme_util.gen_combos(challs) + self.assertEqual(self._call(challs, prefs, combos), (0, 4)) + + # Dumb path trivial test + self.assertTrue(self._call(challs, prefs, None)) + + def test_not_supported(self): + challs = (acme_util.POP, acme_util.DVSNI) + prefs = [challenges.DVSNI] + combos = ((0, 1),) + + self.assertRaises(errors.LetsEncryptAuthHandlerError, + self._call, challs, prefs, combos) + + class MutuallyExclusiveTest(unittest.TestCase): """Tests for letsencrypt.client.auth_handler.mutually_exclusive.""" diff --git a/letsencrypt/client/tests/client_authenticator_test.py b/letsencrypt/client/tests/continuity_auth_test.py similarity index 84% rename from letsencrypt/client/tests/client_authenticator_test.py rename to letsencrypt/client/tests/continuity_auth_test.py index 7db1956d5..c1f4a229c 100644 --- a/letsencrypt/client/tests/client_authenticator_test.py +++ b/letsencrypt/client/tests/continuity_auth_test.py @@ -1,4 +1,4 @@ -"""Test the ClientAuthenticator dispatcher.""" +"""Test the ContinuityAuthenticator dispatcher.""" import unittest import mock @@ -13,9 +13,9 @@ class PerformTest(unittest.TestCase): """Test client perform function.""" def setUp(self): - from letsencrypt.client.client_authenticator import ClientAuthenticator + from letsencrypt.client.continuity_auth import ContinuityAuthenticator - self.auth = ClientAuthenticator( + self.auth = ContinuityAuthenticator( mock.MagicMock(server="demo_server.org")) self.auth.rec_token.perform = mock.MagicMock( name="rec_token_perform", side_effect=gen_client_resp) @@ -38,7 +38,7 @@ class PerformTest(unittest.TestCase): def test_unexpected(self): self.assertRaises( - errors.LetsEncryptClientAuthError, self.auth.perform, [ + errors.LetsEncryptContAuthError, self.auth.perform, [ achallenges.DVSNI(chall=None, domain="0", key="invalid_key")]) def test_chall_pref(self): @@ -50,9 +50,9 @@ class CleanupTest(unittest.TestCase): """Test the Authenticator cleanup function.""" def setUp(self): - from letsencrypt.client.client_authenticator import ClientAuthenticator + from letsencrypt.client.continuity_auth import ContinuityAuthenticator - self.auth = ClientAuthenticator( + self.auth = ContinuityAuthenticator( mock.MagicMock(server="demo_server.org")) self.mock_cleanup = mock.MagicMock(name="rec_token_cleanup") self.auth.rec_token.cleanup = self.mock_cleanup @@ -70,7 +70,7 @@ class CleanupTest(unittest.TestCase): token = achallenges.RecoveryToken(chall=None, domain="0") unexpected = achallenges.DVSNI(chall=None, domain="0", key="dummy_key") - self.assertRaises(errors.LetsEncryptClientAuthError, + self.assertRaises(errors.LetsEncryptContAuthError, self.auth.cleanup, [token, unexpected]) diff --git a/letsencrypt/client/tests/network2_test.py b/letsencrypt/client/tests/network2_test.py new file mode 100644 index 000000000..d42a0b87c --- /dev/null +++ b/letsencrypt/client/tests/network2_test.py @@ -0,0 +1,458 @@ +"""Tests for letsencrypt.client.network2.""" +import datetime +import httplib +import os +import pkg_resources +import unittest + +import M2Crypto +import mock +import requests + +from letsencrypt.client import errors + +from letsencrypt.acme import challenges +from letsencrypt.acme import jose +from letsencrypt.acme import messages2 + + +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( + __name__, os.path.join('testdata/rsa512_key.pem'))) +KEY2 = jose.JWKRSA.load(pkg_resources.resource_string( + __name__, os.path.join('testdata/rsa256_key.pem'))) + + +class NetworkTest(unittest.TestCase): + """Tests for letsencrypt.client.network2.Network.""" + + # pylint: disable=too-many-instance-attributes,too-many-public-methods + + def setUp(self): + from letsencrypt.client.network2 import Network + self.net = Network( + new_reg_uri='https://www.letsencrypt-demo.org/acme/new-reg', + key=KEY, alg=jose.RS256) + self.response = mock.MagicMock(ok=True, status_code=httplib.OK) + self.response.headers = {} + self.response.links = {} + + self.identifier = messages2.Identifier( + typ=messages2.IDENTIFIER_FQDN, value='example.com') + + # Registration + self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212') + reg = messages2.Registration( + contact=self.contact, key=KEY.public(), recovery_token='t') + self.regr = messages2.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, + chall=challenges.DNS(token='foo')) + self.challr = messages2.ChallengeResource( + body=challb, authzr_uri=authzr_uri) + self.authz = messages2.Authorization( + identifier=messages2.Identifier( + typ=messages2.IDENTIFIER_FQDN, value='example.com'), + challenges=(challb,), combinations=None, key=KEY.public()) + self.authzr = messages2.AuthorizationResource( + body=self.authz, uri=authzr_uri, + new_cert_uri='https://www.letsencrypt-demo.org/acme/new-cert') + + # Request issuance + self.certr = messages2.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 = mock.MagicMock(return_value=self.response) + self.net._get = mock.MagicMock(return_value=self.response) + + 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): + return cls(value) + # pylint: disable=protected-access + jws = self.net._wrap_in_jws(MockJSONDeSerializable('foo')) + self.assertEqual(jose.JWS.json_loads(jws).payload, '"foo"') + + 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 = messages2.Error(detail='foo') + # pylint: disable=protected-access + self.assertRaises( + messages2.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.client.network2.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.client.network2.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') + + @mock.patch('letsencrypt.client.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') + + @mock.patch('letsencrypt.client.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.net._check_response.assert_called_once_with( + requests_mock.post('uri', 'data'), content_type='ct') + + 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_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_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.regr) + # TODO: test POST call arguments + + # TODO: split here and separate test + authz_wrong_key = self.authz.update(key=KEY2.public()) + self.response.json.return_value = authz_wrong_key.to_json() + self.assertRaises( + errors.UnexpectedUpdate, self.net.request_challenges, + self.identifier, self.regr) + + 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_answer_challenges(self): + self.net.answer_challenge = mock.MagicMock() + self.assertEqual( + [self.net.answer_challenge( + self.challr.body, challenges.DNSResponse())], + self.net.answer_challenges( + [self.challr.body], [challenges.DNSResponse()])) + + 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.client.network2.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.client.network2.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.client.network2.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)) + + 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.client.network2.datetime') + @mock.patch('letsencrypt.client.network2.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 = messages2.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.assertEqual(self.net._get_cert(self.certr.cert_chain_uri), + 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=messages2.Revocation.NOW) + # pylint: disable=protected-access + self.net._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() diff --git a/setup.py b/setup.py index 6e0ce0aa6..d74e2ebbe 100644 --- a/setup.py +++ b/setup.py @@ -11,6 +11,7 @@ from setuptools import setup if os.path.abspath(__file__).split(os.path.sep)[1] == 'vagrant': del os.link + def read_file(filename, encoding='utf8'): """Read unicode from given file.""" with codecs.open(filename, encoding=encoding) as fd: @@ -36,9 +37,13 @@ install_requires = [ 'pyasn1', # urllib3 InsecurePlatformWarning (#304) 'pycrypto', 'PyOpenSSL', + 'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary? + 'pyrfc3339', 'python-augeas', - 'python2-pythondialog', + 'python2-pythondialog>=3.2.2rc1', # Debian squeeze support, cf. #280 + 'pytz', 'requests', + 'werkzeug', 'zope.component', 'zope.interface', # order of items in install_requires DOES matter and M2Crypto has @@ -100,6 +105,8 @@ setup( 'letsencrypt.client.plugins', 'letsencrypt.client.plugins.apache', 'letsencrypt.client.plugins.apache.tests', + 'letsencrypt.client.plugins.nginx', + 'letsencrypt.client.plugins.nginx.tests', 'letsencrypt.client.plugins.standalone', 'letsencrypt.client.plugins.standalone.tests', 'letsencrypt.client.tests', @@ -124,6 +131,8 @@ setup( 'letsencrypt.plugins': [ 'apache = letsencrypt.client.plugins.apache.configurator' ':ApacheConfigurator', + 'nginx = letsencrypt.client.plugins.nginx.configurator' + ':NginxConfigurator', 'standalone = letsencrypt.client.plugins.standalone.authenticator' ':StandaloneAuthenticator', ], diff --git a/tox.ini b/tox.ini index bb5ac1bb7..fe9da1865 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ setenv = basepython = python2.7 commands = pip install -e .[testing] - python setup.py nosetests --with-coverage --cover-min-percentage=86 + python setup.py nosetests --with-coverage --cover-min-percentage=87 [testenv:lint] # recent versions of pylint do not support Python 2.6 (#97, #187)