mirror of
https://github.com/certbot/certbot.git
synced 2026-06-03 13:59:02 -04:00
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:
commit
dc0f78dd15
207 changed files with 12111 additions and 2986 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,8 +1,13 @@
|
|||
*.pyc
|
||||
*.egg-info
|
||||
.eggs/
|
||||
build/
|
||||
dist/
|
||||
venv/
|
||||
.tox/
|
||||
.coverage
|
||||
m3
|
||||
*~
|
||||
.vagrant
|
||||
*.swp
|
||||
\#*#
|
||||
|
|
|
|||
11
.pylintrc
11
.pylintrc
|
|
@ -38,7 +38,8 @@ load-plugins=linter_plugin
|
|||
# --enable=similarities". If you want to run only the classes checker, but have
|
||||
# no Warning level messages displayed, use"--disable=all --enable=classes
|
||||
# --disable=W"
|
||||
disable=fixme,locally-disabled
|
||||
disable=fixme,locally-disabled,abstract-class-not-used
|
||||
# abstract-class-not-used cannot be disabled locally (at least in pylint 1.4.1)
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
|
@ -148,10 +149,10 @@ module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
|
|||
module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
|
||||
|
||||
# Regular expression matching correct method names
|
||||
method-rgx=[a-z_][a-z0-9_]{2,40}$
|
||||
method-rgx=[a-z_][a-z0-9_]{2,50}$
|
||||
|
||||
# Naming hint for method names
|
||||
method-name-hint=[a-z_][a-z0-9_]{2,40}$
|
||||
method-name-hint=[a-z_][a-z0-9_]{2,50}$
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
15
.travis.yml
15
.travis.yml
|
|
@ -1,10 +1,7 @@
|
|||
language: python
|
||||
|
||||
# please keep this in sync with docs/using.rst (Ubuntu section, apt-get)
|
||||
before_install: >
|
||||
travis_retry sudo apt-get install python python-setuptools
|
||||
python-virtualenv python-dev gcc swig dialog libaugeas0 libssl-dev
|
||||
libffi-dev ca-certificates
|
||||
# http://docs.travis-ci.com/user/ci-environment/#CI-environment-OS
|
||||
before_install: travis_retry sudo ./bootstrap/ubuntu.sh
|
||||
|
||||
install: "travis_retry pip install tox coveralls"
|
||||
script: "travis_retry tox"
|
||||
|
|
@ -22,4 +19,10 @@ env:
|
|||
|
||||
notifications:
|
||||
email: false
|
||||
irc: "chat.freenode.net#letsencrypt"
|
||||
irc:
|
||||
channels:
|
||||
- "chat.freenode.net#letsencrypt"
|
||||
on_success: never
|
||||
on_failure: always
|
||||
use_notice: true
|
||||
skip_join: true
|
||||
|
|
|
|||
18
CONTRIBUTING.md
Normal file
18
CONTRIBUTING.md
Normal 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
|
||||
|
|
@ -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
5
EULA
|
|
@ -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
1
EULA
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
letsencrypt/EULA
|
||||
30
LICENSE.txt
30
LICENSE.txt
|
|
@ -1,4 +1,14 @@
|
|||
Let's Encrypt Preview:
|
||||
Copyright (c) Internet Security Research Group
|
||||
Licensed Apache Version 2.0
|
||||
|
||||
Incorporating code from nginxparser
|
||||
Copyright (c) 2014 Fatih Erikli
|
||||
Licensed MIT
|
||||
|
||||
|
||||
Text of Apache License
|
||||
======================
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
|
@ -173,3 +183,23 @@
|
|||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
|
||||
Text of MIT License
|
||||
===================
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
|
|||
|
|
@ -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 *
|
||||
|
|
|
|||
|
|
@ -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
30
Vagrantfile
vendored
Normal 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
2
bootstrap/README
Normal 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
35
bootstrap/_deb_common.sh
Executable 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
1
bootstrap/debian.sh
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
_deb_common.sh
|
||||
2
bootstrap/mac.sh
Executable file
2
bootstrap/mac.sh
Executable file
|
|
@ -0,0 +1,2 @@
|
|||
#!/bin/sh
|
||||
brew install augeas swig
|
||||
1
bootstrap/ubuntu.sh
Symbolic link
1
bootstrap/ubuntu.sh
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
_deb_common.sh
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
:mod:`letsencrypt.acme.errors`
|
||||
------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.acme.errors
|
||||
:members:
|
||||
61
docs/api/acme/index.rst
Normal file
61
docs/api/acme/index.rst
Normal 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:
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
:mod:`letsencrypt.acme.interfaces`
|
||||
----------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.acme.interfaces
|
||||
:members:
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
:mod:`letsencrypt.acme.messages`
|
||||
--------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.acme.messages
|
||||
:members:
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
:mod:`letsencrypt.acme.other`
|
||||
-----------------------------
|
||||
|
||||
.. automodule:: letsencrypt.acme.other
|
||||
:members:
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
:mod:`letsencrypt.acme.util`
|
||||
----------------------------
|
||||
|
||||
.. automodule:: letsencrypt.acme.util
|
||||
:members:
|
||||
5
docs/api/client/account.rst
Normal file
5
docs/api/client/account.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.client.account`
|
||||
---------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.client.account
|
||||
:members:
|
||||
5
docs/api/client/achallenges.rst
Normal file
5
docs/api/client/achallenges.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.client.achallenges`
|
||||
-------------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.client.achallenges
|
||||
:members:
|
||||
|
|
@ -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:
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
:mod:`letsencrypt.client.challenge_util`
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.client.challenge_util
|
||||
:members:
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
:mod:`letsencrypt.client.client_authenticator`
|
||||
----------------------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.client.client_authenticator
|
||||
:members:
|
||||
5
docs/api/client/continuity_auth.rst
Normal file
5
docs/api/client/continuity_auth.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.client.continuity_auth`
|
||||
-----------------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.client.continuity_auth
|
||||
:members:
|
||||
5
docs/api/client/network2.rst
Normal file
5
docs/api/client/network2.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
:mod:`letsencrypt.client.network2`
|
||||
----------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.client.network2
|
||||
:members:
|
||||
29
docs/api/client/plugins/apache.rst
Normal file
29
docs/api/client/plugins/apache.rst
Normal 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:
|
||||
35
docs/api/client/plugins/nginx.rst
Normal file
35
docs/api/client/plugins/nginx.rst
Normal 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:
|
||||
11
docs/api/client/plugins/standalone.rst
Normal file
11
docs/api/client/plugins/standalone.rst
Normal 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:
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
:mod:`letsencrypt.client.standalone_authenticator`
|
||||
--------------------------------------------------
|
||||
|
||||
.. automodule:: letsencrypt.client.standalone_authenticator
|
||||
:members:
|
||||
|
|
@ -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
203
docs/contributing.rst
Normal 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.
|
||||
|
|
@ -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
19
docs/plugins.rst
Normal 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
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
================================
|
||||
The Let's Encrypt Client Project
|
||||
================================
|
||||
|
||||
.. include:: ../CONTRIBUTING.rst
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
18
examples/plugins/letsencrypt_example_plugins.py
Normal file
18
examples/plugins/letsencrypt_example_plugins.py
Normal 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
16
examples/plugins/setup.py
Normal 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
42
examples/restified.py
Normal 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
|
||||
|
|
@ -1 +0,0 @@
|
|||
letsencrypt/scripts/main.py
|
||||
5
letsencrypt/EULA
Normal file
5
letsencrypt/EULA
Normal 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/.
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
"""
|
||||
|
|
|
|||
252
letsencrypt/acme/challenges.py
Normal file
252
letsencrypt/acme/challenges.py
Normal 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"
|
||||
458
letsencrypt/acme/challenges_test.py
Normal file
458
letsencrypt/acme/challenges_test.py
Normal 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()
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
25
letsencrypt/acme/fields.py
Normal file
25
letsencrypt/acme/fields.py
Normal 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)
|
||||
35
letsencrypt/acme/fields_test.py
Normal file
35
letsencrypt/acme/fields_test.py
Normal 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, '')
|
||||
|
|
@ -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
|
||||
|
||||
"""
|
||||
|
|
@ -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)))
|
||||
75
letsencrypt/acme/jose/__init__.py
Normal file
75
letsencrypt/acme/jose/__init__.py
Normal 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,
|
||||
)
|
||||
58
letsencrypt/acme/jose/b64.py
Normal file
58
letsencrypt/acme/jose/b64.py
Normal 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)))
|
||||
|
|
@ -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):
|
||||
31
letsencrypt/acme/jose/errors.py
Normal file
31
letsencrypt/acme/jose/errors.py
Normal 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)
|
||||
17
letsencrypt/acme/jose/errors_test.py
Normal file
17
letsencrypt/acme/jose/errors_test.py
Normal 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()
|
||||
205
letsencrypt/acme/jose/interfaces.py
Normal file
205
letsencrypt/acme/jose/interfaces.py
Normal 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')
|
||||
115
letsencrypt/acme/jose/interfaces_test.py
Normal file
115
letsencrypt/acme/jose/interfaces_test.py
Normal 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()
|
||||
402
letsencrypt/acme/jose/json_util.py
Normal file
402
letsencrypt/acme/jose/json_util.py
Normal 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))
|
||||
297
letsencrypt/acme/jose/json_util_test.py
Normal file
297
letsencrypt/acme/jose/json_util_test.py
Normal 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()
|
||||
133
letsencrypt/acme/jose/jwa.py
Normal file
133
letsencrypt/acme/jose/jwa.py
Normal 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'))
|
||||
105
letsencrypt/acme/jose/jwa_test.py
Normal file
105
letsencrypt/acme/jose/jwa_test.py
Normal 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()
|
||||
140
letsencrypt/acme/jose/jwk.py
Normal file
140
letsencrypt/acme/jose/jwk.py
Normal 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),
|
||||
}
|
||||
107
letsencrypt/acme/jose/jwk_test.py
Normal file
107
letsencrypt/acme/jose/jwk_test.py
Normal 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()
|
||||
406
letsencrypt/acme/jose/jws.py
Normal file
406
letsencrypt/acme/jose/jws.py
Normal 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
|
||||
241
letsencrypt/acme/jose/jws_test.py
Normal file
241
letsencrypt/acme/jose/jws_test.py
Normal 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
10
letsencrypt/acme/jose/testdata/README
vendored
Normal 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
10
letsencrypt/acme/jose/testdata/csr2.pem
vendored
Normal 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-----
|
||||
15
letsencrypt/acme/jose/testdata/rsa1024_key.pem
vendored
Normal file
15
letsencrypt/acme/jose/testdata/rsa1024_key.pem
vendored
Normal 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-----
|
||||
6
letsencrypt/acme/jose/testdata/rsa256_key.pem
vendored
Normal file
6
letsencrypt/acme/jose/testdata/rsa256_key.pem
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIGrAgEAAiEAm2Fylv+Uz7trgTW8EBHP3FQSMeZs2GNQ6VRo1sIVJEkCAwEAAQIh
|
||||
AJT0BA/xD01dFCAXzSNyj9nfSZa3NpqzJZZn/eOm7vghAhEAzUVNZn4lLLBD1R6N
|
||||
E8TKNQIRAMHHyn3O5JeY36lwKwkUlEUCEAliRauN0L0+QZuYjfJ9aJECEGx4dru3
|
||||
rTPCyighdqWNlHUCEQCiLjlwSRtWgmMBudCkVjzt
|
||||
-----END RSA PRIVATE KEY-----
|
||||
149
letsencrypt/acme/jose/util.py
Normal file
149
letsencrypt/acme/jose/util.py
Normal 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()))
|
||||
140
letsencrypt/acme/jose/util_test.py
Normal file
140
letsencrypt/acme/jose/util_test.py
Normal 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()
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
303
letsencrypt/acme/messages2.py
Normal file
303
letsencrypt/acme/messages2.py
Normal 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)
|
||||
249
letsencrypt/acme/messages2_test.py
Normal file
249
letsencrypt/acme/messages2_test.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__':
|
||||
|
|
|
|||
|
|
@ -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__))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
231
letsencrypt/client/account.py
Normal file
231
letsencrypt/client/account.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""Creates ACME accounts for server."""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import configobj
|
||||
import zope.component
|
||||
|
||||
from letsencrypt.acme import messages2
|
||||
|
||||
from letsencrypt.client import crypto_util
|
||||
from letsencrypt.client import errors
|
||||
from letsencrypt.client import interfaces
|
||||
from letsencrypt.client import le_util
|
||||
|
||||
from letsencrypt.client.display import util as display_util
|
||||
|
||||
|
||||
class Account(object):
|
||||
"""ACME protocol registration.
|
||||
|
||||
:ivar config: Client configuration object
|
||||
:type config: :class:`~letsencrypt.client.interfaces.IConfig`
|
||||
:ivar key: Account/Authorized Key
|
||||
:type key: :class:`~letsencrypt.client.le_util.Key`
|
||||
|
||||
:ivar str email: Client's email address
|
||||
:ivar str phone: Client's phone number
|
||||
|
||||
:ivar regr: Registration Resource
|
||||
:type regr: :class:`~letsencrypt.acme.messages2.RegistrationResource`
|
||||
|
||||
"""
|
||||
|
||||
# Just make sure we don't get pwned
|
||||
# Make sure that it also doesn't start with a period or have two consecutive
|
||||
# periods <- this needs to be done in addition to the regex
|
||||
EMAIL_REGEX = re.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$")
|
||||
|
||||
def __init__(self, config, key, email=None, phone=None, regr=None):
|
||||
le_util.make_or_verify_dir(
|
||||
config.accounts_dir, 0o700, os.geteuid())
|
||||
self.key = key
|
||||
self.config = config
|
||||
if email is not None and self.safe_email(email):
|
||||
self.email = email
|
||||
else:
|
||||
self.email = None
|
||||
self.phone = phone
|
||||
|
||||
self.regr = regr
|
||||
|
||||
@property
|
||||
def uri(self):
|
||||
"""URI link for new registrations."""
|
||||
if self.regr is not None:
|
||||
return self.regr.uri
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def new_authzr_uri(self): # pylint: disable=missing-docstring
|
||||
if self.regr is not None:
|
||||
return self.regr.new_authzr_uri
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def terms_of_service(self): # pylint: disable=missing-docstring
|
||||
if self.regr is not None:
|
||||
return self.regr.terms_of_service
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def recovery_token(self): # pylint: disable=missing-docstring
|
||||
if self.regr is not None and self.regr.body is not None:
|
||||
return self.regr.body.recovery_token
|
||||
else:
|
||||
return None
|
||||
|
||||
def save(self):
|
||||
"""Save account to disk."""
|
||||
le_util.make_or_verify_dir(
|
||||
self.config.accounts_dir, 0o700, os.geteuid())
|
||||
|
||||
acc_config = configobj.ConfigObj()
|
||||
acc_config.filename = os.path.join(
|
||||
self.config.accounts_dir, self._get_config_filename(self.email))
|
||||
|
||||
acc_config.initial_comment = [
|
||||
"DO NOT EDIT THIS FILE",
|
||||
"Account information for %s under %s" % (
|
||||
self._get_config_filename(self.email), self.config.server),
|
||||
]
|
||||
|
||||
acc_config["key"] = self.key.file
|
||||
acc_config["phone"] = self.phone
|
||||
|
||||
if self.regr is not None:
|
||||
acc_config["RegistrationResource"] = {}
|
||||
acc_config["RegistrationResource"]["uri"] = self.uri
|
||||
acc_config["RegistrationResource"]["new_authzr_uri"] = (
|
||||
self.new_authzr_uri)
|
||||
acc_config["RegistrationResource"]["terms_of_service"] = (
|
||||
self.terms_of_service)
|
||||
|
||||
regr_dict = self.regr.body.to_json()
|
||||
acc_config["RegistrationResource"]["body"] = regr_dict
|
||||
|
||||
acc_config.write()
|
||||
|
||||
@classmethod
|
||||
def _get_config_filename(cls, email):
|
||||
return email if email is not None and email else "default"
|
||||
|
||||
@classmethod
|
||||
def from_existing_account(cls, config, email=None):
|
||||
"""Populate an account from an existing email."""
|
||||
config_fp = os.path.join(
|
||||
config.accounts_dir, cls._get_config_filename(email))
|
||||
return cls._from_config_fp(config, config_fp)
|
||||
|
||||
@classmethod
|
||||
def _from_config_fp(cls, config, config_fp):
|
||||
try:
|
||||
acc_config = configobj.ConfigObj(
|
||||
infile=config_fp, file_error=True, create_empty=False)
|
||||
except IOError:
|
||||
raise errors.LetsEncryptClientError(
|
||||
"Account for %s does not exist" % os.path.basename(config_fp))
|
||||
|
||||
if os.path.basename(config_fp) != "default":
|
||||
email = os.path.basename(config_fp)
|
||||
else:
|
||||
email = None
|
||||
phone = acc_config["phone"] if acc_config["phone"] != "None" else None
|
||||
|
||||
with open(acc_config["key"]) as key_file:
|
||||
key = le_util.Key(acc_config["key"], key_file.read())
|
||||
|
||||
if "RegistrationResource" in acc_config:
|
||||
acc_config_rr = acc_config["RegistrationResource"]
|
||||
regr = messages2.RegistrationResource(
|
||||
uri=acc_config_rr["uri"],
|
||||
new_authzr_uri=acc_config_rr["new_authzr_uri"],
|
||||
terms_of_service=acc_config_rr["terms_of_service"],
|
||||
body=messages2.Registration.from_json(acc_config_rr["body"]))
|
||||
else:
|
||||
regr = None
|
||||
|
||||
return cls(config, key, email, phone, regr)
|
||||
|
||||
@classmethod
|
||||
def get_accounts(cls, config):
|
||||
"""Return all current accounts.
|
||||
|
||||
:param config: Configuration
|
||||
:type config: :class:`letsencrypt.client.interfaces.IConfig`
|
||||
|
||||
"""
|
||||
try:
|
||||
filenames = os.listdir(config.accounts_dir)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
accounts = []
|
||||
for name in filenames:
|
||||
# Not some directory ie. keys
|
||||
config_fp = os.path.join(config.accounts_dir, name)
|
||||
if os.path.isfile(config_fp):
|
||||
accounts.append(cls._from_config_fp(config, config_fp))
|
||||
|
||||
return accounts
|
||||
|
||||
@classmethod
|
||||
def from_prompts(cls, config):
|
||||
"""Generate an account from prompted user input.
|
||||
|
||||
:param config: Configuration
|
||||
:type config: :class:`letsencrypt.client.interfaces.IConfig`
|
||||
|
||||
:returns: Account or None
|
||||
:rtype: :class:`letsencrypt.client.account.Account`
|
||||
|
||||
"""
|
||||
while True:
|
||||
code, email = zope.component.getUtility(interfaces.IDisplay).input(
|
||||
"Enter email address (optional, press Enter to skip)")
|
||||
|
||||
if code == display_util.OK:
|
||||
try:
|
||||
return cls.from_email(config, email)
|
||||
except errors.LetsEncryptClientError:
|
||||
continue
|
||||
else:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_email(cls, config, email):
|
||||
"""Generate a new account from an email address.
|
||||
|
||||
:param config: Configuration
|
||||
:type config: :class:`letsencrypt.client.interfaces.IConfig`
|
||||
|
||||
:param str email: Email address
|
||||
|
||||
:raises letsencrypt.client.errors.LetsEncryptClientError: If invalid
|
||||
email address is given.
|
||||
|
||||
"""
|
||||
if not email or cls.safe_email(email):
|
||||
email = email if email else None
|
||||
|
||||
le_util.make_or_verify_dir(
|
||||
config.account_keys_dir, 0o700, os.geteuid())
|
||||
key = crypto_util.init_save_key(
|
||||
config.rsa_key_size, config.account_keys_dir,
|
||||
cls._get_config_filename(email))
|
||||
return cls(config, key, email)
|
||||
|
||||
raise errors.LetsEncryptClientError("Invalid email address.")
|
||||
|
||||
@classmethod
|
||||
def safe_email(cls, email):
|
||||
"""Scrub email address before using it."""
|
||||
if cls.EMAIL_REGEX.match(email):
|
||||
return not email.startswith(".") and ".." not in email
|
||||
else:
|
||||
logging.warn("Invalid email address.")
|
||||
return False
|
||||
92
letsencrypt/client/achallenges.py
Normal file
92
letsencrypt/client/achallenges.py
Normal 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
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""Let's Encrypt client.apache."""
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,28 @@ def choose_authenticator(auths, errs):
|
|||
return
|
||||
|
||||
|
||||
def choose_account(accounts):
|
||||
"""Choose an account.
|
||||
|
||||
:param list accounts: Containing at least one
|
||||
:class:`~letsencrypt.client.account.Account`
|
||||
|
||||
"""
|
||||
# Note this will get more complicated once we start recording authorizations
|
||||
labels = [
|
||||
"%s | %s" % (acc.email.ljust(display_util.WIDTH - 39),
|
||||
acc.phone if acc.phone is not None else "")
|
||||
for acc in accounts
|
||||
]
|
||||
|
||||
code, index = util(interfaces.IDisplay).menu(
|
||||
"Please choose an account", labels)
|
||||
if code == display_util.OK:
|
||||
return accounts[index]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def choose_names(installer):
|
||||
"""Display screen to select domains to validate.
|
||||
|
||||
|
|
|
|||
|
|
@ -133,19 +133,21 @@ class NcursesDisplay(object):
|
|||
message, self.height, self.width,
|
||||
yes_label=yes_label, no_label=no_label)
|
||||
|
||||
def checklist(self, message, tags):
|
||||
def checklist(self, message, tags, default_status=True):
|
||||
"""Displays a checklist.
|
||||
|
||||
:param message: Message to display before choices
|
||||
:param list tags: where each is of type :class:`str`
|
||||
len(tags) > 0
|
||||
:param list tags: where each is of type :class:`str` len(tags) > 0
|
||||
:param bool default_status: If True, items are in a selected state by
|
||||
default.
|
||||
|
||||
|
||||
:returns: tuple of the form (code, list_tags) where
|
||||
`code` - int display exit code
|
||||
`list_tags` - list of str tags selected by the user
|
||||
|
||||
"""
|
||||
choices = [(tag, "", False) for tag in tags]
|
||||
choices = [(tag, "", default_status) for tag in tags]
|
||||
return self.dialog.checklist(
|
||||
message, width=self.width, height=self.height, choices=choices)
|
||||
|
||||
|
|
@ -257,11 +259,13 @@ class FileDisplay(object):
|
|||
ans.startswith(no_label[0].upper())):
|
||||
return False
|
||||
|
||||
def checklist(self, message, tags):
|
||||
def checklist(self, message, tags, default_status=True):
|
||||
# pylint: disable=unused-argument
|
||||
"""Display a checklist.
|
||||
|
||||
:param str message: Message to display to user
|
||||
:param list tags: `str` tags to select, len(tags) > 0
|
||||
:param bool default_status: Not used for FileDisplay
|
||||
|
||||
:returns: tuple of (`code`, `tags`) where
|
||||
`code` - str display exit code
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
547
letsencrypt/client/network2.py
Normal file
547
letsencrypt/client/network2.py
Normal 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
Loading…
Reference in a new issue