Merge branch 'master' into fix_272

This commit is contained in:
James Kasten 2015-03-24 16:18:36 -07:00
commit 26ec3cfcea
57 changed files with 3213 additions and 1364 deletions

3
.gitignore vendored
View file

@ -6,3 +6,6 @@ venv/
.tox/
.coverage
m3
*~
.vagrant
*.swp

View file

@ -38,7 +38,8 @@ load-plugins=linter_plugin
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use"--disable=all --enable=classes
# --disable=W"
disable=fixme,locally-disabled
disable=fixme,locally-disabled,abstract-class-not-used
# abstract-class-not-used cannot be disabled locally (at least in pylint 1.4.1)
[REPORTS]
@ -148,10 +149,10 @@ module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
# Regular expression matching correct method names
method-rgx=[a-z_][a-z0-9_]{2,40}$
method-rgx=[a-z_][a-z0-9_]{2,50}$
# Naming hint for method names
method-name-hint=[a-z_][a-z0-9_]{2,40}$
method-name-hint=[a-z_][a-z0-9_]{2,50}$
# Regular expression which should only match function or class names that do
# not require a docstring.
@ -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

18
CONTRIBUTING.md Normal file
View file

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

View file

@ -1,72 +0,0 @@
.. _hacking:
Hacking
=======
In order to start hacking, you will first have to create a development
environment:
::
./venv/bin/python setup.py dev
The code base, including your pull requests, **must** have 100% test statement
coverage **and** be compliant with the :ref:`coding-style`.
The following tools are there to help you:
- ``./venv/bin/tox`` starts a full set of tests. Please make sure you
run it before submitting a new pull request.
- ``./venv/bin/tox -e cover`` checks the test coverage only.
- ``./venv/bin/tox -e lint`` checks the style of the whole project,
while ``./venv/bin/pylint --rcfile=.pylintrc file`` will check a single `file` only.
.. _coding-style:
Coding style
============
Please:
1. **Be consistent with the rest of the code**.
2. Read `PEP 8 - Style Guide for Python Code`_.
3. Follow the `Google Python Style Guide`_, with the exception that we
use `Sphinx-style`_ documentation:
::
def foo(arg):
"""Short description.
:param int arg: Some number.
:returns: Argument
:rtype: int
"""
return arg
4. Remember to use ``./venv/bin/pylint``.
.. _Google Python Style Guide: https://google-styleguide.googlecode.com/svn/trunk/pyguide.html
.. _Sphinx-style: http://sphinx-doc.org/
.. _PEP 8 - Style Guide for Python Code: https://www.python.org/dev/peps/pep-0008
Updating the Documentation
==========================
In order to generate the Sphinx documentation, run the following commands.
::
cd docs
make clean html SPHINXBUILD=../venv/bin/sphinx-build
This should generate documentation in the ``docs/_build/html`` directory.

View file

@ -1,6 +1,6 @@
include README.rst
include CHANGES.rst
include CONTRIBUTING.rst
include CONTRIBUTING.md
include linter_plugin.py
include letsencrypt/EULA
recursive-include letsencrypt *.json

View file

@ -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.rst_
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.rst: https://github.com/letsencrypt/lets-encrypt-preview/blob/master/CONTRIBUTING.rst

32
Vagrantfile vendored Normal file
View file

@ -0,0 +1,32 @@
# -*- 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
sudo apt-get update
sudo apt-get install -y python python-setuptools python-virtualenv python-dev gcc swig dialog libaugeas0 libssl-dev libffi-dev ca-certificates
cd /vagrant
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

View file

@ -5,12 +5,6 @@
:members:
Interfaces
----------
.. automodule:: letsencrypt.acme.interfaces
:members:
Messages
--------
@ -46,6 +40,3 @@ Utilities
.. automodule:: letsencrypt.acme.util
:members:
.. automodule:: letsencrypt.acme.jose
:members:

67
docs/api/acme/jose.rst Normal file
View file

@ -0,0 +1,67 @@
:mod:`letsencrypt.acme.jose`
============================
.. contents::
.. automodule:: letsencrypt.acme.jose
:members:
JSON Web Algorithms
-------------------
.. automodule:: letsencrypt.acme.jose.jwa
:members:
JSON Web Key
------------
.. automodule:: letsencrypt.acme.jose.jwk
:members:
JSON Web Signature
------------------
.. automodule:: letsencrypt.acme.jose.jws
:members:
Implementation details
----------------------
Interfaces
~~~~~~~~~~
.. automodule:: letsencrypt.acme.jose.interfaces
:members:
Errors
~~~~~~
.. automodule:: letsencrypt.acme.jose.errors
:members:
JSON utilities
~~~~~~~~~~~~~~
.. automodule:: letsencrypt.acme.jose.json_util
:members:
JOSE Base64
~~~~~~~~~~~
.. automodule:: letsencrypt.acme.jose.b64
:members:
Utilities
~~~~~~~~~
.. automodule:: letsencrypt.acme.jose.util
:members:

View file

@ -55,7 +55,7 @@ extensions = [
]
autodoc_member_order = 'bysource'
autodoc_default_flags = ['show-inheritance']
autodoc_default_flags = ['show-inheritance', 'private-members']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@ -101,7 +101,7 @@ exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all
# documents.
#default_role = None
default_role = 'py:obj'
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True

197
docs/contributing.rst Normal file
View file

@ -0,0 +1,197 @@
============
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 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.
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 client specific
challenges (subclasses of `~.ClientChallenge`,
i.e. `~.challenges.RecoveryToken`, `~.challenges.RecoveryContact`,
`~.challenges.ProofOfPossession`). Client specific challenges are
always handled by the `~.ClientAuthenticator`. Right now we have two
DV Authenticators, `~.ApacheConfigurator` and the
`~.StandaloneAuthenticator`. The Standalone and Apache authenticators
only solve the `~.challenges.DVSNI` challenge currently. (You can set
which challenges your authenticator can handle through the
: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. (I am imagining MTA installers).
Display
~~~~~~~
We currently offer a pythondialog and "text" mode for displays. I have
rewritten the interface which should be merged within the next day
(the rewrite is in the revoker branch of the repo and should be merged
within the next day). Display plugins implement
`~letsencrypt.client.interfaces.IDisplay` interface.
Augeas
------
Some plugins, especially those designed to reconfigure UNIX servers,
can take inherit from `~.AugeasConfigurator` class in order to more
efficiently handle common operations on UNIX server configuration
files.
.. _coding-style:
Coding style
============
Please:
1. **Be consistent with the rest of the code**.
2. Read `PEP 8 - Style Guide for Python Code`_.
3. Follow the `Google Python Style Guide`_, with the exception that we
use `Sphinx-style`_ documentation::
def foo(arg):
"""Short description.
:param int arg: Some number.
:returns: Argument
:rtype: int
"""
return arg
4. Remember to use ``./venv/bin/pylint``.
.. _Google Python Style Guide:
https://google-styleguide.googlecode.com/svn/trunk/pyguide.html
.. _Sphinx-style: http://sphinx-doc.org/
.. _PEP 8 - Style Guide for Python Code:
https://www.python.org/dev/peps/pep-0008
Updating the documentation
==========================
In order to generate the Sphinx documentation, run the following
commands:
.. code-block:: shell
cd docs
make clean html SPHINXBUILD=../venv/bin/sphinx-build
This should generate documentation in the ``docs/_build/html``
directory.

View file

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

View file

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

View file

@ -21,30 +21,30 @@ In general:
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 apt-get install python python-setuptools python-virtualenv python-dev \
gcc swig dialog libaugeas0 libssl-dev libffi-dev \
ca-certificates
.. Please keep the above command in sync with .travis.yml (before_install)
Mac OSX
-------
::
.. code-block:: shell
sudo brew install augeas swig
sudo brew install augeas swig
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 +52,7 @@ Usage
The letsencrypt commandline tool has a builtin help:
::
.. code-block:: shell
./venv/bin/letsencrypt --help

View file

@ -7,13 +7,12 @@ import Crypto.Random
from letsencrypt.acme import jose
from letsencrypt.acme import other
from letsencrypt.acme import util
# pylint: disable=too-few-public-methods
class Challenge(util.TypedACMEObject):
class Challenge(jose.TypedJSONObjectWithFields):
# _fields_to_json | pylint: disable=abstract-method
"""ACME challenge."""
TYPES = {}
@ -27,40 +26,33 @@ class DVChallenge(Challenge): # pylint: disable=abstract-method
"""Domain validation challenges."""
class ChallengeResponse(util.TypedACMEObject):
class ChallengeResponse(jose.TypedJSONObjectWithFields):
# _fields_to_json | pylint: disable=abstract-method
"""ACME challenge response."""
TYPES = {}
@classmethod
def from_valid_json(cls, jobj):
def from_json(cls, jobj):
if jobj is None:
# if the client chooses not to respond to a given
# challenge, then the corresponding entry in the response
# array is set to None (null)
return None
return super(ChallengeResponse, cls).from_valid_json(jobj)
return super(ChallengeResponse, cls).from_json(jobj)
@Challenge.register
class SimpleHTTPS(DVChallenge):
"""ACME "simpleHttps" challenge."""
acme_type = "simpleHttps"
__slots__ = ("token",)
def _fields_to_json(self):
return {"token": self.token}
@classmethod
def from_valid_json(cls, jobj):
return cls(token=jobj["token"])
typ = "simpleHttps"
token = jose.Field("token")
@ChallengeResponse.register
class SimpleHTTPSResponse(ChallengeResponse):
"""ACME "simpleHttps" challenge response."""
acme_type = "simpleHttps"
__slots__ = ("path",)
typ = "simpleHttps"
path = jose.Field("path")
URI_TEMPLATE = "https://{domain}/.well-known/acme-challenge/{path}"
"""URI template for HTTPS server provisioned resource."""
@ -76,13 +68,6 @@ class SimpleHTTPSResponse(ChallengeResponse):
"""
return self.URI_TEMPLATE.format(domain=domain, path=self.path)
def _fields_to_json(self):
return {"path": self.path}
@classmethod
def from_valid_json(cls, jobj):
return cls(path=jobj["path"])
@Challenge.register
class DVSNI(DVChallenge):
@ -92,8 +77,7 @@ class DVSNI(DVChallenge):
:ivar str nonce: Random data, **not** hex-encoded.
"""
acme_type = "dvsni"
__slots__ = ("r", "nonce")
typ = "dvsni"
DOMAIN_SUFFIX = ".acme.invalid"
"""Domain name suffix."""
@ -104,22 +88,17 @@ class DVSNI(DVChallenge):
NONCE_SIZE = 16
"""Required size of the :attr:`nonce` in bytes."""
r = jose.Field("r", encoder=jose.b64encode, # pylint: disable=invalid-name
decoder=functools.partial(jose.decode_b64jose, size=R_SIZE))
nonce = jose.Field("nonce", encoder=binascii.hexlify,
decoder=functools.partial(functools.partial(
jose.decode_hex16, size=NONCE_SIZE)))
@property
def nonce_domain(self):
"""Domain name used in SNI."""
return binascii.hexlify(self.nonce) + self.DOMAIN_SUFFIX
def _fields_to_json(self):
return {
"r": jose.b64encode(self.r),
"nonce": binascii.hexlify(self.nonce),
}
@classmethod
def from_valid_json(cls, jobj):
return cls(r=util.decode_b64jose(jobj["r"], cls.R_SIZE),
nonce=util.decode_hex16(jobj["nonce"], cls.NONCE_SIZE))
@ChallengeResponse.register
class DVSNIResponse(ChallengeResponse):
@ -128,8 +107,7 @@ class DVSNIResponse(ChallengeResponse):
:param str s: Random data, **not** base64-encoded.
"""
acme_type = "dvsni"
__slots__ = ("s",)
typ = "dvsni"
DOMAIN_SUFFIX = DVSNI.DOMAIN_SUFFIX
"""Domain name suffix."""
@ -137,6 +115,9 @@ class DVSNIResponse(ChallengeResponse):
S_SIZE = 32
"""Required size of the :attr:`s` in bytes."""
s = jose.Field("s", encoder=jose.b64encode, # pylint: disable=invalid-name
decoder=functools.partial(jose.decode_b64jose, size=S_SIZE))
def __init__(self, s=None, *args, **kwargs):
s = Crypto.Random.get_random_bytes(self.S_SIZE) if s is None else s
super(DVSNIResponse, self).__init__(s=s, *args, **kwargs)
@ -157,90 +138,34 @@ class DVSNIResponse(ChallengeResponse):
"""Domain name for certificate subjectAltName."""
return self.z(chall) + self.DOMAIN_SUFFIX
def _fields_to_json(self):
return {"s": jose.b64encode(self.s)}
@classmethod
def from_valid_json(cls, jobj):
return cls(s=util.decode_b64jose(jobj["s"], cls.S_SIZE))
@Challenge.register
class RecoveryContact(ClientChallenge):
"""ACME "recoveryContact" challenge."""
acme_type = "recoveryContact"
__slots__ = ("activation_url", "success_url", "contact")
typ = "recoveryContact"
def _fields_to_json(self):
fields = {}
add = functools.partial(_extend_if_not_none, fields)
add(self.activation_url, "activationURL")
add(self.success_url, "successURL")
add(self.contact, "contact")
return fields
@classmethod
def from_valid_json(cls, jobj):
return cls(activation_url=jobj.get("activationURL"),
success_url=jobj.get("successURL"),
contact=jobj.get("contact"))
activation_url = jose.Field("activationURL", omitempty=True)
success_url = jose.Field("successURL", omitempty=True)
contact = jose.Field("contact", omitempty=True)
@ChallengeResponse.register
class RecoveryContactResponse(ChallengeResponse):
"""ACME "recoveryContact" challenge response."""
acme_type = "recoveryContact"
__slots__ = ("token",)
def _fields_to_json(self):
fields = {}
if self.token is not None:
fields["token"] = self.token
return fields
@classmethod
def from_valid_json(cls, jobj):
return cls(token=jobj.get("token"))
typ = "recoveryContact"
token = jose.Field("token", omitempty=True)
@Challenge.register
class RecoveryToken(ClientChallenge):
"""ACME "recoveryToken" challenge."""
acme_type = "recoveryToken"
__slots__ = ()
def _fields_to_json(self):
return {}
@classmethod
def from_valid_json(cls, jobj):
return cls()
typ = "recoveryToken"
@ChallengeResponse.register
class RecoveryTokenResponse(ChallengeResponse):
"""ACME "recoveryToken" challenge response."""
acme_type = "recoveryToken"
__slots__ = ("token",)
def _fields_to_json(self):
fields = {}
if self.token is not None:
fields["token"] = self.token
return fields
@classmethod
def from_valid_json(cls, jobj):
return cls(token=jobj.get("token"))
def _extend_if_not_empty(dikt, param, name):
if param:
dikt[name] = param
def _extend_if_not_none(dikt, param, name):
if param is not None:
dikt[name] = param
typ = "recoveryToken"
token = jose.Field("token", omitempty=True)
@Challenge.register
@ -251,57 +176,40 @@ class ProofOfPossession(ClientChallenge):
:ivar hints: Various clues for the client (:class:`Hints`).
"""
acme_type = "proofOfPossession"
__slots__ = ("alg", "nonce", "hints")
typ = "proofOfPossession"
NONCE_SIZE = 16
class Hints(util.ACMEObject):
class Hints(jose.JSONObjectWithFields):
"""Hints for "proofOfPossession" challenge.
:ivar jwk: JSON Web Key (:class:`letsencrypt.acme.other.JWK`)
:ivar jwk: JSON Web Key (:class:`letsencrypt.acme.jose.JWK`)
:ivar list certs: List of :class:`M2Crypto.X509.X509` cetificates.
"""
__slots__ = (
"jwk", "cert_fingerprints", "certs", "subject_key_identifiers",
"serial_numbers", "issuers", "authorized_for")
jwk = jose.Field("jwk", decoder=jose.JWK.from_json)
cert_fingerprints = jose.Field(
"certFingerprints", omitempty=True, default=())
certs = jose.Field("certs", omitempty=True, default=())
subject_key_identifiers = jose.Field(
"subjectKeyIdentifiers", omitempty=True, default=())
serial_numbers = jose.Field("serialNumbers", omitempty=True, default=())
issuers = jose.Field("issuers", omitempty=True, default=())
authorized_for = jose.Field("authorizedFor", omitempty=True, default=())
def to_json(self):
fields = {"jwk": self.jwk}
add = functools.partial(_extend_if_not_empty, fields)
add(self.cert_fingerprints, "certFingerprints")
add([util.encode_cert(cert) for cert in self.certs], "certs")
add(self.subject_key_identifiers, "subjectKeyIdentifiers")
add(self.serial_numbers, "serialNumbers")
add(self.issuers, "issuers")
add(self.authorized_for, "authorizedFor")
return fields
@certs.encoder
def certs(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(jose.encode_cert(cert) for cert in value)
@classmethod
def from_valid_json(cls, jobj):
return cls(
jwk=other.JWK.from_valid_json(jobj["jwk"]),
cert_fingerprints=jobj.get("certFingerprints", []),
certs=[util.decode_cert(cert)
for cert in jobj.get("certs", [])],
subject_key_identifiers=jobj.get("subjectKeyIdentifiers", []),
serial_numbers=jobj.get("serialNumbers", []),
issuers=jobj.get("issuers", []),
authorized_for=jobj.get("authorizedFor", []))
@certs.decoder
def certs(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(jose.decode_cert(cert) for cert in value)
def _fields_to_json(self):
return {
"alg": self.alg,
"nonce": jose.b64encode(self.nonce),
"hints": self.hints,
}
@classmethod
def from_valid_json(cls, jobj):
return cls(alg=jobj["alg"],
nonce=util.decode_b64jose(jobj["nonce"], cls.NONCE_SIZE),
hints=cls.Hints.from_valid_json(jobj["hints"]))
alg = jose.Field("alg", decoder=jose.JWASignature.from_json)
nonce = jose.Field(
"nonce", encoder=jose.b64encode, decoder=functools.partial(
jose.decode_b64jose, size=NONCE_SIZE))
hints = jose.Field("hints", decoder=Hints.from_json)
@ChallengeResponse.register
@ -312,50 +220,29 @@ class ProofOfPossessionResponse(ChallengeResponse):
:ivar signature: :class:`~letsencrypt.acme.other.Signature` of this message.
"""
acme_type = "proofOfPossession"
__slots__ = ("nonce", "signature")
typ = "proofOfPossession"
NONCE_SIZE = ProofOfPossession.NONCE_SIZE
nonce = jose.Field(
"nonce", encoder=jose.b64encode, decoder=functools.partial(
jose.decode_b64jose, size=NONCE_SIZE))
signature = jose.Field("signature", decoder=other.Signature.from_json)
def verify(self):
"""Verify the challenge."""
# self.signature is not Field | pylint: disable=no-member
return self.signature.verify(self.nonce)
def _fields_to_json(self):
return {
"nonce": jose.b64encode(self.nonce),
"signature": self.signature,
}
@classmethod
def from_valid_json(cls, jobj):
return cls(nonce=util.decode_b64jose(jobj["nonce"], cls.NONCE_SIZE),
signature=other.Signature.from_valid_json(jobj["signature"]))
@Challenge.register
class DNS(DVChallenge):
"""ACME "dns" challenge."""
acme_type = "dns"
__slots__ = ("token",)
def _fields_to_json(self):
return {"token": self.token}
@classmethod
def from_valid_json(cls, jobj):
return cls(token=jobj["token"])
typ = "dns"
token = jose.Field("token")
@ChallengeResponse.register
class DNSResponse(ChallengeResponse):
"""ACME "dns" challenge response."""
acme_type = "dns"
__slots__ = ()
def _fields_to_json(self):
return {}
@classmethod
def from_valid_json(cls, jobj):
return cls()
typ = "dns"

View file

@ -6,13 +6,11 @@ import unittest
import Crypto.PublicKey.RSA
import M2Crypto
from letsencrypt.acme import errors
from letsencrypt.acme import jose
from letsencrypt.acme import other
from letsencrypt.acme import util
CERT = util.ComparableX509(M2Crypto.X509.load_cert(
CERT = jose.ComparableX509(M2Crypto.X509.load_cert(
pkg_resources.resource_filename(
'letsencrypt.client.tests', 'testdata/cert.pem')))
KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
@ -35,7 +33,7 @@ class SimpleHTTPSTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.challenges import SimpleHTTPS
self.assertEqual(self.msg, SimpleHTTPS.from_valid_json(self.jmsg))
self.assertEqual(self.msg, SimpleHTTPS.from_json(self.jmsg))
class SimpleHTTPSResponseTest(unittest.TestCase):
@ -58,7 +56,7 @@ class SimpleHTTPSResponseTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.challenges import SimpleHTTPSResponse
self.assertEqual(
self.msg, SimpleHTTPSResponse.from_valid_json(self.jmsg))
self.msg, SimpleHTTPSResponse.from_json(self.jmsg))
class DVSNITest(unittest.TestCase):
@ -84,19 +82,19 @@ class DVSNITest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.challenges import DVSNI
self.assertEqual(self.msg, DVSNI.from_valid_json(self.jmsg))
self.assertEqual(self.msg, DVSNI.from_json(self.jmsg))
def test_from_json_invalid_r_length(self):
from letsencrypt.acme.challenges import DVSNI
self.jmsg['r'] = 'abcd'
self.assertRaises(
errors.ValidationError, DVSNI.from_valid_json, self.jmsg)
jose.DeserializationError, DVSNI.from_json, self.jmsg)
def test_from_json_invalid_nonce_length(self):
from letsencrypt.acme.challenges import DVSNI
self.jmsg['nonce'] = 'abcd'
self.assertRaises(
errors.ValidationError, DVSNI.from_valid_json, self.jmsg)
jose.DeserializationError, DVSNI.from_json, self.jmsg)
class DVSNIResponseTest(unittest.TestCase):
@ -129,7 +127,7 @@ class DVSNIResponseTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.challenges import DVSNIResponse
self.assertEqual(self.msg, DVSNIResponse.from_valid_json(self.jmsg))
self.assertEqual(self.msg, DVSNIResponse.from_json(self.jmsg))
class RecoveryContactTest(unittest.TestCase):
@ -152,7 +150,7 @@ class RecoveryContactTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.challenges import RecoveryContact
self.assertEqual(self.msg, RecoveryContact.from_valid_json(self.jmsg))
self.assertEqual(self.msg, RecoveryContact.from_json(self.jmsg))
def test_json_without_optionals(self):
del self.jmsg['activationURL']
@ -160,7 +158,7 @@ class RecoveryContactTest(unittest.TestCase):
del self.jmsg['contact']
from letsencrypt.acme.challenges import RecoveryContact
msg = RecoveryContact.from_valid_json(self.jmsg)
msg = RecoveryContact.from_json(self.jmsg)
self.assertTrue(msg.activation_url is None)
self.assertTrue(msg.success_url is None)
@ -181,13 +179,13 @@ class RecoveryContactResponseTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.challenges import RecoveryContactResponse
self.assertEqual(
self.msg, RecoveryContactResponse.from_valid_json(self.jmsg))
self.msg, RecoveryContactResponse.from_json(self.jmsg))
def test_json_without_optionals(self):
del self.jmsg['token']
from letsencrypt.acme.challenges import RecoveryContactResponse
msg = RecoveryContactResponse.from_valid_json(self.jmsg)
msg = RecoveryContactResponse.from_json(self.jmsg)
self.assertTrue(msg.token is None)
self.assertEqual(self.jmsg, msg.to_json())
@ -205,7 +203,7 @@ class RecoveryTokenTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.challenges import RecoveryToken
self.assertEqual(self.msg, RecoveryToken.from_valid_json(self.jmsg))
self.assertEqual(self.msg, RecoveryToken.from_json(self.jmsg))
class RecoveryTokenResponseTest(unittest.TestCase):
@ -221,13 +219,13 @@ class RecoveryTokenResponseTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.challenges import RecoveryTokenResponse
self.assertEqual(
self.msg, RecoveryTokenResponse.from_valid_json(self.jmsg))
self.msg, RecoveryTokenResponse.from_json(self.jmsg))
def test_json_without_optionals(self):
del self.jmsg['token']
from letsencrypt.acme.challenges import RecoveryTokenResponse
msg = RecoveryTokenResponse.from_valid_json(self.jmsg)
msg = RecoveryTokenResponse.from_json(self.jmsg)
self.assertTrue(msg.token is None)
self.assertEqual(self.jmsg, msg.to_json())
@ -236,37 +234,37 @@ class RecoveryTokenResponseTest(unittest.TestCase):
class ProofOfPossessionHintsTest(unittest.TestCase):
def setUp(self):
jwk = other.JWK(key=KEY.publickey())
issuers = [
jwk = jose.JWKRSA(key=KEY.publickey())
issuers = (
'C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA',
'O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure',
]
cert_fingerprints = [
)
cert_fingerprints = (
'93416768eb85e33adc4277f4c9acd63e7418fcfe',
'16d95b7b63f1972b980b14c20291f3c0d1855d95',
'48b46570d9fc6358108af43ad1649484def0debf',
]
subject_key_identifiers = ['d0083162dcc4c8a23ecb8aecbd86120e56fd24e5']
authorized_for = ['www.example.com', 'example.net']
serial_numbers = [34234239832, 23993939911, 17]
)
subject_key_identifiers = ('d0083162dcc4c8a23ecb8aecbd86120e56fd24e5')
authorized_for = ('www.example.com', 'example.net')
serial_numbers = (34234239832, 23993939911, 17)
from letsencrypt.acme.challenges import ProofOfPossession
self.msg = ProofOfPossession.Hints(
jwk=jwk, issuers=issuers, cert_fingerprints=cert_fingerprints,
certs=[CERT], subject_key_identifiers=subject_key_identifiers,
certs=(CERT,), subject_key_identifiers=subject_key_identifiers,
authorized_for=authorized_for, serial_numbers=serial_numbers)
self.jmsg_to = {
'jwk': jwk,
'certFingerprints': cert_fingerprints,
'certs': [jose.b64encode(CERT.as_der())],
'certs': (jose.b64encode(CERT.as_der()),),
'subjectKeyIdentifiers': subject_key_identifiers,
'serialNumbers': serial_numbers,
'issuers': issuers,
'authorizedFor': authorized_for,
}
self.jmsg_from = self.jmsg_to.copy()
self.jmsg_from.update({'jwk': jwk.to_json()})
self.jmsg_from.update({'jwk': jwk.fully_serialize()})
def test_to_json(self):
self.assertEqual(self.jmsg_to, self.msg.to_json())
@ -274,7 +272,7 @@ class ProofOfPossessionHintsTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.challenges import ProofOfPossession
self.assertEqual(
self.msg, ProofOfPossession.Hints.from_valid_json(self.jmsg_from))
self.msg, ProofOfPossession.Hints.from_json(self.jmsg_from))
def test_json_without_optionals(self):
for optional in ['certFingerprints', 'certs', 'subjectKeyIdentifiers',
@ -283,14 +281,14 @@ class ProofOfPossessionHintsTest(unittest.TestCase):
del self.jmsg_to[optional]
from letsencrypt.acme.challenges import ProofOfPossession
msg = ProofOfPossession.Hints.from_valid_json(self.jmsg_from)
msg = ProofOfPossession.Hints.from_json(self.jmsg_from)
self.assertEqual(msg.cert_fingerprints, [])
self.assertEqual(msg.certs, [])
self.assertEqual(msg.subject_key_identifiers, [])
self.assertEqual(msg.serial_numbers, [])
self.assertEqual(msg.issuers, [])
self.assertEqual(msg.authorized_for, [])
self.assertEqual(msg.cert_fingerprints, ())
self.assertEqual(msg.certs, ())
self.assertEqual(msg.subject_key_identifiers, ())
self.assertEqual(msg.serial_numbers, ())
self.assertEqual(msg.issuers, ())
self.assertEqual(msg.authorized_for, ())
self.assertEqual(self.jmsg_to, msg.to_json())
@ -300,27 +298,25 @@ class ProofOfPossessionTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.challenges import ProofOfPossession
hints = ProofOfPossession.Hints(
jwk=other.JWK(key=KEY.publickey()), cert_fingerprints=[], certs=[],
serial_numbers=[], subject_key_identifiers=[], issuers=[],
authorized_for=[])
jwk=jose.JWKRSA(key=KEY.publickey()), cert_fingerprints=(),
certs=(), serial_numbers=(), subject_key_identifiers=(),
issuers=(), authorized_for=())
self.msg = ProofOfPossession(
alg='RS256', nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ',
hints=hints)
alg=jose.RS256, hints=hints,
nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ')
self.jmsg_to = {
'type': 'proofOfPossession',
'alg': 'RS256',
'alg': jose.RS256,
'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
'hints': hints,
}
self.jmsg_from = {
'type': 'proofOfPossession',
'alg': 'RS256',
'alg': jose.RS256.fully_serialize(),
'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
'hints': hints.to_json(),
'hints': hints.fully_serialize(),
}
self.jmsg_from['hints']['jwk'] = self.jmsg_from[
'hints']['jwk'].to_json()
def test_to_json(self):
self.assertEqual(self.jmsg_to, self.msg.to_json())
@ -328,7 +324,7 @@ class ProofOfPossessionTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.challenges import ProofOfPossession
self.assertEqual(
self.msg, ProofOfPossession.from_valid_json(self.jmsg_from))
self.msg, ProofOfPossession.from_json(self.jmsg_from))
class ProofOfPossessionResponseTest(unittest.TestCase):
@ -338,7 +334,7 @@ class ProofOfPossessionResponseTest(unittest.TestCase):
# nonce and challenge nonce are the same, don't make the same
# mistake here...
signature = other.Signature(
alg='RS256', jwk=other.JWK(key=KEY.publickey()),
alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.publickey()),
sig='\xa7\xc1\xe7\xe82o\xbc\xcd\xd0\x1e\x010#Z|\xaf\x15\x83'
'\x94\x8f#\x9b\nQo(\x80\x15,\x08\xfcz\x1d\xfd\xfd.\xaap'
'\xfa\x06\xd1\xa2f\x8d8X2>%d\xbd%\xe1T\xdd\xaa0\x18\xde'
@ -359,11 +355,8 @@ class ProofOfPossessionResponseTest(unittest.TestCase):
self.jmsg_from = {
'type': 'proofOfPossession',
'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
'signature': signature.to_json(),
'signature': signature.fully_serialize(),
}
self.jmsg_from['signature']['jwk'] = self.jmsg_from[
'signature']['jwk'].to_json()
def test_verify(self):
self.assertTrue(self.msg.verify())
@ -374,7 +367,7 @@ class ProofOfPossessionResponseTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.challenges import ProofOfPossessionResponse
self.assertEqual(
self.msg, ProofOfPossessionResponse.from_valid_json(self.jmsg_from))
self.msg, ProofOfPossessionResponse.from_json(self.jmsg_from))
class DNSTest(unittest.TestCase):
@ -389,7 +382,7 @@ class DNSTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.challenges import DNS
self.assertEqual(self.msg, DNS.from_valid_json(self.jmsg))
self.assertEqual(self.msg, DNS.from_json(self.jmsg))
class DNSResponseTest(unittest.TestCase):
@ -404,7 +397,7 @@ class DNSResponseTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.challenges import DNSResponse
self.assertEqual(self.msg, DNSResponse.from_valid_json(self.jmsg))
self.assertEqual(self.msg, DNSResponse.from_json(self.jmsg))
if __name__ == '__main__':

View file

@ -1,13 +1,8 @@
"""ACME errors."""
from letsencrypt.acme.jose import errors as jose_errors
class Error(Exception):
"""Generic ACME error."""
class ValidationError(Error):
"""ACME object validation error."""
class UnrecognizedTypeError(ValidationError):
"""Unrecognized ACME object type error."""
class SchemaValidationError(ValidationError):
class SchemaValidationError(jose_errors.DeserializationError):
"""JSON schema ACME object validation error."""

View file

@ -1,69 +0,0 @@
"""ACME interfaces.
Separation between :class:`IJSONSerializable` and :class:`IJSONDeserializable`
is necessary because we want to use ``cls.from_valid_json``
classmethod on class and ``cls().to_json()`` on object, i.e. class
instance. ``cls.to_json()`` doesn't make much sense. Therefore a class
definition that requires both must call
``zope.interface.implements(IJSONSerializable)`` and
``zope.interface.classImplements(IJSONDeSerializable)`` (note the
difference btween `implements` and `classImplements`) and
:class:`letsencrypt.acme.util.ACMEObject` definition is an example.
"""
import zope.interface
# pylint: disable=no-self-argument,no-method-argument,no-init,inherit-non-class
# pylint: disable=too-few-public-methods
class IJSONSerializable(zope.interface.Interface):
# pylint: disable=too-few-public-methods
"""JSON serializable object."""
def to_json():
"""Prepare JSON serializable object.
Note, however, that this method might return other
:class:`letsencrypt.acme.interfaces.IJSONSerializable`
objects that haven't been serialized yet, which is fine as
long as :func:`letsencrypt.acme.util.dump_ijsonserializable`
is used. For example::
class Foo(object):
zope.interface.implements(IJSONSerializable)
def to_json(self):
return 'foo'
class Bar(object):
zope.interface.implements(IJSONSerializable)
def to_json(self):
return [Foo(), Foo()]
bar = Bar()
assert isinstance(bar.to_json()[0], Foo)
assert isinstance(bar.to_json()[1], Foo)
assert json.dumps(
bar, default=dump_ijsonserializable) == ['foo', 'foo']
:returns: JSON object ready to be serialized.
"""
class IJSONDeserializable(zope.interface.Interface):
"""JSON deserializable class."""
def from_valid_json(jobj):
"""Deserialize valid JSON object.
:param jobj: JSON object validated against JSON schema (found in
schemata/ directory).
:raises letsencrypt.acme.errors.ValidationError: It might be the
case that ``jobj`` validates against schema, but still is not
valid (e.g. unparseable X509 certificate, or wrong padding in
JOSE base64 encoded string).
"""

View file

@ -0,0 +1,74 @@
"""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,
ImmutableMap,
)

View file

@ -1,13 +1,19 @@
"""JOSE."""
import base64
"""JOSE Base64.
# https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-37#appendix-C
#
# Jose Base64:
#
# - URL-safe Base64
#
# - padding stripped
`JOSE Base64`_ is defined as:
- URL-safe Base64
- padding stripped
.. _`JOSE Base64`:
https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-37#appendix-C
.. warning:: Do NOT try to call this module "base64",
as it will "shadow" the standard library.
"""
import base64
def b64encode(data):

View file

@ -1,4 +1,4 @@
"""Tests for letsencrypt.acme.jose."""
"""Tests for letsencrypt.acme.jose.b64."""
import unittest
@ -19,11 +19,11 @@ B64_URL_UNSAFE_EXAMPLES = {
class B64EncodeTest(unittest.TestCase):
"""Tests for letsencrypt.acme.jose.b64encode."""
"""Tests for letsencrypt.acme.jose.b64.b64encode."""
@classmethod
def _call(cls, data):
from letsencrypt.acme.jose import b64encode
from letsencrypt.acme.jose.b64 import b64encode
return b64encode(data)
def test_unsafe_url(self):
@ -39,11 +39,11 @@ class B64EncodeTest(unittest.TestCase):
class B64DecodeTest(unittest.TestCase):
"""Tests for letsencrypt.acme.jose.b64decode."""
"""Tests for letsencrypt.acme.jose.b64.b64decode."""
@classmethod
def _call(cls, data):
from letsencrypt.acme.jose import b64decode
from letsencrypt.acme.jose.b64 import b64decode
return b64decode(data)
def test_unsafe_url(self):

View file

@ -0,0 +1,31 @@
"""JOSE errors."""
class Error(Exception):
"""Generic JOSE Error."""
class DeserializationError(Error):
"""JSON deserialization error."""
class SerializationError(Error):
"""JSON serialization error."""
class UnrecognizedTypeError(DeserializationError):
"""Unrecognized type error.
:ivar str typ: The unrecognized type of the JSON object.
:ivar jobj: Full JSON object.
"""
def __init__(self, typ, jobj):
self.typ = typ
self.jobj = jobj
super(UnrecognizedTypeError, self).__init__(str(self))
def __str__(self):
return '{0} was not recognized, full message: {1}'.format(
self.typ, self.jobj)

View file

@ -0,0 +1,17 @@
"""Tests for letsencrypt.acme.jose.errors."""
import unittest
class UnrecognizedTypeErrorTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.jose.errors import UnrecognizedTypeError
self.error = UnrecognizedTypeError('foo', {'type': 'foo'})
def test_str(self):
self.assertEqual(
"foo was not recognized, full message: {'type': 'foo'}",
str(self.error))
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,198 @@
"""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_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_json` produce
only partial serialization, while providing custom :meth:`json_dumps`
that dumps with ``default`` set to :meth:`json_dump_default`.
To make further documentation a bit more concrete, please, consider
the following imaginatory implementation example::
class Foo(JSONDeSerializable):
def to_json(self):
return 'foo'
@classmethod
def from_json(cls, jobj):
return Foo()
class Bar(JSONDeSerializable):
def to_json(self):
return [Foo(), Foo()]
@classmethod
def from_json(cls, jobj):
return Bar()
"""
__metaclass__ = abc.ABCMeta
@abc.abstractmethod
def to_json(self): # pragma: no cover
"""Partially serialize.
Following the example, **partial serialization** means the following::
assert isinstance(Bar().to_json()[0], Foo)
assert isinstance(Bar().to_json()[1], Foo)
# in particular...
assert Bar().to_json() != ['foo', 'foo']
:raises letsencrypt.acme.jose.errors.SerializationError:
in case of any serialization error.
:returns: Partially serializable object.
"""
raise NotImplementedError()
def fully_serialize(self):
"""Fully serialize.
Again, following the example from before, **full serialization**
means the following::
assert Bar().fully_serialize() == ['foo', 'foo']
:raises letsencrypt.acme.jose.errors.SerializationError:
in case of any serialization error.
:returns: Fully serialized object.
"""
partial = self.to_json()
try_serialize = (lambda x: x.fully_serialize()
if isinstance(x, JSONDeSerializable) else x)
if isinstance(partial, basestring): # strings are sequences
return partial
if isinstance(partial, collections.Sequence):
return [try_serialize(elem) for elem in partial]
elif isinstance(partial, collections.Mapping):
return dict([(try_serialize(key), try_serialize(value))
for key, value in partial.iteritems()])
else:
return partial
@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_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_json()
else: # this branch is necessary, cannot just "return"
raise TypeError(repr(python_object) + ' is not JSON serializable')

View file

@ -0,0 +1,106 @@
"""Tests for letsencrypt.acme.jose.interfaces."""
import unittest
class JSONDeSerializableTest(unittest.TestCase):
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_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_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_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)
# pylint: disable=invalid-name
self.Basic = Basic
self.Sequence = Sequence
self.Mapping = Mapping
def test_fully_serialize_sequence(self):
self.assertEqual(self.seq.fully_serialize(), ['foo1', 'foo2'])
def test_fully_serialize_mapping(self):
self.assertEqual(self.mapping.fully_serialize(), {'foo1': 'foo2'})
def test_fully_serialize_other(self):
mock_value = object()
self.assertTrue(self.Basic(mock_value).fully_serialize() is mock_value)
def test_from_json_not_implemented(self):
from letsencrypt.acme.jose.interfaces import JSONDeSerializable
self.assertRaises(TypeError, JSONDeSerializable.from_json, 'xxx')
def test_json_loads(self):
seq = self.Sequence.json_loads('["foo1", "foo2"]')
self.assertTrue(isinstance(seq, self.Sequence))
self.assertTrue(isinstance(seq.x, self.Basic))
self.assertTrue(isinstance(seq.y, self.Basic))
self.assertEqual(seq.x.v, 'foo1')
self.assertEqual(seq.y.v, 'foo2')
def test_json_dumps(self):
self.assertEqual('["foo1", "foo2"]', self.seq.json_dumps())
def test_json_dumps_pretty(self):
self.assertEqual(
self.seq.json_dumps_pretty(), '[\n "foo1",\n "foo2"\n]')
def test_json_dump_default(self):
from letsencrypt.acme.jose.interfaces import JSONDeSerializable
self.assertEqual(
'foo1', JSONDeSerializable.json_dump_default(self.basic1))
jobj = JSONDeSerializable.json_dump_default(self.seq)
self.assertEqual(len(jobj), 2)
self.assertTrue(jobj[0] is self.basic1)
self.assertTrue(jobj[1] is self.basic2)
def test_json_dump_default_type_error(self):
from letsencrypt.acme.jose.interfaces import JSONDeSerializable
self.assertRaises(
TypeError, JSONDeSerializable.json_dump_default, object())
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,404 @@
"""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_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_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_json(self):
"""Serialize fields to JSON."""
jobj = {}
for slot, field in self._fields.iteritems():
value = getattr(self, slot)
if field.omit(value):
logging.debug('Ommiting 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_json(self):
return self.fields_to_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:
type_cls = cls.TYPES[typ]
except KeyError:
raise errors.UnrecognizedTypeError(typ, jobj)
return type_cls
def to_json(self):
"""Get JSON serializable object.
:returns: Serializable JSON object representing ACME typed object.
:meth:`validate` will almost certianly not work, due to reasons
explained in :class:`letsencrypt.acme.interfaces.IJSONSerializable`.
:rtype: dict
"""
jobj = self.fields_to_json()
jobj[self.type_field_name] = self.typ
return jobj
@classmethod
def from_json(cls, jobj):
"""Deserialize ACME object from valid JSON object.
:raises letsencrypt.acme.errors.UnrecognizedTypeError: if type
of the ACME object has not been registered.
"""
# make sure subclasses don't cause infinite recursive from_json calls
type_cls = cls.get_type_cls(jobj)
return type_cls(**type_cls.fields_from_json(jobj))

View file

@ -0,0 +1,296 @@
"""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_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_json_omits_empty(self):
self.assertEqual(self.mock.fields_to_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_json_encoder(self):
self.assertEqual(self.MockJSONObjectWithFields(x=1, y=2, z=3).to_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_json_error_passthrough(self):
self.assertRaises(
errors.SerializationError, self.MockJSONObjectWithFields(
x=1, y=500, z=3).to_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_json(self):
return {'foo': self.foo}
self.parent_cls = MockParentTypedJSONObjectWithFields
self.msg = MockTypedJSONObjectWithFields(foo='bar')
def test_to_json(self):
self.assertEqual(self.msg.to_json(), {
'type': 'test',
'foo': 'bar',
})
def test_from_json_non_dict_fails(self):
for value in [[], (), 5, "asd"]: # all possible input types
self.assertRaises(
errors.DeserializationError, self.parent_cls.from_json, value)
def test_from_json_dict_no_type_fails(self):
self.assertRaises(
errors.DeserializationError, self.parent_cls.from_json, {})
def test_from_json_unknown_type_fails(self):
self.assertRaises(errors.UnrecognizedTypeError,
self.parent_cls.from_json, {'type': 'bar'})
def test_from_json_returns_obj(self):
self.assertEqual({'foo': 'bar'}, self.parent_cls.from_json(
{'type': 'test', 'foo': 'bar'}))
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,130 @@
"""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,too-few-public-methods
"""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_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 as error: # key has no private part
raise errors.Error(error)
except (AttributeError, ValueError) as error:
# key is too small: ValueError for PS, AttributeError for RS
raise errors.Error(error)
def verify(self, key, msg, sig):
return self.padding.new(key).verify(self.digestmod.new(msg), sig)
class _JWAES(JWASignature): # pylint: disable=abstract-class-not-used
# TODO: implement ES signatures
def sign(self, key, msg): # pragma: no cover
raise NotImplementedError()
def verify(self, key, msg, sig): # pragma: no cover
raise NotImplementedError()
HS256 = JWASignature.register(_JWAHS('HS256', SHA256))
HS384 = JWASignature.register(_JWAHS('HS384', SHA384))
HS512 = JWASignature.register(_JWAHS('HS512', SHA512))
RS256 = JWASignature.register(_JWARS('RS256', PKCS1_v1_5, SHA256))
RS384 = JWASignature.register(_JWARS('RS384', PKCS1_v1_5, SHA384))
RS512 = JWASignature.register(_JWARS('RS512', PKCS1_v1_5, SHA512))
PS256 = JWASignature.register(_JWARS('PS256', PKCS1_PSS, SHA256))
PS384 = JWASignature.register(_JWARS('PS384', PKCS1_PSS, SHA384))
PS512 = JWASignature.register(_JWARS('PS512', PKCS1_PSS, SHA512))
ES256 = JWASignature.register(_JWAES('ES256'))
ES256 = JWASignature.register(_JWAES('ES384'))
ES256 = JWASignature.register(_JWAES('ES512'))

View file

@ -0,0 +1,105 @@
"""Tests for letsencrypt.acme.jose.jwa."""
import os
import pkg_resources
import unittest
from Crypto.PublicKey import RSA
from letsencrypt.acme.jose import errors
RSA256_KEY = RSA.importKey(pkg_resources.resource_string(
__name__, os.path.join('testdata', 'rsa256_key.pem')))
RSA512_KEY = RSA.importKey(pkg_resources.resource_string(
__name__, os.path.join('testdata', 'rsa512_key.pem')))
RSA1024_KEY = RSA.importKey(pkg_resources.resource_string(
__name__, os.path.join('testdata', 'rsa1024_key.pem')))
class JWASignatureTest(unittest.TestCase):
"""Tests for letsencrypt.acme.jose.jwa.JWASignature."""
def setUp(self):
from letsencrypt.acme.jose.jwa import JWASignature
class MockSig(JWASignature):
# pylint: disable=missing-docstring,too-few-public-methods
# pylint: disable=abstract-class-not-used
def sign(self, key, msg):
raise NotImplementedError()
def verify(self, key, msg, sig):
raise NotImplementedError()
# pylint: disable=invalid-name
self.Sig1 = MockSig('Sig1')
self.Sig2 = MockSig('Sig2')
def test_eq(self):
self.assertEqual(self.Sig1, self.Sig1)
self.assertNotEqual(self.Sig1, self.Sig2)
def test_repr(self):
self.assertEqual('Sig1', repr(self.Sig1))
self.assertEqual('Sig2', repr(self.Sig2))
def test_to_json(self):
self.assertEqual(self.Sig1.to_json(), 'Sig1')
self.assertEqual(self.Sig2.to_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 = (
'\x13\xf0\xe5\x83\x91\xd8~\x02q\xdf\xbdwX\x97\xecn\xe4UH\xb0'
'\xe1oq\x94\x9f\xf4\x0f\xcb0\x05\xa9\x0fs\xea\xf3\xe3\xe7'
'\x1cAh\xb3@\xb8\xe4UnG\xa0\xb2K\xac-\x1c1\x1c\xe9dw}2@\xa7'
'\xf0\xe8'
)
self.assertEqual(RS256.sign(RSA512_KEY, 'foo'), sig)
# next tests guard that only True/False are return as oppossed
# to e.g. 1/0
self.assertTrue(RS256.verify(RSA512_KEY, 'foo', sig) is True)
self.assertFalse(RS256.verify(RSA512_KEY, 'foo', sig + '!') is False)
def test_ps(self):
from letsencrypt.acme.jose.jwa import PS256
sig = PS256.sign(RSA1024_KEY, 'foo')
self.assertTrue(PS256.verify(RSA1024_KEY, 'foo', sig) is True)
self.assertTrue(PS256.verify(RSA1024_KEY, 'foo', sig + '!') is False)
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,132 @@
"""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``.
"""
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_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_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."""
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=Crypto.PublicKey.RSA.importKey(string))
def public(self):
return type(self)(key=self.key.publickey())
@classmethod
def fields_from_json(cls, jobj):
return cls(key=Crypto.PublicKey.RSA.construct(
(cls._decode_param(jobj['n']),
cls._decode_param(jobj['e']))))
def fields_to_json(self):
return {
'n': self._encode_param(self.key.n),
'e': self._encode_param(self.key.e),
}

View file

@ -0,0 +1,99 @@
"""Tests for letsencrypt.acme.jose.jwk."""
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(
'letsencrypt.client.tests', os.path.join('testdata', 'rsa256_key.pem')))
RSA512_KEY = RSA.importKey(pkg_resources.resource_string(
'letsencrypt.client.tests', 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_json(self):
self.assertEqual(self.jwk.to_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_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': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5'
'80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q',
}
self.jwk512 = JWKRSA(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_load(self):
from letsencrypt.acme.jose.jwk import JWKRSA
self.assertEqual(JWKRSA(key=RSA256_KEY), JWKRSA.load(
pkg_resources.resource_string(
'letsencrypt.client.tests',
os.path.join('testdata', 'rsa256_key.pem'))))
def test_public(self):
self.assertEqual(self.jwk256, self.jwk256_private.public())
def test_to_json(self):
self.assertEqual(self.jwk256.to_json(), self.jwk256json)
self.assertEqual(self.jwk512.to_json(), self.jwk512json)
def test_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_non_schema_errors(self):
# valid against schema, but still failing
from letsencrypt.acme.jose.jwk import JWK
self.assertRaises(errors.DeserializationError, JWK.from_json,
{'kty': 'RSA', 'e': 'AQAB', 'n': ''})
self.assertRaises(errors.DeserializationError, JWK.from_json,
{'kty': 'RSA', 'e': 'AQAB', 'n': '1'})
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,406 @@
"""JOSE Web Signature."""
import argparse
import base64
import sys
import M2Crypto
from letsencrypt.acme.jose import b64
from letsencrypt.acme.jose import errors
from letsencrypt.acme.jose import json_util
from letsencrypt.acme.jose import jwa
from letsencrypt.acme.jose import jwk
from letsencrypt.acme.jose import util
class MediaType(object):
"""MediaType field encoder/decoder."""
PREFIX = 'application/'
"""MIME Media Type and Content Type prefix."""
@classmethod
def decode(cls, value):
"""Decoder."""
# 4.1.10
if '/' not in value:
if ';' in value:
raise errors.DeserializationError('Unexpected semi-colon')
return cls.PREFIX + value
return value
@classmethod
def encode(cls, value):
"""Encoder."""
# 4.1.10
if ';' not in value:
assert value.startswith(cls.PREFIX)
return value[len(cls.PREFIX):]
return value
class Header(json_util.JSONObjectWithFields):
"""JOSE Header.
.. warning:: This class supports **only** Registered Header
Parameter Names (as defined in section 4.1 of the
protocol). If you need Public Header Parameter Names (4.2)
or Private Header Parameter Names (4.3), you must subclass
and override :meth:`from_json` and :meth:`to_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_json(self):
fields = super(Signature, self).fields_to_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_json(self, flat=True): # pylint: disable=arguments-differ
assert self.signatures
payload = b64.b64encode(self.payload)
if flat and len(self.signatures) == 1:
ret = self.signatures[0].to_json()
ret['payload'] = payload
return ret
else:
return {
'payload': payload,
'signatures': self.signatures,
}
@classmethod
def from_json(cls, jobj):
if 'signature' in jobj and 'signatures' in jobj:
raise errors.DeserializationError('Flat mixed with non-flat')
elif 'signature' in jobj: # flat
return cls(payload=json_util.decode_b64jose(jobj.pop('payload')),
signatures=(Signature.from_json(jobj),))
else:
return cls(payload=json_util.decode_b64jose(jobj['payload']),
signatures=tuple(Signature.from_json(sig)
for sig in jobj['signatures']))
class CLI(object):
"""JWS CLI."""
@classmethod
def sign(cls, args):
"""Sign."""
key = args.alg.kty.load(args.key.read())
if args.protect is None:
args.protect = []
if args.compact:
args.protect.append('alg')
sig = JWS.sign(payload=sys.stdin.read(), key=key, alg=args.alg,
protect=set(args.protect))
if args.compact:
print sig.to_compact()
else: # JSON
print sig.json_dumps_pretty()
@classmethod
def verify(cls, args):
"""Verify."""
if args.compact:
sig = JWS.from_compact(sys.stdin.read())
else: # JSON
try:
sig = JWS.json_loads(sys.stdin.read())
except errors.Error as error:
print error
return -1
if args.key is not None:
assert args.kty is not None
key = args.kty.load(args.key.read())
else:
key = None
sys.stdout.write(sig.payload)
return int(not sig.verify(key=key))
@classmethod
def _alg_type(cls, arg):
return jwa.JWASignature.from_json(arg)
@classmethod
def _header_type(cls, arg):
assert arg in Signature.header_cls._fields
return arg
@classmethod
def _kty_type(cls, arg):
assert arg in jwk.JWK.TYPES
return jwk.JWK.TYPES[arg]
@classmethod
def run(cls, args=sys.argv[1:]):
"""Parse arguments and sign/verify."""
parser = argparse.ArgumentParser()
parser.add_argument('--compact', action='store_true')
subparsers = parser.add_subparsers()
parser_sign = subparsers.add_parser('sign')
parser_sign.set_defaults(func=cls.sign)
parser_sign.add_argument(
'-k', '--key', type=argparse.FileType(), required=True)
parser_sign.add_argument(
'-a', '--alg', type=cls._alg_type, default=jwa.RS256)
parser_sign.add_argument(
'-p', '--protect', action='append', type=cls._header_type)
parser_verify = subparsers.add_parser('verify')
parser_verify.set_defaults(func=cls.verify)
parser_verify.add_argument(
'-k', '--key', type=argparse.FileType(), required=False)
parser_verify.add_argument(
'--kty', type=cls._kty_type, required=False)
parsed = parser.parse_args(args)
return parsed.func(parsed)
if __name__ == '__main__':
exit(CLI.run()) # pragma: no cover

View file

@ -0,0 +1,238 @@
"""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_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.KBvYScRMEqJlp2xsReoY3CNDpVCWEU'
'1PyRrf44nPBsmyQz__iuNR56pPNcACeHzJQnXhTVTxqFgjge2i_vw9NA',
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_json(flat=True)
unprotected_jobj = self.unprotected.to_json(flat=True)
self.assertTrue('protected' not in unprotected_jobj)
self.assertTrue('header' not in protected_jobj)
unprotected_jobj['header'] = unprotected_jobj[
'header'].fully_serialize()
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'].fully_serialize()
self.assertEqual(self.mixed.to_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].fully_serialize()]
self.assertEqual(self.mixed.to_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'})
class CLITest(unittest.TestCase):
def setUp(self):
self.key_path = pkg_resources.resource_filename(
__name__, os.path.join('testdata', 'rsa512_key.pem'))
def test_unverified(self):
from letsencrypt.acme.jose.jws import CLI
with mock.patch('sys.stdin') as sin:
sin.read.return_value = '{"payload": "foo", "signature": "xxx"}'
with mock.patch('sys.stdout'):
self.assertEqual(-1, CLI.run(['verify']))
def test_json(self):
from letsencrypt.acme.jose.jws import CLI
with mock.patch('sys.stdin') as sin:
sin.read.return_value = 'foo'
with mock.patch('sys.stdout') as sout:
CLI.run(['sign', '-k', self.key_path, '-a', 'RS256',
'-p', 'jwk'])
sin.read.return_value = sout.write.mock_calls[0][1][0]
self.assertEqual(0, CLI.run(['verify']))
def test_compact(self):
from letsencrypt.acme.jose.jws import CLI
with mock.patch('sys.stdin') as sin:
sin.read.return_value = 'foo'
with mock.patch('sys.stdout') as sout:
CLI.run(['--compact', 'sign', '-k', self.key_path])
sin.read.return_value = sout.write.mock_calls[0][1][0]
self.assertEqual(0, CLI.run([
'--compact', 'verify', '--kty', 'RSA',
'-k', self.key_path]))
if __name__ == '__main__':
unittest.main()

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

@ -0,0 +1,10 @@
The following command has been used to generate test keys:
for x in 256 512 1024; do openssl genrsa -out rsa${k}_key.pem $k; done
and for the CSR:
python -c from 'letsencrypt.client.crypto_util import make_csr;
import pkg_resources; open("csr2.pem",
"w").write(make_csr(pkg_resources.resource_string("letsencrypt.client.tests",
"testdata/rsa512_key.pem"), ["example2.com"])[0])'

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

@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIBXzCCAQkCAQAwejELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIw
EAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECwwWVW5pdmVy
c2l0eSBvZiBNaWNoaWdhbjEVMBMGA1UEAwwMZXhhbXBsZTIuY29tMFwwDQYJKoZI
hvcNAQEBBQADSwAwSAJBAPS2EXFRNza/qpXnnBHF/CcFQ543htV+7nLAmrLrmTNH
tPXJmLlM8SJDIzv/ceAFXL110VzxFfi81lpH5E5c0TMCAwEAAaAqMCgGCSqGSIb3
DQEJDjEbMBkwFwYDVR0RBBAwDoIMZXhhbXBsZTIuY29tMA0GCSqGSIb3DQEBCwUA
A0EAwsdL4FLIgISKV4vXFmc6QTV7CjBiP4XmPFbeN+gMFdR7QcnRyyxSpXxB0v8Z
oqYboP5LGFt9zC6/9GyjcI9/IQ==
-----END CERTIFICATE REQUEST-----

View file

@ -0,0 +1,15 @@
-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQCaifO0fGlcAcjjcYEAPYcIL0Hf0KiNa9VCJ14RBdlZxLWRrVFi
4tdNCKSKqzKuKrrA8DWd4PHFD7UpLyRrPPXY6GozAyCT+5UFBClGJ2KyNKu+eU6/
w4C1kpO4lpeXs8ptFc1lA9P8V1M/MkWzTE402nPNK0uUmZNo2tsFpGJUSQIDAQAB
AoGAFjLWxQhSAhtnhfRZ+XTdHrnbFpFchOQGgDgzdPKIJDLzefeRh0jacIBbUmgB
Ia+Vn/1hVkpnsEzvUvkonBbnoYWlYVQdpNTmrrew7SOztf8/1fYCsSkyDAvqGTXc
TmHM0PaLS+junoWcKOvQRVb0N3k+43OnBkr2b393Sx30qGECQQDNO2IBWOsYs8cB
CZQAZs8zBlbwBFZibqovqpLwXt9adBIsT9XzgagGbJMpzSuoHTUn3QqqJd9uHD8X
UTmmoh4NAkEAwMRauo+PlNj8W1lusflko52KL17+E5cmeOERM2xvhZNpO7d3/1ak
Co9dxVMicrYSh7jXbcXFNt3xNDTv6Dg8LQJAPuJwMDt/pc0IMCAwMkNOP7M0lkyt
73E7QmnAplhblcq0+tDnnLpgsr84BHnyY4u3iuRm7SW3pXSQPGPOB2nrTQJANBXa
HgakWSe4KEal7ljgpITwzZPxOwHgV1EZALgP+hu2l3gfaFLUyDWstKCd8jjYEOwU
6YhCnWyiu+SB3lEzkQJBAJapJpfypFyY8kQNYlYILLBcPu5fmy3QUZKHJ4L3rIVJ
c2UTLMeBBgGFHT04CtWntmjwzSv+V6lwiCxKXsIUySc=
-----END RSA PRIVATE KEY-----

View file

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

View file

@ -0,0 +1,9 @@
-----BEGIN RSA PRIVATE KEY-----
MIIBPAIBAAJBAJ+afYCLq33YTZumktV+Lg9LpDGKCv/DxuXkXc40mFc+82KbsyR8
5/S2pmNQrKzL/jLmenQT67PnRaVNqEsvj2UCAwEAAQJAJWqOaYhU19fRud+/JJXE
LonJIGQAWB2Jj3OOGj1ySWF13ahdsQxXKQoVSUTnrvLJkrQwXwNFck9BnZ1otL6u
MQIhAMw84RdsMJufn7bCMe6ppVukoGKRbjxE8ar/tBGUOOFrAiEAyA2ysBdOXF8z
FweoKED11siyJbHuuavMaoL1ZI779m8CIQCWuf8seA3PbBhEmkCbb9u3LGGpHMcL
952aoydTKd5ojQIhAKuSA+O9uTjDdL+Vk4QiYjS4nwBxH3ohewkGE4sQjcsFAiEA
uToAFyz5vUHnk8vME9y+ZIHSePBqckGwXVOfgIbATF0=
-----END RSA PRIVATE KEY-----

View file

@ -0,0 +1,123 @@
"""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 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 __getitem__(self, key):
try:
return getattr(self, key)
except AttributeError:
raise KeyError(key)
def __iter__(self):
return iter(self.__slots__)
def __len__(self):
return len(self.__slots__)
def __hash__(self):
return hash(tuple(getattr(self, slot) for slot in self.__slots__))
def __setattr__(self, name, value):
raise AttributeError("can't set attribute")
def __repr__(self):
return '{0}({1})'.format(self.__class__.__name__, ', '.join(
'{0}={1!r}'.format(key, value) for key, value in self.iteritems()))
class frozendict(collections.Mapping, collections.Hashable):
# pylint: disable=invalid-name,too-few-public-methods
"""Frozen dictionary."""
__slots__ = ('_items', '_keys')
def __init__(self, *args, **kwargs):
if kwargs and not args:
items = dict(kwargs)
elif len(args) == 1 and isinstance(args[0], collections.Mapping):
items = args[0]
else:
raise TypeError()
# TODO: support generators/iterators
object.__setattr__(self, '_items', items)
object.__setattr__(self, '_keys', tuple(sorted(items.iterkeys())))
def __getitem__(self, key):
return self._items[key]
def __iter__(self):
return iter(self._keys)
def __len__(self):
return len(self._items)
def __hash__(self):
return hash(tuple((key, value) for key, value in self.items()))
def __getattr__(self, name):
try:
return self._items[name]
except KeyError:
raise AttributeError(name)
def __setattr__(self, name, value):
raise AttributeError("can't set attribute")
def __repr__(self):
return 'frozendict({0})'.format(', '.join(
'{0}={1!r}'.format(key, value) for key, value in self.iteritems()))

View file

@ -0,0 +1,107 @@
"""Tests for letsencrypt.acme.jose.util."""
import functools
import unittest
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_get_missing_item_raises_key_error(self):
self.assertRaises(KeyError, self.a1.__getitem__, 'z')
def test_order_of_args_does_not_matter(self):
self.assertEqual(self.a1, self.a1_swap)
def test_type_error_on_missing(self):
self.assertRaises(TypeError, self.A, x=1)
self.assertRaises(TypeError, self.A, y=2)
def test_type_error_on_unrecognized(self):
self.assertRaises(TypeError, self.A, x=1, z=2)
self.assertRaises(TypeError, self.A, x=1, y=2, z=3)
def test_get_attr(self):
self.assertEqual(1, self.a1.x)
self.assertEqual(2, self.a1.y)
self.assertEqual(1, self.a1_swap.x)
self.assertEqual(2, self.a1_swap.y)
def test_set_attr_raises_attribute_error(self):
self.assertRaises(
AttributeError, functools.partial(self.a1.__setattr__, 'x'), 10)
def test_equal(self):
self.assertEqual(self.a1, self.a1)
self.assertEqual(self.a2, self.a2)
self.assertNotEqual(self.a1, self.a2)
def test_hash(self):
self.assertEqual(hash((1, 2)), hash(self.a1))
def test_unhashable(self):
self.assertRaises(TypeError, self.A(x=1, y={}).__hash__)
def test_repr(self):
self.assertEqual('A(x=1, y=2)', repr(self.a1))
self.assertEqual('A(x=1, y=2)', repr(self.a1_swap))
self.assertEqual('B(x=1, y=2)', repr(self.b))
self.assertEqual("B(x='foo', y='bar')", repr(self.B(x='foo', y='bar')))
class frozendictTest(unittest.TestCase): # pylint: disable=invalid-name
"""Tests for letsencrypt.acme.jose.util.frozendict."""
def setUp(self):
from letsencrypt.acme.jose.util import frozendict
self.fdict = frozendict(x=1, y='2')
def test_init_dict(self):
from letsencrypt.acme.jose.util import frozendict
self.assertEqual(self.fdict, frozendict({'x': 1, 'y': '2'}))
def test_init_other_raises_type_error(self):
from letsencrypt.acme.jose.util import frozendict
# specifically fail for generators...
self.assertRaises(TypeError, frozendict, {'a': 'b'}.iteritems())
def test_len(self):
self.assertEqual(2, len(self.fdict))
def test_hash(self):
self.assertEqual(1278944519403861804, hash(self.fdict))
def test_getattr_proxy(self):
self.assertEqual(1, self.fdict.x)
self.assertEqual('2', self.fdict.y)
def test_getattr_raises_attribute_error(self):
self.assertRaises(AttributeError, self.fdict.__getattr__, 'z')
def test_setattr_immutable(self):
self.assertRaises(AttributeError, self.fdict.__setattr__, 'z', 3)
def test_repr(self):
self.assertEqual("frozendict(x=1, y='2')", repr(self.fdict))
if __name__ == '__main__':
unittest.main()

View file

@ -1,6 +1,4 @@
"""ACME protocol messages."""
import json
import jsonschema
from letsencrypt.acme import challenges
@ -10,10 +8,12 @@ from letsencrypt.acme import other
from letsencrypt.acme import util
class Message(util.TypedACMEObject):
class Message(jose.TypedJSONObjectWithFields):
# _fields_to_json | pylint: disable=abstract-method
# pylint: disable=too-few-public-methods
"""ACME message."""
TYPES = {}
type_field_name = "type"
schema = NotImplemented
"""JSON schema the object is tested against in :meth:`from_json`.
@ -24,28 +24,6 @@ class Message(util.TypedACMEObject):
"""
@classmethod
def get_msg_cls(cls, jobj):
"""Get the registered class for ``jobj``."""
if cls in cls.TYPES.itervalues():
# cls is already registered Message type, force to use it
# so that, e.g Revocation.from_json(jobj) fails if
# jobj["type"] != "revocation".
return cls
if not isinstance(jobj, dict):
raise errors.ValidationError(
"{0} is not a dictionary object".format(jobj))
try:
msg_type = jobj["type"]
except KeyError:
raise errors.ValidationError("missing type field")
try:
return cls.TYPES[msg_type]
except KeyError:
raise errors.UnrecognizedTypeError(msg_type)
@classmethod
def from_json(cls, jobj):
"""Deserialize from (possibly invalid) JSON object.
@ -57,35 +35,21 @@ class Message(util.TypedACMEObject):
:raises letsencrypt.acme.errors.SchemaValidationError: if the input
JSON object could not be validated against JSON schema specified
in :attr:`schema`.
:raises letsencrypt.acme.errors.ValidationError: for any other generic
error in decoding.
:raises letsencrypt.acme.jose.errors.DeserializationError: for any
other generic error in decoding.
:returns: instance of the class
"""
msg_cls = cls.get_msg_cls(jobj)
msg_cls = cls.get_type_cls(jobj)
# TODO: is that schema testing still relevant?
try:
jsonschema.validate(jobj, msg_cls.schema)
except jsonschema.ValidationError as error:
raise errors.SchemaValidationError(error)
return cls.from_valid_json(jobj)
@classmethod
def json_loads(cls, json_string):
"""Load JSON string."""
return cls.from_json(json.loads(json_string))
def json_dumps(self, *args, **kwargs):
"""Dump to JSON string using proper serializer.
:returns: JSON serialized string.
:rtype: str
"""
return json.dumps(
self, *args, default=util.dump_ijsonserializable, **kwargs)
return super(Message, cls).from_json(jobj)
@Message.register # pylint: disable=too-few-public-methods
@ -96,86 +60,55 @@ class Challenge(Message):
:ivar list challenges: List of
:class:`~letsencrypt.acme.challenges.Challenge` objects.
"""
acme_type = "challenge"
schema = util.load_schema(acme_type)
__slots__ = ("session_id", "nonce", "challenges", "combinations")
.. todo::
1. can challenges contain two challenges of the same type?
2. can challenges contain duplicates?
3. check "combinations" indices are in valid range
4. turn "combinations" elements into sets?
5. turn "combinations" into set?
def _fields_to_json(self):
fields = {
"sessionID": self.session_id,
"nonce": jose.b64encode(self.nonce),
"challenges": self.challenges,
}
if self.combinations:
fields["combinations"] = self.combinations
return fields
"""
typ = "challenge"
schema = util.load_schema(typ)
session_id = jose.Field("sessionID")
nonce = jose.Field("nonce", encoder=jose.b64encode,
decoder=jose.decode_b64jose)
challenges = jose.Field("challenges")
combinations = jose.Field("combinations", omitempty=True, default=())
@challenges.decoder
def challenges(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(challenges.Challenge.from_json(chall) for chall in value)
@property
def resolved_combinations(self):
"""Combinations with challenges instead of indices."""
return [[self.challenges[idx] for idx in combo]
for combo in self.combinations]
@classmethod
def from_valid_json(cls, jobj):
# TODO: can challenges contain two challenges of the same type?
# TODO: can challenges contain duplicates?
# TODO: check "combinations" indices are in valid range
# TODO: turn "combinations" elements into sets?
# TODO: turn "combinations" into set?
return cls(session_id=jobj["sessionID"],
nonce=util.decode_b64jose(jobj["nonce"]),
challenges=[challenges.Challenge.from_valid_json(chall)
for chall in jobj["challenges"]],
combinations=jobj.get("combinations", []))
return tuple(tuple(self.challenges[idx] for idx in combo)
for combo in self.combinations)
@Message.register # pylint: disable=too-few-public-methods
class ChallengeRequest(Message):
"""ACME "challengeRequest" message."""
acme_type = "challengeRequest"
schema = util.load_schema(acme_type)
__slots__ = ("identifier",)
def _fields_to_json(self):
return {
"identifier": self.identifier,
}
@classmethod
def from_valid_json(cls, jobj):
return cls(identifier=jobj["identifier"])
typ = "challengeRequest"
schema = util.load_schema(typ)
identifier = jose.Field("identifier")
@Message.register # pylint: disable=too-few-public-methods
class Authorization(Message):
"""ACME "authorization" message.
:ivar jwk: :class:`letsencrypt.acme.other.JWK`
:ivar jwk: :class:`letsencrypt.acme.jose.JWK`
"""
acme_type = "authorization"
schema = util.load_schema(acme_type)
__slots__ = ("recovery_token", "identifier", "jwk")
typ = "authorization"
schema = util.load_schema(typ)
def _fields_to_json(self):
fields = {}
if self.recovery_token is not None:
fields["recoveryToken"] = self.recovery_token
if self.identifier is not None:
fields["identifier"] = self.identifier
if self.jwk is not None:
fields["jwk"] = self.jwk
return fields
@classmethod
def from_valid_json(cls, jobj):
jwk = jobj.get("jwk")
if jwk is not None:
jwk = other.JWK.from_valid_json(jwk)
return cls(recovery_token=jobj.get("recoveryToken"),
identifier=jobj.get("identifier"), jwk=jwk)
recovery_token = jose.Field("recoveryToken", omitempty=True)
identifier = jose.Field("identifier", omitempty=True)
jwk = jose.Field("jwk", decoder=jose.JWK.from_json, omitempty=True)
@Message.register
@ -189,9 +122,20 @@ class AuthorizationRequest(Message):
:ivar signature: Signature (:class:`letsencrypt.acme.other.Signature`).
"""
acme_type = "authorizationRequest"
schema = util.load_schema(acme_type)
__slots__ = ("session_id", "nonce", "responses", "signature", "contact")
typ = "authorizationRequest"
schema = util.load_schema(typ)
session_id = jose.Field("sessionID")
nonce = jose.Field("nonce", encoder=jose.b64encode,
decoder=jose.decode_b64jose)
responses = jose.Field("responses")
signature = jose.Field("signature", decoder=other.Signature.from_json)
contact = jose.Field("contact", omitempty=True, default=())
@responses.decoder
def responses(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(challenges.ChallengeResponse.from_json(chall)
for chall in value)
@classmethod
def create(cls, name, key, sig_nonce=None, **kwargs):
@ -213,7 +157,7 @@ class AuthorizationRequest(Message):
signature = other.Signature.from_msg(
name + kwargs["nonce"], key, sig_nonce)
return cls(
signature=signature, contact=kwargs.pop("contact", []), **kwargs)
signature=signature, contact=kwargs.pop("contact", ()), **kwargs)
def verify(self, name):
"""Verify signature.
@ -228,29 +172,9 @@ class AuthorizationRequest(Message):
:rtype: bool
"""
# self.signature is not Field | pylint: disable=no-member
return self.signature.verify(name + self.nonce)
def _fields_to_json(self):
fields = {
"sessionID": self.session_id,
"nonce": jose.b64encode(self.nonce),
"responses": self.responses,
"signature": self.signature,
}
if self.contact:
fields["contact"] = self.contact
return fields
@classmethod
def from_valid_json(cls, jobj):
return cls(
session_id=jobj["sessionID"],
nonce=util.decode_b64jose(jobj["nonce"]),
responses=[challenges.ChallengeResponse.from_valid_json(chall)
for chall in jobj["responses"]],
signature=other.Signature.from_valid_json(jobj["signature"]),
contact=jobj.get("contact", []))
@Message.register # pylint: disable=too-few-public-methods
class Certificate(Message):
@ -263,24 +187,21 @@ class Certificate(Message):
wrapped in :class:`letsencrypt.acme.util.ComparableX509` ).
"""
acme_type = "certificate"
schema = util.load_schema(acme_type)
__slots__ = ("certificate", "chain", "refresh")
typ = "certificate"
schema = util.load_schema(typ)
def _fields_to_json(self):
fields = {"certificate": util.encode_cert(self.certificate)}
if self.chain:
fields["chain"] = [util.encode_cert(cert) for cert in self.chain]
if self.refresh is not None:
fields["refresh"] = self.refresh
return fields
certificate = jose.Field("certificate", encoder=jose.encode_cert,
decoder=jose.decode_cert)
chain = jose.Field("chain", omitempty=True, default=())
refresh = jose.Field("refresh", omitempty=True)
@classmethod
def from_valid_json(cls, jobj):
return cls(certificate=util.decode_cert(jobj["certificate"]),
chain=[util.decode_cert(cert) for cert in
jobj.get("chain", [])],
refresh=jobj.get("refresh"))
@chain.decoder
def chain(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(jose.decode_cert(cert) for cert in value)
@chain.encoder
def chain(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(jose.encode_cert(cert) for cert in value)
@Message.register
@ -292,9 +213,12 @@ class CertificateRequest(Message):
:ivar signature: Signature (:class:`letsencrypt.acme.other.Signature`).
"""
acme_type = "certificateRequest"
schema = util.load_schema(acme_type)
__slots__ = ("csr", "signature")
typ = "certificateRequest"
schema = util.load_schema(typ)
csr = jose.Field("csr", encoder=jose.encode_csr,
decoder=jose.decode_csr)
signature = jose.Field("signature", decoder=other.Signature.from_json)
@classmethod
def create(cls, key, sig_nonce=None, **kwargs):
@ -324,49 +248,32 @@ class CertificateRequest(Message):
:rtype: bool
"""
# self.signature is not Field | pylint: disable=no-member
return self.signature.verify(self.csr.as_der())
def _fields_to_json(self):
return {
"csr": util.encode_csr(self.csr),
"signature": self.signature,
}
@classmethod
def from_valid_json(cls, jobj):
return cls(csr=util.decode_csr(jobj["csr"]),
signature=other.Signature.from_valid_json(jobj["signature"]))
@Message.register # pylint: disable=too-few-public-methods
class Defer(Message):
"""ACME "defer" message."""
acme_type = "defer"
schema = util.load_schema(acme_type)
__slots__ = ("token", "interval", "message")
typ = "defer"
schema = util.load_schema(typ)
def _fields_to_json(self):
fields = {"token": self.token}
if self.interval is not None:
fields["interval"] = self.interval
if self.message is not None:
fields["message"] = self.message
return fields
@classmethod
def from_valid_json(cls, jobj):
return cls(token=jobj["token"], interval=jobj.get("interval"),
message=jobj.get("message"))
token = jose.Field("token")
interval = jose.Field("interval", omitempty=True)
message = jose.Field("message", omitempty=True)
@Message.register # pylint: disable=too-few-public-methods
class Error(Message):
"""ACME "error" message."""
acme_type = "error"
schema = util.load_schema(acme_type)
__slots__ = ("error", "message", "more_info")
typ = "error"
schema = util.load_schema(typ)
CODES = {
error = jose.Field("error")
message = jose.Field("message", omitempty=True)
more_info = jose.Field("moreInfo", omitempty=True)
MESSAGE_CODES = {
"malformed": "The request message was malformed",
"unauthorized": "The client lacks sufficient authorization",
"serverInternal": "The server experienced an internal error",
@ -375,33 +282,12 @@ class Error(Message):
"badCSR": "The CSR is unacceptable (e.g., due to a short key)",
}
def _fields_to_json(self):
fields = {"error": self.error}
if self.message is not None:
fields["message"] = self.message
if self.more_info is not None:
fields["moreInfo"] = self.more_info
return fields
@classmethod
def from_valid_json(cls, jobj):
return cls(error=jobj["error"], message=jobj.get("message"),
more_info=jobj.get("moreInfo"))
@Message.register # pylint: disable=too-few-public-methods
class Revocation(Message):
"""ACME "revocation" message."""
acme_type = "revocation"
schema = util.load_schema(acme_type)
__slots__ = ()
def _fields_to_json(self):
return {}
@classmethod
def from_valid_json(cls, jobj):
return cls()
typ = "revocation"
schema = util.load_schema(typ)
@Message.register
@ -413,9 +299,12 @@ class RevocationRequest(Message):
:ivar signature: Signature (:class:`letsencrypt.acme.other.Signature`).
"""
acme_type = "revocationRequest"
schema = util.load_schema(acme_type)
__slots__ = ("certificate", "signature")
typ = "revocationRequest"
schema = util.load_schema(typ)
certificate = jose.Field("certificate", decoder=jose.decode_cert,
encoder=jose.encode_cert)
signature = jose.Field("signature", decoder=other.Signature.from_json)
@classmethod
def create(cls, key, sig_nonce=None, **kwargs):
@ -445,34 +334,13 @@ class RevocationRequest(Message):
:rtype: bool
"""
# self.signature is not Field | pylint: disable=no-member
return self.signature.verify(self.certificate.as_der())
def _fields_to_json(self):
return {
"certificate": util.encode_cert(self.certificate),
"signature": self.signature,
}
@classmethod
def from_valid_json(cls, jobj):
return cls(certificate=util.decode_cert(jobj["certificate"]),
signature=other.Signature.from_valid_json(jobj["signature"]))
@Message.register # pylint: disable=too-few-public-methods
class StatusRequest(Message):
"""ACME "statusRequest" message.
:ivar unicode token: Token provided in ACME "defer" message.
"""
acme_type = "statusRequest"
schema = util.load_schema(acme_type)
__slots__ = ("token",)
def _fields_to_json(self):
return {"token": self.token}
@classmethod
def from_valid_json(cls, jobj):
return cls(token=jobj["token"])
"""ACME "statusRequest" message."""
typ = "statusRequest"
schema = util.load_schema(typ)
token = jose.Field("token")

View file

@ -9,17 +9,19 @@ 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(
CERT = jose.ComparableX509(M2Crypto.X509.load_cert(
pkg_resources.resource_filename(
'letsencrypt.client.tests', 'testdata/cert.pem')))
CSR = util.ComparableX509(M2Crypto.X509.load_request(
CSR = jose.ComparableX509(M2Crypto.X509.load_request(
pkg_resources.resource_filename(
'letsencrypt.client.tests', 'testdata/csr.pem')))
CSR2 = jose.ComparableX509(M2Crypto.X509.load_request(
pkg_resources.resource_filename(
'letsencrypt.acme.jose', 'testdata/csr2.pem')))
class MessageTest(unittest.TestCase):
@ -35,7 +37,7 @@ class MessageTest(unittest.TestCase):
@MockParentMessage.register
class MockMessage(MockParentMessage):
acme_type = 'test'
typ = 'test'
schema = {
'type': 'object',
'properties': {
@ -43,56 +45,27 @@ class MessageTest(unittest.TestCase):
'name': {'type': 'string'},
},
}
__slots__ = ('price', 'name')
@classmethod
def from_valid_json(cls, jobj):
return cls(price=jobj.get('price'), name=jobj.get('name'))
def _fields_to_json(self):
# pylint: disable=no-member
return {'price': self.price, 'name': self.name}
price = jose.Field('price')
name = jose.Field('name')
self.parent_cls = MockParentMessage
self.msg = MockMessage(price=123, name='foo')
def test_from_json_non_dict_fails(self):
self.assertRaises(errors.ValidationError, self.parent_cls.from_json, [])
def test_from_json_dict_no_type_fails(self):
self.assertRaises(errors.ValidationError, self.parent_cls.from_json, {})
def test_from_json_unrecognized_type(self):
self.assertRaises(errors.UnrecognizedTypeError,
self.parent_cls.from_json, {'type': 'foo'})
def test_from_json_validates(self):
self.assertRaises(errors.SchemaValidationError,
self.parent_cls.from_json,
{'type': 'test', 'price': 'asd'})
def test_from_json(self):
self.assertEqual(self.msg, self.parent_cls.from_json(
{'type': 'test', 'name': 'foo', 'price': 123}))
def test_json_loads(self):
self.assertEqual(self.msg, self.parent_cls.json_loads(
'{"type": "test", "name": "foo", "price": 123}'))
def test_json_dumps(self):
self.assertEqual(self.msg.json_dumps(sort_keys=True),
'{"name": "foo", "price": 123, "type": "test"}')
class ChallengeTest(unittest.TestCase):
def setUp(self):
challs = [
challs = (
challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'),
challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'),
challenges.RecoveryToken(),
]
combinations = [[0, 2], [1, 2]]
)
combinations = ((0, 2), (1, 2))
from letsencrypt.acme.messages import Challenge
self.msg = Challenge(
@ -112,21 +85,21 @@ class ChallengeTest(unittest.TestCase):
'type': 'challenge',
'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
'challenges': [chall.to_json() for chall in challs],
'combinations': combinations,
'challenges': [chall.fully_serialize() for chall in challs],
'combinations': [[0, 2], [1, 2]], # TODO array tuples
}
def test_resolved_combinations(self):
self.assertEqual(self.msg.resolved_combinations, [
[
self.assertEqual(self.msg.resolved_combinations, (
(
challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'),
challenges.RecoveryToken()
],
[
),
(
challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'),
challenges.RecoveryToken(),
]
])
)
))
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg_to)
@ -142,7 +115,7 @@ class ChallengeTest(unittest.TestCase):
from letsencrypt.acme.messages import Challenge
msg = Challenge.from_json(self.jmsg_from)
self.assertEqual(msg.combinations, [])
self.assertEqual(msg.combinations, ())
self.assertEqual(msg.to_json(), self.jmsg_to)
@ -168,7 +141,7 @@ class ChallengeRequestTest(unittest.TestCase):
class AuthorizationTest(unittest.TestCase):
def setUp(self):
jwk = other.JWK(key=KEY.publickey())
jwk = jose.JWKRSA(key=KEY.publickey())
from letsencrypt.acme.messages import Authorization
self.msg = Authorization(recovery_token='tok', jwk=jwk,
@ -207,14 +180,14 @@ class AuthorizationTest(unittest.TestCase):
class AuthorizationRequestTest(unittest.TestCase):
def setUp(self):
self.responses = [
self.responses = (
challenges.SimpleHTTPSResponse(path='Hf5GrX4Q7EBax9hc2jJnfw'),
None, # null
challenges.RecoveryTokenResponse(token='23029d88d9e123e'),
]
self.contact = ["mailto:cert-admin@example.com", "tel:+12025551212"]
)
self.contact = ("mailto:cert-admin@example.com", "tel:+12025551212")
signature = other.Signature(
alg='RS256', jwk=other.JWK(key=KEY.publickey()),
alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.publickey()),
sig='-v\xd8\xc2\xa3\xba0\xd6\x92\x16\xb5.\xbe\xa1[\x04\xbe'
'\x1b\xa1X\xd2)\x18\x94\x8f\xd7\xd0\xc0\xbbcI`W\xdf v'
'\xe4\xed\xe8\x03J\xe8\xc8<?\xc8W\x94\x94cj(\xe7\xaa$'
@ -242,13 +215,14 @@ class AuthorizationRequestTest(unittest.TestCase):
'type': 'authorizationRequest',
'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
'responses': [None if response is None else response.to_json()
'responses': [None if response is None
else response.fully_serialize()
for response in self.responses],
'signature': signature.to_json(),
'contact': self.contact,
'signature': signature.fully_serialize(),
# 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
@ -277,7 +251,7 @@ class AuthorizationRequestTest(unittest.TestCase):
from letsencrypt.acme.messages import AuthorizationRequest
msg = AuthorizationRequest.from_json(self.jmsg_from)
self.assertEqual(msg.contact, [])
self.assertEqual(msg.contact, ())
self.assertEqual(self.jmsg_to, msg.to_json())
@ -288,39 +262,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)
self.assertEqual(self.msg.to_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_json())
class CertificateRequestTest(unittest.TestCase):
def setUp(self):
signature = other.Signature(
alg='RS256', jwk=other.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'
@ -330,11 +309,14 @@ 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'].fully_serialize()
def test_create(self):
from letsencrypt.acme.messages import CertificateRequest
@ -346,13 +328,11 @@ class CertificateRequestTest(unittest.TestCase):
self.assertTrue(self.msg.verify())
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
self.assertEqual(self.msg.to_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):
@ -444,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=other.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'
@ -454,11 +434,14 @@ 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'].fully_serialize()
def test_create(self):
from letsencrypt.acme.messages import RevocationRequest
@ -469,14 +452,11 @@ class RevocationRequestTest(unittest.TestCase):
self.assertTrue(self.msg.verify())
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
self.assertEqual(self.msg.to_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):

View file

@ -1,59 +1,14 @@
"""Other ACME objects."""
import binascii
import functools
import logging
import Crypto.Random
import Crypto.Hash.SHA256
import Crypto.PublicKey.RSA
import Crypto.Signature.PKCS1_v1_5
from letsencrypt.acme import errors
from letsencrypt.acme import jose
from letsencrypt.acme import util
class JWK(util.ACMEObject):
# pylint: disable=too-few-public-methods
"""JSON Web Key.
.. todo:: Currently works for RSA public keys only.
"""
__slots__ = ('key',)
@classmethod
def _encode_param(cls, data):
def _leading_zeros(arg):
if len(arg) % 2:
return '0' + arg
return arg
return jose.b64encode(binascii.unhexlify(
_leading_zeros(hex(data)[2:].rstrip('L'))))
@classmethod
def _decode_param(cls, data):
try:
return long(binascii.hexlify(util.decode_b64jose(data)), 16)
except ValueError: # invalid literal for long() with base 16
raise errors.ValidationError(data)
def to_json(self):
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']))))
class Signature(util.ACMEObject):
class Signature(jose.JSONObjectWithFields):
"""ACME signature.
:ivar str alg: Signature algorithm.
@ -63,16 +18,20 @@ class Signature(util.ACMEObject):
:ivar jwk: JWK.
:type jwk: :class:`JWK`
.. todo:: Currently works for RSA keys only.
"""
__slots__ = ('alg', 'sig', 'nonce', 'jwk')
NONCE_SIZE = 16
"""Minimum size of nonce in bytes."""
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, nonce_size=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?
@ -94,13 +53,11 @@ class Signature(util.ACMEObject):
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=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.
@ -108,22 +65,5 @@ class Signature(util.ACMEObject):
: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):
return {
'alg': self.alg,
'sig': jose.b64encode(self.sig),
'nonce': jose.b64encode(self.nonce),
'jwk': self.jwk,
}
@classmethod
def from_valid_json(cls, jobj):
assert jobj['alg'] == 'RS256' # TODO: support other algorithms
return cls(alg=jobj['alg'], sig=util.decode_b64jose(jobj['sig']),
nonce=util.decode_b64jose(
jobj['nonce'], cls.NONCE_SIZE, minimum=True),
jwk=JWK.from_valid_json(jobj['jwk']))
# self.alg is not Field, but JWA | pylint: disable=no-member
return self.alg.verify(self.jwk.key, self.nonce + msg, self.sig)

View file

@ -4,7 +4,7 @@ import unittest
import Crypto.PublicKey.RSA
from letsencrypt.acme import errors
from letsencrypt.acme import jose
RSA256_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
@ -13,68 +13,20 @@ 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.other.JWK."""
def setUp(self):
from letsencrypt.acme.other 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.other import JWK
self.assertEqual(self.jwk256, JWK.from_valid_json(self.jwk256json))
# TODO: fix schemata to allow RSA512
#self.assertEqual(self.jwk512, JWK.from_json(self.jwk512json))
def test_from_json_non_schema_errors(self):
# valid against schema, but still failing
from letsencrypt.acme.other import JWK
self.assertRaises(errors.ValidationError, JWK.from_valid_json,
{'kty': 'RSA', 'e': 'AQAB', 'n': ''})
self.assertRaises(errors.ValidationError, JWK.from_valid_json,
{'kty': 'RSA', 'e': 'AQAB', 'n': '1'})
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'
from letsencrypt.acme.other import JWK
self.jwk = JWK(key=RSA256_KEY.publickey())
self.alg = jose.RS256
self.jwk = jose.JWKRSA(key=RSA256_KEY.publickey())
b64sig = ('SUPYKucUnhlTt8_sMxLiigOYdf_wlOLXPI-o7aRLTsOquVjDd6r'
'AX9AFJHk-bCMQPJbSzXKjG6H1IWbvxjS2Ew')
@ -88,7 +40,7 @@ class SignatureTest(unittest.TestCase):
self.jsig_from = {
'nonce': b64nonce,
'alg': self.alg,
'alg': self.alg.to_json(),
'jwk': self.jwk.to_json(),
'sig': b64sig,
}
@ -130,15 +82,17 @@ class SignatureTest(unittest.TestCase):
def test_from_json(self):
from letsencrypt.acme.other import Signature
self.assertEqual(
self.signature, Signature.from_valid_json(self.jsig_from))
self.signature, Signature.from_json(self.jsig_from))
def test_from_json_non_schema_errors(self):
from letsencrypt.acme.other import Signature
jwk = self.jwk.to_json()
self.assertRaises(errors.ValidationError, Signature.from_valid_json, {
'alg': 'RS256', 'sig': 'x', 'nonce': '', 'jwk': jwk})
self.assertRaises(errors.ValidationError, Signature.from_valid_json, {
'alg': 'RS256', 'sig': '', 'nonce': 'x', 'jwk': jwk})
self.assertRaises(
jose.DeserializationError, Signature.from_json, {
'alg': 'RS256', 'sig': 'x', 'nonce': '', 'jwk': jwk})
self.assertRaises(
jose.DeserializationError, Signature.from_json, {
'alg': 'RS256', 'sig': '', 'nonce': 'x', 'jwk': jwk})
if __name__ == '__main__':

View file

@ -1,247 +1,9 @@
"""ACME utilities."""
import binascii
import json
import pkg_resources
import M2Crypto
import zope.interface
from letsencrypt.acme import errors
from letsencrypt.acme import interfaces
from letsencrypt.acme import jose
class ComparableX509(object): # pylint: disable=too-few-public-methods
"""Wrapper for M2Crypto.X509.* objects that supports __eq__.
Wraps around:
- :class:`M2Crypto.X509.X509`
- :class:`M2Crypto.X509.Request`
"""
def __init__(self, wrapped):
self._wrapped = wrapped
def __getattr__(self, name):
return getattr(self._wrapped, name)
def __eq__(self, other):
return self.as_der() == other.as_der()
def load_schema(name):
"""Load JSON schema from distribution."""
return json.load(open(pkg_resources.resource_filename(
__name__, "schemata/%s.json" % name)))
def dump_ijsonserializable(python_object):
"""Serialize IJSONSerializable to JSON.
This is meant to be passed to :func:`json.dumps` as ``default``
argument in order to facilitate recursive calls to
:meth:`~letsencrypt.acme.interfaces.IJSONSerializable.to_json`.
Please see :meth:`letsencrypt.acme.interfaces.IJSONSerializable.to_json`
for an example.
"""
# providedBy | pylint: disable=no-member
if interfaces.IJSONSerializable.providedBy(python_object):
return python_object.to_json()
else:
raise TypeError(repr(python_object) + ' is not JSON serializable')
class ImmutableMap(object): # pylint: disable=too-few-public-methods
"""Immutable key to value mapping with attribute access."""
__slots__ = ()
"""Must be overriden in subclasses."""
def __init__(self, **kwargs):
if set(kwargs) != set(self.__slots__):
raise TypeError(
'__init__() takes exactly the following arguments: {0} '
'({1} given)'.format(', '.join(self.__slots__),
', '.join(kwargs) if kwargs else 'none'))
for slot in self.__slots__:
object.__setattr__(self, slot, kwargs.pop(slot))
def __setattr__(self, name, value):
raise AttributeError("can't set attribute")
def __eq__(self, other):
return isinstance(other, self.__class__) and all(
getattr(self, slot) == getattr(other, slot)
for slot in self.__slots__)
def __hash__(self):
return hash(tuple(getattr(self, slot) for slot in self.__slots__))
def __repr__(self):
return '{0}({1})'.format(self.__class__.__name__, ', '.join(
'{0}={1!r}'.format(slot, getattr(self, slot))
for slot in self.__slots__))
class ACMEObject(ImmutableMap): # pylint: disable=too-few-public-methods
"""ACME object."""
zope.interface.implements(interfaces.IJSONSerializable)
zope.interface.classImplements(interfaces.IJSONDeserializable)
def to_json(self): # pragma: no cover
"""Serialize to JSON."""
raise NotImplementedError()
@classmethod
def from_valid_json(cls, jobj): # pragma: no cover
"""Deserialize from valid JSON object."""
raise NotImplementedError()
def decode_b64jose(value, size=None, minimum=False):
"""Decode ACME object JOSE Base64 encoded field.
:param str value: Encoded field value.
:param int size: If specified, this function will check if data size
(after decoding) matches.
:param bool minimum: If ``True``, then ``size`` is the minimum required
size, otherwise ``size`` must be exact.
:raises letsencrypt.acme.errors.ValidationError: if anything goes wrong
:returns: Decoded value.
"""
try:
decoded = jose.b64decode(value)
except TypeError:
raise errors.ValidationError()
if size is not None and ((not minimum and len(decoded) != size)
or (minimum and len(decoded) < size)):
raise errors.ValidationError()
return decoded
def decode_hex16(value, size=None, minimum=False):
"""Decode ACME object hex16-encoded field.
:param str value: Encoded field value.
:param int size: If specified, this function will check if data size
(after decoding) matches.
:param bool minimum: If ``True``, then ``size`` is the minimum required
size, otherwise ``size`` must be exact.
"""
# binascii.hexlify.__doc__: "The resulting string is therefore twice
# as long as the length of data."
if size is not None and ((not minimum and len(value) != size * 2)
or (minimum and len(value) < size * 2)):
raise errors.ValidationError()
try:
return binascii.unhexlify(value)
except TypeError as error: # odd-length string (binascci.unhexlify.__doc__)
raise errors.ValidationError(error)
def encode_cert(cert):
"""Encode ACME object X509 certificate field."""
return jose.b64encode(cert.as_der())
def decode_cert(b64der):
"""Decode ACME object X509 certificate field.
:param str b64der: Input data that's meant to be valid base64
DER-encoded certificate.
:raises letsencrypt.acme.errors.ValidationError: if anything goes wrong
:returns: Decoded certificate.
:rtype: :class:`M2Crypto.X509.X509` wrapped in :class:`ComparableX509`.
"""
try:
return ComparableX509(M2Crypto.X509.load_cert_der_string(
decode_b64jose(b64der)))
except M2Crypto.X509.X509Error:
raise errors.ValidationError()
def encode_csr(csr):
"""Encode ACME object CSR field."""
return encode_cert(csr)
def decode_csr(b64der):
"""Decode ACME object CSR field.
:param str b64der: Input data that's meant to be valid base64
DER-encoded CSR.
:raises letsencrypt.acme.errors.ValidationError: if anything goes wrong
:returns: Decoded certificate.
:rtype: :class:`M2Crypto.X509.X509` wrapped in :class:`ComparableX509`.
"""
try:
return ComparableX509(M2Crypto.X509.load_request_der_string(
decode_b64jose(b64der)))
except M2Crypto.X509.X509Error:
raise errors.ValidationError()
class TypedACMEObject(ACMEObject):
"""ACME object with type (immutable)."""
acme_type = NotImplemented
"""ACME "type" field. Subclasses must override."""
TYPES = NotImplemented
"""Types registered for JSON deserialization"""
@classmethod
def register(cls, msg_cls):
"""Register class for JSON deserialization."""
cls.TYPES[msg_cls.acme_type] = msg_cls
return msg_cls
def to_json(self):
"""Get JSON serializable object.
:returns: Serializable JSON object representing ACME typed object.
:rtype: dict
"""
jobj = self._fields_to_json()
jobj["type"] = self.acme_type
return jobj
def _fields_to_json(self): # pragma: no cover
"""Prepare ACME object fields for JSON serialiazation.
Subclasses must override this method.
:returns: Serializable JSON object containg all ACME object fields
apart from "type".
:rtype: dict
"""
raise NotImplementedError()
@classmethod
def from_valid_json(cls, jobj):
"""Deserialize ACME object from valid JSON object.
:raises letsencrypt.acme.errors.UnrecognizedTypeError: if type
of the ACME object has not been registered.
"""
try:
msg_cls = cls.TYPES[jobj["type"]]
except KeyError:
raise errors.UnrecognizedTypeError(jobj["type"])
return msg_cls.from_valid_json(jobj)

View file

@ -1,240 +0,0 @@
"""Tests for letsencrypt.acme.util."""
import functools
import json
import os
import pkg_resources
import unittest
import M2Crypto
import zope.interface
from letsencrypt.acme import errors
from letsencrypt.acme import interfaces
CERT = M2Crypto.X509.load_cert(pkg_resources.resource_filename(
'letsencrypt.client.tests', os.path.join('testdata', 'cert.pem')))
CSR = M2Crypto.X509.load_request(pkg_resources.resource_filename(
'letsencrypt.client.tests', os.path.join('testdata', 'csr.pem')))
class DumpIJSONSerializableTest(unittest.TestCase):
"""Tests for letsencrypt.acme.util.dump_ijsonserializable."""
class MockJSONSerialiazable(object):
# pylint: disable=missing-docstring,too-few-public-methods,no-self-use
zope.interface.implements(interfaces.IJSONSerializable)
def to_json(self):
return [3, 2, 1]
@classmethod
def _call(cls, obj):
from letsencrypt.acme.util import dump_ijsonserializable
return json.dumps(obj, default=dump_ijsonserializable)
def test_json_type(self):
self.assertEqual('5', self._call(5))
def test_ijsonserializable(self):
self.assertEqual('[3, 2, 1]', self._call(self.MockJSONSerialiazable()))
def test_raises_type_error(self):
self.assertRaises(TypeError, self._call, object())
class ImmutableMapTest(unittest.TestCase):
"""Tests for letsencrypt.acme.util.ImmutableMap."""
def setUp(self):
# pylint: disable=invalid-name,too-few-public-methods
# pylint: disable=missing-docstring
from letsencrypt.acme.util import ImmutableMap
class A(ImmutableMap):
__slots__ = ('x', 'y')
class B(ImmutableMap):
__slots__ = ('x', 'y')
self.A = A
self.B = B
self.a1 = self.A(x=1, y=2)
self.a1_swap = self.A(y=2, x=1)
self.a2 = self.A(x=3, y=4)
self.b = self.B(x=1, y=2)
def test_order_of_args_does_not_matter(self):
self.assertEqual(self.a1, self.a1_swap)
def test_type_error_on_missing(self):
self.assertRaises(TypeError, self.A, x=1)
self.assertRaises(TypeError, self.A, y=2)
def test_type_error_on_unrecognized(self):
self.assertRaises(TypeError, self.A, x=1, z=2)
self.assertRaises(TypeError, self.A, x=1, y=2, z=3)
def test_get_attr(self):
self.assertEqual(1, self.a1.x)
self.assertEqual(2, self.a1.y)
self.assertEqual(1, self.a1_swap.x)
self.assertEqual(2, self.a1_swap.y)
def test_set_attr_raises_attribute_error(self):
self.assertRaises(
AttributeError, functools.partial(self.a1.__setattr__, 'x'), 10)
def test_equal(self):
self.assertEqual(self.a1, self.a1)
self.assertEqual(self.a2, self.a2)
self.assertNotEqual(self.a1, self.a2)
def test_same_slots_diff_cls_not_equal(self):
self.assertEqual(self.a1.x, self.b.x)
self.assertEqual(self.a1.y, self.b.y)
self.assertNotEqual(self.a1, self.b)
def test_hash(self):
self.assertEqual(hash((1, 2)), hash(self.a1))
def test_unhashable(self):
self.assertRaises(TypeError, self.A(x=1, y={}).__hash__)
def test_repr(self):
self.assertEqual('A(x=1, y=2)', repr(self.a1))
self.assertEqual('A(x=1, y=2)', repr(self.a1_swap))
self.assertEqual('B(x=1, y=2)', repr(self.b))
self.assertEqual("B(x='foo', y='bar')", repr(self.B(x='foo', y='bar')))
class EncodersAndDecodersTest(unittest.TestCase):
"""Tests for encoders and decoders from letsencrypt.acme.util"""
# pylint: disable=protected-access
def setUp(self):
self.b64_cert = (
'MIIB3jCCAYigAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhM'
'CVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKz'
'ApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxF'
'DASBgNVBAMMC2V4YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIx'
'ODIyMzQ0NVowdzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRI'
'wEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTW'
'ljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4YW1wbGUuY29tMFwwD'
'QYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR7R_drnBSQ_zfx1vQLHUbFLh1'
'AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c-pVE6K-EdE_twuUCAwE'
'AATANBgkqhkiG9w0BAQsFAANBAC24z0IdwIVKSlntksllvr6zJepBH5fMnd'
'fk3XJp10jT6VE-14KNtjh02a56GoraAvJAT5_H67E8GvJ_ocNnB_o'
)
self.b64_csr = (
'MIIBXTCCAQcCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2F'
'uMRIwEAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECw'
'wWVW5pdmVyc2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAwwLZXhhbXBsZS5jb'
'20wXDANBgkqhkiG9w0BAQEFAANLADBIAkEArHVztFHtH92ucFJD_N_HW9As'
'dRsUuHUBBBDlHwNlRd3fp580rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3'
'C5QIDAQABoCkwJwYJKoZIhvcNAQkOMRowGDAWBgNVHREEDzANggtleGFtcG'
'xlLmNvbTANBgkqhkiG9w0BAQsFAANBAHJH_O6BtC9aGzEVCMGOZ7z9iIRHW'
'Szr9x_bOzn7hLwsbXPAgO1QxEwL-X-4g20Gn9XBE1N9W6HCIEut2d8wACg'
)
def test_decode_b64_jose_padding_error(self):
from letsencrypt.acme.util import decode_b64jose
self.assertRaises(errors.ValidationError, decode_b64jose, 'x')
def test_decode_b64_jose_size(self):
from letsencrypt.acme.util import decode_b64jose
self.assertEqual('foo', decode_b64jose('Zm9v', size=3))
self.assertRaises(
errors.ValidationError, decode_b64jose, 'Zm9v', size=2)
self.assertRaises(
errors.ValidationError, decode_b64jose, 'Zm9v', size=4)
def test_decode_b64_jose_minimum_size(self):
from letsencrypt.acme.util import decode_b64jose
self.assertEqual('foo', decode_b64jose('Zm9v', size=3, minimum=True))
self.assertEqual('foo', decode_b64jose('Zm9v', size=2, minimum=True))
self.assertRaises(errors.ValidationError, decode_b64jose,
'Zm9v', size=4, minimum=True)
def test_decode_hex16(self):
from letsencrypt.acme.util import decode_hex16
self.assertEqual('foo', decode_hex16('666f6f'))
def test_decode_hex16_minimum_size(self):
from letsencrypt.acme.util import decode_hex16
self.assertEqual('foo', decode_hex16('666f6f', size=3, minimum=True))
self.assertEqual('foo', decode_hex16('666f6f', size=2, minimum=True))
self.assertRaises(errors.ValidationError, decode_hex16,
'666f6f', size=4, minimum=True)
def test_decode_hex16_odd_length(self):
from letsencrypt.acme.util import decode_hex16
self.assertRaises(errors.ValidationError, decode_hex16, 'x')
def test_encode_cert(self):
from letsencrypt.acme.util import encode_cert
self.assertEqual(self.b64_cert, encode_cert(CERT))
def test_decode_cert(self):
from letsencrypt.acme.util import ComparableX509
from letsencrypt.acme.util import decode_cert
cert = decode_cert(self.b64_cert)
self.assertTrue(isinstance(cert, ComparableX509))
self.assertEqual(cert, CERT)
self.assertRaises(errors.ValidationError, decode_cert, '')
def test_encode_csr(self):
from letsencrypt.acme.util import encode_csr
self.assertEqual(self.b64_csr, encode_csr(CSR))
def test_decode_csr(self):
from letsencrypt.acme.util import ComparableX509
from letsencrypt.acme.util import decode_csr
csr = decode_csr(self.b64_csr)
self.assertTrue(isinstance(csr, ComparableX509))
self.assertEqual(csr, CSR)
self.assertRaises(errors.ValidationError, decode_csr, '')
class TypedACMEObjectTest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.util import TypedACMEObject
# pylint: disable=missing-docstring,abstract-method
# pylint: disable=too-few-public-methods
class MockParentTypedACMEObject(TypedACMEObject):
TYPES = {}
@MockParentTypedACMEObject.register
class MockTypedACMEObject(MockParentTypedACMEObject):
acme_type = 'test'
@classmethod
def from_valid_json(cls, unused_obj):
return '!'
def _fields_to_json(self):
return {'foo': 'bar'}
self.parent_cls = MockParentTypedACMEObject
self.msg = MockTypedACMEObject()
def test_to_json(self):
self.assertEqual(self.msg.to_json(), {
'type': 'test',
'foo': 'bar',
})
def test_from_json_unknown_type_fails(self):
self.assertRaises(errors.UnrecognizedTypeError,
self.parent_cls.from_valid_json, {'type': 'bar'})
def test_from_json_returns_obj(self):
self.assertEqual(self.parent_cls.from_valid_json({'type': 'test'}), '!')
if __name__ == '__main__':
unittest.main()

View file

@ -17,7 +17,7 @@ Note, that all annotated challenges act as a proxy objects::
"""
from letsencrypt.acme import challenges
from letsencrypt.acme import util as acme_util
from letsencrypt.acme.jose import util as jose_util
from letsencrypt.client import crypto_util
@ -25,7 +25,7 @@ from letsencrypt.client import crypto_util
# pylint: disable=too-few-public-methods
class AnnotatedChallenge(acme_util.ImmutableMap):
class AnnotatedChallenge(jose_util.ImmutableMap):
"""Client annotated challenge.
Wraps around :class:`~letsencrypt.acme.challenges.Challenge` and
@ -88,7 +88,7 @@ class ProofOfPossession(AnnotatedChallenge):
acme_type = challenges.ProofOfPossession
class Indexed(acme_util.ImmutableMap):
class Indexed(jose_util.ImmutableMap):
"""Indexed and annotated ACME challenge.
Wraps around :class:`AnnotatedChallenge` and annotates with an

View file

@ -548,8 +548,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
def _enable_redirect(self, ssl_vhost, unused_options):
"""Redirect all equivalent HTTP traffic to ssl_vhost.
.. todo:: This enhancement should be rewritten and will unfortunately
require lots of debugging by hand.
.. todo:: This enhancement should be rewritten and will
unfortunately require lots of debugging by hand.
Adds Redirect directive to the port 80 equivalent of ssl_vhost
First the function attempts to find the vhost with equivalent
ip addresses that serves on non-ssl ports

View file

@ -301,8 +301,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
else:
raise errors.LetsEncryptClientError(
"Received unsupported challenge of type: "
"%s" % chall.acme_type)
"Received unsupported challenge of type: %s", chall.typ)
ichall = achallenges.Indexed(achall=achall, index=index)

View file

@ -7,7 +7,7 @@ import Crypto.PublicKey.RSA
import M2Crypto
from letsencrypt.acme import messages
from letsencrypt.acme import util as acme_util
from letsencrypt.acme.jose import util as jose_util
from letsencrypt.client import auth_handler
from letsencrypt.client import client_authenticator
@ -130,7 +130,7 @@ class Client(object):
logging.info("Preparing and sending CSR...")
return self.network.send_and_receive_expected(
messages.CertificateRequest.create(
csr=acme_util.ComparableX509(
csr=jose_util.ComparableX509(
M2Crypto.X509.load_request_der_string(csr_der)),
key=Crypto.PublicKey.RSA.importKey(self.authkey.pem)),
messages.Certificate)

View file

@ -5,12 +5,15 @@ import time
import requests
from letsencrypt.acme import errors as acme_errors
from letsencrypt.acme import jose
from letsencrypt.acme import messages
from letsencrypt.client import errors
# https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning
requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3()
logging.getLogger("requests").setLevel(logging.WARNING)
@ -57,7 +60,7 @@ class Network(object):
json_string = response.json()
try:
return messages.Message.from_json(json_string)
except acme_errors.ValidationError as error:
except jose.DeserializationError as error:
logging.error(json_string)
raise # TODO

View file

@ -17,7 +17,7 @@ import Crypto.PublicKey.RSA
import M2Crypto
from letsencrypt.acme import messages
from letsencrypt.acme import util as acme_util
from letsencrypt.acme.jose import util as jose_util
from letsencrypt.client import errors
from letsencrypt.client import le_util
@ -240,7 +240,7 @@ class Revoker(object):
"""
# These will both have to change in the future away from M2Crypto
# pylint: disable=protected-access
certificate = acme_util.ComparableX509(cert._cert)
certificate = jose_util.ComparableX509(cert._cert)
try:
with open(cert.backup_key_path, "rU") as backup_key_file:
key = Crypto.PublicKey.RSA.importKey(backup_key_file.read())

View file

@ -5,7 +5,7 @@ import pkg_resources
import Crypto.PublicKey.RSA
from letsencrypt.acme import challenges
from letsencrypt.acme import other
from letsencrypt.acme import jose
KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
@ -26,7 +26,7 @@ RECOVERY_TOKEN = challenges.RecoveryToken()
POP = challenges.ProofOfPossession(
alg="RS256", nonce="xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ",
hints=challenges.ProofOfPossession.Hints(
jwk=other.JWK(key=KEY.publickey()),
jwk=jose.JWKRSA(key=KEY.publickey()),
cert_fingerprints=[
"93416768eb85e33adc4277f4c9acd63e7418fcfe",
"16d95b7b63f1972b980b14c20291f3c0d1855d95",

View file

@ -335,7 +335,7 @@ class SatisfyChallengesTest(unittest.TestCase):
# pylint: disable=no-self-use
exp_resp = [None] * len(challs)
for i in path:
exp_resp[i] = TRANSLATE[challs[i].acme_type] + str(domain)
exp_resp[i] = TRANSLATE[challs[i].typ] + str(domain)
return exp_resp

View file

@ -21,5 +21,9 @@ def _transform(cls):
for slot in cls.slots():
cls.locals[slot.value] = [nodes.EmptyNode()]
if cls.name == 'JSONObjectWithFields':
# _fields is magically introduced by JSONObjectWithFieldsMeta
cls.locals['_fields'] = [nodes.EmptyNode()]
MANAGER.register_transform(nodes.Class, _transform)

View file

@ -5,6 +5,12 @@ import re
from setuptools import setup
# Workaround for http://bugs.python.org/issue8876, see
# http://bugs.python.org/issue8876#msg208792
# This can be removed when using Python 2.7.9 or later:
# https://hg.python.org/cpython/raw-file/v2.7.9/Misc/NEWS
if os.path.abspath(__file__).split(os.path.sep)[1] == 'vagrant':
del os.link
def read_file(filename, encoding='utf8'):
"""Read unicode from given file."""
@ -26,7 +32,9 @@ install_requires = [
'ConfArgParse',
'jsonschema',
'mock',
'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304)
'psutil>=2.1.0', # net_connections introduced in 2.1.0
'pyasn1', # urllib3 InsecurePlatformWarning (#304)
'pycrypto',
'PyOpenSSL',
'python-augeas',
@ -40,7 +48,9 @@ install_requires = [
]
dev_extras = [
'pylint>=1.4.0', # upstream #248
# Pin astroid==1.3.5, pylint==1.4.2 as a workaround for #289
'astroid==1.3.5',
'pylint==1.4.2', # upstream #248
]
docs_extras = [
@ -85,6 +95,7 @@ setup(
packages=[
'letsencrypt',
'letsencrypt.acme',
'letsencrypt.acme.jose',
'letsencrypt.client',
'letsencrypt.client.apache',
'letsencrypt.client.display',
@ -107,6 +118,7 @@ setup(
entry_points={
'console_scripts': [
'letsencrypt = letsencrypt.scripts.main:main',
'jws = letsencrypt.acme.jose.jws:CLI.run',
],
},

View file

@ -12,12 +12,14 @@ commands =
setenv =
PYTHONPATH = {toxinidir}
PYTHONHASHSEED = 0
# https://testrun.org/tox/latest/example/basic.html#special-handling-of-pythonhas
[testenv:cover]
basepython = python2.7
commands =
pip install -e .[testing]
python setup.py nosetests --with-coverage --cover-min-percentage=85
python setup.py nosetests --with-coverage --cover-min-percentage=86
[testenv:lint]
# recent versions of pylint do not support Python 2.6 (#97, #187)