merge github/letsencrypt/master

This commit is contained in:
Jakub Warmuz 2015-04-22 09:16:13 +00:00
commit b0fe02f732
No known key found for this signature in database
GPG key ID: 2A7BAD3A489B52EA
86 changed files with 5586 additions and 353 deletions

2
.gitignore vendored
View file

@ -1,5 +1,6 @@
*.pyc
*.egg-info
.eggs/
build/
dist/
venv/
@ -9,3 +10,4 @@ m3
*~
.vagrant
*.swp
\#*#

View file

@ -1,10 +1,7 @@
language: python
# please keep this in sync with docs/using.rst (Ubuntu section, apt-get)
before_install: >
travis_retry sudo apt-get install python python-setuptools
python-virtualenv python-dev gcc swig dialog libaugeas0 libssl-dev
libffi-dev ca-certificates
# http://docs.travis-ci.com/user/ci-environment/#CI-environment-OS
before_install: travis_retry sudo ./bootstrap/ubuntu.sh
install: "travis_retry pip install tox coveralls"
script: "travis_retry tox"
@ -22,4 +19,10 @@ env:
notifications:
email: false
irc: "chat.freenode.net#letsencrypt"
irc:
channels:
- "chat.freenode.net#letsencrypt"
on_success: never
on_failure: always
use_notice: true
skip_join: true

View file

@ -1,4 +1,14 @@
Let's Encrypt Preview:
Copyright (c) Internet Security Research Group
Licensed Apache Version 2.0
Incorporating code from nginxparser
Copyright (c) 2014 Fatih Erikli
Licensed MIT
Text of Apache License
======================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
@ -173,3 +183,23 @@
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
Text of MIT License
===================
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

4
Vagrantfile vendored
View file

@ -6,10 +6,8 @@ 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
sudo ./bootstrap/ubuntu.sh
if [ ! -d "venv" ]; then
virtualenv --no-site-packages -p python2 venv
./venv/bin/python setup.py dev

2
bootstrap/README Normal file
View file

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

35
bootstrap/_deb_common.sh Executable file
View file

@ -0,0 +1,35 @@
#!/bin/sh
# Tested with:
# - Ubuntu:
# - 12.04 (x64, Travis)
# - 14.04 (x64, Vagrant)
# - 14.10 (x64)
# - Debian:
# - 6.0.10 "squeeze" (x64)
# - 7.8 "wheezy" (x64)
# - 8.0 "jessie" (x64)
# virtualenv binary can be found in different packages depending on
# distro version (#346)
distro=$(lsb_release -si)
# 6.0.10 => 60, 14.04 => 1404
version=$(lsb_release -sr | awk -F '.' '{print $1 $2}')
if [ "$distro" = "Ubuntu" -a "$version" -ge 1410 ]
then
virtualenv="virtualenv"
elif [ "$distro" = "Debian" -a "$version" -ge 80 ]
then
virtualenv="virtualenv"
else
virtualenv="python-virtualenv"
fi
# dpkg-dev: dpkg-architecture binary necessary to compile M2Crypto, c.f.
# #276, https://github.com/martinpaljak/M2Crypto/issues/62,
# M2Crypto setup.py:add_multiarch_paths
apt-get update
apt-get install -y --no-install-recommends \
python python-setuptools "$virtualenv" python-dev gcc swig \
dialog libaugeas0 libssl-dev libffi-dev ca-certificates dpkg-dev

1
bootstrap/debian.sh Symbolic link
View file

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

2
bootstrap/mac.sh Executable file
View file

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

1
bootstrap/ubuntu.sh Symbolic link
View file

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

View file

@ -1,6 +1,8 @@
:mod:`letsencrypt.acme`
=======================
.. contents::
.. automodule:: letsencrypt.acme
:members:
@ -8,9 +10,18 @@
Messages
--------
v00
~~~
.. automodule:: letsencrypt.acme.messages
:members:
v02
~~~
.. automodule:: letsencrypt.acme.messages2
:members:
Challenges
----------
@ -21,10 +32,18 @@ Challenges
Other ACME objects
------------------
.. automodule:: letsencrypt.acme.other
:members:
Fields
------
.. automodule:: letsencrypt.acme.fields
:members:
Errors
------

View file

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

View file

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

View file

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

View file

@ -80,6 +80,8 @@ Plugin-architecture
Let's Encrypt has a plugin architecture to facilitate support for
different webservers, other TLS servers, and operating systems.
The interfaces available for plugins to implement are defined in
`interfaces.py`_.
The most common kind of plugin is a "Configurator", which is likely to
implement the `~letsencrypt.client.interfaces.IAuthenticator` and
@ -89,6 +91,8 @@ Configurators may implement just one of those).
There are also `~letsencrypt.client.interfaces.IDisplay` plugins,
which implement bindings to alternative UI libraries.
.. _interfaces.py: https://github.com/letsencrypt/lets-encrypt-preview/blob/master/letsencrypt/client/interfaces.py
Authenticators
--------------
@ -98,15 +102,16 @@ the ACME server. From the protocol, there are essentially two
different types of challenges. Challenges that must be solved by
individual plugins in order to satisfy domain validation (subclasses
of `~.DVChallenge`, i.e. `~.challenges.DVSNI`,
`~.challenges.SimpleHTTPS`, `~.challenges.DNS`) and client specific
challenges (subclasses of `~.ClientChallenge`,
`~.challenges.SimpleHTTPS`, `~.challenges.DNS`) and continuity specific
challenges (subclasses of `~.ContinuityChallenge`,
i.e. `~.challenges.RecoveryToken`, `~.challenges.RecoveryContact`,
`~.challenges.ProofOfPossession`). Client specific challenges are
always handled by the `~.ClientAuthenticator`. Right now we have two
DV Authenticators, `~.ApacheConfigurator` and the
`~.StandaloneAuthenticator`. The Standalone and Apache authenticators
only solve the `~.challenges.DVSNI` challenge currently. (You can set
which challenges your authenticator can handle through the
`~.challenges.ProofOfPossession`). Continuity challenges are
always handled by the `~.ContinuityAuthenticator`, while plugins are
expected to handle `~.DVChallenge` types.
Right now, we have two authenticator plugins, the `~.ApacheConfigurator`
and the `~.StandaloneAuthenticator`. The Standalone and Apache
authenticators only solve the `~.challenges.DVSNI` challenge currently.
(You can set which challenges your authenticator can handle through the
:meth:`~.IAuthenticator.get_chall_pref`.
(FYI: We also have a partial implementation for a `~.DNSAuthenticator`
@ -126,26 +131,27 @@ Installers and Authenticators will oftentimes be the same
class/object. Installers and Authenticators are kept separate because
it should be possible to use the `~.StandaloneAuthenticator` (it sets
up its own Python server to perform challenges) with a program that
cannot solve challenges itself. (I am imagining MTA installers).
cannot solve challenges itself. (Imagine MTA installers).
Installer Development
---------------------
There are a few existing classes that may be beneficial while
developing a new `~letsencrypt.client.interfaces.IInstaller`.
Installers aimed to reconfigure UNIX servers may use Augeas for
configuration parsing and can inherit from `~.AugeasConfigurator` class
to handle much of the interface. Installers that are unable to use
Augeas may still find the `~.Reverter` class helpful in handling
configuration checkpoints and rollback.
Display
~~~~~~~
We currently offer a pythondialog and "text" mode for displays. I have
rewritten the interface which should be merged within the next day
(the rewrite is in the revoker branch of the repo and should be merged
within the next day). Display plugins implement
`~letsencrypt.client.interfaces.IDisplay` interface.
Augeas
------
Some plugins, especially those designed to reconfigure UNIX servers,
can take inherit from `~.AugeasConfigurator` class in order to more
efficiently handle common operations on UNIX server configuration
files.
We currently offer a pythondialog and "text" mode for displays. Display
plugins implement the `~letsencrypt.client.interfaces.IDisplay`
interface.
.. _coding-style:

View file

@ -5,36 +5,46 @@ Using the Let's Encrypt client
Prerequisites
=============
The demo code is supported and known to work on **Ubuntu only** (even
closely related `Debian is known to fail`_).
Therefore, prerequisites for other platforms listed below are provided
mainly for the :ref:`developers <hacking>` reference.
The demo code is supported and known to work on **Ubuntu and
Debian**. Therefore, prerequisites for other platforms listed below
are provided mainly for the :ref:`developers <hacking>` reference.
In general:
* ``sudo`` is required as a suggested way of running privileged process
* `swig`_ is required for compiling `m2crypto`_
* `augeas`_ is required for the ``python-augeas`` bindings
.. _Debian is known to fail: https://github.com/letsencrypt/lets-encrypt-preview/issues/68
Ubuntu
------
.. code-block:: shell
sudo apt-get install python python-setuptools python-virtualenv python-dev \
gcc swig dialog libaugeas0 libssl-dev libffi-dev \
ca-certificates
sudo ./bootstrap/ubuntu.sh
Debian
------
.. code-block:: shell
sudo ./bootstrap/debian.sh
For squezze you will need to:
- Use ``virtualenv --no-site-packages -p python`` instead of ``-p python2``.
.. _`#280`: https://github.com/letsencrypt/lets-encrypt-preview/issues/280
.. Please keep the above command in sync with .travis.yml (before_install)
Mac OSX
-------
.. code-block:: shell
sudo brew install augeas swig
sudo ./bootstrap/mac.sh
Installation

42
examples/restified.py Normal file
View file

@ -0,0 +1,42 @@
import logging
import os
import pkg_resources
import M2Crypto
from letsencrypt.acme import messages2
from letsencrypt.acme import jose
from letsencrypt.client import network2
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
NEW_REG_URL = 'https://www.letsencrypt-demo.org/acme/new-reg'
key = jose.JWKRSA.load(pkg_resources.resource_string(
'letsencrypt.acme.jose', os.path.join('testdata', 'rsa512_key.pem')))
net = network2.Network(NEW_REG_URL, key)
regr = net.register(contact=(
'mailto:cert-admin@example.com', 'tel:+12025551212'))
logging.info('Auto-accepting TOS: %s', regr.terms_of_service)
net.update_registration(regr.update(
body=regr.body.update(agreement=regr.terms_of_service)))
logging.debug(regr)
authzr = net.request_challenges(
identifier=messages2.Identifier(
typ=messages2.IDENTIFIER_FQDN, value='example1.com'),
regr=regr)
logging.debug(authzr)
authzr, authzr_response = net.poll(authzr)
csr = M2Crypto.X509.load_request_string(pkg_resources.resource_string(
'letsencrypt.client.tests', os.path.join('testdata', 'csr.pem')))
try:
net.request_issuance(csr, (authzr,))
except messages2.Error as error:
print error.detail

View file

@ -13,12 +13,12 @@ from letsencrypt.acme import other
class Challenge(jose.TypedJSONObjectWithFields):
# _fields_to_json | pylint: disable=abstract-method
# _fields_to_partial_json | pylint: disable=abstract-method
"""ACME challenge."""
TYPES = {}
class ClientChallenge(Challenge): # pylint: disable=abstract-method
class ContinuityChallenge(Challenge): # pylint: disable=abstract-method
"""Client validation challenges."""
@ -27,7 +27,7 @@ class DVChallenge(Challenge): # pylint: disable=abstract-method
class ChallengeResponse(jose.TypedJSONObjectWithFields):
# _fields_to_json | pylint: disable=abstract-method
# _fields_to_partial_json | pylint: disable=abstract-method
"""ACME challenge response."""
TYPES = {}
@ -139,7 +139,7 @@ class DVSNIResponse(ChallengeResponse):
return self.z(chall) + self.DOMAIN_SUFFIX
@Challenge.register
class RecoveryContact(ClientChallenge):
class RecoveryContact(ContinuityChallenge):
"""ACME "recoveryContact" challenge."""
typ = "recoveryContact"
@ -156,7 +156,7 @@ class RecoveryContactResponse(ChallengeResponse):
@Challenge.register
class RecoveryToken(ClientChallenge):
class RecoveryToken(ContinuityChallenge):
"""ACME "recoveryToken" challenge."""
typ = "recoveryToken"
@ -169,7 +169,7 @@ class RecoveryTokenResponse(ChallengeResponse):
@Challenge.register
class ProofOfPossession(ClientChallenge):
class ProofOfPossession(ContinuityChallenge):
"""ACME "proofOfPossession" challenge.
:ivar str nonce: Random data, **not** base64-encoded.
@ -184,7 +184,8 @@ class ProofOfPossession(ClientChallenge):
"""Hints for "proofOfPossession" challenge.
:ivar jwk: JSON Web Key (:class:`letsencrypt.acme.jose.JWK`)
:ivar list certs: List of :class:`M2Crypto.X509.X509` cetificates.
:ivar list certs: List of :class:`letsencrypt.acme.jose.ComparableX509`
certificates.
"""
jwk = jose.Field("jwk", decoder=jose.JWK.from_json)

View file

@ -13,8 +13,10 @@ from letsencrypt.acme import other
CERT = jose.ComparableX509(M2Crypto.X509.load_cert(
pkg_resources.resource_filename(
'letsencrypt.client.tests', 'testdata/cert.pem')))
KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
'letsencrypt.client.tests', os.path.join('testdata', 'rsa256_key.pem')))
KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
pkg_resources.resource_string(
'letsencrypt.client.tests',
os.path.join('testdata', 'rsa256_key.pem'))))
class SimpleHTTPSTest(unittest.TestCase):
@ -28,13 +30,17 @@ class SimpleHTTPSTest(unittest.TestCase):
'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA',
}
def test_to_json(self):
self.assertEqual(self.jmsg, self.msg.to_json())
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import SimpleHTTPS
self.assertEqual(self.msg, SimpleHTTPS.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import SimpleHTTPS
hash(SimpleHTTPS.from_json(self.jmsg))
class SimpleHTTPSResponseTest(unittest.TestCase):
@ -50,14 +56,18 @@ class SimpleHTTPSResponseTest(unittest.TestCase):
self.assertEqual('https://example.com/.well-known/acme-challenge/'
'6tbIMBC5Anhl5bOlWT5ZFA', self.msg.uri('example.com'))
def test_to_json(self):
self.assertEqual(self.jmsg, self.msg.to_json())
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import SimpleHTTPSResponse
self.assertEqual(
self.msg, SimpleHTTPSResponse.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import SimpleHTTPSResponse
hash(SimpleHTTPSResponse.from_json(self.jmsg))
class DVSNITest(unittest.TestCase):
@ -77,13 +87,17 @@ class DVSNITest(unittest.TestCase):
self.assertEqual('a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid',
self.msg.nonce_domain)
def test_to_json(self):
self.assertEqual(self.jmsg, self.msg.to_json())
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import DVSNI
self.assertEqual(self.msg, DVSNI.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import DVSNI
hash(DVSNI.from_json(self.jmsg))
def test_from_json_invalid_r_length(self):
from letsencrypt.acme.challenges import DVSNI
self.jmsg['r'] = 'abcd'
@ -122,13 +136,17 @@ class DVSNIResponseTest(unittest.TestCase):
self.assertEqual(
'{0}.acme.invalid'.format(z), self.msg.z_domain(challenge))
def test_to_json(self):
self.assertEqual(self.jmsg, self.msg.to_json())
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import DVSNIResponse
self.assertEqual(self.msg, DVSNIResponse.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import DVSNIResponse
hash(DVSNIResponse.from_json(self.jmsg))
class RecoveryContactTest(unittest.TestCase):
@ -145,13 +163,17 @@ class RecoveryContactTest(unittest.TestCase):
'contact' : 'c********n@example.com',
}
def test_to_json(self):
self.assertEqual(self.jmsg, self.msg.to_json())
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import RecoveryContact
self.assertEqual(self.msg, RecoveryContact.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import RecoveryContact
hash(RecoveryContact.from_json(self.jmsg))
def test_json_without_optionals(self):
del self.jmsg['activationURL']
del self.jmsg['successURL']
@ -163,7 +185,7 @@ class RecoveryContactTest(unittest.TestCase):
self.assertTrue(msg.activation_url is None)
self.assertTrue(msg.success_url is None)
self.assertTrue(msg.contact is None)
self.assertEqual(self.jmsg, msg.to_json())
self.assertEqual(self.jmsg, msg.to_partial_json())
class RecoveryContactResponseTest(unittest.TestCase):
@ -173,14 +195,18 @@ class RecoveryContactResponseTest(unittest.TestCase):
self.msg = RecoveryContactResponse(token='23029d88d9e123e')
self.jmsg = {'type': 'recoveryContact', 'token': '23029d88d9e123e'}
def test_to_json(self):
self.assertEqual(self.jmsg, self.msg.to_json())
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import RecoveryContactResponse
self.assertEqual(
self.msg, RecoveryContactResponse.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import RecoveryContactResponse
hash(RecoveryContactResponse.from_json(self.jmsg))
def test_json_without_optionals(self):
del self.jmsg['token']
@ -188,7 +214,7 @@ class RecoveryContactResponseTest(unittest.TestCase):
msg = RecoveryContactResponse.from_json(self.jmsg)
self.assertTrue(msg.token is None)
self.assertEqual(self.jmsg, msg.to_json())
self.assertEqual(self.jmsg, msg.to_partial_json())
class RecoveryTokenTest(unittest.TestCase):
@ -198,13 +224,17 @@ class RecoveryTokenTest(unittest.TestCase):
self.msg = RecoveryToken()
self.jmsg = {'type': 'recoveryToken'}
def test_to_json(self):
self.assertEqual(self.jmsg, self.msg.to_json())
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import RecoveryToken
self.assertEqual(self.msg, RecoveryToken.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import RecoveryToken
hash(RecoveryToken.from_json(self.jmsg))
class RecoveryTokenResponseTest(unittest.TestCase):
@ -213,14 +243,18 @@ class RecoveryTokenResponseTest(unittest.TestCase):
self.msg = RecoveryTokenResponse(token='23029d88d9e123e')
self.jmsg = {'type': 'recoveryToken', 'token': '23029d88d9e123e'}
def test_to_json(self):
self.assertEqual(self.jmsg, self.msg.to_json())
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import RecoveryTokenResponse
self.assertEqual(
self.msg, RecoveryTokenResponse.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import RecoveryTokenResponse
hash(RecoveryTokenResponse.from_json(self.jmsg))
def test_json_without_optionals(self):
del self.jmsg['token']
@ -228,7 +262,7 @@ class RecoveryTokenResponseTest(unittest.TestCase):
msg = RecoveryTokenResponse.from_json(self.jmsg)
self.assertTrue(msg.token is None)
self.assertEqual(self.jmsg, msg.to_json())
self.assertEqual(self.jmsg, msg.to_partial_json())
class ProofOfPossessionHintsTest(unittest.TestCase):
@ -264,16 +298,20 @@ class ProofOfPossessionHintsTest(unittest.TestCase):
'authorizedFor': authorized_for,
}
self.jmsg_from = self.jmsg_to.copy()
self.jmsg_from.update({'jwk': jwk.fully_serialize()})
self.jmsg_from.update({'jwk': jwk.to_json()})
def test_to_json(self):
self.assertEqual(self.jmsg_to, self.msg.to_json())
def test_to_partial_json(self):
self.assertEqual(self.jmsg_to, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import ProofOfPossession
self.assertEqual(
self.msg, ProofOfPossession.Hints.from_json(self.jmsg_from))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import ProofOfPossession
hash(ProofOfPossession.Hints.from_json(self.jmsg_from))
def test_json_without_optionals(self):
for optional in ['certFingerprints', 'certs', 'subjectKeyIdentifiers',
'serialNumbers', 'issuers', 'authorizedFor']:
@ -290,7 +328,7 @@ class ProofOfPossessionHintsTest(unittest.TestCase):
self.assertEqual(msg.issuers, ())
self.assertEqual(msg.authorized_for, ())
self.assertEqual(self.jmsg_to, msg.to_json())
self.assertEqual(self.jmsg_to, msg.to_partial_json())
class ProofOfPossessionTest(unittest.TestCase):
@ -313,19 +351,23 @@ class ProofOfPossessionTest(unittest.TestCase):
}
self.jmsg_from = {
'type': 'proofOfPossession',
'alg': jose.RS256.fully_serialize(),
'alg': jose.RS256.to_json(),
'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
'hints': hints.fully_serialize(),
'hints': hints.to_json(),
}
def test_to_json(self):
self.assertEqual(self.jmsg_to, self.msg.to_json())
def test_to_partial_json(self):
self.assertEqual(self.jmsg_to, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import ProofOfPossession
self.assertEqual(
self.msg, ProofOfPossession.from_json(self.jmsg_from))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import ProofOfPossession
hash(ProofOfPossession.from_json(self.jmsg_from))
class ProofOfPossessionResponseTest(unittest.TestCase):
@ -355,20 +397,24 @@ class ProofOfPossessionResponseTest(unittest.TestCase):
self.jmsg_from = {
'type': 'proofOfPossession',
'nonce': 'eET5udtV7aoX8Xl8gYiZIA',
'signature': signature.fully_serialize(),
'signature': signature.to_json(),
}
def test_verify(self):
self.assertTrue(self.msg.verify())
def test_to_json(self):
self.assertEqual(self.jmsg_to, self.msg.to_json())
def test_to_partial_json(self):
self.assertEqual(self.jmsg_to, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import ProofOfPossessionResponse
self.assertEqual(
self.msg, ProofOfPossessionResponse.from_json(self.jmsg_from))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import ProofOfPossessionResponse
hash(ProofOfPossessionResponse.from_json(self.jmsg_from))
class DNSTest(unittest.TestCase):
@ -377,13 +423,17 @@ class DNSTest(unittest.TestCase):
self.msg = DNS(token='17817c66b60ce2e4012dfad92657527a')
self.jmsg = {'type': 'dns', 'token': '17817c66b60ce2e4012dfad92657527a'}
def test_to_json(self):
self.assertEqual(self.jmsg, self.msg.to_json())
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import DNS
self.assertEqual(self.msg, DNS.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import DNS
hash(DNS.from_json(self.jmsg))
class DNSResponseTest(unittest.TestCase):
@ -392,13 +442,17 @@ class DNSResponseTest(unittest.TestCase):
self.msg = DNSResponse()
self.jmsg = {'type': 'dns'}
def test_to_json(self):
self.assertEqual(self.jmsg, self.msg.to_json())
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.challenges import DNSResponse
self.assertEqual(self.msg, DNSResponse.from_json(self.jmsg))
def test_from_json_hashable(self):
from letsencrypt.acme.challenges import DNSResponse
hash(DNSResponse.from_json(self.jmsg))
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,25 @@
"""ACME JSON fields."""
import pyrfc3339
from letsencrypt.acme import jose
class RFC3339Field(jose.Field):
"""RFC3339 field encoder/decoder.
Handles decoding/encoding between RFC3339 strings and aware (not
naive) `datetime.datetime` objects
(e.g. ``datetime.datetime.now(pytz.utc)``).
"""
@classmethod
def default_encoder(cls, value):
return pyrfc3339.generate(value)
@classmethod
def default_decoder(cls, value):
try:
return pyrfc3339.parse(value)
except ValueError as error:
raise jose.DeserializationError(error)

View file

@ -0,0 +1,35 @@
"""Tests for letsencrypt.acme.fields."""
import datetime
import unittest
import pytz
from letsencrypt.acme import jose
class RFC3339FieldTest(unittest.TestCase):
"""Tests for letsencrypt.acme.fields.RFC3339Field."""
def setUp(self):
self.decoded = datetime.datetime(2015, 3, 27, tzinfo=pytz.utc)
self.encoded = '2015-03-27T00:00:00Z'
def test_default_encoder(self):
from letsencrypt.acme.fields import RFC3339Field
self.assertEqual(
self.encoded, RFC3339Field.default_encoder(self.decoded))
def test_default_encoder_naive_fails(self):
from letsencrypt.acme.fields import RFC3339Field
self.assertRaises(
ValueError, RFC3339Field.default_encoder, datetime.datetime.now())
def test_default_decoder(self):
from letsencrypt.acme.fields import RFC3339Field
self.assertEqual(
self.decoded, RFC3339Field.default_decoder(self.encoded))
def test_default_decoder_raises_deserialization_error(self):
from letsencrypt.acme.fields import RFC3339Field
self.assertRaises(
jose.DeserializationError, RFC3339Field.default_decoder, '')

View file

@ -70,5 +70,6 @@ from letsencrypt.acme.jose.jws import JWS
from letsencrypt.acme.jose.util import (
ComparableX509,
HashableRSAKey,
ImmutableMap,
)

View file

@ -36,8 +36,8 @@ class JSONDeSerializable(object):
Turning an arbitrary Python object into Python object that can
be encoded into a JSON document. **Full serialization** produces
a Python object composed of only basic types as required by the
:ref:`conversion table <conversion-table>`.
**Partial serialization** (acomplished by :meth:`to_json`)
:ref:`conversion table <conversion-table>`. **Partial
serialization** (acomplished by :meth:`to_partial_json`)
produces a Python object that might also be built from other
:class:`JSONDeSerializable` objects.
@ -71,15 +71,16 @@ class JSONDeSerializable(object):
Interestingly, ``default`` is required to perform only partial
serialization, as :func:`json.dumps` applies ``default``
recursively. This is the idea behind making :meth:`to_json` produce
only partial serialization, while providing custom :meth:`json_dumps`
that dumps with ``default`` set to :meth:`json_dump_default`.
recursively. This is the idea behind making :meth:`to_partial_json`
produce only partial serialization, while providing custom
:meth:`json_dumps` that dumps with ``default`` set to
:meth:`json_dump_default`.
To make further documentation a bit more concrete, please, consider
the following imaginatory implementation example::
class Foo(JSONDeSerializable):
def to_json(self):
def to_partial_json(self):
return 'foo'
@classmethod
@ -87,7 +88,7 @@ class JSONDeSerializable(object):
return Foo()
class Bar(JSONDeSerializable):
def to_json(self):
def to_partial_json(self):
return [Foo(), Foo()]
@classmethod
@ -98,16 +99,16 @@ class JSONDeSerializable(object):
__metaclass__ = abc.ABCMeta
@abc.abstractmethod
def to_json(self): # pragma: no cover
def to_partial_json(self): # pragma: no cover
"""Partially serialize.
Following the example, **partial serialization** means the following::
assert isinstance(Bar().to_json()[0], Foo)
assert isinstance(Bar().to_json()[1], Foo)
assert isinstance(Bar().to_partial_json()[0], Foo)
assert isinstance(Bar().to_partial_json()[1], Foo)
# in particular...
assert Bar().to_json() != ['foo', 'foo']
assert Bar().to_partial_json() != ['foo', 'foo']
:raises letsencrypt.acme.jose.errors.SerializationError:
in case of any serialization error.
@ -116,31 +117,37 @@ class JSONDeSerializable(object):
"""
raise NotImplementedError()
def fully_serialize(self):
def to_json(self):
"""Fully serialize.
Again, following the example from before, **full serialization**
means the following::
assert Bar().fully_serialize() == ['foo', 'foo']
assert Bar().to_json() == ['foo', 'foo']
:raises letsencrypt.acme.jose.errors.SerializationError:
in case of any serialization error.
:returns: Fully serialized object.
"""
partial = self.to_json()
try_serialize = (lambda x: x.fully_serialize()
if isinstance(x, JSONDeSerializable) else x)
if isinstance(partial, basestring): # strings are sequences
return partial
if isinstance(partial, collections.Sequence):
return [try_serialize(elem) for elem in partial]
elif isinstance(partial, collections.Mapping):
return dict([(try_serialize(key), try_serialize(value))
for key, value in partial.iteritems()])
else:
return partial
def _serialize(obj):
if isinstance(obj, JSONDeSerializable):
return _serialize(obj.to_partial_json())
if isinstance(obj, basestring): # strings are sequence
return obj
elif isinstance(obj, list):
return [_serialize(subobj) for subobj in obj]
elif isinstance(obj, collections.Sequence):
# default to tuple, otherwise Mapping could get
# unhashable list
return tuple(_serialize(subobj) for subobj in obj)
elif isinstance(obj, collections.Mapping):
return dict((_serialize(key), _serialize(value))
for key, value in obj.iteritems())
else:
return obj
return _serialize(self)
@util.abstractclassmethod
def from_json(cls, unused_jobj):
@ -157,7 +164,7 @@ class JSONDeSerializable(object):
"""
# TypeError: Can't instantiate abstract class <cls> with
# abstract methods from_json, to_json
# abstract methods from_json, to_partial_json
return cls() # pylint: disable=abstract-class-instantiated
@classmethod
@ -193,6 +200,6 @@ class JSONDeSerializable(object):
"""
if isinstance(python_object, JSONDeSerializable):
return python_object.to_json()
return python_object.to_partial_json()
else: # this branch is necessary, cannot just "return"
raise TypeError(repr(python_object) + ' is not JSON serializable')

View file

@ -3,6 +3,7 @@ import unittest
class JSONDeSerializableTest(unittest.TestCase):
# pylint: disable=too-many-instance-attributes
def setUp(self):
from letsencrypt.acme.jose.interfaces import JSONDeSerializable
@ -13,7 +14,7 @@ class JSONDeSerializableTest(unittest.TestCase):
def __init__(self, v):
self.v = v
def to_json(self):
def to_partial_json(self):
return self.v
@classmethod
@ -25,7 +26,7 @@ class JSONDeSerializableTest(unittest.TestCase):
self.x = x
self.y = y
def to_json(self):
def to_partial_json(self):
return [self.x, self.y]
@classmethod
@ -38,7 +39,7 @@ class JSONDeSerializableTest(unittest.TestCase):
self.x = x
self.y = y
def to_json(self):
def to_partial_json(self):
return {self.x: self.y}
@classmethod
@ -50,21 +51,29 @@ class JSONDeSerializableTest(unittest.TestCase):
self.basic2 = Basic('foo2')
self.seq = Sequence(self.basic1, self.basic2)
self.mapping = Mapping(self.basic1, self.basic2)
self.nested = Basic([[self.basic1]])
self.tuple = Basic(('foo',))
# pylint: disable=invalid-name
self.Basic = Basic
self.Sequence = Sequence
self.Mapping = Mapping
def test_fully_serialize_sequence(self):
self.assertEqual(self.seq.fully_serialize(), ['foo1', 'foo2'])
def test_to_json_sequence(self):
self.assertEqual(self.seq.to_json(), ['foo1', 'foo2'])
def test_fully_serialize_mapping(self):
self.assertEqual(self.mapping.fully_serialize(), {'foo1': 'foo2'})
def test_to_json_mapping(self):
self.assertEqual(self.mapping.to_json(), {'foo1': 'foo2'})
def test_fully_serialize_other(self):
def test_to_json_other(self):
mock_value = object()
self.assertTrue(self.Basic(mock_value).fully_serialize() is mock_value)
self.assertTrue(self.Basic(mock_value).to_json() is mock_value)
def test_to_json_nested(self):
self.assertEqual(self.nested.to_json(), [['foo1']])
def test_to_json(self):
self.assertEqual(self.tuple.to_json(), (('foo', )))
def test_from_json_not_implemented(self):
from letsencrypt.acme.jose.interfaces import JSONDeSerializable

View file

@ -113,7 +113,7 @@ class Field(object):
@classmethod
def default_encoder(cls, value):
"""Default (passthrough) encoder."""
# field.to_json() is no good as encoder has to do partial
# field.to_partial_json() is no good as encoder has to do partial
# serialization only
return value
@ -189,7 +189,7 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable):
raise errors.DeserializationError('No bar suffix!')
return value[:-3]
assert Foo(bar='baz').to_json() == {'Bar': 'bazbar'}
assert Foo(bar='baz').to_partial_json() == {'Bar': 'bazbar'}
assert Foo.from_json({'Bar': 'bazbar'}) == Foo(bar='baz')
assert (Foo.from_json({'Bar': 'bazbar', 'Empty': '!'})
== Foo(bar='baz', empty='!'))
@ -209,7 +209,7 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable):
super(JSONObjectWithFields, self).__init__(
**(dict(self._defaults(), **kwargs)))
def fields_to_json(self):
def fields_to_partial_json(self):
"""Serialize fields to JSON."""
jobj = {}
for slot, field in self._fields.iteritems():
@ -226,8 +226,8 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable):
slot, value, error))
return jobj
def to_json(self):
return self.fields_to_json()
def to_partial_json(self):
return self.fields_to_partial_json()
@classmethod
def _check_required(cls, jobj):
@ -378,7 +378,7 @@ class TypedJSONObjectWithFields(JSONObjectWithFields):
return type_cls
def to_json(self):
def to_partial_json(self):
"""Get JSON serializable object.
:returns: Serializable JSON object representing ACME typed object.
@ -387,7 +387,7 @@ class TypedJSONObjectWithFields(JSONObjectWithFields):
:rtype: dict
"""
jobj = self.fields_to_json()
jobj = self.fields_to_partial_json()
jobj[self.type_field_name] = self.typ
return jobj

View file

@ -44,7 +44,7 @@ class FieldTest(unittest.TestCase):
def test_default_encoder_is_partial(self):
class MockField(interfaces.JSONDeSerializable):
# pylint: disable=missing-docstring
def to_json(self):
def to_partial_json(self):
return 'foo'
@classmethod
def from_json(cls, jobj):
@ -113,8 +113,8 @@ class JSONObjectWithFieldsTest(unittest.TestCase):
def test_init_defaults(self):
self.assertEqual(self.mock, self.MockJSONObjectWithFields(y=2, z=3))
def test_fields_to_json_omits_empty(self):
self.assertEqual(self.mock.fields_to_json(), {'y': 2, 'Z': 3})
def test_fields_to_partial_json_omits_empty(self):
self.assertEqual(self.mock.fields_to_partial_json(), {'y': 2, 'Z': 3})
def test_fields_from_json_fills_default_for_empty(self):
self.assertEqual(
@ -135,9 +135,10 @@ class JSONObjectWithFieldsTest(unittest.TestCase):
errors.DeserializationError,
self.MockJSONObjectWithFields.fields_from_json, {'x': 0, 'Z': 0})
def test_fields_to_json_encoder(self):
self.assertEqual(self.MockJSONObjectWithFields(x=1, y=2, z=3).to_json(),
{'x': 2, 'y': 2, 'Z': 3})
def test_fields_to_partial_json_encoder(self):
self.assertEqual(
self.MockJSONObjectWithFields(x=1, y=2, z=3).to_partial_json(),
{'x': 2, 'y': 2, 'Z': 3})
def test_fields_from_json_decoder(self):
self.assertEqual(
@ -145,10 +146,10 @@ class JSONObjectWithFieldsTest(unittest.TestCase):
self.MockJSONObjectWithFields.fields_from_json(
{'x': 4, 'y': 2, 'Z': 3}))
def test_fields_to_json_error_passthrough(self):
def test_fields_to_partial_json_error_passthrough(self):
self.assertRaises(
errors.SerializationError, self.MockJSONObjectWithFields(
x=1, y=500, z=3).to_json)
x=1, y=500, z=3).to_partial_json)
def test_fields_from_json_error_passthrough(self):
self.assertRaises(
@ -262,14 +263,14 @@ class TypedJSONObjectWithFieldsTest(unittest.TestCase):
def fields_from_json(cls, jobj):
return {'foo': jobj['foo']}
def fields_to_json(self):
def fields_to_partial_json(self):
return {'foo': self.foo}
self.parent_cls = MockParentTypedJSONObjectWithFields
self.msg = MockTypedJSONObjectWithFields(foo='bar')
def test_to_json(self):
self.assertEqual(self.msg.to_json(), {
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), {
'type': 'test',
'foo': 'bar',
})

View file

@ -38,7 +38,7 @@ class JWASignature(JWA):
cls.SIGNATURES[signature_cls.name] = signature_cls
return signature_cls
def to_json(self):
def to_partial_json(self):
return self.name
@classmethod

View file

@ -43,9 +43,9 @@ class JWASignatureTest(unittest.TestCase):
self.assertEqual('Sig1', repr(self.Sig1))
self.assertEqual('Sig2', repr(self.Sig2))
def test_to_json(self):
self.assertEqual(self.Sig1.to_json(), 'Sig1')
self.assertEqual(self.Sig2.to_json(), 'Sig2')
def test_to_partial_json(self):
self.assertEqual(self.Sig1.to_partial_json(), 'Sig1')
self.assertEqual(self.Sig2.to_partial_json(), 'Sig2')
def test_from_json(self):
from letsencrypt.acme.jose.jwa import JWASignature

View file

@ -41,7 +41,7 @@ class JWKES(JWK): # pragma: no cover
"""
typ = 'ES'
def fields_to_json(self):
def fields_to_partial_json(self):
raise NotImplementedError()
@classmethod
@ -62,7 +62,7 @@ class JWKOct(JWK):
typ = 'oct'
__slots__ = ('key',)
def fields_to_json(self):
def fields_to_partial_json(self):
# TODO: An "alg" member SHOULD also be present to identify the
# algorithm intended to be used with the key, unless the
# application uses another means or convention to determine
@ -83,7 +83,11 @@ class JWKOct(JWK):
@JWK.register
class JWKRSA(JWK):
"""RSA JWK."""
"""RSA JWK.
:ivar key: `Crypto.PublicKey.RSA` wrapped in `.HashableRSAKey`
"""
typ = 'RSA'
__slots__ = ('key',)
@ -114,18 +118,20 @@ class JWKRSA(JWK):
:rtype: :class:`JWKRSA`
"""
return cls(key=Crypto.PublicKey.RSA.importKey(string))
return cls(key=util.HashableRSAKey(
Crypto.PublicKey.RSA.importKey(string)))
def public(self):
return type(self)(key=self.key.publickey())
@classmethod
def fields_from_json(cls, jobj):
return cls(key=Crypto.PublicKey.RSA.construct(
(cls._decode_param(jobj['n']),
cls._decode_param(jobj['e']))))
return cls(key=util.HashableRSAKey(
Crypto.PublicKey.RSA.construct(
(cls._decode_param(jobj['n']),
cls._decode_param(jobj['e'])))))
def fields_to_json(self):
def fields_to_partial_json(self):
return {
'n': self._encode_param(self.key.n),
'e': self._encode_param(self.key.e),

View file

@ -6,6 +6,7 @@ import unittest
from Crypto.PublicKey import RSA
from letsencrypt.acme.jose import errors
from letsencrypt.acme.jose import util
RSA256_KEY = RSA.importKey(pkg_resources.resource_string(
@ -22,13 +23,17 @@ class JWKOctTest(unittest.TestCase):
self.jwk = JWKOct(key='foo')
self.jobj = {'kty': 'oct', 'k': 'foo'}
def test_to_json(self):
self.assertEqual(self.jwk.to_json(), self.jobj)
def test_to_partial_json(self):
self.assertEqual(self.jwk.to_partial_json(), self.jobj)
def test_from_json(self):
from letsencrypt.acme.jose.jwk import JWKOct
self.assertEqual(self.jwk, JWKOct.from_json(self.jobj))
def test_from_json_hashable(self):
from letsencrypt.acme.jose.jwk import JWKOct
hash(JWKOct.from_json(self.jobj))
def test_load(self):
from letsencrypt.acme.jose.jwk import JWKOct
self.assertEqual(self.jwk, JWKOct.load('foo'))
@ -42,15 +47,15 @@ class JWKRSATest(unittest.TestCase):
def setUp(self):
from letsencrypt.acme.jose.jwk import JWKRSA
self.jwk256 = JWKRSA(key=RSA256_KEY.publickey())
self.jwk256_private = JWKRSA(key=RSA256_KEY)
self.jwk256 = JWKRSA(key=util.HashableRSAKey(RSA256_KEY.publickey()))
self.jwk256_private = JWKRSA(key=util.HashableRSAKey(RSA256_KEY))
self.jwk256json = {
'kty': 'RSA',
'e': 'AQAB',
'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5'
'80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q',
}
self.jwk512 = JWKRSA(key=RSA512_KEY.publickey())
self.jwk512 = JWKRSA(key=util.HashableRSAKey(RSA512_KEY.publickey()))
self.jwk512json = {
'kty': 'RSA',
'e': 'AQAB',
@ -68,17 +73,18 @@ class JWKRSATest(unittest.TestCase):
def test_load(self):
from letsencrypt.acme.jose.jwk import JWKRSA
self.assertEqual(JWKRSA(key=RSA256_KEY), JWKRSA.load(
pkg_resources.resource_string(
'letsencrypt.client.tests',
os.path.join('testdata', 'rsa256_key.pem'))))
self.assertEqual(
JWKRSA(key=util.HashableRSAKey(RSA256_KEY)), JWKRSA.load(
pkg_resources.resource_string(
'letsencrypt.client.tests',
os.path.join('testdata', 'rsa256_key.pem'))))
def test_public(self):
self.assertEqual(self.jwk256, self.jwk256_private.public())
def test_to_json(self):
self.assertEqual(self.jwk256.to_json(), self.jwk256json)
self.assertEqual(self.jwk512.to_json(), self.jwk512json)
def test_to_partial_json(self):
self.assertEqual(self.jwk256.to_partial_json(), self.jwk256json)
self.assertEqual(self.jwk512.to_partial_json(), self.jwk512json)
def test_from_json(self):
from letsencrypt.acme.jose.jwk import JWK
@ -86,6 +92,10 @@ class JWKRSATest(unittest.TestCase):
# TODO: fix schemata to allow RSA512
#self.assertEqual(self.jwk512, JWK.from_json(self.jwk512json))
def test_from_json_hashable(self):
from letsencrypt.acme.jose.jwk import JWK
hash(JWK.from_json(self.jwk256json))
def test_from_json_non_schema_errors(self):
# valid against schema, but still failing
from letsencrypt.acme.jose.jwk import JWK

View file

@ -46,7 +46,7 @@ class Header(json_util.JSONObjectWithFields):
Parameter Names (as defined in section 4.1 of the
protocol). If you need Public Header Parameter Names (4.2)
or Private Header Parameter Names (4.3), you must subclass
and override :meth:`from_json` and :meth:`to_json`
and override :meth:`from_json` and :meth:`to_partial_json`
appropriately.
.. warning:: This class does not support any extensions through
@ -223,8 +223,8 @@ class Signature(json_util.JSONObjectWithFields):
return cls(protected=protected, header=header, signature=signature)
def fields_to_json(self):
fields = super(Signature, self).fields_to_json()
def fields_to_partial_json(self):
fields = super(Signature, self).fields_to_partial_json()
if not fields['header'].not_omitted():
del fields['header']
return fields
@ -294,12 +294,12 @@ class JWS(json_util.JSONObjectWithFields):
signature=json_util.decode_b64jose(signature))
return cls(payload=json_util.decode_b64jose(payload), signatures=(sig,))
def to_json(self, flat=True): # pylint: disable=arguments-differ
def to_partial_json(self, flat=True): # pylint: disable=arguments-differ
assert self.signatures
payload = b64.b64encode(self.payload)
if flat and len(self.signatures) == 1:
ret = self.signatures[0].to_json()
ret = self.signatures[0].to_partial_json()
ret['payload'] = payload
return ret
else:

View file

@ -72,7 +72,7 @@ class HeaderTest(unittest.TestCase):
def test_x5c_decoding(self):
from letsencrypt.acme.jose.jws import Header
header = Header(x5c=(CERT, CERT))
jobj = header.to_json()
jobj = header.to_partial_json()
cert_b64 = base64.b64encode(CERT.as_der())
self.assertEqual(jobj, {'x5c': [cert_b64, cert_b64]})
self.assertEqual(header, Header.from_json(jobj))
@ -152,14 +152,13 @@ class JWSTest(unittest.TestCase):
self.assertRaises(errors.DeserializationError, JWS.from_compact, '.')
def test_json_omitempty(self):
protected_jobj = self.protected.to_json(flat=True)
unprotected_jobj = self.unprotected.to_json(flat=True)
protected_jobj = self.protected.to_partial_json(flat=True)
unprotected_jobj = self.unprotected.to_partial_json(flat=True)
self.assertTrue('protected' not in unprotected_jobj)
self.assertTrue('header' not in protected_jobj)
unprotected_jobj['header'] = unprotected_jobj[
'header'].fully_serialize()
unprotected_jobj['header'] = unprotected_jobj['header'].to_json()
from letsencrypt.acme.jose.jws import JWS
self.assertEqual(JWS.from_json(protected_jobj), self.protected)
@ -173,9 +172,9 @@ class JWSTest(unittest.TestCase):
'protected': b64.b64encode(self.mixed.signature.protected),
}
jobj_from = jobj_to.copy()
jobj_from['header'] = jobj_from['header'].fully_serialize()
jobj_from['header'] = jobj_from['header'].to_json()
self.assertEqual(self.mixed.to_json(flat=True), jobj_to)
self.assertEqual(self.mixed.to_partial_json(flat=True), jobj_to)
from letsencrypt.acme.jose.jws import JWS
self.assertEqual(self.mixed, JWS.from_json(jobj_from))
@ -185,9 +184,9 @@ class JWSTest(unittest.TestCase):
'payload': b64.b64encode('foo'),
}
jobj_from = jobj_to.copy()
jobj_from['signatures'] = [jobj_to['signatures'][0].fully_serialize()]
jobj_from['signatures'] = [jobj_to['signatures'][0].to_json()]
self.assertEqual(self.mixed.to_json(flat=False), jobj_to)
self.assertEqual(self.mixed.to_partial_json(flat=False), jobj_to)
from letsencrypt.acme.jose.jws import JWS
self.assertEqual(self.mixed, JWS.from_json(jobj_from))
@ -196,6 +195,10 @@ class JWSTest(unittest.TestCase):
self.assertRaises(errors.DeserializationError, JWS.from_json,
{'signatures': (), 'signature': 'foo'})
def test_from_json_hashable(self):
from letsencrypt.acme.jose.jws import JWS
hash(JWS.from_json(self.mixed.to_json()))
class CLITest(unittest.TestCase):

View file

@ -41,6 +41,26 @@ class ComparableX509(object): # pylint: disable=too-few-public-methods
return self.as_der() == other.as_der()
class HashableRSAKey(object): # pylint: disable=too-few-public-methods
"""Wrapper for `Crypto.PublicKey.RSA` objects that supports hashing."""
def __init__(self, wrapped):
self._wrapped = wrapped
def __getattr__(self, name):
return getattr(self._wrapped, name)
def __eq__(self, other):
return self._wrapped == other
def __hash__(self):
return hash((type(self), self.exportKey(format='DER')))
def publickey(self):
"""Get wrapped public key."""
return type(self)(self._wrapped.publickey())
class ImmutableMap(collections.Mapping, collections.Hashable):
# pylint: disable=too-few-public-methods
"""Immutable key to value mapping with attribute access."""
@ -57,6 +77,12 @@ class ImmutableMap(collections.Mapping, collections.Hashable):
for slot in self.__slots__:
object.__setattr__(self, slot, kwargs.pop(slot))
def update(self, **kwargs):
"""Return updated map."""
items = dict(self)
items.update(kwargs)
return type(self)(**items) # pylint: disable=star-args
def __getitem__(self, key):
try:
return getattr(self, key)

View file

@ -1,7 +1,36 @@
"""Tests for letsencrypt.acme.jose.util."""
import functools
import os
import pkg_resources
import unittest
import Crypto.PublicKey.RSA
class HashableRSAKeyTest(unittest.TestCase):
"""Tests for letsencrypt.acme.jose.util.HashableRSAKey."""
def setUp(self):
from letsencrypt.acme.jose.util import HashableRSAKey
self.key = HashableRSAKey(Crypto.PublicKey.RSA.importKey(
pkg_resources.resource_string(
__name__, os.path.join('testdata', 'rsa256_key.pem'))))
self.key_same = HashableRSAKey(Crypto.PublicKey.RSA.importKey(
pkg_resources.resource_string(
__name__, os.path.join('testdata', 'rsa256_key.pem'))))
def test_eq(self):
# if __eq__ is not defined, then two HashableRSAKeys with same
# _wrapped do not equate
self.assertEqual(self.key, self.key_same)
def test_hash(self):
self.assertTrue(isinstance(hash(self.key), int))
def test_publickey(self):
from letsencrypt.acme.jose.util import HashableRSAKey
self.assertTrue(isinstance(self.key.publickey(), HashableRSAKey))
class ImmutableMapTest(unittest.TestCase):
"""Tests for letsencrypt.acme.jose.util.ImmutableMap."""
@ -25,6 +54,10 @@ class ImmutableMapTest(unittest.TestCase):
self.a2 = self.A(x=3, y=4)
self.b = self.B(x=1, y=2)
def test_update(self):
self.assertEqual(self.A(x=2, y=2), self.a1.update(x=2))
self.assertEqual(self.a2, self.a1.update(x=3, y=4))
def test_get_missing_item_raises_key_error(self):
self.assertRaises(KeyError, self.a1.__getitem__, 'z')

View file

@ -9,7 +9,7 @@ from letsencrypt.acme import util
class Message(jose.TypedJSONObjectWithFields):
# _fields_to_json | pylint: disable=abstract-method
# _fields_to_partial_json | pylint: disable=abstract-method
# pylint: disable=too-few-public-methods
"""ACME message."""
TYPES = {}

View file

@ -0,0 +1,298 @@
"""ACME protocol v02 messages."""
from letsencrypt.acme import challenges
from letsencrypt.acme import fields
from letsencrypt.acme import jose
class Error(jose.JSONObjectWithFields, Exception):
"""ACME error.
https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00
"""
ERROR_TYPE_NAMESPACE = 'urn:acme:error:'
ERROR_TYPE_DESCRIPTIONS = {
'malformed': 'The request message was malformed',
'unauthorized': 'The client lacks sufficient authorization',
'serverInternal': 'The server experienced an internal error',
'badCSR': 'The CSR is unacceptable (e.g., due to a short key)',
}
# TODO: Boulder omits 'type' and 'instance', spec requires
typ = jose.Field('type', omitempty=True)
title = jose.Field('title', omitempty=True)
detail = jose.Field('detail')
instance = jose.Field('instance', omitempty=True)
@typ.encoder
def typ(value): # pylint: disable=missing-docstring,no-self-argument
return Error.ERROR_TYPE_NAMESPACE + value
@typ.decoder
def typ(value): # pylint: disable=missing-docstring,no-self-argument
# pylint thinks isinstance(value, Error), so startswith is not found
# pylint: disable=no-member
if not value.startswith(Error.ERROR_TYPE_NAMESPACE):
raise jose.DeserializationError('Missing error type prefix')
without_prefix = value[len(Error.ERROR_TYPE_NAMESPACE):]
if without_prefix not in Error.ERROR_TYPE_DESCRIPTIONS:
raise jose.DeserializationError('Error type not recognized')
return without_prefix
@property
def description(self):
"""Hardcoded error description based on its type."""
return self.ERROR_TYPE_DESCRIPTIONS[self.typ]
class _Constant(jose.JSONDeSerializable):
"""ACME constant."""
__slots__ = ('name',)
POSSIBLE_NAMES = NotImplemented
def __init__(self, name):
self.POSSIBLE_NAMES[name] = self
self.name = name
def to_partial_json(self):
return self.name
@classmethod
def from_json(cls, value):
if value not in cls.POSSIBLE_NAMES:
raise jose.DeserializationError(
'{0} not recognized'.format(cls.__name__))
return cls.POSSIBLE_NAMES[value]
def __repr__(self):
return '{0}({1})'.format(self.__class__.__name__, self.name)
def __eq__(self, other):
return isinstance(other, type(self)) and other.name == self.name
class Status(_Constant):
"""ACME "status" field."""
POSSIBLE_NAMES = {}
STATUS_UNKNOWN = Status('unknown')
STATUS_PENDING = Status('pending')
STATUS_PROCESSING = Status('processing')
STATUS_VALID = Status('valid')
STATUS_INVALID = Status('invalid')
STATUS_REVOKED = Status('revoked')
class IdentifierType(_Constant):
"""ACME identifier type."""
POSSIBLE_NAMES = {}
IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder
class Identifier(jose.JSONObjectWithFields):
"""ACME identifier.
:ivar letsencrypt.acme.messages2.IdentifierType typ:
"""
typ = jose.Field('type', decoder=IdentifierType.from_json)
value = jose.Field('value')
class Resource(jose.ImmutableMap):
"""ACME Resource.
:ivar letsencrypt.acme.messages2.ResourceBody body: Resource body.
:ivar str uri: Location of the resource.
"""
__slots__ = ('body', 'uri')
class ResourceBody(jose.JSONObjectWithFields):
"""ACME Resource Body."""
class RegistrationResource(Resource):
"""Registration Resource.
:ivar letsencrypt.acme.messages2.Registration body:
:ivar str new_authzr_uri: URI found in the 'next' ``Link`` header
:ivar str terms_of_service: URL for the CA TOS.
"""
__slots__ = ('body', 'uri', 'new_authzr_uri', 'terms_of_service')
class Registration(ResourceBody):
"""Registration Resource Body.
:ivar letsencrypt.acme.jose.jwk.JWK key: Public key.
:ivar tuple contact:
"""
# on new-reg key server ignores 'key' and populates it based on
# JWS.signature.combined.jwk
key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json)
contact = jose.Field('contact', omitempty=True, default=())
recovery_token = jose.Field('recoveryToken', omitempty=True)
agreement = jose.Field('agreement', omitempty=True)
class ChallengeResource(Resource, jose.JSONObjectWithFields):
"""Challenge Resource.
:ivar letsencrypt.acme.messages2.ChallengeBody body:
:ivar str authzr_uri: URI found in the 'up' ``Link`` header.
"""
__slots__ = ('body', 'authzr_uri')
@property
def uri(self): # pylint: disable=missing-docstring,no-self-argument
# bug? 'method already defined line None'
# pylint: disable=function-redefined
return self.body.uri
class ChallengeBody(ResourceBody):
"""Challenge Resource Body.
.. todo::
Confusingly, this has a similar name to `.challenges.Challenge`,
as well as `.achallenges.AnnotatedChallenge` or
`.achallenges.Indexed`... Once `messages2` and `network2` is
integrated with the rest of the client, this class functionality
will be merged with `.challenges.Challenge`. Meanwhile,
separation allows the ``master`` to be still interoperable with
Node.js server (protocol v00). For the time being use names such
as ``challb`` to distinguish instances of this class from
``achall`` or ``ichall``.
:ivar letsencrypt.acme.messages2.Status status:
:ivar datetime.datetime validated:
"""
__slots__ = ('chall',)
uri = jose.Field('uri')
status = jose.Field('status', decoder=Status.from_json)
validated = fields.RFC3339Field('validated', omitempty=True)
def to_partial_json(self):
jobj = super(ChallengeBody, self).to_partial_json()
jobj.update(self.chall.to_partial_json())
return jobj
@classmethod
def fields_from_json(cls, jobj):
jobj_fields = super(ChallengeBody, cls).fields_from_json(jobj)
jobj_fields['chall'] = challenges.Challenge.from_json(jobj)
return jobj_fields
class AuthorizationResource(Resource):
"""Authorization Resource.
:ivar letsencrypt.acme.messages2.Authorization body:
:ivar str new_cert_uri: URI found in the 'next' ``Link`` header
"""
__slots__ = ('body', 'uri', 'new_cert_uri')
class Authorization(ResourceBody):
"""Authorization Resource Body.
:ivar letsencrypt.acme.messages2.Identifier identifier:
:ivar list challenges: `list` of `Challenge`
:ivar tuple combinations: Challenge combinations (`tuple` of `tuple`
of `int`, as opposed to `list` of `list` from the spec).
:ivar letsencrypt.acme.jose.jwk.JWK key: Public key.
:ivar tuple contact:
:ivar letsencrypt.acme.messages2.Status status:
:ivar datetime.datetime expires:
"""
identifier = jose.Field('identifier', decoder=Identifier.from_json)
challenges = jose.Field('challenges', omitempty=True)
combinations = jose.Field('combinations', omitempty=True)
# TODO: acme-spec #92, #98
key = Registration._fields['key']
contact = Registration._fields['contact']
status = jose.Field('status', omitempty=True, decoder=Status.from_json)
# TODO: 'expires' is allowed for Authorization Resources in
# general, but for Key Authorization '[t]he "expires" field MUST
# be absent'... then acme-spec gives example with 'expires'
# present... That's confusing!
expires = fields.RFC3339Field('expires', omitempty=True)
@challenges.decoder
def challenges(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(ChallengeBody.from_json(chall) for chall in value)
@property
def resolved_combinations(self):
"""Combinations with challenges instead of indices."""
return tuple(tuple(self.challenges[idx] for idx in combo)
for combo in self.combinations)
class CertificateRequest(jose.JSONObjectWithFields):
"""ACME new-cert request.
:ivar letsencrypt.acme.jose.util.ComparableX509 csr:
`M2Crypto.X509.Request` wrapped in `.ComparableX509`
:ivar tuple authorizations: `tuple` of URIs (`str`)
"""
csr = jose.Field('csr', decoder=jose.decode_csr, encoder=jose.encode_csr)
authorizations = jose.Field('authorizations', decoder=tuple)
class CertificateResource(Resource):
"""Certificate Resource.
:ivar letsencrypt.acme.jose.util.ComparableX509 body:
`M2Crypto.X509.X509` wrapped in `.ComparableX509`
:ivar str cert_chain_uri: URI found in the 'up' ``Link`` header
:ivar tuple authzrs: `tuple` of `AuthorizationResource`.
"""
__slots__ = ('body', 'uri', 'cert_chain_uri', 'authzrs')
class Revocation(jose.JSONObjectWithFields):
"""Revocation message.
:ivar revoke: Either a `datetime.datetime` or `Revocation.NOW`.
:ivar tuple authorizations: Same as `CertificateRequest.authorizations`
"""
NOW = 'now'
"""A possible value for `revoke`, denoting that certificate should
be revoked now."""
revoke = jose.Field('revoke')
authorizations = CertificateRequest._fields['authorizations']
@revoke.decoder
def revoke(value): # pylint: disable=missing-docstring,no-self-argument
if value == Revocation.NOW:
return value
else:
return fields.RFC3339Field.default_decoder(value)
@revoke.encoder
def revoke(value): # pylint: disable=missing-docstring,no-self-argument
if value == Revocation.NOW:
return value
else:
return fields.RFC3339Field.default_encoder(value)

View file

@ -0,0 +1,232 @@
"""Tests for letsencrypt.acme.messages2."""
import datetime
import os
import pkg_resources
import unittest
import mock
import pytz
from Crypto.PublicKey import RSA
from letsencrypt.acme import challenges
from letsencrypt.acme import jose
class ErrorTest(unittest.TestCase):
"""Tests for letsencrypt.acme.messages2.Error."""
def setUp(self):
from letsencrypt.acme.messages2 import Error
self.error = Error(detail='foo', typ='malformed')
def test_typ_prefix(self):
self.assertEqual('malformed', self.error.typ)
self.assertEqual(
'urn:acme:error:malformed', self.error.to_partial_json()['type'])
self.assertEqual(
'malformed', self.error.from_json(self.error.to_partial_json()).typ)
def test_typ_decoder_missing_prefix(self):
from letsencrypt.acme.messages2 import Error
self.assertRaises(jose.DeserializationError, Error.from_json,
{'detail': 'foo', 'type': 'malformed'})
self.assertRaises(jose.DeserializationError, Error.from_json,
{'detail': 'foo', 'type': 'not valid bare type'})
def test_typ_decoder_not_recognized(self):
from letsencrypt.acme.messages2 import Error
self.assertRaises(jose.DeserializationError, Error.from_json,
{'detail': 'foo', 'type': 'urn:acme:error:baz'})
def test_description(self):
self.assertEqual(
'The request message was malformed', self.error.description)
def test_from_json_hashable(self):
from letsencrypt.acme.messages2 import Error
hash(Error.from_json(self.error.to_json()))
class ConstantTest(unittest.TestCase):
"""Tests for letsencrypt.acme.messages2._Constant."""
def setUp(self):
from letsencrypt.acme.messages2 import _Constant
class MockConstant(_Constant): # pylint: disable=missing-docstring
POSSIBLE_NAMES = {}
self.MockConstant = MockConstant # pylint: disable=invalid-name
self.const_a = MockConstant('a')
self.const_b = MockConstant('b')
def test_to_partial_json(self):
self.assertEqual('a', self.const_a.to_partial_json())
self.assertEqual('b', self.const_b.to_partial_json())
def test_from_json(self):
self.assertEqual(self.const_a, self.MockConstant.from_json('a'))
self.assertRaises(
jose.DeserializationError, self.MockConstant.from_json, 'c')
def test_from_json_hashable(self):
hash(self.MockConstant.from_json('a'))
def test_repr(self):
self.assertEqual('MockConstant(a)', repr(self.const_a))
self.assertEqual('MockConstant(b)', repr(self.const_b))
class RegistrationTest(unittest.TestCase):
"""Tests for letsencrypt.acme.messages2.Registration."""
def setUp(self):
key = jose.jwk.JWKRSA(key=jose.util.HashableRSAKey(
RSA.importKey(pkg_resources.resource_string(
'letsencrypt.client.tests', os.path.join(
'testdata', 'rsa256_key.pem'))).publickey()))
contact = ('mailto:letsencrypt-client@letsencrypt.org',)
recovery_token = 'XYZ'
agreement = 'https://letsencrypt.org/terms'
from letsencrypt.acme.messages2 import Registration
self.reg = Registration(
key=key, contact=contact, recovery_token=recovery_token,
agreement=agreement)
self.jobj_to = {
'contact': contact,
'recoveryToken': recovery_token,
'agreement': agreement,
'key': key,
}
self.jobj_from = self.jobj_to.copy()
self.jobj_from['key'] = key.to_json()
def test_to_partial_json(self):
self.assertEqual(self.jobj_to, self.reg.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.messages2 import Registration
self.assertEqual(self.reg, Registration.from_json(self.jobj_from))
def test_from_json_hashable(self):
from letsencrypt.acme.messages2 import Registration
hash(Registration.from_json(self.jobj_from))
class ChallengeResourceTest(unittest.TestCase):
"""Tests for letsencrypt.acme.messages2.ChallengeResource."""
def test_uri(self):
from letsencrypt.acme.messages2 import ChallengeResource
self.assertEqual('http://challb', ChallengeResource(body=mock.MagicMock(
uri='http://challb'), authzr_uri='http://authz').uri)
class ChallengeBodyTest(unittest.TestCase):
"""Tests for letsencrypt.acme.messages2.ChallengeBody."""
def setUp(self):
self.chall = challenges.DNS(token='foo')
from letsencrypt.acme.messages2 import ChallengeBody
from letsencrypt.acme.messages2 import STATUS_VALID
self.status = STATUS_VALID
self.challb = ChallengeBody(
uri='http://challb', chall=self.chall, status=self.status)
self.jobj_to = {
'uri': 'http://challb',
'status': self.status,
'type': 'dns',
'token': 'foo',
}
self.jobj_from = self.jobj_to.copy()
self.jobj_from['status'] = 'valid'
def test_to_partial_json(self):
self.assertEqual(self.jobj_to, self.challb.to_partial_json())
def test_from_json(self):
from letsencrypt.acme.messages2 import ChallengeBody
self.assertEqual(self.challb, ChallengeBody.from_json(self.jobj_from))
def test_from_json_hashable(self):
from letsencrypt.acme.messages2 import ChallengeBody
hash(ChallengeBody.from_json(self.jobj_from))
class AuthorizationTest(unittest.TestCase):
"""Tests for letsencrypt.acme.messages2.Authorization."""
def setUp(self):
from letsencrypt.acme.messages2 import ChallengeBody
from letsencrypt.acme.messages2 import STATUS_VALID
self.challbs = (
ChallengeBody(
uri='http://challb1', status=STATUS_VALID,
chall=challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A')),
ChallengeBody(uri='http://challb2', status=STATUS_VALID,
chall=challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA')),
ChallengeBody(uri='http://challb3', status=STATUS_VALID,
chall=challenges.RecoveryToken()),
)
combinations = ((0, 2), (1, 2))
from letsencrypt.acme.messages2 import Authorization
from letsencrypt.acme.messages2 import Identifier
from letsencrypt.acme.messages2 import IDENTIFIER_FQDN
identifier = Identifier(typ=IDENTIFIER_FQDN, value='example.com')
self.authz = Authorization(
identifier=identifier, combinations=combinations,
challenges=self.challbs)
self.jobj_from = {
'identifier': identifier.to_json(),
'challenges': [challb.to_json() for challb in self.challbs],
'combinations': combinations,
}
def test_from_json(self):
from letsencrypt.acme.messages2 import Authorization
Authorization.from_json(self.jobj_from)
def test_from_json_hashable(self):
from letsencrypt.acme.messages2 import Authorization
hash(Authorization.from_json(self.jobj_from))
def test_resolved_combinations(self):
self.assertEqual(self.authz.resolved_combinations, (
(self.challbs[0], self.challbs[2]),
(self.challbs[1], self.challbs[2]),
))
class RevocationTest(unittest.TestCase):
"""Tests for letsencrypt.acme.messages2.RevocationTest."""
def setUp(self):
from letsencrypt.acme.messages2 import Revocation
self.rev_now = Revocation(authorizations=(), revoke=Revocation.NOW)
self.rev_date = Revocation(authorizations=(), revoke=datetime.datetime(
2015, 3, 27, tzinfo=pytz.utc))
self.jobj_now = {'authorizations': (), 'revoke': Revocation.NOW}
self.jobj_date = {'authorizations': (),
'revoke': '2015-03-27T00:00:00Z'}
def test_revoke_decoder(self):
from letsencrypt.acme.messages2 import Revocation
self.assertEqual(self.rev_now, Revocation.from_json(self.jobj_now))
self.assertEqual(self.rev_date, Revocation.from_json(self.jobj_date))
def test_revoke_encoder(self):
self.assertEqual(self.jobj_now, self.rev_now.to_partial_json())
self.assertEqual(self.jobj_date, self.rev_date.to_partial_json())
def test_from_json_hashable(self):
from letsencrypt.acme.messages2 import Revocation
hash(Revocation.from_json(self.rev_now.to_json()))
if __name__ == '__main__':
unittest.main()

View file

@ -11,8 +11,9 @@ from letsencrypt.acme import jose
from letsencrypt.acme import other
KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
'letsencrypt.client.tests', 'testdata/rsa256_key.pem'))
KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
pkg_resources.resource_string(
'letsencrypt.client.tests', 'testdata/rsa256_key.pem')))
CERT = jose.ComparableX509(M2Crypto.X509.load_cert(
pkg_resources.resource_filename(
'letsencrypt.client.tests', 'testdata/cert.pem')))
@ -85,7 +86,7 @@ class ChallengeTest(unittest.TestCase):
'type': 'challenge',
'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
'challenges': [chall.fully_serialize() for chall in challs],
'challenges': [chall.to_json() for chall in challs],
'combinations': [[0, 2], [1, 2]], # TODO array tuples
}
@ -101,8 +102,8 @@ class ChallengeTest(unittest.TestCase):
)
))
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg_to)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from letsencrypt.acme.messages import Challenge
@ -116,7 +117,7 @@ class ChallengeTest(unittest.TestCase):
msg = Challenge.from_json(self.jmsg_from)
self.assertEqual(msg.combinations, ())
self.assertEqual(msg.to_json(), self.jmsg_to)
self.assertEqual(msg.to_partial_json(), self.jmsg_to)
class ChallengeRequestTest(unittest.TestCase):
@ -130,8 +131,8 @@ class ChallengeRequestTest(unittest.TestCase):
'identifier': 'example.com',
}
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
from letsencrypt.acme.messages import ChallengeRequest
@ -154,11 +155,11 @@ class AuthorizationTest(unittest.TestCase):
'jwk': jwk,
}
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
self.jmsg['jwk'] = self.jmsg['jwk'].to_json()
self.jmsg['jwk'] = self.jmsg['jwk'].to_partial_json()
from letsencrypt.acme.messages import Authorization
self.assertEqual(Authorization.from_json(self.jmsg), self.msg)
@ -174,7 +175,7 @@ class AuthorizationTest(unittest.TestCase):
self.assertTrue(msg.recovery_token is None)
self.assertTrue(msg.identifier is None)
self.assertTrue(msg.jwk is None)
self.assertEqual(self.jmsg, msg.to_json())
self.assertEqual(self.jmsg, msg.to_partial_json())
class AuthorizationRequestTest(unittest.TestCase):
@ -215,10 +216,9 @@ class AuthorizationRequestTest(unittest.TestCase):
'type': 'authorizationRequest',
'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
'responses': [None if response is None
else response.fully_serialize()
'responses': [None if response is None else response.to_json()
for response in self.responses],
'signature': signature.fully_serialize(),
'signature': signature.to_json(),
# TODO: schema validation doesn't recognize tuples as
# arrays :(
'contact': list(self.contact),
@ -236,8 +236,8 @@ class AuthorizationRequestTest(unittest.TestCase):
def test_verify(self):
self.assertTrue(self.msg.verify('example.com'))
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg_to)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from letsencrypt.acme.messages import AuthorizationRequest
@ -252,7 +252,7 @@ class AuthorizationRequestTest(unittest.TestCase):
msg = AuthorizationRequest.from_json(self.jmsg_from)
self.assertEqual(msg.contact, ())
self.assertEqual(self.jmsg_to, msg.to_json())
self.assertEqual(self.jmsg_to, msg.to_partial_json())
class CertificateTest(unittest.TestCase):
@ -274,8 +274,8 @@ class CertificateTest(unittest.TestCase):
# TODO: schema validation array tuples
self.jmsg_from['chain'] = list(self.jmsg_from['chain'])
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg_to)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from letsencrypt.acme.messages import Certificate
@ -292,7 +292,7 @@ class CertificateTest(unittest.TestCase):
self.assertEqual(msg.chain, ())
self.assertTrue(msg.refresh is None)
self.assertEqual(self.jmsg_to, msg.to_json())
self.assertEqual(self.jmsg_to, msg.to_partial_json())
class CertificateRequestTest(unittest.TestCase):
@ -315,8 +315,7 @@ class CertificateRequestTest(unittest.TestCase):
'signature': signature,
}
self.jmsg_from = self.jmsg_to.copy()
self.jmsg_from['signature'] = self.jmsg_from[
'signature'].fully_serialize()
self.jmsg_from['signature'] = self.jmsg_from['signature'].to_json()
def test_create(self):
from letsencrypt.acme.messages import CertificateRequest
@ -327,8 +326,8 @@ class CertificateRequestTest(unittest.TestCase):
def test_verify(self):
self.assertTrue(self.msg.verify())
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg_to)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from letsencrypt.acme.messages import CertificateRequest
@ -350,8 +349,8 @@ class DeferTest(unittest.TestCase):
'message': 'Warming up the HSM',
}
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
from letsencrypt.acme.messages import Defer
@ -366,7 +365,7 @@ class DeferTest(unittest.TestCase):
self.assertTrue(msg.interval is None)
self.assertTrue(msg.message is None)
self.assertEqual(self.jmsg, msg.to_json())
self.assertEqual(self.jmsg, msg.to_partial_json())
class ErrorTest(unittest.TestCase):
@ -384,8 +383,8 @@ class ErrorTest(unittest.TestCase):
'moreInfo': 'https://ca.example.com/documentation/csr-requirements',
}
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
from letsencrypt.acme.messages import Error
@ -400,7 +399,7 @@ class ErrorTest(unittest.TestCase):
self.assertTrue(msg.message is None)
self.assertTrue(msg.more_info is None)
self.assertEqual(self.jmsg, msg.to_json())
self.assertEqual(self.jmsg, msg.to_partial_json())
class RevocationTest(unittest.TestCase):
@ -410,8 +409,8 @@ class RevocationTest(unittest.TestCase):
self.msg = Revocation()
self.jmsg = {'type': 'revocation'}
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
from letsencrypt.acme.messages import Revocation
@ -440,8 +439,7 @@ class RevocationRequestTest(unittest.TestCase):
'signature': signature,
}
self.jmsg_from = self.jmsg_to.copy()
self.jmsg_from['signature'] = self.jmsg_from[
'signature'].fully_serialize()
self.jmsg_from['signature'] = self.jmsg_from['signature'].to_json()
def test_create(self):
from letsencrypt.acme.messages import RevocationRequest
@ -451,8 +449,8 @@ class RevocationRequestTest(unittest.TestCase):
def test_verify(self):
self.assertTrue(self.msg.verify())
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg_to)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from letsencrypt.acme.messages import RevocationRequest
@ -469,8 +467,8 @@ class StatusRequestTest(unittest.TestCase):
'token': u'O7-s9MNq1siZHlgrMzi9_A',
}
def test_to_json(self):
self.assertEqual(self.msg.to_json(), self.jmsg)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
from letsencrypt.acme.messages import StatusRequest

View file

@ -7,10 +7,12 @@ import Crypto.PublicKey.RSA
from letsencrypt.acme import jose
RSA256_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
'letsencrypt.client.tests', 'testdata/rsa256_key.pem'))
RSA512_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
'letsencrypt.client.tests', 'testdata/rsa512_key.pem'))
RSA256_KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
pkg_resources.resource_string(
'letsencrypt.client.tests', 'testdata/rsa256_key.pem')))
RSA512_KEY = jose.HashableRSAKey(
Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
'letsencrypt.client.tests', 'testdata/rsa512_key.pem')))
class SignatureTest(unittest.TestCase):
@ -40,8 +42,8 @@ class SignatureTest(unittest.TestCase):
self.jsig_from = {
'nonce': b64nonce,
'alg': self.alg.to_json(),
'jwk': self.jwk.to_json(),
'alg': self.alg.to_partial_json(),
'jwk': self.jwk.to_partial_json(),
'sig': b64sig,
}
@ -76,8 +78,8 @@ class SignatureTest(unittest.TestCase):
self.assertEqual(signature.jwk, self.jwk)
self.assertTrue(signature.verify(self.msg))
def test_to_json(self):
self.assertEqual(self.signature.to_json(), self.jsig_to)
def test_to_partial_json(self):
self.assertEqual(self.signature.to_partial_json(), self.jsig_to)
def test_from_json(self):
from letsencrypt.acme.other import Signature
@ -86,7 +88,7 @@ class SignatureTest(unittest.TestCase):
def test_from_json_non_schema_errors(self):
from letsencrypt.acme.other import Signature
jwk = self.jwk.to_json()
jwk = self.jwk.to_partial_json()
self.assertRaises(
jose.DeserializationError, Signature.from_json, {
'alg': 'RS256', 'sig': 'x', 'nonce': '', 'jwk': jwk})

View file

@ -5,6 +5,7 @@ import sys
import Crypto.PublicKey.RSA
from letsencrypt.acme import challenges
from letsencrypt.acme import jose
from letsencrypt.acme import messages
from letsencrypt.client import achallenges
@ -16,12 +17,12 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
"""ACME Authorization Handler for a client.
:ivar dv_auth: Authenticator capable of solving
:const:`~letsencrypt.client.constants.DV_CHALLENGES`
:class:`~letsencrypt.acme.challenges.DVChallenge` types
:type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator`
:ivar client_auth: Authenticator capable of solving
:const:`~letsencrypt.client_auth.constants.CLIENT_CHALLENGES`
:type client_auth: :class:`letsencrypt.client.interfaces.IAuthenticator`
:ivar cont_auth: Authenticator capable of solving
:class:`~letsencrypt.acme.challenges.ContinuityChallenge` types
:type cont_auth: :class:`letsencrypt.client.interfaces.IAuthenticator`
:ivar network: Network object for sending and receiving authorization
messages
@ -36,13 +37,13 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
:ivar dict paths: optimal path for authorization. eg. paths[domain]
:ivar dict dv_c: Keys - domain, Values are DV challenges in the form of
:class:`letsencrypt.client.achallenges.Indexed`
:ivar dict client_c: Keys - domain, Values are Client challenges in the form
of :class:`letsencrypt.client.achallenges.Indexed`
:ivar dict cont_c: Keys - domain, Values are Continuity challenges in the
form of :class:`letsencrypt.client.achallenges.Indexed`
"""
def __init__(self, dv_auth, client_auth, network):
def __init__(self, dv_auth, cont_auth, network):
self.dv_auth = dv_auth
self.client_auth = client_auth
self.cont_auth = cont_auth
self.network = network
self.domains = []
@ -52,7 +53,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
self.paths = dict()
self.dv_c = dict()
self.client_c = dict()
self.cont_c = dict()
def add_chall_msg(self, domain, msg, authkey):
"""Add a challenge message to the AuthHandler.
@ -76,7 +77,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
self.authkey[domain] = authkey
def get_authorizations(self):
"""Retreive all authorizations for challenges.
"""Retrieve all authorizations for challenges.
:raises LetsEncryptAuthHandlerError: If unable to retrieve all
authorizations
@ -119,8 +120,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
nonce=self.msgs[domain].nonce,
responses=self.responses[domain],
name=domain,
key=Crypto.PublicKey.RSA.importKey(
self.authkey[domain].pem)),
key=jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
self.authkey[domain].pem))),
messages.Authorization)
logging.info("Received Authorization for %s", domain)
return auth
@ -147,24 +148,24 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
self._get_chall_pref(dom),
self.msgs[dom].combinations)
self.dv_c[dom], self.client_c[dom] = self._challenge_factory(
self.dv_c[dom], self.cont_c[dom] = self._challenge_factory(
dom, self.paths[dom])
# Flatten challs for authenticator functions and remove index
# Order is important here as we will not expose the outside
# Authenticator to our own indices.
flat_client = []
flat_cont = []
flat_dv = []
for dom in self.domains:
flat_client.extend(ichall.achall for ichall in self.client_c[dom])
flat_cont.extend(ichall.achall for ichall in self.cont_c[dom])
flat_dv.extend(ichall.achall for ichall in self.dv_c[dom])
client_resp = []
cont_resp = []
dv_resp = []
try:
if flat_client:
client_resp = self.client_auth.perform(flat_client)
if flat_cont:
cont_resp = self.cont_auth.perform(flat_cont)
if flat_dv:
dv_resp = self.dv_auth.perform(flat_dv)
# This will catch both specific types of errors.
@ -181,8 +182,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
logging.info("Ready for verification...")
# Assemble Responses
if client_resp:
self._assign_responses(client_resp, self.client_c)
if cont_resp:
self._assign_responses(cont_resp, self.cont_c)
if dv_resp:
self._assign_responses(dv_resp, self.dv_c)
@ -191,7 +192,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
:param list flat_list: flat_list of responses from an IAuthenticator
:param dict ichall_dict: Master dict mapping all domains to a list of
their associated 'client' and 'dv' Indexed challenges, or their
their associated 'continuity' and 'dv' Indexed challenges, or their
:class:`letsencrypt.client.achallenges.Indexed` list
"""
@ -213,7 +214,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
"""
chall_prefs = []
chall_prefs.extend(self.client_auth.get_chall_pref(domain))
chall_prefs.extend(self.cont_auth.get_chall_pref(domain))
chall_prefs.extend(self.dv_auth.get_chall_pref(domain))
return chall_prefs
@ -228,11 +229,11 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
# Chose to make these lists instead of a generator to make it easier to
# work with...
dv_list = [ichall.achall for ichall in self.dv_c[domain]]
client_list = [ichall.achall for ichall in self.client_c[domain]]
cont_list = [ichall.achall for ichall in self.cont_c[domain]]
if dv_list:
self.dv_auth.cleanup(dv_list)
if client_list:
self.client_auth.cleanup(client_list)
if cont_list:
self.cont_auth.cleanup(cont_list)
def _cleanup_state(self, delete_list):
"""Cleanup state after an authorization is received.
@ -247,7 +248,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
del self.authkey[domain]
del self.client_c[domain]
del self.cont_c[domain]
del self.dv_c[domain]
self.domains.remove(domain)
@ -259,9 +260,9 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
:param list path: List of indices from `challenges`.
:returns: dv_chall, list of
:returns: dv_chall, list of DVChallenge type
:class:`letsencrypt.client.achallenges.Indexed`
client_chall, list of
cont_chall, list of ContinuityChallenge type
:class:`letsencrypt.client.achallenges.Indexed`
:rtype: tuple
@ -270,7 +271,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
"""
dv_chall = []
client_chall = []
cont_chall = []
for index in path:
chall = self.msgs[domain].challenges[index]
@ -304,35 +305,38 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes
ichall = achallenges.Indexed(achall=achall, index=index)
if isinstance(chall, challenges.ClientChallenge):
client_chall.append(ichall)
if isinstance(chall, challenges.ContinuityChallenge):
cont_chall.append(ichall)
elif isinstance(chall, challenges.DVChallenge):
dv_chall.append(ichall)
return dv_chall, client_chall
return dv_chall, cont_chall
def gen_challenge_path(challs, preferences, combinations):
"""Generate a plan to get authority over the identity.
.. todo:: Make sure that the challenges are feasible...
Example: Do you have the recovery key?
.. todo:: This can be possibly be rewritten to use resolved_combinations.
:param list challs: A list of challenges
:param tuple challs: A tuple of challenges
(:class:`letsencrypt.acme.challenges.Challenge`) from
:class:`letsencrypt.acme.messages.Challenge` server message to
be fulfilled by the client in order to prove possession of the
identifier.
:param list preferences: List of challenge preferences for domain
(:class:`letsencrypt.acme.challenges.Challege` subclasses)
(:class:`letsencrypt.acme.challenges.Challenge` subclasses)
:param list combinations: A collection of sets of challenges from
:param tuple combinations: A collection of sets of challenges from
:class:`letsencrypt.acme.messages.Challenge`, each of which would
be sufficient to prove possession of the identifier.
:returns: List of indices from ``challenges``.
:rtype: list
:returns: tuple of indices from ``challenges``.
:rtype: tuple
:raises letsencrypt.client.errors.LetsEncryptAuthHandlerError: If a
path cannot be created that satisfies the CA given the preferences and
combinations.
"""
if combinations:
@ -349,29 +353,34 @@ def _find_smart_path(challs, preferences, combinations):
"""
chall_cost = {}
max_cost = 0
max_cost = 1
for i, chall_cls in enumerate(preferences):
chall_cost[chall_cls] = i
max_cost += i
# max_cost is now equal to sum(indices) + 1
best_combo = []
# Set above completing all of the available challenges
best_combo_cost = max_cost + 1
best_combo_cost = max_cost
combo_total = 0
for combo in combinations:
for challenge_index in combo:
combo_total += chall_cost.get(challs[
challenge_index].__class__, max_cost)
if combo_total < best_combo_cost:
best_combo = combo
best_combo_cost = combo_total
combo_total = 0
combo_total = 0
if not best_combo:
logging.fatal("Client does not support any combination of "
"challenges to satisfy ACME server")
sys.exit(22)
msg = ("Client does not support any combination of challenges that "
"will satisfy the CA.")
logging.fatal(msg)
raise errors.LetsEncryptAuthHandlerError(msg)
return best_combo

View file

@ -28,6 +28,7 @@ from letsencrypt.client.display import ops as display_ops
from letsencrypt.client.plugins import disco as plugins_disco
from letsencrypt.client.plugins.apache import configurator as apache_configurator
from letsencrypt.client.plugins.nginx import configurator as nginx_configurator
def _common_run(args, config, authenticator, installer):
@ -303,6 +304,10 @@ def create_parser():
plugin_parser(
parser.add_argument_group("apache"), prefix="apache",
plugin_cls=apache_configurator.ApacheConfigurator)
plugin_parser(
parser.add_argument_group("nginx"), prefix="nginx",
plugin_cls=nginx_configurator.NginxConfigurator)
return parser

View file

@ -6,11 +6,11 @@ import sys
import Crypto.PublicKey.RSA
import M2Crypto
from letsencrypt.acme import jose
from letsencrypt.acme import messages
from letsencrypt.acme.jose import util as jose_util
from letsencrypt.client import auth_handler
from letsencrypt.client import client_authenticator
from letsencrypt.client import continuity_auth
from letsencrypt.client import crypto_util
from letsencrypt.client import errors
from letsencrypt.client import le_util
@ -33,7 +33,8 @@ class Client(object):
:type authkey: :class:`letsencrypt.client.le_util.Key`
:ivar auth_handler: Object that supports the IAuthenticator interface.
auth_handler contains both a dv_authenticator and a client_authenticator
auth_handler contains both a dv_authenticator and a
continuity_authenticator
:type auth_handler: :class:`letsencrypt.client.auth_handler.AuthHandler`
:ivar installer: Object supporting the IInstaller interface.
@ -60,9 +61,9 @@ class Client(object):
self.config = config
if dv_auth is not None:
client_auth = client_authenticator.ClientAuthenticator(config)
cont_auth = continuity_auth.ContinuityAuthenticator(config)
self.auth_handler = auth_handler.AuthHandler(
dv_auth, client_auth, self.network)
dv_auth, cont_auth, self.network)
else:
self.auth_handler = None
@ -130,9 +131,10 @@ class Client(object):
logging.info("Preparing and sending CSR...")
return self.network.send_and_receive_expected(
messages.CertificateRequest.create(
csr=jose_util.ComparableX509(
csr=jose.ComparableX509(
M2Crypto.X509.load_request_der_string(csr_der)),
key=Crypto.PublicKey.RSA.importKey(self.authkey.pem)),
key=jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
self.authkey.pem))),
messages.Certificate)
def save_certificate(self, certificate_msg, cert_path, chain_path):

View file

@ -1,4 +1,4 @@
"""Client Authenticator"""
"""Continuity Authenticator"""
import zope.interface
from letsencrypt.acme import challenges
@ -9,9 +9,9 @@ from letsencrypt.client import interfaces
from letsencrypt.client import recovery_token
class ClientAuthenticator(object):
class ContinuityAuthenticator(object):
"""IAuthenticator for
:const:`~letsencrypt.client.constants.CLIENT_CHALLENGES`.
:const:`~letsencrypt.acme.challenges.ContinuityChallenge` class challenges.
:ivar rec_token: Performs "recoveryToken" challenges
:type rec_token: :class:`letsencrypt.client.recovery_token.RecoveryToken`
@ -41,7 +41,7 @@ class ClientAuthenticator(object):
if isinstance(achall, achallenges.RecoveryToken):
responses.append(self.rec_token.perform(achall))
else:
raise errors.LetsEncryptClientAuthError("Unexpected Challenge")
raise errors.LetsEncryptContAuthError("Unexpected Challenge")
return responses
def cleanup(self, achalls):
@ -50,4 +50,4 @@ class ClientAuthenticator(object):
if isinstance(achall, achallenges.RecoveryToken):
self.rec_token.cleanup(achall)
else:
raise errors.LetsEncryptClientAuthError("Unexpected Challenge")
raise errors.LetsEncryptContAuthError("Unexpected Challenge")

View file

@ -5,6 +5,14 @@ class LetsEncryptClientError(Exception):
"""Generic Let's Encrypt client error."""
class NetworkError(LetsEncryptClientError):
"""Network error."""
class UnexpectedUpdate(NetworkError):
"""Unexpected update."""
class LetsEncryptReverterError(LetsEncryptClientError):
"""Let's Encrypt Reverter error."""
@ -14,7 +22,7 @@ class LetsEncryptAuthHandlerError(LetsEncryptClientError):
"""Let's Encrypt Auth Handler error."""
class LetsEncryptClientAuthError(LetsEncryptAuthHandlerError):
class LetsEncryptContAuthError(LetsEncryptAuthHandlerError):
"""Let's Encrypt Client Authenticator error."""

View file

@ -0,0 +1,506 @@
"""Networking for ACME protocol v02."""
import datetime
import heapq
import httplib
import itertools
import logging
import time
import M2Crypto
import requests
import werkzeug
from letsencrypt.acme import jose
from letsencrypt.acme import messages2
from letsencrypt.client import errors
# https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning
requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3()
class Network(object):
"""ACME networking.
.. todo::
Clean up raised error types hierarchy, document, and handle (wrap)
instances of `.DeserializationError` raised in `from_json()``.
:ivar str new_reg_uri: Location of new-reg
:ivar key: `.JWK` (private)
:ivar alg: `.JWASignature`
"""
DER_CONTENT_TYPE = 'application/pkix-cert'
JSON_CONTENT_TYPE = 'application/json'
JSON_ERROR_CONTENT_TYPE = 'application/problem+json'
def __init__(self, new_reg_uri, key, alg=jose.RS256):
self.new_reg_uri = new_reg_uri
self.key = key
self.alg = alg
def _wrap_in_jws(self, obj):
"""Wrap `JSONDeSerializable` object in JWS.
:rtype: `.JWS`
"""
dumps = obj.json_dumps()
logging.debug('Serialized JSON: %s', dumps)
return jose.JWS.sign(
payload=dumps, key=self.key, alg=self.alg).json_dumps()
@classmethod
def _check_response(cls, response, content_type=None):
"""Check response content and its type.
.. note::
Checking is not strict: wrong server response ``Content-Type``
HTTP header is ignored if response is an expected JSON object
(c.f. Boulder #56).
:param str content_type: Expected Content-Type response header.
If JSON is expected and not present in server response, this
function will raise an error. Otherwise, wrong Content-Type
is ignored, but logged.
:raises letsencrypt.messages2.Error: If server response body
carries HTTP Problem (draft-ietf-appsawg-http-problem-00).
:raises letsencrypt.errors.NetworkError: In case of other
networking errors.
"""
response_ct = response.headers.get('Content-Type')
try:
# TODO: response.json() is called twice, once here, and
# once in _get and _post clients
jobj = response.json()
except ValueError as error:
jobj = None
if not response.ok:
if jobj is not None:
if response_ct != cls.JSON_ERROR_CONTENT_TYPE:
logging.debug(
'Ignoring wrong Content-Type (%r) for JSON Error',
response_ct)
try:
raise messages2.Error.from_json(jobj)
except jose.DeserializationError as error:
# Couldn't deserialize JSON object
raise errors.NetworkError((response, error))
else:
# response is not JSON object
raise errors.NetworkError(response)
else:
if jobj is not None and response_ct != cls.JSON_CONTENT_TYPE:
logging.debug(
'Ignoring wrong Content-Type (%r) for JSON decodable '
'response', response_ct)
if content_type == cls.JSON_CONTENT_TYPE and jobj is None:
raise errors.NetworkError(
'Unexpected response Content-Type: {0}'.format(response_ct))
def _get(self, uri, content_type=JSON_CONTENT_TYPE, **kwargs):
"""Send GET request.
:raises letsencrypt.client.errors.NetworkError:
:returns: HTTP Response
:rtype: `requests.Response`
"""
try:
response = requests.get(uri, **kwargs)
except requests.exceptions.RequestException as error:
raise errors.NetworkError(error)
self._check_response(response, content_type=content_type)
return response
def _post(self, uri, data, content_type=JSON_CONTENT_TYPE, **kwargs):
"""Send POST data.
:param str content_type: Expected ``Content-Type``, fails if not set.
:raises letsencrypt.acme.messages2.NetworkError:
:returns: HTTP Response
:rtype: `requests.Response`
"""
logging.debug('Sending POST data: %s', data)
try:
response = requests.post(uri, data=data, **kwargs)
except requests.exceptions.RequestException as error:
raise errors.NetworkError(error)
logging.debug('Received response %s: %s', response, response.text)
self._check_response(response, content_type=content_type)
return response
@classmethod
def _regr_from_response(cls, response, uri=None, new_authzr_uri=None,
terms_of_service=None):
terms_of_service = (
response.links['terms-of-service']['url']
if 'terms-of-service' in response.links else terms_of_service)
if new_authzr_uri is None:
try:
new_authzr_uri = response.links['next']['url']
except KeyError:
raise errors.NetworkError('"next" link missing')
return messages2.RegistrationResource(
body=messages2.Registration.from_json(response.json()),
uri=response.headers.get('Location', uri),
new_authzr_uri=new_authzr_uri,
terms_of_service=terms_of_service)
def register(self, contact=messages2.Registration._fields[
'contact'].default):
"""Register.
:param contact: Contact list, as accpeted by `.RegistrationResource`
:type contact: `tuple`
:returns: Registration Resource.
:rtype: `.RegistrationResource`
:raises letsencrypt.client.errors.UnexpectedUpdate:
"""
new_reg = messages2.Registration(contact=contact)
response = self._post(self.new_reg_uri, self._wrap_in_jws(new_reg))
assert response.status_code == httplib.CREATED # TODO: handle errors
regr = self._regr_from_response(response)
if regr.body.key != self.key.public() or regr.body.contact != contact:
raise errors.UnexpectedUpdate(regr)
return regr
def update_registration(self, regr):
"""Update registration.
:pram regr: Registration Resource.
:type regr: `.RegistrationResource`
:returns: Updated Registration Resource.
:rtype: `.RegistrationResource`
"""
response = self._post(regr.uri, self._wrap_in_jws(regr.body))
# TODO: Boulder returns httplib.ACCEPTED
#assert response.status_code == httplib.OK
# TODO: Boulder does not set Location or Link on update
# (c.f. acme-spec #94)
updated_regr = self._regr_from_response(
response, uri=regr.uri, new_authzr_uri=regr.new_authzr_uri,
terms_of_service=regr.terms_of_service)
if updated_regr != regr:
# TODO: Boulder reregisters with new recoveryToken and new URI
raise errors.UnexpectedUpdate(regr)
return updated_regr
def _authzr_from_response(self, response, identifier,
uri=None, new_cert_uri=None):
if new_cert_uri is None:
try:
new_cert_uri = response.links['next']['url']
except KeyError:
raise errors.NetworkError('"next" link missing')
authzr = messages2.AuthorizationResource(
body=messages2.Authorization.from_json(response.json()),
uri=response.headers.get('Location', uri),
new_cert_uri=new_cert_uri)
if (authzr.body.key != self.key.public()
or authzr.body.identifier != identifier):
raise errors.UnexpectedUpdate(authzr)
return authzr
def request_challenges(self, identifier, regr):
"""Request challenges.
:param identifier: Identifier to be challenged.
:type identifier: `.messages2.Identifier`
:param regr: Registration Resource.
:type regr: `.RegistrationResource`
:returns: Authorization Resource.
:rtype: `.AuthorizationResource`
"""
new_authz = messages2.Authorization(identifier=identifier)
response = self._post(regr.new_authzr_uri, self._wrap_in_jws(new_authz))
assert response.status_code == httplib.CREATED # TODO: handle errors
return self._authzr_from_response(response, identifier)
def request_domain_challenges(self, domain, regr):
"""Request challenges for domain names.
This is simply a convenience function that wraps around
`request_challenges`, but works with domain names instead of
generic identifiers.
:param str domain: Domain name to be challenged.
"""
return self.request_challenges(messages2.Identifier(
typ=messages2.IDENTIFIER_FQDN, value=domain), regr)
def answer_challenge(self, challb, response):
"""Answer challenge.
:param challb: Challenge Resource body.
:type challb: `.ChallengeBody`
:param response: Corresponding Challenge response
:type response: `.challenges.ChallengeResponse`
:returns: Challenge Resource with updated body.
:rtype: `.ChallengeResource`
:raises errors.UnexpectedUpdate:
"""
response = self._post(challb.uri, self._wrap_in_jws(response))
try:
authzr_uri = response.links['up']['url']
except KeyError:
raise errors.NetworkError('"up" Link header missing')
challr = messages2.ChallengeResource(
authzr_uri=authzr_uri,
body=messages2.ChallengeBody.from_json(response.json()))
# TODO: check that challr.uri == response.headers['Location']?
if challr.uri != challb.uri:
raise errors.UnexpectedUpdate(challr.uri)
return challr
def answer_challenges(self, challbs, responses):
"""Answer multiple challenges.
.. note:: This is a convenience function to make integration
with old proto code easier and shall probably be removed
once restification is over.
"""
return [self.answer_challenge(challb, response)
for challb, response in itertools.izip(challbs, responses)]
@classmethod
def retry_after(cls, response, default):
"""Compute next `poll` time based on response ``Retry-After`` header.
:param response: Response from `poll`.
:type response: `requests.Response`
:param int default: Default value (in seconds), used when
``Retry-After`` header is not present or invalid.
:returns: Time point when next `poll` should be performed.
:rtype: `datetime.datetime`
"""
retry_after = response.headers.get('Retry-After', str(default))
try:
seconds = int(retry_after)
except ValueError:
# pylint: disable=no-member
decoded = werkzeug.parse_date(retry_after) # RFC1123
if decoded is None:
seconds = default
else:
return decoded
return datetime.datetime.now() + datetime.timedelta(seconds=seconds)
def poll(self, authzr):
"""Poll Authorization Resource for status.
:param authzr: Authorization Resource
:type authzr: `.AuthorizationResource`
:returns: Updated Authorization Resource and HTTP response.
:rtype: (`.AuthorizationResource`, `requests.Response`)
"""
response = self._get(authzr.uri)
updated_authzr = self._authzr_from_response(
response, authzr.body.identifier, authzr.uri, authzr.new_cert_uri)
# TODO: check and raise UnexpectedUpdate
return updated_authzr, response
def request_issuance(self, csr, authzrs):
"""Request issuance.
:param csr: CSR
:type csr: `M2Crypto.X509.Request` wrapped in `.ComparableX509`
:param authzrs: `list` of `.AuthorizationResource`
:returns: Issued certificate
:rtype: `.messages2.CertificateResource`
"""
assert authzrs, "Authorizations list is empty"
# TODO: assert len(authzrs) == number of SANs
req = messages2.CertificateRequest(
csr=csr, authorizations=tuple(authzr.uri for authzr in authzrs))
content_type = self.DER_CONTENT_TYPE # TODO: add 'cert_type 'argument
response = self._post(
authzrs[0].new_cert_uri, # TODO: acme-spec #90
self._wrap_in_jws(req),
content_type=content_type,
headers={'Accept': content_type})
cert_chain_uri = response.links.get('up', {}).get('url')
try:
uri = response.headers['Location']
except KeyError:
raise errors.NetworkError('"Location" Header missing')
return messages2.CertificateResource(
uri=uri, authzrs=authzrs, cert_chain_uri=cert_chain_uri,
body=jose.ComparableX509(
M2Crypto.X509.load_cert_der_string(response.content)))
def poll_and_request_issuance(self, csr, authzrs, mintime=5):
"""Poll and request issuance.
This function polls all provided Authorization Resource URIs
until all challenges are valid, respecting ``Retry-After`` HTTP
headers, and then calls `request_issuance`.
.. todo:: add `max_attempts` or `timeout`
:param csr: CSR.
:type csr: `M2Crypto.X509.Request` wrapped in `.ComparableX509`
:param authzrs: `list` of `.AuthorizationResource`
:param int mintime: Minimum time before next attempt, used if
``Retry-After`` is not present in the response.
:returns: ``(cert, updated_authzrs)`` `tuple` where ``cert`` is
the issued certificate (`.messages2.CertificateResource.),
and ``updated_authzrs`` is a `tuple` consisting of updated
Authorization Resources (`.AuthorizationResource`) as
present in the responses from server, and in the same order
as the input ``authzrs``.
:rtype: `tuple`
"""
# priority queue with datetime (based od Retry-After) as key,
# and original Authorization Resource as value
waiting = [(datetime.datetime.now(), authzr) for authzr in authzrs]
# mapping between original Authorization Resource and the most
# recently updated one
updated = dict((authzr, authzr) for authzr in authzrs)
while waiting:
# find the smallest Retry-After, and sleep if necessary
when, authzr = heapq.heappop(waiting)
now = datetime.datetime.now()
if when > now:
seconds = (when - now).seconds
logging.debug('Sleeping for %d seconds', seconds)
time.sleep(seconds)
# Note that we poll with the latest updated Authorization
# URI, which might have a different URI than initial one
updated_authzr, response = self.poll(updated[authzr])
updated[authzr] = updated_authzr
if updated_authzr.body.status != messages2.STATUS_VALID:
# push back to the priority queue, with updated retry_after
heapq.heappush(waiting, (self.retry_after(
response, default=mintime), authzr))
updated_authzrs = tuple(updated[authzr] for authzr in authzrs)
return self.request_issuance(csr, updated_authzrs), updated_authzrs
def _get_cert(self, uri):
content_type = self.DER_CONTENT_TYPE # TODO: make it a param
response = self._get(uri, headers={'Accept': content_type},
content_type=content_type)
return response, jose.ComparableX509(
M2Crypto.X509.load_cert_der_string(response.content))
def check_cert(self, certr):
"""Check for new cert.
:param certr: Certificate Resource
:type certr: `.CertificateResource`
:returns: Updated Certificate Resource.
:rtype: `.CertificateResource`
"""
# TODO: acme-spec 5.1 table action should be renamed to
# "refresh cert", and this method integrated with self.refresh
response, cert = self._get_cert(certr.uri)
if 'Location' not in response.headers:
raise errors.NetworkError('Location header missing')
if response.headers['Location'] != certr.uri:
raise errors.UnexpectedUpdate(response.text)
return certr.update(body=cert)
def refresh(self, certr):
"""Refresh certificate.
:param certr: Certificate Resource
:type certr: `.CertificateResource`
:returns: Updated Certificate Resource.
:rtype: `.CertificateResource`
"""
# TODO: If a client sends a refresh request and the server is
# not willing to refresh the certificate, the server MUST
# respond with status code 403 (Forbidden)
return self.check_cert(certr)
def fetch_chain(self, certr):
"""Fetch chain for certificate.
:param certr: Certificate Resource
:type certr: `.CertificateResource`
:returns: Certificate chain, or `None` if no "up" Link was provided.
:rtype: `M2Crypto.X509.X509` wrapped in `.ComparableX509`
"""
if certr.cert_chain_uri is not None:
return self._get_cert(certr.cert_chain_uri)
def revoke(self, certr, when=messages2.Revocation.NOW):
"""Revoke certificate.
:param when: When should the revocation take place? Takes
the same values as `.messages2.Revocation.revoke`.
"""
rev = messages2.Revocation(revoke=when, authorizations=tuple(
authzr.uri for authzr in certr.authzrs))
response = self._post(certr.uri, self._wrap_in_jws(rev))
if response.status_code != httplib.OK:
raise errors.NetworkError(
'Successful revocation must return HTTP OK status')

View file

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

View file

@ -0,0 +1,570 @@
"""Nginx Configuration"""
import logging
import os
import re
import shutil
import socket
import subprocess
import sys
import zope.interface
from letsencrypt.acme import challenges
from letsencrypt.client import achallenges
from letsencrypt.client import errors
from letsencrypt.client import interfaces
from letsencrypt.client import le_util
from letsencrypt.client import reverter
from letsencrypt.client.plugins.nginx import constants
from letsencrypt.client.plugins.nginx import dvsni
from letsencrypt.client.plugins.nginx import parser
class NginxConfigurator(object):
# pylint: disable=too-many-instance-attributes,too-many-public-methods
"""Nginx configurator.
.. todo:: Add proper support for comments in the config. Currently,
config files modified by the configurator will lose all their comments.
:ivar config: Configuration.
:type config: :class:`~letsencrypt.client.interfaces.IConfig`
:ivar parser: Handles low level parsing
:type parser: :class:`~letsencrypt.client.plugins.nginx.parser`
:ivar str save_notes: Human-readable config change notes
:ivar reverter: saves and reverts checkpoints
:type reverter: :class:`letsencrypt.client.reverter.Reverter`
:ivar tup version: version of Nginx
"""
zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller)
zope.interface.classProvides(interfaces.IPluginFactory)
description = "Nginx Web Server"
@classmethod
def add_parser_arguments(cls, add):
add("server-root", default=constants.DEFAULT_SERVER_ROOT,
help="Nginx server root directory.")
add("mod-ssl-conf", default=constants.DEFAULT_MOD_SSL_CONF,
help="Contains standard nginx SSL directives.")
add("ctl", default=constants.DEFAULT_CTL, help="Path to the "
"'nginx' binary, used for 'configtest' and retrieving nginx "
"version number.")
def __init__(self, config, version=None):
"""Initialize an Nginx Configurator.
:param tup version: version of Nginx as a tuple (1, 4, 7)
(used mostly for unittesting)
"""
self.config = config
# Verify that all directories and files exist with proper permissions
if os.geteuid() == 0:
self._verify_setup()
# Files to save
self.save_notes = ""
# Add number of outstanding challenges
self._chall_out = 0
# These will be set in the prepare function
self.parser = None
self.version = version
self._enhance_func = {} # TODO: Support at least redirects
# Set up reverter
self.reverter = reverter.Reverter(config)
self.reverter.recovery_routine()
# This is called in determine_authenticator and determine_installer
def prepare(self):
"""Prepare the authenticator/installer."""
self.parser = parser.NginxParser(
self.config.nginx_server_root,
self.config.nginx_mod_ssl_conf)
# Set Version
if self.version is None:
self.version = self.get_version()
temp_install(self.config.nginx_mod_ssl_conf)
# Entry point in main.py for installing cert
def deploy_cert(self, domain, cert, key, cert_chain=None):
# pylint: disable=unused-argument
"""Deploys certificate to specified virtual host.
.. note:: Aborts if the vhost is missing ssl_certificate or
ssl_certificate_key.
.. note:: Nginx doesn't have a cert chain directive, so the last
parameter is always ignored. It expects the cert file to have
the concatenated chain.
.. note:: This doesn't save the config files!
:param str domain: domain to deploy certificate
:param str cert: certificate filename
:param str key: private key filename
:param str cert_chain: certificate chain filename
"""
vhost = self.choose_vhost(domain)
directives = [['ssl_certificate', cert], ['ssl_certificate_key', key]]
try:
self.parser.add_server_directives(vhost.filep, vhost.names,
directives, True)
logging.info("Deployed Certificate to VirtualHost %s for %s",
vhost.filep, vhost.names)
except errors.LetsEncryptMisconfigurationError:
logging.warn(
"Cannot find a cert or key directive in %s for %s",
vhost.filep, vhost.names)
logging.warn("VirtualHost was not modified")
# Presumably break here so that the virtualhost is not modified
return False
self.save_notes += ("Changed vhost at %s with addresses of %s\n" %
(vhost.filep,
", ".join(str(addr) for addr in vhost.addrs)))
self.save_notes += "\tssl_certificate %s\n" % cert
self.save_notes += "\tssl_certificate_key %s\n" % key
#######################
# Vhost parsing methods
#######################
def choose_vhost(self, target_name):
"""Chooses a virtual host based on the given domain name.
.. note:: This makes the vhost SSL-enabled if it isn't already. Follows
Nginx's server block selection rules preferring blocks that are
already SSL.
.. todo:: This should maybe return list if no obvious answer
is presented.
.. todo:: The special name "$hostname" corresponds to the machine's
hostname. Currently we just ignore this.
:param str target_name: domain name
:returns: ssl vhost associated with name
:rtype: :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost`
"""
vhost = None
matches = self._get_ranked_matches(target_name)
if not matches:
# No matches at all :'(
pass
elif matches[0]['rank'] in xrange(2, 6):
# Wildcard match - need to find the longest one
rank = matches[0]['rank']
wildcards = [x for x in matches if x['rank'] == rank]
vhost = max(wildcards, key=lambda x: len(x['name']))['vhost']
else:
vhost = matches[0]['vhost']
if vhost is not None:
if not vhost.ssl:
self._make_server_ssl(vhost.filep, vhost.names)
return vhost
def _get_ranked_matches(self, target_name):
"""Returns a ranked list of vhosts that match target_name.
:param str target_name: The name to match
:returns: list of dicts containing the vhost, the matching name, and
the numerical rank
:rtype: list
"""
# Nginx chooses a matching server name for a request with precedence:
# 1. exact name match
# 2. longest wildcard name starting with *
# 3. longest wildcard name ending with *
# 4. first matching regex in order of appearance in the file
matches = []
for vhost in self.parser.get_vhosts():
name_type, name = parser.get_best_match(target_name, vhost.names)
if name_type == 'exact':
matches.append({'vhost': vhost,
'name': name,
'rank': 0 if vhost.ssl else 1})
elif name_type == 'wildcard_start':
matches.append({'vhost': vhost,
'name': name,
'rank': 2 if vhost.ssl else 3})
elif name_type == 'wildcard_end':
matches.append({'vhost': vhost,
'name': name,
'rank': 4 if vhost.ssl else 5})
elif name_type == 'regex':
matches.append({'vhost': vhost,
'name': name,
'rank': 6 if vhost.ssl else 7})
return sorted(matches, key=lambda x: x['rank'])
def get_all_names(self):
"""Returns all names found in the Nginx Configuration.
:returns: All ServerNames, ServerAliases, and reverse DNS entries for
virtual host addresses
:rtype: set
"""
all_names = set()
# Kept in same function to avoid multiple compilations of the regex
priv_ip_regex = (r"(^127\.0\.0\.1)|(^10\.)|(^172\.1[6-9]\.)|"
r"(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)")
private_ips = re.compile(priv_ip_regex)
hostname_regex = r"^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*[a-z]+$"
hostnames = re.compile(hostname_regex, re.IGNORECASE)
for vhost in self.parser.get_vhosts():
all_names.update(vhost.names)
for addr in vhost.addrs:
host = addr.get_addr()
if hostnames.match(host):
# If it's a hostname, add it to the names.
all_names.add(host)
elif not private_ips.match(host):
# If it isn't a private IP, do a reverse DNS lookup
# TODO: IPv6 support
try:
socket.inet_aton(host)
all_names.add(socket.gethostbyaddr(host)[0])
except (socket.error, socket.herror, socket.timeout):
continue
return all_names
def _make_server_ssl(self, filename, names):
"""Makes a server SSL based on server_name and filename by adding
a 'listen 443 ssl' directive to the server block.
.. todo:: Maybe this should create a new block instead of modifying
the existing one?
:param str filename: The absolute filename of the config file.
:param set names: The server names of the block to add SSL in
"""
self.parser.add_server_directives(
filename, names,
[['listen', '443 ssl'],
['ssl_certificate', '/etc/ssl/certs/ssl-cert-snakeoil.pem'],
['ssl_certificate_key', '/etc/ssl/private/ssl-cert-snakeoil.key'],
['include', self.parser.loc["ssl_options"]]])
def get_all_certs_keys(self):
"""Find all existing keys, certs from configuration.
:returns: list of tuples with form [(cert, key, path)]
cert - str path to certificate file
key - str path to associated key file
path - File path to configuration file.
:rtype: set
"""
return self.parser.get_all_certs_keys()
##################################
# enhancement methods (IInstaller)
##################################
def supported_enhancements(self): # pylint: disable=no-self-use
"""Returns currently supported enhancements."""
return []
def enhance(self, domain, enhancement, options=None):
"""Enhance configuration.
:param str domain: domain to enhance
:param str enhancement: enhancement type defined in
:const:`~letsencrypt.client.constants.ENHANCEMENTS`
:param options: options for the enhancement
See :const:`~letsencrypt.client.constants.ENHANCEMENTS`
documentation for appropriate parameter.
"""
try:
return self._enhance_func[enhancement](
self.choose_vhost(domain), options)
except (KeyError, ValueError):
raise errors.LetsEncryptConfiguratorError(
"Unsupported enhancement: {0}".format(enhancement))
except errors.LetsEncryptConfiguratorError:
logging.warn("Failed %s for %s", enhancement, domain)
######################################
# Nginx server management (IInstaller)
######################################
def restart(self):
"""Restarts nginx server.
:returns: Success
:rtype: bool
"""
return nginx_restart(self.config.nginx_ctl)
def config_test(self): # pylint: disable=no-self-use
"""Check the configuration of Nginx for errors.
:returns: Success
:rtype: bool
"""
try:
proc = subprocess.Popen(
[self.config.nginx_ctl, "-t"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
stdout, stderr = proc.communicate()
except (OSError, ValueError):
logging.fatal("Unable to run nginx config test")
sys.exit(1)
if proc.returncode != 0:
# Enter recovery routine...
logging.error("Config test failed")
logging.error(stdout)
logging.error(stderr)
return False
return True
def _verify_setup(self):
"""Verify the setup to ensure safe operating environment.
Make sure that files/directories are setup with appropriate permissions
Aim for defensive coding... make sure all input files
have permissions of root.
"""
uid = os.geteuid()
le_util.make_or_verify_dir(self.config.work_dir, 0o755, uid)
le_util.make_or_verify_dir(self.config.backup_dir, 0o755, uid)
le_util.make_or_verify_dir(self.config.config_dir, 0o755, uid)
def get_version(self):
"""Return version of Nginx Server.
Version is returned as tuple. (ie. 2.4.7 = (2, 4, 7))
:returns: version
:rtype: tuple
:raises errors.LetsEncryptConfiguratorError:
Unable to find Nginx version or version is unsupported
"""
try:
proc = subprocess.Popen(
[self.config.nginx_ctl, "-V"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
text = proc.communicate()[1] # nginx prints output to stderr
except (OSError, ValueError):
raise errors.LetsEncryptConfiguratorError(
"Unable to run %s -V" % self.config.nginx_ctl)
version_regex = re.compile(r"nginx/([0-9\.]*)", re.IGNORECASE)
version_matches = version_regex.findall(text)
sni_regex = re.compile(r"TLS SNI support enabled", re.IGNORECASE)
sni_matches = sni_regex.findall(text)
ssl_regex = re.compile(r" --with-http_ssl_module")
ssl_matches = ssl_regex.findall(text)
if not version_matches:
raise errors.LetsEncryptConfiguratorError(
"Unable to find Nginx version")
if not ssl_matches:
raise errors.LetsEncryptConfiguratorError(
"Nginx build is missing SSL module (--with-http_ssl_module).")
if not sni_matches:
raise errors.LetsEncryptConfiguratorError(
"Nginx build doesn't support SNI")
nginx_version = tuple([int(i) for i in version_matches[0].split(".")])
# nginx < 0.8.21 doesn't use default_server
if nginx_version < (0, 8, 21):
raise errors.LetsEncryptConfiguratorError(
"Nginx version must be 0.8.21+")
return nginx_version
def more_info(self):
"""Human-readable string to help understand the module"""
return (
"Configures Nginx to authenticate and install HTTPS.{0}"
"Server root: {root}{0}"
"Version: {version}".format(
os.linesep, root=self.parser.loc["root"],
version=".".join(str(i) for i in self.version))
)
###################################################
# Wrapper functions for Reverter class (IInstaller)
###################################################
def save(self, title=None, temporary=False):
"""Saves all changes to the configuration files.
:param str title: The title of the save. If a title is given, the
configuration will be saved as a new checkpoint and put in a
timestamped directory.
:param bool temporary: Indicates whether the changes made will
be quickly reversed in the future (ie. challenges)
"""
save_files = set(self.parser.parsed.keys())
# Create Checkpoint
if temporary:
self.reverter.add_to_temp_checkpoint(
save_files, self.save_notes)
else:
self.reverter.add_to_checkpoint(save_files,
self.save_notes)
# Change 'ext' to something else to not override existing conf files
self.parser.filedump(ext='')
if title and not temporary:
self.reverter.finalize_checkpoint(title)
return True
def recovery_routine(self):
"""Revert all previously modified files.
Reverts all modified files that have not been saved as a checkpoint
"""
self.reverter.recovery_routine()
self.parser.load()
def revert_challenge_config(self):
"""Used to cleanup challenge configurations."""
self.reverter.revert_temporary_config()
self.parser.load()
def rollback_checkpoints(self, rollback=1):
"""Rollback saved checkpoints.
:param int rollback: Number of checkpoints to revert
"""
self.reverter.rollback_checkpoints(rollback)
self.parser.load()
def view_config_changes(self):
"""Show all of the configuration changes that have taken place."""
self.reverter.view_config_changes()
###########################################################################
# Challenges Section for IAuthenticator
###########################################################################
def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use
"""Return list of challenge preferences."""
return [challenges.DVSNI]
# Entry point in main.py for performing challenges
def perform(self, achalls):
"""Perform the configuration related challenge.
This function currently assumes all challenges will be fulfilled.
If this turns out not to be the case in the future. Cleanup and
outstanding challenges will have to be designed better.
"""
self._chall_out += len(achalls)
responses = [None] * len(achalls)
nginx_dvsni = dvsni.NginxDvsni(self)
for i, achall in enumerate(achalls):
if isinstance(achall, achallenges.DVSNI):
# Currently also have dvsni hold associated index
# of the challenge. This helps to put all of the responses back
# together when they are all complete.
nginx_dvsni.add_chall(achall, i)
sni_response = nginx_dvsni.perform()
# Must restart in order to activate the challenges.
# Handled here because we may be able to load up other challenge types
self.restart()
# Go through all of the challenges and assign them to the proper place
# in the responses return value. All responses must be in the same order
# as the original challenges.
for i, resp in enumerate(sni_response):
responses[nginx_dvsni.indices[i]] = resp
return responses
# called after challenges are performed
def cleanup(self, achalls):
"""Revert all challenges."""
self._chall_out -= len(achalls)
# If all of the challenges have been finished, clean up everything
if self._chall_out <= 0:
self.revert_challenge_config()
self.restart()
def nginx_restart(nginx_ctl):
"""Restarts the Nginx Server.
:param str nginx_ctl: Path to the Nginx binary.
"""
try:
proc = subprocess.Popen([nginx_ctl, "-s", "reload"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
stdout, stderr = proc.communicate()
if proc.returncode != 0:
# Enter recovery routine...
logging.error("Nginx Restart Failed!")
logging.error(stdout)
logging.error(stderr)
return False
except (OSError, ValueError):
logging.fatal(
"Nginx Restart Failed - Please Check the Configuration")
sys.exit(1)
return True
def temp_install(options_ssl):
"""Temporary install for convenience."""
# WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY
# THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER
# AND TAKEN OUT BEFORE RELEASE, INSTEAD
# SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM.
# Check to make sure options-ssl.conf is installed
if not os.path.isfile(options_ssl):
shutil.copyfile(constants.MOD_SSL_CONF, options_ssl)

View file

@ -0,0 +1,13 @@
"""nginx plugin constants."""
import pkg_resources
DEFAULT_SERVER_ROOT = "/etc/nginx"
DEFAULT_MOD_SSL_CONF = "/etc/letsencrypt/options-ssl-nginx.conf"
DEFAULT_CTL = "nginx"
MOD_SSL_CONF = pkg_resources.resource_filename(
"letsencrypt.client.plugins.nginx", "options-ssl.conf")
"""Path to the Nginx mod_ssl config file found in the Let's Encrypt
distribution."""

View file

@ -0,0 +1,63 @@
"""NginxDVSNI"""
import logging
from letsencrypt.client.plugins.apache.dvsni import ApacheDvsni
class NginxDvsni(ApacheDvsni):
"""Class performs DVSNI challenges within the Nginx configurator.
.. todo:: This is basically copied-and-pasted from the Apache equivalent.
It doesn't actually work yet.
:ivar configurator: NginxConfigurator object
:type configurator: :class:`~nginx.configurator.NginxConfigurator`
:ivar list achalls: Annotated :class:`~letsencrypt.client.achallenges.DVSNI`
challenges.
:param list indices: Meant to hold indices of challenges in a
larger array. NginxDvsni is capable of solving many challenges
at once which causes an indexing issue within NginxConfigurator
who must return all responses in order. Imagine NginxConfigurator
maintaining state about where all of the SimpleHTTPS Challenges,
Dvsni Challenges belong in the response array. This is an optional
utility.
:param str challenge_conf: location of the challenge config file
"""
def perform(self):
"""Perform a DVSNI challenge on Nginx."""
if not self.achalls:
return []
self.configurator.save()
addresses = []
for achall in self.achalls:
vhost = self.configurator.choose_vhost(achall.domain)
if vhost is None:
logging.error(
"No nginx vhost exists with servername or alias of: %s",
achall.domain)
logging.error("No default 443 nginx vhost exists")
logging.error("Please specify servernames in the Nginx config")
return None
else:
addresses.append(list(vhost.addrs))
responses = []
# Create all of the challenge certs
# for achall in self.achalls:
# responses.append(self._setup_challenge_cert(achall))
# Setup the configuration
# self._mod_config(addresses)
# Save reversible changes
self.configurator.save("SNI Challenge", True)
return responses

View file

@ -0,0 +1,130 @@
"""Very low-level nginx config parser based on pyparsing."""
import string
from pyparsing import (
Literal, White, Word, alphanums, CharsNotIn, Forward, Group,
Optional, OneOrMore, ZeroOrMore, pythonStyleComment)
class RawNginxParser(object):
# pylint: disable=expression-not-assigned
"""A class that parses nginx configuration with pyparsing."""
# constants
left_bracket = Literal("{").suppress()
right_bracket = Literal("}").suppress()
semicolon = Literal(";").suppress()
space = White().suppress()
key = Word(alphanums + "_/")
value = CharsNotIn("{};,")
location = CharsNotIn("{};," + string.whitespace)
# modifier for location uri [ = | ~ | ~* | ^~ ]
modifier = Literal("=") | Literal("~*") | Literal("~") | Literal("^~")
# rules
assignment = (key + Optional(space + value) + semicolon)
block = Forward()
block << Group(
Group(key + Optional(space + modifier) + Optional(space + location))
+ left_bracket
+ Group(ZeroOrMore(Group(assignment) | block))
+ right_bracket)
script = OneOrMore(Group(assignment) | block).ignore(pythonStyleComment)
def __init__(self, source):
self.source = source
def parse(self):
"""Returns the parsed tree."""
return self.script.parseString(self.source)
def as_list(self):
"""Returns the parsed tree as a list."""
return self.parse().asList()
class RawNginxDumper(object):
# pylint: disable=too-few-public-methods
"""A class that dumps nginx configuration from the provided tree."""
def __init__(self, blocks, indentation=4):
self.blocks = blocks
self.indentation = indentation
def __iter__(self, blocks=None, current_indent=0, spacer=' '):
"""Iterates the dumped nginx content."""
blocks = blocks or self.blocks
for key, values in blocks:
if current_indent:
yield spacer
indentation = spacer * current_indent
if isinstance(key, list):
yield indentation + spacer.join(key) + ' {'
for parameter in values:
if isinstance(parameter[0], list):
dumped = self.__iter__(
[parameter],
current_indent + self.indentation)
for line in dumped:
yield line
else:
dumped = spacer.join(parameter) + ';'
yield spacer * (
current_indent + self.indentation) + dumped
yield indentation + '}'
else:
yield spacer * current_indent + key + spacer + values + ';'
def as_string(self):
"""Return the parsed block as a string."""
return '\n'.join(self)
# Shortcut functions to respect Python's serialization interface
# (like pyyaml, picker or json)
def loads(source):
"""Parses from a string.
:param str souce: The string to parse
:returns: The parsed tree
:rtype: list
"""
return RawNginxParser(source).as_list()
def load(_file):
"""Parses from a file.
:param file _file: The file to parse
:returns: The parsed tree
:rtype: list
"""
return loads(_file.read())
def dumps(blocks, indentation=4):
"""Dump to a string.
:param list block: The parsed tree
:param int indentation: The number of spaces to indent
:rtype: str
"""
return RawNginxDumper(blocks, indentation).as_string()
def dump(blocks, _file, indentation=4):
"""Dump to a file.
:param list block: The parsed tree
:param file _file: The file to dump to
:param int indentation: The number of spaces to indent
:rtype: NoneType
"""
return _file.write(dumps(blocks, indentation))

View file

@ -0,0 +1,125 @@
"""Module contains classes used by the Nginx Configurator."""
import re
from letsencrypt.client.plugins.apache.obj import Addr as ApacheAddr
class Addr(ApacheAddr):
"""Represents an Nginx address, i.e. what comes after the 'listen'
directive.
According to http://nginx.org/en/docs/http/ngx_http_core_module.html#listen,
this may be address[:port], port, or unix:path. The latter is ignored here.
The default value if no directive is specified is *:80 (superuser) or
*:8000 (otherwise). If no port is specified, the default is 80. If no
address is specified, listen on all addresses.
.. todo:: Old-style nginx configs define SSL vhosts in a separate block
instead of using 'ssl' in the listen directive
:param str addr: addr part of vhost address, may be hostname, IPv4, IPv6,
"", or "*"
:param str port: port number or "*" or ""
:param bool ssl: Whether the directive includes 'ssl'
:param bool default: Whether the directive includes 'default_server'
"""
def __init__(self, host, port, ssl, default):
super(Addr, self).__init__((host, port))
self.ssl = ssl
self.default = default
@classmethod
def fromstring(cls, str_addr):
"""Initialize Addr from string."""
parts = str_addr.split(' ')
ssl = False
default = False
host = ''
port = ''
# The first part must be the address
addr = parts.pop(0)
# Ignore UNIX-domain sockets
if addr.startswith('unix:'):
return None
tup = addr.partition(':')
if re.match(r'^\d+$', tup[0]):
# This is a bare port, not a hostname. E.g. listen 80
host = ''
port = tup[0]
else:
# This is a host-port tuple. E.g. listen 127.0.0.1:*
host = tup[0]
port = tup[2]
# The rest of the parts are options; we only care about ssl and default
while len(parts) > 0:
nextpart = parts.pop()
if nextpart == 'ssl':
ssl = True
elif nextpart == 'default_server':
default = True
return cls(host, port, ssl, default)
def __str__(self):
if self.tup[0] and self.tup[1]:
return "%s:%s" % self.tup
elif self.tup[0]:
return self.tup[0]
else:
return self.tup[1]
def __eq__(self, other):
if isinstance(other, self.__class__):
return (self.tup == other.tup and
self.ssl == other.ssl and
self.default == other.default)
return False
class VirtualHost(object): # pylint: disable=too-few-public-methods
"""Represents an Nginx Virtualhost.
:ivar str filep: file path of VH
:ivar set addrs: Virtual Host addresses (:class:`set` of :class:`Addr`)
:ivar set names: Server names/aliases of vhost
(:class:`list` of :class:`str`)
:ivar array raw: The raw form of the parsed server block
:ivar bool ssl: SSLEngine on in vhost
:ivar bool enabled: Virtual host is enabled
"""
def __init__(self, filep, addrs, ssl, enabled, names, raw):
# pylint: disable=too-many-arguments
"""Initialize a VH."""
self.filep = filep
self.addrs = addrs
self.names = names
self.ssl = ssl
self.enabled = enabled
self.raw = raw
def __str__(self):
addr_str = ", ".join(str(addr) for addr in self.addrs)
return ("file: %s\n"
"addrs: %s\n"
"names: %s\n"
"ssl: %s\n"
"enabled: %s" % (self.filep, addr_str,
self.names, self.ssl, self.enabled))
def __eq__(self, other):
if isinstance(other, self.__class__):
return (self.filep == other.filep and
list(self.addrs) == list(other.addrs) and
self.names == other.names and
self.ssl == other.ssl and self.enabled == other.enabled)
return False

View file

@ -0,0 +1,8 @@
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 1440m;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
# Using list of ciphers from "Bulletproof SSL and TLS"
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256 ECDHE-ECDSA-AES256-GCM-SHA384 ECDHE-ECDSA-AES128-SHA ECDHE-ECDSA-AES256-SHA ECDHE-ECDSA-AES128-SHA256 ECDHE-ECDSA-AES256-SHA384 ECDHE-RSA-AES128-GCM-SHA256 ECDHE-RSA-AES256-GCM-SHA384 ECDHE-RSA-AES128-SHA ECDHE-RSA-AES128-SHA256 ECDHE-RSA-AES256-SHA384 DHE-RSA-AES128-GCM-SHA256 DHE-RSA-AES256-GCM-SHA384 DHE-RSA-AES128-SHA DHE-RSA-AES256-SHA DHE-RSA-AES128-SHA256 DHE-RSA-AES256-SHA256 EDH-RSA-DES-CBC3-SHA";

View file

@ -0,0 +1,484 @@
"""NginxParser is a member object of the NginxConfigurator class."""
import glob
import logging
import os
import pyparsing
import re
from letsencrypt.client import errors
from letsencrypt.client.plugins.nginx import obj
from letsencrypt.client.plugins.nginx.nginxparser import dump, load
class NginxParser(object):
"""Class handles the fine details of parsing the Nginx Configuration.
:ivar str root: Normalized abosulte path to the server root
directory. Without trailing slash.
:ivar dict parsed: Mapping of file paths to parsed trees
"""
def __init__(self, root, ssl_options):
self.parsed = {}
self.root = os.path.abspath(root)
self.loc = self._set_locations(ssl_options)
# Parse nginx.conf and included files.
# TODO: Check sites-available/ as well. For now, the configurator does
# not enable sites from there.
self.load()
def load(self):
"""Loads Nginx files into a parsed tree.
"""
self._parse_recursively(self.loc["root"])
def _parse_recursively(self, filepath):
"""Parses nginx config files recursively by looking at 'include'
directives inside 'http' and 'server' blocks. Note that this only
reads Nginx files that potentially declare a virtual host.
.. todo:: Can Nginx 'virtual hosts' be defined somewhere other than in
the server context?
:param str filepath: The path to the files to parse, as a glob
"""
filepath = self.abs_path(filepath)
trees = self._parse_files(filepath)
for tree in trees:
for entry in tree:
if _is_include_directive(entry):
# Parse the top-level included file
self._parse_recursively(entry[1])
elif entry[0] == ['http'] or entry[0] == ['server']:
# Look for includes in the top-level 'http'/'server' context
for subentry in entry[1]:
if _is_include_directive(subentry):
self._parse_recursively(subentry[1])
elif entry[0] == ['http'] and subentry[0] == ['server']:
# Look for includes in a 'server' context within
# an 'http' context
for server_entry in subentry[1]:
if _is_include_directive(server_entry):
self._parse_recursively(server_entry[1])
def abs_path(self, path):
"""Converts a relative path to an absolute path relative to the root.
Does nothing for paths that are already absolute.
:param str path: The path
:returns: The absolute path
:rtype: str
"""
if not os.path.isabs(path):
return os.path.join(self.root, path)
else:
return path
def get_vhosts(self):
# pylint: disable=cell-var-from-loop
"""Gets list of all 'virtual hosts' found in Nginx configuration.
Technically this is a misnomer because Nginx does not have virtual
hosts, it has 'server blocks'.
:returns: List of
:class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` objects
found in configuration
:rtype: list
"""
enabled = True # We only look at enabled vhosts for now
vhosts = []
servers = {}
for filename in self.parsed:
tree = self.parsed[filename]
servers[filename] = []
srv = servers[filename] # workaround undefined loop var in lambdas
# Find all the server blocks
_do_for_subarray(tree, lambda x: x[0] == ['server'],
lambda x: srv.append(x[1]))
# Find 'include' statements in server blocks and append their trees
for i, server in enumerate(servers[filename]):
new_server = self._get_included_directives(server)
servers[filename][i] = new_server
for filename in servers:
for server in servers[filename]:
# Parse the server block into a VirtualHost object
parsed_server = _parse_server(server)
vhost = obj.VirtualHost(filename,
parsed_server['addrs'],
parsed_server['ssl'],
enabled,
parsed_server['names'],
server)
vhosts.append(vhost)
return vhosts
def _get_included_directives(self, block):
"""Returns array with the "include" directives expanded out by
concatenating the contents of the included file to the block.
:param list block:
:rtype: list
"""
result = list(block) # Copy the list to keep self.parsed idempotent
for directive in block:
if _is_include_directive(directive):
included_files = glob.glob(
self.abs_path(directive[1]))
for incl in included_files:
try:
result.extend(self.parsed[incl])
except KeyError:
pass
return result
def _parse_files(self, filepath, override=False):
"""Parse files from a glob
:param str filepath: Nginx config file path
:param bool override: Whether to parse a file that has been parsed
:returns: list of parsed tree structures
:rtype: list
"""
files = glob.glob(filepath)
trees = []
for item in files:
if item in self.parsed and not override:
continue
try:
with open(item) as _file:
parsed = load(_file)
self.parsed[item] = parsed
trees.append(parsed)
except IOError:
logging.warn("Could not open file: %s", item)
except pyparsing.ParseException:
logging.warn("Could not parse file: %s", item)
return trees
def _set_locations(self, ssl_options):
"""Set default location for directives.
Locations are given as file_paths
.. todo:: Make sure that files are included
"""
root = self._find_config_root()
default = root
nginx_temp = os.path.join(self.root, "nginx_ports.conf")
if os.path.isfile(nginx_temp):
listen = nginx_temp
name = nginx_temp
else:
listen = default
name = default
return {"root": root, "default": default, "listen": listen,
"name": name, "ssl_options": ssl_options}
def _find_config_root(self):
"""Find the Nginx Configuration Root file."""
location = ['nginx.conf']
for name in location:
if os.path.isfile(os.path.join(self.root, name)):
return os.path.join(self.root, name)
raise errors.LetsEncryptNoInstallationError(
"Could not find configuration root")
def filedump(self, ext='tmp'):
"""Dumps parsed configurations into files.
:param str ext: The file extension to use for the dumped files. If
empty, this overrides the existing conf files.
"""
for filename in self.parsed:
tree = self.parsed[filename]
if ext:
filename = filename + os.path.extsep + ext
try:
with open(filename, 'w') as _file:
dump(tree, _file)
except IOError:
logging.error("Could not open file for writing: %s", filename)
def _has_server_names(self, entry, names):
"""Checks if a server block has the given set of server_names. This
is the primary way of identifying server blocks in the configurator.
Returns false if 'entry' doesn't look like a server block at all.
..todo :: Doesn't match server blocks whose server_name directives are
split across multiple conf files.
:param list entry: The block to search
:param set names: The names to match
:rtype: bool
"""
if len(names) == 0:
# Nothing to identify blocks with
return False
if not isinstance(entry, list):
# Can't be a server block
return False
new_entry = self._get_included_directives(entry)
server_names = set()
for item in new_entry:
if not isinstance(item, list):
# Can't be a server block
return False
if item[0] == 'server_name':
server_names.update(_get_servernames(item[1]))
return server_names == names
def add_server_directives(self, filename, names, directives,
replace=False):
"""Add or replace directives in server blocks whose server_name set
is 'names'. If replace is True, this raises a misconfiguration error
if the directive does not already exist.
..todo :: Doesn't match server blocks whose server_name directives are
split across multiple conf files.
:param str filename: The absolute filename of the config file
:param set names: The server_name to match
:param list directives: The directives to add
:param bool replace: Whether to only replace existing directives
"""
if replace:
_do_for_subarray(self.parsed[filename],
lambda x: self._has_server_names(x, names),
lambda x: _replace_directives(x, directives))
else:
_do_for_subarray(self.parsed[filename],
lambda x: self._has_server_names(x, names),
lambda x: x.extend(directives))
def get_all_certs_keys(self):
"""Gets all certs and keys in the nginx config.
:returns: list of tuples with form [(cert, key, path)]
cert - str path to certificate file
key - str path to associated key file
path - File path to configuration file.
:rtype: set
"""
c_k = set()
vhosts = self.get_vhosts()
for vhost in vhosts:
tup = [None, None, vhost.filep]
if vhost.ssl:
for directive in vhost.raw:
if directive[0] == 'ssl_certificate':
tup[0] = directive[1]
elif directive[0] == 'ssl_certificate_key':
tup[1] = directive[1]
if tup[0] is not None and tup[1] is not None:
c_k.add(tuple(tup))
return c_k
def _do_for_subarray(entry, condition, func):
"""Executes a function for a subarray of a nested array if it matches
the given condition.
:param list entry: The list to iterate over
:param function condition: Returns true iff func should be executed on item
:param function func: The function to call for each matching item
"""
if isinstance(entry, list):
if condition(entry):
func(entry)
else:
for item in entry:
_do_for_subarray(item, condition, func)
def get_best_match(target_name, names):
"""Finds the best match for target_name out of names using the Nginx
name-matching rules (exact > longest wildcard starting with * >
longest wildcard ending with * > regex).
:param str target_name: The name to match
:param set names: The candidate server names
:returns: Tuple of (type of match, the name that matched)
:rtype: tuple
"""
exact = []
wildcard_start = []
wildcard_end = []
regex = []
for name in names:
if _exact_match(target_name, name):
exact.append(name)
elif _wildcard_match(target_name, name, True):
wildcard_start.append(name)
elif _wildcard_match(target_name, name, False):
wildcard_end.append(name)
elif _regex_match(target_name, name):
regex.append(name)
if len(exact) > 0:
# There can be more than one exact match; e.g. eff.org, .eff.org
match = min(exact, key=len)
return ('exact', match)
if len(wildcard_start) > 0:
# Return the longest wildcard
match = max(wildcard_start, key=len)
return ('wildcard_start', match)
if len(wildcard_end) > 0:
# Return the longest wildcard
match = max(wildcard_end, key=len)
return ('wildcard_end', match)
if len(regex) > 0:
# Just return the first one for now
match = regex[0]
return ('regex', match)
return (None, None)
def _exact_match(target_name, name):
return target_name == name or '.' + target_name == name
def _wildcard_match(target_name, name, start):
# Degenerate case
if name == '*':
return True
parts = target_name.split('.')
match_parts = name.split('.')
# If the domain ends in a wildcard, do the match procedure in reverse
if not start:
parts.reverse()
match_parts.reverse()
if len(match_parts) == 0:
return False
# The first part must be a wildcard or blank, e.g. '.eff.org'
first = match_parts.pop(0)
if first != '*' and first != '':
return False
target_name = '.'.join(parts)
name = '.'.join(match_parts)
# Ex: www.eff.org matches *.eff.org, eff.org does not match *.eff.org
return target_name.endswith('.' + name)
def _regex_match(target_name, name):
# Must start with a tilde
if len(name) < 2 or name[0] != '~':
return False
# After tilde is a perl-compatible regex
try:
regex = re.compile(name[1:])
if re.match(regex, target_name):
return True
else:
return False
except re.error:
# perl-compatible regexes are sometimes not recognized by python
return False
def _is_include_directive(entry):
"""Checks if an nginx parsed entry is an 'include' directive.
:param list entry: the parsed entry
:returns: Whether it's an 'include' directive
:rtype: bool
"""
return (isinstance(entry, list) and
entry[0] == 'include' and len(entry) == 2 and
isinstance(entry[1], str))
def _get_servernames(names):
"""Turns a server_name string into a list of server names
:param str names: server names
:rtype: list
"""
whitespace_re = re.compile(r'\s+')
names = re.sub(whitespace_re, ' ', names)
return names.split(' ')
def _parse_server(server):
"""Parses a list of server directives.
:param list server: list of directives in a server block
:rtype: dict
"""
parsed_server = {}
parsed_server['addrs'] = set()
parsed_server['ssl'] = False
parsed_server['names'] = set()
for directive in server:
if directive[0] == 'listen':
addr = obj.Addr.fromstring(directive[1])
parsed_server['addrs'].add(addr)
if not parsed_server['ssl'] and addr.ssl:
parsed_server['ssl'] = True
elif directive[0] == 'server_name':
parsed_server['names'].update(
_get_servernames(directive[1]))
return parsed_server
def _replace_directives(block, directives):
"""Replaces directives in a block. If the directive doesn't exist in
the entry already, raises a misconfiguration error.
..todo :: Find directives that are in included files.
:param list block: The block to replace in
:param list directives: The new directives.
"""
for directive in directives:
changed = False
if len(directive) == 0:
continue
for index, line in enumerate(block):
if len(line) > 0 and line[0] == directive[0]:
block[index] = directive
changed = True
if not changed:
raise errors.LetsEncryptMisconfigurationError(
'LetsEncrypt expected directive for %s in the Nginx config '
'but did not find it.' % directive[0])

View file

@ -0,0 +1 @@
"""Let's Encrypt Nginx Tests"""

View file

@ -0,0 +1,264 @@
"""Test for letsencrypt.client.plugins.nginx.configurator."""
import shutil
import unittest
import mock
from letsencrypt.acme import challenges
from letsencrypt.client import achallenges
from letsencrypt.client import errors
from letsencrypt.client import le_util
from letsencrypt.client.plugins.nginx.tests import util
class NginxConfiguratorTest(util.NginxTest):
"""Test a semi complex vhost configuration."""
def setUp(self):
super(NginxConfiguratorTest, self).setUp()
self.config = util.get_nginx_configurator(
self.config_path, self.config_dir, self.work_dir,
self.ssl_options)
def tearDown(self):
shutil.rmtree(self.temp_dir)
shutil.rmtree(self.config_dir)
shutil.rmtree(self.work_dir)
def test_prepare(self):
self.assertEquals((1, 6, 2), self.config.version)
self.assertEquals(5, len(self.config.parser.parsed))
def test_get_all_names(self):
names = self.config.get_all_names()
self.assertEqual(names, set(
["*.www.foo.com", "somename", "another.alias",
"alias", "localhost", ".example.com", r"~^(www\.)?(example|bar)\.",
"155.225.50.69.nephoscale.net", "*.www.example.com",
"example.*", "www.example.org", "myhost"]))
def test_supported_enhancements(self):
self.assertEqual([], self.config.supported_enhancements())
def test_enhance(self):
self.assertRaises(errors.LetsEncryptConfiguratorError,
self.config.enhance,
'myhost',
'redirect')
def test_get_chall_pref(self):
self.assertEqual([challenges.DVSNI],
self.config.get_chall_pref('myhost'))
def test_save(self):
filep = self.config.parser.abs_path('sites-enabled/example.com')
self.config.parser.add_server_directives(
filep, set(['.example.com', 'example.*']),
[['listen', '443 ssl']])
self.config.save()
# pylint: disable=protected-access
parsed = self.config.parser._parse_files(filep, override=True)
self.assertEqual([[['server'], [['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
['server_name', '.example.com'],
['server_name', 'example.*'],
['listen', '443 ssl']]]],
parsed[0])
def test_choose_vhost(self):
localhost_conf = set(['localhost', r'~^(www\.)?(example|bar)\.'])
server_conf = set(['somename', 'another.alias', 'alias'])
example_conf = set(['.example.com', 'example.*'])
foo_conf = set(['*.www.foo.com', '*.www.example.com'])
results = {'localhost': localhost_conf,
'alias': server_conf,
'example.com': example_conf,
'example.com.uk.test': example_conf,
'www.example.com': example_conf,
'test.www.example.com': foo_conf,
'abc.www.foo.com': foo_conf,
'www.bar.co.uk': localhost_conf}
bad_results = ['www.foo.com', 'example', 't.www.bar.co',
'69.255.225.155']
for name in results:
self.assertEqual(results[name],
self.config.choose_vhost(name).names)
for name in bad_results:
self.assertEqual(None, self.config.choose_vhost(name))
def test_more_info(self):
self.assertTrue('nginx.conf' in self.config.more_info())
def test_deploy_cert(self):
server_conf = self.config.parser.abs_path('server.conf')
nginx_conf = self.config.parser.abs_path('nginx.conf')
example_conf = self.config.parser.abs_path('sites-enabled/example.com')
# Get the default 443 vhost
self.config.deploy_cert(
"www.example.com",
"example/cert.pem", "example/key.pem")
self.config.deploy_cert(
"another.alias",
"/etc/nginx/cert.pem", "/etc/nginx/key.pem")
self.config.save()
self.config.parser.load()
self.assertEqual([[['server'],
[['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
['server_name', '.example.com'],
['server_name', 'example.*'],
['listen', '443 ssl'],
['ssl_certificate', 'example/cert.pem'],
['ssl_certificate_key', 'example/key.pem'],
['include',
self.config.parser.loc["ssl_options"]]]]],
self.config.parser.parsed[example_conf])
self.assertEqual([['server_name', 'somename alias another.alias']],
self.config.parser.parsed[server_conf])
self.assertEqual([['server'],
[['listen', '8000'],
['listen', 'somename:8080'],
['include', 'server.conf'],
[['location', '/'],
[['root', 'html'],
['index', 'index.html index.htm']]],
['listen', '443 ssl'],
['ssl_certificate', '/etc/nginx/cert.pem'],
['ssl_certificate_key', '/etc/nginx/key.pem'],
['include',
self.config.parser.loc["ssl_options"]]]],
self.config.parser.parsed[nginx_conf][-1][-1][-1])
def test_get_all_certs_keys(self):
nginx_conf = self.config.parser.abs_path('nginx.conf')
example_conf = self.config.parser.abs_path('sites-enabled/example.com')
# Get the default 443 vhost
self.config.deploy_cert(
"www.example.com",
"example/cert.pem", "example/key.pem")
self.config.deploy_cert(
"another.alias",
"/etc/nginx/cert.pem", "/etc/nginx/key.pem")
self.config.save()
self.config.parser.load()
self.assertEqual(set([
('example/cert.pem', 'example/key.pem', example_conf),
('/etc/nginx/cert.pem', '/etc/nginx/key.pem', nginx_conf),
]), self.config.get_all_certs_keys())
@mock.patch("letsencrypt.client.plugins.nginx.configurator."
"dvsni.NginxDvsni.perform")
@mock.patch("letsencrypt.client.plugins.nginx.configurator."
"NginxConfigurator.restart")
def test_perform(self, mock_restart, mock_dvsni_perform):
# Only tests functionality specific to configurator.perform
# Note: As more challenges are offered this will have to be expanded
auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem)
achall1 = achallenges.DVSNI(
chall=challenges.DVSNI(
r="foo",
nonce="bar"),
domain="localhost", key=auth_key)
achall2 = achallenges.DVSNI(
chall=challenges.DVSNI(
r="abc",
nonce="def"),
domain="example.com", key=auth_key)
dvsni_ret_val = [
challenges.DVSNIResponse(s="irrelevant"),
challenges.DVSNIResponse(s="arbitrary"),
]
mock_dvsni_perform.return_value = dvsni_ret_val
responses = self.config.perform([achall1, achall2])
self.assertEqual(mock_dvsni_perform.call_count, 1)
self.assertEqual(responses, dvsni_ret_val)
self.assertEqual(mock_restart.call_count, 1)
@mock.patch("letsencrypt.client.plugins.nginx.configurator."
"subprocess.Popen")
def test_get_version(self, mock_popen):
mock_popen().communicate.return_value = (
"", "\n".join(["nginx version: nginx/1.4.2",
"built by clang 6.0 (clang-600.0.56)"
" (based on LLVM 3.5svn)",
"TLS SNI support enabled",
"configure arguments: --prefix=/usr/local/Cellar/"
"nginx/1.6.2 --with-http_ssl_module"]))
self.assertEqual(self.config.get_version(), (1, 4, 2))
mock_popen().communicate.return_value = (
"", "\n".join(["nginx version: nginx/0.9",
"built by clang 6.0 (clang-600.0.56)"
" (based on LLVM 3.5svn)",
"TLS SNI support enabled",
"configure arguments: --with-http_ssl_module"]))
self.assertEqual(self.config.get_version(), (0, 9))
mock_popen().communicate.return_value = (
"", "\n".join(["blah 0.0.1",
"built by clang 6.0 (clang-600.0.56)"
" (based on LLVM 3.5svn)",
"TLS SNI support enabled",
"configure arguments: --with-http_ssl_module"]))
self.assertRaises(errors.LetsEncryptConfiguratorError,
self.config.get_version)
mock_popen().communicate.return_value = (
"", "\n".join(["nginx version: nginx/1.4.2",
"TLS SNI support enabled"]))
self.assertRaises(errors.LetsEncryptConfiguratorError,
self.config.get_version)
mock_popen().communicate.return_value = (
"", "\n".join(["nginx version: nginx/1.4.2",
"built by clang 6.0 (clang-600.0.56)"
" (based on LLVM 3.5svn)",
"configure arguments: --with-http_ssl_module"]))
self.assertRaises(errors.LetsEncryptConfiguratorError,
self.config.get_version)
mock_popen().communicate.return_value = (
"", "\n".join(["nginx version: nginx/0.8.1",
"built by clang 6.0 (clang-600.0.56)"
" (based on LLVM 3.5svn)",
"TLS SNI support enabled",
"configure arguments: --with-http_ssl_module"]))
self.assertRaises(errors.LetsEncryptConfiguratorError,
self.config.get_version)
mock_popen.side_effect = OSError("Can't find program")
self.assertRaises(
errors.LetsEncryptConfiguratorError, self.config.get_version)
@mock.patch("letsencrypt.client.plugins.nginx.configurator."
"subprocess.Popen")
def test_nginx_restart(self, mock_popen):
mocked = mock_popen()
mocked.communicate.return_value = ('', '')
mocked.returncode = 0
self.assertTrue(self.config.restart())
@mock.patch("letsencrypt.client.plugins.nginx.configurator."
"subprocess.Popen")
def test_config_test(self, mock_popen):
mocked = mock_popen()
mocked.communicate.return_value = ('', '')
mocked.returncode = 0
self.assertTrue(self.config.config_test())
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,85 @@
"""Test for letsencrypt.client.plugins.nginx.dvsni."""
import pkg_resources
import unittest
import shutil
import mock
from letsencrypt.acme import challenges
from letsencrypt.client import achallenges
from letsencrypt.client import le_util
from letsencrypt.client.plugins.nginx.tests import util
class DvsniPerformTest(util.NginxTest):
"""Test the NginxDVSNI challenge."""
def setUp(self):
super(DvsniPerformTest, self).setUp()
config = util.get_nginx_configurator(
self.config_path, self.config_dir, self.work_dir,
self.ssl_options)
rsa256_file = pkg_resources.resource_filename(
"letsencrypt.client.tests", "testdata/rsa256_key.pem")
rsa256_pem = pkg_resources.resource_string(
"letsencrypt.client.tests", "testdata/rsa256_key.pem")
auth_key = le_util.Key(rsa256_file, rsa256_pem)
from letsencrypt.client.plugins.nginx import dvsni
self.sni = dvsni.NginxDvsni(config)
self.achalls = [
achallenges.DVSNI(
chall=challenges.DVSNI(
r="foo",
nonce="bar",
), domain="www.example.com", key=auth_key),
achallenges.DVSNI(
chall=challenges.DVSNI(
r="\xba\xa9\xda?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y\x80"
"\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945",
nonce="Y\xed\x01L\xac\x95\xf7pW\xb1\xd7"
"\xa1\xb2\xc5\x96\xba",
), domain="blah", key=auth_key),
]
def tearDown(self):
shutil.rmtree(self.temp_dir)
shutil.rmtree(self.config_dir)
shutil.rmtree(self.work_dir)
def test_add_chall(self):
self.sni.add_chall(self.achalls[0], 0)
self.assertEqual(1, len(self.sni.achalls))
self.assertEqual([0], self.sni.indices)
@mock.patch("letsencrypt.client.plugins.nginx.configurator."
"NginxConfigurator.save")
def test_perform0(self, mock_save):
self.sni.add_chall(self.achalls[0])
responses = self.sni.perform()
self.assertEqual([], responses)
self.assertEqual(mock_save.call_count, 2)
def test_setup_challenge_cert(self):
# This is a helper function that can be used for handling
# open context managers more elegantly. It avoids dealing with
# __enter__ and __exit__ calls.
# http://www.voidspace.org.uk/python/mock/helpers.html#mock.mock_open
pass
@mock.patch("letsencrypt.client.plugins.nginx.configurator."
"NginxConfigurator.save")
def test_perform1(self, mock_save):
self.sni.add_chall(self.achalls[1])
responses = self.sni.perform()
self.assertEqual(None, responses)
self.assertEqual(mock_save.call_count, 1)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,108 @@
"""Test for letsencrypt.client.plugins.nginx.nginxparser."""
import operator
import unittest
from letsencrypt.client.plugins.nginx.nginxparser import (RawNginxParser,
load, dumps, dump)
from letsencrypt.client.plugins.nginx.tests import util
FIRST = operator.itemgetter(0)
class TestRawNginxParser(unittest.TestCase):
"""Test the raw low-level Nginx config parser."""
def test_assignments(self):
parsed = RawNginxParser.assignment.parseString('root /test;').asList()
self.assertEqual(parsed, ['root', '/test'])
parsed = RawNginxParser.assignment.parseString('root /test;'
'foo bar;').asList()
self.assertEqual(parsed, ['root', '/test'], ['foo', 'bar'])
def test_blocks(self):
parsed = RawNginxParser.block.parseString('foo {}').asList()
self.assertEqual(parsed, [[['foo'], []]])
parsed = RawNginxParser.block.parseString('location /foo{}').asList()
self.assertEqual(parsed, [[['location', '/foo'], []]])
parsed = RawNginxParser.block.parseString('foo { bar foo; }').asList()
self.assertEqual(parsed, [[['foo'], [['bar', 'foo']]]])
def test_nested_blocks(self):
parsed = RawNginxParser.block.parseString('foo { bar {} }').asList()
block, content = FIRST(parsed)
self.assertEqual(FIRST(content), [['bar'], []])
self.assertEqual(FIRST(block), 'foo')
def test_dump_as_string(self):
dumped = dumps([
['user', 'www-data'],
[['server'], [
['listen', '80'],
['server_name', 'foo.com'],
['root', '/home/ubuntu/sites/foo/'],
[['location', '/status'], [
['check_status'],
[['types'], [['image/jpeg', 'jpg']]],
]]
]]])
self.assertEqual(dumped,
'user www-data;\n'
'server {\n'
' listen 80;\n'
' server_name foo.com;\n'
' root /home/ubuntu/sites/foo/;\n \n'
' location /status {\n'
' check_status;\n \n'
' types {\n'
' image/jpeg jpg;\n'
' }\n'
' }\n'
'}')
def test_parse_from_file(self):
parsed = load(open(util.get_data_filename('foo.conf')))
self.assertEqual(
parsed,
[['user', 'www-data'],
[['http'],
[[['server'], [
['listen', '*:80 default_server ssl'],
['server_name', '*.www.foo.com *.www.example.com'],
['root', '/home/ubuntu/sites/foo/'],
[['location', '/status'], [
[['types'], [['image/jpeg', 'jpg']]],
]],
[['location', '~', r'case_sensitive\.php$'], [
['index', 'index.php'],
['root', '/var/root'],
]],
[['location', '~*', r'case_insensitive\.php$'], []],
[['location', '=', r'exact_match\.php$'], []],
[['location', '^~', r'ignore_regex\.php$'], []]
]]]]]
)
def test_dump_as_file(self):
parsed = load(open(util.get_data_filename('nginx.conf')))
parsed[-1][-1].append([['server'],
[['listen', '443 ssl'],
['server_name', 'localhost'],
['ssl_certificate', 'cert.pem'],
['ssl_certificate_key', 'cert.key'],
['ssl_session_cache', 'shared:SSL:1m'],
['ssl_session_timeout', '5m'],
['ssl_ciphers', 'HIGH:!aNULL:!MD5'],
[['location', '/'],
[['root', 'html'],
['index', 'index.html index.htm']]]]])
_file = open(util.get_data_filename('nginx.new.conf'), 'w')
dump(parsed, _file)
_file.close()
parsed_new = load(open(util.get_data_filename('nginx.new.conf')))
self.assertEquals(parsed, parsed_new)
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,105 @@
"""Test the helper objects in letsencrypt.client.plugins.nginx.obj."""
import unittest
class AddrTest(unittest.TestCase):
"""Test the Addr class."""
def setUp(self):
from letsencrypt.client.plugins.nginx.obj import Addr
self.addr1 = Addr.fromstring("192.168.1.1")
self.addr2 = Addr.fromstring("192.168.1.1:* ssl")
self.addr3 = Addr.fromstring("192.168.1.1:80")
self.addr4 = Addr.fromstring("*:80 default_server ssl")
self.addr5 = Addr.fromstring("myhost")
self.addr6 = Addr.fromstring("80 default_server spdy")
self.addr7 = Addr.fromstring("unix:/var/run/nginx.sock")
def test_fromstring(self):
self.assertEqual(self.addr1.get_addr(), "192.168.1.1")
self.assertEqual(self.addr1.get_port(), "")
self.assertFalse(self.addr1.ssl)
self.assertFalse(self.addr1.default)
self.assertEqual(self.addr2.get_addr(), "192.168.1.1")
self.assertEqual(self.addr2.get_port(), "*")
self.assertTrue(self.addr2.ssl)
self.assertFalse(self.addr2.default)
self.assertEqual(self.addr3.get_addr(), "192.168.1.1")
self.assertEqual(self.addr3.get_port(), "80")
self.assertFalse(self.addr3.ssl)
self.assertFalse(self.addr3.default)
self.assertEqual(self.addr4.get_addr(), "*")
self.assertEqual(self.addr4.get_port(), "80")
self.assertTrue(self.addr4.ssl)
self.assertTrue(self.addr4.default)
self.assertEqual(self.addr5.get_addr(), "myhost")
self.assertEqual(self.addr5.get_port(), "")
self.assertFalse(self.addr5.ssl)
self.assertFalse(self.addr5.default)
self.assertEqual(self.addr6.get_addr(), "")
self.assertEqual(self.addr6.get_port(), "80")
self.assertFalse(self.addr6.ssl)
self.assertTrue(self.addr6.default)
self.assertEqual(None, self.addr7)
def test_str(self):
self.assertEqual(str(self.addr1), "192.168.1.1")
self.assertEqual(str(self.addr2), "192.168.1.1:*")
self.assertEqual(str(self.addr3), "192.168.1.1:80")
self.assertEqual(str(self.addr4), "*:80")
self.assertEqual(str(self.addr5), "myhost")
self.assertEqual(str(self.addr6), "80")
def test_eq(self):
from letsencrypt.client.plugins.nginx.obj import Addr
new_addr1 = Addr.fromstring("192.168.1.1 spdy")
self.assertEqual(self.addr1, new_addr1)
self.assertNotEqual(self.addr1, self.addr2)
self.assertFalse(self.addr1 == 3333)
def test_set_inclusion(self):
from letsencrypt.client.plugins.nginx.obj import Addr
set_a = set([self.addr1, self.addr2])
addr1b = Addr.fromstring("192.168.1.1")
addr2b = Addr.fromstring("192.168.1.1:* ssl")
set_b = set([addr1b, addr2b])
self.assertEqual(set_a, set_b)
class VirtualHostTest(unittest.TestCase):
"""Test the VirtualHost class."""
def setUp(self):
from letsencrypt.client.plugins.nginx.obj import VirtualHost
from letsencrypt.client.plugins.nginx.obj import Addr
self.vhost1 = VirtualHost(
"filep",
set([Addr.fromstring("localhost")]), False, False,
set(['localhost']), [])
def test_eq(self):
from letsencrypt.client.plugins.nginx.obj import Addr
from letsencrypt.client.plugins.nginx.obj import VirtualHost
vhost1b = VirtualHost(
"filep",
set([Addr.fromstring("localhost blah")]), False, False,
set(['localhost']), [])
self.assertEqual(vhost1b, self.vhost1)
self.assertEqual(str(vhost1b), str(self.vhost1))
self.assertFalse(vhost1b == 1234)
def test_str(self):
stringified = '\n'.join(['file: filep', 'addrs: localhost',
"names: set(['localhost'])", 'ssl: False',
'enabled: False'])
self.assertEqual(stringified, str(self.vhost1))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,206 @@
"""Tests for letsencrypt.client.plugins.nginx.parser."""
import glob
import os
import re
import shutil
import unittest
from letsencrypt.client.errors import LetsEncryptMisconfigurationError
from letsencrypt.client.plugins.nginx import nginxparser
from letsencrypt.client.plugins.nginx import obj
from letsencrypt.client.plugins.nginx import parser
from letsencrypt.client.plugins.nginx.tests import util
class NginxParserTest(util.NginxTest):
"""Nginx Parser Test."""
def setUp(self):
super(NginxParserTest, self).setUp()
def tearDown(self):
shutil.rmtree(self.temp_dir)
shutil.rmtree(self.config_dir)
shutil.rmtree(self.work_dir)
def test_root_normalized(self):
path = os.path.join(self.temp_dir, "foo/////"
"bar/../../testdata")
nparser = parser.NginxParser(path, None)
self.assertEqual(nparser.root, self.config_path)
def test_root_absolute(self):
nparser = parser.NginxParser(os.path.relpath(self.config_path), None)
self.assertEqual(nparser.root, self.config_path)
def test_root_no_trailing_slash(self):
nparser = parser.NginxParser(self.config_path + os.path.sep, None)
self.assertEqual(nparser.root, self.config_path)
def test_load(self):
"""Test recursive conf file parsing.
"""
nparser = parser.NginxParser(self.config_path, self.ssl_options)
nparser.load()
self.assertEqual(set([nparser.abs_path(x) for x in
['foo.conf', 'nginx.conf', 'server.conf',
'sites-enabled/default',
'sites-enabled/example.com']]),
set(nparser.parsed.keys()))
self.assertEqual([['server_name', 'somename alias another.alias']],
nparser.parsed[nparser.abs_path('server.conf')])
self.assertEqual([[['server'], [['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
['server_name', '.example.com'],
['server_name', 'example.*']]]],
nparser.parsed[nparser.abs_path(
'sites-enabled/example.com')])
def test_abs_path(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
self.assertEqual('/etc/nginx/*', nparser.abs_path('/etc/nginx/*'))
self.assertEqual(os.path.join(self.config_path, 'foo/bar/'),
nparser.abs_path('foo/bar/'))
def test_filedump(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
nparser.filedump('test')
# pylint: disable=protected-access
parsed = nparser._parse_files(nparser.abs_path(
'sites-enabled/example.com.test'))
self.assertEqual(3, len(glob.glob(nparser.abs_path('*.test'))))
self.assertEqual(2, len(
glob.glob(nparser.abs_path('sites-enabled/*.test'))))
self.assertEqual([[['server'], [['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
['server_name', '.example.com'],
['server_name', 'example.*']]]],
parsed[0])
def test_get_vhosts(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
vhosts = nparser.get_vhosts()
vhost1 = obj.VirtualHost(nparser.abs_path('nginx.conf'),
[obj.Addr('', '8080', False, False)],
False, True,
set(['localhost',
r'~^(www\.)?(example|bar)\.']),
[])
vhost2 = obj.VirtualHost(nparser.abs_path('nginx.conf'),
[obj.Addr('somename', '8080', False, False),
obj.Addr('', '8000', False, False)],
False, True,
set(['somename', 'another.alias', 'alias']),
[])
vhost3 = obj.VirtualHost(nparser.abs_path('sites-enabled/example.com'),
[obj.Addr('69.50.225.155', '9000',
False, False),
obj.Addr('127.0.0.1', '', False, False)],
False, True,
set(['.example.com', 'example.*']), [])
vhost4 = obj.VirtualHost(nparser.abs_path('sites-enabled/default'),
[obj.Addr('myhost', '', False, True)],
False, True, set(['www.example.org']), [])
vhost5 = obj.VirtualHost(nparser.abs_path('foo.conf'),
[obj.Addr('*', '80', True, True)],
True, True, set(['*.www.foo.com',
'*.www.example.com']), [])
self.assertEqual(5, len(vhosts))
example_com = [x for x in vhosts if 'example.com' in x.filep][0]
self.assertEqual(vhost3, example_com)
default = [x for x in vhosts if 'default' in x.filep][0]
self.assertEqual(vhost4, default)
fooconf = [x for x in vhosts if 'foo.conf' in x.filep][0]
self.assertEqual(vhost5, fooconf)
localhost = [x for x in vhosts if 'localhost' in x.names][0]
self.assertEquals(vhost1, localhost)
somename = [x for x in vhosts if 'somename' in x.names][0]
self.assertEquals(vhost2, somename)
def test_add_server_directives(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
nparser.add_server_directives(nparser.abs_path('nginx.conf'),
set(['localhost',
r'~^(www\.)?(example|bar)\.']),
[['foo', 'bar'], ['ssl_certificate',
'/etc/ssl/cert.pem']])
ssl_re = re.compile(r'foo bar;\n\s+ssl_certificate /etc/ssl/cert.pem')
self.assertEqual(1, len(re.findall(ssl_re, nginxparser.dumps(
nparser.parsed[nparser.abs_path('nginx.conf')]))))
nparser.add_server_directives(nparser.abs_path('server.conf'),
set(['alias', 'another.alias',
'somename']),
[['foo', 'bar'], ['ssl_certificate',
'/etc/ssl/cert2.pem']])
self.assertEqual(nparser.parsed[nparser.abs_path('server.conf')],
[['server_name', 'somename alias another.alias'],
['foo', 'bar'],
['ssl_certificate', '/etc/ssl/cert2.pem']])
def test_replace_server_directives(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
target = set(['.example.com', 'example.*'])
filep = nparser.abs_path('sites-enabled/example.com')
nparser.add_server_directives(
filep, target, [['server_name', 'foo bar']], True)
self.assertEqual(
nparser.parsed[filep],
[[['server'], [['listen', '69.50.225.155:9000'],
['listen', '127.0.0.1'],
['server_name', 'foo bar'],
['server_name', 'foo bar']]]])
self.assertRaises(LetsEncryptMisconfigurationError,
nparser.add_server_directives,
filep, set(['foo', 'bar']),
[['ssl_certificate', 'cert.pem']], True)
def test_get_best_match(self):
target_name = 'www.eff.org'
names = [set(['www.eff.org', 'irrelevant.long.name.eff.org', '*.org']),
set(['eff.org', 'ww2.eff.org', 'test.www.eff.org']),
set(['*.eff.org', '.www.eff.org']),
set(['.eff.org', '*.org']),
set(['www.eff.', 'www.eff.*', '*.www.eff.org']),
set(['example.com', r'~^(www\.)?(eff.+)', '*.eff.*']),
set(['*', r'~^(www\.)?(eff.+)']),
set(['www.*', r'~^(www\.)?(eff.+)', '.test.eff.org']),
set(['*.org', r'*.eff.org', 'www.eff.*']),
set(['*.www.eff.org', 'www.*']),
set(['*.org']),
set([]),
set(['example.com'])]
winners = [('exact', 'www.eff.org'),
(None, None),
('exact', '.www.eff.org'),
('wildcard_start', '.eff.org'),
('wildcard_end', 'www.eff.*'),
('regex', r'~^(www\.)?(eff.+)'),
('wildcard_start', '*'),
('wildcard_end', 'www.*'),
('wildcard_start', '*.eff.org'),
('wildcard_end', 'www.*'),
('wildcard_start', '*.org'),
(None, None),
(None, None)]
for i, winner in enumerate(winners):
self.assertEqual(winner,
parser.get_best_match(target_name, names[i]))
def test_get_all_certs_keys(self):
nparser = parser.NginxParser(self.config_path, self.ssl_options)
filep = nparser.abs_path('sites-enabled/example.com')
nparser.add_server_directives(filep,
set(['.example.com', 'example.*']),
[['ssl_certificate', 'foo.pem'],
['ssl_certificate_key', 'bar.key'],
['listen', '443 ssl']])
c_k = nparser.get_all_certs_keys()
self.assertEqual(set([('foo.pem', 'bar.key', filep)]), c_k)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,25 @@
# a test nginx conf
user www-data;
http {
server {
listen *:80 default_server ssl;
server_name *.www.foo.com *.www.example.com;
root /home/ubuntu/sites/foo/;
location /status {
types {
image/jpeg jpg;
}
}
location ~ case_sensitive\.php$ {
index index.php;
root /var/root;
}
location ~* case_insensitive\.php$ {}
location = exact_match\.php$ {}
location ^~ ignore_regex\.php$ {}
}
}

View file

@ -0,0 +1,119 @@
# standard default nginx config
user nobody;
worker_processes 1;
error_log logs/error.log;
error_log logs/error.log notice;
error_log logs/error.log info;
pid logs/nginx.pid;
events {
worker_connections 1024;
}
include foo.conf;
http {
include mime.types;
include sites-enabled/*;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log logs/access.log main;
sendfile on;
tcp_nopush on;
keepalive_timeout 0;
gzip on;
server {
listen 8080;
server_name localhost;
server_name ~^(www\.)?(example|bar)\.;
charset koi8-r;
access_log logs/host.access.log main;
location / {
root html;
index index.html index.htm;
}
error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
# proxy the PHP scripts to Nginx listening on 127.0.0.1:80
#
location ~ \.php$ {
proxy_pass http://127.0.0.1;
}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
location ~ \.php$ {
root html;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
}
# deny access to .htaccess files, if Nginx's document root
# concurs with nginx's one
#
location ~ /\.ht {
deny all;
}
}
# another virtual host using mix of IP-, name-, and port-based configuration
#
server {
listen 8000;
listen somename:8080;
include server.conf;
location / {
root html;
index index.html index.htm;
}
}
# HTTPS server
#
#server {
# listen 443 ssl;
# server_name localhost;
# ssl_certificate cert.pem;
# ssl_certificate_key cert.key;
# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 5m;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
# location / {
# root html;
# index index.html index.htm;
# }
#}
#include conf.d/test.conf;
}

View file

@ -0,0 +1,83 @@
user nobody;
worker_processes 1;
error_log logs/error.log;
error_log logs/error.log notice;
error_log logs/error.log info;
pid logs/nginx.pid;
events {
worker_connections 1024;
}
include foo.conf;
http {
include mime.types;
include sites-enabled/*;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log logs/access.log main;
sendfile on;
tcp_nopush on;
keepalive_timeout 0;
gzip on;
server {
listen 8080;
server_name localhost;
server_name ~^(www\.)?(example|bar)\.;
charset koi8-r;
access_log logs/host.access.log main;
location / {
root html;
index index.html index.htm;
}
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
location ~ \.php$ {
proxy_pass http://127.0.0.1;
}
location ~ \.php$ {
root html;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
}
location ~ /\.ht {
deny all;
}
}
server {
listen 8000;
listen somename:8080;
include server.conf;
location / {
root html;
index index.html index.htm;
}
}
server {
listen 443 ssl;
server_name localhost;
ssl_certificate cert.pem;
ssl_certificate_key cert.key;
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
root html;
index index.html index.htm;
}
}
}

View file

@ -0,0 +1 @@
server_name somename alias another.alias;

View file

@ -0,0 +1,9 @@
server {
listen myhost default_server;
server_name www.example.org;
location / {
root html;
index index.html index.htm;
}
}

View file

@ -0,0 +1,6 @@
server {
listen 69.50.225.155:9000;
listen 127.0.0.1;
server_name .example.com;
server_name example.*;
}

View file

@ -0,0 +1,25 @@
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_FILENAME $request_filename;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_URI $document_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
fastcgi_param SERVER_ADDR $server_addr;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param SERVER_NAME $server_name;
fastcgi_param HTTPS $https if_not_empty;
# PHP only, required if PHP was built with --enable-force-cgi-redirect
fastcgi_param REDIRECT_STATUS 200;

View file

@ -0,0 +1,108 @@
# This map is not a full koi8-r <> utf8 map: it does not contain
# box-drawing and some other characters. Besides this map contains
# several koi8-u and Byelorussian letters which are not in koi8-r.
# If you need a full and standard map, use contrib/unicode2nginx/koi-utf
# map instead.
charset_map koi8-r utf-8 {
80 E282AC; # euro
95 E280A2; # bullet
9A C2A0; # &nbsp;
9E C2B7; # &middot;
A3 D191; # small yo
A4 D194; # small Ukrainian ye
A6 D196; # small Ukrainian i
A7 D197; # small Ukrainian yi
AD D291; # small Ukrainian soft g
AE D19E; # small Byelorussian short u
B0 C2B0; # &deg;
B3 D081; # capital YO
B4 D084; # capital Ukrainian YE
B6 D086; # capital Ukrainian I
B7 D087; # capital Ukrainian YI
B9 E28496; # numero sign
BD D290; # capital Ukrainian soft G
BE D18E; # capital Byelorussian short U
BF C2A9; # (C)
C0 D18E; # small yu
C1 D0B0; # small a
C2 D0B1; # small b
C3 D186; # small ts
C4 D0B4; # small d
C5 D0B5; # small ye
C6 D184; # small f
C7 D0B3; # small g
C8 D185; # small kh
C9 D0B8; # small i
CA D0B9; # small j
CB D0BA; # small k
CC D0BB; # small l
CD D0BC; # small m
CE D0BD; # small n
CF D0BE; # small o
D0 D0BF; # small p
D1 D18F; # small ya
D2 D180; # small r
D3 D181; # small s
D4 D182; # small t
D5 D183; # small u
D6 D0B6; # small zh
D7 D0B2; # small v
D8 D18C; # small soft sign
D9 D18B; # small y
DA D0B7; # small z
DB D188; # small sh
DC D18D; # small e
DD D189; # small shch
DE D187; # small ch
DF D18A; # small hard sign
E0 D0AE; # capital YU
E1 D090; # capital A
E2 D091; # capital B
E3 D0A6; # capital TS
E4 D094; # capital D
E5 D095; # capital YE
E6 D0A4; # capital F
E7 D093; # capital G
E8 D0A5; # capital KH
E9 D098; # capital I
EA D099; # capital J
EB D09A; # capital K
EC D09B; # capital L
ED D09C; # capital M
EE D09D; # capital N
EF D09E; # capital O
F0 D09F; # capital P
F1 D0AF; # capital YA
F2 D0A0; # capital R
F3 D0A1; # capital S
F4 D0A2; # capital T
F5 D0A3; # capital U
F6 D096; # capital ZH
F7 D092; # capital V
F8 D0AC; # capital soft sign
F9 D0AB; # capital Y
FA D097; # capital Z
FB D0A8; # capital SH
FC D0AD; # capital E
FD D0A9; # capital SHCH
FE D0A7; # capital CH
FF D0AA; # capital hard sign
}

View file

@ -0,0 +1,102 @@
charset_map koi8-r windows-1251 {
80 88; # euro
95 95; # bullet
9A A0; # &nbsp;
9E B7; # &middot;
A3 B8; # small yo
A4 BA; # small Ukrainian ye
A6 B3; # small Ukrainian i
A7 BF; # small Ukrainian yi
AD B4; # small Ukrainian soft g
AE A2; # small Byelorussian short u
B0 B0; # &deg;
B3 A8; # capital YO
B4 AA; # capital Ukrainian YE
B6 B2; # capital Ukrainian I
B7 AF; # capital Ukrainian YI
B9 B9; # numero sign
BD A5; # capital Ukrainian soft G
BE A1; # capital Byelorussian short U
BF A9; # (C)
C0 FE; # small yu
C1 E0; # small a
C2 E1; # small b
C3 F6; # small ts
C4 E4; # small d
C5 E5; # small ye
C6 F4; # small f
C7 E3; # small g
C8 F5; # small kh
C9 E8; # small i
CA E9; # small j
CB EA; # small k
CC EB; # small l
CD EC; # small m
CE ED; # small n
CF EE; # small o
D0 EF; # small p
D1 FF; # small ya
D2 F0; # small r
D3 F1; # small s
D4 F2; # small t
D5 F3; # small u
D6 E6; # small zh
D7 E2; # small v
D8 FC; # small soft sign
D9 FB; # small y
DA E7; # small z
DB F8; # small sh
DC FD; # small e
DD F9; # small shch
DE F7; # small ch
DF FA; # small hard sign
E0 DE; # capital YU
E1 C0; # capital A
E2 C1; # capital B
E3 D6; # capital TS
E4 C4; # capital D
E5 C5; # capital YE
E6 D4; # capital F
E7 C3; # capital G
E8 D5; # capital KH
E9 C8; # capital I
EA C9; # capital J
EB CA; # capital K
EC CB; # capital L
ED CC; # capital M
EE CD; # capital N
EF CE; # capital O
F0 CF; # capital P
F1 DF; # capital YA
F2 D0; # capital R
F3 D1; # capital S
F4 D2; # capital T
F5 D3; # capital U
F6 C6; # capital ZH
F7 C2; # capital V
F8 DC; # capital soft sign
F9 DB; # capital Y
FA C7; # capital Z
FB D8; # capital SH
FC DD; # capital E
FD D9; # capital SHCH
FE D7; # capital CH
FF DA; # capital hard sign
}

View file

@ -0,0 +1,79 @@
types {
text/html html htm shtml;
text/css css;
text/xml xml rss;
image/gif gif;
image/jpeg jpeg jpg;
application/x-javascript js;
application/atom+xml atom;
text/mathml mml;
text/plain txt;
text/vnd.sun.j2me.app-descriptor jad;
text/vnd.wap.wml wml;
text/x-component htc;
image/png png;
image/tiff tif tiff;
image/vnd.wap.wbmp wbmp;
image/x-icon ico;
image/x-jng jng;
image/x-ms-bmp bmp;
image/svg+xml svg svgz;
application/java-archive jar war ear;
application/json json;
application/mac-binhex40 hqx;
application/msword doc;
application/pdf pdf;
application/postscript ps eps ai;
application/rtf rtf;
application/vnd.ms-excel xls;
application/vnd.ms-powerpoint ppt;
application/vnd.wap.wmlc wmlc;
application/vnd.google-earth.kml+xml kml;
application/vnd.google-earth.kmz kmz;
application/x-7z-compressed 7z;
application/x-cocoa cco;
application/x-java-archive-diff jardiff;
application/x-java-jnlp-file jnlp;
application/x-makeself run;
application/x-perl pl pm;
application/x-pilot prc pdb;
application/x-rar-compressed rar;
application/x-redhat-package-manager rpm;
application/x-sea sea;
application/x-shockwave-flash swf;
application/x-stuffit sit;
application/x-tcl tcl tk;
application/x-x509-ca-cert der pem crt;
application/x-xpinstall xpi;
application/xhtml+xml xhtml;
application/zip zip;
application/octet-stream bin exe dll;
application/octet-stream deb;
application/octet-stream dmg;
application/octet-stream eot;
application/octet-stream iso img;
application/octet-stream msi msp msm;
application/ogg ogx;
audio/midi mid midi kar;
audio/mpeg mpga mpega mp2 mp3 m4a;
audio/ogg oga ogg spx;
audio/x-realaudio ra;
audio/webm weba;
video/3gpp 3gpp 3gp;
video/mp4 mp4;
video/mpeg mpeg mpg mpe;
video/ogg ogv;
video/quicktime mov;
video/webm webm;
video/x-flv flv;
video/x-mng mng;
video/x-ms-asf asx asf;
video/x-ms-wmv wmv;
video/x-msvideo avi;
}

View file

@ -0,0 +1,16 @@
[nx_extract]
username = naxsi_web
password = test
port = 8081
rules_path = /etc/nginx/naxsi_core.rules
[nx_intercept]
port = 8080
[sql]
dbtype = sqlite
username = root
password =
hostname = 127.0.0.1
dbname = naxsi_sig

View file

@ -0,0 +1,13 @@
# Sample rules file for default vhost.
LearningMode;
SecRulesEnabled;
#SecRulesDisabled;
DeniedUrl "/RequestDenied";
## check rules
CheckRule "$SQL >= 8" BLOCK;
CheckRule "$RFI >= 8" BLOCK;
CheckRule "$TRAVERSAL >= 4" BLOCK;
CheckRule "$EVADE >= 4" BLOCK;
CheckRule "$XSS >= 8" BLOCK;

View file

@ -0,0 +1,75 @@
##################################
## INTERNAL RULES IDS:1-10 ##
##################################
#weird_request : 1
#big_body : 2
#no_content_type : 3
#MainRule "str:yesone" "msg:foobar test pattern" "mz:ARGS" "s:$SQL:42" id:1999;
##################################
## SQL Injections IDs:1000-1099 ##
##################################
MainRule "rx:select|union|update|delete|insert|table|from|ascii|hex|unhex" "msg:sql keywords" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1000;
MainRule "str:\"" "msg:double quote" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1001;
MainRule "str:0x" "msg:0x, possible hex encoding" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:2" id:1002;
## Hardcore rules
MainRule "str:/*" "msg:mysql comment (/*)" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:8" id:1003;
MainRule "str:*/" "msg:mysql comment (*/)" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:8" id:1004;
MainRule "str:|" "msg:mysql keyword (|)" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:8" id:1005;
MainRule "rx:&&" "msg:mysql keyword (&&)" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:8" id:1006;
## end of hardcore rules
MainRule "str:--" "msg:mysql comment (--)" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1007;
MainRule "str:;" "msg:; in stuff" "mz:BODY|URL|ARGS" "s:$SQL:4" id:1008;
MainRule "str:=" "msg:equal in var, probable sql/xss" "mz:ARGS|BODY" "s:$SQL:2" id:1009;
MainRule "str:(" "msg:parenthesis, probable sql/xss" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1010;
MainRule "str:)" "msg:parenthesis, probable sql/xss" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1011;
MainRule "str:'" "msg:simple quote" "mz:ARGS|BODY|URL|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1013;
MainRule "str:\"" "msg:double quote" "mz:ARGS|BODY|URL|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1014;
MainRule "str:," "msg:, in stuff" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1015;
MainRule "str:#" "msg:mysql comment (#)" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$SQL:4" id:1016;
###############################
## OBVIOUS RFI IDs:1100-1199 ##
###############################
MainRule "str:http://" "msg:html comment tag" "mz:ARGS|BODY|$HEADERS_VAR:Cookie" "s:$RFI:8" id:1100;
MainRule "str:https://" "msg:html comment tag" "mz:ARGS|BODY|$HEADERS_VAR:Cookie" "s:$RFI:8" id:1101;
MainRule "str:ftp://" "msg:html comment tag" "mz:ARGS|BODY|$HEADERS_VAR:Cookie" "s:$RFI:8" id:1102;
MainRule "str:php://" "msg:html comment tag" "mz:ARGS|BODY|$HEADERS_VAR:Cookie" "s:$RFI:8" id:1103;
#######################################
## Directory traversal IDs:1200-1299 ##
#######################################
MainRule "str:.." "msg:html comment tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$TRAVERSAL:4" id:1200;
MainRule "str:/etc/passwd" "msg:html comment tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$TRAVERSAL:4" id:1202;
MainRule "str:c:\\" "msg:html comment tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$TRAVERSAL:4" id:1203;
MainRule "str:cmd.exe" "msg:html comment tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$TRAVERSAL:4" id:1204;
MainRule "str:\\" "msg:html comment tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$TRAVERSAL:4" id:1205;
#MainRule "str:/" "msg:slash in args" "mz:ARGS|BODY|$HEADERS_VAR:Cookie" "s:$TRAVERSAL:2" id:1206;
########################################
## Cross Site Scripting IDs:1300-1399 ##
########################################
MainRule "str:<" "msg:html open tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1302;
MainRule "str:>" "msg:html close tag" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1303;
MainRule "str:'" "msg:simple quote" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1306;
MainRule "str:\"" "msg:double quote" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1307;
MainRule "str:(" "msg:parenthesis" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1308;
MainRule "str:)" "msg:parenthesis" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1309;
MainRule "str:[" "msg:html close comment tag" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$XSS:4" id:1310;
MainRule "str:]" "msg:html close comment tag" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$XSS:4" id:1311;
MainRule "str:~" "msg:html close comment tag" "mz:BODY|URL|ARGS|$HEADERS_VAR:Cookie" "s:$XSS:4" id:1312;
MainRule "str:;" "msg:semi coma" "mz:ARGS|URL|BODY" "s:$XSS:8" id:1313;
MainRule "str:`" "msg:grave accent !" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1314;
MainRule "rx:%[2|3]." "msg:double encoding !" "mz:ARGS|URL|BODY|$HEADERS_VAR:Cookie" "s:$XSS:8" id:1315;
####################################
## Evading tricks IDs: 1400-1500 ##
####################################
MainRule "str:&#" "msg: utf7/8 encoding" "mz:ARGS|BODY|URL|$HEADERS_VAR:Cookie" "s:$EVADE:4" id:1400;
MainRule "str:%U" "msg: M$ encoding" "mz:ARGS|BODY|URL|$HEADERS_VAR:Cookie" "s:$EVADE:4" id:1401;
MainRule negative "rx:multipart/form-data|application/x-www-form-urlencoded" "msg:Content is neither mulipart/x-www-form.." "mz:$HEADERS_VAR:Content-type" "s:$EVADE:4" id:1402;
#############################
## File uploads: 1500-1600 ##
#############################
MainRule "rx:.ph*|.asp*" "msg:asp/php file upload!" "mz:FILE_EXT" "s:$UPLOAD:8" id:1500;

View file

@ -0,0 +1,95 @@
user www-data;
worker_processes 4;
pid /run/nginx.pid;
events {
worker_connections 768;
# multi_accept on;
}
http {
##
# Basic Settings
##
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# server_tokens off;
# server_names_hash_bucket_size 64;
# server_name_in_redirect off;
include /etc/nginx/mime.types;
default_type application/octet-stream;
##
# Logging Settings
##
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
##
# Gzip Settings
##
gzip on;
gzip_disable "msie6";
# gzip_vary on;
# gzip_proxied any;
# gzip_comp_level 6;
# gzip_buffers 16 8k;
# gzip_http_version 1.1;
# gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
##
# nginx-naxsi config
##
# Uncomment it if you installed nginx-naxsi
##
#include /etc/nginx/naxsi_core.rules;
##
# nginx-passenger config
##
# Uncomment it if you installed nginx-passenger
##
#passenger_root /usr;
#passenger_ruby /usr/bin/ruby;
##
# Virtual Host Configs
##
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
#mail {
# # See sample authentication script at:
# # http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript
#
# # auth_http localhost/auth.php;
# # pop3_capabilities "TOP" "USER";
# # imap_capabilities "IMAP4rev1" "UIDPLUS";
#
# server {
# listen localhost:110;
# protocol pop3;
# proxy on;
# }
#
# server {
# listen localhost:143;
# protocol imap;
# proxy on;
# }
#}

View file

@ -0,0 +1,4 @@
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

View file

@ -0,0 +1,14 @@
scgi_param REQUEST_METHOD $request_method;
scgi_param REQUEST_URI $request_uri;
scgi_param QUERY_STRING $query_string;
scgi_param CONTENT_TYPE $content_type;
scgi_param DOCUMENT_URI $document_uri;
scgi_param DOCUMENT_ROOT $document_root;
scgi_param SCGI 1;
scgi_param SERVER_PROTOCOL $server_protocol;
scgi_param REMOTE_ADDR $remote_addr;
scgi_param REMOTE_PORT $remote_port;
scgi_param SERVER_PORT $server_port;
scgi_param SERVER_NAME $server_name;

View file

@ -0,0 +1,112 @@
# You may add here your
# server {
# ...
# }
# statements for each of your virtual hosts to this file
##
# You should look at the following URL's in order to grasp a solid understanding
# of Nginx configuration files in order to fully unleash the power of Nginx.
# http://wiki.nginx.org/Pitfalls
# http://wiki.nginx.org/QuickStart
# http://wiki.nginx.org/Configuration
#
# Generally, you will want to move this file somewhere, and start with a clean
# file but keep this around for reference. Or just disable in sites-enabled.
#
# Please see /usr/share/doc/nginx-doc/examples/ for more detailed examples.
##
server {
listen 80 default_server;
listen [::]:80 default_server ipv6only=on;
root /usr/share/nginx/html;
index index.html index.htm;
# Make site accessible from http://localhost/
server_name localhost;
location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
# Uncomment to enable naxsi on this location
# include /etc/nginx/naxsi.rules
}
# Only for nginx-naxsi used with nginx-naxsi-ui : process denied requests
#location /RequestDenied {
# proxy_pass http://127.0.0.1:8080;
#}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
#error_page 500 502 503 504 /50x.html;
#location = /50x.html {
# root /usr/share/nginx/html;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# fastcgi_split_path_info ^(.+\.php)(/.+)$;
# # NOTE: You should have "cgi.fix_pathinfo = 0;" in php.ini
#
# # With php5-cgi alone:
# fastcgi_pass 127.0.0.1:9000;
# # With php5-fpm:
# fastcgi_pass unix:/var/run/php5-fpm.sock;
# fastcgi_index index.php;
# include fastcgi_params;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
# another virtual host using mix of IP-, name-, and port-based configuration
#
#server {
# listen 8000;
# listen somename:8080;
# server_name somename alias another.alias;
# root html;
# index index.html index.htm;
#
# location / {
# try_files $uri $uri/ =404;
# }
#}
# HTTPS server
#
#server {
# listen 443;
# server_name localhost;
#
# root html;
# index index.html index.htm;
#
# ssl on;
# ssl_certificate cert.pem;
# ssl_certificate_key cert.key;
#
# ssl_session_timeout 5m;
#
# ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2;
# ssl_ciphers "HIGH:!aNULL:!MD5 or HIGH:!aNULL:!MD5:!3DES";
# ssl_prefer_server_ciphers on;
#
# location / {
# try_files $uri $uri/ =404;
# }
#}

View file

@ -0,0 +1 @@
/etc/nginx/sites-available/default

View file

@ -0,0 +1,15 @@
uwsgi_param QUERY_STRING $query_string;
uwsgi_param REQUEST_METHOD $request_method;
uwsgi_param CONTENT_TYPE $content_type;
uwsgi_param CONTENT_LENGTH $content_length;
uwsgi_param REQUEST_URI $request_uri;
uwsgi_param PATH_INFO $document_uri;
uwsgi_param DOCUMENT_ROOT $document_root;
uwsgi_param SERVER_PROTOCOL $server_protocol;
uwsgi_param UWSGI_SCHEME $scheme;
uwsgi_param REMOTE_ADDR $remote_addr;
uwsgi_param REMOTE_PORT $remote_port;
uwsgi_param SERVER_PORT $server_port;
uwsgi_param SERVER_NAME $server_name;

View file

@ -0,0 +1,125 @@
# This map is not a full windows-1251 <> utf8 map: it does not
# contain Serbian and Macedonian letters. If you need a full map,
# use contrib/unicode2nginx/win-utf map instead.
charset_map windows-1251 utf-8 {
82 E2809A; # single low-9 quotation mark
84 E2809E; # double low-9 quotation mark
85 E280A6; # ellipsis
86 E280A0; # dagger
87 E280A1; # double dagger
88 E282AC; # euro
89 E280B0; # per mille
91 E28098; # left single quotation mark
92 E28099; # right single quotation mark
93 E2809C; # left double quotation mark
94 E2809D; # right double quotation mark
95 E280A2; # bullet
96 E28093; # en dash
97 E28094; # em dash
99 E284A2; # trade mark sign
A0 C2A0; # &nbsp;
A1 D18E; # capital Byelorussian short U
A2 D19E; # small Byelorussian short u
A4 C2A4; # currency sign
A5 D290; # capital Ukrainian soft G
A6 C2A6; # borken bar
A7 C2A7; # section sign
A8 D081; # capital YO
A9 C2A9; # (C)
AA D084; # capital Ukrainian YE
AB C2AB; # left-pointing double angle quotation mark
AC C2AC; # not sign
AD C2AD; # soft hypen
AE C2AE; # (R)
AF D087; # capital Ukrainian YI
B0 C2B0; # &deg;
B1 C2B1; # plus-minus sign
B2 D086; # capital Ukrainian I
B3 D196; # small Ukrainian i
B4 D291; # small Ukrainian soft g
B5 C2B5; # micro sign
B6 C2B6; # pilcrow sign
B7 C2B7; # &middot;
B8 D191; # small yo
B9 E28496; # numero sign
BA D194; # small Ukrainian ye
BB C2BB; # right-pointing double angle quotation mark
BF D197; # small Ukrainian yi
C0 D090; # capital A
C1 D091; # capital B
C2 D092; # capital V
C3 D093; # capital G
C4 D094; # capital D
C5 D095; # capital YE
C6 D096; # capital ZH
C7 D097; # capital Z
C8 D098; # capital I
C9 D099; # capital J
CA D09A; # capital K
CB D09B; # capital L
CC D09C; # capital M
CD D09D; # capital N
CE D09E; # capital O
CF D09F; # capital P
D0 D0A0; # capital R
D1 D0A1; # capital S
D2 D0A2; # capital T
D3 D0A3; # capital U
D4 D0A4; # capital F
D5 D0A5; # capital KH
D6 D0A6; # capital TS
D7 D0A7; # capital CH
D8 D0A8; # capital SH
D9 D0A9; # capital SHCH
DA D0AA; # capital hard sign
DB D0AB; # capital Y
DC D0AC; # capital soft sign
DD D0AD; # capital E
DE D0AE; # capital YU
DF D0AF; # capital YA
E0 D0B0; # small a
E1 D0B1; # small b
E2 D0B2; # small v
E3 D0B3; # small g
E4 D0B4; # small d
E5 D0B5; # small ye
E6 D0B6; # small zh
E7 D0B7; # small z
E8 D0B8; # small i
E9 D0B9; # small j
EA D0BA; # small k
EB D0BB; # small l
EC D0BC; # small m
ED D0BD; # small n
EE D0BE; # small o
EF D0BF; # small p
F0 D180; # small r
F1 D181; # small s
F2 D182; # small t
F3 D183; # small u
F4 D184; # small f
F5 D185; # small kh
F6 D186; # small ts
F7 D187; # small ch
F8 D188; # small sh
F9 D189; # small shch
FA D18A; # small hard sign
FB D18B; # small y
FC D18C; # small soft sign
FD D18D; # small e
FE D18E; # small yu
FF D18F; # small ya
}

View file

@ -0,0 +1,76 @@
"""Common utilities for letsencrypt.client.nginx."""
import os
import pkg_resources
import shutil
import tempfile
import unittest
import mock
from letsencrypt.client.plugins.nginx import constants
from letsencrypt.client.plugins.nginx import configurator
class NginxTest(unittest.TestCase): # pylint: disable=too-few-public-methods
def setUp(self):
super(NginxTest, self).setUp()
self.temp_dir, self.config_dir, self.work_dir = dir_setup(
"testdata")
self.ssl_options = setup_nginx_ssl_options(self.config_dir)
self.config_path = os.path.join(
self.temp_dir, "testdata")
self.rsa256_file = pkg_resources.resource_filename(
"letsencrypt.client.tests", "testdata/rsa256_key.pem")
self.rsa256_pem = pkg_resources.resource_string(
"letsencrypt.client.tests", "testdata/rsa256_key.pem")
def get_data_filename(filename):
"""Gets the filename of a test data file."""
return pkg_resources.resource_filename(
"letsencrypt.client.plugins.nginx.tests", "testdata/%s" % filename)
def dir_setup(test_dir="debian_nginx/two_vhost_80"):
"""Setup the directories necessary for the configurator."""
temp_dir = tempfile.mkdtemp("temp")
config_dir = tempfile.mkdtemp("config")
work_dir = tempfile.mkdtemp("work")
test_configs = pkg_resources.resource_filename(
"letsencrypt.client.plugins.nginx.tests", test_dir)
shutil.copytree(
test_configs, os.path.join(temp_dir, test_dir), symlinks=True)
return temp_dir, config_dir, work_dir
def setup_nginx_ssl_options(config_dir):
"""Move the ssl_options into position and return the path."""
option_path = os.path.join(config_dir, "options-ssl.conf")
shutil.copyfile(constants.MOD_SSL_CONF, option_path)
return option_path
def get_nginx_configurator(
config_path, config_dir, work_dir, ssl_options, version=(1, 6, 2)):
"""Create an Nginx Configurator with the specified options."""
backups = os.path.join(work_dir, "backups")
config = configurator.NginxConfigurator(
mock.MagicMock(
nginx_server_root=config_path, nginx_mod_ssl_conf=ssl_options,
le_vhost_ext="-le-ssl.conf", backup_dir=backups,
config_dir=config_dir, work_dir=work_dir,
temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"),
in_progress_dir=os.path.join(backups, "IN_PROGRESS")),
version)
config.prepare()
return config

View file

@ -8,8 +8,10 @@ from letsencrypt.acme import challenges
from letsencrypt.acme import jose
KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
"letsencrypt.client.tests", os.path.join("testdata", "rsa256_key.pem")))
KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
pkg_resources.resource_string(
"letsencrypt.client.tests",
os.path.join("testdata", "rsa256_key.pem"))))
# Challenges
SIMPLE_HTTPS = challenges.SimpleHTTPS(
@ -27,40 +29,40 @@ POP = challenges.ProofOfPossession(
alg="RS256", nonce="xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ",
hints=challenges.ProofOfPossession.Hints(
jwk=jose.JWKRSA(key=KEY.publickey()),
cert_fingerprints=[
cert_fingerprints=(
"93416768eb85e33adc4277f4c9acd63e7418fcfe",
"16d95b7b63f1972b980b14c20291f3c0d1855d95",
"48b46570d9fc6358108af43ad1649484def0debf"
],
certs=[], # TODO
subject_key_identifiers=["d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"],
serial_numbers=[34234239832, 23993939911, 17],
issuers=[
),
certs=(), # TODO
subject_key_identifiers=("d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"),
serial_numbers=(34234239832, 23993939911, 17),
issuers=(
"C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA",
"O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure",
],
authorized_for=["www.example.com", "example.net"],
),
authorized_for=("www.example.com", "example.net"),
)
)
CHALLENGES = [SIMPLE_HTTPS, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP]
DV_CHALLENGES = [chall for chall in CHALLENGES
if isinstance(chall, challenges.DVChallenge)]
CLIENT_CHALLENGES = [chall for chall in CHALLENGES
if isinstance(chall, challenges.ClientChallenge)]
CONT_CHALLENGES = [chall for chall in CHALLENGES
if isinstance(chall, challenges.ContinuityChallenge)]
def gen_combos(challs):
"""Generate natural combinations for challs."""
dv_chall = []
renewal_chall = []
cont_chall = []
for i, chall in enumerate(challs): # pylint: disable=redefined-outer-name
if isinstance(chall, challenges.DVChallenge):
dv_chall.append(i)
else:
renewal_chall.append(i)
cont_chall.append(i)
# Gen combos for 1 of each type
return [[i, j] for i in xrange(len(dv_chall))
for j in xrange(len(renewal_chall))]
# Gen combos for 1 of each type, lowest index first (makes testing easier)
return tuple((i, j) if i < j else (j, i)
for i in dv_chall for j in cont_chall)

View file

@ -30,17 +30,17 @@ class SatisfyChallengesTest(unittest.TestCase):
from letsencrypt.client.auth_handler import AuthHandler
self.mock_dv_auth = mock.MagicMock(name="ApacheConfigurator")
self.mock_client_auth = mock.MagicMock(name="ClientAuthenticator")
self.mock_cont_auth = mock.MagicMock(name="ContinuityAuthenticator")
self.mock_dv_auth.get_chall_pref.return_value = [challenges.DVSNI]
self.mock_client_auth.get_chall_pref.return_value = [
self.mock_cont_auth.get_chall_pref.return_value = [
challenges.RecoveryToken]
self.mock_client_auth.perform.side_effect = gen_auth_resp
self.mock_cont_auth.perform.side_effect = gen_auth_resp
self.mock_dv_auth.perform.side_effect = gen_auth_resp
self.handler = AuthHandler(
self.mock_dv_auth, self.mock_client_auth, None)
self.mock_dv_auth, self.mock_cont_auth, None)
logging.disable(logging.CRITICAL)
@ -61,9 +61,9 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual("DVSNI0", self.handler.responses[dom][0])
self.assertEqual(len(self.handler.dv_c), 1)
self.assertEqual(len(self.handler.client_c), 1)
self.assertEqual(len(self.handler.cont_c), 1)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.client_c[dom]), 0)
self.assertEqual(len(self.handler.cont_c[dom]), 0)
def test_name1_rectok1(self):
dom = "0"
@ -78,16 +78,16 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(len(self.handler.responses[dom]), 1)
# Test if statement for dv_auth perform
self.assertEqual(self.mock_client_auth.perform.call_count, 1)
self.assertEqual(self.mock_cont_auth.perform.call_count, 1)
self.assertEqual(self.mock_dv_auth.perform.call_count, 0)
self.assertEqual("RecoveryToken0", self.handler.responses[dom][0])
# Assert 1 domain
self.assertEqual(len(self.handler.dv_c), 1)
self.assertEqual(len(self.handler.client_c), 1)
self.assertEqual(len(self.handler.cont_c), 1)
# Assert 1 auth challenge, 0 dv
self.assertEqual(len(self.handler.dv_c[dom]), 0)
self.assertEqual(len(self.handler.client_c[dom]), 1)
self.assertEqual(len(self.handler.cont_c[dom]), 1)
def test_name5_dvsni5(self):
for i in xrange(5):
@ -102,11 +102,11 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(len(self.handler.responses), 5)
self.assertEqual(len(self.handler.dv_c), 5)
self.assertEqual(len(self.handler.client_c), 5)
self.assertEqual(len(self.handler.cont_c), 5)
# Each message contains 1 auth, 0 client
# Test proper call count for methods
self.assertEqual(self.mock_client_auth.perform.call_count, 0)
self.assertEqual(self.mock_cont_auth.perform.call_count, 0)
self.assertEqual(self.mock_dv_auth.perform.call_count, 1)
for i in xrange(5):
@ -114,7 +114,7 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(len(self.handler.responses[dom]), 1)
self.assertEqual(self.handler.responses[dom][0], "DVSNI%d" % i)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.client_c[dom]), 0)
self.assertEqual(len(self.handler.cont_c[dom]), 0)
self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall,
achallenges.DVSNI))
@ -138,10 +138,10 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(len(self.handler.responses[dom]),
len(acme_util.DV_CHALLENGES))
self.assertEqual(len(self.handler.dv_c), 1)
self.assertEqual(len(self.handler.client_c), 1)
self.assertEqual(len(self.handler.cont_c), 1)
# Test if statement for client_auth perform
self.assertEqual(self.mock_client_auth.perform.call_count, 0)
# Test if statement for cont_auth perform
self.assertEqual(self.mock_cont_auth.perform.call_count, 0)
self.assertEqual(self.mock_dv_auth.perform.call_count, 1)
self.assertEqual(
@ -149,7 +149,7 @@ class SatisfyChallengesTest(unittest.TestCase):
self._get_exp_response(dom, path, acme_util.DV_CHALLENGES))
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.client_c[dom]), 0)
self.assertEqual(len(self.handler.cont_c[dom]), 0)
self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall,
achallenges.SimpleHTTPS))
@ -175,16 +175,16 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(
len(self.handler.responses[dom]), len(acme_util.CHALLENGES))
self.assertEqual(len(self.handler.dv_c), 1)
self.assertEqual(len(self.handler.client_c), 1)
self.assertEqual(len(self.handler.cont_c), 1)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.client_c[dom]), 1)
self.assertEqual(len(self.handler.cont_c[dom]), 1)
self.assertEqual(
self.handler.responses[dom],
self._get_exp_response(dom, path, acme_util.CHALLENGES))
self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall,
achallenges.SimpleHTTPS))
self.assertTrue(isinstance(self.handler.client_c[dom][0].achall,
self.assertTrue(isinstance(self.handler.cont_c[dom][0].achall,
achallenges.RecoveryToken))
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
@ -209,7 +209,7 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(
len(self.handler.responses[str(i)]), len(acme_util.CHALLENGES))
self.assertEqual(len(self.handler.dv_c), 5)
self.assertEqual(len(self.handler.client_c), 5)
self.assertEqual(len(self.handler.cont_c), 5)
for i in xrange(5):
dom = str(i)
@ -217,11 +217,11 @@ class SatisfyChallengesTest(unittest.TestCase):
self.handler.responses[dom],
self._get_exp_response(dom, path, acme_util.CHALLENGES))
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(len(self.handler.client_c[dom]), 1)
self.assertEqual(len(self.handler.cont_c[dom]), 1)
self.assertTrue(isinstance(self.handler.dv_c[dom][0].achall,
achallenges.DVSNI))
self.assertTrue(isinstance(self.handler.client_c[dom][0].achall,
self.assertTrue(isinstance(self.handler.cont_c[dom][0].achall,
achallenges.RecoveryContact))
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
@ -255,7 +255,7 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(len(self.handler.responses), 5)
self.assertEqual(len(self.handler.dv_c), 5)
self.assertEqual(len(self.handler.client_c), 5)
self.assertEqual(len(self.handler.cont_c), 5)
for i in xrange(5):
dom = str(i)
@ -263,7 +263,7 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertEqual(self.handler.responses[dom], resp)
self.assertEqual(len(self.handler.dv_c[dom]), 1)
self.assertEqual(
len(self.handler.client_c[dom]), len(chosen_chall[i]) - 1)
len(self.handler.cont_c[dom]), len(chosen_chall[i]) - 1)
self.assertTrue(isinstance(
self.handler.dv_c["0"][0].achall, achallenges.DNS))
@ -276,10 +276,10 @@ class SatisfyChallengesTest(unittest.TestCase):
self.assertTrue(isinstance(
self.handler.dv_c["4"][0].achall, achallenges.DNS))
self.assertTrue(isinstance(self.handler.client_c["2"][0].achall,
self.assertTrue(isinstance(self.handler.cont_c["2"][0].achall,
achallenges.ProofOfPossession))
self.assertTrue(isinstance(
self.handler.client_c["4"][0].achall, achallenges.RecoveryToken))
self.handler.cont_c["4"][0].achall, achallenges.RecoveryToken))
@mock.patch("letsencrypt.client.auth_handler.gen_challenge_path")
def test_perform_exception_cleanup(self, mock_chall_path):
@ -309,11 +309,11 @@ class SatisfyChallengesTest(unittest.TestCase):
# Verify cleanup is actually run correctly
self.assertEqual(self.mock_dv_auth.cleanup.call_count, 2)
self.assertEqual(self.mock_client_auth.cleanup.call_count, 2)
self.assertEqual(self.mock_cont_auth.cleanup.call_count, 2)
dv_cleanup_args = self.mock_dv_auth.cleanup.call_args_list
client_cleanup_args = self.mock_client_auth.cleanup.call_args_list
cont_cleanup_args = self.mock_cont_auth.cleanup.call_args_list
# Check DV cleanup
for i in xrange(2):
@ -325,10 +325,10 @@ class SatisfyChallengesTest(unittest.TestCase):
# Check Auth cleanup
for i in xrange(2):
client_chall_list = client_cleanup_args[i][0][0]
self.assertEqual(len(client_chall_list), 1)
cont_chall_list = cont_cleanup_args[i][0][0]
self.assertEqual(len(cont_chall_list), 1)
self.assertTrue(
isinstance(client_chall_list[0], achallenges.ProofOfPossession))
isinstance(cont_chall_list[0], achallenges.ProofOfPossession))
def _get_exp_response(self, domain, path, challs):
@ -346,7 +346,7 @@ class GetAuthorizationsTest(unittest.TestCase):
from letsencrypt.client.auth_handler import AuthHandler
self.mock_dv_auth = mock.MagicMock(name="ApacheConfigurator")
self.mock_client_auth = mock.MagicMock(name="ClientAuthenticator")
self.mock_cont_auth = mock.MagicMock(name="ContinuityAuthenticator")
self.mock_sat_chall = mock.MagicMock(name="_satisfy_challenges")
self.mock_acme_auth = mock.MagicMock(name="acme_authorization")
@ -354,7 +354,7 @@ class GetAuthorizationsTest(unittest.TestCase):
self.iteration = 0
self.handler = AuthHandler(
self.mock_dv_auth, self.mock_client_auth, None)
self.mock_dv_auth, self.mock_cont_auth, None)
self.handler._satisfy_challenges = self.mock_sat_chall
self.handler.acme_authorization = self.mock_acme_auth
@ -388,7 +388,7 @@ class GetAuthorizationsTest(unittest.TestCase):
# Assignment was > 80 char...
dv_c, c_c = self.handler._challenge_factory(dom, [0])
self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c
self.handler.dv_c[dom], self.handler.cont_c[dom] = dv_c, c_c
def test_progress_failure(self):
self.handler.add_chall_msg(
@ -414,7 +414,7 @@ class GetAuthorizationsTest(unittest.TestCase):
self.handler.msgs[dom].challenges)
dv_c, c_c = self.handler._challenge_factory(
dom, self.handler.paths[dom])
self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c
self.handler.dv_c[dom], self.handler.cont_c[dom] = dv_c, c_c
def test_incremental_progress(self):
for dom, challs in [("0", acme_util.CHALLENGES),
@ -444,9 +444,9 @@ class GetAuthorizationsTest(unittest.TestCase):
self.handler.paths["1"] = [2]
# This is probably overkill... but set it anyway
dv_c, c_c = self.handler._challenge_factory("0", [1, 3])
self.handler.dv_c["0"], self.handler.client_c["0"] = dv_c, c_c
self.handler.dv_c["0"], self.handler.cont_c["0"] = dv_c, c_c
dv_c, c_c = self.handler._challenge_factory("1", [2])
self.handler.dv_c["1"], self.handler.client_c["1"] = dv_c, c_c
self.handler.dv_c["1"], self.handler.cont_c["1"] = dv_c, c_c
self.iteration += 1
@ -513,6 +513,77 @@ class PathSatisfiedTest(unittest.TestCase):
self.assertFalse(self.handler._path_satisfied(dom[i]))
class GenChallengePathTest(unittest.TestCase):
"""Tests for letsencrypt.client.auth_handler.gen_challenge_path.
.. todo:: Add more tests for dumb_path... depending on what we want to do.
"""
def setUp(self):
logging.disable(logging.fatal)
def tearDown(self):
logging.disable(logging.NOTSET)
@classmethod
def _call(cls, challs, preferences, combinations):
from letsencrypt.client.auth_handler import gen_challenge_path
return gen_challenge_path(challs, preferences, combinations)
def test_common_case(self):
"""Given DVSNI and SimpleHTTPS with appropriate combos."""
challs = (acme_util.DVSNI, acme_util.SIMPLE_HTTPS)
prefs = [challenges.DVSNI]
combos = ((0,), (1,))
# Smart then trivial dumb path test
self.assertEqual(self._call(challs, prefs, combos), (0,))
self.assertTrue(self._call(challs, prefs, None))
# Rearrange order...
self.assertEqual(self._call(challs[::-1], prefs, combos), (1,))
self.assertTrue(self._call(challs[::-1], prefs, None))
def test_common_case_with_continuity(self):
challs = (acme_util.RECOVERY_TOKEN,
acme_util.RECOVERY_CONTACT,
acme_util.DVSNI,
acme_util.SIMPLE_HTTPS)
prefs = [challenges.RecoveryToken, challenges.DVSNI]
combos = acme_util.gen_combos(challs)
self.assertEqual(self._call(challs, prefs, combos), (0, 2))
# dumb_path() trivial test
self.assertTrue(self._call(challs, prefs, None))
def test_full_cont_server(self):
challs = (acme_util.RECOVERY_TOKEN,
acme_util.RECOVERY_CONTACT,
acme_util.POP,
acme_util.DVSNI,
acme_util.SIMPLE_HTTPS,
acme_util.DNS)
# Typical webserver client that can do everything except DNS
# Attempted to make the order realistic
prefs = [challenges.RecoveryToken,
challenges.ProofOfPossession,
challenges.SimpleHTTPS,
challenges.DVSNI,
challenges.RecoveryContact]
combos = acme_util.gen_combos(challs)
self.assertEqual(self._call(challs, prefs, combos), (0, 4))
# Dumb path trivial test
self.assertTrue(self._call(challs, prefs, None))
def test_not_supported(self):
challs = (acme_util.POP, acme_util.DVSNI)
prefs = [challenges.DVSNI]
combos = ((0, 1),)
self.assertRaises(errors.LetsEncryptAuthHandlerError,
self._call, challs, prefs, combos)
class MutuallyExclusiveTest(unittest.TestCase):
"""Tests for letsencrypt.client.auth_handler.mutually_exclusive."""

View file

@ -1,4 +1,4 @@
"""Test the ClientAuthenticator dispatcher."""
"""Test the ContinuityAuthenticator dispatcher."""
import unittest
import mock
@ -13,9 +13,9 @@ class PerformTest(unittest.TestCase):
"""Test client perform function."""
def setUp(self):
from letsencrypt.client.client_authenticator import ClientAuthenticator
from letsencrypt.client.continuity_auth import ContinuityAuthenticator
self.auth = ClientAuthenticator(
self.auth = ContinuityAuthenticator(
mock.MagicMock(server="demo_server.org"))
self.auth.rec_token.perform = mock.MagicMock(
name="rec_token_perform", side_effect=gen_client_resp)
@ -38,7 +38,7 @@ class PerformTest(unittest.TestCase):
def test_unexpected(self):
self.assertRaises(
errors.LetsEncryptClientAuthError, self.auth.perform, [
errors.LetsEncryptContAuthError, self.auth.perform, [
achallenges.DVSNI(chall=None, domain="0", key="invalid_key")])
def test_chall_pref(self):
@ -50,9 +50,9 @@ class CleanupTest(unittest.TestCase):
"""Test the Authenticator cleanup function."""
def setUp(self):
from letsencrypt.client.client_authenticator import ClientAuthenticator
from letsencrypt.client.continuity_auth import ContinuityAuthenticator
self.auth = ClientAuthenticator(
self.auth = ContinuityAuthenticator(
mock.MagicMock(server="demo_server.org"))
self.mock_cleanup = mock.MagicMock(name="rec_token_cleanup")
self.auth.rec_token.cleanup = self.mock_cleanup
@ -70,7 +70,7 @@ class CleanupTest(unittest.TestCase):
token = achallenges.RecoveryToken(chall=None, domain="0")
unexpected = achallenges.DVSNI(chall=None, domain="0", key="dummy_key")
self.assertRaises(errors.LetsEncryptClientAuthError,
self.assertRaises(errors.LetsEncryptContAuthError,
self.auth.cleanup, [token, unexpected])

View file

@ -0,0 +1,458 @@
"""Tests for letsencrypt.client.network2."""
import datetime
import httplib
import os
import pkg_resources
import unittest
import M2Crypto
import mock
import requests
from letsencrypt.client import errors
from letsencrypt.acme import challenges
from letsencrypt.acme import jose
from letsencrypt.acme import messages2
CERT = jose.ComparableX509(M2Crypto.X509.load_cert_string(
pkg_resources.resource_string(
__name__, os.path.join('testdata/cert.pem'))))
CERT2 = jose.ComparableX509(M2Crypto.X509.load_cert_string(
pkg_resources.resource_string(
__name__, os.path.join('testdata/cert-san.pem'))))
CSR = jose.ComparableX509(M2Crypto.X509.load_request_string(
pkg_resources.resource_string(
__name__, os.path.join('testdata/csr.pem'))))
KEY = jose.JWKRSA.load(pkg_resources.resource_string(
__name__, os.path.join('testdata/rsa512_key.pem')))
KEY2 = jose.JWKRSA.load(pkg_resources.resource_string(
__name__, os.path.join('testdata/rsa256_key.pem')))
class NetworkTest(unittest.TestCase):
"""Tests for letsencrypt.client.network2.Network."""
# pylint: disable=too-many-instance-attributes,too-many-public-methods
def setUp(self):
from letsencrypt.client.network2 import Network
self.net = Network(
new_reg_uri='https://www.letsencrypt-demo.org/acme/new-reg',
key=KEY, alg=jose.RS256)
self.response = mock.MagicMock(ok=True, status_code=httplib.OK)
self.response.headers = {}
self.response.links = {}
self.identifier = messages2.Identifier(
typ=messages2.IDENTIFIER_FQDN, value='example.com')
# Registration
self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212')
reg = messages2.Registration(
contact=self.contact, key=KEY.public(), recovery_token='t')
self.regr = messages2.RegistrationResource(
body=reg, uri='https://www.letsencrypt-demo.org/acme/reg/1',
new_authzr_uri='https://www.letsencrypt-demo.org/acme/new-reg',
terms_of_service='https://www.letsencrypt-demo.org/tos')
# Authorization
authzr_uri = 'https://www.letsencrypt-demo.org/acme/authz/1'
challb = messages2.ChallengeBody(
uri=(authzr_uri + '/1'), status=messages2.STATUS_VALID,
chall=challenges.DNS(token='foo'))
self.challr = messages2.ChallengeResource(
body=challb, authzr_uri=authzr_uri)
self.authz = messages2.Authorization(
identifier=messages2.Identifier(
typ=messages2.IDENTIFIER_FQDN, value='example.com'),
challenges=(challb,), combinations=None, key=KEY.public())
self.authzr = messages2.AuthorizationResource(
body=self.authz, uri=authzr_uri,
new_cert_uri='https://www.letsencrypt-demo.org/acme/new-cert')
# Request issuance
self.certr = messages2.CertificateResource(
body=CERT, authzrs=(self.authzr,),
uri='https://www.letsencrypt-demo.org/acme/cert/1',
cert_chain_uri='https://www.letsencrypt-demo.org/ca')
def _mock_post_get(self):
# pylint: disable=protected-access
self.net._post = mock.MagicMock(return_value=self.response)
self.net._get = mock.MagicMock(return_value=self.response)
def test_wrap_in_jws(self):
class MockJSONDeSerializable(jose.JSONDeSerializable):
# pylint: disable=missing-docstring
def __init__(self, value):
self.value = value
def to_partial_json(self):
return self.value
@classmethod
def from_json(cls, value):
return cls(value)
# pylint: disable=protected-access
jws = self.net._wrap_in_jws(MockJSONDeSerializable('foo'))
self.assertEqual(jose.JWS.json_loads(jws).payload, '"foo"')
def test_check_response_not_ok_jobj_no_error(self):
self.response.ok = False
self.response.json.return_value = {}
# pylint: disable=protected-access
self.assertRaises(
errors.NetworkError, self.net._check_response, self.response)
def test_check_response_not_ok_jobj_error(self):
self.response.ok = False
self.response.json.return_value = messages2.Error(detail='foo')
# pylint: disable=protected-access
self.assertRaises(
messages2.Error, self.net._check_response, self.response)
def test_check_response_not_ok_no_jobj(self):
self.response.ok = False
self.response.json.side_effect = ValueError
# pylint: disable=protected-access
self.assertRaises(
errors.NetworkError, self.net._check_response, self.response)
def test_check_response_ok_no_jobj_ct_required(self):
self.response.json.side_effect = ValueError
for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']:
self.response.headers['Content-Type'] = response_ct
# pylint: disable=protected-access
self.assertRaises(
errors.NetworkError, self.net._check_response, self.response,
content_type=self.net.JSON_CONTENT_TYPE)
def test_check_response_ok_no_jobj_no_ct(self):
self.response.json.side_effect = ValueError
for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']:
self.response.headers['Content-Type'] = response_ct
# pylint: disable=protected-access
self.net._check_response(self.response)
def test_check_response_jobj(self):
self.response.json.return_value = {}
for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']:
self.response.headers['Content-Type'] = response_ct
# pylint: disable=protected-access
self.net._check_response(self.response)
@mock.patch('letsencrypt.client.network2.requests')
def test_get_requests_error_passthrough(self, requests_mock):
requests_mock.exceptions = requests.exceptions
requests_mock.get.side_effect = requests.exceptions.RequestException
# pylint: disable=protected-access
self.assertRaises(errors.NetworkError, self.net._get, 'uri')
@mock.patch('letsencrypt.client.network2.requests')
def test_get(self, requests_mock):
# pylint: disable=protected-access
self.net._check_response = mock.MagicMock()
self.net._get('uri', content_type='ct')
self.net._check_response.assert_called_once_with(
requests_mock.get('uri'), content_type='ct')
@mock.patch('letsencrypt.client.network2.requests')
def test_post_requests_error_passthrough(self, requests_mock):
requests_mock.exceptions = requests.exceptions
requests_mock.post.side_effect = requests.exceptions.RequestException
# pylint: disable=protected-access
self.assertRaises(errors.NetworkError, self.net._post, 'uri', 'data')
@mock.patch('letsencrypt.client.network2.requests')
def test_post(self, requests_mock):
# pylint: disable=protected-access
self.net._check_response = mock.MagicMock()
self.net._post('uri', 'data', content_type='ct')
self.net._check_response.assert_called_once_with(
requests_mock.post('uri', 'data'), content_type='ct')
def test_register(self):
self.response.status_code = httplib.CREATED
self.response.json.return_value = self.regr.body.to_json()
self.response.headers['Location'] = self.regr.uri
self.response.links.update({
'next': {'url': self.regr.new_authzr_uri},
'terms-of-service': {'url': self.regr.terms_of_service},
})
self._mock_post_get()
self.assertEqual(self.regr, self.net.register(self.contact))
# TODO: test POST call arguments
# TODO: split here and separate test
reg_wrong_key = self.regr.body.update(key=KEY2.public())
self.response.json.return_value = reg_wrong_key.to_json()
self.assertRaises(
errors.UnexpectedUpdate, self.net.register, self.contact)
def test_register_missing_next(self):
self.response.status_code = httplib.CREATED
self._mock_post_get()
self.assertRaises(
errors.NetworkError, self.net.register, self.regr.body)
def test_update_registration(self):
self.response.headers['Location'] = self.regr.uri
self.response.json.return_value = self.regr.body.to_json()
self._mock_post_get()
self.assertEqual(self.regr, self.net.update_registration(self.regr))
# TODO: split here and separate test
self.response.json.return_value = self.regr.body.update(
contact=()).to_json()
self.assertRaises(
errors.UnexpectedUpdate, self.net.update_registration, self.regr)
def test_request_challenges(self):
self.response.status_code = httplib.CREATED
self.response.headers['Location'] = self.authzr.uri
self.response.json.return_value = self.authz.to_json()
self.response.links = {
'next': {'url': self.authzr.new_cert_uri},
}
self._mock_post_get()
self.net.request_challenges(self.identifier, self.regr)
# TODO: test POST call arguments
# TODO: split here and separate test
authz_wrong_key = self.authz.update(key=KEY2.public())
self.response.json.return_value = authz_wrong_key.to_json()
self.assertRaises(
errors.UnexpectedUpdate, self.net.request_challenges,
self.identifier, self.regr)
def test_request_challenges_missing_next(self):
self.response.status_code = httplib.CREATED
self._mock_post_get()
self.assertRaises(
errors.NetworkError, self.net.request_challenges,
self.identifier, self.regr)
def test_request_domain_challenges(self):
self.net.request_challenges = mock.MagicMock()
self.assertEqual(
self.net.request_challenges(self.identifier),
self.net.request_domain_challenges('example.com', self.regr))
def test_answer_challenge(self):
self.response.links['up'] = {'url': self.challr.authzr_uri}
self.response.json.return_value = self.challr.body.to_json()
chall_response = challenges.DNSResponse()
self._mock_post_get()
self.net.answer_challenge(self.challr.body, chall_response)
# TODO: split here and separate test
self.assertRaises(errors.UnexpectedUpdate, self.net.answer_challenge,
self.challr.body.update(uri='foo'), chall_response)
def test_answer_challenge_missing_next(self):
self._mock_post_get()
self.assertRaises(errors.NetworkError, self.net.answer_challenge,
self.challr.body, challenges.DNSResponse())
def test_answer_challenges(self):
self.net.answer_challenge = mock.MagicMock()
self.assertEqual(
[self.net.answer_challenge(
self.challr.body, challenges.DNSResponse())],
self.net.answer_challenges(
[self.challr.body], [challenges.DNSResponse()]))
def test_retry_after_date(self):
self.response.headers['Retry-After'] = 'Fri, 31 Dec 1999 23:59:59 GMT'
self.assertEqual(
datetime.datetime(1999, 12, 31, 23, 59, 59),
self.net.retry_after(response=self.response, default=10))
@mock.patch('letsencrypt.client.network2.datetime')
def test_retry_after_invalid(self, dt_mock):
dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27)
dt_mock.timedelta = datetime.timedelta
self.response.headers['Retry-After'] = 'foooo'
self.assertEqual(
datetime.datetime(2015, 3, 27, 0, 0, 10),
self.net.retry_after(response=self.response, default=10))
@mock.patch('letsencrypt.client.network2.datetime')
def test_retry_after_seconds(self, dt_mock):
dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27)
dt_mock.timedelta = datetime.timedelta
self.response.headers['Retry-After'] = '50'
self.assertEqual(
datetime.datetime(2015, 3, 27, 0, 0, 50),
self.net.retry_after(response=self.response, default=10))
@mock.patch('letsencrypt.client.network2.datetime')
def test_retry_after_missing(self, dt_mock):
dt_mock.datetime.now.return_value = datetime.datetime(2015, 3, 27)
dt_mock.timedelta = datetime.timedelta
self.assertEqual(
datetime.datetime(2015, 3, 27, 0, 0, 10),
self.net.retry_after(response=self.response, default=10))
def test_poll(self):
self.response.json.return_value = self.authzr.body.to_json()
self._mock_post_get()
self.assertEqual((self.authzr, self.response),
self.net.poll(self.authzr))
def test_request_issuance(self):
self.response.content = CERT.as_der()
self.response.headers['Location'] = self.certr.uri
self.response.links['up'] = {'url': self.certr.cert_chain_uri}
self._mock_post_get()
self.assertEqual(
self.certr, self.net.request_issuance(CSR, (self.authzr,)))
# TODO: check POST args
def test_request_issuance_missing_up(self):
self.response.content = CERT.as_der()
self.response.headers['Location'] = self.certr.uri
self._mock_post_get()
self.assertEqual(
self.certr.update(cert_chain_uri=None),
self.net.request_issuance(CSR, (self.authzr,)))
def test_request_issuance_missing_location(self):
self._mock_post_get()
self.assertRaises(
errors.NetworkError, self.net.request_issuance,
CSR, (self.authzr,))
@mock.patch('letsencrypt.client.network2.datetime')
@mock.patch('letsencrypt.client.network2.time')
def test_poll_and_request_issuance(self, time_mock, dt_mock):
# clock.dt | pylint: disable=no-member
clock = mock.MagicMock(dt=datetime.datetime(2015, 3, 27))
def sleep(seconds):
"""increment clock"""
clock.dt += datetime.timedelta(seconds=seconds)
time_mock.sleep.side_effect = sleep
def now():
"""return current clock value"""
return clock.dt
dt_mock.datetime.now.side_effect = now
dt_mock.timedelta = datetime.timedelta
def poll(authzr): # pylint: disable=missing-docstring
# record poll start time based on the current clock value
authzr.times.append(clock.dt)
# suppose it takes 2 seconds for server to produce the
# result, increment clock
clock.dt += datetime.timedelta(seconds=2)
if not authzr.retries: # no more retries
done = mock.MagicMock(uri=authzr.uri, times=authzr.times)
done.body.status = messages2.STATUS_VALID
return done, []
# response (2nd result tuple element) is reduced to only
# Retry-After header contents represented as integer
# seconds; authzr.retries is a list of Retry-After
# headers, head(retries) is peeled of as a current
# Retry-After header, and tail(retries) is persisted for
# later poll() calls
return (mock.MagicMock(retries=authzr.retries[1:],
uri=authzr.uri + '.', times=authzr.times),
authzr.retries[0])
self.net.poll = mock.MagicMock(side_effect=poll)
mintime = 7
def retry_after(response, default): # pylint: disable=missing-docstring
# check that poll_and_request_issuance correctly passes mintime
self.assertEqual(default, mintime)
return clock.dt + datetime.timedelta(seconds=response)
self.net.retry_after = mock.MagicMock(side_effect=retry_after)
def request_issuance(csr, authzrs): # pylint: disable=missing-docstring
return csr, authzrs
self.net.request_issuance = mock.MagicMock(side_effect=request_issuance)
csr = mock.MagicMock()
authzrs = (
mock.MagicMock(uri='a', times=[], retries=(8, 20, 30)),
mock.MagicMock(uri='b', times=[], retries=(5,)),
)
cert, updated_authzrs = self.net.poll_and_request_issuance(
csr, authzrs, mintime=mintime)
self.assertTrue(cert[0] is csr)
self.assertTrue(cert[1] is updated_authzrs)
self.assertEqual(updated_authzrs[0].uri, 'a...')
self.assertEqual(updated_authzrs[1].uri, 'b.')
self.assertEqual(updated_authzrs[0].times, [
datetime.datetime(2015, 3, 27),
# a is scheduled for 10, but b is polling [9..11), so it
# will be picked up as soon as b is finished, without
# additional sleeping
datetime.datetime(2015, 3, 27, 0, 0, 11),
datetime.datetime(2015, 3, 27, 0, 0, 33),
datetime.datetime(2015, 3, 27, 0, 1, 5),
])
self.assertEqual(updated_authzrs[1].times, [
datetime.datetime(2015, 3, 27, 0, 0, 2),
datetime.datetime(2015, 3, 27, 0, 0, 9),
])
self.assertEqual(clock.dt, datetime.datetime(2015, 3, 27, 0, 1, 7))
def test_check_cert(self):
self.response.headers['Location'] = self.certr.uri
self.response.content = CERT2.as_der()
self._mock_post_get()
self.assertEqual(
self.certr.update(body=CERT2), self.net.check_cert(self.certr))
# TODO: split here and separate test
self.response.headers['Location'] = 'foo'
self.assertRaises(
errors.UnexpectedUpdate, self.net.check_cert, self.certr)
def test_check_cert_missing_location(self):
self.response.content = CERT2.as_der()
self._mock_post_get()
self.assertRaises(errors.NetworkError, self.net.check_cert, self.certr)
def test_refresh(self):
self.net.check_cert = mock.MagicMock()
self.assertEqual(
self.net.check_cert(self.certr), self.net.refresh(self.certr))
def test_fetch_chain(self):
# pylint: disable=protected-access
self.net._get_cert = mock.MagicMock()
self.assertEqual(self.net._get_cert(self.certr.cert_chain_uri),
self.net.fetch_chain(self.certr))
def test_fetch_chain_no_up_link(self):
self.assertTrue(self.net.fetch_chain(self.certr.update(
cert_chain_uri=None)) is None)
def test_revoke(self):
self._mock_post_get()
self.net.revoke(self.certr, when=messages2.Revocation.NOW)
# pylint: disable=protected-access
self.net._post.assert_called_once_with(self.certr.uri, mock.ANY)
def test_revoke_bad_status_raises_error(self):
self.response.status_code = httplib.METHOD_NOT_ALLOWED
self._mock_post_get()
self.assertRaises(errors.NetworkError, self.net.revoke, self.certr)
if __name__ == '__main__':
unittest.main()

View file

@ -11,6 +11,7 @@ from setuptools import setup
if os.path.abspath(__file__).split(os.path.sep)[1] == 'vagrant':
del os.link
def read_file(filename, encoding='utf8'):
"""Read unicode from given file."""
with codecs.open(filename, encoding=encoding) as fd:
@ -36,9 +37,13 @@ install_requires = [
'pyasn1', # urllib3 InsecurePlatformWarning (#304)
'pycrypto',
'PyOpenSSL',
'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary?
'pyrfc3339',
'python-augeas',
'python2-pythondialog',
'python2-pythondialog>=3.2.2rc1', # Debian squeeze support, cf. #280
'pytz',
'requests',
'werkzeug',
'zope.component',
'zope.interface',
# order of items in install_requires DOES matter and M2Crypto has
@ -100,6 +105,8 @@ setup(
'letsencrypt.client.plugins',
'letsencrypt.client.plugins.apache',
'letsencrypt.client.plugins.apache.tests',
'letsencrypt.client.plugins.nginx',
'letsencrypt.client.plugins.nginx.tests',
'letsencrypt.client.plugins.standalone',
'letsencrypt.client.plugins.standalone.tests',
'letsencrypt.client.tests',
@ -124,6 +131,8 @@ setup(
'letsencrypt.plugins': [
'apache = letsencrypt.client.plugins.apache.configurator'
':ApacheConfigurator',
'nginx = letsencrypt.client.plugins.nginx.configurator'
':NginxConfigurator',
'standalone = letsencrypt.client.plugins.standalone.authenticator'
':StandaloneAuthenticator',
],

View file

@ -19,7 +19,7 @@ setenv =
basepython = python2.7
commands =
pip install -e .[testing]
python setup.py nosetests --with-coverage --cover-min-percentage=86
python setup.py nosetests --with-coverage --cover-min-percentage=87
[testenv:lint]
# recent versions of pylint do not support Python 2.6 (#97, #187)