Merge remote-tracking branch 'github/letsencrypt/master' into get_sans

Conflicts:
	letsencrypt/client/crypto_util.py
	letsencrypt/client/tests/crypto_util_test.py
	setup.py
This commit is contained in:
Jakub Warmuz 2015-05-10 09:40:25 +00:00
commit dc0f78dd15
No known key found for this signature in database
GPG key ID: 2A7BAD3A489B52EA
207 changed files with 12111 additions and 2986 deletions

5
.gitignore vendored
View file

@ -1,8 +1,13 @@
*.pyc
*.egg-info
.eggs/
build/
dist/
venv/
.tox/
.coverage
m3
*~
.vagrant
*.swp
\#*#

View file

@ -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.
@ -192,7 +193,7 @@ additional-builtins=
[SIMILARITIES]
# Minimum lines number of a similarity.
min-similarity-lines=4
min-similarity-lines=6
# Ignore comments when computing similarities.
ignore-comments=yes
@ -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

View file

@ -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

18
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,18 @@
<!---
This file serves as an entry point for GitHub's Contributing
Guidelines [1] only.
GitHub doesn't render rST very well, especially in respect to internal
hyperlink targets and cross-references [2]. People also tend to
confuse rST and Markdown syntax. Therefore, instead of keeping the
contents here (and including from rST documentation under doc/), link
to the Sphinx generated docs is provided below.
[1] https://github.com/blog/1184-contributing-guidelines
[2] http://docutils.sourceforge.net/docs/user/rst/quickref.html#hyperlink-targets
-->
https://letsencrypt.readthedocs.org/en/latest/contributing.html

View file

@ -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.

5
EULA
View file

@ -1,5 +0,0 @@
This is a PREVIEW RELEASE of a client application for the Let's Encrypt certificate authority and other services using the ACME protocol. The Let's Encrypt certificate authority is NOT YET ISSUING CERTIFICATES TO THE PUBLIC.
Until publicly-trusted certificates can be issued by Let's Encrypt, this software CANNOT OBTAIN A PUBLICLY-TRUSTED CERTIFICATE FOR YOUR WEB SERVER. You should only use this program if you are a developer interested in experimenting with the ACME protocol or in helping to improve this software. If you want to configure your web site with HTTPS in the meantime, please obtain a certificate from a different authority.
For updates on the status of Let's Encrypt, please visit the Let's Encrypt home page at https://letsencrypt.org/.

1
EULA Symbolic link
View file

@ -0,0 +1 @@
letsencrypt/EULA

View file

@ -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.

View file

@ -1,7 +1,8 @@
include README.rst
include CHANGES.rst
include CONTRIBUTING.rst
include CONTRIBUTING.md
include linter_plugin.py
include letsencrypt/EULA
recursive-include letsencrypt *.json
recursive-include letsencrypt *.conf
recursive-include letsencrypt/client/tests/testdata *

View file

@ -21,7 +21,7 @@ All you need to do is:
::
user@www:~$ sudo letsencrypt www.example.org
user@www:~$ sudo letsencrypt -d www.example.org
**Encrypt ALL the things!**
@ -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

30
Vagrantfile vendored Normal file
View file

@ -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 = <<SETUP_SCRIPT
cd /vagrant
sudo ./bootstrap/ubuntu.sh
if [ ! -d "venv" ]; then
virtualenv --no-site-packages -p python2 venv
./venv/bin/python setup.py dev
fi
SETUP_SCRIPT
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.define "ubuntu-trusty", primary: true do |ubuntu_trusty|
ubuntu_trusty.vm.box = "ubuntu/trusty64"
ubuntu_trusty.vm.provision "shell", inline: $ubuntu_setup_script
ubuntu_trusty.vm.provider "virtualbox" do |v|
# VM needs more memory to run test suite, got "OSError: [Errno 12]
# Cannot allocate memory" when running
# letsencrypt.client.tests.display.util_test.NcursesDisplayTest
v.memory = 1024
end
end
end

2
bootstrap/README Normal file
View file

@ -0,0 +1,2 @@
This directory contains scripts that install necessary OS-specific
prerequisite dependencies (see docs/using.rst).

35
bootstrap/_deb_common.sh Executable file
View file

@ -0,0 +1,35 @@
#!/bin/sh
# Tested with:
# - Ubuntu:
# - 12.04 (x64, Travis)
# - 14.04 (x64, Vagrant)
# - 14.10 (x64)
# - Debian:
# - 6.0.10 "squeeze" (x64)
# - 7.8 "wheezy" (x64)
# - 8.0 "jessie" (x64)
# virtualenv binary can be found in different packages depending on
# distro version (#346)
distro=$(lsb_release -si)
# 6.0.10 => 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

1
bootstrap/debian.sh Symbolic link
View file

@ -0,0 +1 @@
_deb_common.sh

2
bootstrap/mac.sh Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
brew install augeas swig

1
bootstrap/ubuntu.sh Symbolic link
View file

@ -0,0 +1 @@
_deb_common.sh

View file

@ -1,5 +0,0 @@
:mod:`letsencrypt.acme.errors`
------------------------------
.. automodule:: letsencrypt.acme.errors
:members:

61
docs/api/acme/index.rst Normal file
View file

@ -0,0 +1,61 @@
:mod:`letsencrypt.acme`
=======================
.. contents::
.. automodule:: letsencrypt.acme
:members:
Messages
--------
v00
~~~
.. automodule:: letsencrypt.acme.messages
:members:
v02
~~~
.. automodule:: letsencrypt.acme.messages2
:members:
Challenges
----------
.. automodule:: letsencrypt.acme.challenges
:members:
Other ACME objects
------------------
.. automodule:: letsencrypt.acme.other
:members:
Fields
------
.. automodule:: letsencrypt.acme.fields
:members:
Errors
------
.. automodule:: letsencrypt.acme.errors
:members:
:members:
Utilities
---------
.. automodule:: letsencrypt.acme.util
:members:

View file

@ -1,5 +0,0 @@
:mod:`letsencrypt.acme.interfaces`
----------------------------------
.. automodule:: letsencrypt.acme.interfaces
:members:

View file

@ -1,5 +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:

View file

@ -1,5 +0,0 @@
:mod:`letsencrypt.acme.messages`
--------------------------------
.. automodule:: letsencrypt.acme.messages
:members:

View file

@ -1,5 +0,0 @@
:mod:`letsencrypt.acme.other`
-----------------------------
.. automodule:: letsencrypt.acme.other
:members:

View file

@ -1,5 +0,0 @@
:mod:`letsencrypt.acme.util`
----------------------------
.. automodule:: letsencrypt.acme.util
:members:

View file

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

View file

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

View file

@ -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:

View file

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

View file

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

View file

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

View file

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

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

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

View file

@ -54,6 +54,9 @@ extensions = [
'repoze.sphinx.autointerface',
]
autodoc_member_order = 'bysource'
autodoc_default_flags = ['show-inheritance', 'private-members']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@ -98,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

203
docs/contributing.rst Normal file
View file

@ -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 <using>`.
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
<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.

View file

@ -6,7 +6,8 @@ Welcome to the Let's Encrypt client documentation!
intro
using
project
contributing
plugins
.. toctree::
:maxdepth: 1

19
docs/plugins.rst Normal file
View file

@ -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

View file

@ -1,5 +0,0 @@
================================
The Let's Encrypt Client Project
================================
.. include:: ../CONTRIBUTING.rst

View file

@ -5,46 +5,56 @@ 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 <hacking>` 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 <hacking>` reference.
In general:
* ``sudo`` is required as a suggested way of running privileged process
* `swig`_ is required for compiling `m2crypto`_
* `augeas`_ is required for the ``python-augeas`` bindings
.. _Debian is known to fail: https://github.com/letsencrypt/lets-encrypt-preview/issues/68
Ubuntu
------
::
.. code-block:: shell
sudo apt-get install python python-setuptools python-virtualenv python-dev \
gcc swig dialog libaugeas0 libssl-dev libffi-dev \
ca-certificates
sudo ./bootstrap/ubuntu.sh
Debian
------
.. code-block:: shell
sudo ./bootstrap/debian.sh
For squezze you will need to:
- Use ``virtualenv --no-site-packages -p python`` instead of ``-p python2``.
.. _`#280`: https://github.com/letsencrypt/lets-encrypt-preview/issues/280
.. Please keep the above command in sync with .travis.yml (before_install)
Mac OSX
-------
::
.. code-block:: shell
sudo brew install augeas swig
sudo ./bootstrap/mac.sh
Installation
============
::
.. 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
@ -52,7 +62,7 @@ Usage
The letsencrypt commandline tool has a builtin help:
::
.. code-block:: shell
./venv/bin/letsencrypt --help

View file

@ -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

16
examples/plugins/setup.py Normal file
View file

@ -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',
],
},
)

42
examples/restified.py Normal file
View file

@ -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

View file

@ -1 +0,0 @@
letsencrypt/scripts/main.py

5
letsencrypt/EULA Normal file
View file

@ -0,0 +1,5 @@
This is a PREVIEW RELEASE of a client application for the Let's Encrypt certificate authority and other services using the ACME protocol. The Let's Encrypt certificate authority is NOT YET ISSUING CERTIFICATES TO THE PUBLIC.
Until publicly-trusted certificates can be issued by Let's Encrypt, this software CANNOT OBTAIN A PUBLICLY-TRUSTED CERTIFICATE FOR YOUR WEB SERVER. You should only use this program if you are a developer interested in experimenting with the ACME protocol or in helping to improve this software. If you want to configure your web site with HTTPS in the meantime, please obtain a certificate from a different authority.
For updates on the status of Let's Encrypt, please visit the Let's Encrypt home page at https://letsencrypt.org/.

View file

@ -1 +1,12 @@
"""ACME protocol implementation."""
"""ACME protocol implementation.
This module is an implementation of the `ACME protocol`_. Latest
supported version: `v02`_.
.. _`ACME protocol`: https://github.com/letsencrypt/acme-spec
.. _`v02`:
https://github.com/letsencrypt/acme-spec/commit/d328fea2d507deb9822793c512830d827a4150c4
"""

View file

@ -0,0 +1,252 @@
"""ACME Identifier Validation Challenges."""
import binascii
import functools
import hashlib
import Crypto.Random
from letsencrypt.acme import jose
from letsencrypt.acme import other
# pylint: disable=too-few-public-methods
class Challenge(jose.TypedJSONObjectWithFields):
# _fields_to_partial_json | pylint: disable=abstract-method
"""ACME challenge."""
TYPES = {}
class ContinuityChallenge(Challenge): # pylint: disable=abstract-method
"""Client validation challenges."""
class DVChallenge(Challenge): # pylint: disable=abstract-method
"""Domain validation challenges."""
class ChallengeResponse(jose.TypedJSONObjectWithFields):
# _fields_to_partial_json | pylint: disable=abstract-method
"""ACME challenge response."""
TYPES = {}
@classmethod
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_json(jobj)
@Challenge.register
class SimpleHTTPS(DVChallenge):
"""ACME "simpleHttps" challenge."""
typ = "simpleHttps"
token = jose.Field("token")
@ChallengeResponse.register
class SimpleHTTPSResponse(ChallengeResponse):
"""ACME "simpleHttps" challenge response."""
typ = "simpleHttps"
path = jose.Field("path")
URI_TEMPLATE = "https://{domain}/.well-known/acme-challenge/{path}"
"""URI template for HTTPS server provisioned resource."""
def uri(self, domain):
"""Create an URI to the provisioned resource.
Forms an URI to the HTTPS server provisioned resource (containing
:attr:`~SimpleHTTPS.token`) by populating the :attr:`URI_TEMPLATE`.
:param str domain: Domain name being verified.
"""
return self.URI_TEMPLATE.format(domain=domain, path=self.path)
@Challenge.register
class DVSNI(DVChallenge):
"""ACME "dvsni" challenge.
:ivar str r: Random data, **not** base64-encoded.
:ivar str nonce: Random data, **not** hex-encoded.
"""
typ = "dvsni"
DOMAIN_SUFFIX = ".acme.invalid"
"""Domain name suffix."""
R_SIZE = 32
"""Required size of the :attr:`r` in bytes."""
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
@ChallengeResponse.register
class DVSNIResponse(ChallengeResponse):
"""ACME "dvsni" challenge response.
:param str s: Random data, **not** base64-encoded.
"""
typ = "dvsni"
DOMAIN_SUFFIX = DVSNI.DOMAIN_SUFFIX
"""Domain name suffix."""
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)
def z(self, chall): # pylint: disable=invalid-name
"""Compute the parameter ``z``.
:param challenge: Corresponding challenge.
:type challenge: :class:`DVSNI`
"""
z = hashlib.new("sha256") # pylint: disable=invalid-name
z.update(chall.r)
z.update(self.s)
return z.hexdigest()
def z_domain(self, chall):
"""Domain name for certificate subjectAltName."""
return self.z(chall) + self.DOMAIN_SUFFIX
@Challenge.register
class RecoveryContact(ContinuityChallenge):
"""ACME "recoveryContact" challenge."""
typ = "recoveryContact"
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."""
typ = "recoveryContact"
token = jose.Field("token", omitempty=True)
@Challenge.register
class RecoveryToken(ContinuityChallenge):
"""ACME "recoveryToken" challenge."""
typ = "recoveryToken"
@ChallengeResponse.register
class RecoveryTokenResponse(ChallengeResponse):
"""ACME "recoveryToken" challenge response."""
typ = "recoveryToken"
token = jose.Field("token", omitempty=True)
@Challenge.register
class ProofOfPossession(ContinuityChallenge):
"""ACME "proofOfPossession" challenge.
:ivar str nonce: Random data, **not** base64-encoded.
:ivar hints: Various clues for the client (:class:`Hints`).
"""
typ = "proofOfPossession"
NONCE_SIZE = 16
class Hints(jose.JSONObjectWithFields):
"""Hints for "proofOfPossession" challenge.
:ivar jwk: JSON Web Key (:class:`letsencrypt.acme.jose.JWK`)
:ivar list certs: List of :class:`letsencrypt.acme.jose.ComparableX509`
certificates.
"""
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=())
@certs.encoder
def certs(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(jose.encode_cert(cert) for cert in value)
@certs.decoder
def certs(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(jose.decode_cert(cert) for cert in value)
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
class ProofOfPossessionResponse(ChallengeResponse):
"""ACME "proofOfPossession" challenge response.
:ivar str nonce: Random data, **not** base64-encoded.
:ivar signature: :class:`~letsencrypt.acme.other.Signature` of this message.
"""
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)
@Challenge.register
class DNS(DVChallenge):
"""ACME "dns" challenge."""
typ = "dns"
token = jose.Field("token")
@ChallengeResponse.register
class DNSResponse(ChallengeResponse):
"""ACME "dns" challenge response."""
typ = "dns"

View file

@ -0,0 +1,458 @@
"""Tests for letsencrypt.acme.challenges."""
import os
import pkg_resources
import unittest
import Crypto.PublicKey.RSA
import M2Crypto
from letsencrypt.acme import jose
from letsencrypt.acme import other
CERT = jose.ComparableX509(M2Crypto.X509.load_cert(
pkg_resources.resource_filename(
'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):
def setUp(self):
from letsencrypt.acme.challenges import SimpleHTTPS
self.msg = SimpleHTTPS(
token='evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA')
self.jmsg = {
'type': 'simpleHttps',
'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA',
}
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import SimpleHTTPS
self.assertEqual(self.msg, SimpleHTTPS.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import SimpleHTTPS
hash(SimpleHTTPS.from_json(self.jmsg))
class SimpleHTTPSResponseTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.challenges import SimpleHTTPSResponse
self.msg = SimpleHTTPSResponse(path='6tbIMBC5Anhl5bOlWT5ZFA')
self.jmsg = {
'type': 'simpleHttps',
'path': '6tbIMBC5Anhl5bOlWT5ZFA',
}
def test_uri(self):
self.assertEqual('https://example.com/.well-known/acme-challenge/'
'6tbIMBC5Anhl5bOlWT5ZFA', self.msg.uri('example.com'))
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import SimpleHTTPSResponse
self.assertEqual(
self.msg, SimpleHTTPSResponse.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import SimpleHTTPSResponse
hash(SimpleHTTPSResponse.from_json(self.jmsg))
class DVSNITest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.challenges import DVSNI
self.msg = DVSNI(
r="O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6"
"\xbf'\xb3\xed\x9a9nX\x0f'\\m\xe7\x12",
nonce='\xa8-_\xf8\xeft\r\x12\x88\x1fm<"w\xab.')
self.jmsg = {
'type': 'dvsni',
'r': 'Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI',
'nonce': 'a82d5ff8ef740d12881f6d3c2277ab2e',
}
def test_nonce_domain(self):
self.assertEqual('a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid',
self.msg.nonce_domain)
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import DVSNI
self.assertEqual(self.msg, DVSNI.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import DVSNI
hash(DVSNI.from_json(self.jmsg))
def test_from_json_invalid_r_length(self):
from letsencrypt.acme.challenges import DVSNI
self.jmsg['r'] = 'abcd'
self.assertRaises(
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(
jose.DeserializationError, DVSNI.from_json, self.jmsg)
class DVSNIResponseTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.challenges import DVSNIResponse
self.msg = DVSNIResponse(
s='\xf5\xd6\xe3\xb2]\xe0L\x0bN\x9cKJ\x14I\xa1K\xa3#\xf9\xa8'
'\xcd\x8c7\x0e\x99\x19)\xdc\xb7\xf3\x9bw')
self.jmsg = {
'type': 'dvsni',
's': '9dbjsl3gTAtOnEtKFEmhS6Mj-ajNjDcOmRkp3Lfzm3c',
}
def test_z_and_domain(self):
from letsencrypt.acme.challenges import DVSNI
challenge = DVSNI(
r="O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6"
"\xbf'\xb3\xed\x9a9nX\x0f'\\m\xe7\x12",
nonce=long('439736375371401115242521957580409149254868992063'
'44333654741504362774620418661L'))
# pylint: disable=invalid-name
z = '38e612b0397cc2624a07d351d7ef50e46134c0213d9ed52f7d7c611acaeed41b'
self.assertEqual(z, self.msg.z(challenge))
self.assertEqual(
'{0}.acme.invalid'.format(z), self.msg.z_domain(challenge))
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import DVSNIResponse
self.assertEqual(self.msg, DVSNIResponse.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import DVSNIResponse
hash(DVSNIResponse.from_json(self.jmsg))
class RecoveryContactTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.challenges import RecoveryContact
self.msg = RecoveryContact(
activation_url='https://example.ca/sendrecovery/a5bd99383fb0',
success_url='https://example.ca/confirmrecovery/bb1b9928932',
contact='c********n@example.com')
self.jmsg = {
'type': 'recoveryContact',
'activationURL' : 'https://example.ca/sendrecovery/a5bd99383fb0',
'successURL' : 'https://example.ca/confirmrecovery/bb1b9928932',
'contact' : 'c********n@example.com',
}
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import RecoveryContact
self.assertEqual(self.msg, RecoveryContact.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import RecoveryContact
hash(RecoveryContact.from_json(self.jmsg))
def test_json_without_optionals(self):
del self.jmsg['activationURL']
del self.jmsg['successURL']
del self.jmsg['contact']
from letsencrypt.acme.challenges import RecoveryContact
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_partial_json())
class RecoveryContactResponseTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.challenges import RecoveryContactResponse
self.msg = RecoveryContactResponse(token='23029d88d9e123e')
self.jmsg = {'type': 'recoveryContact', 'token': '23029d88d9e123e'}
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import RecoveryContactResponse
self.assertEqual(
self.msg, RecoveryContactResponse.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import RecoveryContactResponse
hash(RecoveryContactResponse.from_json(self.jmsg))
def test_json_without_optionals(self):
del self.jmsg['token']
from letsencrypt.acme.challenges import RecoveryContactResponse
msg = RecoveryContactResponse.from_json(self.jmsg)
self.assertTrue(msg.token is None)
self.assertEqual(self.jmsg, msg.to_partial_json())
class RecoveryTokenTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.challenges import RecoveryToken
self.msg = RecoveryToken()
self.jmsg = {'type': 'recoveryToken'}
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import RecoveryToken
self.assertEqual(self.msg, RecoveryToken.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import RecoveryToken
hash(RecoveryToken.from_json(self.jmsg))
class RecoveryTokenResponseTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.challenges import RecoveryTokenResponse
self.msg = RecoveryTokenResponse(token='23029d88d9e123e')
self.jmsg = {'type': 'recoveryToken', 'token': '23029d88d9e123e'}
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import RecoveryTokenResponse
self.assertEqual(
self.msg, RecoveryTokenResponse.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import RecoveryTokenResponse
hash(RecoveryTokenResponse.from_json(self.jmsg))
def test_json_without_optionals(self):
del self.jmsg['token']
from letsencrypt.acme.challenges import RecoveryTokenResponse
msg = RecoveryTokenResponse.from_json(self.jmsg)
self.assertTrue(msg.token is None)
self.assertEqual(self.jmsg, msg.to_partial_json())
class ProofOfPossessionHintsTest(unittest.TestCase):
def setUp(self):
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 = (
'93416768eb85e33adc4277f4c9acd63e7418fcfe',
'16d95b7b63f1972b980b14c20291f3c0d1855d95',
'48b46570d9fc6358108af43ad1649484def0debf',
)
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,
authorized_for=authorized_for, serial_numbers=serial_numbers)
self.jmsg_to = {
'jwk': jwk,
'certFingerprints': cert_fingerprints,
'certs': (jose.b64encode(CERT.as_der()),),
'subjectKeyIdentifiers': subject_key_identifiers,
'serialNumbers': serial_numbers,
'issuers': issuers,
'authorizedFor': authorized_for,
}
self.jmsg_from = self.jmsg_to.copy()
self.jmsg_from.update({'jwk': jwk.to_json()})
def test_to_partial_json(self):
self.assertEqual(self.jmsg_to, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import ProofOfPossession
self.assertEqual(
self.msg, ProofOfPossession.Hints.from_json(self.jmsg_from))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import ProofOfPossession
hash(ProofOfPossession.Hints.from_json(self.jmsg_from))
def test_json_without_optionals(self):
for optional in ['certFingerprints', 'certs', 'subjectKeyIdentifiers',
'serialNumbers', 'issuers', 'authorizedFor']:
del self.jmsg_from[optional]
del self.jmsg_to[optional]
from letsencrypt.acme.challenges import ProofOfPossession
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(self.jmsg_to, msg.to_partial_json())
class ProofOfPossessionTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.challenges import ProofOfPossession
hints = ProofOfPossession.Hints(
jwk=jose.JWKRSA(key=KEY.publickey()), cert_fingerprints=(),
certs=(), serial_numbers=(), subject_key_identifiers=(),
issuers=(), authorized_for=())
self.msg = ProofOfPossession(
alg=jose.RS256, hints=hints,
nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ')
self.jmsg_to = {
'type': 'proofOfPossession',
'alg': jose.RS256,
'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
'hints': hints,
}
self.jmsg_from = {
'type': 'proofOfPossession',
'alg': jose.RS256.to_json(),
'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
'hints': hints.to_json(),
}
def test_to_partial_json(self):
self.assertEqual(self.jmsg_to, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import ProofOfPossession
self.assertEqual(
self.msg, ProofOfPossession.from_json(self.jmsg_from))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import ProofOfPossession
hash(ProofOfPossession.from_json(self.jmsg_from))
class ProofOfPossessionResponseTest(unittest.TestCase):
def setUp(self):
# acme-spec uses a confusing example in which both signature
# nonce and challenge nonce are the same, don't make the same
# mistake here...
signature = other.Signature(
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'
'\x99\x08\xf0\x0e{',
nonce='\x99\xc7Q\xb3f2\xbc\xdci\xfe\xd6\x98k\xc67\xdf',
)
from letsencrypt.acme.challenges import ProofOfPossessionResponse
self.msg = ProofOfPossessionResponse(
nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ',
signature=signature)
self.jmsg_to = {
'type': 'proofOfPossession',
'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
'signature': signature,
}
self.jmsg_from = {
'type': 'proofOfPossession',
'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
'signature': signature.to_json(),
}
def test_verify(self):
self.assertTrue(self.msg.verify())
def test_to_partial_json(self):
self.assertEqual(self.jmsg_to, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import ProofOfPossessionResponse
self.assertEqual(
self.msg, ProofOfPossessionResponse.from_json(self.jmsg_from))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import ProofOfPossessionResponse
hash(ProofOfPossessionResponse.from_json(self.jmsg_from))
class DNSTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.challenges import DNS
self.msg = DNS(token='17817c66b60ce2e4012dfad92657527a')
self.jmsg = {'type': 'dns', 'token': '17817c66b60ce2e4012dfad92657527a'}
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import DNS
self.assertEqual(self.msg, DNS.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import DNS
hash(DNS.from_json(self.jmsg))
class DNSResponseTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.challenges import DNSResponse
self.msg = DNSResponse()
self.jmsg = {'type': 'dns'}
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import DNSResponse
self.assertEqual(self.msg, DNSResponse.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import DNSResponse
hash(DNSResponse.from_json(self.jmsg))
if __name__ == '__main__':
unittest.main()

View file

@ -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 message validation error."""
class UnrecognizedMessageTypeError(ValidationError):
"""Unrecognized ACME message type error."""
class SchemaValidationError(ValidationError):
"""JSON schema ACME message validation error."""
class SchemaValidationError(jose_errors.DeserializationError):
"""JSON schema ACME object validation error."""

View file

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

View file

@ -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, '')

View file

@ -1,22 +0,0 @@
"""ACME interfaces."""
import zope.interface
# pylint: disable=no-self-argument,no-method-argument,no-init,inherit-non-class
class IJSONSerializable(zope.interface.Interface):
# pylint: disable=too-few-public-methods
"""JSON serializable object."""
def to_json():
"""Prepare JSON serializable object.
:returns: JSON object ready to be serialized. Note, however, that
this 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.
:rtype: dict
"""

View file

@ -1,100 +0,0 @@
"""JOSE."""
import base64
import binascii
import Crypto.PublicKey.RSA
from letsencrypt.acme import util
def _leading_zeros(arg):
if len(arg) % 2:
return '0' + arg
return arg
class JWK(util.JSONDeSerializable, util.ImmutableMap):
# pylint: disable=too-few-public-methods
"""JSON Web Key.
.. todo:: Currently works for RSA public keys only.
"""
__slots__ = ('key',)
schema = util.load_schema('jwk')
@classmethod
def _encode_param(cls, param):
"""Encode numeric key parameter."""
return b64encode(binascii.unhexlify(
_leading_zeros(hex(param)[2:].rstrip('L'))))
@classmethod
def _decode_param(cls, param):
"""Decode numeric key parameter."""
return long(binascii.hexlify(b64decode(param)), 16)
def to_json(self):
"""Serialize to JSON."""
return {
'kty': 'RSA', # TODO
'n': self._encode_param(self.key.n),
'e': self._encode_param(self.key.e),
}
@classmethod
def _from_valid_json(cls, jobj):
assert 'RSA' == jobj['kty'] # TODO
return cls(key=Crypto.PublicKey.RSA.construct(
(cls._decode_param(jobj['n']), cls._decode_param(jobj['e']))))
# https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-37#appendix-C
#
# Jose Base64:
#
# - URL-safe Base64
#
# - padding stripped
def b64encode(data):
"""JOSE Base64 encode.
:param data: Data to be encoded.
:type data: str or bytearray
:returns: JOSE Base64 string.
:rtype: str
:raises TypeError: if `data` is of incorrect type
"""
if not isinstance(data, str):
raise TypeError('argument should be str or bytearray')
return base64.urlsafe_b64encode(data).rstrip('=')
def b64decode(data):
"""JOSE Base64 decode.
:param data: Base64 string to be decoded. If it's unicode, then
only ASCII characters are allowed.
:type data: str or unicode
:returns: Decoded data.
:raises TypeError: if input is of incorrect type
:raises ValueError: if input is unicode with non-ASCII characters
"""
if isinstance(data, unicode):
try:
data = data.encode('ascii')
except UnicodeEncodeError:
raise ValueError(
'unicode argument should contain only ASCII characters')
elif not isinstance(data, str):
raise TypeError('argument should be a str or unicode')
return base64.urlsafe_b64decode(data + '=' * (4 - (len(data) % 4)))

View file

@ -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,
)

View file

@ -0,0 +1,58 @@
"""JOSE Base64.
`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):
"""JOSE Base64 encode.
:param data: Data to be encoded.
:type data: str or bytearray
:returns: JOSE Base64 string.
:rtype: str
:raises TypeError: if `data` is of incorrect type
"""
if not isinstance(data, str):
raise TypeError('argument should be str or bytearray')
return base64.urlsafe_b64encode(data).rstrip('=')
def b64decode(data):
"""JOSE Base64 decode.
:param data: Base64 string to be decoded. If it's unicode, then
only ASCII characters are allowed.
:type data: str or unicode
:returns: Decoded data.
:raises TypeError: if input is of incorrect type
:raises ValueError: if input is unicode with non-ASCII characters
"""
if isinstance(data, unicode):
try:
data = data.encode('ascii')
except UnicodeEncodeError:
raise ValueError(
'unicode argument should contain only ASCII characters')
elif not isinstance(data, str):
raise TypeError('argument should be a str or unicode')
return base64.urlsafe_b64decode(data + '=' * (4 - (len(data) % 4)))

View file

@ -1,54 +1,6 @@
"""Tests for letsencrypt.acme.jose."""
import pkg_resources
"""Tests for letsencrypt.acme.jose.b64."""
import unittest
import Crypto.PublicKey.RSA
RSA256_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
'letsencrypt.client.tests', 'testdata/rsa256_key.pem'))
RSA512_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
'letsencrypt.client.tests', 'testdata/rsa512_key.pem'))
class JWKTest(unittest.TestCase):
"""Tests fro letsencrypt.acme.jose.JWK."""
def setUp(self):
from letsencrypt.acme.jose import JWK
self.jwk256 = JWK(key=RSA256_KEY.publickey())
self.jwk256json = {
'kty': 'RSA',
'e': 'AQAB',
'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5'
'80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q',
}
self.jwk512 = JWK(key=RSA512_KEY.publickey())
self.jwk512json = {
'kty': 'RSA',
'e': 'AQAB',
'n': '9LYRcVE3Nr-qleecEcX8JwVDnjeG1X7ucsCasuuZM0e09c'
'mYuUzxIkMjO_9x4AVcvXXRXPEV-LzWWkfkTlzRMw',
}
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_to_json(self):
self.assertEqual(self.jwk256.to_json(), self.jwk256json)
self.assertEqual(self.jwk512.to_json(), self.jwk512json)
def test_from_json(self):
from letsencrypt.acme.jose 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))
# https://en.wikipedia.org/wiki/Base64#Examples
B64_PADDING_EXAMPLES = {
@ -67,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):
@ -87,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):

View file

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

View file

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

View file

@ -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
<conversion-table>` 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 <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
<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 <cls> 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')

View file

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

View file

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

View file

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

View file

@ -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'))

View file

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

View file

@ -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),
}

View file

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

View file

@ -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

View file

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

10
letsencrypt/acme/jose/testdata/README vendored Normal file
View file

@ -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])'

10
letsencrypt/acme/jose/testdata/csr2.pem vendored Normal file
View file

@ -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-----

View file

@ -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-----

View file

@ -0,0 +1,6 @@
-----BEGIN RSA PRIVATE KEY-----
MIGrAgEAAiEAm2Fylv+Uz7trgTW8EBHP3FQSMeZs2GNQ6VRo1sIVJEkCAwEAAQIh
AJT0BA/xD01dFCAXzSNyj9nfSZa3NpqzJZZn/eOm7vghAhEAzUVNZn4lLLBD1R6N
E8TKNQIRAMHHyn3O5JeY36lwKwkUlEUCEAliRauN0L0+QZuYjfJ9aJECEGx4dru3
rTPCyighdqWNlHUCEQCiLjlwSRtWgmMBudCkVjzt
-----END RSA PRIVATE KEY-----

View file

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

View file

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

View file

@ -1,191 +1,162 @@
"""ACME protocol messages."""
import M2Crypto
import zope.interface
"""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
from letsencrypt.acme import errors
from letsencrypt.acme import interfaces
from letsencrypt.acme import jose
from letsencrypt.acme import other
from letsencrypt.acme import util
class Message(util.JSONDeSerializable, util.ImmutableMap):
"""ACME message.
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"
Messages are considered immutable.
schema = NotImplemented
"""JSON schema the object is tested against in :meth:`from_json`.
Subclasses must overrride it with a value that is acceptable by
:func:`jsonschema.validate`, most probably using
:func:`letsencrypt.acme.util.load_schema`.
"""
zope.interface.implements(interfaces.IJSONSerializable)
acme_type = NotImplemented
"""ACME message "type" field. Subclasses must override."""
TYPES = {}
"""Message 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 from_json(cls, jobj):
"""Deserialize from (possibly invalid) JSON object.
def to_json(self):
"""Get JSON serializable object.
Note that the input ``jobj`` has not been sanitized in any way.
:returns: Serializable JSON object representing ACME message.
:meth:`validate` will almost certainly not work, due to reasons
explained in :class:`letsencrypt.acme.interfaces.IJSONSerializable`.
:rtype: dict
:param jobj: JSON object.
:raises letsencrypt.acme.errors.SchemaValidationError: if the input
JSON object could not be validated against JSON schema specified
in :attr:`schema`.
:raises letsencrypt.acme.jose.errors.DeserializationError: for any
other generic error in decoding.
:returns: instance of the class
"""
jobj = self._fields_to_json()
jobj["type"] = self.acme_type
return jobj
msg_cls = cls.get_type_cls(jobj)
def _fields_to_json(self):
"""Prepare ACME message fields for JSON serialiazation.
Subclasses must override this method.
:returns: Serializable JSON object containg all ACME message fields
apart from "type".
:rtype: dict
"""
raise NotImplementedError()
@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))
# TODO: is that schema testing still relevant?
try:
msg_type = jobj["type"]
except KeyError:
raise errors.ValidationError("missing type field")
jsonschema.validate(jobj, msg_cls.schema)
except jsonschema.ValidationError as error:
raise errors.SchemaValidationError(error)
try:
msg_cls = cls.TYPES[msg_type]
except KeyError:
raise errors.UnrecognizedMessageTypeError(msg_type)
return msg_cls
@classmethod
def from_json(cls, jobj, validate=True):
"""Deserialize validated ACME message from JSON string.
:param str jobj: JSON object.
:param bool validate: Validate against schema before deserializing.
Useful if :class:`JWK` is part of already validated json object.
:raises letsencrypt.acme.errors.ValidationError: if validation
was unsuccessful
:returns: Valid ACME message.
:rtype: subclass of :class:`Message`
"""
msg_cls = cls.get_msg_cls(jobj)
if validate:
msg_cls.validate_json(jobj)
# pylint: disable=protected-access
return msg_cls._from_valid_json(jobj)
return super(Message, cls).from_json(jobj)
@Message.register # pylint: disable=too-few-public-methods
class Challenge(Message):
"""ACME "challenge" message."""
acme_type = "challenge"
schema = util.load_schema(acme_type)
__slots__ = ("session_id", "nonce", "challenges", "combinations")
"""ACME "challenge" message.
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
:ivar str nonce: Random data, **not** base64-encoded.
:ivar list challenges: List of
:class:`~letsencrypt.acme.challenges.Challenge` objects.
@classmethod
def _from_valid_json(cls, jobj):
return cls(session_id=jobj["sessionID"],
nonce=jose.b64decode(jobj["nonce"]),
challenges=jobj["challenges"],
combinations=jobj.get("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?
"""
typ = "challenge"
schema = util.load_schema(typ)
session_id = jose.Field("sessionID")
nonce = jose.Field("nonce", encoder=jose.b64encode,
decoder=jose.decode_b64jose)
challenges = jose.Field("challenges")
combinations = jose.Field("combinations", omitempty=True, default=())
@challenges.decoder
def challenges(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(challenges.Challenge.from_json(chall) for chall in value)
@property
def resolved_combinations(self):
"""Combinations with challenges instead of indices."""
return tuple(tuple(self.challenges[idx] for idx in combo)
for combo in self.combinations)
@Message.register # pylint: disable=too-few-public-methods
class ChallengeRequest(Message):
"""ACME "challengeRequest" message.
:ivar str identifier: Domain name.
"""
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"])
"""ACME "challengeRequest" message."""
typ = "challengeRequest"
schema = util.load_schema(typ)
identifier = jose.Field("identifier")
@Message.register # pylint: disable=too-few-public-methods
class Authorization(Message):
"""ACME "authorization" message."""
acme_type = "authorization"
schema = util.load_schema(acme_type)
__slots__ = ("recovery_token", "identifier", "jwk")
"""ACME "authorization" message.
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
:ivar jwk: :class:`letsencrypt.acme.jose.JWK`
@classmethod
def _from_valid_json(cls, jobj):
jwk = jobj.get("jwk")
if jwk is not None:
jwk = jose.JWK.from_json(jwk, validate=False)
return cls(recovery_token=jobj.get("recoveryToken"),
identifier=jobj.get("identifier"), jwk=jwk)
"""
typ = "authorization"
schema = util.load_schema(typ)
recovery_token = jose.Field("recoveryToken", omitempty=True)
identifier = jose.Field("identifier", omitempty=True)
jwk = jose.Field("jwk", decoder=jose.JWK.from_json, omitempty=True)
@Message.register
class AuthorizationRequest(Message):
"""ACME "authorizationRequest" message.
:ivar str session_id: "sessionID" from the server challenge
:ivar str nonce: Nonce from the server challenge
:ivar list responses: List of completed challenges
:ivar str nonce: Random data from the corresponding
:attr:`Challenge.nonce`, **not** base64-encoded.
:ivar list responses: List of completed challenges (
:class:`letsencrypt.acme.challenges.ChallengeResponse`).
:ivar signature: Signature (:class:`letsencrypt.acme.other.Signature`).
:ivar contact: TODO
"""
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):
@ -207,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.
@ -222,28 +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=jose.b64decode(jobj["nonce"]),
responses=jobj["responses"],
signature=other.Signature.from_json(
jobj["signature"], validate=False),
contact=jobj.get("contact", []))
@Message.register # pylint: disable=too-few-public-methods
class Certificate(Message):
@ -256,33 +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": self._encode_cert(self.certificate)}
if self.chain:
fields["chain"] = [self._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 _decode_cert(cls, b64der):
return util.ComparableX509(M2Crypto.X509.load_cert_der_string(
jose.b64decode(b64der)))
@chain.decoder
def chain(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(jose.decode_cert(cert) for cert in value)
@classmethod
def _encode_cert(cls, cert):
return jose.b64encode(cert.as_der())
@classmethod
def _from_valid_json(cls, jobj):
return cls(certificate=cls._decode_cert(jobj["certificate"]),
chain=[cls._decode_cert(cert) for cert in
jobj.get("chain", [])],
refresh=jobj.get("refresh"))
@chain.encoder
def chain(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(jose.encode_cert(cert) for cert in value)
@Message.register
@ -294,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):
@ -326,59 +269,32 @@ class CertificateRequest(Message):
:rtype: bool
"""
# self.signature is not Field | pylint: disable=no-member
return self.signature.verify(self.csr.as_der())
@classmethod
def _decode_csr(cls, b64der):
return util.ComparableX509(M2Crypto.X509.load_request_der_string(
jose.b64decode(b64der)))
@classmethod
def _encode_csr(cls, csr):
return jose.b64encode(csr.as_der())
def _fields_to_json(self):
return {
"csr": self._encode_csr(self.csr),
"signature": self.signature,
}
@classmethod
def _from_valid_json(cls, jobj):
return cls(csr=cls._decode_csr(jobj["csr"]),
signature=other.Signature.from_json(
jobj["signature"], validate=False))
@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",
@ -387,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
@ -425,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):
@ -457,44 +355,13 @@ class RevocationRequest(Message):
:rtype: bool
"""
# self.signature is not Field | pylint: disable=no-member
return self.signature.verify(self.certificate.as_der())
@classmethod
def _decode_cert(cls, b64der):
return util.ComparableX509(M2Crypto.X509.load_cert_der_string(
jose.b64decode(b64der)))
@classmethod
def _encode_cert(cls, cert):
return jose.b64encode(cert.as_der())
def _fields_to_json(self):
return {
"certificate": self._encode_cert(self.certificate),
"signature": self.signature,
}
@classmethod
def _from_valid_json(cls, jobj):
return cls(certificate=cls._decode_cert(jobj["certificate"]),
signature=other.Signature.from_json(
jobj["signature"], validate=False))
@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")

View file

@ -0,0 +1,303 @@
"""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]
def __str__(self):
if self.typ is not None:
return ' :: '.join([self.typ, self.description, self.detail])
else:
return str(self.detail)
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)

View file

@ -0,0 +1,249 @@
"""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()))
def test_str(self):
self.assertEqual(
'malformed :: The request message was malformed :: foo',
str(self.error))
self.assertEqual('foo', str(self.error.update(typ=None)))
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))
def test_proxy(self):
self.assertEqual('foo', self.challb.token)
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()

View file

@ -1,25 +1,29 @@
"""Tests for letsencrypt.acme.messages."""
import os
import pkg_resources
import unittest
import Crypto.PublicKey.RSA
import M2Crypto.X509
import mock
import M2Crypto
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):
@ -28,8 +32,14 @@ class MessageTest(unittest.TestCase):
def setUp(self):
# pylint: disable=missing-docstring,too-few-public-methods
from letsencrypt.acme.messages import Message
class TestMessage(Message):
acme_type = 'test'
class MockParentMessage(Message):
# pylint: disable=abstract-method
TYPES = {}
@MockParentMessage.register
class MockMessage(MockParentMessage):
typ = 'test'
schema = {
'type': 'object',
'properties': {
@ -37,94 +47,78 @@ class MessageTest(unittest.TestCase):
'name': {'type': 'string'},
},
}
price = jose.Field('price')
name = jose.Field('name')
@classmethod
def _from_valid_json(cls, jobj):
return jobj
self.parent_cls = MockParentMessage
self.msg = MockMessage(price=123, name='foo')
def _fields_to_json(self):
return {'foo': 'bar'}
self.msg_cls = TestMessage
def test_to_json(self):
self.assertEqual(self.msg_cls().to_json(), {
'type': 'test',
'foo': 'bar',
})
def test_fields_to_json_not_implemented(self):
from letsencrypt.acme.messages import Message
# pylint: disable=protected-access
self.assertRaises(NotImplementedError, Message()._fields_to_json)
@classmethod
def _from_json(cls, jobj, validate=True):
from letsencrypt.acme.messages import Message
return Message.from_json(jobj, validate)
def test_from_json_non_dict_fails(self):
self.assertRaises(errors.ValidationError, self._from_json, [])
def test_from_json_dict_no_type_fails(self):
self.assertRaises(errors.ValidationError, self._from_json, {})
def test_from_json_unknown_type_fails(self):
self.assertRaises(errors.UnrecognizedMessageTypeError,
self._from_json, {'type': 'bar'})
@mock.patch('letsencrypt.acme.messages.Message.TYPES')
def test_from_json_validate_errors(self, types):
types.__getitem__.side_effect = lambda x: {'foo': self.msg_cls}[x]
def test_from_json_validates(self):
self.assertRaises(errors.SchemaValidationError,
self._from_json, {'type': 'foo', 'price': 'asd'})
@mock.patch('letsencrypt.acme.messages.Message.TYPES')
def test_from_json_valid_returns_cls(self, types):
types.__getitem__.side_effect = lambda x: {'foo': self.msg_cls}[x]
self.assertEqual(self._from_json({'type': 'foo'}, validate=False),
{'type': 'foo'})
self.parent_cls.from_json,
{'type': 'test', 'price': 'asd'})
class ChallengeTest(unittest.TestCase):
def setUp(self):
challenges = [
{'type': 'simpleHttps', 'token': 'IlirfxKKXAsHtmzK29Pj8A'},
{'type': 'dns', 'token': 'DGyRejmCefe7v4NfDGDKfA'},
{'type': 'recoveryToken'},
]
combinations = [[0, 2], [1, 2]]
challs = (
challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'),
challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'),
challenges.RecoveryToken(),
)
combinations = ((0, 2), (1, 2))
from letsencrypt.acme.messages import Challenge
self.msg = Challenge(
session_id='aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
nonce='\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9',
challenges=challenges, combinations=combinations)
challenges=challs, combinations=combinations)
self.jmsg = {
self.jmsg_to = {
'type': 'challenge',
'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
'challenges': challenges,
'challenges': challs,
'combinations': combinations,
}
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
self.jmsg_from = {
'type': 'challenge',
'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
'challenges': [chall.to_json() for chall in challs],
'combinations': [[0, 2], [1, 2]], # TODO array tuples
}
def test_resolved_combinations(self):
self.assertEqual(self.msg.resolved_combinations, (
(
challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'),
challenges.RecoveryToken()
),
(
challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'),
challenges.RecoveryToken(),
)
))
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from letsencrypt.acme.messages import Challenge
self.assertEqual(Challenge.from_json(self.jmsg), self.msg)
self.assertEqual(Challenge.from_json(self.jmsg_from), self.msg)
def test_json_without_optionals(self):
del self.jmsg['combinations']
del self.jmsg_from['combinations']
del self.jmsg_to['combinations']
from letsencrypt.acme.messages import Challenge
msg = Challenge.from_json(self.jmsg)
msg = Challenge.from_json(self.jmsg_from)
self.assertEqual(msg.combinations, [])
self.assertEqual(msg.to_json(), self.jmsg)
self.assertEqual(msg.combinations, ())
self.assertEqual(msg.to_partial_json(), self.jmsg_to)
class ChallengeRequestTest(unittest.TestCase):
@ -138,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
@ -149,7 +143,7 @@ class ChallengeRequestTest(unittest.TestCase):
class AuthorizationTest(unittest.TestCase):
def setUp(self):
jwk = jose.JWK(key=KEY.publickey())
jwk = jose.JWKRSA(key=KEY.publickey())
from letsencrypt.acme.messages import Authorization
self.msg = Authorization(recovery_token='tok', jwk=jwk,
@ -162,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)
@ -182,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 = [
{'type': 'simpleHttps', 'path': 'Hf5GrX4Q7EBax9hc2jJnfw'},
self.responses = (
challenges.SimpleHTTPSResponse(path='Hf5GrX4Q7EBax9hc2jJnfw'),
None, # null
{'type': 'recoveryToken', 'token': '23029d88d9e123e'},
]
self.contact = ["mailto:cert-admin@example.com", "tel:+12025551212"]
challenges.RecoveryTokenResponse(token='23029d88d9e123e'),
)
self.contact = ("mailto:cert-admin@example.com", "tel:+12025551212")
signature = other.Signature(
alg='RS256', jwk=jose.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\xc8<?\xc8W\x94\x94cj(\xe7\xaa$'
@ -223,12 +217,13 @@ class AuthorizationRequestTest(unittest.TestCase):
'type': 'authorizationRequest',
'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
'responses': self.responses,
'responses': [None if response is None else response.to_json()
for response in self.responses],
'signature': signature.to_json(),
'contact': self.contact,
# TODO: schema validation doesn't recognize tuples as
# arrays :(
'contact': list(self.contact),
}
self.jmsg_from['signature']['jwk'] = self.jmsg_from[
'signature']['jwk'].to_json()
def test_create(self):
from letsencrypt.acme.messages import AuthorizationRequest
@ -242,8 +237,8 @@ class AuthorizationRequestTest(unittest.TestCase):
def test_verify(self):
self.assertTrue(self.msg.verify('example.com'))
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg_to)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from letsencrypt.acme.messages import AuthorizationRequest
@ -257,8 +252,8 @@ class AuthorizationRequestTest(unittest.TestCase):
from letsencrypt.acme.messages import AuthorizationRequest
msg = AuthorizationRequest.from_json(self.jmsg_from)
self.assertEqual(msg.contact, [])
self.assertEqual(self.jmsg_to, msg.to_json())
self.assertEqual(msg.contact, ())
self.assertEqual(self.jmsg_to, msg.to_partial_json())
class CertificateTest(unittest.TestCase):
@ -268,39 +263,44 @@ class CertificateTest(unittest.TestCase):
from letsencrypt.acme.messages import Certificate
self.msg = Certificate(
certificate=CERT, chain=[CERT], refresh=refresh)
certificate=CERT, chain=(CERT,), refresh=refresh)
self.jmsg = {
self.jmsg_to = {
'type': 'certificate',
'certificate': jose.b64encode(CERT.as_der()),
'chain': [jose.b64encode(CERT.as_der())],
'chain': (jose.b64encode(CERT.as_der()),),
'refresh': refresh,
}
self.jmsg_from = self.jmsg_to.copy()
# TODO: schema validation array tuples
self.jmsg_from['chain'] = list(self.jmsg_from['chain'])
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from letsencrypt.acme.messages import Certificate
self.assertEqual(Certificate.from_json(self.jmsg), self.msg)
self.assertEqual(Certificate.from_json(self.jmsg_from), self.msg)
def test_json_without_optionals(self):
del self.jmsg['chain']
del self.jmsg['refresh']
del self.jmsg_from['chain']
del self.jmsg_from['refresh']
del self.jmsg_to['chain']
del self.jmsg_to['refresh']
from letsencrypt.acme.messages import Certificate
msg = Certificate.from_json(self.jmsg)
msg = Certificate.from_json(self.jmsg_from)
self.assertEqual(msg.chain, [])
self.assertEqual(msg.chain, ())
self.assertTrue(msg.refresh is None)
self.assertEqual(self.jmsg, msg.to_json())
self.assertEqual(self.jmsg_to, msg.to_partial_json())
class CertificateRequestTest(unittest.TestCase):
def setUp(self):
signature = other.Signature(
alg='RS256', jwk=jose.JWK(key=KEY.publickey()),
alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.publickey()),
sig='\x15\xed\x84\xaa:\xf2DO\x0e9 \xbcg\xf8\xc0\xcf\x87\x9a'
'\x95\xeb\xffT[\x84[\xec\x85\x7f\x8eK\xe9\xc2\x12\xc8Q'
'\xafo\xc6h\x07\xba\xa6\xdf\xd1\xa7"$\xba=Z\x13n\x14\x0b'
@ -310,11 +310,13 @@ class CertificateRequestTest(unittest.TestCase):
from letsencrypt.acme.messages import CertificateRequest
self.msg = CertificateRequest(csr=CSR, signature=signature)
self.jmsg = {
self.jmsg_to = {
'type': 'certificateRequest',
'csr': jose.b64encode(CSR.as_der()),
'signature': signature,
}
self.jmsg_from = self.jmsg_to.copy()
self.jmsg_from['signature'] = self.jmsg_from['signature'].to_json()
def test_create(self):
from letsencrypt.acme.messages import CertificateRequest
@ -325,14 +327,12 @@ class CertificateRequestTest(unittest.TestCase):
def test_verify(self):
self.assertTrue(self.msg.verify())
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from letsencrypt.acme.messages import CertificateRequest
self.jmsg['signature'] = self.jmsg['signature'].to_json()
self.jmsg['signature']['jwk'] = self.jmsg['signature']['jwk'].to_json()
self.assertEqual(self.msg, CertificateRequest.from_json(self.jmsg))
self.assertEqual(self.msg, CertificateRequest.from_json(self.jmsg_from))
class DeferTest(unittest.TestCase):
@ -350,8 +350,8 @@ class DeferTest(unittest.TestCase):
'message': 'Warming up the HSM',
}
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
from letsencrypt.acme.messages import Defer
@ -366,7 +366,7 @@ class DeferTest(unittest.TestCase):
self.assertTrue(msg.interval is None)
self.assertTrue(msg.message is None)
self.assertEqual(self.jmsg, msg.to_json())
self.assertEqual(self.jmsg, msg.to_partial_json())
class ErrorTest(unittest.TestCase):
@ -384,8 +384,8 @@ class ErrorTest(unittest.TestCase):
'moreInfo': 'https://ca.example.com/documentation/csr-requirements',
}
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
from letsencrypt.acme.messages import Error
@ -400,7 +400,7 @@ class ErrorTest(unittest.TestCase):
self.assertTrue(msg.message is None)
self.assertTrue(msg.more_info is None)
self.assertEqual(self.jmsg, msg.to_json())
self.assertEqual(self.jmsg, msg.to_partial_json())
class RevocationTest(unittest.TestCase):
@ -408,13 +408,10 @@ class RevocationTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.messages import Revocation
self.msg = Revocation()
self.jmsg = {'type': 'revocation'}
self.jmsg = {
'type': 'revocation',
}
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
from letsencrypt.acme.messages import Revocation
@ -427,7 +424,7 @@ class RevocationRequestTest(unittest.TestCase):
self.sig_nonce = '\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9'
signature = other.Signature(
alg='RS256', jwk=jose.JWK(key=KEY.publickey()),
alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.publickey()),
sig='eJ\xfe\x12"U\x87\x8b\xbf/ ,\xdeP\xb2\xdc1\xb00\xe5\x1dB'
'\xfch<\xc6\x9eH@!\x1c\x16\xb2\x0b_\xc4\xddP\x89\xc8\xce?'
'\x16g\x069I\xb9\xb3\x91\xb9\x0e$3\x9f\x87\x8e\x82\xca\xc5'
@ -437,11 +434,13 @@ class RevocationRequestTest(unittest.TestCase):
from letsencrypt.acme.messages import RevocationRequest
self.msg = RevocationRequest(certificate=CERT, signature=signature)
self.jmsg = {
self.jmsg_to = {
'type': 'revocationRequest',
'certificate': jose.b64encode(CERT.as_der()),
'signature': signature,
}
self.jmsg_from = self.jmsg_to.copy()
self.jmsg_from['signature'] = self.jmsg_from['signature'].to_json()
def test_create(self):
from letsencrypt.acme.messages import RevocationRequest
@ -451,15 +450,12 @@ class RevocationRequestTest(unittest.TestCase):
def test_verify(self):
self.assertTrue(self.msg.verify())
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
self.jmsg['signature'] = self.jmsg['signature'].to_json()
self.jmsg['signature']['jwk'] = self.jmsg['signature']['jwk'].to_json()
from letsencrypt.acme.messages import RevocationRequest
self.assertEqual(self.msg, RevocationRequest.from_json(self.jmsg))
self.assertEqual(self.msg, RevocationRequest.from_json(self.jmsg_from))
class StatusRequestTest(unittest.TestCase):
@ -472,8 +468,8 @@ class StatusRequestTest(unittest.TestCase):
'token': u'O7-s9MNq1siZHlgrMzi9_A',
}
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
from letsencrypt.acme.messages import StatusRequest

View file

@ -1,15 +1,14 @@
"""JSON objects in ACME protocol other than messages."""
"""Other ACME objects."""
import functools
import logging
from Crypto import Random
import Crypto.Hash.SHA256
import Crypto.Signature.PKCS1_v1_5
import Crypto.Random
import Crypto.PublicKey.RSA
from letsencrypt.acme import jose
from letsencrypt.acme import util
class Signature(util.JSONDeSerializable, util.ImmutableMap):
class Signature(jose.JSONObjectWithFields):
"""ACME signature.
:ivar str alg: Signature algorithm.
@ -17,19 +16,22 @@ class Signature(util.JSONDeSerializable, util.ImmutableMap):
:ivar str nonce: Nonce.
:ivar jwk: JWK.
:type jwk: :class:`letsencrypt.acme.jose.JWK`
.. todo:: Currently works for RSA keys only.
:type jwk: :class:`JWK`
"""
__slots__ = ('alg', 'sig', 'nonce', 'jwk')
schema = util.load_schema('signature')
NONCE_SIZE = 16
"""Minimum size of nonce in bytes."""
NONCE_LEN = 16
"""Size of nonce in bytes, as specified in the ACME protocol."""
alg = jose.Field('alg', decoder=jose.JWASignature.from_json)
sig = jose.Field('sig', encoder=jose.b64encode,
decoder=jose.decode_b64jose)
nonce = jose.Field(
'nonce', encoder=jose.b64encode, decoder=functools.partial(
jose.decode_b64jose, size=NONCE_SIZE, minimum=True))
jwk = jose.Field('jwk', decoder=jose.JWK.from_json)
@classmethod
def from_msg(cls, msg, key, nonce=None):
def from_msg(cls, msg, key, nonce=None, nonce_size=None, alg=jose.RS256):
"""Create signature with nonce prepended to the message.
.. todo:: Protect against crypto unicode errors... is this sufficient?
@ -40,22 +42,22 @@ class Signature(util.JSONDeSerializable, util.ImmutableMap):
:param key: Key used for signing.
:type key: :class:`Crypto.PublicKey.RSA`
:param nonce: Nonce to be used. If None, nonce of
:const:`NONCE_LEN` size will be randomly generated.
:type nonce: str or None
:param str nonce: Nonce to be used. If None, nonce of
``nonce_size`` will be randomly generated.
:param int nonce_size: Size of the automatically generated nonce.
Defaults to :const:`NONCE_SIZE`.
"""
nonce_size = cls.NONCE_SIZE if nonce_size is None else nonce_size
if nonce is None:
nonce = Random.get_random_bytes(cls.NONCE_LEN)
nonce = Crypto.Random.get_random_bytes(nonce_size)
msg_with_nonce = nonce + msg
hashed = Crypto.Hash.SHA256.new(msg_with_nonce)
sig = Crypto.Signature.PKCS1_v1_5.new(key).sign(hashed)
sig = alg.sign(key, nonce + msg)
logging.debug('%s signed as %s', msg_with_nonce, sig)
return cls(alg='RS256', sig=sig, nonce=nonce,
jwk=jose.JWK(key=key.publickey()))
return cls(alg=alg, sig=sig, nonce=nonce,
jwk=alg.kty(key=key.publickey()))
def verify(self, msg):
"""Verify the signature.
@ -63,21 +65,5 @@ class Signature(util.JSONDeSerializable, util.ImmutableMap):
:param str msg: Message that was used in signing.
"""
hashed = Crypto.Hash.SHA256.new(self.nonce + msg)
return Crypto.Signature.PKCS1_v1_5.new(self.jwk.key).verify(
hashed, self.sig)
def to_json(self):
"""Prepare JSON serializable object."""
return {
'alg': self.alg,
'sig': jose.b64encode(self.sig),
'nonce': jose.b64encode(self.nonce),
'jwk': self.jwk,
}
@classmethod
def _from_valid_json(cls, jobj):
return cls(alg=jobj['alg'], sig=jose.b64decode(jobj['sig']),
nonce=jose.b64decode(jobj['nonce']),
jwk=jose.JWK.from_json(jobj['jwk'], validate=False))
# self.alg is not Field, but JWA | pylint: disable=no-member
return self.alg.verify(self.jwk.key, self.nonce + msg, self.sig)

View file

@ -1,4 +1,5 @@
"""Tests for letsencrypt.acme.sig."""
import os
import pkg_resources
import unittest
@ -7,23 +8,25 @@ import Crypto.PublicKey.RSA
from letsencrypt.acme import jose
RSA256_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
'letsencrypt.client.tests', 'testdata/rsa256_key.pem'))
KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
pkg_resources.resource_string(
'letsencrypt.acme.jose', os.path.join('testdata', 'rsa512_key.pem'))))
class SigatureTest(unittest.TestCase):
class SignatureTest(unittest.TestCase):
# pylint: disable=too-many-instance-attributes
"""Tests for letsencrypt.acme.sig.Signature."""
def setUp(self):
self.msg = 'message'
self.alg = 'RS256'
self.sig = ('IC\xd8*\xe7\x14\x9e\x19S\xb7\xcf\xec3\x12\xe2\x8a\x03'
'\x98u\xff\xf0\x94\xe2\xd7<\x8f\xa8\xed\xa4KN\xc3\xaa'
'\xb9X\xc3w\xaa\xc0_\xd0\x05$y>l#\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'
self.jwk = jose.JWK(key=RSA256_KEY.publickey())
self.alg = jose.RS256
self.jwk = jose.JWKRSA(key=KEY.publickey())
b64sig = ('SUPYKucUnhlTt8_sMxLiigOYdf_wlOLXPI-o7aRLTsOquVjDd6r'
'AX9AFJHk-bCMQPJbSzXKjG6H1IWbvxjS2Ew')
@ -37,8 +40,8 @@ class SigatureTest(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,
}
@ -64,23 +67,32 @@ class SigatureTest(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
# pylint: disable=protected-access
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_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__':

View file

@ -2,146 +2,8 @@
import json
import pkg_resources
import jsonschema
import zope.interface
from letsencrypt.acme import errors
from letsencrypt.acme import interfaces
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)))
class JSONDeSerializable(object):
"""JSON (de)serializable object."""
zope.interface.implements(interfaces.IJSONSerializable)
schema = NotImplemented
@classmethod
def validate_json(cls, jobj):
"""Validate JSON object against schema.
:raises letsencrypt.acme.errors.SchemaValidationError: if object
couldn't be validated.
"""
try:
jsonschema.validate(jobj, cls.schema)
except jsonschema.ValidationError as error:
raise errors.SchemaValidationError(error)
@classmethod
def from_json(cls, jobj, validate=True):
"""Deserialize from JSON.
Note that the input ``jobj`` has not been sanitized in any way.
:param jobj: JSON object.
:param bool validate: Validate against schema before deserializing.
Useful if :class:`JWK` is part of already validated json object.
:raises letsencrypt.acme.errors.SchemaValidationError: if ``validate``
was ``True`` and object couldn't be validated.
:returns: instance of the class
"""
if validate:
cls.validate_json(jobj)
return cls._from_valid_json(jobj)
@classmethod
def _from_valid_json(cls, jobj):
"""Deserializa from valid JSON object.
:param jobj: JSON object that has been validated against schema.
"""
raise NotImplementedError()
@classmethod
def json_loads(cls, json_string, validate=True):
"""Load JSON string."""
return cls.from_json(json.loads(json_string), validate)
def to_json(self):
"""Prepare JSON serializable object."""
raise NotImplementedError()
def json_dumps(self):
"""Dump to JSON string using proper serializer.
:returns: JSON serialized string.
:rtype: str
"""
return json.dumps(self, default=dump_ijsonserializable)
def dump_ijsonserializable(python_object):
"""Serialize IJSONSerializable to JSON.
This is meant to be passed to :func:`json.dumps` as ``default``
argument.
"""
# 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__))

View file

@ -1,167 +0,0 @@
"""Tests for letsencrypt.acme.util."""
import functools
import json
import unittest
import zope.interface
from letsencrypt.acme import errors
from letsencrypt.acme import interfaces
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]
class JSONDeSerializableTest(unittest.TestCase):
"""Tests for letsencrypt.acme.util.JSONDeSerializable."""
def setUp(self):
from letsencrypt.acme.util import JSONDeSerializable
class Tester(JSONDeSerializable):
# pylint: disable=missing-docstring,no-self-use,
# pylint: disable=too-few-public-methods
zope.interface.implements(interfaces.IJSONSerializable)
schema = {'type': 'integer'}
def __init__(self, jobj):
self.jobj = jobj
@classmethod
def _from_valid_json(cls, jobj):
return cls(jobj)
def to_json(self):
return {'foo': MockJSONSerialiazable()}
self.tester_cls = Tester
def test_validate_invalid_json(self):
self.assertRaises(errors.SchemaValidationError,
self.tester_cls.validate_json, 'bang!')
def test_validate_valid_json(self):
self.tester_cls.validate_json(5)
def test_from_json(self):
self.assertEqual(5, self.tester_cls.from_json(5, validate=True).jobj)
def test_from_json_no_validation(self):
self.assertEqual(['1', 2], self.tester_cls.from_json(
['1', 2], validate=False).jobj)
def test_from_valid_json_raises_error(self):
from letsencrypt.acme.util import JSONDeSerializable
# pylint: disable=protected-access
self.assertRaises(
NotImplementedError, JSONDeSerializable._from_valid_json, 'foo')
def test_json_loads(self):
tester = self.tester_cls.json_loads('5', validate=True)
self.assertEqual(tester.jobj, 5)
def test_json_loads_no_validation(self):
self.assertEqual(
'foo', self.tester_cls.json_loads('"foo"', validate=False).jobj)
def test_to_json_raises_error(self):
from letsencrypt.acme.util import JSONDeSerializable
self.assertRaises(NotImplementedError, JSONDeSerializable().to_json)
def test_json_dumps(self):
self.assertEqual(
self.tester_cls('foo').json_dumps(), '{"foo": [3, 2, 1]}')
class DumpIJSONSerializableTest(unittest.TestCase):
"""Tests for letsencrypt.acme.util.dump_ijsonserializable."""
@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(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')))
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,231 @@
"""Creates ACME accounts for server."""
import logging
import os
import re
import configobj
import zope.component
from letsencrypt.acme import messages2
from letsencrypt.client import crypto_util
from letsencrypt.client import errors
from letsencrypt.client import interfaces
from letsencrypt.client import le_util
from letsencrypt.client.display import util as display_util
class Account(object):
"""ACME protocol registration.
:ivar config: Client configuration object
:type config: :class:`~letsencrypt.client.interfaces.IConfig`
:ivar key: Account/Authorized Key
:type key: :class:`~letsencrypt.client.le_util.Key`
:ivar str email: Client's email address
:ivar str phone: Client's phone number
:ivar regr: Registration Resource
:type regr: :class:`~letsencrypt.acme.messages2.RegistrationResource`
"""
# Just make sure we don't get pwned
# Make sure that it also doesn't start with a period or have two consecutive
# periods <- this needs to be done in addition to the regex
EMAIL_REGEX = re.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$")
def __init__(self, config, key, email=None, phone=None, regr=None):
le_util.make_or_verify_dir(
config.accounts_dir, 0o700, os.geteuid())
self.key = key
self.config = config
if email is not None and self.safe_email(email):
self.email = email
else:
self.email = None
self.phone = phone
self.regr = regr
@property
def uri(self):
"""URI link for new registrations."""
if self.regr is not None:
return self.regr.uri
else:
return None
@property
def new_authzr_uri(self): # pylint: disable=missing-docstring
if self.regr is not None:
return self.regr.new_authzr_uri
else:
return None
@property
def terms_of_service(self): # pylint: disable=missing-docstring
if self.regr is not None:
return self.regr.terms_of_service
else:
return None
@property
def recovery_token(self): # pylint: disable=missing-docstring
if self.regr is not None and self.regr.body is not None:
return self.regr.body.recovery_token
else:
return None
def save(self):
"""Save account to disk."""
le_util.make_or_verify_dir(
self.config.accounts_dir, 0o700, os.geteuid())
acc_config = configobj.ConfigObj()
acc_config.filename = os.path.join(
self.config.accounts_dir, self._get_config_filename(self.email))
acc_config.initial_comment = [
"DO NOT EDIT THIS FILE",
"Account information for %s under %s" % (
self._get_config_filename(self.email), self.config.server),
]
acc_config["key"] = self.key.file
acc_config["phone"] = self.phone
if self.regr is not None:
acc_config["RegistrationResource"] = {}
acc_config["RegistrationResource"]["uri"] = self.uri
acc_config["RegistrationResource"]["new_authzr_uri"] = (
self.new_authzr_uri)
acc_config["RegistrationResource"]["terms_of_service"] = (
self.terms_of_service)
regr_dict = self.regr.body.to_json()
acc_config["RegistrationResource"]["body"] = regr_dict
acc_config.write()
@classmethod
def _get_config_filename(cls, email):
return email if email is not None and email else "default"
@classmethod
def from_existing_account(cls, config, email=None):
"""Populate an account from an existing email."""
config_fp = os.path.join(
config.accounts_dir, cls._get_config_filename(email))
return cls._from_config_fp(config, config_fp)
@classmethod
def _from_config_fp(cls, config, config_fp):
try:
acc_config = configobj.ConfigObj(
infile=config_fp, file_error=True, create_empty=False)
except IOError:
raise errors.LetsEncryptClientError(
"Account for %s does not exist" % os.path.basename(config_fp))
if os.path.basename(config_fp) != "default":
email = os.path.basename(config_fp)
else:
email = None
phone = acc_config["phone"] if acc_config["phone"] != "None" else None
with open(acc_config["key"]) as key_file:
key = le_util.Key(acc_config["key"], key_file.read())
if "RegistrationResource" in acc_config:
acc_config_rr = acc_config["RegistrationResource"]
regr = messages2.RegistrationResource(
uri=acc_config_rr["uri"],
new_authzr_uri=acc_config_rr["new_authzr_uri"],
terms_of_service=acc_config_rr["terms_of_service"],
body=messages2.Registration.from_json(acc_config_rr["body"]))
else:
regr = None
return cls(config, key, email, phone, regr)
@classmethod
def get_accounts(cls, config):
"""Return all current accounts.
:param config: Configuration
:type config: :class:`letsencrypt.client.interfaces.IConfig`
"""
try:
filenames = os.listdir(config.accounts_dir)
except OSError:
return []
accounts = []
for name in filenames:
# Not some directory ie. keys
config_fp = os.path.join(config.accounts_dir, name)
if os.path.isfile(config_fp):
accounts.append(cls._from_config_fp(config, config_fp))
return accounts
@classmethod
def from_prompts(cls, config):
"""Generate an account from prompted user input.
:param config: Configuration
:type config: :class:`letsencrypt.client.interfaces.IConfig`
:returns: Account or None
:rtype: :class:`letsencrypt.client.account.Account`
"""
while True:
code, email = zope.component.getUtility(interfaces.IDisplay).input(
"Enter email address (optional, press Enter to skip)")
if code == display_util.OK:
try:
return cls.from_email(config, email)
except errors.LetsEncryptClientError:
continue
else:
return None
@classmethod
def from_email(cls, config, email):
"""Generate a new account from an email address.
:param config: Configuration
:type config: :class:`letsencrypt.client.interfaces.IConfig`
:param str email: Email address
:raises letsencrypt.client.errors.LetsEncryptClientError: If invalid
email address is given.
"""
if not email or cls.safe_email(email):
email = email if email else None
le_util.make_or_verify_dir(
config.account_keys_dir, 0o700, os.geteuid())
key = crypto_util.init_save_key(
config.rsa_key_size, config.account_keys_dir,
cls._get_config_filename(email))
return cls(config, key, email)
raise errors.LetsEncryptClientError("Invalid email address.")
@classmethod
def safe_email(cls, email):
"""Scrub email address before using it."""
if cls.EMAIL_REGEX.match(email):
return not email.startswith(".") and ".." not in email
else:
logging.warn("Invalid email address.")
return False

View file

@ -0,0 +1,92 @@
"""Client annotated ACME challenges.
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')
challb = messages2.ChallengeBody(chall=chall)
achall = achallenges.DNS(chall=challb, domain='example.com')
Note, that all annotated challenges act as a proxy objects::
achall.token == challb.token
"""
from letsencrypt.acme import challenges
from letsencrypt.acme.jose import util as jose_util
from letsencrypt.client import crypto_util
# pylint: disable=too-few-public-methods
class AnnotatedChallenge(jose_util.ImmutableMap):
"""Client annotated challenge.
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.challb, name)
class DVSNI(AnnotatedChallenge):
"""Client annotated "dvsni" ACME challenge."""
__slots__ = ('challb', 'domain', 'key')
acme_type = challenges.DVSNI
def gen_cert_and_response(self, s=None): # pylint: disable=invalid-name
"""Generate a DVSNI cert and save it to filepath.
:returns: ``(cert_pem, response)`` tuple, where ``cert_pem`` is the PEM
encoded certificate and ``response`` is an instance
:class:`letsencrypt.acme.challenges.DVSNIResponse`.
:rtype: tuple
"""
response = challenges.DVSNIResponse(s=s)
cert_pem = crypto_util.make_ss_cert(self.key.pem, [
self.nonce_domain, self.domain, response.z_domain(self.challb)])
return cert_pem, response
class SimpleHTTPS(AnnotatedChallenge):
"""Client annotated "simpleHttps" ACME challenge."""
__slots__ = ('challb', 'domain', 'key')
acme_type = challenges.SimpleHTTPS
class DNS(AnnotatedChallenge):
"""Client annotated "dns" ACME challenge."""
__slots__ = ('challb', 'domain')
acme_type = challenges.DNS
class RecoveryContact(AnnotatedChallenge):
"""Client annotated "recoveryContact" ACME challenge."""
__slots__ = ('challb', 'domain')
acme_type = challenges.RecoveryContact
class RecoveryToken(AnnotatedChallenge):
"""Client annotated "recoveryToken" ACME challenge."""
__slots__ = ('challb', 'domain')
acme_type = challenges.RecoveryToken
class ProofOfPossession(AnnotatedChallenge):
"""Client annotated "proofOfPossession" ACME challenge."""
__slots__ = ('challb', 'domain')
acme_type = challenges.ProofOfPossession

View file

@ -1 +0,0 @@
"""Let's Encrypt client.apache."""

View file

@ -1,209 +1,235 @@
"""ACME AuthHandler."""
import itertools
import logging
import sys
import time
import Crypto.PublicKey.RSA
from letsencrypt.acme import challenges
from letsencrypt.acme import messages2
from letsencrypt.acme import messages
from letsencrypt.client import challenge_util
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 dict responses
: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.challenge_util.IndexedChall`
:ivar dict client_c: Keys - domain, Values are Client challenges in the form
of :class:`letsencrypt.client.challenge_util.IndexedChall`
: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] = ["null"] * 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.chall for ichall in self.client_c[dom])
flat_dv.extend(ichall.chall 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 IndexedChall 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' IndexedChallenges, or their
:class:`letsencrypt.client.challenge_util.IndexedChall` 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(
None != self.responses[dom][i] and "null" != self.responses[dom][i]
for i in self.paths[dom])
# Check for updated status...
self._poll_challenges(chall_update, best_effort)
# This removes challenges from self.dv_c and self.cont_c
self._cleanup_challenges(active_achalls)
def _send_responses(self, achalls, resps, chall_update):
"""Send responses and make sure errors are handled.
:param dict chall_update: parameter that is updated to hold
authzr -> list of outstanding solved annotated challenges
"""
active_achalls = []
for achall, resp in itertools.izip(achalls, resps):
# Don't send challenges for None and False authenticator responses
if resp:
self.network.answer_challenge(achall.challb, resp)
active_achalls.append(achall)
if achall.domain in chall_update:
chall_update[achall.domain].append(achall)
else:
chall_update[achall.domain] = [achall]
return active_achalls
def _poll_challenges(
self, chall_update, best_effort, min_sleep=3, max_rounds=15):
"""Wait for all challenge results to be determined."""
dom_to_check = set(chall_update.keys())
comp_domains = set()
rounds = 0
while dom_to_check and rounds < max_rounds:
# TODO: Use retry-after...
time.sleep(min_sleep)
for domain in dom_to_check:
comp_challs, failed_challs = self._handle_check(
domain, chall_update[domain])
if len(comp_challs) == len(chall_update[domain]):
comp_domains.add(domain)
elif not failed_challs:
for chall in comp_challs:
chall_update[domain].remove(chall)
# We failed some challenges... damage control
else:
# Right now... just assume a loss and carry on...
if best_effort:
comp_domains.add(domain)
else:
raise errors.AuthorizationError(
"Failed Authorization procedure for %s" % domain)
dom_to_check -= comp_domains
comp_domains.clear()
rounds += 1
def _handle_check(self, domain, achalls):
"""Returns tuple of ('completed', 'failed')."""
completed = []
failed = []
self.authzr[domain], _ = self.network.poll(self.authzr[domain])
if self.authzr[domain].body.status == messages2.STATUS_VALID:
return achalls, []
# Note: if the whole authorization is invalid, the individual failed
# challenges will be determined here...
for achall in achalls:
status = self._get_chall_status(self.authzr[domain], achall)
# This does nothing for challenges that have yet to be decided yet.
if status == messages2.STATUS_VALID:
completed.append(achall)
elif status == messages2.STATUS_INVALID:
failed.append(achall)
return completed, failed
def _get_chall_status(self, authzr, achall): # pylint: disable=no-self-use
"""Get the status of the challenge.
.. warning:: This assumes only one instance of type of challenge in
each challenge resource.
:param authzr: Authorization Resource
:type authzr: :class:`letsencrypt.acme.messages2.AuthorizationResource`
:param achall: Annotated challenge for which to get status
:type achall: :class:`letsencrypt.client.achallenges.AnnotatedChallenge`
"""
for authzr_challb in authzr.body.challenges:
if type(authzr_challb.chall) is type(achall.challb.chall):
return authzr_challb.status
raise errors.AuthorizationError(
"Target challenge not found in authorization resource")
def _get_chall_pref(self, domain):
"""Return list of challenge preferences.
@ -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.chall for ichall in self.dv_c[domain]]
client_list = [ichall.chall 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,222 +288,195 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
:param list path: List of indices from `challenges`.
:returns: dv_chall, list of
:class:`letsencrypt.client.challenge_util.IndexedChall`
client_chall, list of
:class:`letsencrypt.client.challenge_util.IndexedChall`
:returns: dv_chall, list of DVChallenge type
:class:`letsencrypt.client.achallenges.Indexed`
cont_chall, list of ContinuityChallenge type
:class:`letsencrypt.client.achallenges.Indexed`
:rtype: tuple
:raises errors.LetsEncryptClientError: If Challenge type is not
recognized
"""
challenges = self.msgs[domain].challenges
dv_chall = []
client_chall = []
cont_chall = []
for index in path:
chall = challenges[index]
challb = self.authzr[domain].body.challenges[index]
chall = challb.chall
# Authenticator Challenges
if chall["type"] in constants.DV_CHALLENGES:
dv_chall.append(challenge_util.IndexedChall(
self._construct_dv_chall(chall, domain), index))
achall = challb_to_achall(challb, self.account.key, domain)
# Client Challenges
elif chall["type"] in constants.CLIENT_CHALLENGES:
client_chall.append(challenge_util.IndexedChall(
self._construct_client_chall(chall, domain), index))
if isinstance(chall, challenges.ContinuityChallenge):
cont_chall.append(achall)
elif isinstance(chall, challenges.DVChallenge):
dv_chall.append(achall)
else:
raise errors.LetsEncryptClientError(
"Received unrecognized challenge of type: "
"%s" % chall["type"])
return dv_chall, client_chall
def _construct_dv_chall(self, chall, domain):
"""Construct Auth Type Challenges.
:param dict chall: Single challenge
:param str domain: challenge's domain
:returns: challenge_util named tuple Chall object
:rtype: `collections.namedtuple`
:raises errors.LetsEncryptClientError: If unimplemented challenge exists
"""
if chall["type"] == "dvsni":
logging.info(" DVSNI challenge for name %s.", domain)
return challenge_util.DvsniChall(
domain, str(chall["r"]), str(chall["nonce"]),
self.authkey[domain])
elif chall["type"] == "simpleHttps":
logging.info(" SimpleHTTPS challenge for name %s.", domain)
return challenge_util.SimpleHttpsChall(
domain, str(chall["token"]), self.authkey[domain])
elif chall["type"] == "dns":
logging.info(" DNS challenge for name %s.", domain)
return challenge_util.DnsChall(domain, str(chall["token"]))
else:
raise errors.LetsEncryptClientError(
"Unimplemented Auth Challenge: %s" % chall["type"])
def _construct_client_chall(self, chall, domain): # pylint: disable=no-self-use
"""Construct Client Type Challenges.
:param dict chall: Single challenge
:param str domain: challenge's domain
:returns: challenge_util named tuple Chall object
:rtype: `collections.namedtuple`
:raises errors.LetsEncryptClientError: If unimplemented challenge exists
"""
if chall["type"] == "recoveryToken":
logging.info(" Recovery Token Challenge for name: %s.", domain)
return challenge_util.RecTokenChall(domain)
elif chall["type"] == "recoveryContact":
logging.info(" Recovery Contact Challenge for name: %s.", domain)
return challenge_util.RecContactChall(
domain,
chall.get("activationURL", None),
chall.get("successURL", None),
chall.get("contact", None))
elif chall["type"] == "proofOfPossession":
logging.info(" Proof-of-Possession Challenge for name: "
"%s", domain)
return challenge_util.PopChall(
domain, chall["alg"], chall["nonce"], chall["hints"])
else:
raise errors.LetsEncryptClientError(
"Unimplemented Client Challenge: %s" % chall["type"])
return cont_chall, dv_chall
def gen_challenge_path(challenges, preferences, combos=None):
"""Generate a plan to get authority over the identity.
def challb_to_achall(challb, key, domain):
"""Converts a ChallengeBody object to an AnnotatedChallenge.
.. todo:: Make sure that the challenges are feasible...
Example: Do you have the recovery key?
:param challb: ChallengeBody
:type challb: :class:`letsencrypt.acme.messages2.ChallengeBody`
:param list challenges: A list of challenges from ACME "challenge"
server message to be fulfilled by the client in order to prove
possession of the identifier.
:param key: Key
:type key: :class:`letsencrypt.client.le_util.Key`
:param list preferences: List of challenge preferences for domain
:param str domain: Domain of the challb
:param combos: A collection of sets of challenges from ACME
"challenge" server message ("combinations"), each of which would
be sufficient to prove possession of the identifier.
:type combos: list or None
:returns: List of indices from `challenges`.
:rtype: list
:returns: Appropriate AnnotatedChallenge
:rtype: :class:`letsencrypt.client.achallenges.AnnotatedChallenge`
"""
if combos:
return _find_smart_path(challenges, preferences, combos)
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:
return _find_dumb_path(challenges, preferences)
raise errors.LetsEncryptClientError(
"Received unsupported challenge of type: %s",
chall.typ)
def _find_smart_path(challenges, preferences, combos):
def gen_challenge_path(challbs, preferences, combinations):
"""Generate a plan to get authority over the identity.
.. todo:: This can be possibly be rewritten to use resolved_combinations.
:param tuple 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.Challenge` subclasses)
: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: 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(challbs, preferences, combinations)
else:
return _find_dumb_path(challbs, preferences)
def _find_smart_path(challbs, preferences, combinations):
"""Find challenge path with server hints.
Can be called if combinations is included. Function uses a simple
ranking system to choose the combo with the lowest cost.
:param list challenges: A list of challenges from ACME "challenge"
server message to be fulfilled by the client in order to prove
possession of the identifier.
:param combos: A collection of sets of challenges from ACME
"challenge" server message ("combinations"), each of which would
be sufficient to prove possession of the identifier.
:type combos: list or None
:returns: List of indices from `challenges`.
:rtype: list
"""
chall_cost = {}
max_cost = 0
for i, chall in enumerate(preferences):
chall_cost[chall] = i
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 combos:
for combo in combinations:
for challenge_index in combo:
combo_total += chall_cost.get(challenges[
challenge_index]["type"], 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(challenges, 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
server. This function returns the best path that does not contain
multiple mutually exclusive challenges.
:param list challenges: A list of challenges from ACME "challenge"
server message to be fulfilled by the client in order to prove
possession of the identifier.
:param list preferences: A list of preferences representing the
challenge type found within the ACME spec. Each challenge type
can only be listed once.
:returns: List of indices from `challenges`.
:rtype: list
"""
# Add logic for a crappy server
# Choose a DV
path = []
assert len(preferences) == len(set(preferences))
path = []
satisfied = set()
for pref_c in preferences:
for i, offered_challenge in enumerate(challenges):
if (pref_c == offered_challenge["type"] and
is_preferred(offered_challenge["type"], path)):
path.append((i, offered_challenge["type"]))
return [i for (i, _) in path]
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_challb)
return path
def is_preferred(offered_challenge_type, path):
"""Return whether or not the challenge is preferred in path."""
for _, challenge_type in path:
for mutually_exclusive in constants.EXCLUSIVE_CHALLENGES:
# Second part is in case we eventually allow multiple names
# to be challenges at the same time
if (challenge_type in mutually_exclusive and
offered_challenge_type in mutually_exclusive and
challenge_type != offered_challenge_type):
def mutually_exclusive(obj1, obj2, groups, different=False):
"""Are two objects mutually exclusive?"""
for group in groups:
obj1_present = False
obj2_present = False
for obj_cls in group:
obj1_present |= isinstance(obj1, obj_cls)
obj2_present |= isinstance(obj2, obj_cls)
if obj1_present and obj2_present and (
not different or not isinstance(obj1, obj2.__class__)):
return False
return True
def is_preferred(offered_challb, satisfied,
exclusive_groups=constants.EXCLUSIVE_CHALLENGES):
"""Return whether or not the challenge is preferred in path."""
for challb in satisfied:
if not mutually_exclusive(
offered_challb.chall, challb.chall, exclusive_groups,
different=True):
return False
return True

View file

@ -1,74 +0,0 @@
"""Challenge specific utility functions."""
import collections
import hashlib
from Crypto import Random
from letsencrypt.acme import jose
from letsencrypt.client import constants
from letsencrypt.client import crypto_util
# Authenticator Challenges
DvsniChall = collections.namedtuple("DvsniChall", "domain, r_b64, nonce, key")
SimpleHttpsChall = collections.namedtuple(
"SimpleHttpsChall", "domain, token, key")
DnsChall = collections.namedtuple("DnsChall", "domain, token")
# Client Challenges
RecContactChall = collections.namedtuple(
"RecContactChall", "domain, a_url, s_url, contact")
RecTokenChall = collections.namedtuple("RecTokenChall", "domain")
PopChall = collections.namedtuple("PopChall", "domain, alg, nonce, hints")
# Helper Challenge Wrapper - Can be used to maintain the proper position of
# the response within a larger challenge list
IndexedChall = collections.namedtuple("IndexedChall", "chall, index")
# DVSNI Challenge functions
def dvsni_gen_cert(name, r_b64, nonce, key):
"""Generate a DVSNI cert and save it to filepath.
:param str name: domain to validate
:param str r_b64: jose base64 encoded dvsni r value
:param str nonce: hex value of nonce
:param key: Key to perform challenge
:type key: :class:`letsencrypt.client.le_util.Key`
:returns: tuple of (cert_pem, s) where
cert_pem is the certificate in pem form
s is the dvsni s value, jose base64 encoded
:rtype: tuple
"""
# Generate S
dvsni_s = Random.get_random_bytes(constants.S_SIZE)
dvsni_r = jose.b64decode(r_b64)
# Generate extension
ext = _dvsni_gen_ext(dvsni_r, dvsni_s)
cert_pem = crypto_util.make_ss_cert(
key.pem, [nonce + constants.DVSNI_DOMAIN_SUFFIX, name, ext])
return cert_pem, jose.b64encode(dvsni_s)
def _dvsni_gen_ext(dvsni_r, dvsni_s):
"""Generates z extension to be placed in certificate extension.
:param bytearray dvsni_r: DVSNI r value
:param bytearray dvsni_s: DVSNI s value
:returns: z + :const:`~letsencrypt.client.constants.DVSNI_DOMAIN_SUFFIX`
:rtype: str
"""
z_base = hashlib.new("sha256")
z_base.update(dvsni_r)
z_base.update(dvsni_s)
return z_base.hexdigest() + constants.DVSNI_DOMAIN_SUFFIX

View file

@ -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,93 +57,99 @@ 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 does not currently handle csr correctly...
: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
:type csr: :class:`CSR`
:returns: cert_file, chain_file (paths to respective files)
:rtype: `tuple` of `str`
:returns: cert_key, cert_path, chain_path
:rtype: `tuple` of (:class:`letsencrypt.client.le_util.Key`, str, str)
"""
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)
cert_key = crypto_util.init_save_key(
self.config.rsa_key_size, self.config.key_dir)
csr = crypto_util.init_save_csr(
cert_key, domains, self.config.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)
cert_path, chain_path = self.save_certificate(
certr, self.config.cert_path, self.config.chain_path)
revoker.Revoker.store_cert_key(
cert_file, self.authkey.file, self.config)
cert_path, self.account.key.file, self.config)
return cert_file, chain_file
return cert_key, cert_path, chain_path
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 +160,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,67 +313,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
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
@ -361,23 +343,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.")
@ -390,6 +381,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.

View file

@ -28,6 +28,8 @@ class NamespaceConfig(object):
zope.interface.implements(interfaces.IConfig)
def __init__(self, namespace):
assert not namespace.server.startswith('https://')
assert not namespace.server.startswith('http://')
self.namespace = namespace
def __getattr__(self, name):
@ -42,11 +44,32 @@ class NamespaceConfig(object):
def in_progress_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.work_dir, constants.IN_PROGRESS_DIR)
@property
def server_path(self):
"""File path based on ``server``."""
return self.namespace.server.replace('/', os.path.sep)
@property
def server_url(self):
"""Full server URL (including HTTPS scheme)."""
return 'https://' + self.namespace.server
@property
def cert_key_backup(self): # pylint: disable=missing-docstring
return os.path.join(
self.namespace.work_dir, constants.CERT_KEY_BACKUP_DIR,
self.namespace.server.partition(":")[0])
self.server_path)
@property
def accounts_dir(self): #pylint: disable=missing-docstring
return os.path.join(
self.namespace.config_dir, constants.ACCOUNTS_DIR, self.server_path)
@property
def account_keys_dir(self): #pylint: disable=missing-docstring
return os.path.join(
self.namespace.config_dir, constants.ACCOUNTS_DIR,
self.server_path, constants.ACCOUNT_KEYS_DIR)
# TODO: This should probably include the server name
@property

View file

@ -1,26 +1,13 @@
"""Let's Encrypt constants."""
import pkg_resources
S_SIZE = 32
"""Size (in bytes) of secret base64-encoded octet string "s" used in
challanges."""
NONCE_SIZE = 16
"""Size of nonce used in JWS objects (in bytes)."""
from letsencrypt.acme import challenges
EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])]
EXCLUSIVE_CHALLENGES = frozenset([frozenset([
challenges.DVSNI, challenges.SimpleHTTPS])])
"""Mutually exclusive challenges."""
DV_CHALLENGES = frozenset(["dvsni", "simpleHttps", "dns"])
"""Challenges that must be solved by a
:class:`letsencrypt.client.interfaces.IAuthenticator` object."""
CLIENT_CHALLENGES = frozenset(
["recoveryToken", "recoveryContact", "proofOfPossession"])
"""Challenges that are handled by the Let's Encrypt client."""
ENHANCEMENTS = ["redirect", "http-header", "ocsp-stapling", "spdy"]
"""List of possible :class:`letsencrypt.client.interfaces.IInstaller`
@ -36,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."""
@ -45,12 +32,14 @@ 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."""
DVSNI_DOMAIN_SUFFIX = ".acme.invalid"
"""Suffix appended to domains in DVSNI validation."""
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)."""
@ -63,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)."""

View file

@ -1,15 +1,17 @@
"""Client Authenticator"""
"""Continuity Authenticator"""
import zope.interface
from letsencrypt.client import challenge_util
from letsencrypt.acme import challenges
from letsencrypt.client import achallenges
from letsencrypt.client import errors
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`
@ -30,22 +32,22 @@ class ClientAuthenticator(object):
def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use
"""Return list of challenge preferences."""
return ["recoveryToken"]
return [challenges.RecoveryToken]
def perform(self, chall_list):
def perform(self, achalls):
"""Perform client specific challenges for IAuthenticator"""
responses = []
for chall in chall_list:
if isinstance(chall, challenge_util.RecTokenChall):
responses.append(self.rec_token.perform(chall))
for achall in achalls:
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, chall_list):
def cleanup(self, achalls):
"""Cleanup call for IAuthenticator."""
for chall in chall_list:
if isinstance(chall, challenge_util.RecTokenChall):
self.rec_token.cleanup(chall)
for achall in achalls:
if isinstance(achall, achallenges.RecoveryToken):
self.rec_token.cleanup(achall)
else:
raise errors.LetsEncryptClientAuthError("Unexpected Challenge")
raise errors.LetsEncryptContAuthError("Unexpected Challenge")

View file

@ -5,6 +5,7 @@
"""
import logging
import os
import time
import Crypto.Hash.SHA256
@ -14,7 +15,75 @@ import Crypto.Signature.PKCS1_v1_5
import M2Crypto
import OpenSSL
from letsencrypt.client import le_util
# High level functions
def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"):
"""Initializes and saves a privkey.
Inits key and saves it in PEM format on the filesystem.
.. note:: keyname is the attempted filename, it may be different if a file
already exists at the path.
:param int key_size: RSA key size in bits
:param str key_dir: Key save directory.
:param str keyname: Filename of key
:returns: Key
:rtype: :class:`letsencrypt.client.le_util.Key`
:raises ValueError: If unable to generate the key given key_size.
"""
try:
key_pem = make_key(key_size)
except ValueError as err:
logging.fatal(str(err))
raise err
# Save file
le_util.make_or_verify_dir(key_dir, 0o700, os.geteuid())
key_f, key_path = le_util.unique_file(
os.path.join(key_dir, keyname), 0o600)
key_f.write(key_pem)
key_f.close()
logging.info("Generating key (%d bits): %s", key_size, key_path)
return le_util.Key(key_path, key_pem)
def init_save_csr(privkey, names, cert_dir, csrname="csr-letsencrypt.pem"):
"""Initialize a CSR with the given private key.
:param privkey: Key to include in the CSR
:type privkey: :class:`letsencrypt.client.le_util.Key`
:param set names: `str` names to include in the CSR
:param str cert_dir: Certificate save directory.
:returns: CSR
:rtype: :class:`letsencrypt.client.le_util.CSR`
"""
csr_pem, csr_der = make_csr(privkey.pem, names)
# Save CSR
le_util.make_or_verify_dir(cert_dir, 0o755)
csr_f, csr_filename = le_util.unique_file(
os.path.join(cert_dir, csrname), 0o644)
csr_f.write(csr_pem)
csr_f.close()
logging.info("Creating CSR: %s", csr_filename)
return le_util.CSR(csr_filename, csr_der, "der")
# Lower level functions
def make_csr(key_str, domains):
"""Generate a CSR.

View file

@ -43,6 +43,28 @@ def choose_authenticator(auths, errs):
return
def choose_account(accounts):
"""Choose an account.
:param list accounts: Containing at least one
:class:`~letsencrypt.client.account.Account`
"""
# Note this will get more complicated once we start recording authorizations
labels = [
"%s | %s" % (acc.email.ljust(display_util.WIDTH - 39),
acc.phone if acc.phone is not None else "")
for acc in accounts
]
code, index = util(interfaces.IDisplay).menu(
"Please choose an account", labels)
if code == display_util.OK:
return accounts[index]
else:
return None
def choose_names(installer):
"""Display screen to select domains to validate.

View file

@ -133,19 +133,21 @@ class NcursesDisplay(object):
message, self.height, self.width,
yes_label=yes_label, no_label=no_label)
def checklist(self, message, tags):
def checklist(self, message, tags, default_status=True):
"""Displays a checklist.
:param message: Message to display before choices
:param list tags: where each is of type :class:`str`
len(tags) > 0
:param list tags: where each is of type :class:`str` len(tags) > 0
:param bool default_status: If True, items are in a selected state by
default.
:returns: tuple of the form (code, list_tags) where
`code` - int display exit code
`list_tags` - list of str tags selected by the user
"""
choices = [(tag, "", False) for tag in tags]
choices = [(tag, "", default_status) for tag in tags]
return self.dialog.checklist(
message, width=self.width, height=self.height, choices=choices)
@ -257,11 +259,13 @@ class FileDisplay(object):
ans.startswith(no_label[0].upper())):
return False
def checklist(self, message, tags):
def checklist(self, message, tags, default_status=True):
# pylint: disable=unused-argument
"""Display a checklist.
:param str message: Message to display to user
:param list tags: `str` tags to select, len(tags) > 0
:param bool default_status: Not used for FileDisplay
:returns: tuple of (`code`, `tags`) where
`code` - str display exit code

View file

@ -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."""

View file

@ -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.
@ -30,43 +34,43 @@ class IAuthenticator(zope.interface.Interface):
:param str domain: Domain for which challenge preferences are sought.
:returns: list of strings with the most preferred challenges first.
If a type is not specified, it means the Authenticator cannot
perform the challenge.
:returns: List of challege types (subclasses of
:class:`letsencrypt.acme.challenges.Challenge`) with the most
preferred challenges first. If a type is not specified, it means the
Authenticator cannot perform the challenge.
:rtype: list
"""
def perform(chall_list):
def perform(achalls):
"""Perform the given challenge.
:param list chall_list: List of namedtuple types defined in
:mod:`letsencrypt.client.challenge_util` (``DvsniChall``, etc.).
:param list achalls: Non-empty (guaranteed) list of
:class:`~letsencrypt.client.achallenges.AnnotatedChallenge`
instances, such that it contains types found within
:func:`get_chall_pref` only.
- chall_list will never be empty
- chall_list will only contain types found within
:func:`get_chall_pref`
:returns: ACME Challenge responses or if it cannot be completed then:
:returns: List of ACME
:class:`~letsencrypt.acme.challenges.ChallengeResponse` instances
or if the :class:`~letsencrypt.acme.challenges.Challenge` cannot
be fulfilled then:
``None``
Authenticator can perform challenge, but can't at this time
Authenticator can perform challenge, but not at this time.
``False``
Authenticator will never be able to perform (error)
Authenticator will never be able to perform (error).
:rtype: :class:`list` of :class:`dict`
:rtype: :class:`list` of
:class:`letsencrypt.acme.challenges.ChallengeResponse`
"""
def cleanup(chall_list):
def cleanup(achalls):
"""Revert changes and shutdown after challenges complete.
:param list chall_list: List of namedtuple types defined in
:mod:`letsencrypt.client.challenge_util` (``DvsniChall``, etc.)
- Only challenges given previously in the perform function will be
found in chall_list.
- chall_list will never be empty
:param list achalls: Non-empty (guaranteed) list of
:class:`~letsencrypt.client.achallenges.AnnotatedChallenge`
instances, a subset of those previously passed to :func:`perform`.
"""
@ -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.
"""

View file

@ -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:

View file

@ -5,11 +5,15 @@ import time
import requests
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)
@ -36,8 +40,8 @@ class Network(object):
:returns: Server response message.
:rtype: :class:`letsencrypt.acme.messages.Message`
:raises TypeError: if `msg` is not JSON serializable
:raises jsonschema.ValidationError: if not valid ACME message
:raises letsencrypt.acme.errors.ValidationError: if `msg` is not
valid serializable ACME JSON message.
:raises errors.LetsEncryptClientError: in case of connection error
or if response from server is not a valid ACME message.
@ -53,7 +57,12 @@ class Network(object):
raise errors.LetsEncryptClientError(
'Sending ACME message to server has failed: %s' % error)
return messages.Message.from_json(response.json(), validate=True)
json_string = response.json()
try:
return messages.Message.from_json(json_string)
except jose.DeserializationError as error:
logging.error(json_string)
raise # TODO
def send_and_receive_expected(self, msg, expected):
"""Send ACME message to server and return expected message.

View file

@ -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')

Some files were not shown because too many files have changed in this diff Show more