diff --git a/.gitignore b/.gitignore index f2cec0721..51164db97 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,13 @@ *.pyc *.egg-info +.eggs/ build/ dist/ venv/ .tox/ .coverage m3 +*~ +.vagrant *.swp +\#*# diff --git a/.pylintrc b/.pylintrc index fe4d471ac..4835dbf74 100644 --- a/.pylintrc +++ b/.pylintrc @@ -38,7 +38,8 @@ load-plugins=linter_plugin # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=fixme,locally-disabled +disable=fixme,locally-disabled,abstract-class-not-used +# abstract-class-not-used cannot be disabled locally (at least in pylint 1.4.1) [REPORTS] @@ -148,10 +149,10 @@ module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ # Regular expression matching correct method names -method-rgx=[a-z_][a-z0-9_]{2,40}$ +method-rgx=[a-z_][a-z0-9_]{2,50}$ # Naming hint for method names -method-name-hint=[a-z_][a-z0-9_]{2,40}$ +method-name-hint=[a-z_][a-z0-9_]{2,50}$ # Regular expression which should only match function or class names that do # not require a docstring. @@ -311,7 +312,7 @@ max-branches=12 max-statements=50 # Maximum number of parents for a class (see R0901). -max-parents=7 +max-parents=12 # Maximum number of attributes for a class (see R0902). max-attributes=7 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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..bf19b18e1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,18 @@ + + +https://letsencrypt.readthedocs.org/en/latest/contributing.html diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst deleted file mode 100644 index 9cb73a654..000000000 --- a/CONTRIBUTING.rst +++ /dev/null @@ -1,72 +0,0 @@ -.. _hacking: - -Hacking -======= - -In order to start hacking, you will first have to create a development -environment: - -:: - - ./venv/bin/python setup.py dev - -The code base, including your pull requests, **must** have 100% test statement -coverage **and** be compliant with the :ref:`coding-style`. - -The following tools are there to help you: - -- ``./venv/bin/tox`` starts a full set of tests. Please make sure you - run it before submitting a new pull request. - -- ``./venv/bin/tox -e cover`` checks the test coverage only. - -- ``./venv/bin/tox -e lint`` checks the style of the whole project, - while ``./venv/bin/pylint --rcfile=.pylintrc file`` will check a single `file` only. - - -.. _coding-style: - -Coding style -============ - -Please: - -1. **Be consistent with the rest of the code**. - -2. Read `PEP 8 - Style Guide for Python Code`_. - -3. Follow the `Google Python Style Guide`_, with the exception that we - use `Sphinx-style`_ documentation: - - :: - - def foo(arg): - """Short description. - - :param int arg: Some number. - - :returns: Argument - :rtype: int - - """ - return arg - -4. Remember to use ``./venv/bin/pylint``. - -.. _Google Python Style Guide: https://google-styleguide.googlecode.com/svn/trunk/pyguide.html -.. _Sphinx-style: http://sphinx-doc.org/ -.. _PEP 8 - Style Guide for Python Code: https://www.python.org/dev/peps/pep-0008 - - -Updating the Documentation -========================== - -In order to generate the Sphinx documentation, run the following commands. - -:: - - cd docs - make clean html SPHINXBUILD=../venv/bin/sphinx-build - - -This should generate documentation in the ``docs/_build/html`` directory. 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/MANIFEST.in b/MANIFEST.in index bea6fd9bb..3bd657b87 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,6 @@ include README.rst include CHANGES.rst -include CONTRIBUTING.rst +include CONTRIBUTING.md include linter_plugin.py include letsencrypt/EULA recursive-include letsencrypt *.json diff --git a/README.rst b/README.rst index 712eb3b22..fac36dbd7 100644 --- a/README.rst +++ b/README.rst @@ -57,6 +57,7 @@ Current Features * web servers supported: - apache2.x (tested and working on Ubuntu Linux) + - standalone (runs its own webserver to prove you control the domain) * the private key is generated locally on your system * can talk to the Let's Encrypt (demo) CA or optionally to other ACME @@ -79,6 +80,8 @@ Documentation: https://letsencrypt.readthedocs.org/ Software project: https://github.com/letsencrypt/lets-encrypt-preview +Notes for developers: CONTRIBUTING.md_ + Main Website: https://letsencrypt.org/ IRC Channel: #letsencrypt on `Freenode`_ @@ -88,3 +91,4 @@ email to client-dev+subscribe@letsencrypt.org) .. _Freenode: https://freenode.net .. _client-dev: https://groups.google.com/a/letsencrypt.org/forum/#!forum/client-dev +.. _CONTRIBUTING.md: https://github.com/letsencrypt/lets-encrypt-preview/blob/master/CONTRIBUTING.md diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 000000000..b4a06ea05 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,30 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# Vagrantfile API/syntax version. Don't touch unless you know what you're doing! +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.rst b/docs/api/acme/index.rst similarity index 80% rename from docs/api/acme.rst rename to docs/api/acme/index.rst index 04c33917a..20206183a 100644 --- a/docs/api/acme.rst +++ b/docs/api/acme/index.rst @@ -1,22 +1,27 @@ :mod:`letsencrypt.acme` ======================= +.. contents:: + .. automodule:: letsencrypt.acme :members: -Interfaces ----------- - -.. automodule:: letsencrypt.acme.interfaces - :members: - Messages -------- +v00 +~~~ + .. automodule:: letsencrypt.acme.messages :members: +v02 +~~~ + +.. automodule:: letsencrypt.acme.messages2 + :members: + Challenges ---------- @@ -27,10 +32,18 @@ Challenges Other ACME objects ------------------ + .. automodule:: letsencrypt.acme.other :members: +Fields +------ + +.. automodule:: letsencrypt.acme.fields + :members: + + Errors ------ @@ -46,6 +59,3 @@ Utilities .. automodule:: letsencrypt.acme.util :members: - -.. automodule:: letsencrypt.acme.jose - :members: diff --git a/docs/api/acme/jose.rst b/docs/api/acme/jose.rst new file mode 100644 index 000000000..9a64d33d3 --- /dev/null +++ b/docs/api/acme/jose.rst @@ -0,0 +1,67 @@ +:mod:`letsencrypt.acme.jose` +============================ + +.. contents:: + +.. automodule:: letsencrypt.acme.jose + :members: + + +JSON Web Algorithms +------------------- + +.. automodule:: letsencrypt.acme.jose.jwa + :members: + + +JSON Web Key +------------ + +.. automodule:: letsencrypt.acme.jose.jwk + :members: + + +JSON Web Signature +------------------ + +.. automodule:: letsencrypt.acme.jose.jws + :members: + + +Implementation details +---------------------- + + +Interfaces +~~~~~~~~~~ + +.. automodule:: letsencrypt.acme.jose.interfaces + :members: + + +Errors +~~~~~~ + +.. automodule:: letsencrypt.acme.jose.errors + :members: + + +JSON utilities +~~~~~~~~~~~~~~ + +.. automodule:: letsencrypt.acme.jose.json_util + :members: + + +JOSE Base64 +~~~~~~~~~~~ + +.. automodule:: letsencrypt.acme.jose.b64 + :members: + + +Utilities +~~~~~~~~~ + +.. automodule:: letsencrypt.acme.jose.util + :members: diff --git a/docs/api/client/account.rst b/docs/api/client/account.rst new file mode 100644 index 000000000..6fad87556 --- /dev/null +++ b/docs/api/client/account.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.account` +--------------------------------- + +.. automodule:: letsencrypt.client.account + :members: diff --git a/docs/api/client/apache.rst b/docs/api/client/apache.rst deleted file mode 100644 index e69826cf9..000000000 --- a/docs/api/client/apache.rst +++ /dev/null @@ -1,29 +0,0 @@ -:mod:`letsencrypt.client.apache` --------------------------------- - -.. automodule:: letsencrypt.client.apache - :members: - -:mod:`letsencrypt.client.apache.configurator` -============================================= - -.. automodule:: letsencrypt.client.apache.configurator - :members: - -:mod:`letsencrypt.client.apache.dvsni` -============================================= - -.. automodule:: letsencrypt.client.apache.dvsni - :members: - -:mod:`letsencrypt.client.apache.obj` -==================================== - -.. automodule:: letsencrypt.client.apache.obj - :members: - -:mod:`letsencrypt.client.apache.parser` -======================================= - -.. automodule:: letsencrypt.client.apache.parser - :members: 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/api/client/plugins/apache.rst b/docs/api/client/plugins/apache.rst new file mode 100644 index 000000000..6e6e6c462 --- /dev/null +++ b/docs/api/client/plugins/apache.rst @@ -0,0 +1,29 @@ +:mod:`letsencrypt.client.plugins.apache` +---------------------------------------- + +.. automodule:: letsencrypt.client.plugins.apache + :members: + +:mod:`letsencrypt.client.plugins.apache.configurator` +===================================================== + +.. automodule:: letsencrypt.client.plugins.apache.configurator + :members: + +:mod:`letsencrypt.client.plugins.apache.dvsni` +============================================== + +.. automodule:: letsencrypt.client.plugins.apache.dvsni + :members: + +:mod:`letsencrypt.client.plugins.apache.obj` +============================================ + +.. automodule:: letsencrypt.client.plugins.apache.obj + :members: + +:mod:`letsencrypt.client.plugins.apache.parser` +=============================================== + +.. automodule:: letsencrypt.client.plugins.apache.parser + :members: diff --git a/docs/api/client/plugins/nginx.rst b/docs/api/client/plugins/nginx.rst new file mode 100644 index 000000000..cd64846bf --- /dev/null +++ b/docs/api/client/plugins/nginx.rst @@ -0,0 +1,35 @@ +:mod:`letsencrypt.client.plugins.nginx` +---------------------------------------- + +.. automodule:: letsencrypt.client.plugins.nginx + :members: + +:mod:`letsencrypt.client.plugins.nginx.configurator` +===================================================== + +.. automodule:: letsencrypt.client.plugins.nginx.configurator + :members: + +:mod:`letsencrypt.client.plugins.nginx.dvsni` +============================================== + +.. automodule:: letsencrypt.client.plugins.nginx.dvsni + :members: + +:mod:`letsencrypt.client.plugins.nginx.obj` +============================================ + +.. automodule:: letsencrypt.client.plugins.nginx.obj + :members: + +:mod:`letsencrypt.client.plugins.nginx.parser` +=============================================== + +.. automodule:: letsencrypt.client.plugins.nginx.parser + :members: + +:mod:`letsencrypt.client.plugins.nginx.nginxparser` +==================================================== + +.. automodule:: letsencrypt.client.plugins.nginx.nginxparser + :members: diff --git a/docs/api/client/plugins/standalone.rst b/docs/api/client/plugins/standalone.rst new file mode 100644 index 000000000..44cf4b8ca --- /dev/null +++ b/docs/api/client/plugins/standalone.rst @@ -0,0 +1,11 @@ +:mod:`letsencrypt.client.plugins.standalone` +-------------------------------------------- + +.. automodule:: letsencrypt.client.plugins.standalone + :members: + +:mod:`letsencrypt.client.plugins.standalone.authenticator` +========================================================== + +.. automodule:: letsencrypt.client.plugins.standalone.authenticator + :members: diff --git a/docs/api/client/standalone_authenticator.rst b/docs/api/client/standalone_authenticator.rst deleted file mode 100644 index d05f4f057..000000000 --- a/docs/api/client/standalone_authenticator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.standalone_authenticator` --------------------------------------------------- - -.. automodule:: letsencrypt.client.standalone_authenticator - :members: diff --git a/docs/conf.py b/docs/conf.py index 2a29b9dd3..a6e5da4ff 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,7 +55,7 @@ extensions = [ ] autodoc_member_order = 'bysource' -autodoc_default_flags = ['show-inheritance'] +autodoc_default_flags = ['show-inheritance', 'private-members'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -101,7 +101,7 @@ exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +default_role = 'py:obj' # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 000000000..0ed022724 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,203 @@ +============ +Contributing +============ + +.. _hacking: + +Hacking +======= + +In order to start hacking, you will first have to create a development +environment. Start by :doc:`installing dependencies and setting up +Let's Encrypt `. + +Now you can install the development packages: + +.. code-block:: shell + + ./venv/bin/python setup.py dev + +The code base, including your pull requests, **must** have 100% test +statement coverage **and** be compliant with the :ref:`coding style +`. + +The following tools are there to help you: + +- ``./venv/bin/tox`` starts a full set of tests. Please make sure you + run it before submitting a new pull request. + +- ``./venv/bin/tox -e cover`` checks the test coverage only. + +- ``./venv/bin/tox -e lint`` checks the style of the whole project, + while ``./venv/bin/pylint --rcfile=.pylintrc file`` will check a + single ``file`` only. + +.. _installing dependencies and setting up Let's Encrypt: + https://letsencrypt.readthedocs.org/en/latest/using.html + + +Vagrant +------- + +If you are a Vagrant user, Let's Encrypt comes with a Vagrantfile that +automates setting up a development environment in an Ubuntu 14.04 +LTS VM. To set it up, simply run ``vagrant up``. The repository is +synced to ``/vagrant``, so you can get started with: + +.. code-block:: shell + + vagrant ssh + cd /vagrant + ./venv/bin/python setup.py install + sudo ./venv/bin/letsencrypt + +Support for other Linux distributions coming soon. + +.. note:: + Unfortunately, Python distutils and, by extension, setup.py and + tox, use hard linking quite extensively. Hard linking is not + supported by the default sync filesystem in Vagrant. As a result, + all actions with these commands are *significantly slower* in + Vagrant. One potential fix is to `use NFS`_ (`related issue`_). + +.. _use NFS: http://docs.vagrantup.com/v2/synced-folders/nfs.html +.. _related issue: https://github.com/ClusterHQ/flocker/issues/516 + + +Code components and layout +========================== + +letsencrypt/acme + contains all protocol specific code +letsencrypt/client + all client code +letsencrypt/scripts + just the starting point of the code, main.py + + +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 +`~letsencrypt.client.interfaces.IInstaller` interfaces (though some +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 +-------------- + +Authenticators are plugins designed to solve challenges received from +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 continuity specific +challenges (subclasses of `~.ContinuityChallenge`, +i.e. `~.challenges.RecoveryToken`, `~.challenges.RecoveryContact`, +`~.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` +in a separate branch). + + +Installer +--------- + +Installers classes exist to actually setup the certificate and be able +to enhance the configuration. (Turn on HSTS, redirect to HTTPS, +etc). You can indicate your abilities through the +:meth:`~.IInstaller.supported_enhancements` call. We currently only +have one Installer written (still developing), `~.ApacheConfigurator`. + +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. (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. Display +plugins implement the `~letsencrypt.client.interfaces.IDisplay` +interface. + + +.. _coding-style: + +Coding style +============ + +Please: + +1. **Be consistent with the rest of the code**. + +2. Read `PEP 8 - Style Guide for Python Code`_. + +3. Follow the `Google Python Style Guide`_, with the exception that we + use `Sphinx-style`_ documentation:: + + def foo(arg): + """Short description. + + :param int arg: Some number. + + :returns: Argument + :rtype: int + + """ + return arg + +4. Remember to use ``./venv/bin/pylint``. + +.. _Google Python Style Guide: + https://google-styleguide.googlecode.com/svn/trunk/pyguide.html +.. _Sphinx-style: http://sphinx-doc.org/ +.. _PEP 8 - Style Guide for Python Code: + https://www.python.org/dev/peps/pep-0008 + + +Updating the documentation +========================== + +In order to generate the Sphinx documentation, run the following +commands: + +.. code-block:: shell + + cd docs + make clean html SPHINXBUILD=../venv/bin/sphinx-build + +This should generate documentation in the ``docs/_build/html`` +directory. diff --git a/docs/index.rst b/docs/index.rst index b290b2231..72be096f9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,8 @@ Welcome to the Let's Encrypt client documentation! intro using - project + contributing + plugins .. toctree:: :maxdepth: 1 diff --git a/docs/plugins.rst b/docs/plugins.rst new file mode 100644 index 000000000..0451bfe3f --- /dev/null +++ b/docs/plugins.rst @@ -0,0 +1,19 @@ +======= +Plugins +======= + +Let's Encrypt client supports dynamic discovery of plugins through the +`setuptools entry points`_. This way you can, for example, create a +custom implementation of +`~letsencrypt.client.interfaces.IAuthenticator` or the +'~letsencrypt.client.interfaces.IInstaller' without having to +merge it with the core upstream source code. An example is provided in +``examples/plugins/`` directory. + +Please be aware though that as this client is still in a developer-preview +stage, the API may undergo a few changes. If you believe the plugin will be +beneficial to the community, please consider submitting a pull request to the +repo and we will update it with any necessary API changes. + +.. _`setuptools entry points`: + https://pythonhosted.org/setuptools/setuptools.html#dynamic-discovery-of-services-and-plugins diff --git a/docs/project.rst b/docs/project.rst deleted file mode 100644 index 421f0b062..000000000 --- a/docs/project.rst +++ /dev/null @@ -1,5 +0,0 @@ -================================ -The Let's Encrypt Client Project -================================ - -.. include:: ../CONTRIBUTING.rst diff --git a/docs/using.rst b/docs/using.rst index 5f49f844e..387652154 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 Quick Usage @@ -50,11 +60,11 @@ And follow the instructions. Your new cert will be available in `certs/` Installation ============ -:: +.. code-block:: shell - virtualenv --no-site-packages -p python2 venv - ./venv/bin/python setup.py install - sudo ./venv/bin/letsencrypt + virtualenv --no-site-packages -p python2 venv + ./venv/bin/python setup.py install + sudo ./venv/bin/letsencrypt Usage @@ -62,7 +72,7 @@ Usage The letsencrypt commandline tool has a builtin help: -:: +.. code-block:: shell ./venv/bin/letsencrypt --help diff --git a/examples/plugins/letsencrypt_example_plugins.py b/examples/plugins/letsencrypt_example_plugins.py new file mode 100644 index 000000000..987a2b33b --- /dev/null +++ b/examples/plugins/letsencrypt_example_plugins.py @@ -0,0 +1,18 @@ +"""Example Let's Encrypt plugins.""" +import zope.interface + +from letsencrypt.client import interfaces + + +class Authenticator(object): + zope.interface.implements(interfaces.IAuthenticator) + + description = 'Example Authenticator plugin' + + def __init__(self, config): + self.config = config + + # Implement all methods from IAuthenticator, remembering to add + # "self" as first argument, e.g. def prepare(self)... + + # For full examples, see letsencrypt.client.plugins diff --git a/examples/plugins/setup.py b/examples/plugins/setup.py new file mode 100644 index 000000000..845d6eb66 --- /dev/null +++ b/examples/plugins/setup.py @@ -0,0 +1,16 @@ +from setuptools import setup + + +setup( + name='letsencrypt-example-plugins', + package='letsencrypt_example_plugins.py', + install_requires=[ + 'letsencrypt', + 'zope.interface', + ], + entry_points={ + 'letsencrypt.authenticators': [ + 'example = letsencrypt_example_plugins:Authenticator', + ], + }, +) 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.py b/letsencrypt.py deleted file mode 120000 index 77b93ee70..000000000 --- a/letsencrypt.py +++ /dev/null @@ -1 +0,0 @@ -letsencrypt/scripts/main.py \ No newline at end of file diff --git a/letsencrypt/acme/__init__.py b/letsencrypt/acme/__init__.py index 95744bbd5..c38cea414 100644 --- a/letsencrypt/acme/__init__.py +++ b/letsencrypt/acme/__init__.py @@ -1,22 +1,12 @@ """ACME protocol implementation. -.. warning:: This module is an implementation of the draft `ACME - protocol version 00`_, and not the latest (as of time of writing), - "RESTified" `ACME protocol version 01`_. It should work with the - server from the `Node.js implementation`_, but will not work with - Boulder_. - +This module is an implementation of the `ACME protocol`_. Latest +supported version: `v02`_. .. _`ACME protocol`: https://github.com/letsencrypt/acme-spec -.. _`ACME protocol version 00`: - https://github.com/letsencrypt/acme-spec/blob/v00/draft-barnes-acme.md +.. _`v02`: + https://github.com/letsencrypt/acme-spec/commit/d328fea2d507deb9822793c512830d827a4150c4 -.. _`ACME protocol version 01`: - https://github.com/letsencrypt/acme-spec/blob/v01/draft-barnes-acme.md - -.. _Boulder: https://github.com/letsencrypt/boulder - -.. _`Node.js implementation`: https://github.com/letsencrypt/node-acme """ diff --git a/letsencrypt/acme/challenges.py b/letsencrypt/acme/challenges.py index 4bbeb4cd2..9c0f263c7 100644 --- a/letsencrypt/acme/challenges.py +++ b/letsencrypt/acme/challenges.py @@ -7,19 +7,18 @@ import Crypto.Random from letsencrypt.acme import jose from letsencrypt.acme import other -from letsencrypt.acme import util # pylint: disable=too-few-public-methods -class Challenge(util.TypedACMEObject): - # _fields_to_json | pylint: disable=abstract-method +class Challenge(jose.TypedJSONObjectWithFields): + # _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,40 +26,33 @@ class DVChallenge(Challenge): # pylint: disable=abstract-method """Domain validation challenges.""" -class ChallengeResponse(util.TypedACMEObject): - # _fields_to_json | pylint: disable=abstract-method +class ChallengeResponse(jose.TypedJSONObjectWithFields): + # _fields_to_partial_json | pylint: disable=abstract-method """ACME challenge response.""" TYPES = {} @classmethod - def from_valid_json(cls, jobj): + def from_json(cls, jobj): if jobj is None: # if the client chooses not to respond to a given # challenge, then the corresponding entry in the response # array is set to None (null) return None - return super(ChallengeResponse, cls).from_valid_json(jobj) + return super(ChallengeResponse, cls).from_json(jobj) @Challenge.register class SimpleHTTPS(DVChallenge): """ACME "simpleHttps" challenge.""" - acme_type = "simpleHttps" - __slots__ = ("token",) - - def _fields_to_json(self): - return {"token": self.token} - - @classmethod - def from_valid_json(cls, jobj): - return cls(token=jobj["token"]) + typ = "simpleHttps" + token = jose.Field("token") @ChallengeResponse.register class SimpleHTTPSResponse(ChallengeResponse): """ACME "simpleHttps" challenge response.""" - acme_type = "simpleHttps" - __slots__ = ("path",) + typ = "simpleHttps" + path = jose.Field("path") URI_TEMPLATE = "https://{domain}/.well-known/acme-challenge/{path}" """URI template for HTTPS server provisioned resource.""" @@ -76,13 +68,6 @@ class SimpleHTTPSResponse(ChallengeResponse): """ return self.URI_TEMPLATE.format(domain=domain, path=self.path) - def _fields_to_json(self): - return {"path": self.path} - - @classmethod - def from_valid_json(cls, jobj): - return cls(path=jobj["path"]) - @Challenge.register class DVSNI(DVChallenge): @@ -92,8 +77,7 @@ class DVSNI(DVChallenge): :ivar str nonce: Random data, **not** hex-encoded. """ - acme_type = "dvsni" - __slots__ = ("r", "nonce") + typ = "dvsni" DOMAIN_SUFFIX = ".acme.invalid" """Domain name suffix.""" @@ -104,22 +88,20 @@ class DVSNI(DVChallenge): NONCE_SIZE = 16 """Required size of the :attr:`nonce` in bytes.""" + PORT = 443 + """Port to perform DVSNI challenge.""" + + r = jose.Field("r", encoder=jose.b64encode, # pylint: disable=invalid-name + decoder=functools.partial(jose.decode_b64jose, size=R_SIZE)) + nonce = jose.Field("nonce", encoder=binascii.hexlify, + decoder=functools.partial(functools.partial( + jose.decode_hex16, size=NONCE_SIZE))) + @property def nonce_domain(self): """Domain name used in SNI.""" return binascii.hexlify(self.nonce) + self.DOMAIN_SUFFIX - def _fields_to_json(self): - return { - "r": jose.b64encode(self.r), - "nonce": binascii.hexlify(self.nonce), - } - - @classmethod - def from_valid_json(cls, jobj): - return cls(r=util.decode_b64jose(jobj["r"], cls.R_SIZE), - nonce=util.decode_hex16(jobj["nonce"], cls.NONCE_SIZE)) - @ChallengeResponse.register class DVSNIResponse(ChallengeResponse): @@ -128,8 +110,7 @@ class DVSNIResponse(ChallengeResponse): :param str s: Random data, **not** base64-encoded. """ - acme_type = "dvsni" - __slots__ = ("s",) + typ = "dvsni" DOMAIN_SUFFIX = DVSNI.DOMAIN_SUFFIX """Domain name suffix.""" @@ -137,6 +118,9 @@ class DVSNIResponse(ChallengeResponse): S_SIZE = 32 """Required size of the :attr:`s` in bytes.""" + s = jose.Field("s", encoder=jose.b64encode, # pylint: disable=invalid-name + decoder=functools.partial(jose.decode_b64jose, size=S_SIZE)) + def __init__(self, s=None, *args, **kwargs): s = Crypto.Random.get_random_bytes(self.S_SIZE) if s is None else s super(DVSNIResponse, self).__init__(s=s, *args, **kwargs) @@ -157,151 +141,79 @@ class DVSNIResponse(ChallengeResponse): """Domain name for certificate subjectAltName.""" return self.z(chall) + self.DOMAIN_SUFFIX - def _fields_to_json(self): - return {"s": jose.b64encode(self.s)} - - @classmethod - def from_valid_json(cls, jobj): - return cls(s=util.decode_b64jose(jobj["s"], cls.S_SIZE)) - - @Challenge.register -class RecoveryContact(ClientChallenge): +class RecoveryContact(ContinuityChallenge): """ACME "recoveryContact" challenge.""" - acme_type = "recoveryContact" - __slots__ = ("activation_url", "success_url", "contact") + typ = "recoveryContact" - def _fields_to_json(self): - fields = {} - add = functools.partial(_extend_if_not_none, fields) - add(self.activation_url, "activationURL") - add(self.success_url, "successURL") - add(self.contact, "contact") - return fields - - @classmethod - def from_valid_json(cls, jobj): - return cls(activation_url=jobj.get("activationURL"), - success_url=jobj.get("successURL"), - contact=jobj.get("contact")) + activation_url = jose.Field("activationURL", omitempty=True) + success_url = jose.Field("successURL", omitempty=True) + contact = jose.Field("contact", omitempty=True) @ChallengeResponse.register class RecoveryContactResponse(ChallengeResponse): """ACME "recoveryContact" challenge response.""" - acme_type = "recoveryContact" - __slots__ = ("token",) - - def _fields_to_json(self): - fields = {} - if self.token is not None: - fields["token"] = self.token - return fields - - @classmethod - def from_valid_json(cls, jobj): - return cls(token=jobj.get("token")) + typ = "recoveryContact" + token = jose.Field("token", omitempty=True) @Challenge.register -class RecoveryToken(ClientChallenge): +class RecoveryToken(ContinuityChallenge): """ACME "recoveryToken" challenge.""" - acme_type = "recoveryToken" - __slots__ = () - - def _fields_to_json(self): - return {} - - @classmethod - def from_valid_json(cls, jobj): - return cls() + typ = "recoveryToken" @ChallengeResponse.register class RecoveryTokenResponse(ChallengeResponse): """ACME "recoveryToken" challenge response.""" - acme_type = "recoveryToken" - __slots__ = ("token",) - - def _fields_to_json(self): - fields = {} - if self.token is not None: - fields["token"] = self.token - return fields - - @classmethod - def from_valid_json(cls, jobj): - return cls(token=jobj.get("token")) - - -def _extend_if_not_empty(dikt, param, name): - if param: - dikt[name] = param - -def _extend_if_not_none(dikt, param, name): - if param is not None: - dikt[name] = param + typ = "recoveryToken" + token = jose.Field("token", omitempty=True) @Challenge.register -class ProofOfPossession(ClientChallenge): +class ProofOfPossession(ContinuityChallenge): """ACME "proofOfPossession" challenge. :ivar str nonce: Random data, **not** base64-encoded. :ivar hints: Various clues for the client (:class:`Hints`). """ - acme_type = "proofOfPossession" - __slots__ = ("alg", "nonce", "hints") + typ = "proofOfPossession" NONCE_SIZE = 16 - class Hints(util.ACMEObject): + class Hints(jose.JSONObjectWithFields): """Hints for "proofOfPossession" challenge. - :ivar jwk: JSON Web Key (:class:`letsencrypt.acme.other.JWK`) - :ivar list certs: List of :class:`M2Crypto.X509.X509` cetificates. + :ivar jwk: JSON Web Key (:class:`letsencrypt.acme.jose.JWK`) + :ivar list certs: List of :class:`letsencrypt.acme.jose.ComparableX509` + certificates. """ - __slots__ = ( - "jwk", "cert_fingerprints", "certs", "subject_key_identifiers", - "serial_numbers", "issuers", "authorized_for") + jwk = jose.Field("jwk", decoder=jose.JWK.from_json) + cert_fingerprints = jose.Field( + "certFingerprints", omitempty=True, default=()) + certs = jose.Field("certs", omitempty=True, default=()) + subject_key_identifiers = jose.Field( + "subjectKeyIdentifiers", omitempty=True, default=()) + serial_numbers = jose.Field("serialNumbers", omitempty=True, default=()) + issuers = jose.Field("issuers", omitempty=True, default=()) + authorized_for = jose.Field("authorizedFor", omitempty=True, default=()) - def to_json(self): - fields = {"jwk": self.jwk} - add = functools.partial(_extend_if_not_empty, fields) - add(self.cert_fingerprints, "certFingerprints") - add([util.encode_cert(cert) for cert in self.certs], "certs") - add(self.subject_key_identifiers, "subjectKeyIdentifiers") - add(self.serial_numbers, "serialNumbers") - add(self.issuers, "issuers") - add(self.authorized_for, "authorizedFor") - return fields + @certs.encoder + def certs(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(jose.encode_cert(cert) for cert in value) - @classmethod - def from_valid_json(cls, jobj): - return cls( - jwk=other.JWK.from_valid_json(jobj["jwk"]), - cert_fingerprints=jobj.get("certFingerprints", []), - certs=[util.decode_cert(cert) - for cert in jobj.get("certs", [])], - subject_key_identifiers=jobj.get("subjectKeyIdentifiers", []), - serial_numbers=jobj.get("serialNumbers", []), - issuers=jobj.get("issuers", []), - authorized_for=jobj.get("authorizedFor", [])) + @certs.decoder + def certs(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(jose.decode_cert(cert) for cert in value) - def _fields_to_json(self): - return { - "alg": self.alg, - "nonce": jose.b64encode(self.nonce), - "hints": self.hints, - } - - @classmethod - def from_valid_json(cls, jobj): - return cls(alg=jobj["alg"], - nonce=util.decode_b64jose(jobj["nonce"], cls.NONCE_SIZE), - hints=cls.Hints.from_valid_json(jobj["hints"])) + alg = jose.Field("alg", decoder=jose.JWASignature.from_json) + nonce = jose.Field( + "nonce", encoder=jose.b64encode, decoder=functools.partial( + jose.decode_b64jose, size=NONCE_SIZE)) + hints = jose.Field("hints", decoder=Hints.from_json) @ChallengeResponse.register @@ -312,50 +224,29 @@ class ProofOfPossessionResponse(ChallengeResponse): :ivar signature: :class:`~letsencrypt.acme.other.Signature` of this message. """ - acme_type = "proofOfPossession" - __slots__ = ("nonce", "signature") + typ = "proofOfPossession" NONCE_SIZE = ProofOfPossession.NONCE_SIZE + nonce = jose.Field( + "nonce", encoder=jose.b64encode, decoder=functools.partial( + jose.decode_b64jose, size=NONCE_SIZE)) + signature = jose.Field("signature", decoder=other.Signature.from_json) + def verify(self): """Verify the challenge.""" + # self.signature is not Field | pylint: disable=no-member return self.signature.verify(self.nonce) - def _fields_to_json(self): - return { - "nonce": jose.b64encode(self.nonce), - "signature": self.signature, - } - - @classmethod - def from_valid_json(cls, jobj): - return cls(nonce=util.decode_b64jose(jobj["nonce"], cls.NONCE_SIZE), - signature=other.Signature.from_valid_json(jobj["signature"])) - @Challenge.register class DNS(DVChallenge): """ACME "dns" challenge.""" - acme_type = "dns" - __slots__ = ("token",) - - def _fields_to_json(self): - return {"token": self.token} - - @classmethod - def from_valid_json(cls, jobj): - return cls(token=jobj["token"]) + typ = "dns" + token = jose.Field("token") @ChallengeResponse.register class DNSResponse(ChallengeResponse): """ACME "dns" challenge response.""" - acme_type = "dns" - __slots__ = () - - def _fields_to_json(self): - return {} - - @classmethod - def from_valid_json(cls, jobj): - return cls() + typ = "dns" diff --git a/letsencrypt/acme/challenges_test.py b/letsencrypt/acme/challenges_test.py index 53b3ff3f1..9ca9f6dd8 100644 --- a/letsencrypt/acme/challenges_test.py +++ b/letsencrypt/acme/challenges_test.py @@ -6,17 +6,17 @@ import unittest import Crypto.PublicKey.RSA import M2Crypto -from letsencrypt.acme import errors from letsencrypt.acme import jose from letsencrypt.acme import other -from letsencrypt.acme import util -CERT = util.ComparableX509(M2Crypto.X509.load_cert( +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'))) + 'letsencrypt.client.tests', os.path.join('testdata', 'cert.pem')))) +KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + 'letsencrypt.acme.jose', + os.path.join('testdata', 'rsa512_key.pem')))) class SimpleHTTPSTest(unittest.TestCase): @@ -30,12 +30,16 @@ 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_valid_json(self.jmsg)) + 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): @@ -52,13 +56,17 @@ 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_valid_json(self.jmsg)) + 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): @@ -79,24 +87,28 @@ 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_valid_json(self.jmsg)) + 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' self.assertRaises( - errors.ValidationError, DVSNI.from_valid_json, self.jmsg) + jose.DeserializationError, DVSNI.from_json, self.jmsg) def test_from_json_invalid_nonce_length(self): from letsencrypt.acme.challenges import DVSNI self.jmsg['nonce'] = 'abcd' self.assertRaises( - errors.ValidationError, DVSNI.from_valid_json, self.jmsg) + jose.DeserializationError, DVSNI.from_json, self.jmsg) class DVSNIResponseTest(unittest.TestCase): @@ -124,12 +136,16 @@ 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_valid_json(self.jmsg)) + 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): @@ -147,12 +163,16 @@ 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_valid_json(self.jmsg)) + 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'] @@ -160,12 +180,12 @@ class RecoveryContactTest(unittest.TestCase): del self.jmsg['contact'] from letsencrypt.acme.challenges import RecoveryContact - msg = RecoveryContact.from_valid_json(self.jmsg) + msg = RecoveryContact.from_json(self.jmsg) 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): @@ -175,22 +195,26 @@ 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_valid_json(self.jmsg)) + 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'] from letsencrypt.acme.challenges import RecoveryContactResponse - msg = RecoveryContactResponse.from_valid_json(self.jmsg) + 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): @@ -200,12 +224,16 @@ 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_valid_json(self.jmsg)) + 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): @@ -215,51 +243,55 @@ 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_valid_json(self.jmsg)) + 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'] from letsencrypt.acme.challenges import RecoveryTokenResponse - msg = RecoveryTokenResponse.from_valid_json(self.jmsg) + 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): def setUp(self): - jwk = other.JWK(key=KEY.publickey()) - issuers = [ + jwk = jose.JWKRSA(key=KEY.publickey()) + issuers = ( 'C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA', 'O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure', - ] - cert_fingerprints = [ + ) + cert_fingerprints = ( '93416768eb85e33adc4277f4c9acd63e7418fcfe', '16d95b7b63f1972b980b14c20291f3c0d1855d95', '48b46570d9fc6358108af43ad1649484def0debf', - ] - subject_key_identifiers = ['d0083162dcc4c8a23ecb8aecbd86120e56fd24e5'] - authorized_for = ['www.example.com', 'example.net'] - serial_numbers = [34234239832, 23993939911, 17] + ) + subject_key_identifiers = ('d0083162dcc4c8a23ecb8aecbd86120e56fd24e5') + authorized_for = ('www.example.com', 'example.net') + serial_numbers = (34234239832, 23993939911, 17) from letsencrypt.acme.challenges import ProofOfPossession self.msg = ProofOfPossession.Hints( jwk=jwk, issuers=issuers, cert_fingerprints=cert_fingerprints, - certs=[CERT], subject_key_identifiers=subject_key_identifiers, + certs=(CERT,), subject_key_identifiers=subject_key_identifiers, authorized_for=authorized_for, serial_numbers=serial_numbers) self.jmsg_to = { 'jwk': jwk, 'certFingerprints': cert_fingerprints, - 'certs': [jose.b64encode(CERT.as_der())], + 'certs': (jose.b64encode(CERT.as_der()),), 'subjectKeyIdentifiers': subject_key_identifiers, 'serialNumbers': serial_numbers, 'issuers': issuers, @@ -268,13 +300,17 @@ class ProofOfPossessionHintsTest(unittest.TestCase): self.jmsg_from = self.jmsg_to.copy() 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_valid_json(self.jmsg_from)) + 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', @@ -283,16 +319,16 @@ class ProofOfPossessionHintsTest(unittest.TestCase): del self.jmsg_to[optional] from letsencrypt.acme.challenges import ProofOfPossession - msg = ProofOfPossession.Hints.from_valid_json(self.jmsg_from) + msg = ProofOfPossession.Hints.from_json(self.jmsg_from) - self.assertEqual(msg.cert_fingerprints, []) - self.assertEqual(msg.certs, []) - self.assertEqual(msg.subject_key_identifiers, []) - self.assertEqual(msg.serial_numbers, []) - self.assertEqual(msg.issuers, []) - self.assertEqual(msg.authorized_for, []) + self.assertEqual(msg.cert_fingerprints, ()) + self.assertEqual(msg.certs, ()) + self.assertEqual(msg.subject_key_identifiers, ()) + self.assertEqual(msg.serial_numbers, ()) + 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): @@ -300,35 +336,37 @@ class ProofOfPossessionTest(unittest.TestCase): def setUp(self): from letsencrypt.acme.challenges import ProofOfPossession hints = ProofOfPossession.Hints( - jwk=other.JWK(key=KEY.publickey()), cert_fingerprints=[], certs=[], - serial_numbers=[], subject_key_identifiers=[], issuers=[], - authorized_for=[]) + jwk=jose.JWKRSA(key=KEY.publickey()), cert_fingerprints=(), + certs=(), serial_numbers=(), subject_key_identifiers=(), + issuers=(), authorized_for=()) self.msg = ProofOfPossession( - alg='RS256', nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ', - hints=hints) + alg=jose.RS256, hints=hints, + nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ') self.jmsg_to = { 'type': 'proofOfPossession', - 'alg': 'RS256', + 'alg': jose.RS256, 'nonce': 'eET5udtV7aoX8Xl8gYiZIA', 'hints': hints, } self.jmsg_from = { 'type': 'proofOfPossession', - 'alg': 'RS256', + 'alg': jose.RS256.to_json(), 'nonce': 'eET5udtV7aoX8Xl8gYiZIA', 'hints': hints.to_json(), } - self.jmsg_from['hints']['jwk'] = self.jmsg_from[ - 'hints']['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.from_valid_json(self.jmsg_from)) + 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): @@ -338,7 +376,7 @@ class ProofOfPossessionResponseTest(unittest.TestCase): # nonce and challenge nonce are the same, don't make the same # mistake here... signature = other.Signature( - alg='RS256', jwk=other.JWK(key=KEY.publickey()), + alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.publickey()), sig='\xa7\xc1\xe7\xe82o\xbc\xcd\xd0\x1e\x010#Z|\xaf\x15\x83' '\x94\x8f#\x9b\nQo(\x80\x15,\x08\xfcz\x1d\xfd\xfd.\xaap' '\xfa\x06\xd1\xa2f\x8d8X2>%d\xbd%\xe1T\xdd\xaa0\x18\xde' @@ -361,20 +399,21 @@ class ProofOfPossessionResponseTest(unittest.TestCase): 'nonce': 'eET5udtV7aoX8Xl8gYiZIA', 'signature': signature.to_json(), } - self.jmsg_from['signature']['jwk'] = self.jmsg_from[ - 'signature']['jwk'].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_valid_json(self.jmsg_from)) + 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): @@ -384,12 +423,16 @@ 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_valid_json(self.jmsg)) + 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): @@ -399,12 +442,16 @@ 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_valid_json(self.jmsg)) + 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__': diff --git a/letsencrypt/acme/errors.py b/letsencrypt/acme/errors.py index c88881412..d69efda11 100644 --- a/letsencrypt/acme/errors.py +++ b/letsencrypt/acme/errors.py @@ -1,13 +1,8 @@ """ACME errors.""" +from letsencrypt.acme.jose import errors as jose_errors class Error(Exception): """Generic ACME error.""" -class ValidationError(Error): - """ACME object validation error.""" - -class UnrecognizedTypeError(ValidationError): - """Unrecognized ACME object type error.""" - -class SchemaValidationError(ValidationError): +class SchemaValidationError(jose_errors.DeserializationError): """JSON schema ACME object validation error.""" 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/interfaces.py b/letsencrypt/acme/interfaces.py deleted file mode 100644 index e49956b4b..000000000 --- a/letsencrypt/acme/interfaces.py +++ /dev/null @@ -1,69 +0,0 @@ -"""ACME interfaces. - -Separation between :class:`IJSONSerializable` and :class:`IJSONDeserializable` -is necessary because we want to use ``cls.from_valid_json`` -classmethod on class and ``cls().to_json()`` on object, i.e. class -instance. ``cls.to_json()`` doesn't make much sense. Therefore a class -definition that requires both must call -``zope.interface.implements(IJSONSerializable)`` and -``zope.interface.classImplements(IJSONDeSerializable)`` (note the -difference btween `implements` and `classImplements`) and -:class:`letsencrypt.acme.util.ACMEObject` definition is an example. - -""" -import zope.interface - -# pylint: disable=no-self-argument,no-method-argument,no-init,inherit-non-class -# pylint: disable=too-few-public-methods - - -class IJSONSerializable(zope.interface.Interface): - # pylint: disable=too-few-public-methods - """JSON serializable object.""" - - def to_json(): - """Prepare JSON serializable object. - - Note, however, that this method might return other - :class:`letsencrypt.acme.interfaces.IJSONSerializable` - objects that haven't been serialized yet, which is fine as - long as :func:`letsencrypt.acme.util.dump_ijsonserializable` - is used. For example:: - - class Foo(object): - zope.interface.implements(IJSONSerializable) - - def to_json(self): - return 'foo' - - class Bar(object): - zope.interface.implements(IJSONSerializable) - - def to_json(self): - return [Foo(), Foo()] - - bar = Bar() - assert isinstance(bar.to_json()[0], Foo) - assert isinstance(bar.to_json()[1], Foo) - assert json.dumps( - bar, default=dump_ijsonserializable) == ['foo', 'foo'] - - :returns: JSON object ready to be serialized. - - """ - -class IJSONDeserializable(zope.interface.Interface): - """JSON deserializable class.""" - - def from_valid_json(jobj): - """Deserialize valid JSON object. - - :param jobj: JSON object validated against JSON schema (found in - schemata/ directory). - - :raises letsencrypt.acme.errors.ValidationError: It might be the - case that ``jobj`` validates against schema, but still is not - valid (e.g. unparseable X509 certificate, or wrong padding in - JOSE base64 encoded string). - - """ diff --git a/letsencrypt/acme/jose/__init__.py b/letsencrypt/acme/jose/__init__.py new file mode 100644 index 000000000..20f9ba7d3 --- /dev/null +++ b/letsencrypt/acme/jose/__init__.py @@ -0,0 +1,75 @@ +"""Javascript Object Signing and Encryption (jose). + +This package is a Python implementation of the stadards developed by +IETF `Javascript Object Signing and Encryption (Active WG)`_, in +particular the following RFCs: + + - `JSON Web Algorithms (JWA)`_ + - `JSON Web Key (JWK)`_ + - `JSON Web Signature (JWS)`_ + + +.. _`Javascript Object Signing and Encryption (Active WG)`: + https://tools.ietf.org/wg/jose/ + +.. _`JSON Web Algorithms (JWA)`: + https://datatracker.ietf.org/doc/draft-ietf-jose-json-web-algorithms/ + +.. _`JSON Web Key (JWK)`: + https://datatracker.ietf.org/doc/draft-ietf-jose-json-web-key/ + +.. _`JSON Web Signature (JWS)`: + https://datatracker.ietf.org/doc/draft-ietf-jose-json-web-signature/ + +""" +from letsencrypt.acme.jose.b64 import ( + b64decode, + b64encode, +) + +from letsencrypt.acme.jose.errors import ( + DeserializationError, + SerializationError, + Error, + UnrecognizedTypeError, +) + +from letsencrypt.acme.jose.interfaces import JSONDeSerializable + +from letsencrypt.acme.jose.json_util import ( + Field, + JSONObjectWithFields, + TypedJSONObjectWithFields, + decode_b64jose, + decode_cert, + decode_csr, + decode_hex16, + encode_cert, + encode_csr, +) + +from letsencrypt.acme.jose.jwa import ( + HS256, + HS384, + HS512, + JWASignature, + PS256, + PS384, + PS512, + RS256, + RS384, + RS512, +) + +from letsencrypt.acme.jose.jwk import ( + JWK, + JWKRSA, +) + +from letsencrypt.acme.jose.jws import JWS + +from letsencrypt.acme.jose.util import ( + ComparableX509, + HashableRSAKey, + ImmutableMap, +) diff --git a/letsencrypt/acme/jose.py b/letsencrypt/acme/jose/b64.py similarity index 79% rename from letsencrypt/acme/jose.py rename to letsencrypt/acme/jose/b64.py index 81c1abbf7..8f2d284ce 100644 --- a/letsencrypt/acme/jose.py +++ b/letsencrypt/acme/jose/b64.py @@ -1,13 +1,19 @@ -"""JOSE.""" -import base64 +"""JOSE Base64. -# https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-37#appendix-C -# -# Jose Base64: -# -# - URL-safe Base64 -# -# - padding stripped +`JOSE Base64`_ is defined as: + + - URL-safe Base64 + - padding stripped + + +.. _`JOSE Base64`: + https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-37#appendix-C + +.. warning:: Do NOT try to call this module "base64", + as it will "shadow" the standard library. + +""" +import base64 def b64encode(data): diff --git a/letsencrypt/acme/jose_test.py b/letsencrypt/acme/jose/b64_test.py similarity index 87% rename from letsencrypt/acme/jose_test.py rename to letsencrypt/acme/jose/b64_test.py index 42cf8051c..89ff27f5d 100644 --- a/letsencrypt/acme/jose_test.py +++ b/letsencrypt/acme/jose/b64_test.py @@ -1,4 +1,4 @@ -"""Tests for letsencrypt.acme.jose.""" +"""Tests for letsencrypt.acme.jose.b64.""" import unittest @@ -19,11 +19,11 @@ B64_URL_UNSAFE_EXAMPLES = { class B64EncodeTest(unittest.TestCase): - """Tests for letsencrypt.acme.jose.b64encode.""" + """Tests for letsencrypt.acme.jose.b64.b64encode.""" @classmethod def _call(cls, data): - from letsencrypt.acme.jose import b64encode + from letsencrypt.acme.jose.b64 import b64encode return b64encode(data) def test_unsafe_url(self): @@ -39,11 +39,11 @@ class B64EncodeTest(unittest.TestCase): class B64DecodeTest(unittest.TestCase): - """Tests for letsencrypt.acme.jose.b64decode.""" + """Tests for letsencrypt.acme.jose.b64.b64decode.""" @classmethod def _call(cls, data): - from letsencrypt.acme.jose import b64decode + from letsencrypt.acme.jose.b64 import b64decode return b64decode(data) def test_unsafe_url(self): diff --git a/letsencrypt/acme/jose/errors.py b/letsencrypt/acme/jose/errors.py new file mode 100644 index 000000000..74708c4a4 --- /dev/null +++ b/letsencrypt/acme/jose/errors.py @@ -0,0 +1,31 @@ +"""JOSE errors.""" + + +class Error(Exception): + """Generic JOSE Error.""" + + +class DeserializationError(Error): + """JSON deserialization error.""" + + +class SerializationError(Error): + """JSON serialization error.""" + + +class UnrecognizedTypeError(DeserializationError): + """Unrecognized type error. + + :ivar str typ: The unrecognized type of the JSON object. + :ivar jobj: Full JSON object. + + """ + + def __init__(self, typ, jobj): + self.typ = typ + self.jobj = jobj + super(UnrecognizedTypeError, self).__init__(str(self)) + + def __str__(self): + return '{0} was not recognized, full message: {1}'.format( + self.typ, self.jobj) diff --git a/letsencrypt/acme/jose/errors_test.py b/letsencrypt/acme/jose/errors_test.py new file mode 100644 index 000000000..dd6af6c1a --- /dev/null +++ b/letsencrypt/acme/jose/errors_test.py @@ -0,0 +1,17 @@ +"""Tests for letsencrypt.acme.jose.errors.""" +import unittest + + +class UnrecognizedTypeErrorTest(unittest.TestCase): + def setUp(self): + from letsencrypt.acme.jose.errors import UnrecognizedTypeError + self.error = UnrecognizedTypeError('foo', {'type': 'foo'}) + + def test_str(self): + self.assertEqual( + "foo was not recognized, full message: {'type': 'foo'}", + str(self.error)) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/jose/interfaces.py b/letsencrypt/acme/jose/interfaces.py new file mode 100644 index 000000000..8e06f99f9 --- /dev/null +++ b/letsencrypt/acme/jose/interfaces.py @@ -0,0 +1,205 @@ +"""JOSE interfaces.""" +import abc +import collections +import json + +from letsencrypt.acme.jose import util + +# pylint: disable=no-self-argument,no-method-argument,no-init,inherit-non-class +# pylint: disable=too-few-public-methods + + +class JSONDeSerializable(object): + # pylint: disable=too-few-public-methods + """Interface for (de)serializable JSON objects. + + Please recall, that standard Python library implements + :class:`json.JSONEncoder` and :class:`json.JSONDecoder` that perform + translations based on respective :ref:`conversion tables + ` that look pretty much like the one below (for + complete tables see relevant Python documentation): + + .. _conversion-table: + + ====== ====== + JSON Python + ====== ====== + object dict + ... ... + ====== ====== + + While the above **conversion table** is about translation of JSON + documents to/from the basic Python types only, + :class:`JSONDeSerializable` introduces the following two concepts: + + serialization + 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_partial_json`) + produces a Python object that might also be built from other + :class:`JSONDeSerializable` objects. + + deserialization + Turning a decoded Python object (necessarily one of the basic + types as required by the :ref:`conversion table + `) into an arbitrary Python object. + + Serialization produces **serialized object** ("partially serialized + object" or "fully serialized object" for partial and full + serialization respectively) and deserialization produces + **deserialized object**, both usually denoted in the source code as + ``jobj``. + + Wording in the official Python documentation might be confusing + after reading the above, but in the light of those definitions, one + can view :meth:`json.JSONDecoder.decode` as decoder and + deserializer of basic types, :meth:`json.JSONEncoder.default` as + serializer of basic types, :meth:`json.JSONEncoder.encode` as + serializer and encoder of basic types. + + One could extend :mod:`json` to support arbitrary object + (de)serialization either by: + + - overriding :meth:`json.JSONDecoder.decode` and + :meth:`json.JSONEncoder.default` in subclasses + + - or passing ``object_hook`` argument (or ``object_hook_pairs``) + to :func:`json.load`/:func:`json.loads` or ``default`` argument + for :func:`json.dump`/:func:`json.dumps`. + + Interestingly, ``default`` is required to perform only partial + serialization, as :func:`json.dumps` applies ``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_partial_json(self): + return 'foo' + + @classmethod + def from_json(cls, jobj): + return Foo() + + class Bar(JSONDeSerializable): + def to_partial_json(self): + return [Foo(), Foo()] + + @classmethod + def from_json(cls, jobj): + return Bar() + + """ + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def to_partial_json(self): # pragma: no cover + """Partially serialize. + + Following the example, **partial serialization** means the following:: + + assert isinstance(Bar().to_partial_json()[0], Foo) + assert isinstance(Bar().to_partial_json()[1], Foo) + + # in particular... + assert Bar().to_partial_json() != ['foo', 'foo'] + + :raises letsencrypt.acme.jose.errors.SerializationError: + in case of any serialization error. + :returns: Partially serializable object. + + """ + raise NotImplementedError() + + def to_json(self): + """Fully serialize. + + Again, following the example from before, **full serialization** + means the following:: + + assert Bar().to_json() == ['foo', 'foo'] + + :raises letsencrypt.acme.jose.errors.SerializationError: + in case of any serialization error. + :returns: Fully serialized object. + + """ + 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): + """Deserialize a decoded JSON document. + + :param jobj: Python object, composed of only other basic data + types, as decoded from JSON document. Not necessarily + :class:`dict` (as decoded from "JSON object" document). + + :raises letsencrypt.acme.jose.errors.DeserializationError: + if decoding was unsuccessful, e.g. in case of unparseable + X509 certificate, or wrong padding in JOSE base64 encoded + string, etc. + + """ + # TypeError: Can't instantiate abstract class with + # abstract methods from_json, to_partial_json + return cls() # pylint: disable=abstract-class-instantiated + + @classmethod + def json_loads(cls, json_string): + """Deserialize from JSON document string.""" + return cls.from_json(json.loads(json_string)) + + def json_dumps(self, **kwargs): + """Dump to JSON string using proper serializer. + + :returns: JSON document string. + :rtype: str + + """ + return json.dumps(self, default=self.json_dump_default, **kwargs) + + def json_dumps_pretty(self): + """Dump the object to pretty JSON document string.""" + return self.json_dumps(sort_keys=True, indent=4, separators=(',', ': ')) + + @classmethod + def json_dump_default(cls, python_object): + """Serialize Python object. + + This function is meant to be passed as ``default`` to + :func:`json.load` or :func:`json.loads`. They call + ``default(python_object)`` only for non-basic Python types, so + this function necessarily raises :class:`TypeError` if + ``python_object`` is not an instance of + :class:`IJSONSerializable`. + + Please read the class docstring for more information. + + """ + if isinstance(python_object, JSONDeSerializable): + 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 new file mode 100644 index 000000000..4c0fc6eb9 --- /dev/null +++ b/letsencrypt/acme/jose/interfaces_test.py @@ -0,0 +1,115 @@ +"""Tests for letsencrypt.acme.jose.interfaces.""" +import unittest + + +class JSONDeSerializableTest(unittest.TestCase): + # pylint: disable=too-many-instance-attributes + + def setUp(self): + from letsencrypt.acme.jose.interfaces import JSONDeSerializable + + # pylint: disable=missing-docstring,invalid-name + + class Basic(JSONDeSerializable): + def __init__(self, v): + self.v = v + + def to_partial_json(self): + return self.v + + @classmethod + def from_json(cls, jobj): + return cls(jobj) + + class Sequence(JSONDeSerializable): + def __init__(self, x, y): + self.x = x + self.y = y + + def to_partial_json(self): + return [self.x, self.y] + + @classmethod + def from_json(cls, jobj): + return cls( + Basic.from_json(jobj[0]), Basic.from_json(jobj[1])) + + class Mapping(JSONDeSerializable): + def __init__(self, x, y): + self.x = x + self.y = y + + def to_partial_json(self): + return {self.x: self.y} + + @classmethod + def from_json(cls, jobj): + return cls(Basic.from_json(jobj.keys()[0]), + Basic.from_json(jobj.values()[0])) + + self.basic1 = Basic('foo1') + 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_to_json_sequence(self): + self.assertEqual(self.seq.to_json(), ['foo1', 'foo2']) + + def test_to_json_mapping(self): + self.assertEqual(self.mapping.to_json(), {'foo1': 'foo2'}) + + def test_to_json_other(self): + mock_value = object() + 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 + self.assertRaises(TypeError, JSONDeSerializable.from_json, 'xxx') + + def test_json_loads(self): + seq = self.Sequence.json_loads('["foo1", "foo2"]') + self.assertTrue(isinstance(seq, self.Sequence)) + self.assertTrue(isinstance(seq.x, self.Basic)) + self.assertTrue(isinstance(seq.y, self.Basic)) + self.assertEqual(seq.x.v, 'foo1') + self.assertEqual(seq.y.v, 'foo2') + + def test_json_dumps(self): + self.assertEqual('["foo1", "foo2"]', self.seq.json_dumps()) + + def test_json_dumps_pretty(self): + self.assertEqual( + self.seq.json_dumps_pretty(), '[\n "foo1",\n "foo2"\n]') + + def test_json_dump_default(self): + from letsencrypt.acme.jose.interfaces import JSONDeSerializable + + self.assertEqual( + 'foo1', JSONDeSerializable.json_dump_default(self.basic1)) + + jobj = JSONDeSerializable.json_dump_default(self.seq) + self.assertEqual(len(jobj), 2) + self.assertTrue(jobj[0] is self.basic1) + self.assertTrue(jobj[1] is self.basic2) + + def test_json_dump_default_type_error(self): + from letsencrypt.acme.jose.interfaces import JSONDeSerializable + self.assertRaises( + TypeError, JSONDeSerializable.json_dump_default, object()) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/jose/json_util.py b/letsencrypt/acme/jose/json_util.py new file mode 100644 index 000000000..ac8cdf7aa --- /dev/null +++ b/letsencrypt/acme/jose/json_util.py @@ -0,0 +1,402 @@ +"""JSON (de)serialization framework. + +The framework presented here is somewhat based on `Go's "json" package`_ +(especially the ``omitempty`` functionality). + +.. _`Go's "json" package`: http://golang.org/pkg/encoding/json/ + +""" +import abc +import binascii +import logging + +import M2Crypto + +from letsencrypt.acme.jose import b64 +from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import interfaces +from letsencrypt.acme.jose import util + + +class Field(object): + """JSON object field. + + :class:`Field` is meant to be used together with + :class:`JSONObjectWithFields`. + + ``encoder`` (``decoder``) is a callable that accepts a single + parameter, i.e. a value to be encoded (decoded), and returns the + serialized (deserialized) value. In case of errors it should raise + :class:`~letsencrypt.acme.jose.errors.SerializationError` + (:class:`~letsencrypt.acme.jose.errors.DeserializationError`). + + Note, that ``decoder`` should perform partial serialization only. + + :ivar str json_name: Name of the field when encoded to JSON. + :ivar default: Default value (used when not present in JSON object). + :ivar bool omitempty: If ``True`` and the field value is empty, then + it will not be included in the serialized JSON object, and + ``default`` will be used for deserialization. Otherwise, if ``False``, + field is considered as required, value will always be included in the + serialized JSON objected, and it must also be present when + deserializing. + + """ + __slots__ = ('json_name', 'default', 'omitempty', 'fdec', 'fenc') + + def __init__(self, json_name, default=None, omitempty=False, + decoder=None, encoder=None): + # pylint: disable=too-many-arguments + self.json_name = json_name + self.default = default + self.omitempty = omitempty + + self.fdec = self.default_decoder if decoder is None else decoder + self.fenc = self.default_encoder if encoder is None else encoder + + @classmethod + def _empty(cls, value): + """Is the provided value cosidered "empty" for this field? + + This is useful for subclasses that might want to override the + definition of being empty, e.g. for some more exotic data types. + + """ + return not value + + def omit(self, value): + """Omit the value in output?""" + return self._empty(value) and self.omitempty + + def _update_params(self, **kwargs): + current = dict(json_name=self.json_name, default=self.default, + omitempty=self.omitempty, + decoder=self.fdec, encoder=self.fenc) + current.update(kwargs) + return type(self)(**current) # pylint: disable=star-args + + def decoder(self, fdec): + """Descriptor to change the decoder on JSON object field.""" + return self._update_params(decoder=fdec) + + def encoder(self, fenc): + """Descriptor to change the encoder on JSON object field.""" + return self._update_params(encoder=fenc) + + def decode(self, value): + """Decode a value, optionally with context JSON object.""" + return self.fdec(value) + + def encode(self, value): + """Encode a value, optionally with context JSON object.""" + return self.fenc(value) + + @classmethod + def default_decoder(cls, value): + """Default decoder. + + Recursively deserialize into immutable types ( + :class:`letsencrypt.acme.jose.util.frozendict` instead of + :func:`dict`, :func:`tuple` instead of :func:`list`). + + """ + # bases cases for different types returned by json.loads + if isinstance(value, list): + return tuple(cls.default_decoder(subvalue) for subvalue in value) + elif isinstance(value, dict): + return util.frozendict( + dict((cls.default_decoder(key), cls.default_decoder(value)) + for key, value in value.iteritems())) + else: # integer or string + return value + + @classmethod + def default_encoder(cls, value): + """Default (passthrough) encoder.""" + # field.to_partial_json() is no good as encoder has to do partial + # serialization only + return value + + +class JSONObjectWithFieldsMeta(abc.ABCMeta): + """Metaclass for :class:`JSONObjectWithFields` and its subclasses. + + It makes sure that, for any class ``cls`` with ``__metaclass__`` + set to ``JSONObjectWithFieldsMeta``: + + 1. All fields (attributes of type :class:`Field`) in the class + definition are moved to the ``cls._fields`` dictionary, where + keys are field attribute names and values are fields themselves. + + 2. ``cls.__slots__`` is extended by all field attribute names + (i.e. not :attr:`Field.json_name`). + + In a consequence, for a field attribute name ``some_field``, + ``cls.some_field`` will be a slot descriptor and not an instance + of :class:`Field`. For example:: + + some_field = Field('someField', default=()) + + class Foo(object): + __metaclass__ = JSONObjectWithFieldsMeta + __slots__ = ('baz',) + some_field = some_field + + assert Foo.__slots__ == ('some_field', 'baz') + assert Foo.some_field is not Field + + assert Foo._fields.keys() == ['some_field'] + assert Foo._fields['some_field'] is some_field + + As an implementation note, this metaclass inherits from + :class:`abc.ABCMeta` (and not the usual :class:`type`) to mitigate + the metaclass conflict (:class:`ImmutableMap` and + :class:`JSONDeSerializable`, parents of :class:`JSONObjectWithFields`, + use :class:`abc.ABCMeta` as its metaclass). + + """ + + def __new__(mcs, name, bases, dikt): + fields = {} + for key, value in dikt.items(): # not iterkeys() (in-place edit!) + if isinstance(value, Field): + fields[key] = dikt.pop(key) + + dikt['__slots__'] = tuple( + list(dikt.get('__slots__', ())) + fields.keys()) + dikt['_fields'] = fields + + return abc.ABCMeta.__new__(mcs, name, bases, dikt) + + +class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable): + # pylint: disable=too-few-public-methods + """JSON object with fields. + + Example:: + + class Foo(JSONObjectWithFields): + bar = Field('Bar') + empty = Field('Empty', omitempty=True) + + @bar.encoder + def bar(value): + return value + 'bar' + + @bar.decoder + def bar(value): + if not value.endswith('bar'): + raise errors.DeserializationError('No bar suffix!') + return value[:-3] + + 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='!')) + assert Foo(bar='baz').bar == 'baz' + + """ + __metaclass__ = JSONObjectWithFieldsMeta + + @classmethod + def _defaults(cls): + """Get default fields values.""" + return dict([(slot, field.default) for slot, field + in cls._fields.iteritems() if field.omitempty]) + + def __init__(self, **kwargs): + # pylint: disable=star-args + super(JSONObjectWithFields, self).__init__( + **(dict(self._defaults(), **kwargs))) + + def fields_to_partial_json(self): + """Serialize fields to JSON.""" + jobj = {} + for slot, field in self._fields.iteritems(): + value = getattr(self, slot) + + if field.omit(value): + logging.debug('Omitting empty field "%s" (%s)', slot, value) + else: + try: + jobj[field.json_name] = field.encode(value) + except errors.SerializationError as error: + raise errors.SerializationError( + 'Could not encode {0} ({1}): {2}'.format( + slot, value, error)) + return jobj + + def to_partial_json(self): + return self.fields_to_partial_json() + + @classmethod + def _check_required(cls, jobj): + missing = set() + for _, field in cls._fields.iteritems(): + if not field.omitempty and field.json_name not in jobj: + missing.add(field.json_name) + + if missing: + raise errors.DeserializationError( + 'The following field are required: {0}'.format( + ','.join(missing))) + + @classmethod + def fields_from_json(cls, jobj): + """Deserialize fields from JSON.""" + cls._check_required(jobj) + fields = {} + for slot, field in cls._fields.iteritems(): + if field.json_name not in jobj and field.omitempty: + fields[slot] = field.default + else: + value = jobj[field.json_name] + try: + fields[slot] = field.decode(value) + except errors.DeserializationError as error: + raise errors.DeserializationError( + 'Could not decode {0!r} ({1!r}): {2}'.format( + slot, value, error)) + return fields + + @classmethod + def from_json(cls, jobj): + return cls(**cls.fields_from_json(jobj)) + + +def decode_b64jose(data, size=None, minimum=False): + """Decode JOSE Base-64 field. + + :param int size: Required length (after decoding). + :param bool minimum: If ``True``, then `size` will be treated as + minimum required length, as opposed to exact equality. + + """ + try: + decoded = b64.b64decode(data) + except TypeError as error: + raise errors.DeserializationError(error) + + if size is not None and ((not minimum and len(decoded) != size) + or (minimum and len(decoded) < size)): + raise errors.DeserializationError() + + return decoded + + +def decode_hex16(value, size=None, minimum=False): + """Decode hexlified field. + + :param int size: Required length (after decoding). + :param bool minimum: If ``True``, then `size` will be treated as + minimum required length, as opposed to exact equality. + + """ + if size is not None and ((not minimum and len(value) != size * 2) + or (minimum and len(value) < size * 2)): + raise errors.DeserializationError() + try: + return binascii.unhexlify(value) + except TypeError as error: + raise errors.DeserializationError(error) + +def encode_cert(cert): + """Encode certificate as JOSE Base-64 DER. + + :param cert: Certificate. + :type cert: :class:`letsencrypt.acme.jose.util.ComparableX509` + + """ + return b64.b64encode(cert.as_der()) + +def decode_cert(b64der): + """Decode JOSE Base-64 DER-encoded certificate.""" + try: + return util.ComparableX509(M2Crypto.X509.load_cert_der_string( + decode_b64jose(b64der))) + except M2Crypto.X509.X509Error as error: + raise errors.DeserializationError(error) + +def encode_csr(csr): + """Encode CSR as JOSE Base-64 DER.""" + return encode_cert(csr) + +def decode_csr(b64der): + """Decode JOSE Base-64 DER-encoded CSR.""" + try: + return util.ComparableX509(M2Crypto.X509.load_request_der_string( + decode_b64jose(b64der))) + except M2Crypto.X509.X509Error as error: + raise errors.DeserializationError(error) + + +class TypedJSONObjectWithFields(JSONObjectWithFields): + """JSON object with type.""" + + typ = NotImplemented + """Type of the object. Subclasses must override.""" + + type_field_name = "type" + """Field name used to distinguish different object types. + + Subclasses will probably have to override this. + + """ + + TYPES = NotImplemented + """Types registered for JSON deserialization""" + + @classmethod + def register(cls, type_cls, typ=None): + """Register class for JSON deserialization.""" + typ = type_cls.typ if typ is None else typ + cls.TYPES[typ] = type_cls + return type_cls + + @classmethod + def get_type_cls(cls, jobj): + """Get the registered class for ``jobj``.""" + if cls in cls.TYPES.itervalues(): + assert jobj[cls.type_field_name] + # cls is already registered type_cls, force to use it + # so that, e.g Revocation.from_json(jobj) fails if + # jobj["type"] != "revocation". + return cls + + if not isinstance(jobj, dict): + raise errors.DeserializationError( + "{0} is not a dictionary object".format(jobj)) + try: + typ = jobj[cls.type_field_name] + except KeyError: + raise errors.DeserializationError("missing type field") + + try: + return cls.TYPES[typ] + except KeyError: + raise errors.UnrecognizedTypeError(typ, jobj) + + def to_partial_json(self): + """Get JSON serializable object. + + :returns: Serializable JSON object representing ACME typed object. + :meth:`validate` will almost certainly not work, due to reasons + explained in :class:`letsencrypt.acme.interfaces.IJSONSerializable`. + :rtype: dict + + """ + jobj = self.fields_to_partial_json() + jobj[self.type_field_name] = self.typ + return jobj + + @classmethod + def from_json(cls, jobj): + """Deserialize ACME object from valid JSON object. + + :raises letsencrypt.acme.errors.UnrecognizedTypeError: if type + of the ACME object has not been registered. + + """ + # make sure subclasses don't cause infinite recursive from_json calls + type_cls = cls.get_type_cls(jobj) + return type_cls(**type_cls.fields_from_json(jobj)) diff --git a/letsencrypt/acme/jose/json_util_test.py b/letsencrypt/acme/jose/json_util_test.py new file mode 100644 index 000000000..88818ed07 --- /dev/null +++ b/letsencrypt/acme/jose/json_util_test.py @@ -0,0 +1,297 @@ +"""Tests for letsencrypt.acme.jose.json_util.""" +import os +import pkg_resources +import unittest + +import M2Crypto +import mock + +from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import interfaces +from letsencrypt.acme.jose import util + + +CERT = M2Crypto.X509.load_cert(pkg_resources.resource_filename( + 'letsencrypt.client.tests', os.path.join('testdata', 'cert.pem'))) +CSR = M2Crypto.X509.load_request(pkg_resources.resource_filename( + 'letsencrypt.client.tests', os.path.join('testdata', 'csr.pem'))) + + +class FieldTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.json_util.Field.""" + + def test_descriptors(self): + mock_value = mock.MagicMock() + + # pylint: disable=missing-docstring + + def decoder(unused_value): + return 'd' + + def encoder(unused_value): + return 'e' + + from letsencrypt.acme.jose.json_util import Field + field = Field('foo') + + field = field.encoder(encoder) + self.assertEqual('e', field.encode(mock_value)) + + field = field.decoder(decoder) + self.assertEqual('e', field.encode(mock_value)) + self.assertEqual('d', field.decode(mock_value)) + + def test_default_encoder_is_partial(self): + class MockField(interfaces.JSONDeSerializable): + # pylint: disable=missing-docstring + def to_partial_json(self): + return 'foo' + @classmethod + def from_json(cls, jobj): + pass + mock_field = MockField() + + from letsencrypt.acme.jose.json_util import Field + self.assertTrue(Field.default_encoder(mock_field) is mock_field) + # in particular... + self.assertNotEqual('foo', Field.default_encoder(mock_field)) + + def test_default_encoder_passthrough(self): + mock_value = mock.MagicMock() + from letsencrypt.acme.jose.json_util import Field + self.assertTrue(Field.default_encoder(mock_value) is mock_value) + + def test_default_decoder_list_to_tuple(self): + from letsencrypt.acme.jose.json_util import Field + self.assertEqual((1, 2, 3), Field.default_decoder([1, 2, 3])) + + def test_default_decoder_dict_to_frozendict(self): + from letsencrypt.acme.jose.json_util import Field + obj = Field.default_decoder({'x': 2}) + self.assertTrue(isinstance(obj, util.frozendict)) + self.assertEqual(obj, util.frozendict(x=2)) + + def test_default_decoder_passthrough(self): + mock_value = mock.MagicMock() + from letsencrypt.acme.jose.json_util import Field + self.assertTrue(Field.default_decoder(mock_value) is mock_value) + + +class JSONObjectWithFieldsTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.json_util.JSONObjectWithFields.""" + # pylint: disable=protected-access + + def setUp(self): + from letsencrypt.acme.jose.json_util import JSONObjectWithFields + from letsencrypt.acme.jose.json_util import Field + + class MockJSONObjectWithFields(JSONObjectWithFields): + # pylint: disable=invalid-name,missing-docstring,no-self-argument + # pylint: disable=too-few-public-methods + x = Field('x', omitempty=True, + encoder=(lambda x: x * 2), + decoder=(lambda x: x / 2)) + y = Field('y') + z = Field('Z') # on purpose uppercase + + @y.encoder + def y(value): + if value == 500: + raise errors.SerializationError() + return value + + @y.decoder + def y(value): + if value == 500: + raise errors.DeserializationError() + return value + + # pylint: disable=invalid-name + self.MockJSONObjectWithFields = MockJSONObjectWithFields + self.mock = MockJSONObjectWithFields(x=None, y=2, z=3) + + def test_init_defaults(self): + self.assertEqual(self.mock, self.MockJSONObjectWithFields(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( + {'x': None, 'y': 2, 'z': 3}, + self.MockJSONObjectWithFields.fields_from_json({'y': 2, 'Z': 3})) + + def test_fields_from_json_fails_on_missing(self): + self.assertRaises( + errors.DeserializationError, + self.MockJSONObjectWithFields.fields_from_json, {'y': 0}) + self.assertRaises( + errors.DeserializationError, + self.MockJSONObjectWithFields.fields_from_json, {'Z': 0}) + self.assertRaises( + errors.DeserializationError, + self.MockJSONObjectWithFields.fields_from_json, {'x': 0, 'y': 0}) + self.assertRaises( + errors.DeserializationError, + self.MockJSONObjectWithFields.fields_from_json, {'x': 0, 'Z': 0}) + + 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( + {'x': 2, 'y': 2, 'z': 3}, + self.MockJSONObjectWithFields.fields_from_json( + {'x': 4, 'y': 2, 'Z': 3})) + + def test_fields_to_partial_json_error_passthrough(self): + self.assertRaises( + errors.SerializationError, self.MockJSONObjectWithFields( + x=1, y=500, z=3).to_partial_json) + + def test_fields_from_json_error_passthrough(self): + self.assertRaises( + errors.DeserializationError, + self.MockJSONObjectWithFields.from_json, + {'x': 4, 'y': 500, 'Z': 3}) + + +class DeEncodersTest(unittest.TestCase): + def setUp(self): + self.b64_cert = ( + 'MIIB3jCCAYigAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhM' + 'CVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKz' + 'ApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxF' + 'DASBgNVBAMMC2V4YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIx' + 'ODIyMzQ0NVowdzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRI' + 'wEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTW' + 'ljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4YW1wbGUuY29tMFwwD' + 'QYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR7R_drnBSQ_zfx1vQLHUbFLh1' + 'AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c-pVE6K-EdE_twuUCAwE' + 'AATANBgkqhkiG9w0BAQsFAANBAC24z0IdwIVKSlntksllvr6zJepBH5fMnd' + 'fk3XJp10jT6VE-14KNtjh02a56GoraAvJAT5_H67E8GvJ_ocNnB_o' + ) + self.b64_csr = ( + 'MIIBXTCCAQcCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2F' + 'uMRIwEAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECw' + 'wWVW5pdmVyc2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAwwLZXhhbXBsZS5jb' + '20wXDANBgkqhkiG9w0BAQEFAANLADBIAkEArHVztFHtH92ucFJD_N_HW9As' + 'dRsUuHUBBBDlHwNlRd3fp580rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3' + 'C5QIDAQABoCkwJwYJKoZIhvcNAQkOMRowGDAWBgNVHREEDzANggtleGFtcG' + 'xlLmNvbTANBgkqhkiG9w0BAQsFAANBAHJH_O6BtC9aGzEVCMGOZ7z9iIRHW' + 'Szr9x_bOzn7hLwsbXPAgO1QxEwL-X-4g20Gn9XBE1N9W6HCIEut2d8wACg' + ) + + def test_decode_b64_jose_padding_error(self): + from letsencrypt.acme.jose.json_util import decode_b64jose + self.assertRaises(errors.DeserializationError, decode_b64jose, 'x') + + def test_decode_b64_jose_size(self): + from letsencrypt.acme.jose.json_util import decode_b64jose + self.assertEqual('foo', decode_b64jose('Zm9v', size=3)) + self.assertRaises( + errors.DeserializationError, decode_b64jose, 'Zm9v', size=2) + self.assertRaises( + errors.DeserializationError, decode_b64jose, 'Zm9v', size=4) + + def test_decode_b64_jose_minimum_size(self): + from letsencrypt.acme.jose.json_util import decode_b64jose + self.assertEqual('foo', decode_b64jose('Zm9v', size=3, minimum=True)) + self.assertEqual('foo', decode_b64jose('Zm9v', size=2, minimum=True)) + self.assertRaises(errors.DeserializationError, decode_b64jose, + 'Zm9v', size=4, minimum=True) + + def test_decode_hex16(self): + from letsencrypt.acme.jose.json_util import decode_hex16 + self.assertEqual('foo', decode_hex16('666f6f')) + + def test_decode_hex16_minimum_size(self): + from letsencrypt.acme.jose.json_util import decode_hex16 + self.assertEqual('foo', decode_hex16('666f6f', size=3, minimum=True)) + self.assertEqual('foo', decode_hex16('666f6f', size=2, minimum=True)) + self.assertRaises(errors.DeserializationError, decode_hex16, + '666f6f', size=4, minimum=True) + + def test_decode_hex16_odd_length(self): + from letsencrypt.acme.jose.json_util import decode_hex16 + self.assertRaises(errors.DeserializationError, decode_hex16, 'x') + + def test_encode_cert(self): + from letsencrypt.acme.jose.json_util import encode_cert + self.assertEqual(self.b64_cert, encode_cert(CERT)) + + def test_decode_cert(self): + from letsencrypt.acme.jose.json_util import decode_cert + cert = decode_cert(self.b64_cert) + self.assertTrue(isinstance(cert, util.ComparableX509)) + self.assertEqual(cert, CERT) + self.assertRaises(errors.DeserializationError, decode_cert, '') + + def test_encode_csr(self): + from letsencrypt.acme.jose.json_util import encode_csr + self.assertEqual(self.b64_cert, encode_csr(CERT)) + + def test_decode_csr(self): + from letsencrypt.acme.jose.json_util import decode_csr + csr = decode_csr(self.b64_csr) + self.assertTrue(isinstance(csr, util.ComparableX509)) + self.assertEqual(csr, CSR) + self.assertRaises(errors.DeserializationError, decode_csr, '') + + +class TypedJSONObjectWithFieldsTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.acme.jose.json_util import TypedJSONObjectWithFields + + # pylint: disable=missing-docstring,abstract-method + # pylint: disable=too-few-public-methods + + class MockParentTypedJSONObjectWithFields(TypedJSONObjectWithFields): + TYPES = {} + type_field_name = 'type' + + @MockParentTypedJSONObjectWithFields.register + class MockTypedJSONObjectWithFields( + MockParentTypedJSONObjectWithFields): + typ = 'test' + __slots__ = ('foo',) + + @classmethod + def fields_from_json(cls, jobj): + return {'foo': jobj['foo']} + + def fields_to_partial_json(self): + return {'foo': self.foo} + + self.parent_cls = MockParentTypedJSONObjectWithFields + self.msg = MockTypedJSONObjectWithFields(foo='bar') + + def test_to_partial_json(self): + self.assertEqual(self.msg.to_partial_json(), { + 'type': 'test', + 'foo': 'bar', + }) + + def test_from_json_non_dict_fails(self): + for value in [[], (), 5, "asd"]: # all possible input types + self.assertRaises( + errors.DeserializationError, self.parent_cls.from_json, value) + + def test_from_json_dict_no_type_fails(self): + self.assertRaises( + errors.DeserializationError, self.parent_cls.from_json, {}) + + def test_from_json_unknown_type_fails(self): + self.assertRaises(errors.UnrecognizedTypeError, + self.parent_cls.from_json, {'type': 'bar'}) + + def test_from_json_returns_obj(self): + self.assertEqual({'foo': 'bar'}, self.parent_cls.from_json( + {'type': 'test', 'foo': 'bar'})) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/jose/jwa.py b/letsencrypt/acme/jose/jwa.py new file mode 100644 index 000000000..b1f058d77 --- /dev/null +++ b/letsencrypt/acme/jose/jwa.py @@ -0,0 +1,133 @@ +"""JSON Web Algorithm. + +https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40 + +""" +import abc + +from Crypto.Hash import HMAC +from Crypto.Hash import SHA256 +from Crypto.Hash import SHA384 +from Crypto.Hash import SHA512 + +from Crypto.Signature import PKCS1_PSS +from Crypto.Signature import PKCS1_v1_5 + +from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import interfaces +from letsencrypt.acme.jose import jwk + + +class JWA(interfaces.JSONDeSerializable): # pylint: disable=abstract-method + # pylint: disable=too-few-public-methods + # for some reason disable=abstract-method has to be on the line + # above... + """JSON Web Algorithm.""" + + +class JWASignature(JWA): + """JSON Web Signature Algorithm.""" + SIGNATURES = {} + + def __init__(self, name): + self.name = name + + def __eq__(self, other): + return isinstance(other, JWASignature) and self.name == other.name + + @classmethod + def register(cls, signature_cls): + """Register class for JSON deserialization.""" + cls.SIGNATURES[signature_cls.name] = signature_cls + return signature_cls + + def to_partial_json(self): + return self.name + + @classmethod + def from_json(cls, jobj): + return cls.SIGNATURES[jobj] + + @abc.abstractmethod + def sign(self, key, msg): # pragma: no cover + """Sign the ``msg`` using ``key``.""" + raise NotImplementedError() + + @abc.abstractmethod + def verify(self, key, msg, sig): # pragma: no cover + """Verify the ``msg` and ``sig`` using ``key``.""" + raise NotImplementedError() + + def __repr__(self): + return self.name + + +class _JWAHS(JWASignature): + + kty = jwk.JWKOct + + def __init__(self, name, digestmod): + super(_JWAHS, self).__init__(name) + self.digestmod = digestmod + + def sign(self, key, msg): + return HMAC.new(key, msg, self.digestmod).digest() + + def verify(self, key, msg, sig): + """Verify the signature. + + .. warning:: + Does not protect against timing attack (no constant compare). + + """ + return self.sign(key, msg) == sig + + +class _JWARS(JWASignature): + + kty = jwk.JWKRSA + + def __init__(self, name, padding, digestmod): + super(_JWARS, self).__init__(name) + self.padding = padding + self.digestmod = digestmod + + def sign(self, key, msg): + try: + return self.padding.new(key).sign(self.digestmod.new(msg)) + except TypeError: + raise errors.Error('Key has no private part necessary for signing') + except (AttributeError, ValueError): + # ValueError for PS, AttributeError for RS + raise errors.Error('Key too small ({0})'.format(key.size())) + + def verify(self, key, msg, sig): + return self.padding.new(key).verify(self.digestmod.new(msg), sig) + + +class _JWAES(JWASignature): # pylint: disable=abstract-class-not-used + + # TODO: implement ES signatures + + def sign(self, key, msg): # pragma: no cover + raise NotImplementedError() + + def verify(self, key, msg, sig): # pragma: no cover + raise NotImplementedError() + + +HS256 = JWASignature.register(_JWAHS('HS256', SHA256)) +HS384 = JWASignature.register(_JWAHS('HS384', SHA384)) +HS512 = JWASignature.register(_JWAHS('HS512', SHA512)) + +RS256 = JWASignature.register(_JWARS('RS256', PKCS1_v1_5, SHA256)) +RS384 = JWASignature.register(_JWARS('RS384', PKCS1_v1_5, SHA384)) +RS512 = JWASignature.register(_JWARS('RS512', PKCS1_v1_5, SHA512)) + +PS256 = JWASignature.register(_JWARS('PS256', PKCS1_PSS, SHA256)) +PS384 = JWASignature.register(_JWARS('PS384', PKCS1_PSS, SHA384)) +PS512 = JWASignature.register(_JWARS('PS512', PKCS1_PSS, SHA512)) + +ES256 = JWASignature.register(_JWAES('ES256')) +ES256 = JWASignature.register(_JWAES('ES384')) +ES256 = JWASignature.register(_JWAES('ES512')) diff --git a/letsencrypt/acme/jose/jwa_test.py b/letsencrypt/acme/jose/jwa_test.py new file mode 100644 index 000000000..48fdfce0d --- /dev/null +++ b/letsencrypt/acme/jose/jwa_test.py @@ -0,0 +1,105 @@ +"""Tests for letsencrypt.acme.jose.jwa.""" +import os +import pkg_resources +import unittest + +from Crypto.PublicKey import RSA + +from letsencrypt.acme.jose import errors + + +RSA256_KEY = RSA.importKey(pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa256_key.pem'))) +RSA512_KEY = RSA.importKey(pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa512_key.pem'))) +RSA1024_KEY = RSA.importKey(pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa1024_key.pem'))) + + +class JWASignatureTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jwa.JWASignature.""" + + def setUp(self): + from letsencrypt.acme.jose.jwa import JWASignature + + class MockSig(JWASignature): + # pylint: disable=missing-docstring,too-few-public-methods + # pylint: disable=abstract-class-not-used + def sign(self, key, msg): + raise NotImplementedError() + + def verify(self, key, msg, sig): + raise NotImplementedError() + + # pylint: disable=invalid-name + self.Sig1 = MockSig('Sig1') + self.Sig2 = MockSig('Sig2') + + def test_eq(self): + self.assertEqual(self.Sig1, self.Sig1) + self.assertNotEqual(self.Sig1, self.Sig2) + + def test_repr(self): + self.assertEqual('Sig1', repr(self.Sig1)) + self.assertEqual('Sig2', repr(self.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 + from letsencrypt.acme.jose.jwa import RS256 + self.assertTrue(JWASignature.from_json('RS256') is RS256) + + +class JWAHSTest(unittest.TestCase): # pylint: disable=too-few-public-methods + + def test_it(self): + from letsencrypt.acme.jose.jwa import HS256 + sig = ( + "\xceR\xea\xcd\x94\xab\xcf\xfb\xe0\xacA.:\x1a'\x08i\xe2\xc4" + "\r\x85+\x0e\x85\xaeUZ\xd4\xb3\x97zO" + ) + self.assertEqual(HS256.sign('some key', 'foo'), sig) + self.assertTrue(HS256.verify('some key', 'foo', sig) is True) + self.assertTrue(HS256.verify('some key', 'foo', sig + '!') is False) + + +class JWARSTest(unittest.TestCase): + + def test_sign_no_private_part(self): + from letsencrypt.acme.jose.jwa import RS256 + self.assertRaises( + errors.Error, RS256.sign, RSA512_KEY.publickey(), 'foo') + + def test_sign_key_too_small(self): + from letsencrypt.acme.jose.jwa import RS256 + from letsencrypt.acme.jose.jwa import PS256 + self.assertRaises(errors.Error, RS256.sign, RSA256_KEY, 'foo') + self.assertRaises(errors.Error, PS256.sign, RSA256_KEY, 'foo') + self.assertRaises(errors.Error, PS256.sign, RSA512_KEY, 'foo') + + def test_rs(self): + from letsencrypt.acme.jose.jwa import RS256 + sig = ( + '|\xc6\xb2\xa4\xab(\x87\x99\xfa*:\xea\xf8\xa0N&}\x9f\x0f\xc0O' + '\xc6t\xa3\xe6\xfa\xbb"\x15Y\x80Y\xe0\x81\xb8\x88)\xba\x0c\x9c' + '\xa4\x99\x1e\x19&\xd8\xc7\x99S\x97\xfc\x85\x0cOV\xe6\x07\x99' + '\xd2\xb9.>}\xfd' + ) + self.assertEqual(RS256.sign(RSA512_KEY, 'foo'), sig) + # next tests guard that only True/False are return as oppossed + # to e.g. 1/0 + self.assertTrue(RS256.verify(RSA512_KEY, 'foo', sig) is True) + self.assertFalse(RS256.verify(RSA512_KEY, 'foo', sig + '!') is False) + + def test_ps(self): + from letsencrypt.acme.jose.jwa import PS256 + sig = PS256.sign(RSA1024_KEY, 'foo') + self.assertTrue(PS256.verify(RSA1024_KEY, 'foo', sig) is True) + self.assertTrue(PS256.verify(RSA1024_KEY, 'foo', sig + '!') is False) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/jose/jwk.py b/letsencrypt/acme/jose/jwk.py new file mode 100644 index 000000000..ec35baa18 --- /dev/null +++ b/letsencrypt/acme/jose/jwk.py @@ -0,0 +1,140 @@ +"""JSON Web Key.""" +import abc +import binascii + +import Crypto.PublicKey.RSA + +from letsencrypt.acme.jose import b64 +from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import json_util +from letsencrypt.acme.jose import util + + +class JWK(json_util.TypedJSONObjectWithFields): + # pylint: disable=too-few-public-methods + """JSON Web Key.""" + type_field_name = 'kty' + TYPES = {} + + @util.abstractclassmethod + def load(cls, string): # pragma: no cover + """Load key from normalized string form.""" + raise NotImplementedError() + + @abc.abstractmethod + def public(self): # pragma: no cover + """Generate JWK with public key. + + For symmetric cryptosystems, this would return ``self``. + + """ + # TODO: rename publickey to stay consistent with + # HashableRSAKey.publickey + raise NotImplementedError() + + +@JWK.register +class JWKES(JWK): # pragma: no cover + # pylint: disable=abstract-class-not-used + """ES JWK. + + .. warning:: This is not yet implemented! + + """ + typ = 'ES' + + def fields_to_partial_json(self): + raise NotImplementedError() + + @classmethod + def fields_from_json(cls, jobj): + raise NotImplementedError() + + @classmethod + def load(cls, string): + raise NotImplementedError() + + def public(self): + raise NotImplementedError() + + +@JWK.register +class JWKOct(JWK): + """Symmetric JWK.""" + typ = 'oct' + __slots__ = ('key',) + + 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 + # the algorithm used. + return {'k': self.key} + + @classmethod + def fields_from_json(cls, jobj): + return cls(key=jobj['k']) + + @classmethod + def load(cls, string): + return cls(key=string) + + def public(self): + return self + + +@JWK.register +class JWKRSA(JWK): + """RSA JWK. + + :ivar key: `Crypto.PublicKey.RSA` wrapped in `.HashableRSAKey` + + """ + typ = 'RSA' + __slots__ = ('key',) + + @classmethod + def _encode_param(cls, data): + def _leading_zeros(arg): + if len(arg) % 2: + return '0' + arg + return arg + + return b64.b64encode(binascii.unhexlify( + _leading_zeros(hex(data)[2:].rstrip('L')))) + + @classmethod + def _decode_param(cls, data): + try: + return long(binascii.hexlify(json_util.decode_b64jose(data)), 16) + except ValueError: # invalid literal for long() with base 16 + raise errors.DeserializationError() + + @classmethod + def load(cls, string): + """Load RSA key from string. + + :param str string: RSA key in string form. + + :returns: + :rtype: :class:`JWKRSA` + + """ + 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=util.HashableRSAKey( + Crypto.PublicKey.RSA.construct( + (cls._decode_param(jobj['n']), + cls._decode_param(jobj['e']))))) + + 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 new file mode 100644 index 000000000..1328528e8 --- /dev/null +++ b/letsencrypt/acme/jose/jwk_test.py @@ -0,0 +1,107 @@ +"""Tests for letsencrypt.acme.jose.jwk.""" +import os +import pkg_resources +import unittest + +from Crypto.PublicKey import RSA + +from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import util + + +RSA256_KEY = util.HashableRSAKey(RSA.importKey(pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa256_key.pem')))) +RSA512_KEY = util.HashableRSAKey(RSA.importKey(pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa512_key.pem')))) + + +class JWKOctTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jwk.JWKOct.""" + + def setUp(self): + from letsencrypt.acme.jose.jwk import JWKOct + self.jwk = JWKOct(key='foo') + self.jobj = {'kty': 'oct', 'k': 'foo'} + + 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')) + + def test_public(self): + self.assertTrue(self.jwk.public() is self.jwk) + + +class JWKRSATest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jwk.JWKRSA.""" + + 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.jwk256json = { + 'kty': 'RSA', + 'e': 'AQAB', + 'n': 'm2Fylv-Uz7trgTW8EBHP3FQSMeZs2GNQ6VRo1sIVJEk', + } + self.jwk512 = JWKRSA(key=RSA512_KEY.publickey()) + self.jwk512json = { + 'kty': 'RSA', + 'e': 'AQAB', + 'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5' + '80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q', + } + + def test_equals(self): + self.assertEqual(self.jwk256, self.jwk256) + self.assertEqual(self.jwk512, self.jwk512) + + def test_not_equals(self): + self.assertNotEqual(self.jwk256, self.jwk512) + self.assertNotEqual(self.jwk512, self.jwk256) + + def test_load(self): + from letsencrypt.acme.jose.jwk import JWKRSA + self.assertEqual( + JWKRSA(key=util.HashableRSAKey(RSA256_KEY)), JWKRSA.load( + pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa256_key.pem')))) + + def test_public(self): + self.assertEqual(self.jwk256, self.jwk256_private.public()) + + 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 + self.assertEqual(self.jwk256, JWK.from_json(self.jwk256json)) + # 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 + self.assertRaises(errors.DeserializationError, JWK.from_json, + {'kty': 'RSA', 'e': 'AQAB', 'n': ''}) + self.assertRaises(errors.DeserializationError, JWK.from_json, + {'kty': 'RSA', 'e': 'AQAB', 'n': '1'}) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/jose/jws.py b/letsencrypt/acme/jose/jws.py new file mode 100644 index 000000000..fc37227fd --- /dev/null +++ b/letsencrypt/acme/jose/jws.py @@ -0,0 +1,406 @@ +"""JOSE Web Signature.""" +import argparse +import base64 +import sys + +import M2Crypto + +from letsencrypt.acme.jose import b64 +from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import json_util +from letsencrypt.acme.jose import jwa +from letsencrypt.acme.jose import jwk +from letsencrypt.acme.jose import util + + +class MediaType(object): + """MediaType field encoder/decoder.""" + + PREFIX = 'application/' + """MIME Media Type and Content Type prefix.""" + + @classmethod + def decode(cls, value): + """Decoder.""" + # 4.1.10 + if '/' not in value: + if ';' in value: + raise errors.DeserializationError('Unexpected semi-colon') + return cls.PREFIX + value + return value + + @classmethod + def encode(cls, value): + """Encoder.""" + # 4.1.10 + if ';' not in value: + assert value.startswith(cls.PREFIX) + return value[len(cls.PREFIX):] + return value + + +class Header(json_util.JSONObjectWithFields): + """JOSE Header. + + .. warning:: This class supports **only** Registered Header + 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_partial_json` + appropriately. + + .. warning:: This class does not support any extensions through + the "crit" (Critical) Header Parameter (4.1.11) and as a + conforming implementation, :meth:`from_json` treats its + occurence as an error. Please subclass if you seek for + a diferent behaviour. + + :ivar x5tS256: "x5t#S256" + :ivar str typ: MIME Media Type, inc. :const:`MediaType.PREFIX`. + :ivar str cty: Content-Type, inc. :const:`MediaType.PREFIX`. + + """ + alg = json_util.Field( + 'alg', decoder=jwa.JWASignature.from_json, omitempty=True) + jku = json_util.Field('jku', omitempty=True) + jwk = json_util.Field('jwk', decoder=jwk.JWK.from_json, omitempty=True) + kid = json_util.Field('kid', omitempty=True) + x5u = json_util.Field('x5u', omitempty=True) + x5c = json_util.Field('x5c', omitempty=True, default=()) + x5t = json_util.Field( + 'x5t', decoder=json_util.decode_b64jose, omitempty=True) + x5tS256 = json_util.Field( + 'x5t#S256', decoder=json_util.decode_b64jose, omitempty=True) + typ = json_util.Field('typ', encoder=MediaType.encode, + decoder=MediaType.decode, omitempty=True) + cty = json_util.Field('cty', encoder=MediaType.encode, + decoder=MediaType.decode, omitempty=True) + crit = json_util.Field('crit', omitempty=True, default=()) + + def not_omitted(self): + """Fields that would not be omitted in the JSON object.""" + return dict((name, getattr(self, name)) + for name, field in self._fields.iteritems() + if not field.omit(getattr(self, name))) + + def __add__(self, other): + if not isinstance(other, type(self)): + raise TypeError('Header cannot be added to: {0}'.format( + type(other))) + + not_omitted_self = self.not_omitted() + not_omitted_other = other.not_omitted() + + if set(not_omitted_self).intersection(not_omitted_other): + raise TypeError('Addition of overlapping headers not defined') + + not_omitted_self.update(not_omitted_other) + return type(self)(**not_omitted_self) # pylint: disable=star-args + + def find_key(self): + """Find key based on header. + + .. todo:: Supports only "jwk" header parameter lookup. + + :returns: (Public) key found in the header. + :rtype: :class:`letsencrypt.acme.jose.jwk.JWK` + + :raises letsencrypt.acme.jose.errors.Error: if key could not be found + + """ + if self.jwk is None: + raise errors.Error('No key found') + return self.jwk + + @crit.decoder + def crit(unused_value): + # pylint: disable=missing-docstring,no-self-argument,no-self-use + raise errors.DeserializationError( + '"crit" is not supported, please subclass') + + # x5c does NOT use JOSE Base64 (4.1.6) + + @x5c.encoder + def x5c(value): # pylint: disable=missing-docstring,no-self-argument + return [base64.b64encode(cert.as_der()) for cert in value] + + @x5c.decoder + def x5c(value): # pylint: disable=missing-docstring,no-self-argument + try: + return tuple(util.ComparableX509(M2Crypto.X509.load_cert_der_string( + base64.b64decode(cert))) for cert in value) + except M2Crypto.X509.X509Error as error: + raise errors.DeserializationError(error) + + +class Signature(json_util.JSONObjectWithFields): + """JWS Signature. + + :ivar combined: Combined Header (protected and unprotected, + :class:`Header`). + :ivar unicode protected: JWS protected header (Jose Base-64 decoded). + :ivar header: JWS Unprotected Header (:class:`Header`). + :ivar str signature: The signature. + + """ + header_cls = Header + + __slots__ = ('combined',) + protected = json_util.Field( + 'protected', omitempty=True, default='', + decoder=json_util.decode_b64jose, encoder=b64.b64encode) # TODO: utf-8? + header = json_util.Field( + 'header', omitempty=True, default=header_cls(), + decoder=header_cls.from_json) + signature = json_util.Field( + 'signature', decoder=json_util.decode_b64jose, + encoder=b64.b64encode) + + def __init__(self, **kwargs): + if 'combined' not in kwargs: + kwargs = self._with_combined(kwargs) + super(Signature, self).__init__(**kwargs) + assert self.combined.alg is not None + + @classmethod + def _with_combined(cls, kwargs): + assert 'combined' not in kwargs + header = kwargs.get('header', cls._fields['header'].default) + protected = kwargs.get('protected', cls._fields['protected'].default) + + if protected: + combined = header + cls.header_cls.json_loads(protected) + else: + combined = header + + kwargs['combined'] = combined + return kwargs + + def verify(self, payload, key=None): + """Verify. + + :param key: Key used for verification. + :type key: :class:`letsencrypt.acme.jose.jwk.JWK` + + """ + key = self.combined.find_key() if key is None else key + return self.combined.alg.verify( + key=key.key, sig=self.signature, + msg=(b64.b64encode(self.protected) + '.' + + b64.b64encode(payload))) + + @classmethod + def sign(cls, payload, key, alg, include_jwk=True, + protect=frozenset(), **kwargs): + """Sign. + + :param key: Key for signature. + :type key: :class:`letsencrypt.acme.jose.jwk.JWK` + + """ + assert isinstance(key, alg.kty) + + header_params = kwargs + header_params['alg'] = alg + if include_jwk: + header_params['jwk'] = key.public() + + assert set(header_params).issubset(cls.header_cls._fields) + assert protect.issubset(cls.header_cls._fields) + + protected_params = {} + for header in protect: + protected_params[header] = header_params.pop(header) + if protected_params: + # pylint: disable=star-args + protected = cls.header_cls(**protected_params).json_dumps() + else: + protected = '' + + header = cls.header_cls(**header_params) # pylint: disable=star-args + signature = alg.sign(key.key, b64.b64encode(protected) + + '.' + b64.b64encode(payload)) + + return cls(protected=protected, header=header, signature=signature) + + 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 + + @classmethod + def fields_from_json(cls, jobj): + fields = super(Signature, cls).fields_from_json(jobj) + fields_with_combined = cls._with_combined(fields) + if 'alg' not in fields_with_combined['combined'].not_omitted(): + raise errors.DeserializationError('alg not present') + return fields_with_combined + + +class JWS(json_util.JSONObjectWithFields): + """JSON Web Signature. + + from letsencrypt.acme.jose import interfaces + + :ivar str payload: JWS Payload. + :ivar str signaturea: JWS Signatures. + + """ + __slots__ = ('payload', 'signatures') + + def verify(self, key=None): + """Verify.""" + return all(sig.verify(self.payload, key) for sig in self.signatures) + + @classmethod + def sign(cls, payload, **kwargs): + """Sign.""" + return cls(payload=payload, signatures=( + Signature.sign(payload=payload, **kwargs),)) + + @property + def signature(self): + """Get a singleton signature. + + :rtype: :class:`Signature` + + """ + assert len(self.signatures) == 1 + return self.signatures[0] + + def to_compact(self): + """Compact serialization.""" + assert len(self.signatures) == 1 + + assert 'alg' not in self.signature.header.not_omitted() + # ... it must be in protected + + return '{0}.{1}.{2}'.format( + b64.b64encode(self.signature.protected), + b64.b64encode(self.payload), + b64.b64encode(self.signature.signature)) + + @classmethod + def from_compact(cls, compact): + """Compact deserialization.""" + try: + protected, payload, signature = compact.split('.') + except ValueError: + raise errors.DeserializationError( + 'Compact JWS serialization should comprise of exactly' + ' 3 dot-separated components') + sig = Signature(protected=json_util.decode_b64jose(protected), + signature=json_util.decode_b64jose(signature)) + return cls(payload=json_util.decode_b64jose(payload), signatures=(sig,)) + + 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_partial_json() + ret['payload'] = payload + return ret + else: + return { + 'payload': payload, + 'signatures': self.signatures, + } + + @classmethod + def from_json(cls, jobj): + if 'signature' in jobj and 'signatures' in jobj: + raise errors.DeserializationError('Flat mixed with non-flat') + elif 'signature' in jobj: # flat + return cls(payload=json_util.decode_b64jose(jobj.pop('payload')), + signatures=(Signature.from_json(jobj),)) + else: + return cls(payload=json_util.decode_b64jose(jobj['payload']), + signatures=tuple(Signature.from_json(sig) + for sig in jobj['signatures'])) + +class CLI(object): + """JWS CLI.""" + + @classmethod + def sign(cls, args): + """Sign.""" + key = args.alg.kty.load(args.key.read()) + if args.protect is None: + args.protect = [] + if args.compact: + args.protect.append('alg') + + sig = JWS.sign(payload=sys.stdin.read(), key=key, alg=args.alg, + protect=set(args.protect)) + + if args.compact: + print sig.to_compact() + else: # JSON + print sig.json_dumps_pretty() + + @classmethod + def verify(cls, args): + """Verify.""" + if args.compact: + sig = JWS.from_compact(sys.stdin.read()) + else: # JSON + try: + sig = JWS.json_loads(sys.stdin.read()) + except errors.Error as error: + print error + return -1 + + if args.key is not None: + assert args.kty is not None + key = args.kty.load(args.key.read()) + else: + key = None + + sys.stdout.write(sig.payload) + return int(not sig.verify(key=key)) + + @classmethod + def _alg_type(cls, arg): + return jwa.JWASignature.from_json(arg) + + @classmethod + def _header_type(cls, arg): + assert arg in Signature.header_cls._fields + return arg + + @classmethod + def _kty_type(cls, arg): + assert arg in jwk.JWK.TYPES + return jwk.JWK.TYPES[arg] + + @classmethod + def run(cls, args=sys.argv[1:]): + """Parse arguments and sign/verify.""" + parser = argparse.ArgumentParser() + parser.add_argument('--compact', action='store_true') + + subparsers = parser.add_subparsers() + parser_sign = subparsers.add_parser('sign') + parser_sign.set_defaults(func=cls.sign) + parser_sign.add_argument( + '-k', '--key', type=argparse.FileType(), required=True) + parser_sign.add_argument( + '-a', '--alg', type=cls._alg_type, default=jwa.RS256) + parser_sign.add_argument( + '-p', '--protect', action='append', type=cls._header_type) + + parser_verify = subparsers.add_parser('verify') + parser_verify.set_defaults(func=cls.verify) + parser_verify.add_argument( + '-k', '--key', type=argparse.FileType(), required=False) + parser_verify.add_argument( + '--kty', type=cls._kty_type, required=False) + + parsed = parser.parse_args(args) + return parsed.func(parsed) + + +if __name__ == '__main__': + exit(CLI.run()) # pragma: no cover diff --git a/letsencrypt/acme/jose/jws_test.py b/letsencrypt/acme/jose/jws_test.py new file mode 100644 index 000000000..dca61c3d9 --- /dev/null +++ b/letsencrypt/acme/jose/jws_test.py @@ -0,0 +1,241 @@ +"""Tests for letsencrypt.acme.jose.jws.""" +import base64 +import os +import pkg_resources +import unittest + +import Crypto.PublicKey.RSA +import M2Crypto +import mock + +from letsencrypt.acme.jose import b64 +from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import jwa +from letsencrypt.acme.jose import jwk +from letsencrypt.acme.jose import util + + +CERT = util.ComparableX509(M2Crypto.X509.load_cert( + pkg_resources.resource_filename( + 'letsencrypt.client.tests', 'testdata/cert.pem'))) +RSA512_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa512_key.pem'))) + + +class MediaTypeTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jws.MediaType.""" + + def test_decode(self): + from letsencrypt.acme.jose.jws import MediaType + self.assertEqual('application/app', MediaType.decode('application/app')) + self.assertEqual('application/app', MediaType.decode('app')) + self.assertRaises( + errors.DeserializationError, MediaType.decode, 'app;foo') + + def test_encode(self): + from letsencrypt.acme.jose.jws import MediaType + self.assertEqual('app', MediaType.encode('application/app')) + self.assertEqual('application/app;foo', + MediaType.encode('application/app;foo')) + + +class HeaderTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jws.Header.""" + + def setUp(self): + from letsencrypt.acme.jose.jws import Header + self.header1 = Header(jwk='foo') + self.header2 = Header(jwk='bar') + self.crit = Header(crit=('a', 'b')) + self.empty = Header() + + def test_add_non_empty(self): + from letsencrypt.acme.jose.jws import Header + self.assertEqual(Header(jwk='foo', crit=('a', 'b')), + self.header1 + self.crit) + + def test_add_empty(self): + self.assertEqual(self.header1, self.header1 + self.empty) + self.assertEqual(self.header1, self.empty + self.header1) + + def test_add_overlapping_error(self): + self.assertRaises(TypeError, self.header1.__add__, self.header2) + + def test_add_wrong_type_error(self): + self.assertRaises(TypeError, self.header1.__add__, 'xxx') + + def test_crit_decode_always_errors(self): + from letsencrypt.acme.jose.jws import Header + self.assertRaises(errors.DeserializationError, Header.from_json, + {'crit': ['a', 'b']}) + + def test_x5c_decoding(self): + from letsencrypt.acme.jose.jws import Header + header = Header(x5c=(CERT, CERT)) + 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)) + jobj['x5c'][0] = base64.b64encode('xxx' + CERT.as_der()) + self.assertRaises(errors.DeserializationError, Header.from_json, jobj) + + def test_find_key(self): + self.assertEqual('foo', self.header1.find_key()) + self.assertEqual('bar', self.header2.find_key()) + self.assertRaises(errors.Error, self.crit.find_key) + + +class SignatureTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jws.Signature.""" + + def test_from_json(self): + from letsencrypt.acme.jose.jws import Header + from letsencrypt.acme.jose.jws import Signature + self.assertEqual( + Signature(signature='foo', header=Header(alg=jwa.RS256)), + Signature.from_json( + {'signature': 'Zm9v', 'header': {'alg': 'RS256'}})) + + def test_from_json_no_alg_error(self): + from letsencrypt.acme.jose.jws import Signature + self.assertRaises(errors.DeserializationError, + Signature.from_json, {'signature': 'foo'}) + + +class JWSTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jws.JWS.""" + + def setUp(self): + self.privkey = jwk.JWKRSA(key=RSA512_KEY) + self.pubkey = self.privkey.public() + + from letsencrypt.acme.jose.jws import JWS + self.unprotected = JWS.sign( + payload='foo', key=self.privkey, alg=jwa.RS256) + self.protected = JWS.sign( + payload='foo', key=self.privkey, alg=jwa.RS256, + protect=frozenset(['jwk', 'alg'])) + self.mixed = JWS.sign( + payload='foo', key=self.privkey, alg=jwa.RS256, + protect=frozenset(['alg'])) + + def test_pubkey_jwk(self): + self.assertEqual(self.unprotected.signature.combined.jwk, self.pubkey) + self.assertEqual(self.protected.signature.combined.jwk, self.pubkey) + self.assertEqual(self.mixed.signature.combined.jwk, self.pubkey) + + def test_sign_unprotected(self): + self.assertTrue(self.unprotected.verify()) + + def test_sign_protected(self): + self.assertTrue(self.protected.verify()) + + def test_sign_mixed(self): + self.assertTrue(self.mixed.verify()) + + def test_compact_lost_unprotected(self): + compact = self.mixed.to_compact() + self.assertEqual( + 'eyJhbGciOiAiUlMyNTYifQ.Zm9v.OHdxFVj73l5LpxbFp1AmYX4yJM0Pyb' + '_893n1zQjpim_eLS5J1F61lkvrCrCDErTEJnBGOGesJ72M7b6Ve1cAJA', + compact) + + from letsencrypt.acme.jose.jws import JWS + mixed = JWS.from_compact(compact) + + self.assertNotEqual(self.mixed, mixed) + self.assertEqual( + set(['alg']), set(mixed.signature.combined.not_omitted())) + + def test_from_compact_missing_components(self): + from letsencrypt.acme.jose.jws import JWS + self.assertRaises(errors.DeserializationError, JWS.from_compact, '.') + + def test_json_omitempty(self): + 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'].to_json() + + from letsencrypt.acme.jose.jws import JWS + self.assertEqual(JWS.from_json(protected_jobj), self.protected) + self.assertEqual(JWS.from_json(unprotected_jobj), self.unprotected) + + def test_json_flat(self): + jobj_to = { + 'signature': b64.b64encode(self.mixed.signature.signature), + 'payload': b64.b64encode('foo'), + 'header': self.mixed.signature.header, + 'protected': b64.b64encode(self.mixed.signature.protected), + } + jobj_from = jobj_to.copy() + jobj_from['header'] = jobj_from['header'].to_json() + + 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)) + + def test_json_not_flat(self): + jobj_to = { + 'signatures': (self.mixed.signature,), + 'payload': b64.b64encode('foo'), + } + jobj_from = jobj_to.copy() + jobj_from['signatures'] = [jobj_to['signatures'][0].to_json()] + + 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)) + + def test_from_json_mixed_flat(self): + from letsencrypt.acme.jose.jws import JWS + 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): + + def setUp(self): + self.key_path = pkg_resources.resource_filename( + __name__, os.path.join('testdata', 'rsa512_key.pem')) + + def test_unverified(self): + from letsencrypt.acme.jose.jws import CLI + with mock.patch('sys.stdin') as sin: + sin.read.return_value = '{"payload": "foo", "signature": "xxx"}' + with mock.patch('sys.stdout'): + self.assertEqual(-1, CLI.run(['verify'])) + + def test_json(self): + from letsencrypt.acme.jose.jws import CLI + + with mock.patch('sys.stdin') as sin: + sin.read.return_value = 'foo' + with mock.patch('sys.stdout') as sout: + CLI.run(['sign', '-k', self.key_path, '-a', 'RS256', + '-p', 'jwk']) + sin.read.return_value = sout.write.mock_calls[0][1][0] + self.assertEqual(0, CLI.run(['verify'])) + + def test_compact(self): + from letsencrypt.acme.jose.jws import CLI + + with mock.patch('sys.stdin') as sin: + sin.read.return_value = 'foo' + with mock.patch('sys.stdout') as sout: + CLI.run(['--compact', 'sign', '-k', self.key_path]) + sin.read.return_value = sout.write.mock_calls[0][1][0] + self.assertEqual(0, CLI.run([ + '--compact', 'verify', '--kty', 'RSA', + '-k', self.key_path])) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/jose/testdata/README b/letsencrypt/acme/jose/testdata/README new file mode 100644 index 000000000..4b37ae921 --- /dev/null +++ b/letsencrypt/acme/jose/testdata/README @@ -0,0 +1,10 @@ +The following command has been used to generate test keys: + + for x in 256 512 1024; do openssl genrsa -out rsa${k}_key.pem $k; done + +and for the CSR: + + python -c from 'letsencrypt.client.crypto_util import make_csr; + import pkg_resources; open("csr2.pem", + "w").write(make_csr(pkg_resources.resource_string("letsencrypt.client.tests", + "testdata/rsa512_key.pem"), ["example2.com"])[0])' diff --git a/letsencrypt/acme/jose/testdata/csr2.pem b/letsencrypt/acme/jose/testdata/csr2.pem new file mode 100644 index 000000000..bd059a448 --- /dev/null +++ b/letsencrypt/acme/jose/testdata/csr2.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBXzCCAQkCAQAwejELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIw +EAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECwwWVW5pdmVy +c2l0eSBvZiBNaWNoaWdhbjEVMBMGA1UEAwwMZXhhbXBsZTIuY29tMFwwDQYJKoZI +hvcNAQEBBQADSwAwSAJBAPS2EXFRNza/qpXnnBHF/CcFQ543htV+7nLAmrLrmTNH +tPXJmLlM8SJDIzv/ceAFXL110VzxFfi81lpH5E5c0TMCAwEAAaAqMCgGCSqGSIb3 +DQEJDjEbMBkwFwYDVR0RBBAwDoIMZXhhbXBsZTIuY29tMA0GCSqGSIb3DQEBCwUA +A0EAwsdL4FLIgISKV4vXFmc6QTV7CjBiP4XmPFbeN+gMFdR7QcnRyyxSpXxB0v8Z +oqYboP5LGFt9zC6/9GyjcI9/IQ== +-----END CERTIFICATE REQUEST----- diff --git a/letsencrypt/acme/jose/testdata/rsa1024_key.pem b/letsencrypt/acme/jose/testdata/rsa1024_key.pem new file mode 100644 index 000000000..de5339d03 --- /dev/null +++ b/letsencrypt/acme/jose/testdata/rsa1024_key.pem @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQCaifO0fGlcAcjjcYEAPYcIL0Hf0KiNa9VCJ14RBdlZxLWRrVFi +4tdNCKSKqzKuKrrA8DWd4PHFD7UpLyRrPPXY6GozAyCT+5UFBClGJ2KyNKu+eU6/ +w4C1kpO4lpeXs8ptFc1lA9P8V1M/MkWzTE402nPNK0uUmZNo2tsFpGJUSQIDAQAB +AoGAFjLWxQhSAhtnhfRZ+XTdHrnbFpFchOQGgDgzdPKIJDLzefeRh0jacIBbUmgB +Ia+Vn/1hVkpnsEzvUvkonBbnoYWlYVQdpNTmrrew7SOztf8/1fYCsSkyDAvqGTXc +TmHM0PaLS+junoWcKOvQRVb0N3k+43OnBkr2b393Sx30qGECQQDNO2IBWOsYs8cB +CZQAZs8zBlbwBFZibqovqpLwXt9adBIsT9XzgagGbJMpzSuoHTUn3QqqJd9uHD8X +UTmmoh4NAkEAwMRauo+PlNj8W1lusflko52KL17+E5cmeOERM2xvhZNpO7d3/1ak +Co9dxVMicrYSh7jXbcXFNt3xNDTv6Dg8LQJAPuJwMDt/pc0IMCAwMkNOP7M0lkyt +73E7QmnAplhblcq0+tDnnLpgsr84BHnyY4u3iuRm7SW3pXSQPGPOB2nrTQJANBXa +HgakWSe4KEal7ljgpITwzZPxOwHgV1EZALgP+hu2l3gfaFLUyDWstKCd8jjYEOwU +6YhCnWyiu+SB3lEzkQJBAJapJpfypFyY8kQNYlYILLBcPu5fmy3QUZKHJ4L3rIVJ +c2UTLMeBBgGFHT04CtWntmjwzSv+V6lwiCxKXsIUySc= +-----END RSA PRIVATE KEY----- diff --git a/letsencrypt/acme/jose/testdata/rsa256_key.pem b/letsencrypt/acme/jose/testdata/rsa256_key.pem new file mode 100644 index 000000000..659274d1d --- /dev/null +++ b/letsencrypt/acme/jose/testdata/rsa256_key.pem @@ -0,0 +1,6 @@ +-----BEGIN RSA PRIVATE KEY----- +MIGrAgEAAiEAm2Fylv+Uz7trgTW8EBHP3FQSMeZs2GNQ6VRo1sIVJEkCAwEAAQIh +AJT0BA/xD01dFCAXzSNyj9nfSZa3NpqzJZZn/eOm7vghAhEAzUVNZn4lLLBD1R6N +E8TKNQIRAMHHyn3O5JeY36lwKwkUlEUCEAliRauN0L0+QZuYjfJ9aJECEGx4dru3 +rTPCyighdqWNlHUCEQCiLjlwSRtWgmMBudCkVjzt +-----END RSA PRIVATE KEY----- diff --git a/letsencrypt/client/tests/testdata/rsa256_key.pem b/letsencrypt/acme/jose/testdata/rsa512_key.pem similarity index 100% rename from letsencrypt/client/tests/testdata/rsa256_key.pem rename to letsencrypt/acme/jose/testdata/rsa512_key.pem diff --git a/letsencrypt/acme/jose/util.py b/letsencrypt/acme/jose/util.py new file mode 100644 index 000000000..2312055f7 --- /dev/null +++ b/letsencrypt/acme/jose/util.py @@ -0,0 +1,149 @@ +"""JOSE utilities.""" +import collections + + +class abstractclassmethod(classmethod): + # pylint: disable=invalid-name,too-few-public-methods + """Descriptor for an abstract classmethod. + + It augments the :mod:`abc` framework with an abstract + classmethod. This is implemented as :class:`abc.abstractclassmethod` + in the standard Python library starting with version 3.2. + + This particular implementation, allegedly based on Python 3.3 source + code, is stolen from + http://stackoverflow.com/questions/11217878/python-2-7-combine-abc-abstractmethod-and-classmethod. + + """ + __isabstractmethod__ = True + + def __init__(self, target): + target.__isabstractmethod__ = True + super(abstractclassmethod, self).__init__(target) + + +class ComparableX509(object): # pylint: disable=too-few-public-methods + """Wrapper for M2Crypto.X509.* objects that supports __eq__. + + Wraps around: + + - :class:`M2Crypto.X509.X509` + - :class:`M2Crypto.X509.Request` + + """ + def __init__(self, wrapped): + self._wrapped = wrapped + + def __getattr__(self, name): + return getattr(self._wrapped, name) + + def __eq__(self, other): + 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.""" + + __slots__ = () + """Must be overriden in subclasses.""" + + def __init__(self, **kwargs): + if set(kwargs) != set(self.__slots__): + raise TypeError( + '__init__() takes exactly the following arguments: {0} ' + '({1} given)'.format(', '.join(self.__slots__), + ', '.join(kwargs) if kwargs else 'none')) + 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) + except AttributeError: + raise KeyError(key) + + def __iter__(self): + return iter(self.__slots__) + + def __len__(self): + return len(self.__slots__) + + def __hash__(self): + return hash(tuple(getattr(self, slot) for slot in self.__slots__)) + + def __setattr__(self, name, value): + raise AttributeError("can't set attribute") + + def __repr__(self): + return '{0}({1})'.format(self.__class__.__name__, ', '.join( + '{0}={1!r}'.format(key, value) for key, value in self.iteritems())) + + +class frozendict(collections.Mapping, collections.Hashable): + # pylint: disable=invalid-name,too-few-public-methods + """Frozen dictionary.""" + __slots__ = ('_items', '_keys') + + def __init__(self, *args, **kwargs): + if kwargs and not args: + items = dict(kwargs) + elif len(args) == 1 and isinstance(args[0], collections.Mapping): + items = args[0] + else: + raise TypeError() + # TODO: support generators/iterators + + object.__setattr__(self, '_items', items) + object.__setattr__(self, '_keys', tuple(sorted(items.iterkeys()))) + + def __getitem__(self, key): + return self._items[key] + + def __iter__(self): + return iter(self._keys) + + def __len__(self): + return len(self._items) + + def __hash__(self): + return hash(tuple((key, value) for key, value in self.items())) + + def __getattr__(self, name): + try: + return self._items[name] + except KeyError: + raise AttributeError(name) + + def __setattr__(self, name, value): + raise AttributeError("can't set attribute") + + def __repr__(self): + return 'frozendict({0})'.format(', '.join( + '{0}={1!r}'.format(key, value) for key, value in self.iteritems())) diff --git a/letsencrypt/acme/jose/util_test.py b/letsencrypt/acme/jose/util_test.py new file mode 100644 index 000000000..fc75497e0 --- /dev/null +++ b/letsencrypt/acme/jose/util_test.py @@ -0,0 +1,140 @@ +"""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.""" + + def setUp(self): + # pylint: disable=invalid-name,too-few-public-methods + # pylint: disable=missing-docstring + from letsencrypt.acme.jose.util import ImmutableMap + + class A(ImmutableMap): + __slots__ = ('x', 'y') + + class B(ImmutableMap): + __slots__ = ('x', 'y') + + self.A = A + self.B = B + + self.a1 = self.A(x=1, y=2) + self.a1_swap = self.A(y=2, x=1) + 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') + + def test_order_of_args_does_not_matter(self): + self.assertEqual(self.a1, self.a1_swap) + + def test_type_error_on_missing(self): + self.assertRaises(TypeError, self.A, x=1) + self.assertRaises(TypeError, self.A, y=2) + + def test_type_error_on_unrecognized(self): + self.assertRaises(TypeError, self.A, x=1, z=2) + self.assertRaises(TypeError, self.A, x=1, y=2, z=3) + + def test_get_attr(self): + self.assertEqual(1, self.a1.x) + self.assertEqual(2, self.a1.y) + self.assertEqual(1, self.a1_swap.x) + self.assertEqual(2, self.a1_swap.y) + + def test_set_attr_raises_attribute_error(self): + self.assertRaises( + AttributeError, functools.partial(self.a1.__setattr__, 'x'), 10) + + def test_equal(self): + self.assertEqual(self.a1, self.a1) + self.assertEqual(self.a2, self.a2) + self.assertNotEqual(self.a1, self.a2) + + def test_hash(self): + self.assertEqual(hash((1, 2)), hash(self.a1)) + + def test_unhashable(self): + self.assertRaises(TypeError, self.A(x=1, y={}).__hash__) + + def test_repr(self): + self.assertEqual('A(x=1, y=2)', repr(self.a1)) + self.assertEqual('A(x=1, y=2)', repr(self.a1_swap)) + self.assertEqual('B(x=1, y=2)', repr(self.b)) + self.assertEqual("B(x='foo', y='bar')", repr(self.B(x='foo', y='bar'))) + + +class frozendictTest(unittest.TestCase): # pylint: disable=invalid-name + """Tests for letsencrypt.acme.jose.util.frozendict.""" + + def setUp(self): + from letsencrypt.acme.jose.util import frozendict + self.fdict = frozendict(x=1, y='2') + + def test_init_dict(self): + from letsencrypt.acme.jose.util import frozendict + self.assertEqual(self.fdict, frozendict({'x': 1, 'y': '2'})) + + def test_init_other_raises_type_error(self): + from letsencrypt.acme.jose.util import frozendict + # specifically fail for generators... + self.assertRaises(TypeError, frozendict, {'a': 'b'}.iteritems()) + + def test_len(self): + self.assertEqual(2, len(self.fdict)) + + def test_hash(self): + self.assertEqual(1278944519403861804, hash(self.fdict)) + + def test_getattr_proxy(self): + self.assertEqual(1, self.fdict.x) + self.assertEqual('2', self.fdict.y) + + def test_getattr_raises_attribute_error(self): + self.assertRaises(AttributeError, self.fdict.__getattr__, 'z') + + def test_setattr_immutable(self): + self.assertRaises(AttributeError, self.fdict.__setattr__, 'z', 3) + + def test_repr(self): + self.assertEqual("frozendict(x=1, y='2')", repr(self.fdict)) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py index 64f7a0350..41b7389a7 100644 --- a/letsencrypt/acme/messages.py +++ b/letsencrypt/acme/messages.py @@ -1,6 +1,25 @@ -"""ACME protocol messages.""" -import json +"""ACME protocol v00 messages. +.. warning:: This module is an implementation of the draft `ACME + protocol version 00`_, and not the "RESTified" `ACME protocol version + 01`_ or later. It should work with `older Node.js implementation`_, + but will definitely not work with Boulder_. It is kept for reference + purposes only. + + +.. _`ACME protocol version 00`: + https://github.com/letsencrypt/acme-spec/blob/v00/draft-barnes-acme.md + +.. _`ACME protocol version 01`: + https://github.com/letsencrypt/acme-spec/blob/v01/draft-barnes-acme.md + +.. _Boulder: https://github.com/letsencrypt/boulder + +.. _`older Node.js implementation`: + https://github.com/letsencrypt/node-acme/commit/f42aa5b7fad4cd2fc289653c4ab14f18052367b3 + + +""" import jsonschema from letsencrypt.acme import challenges @@ -10,10 +29,12 @@ from letsencrypt.acme import other from letsencrypt.acme import util -class Message(util.TypedACMEObject): - # _fields_to_json | pylint: disable=abstract-method +class Message(jose.TypedJSONObjectWithFields): + # _fields_to_partial_json | pylint: disable=abstract-method + # pylint: disable=too-few-public-methods """ACME message.""" TYPES = {} + type_field_name = "type" schema = NotImplemented """JSON schema the object is tested against in :meth:`from_json`. @@ -24,28 +45,6 @@ class Message(util.TypedACMEObject): """ - @classmethod - def get_msg_cls(cls, jobj): - """Get the registered class for ``jobj``.""" - if cls in cls.TYPES.itervalues(): - # cls is already registered Message type, force to use it - # so that, e.g Revocation.from_json(jobj) fails if - # jobj["type"] != "revocation". - return cls - - if not isinstance(jobj, dict): - raise errors.ValidationError( - "{0} is not a dictionary object".format(jobj)) - try: - msg_type = jobj["type"] - except KeyError: - raise errors.ValidationError("missing type field") - - try: - return cls.TYPES[msg_type] - except KeyError: - raise errors.UnrecognizedTypeError(msg_type) - @classmethod def from_json(cls, jobj): """Deserialize from (possibly invalid) JSON object. @@ -57,35 +56,21 @@ class Message(util.TypedACMEObject): :raises letsencrypt.acme.errors.SchemaValidationError: if the input JSON object could not be validated against JSON schema specified in :attr:`schema`. - :raises letsencrypt.acme.errors.ValidationError: for any other generic - error in decoding. + :raises letsencrypt.acme.jose.errors.DeserializationError: for any + other generic error in decoding. :returns: instance of the class """ - msg_cls = cls.get_msg_cls(jobj) + msg_cls = cls.get_type_cls(jobj) + # TODO: is that schema testing still relevant? try: jsonschema.validate(jobj, msg_cls.schema) except jsonschema.ValidationError as error: raise errors.SchemaValidationError(error) - return cls.from_valid_json(jobj) - - @classmethod - def json_loads(cls, json_string): - """Load JSON string.""" - return cls.from_json(json.loads(json_string)) - - def json_dumps(self, *args, **kwargs): - """Dump to JSON string using proper serializer. - - :returns: JSON serialized string. - :rtype: str - - """ - return json.dumps( - self, *args, default=util.dump_ijsonserializable, **kwargs) + return super(Message, cls).from_json(jobj) @Message.register # pylint: disable=too-few-public-methods @@ -96,86 +81,55 @@ class Challenge(Message): :ivar list challenges: List of :class:`~letsencrypt.acme.challenges.Challenge` objects. - """ - acme_type = "challenge" - schema = util.load_schema(acme_type) - __slots__ = ("session_id", "nonce", "challenges", "combinations") + .. todo:: + 1. can challenges contain two challenges of the same type? + 2. can challenges contain duplicates? + 3. check "combinations" indices are in valid range + 4. turn "combinations" elements into sets? + 5. turn "combinations" into set? - def _fields_to_json(self): - fields = { - "sessionID": self.session_id, - "nonce": jose.b64encode(self.nonce), - "challenges": self.challenges, - } - if self.combinations: - fields["combinations"] = self.combinations - return fields + """ + typ = "challenge" + schema = util.load_schema(typ) + + session_id = jose.Field("sessionID") + nonce = jose.Field("nonce", encoder=jose.b64encode, + decoder=jose.decode_b64jose) + challenges = jose.Field("challenges") + combinations = jose.Field("combinations", omitempty=True, default=()) + + @challenges.decoder + def challenges(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(challenges.Challenge.from_json(chall) for chall in value) @property def resolved_combinations(self): """Combinations with challenges instead of indices.""" - return [[self.challenges[idx] for idx in combo] - for combo in self.combinations] - - @classmethod - def from_valid_json(cls, jobj): - # TODO: can challenges contain two challenges of the same type? - # TODO: can challenges contain duplicates? - # TODO: check "combinations" indices are in valid range - # TODO: turn "combinations" elements into sets? - # TODO: turn "combinations" into set? - return cls(session_id=jobj["sessionID"], - nonce=util.decode_b64jose(jobj["nonce"]), - challenges=[challenges.Challenge.from_valid_json(chall) - for chall in jobj["challenges"]], - combinations=jobj.get("combinations", [])) + return tuple(tuple(self.challenges[idx] for idx in combo) + for combo in self.combinations) @Message.register # pylint: disable=too-few-public-methods class ChallengeRequest(Message): """ACME "challengeRequest" message.""" - acme_type = "challengeRequest" - schema = util.load_schema(acme_type) - __slots__ = ("identifier",) - - def _fields_to_json(self): - return { - "identifier": self.identifier, - } - - @classmethod - def from_valid_json(cls, jobj): - return cls(identifier=jobj["identifier"]) + typ = "challengeRequest" + schema = util.load_schema(typ) + identifier = jose.Field("identifier") @Message.register # pylint: disable=too-few-public-methods class Authorization(Message): """ACME "authorization" message. - :ivar jwk: :class:`letsencrypt.acme.other.JWK` + :ivar jwk: :class:`letsencrypt.acme.jose.JWK` """ - acme_type = "authorization" - schema = util.load_schema(acme_type) - __slots__ = ("recovery_token", "identifier", "jwk") + typ = "authorization" + schema = util.load_schema(typ) - def _fields_to_json(self): - fields = {} - if self.recovery_token is not None: - fields["recoveryToken"] = self.recovery_token - if self.identifier is not None: - fields["identifier"] = self.identifier - if self.jwk is not None: - fields["jwk"] = self.jwk - return fields - - @classmethod - def from_valid_json(cls, jobj): - jwk = jobj.get("jwk") - if jwk is not None: - jwk = other.JWK.from_valid_json(jwk) - return cls(recovery_token=jobj.get("recoveryToken"), - identifier=jobj.get("identifier"), jwk=jwk) + recovery_token = jose.Field("recoveryToken", omitempty=True) + identifier = jose.Field("identifier", omitempty=True) + jwk = jose.Field("jwk", decoder=jose.JWK.from_json, omitempty=True) @Message.register @@ -189,9 +143,20 @@ class AuthorizationRequest(Message): :ivar signature: Signature (:class:`letsencrypt.acme.other.Signature`). """ - acme_type = "authorizationRequest" - schema = util.load_schema(acme_type) - __slots__ = ("session_id", "nonce", "responses", "signature", "contact") + typ = "authorizationRequest" + schema = util.load_schema(typ) + + session_id = jose.Field("sessionID") + nonce = jose.Field("nonce", encoder=jose.b64encode, + decoder=jose.decode_b64jose) + responses = jose.Field("responses") + signature = jose.Field("signature", decoder=other.Signature.from_json) + contact = jose.Field("contact", omitempty=True, default=()) + + @responses.decoder + def responses(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(challenges.ChallengeResponse.from_json(chall) + for chall in value) @classmethod def create(cls, name, key, sig_nonce=None, **kwargs): @@ -213,7 +178,7 @@ class AuthorizationRequest(Message): signature = other.Signature.from_msg( name + kwargs["nonce"], key, sig_nonce) return cls( - signature=signature, contact=kwargs.pop("contact", []), **kwargs) + signature=signature, contact=kwargs.pop("contact", ()), **kwargs) def verify(self, name): """Verify signature. @@ -228,29 +193,9 @@ class AuthorizationRequest(Message): :rtype: bool """ + # self.signature is not Field | pylint: disable=no-member return self.signature.verify(name + self.nonce) - def _fields_to_json(self): - fields = { - "sessionID": self.session_id, - "nonce": jose.b64encode(self.nonce), - "responses": self.responses, - "signature": self.signature, - } - if self.contact: - fields["contact"] = self.contact - return fields - - @classmethod - def from_valid_json(cls, jobj): - return cls( - session_id=jobj["sessionID"], - nonce=util.decode_b64jose(jobj["nonce"]), - responses=[challenges.ChallengeResponse.from_valid_json(chall) - for chall in jobj["responses"]], - signature=other.Signature.from_valid_json(jobj["signature"]), - contact=jobj.get("contact", [])) - @Message.register # pylint: disable=too-few-public-methods class Certificate(Message): @@ -263,24 +208,21 @@ class Certificate(Message): wrapped in :class:`letsencrypt.acme.util.ComparableX509` ). """ - acme_type = "certificate" - schema = util.load_schema(acme_type) - __slots__ = ("certificate", "chain", "refresh") + typ = "certificate" + schema = util.load_schema(typ) - def _fields_to_json(self): - fields = {"certificate": util.encode_cert(self.certificate)} - if self.chain: - fields["chain"] = [util.encode_cert(cert) for cert in self.chain] - if self.refresh is not None: - fields["refresh"] = self.refresh - return fields + certificate = jose.Field("certificate", encoder=jose.encode_cert, + decoder=jose.decode_cert) + chain = jose.Field("chain", omitempty=True, default=()) + refresh = jose.Field("refresh", omitempty=True) - @classmethod - def from_valid_json(cls, jobj): - return cls(certificate=util.decode_cert(jobj["certificate"]), - chain=[util.decode_cert(cert) for cert in - jobj.get("chain", [])], - refresh=jobj.get("refresh")) + @chain.decoder + def chain(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(jose.decode_cert(cert) for cert in value) + + @chain.encoder + def chain(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(jose.encode_cert(cert) for cert in value) @Message.register @@ -292,9 +234,12 @@ class CertificateRequest(Message): :ivar signature: Signature (:class:`letsencrypt.acme.other.Signature`). """ - acme_type = "certificateRequest" - schema = util.load_schema(acme_type) - __slots__ = ("csr", "signature") + typ = "certificateRequest" + schema = util.load_schema(typ) + + csr = jose.Field("csr", encoder=jose.encode_csr, + decoder=jose.decode_csr) + signature = jose.Field("signature", decoder=other.Signature.from_json) @classmethod def create(cls, key, sig_nonce=None, **kwargs): @@ -324,49 +269,32 @@ class CertificateRequest(Message): :rtype: bool """ + # self.signature is not Field | pylint: disable=no-member return self.signature.verify(self.csr.as_der()) - def _fields_to_json(self): - return { - "csr": util.encode_csr(self.csr), - "signature": self.signature, - } - - @classmethod - def from_valid_json(cls, jobj): - return cls(csr=util.decode_csr(jobj["csr"]), - signature=other.Signature.from_valid_json(jobj["signature"])) - @Message.register # pylint: disable=too-few-public-methods class Defer(Message): """ACME "defer" message.""" - acme_type = "defer" - schema = util.load_schema(acme_type) - __slots__ = ("token", "interval", "message") + typ = "defer" + schema = util.load_schema(typ) - def _fields_to_json(self): - fields = {"token": self.token} - if self.interval is not None: - fields["interval"] = self.interval - if self.message is not None: - fields["message"] = self.message - return fields - - @classmethod - def from_valid_json(cls, jobj): - return cls(token=jobj["token"], interval=jobj.get("interval"), - message=jobj.get("message")) + token = jose.Field("token") + interval = jose.Field("interval", omitempty=True) + message = jose.Field("message", omitempty=True) @Message.register # pylint: disable=too-few-public-methods class Error(Message): """ACME "error" message.""" - acme_type = "error" - schema = util.load_schema(acme_type) - __slots__ = ("error", "message", "more_info") + typ = "error" + schema = util.load_schema(typ) - CODES = { + error = jose.Field("error") + message = jose.Field("message", omitempty=True) + more_info = jose.Field("moreInfo", omitempty=True) + + MESSAGE_CODES = { "malformed": "The request message was malformed", "unauthorized": "The client lacks sufficient authorization", "serverInternal": "The server experienced an internal error", @@ -375,33 +303,12 @@ class Error(Message): "badCSR": "The CSR is unacceptable (e.g., due to a short key)", } - def _fields_to_json(self): - fields = {"error": self.error} - if self.message is not None: - fields["message"] = self.message - if self.more_info is not None: - fields["moreInfo"] = self.more_info - return fields - - @classmethod - def from_valid_json(cls, jobj): - return cls(error=jobj["error"], message=jobj.get("message"), - more_info=jobj.get("moreInfo")) - @Message.register # pylint: disable=too-few-public-methods class Revocation(Message): """ACME "revocation" message.""" - acme_type = "revocation" - schema = util.load_schema(acme_type) - __slots__ = () - - def _fields_to_json(self): - return {} - - @classmethod - def from_valid_json(cls, jobj): - return cls() + typ = "revocation" + schema = util.load_schema(typ) @Message.register @@ -413,9 +320,12 @@ class RevocationRequest(Message): :ivar signature: Signature (:class:`letsencrypt.acme.other.Signature`). """ - acme_type = "revocationRequest" - schema = util.load_schema(acme_type) - __slots__ = ("certificate", "signature") + typ = "revocationRequest" + schema = util.load_schema(typ) + + certificate = jose.Field("certificate", decoder=jose.decode_cert, + encoder=jose.encode_cert) + signature = jose.Field("signature", decoder=other.Signature.from_json) @classmethod def create(cls, key, sig_nonce=None, **kwargs): @@ -445,34 +355,13 @@ class RevocationRequest(Message): :rtype: bool """ + # self.signature is not Field | pylint: disable=no-member return self.signature.verify(self.certificate.as_der()) - def _fields_to_json(self): - return { - "certificate": util.encode_cert(self.certificate), - "signature": self.signature, - } - - @classmethod - def from_valid_json(cls, jobj): - return cls(certificate=util.decode_cert(jobj["certificate"]), - signature=other.Signature.from_valid_json(jobj["signature"])) - @Message.register # pylint: disable=too-few-public-methods class StatusRequest(Message): - """ACME "statusRequest" message. - - :ivar unicode token: Token provided in ACME "defer" message. - - """ - acme_type = "statusRequest" - schema = util.load_schema(acme_type) - __slots__ = ("token",) - - def _fields_to_json(self): - return {"token": self.token} - - @classmethod - def from_valid_json(cls, jobj): - return cls(token=jobj["token"]) + """ACME "statusRequest" message.""" + typ = "statusRequest" + schema = util.load_schema(typ) + token = jose.Field("token") diff --git a/letsencrypt/acme/messages2.py b/letsencrypt/acme/messages2.py new file mode 100644 index 000000000..93f77a3e9 --- /dev/null +++ b/letsencrypt/acme/messages2.py @@ -0,0 +1,298 @@ +"""ACME protocol 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, boulder#128 + 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 + + def __ne__(self, other): + return not self.__eq__(other) + + +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: Contact information following ACME spec + + """ + # on new-reg key server ignores 'key' and populates it based on + # JWS.signature.combined.jwk + key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) + 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`. Please use names + such as ``challb`` to distinguish instances of this class from + ``achall``. + + :ivar letsencrypt.acme.challenges.Challenge: Wrapped challenge. + Conveniently, all challenge fields are proxied, i.e. you can + call ``challb.x`` to get ``challb.chall.x`` contents. + :ivar letsencrypt.acme.messages2.Status status: + :ivar datetime.datetime validated: + + """ + __slots__ = ('chall',) + uri = jose.Field('uri') + status = jose.Field('status', decoder=Status.from_json) + 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 + + def __getattr__(self, name): + return getattr(self.chall, name) + + +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 `.ChallengeBody` + :ivar tuple combinations: Challenge combinations (`tuple` of `tuple` + of `int`, as opposed to `list` of `list` from the spec). + :ivar letsencrypt.acme.jose.jwk.JWK key: Public key. + :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..d45aa7f9e --- /dev/null +++ b/letsencrypt/acme/messages2_test.py @@ -0,0 +1,240 @@ +"""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 + + +KEY = jose.util.HashableRSAKey(RSA.importKey(pkg_resources.resource_string( + 'letsencrypt.acme.jose', os.path.join('testdata', 'rsa512_key.pem')))) + + +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)) + + def test_equality(self): + const_a_prime = self.MockConstant('a') + self.assertFalse(self.const_a == self.const_b) + self.assertTrue(self.const_a == const_a_prime) + + self.assertTrue(self.const_a != self.const_b) + self.assertFalse(self.const_a != const_a_prime) + +class RegistrationTest(unittest.TestCase): + """Tests for letsencrypt.acme.messages2.Registration.""" + + def setUp(self): + key = jose.jwk.JWKRSA(key=KEY.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 ab9f4f64e..56781db18 100644 --- a/letsencrypt/acme/messages_test.py +++ b/letsencrypt/acme/messages_test.py @@ -1,4 +1,5 @@ """Tests for letsencrypt.acme.messages.""" +import os import pkg_resources import unittest @@ -9,17 +10,20 @@ from letsencrypt.acme import challenges from letsencrypt.acme import errors from letsencrypt.acme import jose from letsencrypt.acme import other -from letsencrypt.acme import util -KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - 'letsencrypt.client.tests', 'testdata/rsa256_key.pem')) -CERT = util.ComparableX509(M2Crypto.X509.load_cert( +KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + 'letsencrypt.acme.jose', os.path.join('testdata', 'rsa512_key.pem')))) +CERT = jose.ComparableX509(M2Crypto.X509.load_cert( pkg_resources.resource_filename( - 'letsencrypt.client.tests', 'testdata/cert.pem'))) -CSR = util.ComparableX509(M2Crypto.X509.load_request( + 'letsencrypt.client.tests', os.path.join('testdata', 'cert.pem')))) +CSR = jose.ComparableX509(M2Crypto.X509.load_request( pkg_resources.resource_filename( - 'letsencrypt.client.tests', 'testdata/csr.pem'))) + 'letsencrypt.client.tests', os.path.join('testdata', 'csr.pem')))) +CSR2 = jose.ComparableX509(M2Crypto.X509.load_request( + pkg_resources.resource_filename( + 'letsencrypt.acme.jose', os.path.join('testdata', 'csr2.pem')))) class MessageTest(unittest.TestCase): @@ -35,7 +39,7 @@ class MessageTest(unittest.TestCase): @MockParentMessage.register class MockMessage(MockParentMessage): - acme_type = 'test' + typ = 'test' schema = { 'type': 'object', 'properties': { @@ -43,56 +47,27 @@ class MessageTest(unittest.TestCase): 'name': {'type': 'string'}, }, } - __slots__ = ('price', 'name') - - @classmethod - def from_valid_json(cls, jobj): - return cls(price=jobj.get('price'), name=jobj.get('name')) - - def _fields_to_json(self): - # pylint: disable=no-member - return {'price': self.price, 'name': self.name} + price = jose.Field('price') + name = jose.Field('name') self.parent_cls = MockParentMessage self.msg = MockMessage(price=123, name='foo') - def test_from_json_non_dict_fails(self): - self.assertRaises(errors.ValidationError, self.parent_cls.from_json, []) - - def test_from_json_dict_no_type_fails(self): - self.assertRaises(errors.ValidationError, self.parent_cls.from_json, {}) - - def test_from_json_unrecognized_type(self): - self.assertRaises(errors.UnrecognizedTypeError, - self.parent_cls.from_json, {'type': 'foo'}) - def test_from_json_validates(self): self.assertRaises(errors.SchemaValidationError, self.parent_cls.from_json, {'type': 'test', 'price': 'asd'}) - def test_from_json(self): - self.assertEqual(self.msg, self.parent_cls.from_json( - {'type': 'test', 'name': 'foo', 'price': 123})) - - def test_json_loads(self): - self.assertEqual(self.msg, self.parent_cls.json_loads( - '{"type": "test", "name": "foo", "price": 123}')) - - def test_json_dumps(self): - self.assertEqual(self.msg.json_dumps(sort_keys=True), - '{"name": "foo", "price": 123, "type": "test"}') - class ChallengeTest(unittest.TestCase): def setUp(self): - challs = [ + challs = ( challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'), challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'), challenges.RecoveryToken(), - ] - combinations = [[0, 2], [1, 2]] + ) + combinations = ((0, 2), (1, 2)) from letsencrypt.acme.messages import Challenge self.msg = Challenge( @@ -113,23 +88,23 @@ class ChallengeTest(unittest.TestCase): 'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4', 'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ', 'challenges': [chall.to_json() for chall in challs], - 'combinations': combinations, + 'combinations': [[0, 2], [1, 2]], # TODO array tuples } def test_resolved_combinations(self): - self.assertEqual(self.msg.resolved_combinations, [ - [ + self.assertEqual(self.msg.resolved_combinations, ( + ( challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'), challenges.RecoveryToken() - ], - [ + ), + ( challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'), challenges.RecoveryToken(), - ] - ]) + ) + )) - 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 @@ -142,8 +117,8 @@ class ChallengeTest(unittest.TestCase): from letsencrypt.acme.messages import Challenge msg = Challenge.from_json(self.jmsg_from) - self.assertEqual(msg.combinations, []) - self.assertEqual(msg.to_json(), self.jmsg_to) + self.assertEqual(msg.combinations, ()) + self.assertEqual(msg.to_partial_json(), self.jmsg_to) class ChallengeRequestTest(unittest.TestCase): @@ -157,8 +132,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 @@ -168,7 +143,7 @@ class ChallengeRequestTest(unittest.TestCase): class AuthorizationTest(unittest.TestCase): def setUp(self): - jwk = other.JWK(key=KEY.publickey()) + jwk = jose.JWKRSA(key=KEY.publickey()) from letsencrypt.acme.messages import Authorization self.msg = Authorization(recovery_token='tok', jwk=jwk, @@ -181,11 +156,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) @@ -201,20 +176,20 @@ 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): def setUp(self): - self.responses = [ + self.responses = ( challenges.SimpleHTTPSResponse(path='Hf5GrX4Q7EBax9hc2jJnfw'), None, # null challenges.RecoveryTokenResponse(token='23029d88d9e123e'), - ] - self.contact = ["mailto:cert-admin@example.com", "tel:+12025551212"] + ) + self.contact = ("mailto:cert-admin@example.com", "tel:+12025551212") signature = other.Signature( - alg='RS256', jwk=other.JWK(key=KEY.publickey()), + alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.publickey()), sig='-v\xd8\xc2\xa3\xba0\xd6\x92\x16\xb5.\xbe\xa1[\x04\xbe' '\x1b\xa1X\xd2)\x18\x94\x8f\xd7\xd0\xc0\xbbcI`W\xdf v' '\xe4\xed\xe8\x03J\xe8\xc8l#\x10<\x96\xd2\xcdr\xa3' '\x1b\xa1\xf5!f\xef\xc64\xb6\x13') self.nonce = '\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9' - from letsencrypt.acme.other import JWK - self.jwk = JWK(key=RSA256_KEY.publickey()) + self.alg = jose.RS256 + self.jwk = jose.JWKRSA(key=KEY.publickey()) b64sig = ('SUPYKucUnhlTt8_sMxLiigOYdf_wlOLXPI-o7aRLTsOquVjDd6r' 'AX9AFJHk-bCMQPJbSzXKjG6H1IWbvxjS2Ew') @@ -88,8 +40,8 @@ class SignatureTest(unittest.TestCase): self.jsig_from = { 'nonce': b64nonce, - 'alg': self.alg, - 'jwk': self.jwk.to_json(), + 'alg': self.alg.to_partial_json(), + 'jwk': self.jwk.to_partial_json(), 'sig': b64sig, } @@ -115,30 +67,32 @@ class SignatureTest(unittest.TestCase): return Signature.from_msg(*args, **kwargs) def test_create_from_msg(self): - signature = self._from_msg(self.msg, RSA256_KEY, self.nonce) + signature = self._from_msg(self.msg, KEY, self.nonce) self.assertEqual(self.signature, signature) def test_create_from_msg_random_nonce(self): - signature = self._from_msg(self.msg, RSA256_KEY) + signature = self._from_msg(self.msg, KEY) self.assertEqual(signature.alg, self.alg) 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 self.assertEqual( - self.signature, Signature.from_valid_json(self.jsig_from)) + self.signature, Signature.from_json(self.jsig_from)) def test_from_json_non_schema_errors(self): from letsencrypt.acme.other import Signature - jwk = self.jwk.to_json() - self.assertRaises(errors.ValidationError, Signature.from_valid_json, { - 'alg': 'RS256', 'sig': 'x', 'nonce': '', 'jwk': jwk}) - self.assertRaises(errors.ValidationError, Signature.from_valid_json, { - 'alg': 'RS256', 'sig': '', 'nonce': 'x', 'jwk': jwk}) + jwk = self.jwk.to_partial_json() + self.assertRaises( + jose.DeserializationError, Signature.from_json, { + 'alg': 'RS256', 'sig': 'x', 'nonce': '', 'jwk': jwk}) + self.assertRaises( + jose.DeserializationError, Signature.from_json, { + 'alg': 'RS256', 'sig': '', 'nonce': 'x', 'jwk': jwk}) if __name__ == '__main__': diff --git a/letsencrypt/acme/util.py b/letsencrypt/acme/util.py index cc00dc2bb..8bea9091a 100644 --- a/letsencrypt/acme/util.py +++ b/letsencrypt/acme/util.py @@ -1,247 +1,9 @@ """ACME utilities.""" -import binascii import json import pkg_resources -import M2Crypto -import zope.interface - -from letsencrypt.acme import errors -from letsencrypt.acme import interfaces -from letsencrypt.acme import jose - - -class ComparableX509(object): # pylint: disable=too-few-public-methods - """Wrapper for M2Crypto.X509.* objects that supports __eq__. - - Wraps around: - - - :class:`M2Crypto.X509.X509` - - :class:`M2Crypto.X509.Request` - - """ - def __init__(self, wrapped): - self._wrapped = wrapped - - def __getattr__(self, name): - return getattr(self._wrapped, name) - - def __eq__(self, other): - return self.as_der() == other.as_der() - def load_schema(name): """Load JSON schema from distribution.""" return json.load(open(pkg_resources.resource_filename( __name__, "schemata/%s.json" % name))) - - -def dump_ijsonserializable(python_object): - """Serialize IJSONSerializable to JSON. - - This is meant to be passed to :func:`json.dumps` as ``default`` - argument in order to facilitate recursive calls to - :meth:`~letsencrypt.acme.interfaces.IJSONSerializable.to_json`. - Please see :meth:`letsencrypt.acme.interfaces.IJSONSerializable.to_json` - for an example. - - """ - # providedBy | pylint: disable=no-member - if interfaces.IJSONSerializable.providedBy(python_object): - return python_object.to_json() - else: - raise TypeError(repr(python_object) + ' is not JSON serializable') - - -class ImmutableMap(object): # pylint: disable=too-few-public-methods - """Immutable key to value mapping with attribute access.""" - - __slots__ = () - """Must be overriden in subclasses.""" - - def __init__(self, **kwargs): - if set(kwargs) != set(self.__slots__): - raise TypeError( - '__init__() takes exactly the following arguments: {0} ' - '({1} given)'.format(', '.join(self.__slots__), - ', '.join(kwargs) if kwargs else 'none')) - for slot in self.__slots__: - object.__setattr__(self, slot, kwargs.pop(slot)) - - def __setattr__(self, name, value): - raise AttributeError("can't set attribute") - - def __eq__(self, other): - return isinstance(other, self.__class__) and all( - getattr(self, slot) == getattr(other, slot) - for slot in self.__slots__) - - def __hash__(self): - return hash(tuple(getattr(self, slot) for slot in self.__slots__)) - - def __repr__(self): - return '{0}({1})'.format(self.__class__.__name__, ', '.join( - '{0}={1!r}'.format(slot, getattr(self, slot)) - for slot in self.__slots__)) - - -class ACMEObject(ImmutableMap): # pylint: disable=too-few-public-methods - """ACME object.""" - zope.interface.implements(interfaces.IJSONSerializable) - zope.interface.classImplements(interfaces.IJSONDeserializable) - - def to_json(self): # pragma: no cover - """Serialize to JSON.""" - raise NotImplementedError() - - @classmethod - def from_valid_json(cls, jobj): # pragma: no cover - """Deserialize from valid JSON object.""" - raise NotImplementedError() - - -def decode_b64jose(value, size=None, minimum=False): - """Decode ACME object JOSE Base64 encoded field. - - :param str value: Encoded field value. - :param int size: If specified, this function will check if data size - (after decoding) matches. - :param bool minimum: If ``True``, then ``size`` is the minimum required - size, otherwise ``size`` must be exact. - - :raises letsencrypt.acme.errors.ValidationError: if anything goes wrong - :returns: Decoded value. - - """ - try: - decoded = jose.b64decode(value) - except TypeError: - raise errors.ValidationError() - - if size is not None and ((not minimum and len(decoded) != size) - or (minimum and len(decoded) < size)): - raise errors.ValidationError() - - return decoded - - -def decode_hex16(value, size=None, minimum=False): - """Decode ACME object hex16-encoded field. - - :param str value: Encoded field value. - :param int size: If specified, this function will check if data size - (after decoding) matches. - :param bool minimum: If ``True``, then ``size`` is the minimum required - size, otherwise ``size`` must be exact. - - """ - # binascii.hexlify.__doc__: "The resulting string is therefore twice - # as long as the length of data." - if size is not None and ((not minimum and len(value) != size * 2) - or (minimum and len(value) < size * 2)): - raise errors.ValidationError() - try: - return binascii.unhexlify(value) - except TypeError as error: # odd-length string (binascci.unhexlify.__doc__) - raise errors.ValidationError(error) - - -def encode_cert(cert): - """Encode ACME object X509 certificate field.""" - return jose.b64encode(cert.as_der()) - - -def decode_cert(b64der): - """Decode ACME object X509 certificate field. - - :param str b64der: Input data that's meant to be valid base64 - DER-encoded certificate. - - :raises letsencrypt.acme.errors.ValidationError: if anything goes wrong - - :returns: Decoded certificate. - :rtype: :class:`M2Crypto.X509.X509` wrapped in :class:`ComparableX509`. - - """ - try: - return ComparableX509(M2Crypto.X509.load_cert_der_string( - decode_b64jose(b64der))) - except M2Crypto.X509.X509Error: - raise errors.ValidationError() - - -def encode_csr(csr): - """Encode ACME object CSR field.""" - return encode_cert(csr) - - -def decode_csr(b64der): - """Decode ACME object CSR field. - - :param str b64der: Input data that's meant to be valid base64 - DER-encoded CSR. - - :raises letsencrypt.acme.errors.ValidationError: if anything goes wrong - - :returns: Decoded certificate. - :rtype: :class:`M2Crypto.X509.X509` wrapped in :class:`ComparableX509`. - - """ - try: - return ComparableX509(M2Crypto.X509.load_request_der_string( - decode_b64jose(b64der))) - except M2Crypto.X509.X509Error: - raise errors.ValidationError() - - -class TypedACMEObject(ACMEObject): - """ACME object with type (immutable).""" - - acme_type = NotImplemented - """ACME "type" field. Subclasses must override.""" - - TYPES = NotImplemented - """Types registered for JSON deserialization""" - - @classmethod - def register(cls, msg_cls): - """Register class for JSON deserialization.""" - cls.TYPES[msg_cls.acme_type] = msg_cls - return msg_cls - - def to_json(self): - """Get JSON serializable object. - - :returns: Serializable JSON object representing ACME typed object. - :rtype: dict - - """ - jobj = self._fields_to_json() - jobj["type"] = self.acme_type - return jobj - - def _fields_to_json(self): # pragma: no cover - """Prepare ACME object fields for JSON serialiazation. - - Subclasses must override this method. - - :returns: Serializable JSON object containg all ACME object fields - apart from "type". - :rtype: dict - - """ - raise NotImplementedError() - - @classmethod - def from_valid_json(cls, jobj): - """Deserialize ACME object from valid JSON object. - - :raises letsencrypt.acme.errors.UnrecognizedTypeError: if type - of the ACME object has not been registered. - - """ - try: - msg_cls = cls.TYPES[jobj["type"]] - except KeyError: - raise errors.UnrecognizedTypeError(jobj["type"]) - return msg_cls.from_valid_json(jobj) diff --git a/letsencrypt/acme/util_test.py b/letsencrypt/acme/util_test.py deleted file mode 100644 index 0b500a2c7..000000000 --- a/letsencrypt/acme/util_test.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Tests for letsencrypt.acme.util.""" -import functools -import json -import os -import pkg_resources -import unittest - -import M2Crypto -import zope.interface - -from letsencrypt.acme import errors -from letsencrypt.acme import interfaces - - -CERT = M2Crypto.X509.load_cert(pkg_resources.resource_filename( - 'letsencrypt.client.tests', os.path.join('testdata', 'cert.pem'))) -CSR = M2Crypto.X509.load_request(pkg_resources.resource_filename( - 'letsencrypt.client.tests', os.path.join('testdata', 'csr.pem'))) - - -class DumpIJSONSerializableTest(unittest.TestCase): - """Tests for letsencrypt.acme.util.dump_ijsonserializable.""" - - class MockJSONSerialiazable(object): - # pylint: disable=missing-docstring,too-few-public-methods,no-self-use - zope.interface.implements(interfaces.IJSONSerializable) - - def to_json(self): - return [3, 2, 1] - - @classmethod - def _call(cls, obj): - from letsencrypt.acme.util import dump_ijsonserializable - return json.dumps(obj, default=dump_ijsonserializable) - - def test_json_type(self): - self.assertEqual('5', self._call(5)) - - def test_ijsonserializable(self): - self.assertEqual('[3, 2, 1]', self._call(self.MockJSONSerialiazable())) - - def test_raises_type_error(self): - self.assertRaises(TypeError, self._call, object()) - - -class ImmutableMapTest(unittest.TestCase): - """Tests for letsencrypt.acme.util.ImmutableMap.""" - - def setUp(self): - # pylint: disable=invalid-name,too-few-public-methods - # pylint: disable=missing-docstring - from letsencrypt.acme.util import ImmutableMap - - class A(ImmutableMap): - __slots__ = ('x', 'y') - - class B(ImmutableMap): - __slots__ = ('x', 'y') - - self.A = A - self.B = B - - self.a1 = self.A(x=1, y=2) - self.a1_swap = self.A(y=2, x=1) - self.a2 = self.A(x=3, y=4) - self.b = self.B(x=1, y=2) - - def test_order_of_args_does_not_matter(self): - self.assertEqual(self.a1, self.a1_swap) - - def test_type_error_on_missing(self): - self.assertRaises(TypeError, self.A, x=1) - self.assertRaises(TypeError, self.A, y=2) - - def test_type_error_on_unrecognized(self): - self.assertRaises(TypeError, self.A, x=1, z=2) - self.assertRaises(TypeError, self.A, x=1, y=2, z=3) - - def test_get_attr(self): - self.assertEqual(1, self.a1.x) - self.assertEqual(2, self.a1.y) - self.assertEqual(1, self.a1_swap.x) - self.assertEqual(2, self.a1_swap.y) - - def test_set_attr_raises_attribute_error(self): - self.assertRaises( - AttributeError, functools.partial(self.a1.__setattr__, 'x'), 10) - - def test_equal(self): - self.assertEqual(self.a1, self.a1) - self.assertEqual(self.a2, self.a2) - self.assertNotEqual(self.a1, self.a2) - - def test_same_slots_diff_cls_not_equal(self): - self.assertEqual(self.a1.x, self.b.x) - self.assertEqual(self.a1.y, self.b.y) - self.assertNotEqual(self.a1, self.b) - - def test_hash(self): - self.assertEqual(hash((1, 2)), hash(self.a1)) - - def test_unhashable(self): - self.assertRaises(TypeError, self.A(x=1, y={}).__hash__) - - def test_repr(self): - self.assertEqual('A(x=1, y=2)', repr(self.a1)) - self.assertEqual('A(x=1, y=2)', repr(self.a1_swap)) - self.assertEqual('B(x=1, y=2)', repr(self.b)) - self.assertEqual("B(x='foo', y='bar')", repr(self.B(x='foo', y='bar'))) - - -class EncodersAndDecodersTest(unittest.TestCase): - """Tests for encoders and decoders from letsencrypt.acme.util""" - # pylint: disable=protected-access - - def setUp(self): - self.b64_cert = ( - 'MIIB3jCCAYigAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhM' - 'CVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKz' - 'ApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxF' - 'DASBgNVBAMMC2V4YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIx' - 'ODIyMzQ0NVowdzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRI' - 'wEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTW' - 'ljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4YW1wbGUuY29tMFwwD' - 'QYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR7R_drnBSQ_zfx1vQLHUbFLh1' - 'AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c-pVE6K-EdE_twuUCAwE' - 'AATANBgkqhkiG9w0BAQsFAANBAC24z0IdwIVKSlntksllvr6zJepBH5fMnd' - 'fk3XJp10jT6VE-14KNtjh02a56GoraAvJAT5_H67E8GvJ_ocNnB_o' - ) - self.b64_csr = ( - 'MIIBXTCCAQcCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2F' - 'uMRIwEAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECw' - 'wWVW5pdmVyc2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAwwLZXhhbXBsZS5jb' - '20wXDANBgkqhkiG9w0BAQEFAANLADBIAkEArHVztFHtH92ucFJD_N_HW9As' - 'dRsUuHUBBBDlHwNlRd3fp580rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3' - 'C5QIDAQABoCkwJwYJKoZIhvcNAQkOMRowGDAWBgNVHREEDzANggtleGFtcG' - 'xlLmNvbTANBgkqhkiG9w0BAQsFAANBAHJH_O6BtC9aGzEVCMGOZ7z9iIRHW' - 'Szr9x_bOzn7hLwsbXPAgO1QxEwL-X-4g20Gn9XBE1N9W6HCIEut2d8wACg' - ) - - def test_decode_b64_jose_padding_error(self): - from letsencrypt.acme.util import decode_b64jose - self.assertRaises(errors.ValidationError, decode_b64jose, 'x') - - def test_decode_b64_jose_size(self): - from letsencrypt.acme.util import decode_b64jose - self.assertEqual('foo', decode_b64jose('Zm9v', size=3)) - self.assertRaises( - errors.ValidationError, decode_b64jose, 'Zm9v', size=2) - self.assertRaises( - errors.ValidationError, decode_b64jose, 'Zm9v', size=4) - - def test_decode_b64_jose_minimum_size(self): - from letsencrypt.acme.util import decode_b64jose - self.assertEqual('foo', decode_b64jose('Zm9v', size=3, minimum=True)) - self.assertEqual('foo', decode_b64jose('Zm9v', size=2, minimum=True)) - self.assertRaises(errors.ValidationError, decode_b64jose, - 'Zm9v', size=4, minimum=True) - - def test_decode_hex16(self): - from letsencrypt.acme.util import decode_hex16 - self.assertEqual('foo', decode_hex16('666f6f')) - - def test_decode_hex16_minimum_size(self): - from letsencrypt.acme.util import decode_hex16 - self.assertEqual('foo', decode_hex16('666f6f', size=3, minimum=True)) - self.assertEqual('foo', decode_hex16('666f6f', size=2, minimum=True)) - self.assertRaises(errors.ValidationError, decode_hex16, - '666f6f', size=4, minimum=True) - - def test_decode_hex16_odd_length(self): - from letsencrypt.acme.util import decode_hex16 - self.assertRaises(errors.ValidationError, decode_hex16, 'x') - - def test_encode_cert(self): - from letsencrypt.acme.util import encode_cert - self.assertEqual(self.b64_cert, encode_cert(CERT)) - - def test_decode_cert(self): - from letsencrypt.acme.util import ComparableX509 - from letsencrypt.acme.util import decode_cert - cert = decode_cert(self.b64_cert) - self.assertTrue(isinstance(cert, ComparableX509)) - self.assertEqual(cert, CERT) - self.assertRaises(errors.ValidationError, decode_cert, '') - - def test_encode_csr(self): - from letsencrypt.acme.util import encode_csr - self.assertEqual(self.b64_csr, encode_csr(CSR)) - - def test_decode_csr(self): - from letsencrypt.acme.util import ComparableX509 - from letsencrypt.acme.util import decode_csr - csr = decode_csr(self.b64_csr) - self.assertTrue(isinstance(csr, ComparableX509)) - self.assertEqual(csr, CSR) - self.assertRaises(errors.ValidationError, decode_csr, '') - - -class TypedACMEObjectTest(unittest.TestCase): - - def setUp(self): - from letsencrypt.acme.util import TypedACMEObject - - # pylint: disable=missing-docstring,abstract-method - # pylint: disable=too-few-public-methods - - class MockParentTypedACMEObject(TypedACMEObject): - TYPES = {} - - @MockParentTypedACMEObject.register - class MockTypedACMEObject(MockParentTypedACMEObject): - acme_type = 'test' - - @classmethod - def from_valid_json(cls, unused_obj): - return '!' - - def _fields_to_json(self): - return {'foo': 'bar'} - - self.parent_cls = MockParentTypedACMEObject - self.msg = MockTypedACMEObject() - - def test_to_json(self): - self.assertEqual(self.msg.to_json(), { - 'type': 'test', - 'foo': 'bar', - }) - - def test_from_json_unknown_type_fails(self): - self.assertRaises(errors.UnrecognizedTypeError, - self.parent_cls.from_valid_json, {'type': 'bar'}) - - def test_from_json_returns_obj(self): - self.assertEqual(self.parent_cls.from_valid_json({'type': 'test'}), '!') - - -if __name__ == '__main__': - unittest.main() diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py new file mode 100644 index 000000000..6c0ca9262 --- /dev/null +++ b/letsencrypt/client/account.py @@ -0,0 +1,231 @@ +"""Creates ACME accounts for server.""" +import logging +import os +import re + +import configobj +import zope.component + +from letsencrypt.acme import messages2 + +from letsencrypt.client import crypto_util +from letsencrypt.client import errors +from letsencrypt.client import interfaces +from letsencrypt.client import le_util + +from letsencrypt.client.display import util as display_util + + +class Account(object): + """ACME protocol registration. + + :ivar config: Client configuration object + :type config: :class:`~letsencrypt.client.interfaces.IConfig` + :ivar key: Account/Authorized Key + :type key: :class:`~letsencrypt.client.le_util.Key` + + :ivar str email: Client's email address + :ivar str phone: Client's phone number + + :ivar regr: Registration Resource + :type regr: :class:`~letsencrypt.acme.messages2.RegistrationResource` + + """ + + # Just make sure we don't get pwned + # Make sure that it also doesn't start with a period or have two consecutive + # periods <- this needs to be done in addition to the regex + EMAIL_REGEX = re.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$") + + def __init__(self, config, key, email=None, phone=None, regr=None): + le_util.make_or_verify_dir( + config.accounts_dir, 0o700, os.geteuid()) + self.key = key + self.config = config + if email is not None and self.safe_email(email): + self.email = email + else: + self.email = None + self.phone = phone + + self.regr = regr + + @property + def uri(self): + """URI link for new registrations.""" + if self.regr is not None: + return self.regr.uri + else: + return None + + @property + def new_authzr_uri(self): # pylint: disable=missing-docstring + if self.regr is not None: + return self.regr.new_authzr_uri + else: + return None + + @property + def terms_of_service(self): # pylint: disable=missing-docstring + if self.regr is not None: + return self.regr.terms_of_service + else: + return None + + @property + def recovery_token(self): # pylint: disable=missing-docstring + if self.regr is not None and self.regr.body is not None: + return self.regr.body.recovery_token + else: + return None + + def save(self): + """Save account to disk.""" + le_util.make_or_verify_dir( + self.config.accounts_dir, 0o700, os.geteuid()) + + acc_config = configobj.ConfigObj() + acc_config.filename = os.path.join( + self.config.accounts_dir, self._get_config_filename(self.email)) + + acc_config.initial_comment = [ + "DO NOT EDIT THIS FILE", + "Account information for %s under %s" % ( + self._get_config_filename(self.email), self.config.server), + ] + + acc_config["key"] = self.key.file + acc_config["phone"] = self.phone + + if self.regr is not None: + acc_config["RegistrationResource"] = {} + acc_config["RegistrationResource"]["uri"] = self.uri + acc_config["RegistrationResource"]["new_authzr_uri"] = ( + self.new_authzr_uri) + acc_config["RegistrationResource"]["terms_of_service"] = ( + self.terms_of_service) + + regr_dict = self.regr.body.to_json() + acc_config["RegistrationResource"]["body"] = regr_dict + + acc_config.write() + + @classmethod + def _get_config_filename(cls, email): + return email if email is not None and email else "default" + + @classmethod + def from_existing_account(cls, config, email=None): + """Populate an account from an existing email.""" + config_fp = os.path.join( + config.accounts_dir, cls._get_config_filename(email)) + return cls._from_config_fp(config, config_fp) + + @classmethod + def _from_config_fp(cls, config, config_fp): + try: + acc_config = configobj.ConfigObj( + infile=config_fp, file_error=True, create_empty=False) + except IOError: + raise errors.LetsEncryptClientError( + "Account for %s does not exist" % os.path.basename(config_fp)) + + if os.path.basename(config_fp) != "default": + email = os.path.basename(config_fp) + else: + email = None + phone = acc_config["phone"] if acc_config["phone"] != "None" else None + + with open(acc_config["key"]) as key_file: + key = le_util.Key(acc_config["key"], key_file.read()) + + if "RegistrationResource" in acc_config: + acc_config_rr = acc_config["RegistrationResource"] + regr = messages2.RegistrationResource( + uri=acc_config_rr["uri"], + new_authzr_uri=acc_config_rr["new_authzr_uri"], + terms_of_service=acc_config_rr["terms_of_service"], + body=messages2.Registration.from_json(acc_config_rr["body"])) + else: + regr = None + + return cls(config, key, email, phone, regr) + + @classmethod + def get_accounts(cls, config): + """Return all current accounts. + + :param config: Configuration + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + """ + try: + filenames = os.listdir(config.accounts_dir) + except OSError: + return [] + + accounts = [] + for name in filenames: + # Not some directory ie. keys + config_fp = os.path.join(config.accounts_dir, name) + if os.path.isfile(config_fp): + accounts.append(cls._from_config_fp(config, config_fp)) + + return accounts + + @classmethod + def from_prompts(cls, config): + """Generate an account from prompted user input. + + :param config: Configuration + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + :returns: Account or None + :rtype: :class:`letsencrypt.client.account.Account` + + """ + while True: + code, email = zope.component.getUtility(interfaces.IDisplay).input( + "Enter email address (optional, press Enter to skip)") + + if code == display_util.OK: + try: + return cls.from_email(config, email) + except errors.LetsEncryptClientError: + continue + else: + return None + + @classmethod + def from_email(cls, config, email): + """Generate a new account from an email address. + + :param config: Configuration + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + :param str email: Email address + + :raises letsencrypt.client.errors.LetsEncryptClientError: If invalid + email address is given. + + """ + if not email or cls.safe_email(email): + email = email if email else None + + le_util.make_or_verify_dir( + config.account_keys_dir, 0o700, os.geteuid()) + key = crypto_util.init_save_key( + config.rsa_key_size, config.account_keys_dir, + cls._get_config_filename(email)) + return cls(config, key, email) + + raise errors.LetsEncryptClientError("Invalid email address.") + + @classmethod + def safe_email(cls, email): + """Scrub email address before using it.""" + if cls.EMAIL_REGEX.match(email): + return not email.startswith(".") and ".." not in email + else: + logging.warn("Invalid email address.") + return False diff --git a/letsencrypt/client/achallenges.py b/letsencrypt/client/achallenges.py index 835bd1e8d..1a5cf9c8e 100644 --- a/letsencrypt/client/achallenges.py +++ b/letsencrypt/client/achallenges.py @@ -1,23 +1,24 @@ """Client annotated ACME challenges. -Please use names such as ``achall`` and ``ichall`` (respectively ``achalls`` -and ``ichalls`` for collections) to distiguish from variables "of type" -:class:`letsencrypt.acme.challenges.Challenge` (denoted by ``chall``):: +Please use names such as ``achall`` to distiguish from variables "of type" +:class:`letsencrypt.acme.challenges.Challenge` (denoted by ``chall``) +and :class:`.ChallengeBody` (denoted by ``challb``):: from letsencrypt.acme import challenges + from letsencrypt.acme import messages2 from letsencrypt.client import achallenges chall = challenges.DNS(token='foo') - achall = achallenges.DNS(chall=chall, domain='example.com') - ichall = achallenges.Indexed(achall=achall, index=0) + challb = messages2.ChallengeBody(chall=chall) + achall = achallenges.DNS(chall=challb, domain='example.com') Note, that all annotated challenges act as a proxy objects:: - ichall.token == achall.token == chall.token + achall.token == challb.token """ from letsencrypt.acme import challenges -from letsencrypt.acme import util as acme_util +from letsencrypt.acme.jose import util as jose_util from letsencrypt.client import crypto_util @@ -25,22 +26,25 @@ from letsencrypt.client import crypto_util # pylint: disable=too-few-public-methods -class AnnotatedChallenge(acme_util.ImmutableMap): +class AnnotatedChallenge(jose_util.ImmutableMap): """Client annotated challenge. - Wraps around :class:`~letsencrypt.acme.challenges.Challenge` and - annotates with data usfeul for the client. + Wraps around server provided challenge and annotates with data + useful for the client. + + :ivar challb: Wrapped `~.ChallengeBody`. """ + __slots__ = ('challb',) acme_type = NotImplemented def __getattr__(self, name): - return getattr(self.chall, name) + return getattr(self.challb, name) class DVSNI(AnnotatedChallenge): """Client annotated "dvsni" ACME challenge.""" - __slots__ = ('chall', 'domain', 'key') + __slots__ = ('challb', 'domain', 'key') acme_type = challenges.DVSNI def gen_cert_and_response(self, s=None): # pylint: disable=invalid-name @@ -54,49 +58,35 @@ class DVSNI(AnnotatedChallenge): """ response = challenges.DVSNIResponse(s=s) cert_pem = crypto_util.make_ss_cert(self.key.pem, [ - self.nonce_domain, self.domain, response.z_domain(self.chall)]) + self.nonce_domain, self.domain, response.z_domain(self.challb)]) return cert_pem, response class SimpleHTTPS(AnnotatedChallenge): """Client annotated "simpleHttps" ACME challenge.""" - __slots__ = ('chall', 'domain', 'key') + __slots__ = ('challb', 'domain', 'key') acme_type = challenges.SimpleHTTPS class DNS(AnnotatedChallenge): """Client annotated "dns" ACME challenge.""" - __slots__ = ('chall', 'domain') + __slots__ = ('challb', 'domain') acme_type = challenges.DNS class RecoveryContact(AnnotatedChallenge): """Client annotated "recoveryContact" ACME challenge.""" - __slots__ = ('chall', 'domain') + __slots__ = ('challb', 'domain') acme_type = challenges.RecoveryContact class RecoveryToken(AnnotatedChallenge): """Client annotated "recoveryToken" ACME challenge.""" - __slots__ = ('chall', 'domain') + __slots__ = ('challb', 'domain') acme_type = challenges.RecoveryToken class ProofOfPossession(AnnotatedChallenge): """Client annotated "proofOfPossession" ACME challenge.""" - __slots__ = ('chall', 'domain') + __slots__ = ('challb', 'domain') acme_type = challenges.ProofOfPossession - - -class Indexed(acme_util.ImmutableMap): - """Indexed and annotated ACME challenge. - - Wraps around :class:`AnnotatedChallenge` and annotates with an - ``index`` in order to maintain the proper position of the response - within a larger challenge list. - - """ - __slots__ = ('achall', 'index') - - def __getattr__(self, name): - return getattr(self.achall, name) diff --git a/letsencrypt/client/apache/__init__.py b/letsencrypt/client/apache/__init__.py deleted file mode 100644 index f1b2c08e7..000000000 --- a/letsencrypt/client/apache/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Let's Encrypt client.apache.""" diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 4e3b5f68f..0f2d76653 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -1,209 +1,235 @@ """ACME AuthHandler.""" +import itertools import logging -import sys - -import Crypto.PublicKey.RSA +import time from letsencrypt.acme import challenges -from letsencrypt.acme import messages +from letsencrypt.acme import messages2 from letsencrypt.client import achallenges from letsencrypt.client import constants from letsencrypt.client import errors -class AuthHandler(object): # pylint: disable=too-many-instance-attributes +class AuthHandler(object): """ACME Authorization Handler for a client. :ivar dv_auth: Authenticator capable of solving - :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 - :type network: :class:`letsencrypt.client.network.Network` + :type network: :class:`letsencrypt.client.network2.Network` - :ivar list domains: list of str domains to get authorization - :ivar dict authkey: Authorized Keys for each domain. - values are of type :class:`letsencrypt.client.le_util.Key` - :ivar dict responses: keys: domain, values: list of responses - (:class:`letsencrypt.acme.challenges.ChallengeResponse`. - :ivar dict msgs: ACME Challenge messages with domain as a key. - :ivar dict paths: optimal path for authorization. eg. paths[domain] - :ivar dict dv_c: Keys - domain, Values are DV challenges in the form of - :class:`letsencrypt.client.achallenges.Indexed` - :ivar dict client_c: Keys - domain, Values are Client challenges in the form - of :class:`letsencrypt.client.achallenges.Indexed` + :ivar account: Client's Account + :type account: :class:`letsencrypt.client.account.Account` + + :ivar dict authzr: ACME Authorization Resource dict where keys are domains + and values are :class:`letsencrypt.acme.messages2.AuthorizationResource` + :ivar list dv_c: DV challenges in the form of + :class:`letsencrypt.client.achallenges.AnnotatedChallenge` + :ivar list cont_c: Continuity challenges in the + form of :class:`letsencrypt.client.achallenges.AnnotatedChallenge` """ - def __init__(self, dv_auth, client_auth, network): + def __init__(self, dv_auth, cont_auth, network, account): self.dv_auth = dv_auth - self.client_auth = client_auth + self.cont_auth = cont_auth self.network = network - self.domains = [] - self.authkey = dict() - self.responses = dict() - self.msgs = dict() - self.paths = dict() + self.account = account + self.authzr = dict() - self.dv_c = dict() - self.client_c = dict() + # List must be used to keep responses straight. + self.dv_c = [] + self.cont_c = [] - def add_chall_msg(self, domain, msg, authkey): - """Add a challenge message to the AuthHandler. + def get_authorizations(self, domains, best_effort=False): + """Retrieve all authorizations for challenges. - :param str domain: domain for authorization + :param set domains: Domains for authorization + :param bool best_effort: Whether or not all authorizations are required + (this is useful in renewal) - :param msg: ACME "challenge" message - :type msg: :class:`letsencrypt.acme.message.Challenge` + :returns: tuple of lists of authorization resources. Takes the form of + (`completed`, `failed`) + rtype: tuple - :param authkey: authorized key for the challenge - :type authkey: :class:`letsencrypt.client.le_util.Key` - - """ - if domain in self.domains: - raise errors.LetsEncryptAuthHandlerError( - "Multiple ACMEChallengeMessages for the same domain " - "is not supported.") - self.domains.append(domain) - self.responses[domain] = [None] * len(msg.challenges) - self.msgs[domain] = msg - self.authkey[domain] = authkey - - def get_authorizations(self): - """Retreive all authorizations for challenges. - - :raises LetsEncryptAuthHandlerError: If unable to retrieve all + :raises AuthorizationError: If unable to retrieve all authorizations """ - progress = True - while self.msgs and progress: - progress = False - self._satisfy_challenges() + for domain in domains: + self.authzr[domain] = self.network.request_domain_challenges( + domain, self.account.new_authzr_uri) - delete_list = [] + self._choose_challenges(domains) - for dom in self.domains: - if self._path_satisfied(dom): - self.acme_authorization(dom) - delete_list.append(dom) + # While there are still challenges remaining... + while self.dv_c or self.cont_c: + cont_resp, dv_resp = self._solve_challenges() + logging.info("Waiting for verification...") - # This avoids modifying while iterating over the list - if delete_list: - self._cleanup_state(delete_list) - progress = True + # Send all Responses - this modifies dv_c and cont_c + self._respond(cont_resp, dv_resp, best_effort) - if not progress: - raise errors.LetsEncryptAuthHandlerError( - "Unable to solve challenges for requested names.") + # Just make sure all decisions are complete. + self.verify_authzr_complete() + # Only return valid authorizations + return [authzr for authzr in self.authzr.values() + if authzr.body.status == messages2.STATUS_VALID] - def acme_authorization(self, domain): - """Handle ACME "authorization" phase. - - :param str domain: domain that is requesting authorization - - :returns: ACME "authorization" message. - :rtype: :class:`letsencrypt.acme.messages.Authorization` - - """ - try: - auth = self.network.send_and_receive_expected( - messages.AuthorizationRequest.create( - session_id=self.msgs[domain].session_id, - nonce=self.msgs[domain].nonce, - responses=self.responses[domain], - name=domain, - key=Crypto.PublicKey.RSA.importKey( - self.authkey[domain].pem)), - messages.Authorization) - logging.info("Received Authorization for %s", domain) - return auth - except errors.LetsEncryptClientError as err: - logging.fatal(str(err)) - logging.fatal( - "Failed Authorization procedure - cleaning up challenges") - sys.exit(1) - finally: - self._cleanup_challenges(domain) - - def _satisfy_challenges(self): - """Attempt to satisfy all saved challenge messages. - - .. todo:: It might be worth it to try different challenges to - find one that doesn't throw an exception - .. todo:: separate into more functions - - """ + def _choose_challenges(self, domains): + """Retrieve necessary challenges to satisfy server.""" logging.info("Performing the following challenges:") - for dom in self.domains: - self.paths[dom] = gen_challenge_path( - self.msgs[dom].challenges, + for dom in domains: + path = gen_challenge_path( + self.authzr[dom].body.challenges, self._get_chall_pref(dom), - self.msgs[dom].combinations) + self.authzr[dom].body.combinations) - self.dv_c[dom], self.client_c[dom] = self._challenge_factory( - dom, self.paths[dom]) + dom_cont_c, dom_dv_c = self._challenge_factory( + dom, path) + self.dv_c.extend(dom_dv_c) + self.cont_c.extend(dom_cont_c) - # 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_dv = [] - - for dom in self.domains: - flat_client.extend(ichall.achall for ichall in self.client_c[dom]) - flat_dv.extend(ichall.achall for ichall in self.dv_c[dom]) - - client_resp = [] + def _solve_challenges(self): + """Get Responses for challenges from authenticators.""" + cont_resp = [] dv_resp = [] try: - if flat_client: - client_resp = self.client_auth.perform(flat_client) - if flat_dv: - dv_resp = self.dv_auth.perform(flat_dv) + if self.cont_c: + cont_resp = self.cont_auth.perform(self.cont_c) + if self.dv_c: + dv_resp = self.dv_auth.perform(self.dv_c) # This will catch both specific types of errors. - except errors.LetsEncryptAuthHandlerError as err: - logging.critical("Failure in setting up challenges:") - logging.critical(str(err)) + except errors.AuthorizationError: + logging.critical("Failure in setting up challenges.") logging.info("Attempting to clean up outstanding challenges...") - for dom in self.domains: - self._cleanup_challenges(dom) + self._cleanup_challenges() + raise - raise errors.LetsEncryptAuthHandlerError( - "Unable to perform challenges") + assert len(cont_resp) == len(self.cont_c) + assert len(dv_resp) == len(self.dv_c) - logging.info("Ready for verification...") + return cont_resp, dv_resp - # Assemble Responses - if client_resp: - self._assign_responses(client_resp, self.client_c) - if dv_resp: - self._assign_responses(dv_resp, self.dv_c) + def _respond(self, cont_resp, dv_resp, best_effort): + """Send/Receive confirmation of all challenges. - def _assign_responses(self, flat_list, ichall_dict): - """Assign responses from flat_list back to the Indexed dicts. - - :param list flat_list: flat_list of responses from an IAuthenticator - :param dict ichall_dict: Master dict mapping all domains to a list of - their associated 'client' and 'dv' Indexed challenges, or their - :class:`letsencrypt.client.achallenges.Indexed` list + .. note:: This method also cleans up the auth_handler state. """ - flat_index = 0 - for dom in self.domains: - for ichall in ichall_dict[dom]: - self.responses[dom][ichall.index] = flat_list[flat_index] - flat_index += 1 + # TODO: chall_update is a dirty hack to get around acme-spec #105 + chall_update = dict() + active_achalls = [] + active_achalls.extend( + self._send_responses(self.dv_c, dv_resp, chall_update)) + active_achalls.extend( + self._send_responses(self.cont_c, cont_resp, chall_update)) - def _path_satisfied(self, dom): - """Returns whether a path has been completely satisfied.""" - return all(self.responses[dom][i] is not None for i in self.paths[dom]) + # Check for updated status... + self._poll_challenges(chall_update, best_effort) + # This removes challenges from self.dv_c and self.cont_c + self._cleanup_challenges(active_achalls) + + def _send_responses(self, achalls, resps, chall_update): + """Send responses and make sure errors are handled. + + :param dict chall_update: parameter that is updated to hold + authzr -> list of outstanding solved annotated challenges + + """ + active_achalls = [] + for achall, resp in itertools.izip(achalls, resps): + # Don't send challenges for None and False authenticator responses + if resp: + self.network.answer_challenge(achall.challb, resp) + active_achalls.append(achall) + if achall.domain in chall_update: + chall_update[achall.domain].append(achall) + else: + chall_update[achall.domain] = [achall] + + return active_achalls + + def _poll_challenges( + self, chall_update, best_effort, min_sleep=3, max_rounds=15): + """Wait for all challenge results to be determined.""" + dom_to_check = set(chall_update.keys()) + comp_domains = set() + rounds = 0 + + while dom_to_check and rounds < max_rounds: + # TODO: Use retry-after... + time.sleep(min_sleep) + for domain in dom_to_check: + comp_challs, failed_challs = self._handle_check( + domain, chall_update[domain]) + + if len(comp_challs) == len(chall_update[domain]): + comp_domains.add(domain) + elif not failed_challs: + for chall in comp_challs: + chall_update[domain].remove(chall) + # We failed some challenges... damage control + else: + # Right now... just assume a loss and carry on... + if best_effort: + comp_domains.add(domain) + else: + raise errors.AuthorizationError( + "Failed Authorization procedure for %s" % domain) + + dom_to_check -= comp_domains + comp_domains.clear() + rounds += 1 + + def _handle_check(self, domain, achalls): + """Returns tuple of ('completed', 'failed').""" + completed = [] + failed = [] + + self.authzr[domain], _ = self.network.poll(self.authzr[domain]) + if self.authzr[domain].body.status == messages2.STATUS_VALID: + return achalls, [] + + # Note: if the whole authorization is invalid, the individual failed + # challenges will be determined here... + for achall in achalls: + status = self._get_chall_status(self.authzr[domain], achall) + + # This does nothing for challenges that have yet to be decided yet. + if status == messages2.STATUS_VALID: + completed.append(achall) + elif status == messages2.STATUS_INVALID: + failed.append(achall) + + return completed, failed + + def _get_chall_status(self, authzr, achall): # pylint: disable=no-self-use + """Get the status of the challenge. + + .. warning:: This assumes only one instance of type of challenge in + each challenge resource. + + :param authzr: Authorization Resource + :type authzr: :class:`letsencrypt.acme.messages2.AuthorizationResource` + + :param achall: Annotated challenge for which to get status + :type achall: :class:`letsencrypt.client.achallenges.AnnotatedChallenge` + + """ + for authzr_challb in authzr.body.challenges: + if type(authzr_challb.chall) is type(achall.challb.chall): + return authzr_challb.status + raise errors.AuthorizationError( + "Target challenge not found in authorization resource") def _get_chall_pref(self, domain): """Return list of challenge preferences. @@ -211,45 +237,49 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :param str domain: domain for which you are requesting preferences """ + # Make sure to make a copy... chall_prefs = [] - chall_prefs.extend(self.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 - def _cleanup_challenges(self, domain): - """Cleanup configuration challenges + def _cleanup_challenges(self, achall_list=None): + """Cleanup challenges. - :param str domain: domain for which to clean up challenges + If achall_list is not provided, cleanup all achallenges. """ - logging.info("Cleaning up challenges for %s", domain) - # These are indexed challenges... give just the challenges to the auth - # Chose to make these lists instead of a generator to make it easier to - # work with... - dv_list = [ichall.achall for ichall in self.dv_c[domain]] - client_list = [ichall.achall for ichall in self.client_c[domain]] - if dv_list: - self.dv_auth.cleanup(dv_list) - if client_list: - self.client_auth.cleanup(client_list) + logging.info("Cleaning up challenges") - def _cleanup_state(self, delete_list): - """Cleanup state after an authorization is received. + if achall_list is None: + dv_c = self.dv_c + cont_c = self.cont_c + else: + dv_c = [achall for achall in achall_list + if isinstance(achall.chall, challenges.DVChallenge)] + cont_c = [achall for achall in achall_list if isinstance( + achall.chall, challenges.ContinuityChallenge)] - :param list delete_list: list of domains in str form + if dv_c: + self.dv_auth.cleanup(dv_c) + for achall in dv_c: + self.dv_c.remove(achall) + if cont_c: + self.cont_auth.cleanup(cont_c) + for achall in cont_c: + self.cont_c.remove(achall) + + def verify_authzr_complete(self): + """Verifies that all authorizations have been decided. + + :returns: Whether all authzr are complete + :rtype: bool """ - for domain in delete_list: - del self.msgs[domain] - del self.responses[domain] - del self.paths[domain] - - del self.authkey[domain] - - del self.client_c[domain] - del self.dv_c[domain] - - self.domains.remove(domain) + for authzr in self.authzr.values(): + if (authzr.body.status != messages2.STATUS_VALID and + authzr.body.status != messages2.STATUS_INVALID): + raise errors.AuthorizationError("Incomplete authorizations") def _challenge_factory(self, domain, path): """Construct Namedtuple Challenges @@ -258,9 +288,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 @@ -269,79 +299,102 @@ 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] + challb = self.authzr[domain].body.challenges[index] + chall = challb.chall - if isinstance(chall, challenges.DVSNI): - logging.info(" DVSNI challenge for %s.", domain) - achall = achallenges.DVSNI( - chall=chall, domain=domain, key=self.authkey[domain]) - elif isinstance(chall, challenges.SimpleHTTPS): - logging.info(" SimpleHTTPS challenge for %s.", domain) - achall = achallenges.SimpleHTTPS( - chall=chall, domain=domain, key=self.authkey[domain]) - elif isinstance(chall, challenges.DNS): - logging.info(" DNS challenge for %s.", domain) - achall = achallenges.DNS(chall=chall, domain=domain) + achall = challb_to_achall(challb, self.account.key, domain) - elif isinstance(chall, challenges.RecoveryToken): - logging.info(" Recovery Token Challenge for %s.", domain) - achall = achallenges.RecoveryToken(chall=chall, domain=domain) - elif isinstance(chall, challenges.RecoveryContact): - logging.info(" Recovery Contact Challenge for %s.", domain) - achall = achallenges.RecoveryContact(chall=chall, domain=domain) - elif isinstance(chall, challenges.ProofOfPossession): - logging.info(" Proof-of-Possession Challenge for %s", domain) - achall = achallenges.ProofOfPossession( - chall=chall, domain=domain) - - else: - raise errors.LetsEncryptClientError( - "Received unsupported challenge of type: " - "%s" % chall.acme_type) - - ichall = achallenges.Indexed(achall=achall, index=index) - - if isinstance(chall, challenges.ClientChallenge): - client_chall.append(ichall) + if isinstance(chall, challenges.ContinuityChallenge): + cont_chall.append(achall) elif isinstance(chall, challenges.DVChallenge): - dv_chall.append(ichall) + dv_chall.append(achall) - return dv_chall, client_chall + return cont_chall, dv_chall -def gen_challenge_path(challs, preferences, combinations): +def challb_to_achall(challb, key, domain): + """Converts a ChallengeBody object to an AnnotatedChallenge. + + :param challb: ChallengeBody + :type challb: :class:`letsencrypt.acme.messages2.ChallengeBody` + + :param key: Key + :type key: :class:`letsencrypt.client.le_util.Key` + + :param str domain: Domain of the challb + + :returns: Appropriate AnnotatedChallenge + :rtype: :class:`letsencrypt.client.achallenges.AnnotatedChallenge` + + """ + chall = challb.chall + + if isinstance(chall, challenges.DVSNI): + logging.info(" DVSNI challenge for %s.", domain) + return achallenges.DVSNI( + challb=challb, domain=domain, key=key) + elif isinstance(chall, challenges.SimpleHTTPS): + logging.info(" SimpleHTTPS challenge for %s.", domain) + return achallenges.SimpleHTTPS( + challb=challb, domain=domain, key=key) + elif isinstance(chall, challenges.DNS): + logging.info(" DNS challenge for %s.", domain) + return achallenges.DNS(challb=challb, domain=domain) + + elif isinstance(chall, challenges.RecoveryToken): + logging.info(" Recovery Token Challenge for %s.", domain) + return achallenges.RecoveryToken(challb=challb, domain=domain) + elif isinstance(chall, challenges.RecoveryContact): + logging.info(" Recovery Contact Challenge for %s.", domain) + return achallenges.RecoveryContact( + challb=challb, domain=domain) + elif isinstance(chall, challenges.ProofOfPossession): + logging.info(" Proof-of-Possession Challenge for %s", domain) + return achallenges.ProofOfPossession( + challb=challb, domain=domain) + + else: + raise errors.LetsEncryptClientError( + "Received unsupported challenge of type: %s", + chall.typ) + + +def gen_challenge_path(challbs, preferences, combinations): """Generate a plan to get authority over the identity. - .. todo:: 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 - (:class:`letsencrypt.acme.challenges.Challenge`) from - :class:`letsencrypt.acme.messages.Challenge` server message to - be fulfilled by the client in order to prove possession of the + :param tuple challbs: A tuple of challenges + (:class:`letsencrypt.acme.messages2.Challenge`) from + :class:`letsencrypt.acme.messages2.AuthorizationResource` to be + fulfilled by the client in order to prove possession of the identifier. :param list preferences: List of challenge preferences for domain - (: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.AuthorizationError: If a + path cannot be created that satisfies the CA given the preferences and + combinations. """ if combinations: - return _find_smart_path(challs, preferences, combinations) + return _find_smart_path(challbs, preferences, combinations) else: - return _find_dumb_path(challs, preferences) + return _find_dumb_path(challbs, preferences) -def _find_smart_path(challs, preferences, combinations): +def _find_smart_path(challbs, preferences, combinations): """Find challenge path with server hints. Can be called if combinations is included. Function uses a simple @@ -349,34 +402,39 @@ 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) + combo_total += chall_cost.get(challbs[ + challenge_index].chall.__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.AuthorizationError(msg) return best_combo -def _find_dumb_path(challs, preferences): +def _find_dumb_path(challbs, preferences): """Find challenge path without server hints. Should be called if the combinations hint is not included by the @@ -389,11 +447,11 @@ def _find_dumb_path(challs, preferences): path = [] satisfied = set() for pref_c in preferences: - for i, offered_chall in enumerate(challs): - if (isinstance(offered_chall, pref_c) and - is_preferred(offered_chall, satisfied)): + for i, offered_challb in enumerate(challbs): + if (isinstance(offered_challb.chall, pref_c) and + is_preferred(offered_challb, satisfied)): path.append(i) - satisfied.add(offered_chall) + satisfied.add(offered_challb) return path @@ -413,11 +471,12 @@ def mutually_exclusive(obj1, obj2, groups, different=False): return True -def is_preferred(offered_chall, satisfied, +def is_preferred(offered_challb, satisfied, exclusive_groups=constants.EXCLUSIVE_CHALLENGES): """Return whether or not the challenge is preferred in path.""" - for chall in satisfied: + for challb in satisfied: if not mutually_exclusive( - offered_chall, chall, exclusive_groups, different=True): + offered_challb.chall, challb.chall, exclusive_groups, + different=True): return False return True diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 3a4388076..a4e98fa41 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,24 +1,26 @@ """ACME protocol client class and helper functions.""" import logging import os -import sys +import pkg_resources -import Crypto.PublicKey.RSA import M2Crypto +import zope.component -from letsencrypt.acme import messages -from letsencrypt.acme import util as acme_util +from letsencrypt.acme import jose +from letsencrypt.acme.jose import jwk +from letsencrypt.client import account 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 interfaces from letsencrypt.client import le_util -from letsencrypt.client import network +from letsencrypt.client import network2 from letsencrypt.client import reverter from letsencrypt.client import revoker -from letsencrypt.client.apache import configurator +from letsencrypt.client.plugins.apache import configurator from letsencrypt.client.display import ops as display_ops from letsencrypt.client.display import enhancements @@ -27,13 +29,14 @@ class Client(object): """ACME protocol client. :ivar network: Network object for sending and receiving messages - :type network: :class:`letsencrypt.client.network.Network` + :type network: :class:`letsencrypt.client.network2.Network` - :ivar authkey: Authorization Key - :type authkey: :class:`letsencrypt.client.le_util.Key` + :ivar account: Account object used for registration + :type account: :class:`letsencrypt.client.account.Account` :ivar auth_handler: Object that supports the IAuthenticator interface. - auth_handler contains both a dv_authenticator and a 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. @@ -44,7 +47,7 @@ class Client(object): """ - def __init__(self, config, authkey, dv_auth, installer): + def __init__(self, config, account_, dv_auth, installer): """Initialize a client. :param dv_auth: IAuthenticator that can solve the @@ -54,22 +57,53 @@ class Client(object): :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` """ - self.network = network.Network(config.server) - self.authkey = authkey + self.account = account_ + self.installer = installer + + # TODO: Allow for other alg types besides RS256 + self.network = network2.Network( + config.server_url, jwk.JWKRSA.load(self.account.key.pem)) + self.config = config if dv_auth is not None: - 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, self.account) else: self.auth_handler = None + def register(self): + """New Registration with the ACME server.""" + self.account = self.network.register_from_account(self.account) + if self.account.terms_of_service: + if not self.config.tos: + # TODO: Replace with self.account.terms_of_service + eula = pkg_resources.resource_string("letsencrypt", "EULA") + agree = zope.component.getUtility(interfaces.IDisplay).yesno( + eula, "Agree", "Cancel") + else: + agree = True + + if agree: + self.account.regr = self.network.agree_to_tos(self.account.regr) + else: + # What is the proper response here... + raise errors.LetsEncryptClientError("Must agree to TOS") + + self.account.save() + def obtain_certificate(self, domains, csr=None): """Obtains a certificate from the ACME server. - :param str domains: list of domains to get a certificate + :meth:`.register` must be called before :meth:`.obtain_certificate` + + .. todo:: This function currently uses the account key for the cert. + This should be changed to an independent key once renewal is sorted + out. + + :param set domains: domains to get a certificate :param csr: CSR must contain requested domains, the key used to generate this CSR can be different than self.authkey @@ -80,67 +114,43 @@ class Client(object): """ if self.auth_handler is None: - logging.warning("Unable to obtain a certificate, because client " - "does not have a valid auth handler.") - - # Request Challenges - for name in domains: - self.auth_handler.add_chall_msg( - name, self.acme_challenge(name), self.authkey) + msg = ("Unable to obtain certificate because authenticator is " + "not set.") + logging.warning(msg) + raise errors.LetsEncryptClientError(msg) + if self.account.regr is None: + raise errors.LetsEncryptClientError( + "Please register with the ACME server first.") # Perform Challenges/Get Authorizations - self.auth_handler.get_authorizations() + authzr = self.auth_handler.get_authorizations(domains) # Create CSR from names if csr is None: - csr = init_csr(self.authkey, domains, self.config.cert_dir) + csr = crypto_util.init_save_csr( + self.account.key, domains, self.config.cert_dir) # Retrieve certificate - certificate_msg = self.acme_certificate(csr.data) + certr = self.network.request_issuance( + jose.ComparableX509( + M2Crypto.X509.load_request_der_string(csr.data)), + authzr) # Save Certificate cert_file, chain_file = self.save_certificate( - certificate_msg, self.config.cert_path, self.config.chain_path) + certr, self.config.cert_path, self.config.chain_path) revoker.Revoker.store_cert_key( - cert_file, self.authkey.file, self.config) + cert_file, self.account.key.file, self.config) return cert_file, chain_file - def acme_challenge(self, domain): - """Handle ACME "challenge" phase. - - :returns: ACME "challenge" message. - :rtype: :class:`letsencrypt.acme.messages.Challenge` - - """ - return self.network.send_and_receive_expected( - messages.ChallengeRequest(identifier=domain), - messages.Challenge) - - def acme_certificate(self, csr_der): - """Handle ACME "certificate" phase. - - :param str csr_der: CSR in DER format. - - :returns: ACME "certificate" message. - :rtype: :class:`letsencrypt.acme.message.Certificate` - - """ - logging.info("Preparing and sending CSR...") - return self.network.send_and_receive_expected( - messages.CertificateRequest.create( - csr=acme_util.ComparableX509( - M2Crypto.X509.load_request_der_string(csr_der)), - key=Crypto.PublicKey.RSA.importKey(self.authkey.pem)), - messages.Certificate) - - def save_certificate(self, certificate_msg, cert_path, chain_path): + def save_certificate(self, certr, cert_path, chain_path): # pylint: disable=no-self-use """Saves the certificate received from the ACME server. - :param certificate_msg: ACME "certificate" message from server. - :type certificate_msg: :class:`letsencrypt.acme.messages.Certificate` + :param certr: ACME "certificate" resource. + :type certr: :class:`letsencrypt.acme.messages.Certificate` :param str cert_path: Path to attempt to save the cert file :param str chain_path: Path to attempt to save the chain file @@ -151,25 +161,36 @@ class Client(object): :raises IOError: If unable to find room to write the cert files """ + # try finally close cert_chain_abspath = None - cert_fd, cert_file = le_util.unique_file(cert_path, 0o644) - cert_fd.write(certificate_msg.certificate.as_pem()) - cert_fd.close() - logging.info( - "Server issued certificate; certificate written to %s", cert_file) + cert_file, act_cert_path = le_util.unique_file(cert_path, 0o644) + # TODO: Except + cert_pem = certr.body.as_pem() + try: + cert_file.write(cert_pem) + finally: + cert_file.close() + logging.info("Server issued certificate; certificate written to %s", + act_cert_path) - if certificate_msg.chain: - chain_fd, chain_fn = le_util.unique_file(chain_path, 0o644) - for cert in certificate_msg.chain: - chain_fd.write(cert.to_pem()) - chain_fd.close() + if certr.cert_chain_uri: + # TODO: Except + chain_cert = self.network.fetch_chain(certr.cert_chain_uri) + if chain_cert: + chain_file, act_chain_path = le_util.unique_file( + chain_path, 0o644) + chain_pem = chain_cert.to_pem() + try: + chain_file.write(chain_pem) + finally: + chain_file.close() - logging.info("Cert chain written to %s", chain_fn) + logging.info("Cert chain written to %s", act_chain_path) - # This expects a valid chain file - cert_chain_abspath = os.path.abspath(chain_fn) + # This expects a valid chain file + cert_chain_abspath = os.path.abspath(act_chain_path) - return os.path.abspath(cert_file), cert_chain_abspath + return os.path.abspath(act_cert_path), cert_chain_abspath def deploy_certificate(self, domains, privkey, cert_file, chain_file=None): """Install certificate @@ -293,68 +314,29 @@ def validate_key_csr(privkey, csr=None): "The key and CSR do not match") -def init_key(key_size, key_dir): - """Initializes privkey. +def list_available_authenticators(avail_auths): + """Return a pretty-printed list of authenticators. - Inits key and CSR using provided files or generating new files - if necessary. Both will be saved in PEM format on the - filesystem. The CSR is placed into DER format to allow - the namedtuple to easily work with the protocol. - - :param str key_dir: Key save directory. + This is used to provide helpful feedback in the case where a user + specifies an invalid authenticator on the command line. """ - try: - key_pem = crypto_util.make_key(key_size) - except ValueError as err: - logging.fatal(str(err)) - sys.exit(1) - - # Save file - le_util.make_or_verify_dir(key_dir, 0o700) - key_f, key_filename = le_util.unique_file( - os.path.join(key_dir, "key-letsencrypt.pem"), 0o600) - key_f.write(key_pem) - key_f.close() - - logging.info("Generating key (%d bits): %s", key_size, key_filename) - - return le_util.Key(key_filename, key_pem) - - -def init_csr(privkey, names, cert_dir): - """Initialize a CSR with the given private key. - - :param privkey: Key to include in the CSR - :type privkey: :class:`letsencrypt.client.le_util.Key` - - :param list names: `str` names to include in the CSR - - :param str cert_dir: Certificate save directory. - - """ - csr_pem, csr_der = crypto_util.make_csr(privkey.pem, names) - - # Save CSR - if not os.environ.get('DOCKER_RUN'): - le_util.make_or_verify_dir(cert_dir, 0o755) - csr_f, csr_filename = le_util.unique_file( - os.path.join(cert_dir, "csr-letsencrypt.pem"), 0o644) - csr_f.write(csr_pem) - csr_f.close() - - logging.info("Creating CSR: %s", csr_filename) - - return le_util.CSR(csr_filename, csr_der, "der") + output_lines = ["Available authenticators:"] + for auth_name, auth in avail_auths.iteritems(): + output_lines.append(" - %s : %s" % (auth_name, auth.description)) + return '\n'.join(output_lines) # This should be controlled by commandline parameters -def determine_authenticator(all_auths): +def determine_authenticator(all_auths, config): """Returns a valid IAuthenticator. :param list all_auths: Where each is a :class:`letsencrypt.client.interfaces.IAuthenticator` object + :param config: Used if an authenticator was specified on the command line. + :type config: :class:`letsencrypt.client.interfaces.IConfig` + :returns: Valid Authenticator object or None :raises letsencrypt.client.errors.LetsEncryptClientError: If no @@ -362,23 +344,32 @@ def determine_authenticator(all_auths): """ # Available Authenticator objects - avail_auths = [] + avail_auths = {} # Error messages for misconfigured authenticators errs = {} - for pot_auth in all_auths: + for auth_name, auth in all_auths.iteritems(): try: - pot_auth.prepare() + auth.prepare() except errors.LetsEncryptMisconfigurationError as err: - errs[pot_auth] = err + errs[auth] = err except errors.LetsEncryptNoInstallationError: continue - avail_auths.append(pot_auth) + avail_auths[auth_name] = auth - if len(avail_auths) > 1: - auth = display_ops.choose_authenticator(avail_auths, errs) - elif len(avail_auths) == 1: - auth = avail_auths[0] + # If an authenticator was specified on the command line, try to use it + if config.authenticator: + try: + auth = avail_auths[config.authenticator] + except KeyError: + logging.info(list_available_authenticators(avail_auths)) + raise errors.LetsEncryptClientError( + "The specified authenticator '%s' could not be found" % + config.authenticator) + elif len(avail_auths) > 1: + auth = display_ops.choose_authenticator(avail_auths.values(), errs) + elif len(avail_auths.keys()) == 1: + auth = avail_auths[avail_auths.keys()[0]] else: raise errors.LetsEncryptClientError("No Authenticators available.") @@ -391,6 +382,28 @@ def determine_authenticator(all_auths): return auth +def determine_account(config): + """Determine which account to use. + + Will create an account if necessary. + + :param config: Configuration object + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + :returns: Account + :rtype: :class:`letsencrypt.client.account.Account` + + """ + accounts = account.Account.get_accounts(config) + + if len(accounts) == 1: + return accounts[0] + elif len(accounts) > 1: + return display_ops.choose_account(accounts) + + return account.Account.from_prompts(config) + + def determine_installer(config): """Returns a valid installer if one exists. diff --git a/letsencrypt/client/configuration.py b/letsencrypt/client/configuration.py index 87502ed63..14c7b23cd 100644 --- a/letsencrypt/client/configuration.py +++ b/letsencrypt/client/configuration.py @@ -28,6 +28,8 @@ class NamespaceConfig(object): zope.interface.implements(interfaces.IConfig) def __init__(self, namespace): + assert not namespace.server.startswith('https://') + assert not namespace.server.startswith('http://') self.namespace = namespace def __getattr__(self, name): @@ -42,11 +44,32 @@ class NamespaceConfig(object): def in_progress_dir(self): # pylint: disable=missing-docstring return os.path.join(self.namespace.work_dir, constants.IN_PROGRESS_DIR) + @property + def server_path(self): + """File path based on ``server``.""" + return self.namespace.server.replace('/', os.path.sep) + + @property + def server_url(self): + """Full server URL (including HTTPS scheme).""" + return 'https://' + self.namespace.server + @property def cert_key_backup(self): # pylint: disable=missing-docstring return os.path.join( self.namespace.work_dir, constants.CERT_KEY_BACKUP_DIR, - self.namespace.server.partition(":")[0]) + self.server_path) + + @property + def accounts_dir(self): #pylint: disable=missing-docstring + return os.path.join( + self.namespace.config_dir, constants.ACCOUNTS_DIR, self.server_path) + + @property + def account_keys_dir(self): #pylint: disable=missing-docstring + return os.path.join( + self.namespace.config_dir, constants.ACCOUNTS_DIR, + self.server_path, constants.ACCOUNT_KEYS_DIR) # TODO: This should probably include the server name @property diff --git a/letsencrypt/client/constants.py b/letsencrypt/client/constants.py index 3e27d88ac..d7cf1bae9 100644 --- a/letsencrypt/client/constants.py +++ b/letsencrypt/client/constants.py @@ -4,14 +4,6 @@ import pkg_resources from letsencrypt.acme import challenges -S_SIZE = 32 -"""Size (in bytes) of secret base64-encoded octet string "s" used in -challenges.""" - -NONCE_SIZE = 16 -"""Size of nonce used in JWS objects (in bytes).""" - - EXCLUSIVE_CHALLENGES = frozenset([frozenset([ challenges.DVSNI, challenges.SimpleHTTPS])]) """Mutually exclusive challenges.""" @@ -31,7 +23,7 @@ List of expected options parameters: APACHE_MOD_SSL_CONF = pkg_resources.resource_filename( - "letsencrypt.client.apache", "options-ssl.conf") + "letsencrypt.client.plugins.apache", "options-ssl.conf") """Path to the Apache mod_ssl config file found in the Let's Encrypt distribution.""" @@ -40,10 +32,15 @@ APACHE_REWRITE_HTTPS_ARGS = [ """Apache rewrite rule arguments used for redirections to https vhost""" -DVSNI_CHALLENGE_PORT = 443 -"""Port to perform DVSNI challenge.""" +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.""" +CONFIG_DIRS_MODE = 0o755 +"""Directory mode for ``.IConfig.config_dir`` et al.""" + TEMP_CHECKPOINT_DIR = "temp_checkpoint" """Temporary checkpoint directory (relative to IConfig.work_dir).""" @@ -55,6 +52,12 @@ CERT_KEY_BACKUP_DIR = "keys-certs" """Directory where all certificates and keys are stored (relative to IConfig.work_dir. Used for easy revocation.""" +ACCOUNTS_DIR = "accounts" +"""Directory where all accounts are saved.""" + +ACCOUNT_KEYS_DIR = "keys" +"""Directory where account keys are saved. Relative to ACCOUNTS_DIR.""" + REC_TOKEN_DIR = "recovery_tokens" """Directory where all recovery tokens are saved (relative to IConfig.work_dir).""" 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/crypto_util.py b/letsencrypt/client/crypto_util.py index e3d0d1c4d..c2b761d59 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -4,6 +4,8 @@ is capable of handling the signatures. """ +import logging +import os import time import Crypto.Hash.SHA256 @@ -12,7 +14,75 @@ import Crypto.Signature.PKCS1_v1_5 import M2Crypto +from letsencrypt.client import le_util + +# High level functions +def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"): + """Initializes and saves a privkey. + + Inits key and saves it in PEM format on the filesystem. + + .. note:: keyname is the attempted filename, it may be different if a file + already exists at the path. + + :param int key_size: RSA key size in bits + :param str key_dir: Key save directory. + :param str keyname: Filename of key + + :returns: Key + :rtype: :class:`letsencrypt.client.le_util.Key` + + :raises ValueError: If unable to generate the key given key_size. + + """ + try: + key_pem = make_key(key_size) + except ValueError as err: + logging.fatal(str(err)) + raise err + + # Save file + le_util.make_or_verify_dir(key_dir, 0o700, os.geteuid()) + key_f, key_path = le_util.unique_file( + os.path.join(key_dir, keyname), 0o600) + key_f.write(key_pem) + key_f.close() + + logging.info("Generating key (%d bits): %s", key_size, key_path) + + return le_util.Key(key_path, key_pem) + + +def init_save_csr(privkey, names, cert_dir, csrname="csr-letsencrypt.pem"): + """Initialize a CSR with the given private key. + + :param privkey: Key to include in the CSR + :type privkey: :class:`letsencrypt.client.le_util.Key` + + :param set names: `str` names to include in the CSR + + :param str cert_dir: Certificate save directory. + + :returns: CSR + :rtype: :class:`letsencrypt.client.le_util.CSR` + + """ + csr_pem, csr_der = make_csr(privkey.pem, names) + + # Save CSR + le_util.make_or_verify_dir(cert_dir, 0o755) + csr_f, csr_filename = le_util.unique_file( + os.path.join(cert_dir, csrname), 0o644) + csr_f.write(csr_pem) + csr_f.close() + + logging.info("Creating CSR: %s", csr_filename) + + return le_util.CSR(csr_filename, csr_der, "der") + + +# Lower level functions def make_csr(key_str, domains): """Generate a CSR. diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index 1cffe2846..d396e1641 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -43,6 +43,28 @@ def choose_authenticator(auths, errs): return +def choose_account(accounts): + """Choose an account. + + :param list accounts: Containing at least one + :class:`~letsencrypt.client.account.Account` + + """ + # Note this will get more complicated once we start recording authorizations + labels = [ + "%s | %s" % (acc.email.ljust(display_util.WIDTH - 39), + acc.phone if acc.phone is not None else "") + for acc in accounts + ] + + code, index = util(interfaces.IDisplay).menu( + "Please choose an account", labels) + if code == display_util.OK: + return accounts[index] + else: + return None + + def choose_names(installer): """Display screen to select domains to validate. diff --git a/letsencrypt/client/display/util.py b/letsencrypt/client/display/util.py index a55716a73..d34c6b46b 100644 --- a/letsencrypt/client/display/util.py +++ b/letsencrypt/client/display/util.py @@ -133,19 +133,21 @@ class NcursesDisplay(object): message, self.height, self.width, yes_label=yes_label, no_label=no_label) - def checklist(self, message, tags): + def checklist(self, message, tags, default_status=True): """Displays a checklist. :param message: Message to display before choices - :param list tags: where each is of type :class:`str` - len(tags) > 0 + :param list tags: where each is of type :class:`str` len(tags) > 0 + :param bool default_status: If True, items are in a selected state by + default. + :returns: tuple of the form (code, list_tags) where `code` - int display exit code `list_tags` - list of str tags selected by the user """ - choices = [(tag, "", False) for tag in tags] + choices = [(tag, "", default_status) for tag in tags] return self.dialog.checklist( message, width=self.width, height=self.height, choices=choices) @@ -257,11 +259,13 @@ class FileDisplay(object): ans.startswith(no_label[0].upper())): return False - def checklist(self, message, tags): + def checklist(self, message, tags, default_status=True): + # pylint: disable=unused-argument """Display a checklist. :param str message: Message to display to user :param list tags: `str` tags to select, len(tags) > 0 + :param bool default_status: Not used for FileDisplay :returns: tuple of (`code`, `tags`) where `code` - str display exit code diff --git a/letsencrypt/client/errors.py b/letsencrypt/client/errors.py index c1d6c785f..f5d9f5f44 100644 --- a/letsencrypt/client/errors.py +++ b/letsencrypt/client/errors.py @@ -5,20 +5,28 @@ 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.""" # Auth Handler Errors -class LetsEncryptAuthHandlerError(LetsEncryptClientError): - """Let's Encrypt Auth Handler error.""" +class AuthorizationError(LetsEncryptClientError): + """Authorization error.""" -class LetsEncryptClientAuthError(LetsEncryptAuthHandlerError): - """Let's Encrypt Client Authenticator error.""" +class LetsEncryptContAuthError(AuthorizationError): + """Let's Encrypt Continuity Authenticator error.""" -class LetsEncryptDvAuthError(LetsEncryptAuthHandlerError): +class LetsEncryptDvAuthError(AuthorizationError): """Let's Encrypt DV Authenticator error.""" diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 6779d4e1e..1d52d854c 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -13,6 +13,10 @@ class IAuthenticator(zope.interface.Interface): """ + description = zope.interface.Attribute( + "Short description of this authenticator. " + "Used in interactive configuration.") + def prepare(): """Prepare the authenticator. @@ -89,6 +93,10 @@ class IConfig(zope.interface.Interface): server = zope.interface.Attribute( "CA hostname (and optionally :port). The server certificate must " "be trusted in order to avoid further modifications to the client.") + authenticator = zope.interface.Attribute( + "Authenticator to use for responding to challenges.") + email = zope.interface.Attribute( + "Email used for registration and recovery contact.") rsa_key_size = zope.interface.Attribute("Size of the RSA key.") config_dir = zope.interface.Attribute("Configuration directory.") @@ -101,6 +109,10 @@ class IConfig(zope.interface.Interface): cert_key_backup = zope.interface.Attribute( "Directory where all certificates and keys are stored. " "Used for easy revocation.") + accounts_dir = zope.interface.Attribute( + "Directory where all account information is stored.") + account_keys_dir = zope.interface.Attribute( + "Directory where all account keys are stored.") rec_token_dir = zope.interface.Attribute( "Directory where all recovery tokens are saved.") key_dir = zope.interface.Attribute("Keys storage.") @@ -123,6 +135,14 @@ class IConfig(zope.interface.Interface): apache_mod_ssl_conf = zope.interface.Attribute( "Contains standard Apache SSL directives.") + nginx_server_root = zope.interface.Attribute( + "Nginx server root directory.") + nginx_ctl = zope.interface.Attribute( + "Path to the 'nginx' binary, used for 'configtest' and " + "retrieving nginx version number.") + nginx_mod_ssl_conf = zope.interface.Attribute( + "Contains standard nginx SSL directives.") + class IInstaller(zope.interface.Interface): """Generic Let's Encrypt Installer Interface. @@ -275,13 +295,13 @@ class IDisplay(zope.interface.Interface): """ - def checklist(message, choices): + def checklist(message, tags, default_state): """Allow for multiple selections from a menu. :param str message: message to display to the user - - :param tags: tags - :type tags: :class:`list` of :class:`str` + :param list tags: where each is of type :class:`str` len(tags) > 0 + :param bool default_status: If True, items are in a selected state by + default. """ diff --git a/letsencrypt/client/log.py b/letsencrypt/client/log.py index a267fa77e..57c642ce2 100644 --- a/letsencrypt/client/log.py +++ b/letsencrypt/client/log.py @@ -37,7 +37,7 @@ class DialogHandler(logging.Handler): # pylint: disable=too-few-public-methods lines. """ - for line in (record.msg % record.args).splitlines(): + for line in record.getMessage().splitlines(): # check for lines that would wrap cur_out = line while len(cur_out) > self.width: diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py index b61a8a2f8..2719583c3 100644 --- a/letsencrypt/client/network.py +++ b/letsencrypt/client/network.py @@ -5,12 +5,15 @@ import time import requests -from letsencrypt.acme import errors as acme_errors +from letsencrypt.acme import jose from letsencrypt.acme import messages from letsencrypt.client import errors +# https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning +requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() + logging.getLogger("requests").setLevel(logging.WARNING) @@ -57,7 +60,7 @@ class Network(object): json_string = response.json() try: return messages.Message.from_json(json_string) - except acme_errors.ValidationError as error: + except jose.DeserializationError as error: logging.error(json_string) raise # TODO diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py new file mode 100644 index 000000000..eaa485a8d --- /dev/null +++ b/letsencrypt/client/network2.py @@ -0,0 +1,547 @@ +"""Networking for ACME protocol v02.""" +import datetime +import heapq +import httplib +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: + logging.error("Error: %s", jobj) + logging.error("Response from server: %s", response.content) + raise messages2.Error.from_json(jobj) + except jose.DeserializationError as error: + # Couldn't deserialize JSON object + 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 accepted by `.Registration` + :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 register_from_account(self, account): + """Register with server. + + :param account: Account + :type account: :class:`letsencrypt.client.account.Account` + + :returns: Updated account + :rtype: :class:`letsencrypt.client.account.Account` + + """ + details = ( + "mailto:" + account.email if account.email is not None else None, + "tel:" + account.phone if account.phone is not None else None, + ) + account.regr = self.register(contact=tuple( + det for det in details if det is not None)) + return account + + def update_registration(self, regr): + """Update registration. + + :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 agree_to_tos(self, regr): + """Agree to the terms-of-service. + + Agree to the terms-of-service in a Registration Resource. + + :param regr: Registration Resource. + :type regr: `.RegistrationResource` + + :returns: Updated Registration Resource. + :rtype: `.RegistrationResource` + + """ + return self.update_registration( + regr.update(body=regr.body.update(agreement=regr.terms_of_service))) + + def _authzr_from_response(self, response, identifier, + uri=None, new_cert_uri=None): + if new_cert_uri is None: + 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, new_authzr_uri): + """Request challenges. + + :param identifier: Identifier to be challenged. + :type identifier: `.messages2.Identifier` + + :param str new_authzr_uri: new-authorization URI + + :returns: Authorization Resource. + :rtype: `.AuthorizationResource` + + """ + new_authz = messages2.Authorization(identifier=identifier) + response = self._post(new_authzr_uri, self._wrap_in_jws(new_authz)) + assert response.status_code == httplib.CREATED # TODO: handle errors + return self._authzr_from_response(response, identifier) + + def request_domain_challenges(self, domain, new_authz_uri): + """Request challenges for domain names. + + This is simply a convenience function that wraps around + `request_challenges`, but works with domain names instead of + generic identifiers. + + :param str domain: Domain name to be challenged. + :param str new_authzr_uri: new-authorization URI + + :returns: Authorization Resource. + :rtype: `.AuthorizationResource` + + """ + return self.request_challenges(messages2.Identifier( + typ=messages2.IDENTIFIER_FQDN, value=domain), new_authz_uri) + + def answer_challenge(self, challb, response): + """Answer challenge. + + :param challb: Challenge Resource body. + :type challb: `.ChallengeBody` + + :param response: Corresponding Challenge response + :type response: `.challenges.ChallengeResponse` + + :returns: Challenge Resource with updated body. + :rtype: `.ChallengeResource` + + :raises errors.UnexpectedUpdate: + + """ + response = self._post(challb.uri, 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 + + @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" + logging.debug("Requesting issuance...") + + # 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 on Retry-After) as key, + # and original Authorization Resource as value + waiting = [(datetime.datetime.now(), authzr) for authzr in authzrs] + # mapping between original Authorization Resource and the most + # recently updated one + updated = dict((authzr, authzr) for authzr in authzrs) + + while waiting: + # find the smallest Retry-After, and sleep if necessary + when, authzr = heapq.heappop(waiting) + now = datetime.datetime.now() + if when > now: + seconds = (when - now).seconds + logging.debug('Sleeping for %d seconds', seconds) + time.sleep(seconds) + + # Note that we poll with the latest updated Authorization + # URI, which might have a different URI than initial one + updated_authzr, response = self.poll(updated[authzr]) + updated[authzr] = updated_authzr + + if updated_authzr.body.status != 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): + """Returns certificate from URI. + + :param str uri: URI of certificate + + :returns: tuple of the form + (response, :class:`letsencrypt.acme.jose.ComparableX509`) + :rtype: tuple + + """ + content_type = self.DER_CONTENT_TYPE # TODO: make it a param + response = self._get(uri, headers={'Accept': content_type}, + content_type=content_type) + return response, jose.ComparableX509( + M2Crypto.X509.load_cert_der_string(response.content)) + + def check_cert(self, certr): + """Check for new cert. + + :param certr: Certificate Resource + :type certr: `.CertificateResource` + + :returns: Updated Certificate Resource. + :rtype: `.CertificateResource` + + """ + # TODO: acme-spec 5.1 table action should be renamed to + # "refresh cert", and this method integrated with self.refresh + response, cert = self._get_cert(certr.uri) + if 'Location' not in response.headers: + raise errors.NetworkError('Location header missing') + if response.headers['Location'] != certr.uri: + raise errors.UnexpectedUpdate(response.text) + return certr.update(body=cert) + + def refresh(self, certr): + """Refresh certificate. + + :param certr: Certificate Resource + :type certr: `.CertificateResource` + + :returns: Updated Certificate Resource. + :rtype: `.CertificateResource` + + """ + # TODO: If a client sends a refresh request and the server is + # not willing to refresh the certificate, the server MUST + # respond with status code 403 (Forbidden) + return self.check_cert(certr) + + def fetch_chain(self, certr): + """Fetch chain for certificate. + + :param certr: Certificate Resource + :type certr: `.CertificateResource` + + :returns: Certificate chain, or `None` if no "up" Link was provided. + :rtype: `M2Crypto.X509.X509` wrapped in `.ComparableX509` + + """ + if certr.cert_chain_uri is not None: + return self._get_cert(certr.cert_chain_uri)[1] + else: + return None + + def revoke(self, certr, when=messages2.Revocation.NOW): + """Revoke certificate. + + :param certr: Certificate Resource + :type certr: `.CertificateResource` + + :param when: When should the revocation take place? Takes + the same values as `.messages2.Revocation.revoke`. + + :raises letsencrypt.client.errors.NetworkError: If revocation is + unsuccessful. + + """ + rev = messages2.Revocation(revoke=when, authorizations=tuple( + authzr.uri for authzr in certr.authzrs)) + 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/__init__.py b/letsencrypt/client/plugins/__init__.py new file mode 100644 index 000000000..538189015 --- /dev/null +++ b/letsencrypt/client/plugins/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.plugins.""" diff --git a/letsencrypt/client/plugins/apache/__init__.py b/letsencrypt/client/plugins/apache/__init__.py new file mode 100644 index 000000000..70172b06d --- /dev/null +++ b/letsencrypt/client/plugins/apache/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.plugins.apache.""" diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/plugins/apache/configurator.py similarity index 90% rename from letsencrypt/client/apache/configurator.py rename to letsencrypt/client/plugins/apache/configurator.py index 93db689f8..33abad3c5 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/plugins/apache/configurator.py @@ -18,9 +18,9 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util -from letsencrypt.client.apache import dvsni -from letsencrypt.client.apache import obj -from letsencrypt.client.apache import parser +from letsencrypt.client.plugins.apache import dvsni +from letsencrypt.client.plugins.apache import obj +from letsencrypt.client.plugins.apache import parser # TODO: Augeas sections ie. , beginning and closing @@ -68,11 +68,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :type config: :class:`~letsencrypt.client.interfaces.IConfig` :ivar parser: Handles low level parsing - :type parser: :class:`letsencrypt.client.apache.parser` + :type parser: :class:`~letsencrypt.client.plugins.apache.parser` :ivar tup version: version of Apache :ivar list vhosts: All vhosts found in the configuration - (:class:`list` of :class:`letsencrypt.client.apache.obj.VirtualHost`) + (:class:`list` of + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost`) :ivar dict assoc: Mapping between domains and vhosts @@ -164,7 +165,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): parser.case_i("SSLCertificateChainFile"), None, vhost.path) if len(path["cert_file"]) == 0 or len(path["cert_key"]) == 0: - # Throw some "can't find all of the directives error" + # Throw some can't find all of the directives error" logging.warn( "Cannot find a cert or key directive in %s", vhost.path) logging.warn("VirtualHost was not modified") @@ -203,7 +204,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str target_name: domain name :returns: ssl vhost associated with name - :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` + :rtype: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ # Allows for domain names to be associated with a virtual host @@ -223,7 +224,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.assoc[target_name] = vhost return vhost - # Check for non ssl vhosts with servernames/aliases == 'name' + # Check for non ssl vhosts with servernames/aliases == "name" for vhost in self.vhosts: if not vhost.ssl and target_name in vhost.names: vhost = self.make_vhost_ssl(vhost) @@ -244,7 +245,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str domain: domain name to associate :param vhost: virtual host to associate with domain - :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ self.assoc[domain] = vhost @@ -281,15 +282,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Helper function for get_virtual_hosts(). :param host: In progress vhost whose names will be added - :type host: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type host: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ name_match = self.aug.match(("%s//*[self::directive=~regexp('%s')] | " "%s//*[self::directive=~regexp('%s')]" % (host.path, - parser.case_i('ServerName'), + parser.case_i("ServerName"), host.path, - parser.case_i('ServerAlias')))) + parser.case_i("ServerAlias")))) for name in name_match: args = self.aug.match(name + "/*") @@ -302,7 +303,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str path: Augeas path to virtual host :returns: newly created vhost - :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` + :rtype: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ addrs = set() @@ -326,7 +327,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Returns list of virtual hosts found in the Apache configuration. :returns: List of - :class:`letsencrypt.client.apache.obj.VirtualHost` objects + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` objects found in configuration :rtype: list @@ -334,7 +335,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Search sites-available, httpd.conf for possible virtual hosts paths = self.aug.match( ("/files%s/sites-available//*[label()=~regexp('%s')]" % - (self.parser.root, parser.case_i('VirtualHost')))) + (self.parser.root, parser.case_i("VirtualHost")))) vhs = [] for path in paths: @@ -404,7 +405,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Checks to see if the server is ready for SNI challenges. :param vhost: VirtualHost to check SNI compatibility - :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :param str default_addr: TODO - investigate function further @@ -436,10 +437,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. note:: This function saves the configuration :param nonssl_vhost: Valid VH that doesn't have SSLEngine on - :type nonssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type nonssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: SSL vhost - :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` + :rtype: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ avail_fp = nonssl_vhost.filep @@ -454,8 +456,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.reverter.register_file_creation(False, ssl_fp) try: - with open(avail_fp, 'r') as orig_file: - with open(ssl_fp, 'w') as new_file: + with open(avail_fp, "r") as orig_file: + with open(ssl_fp, "w") as new_file: new_file.write("\n") for line in orig_file: new_file.write(line) @@ -471,7 +473,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # change address to address:443 addr_match = "/files%s//* [label()=~regexp('%s')]/arg" ssl_addr_p = self.aug.match( - addr_match % (ssl_fp, parser.case_i('VirtualHost'))) + addr_match % (ssl_fp, parser.case_i("VirtualHost"))) for addr in ssl_addr_p: old_addr = obj.Addr.fromstring( @@ -482,7 +484,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Add directives vh_p = self.aug.match("/files%s//* [label()=~regexp('%s')]" % - (ssl_fp, parser.case_i('VirtualHost'))) + (ssl_fp, parser.case_i("VirtualHost"))) if len(vh_p) != 1: logging.error("Error: should only be one vhost in %s", avail_fp) sys.exit(1) @@ -495,7 +497,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Log actions and create save notes logging.info("Created an SSL vhost at %s", ssl_fp) - self.save_notes += 'Created ssl vhost at %s\n' % ssl_fp + self.save_notes += "Created ssl vhost at %s\n" % ssl_fp self.save() # We know the length is one because of the assertion above @@ -548,8 +550,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def _enable_redirect(self, ssl_vhost, unused_options): """Redirect all equivalent HTTP traffic to ssl_vhost. - .. todo:: This enhancement should be rewritten and will unfortunately - require lots of debugging by hand. + .. todo:: This enhancement should be rewritten and will + unfortunately require lots of debugging by hand. + Adds Redirect directive to the port 80 equivalent of ssl_vhost First the function attempts to find the vhost with equivalent ip addresses that serves on non-ssl ports @@ -558,13 +561,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. note:: This function saves the configuration :param ssl_vhost: Destination of traffic, an ssl enabled vhost - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :param unused_options: Not currently used :type unused_options: Not Available :returns: Success, general_vhost (HTTP vhost) - :rtype: (bool, :class:`letsencrypt.client.apache.obj.VirtualHost`) + :rtype: (bool, + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost`) """ if not mod_loaded("rewrite_module", self.config.apache_ctl): @@ -595,7 +600,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.parser.add_dir(general_v.path, "RewriteEngine", "On") self.parser.add_dir(general_v.path, "RewriteRule", constants.APACHE_REWRITE_HTTPS_ARGS) - self.save_notes += ('Redirecting host in %s to ssl vhost in %s\n' % + self.save_notes += ("Redirecting host in %s to ssl vhost in %s\n" % (general_v.filep, ssl_vhost.filep)) self.save() @@ -616,7 +621,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): -1 is also returned in case of no redirection/rewrite directives :param vhost: vhost to check - :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: Success, code value... see documentation :rtype: bool, int @@ -648,10 +653,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Creates an http_vhost specifically to redirect for the ssl_vhost. :param ssl_vhost: ssl vhost - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` - :returns: Success, vhost - :rtype: (bool, :class:`letsencrypt.client.apache.obj.VirtualHost`) + :returns: tuple of the form + (`success`, + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost`) + :rtype: tuple """ # Consider changing this to a dictionary check @@ -699,7 +707,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): redirect_filename = "le-redirect-%s.conf" % ssl_vhost.names[0] redirect_filepath = os.path.join( - self.parser.root, 'sites-available', redirect_filename) + self.parser.root, "sites-available", redirect_filename) # Register the new file that will be created # Note: always register the creation before writing to ensure file will @@ -707,7 +715,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.reverter.register_file_creation(False, redirect_filepath) # Write out file - with open(redirect_filepath, 'w') as redirect_fd: + with open(redirect_filepath, "w") as redirect_fd: redirect_fd.write(redirect_file) logging.info("Created redirect file: %s", redirect_filename) @@ -717,8 +725,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.vhosts.append(new_vhost) # Finally create documentation for the change - self.save_notes += ('Created a port 80 vhost, %s, for redirection to ' - 'ssl vhost %s\n' % + self.save_notes += ("Created a port 80 vhost, %s, for redirection to " + "ssl vhost %s\n" % (new_vhost.filep, ssl_vhost.filep)) def _conflicting_host(self, ssl_vhost): @@ -733,7 +741,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if not conflict: returns space separated list of new host addrs :param ssl_vhost: SSL Vhost to check for possible port 80 redirection - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: TODO :rtype: TODO @@ -766,10 +775,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): Consider changing this into a dict check :param ssl_vhost: ssl vhost to check - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: HTTP vhost or None if unsuccessful - :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` or None + :rtype: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` + or None """ # _default_:443 check @@ -859,7 +870,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. todo:: Make sure link is not broken... :param vhost: vhost to enable - :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: Success :rtype: bool @@ -875,7 +886,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): os.symlink(vhost.filep, enabled_path) vhost.enabled = True logging.info("Enabling available site: %s", vhost.filep) - self.save_notes += 'Enabled site %s\n' % vhost.filep + self.save_notes += "Enabled site %s\n" % vhost.filep return True return False @@ -897,7 +908,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ try: proc = subprocess.Popen( - ['sudo', self.config.apache_ctl, 'configtest'], # TODO: sudo? + [self.config.apache_ctl, "configtest"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() @@ -923,9 +934,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ uid = os.geteuid() - le_util.make_or_verify_dir(self.config.config_dir, 0o755, uid) - le_util.make_or_verify_dir(self.config.work_dir, 0o755, uid) - le_util.make_or_verify_dir(self.config.backup_dir, 0o755, uid) + le_util.make_or_verify_dir( + self.config.config_dir, constants.CONFIG_DIRS_MODE, uid) + le_util.make_or_verify_dir( + self.config.work_dir, constants.CONFIG_DIRS_MODE, uid) + le_util.make_or_verify_dir( + self.config.backup_dir, constants.CONFIG_DIRS_MODE, uid) def get_version(self): """Return version of Apache Server. @@ -941,7 +955,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ try: proc = subprocess.Popen( - [self.config.apache_ctl, '-v'], + [self.config.apache_ctl, "-v"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) text = proc.communicate()[0] @@ -956,7 +970,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): raise errors.LetsEncryptConfiguratorError( "Unable to find Apache version") - return tuple([int(i) for i in matches[0].split('.')]) + return tuple([int(i) for i in matches[0].split(".")]) def more_info(self): """Human-readable string to help understand the module""" @@ -995,15 +1009,17 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): apache_dvsni.add_chall(achall, i) sni_response = apache_dvsni.perform() - # Must restart in order to activate the challenges. - # Handled here because we may be able to load up other challenge types - self.restart() + if sni_response: + # Must restart in order to activate the challenges. + # Handled here because we may be able to load up other challenge + # types + self.restart() - # Go through all of the challenges and assign them to the proper place - # in the responses return value. All responses must be in the same order - # as the original challenges. - for i, resp in enumerate(sni_response): - responses[apache_dvsni.indices[i]] = resp + # Go through all of the challenges and assign them to the proper + # place in the responses return value. All responses must be in the + # same order as the original challenges. + for i, resp in enumerate(sni_response): + responses[apache_dvsni.indices[i]] = resp return responses @@ -1030,9 +1046,9 @@ def enable_mod(mod_name, apache_init_script, apache_enmod): try: # Use check_output so the command will finish before reloading # TODO: a2enmod is debian specific... - subprocess.check_call(["sudo", apache_enmod, mod_name], # TODO: sudo? - stdout=open("/dev/null", 'w'), - stderr=open("/dev/null", 'w')) + subprocess.check_call([apache_enmod, mod_name], + stdout=open("/dev/null", "w"), + stderr=open("/dev/null", "w")) apache_restart(apache_init_script) except (OSError, subprocess.CalledProcessError) as err: logging.error("Error enabling mod_%s", mod_name) @@ -1054,7 +1070,7 @@ def mod_loaded(module, apache_ctl): """ try: proc = subprocess.Popen( - [apache_ctl, '-M'], + [apache_ctl, "-M"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() @@ -1092,7 +1108,7 @@ def apache_restart(apache_init_script): """ try: - proc = subprocess.Popen([apache_init_script, 'restart'], + proc = subprocess.Popen([apache_init_script, "restart"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/plugins/apache/dvsni.py similarity index 80% rename from letsencrypt/client/apache/dvsni.py rename to letsencrypt/client/plugins/apache/dvsni.py index b980fdb36..7755658e7 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/plugins/apache/dvsni.py @@ -2,20 +2,19 @@ import logging import os -from letsencrypt.client.apache import parser +from letsencrypt.client.plugins.apache import parser class ApacheDvsni(object): """Class performs DVSNI challenges within the Apache configurator. :ivar configurator: ApacheConfigurator object - :type configurator: - :class:`letsencrypt.client.apache.configurator.ApacheConfigurator` + :type configurator: :class:`~apache.configurator.ApacheConfigurator` :ivar list achalls: Annotated :class:`~letsencrypt.client.achallenges.DVSNI` challenges. - :param list indicies: Meant to hold indices of challenges in a + :param list indices: Meant to hold indices of challenges in a larger array. ApacheDvsni is capable of solving many challenges at once which causes an indexing issue within ApacheConfigurator who must return all responses in order. Imagine ApacheConfigurator @@ -26,6 +25,23 @@ class ApacheDvsni(object): :param str challenge_conf: location of the challenge config file """ + + VHOST_TEMPLATE = """\ + + ServerName {server_name} + UseCanonicalName on + SSLStrictSNIVHostCheck on + + LimitRequestBody 1048576 + + Include {ssl_options_conf_path} + SSLCertificateFile {cert_path} + SSLCertificateKeyFile {key_path} + + DocumentRoot {document_root} + + +""" def __init__(self, configurator): self.configurator = configurator self.achalls = [] @@ -50,7 +66,7 @@ class ApacheDvsni(object): def perform(self): """Peform a DVSNI challenge.""" if not self.achalls: - return None + return [] # Save any changes to the configuration as a precaution # About to make temporary changes to the config self.configurator.save() @@ -101,7 +117,7 @@ class ApacheDvsni(object): cert_pem, response = achall.gen_cert_and_response(s) # Write out challenge cert - with open(cert_path, 'w') as cert_chall_fd: + with open(cert_path, "w") as cert_chall_fd: cert_chall_fd.write(cert_pem) return response @@ -112,7 +128,7 @@ class ApacheDvsni(object): Result: Apache config includes virtual servers for issued challs :param list ll_addrs: list of list of - :class:`letsencrypt.client.apache.obj.Addr` to apply + :class:`letsencrypt.client.plugins.apache.obj.Addr` to apply """ # TODO: Use ip address of existing vhost instead of relying on FQDN @@ -125,7 +141,7 @@ class ApacheDvsni(object): self.configurator.reverter.register_file_creation( True, self.challenge_conf) - with open(self.challenge_conf, 'w') as new_conf: + with open(self.challenge_conf, "w") as new_conf: new_conf.write(config_text) def _conf_include_check(self, main_config): @@ -151,7 +167,7 @@ class ApacheDvsni(object): :type achall: :class:`letsencrypt.client.achallenges.DVSNI` :param list ip_addrs: addresses of challenged domain - :class:`list` of type :class:`letsencrypt.client.apache.obj.Addr` + :class:`list` of type :class:`~apache.obj.Addr` :returns: virtual host configuration text :rtype: str @@ -160,19 +176,16 @@ class ApacheDvsni(object): ips = " ".join(str(i) for i in ip_addrs) document_root = os.path.join( self.configurator.config.config_dir, "dvsni_page/") - return ("\n" - "ServerName " + achall.nonce_domain + "\n" - "UseCanonicalName on\n" - "SSLStrictSNIVHostCheck on\n" - "\n" - "LimitRequestBody 1048576\n" - "\n" - "Include " + self.configurator.parser.loc["ssl_options"] + "\n" - "SSLCertificateFile " + self.get_cert_file(achall) + "\n" - "SSLCertificateKeyFile " + achall.key.file + "\n" - "\n" - "DocumentRoot " + document_root + "\n" - "\n\n") + # TODO: Python docs is not clear how mutliline string literal + # newlines are parsed on different platforms. At least on + # Linux (Debian sid), when source file uses CRLF, Python still + # parses it as "\n"... c.f.: + # https://docs.python.org/2.7/reference/lexical_analysis.html + return self.VHOST_TEMPLATE.format( + vhost=ips, server_name=achall.nonce_domain, + ssl_options_conf_path=self.configurator.parser.loc["ssl_options"], + cert_path=self.get_cert_file(achall), key_path=achall.key.file, + document_root=document_root).replace("\n", os.linesep) def get_cert_file(self, achall): """Returns standardized name for challenge certificate. diff --git a/letsencrypt/client/apache/obj.py b/letsencrypt/client/plugins/apache/obj.py similarity index 100% rename from letsencrypt/client/apache/obj.py rename to letsencrypt/client/plugins/apache/obj.py diff --git a/letsencrypt/client/apache/options-ssl.conf b/letsencrypt/client/plugins/apache/options-ssl.conf similarity index 100% rename from letsencrypt/client/apache/options-ssl.conf rename to letsencrypt/client/plugins/apache/options-ssl.conf diff --git a/letsencrypt/client/apache/parser.py b/letsencrypt/client/plugins/apache/parser.py similarity index 100% rename from letsencrypt/client/apache/parser.py rename to letsencrypt/client/plugins/apache/parser.py diff --git a/letsencrypt/client/tests/apache/__init__.py b/letsencrypt/client/plugins/apache/tests/__init__.py similarity index 100% rename from letsencrypt/client/tests/apache/__init__.py rename to letsencrypt/client/plugins/apache/tests/__init__.py diff --git a/letsencrypt/client/tests/apache/configurator_test.py b/letsencrypt/client/plugins/apache/tests/configurator_test.py similarity index 78% rename from letsencrypt/client/tests/apache/configurator_test.py rename to letsencrypt/client/plugins/apache/tests/configurator_test.py index 1bb4207a3..ae2097b3e 100644 --- a/letsencrypt/client/tests/apache/configurator_test.py +++ b/letsencrypt/client/plugins/apache/tests/configurator_test.py @@ -1,4 +1,4 @@ -"""Test for letsencrypt.client.apache.configurator.""" +"""Test for letsencrypt.client.plugins.apache.configurator.""" import os import re import shutil @@ -12,11 +12,13 @@ from letsencrypt.client import achallenges from letsencrypt.client import errors from letsencrypt.client import le_util -from letsencrypt.client.apache import configurator -from letsencrypt.client.apache import obj -from letsencrypt.client.apache import parser +from letsencrypt.client.plugins.apache import configurator +from letsencrypt.client.plugins.apache import obj +from letsencrypt.client.plugins.apache import parser -from letsencrypt.client.tests.apache import util +from letsencrypt.client.plugins.apache.tests import util + +from letsencrypt.client.tests import acme_util class TwoVhost80Test(util.ApacheTest): @@ -25,7 +27,7 @@ class TwoVhost80Test(util.ApacheTest): def setUp(self): super(TwoVhost80Test, self).setUp() - with mock.patch("letsencrypt.client.apache.configurator." + with mock.patch("letsencrypt.client.plugins.apache.configurator." "mod_loaded") as mock_load: mock_load.return_value = True self.config = util.get_apache_configurator( @@ -43,9 +45,15 @@ class TwoVhost80Test(util.ApacheTest): def test_get_all_names(self): names = self.config.get_all_names() self.assertEqual(names, set( - ['letsencrypt.demo', 'encryption-example.demo', 'ip-172-30-0-17'])) + ["letsencrypt.demo", "encryption-example.demo", "ip-172-30-0-17"])) def test_get_virtual_hosts(self): + """Make sure all vhosts are being properly found. + + .. note:: If test fails, only finding 1 Vhost... it is likely that + it is a problem with is_enabled. + + """ vhs = self.config.get_virtual_hosts() self.assertEqual(len(vhs), 4) found = 0 @@ -59,6 +67,14 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(found, 4) def test_is_site_enabled(self): + """Test if site is enabled. + + .. note:: This test currently fails for hard links + (which may happen if you move dirs incorrectly) + .. warning:: This test does not work when running using the + unittest.main() function. It incorrectly copies symlinks. + + """ self.assertTrue(self.config.is_site_enabled(self.vh_truth[0].filep)) self.assertFalse(self.config.is_site_enabled(self.vh_truth[1].filep)) self.assertTrue(self.config.is_site_enabled(self.vh_truth[2].filep)) @@ -134,23 +150,27 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(len(self.config.vhosts), 5) - @mock.patch("letsencrypt.client.apache.configurator." + @mock.patch("letsencrypt.client.plugins.apache.configurator." "dvsni.ApacheDvsni.perform") - @mock.patch("letsencrypt.client.apache.configurator." + @mock.patch("letsencrypt.client.plugins.apache.configurator." "ApacheConfigurator.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="jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", - nonce="37bc5eb75d3e00a19b4f6355845e5a18"), + challb=acme_util.chall_to_challb( + challenges.DVSNI( + r="jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + nonce="37bc5eb75d3e00a19b4f6355845e5a18"), + "pending"), domain="encryption-example.demo", key=auth_key) achall2 = achallenges.DVSNI( - chall=challenges.DVSNI( - r="uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", - nonce="59ed014cac95f77057b1d7a1b2c596ba"), + challb=acme_util.chall_to_challb( + challenges.DVSNI( + r="uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", + nonce="59ed014cac95f77057b1d7a1b2c596ba"), + "pending"), domain="letsencrypt.demo", key=auth_key) dvsni_ret_val = [ @@ -166,7 +186,7 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(mock_restart.call_count, 1) - @mock.patch("letsencrypt.client.apache.configurator." + @mock.patch("letsencrypt.client.plugins.apache.configurator." "subprocess.Popen") def test_get_version(self, mock_popen): mock_popen().communicate.return_value = ( @@ -183,7 +203,7 @@ class TwoVhost80Test(util.ApacheTest): errors.LetsEncryptConfiguratorError, self.config.get_version) mock_popen().communicate.return_value = ( - "Server Version: Apache/2.3\n Apache/2.4.7", "") + "Server Version: Apache/2.3{0} Apache/2.4.7".format(os.linesep), "") self.assertRaises( errors.LetsEncryptConfiguratorError, self.config.get_version) @@ -192,5 +212,5 @@ class TwoVhost80Test(util.ApacheTest): errors.LetsEncryptConfiguratorError, self.config.get_version) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/plugins/apache/tests/dvsni_test.py similarity index 77% rename from letsencrypt/client/tests/apache/dvsni_test.py rename to letsencrypt/client/plugins/apache/tests/dvsni_test.py index 384e426bb..2780749b5 100644 --- a/letsencrypt/client/tests/apache/dvsni_test.py +++ b/letsencrypt/client/plugins/apache/tests/dvsni_test.py @@ -1,4 +1,4 @@ -"""Test for letsencrypt.client.apache.dvsni.""" +"""Test for letsencrypt.client.plugins.apache.dvsni.""" import pkg_resources import unittest import shutil @@ -10,9 +10,11 @@ from letsencrypt.acme import challenges from letsencrypt.client import achallenges from letsencrypt.client import le_util -from letsencrypt.client.apache.obj import Addr +from letsencrypt.client.plugins.apache.obj import Addr -from letsencrypt.client.tests.apache import util +from letsencrypt.client.plugins.apache.tests import util + +from letsencrypt.client.tests import acme_util class DvsniPerformTest(util.ApacheTest): @@ -21,36 +23,40 @@ class DvsniPerformTest(util.ApacheTest): def setUp(self): super(DvsniPerformTest, self).setUp() - with mock.patch("letsencrypt.client.apache.configurator." + with mock.patch("letsencrypt.client.plugins.apache.configurator." "mod_loaded") as mock_load: mock_load.return_value = True config = util.get_apache_configurator( self.config_path, self.config_dir, self.work_dir, self.ssl_options) - from letsencrypt.client.apache import dvsni + from letsencrypt.client.plugins.apache import dvsni self.sni = dvsni.ApacheDvsni(config) rsa256_file = pkg_resources.resource_filename( - "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + "letsencrypt.acme.jose", "testdata/rsa256_key.pem") rsa256_pem = pkg_resources.resource_string( - "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + "letsencrypt.acme.jose", "testdata/rsa256_key.pem") auth_key = le_util.Key(rsa256_file, rsa256_pem) self.achalls = [ achallenges.DVSNI( - chall=challenges.DVSNI( - r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9\xf1" - "\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4", - nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18", - ), domain="encryption-example.demo", key=auth_key), + challb=acme_util.chall_to_challb( + challenges.DVSNI( + r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9" + "\xf1\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4", + nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18", + ), "pending"), + domain="encryption-example.demo", key=auth_key), achallenges.DVSNI( - chall=challenges.DVSNI( - r="\xba\xa9\xda? 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..cb5fef6bf --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/configurator_test.py @@ -0,0 +1,271 @@ +"""Test for letsencrypt.client.plugins.nginx.configurator.""" +import shutil +import unittest + +import mock + +from letsencrypt.acme import challenges +from letsencrypt.acme import messages2 + +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( + challb=messages2.ChallengeBody( + chall=challenges.DVSNI( + r="foo", + nonce="bar"), + uri="https://ca.org/chall0_uri", + status=messages2.Status("pending"), + ), domain="localhost", key=auth_key) + achall2 = achallenges.DVSNI( + challb=messages2.ChallengeBody( + chall=challenges.DVSNI( + r="abc", + nonce="def"), + uri="https://ca.org/chall1_uri", + status=messages2.Status("pending"), + ), domain="example.com", key=auth_key) + + dvsni_ret_val = [ + challenges.DVSNIResponse(s="irrelevant"), + 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..bf66367e6 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/dvsni_test.py @@ -0,0 +1,94 @@ +"""Test for letsencrypt.client.plugins.nginx.dvsni.""" +import pkg_resources +import unittest +import shutil + +import mock + +from letsencrypt.acme import challenges +from letsencrypt.acme import messages2 + +from letsencrypt.client import achallenges +from letsencrypt.client import le_util + +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.acme.jose", "testdata/rsa256_key.pem") + rsa256_pem = pkg_resources.resource_string( + "letsencrypt.acme.jose", "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( + challb=messages2.ChallengeBody( + chall=challenges.DVSNI( + r="foo", + nonce="bar", + ), + uri="https://letsencrypt-ca.org/chall0_uri", + status=messages2.Status("pending"), + ), domain="www.example.com", key=auth_key), + achallenges.DVSNI( + challb=messages2.ChallengeBody( + 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..6d9ba3371 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/testdata/ubuntu_nginx_1_4_6/default_vhost/nginx/sites-enabled/default @@ -0,0 +1 @@ +../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..58c5730cf --- /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 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.acme.jose", "testdata/rsa256_key.pem") + self.rsa256_pem = pkg_resources.resource_string( + "letsencrypt.acme.jose", "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.NGINX_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/plugins/standalone/__init__.py b/letsencrypt/client/plugins/standalone/__init__.py new file mode 100644 index 000000000..41de6eaf7 --- /dev/null +++ b/letsencrypt/client/plugins/standalone/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.plugins.standalone.""" diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/plugins/standalone/authenticator.py old mode 100755 new mode 100644 similarity index 98% rename from letsencrypt/client/standalone_authenticator.py rename to letsencrypt/client/plugins/standalone/authenticator.py index bf08a39ec..3912033e8 --- a/letsencrypt/client/standalone_authenticator.py +++ b/letsencrypt/client/plugins/standalone/authenticator.py @@ -15,7 +15,6 @@ import zope.interface from letsencrypt.acme import challenges from letsencrypt.client import achallenges -from letsencrypt.client import constants from letsencrypt.client import interfaces @@ -33,7 +32,7 @@ class StandaloneAuthenticator(object): description = "Standalone Authenticator" - def __init__(self): + def __init__(self, unused_config): self.child_pid = None self.parent_pid = os.getpid() self.subproc_state = None @@ -362,7 +361,7 @@ class StandaloneAuthenticator(object): results_if_failure.append(False) if not self.tasks: raise ValueError("nothing for .perform() to do") - if self.already_listening(constants.DVSNI_CHALLENGE_PORT): + if self.already_listening(challenges.DVSNI.PORT): # If we know a process is already listening on this port, # tell the user, and don't even attempt to bind it. (This # test is Linux-specific and won't indicate that the port @@ -370,7 +369,7 @@ class StandaloneAuthenticator(object): return results_if_failure # Try to do the authentication; note that this creates # the listener subprocess via os.fork() - if self.start_listener(constants.DVSNI_CHALLENGE_PORT, key): + if self.start_listener(challenges.DVSNI.PORT, key): return results_if_success else: # TODO: This should probably raise a DVAuthError exception @@ -410,5 +409,5 @@ class StandaloneAuthenticator(object): "on port 443 and perform DVSNI challenges. Once a certificate" "is attained, it will be saved in the " "(TODO) current working directory.{0}{0}" - "Port 443 must be open in order to use the " + "TCP port 443 must be available in order to use the " "Standalone Authenticator.".format(os.linesep)) diff --git a/letsencrypt/client/plugins/standalone/tests/__init__.py b/letsencrypt/client/plugins/standalone/tests/__init__.py new file mode 100644 index 000000000..059cd2780 --- /dev/null +++ b/letsencrypt/client/plugins/standalone/tests/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt Standalone Tests""" diff --git a/letsencrypt/client/tests/standalone_authenticator_test.py b/letsencrypt/client/plugins/standalone/tests/authenticator_test.py similarity index 70% rename from letsencrypt/client/tests/standalone_authenticator_test.py rename to letsencrypt/client/plugins/standalone/tests/authenticator_test.py index 9adf6a167..c69e5399e 100644 --- a/letsencrypt/client/tests/standalone_authenticator_test.py +++ b/letsencrypt/client/plugins/standalone/tests/authenticator_test.py @@ -1,4 +1,4 @@ -"""Tests for letsencrypt.client.standalone_authenticator.""" +"""Tests for letsencrypt.client.plugins.standalone.authenticator.""" import os import pkg_resources import psutil @@ -15,6 +15,14 @@ from letsencrypt.acme import challenges from letsencrypt.client import achallenges from letsencrypt.client import le_util +from letsencrypt.client.tests import acme_util + + +KEY = le_util.Key("foo", pkg_resources.resource_string( + "letsencrypt.acme.jose", os.path.join("testdata", "rsa512_key.pem"))) +PRIVATE_KEY = OpenSSL.crypto.load_privatekey( + OpenSSL.crypto.FILETYPE_PEM, KEY.pem) + # Classes based on to allow interrupting infinite loop under test # after one iteration, based on. @@ -49,9 +57,9 @@ class CallableExhausted(Exception): class ChallPrefTest(unittest.TestCase): """Tests for chall_pref() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) def test_chall_pref(self): self.assertEqual(self.authenticator.get_chall_pref("example.com"), @@ -61,18 +69,13 @@ class ChallPrefTest(unittest.TestCase): class SNICallbackTest(unittest.TestCase): """Tests for sni_callback() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() - test_key = pkg_resources.resource_string( - __name__, "testdata/rsa256_key.pem") - key = le_util.Key("foo", test_key) + self.authenticator = StandaloneAuthenticator(None) self.cert = achallenges.DVSNI( - chall=challenges.DVSNI(r="x"*32, nonce="abcdef"), - domain="example.com", key=key).gen_cert_and_response()[0] - private_key = OpenSSL.crypto.load_privatekey( - OpenSSL.crypto.FILETYPE_PEM, key.pem) - self.authenticator.private_key = private_key + challb=acme_util.DVSNI_P, + domain="example.com", key=KEY).gen_cert_and_response()[0] + self.authenticator.private_key = PRIVATE_KEY self.authenticator.tasks = {"abcdef.acme.invalid": self.cert} self.authenticator.child_pid = 12345 @@ -104,9 +107,9 @@ class SNICallbackTest(unittest.TestCase): class ClientSignalHandlerTest(unittest.TestCase): """Tests for client_signal_handler() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} self.authenticator.child_pid = 12345 @@ -133,15 +136,15 @@ class ClientSignalHandlerTest(unittest.TestCase): class SubprocSignalHandlerTest(unittest.TestCase): """Tests for subproc_signal_handler() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} self.authenticator.child_pid = 12345 self.authenticator.parent_pid = 23456 - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.sys.exit") def test_subproc_signal_handler(self, mock_exit, mock_kill): self.authenticator.ssl_conn = mock.MagicMock() self.authenticator.connection = mock.MagicMock() @@ -155,8 +158,8 @@ class SubprocSignalHandlerTest(unittest.TestCase): self.authenticator.parent_pid, signal.SIGUSR1) mock_exit.assert_called_once_with(0) - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.sys.exit") def test_subproc_signal_handler_trouble(self, mock_exit, mock_kill): """Test attempting to shut down a non-existent connection. @@ -185,14 +188,15 @@ class SubprocSignalHandlerTest(unittest.TestCase): class AlreadyListeningTest(unittest.TestCase): """Tests for already_listening() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) - @mock.patch("letsencrypt.client.standalone_authenticator.psutil." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil." "net_connections") - @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "psutil.Process") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_race_condition(self, mock_get_utility, mock_process, mock_net): # This tests a race condition, or permission problem, or OS @@ -200,14 +204,14 @@ class AlreadyListeningTest(unittest.TestCase): # found to match the identified listening PID. from psutil._common import sconn conns = [ - sconn(fd=-1, family=2, type=1, laddr=('0.0.0.0', 30), - raddr=(), status='LISTEN', pid=None), - sconn(fd=3, family=2, type=1, laddr=('192.168.5.10', 32783), - raddr=('20.40.60.80', 22), status='ESTABLISHED', pid=1234), - sconn(fd=-1, family=10, type=1, laddr=('::1', 54321), - raddr=('::1', 111), status='CLOSE_WAIT', pid=None), - sconn(fd=3, family=2, type=1, laddr=('0.0.0.0', 17), - raddr=(), status='LISTEN', pid=4416)] + sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), + raddr=(), status="LISTEN", pid=None), + sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), + raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), + sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), + raddr=("::1", 111), status="CLOSE_WAIT", pid=None), + sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17), + raddr=(), status="LISTEN", pid=4416)] mock_net.return_value = conns mock_process.side_effect = psutil.NoSuchProcess("No such PID") # We simulate being unable to find the process name of PID 4416, @@ -216,42 +220,44 @@ class AlreadyListeningTest(unittest.TestCase): self.assertEqual(mock_get_utility.generic_notification.call_count, 0) mock_process.assert_called_once_with(4416) - @mock.patch("letsencrypt.client.standalone_authenticator.psutil." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil." "net_connections") - @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "psutil.Process") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_not_listening(self, mock_get_utility, mock_process, mock_net): from psutil._common import sconn conns = [ - sconn(fd=-1, family=2, type=1, laddr=('0.0.0.0', 30), - raddr=(), status='LISTEN', pid=None), - sconn(fd=3, family=2, type=1, laddr=('192.168.5.10', 32783), - raddr=('20.40.60.80', 22), status='ESTABLISHED', pid=1234), - sconn(fd=-1, family=10, type=1, laddr=('::1', 54321), - raddr=('::1', 111), status='CLOSE_WAIT', pid=None)] + sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), + raddr=(), status="LISTEN", pid=None), + sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), + raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), + sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), + raddr=("::1", 111), status="CLOSE_WAIT", pid=None)] mock_net.return_value = conns mock_process.name.return_value = "inetd" self.assertFalse(self.authenticator.already_listening(17)) self.assertEqual(mock_get_utility.generic_notification.call_count, 0) self.assertEqual(mock_process.call_count, 0) - @mock.patch("letsencrypt.client.standalone_authenticator.psutil." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil." "net_connections") - @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "psutil.Process") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_listening_ipv4(self, mock_get_utility, mock_process, mock_net): from psutil._common import sconn conns = [ - sconn(fd=-1, family=2, type=1, laddr=('0.0.0.0', 30), - raddr=(), status='LISTEN', pid=None), - sconn(fd=3, family=2, type=1, laddr=('192.168.5.10', 32783), - raddr=('20.40.60.80', 22), status='ESTABLISHED', pid=1234), - sconn(fd=-1, family=10, type=1, laddr=('::1', 54321), - raddr=('::1', 111), status='CLOSE_WAIT', pid=None), - sconn(fd=3, family=2, type=1, laddr=('0.0.0.0', 17), - raddr=(), status='LISTEN', pid=4416)] + sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), + raddr=(), status="LISTEN", pid=None), + sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), + raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), + sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), + raddr=("::1", 111), status="CLOSE_WAIT", pid=None), + sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17), + raddr=(), status="LISTEN", pid=4416)] mock_net.return_value = conns mock_process.name.return_value = "inetd" result = self.authenticator.already_listening(17) @@ -259,24 +265,25 @@ class AlreadyListeningTest(unittest.TestCase): self.assertEqual(mock_get_utility.call_count, 1) mock_process.assert_called_once_with(4416) - @mock.patch("letsencrypt.client.standalone_authenticator.psutil." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil." "net_connections") - @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "psutil.Process") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_listening_ipv6(self, mock_get_utility, mock_process, mock_net): from psutil._common import sconn conns = [ - sconn(fd=-1, family=2, type=1, laddr=('0.0.0.0', 30), - raddr=(), status='LISTEN', pid=None), - sconn(fd=3, family=2, type=1, laddr=('192.168.5.10', 32783), - raddr=('20.40.60.80', 22), status='ESTABLISHED', pid=1234), - sconn(fd=-1, family=10, type=1, laddr=('::1', 54321), - raddr=('::1', 111), status='CLOSE_WAIT', pid=None), - sconn(fd=3, family=10, type=1, laddr=('::', 12345), raddr=(), - status='LISTEN', pid=4420), - sconn(fd=3, family=2, type=1, laddr=('0.0.0.0', 17), - raddr=(), status='LISTEN', pid=4416)] + sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), + raddr=(), status="LISTEN", pid=None), + sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), + raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), + sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), + raddr=("::1", 111), status="CLOSE_WAIT", pid=None), + sconn(fd=3, family=10, type=1, laddr=("::", 12345), raddr=(), + status="LISTEN", pid=4420), + sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17), + raddr=(), status="LISTEN", pid=4416)] mock_net.return_value = conns mock_process.name.return_value = "inetd" result = self.authenticator.already_listening(12345) @@ -288,20 +295,18 @@ class AlreadyListeningTest(unittest.TestCase): class PerformTest(unittest.TestCase): """Tests for perform() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() - - test_key = pkg_resources.resource_string( - __name__, "testdata/rsa256_key.pem") - self.key = le_util.Key("something", test_key) + self.authenticator = StandaloneAuthenticator(None) self.achall1 = achallenges.DVSNI( - chall=challenges.DVSNI(r="whee", nonce="foo"), - domain="foo.example.com", key=self.key) + challb=acme_util.chall_to_challb( + challenges.DVSNI(r="whee", nonce="foo"), "pending"), + domain="foo.example.com", key=KEY) self.achall2 = achallenges.DVSNI( - chall=challenges.DVSNI(r="whee", nonce="bar"), - domain="bar.example.com", key=self.key) + challb=acme_util.chall_to_challb( + challenges.DVSNI(r="whee", nonce="bar"), "pending"), + domain="bar.example.com", key=KEY) bad_achall = ("This", "Represents", "A Non-DVSNI", "Challenge") self.achalls = [self.achall1, self.achall2, bad_achall] @@ -326,7 +331,7 @@ class PerformTest(unittest.TestCase): self.assertTrue(isinstance(result[0], challenges.ChallengeResponse)) self.assertTrue(isinstance(result[1], challenges.ChallengeResponse)) self.assertFalse(result[2]) - self.authenticator.start_listener.assert_called_once_with(443, self.key) + self.authenticator.start_listener.assert_called_once_with(443, KEY) def test_cannot_perform(self): """What happens if start_listener() returns False.""" @@ -341,17 +346,16 @@ class PerformTest(unittest.TestCase): self.assertTrue(isinstance(result, list)) self.assertEqual(len(result), 3) self.assertEqual(result, [None, None, False]) - self.authenticator.start_listener.assert_called_once_with( - 443, self. key) + self.authenticator.start_listener.assert_called_once_with(443, KEY) def test_perform_with_pending_tasks(self): self.authenticator.tasks = {"foononce.acme.invalid": "cert_data"} - extra_achall = achallenges.DVSNI(chall="a", domain="b", key="c") + extra_achall = acme_util.DVSNI_P self.assertRaises( ValueError, self.authenticator.perform, [extra_achall]) def test_perform_without_challenge_list(self): - extra_achall = achallenges.DVSNI(chall="a", domain="b", key="c") + extra_achall = acme_util.DVSNI_P # This is wrong because a challenge must be specified. self.assertRaises(ValueError, self.authenticator.perform, []) # This is wrong because it must be a list, not a bare challenge. @@ -365,13 +369,13 @@ class PerformTest(unittest.TestCase): class StartListenerTest(unittest.TestCase): """Tests for start_listener() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "Crypto.Random.atfork") - @mock.patch("letsencrypt.client.standalone_authenticator.os.fork") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.fork") def test_start_listener_fork_parent(self, mock_fork, mock_atfork): self.authenticator.do_parent_process = mock.Mock() self.authenticator.do_parent_process.return_value = True @@ -384,9 +388,9 @@ class StartListenerTest(unittest.TestCase): self.authenticator.do_parent_process.assert_called_once_with(1717) mock_atfork.assert_called_once_with() - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "Crypto.Random.atfork") - @mock.patch("letsencrypt.client.standalone_authenticator.os.fork") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.fork") def test_start_listener_fork_child(self, mock_fork, mock_atfork): self.authenticator.do_parent_process = mock.Mock() self.authenticator.do_child_process = mock.Mock() @@ -400,12 +404,13 @@ class StartListenerTest(unittest.TestCase): class DoParentProcessTest(unittest.TestCase): """Tests for do_parent_process() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) - @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "signal.signal") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_do_parent_process_ok(self, mock_get_utility, mock_signal): self.authenticator.subproc_state = "ready" @@ -414,8 +419,9 @@ class DoParentProcessTest(unittest.TestCase): self.assertEqual(mock_get_utility.call_count, 1) self.assertEqual(mock_signal.call_count, 3) - @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "signal.signal") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_do_parent_process_inuse(self, mock_get_utility, mock_signal): self.authenticator.subproc_state = "inuse" @@ -424,8 +430,9 @@ class DoParentProcessTest(unittest.TestCase): self.assertEqual(mock_get_utility.call_count, 1) self.assertEqual(mock_signal.call_count, 3) - @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "signal.signal") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_do_parent_process_cantbind(self, mock_get_utility, mock_signal): self.authenticator.subproc_state = "cantbind" @@ -434,8 +441,9 @@ class DoParentProcessTest(unittest.TestCase): self.assertEqual(mock_get_utility.call_count, 1) self.assertEqual(mock_signal.call_count, 3) - @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "signal.signal") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_do_parent_process_timeout(self, mock_get_utility, mock_signal): # Normally times out in 5 seconds and returns False. We can @@ -450,25 +458,21 @@ class DoParentProcessTest(unittest.TestCase): class DoChildProcessTest(unittest.TestCase): """Tests for do_child_process() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() - test_key = pkg_resources.resource_string( - __name__, "testdata/rsa256_key.pem") - key = le_util.Key("foo", test_key) - self.key = key + self.authenticator = StandaloneAuthenticator(None) self.cert = achallenges.DVSNI( - chall=challenges.DVSNI(r="x"*32, nonce="abcdef"), - domain="example.com", key=key).gen_cert_and_response()[0] - private_key = OpenSSL.crypto.load_privatekey( - OpenSSL.crypto.FILETYPE_PEM, key.pem) - self.authenticator.private_key = private_key + challb=acme_util.chall_to_challb( + challenges.DVSNI(r=("x" * 32), nonce="abcdef"), "pending"), + domain="example.com", key=KEY).gen_cert_and_response()[0] + self.authenticator.private_key = PRIVATE_KEY self.authenticator.tasks = {"abcdef.acme.invalid": self.cert} self.authenticator.parent_pid = 12345 - @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "socket.socket") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.sys.exit") def test_do_child_process_cantbind1( self, mock_exit, mock_kill, mock_socket): mock_exit.side_effect = IndentationError("subprocess would exit here") @@ -483,14 +487,14 @@ class DoChildProcessTest(unittest.TestCase): # do_child_process code assumes that calling sys.exit() will # cause subsequent code not to be executed.) self.assertRaises( - IndentationError, self.authenticator.do_child_process, 1717, - self.key) + IndentationError, self.authenticator.do_child_process, 1717, KEY) mock_exit.assert_called_once_with(1) mock_kill.assert_called_once_with(12345, signal.SIGUSR2) - @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "socket.socket") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.sys.exit") def test_do_child_process_cantbind2(self, mock_exit, mock_kill, mock_socket): mock_exit.side_effect = IndentationError("subprocess would exit here") @@ -499,12 +503,12 @@ class DoChildProcessTest(unittest.TestCase): sample_socket.bind.side_effect = eaccess mock_socket.return_value = sample_socket self.assertRaises( - IndentationError, self.authenticator.do_child_process, 1717, - self.key) + IndentationError, self.authenticator.do_child_process, 1717, KEY) mock_exit.assert_called_once_with(1) mock_kill.assert_called_once_with(12345, signal.SIGUSR1) - @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "socket.socket") def test_do_child_process_cantbind3(self, mock_socket): """Test case where attempt to bind socket results in an unhandled socket error. (The expected behavior is arguably wrong because it @@ -515,12 +519,13 @@ class DoChildProcessTest(unittest.TestCase): sample_socket.bind.side_effect = eio mock_socket.return_value = sample_socket self.assertRaises( - socket.error, self.authenticator.do_child_process, 1717, self.key) + socket.error, self.authenticator.do_child_process, 1717, KEY) - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "OpenSSL.SSL.Connection") - @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "socket.socket") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") def test_do_child_process_success( self, mock_kill, mock_socket, mock_connection): sample_socket = mock.MagicMock() @@ -528,8 +533,7 @@ class DoChildProcessTest(unittest.TestCase): mock_socket.return_value = sample_socket mock_connection.return_value = mock.MagicMock() self.assertRaises( - CallableExhausted, self.authenticator.do_child_process, 1717, - self.key) + CallableExhausted, self.authenticator.do_child_process, 1717, KEY) mock_socket.assert_called_once_with() sample_socket.bind.assert_called_once_with(("0.0.0.0", 1717)) sample_socket.listen.assert_called_once_with(1) @@ -543,17 +547,19 @@ class DoChildProcessTest(unittest.TestCase): class CleanupTest(unittest.TestCase): """Tests for cleanup() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) self.achall = achallenges.DVSNI( - chall=challenges.DVSNI(r="whee", nonce="foononce"), + challb=acme_util.chall_to_challb( + challenges.DVSNI(r="whee", nonce="foononce"), "pending"), domain="foo.example.com", key="key") self.authenticator.tasks = {self.achall.nonce_domain: "stuff"} self.authenticator.child_pid = 12345 - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.time.sleep") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "time.sleep") def test_cleanup(self, mock_sleep, mock_kill): mock_sleep.return_value = None mock_kill.return_value = None @@ -566,16 +572,17 @@ class CleanupTest(unittest.TestCase): def test_bad_cleanup(self): self.assertRaises( ValueError, self.authenticator.cleanup, [achallenges.DVSNI( - chall=challenges.DVSNI(r="whee", nonce="badnonce"), + challb=acme_util.chall_to_challb( + challenges.DVSNI(r="whee", nonce="badnonce"), "pending"), domain="bad.example.com", key="key")]) class MoreInfoTest(unittest.TestCase): """Tests for more_info() method. (trivially)""" def setUp(self): - from letsencrypt.client.standalone_authenticator import ( + from letsencrypt.client.plugins.standalone.authenticator import ( StandaloneAuthenticator) - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) def test_more_info(self): """Make sure exceptions aren't raised.""" @@ -585,9 +592,9 @@ class MoreInfoTest(unittest.TestCase): class InitTest(unittest.TestCase): """Tests for more_info() method. (trivially)""" def setUp(self): - from letsencrypt.client.standalone_authenticator import ( + from letsencrypt.client.plugins.standalone.authenticator import ( StandaloneAuthenticator) - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) def test_prepare(self): """Make sure exceptions aren't raised. diff --git a/letsencrypt/client/reverter.py b/letsencrypt/client/reverter.py index 715b44f80..9d739f37e 100644 --- a/letsencrypt/client/reverter.py +++ b/letsencrypt/client/reverter.py @@ -6,9 +6,11 @@ import time import zope.component +from letsencrypt.client import constants from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util + from letsencrypt.client.display import util as display_util @@ -83,7 +85,8 @@ class Reverter(object): def view_config_changes(self): """Displays all saved checkpoints. - All checkpoints are printed to the console. + All checkpoints are printed by + :meth:`letsencrypt.client.interfaces.IDisplay.notification`. .. todo:: Decide on a policy for error handling, OSError IOError... @@ -130,17 +133,17 @@ class Reverter(object): os.linesep.join(output), display_util.HEIGHT) def add_to_temp_checkpoint(self, save_files, save_notes): - """Add files to temporary checkpoint + """Add files to temporary checkpoint. - param set save_files: set of filepaths to save - param str save_notes: notes about changes during the save + :param set save_files: set of filepaths to save + :param str save_notes: notes about changes during the save """ self._add_to_checkpoint_dir( self.config.temp_checkpoint_dir, save_files, save_notes) def add_to_checkpoint(self, save_files, save_notes): - """Add files to a permanent checkpoint + """Add files to a permanent checkpoint. :param set save_files: set of filepaths to save :param str save_notes: notes about changes during the save @@ -163,7 +166,8 @@ class Reverter(object): unable to add checkpoint """ - le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) + le_util.make_or_verify_dir( + cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid()) op_fd, existing_filepaths = self._read_and_append( os.path.join(cp_dir, "FILEPATHS")) @@ -304,7 +308,8 @@ class Reverter(object): else: cp_dir = self.config.in_progress_dir - le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) + le_util.make_or_verify_dir( + cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid()) # Append all new files (that aren't already registered) new_fd = None @@ -324,15 +329,18 @@ class Reverter(object): new_fd.close() def recovery_routine(self): - """Revert all previously modified files. + """Revert configuration to most recent finalized checkpoint. - First, any changes found in IConfig.temp_checkpoint_dir are removed, - then IN_PROGRESS changes are removed The order is important. - IN_PROGRESS is unable to add files that are already added by a TEMP - change. Thus TEMP must be rolled back first because that will be the - 'latest' occurrence of the file. + Remove all changes (temporary and permanent) that have not been + finalized. This is useful to protect against crashes and other + execution interruptions. """ + # First, any changes found in IConfig.temp_checkpoint_dir are removed, + # then IN_PROGRESS changes are removed The order is important. + # IN_PROGRESS is unable to add files that are already added by a TEMP + # change. Thus TEMP must be rolled back first because that will be the + # 'latest' occurrence of the file. self.revert_temporary_config() if os.path.isdir(self.config.in_progress_dir): try: @@ -385,11 +393,10 @@ class Reverter(object): return True def finalize_checkpoint(self, title): - """Move IN_PROGRESS checkpoint to timestamped checkpoint. + """Finalize the checkpoint. - Adds title to self.config.in_progress_dir CHANGES_SINCE - Move self.config.in_progress_dir to Backups directory and - rename the directory as a timestamp + Timestamps and permanently saves all changes made through the use + of :func:`~add_to_checkpoint` and :func:`~register_file_creation` :param str title: Title describing checkpoint @@ -397,6 +404,9 @@ class Reverter(object): checkpoint is not able to be finalized. """ + # Adds title to self.config.in_progress_dir CHANGES_SINCE + # Move self.config.in_progress_dir to Backups directory and + # rename the directory as a timestamp # Check to make sure an "in progress" directory exists if not os.path.isdir(self.config.in_progress_dir): return diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index 98cf1704e..c18b5ffa6 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -17,7 +17,7 @@ import Crypto.PublicKey.RSA import M2Crypto from letsencrypt.acme import messages -from letsencrypt.acme import util as acme_util +from letsencrypt.acme.jose import util as jose_util from letsencrypt.client import errors from letsencrypt.client import le_util @@ -240,7 +240,7 @@ class Revoker(object): """ # These will both have to change in the future away from M2Crypto # pylint: disable=protected-access - certificate = acme_util.ComparableX509(cert._cert) + certificate = jose_util.ComparableX509(cert._cert) try: with open(cert.backup_key_path, "rU") as backup_key_file: key = Crypto.PublicKey.RSA.importKey(backup_key_file.read()) diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py new file mode 100644 index 000000000..6a79a94c7 --- /dev/null +++ b/letsencrypt/client/tests/account_test.py @@ -0,0 +1,211 @@ +"""Tests for letsencrypt.client.account.""" +import logging +import mock +import os +import pkg_resources +import shutil +import tempfile +import unittest + +from letsencrypt.acme import messages2 + +from letsencrypt.client import configuration +from letsencrypt.client import errors +from letsencrypt.client import le_util + +from letsencrypt.client.display import util as display_util + + +class AccountTest(unittest.TestCase): + """Tests letsencrypt.client.account.Account.""" + + def setUp(self): + from letsencrypt.client.account import Account + + logging.disable(logging.CRITICAL) + + self.accounts_dir = tempfile.mkdtemp("accounts") + self.account_keys_dir = os.path.join(self.accounts_dir, "keys") + os.makedirs(self.account_keys_dir, 0o700) + + self.config = mock.MagicMock( + spec=configuration.NamespaceConfig, accounts_dir=self.accounts_dir, + account_keys_dir=self.account_keys_dir, rsa_key_size=2048, + server="letsencrypt-demo.org") + + key_file = pkg_resources.resource_filename( + "letsencrypt.acme.jose", os.path.join("testdata", "rsa512_key.pem")) + key_pem = pkg_resources.resource_string( + "letsencrypt.acme.jose", os.path.join("testdata", "rsa512_key.pem")) + + self.key = le_util.Key(key_file, key_pem) + self.email = "client@letsencrypt.org" + self.regr = messages2.RegistrationResource( + uri="uri", + new_authzr_uri="new_authzr_uri", + terms_of_service="terms_of_service", + body=messages2.Registration( + recovery_token="recovery_token", agreement="agreement") + ) + + self.test_account = Account( + self.config, self.key, self.email, None, self.regr) + + def tearDown(self): + shutil.rmtree(self.accounts_dir) + logging.disable(logging.NOTSET) + + @mock.patch("letsencrypt.client.account.zope.component.getUtility") + @mock.patch("letsencrypt.client.account.crypto_util.init_save_key") + def test_prompts(self, mock_key, mock_util): + from letsencrypt.client.account import Account + + mock_util().input.return_value = (display_util.OK, self.email) + mock_key.return_value = self.key + + acc = Account.from_prompts(self.config) + self.assertEqual(acc.email, self.email) + self.assertEqual(acc.key, self.key) + self.assertEqual(acc.config, self.config) + + @mock.patch("letsencrypt.client.account.zope.component.getUtility") + @mock.patch("letsencrypt.client.account.Account.from_email") + def test_prompts_bad_email(self, mock_from_email, mock_util): + from letsencrypt.client.account import Account + + mock_from_email.side_effect = (errors.LetsEncryptClientError, "acc") + mock_util().input.return_value = (display_util.OK, self.email) + + self.assertEqual(Account.from_prompts(self.config), "acc") + + + @mock.patch("letsencrypt.client.account.zope.component.getUtility") + @mock.patch("letsencrypt.client.account.crypto_util.init_save_key") + def test_prompts_empty_email(self, mock_key, mock_util): + from letsencrypt.client.account import Account + + mock_util().input.return_value = (display_util.OK, "") + acc = Account.from_prompts(self.config) + self.assertTrue(acc.email is None) + # _get_config_filename | pylint: disable=protected-access + mock_key.assert_called_once_with( + mock.ANY, mock.ANY, acc._get_config_filename(None)) + + @mock.patch("letsencrypt.client.account.zope.component.getUtility") + def test_prompts_cancel(self, mock_util): + from letsencrypt.client.account import Account + + mock_util().input.return_value = (display_util.CANCEL, "") + + self.assertTrue(Account.from_prompts(self.config) is None) + + def test_from_email(self): + from letsencrypt.client.account import Account + + self.assertRaises(errors.LetsEncryptClientError, + Account.from_email, self.config, "not_valid...email") + + def test_save_from_existing_account(self): + from letsencrypt.client.account import Account + + self.test_account.save() + acc = Account.from_existing_account(self.config, self.email) + + self.assertEqual(acc.key, self.test_account.key) + self.assertEqual(acc.email, self.test_account.email) + self.assertEqual(acc.phone, self.test_account.phone) + self.assertEqual(acc.regr, self.test_account.regr) + + def test_properties(self): + self.assertEqual(self.test_account.uri, "uri") + self.assertEqual(self.test_account.new_authzr_uri, "new_authzr_uri") + self.assertEqual(self.test_account.terms_of_service, "terms_of_service") + self.assertEqual(self.test_account.recovery_token, "recovery_token") + + def test_partial_properties(self): + from letsencrypt.client.account import Account + + partial = Account(self.config, self.key) + + self.assertTrue(partial.uri is None) + self.assertTrue(partial.new_authzr_uri is None) + self.assertTrue(partial.terms_of_service is None) + self.assertTrue(partial.recovery_token is None) + + def test_partial_account_default(self): + from letsencrypt.client.account import Account + + partial = Account(self.config, self.key) + partial.save() + + acc = Account.from_existing_account(self.config) + + self.assertEqual(partial.key, acc.key) + self.assertEqual(partial.email, acc.email) + self.assertEqual(partial.phone, acc.phone) + self.assertEqual(partial.regr, acc.regr) + + def test_get_accounts(self): + from letsencrypt.client.account import Account + + accs = Account.get_accounts(self.config) + self.assertFalse(accs) + + self.test_account.save() + accs = Account.get_accounts(self.config) + self.assertEqual(len(accs), 1) + self.assertEqual(accs[0].email, self.test_account.email) + + acc2 = Account(self.config, self.key, "testing_email@gmail.com") + acc2.save() + accs = Account.get_accounts(self.config) + self.assertEqual(len(accs), 2) + + def test_get_accounts_no_accounts(self): + from letsencrypt.client.account import Account + + self.assertEqual(Account.get_accounts( + mock.Mock(accounts_dir="non-existant")), []) + + def test_failed_existing_account(self): + from letsencrypt.client.account import Account + + self.assertRaises( + errors.LetsEncryptClientError, + Account.from_existing_account, + self.config, "non-existant@email.org") + +class SafeEmailTest(unittest.TestCase): + """Test safe_email.""" + def setUp(self): + logging.disable(logging.CRITICAL) + + def tearDown(self): + logging.disable(logging.NOTSET) + + @classmethod + def _call(cls, addr): + from letsencrypt.client.account import Account + return Account.safe_email(addr) + + def test_valid_emails(self): + addrs = [ + "letsencrypt@letsencrypt.org", + "tbd.ade@gmail.com", + "abc_def.jdk@hotmail.museum", + ] + for addr in addrs: + self.assertTrue(self._call(addr), "%s failed." % addr) + + def test_invalid_emails(self): + addrs = [ + "letsencrypt@letsencrypt..org", + ".tbd.ade@gmail.com", + "~/abc_def.jdk@hotmail.museum", + ] + for addr in addrs: + self.assertFalse(self._call(addr), "%s failed." % addr) + + +if __name__ == "__main__": + unittest.main() diff --git a/letsencrypt/client/tests/achallenges_test.py b/letsencrypt/client/tests/achallenges_test.py index 1ed307bd9..72c610f31 100644 --- a/letsencrypt/client/tests/achallenges_test.py +++ b/letsencrypt/client/tests/achallenges_test.py @@ -5,23 +5,25 @@ import re import unittest import M2Crypto -import mock from letsencrypt.acme import challenges from letsencrypt.client import le_util +from letsencrypt.client.tests import acme_util class DVSNITest(unittest.TestCase): """Tests for letsencrypt.client.achallenges.DVSNI.""" def setUp(self): - self.chall = challenges.DVSNI(r="r_value", nonce="12345ABCDE") + self.chall = acme_util.chall_to_challb( + challenges.DVSNI(r="r_value", nonce="12345ABCDE"), "pending") self.response = challenges.DVSNIResponse() key = le_util.Key("path", pkg_resources.resource_string( - __name__, os.path.join("testdata", "rsa256_key.pem"))) + "letsencrypt.acme.jose", + os.path.join("testdata", "rsa512_key.pem"))) from letsencrypt.client.achallenges import DVSNI - self.achall = DVSNI(chall=self.chall, domain="example.com", key=key) + self.achall = DVSNI(challb=self.chall, domain="example.com", key=key) def test_proxy(self): self.assertEqual(self.chall.r, self.achall.r) @@ -41,22 +43,5 @@ class DVSNITest(unittest.TestCase): ) -class IndexedTest(unittest.TestCase): - """Tests for letsencrypt.client.achallenges.Indexed.""" - - def setUp(self): - from letsencrypt.client.achallenges import Indexed - self.achall = mock.MagicMock() - self.ichall = Indexed(achall=self.achall, index=0) - - def test_attributes(self): - self.assertEqual(self.achall, self.ichall.achall) - self.assertEqual(0, self.ichall.index) - - def test_proxy(self): - self.assertEqual(self.achall.foo, self.ichall.foo) - - - if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index 233436361..0036844e0 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -1,15 +1,19 @@ """Class helps construct valid ACME messages for testing.""" +import datetime +import itertools import os import pkg_resources import Crypto.PublicKey.RSA from letsencrypt.acme import challenges -from letsencrypt.acme import other +from letsencrypt.acme import jose +from letsencrypt.acme import messages2 -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.acme.jose", os.path.join("testdata", "rsa512_key.pem")))) # Challenges SIMPLE_HTTPS = challenges.SimpleHTTPS( @@ -26,41 +30,114 @@ RECOVERY_TOKEN = challenges.RecoveryToken() POP = challenges.ProofOfPossession( alg="RS256", nonce="xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ", hints=challenges.ProofOfPossession.Hints( - jwk=other.JWK(key=KEY.publickey()), - cert_fingerprints=[ + jwk=jose.JWKRSA(key=KEY.publickey()), + 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.""" +def gen_combos(challbs): + """Generate natural combinations for challbs.""" dv_chall = [] - renewal_chall = [] + cont_chall = [] - for i, chall in enumerate(challs): # pylint: disable=redefined-outer-name - if isinstance(chall, challenges.DVChallenge): + for i, challb in enumerate(challbs): # pylint: disable=redefined-outer-name + if isinstance(challb.chall, challenges.DVChallenge): dv_chall.append(i) else: - 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) + + +def chall_to_challb(chall, status): # pylint: disable=redefined-outer-name + """Return ChallengeBody from Challenge.""" + kwargs = { + "chall": chall, + "uri": chall.typ + "_uri", + "status": status, + } + + if status == messages2.STATUS_VALID: + kwargs.update({"validated": datetime.datetime.now()}) + + return messages2.ChallengeBody(**kwargs) # pylint: disable=star-args + + +# Pending ChallengeBody objects +DVSNI_P = chall_to_challb(DVSNI, messages2.STATUS_PENDING) +SIMPLE_HTTPS_P = chall_to_challb(SIMPLE_HTTPS, messages2.STATUS_PENDING) +DNS_P = chall_to_challb(DNS, messages2.STATUS_PENDING) +RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, messages2.STATUS_PENDING) +RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, messages2.STATUS_PENDING) +POP_P = chall_to_challb(POP, messages2.STATUS_PENDING) + +CHALLENGES_P = [SIMPLE_HTTPS_P, DVSNI_P, DNS_P, + RECOVERY_CONTACT_P, RECOVERY_TOKEN_P, POP_P] +DV_CHALLENGES_P = [challb for challb in CHALLENGES_P + if isinstance(challb.chall, challenges.DVChallenge)] +CONT_CHALLENGES_P = [ + challb for challb in CHALLENGES_P + if isinstance(challb.chall, challenges.ContinuityChallenge) +] + + +def gen_authzr(authz_status, domain, challs, statuses, combos=True): + """Generate an authorization resource. + + :param authz_status: Status object + :type authz_status: :class:`letsencrypt.acme.messages2.Status` + :param list challs: Challenge objects + :param list statuses: status of each challenge object + :param bool combos: Whether or not to add combinations + + """ + # pylint: disable=redefined-outer-name + challbs = tuple( + chall_to_challb(chall, status) + for chall, status in itertools.izip(challs, statuses) + ) + authz_kwargs = { + "identifier": messages2.Identifier( + typ=messages2.IDENTIFIER_FQDN, value=domain), + "challenges": challbs, + } + if combos: + authz_kwargs.update({"combinations": gen_combos(challbs)}) + if authz_status == messages2.STATUS_VALID: + now = datetime.datetime.now() + authz_kwargs.update({ + "status": authz_status, + "expires": datetime.datetime(now.year, now.month + 1, now.day), + }) + else: + authz_kwargs.update({ + "status": authz_status, + }) + + # pylint: disable=star-args + return messages2.AuthorizationResource( + uri="https://trusted.ca/new-authz-resource", + new_cert_uri="https://trusted.ca/new-cert", + body=messages2.Authorization(**authz_kwargs) + ) diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index 91874dc0c..c6e3b6153 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -1,14 +1,16 @@ """Tests for letsencrypt.client.auth_handler.""" +import functools import logging import unittest import mock from letsencrypt.acme import challenges -from letsencrypt.acme import messages +from letsencrypt.acme import messages2 -from letsencrypt.client import achallenges from letsencrypt.client import errors +from letsencrypt.client import le_util +from letsencrypt.client import network2 from letsencrypt.client.tests import acme_util @@ -23,491 +25,341 @@ TRANSLATE = { } -class SatisfyChallengesTest(unittest.TestCase): - """verify_identities test.""" +class ChallengeFactoryTest(unittest.TestCase): + # pylint: disable=protected-access + + def setUp(self): + from letsencrypt.client.auth_handler import AuthHandler + + # Account is mocked... + self.handler = AuthHandler( + None, None, None, mock.Mock(key="mock_key")) + + self.dom = "test" + self.handler.authzr[self.dom] = acme_util.gen_authzr( + messages2.STATUS_PENDING, self.dom, acme_util.CHALLENGES, + [messages2.STATUS_PENDING]*6, False) + + def test_all(self): + cont_c, dv_c = self.handler._challenge_factory(self.dom, range(0, 6)) + + self.assertEqual( + [achall.chall for achall in cont_c], acme_util.CONT_CHALLENGES) + self.assertEqual( + [achall.chall for achall in dv_c], acme_util.DV_CHALLENGES) + + def test_one_dv_one_cont(self): + cont_c, dv_c = self.handler._challenge_factory(self.dom, [1, 4]) + + self.assertEqual( + [achall.chall for achall in cont_c], [acme_util.RECOVERY_TOKEN]) + self.assertEqual([achall.chall for achall in dv_c], [acme_util.DVSNI]) + + def test_unrecognized(self): + self.handler.authzr["failure.com"] = acme_util.gen_authzr( + messages2.STATUS_PENDING, "failure.com", + [mock.Mock(chall="chall", typ="unrecognized")], + [messages2.STATUS_PENDING]) + + self.assertRaises(errors.LetsEncryptClientError, + self.handler._challenge_factory, "failure.com", [0]) + + +class GetAuthorizationsTest(unittest.TestCase): + """get_authorizations test. + + This tests everything except for all functions under _poll_challenges. + + """ def setUp(self): from letsencrypt.client.auth_handler import AuthHandler 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.mock_account = mock.Mock(key=le_util.Key("file_path", "PEM")) + self.mock_net = mock.MagicMock(spec=network2.Network) + self.handler = AuthHandler( - self.mock_dv_auth, self.mock_client_auth, None) + self.mock_dv_auth, self.mock_cont_auth, + self.mock_net, self.mock_account) logging.disable(logging.CRITICAL) def tearDown(self): logging.disable(logging.NOTSET) - def test_name1_dvsni1(self): - dom = "0" - msg = messages.Challenge( - session_id=dom, nonce="nonce0", combinations=[], - challenges=[acme_util.DVSNI]) - self.handler.add_chall_msg(dom, msg, "dummy_key") + @mock.patch("letsencrypt.client.auth_handler.AuthHandler._poll_challenges") + def test_name1_dvsni1(self, mock_poll): + self.mock_net.request_domain_challenges.side_effect = functools.partial( + gen_dom_authzr, challs=acme_util.DV_CHALLENGES) - self.handler._satisfy_challenges() # pylint: disable=protected-access + mock_poll.side_effect = self._validate_all - self.assertEqual(len(self.handler.responses), 1) - self.assertEqual(len(self.handler.responses[dom]), 1) + authzr = self.handler.get_authorizations(["0"]) - self.assertEqual("DVSNI0", self.handler.responses[dom][0]) - self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.client_c), 1) - self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 0) + self.assertEqual(self.mock_net.answer_challenge.call_count, 1) - def test_name1_rectok1(self): - dom = "0" - msg = messages.Challenge( - session_id=dom, nonce="nonce0", combinations=[], - challenges=[acme_util.RECOVERY_TOKEN]) - self.handler.add_chall_msg(dom, msg, "dummy_key") - - self.handler._satisfy_challenges() # pylint: disable=protected-access - - self.assertEqual(len(self.handler.responses), 1) - self.assertEqual(len(self.handler.responses[dom]), 1) - - # Test if statement for dv_auth perform - self.assertEqual(self.mock_client_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) - # Assert 1 auth challenge, 0 dv - self.assertEqual(len(self.handler.dv_c[dom]), 0) - self.assertEqual(len(self.handler.client_c[dom]), 1) - - def test_name5_dvsni5(self): - for i in xrange(5): - self.handler.add_chall_msg( - str(i), - messages.Challenge(session_id=str(i), nonce="nonce%d" % i, - challenges=[acme_util.DVSNI], - combinations=[]), - "dummy_key") - - self.handler._satisfy_challenges() # pylint: disable=protected-access - - self.assertEqual(len(self.handler.responses), 5) - self.assertEqual(len(self.handler.dv_c), 5) - self.assertEqual(len(self.handler.client_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_dv_auth.perform.call_count, 1) - - for i in xrange(5): - dom = str(i) - self.assertEqual(len(self.handler.responses[dom]), 1) - self.assertEqual(self.handler.responses[dom][0], "DVSNI%d" % i) - self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 0) - self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, - achallenges.DVSNI)) - - @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") - def test_name1_auth(self, mock_chall_path): - dom = "0" - - self.handler.add_chall_msg( - dom, - messages.Challenge( - session_id="0", nonce="nonce0", - challenges=acme_util.DV_CHALLENGES, - combinations=acme_util.gen_combos(acme_util.DV_CHALLENGES)), - "dummy_key") - - path = gen_path([acme_util.SIMPLE_HTTPS], acme_util.DV_CHALLENGES) - mock_chall_path.return_value = path - self.handler._satisfy_challenges() # pylint: disable=protected-access - - self.assertEqual(len(self.handler.responses), 1) - self.assertEqual(len(self.handler.responses[dom]), - len(acme_util.DV_CHALLENGES)) - self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.client_c), 1) - - # Test if statement for client_auth perform - self.assertEqual(self.mock_client_auth.perform.call_count, 0) - self.assertEqual(self.mock_dv_auth.perform.call_count, 1) + self.assertEqual(mock_poll.call_count, 1) + chall_update = mock_poll.call_args[0][0] + self.assertEqual(chall_update.keys(), ["0"]) + self.assertEqual(len(chall_update.values()), 1) + self.assertEqual(self.mock_dv_auth.cleanup.call_count, 1) + self.assertEqual(self.mock_cont_auth.cleanup.call_count, 0) + # Test if list first element is DVSNI, use typ because it is an achall self.assertEqual( - self.handler.responses[dom], - self._get_exp_response(dom, path, acme_util.DV_CHALLENGES)) + self.mock_dv_auth.cleanup.call_args[0][0][0].typ, "dvsni") - self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 0) - self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, - achallenges.SimpleHTTPS)) + self.assertEqual(len(authzr), 1) - @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") - def test_name1_all(self, mock_chall_path): - dom = "0" + @mock.patch("letsencrypt.client.auth_handler.AuthHandler._poll_challenges") + def test_name3_dvsni3_rectok_3(self, mock_poll): + self.mock_net.request_domain_challenges.side_effect = functools.partial( + gen_dom_authzr, challs=acme_util.CHALLENGES) - combos = acme_util.gen_combos(acme_util.CHALLENGES) - self.handler.add_chall_msg( - dom, - messages.Challenge( - session_id=dom, nonce="nonce0", challenges=acme_util.CHALLENGES, - combinations=combos), - "dummy_key") + mock_poll.side_effect = self._validate_all - path = gen_path([acme_util.SIMPLE_HTTPS, acme_util.RECOVERY_TOKEN], - acme_util.CHALLENGES) - mock_chall_path.return_value = path + authzr = self.handler.get_authorizations(["0", "1", "2"]) - self.handler._satisfy_challenges() # pylint: disable=protected-access + self.assertEqual(self.mock_net.answer_challenge.call_count, 6) - self.assertEqual(len(self.handler.responses), 1) - self.assertEqual( - len(self.handler.responses[dom]), len(acme_util.CHALLENGES)) - self.assertEqual(len(self.handler.dv_c), 1) - self.assertEqual(len(self.handler.client_c), 1) - self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_c[dom]), 1) + # Check poll call + self.assertEqual(mock_poll.call_count, 1) + chall_update = mock_poll.call_args[0][0] + self.assertEqual(len(chall_update.keys()), 3) + self.assertTrue("0" in chall_update.keys()) + self.assertEqual(len(chall_update["0"]), 2) + self.assertTrue("1" in chall_update.keys()) + self.assertEqual(len(chall_update["1"]), 2) + self.assertTrue("2" in chall_update.keys()) + self.assertEqual(len(chall_update["2"]), 2) - self.assertEqual( - self.handler.responses[dom], - self._get_exp_response(dom, path, acme_util.CHALLENGES)) - self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall, - achallenges.SimpleHTTPS)) - self.assertTrue(isinstance(self.handler.client_c[dom][0].achall, - achallenges.RecoveryToken)) + self.assertEqual(self.mock_dv_auth.cleanup.call_count, 1) + self.assertEqual(self.mock_cont_auth.cleanup.call_count, 1) - @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") - def test_name5_all(self, mock_chall_path): - combos = acme_util.gen_combos(acme_util.CHALLENGES) - for i in xrange(5): - self.handler.add_chall_msg( - str(i), - messages.Challenge( - session_id=str(i), nonce="nonce%d" % i, - challenges=acme_util.CHALLENGES, combinations=combos), - "dummy_key") + self.assertEqual(len(authzr), 3) - path = gen_path([acme_util.DVSNI, acme_util.RECOVERY_CONTACT], - acme_util.CHALLENGES) - mock_chall_path.return_value = path - - self.handler._satisfy_challenges() # pylint: disable=protected-access - - self.assertEqual(len(self.handler.responses), 5) - for i in xrange(5): - self.assertEqual( - len(self.handler.responses[str(i)]), len(acme_util.CHALLENGES)) - self.assertEqual(len(self.handler.dv_c), 5) - self.assertEqual(len(self.handler.client_c), 5) - - for i in xrange(5): - dom = str(i) - self.assertEqual( - self.handler.responses[dom], - self._get_exp_response(dom, path, acme_util.CHALLENGES)) - self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual(len(self.handler.client_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, - achallenges.RecoveryContact)) - - @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") - def test_name5_mix(self, mock_chall_path): - paths = [] - chosen_chall = [[acme_util.DNS], - [acme_util.DVSNI], - [acme_util.SIMPLE_HTTPS, acme_util.POP], - [acme_util.SIMPLE_HTTPS], - [acme_util.DNS, acme_util.RECOVERY_TOKEN]] - challenge_list = [acme_util.DV_CHALLENGES, - [acme_util.DVSNI], - acme_util.CHALLENGES, - acme_util.DV_CHALLENGES, - acme_util.CHALLENGES] - - # Combos doesn't matter since I am overriding the gen_path function - for i in xrange(5): - dom = str(i) - paths.append(gen_path(chosen_chall[i], challenge_list[i])) - self.handler.add_chall_msg( - dom, - messages.Challenge( - session_id=dom, nonce="nonce%d" % i, - challenges=challenge_list[i], combinations=[]), - "dummy_key") - - mock_chall_path.side_effect = paths - - self.handler._satisfy_challenges() # pylint: disable=protected-access - - self.assertEqual(len(self.handler.responses), 5) - self.assertEqual(len(self.handler.dv_c), 5) - self.assertEqual(len(self.handler.client_c), 5) - - for i in xrange(5): - dom = str(i) - resp = self._get_exp_response(i, paths[i], challenge_list[i]) - self.assertEqual(self.handler.responses[dom], resp) - self.assertEqual(len(self.handler.dv_c[dom]), 1) - self.assertEqual( - len(self.handler.client_c[dom]), len(chosen_chall[i]) - 1) - - self.assertTrue(isinstance( - self.handler.dv_c["0"][0].achall, achallenges.DNS)) - self.assertTrue(isinstance( - self.handler.dv_c["1"][0].achall, achallenges.DVSNI)) - self.assertTrue(isinstance( - self.handler.dv_c["2"][0].achall, achallenges.SimpleHTTPS)) - self.assertTrue(isinstance( - self.handler.dv_c["3"][0].achall, achallenges.SimpleHTTPS)) - self.assertTrue(isinstance( - self.handler.dv_c["4"][0].achall, achallenges.DNS)) - - self.assertTrue(isinstance(self.handler.client_c["2"][0].achall, - achallenges.ProofOfPossession)) - self.assertTrue(isinstance( - self.handler.client_c["4"][0].achall, achallenges.RecoveryToken)) - - @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") - def test_perform_exception_cleanup(self, mock_chall_path): - """3 Challenge messages... fail perform... clean up.""" - # pylint: disable=protected-access - self.mock_dv_auth.perform.side_effect = errors.LetsEncryptDvsniError - - combos = acme_util.gen_combos(acme_util.CHALLENGES) - - for i in xrange(3): - self.handler.add_chall_msg( - str(i), - messages.Challenge( - session_id=str(i), nonce="nonce%d" % i, - challenges=acme_util.CHALLENGES, combinations=combos), - "dummy_key") - - mock_chall_path.side_effect = [ - gen_path([acme_util.DVSNI, acme_util.POP], acme_util.CHALLENGES), - gen_path([acme_util.POP], acme_util.CHALLENGES), - gen_path([acme_util.DVSNI], acme_util.CHALLENGES), - ] - - # This may change in the future... but for now catch the error - self.assertRaises(errors.LetsEncryptAuthHandlerError, - self.handler._satisfy_challenges) - - # Verify cleanup is actually run correctly - self.assertEqual(self.mock_dv_auth.cleanup.call_count, 2) - self.assertEqual(self.mock_client_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 - - # Check DV cleanup - for i in xrange(2): - dv_chall_list = dv_cleanup_args[i][0][0] - self.assertEqual(len(dv_chall_list), 1) - self.assertTrue( - isinstance(dv_chall_list[0], achallenges.DVSNI)) - - - # Check Auth cleanup - for i in xrange(2): - client_chall_list = client_cleanup_args[i][0][0] - self.assertEqual(len(client_chall_list), 1) - self.assertTrue( - isinstance(client_chall_list[0], achallenges.ProofOfPossession)) + def test_perform_failure(self): + self.mock_net.request_domain_challenges.side_effect = functools.partial( + gen_dom_authzr, challs=acme_util.CHALLENGES) + self.mock_dv_auth.perform.side_effect = errors.AuthorizationError + self.assertRaises(errors.AuthorizationError, + self.handler.get_authorizations, ["0"]) def _get_exp_response(self, domain, path, challs): # pylint: disable=no-self-use exp_resp = [None] * len(challs) for i in path: - exp_resp[i] = TRANSLATE[challs[i].acme_type] + str(domain) + exp_resp[i] = TRANSLATE[challs[i].typ] + str(domain) return exp_resp - -# pylint: disable=protected-access -class GetAuthorizationsTest(unittest.TestCase): - def setUp(self): - from letsencrypt.client.auth_handler import AuthHandler - - self.mock_dv_auth = mock.MagicMock(name="ApacheConfigurator") - self.mock_client_auth = mock.MagicMock(name="ClientAuthenticator") - - self.mock_sat_chall = mock.MagicMock(name="_satisfy_challenges") - self.mock_acme_auth = mock.MagicMock(name="acme_authorization") - - self.iteration = 0 - - self.handler = AuthHandler( - self.mock_dv_auth, self.mock_client_auth, None) - - self.handler._satisfy_challenges = self.mock_sat_chall - self.handler.acme_authorization = self.mock_acme_auth - - def test_solved3_at_once(self): - # Set 3 DVSNI challenges - for i in xrange(3): - self.handler.add_chall_msg( - str(i), - messages.Challenge( - session_id=str(i), nonce="nonce%d" % i, - challenges=[acme_util.DVSNI], combinations=[]), - "dummy_key") - - self.mock_sat_chall.side_effect = self._sat_solved_at_once - self.handler.get_authorizations() - - self.assertEqual(self.mock_sat_chall.call_count, 1) - self.assertEqual(self.mock_acme_auth.call_count, 3) - - exp_call_list = [mock.call("0"), mock.call("1"), mock.call("2")] - self.assertEqual( - self.mock_acme_auth.call_args_list, exp_call_list) - self._test_finished() - - def _sat_solved_at_once(self): - for i in xrange(3): - dom = str(i) - self.handler.responses[dom] = ["DVSNI%d" % i] - self.handler.paths[dom] = [0] - # Assignment was > 80 char... - dv_c, c_c = self.handler._challenge_factory(dom, [0]) - - self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c - - def test_progress_failure(self): - self.handler.add_chall_msg( - "0", - messages.Challenge( - session_id="0", nonce="nonce0", challenges=acme_util.CHALLENGES, - combinations=[]), - "dummy_key") - - # Don't do anything to satisfy challenges - self.mock_sat_chall.side_effect = self._sat_failure - - self.assertRaises( - errors.LetsEncryptAuthHandlerError, self.handler.get_authorizations) - - # Check to make sure program didn't loop - self.assertEqual(self.mock_sat_chall.call_count, 1) - - def _sat_failure(self): - dom = "0" - self.handler.paths[dom] = gen_path( - [acme_util.DNS, acme_util.RECOVERY_TOKEN], - self.handler.msgs[dom].challenges) - dv_c, c_c = self.handler._challenge_factory( - dom, self.handler.paths[dom]) - self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c - - def test_incremental_progress(self): - for dom, challs in [("0", acme_util.CHALLENGES), - ("1", acme_util.DV_CHALLENGES)]: - self.handler.add_chall_msg( + def _validate_all(self, unused_1, unused_2): + for dom in self.handler.authzr.keys(): + azr = self.handler.authzr[dom] + self.handler.authzr[dom] = acme_util.gen_authzr( + messages2.STATUS_VALID, dom, - messages.Challenge(session_id=dom, nonce="nonce", - combinations=[], challenges=challs), - "dummy_key") - - self.mock_sat_chall.side_effect = self._sat_incremental - - self.handler.get_authorizations() - - self._test_finished() - self.assertEqual(self.mock_acme_auth.call_args_list, - [mock.call("1"), mock.call("0")]) - - def _sat_incremental(self): - # Exact responses don't matter, just path/response match - if self.iteration == 0: - # Only solve one of "0" required challs - self.handler.responses["0"][1] = "onecomplete" - self.handler.responses["0"][3] = None - self.handler.responses["1"] = [None, None, "goodresp"] - self.handler.paths["0"] = [1, 3] - self.handler.paths["1"] = [2] - # This is probably overkill... but set it anyway - dv_c, c_c = self.handler._challenge_factory("0", [1, 3]) - self.handler.dv_c["0"], self.handler.client_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.iteration += 1 - - elif self.iteration == 1: - # Quick check to make sure it was actually completed. - self.assertEqual( - self.mock_acme_auth.call_args_list, [mock.call("1")]) - self.handler.responses["0"][1] = "now_finish" - self.handler.responses["0"][3] = "finally!" - - else: - raise errors.LetsEncryptAuthHandlerError( - "Failed incremental test: too many invocations") - - def _test_finished(self): - self.assertFalse(self.handler.msgs) - self.assertFalse(self.handler.dv_c) - self.assertFalse(self.handler.responses) - self.assertFalse(self.handler.paths) - self.assertFalse(self.handler.domains) + [challb.chall for challb in azr.body.challenges], + [messages2.STATUS_VALID]*len(azr.body.challenges), + azr.body.combinations) -# pylint: disable=protected-access -class PathSatisfiedTest(unittest.TestCase): +class PollChallengesTest(unittest.TestCase): + # pylint: disable=protected-access + """Test poll challenges.""" + def setUp(self): + from letsencrypt.client.auth_handler import challb_to_achall from letsencrypt.client.auth_handler import AuthHandler - self.handler = AuthHandler(None, None, None) - def test_satisfied_true(self): - dom = ["0", "1", "2", "3", "4"] - self.handler.paths[dom[0]] = [1, 2] - self.handler.responses[dom[0]] = [None, "sat", "sat2", None] + # Account and network are mocked... + self.mock_net = mock.MagicMock() + self.handler = AuthHandler( + None, None, self.mock_net, mock.Mock(key="mock_key")) - self.handler.paths[dom[1]] = [0] - self.handler.responses[dom[1]] = ["sat", None, None, None] + self.doms = ["0", "1", "2"] + self.handler.authzr[self.doms[0]] = acme_util.gen_authzr( + messages2.STATUS_PENDING, self.doms[0], + acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) - self.handler.paths[dom[2]] = [0] - self.handler.responses[dom[2]] = ["sat"] + self.handler.authzr[self.doms[1]] = acme_util.gen_authzr( + messages2.STATUS_PENDING, self.doms[1], + acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) - self.handler.paths[dom[3]] = [] - self.handler.responses[dom[3]] = [] + self.handler.authzr[self.doms[2]] = acme_util.gen_authzr( + messages2.STATUS_PENDING, self.doms[2], + acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False) - self.handler.paths[dom[4]] = [] - self.handler.responses[dom[4]] = ["respond... sure"] + self.chall_update = {} + for dom in self.doms: + self.chall_update[dom] = [ + challb_to_achall(challb, "dummy_key", dom) + for challb in self.handler.authzr[dom].body.challenges] - for i in xrange(5): - self.assertTrue(self.handler._path_satisfied(dom[i])) + @mock.patch("letsencrypt.client.auth_handler.time") + def test_poll_challenges(self, unused_mock_time): + self.mock_net.poll.side_effect = self._mock_poll_solve_one_valid + self.handler._poll_challenges(self.chall_update, False) - def test_not_satisfied(self): - dom = ["0", "1", "2"] - self.handler.paths[dom[0]] = [1, 2] - self.handler.responses[dom[0]] = ["sat1", None, "sat2", None] + for authzr in self.handler.authzr.values(): + self.assertEqual(authzr.body.status, messages2.STATUS_VALID) - self.handler.paths[dom[1]] = [0] - self.handler.responses[dom[1]] = [None, None, None, None] + @mock.patch("letsencrypt.client.auth_handler.time") + def test_poll_challenges_failure_best_effort(self, unused_mock_time): + self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid + self.handler._poll_challenges(self.chall_update, True) - self.handler.paths[dom[2]] = [0] - self.handler.responses[dom[2]] = [None] + for authzr in self.handler.authzr.values(): + self.assertEqual(authzr.body.status, messages2.STATUS_PENDING) - for i in xrange(3): - self.assertFalse(self.handler._path_satisfied(dom[i])) + @mock.patch("letsencrypt.client.auth_handler.time") + def test_poll_challenges_failure(self, unused_mock_time): + self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid + self.assertRaises(errors.AuthorizationError, + self.handler._poll_challenges, + self.chall_update, False) + + @mock.patch("letsencrypt.client.auth_handler.time") + def test_unable_to_find_challenge_status(self, unused_mock_time): + from letsencrypt.client.auth_handler import challb_to_achall + self.mock_net.poll.side_effect = self._mock_poll_solve_one_valid + self.chall_update[self.doms[0]].append( + challb_to_achall(acme_util.RECOVERY_CONTACT_P, "key", self.doms[0])) + self.assertRaises( + errors.AuthorizationError, + self.handler._poll_challenges, self.chall_update, False) + + def test_verify_authzr_failure(self): + self.assertRaises( + errors.AuthorizationError, self.handler.verify_authzr_complete) + + def _mock_poll_solve_one_valid(self, authzr): + # Pending here because my dummy script won't change the full status. + # Basically it didn't raise an error and it stopped earlier than + # Making all challenges invalid which would make mock_poll_solve_one + # change authzr to invalid + return self._mock_poll_solve_one_chall(authzr, messages2.STATUS_VALID) + + def _mock_poll_solve_one_invalid(self, authzr): + return self._mock_poll_solve_one_chall(authzr, messages2.STATUS_INVALID) + + def _mock_poll_solve_one_chall(self, authzr, desired_status): + # pylint: disable=no-self-use + """Dummy method that solves one chall at a time to desired_status. + + When all are solved.. it changes authzr.status to desired_status + + """ + new_challbs = authzr.body.challenges + for challb in authzr.body.challenges: + if challb.status != desired_status: + new_challbs = tuple( + challb_temp if challb_temp != challb + else acme_util.chall_to_challb(challb.chall, desired_status) + for challb_temp in authzr.body.challenges + ) + break + + if all(test_challb.status == desired_status + for test_challb in new_challbs): + status_ = desired_status + else: + status_ = authzr.body.status + + new_authzr = messages2.AuthorizationResource( + uri=authzr.uri, + new_cert_uri=authzr.new_cert_uri, + body=messages2.Authorization( + identifier=authzr.body.identifier, + challenges=new_challbs, + combinations=authzr.body.combinations, + key=authzr.body.key, + contact=authzr.body.contact, + status=status_, + ), + ) + return (new_authzr, "response") + +class GenChallengePathTest(unittest.TestCase): + """Tests for letsencrypt.client.auth_handler.gen_challenge_path. + + .. 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, challbs, preferences, combinations): + from letsencrypt.client.auth_handler import gen_challenge_path + return gen_challenge_path(challbs, preferences, combinations) + + def test_common_case(self): + """Given DVSNI and SimpleHTTPS with appropriate combos.""" + challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTPS_P) + prefs = [challenges.DVSNI] + combos = ((0,), (1,)) + + # Smart then trivial dumb path test + self.assertEqual(self._call(challbs, prefs, combos), (0,)) + self.assertTrue(self._call(challbs, prefs, None)) + # Rearrange order... + self.assertEqual(self._call(challbs[::-1], prefs, combos), (1,)) + self.assertTrue(self._call(challbs[::-1], prefs, None)) + + def test_common_case_with_continuity(self): + challbs = (acme_util.RECOVERY_TOKEN_P, + acme_util.RECOVERY_CONTACT_P, + acme_util.DVSNI_P, + acme_util.SIMPLE_HTTPS_P) + prefs = [challenges.RecoveryToken, challenges.DVSNI] + combos = acme_util.gen_combos(challbs) + self.assertEqual(self._call(challbs, prefs, combos), (0, 2)) + + # dumb_path() trivial test + self.assertTrue(self._call(challbs, prefs, None)) + + def test_full_cont_server(self): + challbs = (acme_util.RECOVERY_TOKEN_P, + acme_util.RECOVERY_CONTACT_P, + acme_util.POP_P, + acme_util.DVSNI_P, + acme_util.SIMPLE_HTTPS_P, + acme_util.DNS_P) + # Typical webserver client that can do everything except DNS + # Attempted to make the order realistic + prefs = [challenges.RecoveryToken, + challenges.ProofOfPossession, + challenges.SimpleHTTPS, + challenges.DVSNI, + challenges.RecoveryContact] + combos = acme_util.gen_combos(challbs) + self.assertEqual(self._call(challbs, prefs, combos), (0, 4)) + + # Dumb path trivial test + self.assertTrue(self._call(challbs, prefs, None)) + + def test_not_supported(self): + challbs = (acme_util.POP_P, acme_util.DVSNI_P) + prefs = [challenges.DVSNI] + combos = ((0, 1),) + + self.assertRaises(errors.AuthorizationError, + self._call, challbs, prefs, combos) class MutuallyExclusiveTest(unittest.TestCase): @@ -566,15 +418,16 @@ class IsPreferredTest(unittest.TestCase): ])) def test_empty_satisfied(self): - self.assertTrue(self._call(acme_util.DNS, frozenset())) + self.assertTrue(self._call(acme_util.DNS_P, frozenset())) def test_mutually_exclusvie(self): self.assertFalse( - self._call(acme_util.DVSNI, frozenset([acme_util.SIMPLE_HTTPS]))) + self._call( + acme_util.DVSNI_P, frozenset([acme_util.SIMPLE_HTTPS_P]))) def test_mutually_exclusive_same_type(self): self.assertTrue( - self._call(acme_util.DVSNI, frozenset([acme_util.DVSNI]))) + self._call(acme_util.DVSNI_P, frozenset([acme_util.DVSNI_P]))) def gen_auth_resp(chall_list): @@ -583,6 +436,13 @@ def gen_auth_resp(chall_list): for chall in chall_list] +def gen_dom_authzr(domain, unused_new_authzr_uri, challs): + """Generates new authzr for domains.""" + return acme_util.gen_authzr( + messages2.STATUS_PENDING, domain, challs, + [messages2.STATUS_PENDING]*len(challs)) + + def gen_path(required, challs): """Generate a combination by picking ``required`` from ``challs``. @@ -596,5 +456,6 @@ def gen_path(required, challs): """ return [challs.index(chall) for chall in required] + if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 5ae6d6107..2a50af93c 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -1,15 +1,64 @@ """letsencrypt.client.client.py tests.""" +import os import unittest +import shutil +import tempfile import mock +from letsencrypt.client import account +from letsencrypt.client import configuration from letsencrypt.client import errors +from letsencrypt.client import le_util + + +class DetermineAccountTest(unittest.TestCase): + def setUp(self): + self.accounts_dir = tempfile.mkdtemp("accounts") + account_keys_dir = os.path.join(self.accounts_dir, "keys") + os.makedirs(account_keys_dir, 0o700) + + self.config = mock.MagicMock( + spec=configuration.NamespaceConfig, accounts_dir=self.accounts_dir, + account_keys_dir=account_keys_dir, rsa_key_size=2048, + server="letsencrypt-demo.org") + + def tearDown(self): + shutil.rmtree(self.accounts_dir) + + @mock.patch("letsencrypt.client.client.account.Account.from_prompts") + @mock.patch("letsencrypt.client.client.display_ops.choose_account") + def determine_account(self, mock_op, mock_prompt): + """Test determine account""" + from letsencrypt.client import client + + key = le_util.Key("file", "pem") + test_acc = account.Account(self.config, key, "email1@gmail.com") + mock_op.return_value = test_acc + + # Test 0 + mock_prompt.return_value = None + self.assertTrue(client.determine_account(self.config) is None) + + # Test 1 + test_acc.save() + acc = client.determine_account(self.config) + self.assertEqual(acc.email, test_acc.email) + + # Test multiple + self.assertFalse(mock_op.called) + acc2 = account.Account(self.config, key) + acc2.save() + chosen_acc = client.determine_account(self.config) + self.assertTrue(mock_op.called) + self.assertTrue(chosen_acc.email, test_acc.email) class DetermineAuthenticatorTest(unittest.TestCase): def setUp(self): - from letsencrypt.client.apache.configurator import ApacheConfigurator - from letsencrypt.client.standalone_authenticator import ( + from letsencrypt.client.plugins.apache.configurator import ( + ApacheConfigurator) + from letsencrypt.client.plugins.standalone.authenticator import ( StandaloneAuthenticator) self.mock_stand = mock.MagicMock( @@ -17,30 +66,39 @@ class DetermineAuthenticatorTest(unittest.TestCase): self.mock_apache = mock.MagicMock( spec=ApacheConfigurator, description="Standalone Authenticator") - self.mock_config = mock.Mock() + self.mock_config = mock.MagicMock( + spec=configuration.NamespaceConfig, authenticator=None) - self.all_auths = [self.mock_apache, self.mock_stand] + self.all_auths = { + 'apache': self.mock_apache, + 'standalone': self.mock_stand + } @classmethod - def _call(cls, all_auths): + def _call(cls, all_auths, config): from letsencrypt.client.client import determine_authenticator - return determine_authenticator(all_auths) + return determine_authenticator(all_auths, config) @mock.patch("letsencrypt.client.client.display_ops.choose_authenticator") def test_accept_two(self, mock_choose): mock_choose.return_value = self.mock_stand() - self.assertEqual(self._call(self.all_auths), self.mock_stand()) + self.assertEqual(self._call(self.all_auths, self.mock_config), + self.mock_stand()) def test_accept_one(self): self.mock_apache.prepare.return_value = self.mock_apache - self.assertEqual( - self._call(self.all_auths[:1]), self.mock_apache) + one_avail_auth = { + 'apache': self.mock_apache + } + self.assertEqual(self._call(one_avail_auth, self.mock_config), + self.mock_apache) def test_no_installation_one(self): self.mock_apache.prepare.side_effect = ( errors.LetsEncryptNoInstallationError) - self.assertEqual(self._call(self.all_auths), self.mock_stand) + self.assertEqual(self._call(self.all_auths, self.mock_config), + self.mock_stand) def test_no_installations(self): self.mock_apache.prepare.side_effect = ( @@ -50,7 +108,8 @@ class DetermineAuthenticatorTest(unittest.TestCase): self.assertRaises(errors.LetsEncryptClientError, self._call, - self.all_auths) + self.all_auths, + self.mock_config) @mock.patch("letsencrypt.client.client.logging") @mock.patch("letsencrypt.client.client.display_ops.choose_authenticator") @@ -59,13 +118,33 @@ class DetermineAuthenticatorTest(unittest.TestCase): errors.LetsEncryptMisconfigurationError) mock_choose.return_value = self.mock_apache - self.assertTrue(self._call(self.all_auths) is None) + self.assertTrue(self._call(self.all_auths, self.mock_config) is None) + + def test_choose_valid_auth_from_cmd_line(self): + standalone_config = mock.MagicMock(spec=configuration.NamespaceConfig, + authenticator='standalone') + self.assertEqual(self._call(self.all_auths, standalone_config), + self.mock_stand) + + apache_config = mock.MagicMock(spec=configuration.NamespaceConfig, + authenticator='apache') + self.assertEqual(self._call(self.all_auths, apache_config), + self.mock_apache) + + def test_choose_invalid_auth_from_cmd_line(self): + invalid_config = mock.MagicMock(spec=configuration.NamespaceConfig, + authenticator='foobar') + self.assertRaises(errors.LetsEncryptClientError, + self._call, + self.all_auths, + invalid_config) class RollbackTest(unittest.TestCase): """Test the rollback function.""" def setUp(self): - from letsencrypt.client.apache.configurator import ApacheConfigurator + from letsencrypt.client.plugins.apache.configurator import ( + ApacheConfigurator) self.m_install = mock.MagicMock(spec=ApacheConfigurator) @classmethod diff --git a/letsencrypt/client/tests/configuration_test.py b/letsencrypt/client/tests/configuration_test.py index dde1f44cb..cbbcd57ba 100644 --- a/letsencrypt/client/tests/configuration_test.py +++ b/letsencrypt/client/tests/configuration_test.py @@ -1,4 +1,5 @@ """Tests for letsencrypt.client.configuration.""" +import os import unittest import mock @@ -10,24 +11,41 @@ class NamespaceConfigTest(unittest.TestCase): def setUp(self): from letsencrypt.client.configuration import NamespaceConfig namespace = mock.MagicMock( - work_dir='/tmp/foo', foo='bar', server='acme-server.org:443') + config_dir='/tmp/config', work_dir='/tmp/foo', foo='bar', + server='acme-server.org:443/new') self.config = NamespaceConfig(namespace) def test_proxy_getattr(self): self.assertEqual(self.config.foo, 'bar') self.assertEqual(self.config.work_dir, '/tmp/foo') + def test_server_path(self): + self.assertEqual(['acme-server.org:443', 'new'], + self.config.server_path.split(os.path.sep)) + + def test_server_url(self): + self.assertEqual( + self.config.server_url, 'https://acme-server.org:443/new') + @mock.patch('letsencrypt.client.configuration.constants') def test_dynamic_dirs(self, constants): constants.TEMP_CHECKPOINT_DIR = 't' constants.IN_PROGRESS_DIR = '../p' constants.CERT_KEY_BACKUP_DIR = 'c/' constants.REC_TOKEN_DIR = '/r' + constants.ACCOUNTS_DIR = 'acc' + constants.ACCOUNT_KEYS_DIR = 'keys' + self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t') self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p') self.assertEqual( - self.config.cert_key_backup, '/tmp/foo/c/acme-server.org') + self.config.cert_key_backup, '/tmp/foo/c/acme-server.org:443/new') self.assertEqual(self.config.rec_token_dir, '/r') + self.assertEqual( + self.config.accounts_dir, '/tmp/config/acc/acme-server.org:443/new') + self.assertEqual( + self.config.account_keys_dir, + '/tmp/config/acc/acme-server.org:443/new/keys') if __name__ == '__main__': diff --git a/letsencrypt/client/tests/client_authenticator_test.py b/letsencrypt/client/tests/continuity_auth_test.py similarity index 66% rename from letsencrypt/client/tests/client_authenticator_test.py rename to letsencrypt/client/tests/continuity_auth_test.py index 7db1956d5..7a2279bcd 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,22 +13,22 @@ 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) def test_rec_token1(self): - token = achallenges.RecoveryToken(chall=None, domain="0") + token = achallenges.RecoveryToken(challb=None, domain="0") responses = self.auth.perform([token]) self.assertEqual(responses, ["RecoveryToken0"]) def test_rec_token5(self): tokens = [] for i in xrange(5): - tokens.append(achallenges.RecoveryToken(chall=None, domain=str(i))) + tokens.append(achallenges.RecoveryToken(challb=None, domain=str(i))) responses = self.auth.perform(tokens) @@ -38,8 +38,8 @@ class PerformTest(unittest.TestCase): def test_unexpected(self): self.assertRaises( - errors.LetsEncryptClientAuthError, self.auth.perform, [ - achallenges.DVSNI(chall=None, domain="0", key="invalid_key")]) + errors.LetsEncryptContAuthError, self.auth.perform, [ + achallenges.DVSNI(challb=None, domain="0", key="invalid_key")]) def test_chall_pref(self): self.assertEqual( @@ -50,16 +50,16 @@ 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 def test_rec_token2(self): - token1 = achallenges.RecoveryToken(chall=None, domain="0") - token2 = achallenges.RecoveryToken(chall=None, domain="1") + token1 = achallenges.RecoveryToken(challb=None, domain="0") + token2 = achallenges.RecoveryToken(challb=None, domain="1") self.auth.cleanup([token1, token2]) @@ -67,10 +67,10 @@ class CleanupTest(unittest.TestCase): [mock.call(token1), mock.call(token2)]) def test_unexpected(self): - token = achallenges.RecoveryToken(chall=None, domain="0") - unexpected = achallenges.DVSNI(chall=None, domain="0", key="dummy_key") + token = achallenges.RecoveryToken(challb=None, domain="0") + unexpected = achallenges.DVSNI(challb=None, domain="0", key="dummy_key") - self.assertRaises(errors.LetsEncryptClientAuthError, + self.assertRaises(errors.LetsEncryptContAuthError, self.auth.cleanup, [token, unexpected]) diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index 9752c3d04..a36b96c99 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -1,15 +1,72 @@ """Tests for letsencrypt.client.crypto_util.""" +import logging import os import pkg_resources +import shutil +import tempfile import unittest import M2Crypto +import mock -RSA256_KEY = pkg_resources.resource_string(__name__, 'testdata/rsa256_key.pem') -RSA512_KEY = pkg_resources.resource_string(__name__, 'testdata/rsa512_key.pem') +RSA256_KEY = pkg_resources.resource_string( + 'letsencrypt.acme.jose', os.path.join('testdata', 'rsa256_key.pem')) +RSA512_KEY = pkg_resources.resource_string( + 'letsencrypt.acme.jose', os.path.join('testdata', 'rsa512_key.pem')) +class InitSaveKeyTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.init_save_key.""" + def setUp(self): + logging.disable(logging.CRITICAL) + self.key_dir = tempfile.mkdtemp('key_dir') + + def tearDown(self): + logging.disable(logging.NOTSET) + shutil.rmtree(self.key_dir) + + @classmethod + def _call(cls, key_size, key_dir): + from letsencrypt.client.crypto_util import init_save_key + return init_save_key(key_size, key_dir, 'key-letsencrypt.pem') + + @mock.patch('letsencrypt.client.crypto_util.make_key') + def test_success(self, mock_make): + mock_make.return_value = 'key_pem' + key = self._call(1024, self.key_dir) + self.assertEqual(key.pem, 'key_pem') + self.assertTrue('key-letsencrypt.pem' in key.file) + + @mock.patch('letsencrypt.client.crypto_util.make_key') + def test_key_failure(self, mock_make): + mock_make.side_effect = ValueError + self.assertRaises(ValueError, self._call, 431, self.key_dir) + + +class InitSaveCSRTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.init_save_csr.""" + + def setUp(self): + self.csr_dir = tempfile.mkdtemp('csr_dir') + + def tearDown(self): + shutil.rmtree(self.csr_dir) + + @mock.patch('letsencrypt.client.crypto_util.make_csr') + @mock.patch('letsencrypt.client.crypto_util.le_util.make_or_verify_dir') + def test_it(self, unused_mock_verify, mock_csr): + from letsencrypt.client.crypto_util import init_save_csr + + mock_csr.return_value = ('csr_pem', 'csr_der') + + csr = init_save_csr( + mock.Mock(pem='dummy_key'), 'example.com', self.csr_dir, + 'csr-letsencrypt.pem') + + self.assertEqual(csr.data, 'csr_der') + self.assertTrue('csr-letsencrypt.pem' in csr.file) + class ValidCSRTest(unittest.TestCase): """Tests for letsencrypt.client.crypto_util.valid_csr.""" @@ -51,10 +108,10 @@ class CSRMatchesPubkeyTest(unittest.TestCase): __name__, os.path.join('testdata', name)), privkey) def test_valid_true(self): - self.assertTrue(self._call_testdata('csr.pem', RSA256_KEY)) + self.assertTrue(self._call_testdata('csr.pem', RSA512_KEY)) def test_invalid_false(self): - self.assertFalse(self._call_testdata('csr.pem', RSA512_KEY)) + self.assertFalse(self._call_testdata('csr.pem', RSA256_KEY)) class MakeKeyTest(unittest.TestCase): # pylint: disable=too-few-public-methods @@ -90,7 +147,7 @@ class MakeSSCertTest(unittest.TestCase): def test_it(self): # pylint: disable=no-self-use from letsencrypt.client.crypto_util import make_ss_cert - make_ss_cert(RSA256_KEY, ['example.com', 'www.example.com']) + make_ss_cert(RSA512_KEY, ['example.com', 'www.example.com']) if __name__ == '__main__': diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index 11edfe4e3..de5745e8e 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -1,13 +1,16 @@ """Test letsencrypt.client.display.ops.""" +import os import sys +import tempfile import unittest import mock import zope.component +from letsencrypt.client import account +from letsencrypt.client import le_util from letsencrypt.client.display import util as display_util - class ChooseAuthenticatorTest(unittest.TestCase): """Test choose_authenticator function.""" def setUp(self): @@ -50,10 +53,51 @@ class ChooseAuthenticatorTest(unittest.TestCase): @mock.patch("letsencrypt.client.display.ops.util") def test_no_choice(self, mock_util): mock_util().menu.return_value = (display_util.CANCEL, 0) - self.assertTrue(self._call(self.auths, {}) is None) +class ChooseAccountTest(unittest.TestCase): + """Test choose_account.""" + def setUp(self): + zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) + + self.accounts_dir = tempfile.mkdtemp("accounts") + self.account_keys_dir = os.path.join(self.accounts_dir, "keys") + os.makedirs(self.account_keys_dir, 0o700) + + self.config = mock.MagicMock( + accounts_dir=self.accounts_dir, + account_keys_dir=self.account_keys_dir, + server="letsencrypt-demo.org") + self.key = le_util.Key("keypath", "pem") + + self.acc1 = account.Account(self.config, self.key, "email1@g.com") + self.acc2 = account.Account( + self.config, self.key, "email2@g.com", "phone") + self.acc1.save() + self.acc2.save() + + @classmethod + def _call(cls, accounts): + from letsencrypt.client.display import ops + return ops.choose_account(accounts) + + @mock.patch("letsencrypt.client.display.ops.util") + def test_one(self, mock_util): + mock_util().menu.return_value = (display_util.OK, 0) + self.assertEqual(self._call([self.acc1]), self.acc1) + + @mock.patch("letsencrypt.client.display.ops.util") + def test_two(self, mock_util): + mock_util().menu.return_value = (display_util.OK, 1) + self.assertEqual(self._call([self.acc1, self.acc2]), self.acc2) + + @mock.patch("letsencrypt.client.display.ops.util") + def test_cancel(self, mock_util): + mock_util().menu.return_value = (display_util.CANCEL, 1) + self.assertTrue(self._call([self.acc1, self.acc2]) is None) + + class GenHttpsNamesTest(unittest.TestCase): """Test _gen_https_names.""" def setUp(self): diff --git a/letsencrypt/client/tests/display/util_test.py b/letsencrypt/client/tests/display/util_test.py index 69dea26ea..42c948c79 100644 --- a/letsencrypt/client/tests/display/util_test.py +++ b/letsencrypt/client/tests/display/util_test.py @@ -128,9 +128,9 @@ class NcursesDisplayTest(DisplayT): self.displayer.checklist("message", self.tags) choices = [ - (self.tags[0], "", False), - (self.tags[1], "", False), - (self.tags[2], "", False) + (self.tags[0], "", True), + (self.tags[1], "", True), + (self.tags[2], "", True), ] mock_checklist.assert_called_with( "message", width=display_util.WIDTH, height=display_util.HEIGHT, diff --git a/letsencrypt/client/tests/log_test.py b/letsencrypt/client/tests/log_test.py index 155f26567..49fbdc7c2 100644 --- a/letsencrypt/client/tests/log_test.py +++ b/letsencrypt/client/tests/log_test.py @@ -1,4 +1,5 @@ """Tests for letsencrypt.client.log.""" +import logging import unittest import mock @@ -15,29 +16,33 @@ class DialogHandlerTest(unittest.TestCase): self.handler.PADDING_WIDTH = 4 def test_adds_padding(self): - self.handler.emit(mock.MagicMock()) + self.handler.emit(logging.makeLogRecord({})) self.d.infobox.assert_called_once_with(mock.ANY, 4, 10) def test_args_in_msg_get_replaced(self): assert len('123456') <= self.handler.width - self.handler.emit(mock.MagicMock(msg='123%s', args=(456,))) + self.handler.emit(logging.makeLogRecord( + {'msg': '123%s', 'args': (456,)})) self.d.infobox.assert_called_once_with('123456', mock.ANY, mock.ANY) def test_wraps_nospace_is_greedy(self): assert len('1234567') > self.handler.width - self.handler.emit(mock.MagicMock(msg='1234567')) + self.handler.emit(logging.makeLogRecord({'msg': '1234567'})) self.d.infobox.assert_called_once_with('123456\n7', mock.ANY, mock.ANY) def test_wraps_at_whitespace(self): assert len('123 567') > self.handler.width - self.handler.emit(mock.MagicMock(msg='123 567')) + self.handler.emit(logging.makeLogRecord({'msg': '123 567'})) self.d.infobox.assert_called_once_with('123\n567', mock.ANY, mock.ANY) def test_only_last_lines_are_printed(self): assert len('a\nb\nc'.split()) > self.handler.height - self.handler.emit(mock.MagicMock(msg='a\n\nb\nc')) + self.handler.emit(logging.makeLogRecord({'msg': 'a\n\nb\nc'})) self.d.infobox.assert_called_once_with('b\nc', mock.ANY, mock.ANY) + def test_non_str(self): + self.handler.emit(logging.makeLogRecord({'msg': {'foo': 'bar'}})) + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/network2_test.py b/letsencrypt/client/tests/network2_test.py new file mode 100644 index 000000000..d14d27f6a --- /dev/null +++ b/letsencrypt/client/tests/network2_test.py @@ -0,0 +1,482 @@ +"""Tests for letsencrypt.client.network2.""" +import datetime +import httplib +import os +import pkg_resources +import unittest + +import M2Crypto +import mock +import requests + +from letsencrypt.acme import challenges +from letsencrypt.acme import jose +from letsencrypt.acme import messages2 + +from letsencrypt.client import account +from letsencrypt.client import errors + + +CERT = jose.ComparableX509(M2Crypto.X509.load_cert_string( + pkg_resources.resource_string( + __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( + 'letsencrypt.acme.jose', os.path.join('testdata', 'rsa512_key.pem'))) +KEY2 = jose.JWKRSA.load(pkg_resources.resource_string( + 'letsencrypt.acme.jose', 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_register_from_account(self): + self.net.register = mock.Mock() + acc = account.Account( + mock.Mock(accounts_dir='mock_dir'), 'key', + email='cert-admin@example.com', phone='+12025551212') + + self.net.register_from_account(acc) + + self.net.register.assert_called_with(contact=self.contact) + + def test_register_from_account_partial_info(self): + self.net.register = mock.Mock() + acc = account.Account( + mock.Mock(accounts_dir='mock_dir'), 'key', + email='cert-admin@example.com') + acc2 = account.Account(mock.Mock(accounts_dir='mock_dir'), 'key') + + self.net.register_from_account(acc) + self.net.register.assert_called_with( + contact=('mailto:cert-admin@example.com',)) + + self.net.register_from_account(acc2) + self.net.register.assert_called_with(contact=()) + + def test_update_registration(self): + self.response.headers['Location'] = self.regr.uri + self.response.json.return_value = self.regr.body.to_json() + self._mock_post_get() + self.assertEqual(self.regr, self.net.update_registration(self.regr)) + + # TODO: split here and separate test + self.response.json.return_value = self.regr.body.update( + contact=()).to_json() + self.assertRaises( + errors.UnexpectedUpdate, self.net.update_registration, self.regr) + + def test_agree_to_tos(self): + self.net.update_registration = mock.Mock() + self.net.agree_to_tos(self.regr) + regr = self.net.update_registration.call_args[0][0] + self.assertEqual(self.regr.terms_of_service, regr.body.agreement) + + def test_request_challenges(self): + self.response.status_code = httplib.CREATED + self.response.headers['Location'] = self.authzr.uri + self.response.json.return_value = self.authz.to_json() + self.response.links = { + 'next': {'url': self.authzr.new_cert_uri}, + } + + self._mock_post_get() + self.net.request_challenges(self.identifier, self.authzr.uri) + # TODO: test POST call arguments + + # TODO: split here and separate test + 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_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.net._get_cert.return_value = ("response", "certificate") + self.assertEqual(self.net._get_cert(self.certr.cert_chain_uri)[1], + self.net.fetch_chain(self.certr)) + + def test_fetch_chain_no_up_link(self): + self.assertTrue(self.net.fetch_chain(self.certr.update( + cert_chain_uri=None)) is None) + + def test_revoke(self): + self._mock_post_get() + self.net.revoke(self.certr, when=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/letsencrypt/client/tests/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py index 01ba78d72..0de31a8d0 100644 --- a/letsencrypt/client/tests/recovery_token_test.py +++ b/letsencrypt/client/tests/recovery_token_test.py @@ -39,22 +39,24 @@ class RecoveryTokenTest(unittest.TestCase): self.assertFalse(self.rec_token.requires_human("example3.com")) self.rec_token.cleanup(achallenges.RecoveryToken( - chall=None, domain="example3.com")) + challb=challenges.RecoveryToken(), domain="example3.com")) self.assertTrue(self.rec_token.requires_human("example3.com")) # Shouldn't throw an error self.rec_token.cleanup(achallenges.RecoveryToken( - chall=None, domain="example4.com")) + challb=None, domain="example4.com")) # SHOULD throw an error (OSError other than nonexistent file) self.assertRaises( OSError, self.rec_token.cleanup, - achallenges.RecoveryToken(chall=None, domain="a"+"r"*10000+".com")) + achallenges.RecoveryToken( + challb=None, domain=("a" + "r" * 10000 + ".com"))) def test_perform_stored(self): self.rec_token.store_token("example4.com", 444) response = self.rec_token.perform( - achallenges.RecoveryToken(chall=None, domain="example4.com")) + achallenges.RecoveryToken( + challb=challenges.RecoveryToken(), domain="example4.com")) self.assertEqual( response, challenges.RecoveryTokenResponse(token="444")) @@ -63,12 +65,14 @@ class RecoveryTokenTest(unittest.TestCase): def test_perform_not_stored(self, mock_input): mock_input().input.side_effect = [(0, "555"), (1, "000")] response = self.rec_token.perform( - achallenges.RecoveryToken(chall=None, domain="example5.com")) + achallenges.RecoveryToken( + challb=challenges.RecoveryToken(), domain="example5.com")) self.assertEqual( response, challenges.RecoveryTokenResponse(token="555")) response = self.rec_token.perform( - achallenges.RecoveryToken(chall=None, domain="example6.com")) + achallenges.RecoveryToken( + challb=challenges.RecoveryToken(), domain="example6.com")) self.assertTrue(response is None) diff --git a/letsencrypt/client/tests/revoker_test.py b/letsencrypt/client/tests/revoker_test.py index f5a940df8..1ceb8ae9a 100644 --- a/letsencrypt/client/tests/revoker_test.py +++ b/letsencrypt/client/tests/revoker_test.py @@ -10,7 +10,7 @@ import mock from letsencrypt.client import errors from letsencrypt.client import le_util -from letsencrypt.client.apache import configurator +from letsencrypt.client.plugins.apache import configurator from letsencrypt.client.display import util as display_util @@ -99,7 +99,7 @@ class RevokerTest(RevokerBase): mock_display().confirm_revocation.return_value = True key_path = pkg_resources.resource_filename( - "letsencrypt.client.tests", os.path.join( + "letsencrypt.acme.jose", os.path.join( "testdata", "rsa256_key.pem")) wrong_key = le_util.Key(key_path, open(key_path).read()) diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py old mode 100755 new mode 100644 index 0d76a1581..ae15f22dd --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python """Parse command line and call the appropriate functions. .. todo:: Sanity check all input. Be sure to avoid shell code etc... @@ -12,21 +11,48 @@ import sys import confargparse import zope.component +import zope.interface.exceptions +import zope.interface.verify import letsencrypt +from letsencrypt.client import account from letsencrypt.client import configuration +from letsencrypt.client import constants from letsencrypt.client import client from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import log -from letsencrypt.client import standalone_authenticator as standalone -from letsencrypt.client.apache import configurator + from letsencrypt.client.display import util as display_util from letsencrypt.client.display import ops as display_ops +SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT = "letsencrypt.authenticators" +"""Setuptools entry point group name for Authenticator plugins.""" + + +def init_auths(config): + """Find (setuptools entry points) and initialize Authenticators.""" + # TODO: handle collisions in authenticator names. Or is this + # already handled for us by pkg_resources? + auths = {} + for entrypoint in pkg_resources.iter_entry_points( + SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT): + auth_cls = entrypoint.load() + auth = auth_cls(config) + try: + zope.interface.verify.verifyObject(interfaces.IAuthenticator, auth) + except zope.interface.exceptions.BrokenImplementation: + logging.debug( + "%r object does not provide IAuthenticator, skipping", + entrypoint.name) + else: + auths[entrypoint.name] = auth + return auths + + def create_parser(): """Create parser.""" parser = confargparse.ConfArgParser( @@ -36,11 +62,20 @@ def create_parser(): config_help = lambda name: interfaces.IConfig[name].__doc__ add("-d", "--domains", metavar="DOMAIN", nargs="+") - add("-s", "--server", default="letsencrypt-demo.org:443", + add("-s", "--server", + default="www.letsencrypt-demo.org/acme/new-reg", help=config_help("server")) + # TODO: we should generate the list of choices from the set of + # available authenticators, but that is tricky due to the + # dependency between init_auths and config. Hardcoding it for now. + add("-a", "--authenticator", dest="authenticator", + help=config_help("authenticator")) + add("-k", "--authkey", type=read_file, help="Path to the authorized key file") + add("-m", "--email", type=str, + help="Email address used for account registration.") add("-B", "--rsa-key-size", type=int, default=2048, metavar="N", help=config_help("rsa_key_size")) @@ -64,7 +99,7 @@ def create_parser(): add("--no-confirm", dest="no_confirm", action="store_true", help="Turn off confirmation screens, currently used for --revoke") - add("-e", "--agree-tos", dest="eula", action="store_true", + add("-e", "--agree-tos", dest="tos", action="store_true", help="Skip the end user license agreement screen.") add("-t", "--text", dest="use_curses", action="store_false", help="Use the text output instead of the curses UI.") @@ -96,6 +131,13 @@ def create_parser(): add("--apache-init-script", default="/etc/init.d/apache2", help=config_help("apache_init_script")) + add("--nginx-server-root", default="/etc/nginx", + help=config_help("nginx_server_root")) + add("--nginx-mod-ssl-conf", + default="/etc/letsencrypt/options-ssl-nginx.conf", + help=config_help("nginx_mod_ssl_conf")) + add("--nginx-ctl", default="nginx", help=config_help("nginx_ctl")) + return parser @@ -127,25 +169,45 @@ def main(): # pylint: disable=too-many-branches, too-many-statements sys.exit() if args.revoke or args.rev_cert is not None or args.rev_key is not None: - client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key) + # This depends on the renewal config and cannot be completed yet. + zope.component.getUtility(interfaces.IDisplay).notification( + "Revocation is not available with the new Boulder server yet.") + + # client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key) sys.exit() if args.rollback > 0: client.rollback(args.rollback, config) sys.exit() - if not args.eula: - display_eula() + le_util.make_or_verify_dir( + config.config_dir, constants.CONFIG_DIRS_MODE, os.geteuid()) - all_auths = [ - configurator.ApacheConfigurator(config), - standalone.StandaloneAuthenticator(), - ] + # Prepare for init of Client + if args.email is None: + acc = client.determine_account(config) + else: + try: + # The way to get the default would be args.email = "" + # First try existing account + acc = account.Account.from_existing_account(config, args.email) + except errors.LetsEncryptClientError: + try: + # Try to make an account based on the email address + acc = account.Account.from_email(config, args.email) + except errors.LetsEncryptClientError: + sys.exit(1) + + if acc is None: + sys.exit(0) + + all_auths = init_auths(config) + logging.debug('Initialized authenticators: %s', all_auths.keys()) try: - auth = client.determine_authenticator(all_auths) - except errors.LetsEncryptClientError: - logging.critical("No authentication mechanisms were found on your " - "system.") + auth = client.determine_authenticator(all_auths, config) + logging.debug("Selected authenticator: %s", auth) + except errors.LetsEncryptClientError as err: + logging.critical(str(err)) sys.exit(1) if auth is None: @@ -166,16 +228,10 @@ def main(): # pylint: disable=too-many-branches, too-many-statements if not doms: sys.exit(0) - # Prepare for init of Client - if args.authkey is None: - authkey = client.init_key(args.rsa_key_size, config.key_dir) - else: - authkey = le_util.Key(args.authkey[0], args.authkey[1]) - - acme = client.Client(config, authkey, auth, installer) + acme = client.Client(config, acc, auth, installer) # Validate the key and csr - client.validate_key_csr(authkey) + client.validate_key_csr(acc.key) # This more closely mimics the capabilities of the CLI # It should be possible for reconfig only, install-only, no-install @@ -183,21 +239,18 @@ def main(): # pylint: disable=too-many-branches, too-many-statements # but this code should be safe on all environments. cert_file = None if auth is not None: + if acc.regr is None: + try: + acme.register() + except errors.LetsEncryptClientError: + sys.exit(0) cert_file, chain_file = acme.obtain_certificate(doms) if installer is not None and cert_file is not None: - acme.deploy_certificate(doms, authkey, cert_file, chain_file) + acme.deploy_certificate(doms, acc.key, cert_file, chain_file) if installer is not None: acme.enhance_config(doms, args.redirect) -def display_eula(): - """Displays the end user agreement.""" - eula = pkg_resources.resource_string("letsencrypt", "EULA") - if not zope.component.getUtility(interfaces.IDisplay).yesno( - eula, "Agree", "Cancel"): - sys.exit(0) - - def read_file(filename): """Returns the given file's contents with universal new line support. diff --git a/linter_plugin.py b/linter_plugin.py index ac2a01f6d..9a165d81f 100644 --- a/linter_plugin.py +++ b/linter_plugin.py @@ -21,5 +21,9 @@ def _transform(cls): for slot in cls.slots(): cls.locals[slot.value] = [nodes.EmptyNode()] + if cls.name == 'JSONObjectWithFields': + # _fields is magically introduced by JSONObjectWithFieldsMeta + cls.locals['_fields'] = [nodes.EmptyNode()] + MANAGER.register_transform(nodes.Class, _transform) diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 index 46aca4434..d8728f5e2 --- a/setup.py +++ b/setup.py @@ -1,10 +1,16 @@ -#!/usr/bin/env python import codecs import os import re from setuptools import setup +# Workaround for http://bugs.python.org/issue8876, see +# http://bugs.python.org/issue8876#msg208792 +# This can be removed when using Python 2.7.9 or later: +# https://hg.python.org/cpython/raw-file/v2.7.9/Misc/NEWS +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.""" @@ -24,14 +30,21 @@ changes = read_file(os.path.join(here, 'CHANGES.rst')) install_requires = [ 'argparse', 'ConfArgParse', + 'configobj', 'jsonschema', 'mock', + 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) 'psutil>=2.1.0', # net_connections introduced in 2.1.0 + '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 @@ -40,7 +53,9 @@ install_requires = [ ] dev_extras = [ - 'pylint>=1.4.0', # upstream #248 + # Pin astroid==1.3.5, pylint==1.4.2 as a workaround for #289 + 'astroid==1.3.5', + 'pylint==1.4.2', # upstream #248 ] docs_extras = [ @@ -85,11 +100,17 @@ setup( packages=[ 'letsencrypt', 'letsencrypt.acme', + 'letsencrypt.acme.jose', 'letsencrypt.client', - 'letsencrypt.client.apache', 'letsencrypt.client.display', + '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', - 'letsencrypt.client.tests.apache', 'letsencrypt.client.tests.display', 'letsencrypt.scripts', ], @@ -107,6 +128,15 @@ setup( entry_points={ 'console_scripts': [ 'letsencrypt = letsencrypt.scripts.main:main', + 'jws = letsencrypt.acme.jose.jws:CLI.run', + ], + 'letsencrypt.authenticators': [ + '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 bf609a747..47b509203 100644 --- a/tox.ini +++ b/tox.ini @@ -12,12 +12,14 @@ commands = setenv = PYTHONPATH = {toxinidir} + PYTHONHASHSEED = 0 +# https://testrun.org/tox/latest/example/basic.html#special-handling-of-pythonhas [testenv:cover] basepython = python2.7 commands = pip install -e .[testing] - python setup.py nosetests --with-coverage --cover-min-percentage=85 + python setup.py nosetests --with-coverage --cover-min-percentage=89 [testenv:lint] # recent versions of pylint do not support Python 2.6 (#97, #187)