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

Conflicts:
	letsencrypt/cli.py
	letsencrypt/client.py
	letsencrypt/tests/client_test.py
This commit is contained in:
Jakub Warmuz 2015-06-25 12:58:44 +00:00
commit e51f300ee6
No known key found for this signature in database
GPG key ID: 2A7BAD3A489B52EA
112 changed files with 2889 additions and 3262 deletions

5
.gitignore vendored
View file

@ -15,4 +15,7 @@ dist/
# editor temporary files
*~
*.swp
\#*#
\#*#
# auth --cert-path --chain-path
/*.pem

View file

@ -3,7 +3,7 @@ ChangeLog
Please note:
the change log will only get updated after first release - for now please use the
`commit log <https://github.com/letsencrypt/lets-encrypt-preview/commits/master>`_.
`commit log <https://github.com/letsencrypt/letsencrypt/commits/master>`_.
Release 0.1.0 (not released yet)

View file

@ -1,4 +1,4 @@
# https://github.com/letsencrypt/lets-encrypt-preview/pull/431#issuecomment-103659297
# https://github.com/letsencrypt/letsencrypt/pull/431#issuecomment-103659297
# it is more likely developers will already have ubuntu:trusty rather
# than e.g. debian:jessie and image size differences are negligible
FROM ubuntu:trusty
@ -48,6 +48,7 @@ COPY letsencrypt_apache /opt/letsencrypt/src/letsencrypt_apache/
COPY letsencrypt_nginx /opt/letsencrypt/src/letsencrypt_nginx/
# requirements.txt not installed!
RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv && \
/opt/letsencrypt/venv/bin/pip install -e /opt/letsencrypt/src

View file

@ -1,4 +1,4 @@
Let's Encrypt Preview:
Let's Encrypt:
Copyright (c) Internet Security Research Group
Licensed Apache Version 2.0

View file

@ -1,3 +1,13 @@
.. notice for github users
Official **documentation**, including `installation instructions`_, is
available at https://letsencrypt.readthedocs.org.
Generic information about Let's Encrypt project can be found at
https://letsencrypt.org. Please read `Frequently Asked Questions (FAQ)
<https://letsencrypt.org/faq/>`_.
About the Let's Encrypt Client
==============================
@ -31,22 +41,25 @@ server automatically!::
**Encrypt ALL the things!**
.. |build-status| image:: https://travis-ci.org/letsencrypt/lets-encrypt-preview.svg?branch=master
:target: https://travis-ci.org/letsencrypt/lets-encrypt-preview
.. |build-status| image:: https://travis-ci.org/letsencrypt/letsencrypt.svg?branch=master
:target: https://travis-ci.org/letsencrypt/letsencrypt
:alt: Travis CI status
.. |coverage| image:: https://coveralls.io/repos/letsencrypt/lets-encrypt-preview/badge.svg?branch=master
:target: https://coveralls.io/r/letsencrypt/lets-encrypt-preview
.. |coverage| image:: https://coveralls.io/repos/letsencrypt/letsencrypt/badge.svg?branch=master
:target: https://coveralls.io/r/letsencrypt/letsencrypt
:alt: Coverage status
.. |docs| image:: https://readthedocs.org/projects/letsencrypt/badge/
:target: https://readthedocs.org/projects/letsencrypt/
:alt: Documentation status
.. |container| image:: https://quay.io/repository/letsencrypt/lets-encrypt-preview/status
:target: https://quay.io/repository/letsencrypt/lets-encrypt-preview
.. |container| image:: https://quay.io/repository/letsencrypt/letsencrypt/status
:target: https://quay.io/repository/letsencrypt/letsencrypt
:alt: Docker Repository on Quay.io
.. _`installation instructions`:
https://letsencrypt.readthedocs.org/en/latest/using.html
.. _watch demo video: https://www.youtube.com/watch?v=Gas_sSB-5SU
@ -85,9 +98,9 @@ Current Features
Links
-----
Documentation: https://letsencrypt.readthedocs.org/
Documentation: https://letsencrypt.readthedocs.org
Software project: https://github.com/letsencrypt/lets-encrypt-preview
Software project: https://github.com/letsencrypt/letsencrypt
Notes for developers: CONTRIBUTING.md_
@ -100,4 +113,4 @@ email to client-dev+subscribe@letsencrypt.org)
.. _Freenode: https://freenode.net
.. _client-dev: https://groups.google.com/a/letsencrypt.org/forum/#!forum/client-dev
.. _CONTRIBUTING.md: https://github.com/letsencrypt/lets-encrypt-preview/blob/master/CONTRIBUTING.md
.. _CONTRIBUTING.md: https://github.com/letsencrypt/letsencrypt/blob/master/CONTRIBUTING.md

2
Vagrantfile vendored
View file

@ -8,8 +8,6 @@ VAGRANTFILE_API_VERSION = "2"
$ubuntu_setup_script = <<SETUP_SCRIPT
cd /vagrant
sudo ./bootstrap/ubuntu.sh
sudo apt-get -y --no-install-recommends install git-core
# the above is required by the 'git+https' lines of requirements.txt
if [ ! -d "venv" ]; then
virtualenv --no-site-packages -p python2 venv
./venv/bin/pip install -r requirements.txt -e .[dev,docs,testing]

View file

@ -46,7 +46,6 @@ class SimpleHTTP(DVChallenge):
"""ACME "simpleHttp" challenge."""
typ = "simpleHttp"
token = jose.Field("token")
tls = jose.Field("tls", default=True, omitempty=True)
@ChallengeResponse.register
@ -54,20 +53,43 @@ class SimpleHTTPResponse(ChallengeResponse):
"""ACME "simpleHttp" challenge response."""
typ = "simpleHttp"
path = jose.Field("path")
tls = jose.Field("tls", default=True, omitempty=True)
URI_TEMPLATE = "https://{domain}/.well-known/acme-challenge/{path}"
"""URI template for HTTPS server provisioned resource."""
URI_ROOT_PATH = ".well-known/acme-challenge"
"""URI root path for the server provisioned resource."""
_URI_TEMPLATE = "{scheme}://{domain}/" + URI_ROOT_PATH + "/{path}"
MAX_PATH_LEN = 25
"""Maximum allowed `path` length."""
@property
def good_path(self):
"""Is `path` good?
.. todo:: acme-spec: "The value MUST be comprised entirely of
characters from the URL-safe alphabet for Base64 encoding
[RFC4648]", base64.b64decode ignores those characters
"""
return len(self.path) <= 25
@property
def scheme(self):
"""URL scheme for the provisioned resource."""
return "https" if self.tls else "http"
def uri(self, domain):
"""Create an URI to the provisioned resource.
Forms an URI to the HTTPS server provisioned resource (containing
:attr:`~SimpleHTTP.token`) by populating the :attr:`URI_TEMPLATE`.
Forms an URI to the HTTPS server provisioned resource
(containing :attr:`~SimpleHTTP.token`).
:param str domain: Domain name being verified.
"""
return self.URI_TEMPLATE.format(domain=domain, path=self.path)
return self._URI_TEMPLATE.format(
scheme=self.scheme, domain=domain, path=self.path)
@Challenge.register

View file

@ -18,6 +18,13 @@ KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
'acme.jose', os.path.join('testdata', 'rsa512_key.pem'))))
class ChallengeResponseTest(unittest.TestCase):
def test_from_json_none(self):
from acme.challenges import ChallengeResponse
self.assertTrue(ChallengeResponse.from_json(None) is None)
class SimpleHTTPTest(unittest.TestCase):
def setUp(self):
@ -27,17 +34,8 @@ class SimpleHTTPTest(unittest.TestCase):
self.jmsg = {
'type': 'simpleHttp',
'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA',
'tls': True,
}
def test_no_tls(self):
from acme.challenges import SimpleHTTP
self.assertEqual(SimpleHTTP(token='tok', tls=False).to_json(), {
'tls': False,
'token': 'tok',
'type': 'simpleHttp',
})
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
@ -54,27 +52,51 @@ class SimpleHTTPResponseTest(unittest.TestCase):
def setUp(self):
from acme.challenges import SimpleHTTPResponse
self.msg = SimpleHTTPResponse(path='6tbIMBC5Anhl5bOlWT5ZFA')
self.jmsg = {
self.msg_http = SimpleHTTPResponse(
path='6tbIMBC5Anhl5bOlWT5ZFA', tls=False)
self.msg_https = SimpleHTTPResponse(path='6tbIMBC5Anhl5bOlWT5ZFA')
self.jmsg_http = {
'type': 'simpleHttp',
'path': '6tbIMBC5Anhl5bOlWT5ZFA',
'tls': False,
}
self.jmsg_https = {
'type': 'simpleHttp',
'path': '6tbIMBC5Anhl5bOlWT5ZFA',
'tls': True,
}
def test_good_path(self):
self.assertTrue(self.msg_http.good_path)
self.assertTrue(self.msg_https.good_path)
self.assertFalse(
self.msg_http.update(path=(self.msg_http.path * 10)).good_path)
def test_scheme(self):
self.assertEqual('http', self.msg_http.scheme)
self.assertEqual('https', self.msg_https.scheme)
def test_uri(self):
self.assertEqual('http://example.com/.well-known/acme-challenge/'
'6tbIMBC5Anhl5bOlWT5ZFA', self.msg_http.uri('example.com'))
self.assertEqual('https://example.com/.well-known/acme-challenge/'
'6tbIMBC5Anhl5bOlWT5ZFA', self.msg.uri('example.com'))
'6tbIMBC5Anhl5bOlWT5ZFA', self.msg_https.uri('example.com'))
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
self.assertEqual(self.jmsg_http, self.msg_http.to_partial_json())
self.assertEqual(self.jmsg_https, self.msg_https.to_partial_json())
def test_from_json(self):
from acme.challenges import SimpleHTTPResponse
self.assertEqual(
self.msg, SimpleHTTPResponse.from_json(self.jmsg))
self.msg_http, SimpleHTTPResponse.from_json(self.jmsg_http))
self.assertEqual(
self.msg_https, SimpleHTTPResponse.from_json(self.jmsg_https))
def test_from_json_hashable(self):
from acme.challenges import SimpleHTTPResponse
hash(SimpleHTTPResponse.from_json(self.jmsg))
hash(SimpleHTTPResponse.from_json(self.jmsg_http))
hash(SimpleHTTPResponse.from_json(self.jmsg_https))
class DVSNITest(unittest.TestCase):

View file

@ -1,4 +1,4 @@
"""Networking for ACME protocol v02."""
"""ACME client API."""
import datetime
import heapq
import httplib
@ -9,23 +9,22 @@ import M2Crypto
import requests
import werkzeug
from acme import errors
from acme import jose
from acme import jws as acme_jws
from acme import messages2
from letsencrypt import errors
from acme import jws
from acme import messages
# https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning
requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3()
class Network(object):
"""ACME networking.
class Client(object): # pylint: disable=too-many-instance-attributes
"""ACME client.
.. todo::
Clean up raised error types hierarchy, document, and handle (wrap)
instances of `.DeserializationError` raised in `from_json()``.
instances of `.DeserializationError` raised in `from_json()`.
:ivar str new_reg_uri: Location of new-reg
:ivar key: `.JWK` (private)
@ -33,8 +32,6 @@ class Network(object):
:ivar bool verify_ssl: Verify SSL certificates?
"""
# TODO: Move below to acme module?
DER_CONTENT_TYPE = 'application/pkix-cert'
JSON_CONTENT_TYPE = 'application/json'
JSON_ERROR_CONTENT_TYPE = 'application/problem+json'
@ -58,7 +55,7 @@ class Network(object):
"""
dumps = obj.json_dumps()
logging.debug('Serialized JSON: %s', dumps)
return acme_jws.JWS.sign(
return jws.JWS.sign(
payload=dumps, key=self.key, alg=self.alg, nonce=nonce).json_dumps()
@classmethod
@ -75,12 +72,14 @@ class Network(object):
function will raise an error. Otherwise, wrong Content-Type
is ignored, but logged.
:raises letsencrypt.messages2.Error: If server response body
:raises .messages.Error: If server response body
carries HTTP Problem (draft-ietf-appsawg-http-problem-00).
:raises letsencrypt.errors.NetworkError: In case of other
networking errors.
:raises .ClientError: In case of other networking errors.
"""
logging.debug('Received response %s (headers: %s): %r',
response, response.headers, response.content)
response_ct = response.headers.get('Content-Type')
try:
# TODO: response.json() is called twice, once here, and
@ -96,15 +95,13 @@ class Network(object):
'Ignoring wrong Content-Type (%r) for JSON Error',
response_ct)
try:
logging.error("Error: %s", jobj)
logging.error("Response from server: %s", response.content)
raise messages2.Error.from_json(jobj)
raise messages.Error.from_json(jobj)
except jose.DeserializationError as error:
# Couldn't deserialize JSON object
raise errors.NetworkError((response, error))
raise errors.ClientError((response, error))
else:
# response is not JSON object
raise errors.NetworkError(response)
raise errors.ClientError(response)
else:
if jobj is not None and response_ct != cls.JSON_CONTENT_TYPE:
logging.debug(
@ -112,13 +109,13 @@ class Network(object):
'response', response_ct)
if content_type == cls.JSON_CONTENT_TYPE and jobj is None:
raise errors.NetworkError(
raise errors.ClientError(
'Unexpected response Content-Type: {0}'.format(response_ct))
def _get(self, uri, content_type=JSON_CONTENT_TYPE, **kwargs):
"""Send GET request.
:raises letsencrypt.errors.NetworkError:
:raises .ClientError:
:returns: HTTP Response
:rtype: `requests.Response`
@ -129,22 +126,22 @@ class Network(object):
try:
response = requests.get(uri, **kwargs)
except requests.exceptions.RequestException as error:
raise errors.NetworkError(error)
raise errors.ClientError(error)
self._check_response(response, content_type=content_type)
return response
def _add_nonce(self, response):
if self.REPLAY_NONCE_HEADER in response.headers:
nonce = response.headers[self.REPLAY_NONCE_HEADER]
error = acme_jws.Header.validate_nonce(nonce)
error = jws.Header.validate_nonce(nonce)
if error is None:
logging.debug('Storing nonce: %r', nonce)
self._nonces.add(nonce)
else:
raise errors.NetworkError('Invalid nonce ({0}): {1}'.format(
raise errors.ClientError('Invalid nonce ({0}): {1}'.format(
nonce, error))
else:
raise errors.NetworkError(
raise errors.ClientError(
'Server {0} response did not include a replay nonce'.format(
response.request.method))
@ -160,7 +157,7 @@ class Network(object):
:param JSONDeSerializable obj: Will be wrapped in JWS.
:param str content_type: Expected ``Content-Type``, fails if not set.
:raises acme.messages2.NetworkError:
:raises acme.messages.ClientError:
:returns: HTTP Response
:rtype: `requests.Response`
@ -172,8 +169,7 @@ class Network(object):
try:
response = requests.post(uri, data=data, **kwargs)
except requests.exceptions.RequestException as error:
raise errors.NetworkError(error)
logging.debug('Received response %s: %r', response, response.text)
raise errors.ClientError(error)
self._add_nonce(response)
self._check_response(response, content_type=content_type)
@ -190,15 +186,15 @@ class Network(object):
try:
new_authzr_uri = response.links['next']['url']
except KeyError:
raise errors.NetworkError('"next" link missing')
raise errors.ClientError('"next" link missing')
return messages2.RegistrationResource(
body=messages2.Registration.from_json(response.json()),
return messages.RegistrationResource(
body=messages.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[
def register(self, contact=messages.Registration._fields[
'contact'].default):
"""Register.
@ -208,10 +204,10 @@ class Network(object):
:returns: Registration Resource.
:rtype: `.RegistrationResource`
:raises letsencrypt.errors.UnexpectedUpdate:
:raises .UnexpectedUpdate:
"""
new_reg = messages2.Registration(contact=contact)
new_reg = messages.Registration(contact=contact)
response = self._post(self.new_reg_uri, new_reg)
assert response.status_code == httplib.CREATED # TODO: handle errors
@ -222,24 +218,6 @@ class Network(object):
return regr
def register_from_account(self, account):
"""Register with server.
:param account: Account
:type account: :class:`letsencrypt.account.Account`
:returns: Updated account
:rtype: :class:`letsencrypt.account.Account`
"""
details = (
"mailto:" + account.email if account.email is not None else None,
"tel:" + account.phone if account.phone is not None else None,
)
account.regr = self.register(contact=tuple(
det for det in details if det is not None))
return account
def update_registration(self, regr):
"""Update registration.
@ -262,7 +240,6 @@ class Network(object):
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
@ -288,10 +265,10 @@ class Network(object):
try:
new_cert_uri = response.links['next']['url']
except KeyError:
raise errors.NetworkError('"next" link missing')
raise errors.ClientError('"next" link missing')
authzr = messages2.AuthorizationResource(
body=messages2.Authorization.from_json(response.json()),
authzr = messages.AuthorizationResource(
body=messages.Authorization.from_json(response.json()),
uri=response.headers.get('Location', uri),
new_cert_uri=new_cert_uri)
if authzr.body.identifier != identifier:
@ -302,7 +279,7 @@ class Network(object):
"""Request challenges.
:param identifier: Identifier to be challenged.
:type identifier: `.messages2.Identifier`
:type identifier: `.messages.Identifier`
:param str new_authzr_uri: new-authorization URI
@ -310,7 +287,7 @@ class Network(object):
:rtype: `.AuthorizationResource`
"""
new_authz = messages2.Authorization(identifier=identifier)
new_authz = messages.Authorization(identifier=identifier)
response = self._post(new_authzr_uri, new_authz)
assert response.status_code == httplib.CREATED # TODO: handle errors
return self._authzr_from_response(response, identifier)
@ -329,8 +306,8 @@ class Network(object):
:rtype: `.AuthorizationResource`
"""
return self.request_challenges(messages2.Identifier(
typ=messages2.IDENTIFIER_FQDN, value=domain), new_authz_uri)
return self.request_challenges(messages.Identifier(
typ=messages.IDENTIFIER_FQDN, value=domain), new_authz_uri)
def answer_challenge(self, challb, response):
"""Answer challenge.
@ -344,17 +321,17 @@ class Network(object):
:returns: Challenge Resource with updated body.
:rtype: `.ChallengeResource`
:raises errors.UnexpectedUpdate:
:raises .UnexpectedUpdate:
"""
response = self._post(challb.uri, response)
try:
authzr_uri = response.links['up']['url']
except KeyError:
raise errors.NetworkError('"up" Link header missing')
challr = messages2.ChallengeResource(
raise errors.ClientError('"up" Link header missing')
challr = messages.ChallengeResource(
authzr_uri=authzr_uri,
body=messages2.ChallengeBody.from_json(response.json()))
body=messages.ChallengeBody.from_json(response.json()))
# TODO: check that challr.uri == response.headers['Location']?
if challr.uri != challb.uri:
raise errors.UnexpectedUpdate(challr.uri)
@ -413,14 +390,14 @@ class Network(object):
:param authzrs: `list` of `.AuthorizationResource`
:returns: Issued certificate
:rtype: `.messages2.CertificateResource`
:rtype: `.messages.CertificateResource`
"""
assert authzrs, "Authorizations list is empty"
logging.debug("Requesting issuance...")
# TODO: assert len(authzrs) == number of SANs
req = messages2.CertificateRequest(
req = messages.CertificateRequest(
csr=csr, authorizations=tuple(authzr.uri for authzr in authzrs))
content_type = self.DER_CONTENT_TYPE # TODO: add 'cert_type 'argument
@ -435,9 +412,9 @@ class Network(object):
try:
uri = response.headers['Location']
except KeyError:
raise errors.NetworkError('"Location" Header missing')
raise errors.ClientError('"Location" Header missing')
return messages2.CertificateResource(
return messages.CertificateResource(
uri=uri, authzrs=authzrs, cert_chain_uri=cert_chain_uri,
body=jose.ComparableX509(
M2Crypto.X509.load_cert_der_string(response.content)))
@ -460,7 +437,7 @@ class Network(object):
``Retry-After`` is not present in the response.
:returns: ``(cert, updated_authzrs)`` `tuple` where ``cert`` is
the issued certificate (`.messages2.CertificateResource.),
the issued certificate (`.messages.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
@ -489,7 +466,8 @@ class Network(object):
updated_authzr, response = self.poll(updated[authzr])
updated[authzr] = updated_authzr
if updated_authzr.body.status != messages2.STATUS_VALID:
# pylint: disable=no-member
if updated_authzr.body.status != messages.STATUS_VALID:
# push back to the priority queue, with updated retry_after
heapq.heappush(waiting, (self.retry_after(
response, default=mintime), authzr))
@ -527,7 +505,7 @@ class Network(object):
# "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')
raise errors.ClientError('Location header missing')
if response.headers['Location'] != certr.uri:
raise errors.UnexpectedUpdate(response.text)
return certr.update(body=cert)
@ -562,22 +540,17 @@ class Network(object):
else:
return None
def revoke(self, certr, when=messages2.Revocation.NOW):
def revoke(self, cert):
"""Revoke certificate.
:param certr: Certificate Resource
:type certr: `.CertificateResource`
:param .ComparableX509 cert: `M2Crypto.X509.X509` wrapped in
`.ComparableX509`
:param when: When should the revocation take place? Takes
the same values as `.messages2.Revocation.revoke`.
:raises letsencrypt.errors.NetworkError: If revocation is
unsuccessful.
:raises .ClientError: If revocation is unsuccessful.
"""
rev = messages2.Revocation(revoke=when, authorizations=tuple(
authzr.uri for authzr in certr.authzrs))
response = self._post(certr.uri, rev)
response = self._post(messages.Revocation.url(self.new_reg_uri),
messages.Revocation(certificate=cert))
if response.status_code != httplib.OK:
raise errors.NetworkError(
raise errors.ClientError(
'Successful revocation must return HTTP OK status')

View file

@ -1,42 +1,29 @@
"""Tests for letsencrypt.network2."""
"""Tests for acme.client."""
import datetime
import httplib
import os
import pkg_resources
import shutil
import tempfile
import unittest
import M2Crypto
import mock
import requests
from acme import challenges
from acme import errors
from acme import jose
from acme import jws as acme_jws
from acme import messages2
from letsencrypt import account
from letsencrypt import errors
from acme import messages
from acme import messages_test
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(
'acme.jose', os.path.join('testdata', 'rsa512_key.pem')))
KEY2 = jose.JWKRSA.load(pkg_resources.resource_string(
'acme.jose', os.path.join('testdata', 'rsa256_key.pem')))
class NetworkTest(unittest.TestCase):
"""Tests for letsencrypt.network2.Network."""
class ClientTest(unittest.TestCase):
"""Tests for acme.client.Client."""
# pylint: disable=too-many-instance-attributes,too-many-public-methods
@ -44,8 +31,8 @@ class NetworkTest(unittest.TestCase):
self.verify_ssl = mock.MagicMock()
self.wrap_in_jws = mock.MagicMock(return_value=mock.sentinel.wrapped)
from letsencrypt.network2 import Network
self.net = Network(
from acme.client import Client
self.net = Client(
new_reg_uri='https://www.letsencrypt-demo.org/acme/new-reg',
key=KEY, alg=jose.RS256, verify_ssl=self.verify_ssl)
self.nonce = jose.b64encode('Nonce')
@ -58,44 +45,39 @@ class NetworkTest(unittest.TestCase):
self.post = mock.MagicMock(return_value=self.response)
self.get = mock.MagicMock(return_value=self.response)
self.identifier = messages2.Identifier(
typ=messages2.IDENTIFIER_FQDN, value='example.com')
self.config = mock.Mock(accounts_dir=tempfile.mkdtemp())
self.identifier = messages.Identifier(
typ=messages.IDENTIFIER_FQDN, value='example.com')
# Registration
self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212')
reg = messages2.Registration(
reg = messages.Registration(
contact=self.contact, key=KEY.public(), recovery_token='t')
self.regr = messages2.RegistrationResource(
self.regr = messages.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,
challb = messages.ChallengeBody(
uri=(authzr_uri + '/1'), status=messages.STATUS_VALID,
chall=challenges.DNS(token='foo'))
self.challr = messages2.ChallengeResource(
self.challr = messages.ChallengeResource(
body=challb, authzr_uri=authzr_uri)
self.authz = messages2.Authorization(
identifier=messages2.Identifier(
typ=messages2.IDENTIFIER_FQDN, value='example.com'),
self.authz = messages.Authorization(
identifier=messages.Identifier(
typ=messages.IDENTIFIER_FQDN, value='example.com'),
challenges=(challb,), combinations=None)
self.authzr = messages2.AuthorizationResource(
self.authzr = messages.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,),
self.certr = messages.CertificateResource(
body=messages_test.CERT, authzrs=(self.authzr,),
uri='https://www.letsencrypt-demo.org/acme/cert/1',
cert_chain_uri='https://www.letsencrypt-demo.org/ca')
def tearDown(self):
shutil.rmtree(self.config.accounts_dir)
def _mock_post_get(self):
# pylint: disable=protected-access
self.net._post = self.post
@ -127,22 +109,22 @@ class NetworkTest(unittest.TestCase):
self.response.json.return_value = {}
# pylint: disable=protected-access
self.assertRaises(
errors.NetworkError, self.net._check_response, self.response)
errors.ClientError, 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(
self.response.json.return_value = messages.Error(
detail='foo', typ='serverInternal', title='some title').to_json()
# pylint: disable=protected-access
self.assertRaises(
messages2.Error, self.net._check_response, self.response)
messages.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)
errors.ClientError, self.net._check_response, self.response)
def test_check_response_ok_no_jobj_ct_required(self):
self.response.json.side_effect = ValueError
@ -150,7 +132,7 @@ class NetworkTest(unittest.TestCase):
self.response.headers['Content-Type'] = response_ct
# pylint: disable=protected-access
self.assertRaises(
errors.NetworkError, self.net._check_response, self.response,
errors.ClientError, self.net._check_response, self.response,
content_type=self.net.JSON_CONTENT_TYPE)
def test_check_response_ok_no_jobj_no_ct(self):
@ -167,14 +149,14 @@ class NetworkTest(unittest.TestCase):
# pylint: disable=protected-access
self.net._check_response(self.response)
@mock.patch('letsencrypt.network2.requests')
@mock.patch('acme.client.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')
self.assertRaises(errors.ClientError, self.net._get, 'uri')
@mock.patch('letsencrypt.network2.requests')
@mock.patch('acme.client.requests')
def test_get(self, requests_mock):
# pylint: disable=protected-access
self.net._check_response = mock.MagicMock()
@ -186,16 +168,16 @@ class NetworkTest(unittest.TestCase):
# pylint: disable=protected-access
self.net._wrap_in_jws = self.wrap_in_jws
@mock.patch('letsencrypt.network2.requests')
@mock.patch('acme.client.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._mock_wrap_in_jws()
self.assertRaises(
errors.NetworkError, self.net._post, 'uri', mock.sentinel.obj)
errors.ClientError, self.net._post, 'uri', mock.sentinel.obj)
@mock.patch('letsencrypt.network2.requests')
@mock.patch('acme.client.requests')
def test_post(self, requests_mock):
# pylint: disable=protected-access
self.net._check_response = mock.MagicMock()
@ -206,7 +188,7 @@ class NetworkTest(unittest.TestCase):
self.net._check_response.assert_called_once_with(
requests_mock.post('uri', mock.sentinel.wrapped), content_type='ct')
@mock.patch('letsencrypt.network2.requests')
@mock.patch('acme.client.requests')
def test_post_replay_nonce_handling(self, requests_mock):
# pylint: disable=protected-access
self.net._check_response = mock.MagicMock()
@ -214,7 +196,7 @@ class NetworkTest(unittest.TestCase):
self.net._nonces.clear()
self.assertRaises(
errors.NetworkError, self.net._post, 'uri', mock.sentinel.obj)
errors.ClientError, self.net._post, 'uri', mock.sentinel.obj)
nonce2 = jose.b64encode('Nonce2')
requests_mock.head('uri').headers = {
@ -231,9 +213,9 @@ class NetworkTest(unittest.TestCase):
# wrong nonce
requests_mock.post('uri').headers = {self.net.REPLAY_NONCE_HEADER: 'F'}
self.assertRaises(
errors.NetworkError, self.net._post, 'uri', mock.sentinel.obj)
errors.ClientError, self.net._post, 'uri', mock.sentinel.obj)
@mock.patch('letsencrypt.client.network2.requests')
@mock.patch('acme.client.requests')
def test_get_post_verify_ssl(self, requests_mock):
# pylint: disable=protected-access
self._mock_wrap_in_jws()
@ -274,30 +256,7 @@ class NetworkTest(unittest.TestCase):
self.response.status_code = httplib.CREATED
self._mock_post_get()
self.assertRaises(
errors.NetworkError, self.net.register, self.regr.body)
def test_register_from_account(self):
self.net.register = mock.Mock()
acc = account.Account(
self.config, 'key', email='cert-admin@example.com',
phone='+12025551212')
self.net.register_from_account(acc)
self.net.register.assert_called_with(contact=self.contact)
def test_register_from_account_partial_info(self):
self.net.register = mock.Mock()
acc = account.Account(
self.config, 'key', email='cert-admin@example.com')
acc2 = account.Account(self.config, 'key')
self.net.register_from_account(acc)
self.net.register.assert_called_with(
contact=('mailto:cert-admin@example.com',))
self.net.register_from_account(acc2)
self.net.register.assert_called_with(contact=())
errors.ClientError, self.net.register, self.regr.body)
def test_update_registration(self):
self.response.headers['Location'] = self.regr.uri
@ -339,7 +298,7 @@ class NetworkTest(unittest.TestCase):
self.response.status_code = httplib.CREATED
self._mock_post_get()
self.assertRaises(
errors.NetworkError, self.net.request_challenges,
errors.ClientError, self.net.request_challenges,
self.identifier, self.regr)
def test_request_domain_challenges(self):
@ -363,7 +322,7 @@ class NetworkTest(unittest.TestCase):
def test_answer_challenge_missing_next(self):
self._mock_post_get()
self.assertRaises(errors.NetworkError, self.net.answer_challenge,
self.assertRaises(errors.ClientError, self.net.answer_challenge,
self.challr.body, challenges.DNSResponse())
def test_retry_after_date(self):
@ -372,7 +331,7 @@ class NetworkTest(unittest.TestCase):
datetime.datetime(1999, 12, 31, 23, 59, 59),
self.net.retry_after(response=self.response, default=10))
@mock.patch('letsencrypt.network2.datetime')
@mock.patch('acme.client.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
@ -382,7 +341,7 @@ class NetworkTest(unittest.TestCase):
datetime.datetime(2015, 3, 27, 0, 0, 10),
self.net.retry_after(response=self.response, default=10))
@mock.patch('letsencrypt.network2.datetime')
@mock.patch('acme.client.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
@ -392,7 +351,7 @@ class NetworkTest(unittest.TestCase):
datetime.datetime(2015, 3, 27, 0, 0, 50),
self.net.retry_after(response=self.response, default=10))
@mock.patch('letsencrypt.network2.datetime')
@mock.patch('acme.client.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
@ -413,30 +372,30 @@ class NetworkTest(unittest.TestCase):
self.assertRaises(errors.UnexpectedUpdate, self.net.poll, self.authzr)
def test_request_issuance(self):
self.response.content = CERT.as_der()
self.response.content = messages_test.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,)))
self.assertEqual(self.certr, self.net.request_issuance(
messages_test.CSR, (self.authzr,)))
# TODO: check POST args
def test_request_issuance_missing_up(self):
self.response.content = CERT.as_der()
self.response.content = messages_test.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,)))
self.net.request_issuance(messages_test.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,))
errors.ClientError, self.net.request_issuance,
messages_test.CSR, (self.authzr,))
@mock.patch('letsencrypt.network2.datetime')
@mock.patch('letsencrypt.network2.time')
@mock.patch('acme.client.datetime')
@mock.patch('acme.client.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))
@ -462,7 +421,7 @@ class NetworkTest(unittest.TestCase):
if not authzr.retries: # no more retries
done = mock.MagicMock(uri=authzr.uri, times=authzr.times)
done.body.status = messages2.STATUS_VALID
done.body.status = messages.STATUS_VALID
return done, []
# response (2nd result tuple element) is reduced to only
@ -517,10 +476,10 @@ class NetworkTest(unittest.TestCase):
def test_check_cert(self):
self.response.headers['Location'] = self.certr.uri
self.response.content = CERT2.as_der()
self.response.content = messages_test.CERT.as_der()
self._mock_post_get()
self.assertEqual(
self.certr.update(body=CERT2), self.net.check_cert(self.certr))
self.assertEqual(self.certr.update(body=messages_test.CERT),
self.net.check_cert(self.certr))
# TODO: split here and separate test
self.response.headers['Location'] = 'foo'
@ -528,9 +487,9 @@ class NetworkTest(unittest.TestCase):
errors.UnexpectedUpdate, self.net.check_cert, self.certr)
def test_check_cert_missing_location(self):
self.response.content = CERT2.as_der()
self.response.content = messages_test.CERT.as_der()
self._mock_post_get()
self.assertRaises(errors.NetworkError, self.net.check_cert, self.certr)
self.assertRaises(errors.ClientError, self.net.check_cert, self.certr)
def test_refresh(self):
self.net.check_cert = mock.MagicMock()
@ -550,13 +509,14 @@ class NetworkTest(unittest.TestCase):
def test_revoke(self):
self._mock_post_get()
self.net.revoke(self.certr, when=messages2.Revocation.NOW)
self.post.assert_called_once_with(self.certr.uri, mock.ANY)
self.net.revoke(self.certr.body)
self.post.assert_called_once_with(messages.Revocation.url(
self.net.new_reg_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)
self.assertRaises(errors.ClientError, self.net.revoke, self.certr)
if __name__ == '__main__':

View file

@ -1,8 +1,15 @@
"""ACME errors."""
from acme.jose import errors as jose_errors
class Error(Exception):
"""Generic ACME error."""
class SchemaValidationError(jose_errors.DeserializationError):
"""JSON schema ACME object validation error."""
class ClientError(Error):
"""Network error."""
class UnexpectedUpdate(ClientError):
"""Unexpected update."""

View file

@ -218,11 +218,12 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable):
def fields_to_partial_json(self):
"""Serialize fields to JSON."""
jobj = {}
omitted = set()
for slot, field in self._fields.iteritems():
value = getattr(self, slot)
if field.omit(value):
logging.debug('Omitting empty field "%s" (%s)', slot, value)
omitted.add((slot, value))
else:
try:
jobj[field.json_name] = field.encode(value)
@ -230,6 +231,10 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable):
raise errors.SerializationError(
'Could not encode {0} ({1}): {2}'.format(
slot, value, error))
if omitted:
# pylint: disable=star-args
logging.debug('Omitted empty fields: %s', ', '.join(
'{0!s}={1!r}'.format(*field) for field in omitted))
return jobj
def to_partial_json(self):

View file

@ -4,7 +4,8 @@ The following command has been used to generate test keys:
and for the CSR:
python -c from 'letsencrypt.crypto_util import make_csr;
import pkg_resources; open("csr2.pem",
"w").write(make_csr(pkg_resources.resource_string("letsencrypt.tests",
"testdata/rsa512_key.pem"), ["example2.com"])[0])'
openssl req -key rsa512_key.pem -new -subj '/CN=example.com' -outform DER > csr.der
and for the certificate:
openssl req -key rsa512_key.pem -new -subj '/CN=example.com' -x509 -outform DER > cert.der

BIN
acme/jose/testdata/cert.der vendored Normal file

Binary file not shown.

BIN
acme/jose/testdata/csr.der vendored Normal file

Binary file not shown.

View file

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

View file

@ -1,106 +1,294 @@
"""ACME protocol v00 messages.
.. warning:: This module is an implementation of the draft `ACME
protocol version 00`_, and not the "RESTified" `ACME protocol version
01`_ or later. It should work with `older Node.js implementation`_,
but will definitely not work with Boulder_. It is kept for reference
purposes only.
.. _`ACME protocol version 00`:
https://github.com/letsencrypt/acme-spec/blob/v00/draft-barnes-acme.md
.. _`ACME protocol version 01`:
https://github.com/letsencrypt/acme-spec/blob/v01/draft-barnes-acme.md
.. _Boulder: https://github.com/letsencrypt/boulder
.. _`older Node.js implementation`:
https://github.com/letsencrypt/node-acme/commit/f42aa5b7fad4cd2fc289653c4ab14f18052367b3
"""
import jsonschema
"""ACME protocol messages."""
import urlparse
from acme import challenges
from acme import errors
from acme import fields
from acme import jose
from acme import other
from acme import util
class Message(jose.TypedJSONObjectWithFields):
# _fields_to_partial_json | pylint: disable=abstract-method
# pylint: disable=too-few-public-methods
"""ACME message."""
TYPES = {}
type_field_name = "type"
class Error(jose.JSONObjectWithFields, Exception):
"""ACME error.
schema = NotImplemented
"""JSON schema the object is tested against in :meth:`from_json`.
Subclasses must overrride it with a value that is acceptable by
:func:`jsonschema.validate`, most probably using
:func:`acme.util.load_schema`.
https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00
"""
ERROR_TYPE_NAMESPACE = 'urn:acme:error:'
ERROR_TYPE_DESCRIPTIONS = {
'badCSR': 'The CSR is unacceptable (e.g., due to a short key)',
'badNonce': 'The client sent an unacceptable anti-replay nonce',
'connection': 'The server could not connect to the client for DV',
'dnssec': 'The server could not validate a DNSSEC signed domain',
'malformed': 'The request message was malformed',
'serverInternal': 'The server experienced an internal error',
'tls': 'The server experienced a TLS error during DV',
'unauthorized': 'The client lacks sufficient authorization',
'unknownHost': 'The server could not resolve a domain name',
}
typ = jose.Field('type')
title = jose.Field('title', omitempty=True)
detail = jose.Field('detail')
@typ.encoder
def typ(value): # pylint: disable=missing-docstring,no-self-argument
return Error.ERROR_TYPE_NAMESPACE + value
@typ.decoder
def typ(value): # pylint: disable=missing-docstring,no-self-argument
# pylint thinks isinstance(value, Error), so startswith is not found
# pylint: disable=no-member
if not value.startswith(Error.ERROR_TYPE_NAMESPACE):
raise jose.DeserializationError('Missing error type prefix')
without_prefix = value[len(Error.ERROR_TYPE_NAMESPACE):]
if without_prefix not in Error.ERROR_TYPE_DESCRIPTIONS:
raise jose.DeserializationError('Error type not recognized')
return without_prefix
@property
def description(self):
"""Hardcoded error description based on its type."""
return self.ERROR_TYPE_DESCRIPTIONS[self.typ]
def __str__(self):
if self.typ is not None:
return ' :: '.join([self.typ, self.description, self.detail])
else:
return str(self.detail)
class _Constant(jose.JSONDeSerializable):
"""ACME constant."""
__slots__ = ('name',)
POSSIBLE_NAMES = NotImplemented
def __init__(self, name):
self.POSSIBLE_NAMES[name] = self
self.name = name
def to_partial_json(self):
return self.name
@classmethod
def from_json(cls, jobj):
"""Deserialize from (possibly invalid) JSON object.
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]
Note that the input ``jobj`` has not been sanitized in any way.
def __repr__(self):
return '{0}({1})'.format(self.__class__.__name__, self.name)
:param jobj: JSON object.
def __eq__(self, other):
return isinstance(other, type(self)) and other.name == self.name
:raises acme.errors.SchemaValidationError: if the input
JSON object could not be validated against JSON schema specified
in :attr:`schema`.
:raises acme.jose.errors.DeserializationError: for any
other generic error in decoding.
:returns: instance of the class
"""
msg_cls = cls.get_type_cls(jobj)
# TODO: is that schema testing still relevant?
try:
jsonschema.validate(jobj, msg_cls.schema)
except jsonschema.ValidationError as error:
raise errors.SchemaValidationError(error)
return super(Message, cls).from_json(jobj)
def __ne__(self, other):
return not self.__eq__(other)
@Message.register # pylint: disable=too-few-public-methods
class Challenge(Message):
"""ACME "challenge" message.
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')
:ivar str nonce: Random data, **not** base64-encoded.
:ivar list challenges: List of
:class:`~acme.challenges.Challenge` objects.
.. todo::
1. can challenges contain two challenges of the same type?
2. can challenges contain duplicates?
3. check "combinations" indices are in valid range
4. turn "combinations" elements into sets?
5. turn "combinations" into set?
class IdentifierType(_Constant):
"""ACME identifier type."""
POSSIBLE_NAMES = {}
IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder
class Identifier(jose.JSONObjectWithFields):
"""ACME identifier.
:ivar acme.messages.IdentifierType typ:
"""
typ = "challenge"
schema = util.load_schema(typ)
typ = jose.Field('type', decoder=IdentifierType.from_json)
value = jose.Field('value')
session_id = jose.Field("sessionID")
nonce = jose.Field("nonce", encoder=jose.b64encode,
decoder=jose.decode_b64jose)
challenges = jose.Field("challenges")
combinations = jose.Field("combinations", omitempty=True, default=())
class Resource(jose.JSONObjectWithFields):
"""ACME Resource.
:ivar str uri: Location of the resource.
:ivar acme.messages.ResourceBody body: Resource body.
"""
body = jose.Field('body')
class ResourceWithURI(Resource):
"""ACME Resource with URI.
:ivar str uri: Location of the resource.
"""
uri = jose.Field('uri') # no ChallengeResource.uri
class ResourceBody(jose.JSONObjectWithFields):
"""ACME Resource Body."""
class Registration(ResourceBody):
"""Registration Resource Body.
:ivar acme.jose.jwk.JWK key: Public key.
:ivar tuple contact: Contact information following ACME spec
"""
# on new-reg key server ignores 'key' and populates it based on
# JWS.signature.combined.jwk
key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json)
contact = jose.Field('contact', omitempty=True, default=())
recovery_token = jose.Field('recoveryToken', omitempty=True)
agreement = jose.Field('agreement', omitempty=True)
phone_prefix = 'tel:'
email_prefix = 'mailto:'
@classmethod
def from_data(cls, phone=None, email=None, **kwargs):
"""Create registration resource from contact details."""
details = list(kwargs.pop('contact', ()))
if phone is not None:
details.append(cls.phone_prefix + phone)
if email is not None:
details.append(cls.email_prefix + email)
kwargs['contact'] = tuple(details)
return cls(**kwargs)
def _filter_contact(self, prefix):
return tuple(
detail[len(prefix):] for detail in self.contact
if detail.startswith(prefix))
@property
def phones(self):
"""All phones found in the ``contact`` field."""
return self._filter_contact(self.phone_prefix)
@property
def emails(self):
"""All emails found in the ``contact`` field."""
return self._filter_contact(self.email_prefix)
@property
def phone(self):
"""Phone."""
assert len(self.phones) == 1
return self.phones[0]
@property
def email(self):
"""Email."""
assert len(self.emails) == 1
return self.emails[0]
class RegistrationResource(ResourceWithURI):
"""Registration Resource.
:ivar acme.messages.Registration body:
:ivar str new_authzr_uri: URI found in the 'next' ``Link`` header
:ivar str terms_of_service: URL for the CA TOS.
"""
body = jose.Field('body', decoder=Registration.from_json)
new_authzr_uri = jose.Field('new_authzr_uri')
terms_of_service = jose.Field('terms_of_service', omitempty=True)
class ChallengeBody(ResourceBody):
"""Challenge Resource Body.
.. todo::
Confusingly, this has a similar name to `.challenges.Challenge`,
as well as `.achallenges.AnnotatedChallenge`. Please use names
such as ``challb`` to distinguish instances of this class from
``achall``.
:ivar acme.challenges.Challenge: Wrapped challenge.
Conveniently, all challenge fields are proxied, i.e. you can
call ``challb.x`` to get ``challb.chall.x`` contents.
:ivar acme.messages.Status status:
:ivar datetime.datetime validated:
"""
__slots__ = ('chall',)
uri = jose.Field('uri')
status = jose.Field('status', decoder=Status.from_json,
omitempty=True, default=STATUS_PENDING)
validated = fields.RFC3339Field('validated', omitempty=True)
error = jose.Field('error', decoder=Error.from_json,
omitempty=True, default=None)
def to_partial_json(self):
jobj = super(ChallengeBody, self).to_partial_json()
jobj.update(self.chall.to_partial_json())
return jobj
@classmethod
def fields_from_json(cls, jobj):
jobj_fields = super(ChallengeBody, cls).fields_from_json(jobj)
jobj_fields['chall'] = challenges.Challenge.from_json(jobj)
return jobj_fields
def __getattr__(self, name):
return getattr(self.chall, name)
class ChallengeResource(Resource):
"""Challenge Resource.
:ivar acme.messages.ChallengeBody body:
:ivar str authzr_uri: URI found in the 'up' ``Link`` header.
"""
body = jose.Field('body', decoder=ChallengeBody.from_json)
authzr_uri = jose.Field('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 # pylint: disable=no-member
class Authorization(ResourceBody):
"""Authorization Resource Body.
:ivar acme.messages.Identifier identifier:
:ivar list challenges: `list` of `.ChallengeBody`
:ivar tuple combinations: Challenge combinations (`tuple` of `tuple`
of `int`, as opposed to `list` of `list` from the spec).
:ivar acme.jose.jwk.JWK key: Public key.
:ivar tuple contact:
:ivar acme.messages.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)
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(challenges.Challenge.from_json(chall) for chall in value)
return tuple(ChallengeBody.from_json(chall) for chall in value)
@property
def resolved_combinations(self):
@ -109,259 +297,61 @@ class Challenge(Message):
for combo in self.combinations)
@Message.register # pylint: disable=too-few-public-methods
class ChallengeRequest(Message):
"""ACME "challengeRequest" message."""
typ = "challengeRequest"
schema = util.load_schema(typ)
identifier = jose.Field("identifier")
class AuthorizationResource(ResourceWithURI):
"""Authorization Resource.
@Message.register # pylint: disable=too-few-public-methods
class Authorization(Message):
"""ACME "authorization" message.
:ivar jwk: :class:`acme.jose.JWK`
:ivar acme.messages.Authorization body:
:ivar str new_cert_uri: URI found in the 'next' ``Link`` header
"""
typ = "authorization"
schema = util.load_schema(typ)
recovery_token = jose.Field("recoveryToken", omitempty=True)
identifier = jose.Field("identifier", omitempty=True)
jwk = jose.Field("jwk", decoder=jose.JWK.from_json, omitempty=True)
body = jose.Field('body', decoder=Authorization.from_json)
new_cert_uri = jose.Field('new_cert_uri')
@Message.register
class AuthorizationRequest(Message):
"""ACME "authorizationRequest" message.
class CertificateRequest(jose.JSONObjectWithFields):
"""ACME new-cert request.
:ivar str nonce: Random data from the corresponding
:attr:`Challenge.nonce`, **not** base64-encoded.
:ivar list responses: List of completed challenges (
:class:`acme.challenges.ChallengeResponse`).
:ivar signature: Signature (:class:`acme.other.Signature`).
:ivar acme.jose.util.ComparableX509 csr:
`M2Crypto.X509.Request` wrapped in `.ComparableX509`
:ivar tuple authorizations: `tuple` of URIs (`str`)
"""
typ = "authorizationRequest"
schema = util.load_schema(typ)
csr = jose.Field('csr', decoder=jose.decode_csr, encoder=jose.encode_csr)
authorizations = jose.Field('authorizations', decoder=tuple)
session_id = jose.Field("sessionID")
nonce = jose.Field("nonce", encoder=jose.b64encode,
decoder=jose.decode_b64jose)
responses = jose.Field("responses")
signature = jose.Field("signature", decoder=other.Signature.from_json)
contact = jose.Field("contact", omitempty=True, default=())
@responses.decoder
def responses(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(challenges.ChallengeResponse.from_json(chall)
for chall in value)
class CertificateResource(ResourceWithURI):
"""Certificate Resource.
:ivar 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`.
"""
cert_chain_uri = jose.Field('cert_chain_uri')
authzrs = jose.Field('authzrs')
class Revocation(jose.JSONObjectWithFields):
"""Revocation message.
:ivar .ComparableX509 certificate: `M2Crypto.X509.X509` wrapped in
`.ComparableX509`
"""
certificate = jose.Field(
'certificate', decoder=jose.decode_cert, encoder=jose.encode_cert)
# TODO: acme-spec#138, this allows only one ACME server instance per domain
PATH = '/acme/revoke-cert'
"""Path to revocation URL, see `url`"""
@classmethod
def create(cls, name, key, sig_nonce=None, **kwargs):
"""Create signed "authorizationRequest".
def url(cls, base):
"""Get revocation URL.
:param str name: Hostname
:param key: Key used for signing.
:type key: :class:`Crypto.PublicKey.RSA`
:param str sig_nonce: Nonce used for signature. Useful for testing.
:kwargs: Any other arguments accepted by the class constructor.
:returns: Signed "authorizationRequest" ACME message.
:rtype: :class:`AuthorizationRequest`
:param str base: New Registration Resource or server (root) URL.
"""
# pylint: disable=too-many-arguments
signature = other.Signature.from_msg(
name + kwargs["nonce"], key, sig_nonce)
return cls(
signature=signature, contact=kwargs.pop("contact", ()), **kwargs)
def verify(self, name):
"""Verify signature.
.. warning:: Caller must check that the public key encoded in the
:attr:`signature`'s :class:`acme.jose.JWK` object
is the correct key for a given context.
:param str name: Hostname
:returns: True iff ``signature`` can be verified, False otherwise.
:rtype: bool
"""
# self.signature is not Field | pylint: disable=no-member
return self.signature.verify(name + self.nonce)
@Message.register # pylint: disable=too-few-public-methods
class Certificate(Message):
"""ACME "certificate" message.
:ivar certificate: The certificate (:class:`M2Crypto.X509.X509`
wrapped in :class:`acme.util.ComparableX509`).
:ivar list chain: Chain of certificates (:class:`M2Crypto.X509.X509`
wrapped in :class:`acme.util.ComparableX509` ).
"""
typ = "certificate"
schema = util.load_schema(typ)
certificate = jose.Field("certificate", encoder=jose.encode_cert,
decoder=jose.decode_cert)
chain = jose.Field("chain", omitempty=True, default=())
refresh = jose.Field("refresh", omitempty=True)
@chain.decoder
def chain(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(jose.decode_cert(cert) for cert in value)
@chain.encoder
def chain(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(jose.encode_cert(cert) for cert in value)
@Message.register
class CertificateRequest(Message):
"""ACME "certificateRequest" message.
:ivar csr: Certificate Signing Request (:class:`M2Crypto.X509.Request`
wrapped in :class:`acme.util.ComparableX509`.
:ivar signature: Signature (:class:`acme.other.Signature`).
"""
typ = "certificateRequest"
schema = util.load_schema(typ)
csr = jose.Field("csr", encoder=jose.encode_csr,
decoder=jose.decode_csr)
signature = jose.Field("signature", decoder=other.Signature.from_json)
@classmethod
def create(cls, key, sig_nonce=None, **kwargs):
"""Create signed "certificateRequest".
:param key: Key used for signing.
:type key: :class:`Crypto.PublicKey.RSA`
:param str sig_nonce: Nonce used for signature. Useful for testing.
:kwargs: Any other arguments accepted by the class constructor.
:returns: Signed "certificateRequest" ACME message.
:rtype: :class:`CertificateRequest`
"""
return cls(signature=other.Signature.from_msg(
kwargs["csr"].as_der(), key, sig_nonce), **kwargs)
def verify(self):
"""Verify signature.
.. warning:: Caller must check that the public key encoded in the
:attr:`signature`'s :class:`acme.jose.JWK` object
is the correct key for a given context.
:returns: True iff ``signature`` can be verified, False otherwise.
:rtype: bool
"""
# self.signature is not Field | pylint: disable=no-member
return self.signature.verify(self.csr.as_der())
@Message.register # pylint: disable=too-few-public-methods
class Defer(Message):
"""ACME "defer" message."""
typ = "defer"
schema = util.load_schema(typ)
token = jose.Field("token")
interval = jose.Field("interval", omitempty=True)
message = jose.Field("message", omitempty=True)
@Message.register # pylint: disable=too-few-public-methods
class Error(Message):
"""ACME "error" message."""
typ = "error"
schema = util.load_schema(typ)
error = jose.Field("error")
message = jose.Field("message", omitempty=True)
more_info = jose.Field("moreInfo", omitempty=True)
MESSAGE_CODES = {
"malformed": "The request message was malformed",
"unauthorized": "The client lacks sufficient authorization",
"serverInternal": "The server experienced an internal error",
"notSupported": "The request type is not supported",
"unknown": "The server does not recognize an ID/token in the request",
"badCSR": "The CSR is unacceptable (e.g., due to a short key)",
}
@Message.register # pylint: disable=too-few-public-methods
class Revocation(Message):
"""ACME "revocation" message."""
typ = "revocation"
schema = util.load_schema(typ)
@Message.register
class RevocationRequest(Message):
"""ACME "revocationRequest" message.
:ivar certificate: Certificate (:class:`M2Crypto.X509.X509`
wrapped in :class:`acme.util.ComparableX509`).
:ivar signature: Signature (:class:`acme.other.Signature`).
"""
typ = "revocationRequest"
schema = util.load_schema(typ)
certificate = jose.Field("certificate", decoder=jose.decode_cert,
encoder=jose.encode_cert)
signature = jose.Field("signature", decoder=other.Signature.from_json)
@classmethod
def create(cls, key, sig_nonce=None, **kwargs):
"""Create signed "revocationRequest".
:param key: Key used for signing.
:type key: :class:`Crypto.PublicKey.RSA`
:param str sig_nonce: Nonce used for signature. Useful for testing.
:kwargs: Any other arguments accepted by the class constructor.
:returns: Signed "revocationRequest" ACME message.
:rtype: :class:`RevocationRequest`
"""
return cls(signature=other.Signature.from_msg(
kwargs["certificate"].as_der(), key, sig_nonce), **kwargs)
def verify(self):
"""Verify signature.
.. warning:: Caller must check that the public key encoded in the
:attr:`signature`'s :class:`acme.jose.JWK` object
is the correct key for a given context.
:returns: True iff ``signature`` can be verified, False otherwise.
:rtype: bool
"""
# self.signature is not Field | pylint: disable=no-member
return self.signature.verify(self.certificate.as_der())
@Message.register # pylint: disable=too-few-public-methods
class StatusRequest(Message):
"""ACME "statusRequest" message."""
typ = "statusRequest"
schema = util.load_schema(typ)
token = jose.Field("token")
return urlparse.urljoin(base, cls.PATH)

View file

@ -1,298 +0,0 @@
"""ACME protocol messages."""
from acme import challenges
from acme import fields
from 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)',
'badNonce': 'The client sent an unacceptable anti-replay nonce',
}
typ = jose.Field('type')
title = jose.Field('title', omitempty=True)
detail = jose.Field('detail')
@typ.encoder
def typ(value): # pylint: disable=missing-docstring,no-self-argument
return Error.ERROR_TYPE_NAMESPACE + value
@typ.decoder
def typ(value): # pylint: disable=missing-docstring,no-self-argument
# pylint thinks isinstance(value, Error), so startswith is not found
# pylint: disable=no-member
if not value.startswith(Error.ERROR_TYPE_NAMESPACE):
raise jose.DeserializationError('Missing error type prefix')
without_prefix = value[len(Error.ERROR_TYPE_NAMESPACE):]
if without_prefix not in Error.ERROR_TYPE_DESCRIPTIONS:
raise jose.DeserializationError('Error type not recognized')
return without_prefix
@property
def description(self):
"""Hardcoded error description based on its type."""
return self.ERROR_TYPE_DESCRIPTIONS[self.typ]
def __str__(self):
if self.typ is not None:
return ' :: '.join([self.typ, self.description, self.detail])
else:
return str(self.detail)
class _Constant(jose.JSONDeSerializable):
"""ACME constant."""
__slots__ = ('name',)
POSSIBLE_NAMES = NotImplemented
def __init__(self, name):
self.POSSIBLE_NAMES[name] = self
self.name = name
def to_partial_json(self):
return self.name
@classmethod
def from_json(cls, value):
if value not in cls.POSSIBLE_NAMES:
raise jose.DeserializationError(
'{0} not recognized'.format(cls.__name__))
return cls.POSSIBLE_NAMES[value]
def __repr__(self):
return '{0}({1})'.format(self.__class__.__name__, self.name)
def __eq__(self, other):
return isinstance(other, type(self)) and other.name == self.name
def __ne__(self, other):
return not self.__eq__(other)
class Status(_Constant):
"""ACME "status" field."""
POSSIBLE_NAMES = {}
STATUS_UNKNOWN = Status('unknown')
STATUS_PENDING = Status('pending')
STATUS_PROCESSING = Status('processing')
STATUS_VALID = Status('valid')
STATUS_INVALID = Status('invalid')
STATUS_REVOKED = Status('revoked')
class IdentifierType(_Constant):
"""ACME identifier type."""
POSSIBLE_NAMES = {}
IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder
class Identifier(jose.JSONObjectWithFields):
"""ACME identifier.
:ivar acme.messages2.IdentifierType typ:
"""
typ = jose.Field('type', decoder=IdentifierType.from_json)
value = jose.Field('value')
class Resource(jose.ImmutableMap):
"""ACME Resource.
:ivar 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 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 acme.jose.jwk.JWK key: Public key.
:ivar tuple contact: Contact information following ACME spec
"""
# on new-reg key server ignores 'key' and populates it based on
# JWS.signature.combined.jwk
key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json)
contact = jose.Field('contact', omitempty=True, default=())
recovery_token = jose.Field('recoveryToken', omitempty=True)
agreement = jose.Field('agreement', omitempty=True)
class ChallengeResource(Resource, jose.JSONObjectWithFields):
"""Challenge Resource.
:ivar acme.messages2.ChallengeBody body:
:ivar str authzr_uri: URI found in the 'up' ``Link`` header.
"""
__slots__ = ('body', 'authzr_uri')
@property
def uri(self): # pylint: disable=missing-docstring,no-self-argument
# bug? 'method already defined line None'
# pylint: disable=function-redefined
return self.body.uri
class ChallengeBody(ResourceBody):
"""Challenge Resource Body.
.. todo::
Confusingly, this has a similar name to `.challenges.Challenge`,
as well as `.achallenges.AnnotatedChallenge`. Please use names
such as ``challb`` to distinguish instances of this class from
``achall``.
:ivar acme.challenges.Challenge: Wrapped challenge.
Conveniently, all challenge fields are proxied, i.e. you can
call ``challb.x`` to get ``challb.chall.x`` contents.
:ivar acme.messages2.Status status:
:ivar datetime.datetime validated:
"""
__slots__ = ('chall',)
uri = jose.Field('uri')
status = jose.Field('status', decoder=Status.from_json)
validated = fields.RFC3339Field('validated', omitempty=True)
def to_partial_json(self):
jobj = super(ChallengeBody, self).to_partial_json()
jobj.update(self.chall.to_partial_json())
return jobj
@classmethod
def fields_from_json(cls, jobj):
jobj_fields = super(ChallengeBody, cls).fields_from_json(jobj)
jobj_fields['chall'] = challenges.Challenge.from_json(jobj)
return jobj_fields
def __getattr__(self, name):
return getattr(self.chall, name)
class AuthorizationResource(Resource):
"""Authorization Resource.
:ivar 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 acme.messages2.Identifier identifier:
:ivar list challenges: `list` of `.ChallengeBody`
:ivar tuple combinations: Challenge combinations (`tuple` of `tuple`
of `int`, as opposed to `list` of `list` from the spec).
:ivar acme.jose.jwk.JWK key: Public key.
:ivar tuple contact:
:ivar 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)
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 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 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

@ -1,250 +0,0 @@
"""Tests for acme.messages2."""
import datetime
import os
import pkg_resources
import unittest
import mock
import pytz
from Crypto.PublicKey import RSA
from acme import challenges
from acme import jose
KEY = jose.util.HashableRSAKey(RSA.importKey(pkg_resources.resource_string(
'acme.jose', os.path.join('testdata', 'rsa512_key.pem'))))
class ErrorTest(unittest.TestCase):
"""Tests for acme.messages2.Error."""
def setUp(self):
from acme.messages2 import Error
self.error = Error(detail='foo', typ='malformed', title='title')
self.jobj = {'detail': 'foo', 'title': 'some title'}
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 acme.messages2 import Error
self.jobj['type'] = 'malformed'
self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
self.jobj['type'] = 'not valid bare type'
self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
def test_typ_decoder_not_recognized(self):
from acme.messages2 import Error
self.jobj['type'] = 'urn:acme:error:baz'
self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
def test_description(self):
self.assertEqual(
'The request message was malformed', self.error.description)
def test_from_json_hashable(self):
from acme.messages2 import Error
hash(Error.from_json(self.error.to_json()))
def test_str(self):
self.assertEqual(
'malformed :: The request message was malformed :: foo',
str(self.error))
self.assertEqual('foo', str(self.error.update(typ=None)))
class ConstantTest(unittest.TestCase):
"""Tests for acme.messages2._Constant."""
def setUp(self):
from acme.messages2 import _Constant
class MockConstant(_Constant): # pylint: disable=missing-docstring
POSSIBLE_NAMES = {}
self.MockConstant = MockConstant # pylint: disable=invalid-name
self.const_a = MockConstant('a')
self.const_b = MockConstant('b')
def test_to_partial_json(self):
self.assertEqual('a', self.const_a.to_partial_json())
self.assertEqual('b', self.const_b.to_partial_json())
def test_from_json(self):
self.assertEqual(self.const_a, self.MockConstant.from_json('a'))
self.assertRaises(
jose.DeserializationError, self.MockConstant.from_json, 'c')
def test_from_json_hashable(self):
hash(self.MockConstant.from_json('a'))
def test_repr(self):
self.assertEqual('MockConstant(a)', repr(self.const_a))
self.assertEqual('MockConstant(b)', repr(self.const_b))
def test_equality(self):
const_a_prime = self.MockConstant('a')
self.assertFalse(self.const_a == self.const_b)
self.assertTrue(self.const_a == const_a_prime)
self.assertTrue(self.const_a != self.const_b)
self.assertFalse(self.const_a != const_a_prime)
class RegistrationTest(unittest.TestCase):
"""Tests for acme.messages2.Registration."""
def setUp(self):
key = jose.jwk.JWKRSA(key=KEY.publickey())
contact = ('mailto:letsencrypt-client@letsencrypt.org',)
recovery_token = 'XYZ'
agreement = 'https://letsencrypt.org/terms'
from 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 acme.messages2 import Registration
self.assertEqual(self.reg, Registration.from_json(self.jobj_from))
def test_from_json_hashable(self):
from acme.messages2 import Registration
hash(Registration.from_json(self.jobj_from))
class ChallengeResourceTest(unittest.TestCase):
"""Tests for acme.messages2.ChallengeResource."""
def test_uri(self):
from 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 acme.messages2.ChallengeBody."""
def setUp(self):
self.chall = challenges.DNS(token='foo')
from acme.messages2 import ChallengeBody
from 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 acme.messages2 import ChallengeBody
self.assertEqual(self.challb, ChallengeBody.from_json(self.jobj_from))
def test_from_json_hashable(self):
from acme.messages2 import ChallengeBody
hash(ChallengeBody.from_json(self.jobj_from))
def test_proxy(self):
self.assertEqual('foo', self.challb.token)
class AuthorizationTest(unittest.TestCase):
"""Tests for acme.messages2.Authorization."""
def setUp(self):
from acme.messages2 import ChallengeBody
from acme.messages2 import STATUS_VALID
self.challbs = (
ChallengeBody(
uri='http://challb1', status=STATUS_VALID,
chall=challenges.SimpleHTTP(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 acme.messages2 import Authorization
from acme.messages2 import Identifier
from 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 acme.messages2 import Authorization
Authorization.from_json(self.jobj_from)
def test_from_json_hashable(self):
from 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 acme.messages2.RevocationTest."""
def setUp(self):
from 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 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 acme.messages2 import Revocation
hash(Revocation.from_json(self.rev_now.to_json()))
if __name__ == '__main__':
unittest.main() # pragma: no cover

View file

@ -3,477 +3,346 @@ import os
import pkg_resources
import unittest
import Crypto.PublicKey.RSA
from Crypto.PublicKey import RSA
import M2Crypto
import mock
from acme import challenges
from acme import errors
from acme import jose
from acme import other
KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
CERT = jose.ComparableX509(M2Crypto.X509.load_cert_string(
pkg_resources.resource_string(
'acme.jose', os.path.join('testdata', 'rsa512_key.pem'))))
'acme.jose', os.path.join('testdata', 'cert.der')),
M2Crypto.X509.FORMAT_DER))
CSR = jose.ComparableX509(M2Crypto.X509.load_request_string(
pkg_resources.resource_string(
'acme.jose', os.path.join('testdata', 'csr.der')),
M2Crypto.X509.FORMAT_DER))
KEY = jose.util.HashableRSAKey(RSA.importKey(pkg_resources.resource_string(
'acme.jose', os.path.join('testdata', 'rsa512_key.pem'))))
CERT = jose.ComparableX509(M2Crypto.X509.load_cert(
pkg_resources.resource_filename(
'letsencrypt.tests', os.path.join('testdata', 'cert.pem'))))
CSR = jose.ComparableX509(M2Crypto.X509.load_request(
pkg_resources.resource_filename(
'letsencrypt.tests', os.path.join('testdata', 'csr.pem'))))
CSR2 = jose.ComparableX509(M2Crypto.X509.load_request(
pkg_resources.resource_filename(
'acme.jose', os.path.join('testdata', 'csr2.pem'))))
class MessageTest(unittest.TestCase):
"""Tests for acme.messages.Message."""
def setUp(self):
# pylint: disable=missing-docstring,too-few-public-methods
from acme.messages import Message
class MockParentMessage(Message):
# pylint: disable=abstract-method
TYPES = {}
@MockParentMessage.register
class MockMessage(MockParentMessage):
typ = 'test'
schema = {
'type': 'object',
'properties': {
'price': {'type': 'number'},
'name': {'type': 'string'},
},
}
price = jose.Field('price')
name = jose.Field('name')
self.parent_cls = MockParentMessage
self.msg = MockMessage(price=123, name='foo')
def test_from_json_validates(self):
self.assertRaises(errors.SchemaValidationError,
self.parent_cls.from_json,
{'type': 'test', 'price': 'asd'})
class ChallengeTest(unittest.TestCase):
def setUp(self):
challs = (
challenges.SimpleHTTP(token='IlirfxKKXAsHtmzK29Pj8A'),
challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'),
challenges.RecoveryToken(),
)
combinations = ((0, 2), (1, 2))
from acme.messages import Challenge
self.msg = Challenge(
session_id='aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
nonce='\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9',
challenges=challs, combinations=combinations)
self.jmsg_to = {
'type': 'challenge',
'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
'challenges': challs,
'combinations': combinations,
}
self.jmsg_from = {
'type': 'challenge',
'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
'challenges': [chall.to_json() for chall in challs],
'combinations': [[0, 2], [1, 2]], # TODO array tuples
}
def test_resolved_combinations(self):
self.assertEqual(self.msg.resolved_combinations, (
(
challenges.SimpleHTTP(token='IlirfxKKXAsHtmzK29Pj8A'),
challenges.RecoveryToken()
),
(
challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'),
challenges.RecoveryToken(),
)
))
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from acme.messages import Challenge
self.assertEqual(Challenge.from_json(self.jmsg_from), self.msg)
def test_json_without_optionals(self):
del self.jmsg_from['combinations']
del self.jmsg_to['combinations']
from acme.messages import Challenge
msg = Challenge.from_json(self.jmsg_from)
self.assertEqual(msg.combinations, ())
self.assertEqual(msg.to_partial_json(), self.jmsg_to)
class ChallengeRequestTest(unittest.TestCase):
def setUp(self):
from acme.messages import ChallengeRequest
self.msg = ChallengeRequest(identifier='example.com')
self.jmsg = {
'type': 'challengeRequest',
'identifier': 'example.com',
}
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
from acme.messages import ChallengeRequest
self.assertEqual(ChallengeRequest.from_json(self.jmsg), self.msg)
class AuthorizationTest(unittest.TestCase):
def setUp(self):
jwk = jose.JWKRSA(key=KEY.publickey())
from acme.messages import Authorization
self.msg = Authorization(recovery_token='tok', jwk=jwk,
identifier='example.com')
self.jmsg = {
'type': 'authorization',
'recoveryToken': 'tok',
'identifier': 'example.com',
'jwk': jwk,
}
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_partial_json()
from acme.messages import Authorization
self.assertEqual(Authorization.from_json(self.jmsg), self.msg)
def test_json_without_optionals(self):
del self.jmsg['recoveryToken']
del self.jmsg['identifier']
del self.jmsg['jwk']
from acme.messages import Authorization
msg = Authorization.from_json(self.jmsg)
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_partial_json())
class AuthorizationRequestTest(unittest.TestCase):
def setUp(self):
self.responses = (
challenges.SimpleHTTPResponse(path='Hf5GrX4Q7EBax9hc2jJnfw'),
None, # null
challenges.RecoveryTokenResponse(token='23029d88d9e123e'),
)
self.contact = ("mailto:cert-admin@example.com", "tel:+12025551212")
signature = other.Signature(
alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.publickey()),
sig='-v\xd8\xc2\xa3\xba0\xd6\x92\x16\xb5.\xbe\xa1[\x04\xbe'
'\x1b\xa1X\xd2)\x18\x94\x8f\xd7\xd0\xc0\xbbcI`W\xdf v'
'\xe4\xed\xe8\x03J\xe8\xc8<?\xc8W\x94\x94cj(\xe7\xaa$'
'\x92\xe9\x96\x11\xc2\xefx\x0bR',
nonce='\xab?\x08o\xe6\x81$\x9f\xa1\xc9\x025\x1c\x1b\xa5+')
from acme.messages import AuthorizationRequest
self.msg = AuthorizationRequest(
session_id='aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
nonce='\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9',
responses=self.responses,
signature=signature,
contact=self.contact,
)
self.jmsg_to = {
'type': 'authorizationRequest',
'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
'responses': self.responses,
'signature': signature,
'contact': self.contact,
}
self.jmsg_from = {
'type': 'authorizationRequest',
'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ',
'responses': [None if response is None else response.to_json()
for response in self.responses],
'signature': signature.to_json(),
# TODO: schema validation doesn't recognize tuples as
# arrays :(
'contact': list(self.contact),
}
def test_create(self):
from acme.messages import AuthorizationRequest
self.assertEqual(self.msg, AuthorizationRequest.create(
name='example.com', key=KEY, responses=self.responses,
nonce='\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9',
session_id='aefoGaavieG9Wihuk2aufai3aeZ5EeW4',
sig_nonce='\xab?\x08o\xe6\x81$\x9f\xa1\xc9\x025\x1c\x1b\xa5+',
contact=self.contact))
def test_verify(self):
self.assertTrue(self.msg.verify('example.com'))
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from acme.messages import AuthorizationRequest
self.assertEqual(
self.msg, AuthorizationRequest.from_json(self.jmsg_from))
def test_json_without_optionals(self):
del self.jmsg_from['contact']
del self.jmsg_to['contact']
from acme.messages import AuthorizationRequest
msg = AuthorizationRequest.from_json(self.jmsg_from)
self.assertEqual(msg.contact, ())
self.assertEqual(self.jmsg_to, msg.to_partial_json())
class CertificateTest(unittest.TestCase):
def setUp(self):
refresh = 'https://example.com/refresh/Dr8eAwTVQfSS/'
from acme.messages import Certificate
self.msg = Certificate(
certificate=CERT, chain=(CERT,), refresh=refresh)
self.jmsg_to = {
'type': 'certificate',
'certificate': jose.b64encode(CERT.as_der()),
'chain': (jose.b64encode(CERT.as_der()),),
'refresh': refresh,
}
self.jmsg_from = self.jmsg_to.copy()
# TODO: schema validation array tuples
self.jmsg_from['chain'] = list(self.jmsg_from['chain'])
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from acme.messages import Certificate
self.assertEqual(Certificate.from_json(self.jmsg_from), self.msg)
def test_json_without_optionals(self):
del self.jmsg_from['chain']
del self.jmsg_from['refresh']
del self.jmsg_to['chain']
del self.jmsg_to['refresh']
from acme.messages import Certificate
msg = Certificate.from_json(self.jmsg_from)
self.assertEqual(msg.chain, ())
self.assertTrue(msg.refresh is None)
self.assertEqual(self.jmsg_to, msg.to_partial_json())
class CertificateRequestTest(unittest.TestCase):
def setUp(self):
signature = other.Signature(
alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.publickey()),
sig='\x15\xed\x84\xaa:\xf2DO\x0e9 \xbcg\xf8\xc0\xcf\x87\x9a'
'\x95\xeb\xffT[\x84[\xec\x85\x7f\x8eK\xe9\xc2\x12\xc8Q'
'\xafo\xc6h\x07\xba\xa6\xdf\xd1\xa7"$\xba=Z\x13n\x14\x0b'
'k\xfe\xee\xb4\xe4\xc8\x05\x9a\x08\xa7',
nonce='\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9')
from acme.messages import CertificateRequest
self.msg = CertificateRequest(csr=CSR, signature=signature)
self.jmsg_to = {
'type': 'certificateRequest',
'csr': jose.b64encode(CSR.as_der()),
'signature': signature,
}
self.jmsg_from = self.jmsg_to.copy()
self.jmsg_from['signature'] = self.jmsg_from['signature'].to_json()
def test_create(self):
from acme.messages import CertificateRequest
self.assertEqual(self.msg, CertificateRequest.create(
csr=CSR, key=KEY,
sig_nonce='\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9'))
def test_verify(self):
self.assertTrue(self.msg.verify())
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from acme.messages import CertificateRequest
self.assertEqual(self.msg, CertificateRequest.from_json(self.jmsg_from))
class DeferTest(unittest.TestCase):
def setUp(self):
from acme.messages import Defer
self.msg = Defer(
token='O7-s9MNq1siZHlgrMzi9_A', interval=60,
message='Warming up the HSM')
self.jmsg = {
'type': 'defer',
'token': 'O7-s9MNq1siZHlgrMzi9_A',
'interval': 60,
'message': 'Warming up the HSM',
}
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
from acme.messages import Defer
self.assertEqual(Defer.from_json(self.jmsg), self.msg)
def test_json_without_optionals(self):
del self.jmsg['interval']
del self.jmsg['message']
from acme.messages import Defer
msg = Defer.from_json(self.jmsg)
self.assertTrue(msg.interval is None)
self.assertTrue(msg.message is None)
self.assertEqual(self.jmsg, msg.to_partial_json())
format=M2Crypto.X509.FORMAT_DER, file=pkg_resources.resource_filename(
'acme.jose', os.path.join('testdata', 'cert.der'))))
class ErrorTest(unittest.TestCase):
"""Tests for acme.messages.Error."""
def setUp(self):
from acme.messages import Error
self.msg = Error(
error='badCSR', message='RSA keys must be at least 2048 bits long',
more_info='https://ca.example.com/documentation/csr-requirements')
self.error = Error(detail='foo', typ='malformed', title='title')
self.jobj = {'detail': 'foo', 'title': 'some title'}
self.jmsg = {
'type': 'error',
'error': 'badCSR',
'message':'RSA keys must be at least 2048 bits long',
'moreInfo': 'https://ca.example.com/documentation/csr-requirements',
}
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 acme.messages import Error
self.jobj['type'] = 'malformed'
self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
self.jobj['type'] = 'not valid bare type'
self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
def test_typ_decoder_not_recognized(self):
from acme.messages import Error
self.jobj['type'] = 'urn:acme:error:baz'
self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
def test_description(self):
self.assertEqual(
'The request message was malformed', self.error.description)
def test_from_json_hashable(self):
from acme.messages import Error
hash(Error.from_json(self.error.to_json()))
def test_str(self):
self.assertEqual(
'malformed :: The request message was malformed :: foo',
str(self.error))
self.assertEqual('foo', str(self.error.update(typ=None)))
class ConstantTest(unittest.TestCase):
"""Tests for acme.messages._Constant."""
def setUp(self):
from acme.messages 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(self.msg.to_partial_json(), self.jmsg)
self.assertEqual('a', self.const_a.to_partial_json())
self.assertEqual('b', self.const_b.to_partial_json())
def test_from_json(self):
self.assertEqual(self.const_a, self.MockConstant.from_json('a'))
self.assertRaises(
jose.DeserializationError, self.MockConstant.from_json, 'c')
def test_from_json_hashable(self):
hash(self.MockConstant.from_json('a'))
def test_repr(self):
self.assertEqual('MockConstant(a)', repr(self.const_a))
self.assertEqual('MockConstant(b)', repr(self.const_b))
def test_equality(self):
const_a_prime = self.MockConstant('a')
self.assertFalse(self.const_a == self.const_b)
self.assertTrue(self.const_a == const_a_prime)
self.assertTrue(self.const_a != self.const_b)
self.assertFalse(self.const_a != const_a_prime)
class RegistrationTest(unittest.TestCase):
"""Tests for acme.messages.Registration."""
def setUp(self):
key = jose.jwk.JWKRSA(key=KEY.publickey())
contact = (
'mailto:admin@foo.com',
'tel:1234',
)
recovery_token = 'XYZ'
agreement = 'https://letsencrypt.org/terms'
from acme.messages 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_from_data(self):
from acme.messages import Registration
reg = Registration.from_data(phone='1234', email='admin@foo.com')
self.assertEqual(reg.contact, (
'tel:1234',
'mailto:admin@foo.com',
))
def test_phones(self):
self.assertEqual(('1234',), self.reg.phones)
def test_emails(self):
self.assertEqual(('admin@foo.com',), self.reg.emails)
def test_phone(self):
self.assertEqual('1234', self.reg.phone)
def test_email(self):
self.assertEqual('admin@foo.com', self.reg.email)
def test_to_partial_json(self):
self.assertEqual(self.jobj_to, self.reg.to_partial_json())
def test_from_json(self):
from acme.messages import Registration
self.assertEqual(self.reg, Registration.from_json(self.jobj_from))
def test_from_json_hashable(self):
from acme.messages import Registration
hash(Registration.from_json(self.jobj_from))
class RegistrationResourceTest(unittest.TestCase):
"""Tests for acme.messages.RegistrationResource."""
def setUp(self):
from acme.messages import RegistrationResource
self.regr = RegistrationResource(
body=mock.sentinel.body, uri=mock.sentinel.uri,
new_authzr_uri=mock.sentinel.new_authzr_uri,
terms_of_service=mock.sentinel.terms_of_service)
def test_to_partial_json(self):
self.assertEqual(self.regr.to_json(), {
'body': mock.sentinel.body,
'uri': mock.sentinel.uri,
'new_authzr_uri': mock.sentinel.new_authzr_uri,
'terms_of_service': mock.sentinel.terms_of_service,
})
class ChallengeResourceTest(unittest.TestCase):
"""Tests for acme.messages.ChallengeResource."""
def test_uri(self):
from acme.messages import ChallengeResource
self.assertEqual('http://challb', ChallengeResource(body=mock.MagicMock(
uri='http://challb'), authzr_uri='http://authz').uri)
class ChallengeBodyTest(unittest.TestCase):
"""Tests for acme.messages.ChallengeBody."""
def setUp(self):
self.chall = challenges.DNS(token='foo')
from acme.messages import ChallengeBody
from acme.messages import Error
self.assertEqual(Error.from_json(self.jmsg), self.msg)
from acme.messages import STATUS_INVALID
self.status = STATUS_INVALID
error = Error(typ='serverInternal',
detail='Unable to communicate with DNS server')
self.challb = ChallengeBody(
uri='http://challb', chall=self.chall, status=self.status,
error=error)
def test_json_without_optionals(self):
del self.jmsg['message']
del self.jmsg['moreInfo']
self.jobj_to = {
'uri': 'http://challb',
'status': self.status,
'type': 'dns',
'token': 'foo',
'error': error,
}
self.jobj_from = self.jobj_to.copy()
self.jobj_from['status'] = 'invalid'
self.jobj_from['error'] = {
'type': 'urn:acme:error:serverInternal',
'detail': 'Unable to communicate with DNS server',
}
from acme.messages import Error
msg = Error.from_json(self.jmsg)
self.assertTrue(msg.message is None)
self.assertTrue(msg.more_info is None)
self.assertEqual(self.jmsg, msg.to_partial_json())
def test_to_partial_json(self):
self.assertEqual(self.jobj_to, self.challb.to_partial_json())
def test_from_json(self):
from acme.messages import ChallengeBody
self.assertEqual(self.challb, ChallengeBody.from_json(self.jobj_from))
def test_from_json_hashable(self):
from acme.messages import ChallengeBody
hash(ChallengeBody.from_json(self.jobj_from))
def test_proxy(self):
self.assertEqual('foo', self.challb.token)
class AuthorizationTest(unittest.TestCase):
"""Tests for acme.messages.Authorization."""
def setUp(self):
from acme.messages import ChallengeBody
from acme.messages import STATUS_VALID
self.challbs = (
ChallengeBody(
uri='http://challb1', status=STATUS_VALID,
chall=challenges.SimpleHTTP(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 acme.messages import Authorization
from acme.messages import Identifier
from acme.messages 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 acme.messages import Authorization
Authorization.from_json(self.jobj_from)
def test_from_json_hashable(self):
from acme.messages 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 AuthorizationResourceTest(unittest.TestCase):
"""Tests for acme.messages.AuthorizationResource."""
def test_json_de_serializable(self):
from acme.messages import AuthorizationResource
authzr = AuthorizationResource(
uri=mock.sentinel.uri,
body=mock.sentinel.body,
new_cert_uri=mock.sentinel.new_cert_uri,
)
self.assertTrue(isinstance(authzr, jose.JSONDeSerializable))
class CertificateRequestTest(unittest.TestCase):
"""Tests for acme.messages.CertificateRequest."""
def setUp(self):
from acme.messages import CertificateRequest
self.req = CertificateRequest(csr=CSR, authorizations=('foo',))
def test_json_de_serializable(self):
self.assertTrue(isinstance(self.req, jose.JSONDeSerializable))
from acme.messages import CertificateRequest
self.assertEqual(
self.req, CertificateRequest.from_json(self.req.to_json()))
class CertificateResourceTest(unittest.TestCase):
"""Tests for acme.messages.CertificateResourceTest."""
def setUp(self):
from acme.messages import CertificateResource
self.certr = CertificateResource(
body=CERT, uri=mock.sentinel.uri, authzrs=(),
cert_chain_uri=mock.sentinel.cert_chain_uri)
def test_json_de_serializable(self):
self.assertTrue(isinstance(self.certr, jose.JSONDeSerializable))
from acme.messages import CertificateResource
self.assertEqual(
self.certr, CertificateResource.from_json(self.certr.to_json()))
class RevocationTest(unittest.TestCase):
"""Tests for acme.messages.RevocationTest."""
def test_url(self):
from acme.messages import Revocation
url = 'https://letsencrypt-demo.org/acme/revoke-cert'
self.assertEqual(url, Revocation.url('https://letsencrypt-demo.org'))
self.assertEqual(
url, Revocation.url('https://letsencrypt-demo.org/acme/new-reg'))
def setUp(self):
from acme.messages import Revocation
self.msg = Revocation()
self.jmsg = {'type': 'revocation'}
self.rev = Revocation(certificate=CERT)
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
def test_from_json_hashable(self):
from acme.messages import Revocation
self.assertEqual(Revocation.from_json(self.jmsg), self.msg)
class RevocationRequestTest(unittest.TestCase):
def setUp(self):
self.sig_nonce = '\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9'
signature = other.Signature(
alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.publickey()),
sig='eJ\xfe\x12"U\x87\x8b\xbf/ ,\xdeP\xb2\xdc1\xb00\xe5\x1dB'
'\xfch<\xc6\x9eH@!\x1c\x16\xb2\x0b_\xc4\xddP\x89\xc8\xce?'
'\x16g\x069I\xb9\xb3\x91\xb9\x0e$3\x9f\x87\x8e\x82\xca\xc5'
's\xd9\xd0\xe7',
nonce=self.sig_nonce)
from acme.messages import RevocationRequest
self.msg = RevocationRequest(certificate=CERT, signature=signature)
self.jmsg_to = {
'type': 'revocationRequest',
'certificate': jose.b64encode(CERT.as_der()),
'signature': signature,
}
self.jmsg_from = self.jmsg_to.copy()
self.jmsg_from['signature'] = self.jmsg_from['signature'].to_json()
def test_create(self):
from acme.messages import RevocationRequest
self.assertEqual(self.msg, RevocationRequest.create(
certificate=CERT, key=KEY, sig_nonce=self.sig_nonce))
def test_verify(self):
self.assertTrue(self.msg.verify())
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
def test_from_json(self):
from acme.messages import RevocationRequest
self.assertEqual(self.msg, RevocationRequest.from_json(self.jmsg_from))
class StatusRequestTest(unittest.TestCase):
def setUp(self):
from acme.messages import StatusRequest
self.msg = StatusRequest(token=u'O7-s9MNq1siZHlgrMzi9_A')
self.jmsg = {
'type': 'statusRequest',
'token': u'O7-s9MNq1siZHlgrMzi9_A',
}
def test_to_partial_json(self):
self.assertEqual(self.msg.to_partial_json(), self.jmsg)
def test_from_json(self):
from acme.messages import StatusRequest
self.assertEqual(StatusRequest.from_json(self.jmsg), self.msg)
hash(Revocation.from_json(self.rev.to_json()))
if __name__ == '__main__':

View file

@ -1,21 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/authorization#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for an authorization message",
"type": "object",
"required": ["type"],
"properties": {
"type" : {
"enum" : [ "authorization" ]
},
"recoveryToken" : {
"type": "string"
},
"identifier" : {
"type": "string"
},
"jwk": {
"$ref": "file:acme/schemata/jwk.json"
}
}
}

View file

@ -1,38 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/authorizationRequest#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for an authorizationRequest message",
"type": "object",
"required": ["type", "sessionID", "nonce", "signature", "responses"],
"properties": {
"type" : {
"enum" : [ "authorizationRequest" ]
},
"sessionID" : {
"type" : "string"
},
"nonce" : {
"type": "string"
},
"signature" : {
"$ref": "file:acme/schemata/signature.json"
},
"responses": {
"type": "array",
"minItems": 1,
"items": {
"anyOf": [
{ "$ref": "file:acme/schemata/responseobject.json" },
{ "type": "null" }
]
}
},
"contact": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
}
}
}

View file

@ -1,25 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/certificate#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for a certificate message",
"type": "object",
"required": ["type", "certificate"],
"properties": {
"type" : {
"enum" : [ "certificate" ]
},
"certificate" : {
"type" : "string"
},
"chain" : {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"refresh" : {
"type": "string"
}
}
}

View file

@ -1,19 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/certificateRequest#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for a certificateRequest message",
"type": "object",
"required": ["type", "csr", "signature"],
"properties": {
"type" : {
"enum" : [ "certificateRequest" ]
},
"csr" : {
"type" : "string" ,
"pattern": "^[-_=0-9A-Za-z]+$"
},
"signature" : {
"$ref": "file:acme/schemata/signature.json"
}
}
}

View file

@ -1,36 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/challenge#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for a challenge message",
"type": "object",
"required": ["type", "sessionID", "nonce", "challenges"],
"properties": {
"type" : {
"enum" : [ "challenge" ]
},
"sessionID" : {
"type" : "string"
},
"nonce" : {
"type": "string"
},
"challenges": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "file:acme/schemata/challengeobject.json"
}
},
"combinations": {
"type": "array",
"minItems": 1,
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "integer"
}
}
}
}
}

View file

@ -1,15 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/challengeRequest#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for a challengeRequest message",
"type": "object",
"required": ["type", "identifier"],
"properties": {
"type" : {
"enum" : [ "challengeRequest" ]
},
"identifier" : {
"type": "string"
}
}
}

View file

@ -1,130 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/challengeobject#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Subschema for an individual challenge (within challenge)",
"anyOf": [
{ "type": "object",
"required": ["type", "token"],
"properties": {
"type": {
"enum" : [ "simpleHttp" ]
},
"token": {
"type": "string"
}
}
},
{ "type": "object",
"required": ["type", "r", "nonce"],
"properties": {
"type": {
"enum" : [ "dvsni" ]
},
"r": {
"type" : [ "string" ],
"pattern": "^[-_=0-9A-Za-z]+$"
},
"nonce": {
"type": "string",
"pattern": "^[0-9a-f]+$"
}
}
},
{ "type": "object",
"required": ["type"],
"properties": {
"type": {
"enum" : [ "recoveryContact" ]
},
"activationURL": {
"type" : "string"
},
"successURL": {
"type": "string"
},
"contact": {
"type": "string"
}
}
},
{ "type": "object",
"required": ["type"],
"properties": {
"type": {
"enum" : [ "recoveryToken" ]
}
}
},
{ "type": "object",
"required": ["type", "alg", "nonce", "hints"],
"properties": {
"type": {
"enum" : [ "proofOfPossession" ]
},
"alg": {
"type": "string"
},
"nonce": {
"type": "string",
"pattern": "^[-_=0-9A-Za-z]+$"
},
"hints": {
"type": "object",
"properties": {
"jwk": {
"type": "object"
},
"certFingerprints": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^[0-9a-f]+$"
}
},
"subjectKeyIdentifiers": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^[0-9a-f]+$"
}
},
"serialNumbers": {
"type": "array",
"minItems": 1,
"items": {
"type": "integer"
}
},
"issuers": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"authorizedFor": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
}
}
}
}
},
{ "type": "object",
"required": ["type", "token"],
"properties": {
"type": {
"enum" : [ "dns" ]
},
"token": {
"type": "string"
}
}
}
]
}

View file

@ -1,21 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/defer#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for a defer message",
"type": "object",
"required": ["type", "token"],
"properties": {
"type" : {
"enum" : [ "defer" ]
},
"token" : {
"type": "string"
},
"interval" : {
"type": "integer"
},
"message": {
"type": "string"
}
}
}

View file

@ -1,21 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/error#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for an error message",
"type": "object",
"required": ["type", "error"],
"properties": {
"type" : {
"enum" : [ "error" ]
},
"error" : {
"enum" : [ "malformed", "unauthorized", "serverInternal", "nonSupported", "unknown", "badCSR" ]
},
"message" : {
"type": "string"
},
"moreInfo": {
"type": "string"
}
}
}

View file

@ -1,19 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/jwk#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for a jwk (**kty RSA/e=65537 ONLY**)",
"type": "object",
"required": ["kty", "e", "n"],
"properties": {
"kty": {
"enum" : [ "RSA" ]
},
"e": {
"enum" : [ "AQAB" ]
},
"n": {
"type": "string",
"pattern": "^[-_=0-9A-Za-z]+$"
}
}
}

View file

@ -1,75 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/responseobject#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Subschema for an individual challenge response (within authorizationRequest)",
"anyOf": [
{ "type": "object",
"required": ["type", "path"],
"properties": {
"type": {
"enum" : [ "simpleHttp" ]
},
"path": {
"type": "string"
}
}
},
{ "type": "object",
"required": ["type", "s"],
"properties": {
"type": {
"enum" : [ "dvsni" ]
},
"s": {
"type" : [ "string" ],
"pattern": "^[-_=0-9A-Za-z]+$"
}
}
},
{ "type": "object",
"required": ["type"],
"properties": {
"type": {
"enum" : [ "recoveryContact" ]
},
"token": {
"type" : "string"
}
}
},
{ "type": "object",
"required": ["type"],
"properties": {
"type": {
"enum" : [ "recoveryToken" ]
},
"token": {
"type" : "string"
}
}
},
{ "type": "object",
"required": ["type", "nonce", "signature"],
"properties": {
"type": {
"enum" : [ "proofOfPossession" ]
},
"nonce": {
"type": "string",
"pattern": "^[-_=0-9A-Za-z]+$"
},
"signature": {
"$ref": "file:acme/schemata/signature.json"
}
}
},
{ "type": "object",
"required": ["type"],
"properties": {
"type": {
"enum" : [ "dns" ]
}
}
}
]
}

View file

@ -1,12 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/revocation#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for a revocation message",
"type": "object",
"required": ["type"],
"properties": {
"type" : {
"enum" : [ "revocation" ]
}
}
}

View file

@ -1,18 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/revocationRequest#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for a revocationRequest message",
"type": "object",
"required": ["type", "certificate", "signature"],
"properties": {
"type" : {
"enum" : [ "revocationRequest" ]
},
"certificate" : {
"type" : "string"
},
"signature" : {
"$ref": "file:acme/schemata/signature.json"
}
}
}

View file

@ -1,71 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/signature#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for a signature (alg RS256/e=65537 or P-256 ONLY)",
"type": "object",
"required": ["alg", "nonce", "sig", "jwk"],
"properties": {
"anyOf": [
{
"alg" : {
"enum" : [ "RS256" ]
},
"nonce" : {
"type" : "string"
},
"sig" : {
"type": "string",
"pattern": "^[-_=0-9A-Za-z]+$"
},
"jwk": {
"type": "object",
"required": ["kty", "e", "n"],
"properties": {
"kty": {
"enum" : [ "RSA" ]
},
"e": {
"enum" : [ "AQAB" ]
},
"n": {
"type": "string",
"pattern": "^[-_=0-9A-Za-z]+$"
}
}
}
},
{
"alg" : {
"enum" : [ "ES256" ]
},
"nonce" : {
"type" : "string"
},
"sig" : {
"type": "string",
"pattern": "^[-_=0-9A-Za-z]+$"
},
"jwk": {
"type": "object",
"required": ["kty", "crv", "x", "y"],
"properties": {
"kty": {
"enum" : [ "EC" ]
},
"crv": {
"enum" : [ "P-256" ]
},
"x": {
"type": "string",
"pattern": "^[-_=0-9A-Za-z]+$"
},
"y": {
"type": "string",
"pattern": "^[-_=0-9A-Za-z]+$"
}
}
}
}
]
}
}

View file

@ -1,15 +0,0 @@
{
"id": "https://letsencrypt.org/schema/01/statusRequest#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for a statusRequest message",
"type": "object",
"required": ["type", "token"],
"properties": {
"type" : {
"enum" : [ "statusRequest" ]
},
"token" : {
"type": "string"
}
}
}

View file

@ -1,2 +1,7 @@
This directory contains scripts that install necessary OS-specific
prerequisite dependencies (see docs/using.rst).
prerequisite dependencies (see docs/using.rst).
General dependencies:
- git-core: requirements.txt git+https://*
- ca-certificates: communication with demo ACMO server at
https://www.letsencrypt-demo.org, requirements.txt git+https://*

View file

@ -9,11 +9,14 @@
# - 6.0.10 "squeeze" (x64)
# - 7.8 "wheezy" (x64)
# - 8.0 "jessie" (x64)
# - Raspbian:
# - 7.8 (armhf)
# virtualenv binary can be found in different packages depending on
# distro version (#346)
newer () {
apt-get install -y lsb-release --no-install-recommends
distro=$(lsb_release -si)
# 6.0.10 => 60, 14.04 => 1404
# TODO: in sid version==unstable
@ -29,6 +32,8 @@ newer () {
fi
}
apt-get update
# you can force newer if lsb_release is not available (e.g. Docker
# debian:jessie base image)
if [ "$1" = "newer" ] || newer
@ -43,7 +48,16 @@ fi
# #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
git-core \
python \
python-dev \
"$virtualenv" \
gcc \
swig \
dialog \
libaugeas0 \
libssl-dev \
libffi-dev \
ca-certificates \
dpkg-dev \

20
bootstrap/_rpm_common.sh Executable file
View file

@ -0,0 +1,20 @@
#!/bin/sh
# Tested with:
# - Fedora 22 (x64)
# - Centos 7 (x64: on AWS EC2 t2.micro, DigitalOcean droplet)
# "git-core" seems to be an alias for "git" in CentOS 7 (yum search fails)
yum install -y \
git-core \
python \
python-devel \
python-virtualenv \
python-devel \
gcc \
swig \
dialog \
augeas-libs \
openssl-devel \
libffi-devel \
ca-certificates \

1
bootstrap/centos.sh Symbolic link
View file

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

1
bootstrap/fedora.sh Symbolic link
View file

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

View file

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

View file

@ -0,0 +1,5 @@
:mod:`letsencrypt.plugins.manual`
---------------------------------
.. automodule:: letsencrypt.plugins.manual
:members:

View file

@ -17,6 +17,14 @@ Now you can install the development packages:
./venv/bin/pip install -r requirements.txt -e .[dev,docs,testing]
.. note:: `-e` (short for `--editable`) turns on *editable mode* in
which any source code changes in the current working
directory are "live" and no further `pip install ...`
invocations are necessary while developing.
This is roughly equivalent to `python setup.py develop`. For
more info see `man pip`.
The code base, including your pull requests, **must** have 100% test
statement coverage **and** be compliant with the :ref:`coding style
<coding-style>`.
@ -48,7 +56,7 @@ synced to ``/vagrant``, so you can get started with:
vagrant ssh
cd /vagrant
./venv/bin/pip install -r requirements.txt
./venv/bin/pip install -r requirements.txt .[dev,docs,testing]
sudo ./venv/bin/letsencrypt
Support for other Linux distributions coming soon.
@ -89,7 +97,7 @@ Configurators may implement just one of those).
There are also `~letsencrypt.interfaces.IDisplay` plugins,
which implement bindings to alternative UI libraries.
.. _interfaces.py: https://github.com/letsencrypt/lets-encrypt-preview/blob/master/letsencrypt/interfaces.py
.. _interfaces.py: https://github.com/letsencrypt/letsencrypt/blob/master/letsencrypt/interfaces.py
Authenticators

View file

@ -6,7 +6,7 @@ Packages
described in `#358`_. For the time being those packages are bundled
together into a single repo, and single documentation.
.. _`#358`: https://github.com/letsencrypt/lets-encrypt-preview/issues/358
.. _`#358`: https://github.com/letsencrypt/letsencrypt/issues/358
.. toctree::
:glob:

View file

@ -7,21 +7,19 @@
:members:
Client
------
.. automodule:: acme.client
:members:
Messages
--------
v00
~~~
.. automodule:: acme.messages
:members:
v02
~~~
.. automodule:: acme.messages2
:members:
Challenges
----------
@ -51,9 +49,6 @@ Errors
:members:
:members:
Utilities
---------

View file

@ -5,20 +5,42 @@ Using the Let's Encrypt client
Quick start
===========
Using docker you can quickly get yourself a testing cert. From the
Using Docker_ you can quickly get yourself a testing cert. From the
server that the domain your requesting a cert for resolves to,
download docker, and issue the following command
`install Docker`_, issue the following command:
.. code-block:: shell
sudo docker run -it --rm -p 443:443 --name letsencrypt \
-v "/etc/letsencrypt:/etc/letsencrypt" \
-v "/var/lib/letsencrypt:/var/lib/letsencrypt" \
quay.io/letsencrypt/lets-encrypt-preview:latest
quay.io/letsencrypt/letsencrypt:latest
And follow the instructions. Your new cert will be available in
and follow the instructions. Your new cert will be available in
``/etc/letsencrypt/certs``.
.. _Docker: https://docker.com
.. _`install Docker`: https://docs.docker.com/docker/userguide/
Getting the code
================
Please `install Git`_ and run the following commands:
.. code-block:: shell
git clone https://github.com/letsencrypt/letsencrypt
cd letsencrypt
Alternatively you could `download the ZIP archive`_ and extract the
snapshot of our repository, but it's strongly recommended to use the
above method instead.
.. _`install Git`: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git
.. _`download the ZIP archive`:
https://github.com/letsencrypt/letsencrypt/archive/master.zip
Prerequisites
=============
@ -30,8 +52,8 @@ 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
* `SWIG`_ is required for compiling `M2Crypto`_
* `Augeas`_ is required for the Python bindings
Ubuntu
@ -54,7 +76,7 @@ For squeeze 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
.. _`#280`: https://github.com/letsencrypt/letsencrypt/issues/280
Mac OSX
@ -65,25 +87,71 @@ Mac OSX
sudo ./bootstrap/mac.sh
Fedora
------
.. code-block:: shell
sudo ./bootstrap/fedora.sh
Centos 7
--------
.. code-block:: shell
sudo ./bootstrap/centos.sh
For installation run this modified command (note the trailing
backslash):
.. code-block:: shell
SWIG_FEATURES="-includeall -D__`uname -m`__-I/usr/include/openssl" \
./venv/bin/pip install -r requirements.txt .
Installation
============
.. code-block:: shell
virtualenv --no-site-packages -p python2 venv
./venv/bin/pip install -r requirements.txt
./venv/bin/pip install -r requirements.txt .
.. warning:: Please do **not** use ``python setup.py install``. Please
do **not** attempt the installation commands as
superuser/root and/or without Virtualenv_, e.g. ``sudo
python setup.py install``, ``sudo pip install``, ``sudo
./venv/bin/...``. These modes of operation might corrupt
your operating system and are **not supported** by the
Let's Encrypt team!
.. note:: If your operating system uses SWIG 3.0.5+, you will need to
run ``pip install -r requirements-swig-3.0.5.txt -r
requirements.txt .`` instead. Known affected systems:
* Fedora 22
* some versions of Mac OS X
Usage
=====
The letsencrypt commandline tool has a builtin help:
To get a new certificate run:
.. code-block:: shell
./venv/bin/letsencrypt auth
The ``letsencrypt`` commandline tool has a builtin help:
.. code-block:: shell
./venv/bin/letsencrypt --help
.. _augeas: http://augeas.net/
.. _m2crypto: https://github.com/M2Crypto/M2Crypto
.. _swig: http://www.swig.org/
.. _Augeas: http://augeas.net/
.. _M2Crypto: https://github.com/M2Crypto/M2Crypto
.. _SWIG: http://www.swig.org/
.. _Virtualenv: https://virtualenv.pypa.io

45
examples/acme_client.py Normal file
View file

@ -0,0 +1,45 @@
"""Example script showing how to use acme client API."""
import logging
import os
import pkg_resources
import Crypto.PublicKey.RSA
import M2Crypto
from acme import client
from acme import messages
from acme import jose
logging.basicConfig(level=logging.DEBUG)
NEW_REG_URL = 'https://www.letsencrypt-demo.org/acme/new-reg'
BITS = 2048 # minimum for Boulder
DOMAIN = 'example1.com' # example.com is ignored by Boulder
key = jose.JWKRSA.load(
Crypto.PublicKey.RSA.generate(BITS).exportKey(format="PEM"))
acme = client.Client(NEW_REG_URL, key)
regr = acme.register(contact=())
logging.info('Auto-accepting TOS: %s', regr.terms_of_service)
acme.update_registration(regr.update(
body=regr.body.update(agreement=regr.terms_of_service)))
logging.debug(regr)
authzr = acme.request_challenges(
identifier=messages.Identifier(typ=messages.IDENTIFIER_FQDN, value=DOMAIN),
new_authzr_uri=regr.new_authzr_uri)
logging.debug(authzr)
authzr, authzr_response = acme.poll(authzr)
csr = M2Crypto.X509.load_request_string(pkg_resources.resource_string(
'acme.jose', os.path.join('testdata', 'csr.der')),
M2Crypto.X509.FORMAT_DER)
try:
acme.request_issuance(csr, (authzr,))
except messages.Error as error:
print ("This script is doomed to fail as no authorization "
"challenges are ever solved. Error from server: {0}".format(error))

View file

@ -1,42 +0,0 @@
import logging
import os
import pkg_resources
import M2Crypto
from acme import messages2
from acme import jose
from letsencrypt 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(
'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'),
new_authzr_uri=regr.new_authzr_uri)
logging.debug(authzr)
authzr, authzr_response = net.poll(authzr)
csr = M2Crypto.X509.load_request_string(pkg_resources.resource_string(
'letsencrypt.tests', os.path.join('testdata', 'csr.pem')))
try:
net.request_issuance(csr, (authzr,))
except messages2.Error as error:
print error.detail

View file

@ -6,7 +6,7 @@ import re
import configobj
import zope.component
from acme import messages2
from acme import messages
from letsencrypt import crypto_util
from letsencrypt import errors
@ -28,7 +28,7 @@ class Account(object):
:ivar str phone: Client's phone number
:ivar regr: Registration Resource
:type regr: :class:`~acme.messages2.RegistrationResource`
:type regr: :class:`~acme.messages.RegistrationResource`
"""
@ -127,7 +127,7 @@ class Account(object):
acc_config = configobj.ConfigObj(
infile=config_fp, file_error=True, create_empty=False)
except IOError:
raise errors.LetsEncryptClientError(
raise errors.Error(
"Account for %s does not exist" % os.path.basename(config_fp))
if os.path.basename(config_fp) != "default":
@ -141,11 +141,11 @@ class Account(object):
if "RegistrationResource" in acc_config:
acc_config_rr = acc_config["RegistrationResource"]
regr = messages2.RegistrationResource(
regr = messages.RegistrationResource(
uri=acc_config_rr["uri"],
new_authzr_uri=acc_config_rr["new_authzr_uri"],
terms_of_service=acc_config_rr["terms_of_service"],
body=messages2.Registration.from_json(acc_config_rr["body"]))
body=messages.Registration.from_json(acc_config_rr["body"]))
else:
regr = None
@ -186,12 +186,12 @@ class Account(object):
"""
while True:
code, email = zope.component.getUtility(interfaces.IDisplay).input(
"Enter email address (optional, press Enter to skip)")
"Enter email address")
if code == display_util.OK:
try:
return cls.from_email(config, email)
except errors.LetsEncryptClientError:
except errors.Error:
continue
else:
return None
@ -205,8 +205,7 @@ class Account(object):
:param str email: Email address
:raises letsencrypt.errors.LetsEncryptClientError: If invalid
email address is given.
:raises .errors.Error: If invalid email address is given.
"""
if not email or cls.safe_email(email):
@ -219,7 +218,7 @@ class Account(object):
cls._get_config_filename(email))
return cls(config, key, email)
raise errors.LetsEncryptClientError("Invalid email address.")
raise errors.Error("Invalid email address.")
@classmethod
def safe_email(cls, email):
@ -227,5 +226,5 @@ class Account(object):
if cls.EMAIL_REGEX.match(email):
return not email.startswith(".") and ".." not in email
else:
logging.warn("Invalid email address.")
logging.warn("Invalid email address: %s.", email)
return False

View file

@ -5,11 +5,11 @@ Please use names such as ``achall`` to distiguish from variables "of type"
and :class:`.ChallengeBody` (denoted by ``challb``)::
from acme import challenges
from acme import messages2
from acme import messages
from letsencrypt import achallenges
chall = challenges.DNS(token='foo')
challb = messages2.ChallengeBody(chall=chall)
challb = messages.ChallengeBody(chall=chall)
achall = achallenges.DNS(chall=challb, domain='example.com')
Note, that all annotated challenges act as a proxy objects::

View file

@ -52,10 +52,10 @@ class AugeasConfigurator(common.Plugin):
lens_path = self.aug.get(path + "/lens")
# As aug.get may return null
if lens_path and lens in lens_path:
# Strip off /augeas/files and /error
logging.error("There has been an error in parsing the file: %s",
path[13:len(path) - 6])
logging.error(self.aug.get(path + "/message"))
logging.error(
"There has been an error in parsing the file (%s): %s",
# Strip off /augeas/files and /error
path[13:len(path) - 6], self.aug.get(path + "/message"))
def save(self, title=None, temporary=False):
"""Saves all changes to the configuration files.
@ -122,13 +122,10 @@ class AugeasConfigurator(common.Plugin):
# Check for the root of save problems
new_errs = self.aug.match("/augeas//error")
# logging.error("During Save - %s", mod_conf)
# Only print new errors caused by recent save
for err in new_errs:
if err not in ex_errs:
logging.error(
"Unable to save file - %s", err[13:len(err) - 6])
logging.error("Attempted Save Notes")
logging.error(self.save_notes)
logging.error("Unable to save files: %s. Attempted Save Notes: %s",
", ".join(err[13:len(err) - 6] for err in new_errs
# Only new errors caused by recent save
if err not in ex_errs), self.save_notes)
# Wrapper functions for Reverter class
def recovery_routine(self):

View file

@ -3,12 +3,15 @@ import itertools
import logging
import time
import zope.component
from acme import challenges
from acme import messages2
from acme import messages
from letsencrypt import achallenges
from letsencrypt import constants
from letsencrypt import errors
from letsencrypt import interfaces
class AuthHandler(object):
@ -24,13 +27,13 @@ class AuthHandler(object):
:ivar network: Network object for sending and receiving authorization
messages
:type network: :class:`letsencrypt.network2.Network`
:type network: :class:`letsencrypt.network.Network`
:ivar account: Client's Account
:type account: :class:`letsencrypt.account.Account`
:ivar dict authzr: ACME Authorization Resource dict where keys are domains
and values are :class:`acme.messages2.AuthorizationResource`
and values are :class:`acme.messages.AuthorizationResource`
:ivar list dv_c: DV challenges in the form of
:class:`letsencrypt.achallenges.AnnotatedChallenge`
:ivar list cont_c: Continuity challenges in the
@ -60,7 +63,7 @@ class AuthHandler(object):
form of (`completed`, `failed`)
:rtype: tuple
:raises AuthorizationError: If unable to retrieve all
:raises .AuthorizationError: If unable to retrieve all
authorizations
"""
@ -82,7 +85,7 @@ class AuthHandler(object):
self.verify_authzr_complete()
# Only return valid authorizations
return [authzr for authzr in self.authzr.values()
if authzr.body.status == messages2.STATUS_VALID]
if authzr.body.status == messages.STATUS_VALID]
def _choose_challenges(self, domains):
"""Retrieve necessary challenges to satisfy server."""
@ -134,9 +137,11 @@ class AuthHandler(object):
self._send_responses(self.cont_c, cont_resp, chall_update))
# Check for updated status...
self._poll_challenges(chall_update, best_effort)
# This removes challenges from self.dv_c and self.cont_c
self._cleanup_challenges(active_achalls)
try:
self._poll_challenges(chall_update, best_effort)
finally:
# This removes challenges from self.dv_c and self.cont_c
self._cleanup_challenges(active_achalls)
def _send_responses(self, achalls, resps, chall_update):
"""Send responses and make sure errors are handled.
@ -150,6 +155,9 @@ class AuthHandler(object):
# Don't send challenges for None and False authenticator responses
if resp:
self.network.answer_challenge(achall.challb, resp)
# TODO: answer_challenge returns challr, with URI,
# that can be used in _find_updated_challr
# comparisons...
active_achalls.append(achall)
if achall.domain in chall_update:
chall_update[achall.domain].append(achall)
@ -168,23 +176,28 @@ class AuthHandler(object):
while dom_to_check and rounds < max_rounds:
# TODO: Use retry-after...
time.sleep(min_sleep)
all_failed_achalls = set()
for domain in dom_to_check:
comp_challs, failed_challs = self._handle_check(
comp_achalls, failed_achalls = self._handle_check(
domain, chall_update[domain])
if len(comp_challs) == len(chall_update[domain]):
if len(comp_achalls) == len(chall_update[domain]):
comp_domains.add(domain)
elif not failed_challs:
for chall in comp_challs:
chall_update[domain].remove(chall)
elif not failed_achalls:
for achall, _ in comp_achalls:
chall_update[domain].remove(achall)
# We failed some challenges... damage control
else:
# Right now... just assume a loss and carry on...
if best_effort:
comp_domains.add(domain)
else:
raise errors.AuthorizationError(
"Failed Authorization procedure for %s" % domain)
all_failed_achalls.update(
updated for _, updated in failed_achalls)
if all_failed_achalls:
_report_failed_challs(all_failed_achalls)
raise errors.FailedChallenges(all_failed_achalls)
dom_to_check -= comp_domains
comp_domains.clear()
@ -196,38 +209,37 @@ class AuthHandler(object):
failed = []
self.authzr[domain], _ = self.network.poll(self.authzr[domain])
if self.authzr[domain].body.status == messages2.STATUS_VALID:
if self.authzr[domain].body.status == messages.STATUS_VALID:
return achalls, []
# Note: if the whole authorization is invalid, the individual failed
# challenges will be determined here...
for achall in achalls:
status = self._get_chall_status(self.authzr[domain], achall)
updated_achall = achall.update(challb=self._find_updated_challb(
self.authzr[domain], achall))
# This does nothing for challenges that have yet to be decided yet.
if status == messages2.STATUS_VALID:
completed.append(achall)
elif status == messages2.STATUS_INVALID:
failed.append(achall)
if updated_achall.status == messages.STATUS_VALID:
completed.append((achall, updated_achall))
elif updated_achall.status == messages.STATUS_INVALID:
failed.append((achall, updated_achall))
return completed, failed
def _get_chall_status(self, authzr, achall): # pylint: disable=no-self-use
"""Get the status of the challenge.
def _find_updated_challb(self, authzr, achall): # pylint: disable=no-self-use
"""Find updated challenge body within Authorization Resource.
.. warning:: This assumes only one instance of type of challenge in
each challenge resource.
:param authzr: Authorization Resource
:type authzr: :class:`acme.messages2.AuthorizationResource`
:param achall: Annotated challenge for which to get status
:type achall: :class:`letsencrypt.achallenges.AnnotatedChallenge`
:param .AuthorizationResource authzr: Authorization Resource
:param .AnnotatedChallenge achall: Annotated challenge for which
to get status
"""
for authzr_challb in authzr.body.challenges:
if type(authzr_challb.chall) is type(achall.challb.chall):
return authzr_challb.status
return authzr_challb
raise errors.AuthorizationError(
"Target challenge not found in authorization resource")
@ -277,8 +289,8 @@ class AuthHandler(object):
"""
for authzr in self.authzr.values():
if (authzr.body.status != messages2.STATUS_VALID and
authzr.body.status != messages2.STATUS_INVALID):
if (authzr.body.status != messages.STATUS_VALID and
authzr.body.status != messages.STATUS_INVALID):
raise errors.AuthorizationError("Incomplete authorizations")
def _challenge_factory(self, domain, path):
@ -294,8 +306,7 @@ class AuthHandler(object):
:class:`letsencrypt.achallenges.Indexed`
:rtype: tuple
:raises errors.LetsEncryptClientError: If Challenge type is not
recognized
:raises .errors.Error: if challenge type is not recognized
"""
dv_chall = []
@ -319,7 +330,7 @@ def challb_to_achall(challb, key, domain):
"""Converts a ChallengeBody object to an AnnotatedChallenge.
:param challb: ChallengeBody
:type challb: :class:`acme.messages2.ChallengeBody`
:type challb: :class:`acme.messages.ChallengeBody`
:param key: Key
:type key: :class:`letsencrypt.le_util.Key`
@ -331,35 +342,28 @@ def challb_to_achall(challb, key, domain):
"""
chall = challb.chall
logging.info("%s challenge for %s", chall.typ, domain)
if isinstance(chall, challenges.DVSNI):
logging.info(" DVSNI challenge for %s.", domain)
return achallenges.DVSNI(
challb=challb, domain=domain, key=key)
elif isinstance(chall, challenges.SimpleHTTP):
logging.info(" SimpleHTTP challenge for %s.", domain)
return achallenges.SimpleHTTP(
challb=challb, domain=domain, key=key)
elif isinstance(chall, challenges.DNS):
logging.info(" DNS challenge for %s.", domain)
return achallenges.DNS(challb=challb, domain=domain)
elif isinstance(chall, challenges.RecoveryToken):
logging.info(" Recovery Token Challenge for %s.", domain)
return achallenges.RecoveryToken(challb=challb, domain=domain)
elif isinstance(chall, challenges.RecoveryContact):
logging.info(" Recovery Contact Challenge for %s.", domain)
return achallenges.RecoveryContact(
challb=challb, domain=domain)
elif isinstance(chall, challenges.ProofOfPossession):
logging.info(" Proof-of-Possession Challenge for %s", domain)
return achallenges.ProofOfPossession(
challb=challb, domain=domain)
else:
raise errors.LetsEncryptClientError(
"Received unsupported challenge of type: %s",
chall.typ)
raise errors.Error(
"Received unsupported challenge of type: %s", chall.typ)
def gen_challenge_path(challbs, preferences, combinations):
@ -368,8 +372,8 @@ def gen_challenge_path(challbs, preferences, combinations):
.. todo:: This can be possibly be rewritten to use resolved_combinations.
:param tuple challbs: A tuple of challenges
(:class:`acme.messages2.Challenge`) from
:class:`acme.messages2.AuthorizationResource` to be
(:class:`acme.messages.Challenge`) from
:class:`acme.messages.AuthorizationResource` to be
fulfilled by the client in order to prove possession of the
identifier.
@ -480,3 +484,80 @@ def is_preferred(offered_challb, satisfied,
different=True):
return False
return True
_ERROR_HELP_COMMON = (
"To fix these errors, please make sure that your domain name was entered "
"correctly and the DNS A/AAAA record(s) for that domain contains the "
"right IP address.")
_ERROR_HELP = {
"connection" :
_ERROR_HELP_COMMON + " Additionally, please check that your computer "
"has publicly routable IP address and no firewalls are preventing the "
"server from communicating with the client.",
"dnssec" :
_ERROR_HELP_COMMON + " Additionally, if you have DNSSEC enabled for "
"your domain, please ensure the signature is valid.",
"malformed" :
"To fix these errors, please make sure that you did not provide any "
"invalid information to the client and try running Let's Encrypt "
"again.",
"serverInternal" :
"Unfortunately, an error on the ACME server prevented you from completing "
"authorization. Please try again later.",
"tls" :
_ERROR_HELP_COMMON + " Additionally, please check that you have an up "
"to date TLS configuration that allows the server to communicate with "
"the Let's Encrypt client.",
"unauthorized" : _ERROR_HELP_COMMON,
"unknownHost" : _ERROR_HELP_COMMON,}
def _report_failed_challs(failed_achalls):
"""Notifies the user about failed challenges.
:param set failed_achalls: A set of failed
:class:`letsencrypt.achallenges.AnnotatedChallenge`.
"""
problems = dict()
for achall in failed_achalls:
if achall.error:
problems.setdefault(achall.error.typ, []).append(achall)
reporter = zope.component.getUtility(interfaces.IReporter)
for achalls in problems.itervalues():
reporter.add_message(
_generate_failed_chall_msg(achalls), reporter.MEDIUM_PRIORITY, True)
def _generate_failed_chall_msg(failed_achalls):
"""Creates a user friendly error message about failed challenges.
:param list failed_achalls: A list of failed
:class:`letsencrypt.achallenges.AnnotatedChallenge` with the same error
type.
:returns: A formatted error message for the client.
:rtype: str
"""
typ = failed_achalls[0].error.typ
msg = [
"The following '{0}' errors were reported by the server:".format(typ)]
problems = dict()
for achall in failed_achalls:
problems.setdefault(achall.error.description, set()).add(achall.domain)
for problem in problems:
msg.append("\n\nDomains: ")
msg.append(", ".join(sorted(problems[problem])))
msg.append("\nError: {0}".format(problem))
if typ in _ERROR_HELP:
msg.append("\n\n")
msg.append(_ERROR_HELP[typ])
return "".join(msg)

View file

@ -25,9 +25,49 @@ from letsencrypt import reporter
from letsencrypt.display import util as display_util
from letsencrypt.display import ops as display_ops
from letsencrypt.plugins import disco as plugins_disco
# Argparse's help formatting has a lot of unhelpful peculiarities, so we want
# to replace as much of it as we can...
# This is the stub to include in help generated by argparse
SHORT_USAGE = """
letsencrypt [SUBCOMMAND] [options] [domains]
The Let's Encrypt agent can obtain and install HTTPS/TLS/SSL certificates. By
default, it will attempt to use a webserver both for obtaining and installing
the cert. """
# This is the short help for letsencrypt --help, where we disable argparse
# altogether
USAGE = SHORT_USAGE + """Major SUBCOMMANDS are:
(default) everything Obtain & install a cert in your current webserver
auth Authenticate & obtain cert, but do not install it
install Install a previously obtained cert in a server
revoke Revoke a previously obtained certificate
rollback Rollback server configuration changes made during install
config-changes Show changes made to server config during installation
Choice of server for authentication/installation:
--apache Use the Apache plugin for authentication & installation
--nginx Use the Nginx plugin for authentication & installation
--standalone Run a standalone HTTPS server (for authentication only)
OR:
--authenticator standalone --installer nginx
More detailed help:
-h, --help [topic] print this message, or detailed help on a topic;
the available topics are:
all, apache, automation, nginx, paths, security, testing, or any of the
sucommands
"""
def _account_init(args, config):
le_util.make_or_verify_dir(
@ -115,13 +155,14 @@ def run(args, config, plugins):
def auth(args, config, plugins):
"""Obtain a certificate (no install)."""
"""Authenticate & obtain cert, but do not install it."""
# XXX: Update for renewer / RenewableCert
if args.domains is not None and args.csr is not None:
# TODO: --csr could have a priority, when --domains is
# supplied, check if CSR matches given domains?
return "--domains and --csr are mutually exclusive"
# XXX: Update for renewer / RenewableCert
acc = _account_init(args, config)
if acc is None:
return None
@ -150,7 +191,7 @@ def auth(args, config, plugins):
return "Certificate could not be obtained"
def install(args, config, plugins):
"""Install (no auth)."""
"""Install a previously obtained cert in a server."""
# XXX: Update for renewer/RenewableCert
acc = _account_init(args, config)
if acc is None:
@ -167,7 +208,7 @@ def install(args, config, plugins):
def revoke(args, unused_config, unused_plugins):
"""Revoke."""
"""Revoke a previously obtained certificate."""
if args.rev_cert is None and args.rev_key is None:
return "At least one of --certificate or --key is required"
@ -179,12 +220,12 @@ def revoke(args, unused_config, unused_plugins):
def rollback(args, config, plugins):
"""Rollback."""
"""Rollback server configuration changes made during install."""
client.rollback(args.installer, args.checkpoints, config, plugins)
def config_changes(unused_args, config, unused_plugins):
"""View config changes.
"""Show changes made to server config during installation
View checkpoints and associated configuration changes.
@ -193,7 +234,7 @@ def config_changes(unused_args, config, unused_plugins):
def plugins_cmd(args, config, plugins): # TODO: Use IDiplay rathern than print
"""List plugins."""
"""List server software plugins."""
logging.debug("Expected interfaces: %s", args.ifaces)
ifaces = [] if args.ifaces is None else args.ifaces
@ -240,51 +281,201 @@ def flag_default(name):
"""Default value for CLI flag."""
return constants.CLI_DEFAULTS[name]
def config_help(name):
def config_help(name, hidden=False):
"""Help message for `.IConfig` attribute."""
return interfaces.IConfig[name].__doc__
if hidden:
return argparse.SUPPRESS
else:
return interfaces.IConfig[name].__doc__
def create_parser(plugins):
class SilentParser(object): # pylint: disable=too-few-public-methods
"""Silent wrapper around argparse.
A mini parser wrapper that doesn't print help for its
arguments. This is needed for the use of callbacks to define
arguments within plugins.
"""
def __init__(self, parser):
self.parser = parser
def add_argument(self, *args, **kwargs):
"""Wrap, but silence help"""
kwargs["help"] = argparse.SUPPRESS
self.parser.add_argument(*args, **kwargs)
HELP_TOPICS = ["all", "security", "paths", "automation", "testing"]
class HelpfulArgumentParser(object):
"""Argparse Wrapper.
This class wraps argparse, adding the ability to make --help less
verbose, and request help on specific subcategories at a time, eg
'letsencrypt --help security' for security options.
"""
def __init__(self, args, plugins):
self.args = args
plugin_names = [name for name, _p in plugins.iteritems()]
self.help_topics = HELP_TOPICS + plugin_names
self.parser = configargparse.ArgParser(
usage=SHORT_USAGE,
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
args_for_setting_config_path=["-c", "--config"],
default_config_files=flag_default("config_files"))
# This is the only way to turn off overly verbose config flag documentation
self.parser._add_config_file_help = False # pylint: disable=protected-access
self.silent_parser = SilentParser(self.parser)
help1 = self.prescan_for_flag("-h", self.help_topics)
help2 = self.prescan_for_flag("--help", self.help_topics)
assert max(True, "a") == "a", "Gravity changed direction"
help_arg = max(help1, help2)
if help_arg == True:
# just --help with no topic; avoid argparse altogether
print USAGE
sys.exit(0)
self.visible_topics = self.determine_help_topics(help_arg)
#print self.visible_topics
self.groups = {} # elements are added by .add_group()
self.add_plugin_args(plugins)
def prescan_for_flag(self, flag, possible_arguments):
"""Checks cli input for flags.
Check for a flag, which accepts a fixed set of possible arguments, in
the command line; we will use this information to configure argparse's
help correctly. Return the flag's argument, if it has one that matches
the sequence @possible_arguments; otherwise return whether the flag is
present.
"""
if flag not in self.args:
return False
pos = self.args.index(flag)
try:
nxt = self.args[pos + 1]
if nxt in possible_arguments:
return nxt
except IndexError:
pass
return True
def add(self, topic, *args, **kwargs):
"""Add a new command line argument.
@topic is required, to indicate which part of the help will document
it, but can be None for `always documented'.
"""
if topic and self.visible_topics[topic]:
group = self.groups[topic]
group.add_argument(*args, **kwargs)
else:
kwargs["help"] = argparse.SUPPRESS
self.parser.add_argument(*args, **kwargs)
def add_group(self, topic, **kwargs):
"""
This has to be called once for every topic; but we leave those calls
next to the argument definitions for clarity. Return something
arguments can be added to if necessary, either the parser or an argument
group.
"""
if self.visible_topics[topic]:
#print "Adding visible group " + topic
group = self.parser.add_argument_group(topic, **kwargs)
self.groups[topic] = group
return group
else:
#print "Invisible group " + topic
return self.silent_parser
def add_plugin_args(self, plugins):
"""
Let each of the plugins add its own command line arguments, which
may or may not be displayed as help topics.
"""
# TODO: plugin_parser should be called for every detected plugin
for name, plugin_ep in plugins.iteritems():
parser_or_group = self.add_group(name, description=plugin_ep.description)
#print parser_or_group
plugin_ep.plugin_cls.inject_parser_options(parser_or_group, name)
def determine_help_topics(self, chosen_topic):
"""
The user may have requested help on a topic, return a dict of which
topics to display. @chosen_topic has prescan_for_flag's return type
:returns: dict
"""
# topics maps each topic to whether it should be documented by
# argparse on the command line
if chosen_topic == "all":
return dict([(t, True) for t in self.help_topics])
elif not chosen_topic:
return dict([(t, False) for t in self.help_topics])
else:
return dict([(t, t == chosen_topic) for t in self.help_topics])
def create_parser(plugins, args):
"""Create parser."""
parser = configargparse.ArgParser(
description=__doc__,
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
args_for_setting_config_path=["-c", "--config"],
default_config_files=flag_default("config_files"))
add = parser.add_argument
helpful = HelpfulArgumentParser(args, plugins)
# --help is automatically provided by argparse
add("--version", action="version", version="%(prog)s {0}".format(
letsencrypt.__version__))
add("-v", "--verbose", dest="verbose_count", action="count",
helpful.add(
None, "-v", "--verbose", dest="verbose_count", action="count",
default=flag_default("verbose_count"), help="This flag can be used "
"multiple times to incrementally increase the verbosity of output, "
"e.g. -vvv.")
add("--no-confirm", dest="no_confirm", action="store_true",
# --help is automatically provided by argparse
helpful.add_group(
"automation",
description="Arguments for automating execution & other tweaks")
helpful.add(
"automation", "--version", action="version",
version="%(prog)s {0}".format(letsencrypt.__version__),
help="show program's version number and exit")
helpful.add(
"automation", "--no-confirm", dest="no_confirm", action="store_true",
help="Turn off confirmation screens, currently used for --revoke")
add("-e", "--agree-tos", dest="tos", action="store_true",
help="Skip the end user license agreement screen.")
add("-t", "--text", dest="text_mode", action="store_true",
helpful.add(
"automation", "--agree-eula", "-e", dest="tos", action="store_true",
help="Agree to the Let's Encrypt Subscriber Agreement")
helpful.add(
None, "-t", "--text", dest="text_mode", action="store_true",
help="Use the text output instead of the curses UI.")
add("--no-simple-http-tls", action="store_true",
help=config_help("no_simple_http_tls"))
testing_group = parser.add_argument_group(
helpful.add_group(
"testing", description="The following flags are meant for "
"testing purposes only! Do NOT change them, unless you "
"really know what you're doing!")
testing_group.add_argument(
"--no-verify-ssl", action="store_true",
helpful.add(
"testing", "--no-verify-ssl", action="store_true",
help=config_help("no_verify_ssl"),
default=flag_default("no_verify_ssl"))
# TODO: apache and nginx plugins do NOT respect it
testing_group.add_argument(
"--dvsni-port", type=int, help=config_help("dvsni_port"),
default=flag_default("dvsni_port"))
helpful.add(
"testing", "--dvsni-port", type=int, default=flag_default("dvsni_port"),
help=config_help("dvsni_port"))
helpful.add("testing", "--no-simple-http-tls", action="store_true",
help=config_help("no_simple_http_tls"))
subparsers = helpful.parser.add_subparsers(metavar="SUBCOMMAND")
subparsers = parser.add_subparsers(metavar="SUBCOMMAND")
def add_subparser(name, func): # pylint: disable=missing-docstring
subparser = subparsers.add_parser(
name, help=func.__doc__.splitlines()[0], description=func.__doc__)
@ -301,6 +492,12 @@ def create_parser(plugins):
parser_auth.add_argument(
"--csr", type=read_file, help="Path to a Certificate Signing "
"Request (CSR) in DER format.")
parser_auth.add_argument(
"--cert-path", default=flag_default("cert_path"),
help="When using --csr this is where certificate is saved.")
parser_auth.add_argument(
"--chain-path", default=flag_default("chain_path"),
help="When using --csr this is where certificate chain is saved.")
parser_plugins = add_subparser("plugins", plugins_cmd)
parser_plugins.add_argument("--init", action="store_true")
@ -312,25 +509,28 @@ def create_parser(plugins):
"--installers", action="append_const", dest="ifaces",
const=interfaces.IInstaller)
parser.add_argument("--configurator")
parser.add_argument("-a", "--authenticator")
parser.add_argument("-i", "--installer")
helpful.add(None, "--configurator")
helpful.add(None, "-a", "--authenticator")
helpful.add(None, "-i", "--installer")
# positional arg shadows --domains, instead of appending, and
# --domains is useful, because it can be stored in config
#for subparser in parser_run, parser_auth, parser_install:
# subparser.add_argument("domains", nargs="*", metavar="domain")
add("-d", "--domains", metavar="DOMAIN", action="append")
add("-s", "--server", default=flag_default("server"),
help=config_help("server"))
add("-k", "--authkey", type=read_file,
help="Path to the authorized key file")
add("-m", "--email", help=config_help("email"))
add("-B", "--rsa-key-size", type=int, metavar="N",
helpful.add(None, "-d", "--domains", metavar="DOMAIN", action="append")
helpful.add(None, "-k", "--accountkey", type=read_file,
help="Path to the account key file")
helpful.add(None, "-m", "--email", help=config_help("email"))
helpful.add_group(
"security", description="Security parameters & server settings")
helpful.add(
"security", "-B", "--rsa-key-size", type=int, metavar="N",
default=flag_default("rsa_key_size"), help=config_help("rsa_key_size"))
# TODO: resolve - assumes binary logic while client.py assumes ternary.
add("-r", "--redirect", action="store_true",
helpful.add(
"security", "-r", "--redirect", action="store_true",
help="Automatically redirect all HTTP traffic to HTTPS for the newly "
"authenticated vhost.")
@ -346,48 +546,37 @@ def create_parser(plugins):
default=flag_default("rollback_checkpoints"),
help="Revert configuration N number of checkpoints.")
_paths_parser(parser.add_argument_group("paths"))
_paths_parser(helpful)
# TODO: plugin_parser should be called for every detected plugin
for name, plugin_ep in plugins.iteritems():
plugin_ep.plugin_cls.inject_parser_options(
parser.add_argument_group(
name, description=plugin_ep.description), name)
return parser
return helpful.parser
def _paths_parser(parser):
add = parser.add_argument
add("--config-dir", default=flag_default("config_dir"),
def _paths_parser(helpful):
add = helpful.add
helpful.add_group("paths", description="Arguments changing execution paths & servers")
add("paths", "--config-dir", default=flag_default("config_dir"),
help=config_help("config_dir"))
add("--work-dir", default=flag_default("work_dir"),
add("paths", "--work-dir", default=flag_default("work_dir"),
help=config_help("work_dir"))
add("--backup-dir", default=flag_default("backup_dir"),
add("paths", "--backup-dir", default=flag_default("backup_dir"),
help=config_help("backup_dir"))
add("--key-dir", default=flag_default("key_dir"),
add("paths", "--key-dir", default=flag_default("key_dir"),
help=config_help("key_dir"))
add("--cert-dir", default=flag_default("certs_dir"),
add("paths", "--cert-dir", default=flag_default("certs_dir"),
help=config_help("cert_dir"))
add("--le-vhost-ext", default="-le-ssl.conf",
add("paths", "--le-vhost-ext", default="-le-ssl.conf",
help=config_help("le_vhost_ext"))
add("--cert-path", default=flag_default("cert_path"),
help=config_help("cert_path"))
add("--chain-path", default=flag_default("chain_path"),
help=config_help("chain_path"))
add("--renewer-config-file", default=flag_default("renewer_config_file"),
add("paths", "--renewer-config-file", default=flag_default("renewer_config_file"),
help=config_help("renewer_config_file"))
return parser
add("paths", "-s", "--server", default=flag_default("server"),
help=config_help("server"))
def main(args=sys.argv[1:]):
"""Command line argument parsing and main script execution."""
# note: arg parser internally handles --help (and exits afterwards)
plugins = plugins_disco.PluginsRegistry.find_all()
args = create_parser(plugins).parse_args(args)
args = create_parser(plugins, args).parse_args(args)
config = configuration.NamespaceConfig(args)
# Displayer

View file

@ -12,12 +12,14 @@ from acme.jose import jwk
from letsencrypt import account
from letsencrypt import auth_handler
from letsencrypt import configuration
from letsencrypt import constants
from letsencrypt import continuity_auth
from letsencrypt import crypto_util
from letsencrypt import errors
from letsencrypt import interfaces
from letsencrypt import le_util
from letsencrypt import network2
from letsencrypt import network
from letsencrypt import reverter
from letsencrypt import revoker
from letsencrypt import storage
@ -30,7 +32,7 @@ class Client(object):
"""ACME protocol client.
:ivar network: Network object for sending and receiving messages
:type network: :class:`letsencrypt.network2.Network`
:type network: :class:`letsencrypt.network.Network`
:ivar account: Account object used for registration
:type account: :class:`letsencrypt.account.Account`
@ -63,7 +65,7 @@ class Client(object):
self.installer = installer
# TODO: Allow for other alg types besides RS256
self.network = network2.Network(
self.network = network.Network(
config.server, jwk.JWKRSA.load(self.account.key.pem),
verify_ssl=(not config.no_verify_ssl))
@ -97,7 +99,7 @@ class Client(object):
self.account.regr = self.network.agree_to_tos(self.account.regr)
else:
# What is the proper response here...
raise errors.LetsEncryptClientError("Must agree to TOS")
raise errors.Error("Must agree to TOS")
self.account.save()
self._report_new_account()
@ -144,10 +146,9 @@ class Client(object):
msg = ("Unable to obtain certificate because authenticator is "
"not set.")
logging.warning(msg)
raise errors.LetsEncryptClientError(msg)
raise errors.Error(msg)
if self.account.regr is None:
raise errors.LetsEncryptClientError(
"Please register with the ACME server first.")
raise errors.Error("Please register with the ACME server first.")
logging.debug("CSR: %s, domains: %s", csr, domains)
@ -232,23 +233,29 @@ class Client(object):
# ideally should be a ConfigObj, but in this case a dict will be
# accepted in practice.)
params = vars(self.config.namespace)
config = {"renewer_config_file":
params["renewer_config_file"]} if "renewer_config_file" in params else None
config = {}
cli_config = configuration.RenewerConfiguration(self.config.namespace)
if (cli_config.config_dir != constants.CLI_DEFAULTS["config_dir"] or
cli_config.work_dir != constants.CLI_DEFAULTS["work_dir"]):
logging.warning(
"Non-standard path(s), might not work with crontab installed "
"by your operating system package manager")
# XXX: just to stop RenewableCert from complaining; this is
# probably not a good solution
chain_pem = "" if chain is None else chain.as_pem()
renewable_cert = storage.RenewableCert.new_lineage(
domains[0], certr.body.as_pem(), key.pem, chain_pem, params, config)
self._report_renewal_status(renewable_cert)
return renewable_cert
lineage = storage.RenewableCert.new_lineage(
domains[0], certr.body.as_pem(), key.pem, chain_pem, params,
config, cli_config)
self._report_renewal_status(lineage)
return lineage
def _report_renewal_status(self, cert):
# pylint: disable=no-self-use
"""Informs the user about automatic renewal and deployment.
:param cert: Newly issued certificate
:type cert: :class:`letsencrypt.storage.RenewableCert`
:param .RenewableCert cert: Newly issued certificate
"""
if ("autorenew" not in cert.configuration
@ -267,7 +274,7 @@ class Client(object):
msg += ("been enabled for your certificate. These settings can be "
"configured in the directories under {0}.").format(
cert.configuration["renewal_configs_dir"])
cert.cli_config.renewal_configs_dir)
reporter = zope.component.getUtility(interfaces.IReporter)
reporter.add_message(msg, reporter.LOW_PRIORITY, True)
@ -279,9 +286,8 @@ class Client(object):
:type certr: :class:`acme.messages.Certificate`
:param chain_cert:
:param str cert_path: Path to attempt to save the cert file
:param str chain_path: Path to attempt to save the chain file
:param str cert_path: Candidate path to a certificate.
:param str chain_path: Candidate path to a certificate chain.
:returns: cert_path, chain_path (absolute paths to the actual files)
:rtype: `tuple` of `str`
@ -334,7 +340,7 @@ class Client(object):
if self.installer is None:
logging.warning("No installer specified, client is unable to deploy"
"the certificate")
raise errors.LetsEncryptClientError("No installer available")
raise errors.Error("No installer available")
chain_path = None if chain_path is None else os.path.abspath(chain_path)
@ -363,14 +369,14 @@ class Client(object):
:param redirect: If traffic should be forwarded from HTTP to HTTPS.
:type redirect: bool or None
:raises letsencrypt.errors.LetsEncryptClientError: if
no installer is specified in the client.
:raises .errors.Error: if no installer is specified in the
client.
"""
if self.installer is None:
logging.warning("No installer is specified, there isn't any "
"configuration to enhance.")
raise errors.LetsEncryptClientError("No installer available")
raise errors.Error("No installer available")
if redirect is None:
redirect = enhancements.ask("redirect")
@ -388,7 +394,7 @@ class Client(object):
for dom in domains:
try:
self.installer.enhance(dom, "redirect")
except errors.LetsEncryptConfiguratorError:
except errors.ConfiguratorError:
logging.warn("Unable to perform redirect for %s", dom)
self.installer.save("Add Redirects")
@ -409,8 +415,7 @@ def validate_key_csr(privkey, csr=None):
:param .le_util.CSR csr: CSR
:raises letsencrypt.errors.LetsEncryptClientError: when
validation fails
:raises .errors.Error: when validation fails
"""
# TODO: Handle all of these problems appropriately
@ -419,8 +424,7 @@ def validate_key_csr(privkey, csr=None):
# Key must be readable and valid.
if privkey.pem and not crypto_util.valid_privkey(privkey.pem):
raise errors.LetsEncryptClientError(
"The provided key is not a valid key")
raise errors.Error("The provided key is not a valid key")
if csr:
if csr.form == "der":
@ -429,16 +433,14 @@ def validate_key_csr(privkey, csr=None):
# If CSR is provided, it must be readable and valid.
if csr.data and not crypto_util.valid_csr(csr.data):
raise errors.LetsEncryptClientError(
"The provided CSR is not a valid CSR")
raise errors.Error("The provided CSR is not a valid CSR")
# If both CSR and key are provided, the key must be the same key used
# in the CSR.
if csr.data and privkey.pem:
if not crypto_util.csr_matches_pubkey(
csr.data, privkey.pem):
raise errors.LetsEncryptClientError(
"The key and CSR do not match")
raise errors.Error("The key and CSR do not match")
def determine_account(config):

View file

@ -17,10 +17,15 @@ class NamespaceConfig(object):
:attr:`~letsencrypt.interfaces.IConfig.work_dir` and relative
paths defined in :py:mod:`letsencrypt.constants`:
- ``temp_checkpoint_dir``
- ``in_progress_dir``
- ``cert_key_backup``
- ``rec_token_dir``
- `accounts_dir`
- `account_keys_dir`
- `cert_dir`
- `cert_key_backup`
- `in_progress_dir`
- `key_dir`
- `rec_token_dir`
- `renewer_config_file`
- `temp_checkpoint_dir`
:ivar namespace: Namespace typically produced by
:meth:`argparse.ArgumentParser.parse_args`.
@ -35,27 +40,12 @@ class NamespaceConfig(object):
def __getattr__(self, name):
return getattr(self.namespace, name)
@property
def temp_checkpoint_dir(self): # pylint: disable=missing-docstring
return os.path.join(
self.namespace.work_dir, constants.TEMP_CHECKPOINT_DIR)
@property
def in_progress_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.work_dir, constants.IN_PROGRESS_DIR)
@property
def server_path(self):
"""File path based on ``server``."""
parsed = urlparse.urlparse(self.namespace.server)
return (parsed.netloc + parsed.path).replace('/', os.path.sep)
@property
def cert_key_backup(self): # pylint: disable=missing-docstring
return os.path.join(
self.namespace.work_dir, constants.CERT_KEY_BACKUP_DIR,
self.server_path)
@property
def accounts_dir(self): #pylint: disable=missing-docstring
return os.path.join(
@ -63,11 +53,63 @@ class NamespaceConfig(object):
@property
def account_keys_dir(self): #pylint: disable=missing-docstring
return os.path.join(
self.namespace.config_dir, constants.ACCOUNTS_DIR,
self.server_path, constants.ACCOUNT_KEYS_DIR)
return os.path.join(self.accounts_dir, constants.ACCOUNT_KEYS_DIR)
@property
def backup_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.work_dir, constants.BACKUP_DIR)
@property
def cert_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.config_dir, constants.CERT_DIR)
@property
def cert_key_backup(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.work_dir,
constants.CERT_KEY_BACKUP_DIR, self.server_path)
@property
def in_progress_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.work_dir, constants.IN_PROGRESS_DIR)
@property
def key_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.config_dir, constants.KEY_DIR)
# TODO: This should probably include the server name
@property
def rec_token_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.work_dir, constants.REC_TOKEN_DIR)
@property
def temp_checkpoint_dir(self): # pylint: disable=missing-docstring
return os.path.join(
self.namespace.work_dir, constants.TEMP_CHECKPOINT_DIR)
class RenewerConfiguration(object):
"""Configuration wrapper for renewer."""
def __init__(self, namespace):
self.namespace = namespace
def __getattr__(self, name):
return getattr(self.namespace, name)
@property
def archive_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.config_dir, constants.ARCHIVE_DIR)
@property
def live_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.config_dir, constants.LIVE_DIR)
@property
def renewal_configs_dir(self): # pylint: disable=missing-docstring
return os.path.join(
self.namespace.config_dir, constants.RENEWAL_CONFIGS_DIR)
@property
def renewer_config_file(self): # pylint: disable=missing-docstring
return os.path.join(
self.namespace.config_dir, constants.RENEWER_CONFIG_FILENAME)

View file

@ -7,7 +7,6 @@ from acme import challenges
SETUPTOOLS_PLUGINS_ENTRY_POINT = "letsencrypt.plugins"
"""Setuptools entry point group name for plugins."""
CLI_DEFAULTS = dict(
config_files=["/etc/letsencrypt/cli.ini"],
verbose_count=-(logging.WARNING / 10),
@ -16,23 +15,21 @@ CLI_DEFAULTS = dict(
rollback_checkpoints=0,
config_dir="/etc/letsencrypt",
work_dir="/var/lib/letsencrypt",
backup_dir="/var/lib/letsencrypt/backups",
key_dir="/etc/letsencrypt/keys",
certs_dir="/etc/letsencrypt/certs",
cert_path="/etc/letsencrypt/certs/cert-letsencrypt.pem",
chain_path="/etc/letsencrypt/certs/chain-letsencrypt.pem",
renewer_config_file="/etc/letsencrypt/renewer.conf",
no_verify_ssl=False,
dvsni_port=challenges.DVSNI.PORT,
cert_path="./cert.pem",
chain_path="./chain.pem",
# TODO: blocked by #485, values ignored
backup_dir="not used",
key_dir="not used",
certs_dir="not used",
renewer_config_file="not used",
)
"""Defaults for CLI flags and `.IConfig` attributes."""
RENEWER_DEFAULTS = dict(
renewer_config_file="/etc/letsencrypt/renewer.conf",
renewal_configs_dir="/etc/letsencrypt/configs",
archive_dir="/etc/letsencrypt/archive",
live_dir="/etc/letsencrypt/live",
renewer_enabled="yes",
renew_before_expiry="30 days",
deploy_before_expiry="20 days",
@ -57,31 +54,47 @@ List of expected options parameters:
"""
ARCHIVE_DIR = "archive"
"""Archive directory, relative to `IConfig.config_dir`."""
CONFIG_DIRS_MODE = 0o755
"""Directory mode for ``.IConfig.config_dir`` et al."""
TEMP_CHECKPOINT_DIR = "temp_checkpoint"
"""Temporary checkpoint directory (relative to IConfig.work_dir)."""
IN_PROGRESS_DIR = "IN_PROGRESS"
"""Directory used before a permanent checkpoint is finalized (relative to
IConfig.work_dir)."""
CERT_KEY_BACKUP_DIR = "keys-certs"
"""Directory where all certificates and keys are stored (relative to
IConfig.work_dir. Used for easy revocation."""
ACCOUNTS_DIR = "accounts"
"""Directory where all accounts are saved."""
ACCOUNT_KEYS_DIR = "keys"
"""Directory where account keys are saved. Relative to ACCOUNTS_DIR."""
"""Directory where account keys are saved. Relative to `ACCOUNTS_DIR`."""
BACKUP_DIR = "backups"
"""Directory (relative to `IConfig.work_dir`) where backups are kept."""
CERT_DIR = "certs"
"""See `.IConfig.cert_dir`."""
CERT_KEY_BACKUP_DIR = "keys-certs"
"""Directory where all certificates and keys are stored (relative to
`IConfig.work_dir`). Used for easy revocation."""
IN_PROGRESS_DIR = "IN_PROGRESS"
"""Directory used before a permanent checkpoint is finalized (relative to
`IConfig.work_dir`)."""
KEY_DIR = "keys"
"""Directory (relative to `IConfig.config_dir`) where keys are saved."""
LIVE_DIR = "live"
"""Live directory, relative to `IConfig.config_dir`."""
TEMP_CHECKPOINT_DIR = "temp_checkpoint"
"""Temporary checkpoint directory (relative to `IConfig.work_dir`)."""
REC_TOKEN_DIR = "recovery_tokens"
"""Directory where all recovery tokens are saved (relative to
IConfig.work_dir)."""
`IConfig.work_dir`)."""
NETSTAT = "/bin/netstat"
"""Location of netstat binary for checking whether a listener is already
running on the specified port (Linux-specific)."""
RENEWAL_CONFIGS_DIR = "configs"
"""Renewal configs directory, relative to `IConfig.config_dir`."""
RENEWER_CONFIG_FILENAME = "renewer.conf"
"""Renewer config file name (relative to `IConfig.config_dir`)."""

View file

@ -52,7 +52,7 @@ class ContinuityAuthenticator(object):
elif isinstance(achall, achallenges.RecoveryToken):
responses.append(self.rec_token.perform(achall))
else:
raise errors.LetsEncryptContAuthError("Unexpected Challenge")
raise errors.ContAuthError("Unexpected Challenge")
return responses
def cleanup(self, achalls):
@ -61,4 +61,4 @@ class ContinuityAuthenticator(object):
if isinstance(achall, achallenges.RecoveryToken):
self.rec_token.cleanup(achall)
elif not isinstance(achall, achallenges.ProofOfPossession):
raise errors.LetsEncryptContAuthError("Unexpected Challenge")
raise errors.ContAuthError("Unexpected Challenge")

View file

@ -40,7 +40,7 @@ def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"):
try:
key_pem = make_key(key_size)
except ValueError as err:
logging.fatal(str(err))
logging.exception(err)
raise err
# Save file
@ -55,7 +55,7 @@ def init_save_key(key_size, key_dir, keyname="key-letsencrypt.pem"):
return le_util.Key(key_path, key_pem)
def init_save_csr(privkey, names, cert_dir, csrname="csr-letsencrypt.pem"):
def init_save_csr(privkey, names, path, csrname="csr-letsencrypt.pem"):
"""Initialize a CSR with the given private key.
:param privkey: Key to include in the CSR
@ -63,7 +63,7 @@ def init_save_csr(privkey, names, cert_dir, csrname="csr-letsencrypt.pem"):
:param set names: `str` names to include in the CSR
:param str cert_dir: Certificate save directory.
:param str path: Certificate save directory.
:returns: CSR
:rtype: :class:`letsencrypt.le_util.CSR`
@ -72,9 +72,9 @@ def init_save_csr(privkey, names, cert_dir, csrname="csr-letsencrypt.pem"):
csr_pem, csr_der = make_csr(privkey.pem, names)
# Save CSR
le_util.make_or_verify_dir(cert_dir, 0o755, os.geteuid())
le_util.make_or_verify_dir(path, 0o755, os.geteuid())
csr_f, csr_filename = le_util.unique_file(
os.path.join(cert_dir, csrname), 0o644)
os.path.join(path, csrname), 0o644)
csr_f.write(csr_pem)
csr_f.close()

View file

@ -21,8 +21,7 @@ def ask(enhancement):
:returns: True if feature is desired, False otherwise
:rtype: bool
:raises letsencrypt.errors.LetsEncryptClientError: If
the enhancement provided is not supported.
:raises .errors.Error: if the enhancement provided is not supported
"""
try:
@ -30,7 +29,7 @@ def ask(enhancement):
return DISPATCH[enhancement]()
except KeyError:
logging.error("Unsupported enhancement given to ask(): %s", enhancement)
raise errors.LetsEncryptClientError("Unsupported Enhancement")
raise errors.Error("Unsupported Enhancement")
def redirect_by_default():

View file

@ -1,52 +1,63 @@
"""Let's Encrypt client errors."""
class LetsEncryptClientError(Exception):
class Error(Exception):
"""Generic Let's Encrypt client error."""
LetsEncryptClientError = Error # TODO: blocked by #485
class NetworkError(LetsEncryptClientError):
"""Network error."""
class UnexpectedUpdate(NetworkError):
"""Unexpected update."""
class LetsEncryptReverterError(LetsEncryptClientError):
class ReverterError(Error):
"""Let's Encrypt Reverter error."""
# Auth Handler Errors
class AuthorizationError(LetsEncryptClientError):
class AuthorizationError(Error):
"""Authorization error."""
class LetsEncryptContAuthError(AuthorizationError):
class FailedChallenges(AuthorizationError):
"""Failed challenges error.
:ivar set failed_achalls: Failed `.AnnotatedChallenge` instances.
"""
def __init__(self, failed_achalls):
assert failed_achalls
self.failed_achalls = failed_achalls
super(FailedChallenges, self).__init__()
def __str__(self):
return "Failed authorization procedure. {0}".format(
", ".join(
"{0} ({1}): {2}".format(achall.domain, achall.typ, achall.error)
for achall in self.failed_achalls if achall.error is not None))
class ContAuthError(AuthorizationError):
"""Let's Encrypt Continuity Authenticator error."""
class LetsEncryptDvAuthError(AuthorizationError):
class DvAuthError(AuthorizationError):
"""Let's Encrypt DV Authenticator error."""
# Authenticator - Challenge specific errors
class LetsEncryptDvsniError(LetsEncryptDvAuthError):
class DvsniError(DvAuthError):
"""Let's Encrypt DVSNI error."""
# Configurator Errors
class LetsEncryptConfiguratorError(LetsEncryptClientError):
class ConfiguratorError(Error):
"""Let's Encrypt Configurator error."""
class LetsEncryptNoInstallationError(LetsEncryptConfiguratorError):
class NoInstallationError(ConfiguratorError):
"""Let's Encrypt No Installation error."""
class LetsEncryptMisconfigurationError(LetsEncryptConfiguratorError):
class MisconfigurationError(ConfiguratorError):
"""Let's Encrypt Misconfiguration error."""
class LetsEncryptRevokerError(LetsEncryptClientError):
class RevokerError(Error):
"""Let's Encrypt Revoker error."""

View file

@ -68,10 +68,10 @@ class IPlugin(zope.interface.Interface):
Finish up any additional initialization.
:raises letsencrypt.errors.LetsEncryptMisconfigurationError:
when full initialization cannot be completed. Plugin will be
displayed on a list of available plugins.
:raises letsencrypt.errors.LetsEncryptNoInstallationError:
:raises .MisconfigurationError:
when full initialization cannot be completed. Plugin will
be displayed on a list of available plugins.
:raises .NoInstallationError:
when the necessary programs/files cannot be located. Plugin
will NOT be displayed on a list of available plugins.
@ -148,40 +148,36 @@ class IConfig(zope.interface.Interface):
"""
server = zope.interface.Attribute(
"CA hostname (and optionally :port). The server certificate must "
"be trusted in order to avoid further modifications to the client.")
"ACME new registration URI (including /acme/new-reg).")
email = zope.interface.Attribute(
"Email used for registration and recovery contact.")
rsa_key_size = zope.interface.Attribute("Size of the RSA key.")
config_dir = zope.interface.Attribute("Configuration directory.")
work_dir = zope.interface.Attribute("Working directory.")
backup_dir = zope.interface.Attribute("Configuration backups directory.")
temp_checkpoint_dir = zope.interface.Attribute(
"Temporary checkpoint directory.")
in_progress_dir = zope.interface.Attribute(
"Directory used before a permanent checkpoint is finalized.")
cert_key_backup = zope.interface.Attribute(
"Directory where all certificates and keys are stored. "
"Used for easy revocation.")
accounts_dir = zope.interface.Attribute(
"Directory where all account information is stored.")
account_keys_dir = zope.interface.Attribute(
"Directory where all account keys are stored.")
backup_dir = zope.interface.Attribute("Configuration backups directory.")
cert_dir = zope.interface.Attribute(
"Directory where newly generated Certificate Signing Requests "
"(CSRs) and certificates not enrolled in the renewer are saved.")
cert_key_backup = zope.interface.Attribute(
"Directory where all certificates and keys are stored. "
"Used for easy revocation.")
in_progress_dir = zope.interface.Attribute(
"Directory used before a permanent checkpoint is finalized.")
key_dir = zope.interface.Attribute("Keys storage.")
rec_token_dir = zope.interface.Attribute(
"Directory where all recovery tokens are saved.")
key_dir = zope.interface.Attribute("Keys storage.")
cert_dir = zope.interface.Attribute("Certificates storage.")
le_vhost_ext = zope.interface.Attribute(
"SSL vhost configuration extension.")
temp_checkpoint_dir = zope.interface.Attribute(
"Temporary checkpoint directory.")
renewer_config_file = zope.interface.Attribute(
"Location of renewal configuration file.")
cert_path = zope.interface.Attribute("Let's Encrypt certificate file path.")
chain_path = zope.interface.Attribute("Let's Encrypt chain file path.")
no_verify_ssl = zope.interface.Attribute(
"Disable SSL certificate verification.")
dvsni_port = zope.interface.Attribute(
@ -192,6 +188,9 @@ class IConfig(zope.interface.Interface):
no_simple_http_tls = zope.interface.Attribute(
"Do not use TLS when solving SimpleHTTP challenges.")
# TODO: the following are not used, but blocked by #485
le_vhost_ext = zope.interface.Attribute("not used")
class IInstaller(IPlugin):
"""Generic Let's Encrypt Installer Interface.

View file

@ -19,7 +19,7 @@ def make_or_verify_dir(directory, mode=0o755, uid=0):
:param int mode: Directory mode.
:param int uid: Directory owner.
:raises LetsEncryptClientError: if a directory already exists,
:raises .errors.Error: if a directory already exists,
but has wrong permissions or owner
:raises OSError: if invalid or inaccessible file names and
@ -32,9 +32,8 @@ def make_or_verify_dir(directory, mode=0o755, uid=0):
except OSError as exception:
if exception.errno == errno.EEXIST:
if not check_permissions(directory, mode, uid):
raise errors.LetsEncryptClientError(
"%s exists, but does not have the proper "
"permissions or owner" % directory)
raise errors.Error(
"%s exists, this client can't access it" % directory)
else:
raise

View file

@ -1,121 +1,26 @@
"""Network Module."""
import logging
import sys
import time
import requests
from acme import jose
from acme import messages
from letsencrypt import errors
"""Networking for ACME protocol."""
from acme import client
# https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning
requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3()
class Network(client.Client):
"""ACME networking."""
logging.getLogger("requests").setLevel(logging.WARNING)
def register_from_account(self, account):
"""Register with server.
.. todo:: this should probably not be a part of network...
class Network(object):
"""Class for communicating with ACME servers.
:param account: Account
:type account: :class:`letsencrypt.account.Account`
:ivar str server_url: Full URL of the ACME service
"""
def __init__(self, server):
"""Initialize Network instance.
:param str server: ACME (CA) server[:port]
:returns: Updated account
:rtype: :class:`letsencrypt.account.Account`
"""
self.server_url = "https://%s/acme/" % server
def send(self, msg):
"""Send ACME message to server.
:param msg: ACME message.
:type msg: :class:`acme.messages.Message`
:returns: Server response message.
:rtype: :class:`acme.messages.Message`
:raises acme.errors.ValidationError: if `msg` is not
valid serializable ACME JSON message.
:raises errors.LetsEncryptClientError: in case of connection error
or if response from server is not a valid ACME message.
"""
try:
response = requests.post(
self.server_url,
data=msg.json_dumps(),
headers={"Content-Type": "application/json"},
verify=True
)
except requests.exceptions.RequestException as error:
raise errors.LetsEncryptClientError(
'Sending ACME message to server has failed: %s' % error)
json_string = response.json()
try:
return messages.Message.from_json(json_string)
except jose.DeserializationError as error:
logging.error(json_string)
raise # TODO
def send_and_receive_expected(self, msg, expected):
"""Send ACME message to server and return expected message.
:param msg: ACME message.
:type msg: :class:`acme.Message`
:returns: ACME response message of expected type.
:rtype: :class:`acme.messages.Message`
:raises errors.LetsEncryptClientError: An exception is thrown
"""
response = self.send(msg)
return self.is_expected_msg(response, expected)
def is_expected_msg(self, response, expected, delay=3, rounds=20):
"""Is response expected ACME message?
:param response: ACME response message from server.
:type response: :class:`acme.messages.Message`
:param expected: Expected response type.
:type expected: subclass of :class:`acme.messages.Message`
:param int delay: Number of seconds to delay before next round
in case of ACME "defer" response message.
:param int rounds: Number of resend attempts in case of ACME "defer"
response message.
:returns: ACME response message from server.
:rtype: :class:`acme.messages.Message`
:raises LetsEncryptClientError: if server sent ACME "error" message
"""
for _ in xrange(rounds):
if isinstance(response, expected):
return response
elif isinstance(response, messages.Error):
logging.error("%s", response)
raise errors.LetsEncryptClientError(response.error)
elif isinstance(response, messages.Defer):
logging.info("Waiting for %d seconds...", delay)
time.sleep(delay)
response = self.send(
messages.StatusRequest(token=response.token))
else:
logging.fatal("Received unexpected message")
logging.fatal("Expected: %s", expected)
logging.fatal("Received: %s", response)
sys.exit(33)
logging.error(
"Server has deferred past the max of %d seconds", rounds * delay)
details = (
"mailto:" + account.email if account.email is not None else None,
"tel:" + account.phone if account.phone is not None else None,
)
account.regr = self.register(contact=tuple(
det for det in details if det is not None))
return account

View file

@ -1,8 +1,14 @@
"""Plugin common functions."""
import os
import pkg_resources
import shutil
import tempfile
import zope.interface
from acme.jose import util as jose_util
from letsencrypt import constants
from letsencrypt import interfaces
@ -69,3 +75,127 @@ class Plugin(object):
with unique plugin name prefix.
"""
# other
class Addr(object):
r"""Represents an virtual host address.
:param str addr: addr part of vhost address
:param str port: port number or \*, or ""
"""
def __init__(self, tup):
self.tup = tup
@classmethod
def fromstring(cls, str_addr):
"""Initialize Addr from string."""
tup = str_addr.partition(':')
return cls((tup[0], tup[2]))
def __str__(self):
if self.tup[1]:
return "%s:%s" % self.tup
return self.tup[0]
def __eq__(self, other):
if isinstance(other, self.__class__):
return self.tup == other.tup
return False
def __hash__(self):
return hash(self.tup)
def get_addr(self):
"""Return addr part of Addr object."""
return self.tup[0]
def get_port(self):
"""Return port."""
return self.tup[1]
def get_addr_obj(self, port):
"""Return new address object with same addr and new port."""
return self.__class__((self.tup[0], port))
class Dvsni(object):
"""Class that perform DVSNI challenges."""
def __init__(self, configurator):
self.configurator = configurator
self.achalls = []
self.indices = []
self.challenge_conf = os.path.join(
configurator.config.config_dir, "le_dvsni_cert_challenge.conf")
# self.completed = 0
def add_chall(self, achall, idx=None):
"""Add challenge to DVSNI object to perform at once.
:param achall: Annotated DVSNI challenge.
:type achall: :class:`letsencrypt.achallenges.DVSNI`
:param int idx: index to challenge in a larger array
"""
self.achalls.append(achall)
if idx is not None:
self.indices.append(idx)
def get_cert_file(self, achall):
"""Returns standardized name for challenge certificate.
:param achall: Annotated DVSNI challenge.
:type achall: :class:`letsencrypt.achallenges.DVSNI`
:returns: certificate file name
:rtype: str
"""
return os.path.join(
self.configurator.config.work_dir, achall.nonce_domain + ".crt")
def _setup_challenge_cert(self, achall, s=None):
# pylint: disable=invalid-name
"""Generate and write out challenge certificate."""
cert_path = self.get_cert_file(achall)
# Register the path before you write out the file
self.configurator.reverter.register_file_creation(True, cert_path)
cert_pem, response = achall.gen_cert_and_response(s)
# Write out challenge cert
with open(cert_path, "w") as cert_chall_fd:
cert_chall_fd.write(cert_pem)
return response
# test utils
def setup_ssl_options(config_dir, src, dest):
"""Move the ssl_options into position and return the path."""
option_path = os.path.join(config_dir, dest)
shutil.copyfile(src, option_path)
return option_path
def dir_setup(test_dir, pkg):
"""Setup the directories necessary for the configurator."""
temp_dir = tempfile.mkdtemp("temp")
config_dir = tempfile.mkdtemp("config")
work_dir = tempfile.mkdtemp("work")
os.chmod(temp_dir, constants.CONFIG_DIRS_MODE)
os.chmod(config_dir, constants.CONFIG_DIRS_MODE)
os.chmod(work_dir, constants.CONFIG_DIRS_MODE)
test_configs = pkg_resources.resource_filename(
pkg, os.path.join("testdata", test_dir))
shutil.copytree(
test_configs, os.path.join(temp_dir, test_dir), symlinks=True)
return temp_dir, config_dir, work_dir

View file

@ -1,8 +1,16 @@
"""Tests for letsencrypt.plugins.common."""
import pkg_resources
import unittest
import mock
from acme import challenges
from letsencrypt import achallenges
from letsencrypt import le_util
from letsencrypt.tests import acme_util
class NamespaceFunctionsTest(unittest.TestCase):
"""Tests for letsencrypt.plugins.common.*_namespace functions."""
@ -57,5 +65,103 @@ class PluginTest(unittest.TestCase):
"--mock-foo-bar", dest="different_to_foo_bar", x=1, y=None)
class AddrTest(unittest.TestCase):
"""Tests for letsencrypt.client.plugins.common.Addr."""
def setUp(self):
from letsencrypt.plugins.common import Addr
self.addr1 = Addr.fromstring("192.168.1.1")
self.addr2 = Addr.fromstring("192.168.1.1:*")
self.addr3 = Addr.fromstring("192.168.1.1:80")
def test_fromstring(self):
self.assertEqual(self.addr1.get_addr(), "192.168.1.1")
self.assertEqual(self.addr1.get_port(), "")
self.assertEqual(self.addr2.get_addr(), "192.168.1.1")
self.assertEqual(self.addr2.get_port(), "*")
self.assertEqual(self.addr3.get_addr(), "192.168.1.1")
self.assertEqual(self.addr3.get_port(), "80")
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")
def test_get_addr_obj(self):
self.assertEqual(str(self.addr1.get_addr_obj("443")), "192.168.1.1:443")
self.assertEqual(str(self.addr2.get_addr_obj("")), "192.168.1.1")
self.assertEqual(str(self.addr1.get_addr_obj("*")), "192.168.1.1:*")
def test_eq(self):
self.assertEqual(self.addr1, self.addr2.get_addr_obj(""))
self.assertNotEqual(self.addr1, self.addr2)
self.assertFalse(self.addr1 == 3333)
def test_set_inclusion(self):
from letsencrypt.plugins.common import Addr
set_a = set([self.addr1, self.addr2])
addr1b = Addr.fromstring("192.168.1.1")
addr2b = Addr.fromstring("192.168.1.1:*")
set_b = set([addr1b, addr2b])
self.assertEqual(set_a, set_b)
class DvsniTest(unittest.TestCase):
"""Tests for letsencrypt.plugins.common.DvsniTest."""
rsa256_file = pkg_resources.resource_filename(
"acme.jose", "testdata/rsa256_key.pem")
rsa256_pem = pkg_resources.resource_string(
"acme.jose", "testdata/rsa256_key.pem")
auth_key = le_util.Key(rsa256_file, rsa256_pem)
achalls = [
achallenges.DVSNI(
challb=acme_util.chall_to_challb(
challenges.DVSNI(
r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9"
"\xf1\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4",
nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18",
), "pending"),
domain="encryption-example.demo", key=auth_key),
achallenges.DVSNI(
challb=acme_util.chall_to_challb(
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",
), "pending"),
domain="letsencrypt.demo", key=auth_key),
]
def setUp(self):
from letsencrypt.plugins.common import Dvsni
self.sni = Dvsni(configurator=mock.MagicMock())
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
m_open = mock.mock_open()
response = challenges.DVSNIResponse(s="randomS1")
achall = mock.MagicMock(nonce=self.achalls[0].nonce,
nonce_domain=self.achalls[0].nonce_domain)
achall.gen_cert_and_response.return_value = ("pem", response)
with mock.patch("letsencrypt.plugins.common.open", m_open, create=True):
# pylint: disable=protected-access
self.assertEqual(response, self.sni._setup_challenge_cert(
achall, "randomS1"))
self.assertTrue(m_open.called)
self.assertEqual(
m_open.call_args[0], (self.sni.get_cert_file(achall), "w"))
self.assertEqual(m_open().write.call_args[0][0], "pem")
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -89,10 +89,10 @@ class PluginEntryPoint(object):
if self._prepared is None:
try:
self._initialized.prepare()
except errors.LetsEncryptMisconfigurationError as error:
except errors.MisconfigurationError as error:
logging.debug("Misconfigured %r: %s", self, error)
self._prepared = error
except errors.LetsEncryptNoInstallationError as error:
except errors.NoInstallationError as error:
logging.debug("No installation (%r): %s", self, error)
self._prepared = error
else:
@ -102,8 +102,7 @@ class PluginEntryPoint(object):
@property
def misconfigured(self):
"""Is plugin misconfigured?"""
return isinstance(
self._prepared, errors.LetsEncryptMisconfigurationError)
return isinstance(self._prepared, errors.MisconfigurationError)
@property
def available(self):

View file

@ -124,22 +124,22 @@ class PluginEntryPointTest(unittest.TestCase):
def test_prepare_misconfigured(self):
plugin = mock.MagicMock()
plugin.prepare.side_effect = errors.LetsEncryptMisconfigurationError
plugin.prepare.side_effect = errors.MisconfigurationError
# pylint: disable=protected-access
self.plugin_ep._initialized = plugin
self.assertTrue(isinstance(self.plugin_ep.prepare(),
errors.LetsEncryptMisconfigurationError))
errors.MisconfigurationError))
self.assertTrue(self.plugin_ep.prepared)
self.assertTrue(self.plugin_ep.misconfigured)
self.assertTrue(self.plugin_ep.available)
def test_prepare_no_installation(self):
plugin = mock.MagicMock()
plugin.prepare.side_effect = errors.LetsEncryptNoInstallationError
plugin.prepare.side_effect = errors.NoInstallationError
# pylint: disable=protected-access
self.plugin_ep._initialized = plugin
self.assertTrue(isinstance(self.plugin_ep.prepare(),
errors.LetsEncryptNoInstallationError))
errors.NoInstallationError))
self.assertTrue(self.plugin_ep.prepared)
self.assertFalse(self.plugin_ep.misconfigured)
self.assertFalse(self.plugin_ep.available)

View file

@ -0,0 +1,138 @@
"""Manual plugin."""
import logging
import os
import sys
import requests
import zope.component
import zope.interface
from acme import challenges
from acme import jose
from letsencrypt import interfaces
from letsencrypt.plugins import common
class ManualAuthenticator(common.Plugin):
"""Manual Authenticator.
.. todo:: Support for `~.challenges.DVSNI`.
"""
zope.interface.implements(interfaces.IAuthenticator)
zope.interface.classProvides(interfaces.IPluginFactory)
description = "Manual Authenticator"
MESSAGE_TEMPLATE = """\
Make sure your web server displays the following content at
{uri} before continuing:
{achall.token}
If you don't have HTTP server configured, you can run the following
command on the target server (as root):
{command}
"""
HTTP_TEMPLATE = """\
mkdir -p {response.URI_ROOT_PATH}
echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path}
# run only once per server:
python -m SimpleHTTPServer 80"""
"""Non-TLS command template."""
# https://www.piware.de/2011/01/creating-an-https-server-in-python/
HTTPS_TEMPLATE = """\
mkdir -p {response.URI_ROOT_PATH} # run only once per server
echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path}
# run only once per server:
openssl req -new -newkey rsa:4096 -subj "/" -days 1 -nodes -x509 -keyout key.pem -out cert.pem
python -c "import BaseHTTPServer, SimpleHTTPServer, ssl; \\
s = BaseHTTPServer.HTTPServer(('', 443), SimpleHTTPServer.SimpleHTTPRequestHandler); \\
s.socket = ssl.wrap_socket(s.socket, keyfile='key.pem', certfile='cert.pem'); \\
s.serve_forever()" """
"""TLS command template.
According to the ACME specification, "the ACME server MUST ignore
the certificate provided by the HTTPS server", so the first command
generates temporary self-signed certificate. For the same reason
``requests.get`` in `_verify` sets ``verify=False``. Python HTTPS
server command serves the ``token`` on all URIs.
"""
def __init__(self, *args, **kwargs):
super(ManualAuthenticator, self).__init__(*args, **kwargs)
self.template = (self.HTTP_TEMPLATE if self.config.no_simple_http_tls
else self.HTTPS_TEMPLATE)
def prepare(self): # pylint: disable=missing-docstring,no-self-use
pass # pragma: no cover
def more_info(self): # pylint: disable=missing-docstring,no-self-use
return """\
This plugin requires user's manual intervention in setting up a HTTP
server for solving SimpleHTTP challenges and thus does not need to be
run as a privilidged process. Alternatively shows instructions on how
to use Python's built-in HTTP server and, in case of HTTPS, openssl
binary for temporary key/certificate generation.""".replace("\n", "")
def get_chall_pref(self, domain):
# pylint: disable=missing-docstring,no-self-use,unused-argument
return [challenges.SimpleHTTP]
def perform(self, achalls): # pylint: disable=missing-docstring
responses = []
# TODO: group achalls by the same socket.gethostbyname(_ex)
# and prompt only once per server (one "echo -n" per domain)
for achall in achalls:
responses.append(self._perform_single(achall))
return responses
def _perform_single(self, achall):
# same path for each challenge response would be easier for
# users, but will not work if multiple domains point at the
# same server: default command doesn't support virtual hosts
response = challenges.SimpleHTTPResponse(
path=jose.b64encode(os.urandom(18)),
tls=(not self.config.no_simple_http_tls))
assert response.good_path # is encoded os.urandom(18) good?
self._notify_and_wait(self.MESSAGE_TEMPLATE.format(
achall=achall, response=response,
uri=response.uri(achall.domain),
command=self.template.format(achall=achall, response=response)))
if self._verify(achall, response):
return response
else:
return None
def _notify_and_wait(self, message): # pylint: disable=no-self-use
# TODO: IDisplay wraps messages, breaking the command
#answer = zope.component.getUtility(interfaces.IDisplay).notification(
# message=message, height=25, pause=True)
sys.stdout.write(message)
raw_input("Press ENTER to continue")
def _verify(self, achall, chall_response): # pylint: disable=no-self-use
uri = chall_response.uri(achall.domain)
logging.debug("Verifying %s...", uri)
try:
response = requests.get(uri, verify=False)
except requests.exceptions.ConnectionError as error:
logging.exception(error)
return False
ret = response.text == achall.token
if not ret:
logging.error("Unable to verify %s! Expected: %r, returned: %r.",
uri, achall.token, response.text)
return ret
def cleanup(self, achalls): # pylint: disable=missing-docstring,no-self-use
pass # pragma: no cover

View file

@ -0,0 +1,59 @@
"""Tests for letsencrypt.plugins.manual."""
import unittest
import mock
import requests
from acme import challenges
from letsencrypt import achallenges
from letsencrypt.tests import acme_util
class ManualAuthenticatorTest(unittest.TestCase):
"""Tests for letsencrypt.plugins.manual.ManualAuthenticator."""
def setUp(self):
from letsencrypt.plugins.manual import ManualAuthenticator
self.config = mock.MagicMock(no_simple_http_tls=True)
self.auth = ManualAuthenticator(config=self.config, name="manual")
self.achalls = [achallenges.SimpleHTTP(
challb=acme_util.SIMPLE_HTTP, domain="foo.com", key=None)]
def test_more_info(self):
self.assertTrue(isinstance(self.auth.more_info(), str))
def test_get_chall_pref(self):
self.assertTrue(all(issubclass(pref, challenges.Challenge)
for pref in self.auth.get_chall_pref("foo.com")))
def test_perform_empty(self):
self.assertEqual([], self.auth.perform([]))
@mock.patch("letsencrypt.plugins.manual.sys.stdout")
@mock.patch("letsencrypt.plugins.manual.os.urandom")
@mock.patch("letsencrypt.plugins.manual.requests.get")
@mock.patch("__builtin__.raw_input")
def test_perform(self, mock_raw_input, mock_get, mock_urandom, mock_stdout):
mock_urandom.return_value = "foo"
mock_get().text = self.achalls[0].token
self.assertEqual(
[challenges.SimpleHTTPResponse(tls=False, path='Zm9v')],
self.auth.perform(self.achalls))
mock_raw_input.assert_called_once()
mock_get.assert_called_with(
"http://foo.com/.well-known/acme-challenge/Zm9v", verify=False)
message = mock_stdout.write.mock_calls[0][1][0]
self.assertTrue(self.achalls[0].token in message)
self.assertTrue('Zm9v' in message)
mock_get().text = self.achalls[0].token + '!'
self.assertEqual([None], self.auth.perform(self.achalls))
mock_get.side_effect = requests.exceptions.ConnectionError
self.assertEqual([None], self.auth.perform(self.achalls))
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -7,15 +7,19 @@ within lineages of successor certificates, according to configuration.
.. todo:: Call new installer API to restart servers after deployment
"""
import argparse
import os
import sys
import configobj
from letsencrypt import configuration
from letsencrypt import cli
from letsencrypt import client
from letsencrypt import crypto_util
from letsencrypt import notify
from letsencrypt import storage
from letsencrypt.plugins import disco as plugins_disco
@ -92,7 +96,23 @@ def renew(cert, old_version):
# (where fewer than all names were renewed)
def main(config=None):
def _paths_parser(parser):
add = parser.add_argument_group("paths").add_argument
add("--config-dir", default=cli.flag_default("config_dir"),
help=cli.config_help("config_dir"))
add("--work-dir", default=cli.flag_default("work_dir"),
help=cli.config_help("work_dir"))
return parser
def _create_parser():
parser = argparse.ArgumentParser()
#parser.add_argument("--cron", action="store_true", help="Run as cronjob.")
# pylint: disable=protected-access
return _paths_parser(parser)
def main(config=None, args=sys.argv[1:]):
"""Main function for autorenewer script."""
# TODO: Distinguish automated invocation from manual invocation,
# perhaps by looking at sys.argv[0] and inhibiting automated
@ -100,6 +120,9 @@ def main(config=None):
# turned it off. (The boolean parameter should probably be
# called renewer_enabled.)
cli_config = configuration.RenewerConfiguration(
_create_parser().parse_args(args))
config = storage.config_with_defaults(config)
# Now attempt to read the renewer config file and augment or replace
# the renewer defaults with any options contained in that file. If
@ -108,14 +131,15 @@ def main(config=None):
# elaborate renewer command line, we will presumably also be able to
# specify a config file on the command line, which, if provided, should
# take precedence over this one.
config.merge(configobj.ConfigObj(config.get("renewer_config_file", "")))
config.merge(configobj.ConfigObj(cli_config.renewer_config_file))
for i in os.listdir(config["renewal_configs_dir"]):
for i in os.listdir(cli_config.renewal_configs_dir):
print "Processing", i
if not i.endswith(".conf"):
continue
rc_config = configobj.ConfigObj(
os.path.join(config["renewal_configs_dir"], i))
rc_config = configobj.ConfigObj(cli_config.renewer_config_file)
rc_config.merge(configobj.ConfigObj(
os.path.join(cli_config.renewal_configs_dir, i)))
try:
# TODO: Before trying to initialize the RenewableCert object,
# we could check here whether the combination of the config
@ -125,7 +149,7 @@ def main(config=None):
# RenewableCert object for this cert at all, which could
# dramatically improve performance for large deployments
# where autorenewal is widely turned off.
cert = storage.RenewableCert(rc_config)
cert = storage.RenewableCert(rc_config, cli_config=cli_config)
except ValueError:
# This indicates an invalid renewal configuration file, such
# as one missing a required parameter (in the future, perhaps

View file

@ -66,7 +66,8 @@ class Reporter(object):
If there is an unhandled exception, only messages for which
``on_crash`` is ``True`` are printed.
"""
"""
bold_on = False
if not self.messages.empty():
no_exception = sys.exc_info()[0] is None
@ -74,14 +75,21 @@ class Reporter(object):
if bold_on:
print self._BOLD
print 'IMPORTANT NOTES:'
wrapper = textwrap.TextWrapper(initial_indent=' - ',
subsequent_indent=(' ' * 3))
first_wrapper = textwrap.TextWrapper(
initial_indent=' - ', subsequent_indent=(' ' * 3))
next_wrapper = textwrap.TextWrapper(
initial_indent=first_wrapper.subsequent_indent,
subsequent_indent=first_wrapper.subsequent_indent)
while not self.messages.empty():
msg = self.messages.get()
if no_exception or msg.on_crash:
if bold_on and msg.priority > self.HIGH_PRIORITY:
sys.stdout.write(self._RESET)
bold_on = False
print wrapper.fill(msg.text)
lines = msg.text.splitlines()
print first_wrapper.fill(lines[0])
if len(lines) > 1:
print "\n".join(
next_wrapper.fill(line) for line in lines[1:])
if bold_on:
sys.stdout.write(self._RESET)

View file

@ -30,19 +30,17 @@ class Reverter(object):
This function should reinstall the users original configuration files
for all saves with temporary=True
:raises letsencrypt.errors.LetsEncryptReverterError: when
unable to revert config
:raises .ReverterError: when unable to revert config
"""
if os.path.isdir(self.config.temp_checkpoint_dir):
try:
self._recover_checkpoint(self.config.temp_checkpoint_dir)
except errors.LetsEncryptReverterError:
except errors.ReverterError:
# We have a partial or incomplete recovery
logging.fatal("Incomplete or failed recovery for %s",
self.config.temp_checkpoint_dir)
raise errors.LetsEncryptReverterError(
"Unable to revert temporary config")
raise errors.ReverterError("Unable to revert temporary config")
def rollback_checkpoints(self, rollback=1):
"""Revert 'rollback' number of configuration checkpoints.
@ -50,20 +48,20 @@ class Reverter(object):
:param int rollback: Number of checkpoints to reverse. A str num will be
cast to an integer. So "2" is also acceptable.
:raises letsencrypt.errors.LetsEncryptReverterError: If
there is a problem with the input or if the function is unable to
correctly revert the configuration checkpoints.
:raises .ReverterError:
if there is a problem with the input or if the function is
unable to correctly revert the configuration checkpoints
"""
try:
rollback = int(rollback)
except ValueError:
logging.error("Rollback argument must be a positive integer")
raise errors.LetsEncryptReverterError("Invalid Input")
raise errors.ReverterError("Invalid Input")
# Sanity check input
if rollback < 0:
logging.error("Rollback argument must be a positive integer")
raise errors.LetsEncryptReverterError("Invalid Input")
raise errors.ReverterError("Invalid Input")
backups = os.listdir(self.config.backup_dir)
backups.sort()
@ -76,9 +74,9 @@ class Reverter(object):
cp_dir = os.path.join(self.config.backup_dir, backups.pop())
try:
self._recover_checkpoint(cp_dir)
except errors.LetsEncryptReverterError:
except errors.ReverterError:
logging.fatal("Failed to load checkpoint during rollback")
raise errors.LetsEncryptReverterError(
raise errors.ReverterError(
"Unable to load checkpoint during rollback")
rollback -= 1
@ -104,7 +102,7 @@ class Reverter(object):
for bkup in backups:
float(bkup)
except ValueError:
raise errors.LetsEncryptReverterError(
raise errors.ReverterError(
"Invalid directories in {0}".format(self.config.backup_dir))
output = []
@ -161,9 +159,8 @@ class Reverter(object):
:param set save_files: set of files to save
:param str save_notes: notes about changes made during the save
:raises IOError: If unable to open cp_dir + FILEPATHS file
:raises letsencrypt.errors.LetsEncryptReverterError: If
unable to add checkpoint
:raises IOError: if unable to open cp_dir + FILEPATHS file
:raises .ReverterError: if unable to add checkpoint
"""
le_util.make_or_verify_dir(
@ -191,7 +188,7 @@ class Reverter(object):
logging.error(
"Unable to add file %s to checkpoint %s",
filename, cp_dir)
raise errors.LetsEncryptReverterError(
raise errors.ReverterError(
"Unable to add file {0} to checkpoint "
"{1}".format(filename, cp_dir))
idx += 1
@ -224,7 +221,7 @@ class Reverter(object):
:param str cp_dir: checkpoint directory file path
:raises errors.LetsEncryptReverterError: If unable to recover checkpoint
:raises errors.ReverterError: If unable to recover checkpoint
"""
if os.path.isfile(os.path.join(cp_dir, "FILEPATHS")):
@ -238,7 +235,7 @@ class Reverter(object):
except (IOError, OSError):
# This file is required in all checkpoints.
logging.error("Unable to recover files from %s", cp_dir)
raise errors.LetsEncryptReverterError(
raise errors.ReverterError(
"Unable to recover files from %s" % cp_dir)
# Remove any newly added files if they exist
@ -248,7 +245,7 @@ class Reverter(object):
shutil.rmtree(cp_dir)
except OSError:
logging.error("Unable to remove directory: %s", cp_dir)
raise errors.LetsEncryptReverterError(
raise errors.ReverterError(
"Unable to remove directory: %s" % cp_dir)
def _check_tempfile_saves(self, save_files):
@ -256,7 +253,7 @@ class Reverter(object):
:param set save_files: Set of files about to be saved.
:raises letsencrypt.errors.LetsEncryptReverterError:
:raises letsencrypt.errors.ReverterError:
when save is attempting to overwrite a temporary file.
"""
@ -277,7 +274,7 @@ class Reverter(object):
# Verify no save_file is in protected_files
for filename in protected_files:
if filename in save_files:
raise errors.LetsEncryptReverterError(
raise errors.ReverterError(
"Attempting to overwrite challenge "
"file - %s" % filename)
@ -292,7 +289,7 @@ class Reverter(object):
a temp or permanent save.
:param \*files: file paths (str) to be registered
:raises letsencrypt.errors.LetsEncryptReverterError: If
:raises letsencrypt.errors.ReverterError: If
call does not contain necessary parameters or if the file creation
is unable to be registered.
@ -300,7 +297,7 @@ class Reverter(object):
# Make sure some files are provided... as this is an error
# Made this mistake in my initial implementation of apache.dvsni.py
if not files:
raise errors.LetsEncryptReverterError(
raise errors.ReverterError(
"Forgot to provide files to registration call")
if temporary:
@ -322,7 +319,7 @@ class Reverter(object):
new_fd.write("{0}{1}".format(path, os.linesep))
except (IOError, OSError):
logging.error("Unable to register file creation(s) - %s", files)
raise errors.LetsEncryptReverterError(
raise errors.ReverterError(
"Unable to register file creation(s) - {0}".format(files))
finally:
if new_fd is not None:
@ -345,12 +342,12 @@ class Reverter(object):
if os.path.isdir(self.config.in_progress_dir):
try:
self._recover_checkpoint(self.config.in_progress_dir)
except errors.LetsEncryptReverterError:
except errors.ReverterError:
# We have a partial or incomplete recovery
logging.fatal("Incomplete or failed recovery for IN_PROGRESS "
"checkpoint - %s",
self.config.in_progress_dir)
raise errors.LetsEncryptReverterError(
raise errors.ReverterError(
"Incomplete or failed recovery for IN_PROGRESS checkpoint "
"- %s" % self.config.in_progress_dir)
@ -362,7 +359,7 @@ class Reverter(object):
:returns: Success
:rtype: bool
:raises letsencrypt.errors.LetsEncryptReverterError: If
:raises letsencrypt.errors.ReverterError: If
all files within file_list cannot be removed
"""
@ -386,7 +383,7 @@ class Reverter(object):
except (IOError, OSError):
logging.fatal(
"Unable to remove filepaths contained within %s", file_list)
raise errors.LetsEncryptReverterError(
raise errors.ReverterError(
"Unable to remove filepaths contained within "
"{0}".format(file_list))
@ -400,7 +397,7 @@ class Reverter(object):
:param str title: Title describing checkpoint
:raises letsencrypt.errors.LetsEncryptReverterError: when the
:raises letsencrypt.errors.ReverterError: when the
checkpoint is not able to be finalized.
"""
@ -426,7 +423,7 @@ class Reverter(object):
shutil.move(changes_since_tmp_path, changes_since_path)
except (IOError, OSError):
logging.error("Unable to finalize checkpoint - adding title")
raise errors.LetsEncryptReverterError("Unable to add title")
raise errors.ReverterError("Unable to add title")
self._timestamp_progress_dir()
@ -451,5 +448,5 @@ class Reverter(object):
logging.error(
"Unable to finalize checkpoint, %s -> %s",
self.config.in_progress_dir, final_dir)
raise errors.LetsEncryptReverterError(
raise errors.ReverterError(
"Unable to finalize checkpoint renaming")

View file

@ -16,7 +16,6 @@ import tempfile
import Crypto.PublicKey.RSA
import M2Crypto
from acme import messages
from acme.jose import util as jose_util
from letsencrypt import errors
@ -45,7 +44,9 @@ class Revoker(object):
"""
def __init__(self, installer, config, no_confirm=False):
self.network = network.Network(config.server)
# XXX
self.network = network.Network(new_reg_uri=None, key=None, alg=None)
self.installer = installer
self.config = config
self.no_confirm = no_confirm
@ -70,7 +71,7 @@ class Revoker(object):
authkey.pem).exportKey("PEM")
# https://www.dlitz.net/software/pycrypto/api/current/Crypto.PublicKey.RSA-module.html
except (IndexError, ValueError, TypeError):
raise errors.LetsEncryptRevokerError(
raise errors.RevokerError(
"Invalid key file specified to revoke_from_key")
with open(self.list_path, "rb") as csvfile:
@ -88,8 +89,7 @@ class Revoker(object):
# This should never happen given the assumptions of the
# module. If it does, it is probably best to delete the
# the offending key/cert. For now... just raise an exception
raise errors.LetsEncryptRevokerError(
"%s - backup file is corrupted.")
raise errors.RevokerError("%s - backup file is corrupted.")
if clean_pem == test_pem:
certs.append(
@ -217,7 +217,7 @@ class Revoker(object):
if self.no_confirm or revocation.confirm_revocation(cert):
try:
self._acme_revoke(cert)
except errors.LetsEncryptClientError:
except errors.Error:
# TODO: Improve error handling when networking is set...
logging.error(
"Unable to revoke cert:%s%s", os.linesep, str(cert))
@ -238,6 +238,8 @@ class Revoker(object):
:returns: TODO
"""
# XXX | pylint: disable=unused-variable
# These will both have to change in the future away from M2Crypto
# pylint: disable=protected-access
certificate = jose_util.ComparableX509(cert._cert)
@ -247,13 +249,10 @@ class Revoker(object):
# If the key file doesn't exist... or is corrupted
except (IndexError, ValueError, TypeError):
raise errors.LetsEncryptRevokerError(
raise errors.RevokerError(
"Corrupted backup key file: %s" % cert.backup_key_path)
# TODO: Catch error associated with already revoked and proceed.
return self.network.send_and_receive_expected(
messages.RevocationRequest.create(certificate=certificate, key=key),
messages.Revocation)
return self.network.revoke(cert=None) # XXX
def _remove_certs_keys(self, cert_list): # pylint: disable=no-self-use
"""Remove certificate and key.
@ -293,7 +292,7 @@ class Revoker(object):
# This should never happen...
if idx != len(cert_list):
raise errors.LetsEncryptRevokerError(
raise errors.RevokerError(
"Did not find all cert_list items to remove from LIST")
shutil.copy2(list_path2, self.list_path)
@ -398,7 +397,7 @@ class Cert(object):
try:
self._cert = M2Crypto.X509.load_cert(cert_path)
except (IOError, M2Crypto.X509.X509Error):
raise errors.LetsEncryptRevokerError(
raise errors.RevokerError(
"Error loading certificate: %s" % cert_path)
self.idx = -1

View file

@ -78,14 +78,17 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
renewal configuration file and/or systemwide defaults.
"""
def __init__(self, configfile, config_opts=None):
def __init__(self, configfile, config_opts=None, cli_config=None):
"""Instantiate a RenewableCert object from an existing lineage.
:param configobj.ConfigObj configfile: an already-parsed
ConfigObj object made from reading the renewal config file
that defines this lineage. :param configobj.ConfigObj
config_opts: systemwide defaults for renewal properties not
otherwise specified in the individual renewal config file.
ConfigObj object made from reading the renewal config file
that defines this lineage.
:param configobj.ConfigObj config_opts: systemwide defaults for
renewal properties not otherwise specified in the individual
renewal config file.
:param .RenewerConfiguration cli_config:
:raises ValueError: if the configuration file's name didn't end
in ".conf", or the file is missing or broken.
@ -93,6 +96,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
ConfigObj object.
"""
self.cli_config = cli_config
if isinstance(configfile, configobj.ConfigObj):
if not os.path.basename(configfile.filename).endswith(".conf"):
raise ValueError("renewal config file name must end in .conf")
@ -149,7 +153,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
# Each element's link must point within the cert lineage's
# directory within the official archive directory
desired_directory = os.path.join(
self.configuration["archive_dir"], self.lineagename)
self.cli_config.archive_dir, self.lineagename)
if not os.path.samefile(os.path.dirname(target),
desired_directory):
return False
@ -499,7 +503,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
@classmethod
def new_lineage(cls, lineagename, cert, privkey, chain,
renewalparams=None, config=None):
renewalparams=None, config=None, cli_config=None):
# pylint: disable=too-many-locals,too-many-arguments
"""Create a new certificate lineage.
@ -536,17 +540,15 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
# the renewer defaults with any options contained in that file. If
# renewer_config_file is undefined or if the file is nonexistent or
# empty, this .merge() will have no effect.
config.merge(configobj.ConfigObj(config.get("renewer_config_file", "")))
config.merge(configobj.ConfigObj(cli_config.renewer_config_file))
# Examine the configuration and find the new lineage's name
configs_dir = config["renewal_configs_dir"]
archive_dir = config["archive_dir"]
live_dir = config["live_dir"]
for i in (configs_dir, archive_dir, live_dir):
for i in (cli_config.renewal_configs_dir, cli_config.archive_dir,
cli_config.live_dir):
if not os.path.exists(i):
os.makedirs(i, 0700)
config_file, config_filename = le_util.unique_lineage_name(configs_dir,
lineagename)
config_file, config_filename = le_util.unique_lineage_name(
cli_config.renewal_configs_dir, lineagename)
if not config_filename.endswith(".conf"):
raise ValueError("renewal config file name must end in .conf")
@ -554,8 +556,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
# lineagename will now potentially be modified based on which
# renewal configuration file could actually be created
lineagename = os.path.basename(config_filename)[:-len(".conf")]
archive = os.path.join(archive_dir, lineagename)
live_dir = os.path.join(live_dir, lineagename)
archive = os.path.join(cli_config.archive_dir, lineagename)
live_dir = os.path.join(cli_config.live_dir, lineagename)
if os.path.exists(archive):
raise ValueError("archive directory exists for " + lineagename)
if os.path.exists(live_dir):
@ -593,7 +595,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
# TODO: add human-readable comments explaining other available
# parameters
new_config.write()
return cls(new_config, config)
return cls(new_config, config, cli_config)
def save_successor(self, prior_version, new_cert, new_privkey, new_chain):
@ -624,7 +626,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
# Figure out what the new version is and hence where to save things
target_version = self.next_free_version()
archive = self.configuration["archive_dir"]
archive = self.cli_config.archive_dir
prefix = os.path.join(archive, self.lineagename)
target = dict(
[(kind,

View file

@ -7,7 +7,7 @@ import shutil
import tempfile
import unittest
from acme import messages2
from acme import messages
from letsencrypt import configuration
from letsencrypt import errors
@ -40,11 +40,11 @@ class AccountTest(unittest.TestCase):
self.key = le_util.Key(key_file, key_pem)
self.email = "client@letsencrypt.org"
self.regr = messages2.RegistrationResource(
self.regr = messages.RegistrationResource(
uri="uri",
new_authzr_uri="new_authzr_uri",
terms_of_service="terms_of_service",
body=messages2.Registration(
body=messages.Registration(
recovery_token="recovery_token", agreement="agreement")
)
@ -73,7 +73,7 @@ class AccountTest(unittest.TestCase):
def test_prompts_bad_email(self, mock_from_email, mock_util):
from letsencrypt.account import Account
mock_from_email.side_effect = (errors.LetsEncryptClientError, "acc")
mock_from_email.side_effect = (errors.Error, "acc")
mock_util().input.return_value = (display_util.OK, self.email)
self.assertEqual(Account.from_prompts(self.config), "acc")
@ -102,8 +102,8 @@ class AccountTest(unittest.TestCase):
def test_from_email(self):
from letsencrypt.account import Account
self.assertRaises(errors.LetsEncryptClientError,
Account.from_email, self.config, "not_valid...email")
self.assertRaises(
errors.Error, Account.from_email, self.config, "not_valid...email")
def test_save_from_existing_account(self):
from letsencrypt.account import Account
@ -170,10 +170,8 @@ class AccountTest(unittest.TestCase):
def test_failed_existing_account(self):
from letsencrypt.account import Account
self.assertRaises(
errors.LetsEncryptClientError,
Account.from_existing_account,
self.config, "non-existant@email.org")
self.assertRaises(errors.Error, Account.from_existing_account,
self.config, "non-existant@email.org")
class SafeEmailTest(unittest.TestCase):
"""Test safe_email."""

View file

@ -8,7 +8,7 @@ import Crypto.PublicKey.RSA
from acme import challenges
from acme import jose
from acme import messages2
from acme import messages
KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
@ -16,7 +16,7 @@ KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey(
"acme.jose", os.path.join("testdata", "rsa512_key.pem"))))
# Challenges
SIMPLE_HTTPS = challenges.SimpleHTTP(
SIMPLE_HTTP = challenges.SimpleHTTP(
token="evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA")
DVSNI = challenges.DVSNI(
r="O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6\xbf'\xb3"
@ -47,7 +47,7 @@ POP = challenges.ProofOfPossession(
)
)
CHALLENGES = [SIMPLE_HTTPS, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP]
CHALLENGES = [SIMPLE_HTTP, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP]
DV_CHALLENGES = [chall for chall in CHALLENGES
if isinstance(chall, challenges.DVChallenge)]
CONT_CHALLENGES = [chall for chall in CHALLENGES
@ -78,21 +78,21 @@ def chall_to_challb(chall, status): # pylint: disable=redefined-outer-name
"status": status,
}
if status == messages2.STATUS_VALID:
if status == messages.STATUS_VALID:
kwargs.update({"validated": datetime.datetime.now()})
return messages2.ChallengeBody(**kwargs) # pylint: disable=star-args
return messages.ChallengeBody(**kwargs) # pylint: disable=star-args
# Pending ChallengeBody objects
DVSNI_P = chall_to_challb(DVSNI, messages2.STATUS_PENDING)
SIMPLE_HTTPS_P = chall_to_challb(SIMPLE_HTTPS, messages2.STATUS_PENDING)
DNS_P = chall_to_challb(DNS, messages2.STATUS_PENDING)
RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, messages2.STATUS_PENDING)
RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, messages2.STATUS_PENDING)
POP_P = chall_to_challb(POP, messages2.STATUS_PENDING)
DVSNI_P = chall_to_challb(DVSNI, messages.STATUS_PENDING)
SIMPLE_HTTP_P = chall_to_challb(SIMPLE_HTTP, messages.STATUS_PENDING)
DNS_P = chall_to_challb(DNS, messages.STATUS_PENDING)
RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, messages.STATUS_PENDING)
RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, messages.STATUS_PENDING)
POP_P = chall_to_challb(POP, messages.STATUS_PENDING)
CHALLENGES_P = [SIMPLE_HTTPS_P, DVSNI_P, DNS_P,
CHALLENGES_P = [SIMPLE_HTTP_P, DVSNI_P, DNS_P,
RECOVERY_CONTACT_P, RECOVERY_TOKEN_P, POP_P]
DV_CHALLENGES_P = [challb for challb in CHALLENGES_P
if isinstance(challb.chall, challenges.DVChallenge)]
@ -106,7 +106,7 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True):
"""Generate an authorization resource.
:param authz_status: Status object
:type authz_status: :class:`acme.messages2.Status`
:type authz_status: :class:`acme.messages.Status`
:param list challs: Challenge objects
:param list statuses: status of each challenge object
:param bool combos: Whether or not to add combinations
@ -118,13 +118,13 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True):
for chall, status in itertools.izip(challs, statuses)
)
authz_kwargs = {
"identifier": messages2.Identifier(
typ=messages2.IDENTIFIER_FQDN, value=domain),
"identifier": messages.Identifier(
typ=messages.IDENTIFIER_FQDN, value=domain),
"challenges": challbs,
}
if combos:
authz_kwargs.update({"combinations": gen_combos(challbs)})
if authz_status == messages2.STATUS_VALID:
if authz_status == messages.STATUS_VALID:
authz_kwargs.update({
"status": authz_status,
"expires": datetime.datetime.now() + datetime.timedelta(days=31),
@ -135,8 +135,8 @@ def gen_authzr(authz_status, domain, challs, statuses, combos=True):
})
# pylint: disable=star-args
return messages2.AuthorizationResource(
return messages.AuthorizationResource(
uri="https://trusted.ca/new-authz-resource",
new_cert_uri="https://trusted.ca/new-cert",
body=messages2.Authorization(**authz_kwargs)
body=messages.Authorization(**authz_kwargs)
)

View file

@ -6,11 +6,11 @@ import unittest
import mock
from acme import challenges
from acme import messages2
from acme import messages
from letsencrypt import errors
from letsencrypt import le_util
from letsencrypt import network2
from letsencrypt import network
from letsencrypt.tests import acme_util
@ -37,8 +37,8 @@ class ChallengeFactoryTest(unittest.TestCase):
self.dom = "test"
self.handler.authzr[self.dom] = acme_util.gen_authzr(
messages2.STATUS_PENDING, self.dom, acme_util.CHALLENGES,
[messages2.STATUS_PENDING]*6, False)
messages.STATUS_PENDING, self.dom, acme_util.CHALLENGES,
[messages.STATUS_PENDING]*6, False)
def test_all(self):
cont_c, dv_c = self.handler._challenge_factory(self.dom, range(0, 6))
@ -57,12 +57,12 @@ class ChallengeFactoryTest(unittest.TestCase):
def test_unrecognized(self):
self.handler.authzr["failure.com"] = acme_util.gen_authzr(
messages2.STATUS_PENDING, "failure.com",
messages.STATUS_PENDING, "failure.com",
[mock.Mock(chall="chall", typ="unrecognized")],
[messages2.STATUS_PENDING])
[messages.STATUS_PENDING])
self.assertRaises(errors.LetsEncryptClientError,
self.handler._challenge_factory, "failure.com", [0])
self.assertRaises(
errors.Error, self.handler._challenge_factory, "failure.com", [0])
class GetAuthorizationsTest(unittest.TestCase):
@ -86,7 +86,7 @@ class GetAuthorizationsTest(unittest.TestCase):
self.mock_dv_auth.perform.side_effect = gen_auth_resp
self.mock_account = mock.Mock(key=le_util.Key("file_path", "PEM"))
self.mock_net = mock.MagicMock(spec=network2.Network)
self.mock_net = mock.MagicMock(spec=network.Network)
self.handler = AuthHandler(
self.mock_dv_auth, self.mock_cont_auth,
@ -153,17 +153,17 @@ class GetAuthorizationsTest(unittest.TestCase):
gen_dom_authzr, challs=acme_util.CHALLENGES)
self.mock_dv_auth.perform.side_effect = errors.AuthorizationError
self.assertRaises(errors.AuthorizationError,
self.handler.get_authorizations, ["0"])
self.assertRaises(
errors.AuthorizationError, self.handler.get_authorizations, ["0"])
def _validate_all(self, unused_1, unused_2):
for dom in self.handler.authzr.keys():
azr = self.handler.authzr[dom]
self.handler.authzr[dom] = acme_util.gen_authzr(
messages2.STATUS_VALID,
messages.STATUS_VALID,
dom,
[challb.chall for challb in azr.body.challenges],
[messages2.STATUS_VALID]*len(azr.body.challenges),
[messages.STATUS_VALID]*len(azr.body.challenges),
azr.body.combinations)
@ -182,16 +182,16 @@ class PollChallengesTest(unittest.TestCase):
self.doms = ["0", "1", "2"]
self.handler.authzr[self.doms[0]] = acme_util.gen_authzr(
messages2.STATUS_PENDING, self.doms[0],
acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False)
messages.STATUS_PENDING, self.doms[0],
acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False)
self.handler.authzr[self.doms[1]] = acme_util.gen_authzr(
messages2.STATUS_PENDING, self.doms[1],
acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False)
messages.STATUS_PENDING, self.doms[1],
acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False)
self.handler.authzr[self.doms[2]] = acme_util.gen_authzr(
messages2.STATUS_PENDING, self.doms[2],
acme_util.DV_CHALLENGES, [messages2.STATUS_PENDING]*3, False)
messages.STATUS_PENDING, self.doms[2],
acme_util.DV_CHALLENGES, [messages.STATUS_PENDING]*3, False)
self.chall_update = {}
for dom in self.doms:
@ -205,7 +205,7 @@ class PollChallengesTest(unittest.TestCase):
self.handler._poll_challenges(self.chall_update, False)
for authzr in self.handler.authzr.values():
self.assertEqual(authzr.body.status, messages2.STATUS_VALID)
self.assertEqual(authzr.body.status, messages.STATUS_VALID)
@mock.patch("letsencrypt.auth_handler.time")
def test_poll_challenges_failure_best_effort(self, unused_mock_time):
@ -213,14 +213,15 @@ class PollChallengesTest(unittest.TestCase):
self.handler._poll_challenges(self.chall_update, True)
for authzr in self.handler.authzr.values():
self.assertEqual(authzr.body.status, messages2.STATUS_PENDING)
self.assertEqual(authzr.body.status, messages.STATUS_PENDING)
@mock.patch("letsencrypt.auth_handler.time")
def test_poll_challenges_failure(self, unused_mock_time):
@mock.patch("letsencrypt.auth_handler.zope.component.getUtility")
def test_poll_challenges_failure(self, unused_mock_time, unused_mock_zope):
self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid
self.assertRaises(errors.AuthorizationError,
self.handler._poll_challenges,
self.chall_update, False)
self.assertRaises(
errors.AuthorizationError, self.handler._poll_challenges,
self.chall_update, False)
@mock.patch("letsencrypt.auth_handler.time")
def test_unable_to_find_challenge_status(self, unused_mock_time):
@ -229,8 +230,8 @@ class PollChallengesTest(unittest.TestCase):
self.chall_update[self.doms[0]].append(
challb_to_achall(acme_util.RECOVERY_CONTACT_P, "key", self.doms[0]))
self.assertRaises(
errors.AuthorizationError,
self.handler._poll_challenges, self.chall_update, False)
errors.AuthorizationError, self.handler._poll_challenges,
self.chall_update, False)
def test_verify_authzr_failure(self):
self.assertRaises(
@ -241,10 +242,10 @@ class PollChallengesTest(unittest.TestCase):
# Basically it didn't raise an error and it stopped earlier than
# Making all challenges invalid which would make mock_poll_solve_one
# change authzr to invalid
return self._mock_poll_solve_one_chall(authzr, messages2.STATUS_VALID)
return self._mock_poll_solve_one_chall(authzr, messages.STATUS_VALID)
def _mock_poll_solve_one_invalid(self, authzr):
return self._mock_poll_solve_one_chall(authzr, messages2.STATUS_INVALID)
return self._mock_poll_solve_one_chall(authzr, messages.STATUS_INVALID)
def _mock_poll_solve_one_chall(self, authzr, desired_status):
# pylint: disable=no-self-use
@ -269,10 +270,10 @@ class PollChallengesTest(unittest.TestCase):
else:
status_ = authzr.body.status
new_authzr = messages2.AuthorizationResource(
new_authzr = messages.AuthorizationResource(
uri=authzr.uri,
new_cert_uri=authzr.new_cert_uri,
body=messages2.Authorization(
body=messages.Authorization(
identifier=authzr.body.identifier,
challenges=new_challbs,
combinations=authzr.body.combinations,
@ -300,7 +301,7 @@ class GenChallengePathTest(unittest.TestCase):
def test_common_case(self):
"""Given DVSNI and SimpleHTTP with appropriate combos."""
challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTPS_P)
challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTP_P)
prefs = [challenges.DVSNI]
combos = ((0,), (1,))
@ -315,7 +316,7 @@ class GenChallengePathTest(unittest.TestCase):
challbs = (acme_util.RECOVERY_TOKEN_P,
acme_util.RECOVERY_CONTACT_P,
acme_util.DVSNI_P,
acme_util.SIMPLE_HTTPS_P)
acme_util.SIMPLE_HTTP_P)
prefs = [challenges.RecoveryToken, challenges.DVSNI]
combos = acme_util.gen_combos(challbs)
self.assertEqual(self._call(challbs, prefs, combos), (0, 2))
@ -328,7 +329,7 @@ class GenChallengePathTest(unittest.TestCase):
acme_util.RECOVERY_CONTACT_P,
acme_util.POP_P,
acme_util.DVSNI_P,
acme_util.SIMPLE_HTTPS_P,
acme_util.SIMPLE_HTTP_P,
acme_util.DNS_P)
# Typical webserver client that can do everything except DNS
# Attempted to make the order realistic
@ -348,8 +349,8 @@ class GenChallengePathTest(unittest.TestCase):
prefs = [challenges.DVSNI]
combos = ((0, 1),)
self.assertRaises(errors.AuthorizationError,
self._call, challbs, prefs, combos)
self.assertRaises(
errors.AuthorizationError, self._call, challbs, prefs, combos)
class MutuallyExclusiveTest(unittest.TestCase):
@ -413,13 +414,61 @@ class IsPreferredTest(unittest.TestCase):
def test_mutually_exclusvie(self):
self.assertFalse(
self._call(
acme_util.DVSNI_P, frozenset([acme_util.SIMPLE_HTTPS_P])))
acme_util.DVSNI_P, frozenset([acme_util.SIMPLE_HTTP_P])))
def test_mutually_exclusive_same_type(self):
self.assertTrue(
self._call(acme_util.DVSNI_P, frozenset([acme_util.DVSNI_P])))
class ReportFailedChallsTest(unittest.TestCase):
"""Tests for letsencrypt.auth_handler._report_failed_challs."""
# pylint: disable=protected-access
def setUp(self):
from letsencrypt import achallenges
kwargs = {
"chall" : acme_util.SIMPLE_HTTP,
"uri": "uri",
"status": messages.STATUS_INVALID,
"error": messages.Error(typ="tls", detail="detail"),
}
self.simple_http = achallenges.SimpleHTTP(
challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args
domain="example.com",
key=acme_util.KEY)
kwargs["chall"] = acme_util.DVSNI
self.dvsni_same = achallenges.DVSNI(
challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args
domain="example.com",
key=acme_util.KEY)
kwargs["error"] = messages.Error(typ="dnssec", detail="detail")
self.dvsni_diff = achallenges.DVSNI(
challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args
domain="foo.bar",
key=acme_util.KEY)
@mock.patch("letsencrypt.auth_handler.zope.component.getUtility")
def test_same_error_and_domain(self, mock_zope):
from letsencrypt import auth_handler
auth_handler._report_failed_challs([self.simple_http, self.dvsni_same])
call_list = mock_zope().add_message.call_args_list
self.assertTrue(len(call_list) == 1)
self.assertTrue("Domains: example.com\n" in call_list[0][0][0])
@mock.patch("letsencrypt.auth_handler.zope.component.getUtility")
def test_different_errors_and_domains(self, mock_zope):
from letsencrypt import auth_handler
auth_handler._report_failed_challs([self.simple_http, self.dvsni_diff])
self.assertTrue(mock_zope().add_message.call_count == 2)
def gen_auth_resp(chall_list):
"""Generate a dummy authorization response."""
return ["%s%s" % (chall.__class__.__name__, chall.domain)
@ -429,8 +478,8 @@ def gen_auth_resp(chall_list):
def gen_dom_authzr(domain, unused_new_authzr_uri, challs):
"""Generates new authzr for domains."""
return acme_util.gen_authzr(
messages2.STATUS_PENDING, domain, challs,
[messages2.STATUS_PENDING]*len(challs))
messages.STATUS_PENDING, domain, challs,
[messages.STATUS_PENDING]*len(challs))
if __name__ == "__main__":

View file

@ -26,12 +26,13 @@ class ClientTest(unittest.TestCase):
"""Tests for letsencrypt.client.Client."""
def setUp(self):
self.config = mock.MagicMock(no_verify_ssl=False)
self.config = mock.MagicMock(
no_verify_ssl=False, config_dir="/etc/letsencrypt")
# pylint: disable=star-args
self.account = mock.MagicMock(**{"key.pem": KEY})
from letsencrypt.client import Client
with mock.patch("letsencrypt.client.network2.Network") as network:
with mock.patch("letsencrypt.client.network.Network") as network:
self.client = Client(
config=self.config, account_=self.account,
dv_auth=None, installer=None)
@ -85,7 +86,6 @@ class ClientTest(unittest.TestCase):
@mock.patch("letsencrypt.client.zope.component.getUtility")
def test_report_new_account(self, mock_zope):
# pylint: disable=protected-access
self.config.config_dir = "/usr/bin/coffee"
self.account.recovery_token = "ECCENTRIC INVISIBILITY RHINOCEROS"
self.account.email = "rhino@jungle.io"
@ -100,32 +100,33 @@ class ClientTest(unittest.TestCase):
# pylint: disable=protected-access
cert = mock.MagicMock()
cert.configuration = configobj.ConfigObj()
cert.configuration["renewal_configs_dir"] = "/etc/letsencrypt/configs"
cert.cli_config = configuration.RenewerConfiguration(self.config)
cert.configuration["autorenew"] = "True"
cert.configuration["autodeploy"] = "True"
self.client._report_renewal_status(cert)
msg = mock_zope().add_message.call_args[0][0]
self.assertTrue("renewal and deployment has been" in msg)
self.assertTrue(cert.configuration["renewal_configs_dir"] in msg)
self.assertTrue(cert.cli_config.renewal_configs_dir in msg)
cert.configuration["autorenew"] = "False"
self.client._report_renewal_status(cert)
msg = mock_zope().add_message.call_args[0][0]
self.assertTrue("deployment but not automatic renewal" in msg)
self.assertTrue(cert.configuration["renewal_configs_dir"] in msg)
self.assertTrue(cert.cli_config.renewal_configs_dir in msg)
cert.configuration["autodeploy"] = "False"
self.client._report_renewal_status(cert)
msg = mock_zope().add_message.call_args[0][0]
self.assertTrue("renewal and deployment has not" in msg)
self.assertTrue(cert.configuration["renewal_configs_dir"] in msg)
self.assertTrue(cert.cli_config.renewal_configs_dir in msg)
cert.configuration["autorenew"] = "True"
self.client._report_renewal_status(cert)
msg = mock_zope().add_message.call_args[0][0]
self.assertTrue("renewal but not automatic deployment" in msg)
self.assertTrue(cert.configuration["renewal_configs_dir"] in msg)
self.assertTrue(cert.cli_config.renewal_configs_dir in msg)
class DetermineAccountTest(unittest.TestCase):
"""Tests for letsencrypt.client.determine_authenticator."""

View file

@ -9,10 +9,10 @@ class NamespaceConfigTest(unittest.TestCase):
"""Tests for letsencrypt.configuration.NamespaceConfig."""
def setUp(self):
from letsencrypt.configuration import NamespaceConfig
self.namespace = mock.MagicMock(
config_dir='/tmp/config', work_dir='/tmp/foo', foo='bar',
server='https://acme-server.org:443/new')
from letsencrypt.configuration import NamespaceConfig
self.config = NamespaceConfig(self.namespace)
def test_proxy_getattr(self):
@ -30,23 +30,51 @@ class NamespaceConfigTest(unittest.TestCase):
@mock.patch('letsencrypt.configuration.constants')
def test_dynamic_dirs(self, constants):
constants.TEMP_CHECKPOINT_DIR = 't'
constants.IN_PROGRESS_DIR = '../p'
constants.CERT_KEY_BACKUP_DIR = 'c/'
constants.REC_TOKEN_DIR = '/r'
constants.ACCOUNTS_DIR = 'acc'
constants.ACCOUNT_KEYS_DIR = 'keys'
constants.BACKUP_DIR = 'backups'
constants.CERT_KEY_BACKUP_DIR = 'c/'
constants.CERT_DIR = 'certs'
constants.IN_PROGRESS_DIR = '../p'
constants.KEY_DIR = 'keys'
constants.REC_TOKEN_DIR = '/r'
constants.TEMP_CHECKPOINT_DIR = 't'
self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t')
self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p')
self.assertEqual(
self.config.cert_key_backup, '/tmp/foo/c/acme-server.org:443/new')
self.assertEqual(self.config.rec_token_dir, '/r')
self.assertEqual(
self.config.accounts_dir, '/tmp/config/acc/acme-server.org:443/new')
self.assertEqual(
self.config.account_keys_dir,
'/tmp/config/acc/acme-server.org:443/new/keys')
self.assertEqual(self.config.backup_dir, '/tmp/foo/backups')
self.assertEqual(self.config.cert_dir, '/tmp/config/certs')
self.assertEqual(
self.config.cert_key_backup, '/tmp/foo/c/acme-server.org:443/new')
self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p')
self.assertEqual(self.config.key_dir, '/tmp/config/keys')
self.assertEqual(self.config.rec_token_dir, '/r')
self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t')
class RenewerConfigurationTest(unittest.TestCase):
"""Test for letsencrypt.configuration.RenewerConfiguration."""
def setUp(self):
self.namespace = mock.MagicMock(config_dir='/tmp/config')
from letsencrypt.configuration import RenewerConfiguration
self.config = RenewerConfiguration(self.namespace)
@mock.patch('letsencrypt.configuration.constants')
def test_dynamic_dirs(self, constants):
constants.ARCHIVE_DIR = "a"
constants.LIVE_DIR = 'l'
constants.RENEWAL_CONFIGS_DIR = "renewal_configs"
constants.RENEWER_CONFIG_FILENAME = 'r.conf'
self.assertEqual(self.config.archive_dir, '/tmp/config/a')
self.assertEqual(self.config.live_dir, '/tmp/config/l')
self.assertEqual(
self.config.renewal_configs_dir, '/tmp/config/renewal_configs')
self.assertEqual(self.config.renewer_config_file, '/tmp/config/r.conf')
if __name__ == '__main__':

View file

@ -58,7 +58,7 @@ class PerformTest(unittest.TestCase):
def test_unexpected(self):
self.assertRaises(
errors.LetsEncryptContAuthError, self.auth.perform, [
errors.ContAuthError, self.auth.perform, [
achallenges.DVSNI(challb=None, domain="0", key="invalid_key")])
def test_chall_pref(self):
@ -91,8 +91,8 @@ class CleanupTest(unittest.TestCase):
token = achallenges.RecoveryToken(challb=None, domain="0")
unexpected = achallenges.DVSNI(challb=None, domain="0", key="dummy_key")
self.assertRaises(errors.LetsEncryptContAuthError,
self.auth.cleanup, [token, unexpected])
self.assertRaises(
errors.ContAuthError, self.auth.cleanup, [token, unexpected])
def gen_client_resp(chall):

View file

@ -27,8 +27,7 @@ class AskTest(unittest.TestCase):
self.assertTrue(self._call("redirect"))
def test_key_error(self):
self.assertRaises(
errors.LetsEncryptClientError, self._call, "unknown_enhancement")
self.assertRaises(errors.Error, self._call, "unknown_enhancement")
class RedirectTest(unittest.TestCase):

View file

@ -0,0 +1,26 @@
"""Tests for letsencrypt.errors."""
import unittest
from acme import messages
from letsencrypt import achallenges
from letsencrypt.tests import acme_util
class FaiiledChallengesTest(unittest.TestCase):
"""Tests for letsencrypt.errors.FailedChallenges."""
def setUp(self):
from letsencrypt.errors import FailedChallenges
self.error = FailedChallenges(set([achallenges.DNS(
domain="example.com", challb=messages.ChallengeBody(
chall=acme_util.DNS, uri=None,
error=messages.Error(typ="tls", detail="detail")))]))
def test_str(self):
self.assertTrue(str(self.error).startswith(
"Failed authorization procedure. example.com (dns): tls"))
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -44,8 +44,7 @@ class MakeOrVerifyDirTest(unittest.TestCase):
self.assertEqual(stat.S_IMODE(os.stat(self.path).st_mode), 0o400)
def test_existing_wrong_mode_fails(self):
self.assertRaises(
errors.LetsEncryptClientError, self._call, self.path, 0o600)
self.assertRaises(errors.Error, self._call, self.path, 0o600)
def test_reraises_os_error(self):
with mock.patch.object(os, 'makedirs') as makedirs:

View file

@ -0,0 +1,50 @@
"""Tests for letsencrypt.network."""
import shutil
import tempfile
import unittest
import mock
from letsencrypt import account
class NetworkTest(unittest.TestCase):
"""Tests for letsencrypt.network.Network."""
def setUp(self):
from letsencrypt.network import Network
self.net = Network(
new_reg_uri=None, key=None, alg=None, verify_ssl=None)
self.config = mock.Mock(accounts_dir=tempfile.mkdtemp())
self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212')
def tearDown(self):
shutil.rmtree(self.config.accounts_dir)
def test_register_from_account(self):
self.net.register = mock.Mock()
acc = account.Account(
self.config, 'key', email='cert-admin@example.com',
phone='+12025551212')
self.net.register_from_account(acc)
self.net.register.assert_called_with(contact=self.contact)
def test_register_from_account_partial_info(self):
self.net.register = mock.Mock()
acc = account.Account(
self.config, 'key', email='cert-admin@example.com')
acc2 = account.Account(self.config, 'key')
self.net.register_from_account(acc)
self.net.register.assert_called_with(
contact=('mailto:cert-admin@example.com',))
self.net.register_from_account(acc2)
self.net.register.assert_called_with(contact=())
if __name__ == '__main__':
unittest.main() # pragma: no cover

View file

@ -8,7 +8,7 @@ import mock
from acme import challenges
from acme import jose
from acme import messages2
from acme import messages
from letsencrypt import achallenges
from letsencrypt import proof_of_possession
@ -48,8 +48,8 @@ class ProofOfPossessionTest(unittest.TestCase):
issuers=(), authorized_for=())
chall = challenges.ProofOfPossession(
alg=jose.RS256, nonce='zczv4HMLVe_0kimJ25Juig', hints=hints)
challb = messages2.ChallengeBody(
chall=chall, uri="http://example", status=messages2.STATUS_PENDING)
challb = messages.ChallengeBody(
chall=chall, uri="http://example", status=messages.STATUS_PENDING)
self.achall = achallenges.ProofOfPossession(
challb=challb, domain="example.com")
@ -60,8 +60,8 @@ class ProofOfPossessionTest(unittest.TestCase):
issuers=(), authorized_for=())
chall = challenges.ProofOfPossession(
alg=jose.HS512, nonce='zczv4HMLVe_0kimJ25Juig', hints=hints)
challb = messages2.ChallengeBody(
chall=chall, uri="http://example", status=messages2.STATUS_PENDING)
challb = messages.ChallengeBody(
chall=chall, uri="http://example", status=messages.STATUS_PENDING)
self.achall = achallenges.ProofOfPossession(
challb=challb, domain="example.com")
self.assertEqual(self.proof_of_pos.perform(self.achall), None)

View file

@ -10,6 +10,7 @@ import configobj
import mock
import pytz
from letsencrypt import configuration
from letsencrypt.storage import ALL_FOUR
@ -31,22 +32,24 @@ class RenewableCertTests(unittest.TestCase):
def setUp(self):
from letsencrypt import storage
self.tempdir = tempfile.mkdtemp()
self.cli_config = configuration.RenewerConfiguration(
namespace=mock.MagicMock(config_dir=self.tempdir))
# TODO: maybe provide RenewerConfiguration.make_dirs?
os.makedirs(os.path.join(self.tempdir, "live", "example.org"))
os.makedirs(os.path.join(self.tempdir, "archive", "example.org"))
os.makedirs(os.path.join(self.tempdir, "configs"))
defaults = configobj.ConfigObj()
defaults["live_dir"] = os.path.join(self.tempdir, "live")
defaults["archive_dir"] = os.path.join(self.tempdir, "archive")
defaults["renewal_configs_dir"] = os.path.join(self.tempdir,
"configs")
config = configobj.ConfigObj()
for kind in ALL_FOUR:
config[kind] = os.path.join(self.tempdir, "live", "example.org",
kind + ".pem")
config.filename = os.path.join(self.tempdir, "configs",
"example.org.conf")
self.defaults = defaults # for main() test
self.test_rc = storage.RenewableCert(config, defaults)
self.defaults = configobj.ConfigObj()
self.test_rc = storage.RenewableCert(
config, self.defaults, self.cli_config)
def tearDown(self):
shutil.rmtree(self.tempdir)
@ -457,60 +460,57 @@ class RenewableCertTests(unittest.TestCase):
def test_new_lineage(self):
"""Test for new_lineage() class method."""
from letsencrypt import storage
config_dir = self.defaults["renewal_configs_dir"]
archive_dir = self.defaults["archive_dir"]
live_dir = self.defaults["live_dir"]
result = storage.RenewableCert.new_lineage("the-lineage.com", "cert",
"privkey", "chain", None,
self.defaults)
result = storage.RenewableCert.new_lineage(
"the-lineage.com", "cert", "privkey", "chain", None,
self.defaults, self.cli_config)
# This consistency check tests most relevant properties about the
# newly created cert lineage.
self.assertTrue(result.consistent())
self.assertTrue(os.path.exists(os.path.join(config_dir,
"the-lineage.com.conf")))
self.assertTrue(os.path.exists(os.path.join(
self.cli_config.renewal_configs_dir, "the-lineage.com.conf")))
with open(result.fullchain) as f:
self.assertEqual(f.read(), "cert" + "chain")
# Let's do it again and make sure it makes a different lineage
result = storage.RenewableCert.new_lineage("the-lineage.com", "cert2",
"privkey2", "chain2", None,
self.defaults)
self.assertTrue(os.path.exists(
os.path.join(config_dir, "the-lineage.com-0001.conf")))
result = storage.RenewableCert.new_lineage(
"the-lineage.com", "cert2", "privkey2", "chain2", None,
self.defaults, self.cli_config)
self.assertTrue(os.path.exists(os.path.join(
self.cli_config.renewal_configs_dir, "the-lineage.com-0001.conf")))
# Now trigger the detection of already existing files
os.mkdir(os.path.join(live_dir, "the-lineage.com-0002"))
os.mkdir(os.path.join(
self.cli_config.live_dir, "the-lineage.com-0002"))
self.assertRaises(ValueError, storage.RenewableCert.new_lineage,
"the-lineage.com", "cert3", "privkey3", "chain3",
None, self.defaults)
os.mkdir(os.path.join(archive_dir, "other-example.com"))
None, self.defaults, self.cli_config)
os.mkdir(os.path.join(self.cli_config.archive_dir, "other-example.com"))
self.assertRaises(ValueError, storage.RenewableCert.new_lineage,
"other-example.com", "cert4", "privkey4", "chain4",
None, self.defaults)
None, self.defaults, self.cli_config)
# Make sure it can accept renewal parameters
params = {"stuff": "properties of stuff", "great": "awesome"}
result = storage.RenewableCert.new_lineage("the-lineage.com", "cert2",
"privkey2", "chain2",
params, self.defaults)
result = storage.RenewableCert.new_lineage(
"the-lineage.com", "cert2", "privkey2", "chain2",
params, self.defaults, self.cli_config)
# TODO: Conceivably we could test that the renewal parameters actually
# got saved
def test_new_lineage_nonexistent_dirs(self):
"""Test that directories can be created if they don't exist."""
from letsencrypt import storage
config_dir = self.defaults["renewal_configs_dir"]
archive_dir = self.defaults["archive_dir"]
live_dir = self.defaults["live_dir"]
shutil.rmtree(config_dir)
shutil.rmtree(archive_dir)
shutil.rmtree(live_dir)
storage.RenewableCert.new_lineage("the-lineage.com", "cert2",
"privkey2", "chain2",
None, self.defaults)
shutil.rmtree(self.cli_config.renewal_configs_dir)
shutil.rmtree(self.cli_config.archive_dir)
shutil.rmtree(self.cli_config.live_dir)
storage.RenewableCert.new_lineage(
"the-lineage.com", "cert2", "privkey2", "chain2",
None, self.defaults, self.cli_config)
self.assertTrue(os.path.exists(
os.path.join(config_dir, "the-lineage.com.conf")))
self.assertTrue(os.path.exists(
os.path.join(live_dir, "the-lineage.com", "privkey.pem")))
self.assertTrue(os.path.exists(
os.path.join(archive_dir, "the-lineage.com", "privkey1.pem")))
os.path.join(
self.cli_config.renewal_configs_dir, "the-lineage.com.conf")))
self.assertTrue(os.path.exists(os.path.join(
self.cli_config.live_dir, "the-lineage.com", "privkey.pem")))
self.assertTrue(os.path.exists(os.path.join(
self.cli_config.archive_dir, "the-lineage.com", "privkey1.pem")))
@mock.patch("letsencrypt.storage.le_util.unique_lineage_name")
def test_invalid_config_filename(self, mock_uln):
@ -518,7 +518,7 @@ class RenewableCertTests(unittest.TestCase):
mock_uln.return_value = "this_does_not_end_with_dot_conf", "yikes"
self.assertRaises(ValueError, storage.RenewableCert.new_lineage,
"example.com", "cert", "privkey", "chain",
None, self.defaults)
None, self.defaults, self.cli_config)
def test_bad_kind(self):
self.assertRaises(ValueError, self.test_rc.current_target, "elephant")
@ -602,22 +602,23 @@ class RenewableCertTests(unittest.TestCase):
mock_rc_instance.should_autorenew.return_value = True
mock_rc_instance.latest_common_version.return_value = 10
mock_rc.return_value = mock_rc_instance
with open(os.path.join(self.defaults["renewal_configs_dir"],
with open(os.path.join(self.cli_config.renewal_configs_dir,
"README"), "w") as f:
f.write("This is a README file to make sure that the renewer is")
f.write("able to correctly ignore files that don't end in .conf.")
with open(os.path.join(self.defaults["renewal_configs_dir"],
with open(os.path.join(self.cli_config.renewal_configs_dir,
"example.org.conf"), "w") as f:
# This isn't actually parsed in this test; we have a separate
# test_initialization that tests the initialization, assuming
# that configobj can correctly parse the config file.
f.write("cert = cert.pem\nprivkey = privkey.pem\n")
f.write("chain = chain.pem\nfullchain = fullchain.pem\n")
with open(os.path.join(self.defaults["renewal_configs_dir"],
with open(os.path.join(self.cli_config.renewal_configs_dir,
"example.com.conf"), "w") as f:
f.write("cert = cert.pem\nprivkey = privkey.pem\n")
f.write("chain = chain.pem\nfullchain = fullchain.pem\n")
renewer.main(self.defaults)
renewer.main(self.defaults, args=[
'--config-dir', self.cli_config.config_dir])
self.assertEqual(mock_rc.call_count, 2)
self.assertEqual(mock_rc_instance.update_all_links_to.call_count, 2)
self.assertEqual(mock_notify.notify.call_count, 4)
@ -630,7 +631,8 @@ class RenewableCertTests(unittest.TestCase):
mock_happy_instance.should_autorenew.return_value = False
mock_happy_instance.latest_common_version.return_value = 10
mock_rc.return_value = mock_happy_instance
renewer.main(self.defaults)
renewer.main(self.defaults, args=[
'--config-dir', self.cli_config.config_dir])
self.assertEqual(mock_rc.call_count, 4)
self.assertEqual(mock_happy_instance.update_all_links_to.call_count, 0)
self.assertEqual(mock_notify.notify.call_count, 4)
@ -638,10 +640,11 @@ class RenewableCertTests(unittest.TestCase):
def test_bad_config_file(self):
from letsencrypt import renewer
with open(os.path.join(self.defaults["renewal_configs_dir"],
with open(os.path.join(self.cli_config.renewal_configs_dir,
"bad.conf"), "w") as f:
f.write("incomplete = configfile\n")
renewer.main(self.defaults)
renewer.main(self.defaults, args=[
'--config-dir', self.cli_config.config_dir])
# The ValueError is caught inside and nothing happens.

View file

@ -50,10 +50,9 @@ class ReverterCheckpointLocalTest(unittest.TestCase):
def test_add_to_checkpoint_copy_failure(self):
with mock.patch("letsencrypt.reverter.shutil.copy2") as mock_copy2:
mock_copy2.side_effect = IOError("bad copy")
self.assertRaises(errors.LetsEncryptReverterError,
self.reverter.add_to_checkpoint,
self.sets[0],
"save1")
self.assertRaises(
errors.ReverterError, self.reverter.add_to_checkpoint,
self.sets[0], "save1")
def test_checkpoint_conflict(self):
"""Make sure that checkpoint errors are thrown appropriately."""
@ -65,17 +64,14 @@ class ReverterCheckpointLocalTest(unittest.TestCase):
# This shouldn't throw an error
self.reverter.add_to_temp_checkpoint(self.sets[0], "save2")
# Raise error
self.assertRaises(
errors.LetsEncryptReverterError, self.reverter.add_to_checkpoint,
self.sets[2], "save3")
self.assertRaises(errors.ReverterError, self.reverter.add_to_checkpoint,
self.sets[2], "save3")
# Should not cause an error
self.reverter.add_to_checkpoint(self.sets[1], "save4")
# Check to make sure new files are also checked...
self.assertRaises(
errors.LetsEncryptReverterError,
self.reverter.add_to_checkpoint,
set([config3]), "invalid save")
self.assertRaises(errors.ReverterError, self.reverter.add_to_checkpoint,
set([config3]), "invalid save")
def test_multiple_saves_and_temp_revert(self):
self.reverter.add_to_temp_checkpoint(self.sets[0], "save1")
@ -120,65 +116,64 @@ class ReverterCheckpointLocalTest(unittest.TestCase):
m_open = mock.mock_open()
with mock.patch("letsencrypt.reverter.open", m_open, create=True):
m_open.side_effect = OSError("bad open")
self.assertRaises(errors.LetsEncryptReverterError,
self.reverter.register_file_creation,
True, self.config1)
self.assertRaises(
errors.ReverterError, self.reverter.register_file_creation,
True, self.config1)
def test_bad_registration(self):
# Made this mistake and want to make sure it doesn't happen again...
self.assertRaises(errors.LetsEncryptReverterError,
self.reverter.register_file_creation,
"filepath")
self.assertRaises(
errors.ReverterError, self.reverter.register_file_creation,
"filepath")
def test_recovery_routine_in_progress_failure(self):
self.reverter.add_to_checkpoint(self.sets[0], "perm save")
# pylint: disable=protected-access
self.reverter._recover_checkpoint = mock.MagicMock(
side_effect=errors.LetsEncryptReverterError)
self.assertRaises(errors.LetsEncryptReverterError,
self.reverter.recovery_routine)
side_effect=errors.ReverterError)
self.assertRaises(errors.ReverterError, self.reverter.recovery_routine)
def test_recover_checkpoint_revert_temp_failures(self):
# pylint: disable=invalid-name
mock_recover = mock.MagicMock(
side_effect=errors.LetsEncryptReverterError("e"))
side_effect=errors.ReverterError("e"))
# pylint: disable=protected-access
self.reverter._recover_checkpoint = mock_recover
self.reverter.add_to_temp_checkpoint(self.sets[0], "config1 save")
self.assertRaises(errors.LetsEncryptReverterError,
self.reverter.revert_temporary_config)
self.assertRaises(
errors.ReverterError, self.reverter.revert_temporary_config)
def test_recover_checkpoint_rollback_failure(self):
mock_recover = mock.MagicMock(
side_effect=errors.LetsEncryptReverterError("e"))
side_effect=errors.ReverterError("e"))
# pylint: disable=protected-access
self.reverter._recover_checkpoint = mock_recover
self.reverter.add_to_checkpoint(self.sets[0], "config1 save")
self.reverter.finalize_checkpoint("Title")
self.assertRaises(errors.LetsEncryptReverterError,
self.reverter.rollback_checkpoints, 1)
self.assertRaises(
errors.ReverterError, self.reverter.rollback_checkpoints, 1)
def test_recover_checkpoint_copy_failure(self):
self.reverter.add_to_temp_checkpoint(self.sets[0], "save1")
with mock.patch("letsencrypt.reverter.shutil.copy2") as mock_copy2:
mock_copy2.side_effect = OSError("bad copy")
self.assertRaises(errors.LetsEncryptReverterError,
self.reverter.revert_temporary_config)
self.assertRaises(
errors.ReverterError, self.reverter.revert_temporary_config)
def test_recover_checkpoint_rm_failure(self):
self.reverter.add_to_temp_checkpoint(self.sets[0], "temp save")
with mock.patch("letsencrypt.reverter.shutil.rmtree") as mock_rmtree:
mock_rmtree.side_effect = OSError("Cannot remove tree")
self.assertRaises(errors.LetsEncryptReverterError,
self.reverter.revert_temporary_config)
self.assertRaises(
errors.ReverterError, self.reverter.revert_temporary_config)
@mock.patch("letsencrypt.reverter.logging.warning")
def test_recover_checkpoint_missing_new_files(self, mock_warn):
@ -191,8 +186,8 @@ class ReverterCheckpointLocalTest(unittest.TestCase):
def test_recover_checkpoint_remove_failure(self, mock_remove):
self.reverter.register_file_creation(True, self.config1)
mock_remove.side_effect = OSError("Can't remove")
self.assertRaises(errors.LetsEncryptReverterError,
self.reverter.revert_temporary_config)
self.assertRaises(
errors.ReverterError, self.reverter.revert_temporary_config)
def test_recovery_routine_temp_and_perm(self):
# Register a new perm checkpoint file
@ -251,14 +246,11 @@ class TestFullCheckpointsReverter(unittest.TestCase):
def test_rollback_improper_inputs(self):
self.assertRaises(
errors.LetsEncryptReverterError,
self.reverter.rollback_checkpoints, "-1")
errors.ReverterError, self.reverter.rollback_checkpoints, "-1")
self.assertRaises(
errors.LetsEncryptReverterError,
self.reverter.rollback_checkpoints, -1000)
errors.ReverterError, self.reverter.rollback_checkpoints, -1000)
self.assertRaises(
errors.LetsEncryptReverterError,
self.reverter.rollback_checkpoints, "one")
errors.ReverterError, self.reverter.rollback_checkpoints, "one")
def test_rollback_finalize_checkpoint_valid_inputs(self):
# pylint: disable=invalid-name
@ -299,9 +291,8 @@ class TestFullCheckpointsReverter(unittest.TestCase):
self.reverter.add_to_checkpoint(self.sets[0], "perm save")
mock_move.side_effect = OSError("cannot move")
self.assertRaises(errors.LetsEncryptReverterError,
self.reverter.finalize_checkpoint,
"Title")
self.assertRaises(
errors.ReverterError, self.reverter.finalize_checkpoint, "Title")
@mock.patch("letsencrypt.reverter.os.rename")
def test_finalize_checkpoint_no_rename_directory(self, mock_rename):
@ -309,9 +300,8 @@ class TestFullCheckpointsReverter(unittest.TestCase):
self.reverter.add_to_checkpoint(self.sets[0], "perm save")
mock_rename.side_effect = OSError
self.assertRaises(errors.LetsEncryptReverterError,
self.reverter.finalize_checkpoint,
"Title")
self.assertRaises(
errors.ReverterError, self.reverter.finalize_checkpoint, "Title")
@mock.patch("letsencrypt.reverter.logging")
def test_rollback_too_many(self, mock_logging):
@ -347,8 +337,8 @@ class TestFullCheckpointsReverter(unittest.TestCase):
# It must just be clean checkpoints
os.makedirs(os.path.join(self.config.backup_dir, "in_progress"))
self.assertRaises(errors.LetsEncryptReverterError,
self.reverter.view_config_changes)
self.assertRaises(
errors.ReverterError, self.reverter.view_config_changes)
def _setup_three_checkpoints(self):
"""Generate some finalized checkpoints."""

View file

@ -63,7 +63,7 @@ class RevokerTest(RevokerBase):
def tearDown(self):
shutil.rmtree(self.backup_dir)
@mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected")
@mock.patch("letsencrypt.network.Network.revoke")
@mock.patch("letsencrypt.revoker.revocation")
def test_revoke_by_key_all(self, mock_display, mock_net):
mock_display().confirm_revocation.return_value = True
@ -80,16 +80,14 @@ class RevokerTest(RevokerBase):
@mock.patch("letsencrypt.revoker.Crypto.PublicKey.RSA.importKey")
def test_revoke_by_invalid_keys(self, mock_import):
mock_import.side_effect = ValueError
self.assertRaises(errors.LetsEncryptRevokerError,
self.revoker.revoke_from_key,
self.key)
self.assertRaises(
errors.RevokerError, self.revoker.revoke_from_key, self.key)
mock_import.side_effect = [mock.Mock(), IndexError]
self.assertRaises(errors.LetsEncryptRevokerError,
self.revoker.revoke_from_key,
self.key)
self.assertRaises(
errors.RevokerError, self.revoker.revoke_from_key, self.key)
@mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected")
@mock.patch("letsencrypt.network.Network.revoke")
@mock.patch("letsencrypt.revoker.revocation")
def test_revoke_by_wrong_key(self, mock_display, mock_net):
mock_display().confirm_revocation.return_value = True
@ -105,7 +103,7 @@ class RevokerTest(RevokerBase):
# No revocation went through
self.assertEqual(mock_net.call_count, 0)
@mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected")
@mock.patch("letsencrypt.network.Network.revoke")
@mock.patch("letsencrypt.revoker.revocation")
def test_revoke_by_cert(self, mock_display, mock_net):
mock_display().confirm_revocation.return_value = True
@ -122,7 +120,7 @@ class RevokerTest(RevokerBase):
self.assertEqual(mock_net.call_count, 1)
@mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected")
@mock.patch("letsencrypt.network.Network.revoke")
@mock.patch("letsencrypt.revoker.revocation")
def test_revoke_by_cert_not_found(self, mock_display, mock_net):
mock_display().confirm_revocation.return_value = True
@ -141,7 +139,7 @@ class RevokerTest(RevokerBase):
self.assertEqual(mock_net.call_count, 1)
@mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected")
@mock.patch("letsencrypt.network.Network.revoke")
@mock.patch("letsencrypt.revoker.revocation")
def test_revoke_by_menu(self, mock_display, mock_net):
mock_display().confirm_revocation.return_value = True
@ -165,7 +163,7 @@ class RevokerTest(RevokerBase):
self.assertEqual(mock_display.more_info_cert.call_count, 1)
@mock.patch("letsencrypt.revoker.logging")
@mock.patch("letsencrypt.revoker.network.Network.send_and_receive_expected")
@mock.patch("letsencrypt.network.Network.revoke")
@mock.patch("letsencrypt.revoker.revocation")
def test_revoke_by_menu_delete_all(self, mock_display, mock_net, mock_log):
mock_display().confirm_revocation.return_value = True
@ -188,7 +186,7 @@ class RevokerTest(RevokerBase):
@mock.patch("letsencrypt.revoker.logging")
def test_safe_revoke_acme_fail(self, mock_log, mock_revoke, mock_display):
# pylint: disable=protected-access
mock_revoke.side_effect = errors.LetsEncryptClientError
mock_revoke.side_effect = errors.Error
mock_display().confirm_revocation.return_value = True
self.revoker._safe_revoke(self.certs)
@ -198,9 +196,8 @@ class RevokerTest(RevokerBase):
def test_acme_revoke_failure(self, mock_crypto):
# pylint: disable=protected-access
mock_crypto.side_effect = ValueError
self.assertRaises(errors.LetsEncryptClientError,
self.revoker._acme_revoke,
self.certs[0])
self.assertRaises(
errors.Error, self.revoker._acme_revoke, self.certs[0])
def test_remove_certs_from_list_bad_certs(self):
# pylint: disable=protected-access
@ -215,9 +212,8 @@ class RevokerTest(RevokerBase):
new_cert.orig = Cert.PathStatus("false path", "not here")
new_cert.orig_key = Cert.PathStatus("false path", "not here")
self.assertRaises(errors.LetsEncryptRevokerError,
self.revoker._remove_certs_from_list,
[new_cert])
self.assertRaises(errors.RevokerError,
self.revoker._remove_certs_from_list, [new_cert])
def _backups_exist(self, row):
# pylint: disable=protected-access
@ -330,7 +326,7 @@ class CertTest(unittest.TestCase):
def test_failed_load(self):
from letsencrypt.revoker import Cert
self.assertRaises(errors.LetsEncryptRevokerError, Cert, self.key_path)
self.assertRaises(errors.RevokerError, Cert, self.key_path)
def test_no_row(self):
self.assertEqual(self.certs[0].get_row(), None)

View file

@ -18,6 +18,8 @@ from letsencrypt import errors
from letsencrypt import interfaces
from letsencrypt import le_util
from letsencrypt.plugins import common
from letsencrypt_apache import constants
from letsencrypt_apache import dvsni
from letsencrypt_apache import obj
@ -87,8 +89,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
def add_parser_arguments(cls, add):
add("server-root", default=constants.CLI_DEFAULTS["server_root"],
help="Apache server root directory.")
add("mod-ssl-conf", default=constants.CLI_DEFAULTS["mod_ssl_conf"],
help="Contains standard Apache SSL directives.")
add("ctl", default=constants.CLI_DEFAULTS["ctl"],
help="Path to the 'apache2ctl' binary, used for 'configtest' and "
"retrieving Apache2 version number.")
@ -97,6 +97,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
add("init-script", default=constants.CLI_DEFAULTS["init_script"],
help="Path to the Apache init script (used for server "
"reload/restart).")
add("le-vhost-ext", default=constants.CLI_DEFAULTS["le_vhost_ext"],
help="SSL vhost configuration extension.")
def __init__(self, *args, **kwargs):
"""Initialize an Apache Configurator.
@ -123,10 +126,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
self.vhosts = None
self._enhance_func = {"redirect": self._enable_redirect}
@property
def mod_ssl_conf(self):
"""Full absolute path to SSL configuration file."""
return os.path.join(self.config.config_dir, constants.MOD_SSL_CONF_DEST)
def prepare(self):
"""Prepare the authenticator/installer."""
self.parser = parser.ApacheParser(
self.aug, self.conf('server-root'), self.conf('mod-ssl-conf'))
self.aug, self.conf('server-root'), self.mod_ssl_conf)
# Check for errors in parsing files with Augeas
self.check_parsing_errors("httpd.aug")
@ -144,7 +152,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
# on initialization
self._prepare_server_https()
temp_install(self.conf('mod-ssl-conf'))
temp_install(self.mod_ssl_conf)
def deploy_cert(self, domain, cert_path, key_path, chain_path=None):
"""Deploys certificate to specified virtual host.
@ -179,8 +187,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
if not path["cert_path"] or not path["cert_key"]:
# Throw some can't find all of the directives error"
logging.warn(
"Cannot find a cert or key directive in %s", vhost.path)
logging.warn("VirtualHost was not modified")
"Cannot find a cert or key directive in %s. "
"VirtualHost was not modified", vhost.path)
# Presumably break here so that the virtualhost is not modified
return False
@ -230,7 +238,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
return vhost
# Checking for domain name in vhost address
# This technique is not recommended by Apache but is technically valid
target_addr = obj.Addr((target_name, "443"))
target_addr = common.Addr((target_name, "443"))
for vhost in self.vhosts:
if target_addr in vhost.addrs:
self.assoc[target_name] = vhost
@ -321,7 +329,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
addrs = set()
args = self.aug.match(path + "/arg")
for arg in args:
addrs.add(obj.Addr.fromstring(self.aug.get(arg)))
addrs.add(common.Addr.fromstring(self.aug.get(arg)))
is_ssl = False
if self.parser.find_dir(
@ -406,8 +414,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
# Note: This could be made to also look for ip:443 combo
# TODO: Need to search only open directives and IfMod mod_ssl.c
if len(self.parser.find_dir(parser.case_i("Listen"), "443")) == 0:
logging.debug("No Listen 443 directive found")
logging.debug("Setting the Apache Server to Listen on port 443")
logging.debug("No Listen 443 directive found. Setting the "
"Apache Server to Listen on port 443")
path = self.parser.add_dir_to_ifmodssl(
parser.get_aug_path(self.parser.loc["listen"]), "Listen", "443")
self.save_notes += "Added Listen 443 directive to %s\n" % path
@ -443,7 +451,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"""Makes an ssl_vhost version of a nonssl_vhost.
Duplicates vhost and adds default ssl options
New vhost will reside as (nonssl_vhost.path) + ``IConfig.le_vhost_ext``
New vhost will reside as (nonssl_vhost.path) +
``letsencrypt_apache.constants.CLI_DEFAULTS["le_vhost_ext"]``
.. note:: This function saves the configuration
@ -457,9 +466,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
avail_fp = nonssl_vhost.filep
# Get filepath of new ssl_vhost
if avail_fp.endswith(".conf"):
ssl_fp = avail_fp[:-(len(".conf"))] + self.config.le_vhost_ext
ssl_fp = avail_fp[:-(len(".conf"))] + self.conf("le_vhost_ext")
else:
ssl_fp = avail_fp + self.config.le_vhost_ext
ssl_fp = avail_fp + self.conf("le_vhost_ext")
# First register the creation so that it is properly removed if
# configuration is rolled back
@ -486,7 +495,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
addr_match % (ssl_fp, parser.case_i("VirtualHost")))
for addr in ssl_addr_p:
old_addr = obj.Addr.fromstring(
old_addr = common.Addr.fromstring(
str(self.aug.get(addr)))
ssl_addr = old_addr.get_addr_obj("443")
self.aug.set(addr, str(ssl_addr))
@ -552,9 +561,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
return self._enhance_func[enhancement](
self.choose_vhost(domain), options)
except ValueError:
raise errors.LetsEncryptConfiguratorError(
raise errors.ConfiguratorError(
"Unsupported enhancement: {}".format(enhancement))
except errors.LetsEncryptConfiguratorError:
except errors.ConfiguratorError:
logging.warn("Failed %s for %s", enhancement, domain)
def _enable_redirect(self, ssl_vhost, unused_options):
@ -600,7 +609,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
return
else:
logging.info("Unknown redirect exists for this vhost")
raise errors.LetsEncryptConfiguratorError(
raise errors.ConfiguratorError(
"Unknown redirect already exists "
"in {}".format(general_v.filep))
# Add directives to server
@ -671,9 +680,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
# Make sure adding the vhost will be safe
conflict, host_or_addrs = self._conflicting_host(ssl_vhost)
if conflict:
raise errors.LetsEncryptConfiguratorError(
"Unable to create a redirection vhost "
"- {}".format(host_or_addrs))
raise errors.ConfiguratorError(
"Unable to create a redirection vhost - {}".format(
host_or_addrs))
redirect_addrs = host_or_addrs
@ -789,8 +798,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
# Instead... should look for vhost of the form *:80
# Should we prompt the user?
ssl_addrs = ssl_vhost.addrs
if ssl_addrs == obj.Addr.fromstring("_default_:443"):
ssl_addrs = [obj.Addr.fromstring("*:443")]
if ssl_addrs == common.Addr.fromstring("_default_:443"):
ssl_addrs = [common.Addr.fromstring("*:443")]
for vhost in self.vhosts:
found = 0
@ -920,9 +929,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
if proc.returncode != 0:
# Enter recovery routine...
logging.error("Configtest failed")
logging.error(stdout)
logging.error(stderr)
logging.error("Configtest failed\n%s\n%s", stdout, stderr)
return False
return True
@ -951,8 +958,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
:returns: version
:rtype: tuple
:raises errors.LetsEncryptConfiguratorError:
Unable to find Apache version
:raises .ConfiguratorError: if unable to find Apache version
"""
try:
@ -962,15 +968,14 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
stderr=subprocess.PIPE)
text = proc.communicate()[0]
except (OSError, ValueError):
raise errors.LetsEncryptConfiguratorError(
raise errors.ConfiguratorError(
"Unable to run %s -v" % self.conf('ctl'))
regex = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE)
matches = regex.findall(text)
if len(matches) != 1:
raise errors.LetsEncryptConfiguratorError(
"Unable to find Apache version")
raise errors.ConfiguratorError("Unable to find Apache version")
return tuple([int(i) for i in matches[0].split(".")])
@ -1052,9 +1057,8 @@ def enable_mod(mod_name, apache_init_script, apache_enmod):
stdout=open("/dev/null", "w"),
stderr=open("/dev/null", "w"))
apache_restart(apache_init_script)
except (OSError, subprocess.CalledProcessError) as err:
logging.error("Error enabling mod_%s", mod_name)
logging.error("Exception: %s", err)
except (OSError, subprocess.CalledProcessError):
logging.exception("Error enabling mod_%s", mod_name)
sys.exit(1)
@ -1080,12 +1084,11 @@ def mod_loaded(module, apache_ctl):
except (OSError, ValueError):
logging.error(
"Error accessing %s for loaded modules!", apache_ctl)
raise errors.LetsEncryptConfiguratorError(
"Error accessing loaded modules")
raise errors.ConfiguratorError("Error accessing loaded modules")
# Small errors that do not impede
if proc.returncode != 0:
logging.warn("Error in checking loaded module list: %s", stderr)
raise errors.LetsEncryptMisconfigurationError(
raise errors.MisconfigurationError(
"Apache is unable to check whether or not the module is "
"loaded because Apache is misconfigured.")
@ -1117,9 +1120,7 @@ def apache_restart(apache_init_script):
if proc.returncode != 0:
# Enter recovery routine...
logging.error("Apache Restart Failed!")
logging.error(stdout)
logging.error(stderr)
logging.error("Apache Restart Failed!\n%s\n%s", stdout, stderr)
return False
except (OSError, ValueError):
@ -1167,4 +1168,4 @@ def temp_install(options_ssl):
# Check to make sure options-ssl.conf is installed
if not os.path.isfile(options_ssl):
shutil.copyfile(constants.MOD_SSL_CONF, options_ssl)
shutil.copyfile(constants.MOD_SSL_CONF_SRC, options_ssl)

View file

@ -4,15 +4,17 @@ import pkg_resources
CLI_DEFAULTS = dict(
server_root="/etc/apache2",
mod_ssl_conf="/etc/letsencrypt/options-ssl-apache.conf",
ctl="apache2ctl",
enmod="a2enmod",
init_script="/etc/init.d/apache2",
le_vhost_ext="-le-ssl.conf",
)
"""CLI defaults."""
MOD_SSL_CONF_DEST = "options-ssl-apache.conf"
"""Name of the mod_ssl config file as saved in `IConfig.config_dir`."""
MOD_SSL_CONF = pkg_resources.resource_filename(
MOD_SSL_CONF_SRC = pkg_resources.resource_filename(
"letsencrypt_apache", "options-ssl-apache.conf")
"""Path to the Apache mod_ssl config file found in the Let's Encrypt
distribution."""

View file

@ -2,10 +2,12 @@
import logging
import os
from letsencrypt.plugins import common
from letsencrypt_apache import parser
class ApacheDvsni(object):
class ApacheDvsni(common.Dvsni):
"""Class performs DVSNI challenges within the Apache configurator.
:ivar configurator: ApacheConfigurator object
@ -42,26 +44,6 @@ class ApacheDvsni(object):
</VirtualHost>
"""
def __init__(self, configurator):
self.configurator = configurator
self.achalls = []
self.indices = []
self.challenge_conf = os.path.join(
configurator.config.config_dir, "le_dvsni_cert_challenge.conf")
# self.completed = 0
def add_chall(self, achall, idx=None):
"""Add challenge to DVSNI object to perform at once.
:param achall: Annotated DVSNI challenge.
:type achall: :class:`letsencrypt.achallenges.DVSNI`
:param int idx: index to challenge in a larger array
"""
self.achalls.append(achall)
if idx is not None:
self.indices.append(idx)
def perform(self):
"""Peform a DVSNI challenge."""
@ -77,10 +59,9 @@ class ApacheDvsni(object):
vhost = self.configurator.choose_vhost(achall.domain)
if vhost is None:
logging.error(
"No vhost exists with servername or alias of: %s",
achall.domain)
logging.error("No _default_:443 vhost exists")
logging.error("Please specify servernames in the Apache config")
"No vhost exists with servername or alias of: %s. "
"No _default_:443 vhost exists. Please specify servernames "
"in the Apache config", achall.domain)
return None
# TODO - @jdkasten review this code to make sure it makes sense
@ -107,28 +88,12 @@ class ApacheDvsni(object):
return responses
def _setup_challenge_cert(self, achall, s=None):
# pylint: disable=invalid-name
"""Generate and write out challenge certificate."""
cert_path = self.get_cert_file(achall)
# Register the path before you write out the file
self.configurator.reverter.register_file_creation(True, cert_path)
cert_pem, response = achall.gen_cert_and_response(s)
# Write out challenge cert
with open(cert_path, "w") as cert_chall_fd:
cert_chall_fd.write(cert_pem)
return response
def _mod_config(self, ll_addrs):
"""Modifies Apache config files to include challenge vhosts.
Result: Apache config includes virtual servers for issued challs
:param list ll_addrs: list of list of
:class:`letsencrypt.plugins.apache.obj.Addr` to apply
:param list ll_addrs: list of list of `~.common.Addr` to apply
"""
# TODO: Use ip address of existing vhost instead of relying on FQDN
@ -167,7 +132,7 @@ class ApacheDvsni(object):
:type achall: :class:`letsencrypt.achallenges.DVSNI`
:param list ip_addrs: addresses of challenged domain
:class:`list` of type :class:`~apache.obj.Addr`
:class:`list` of type `~.common.Addr`
:returns: virtual host configuration text
:rtype: str
@ -175,7 +140,7 @@ class ApacheDvsni(object):
"""
ips = " ".join(str(i) for i in ip_addrs)
document_root = os.path.join(
self.configurator.config.config_dir, "dvsni_page/")
self.configurator.config.work_dir, "dvsni_page/")
# TODO: Python docs is not clear how mutliline string literal
# newlines are parsed on different platforms. At least on
# Linux (Debian sid), when source file uses CRLF, Python still
@ -186,16 +151,3 @@ class ApacheDvsni(object):
ssl_options_conf_path=self.configurator.parser.loc["ssl_options"],
cert_path=self.get_cert_file(achall), key_path=achall.key.file,
document_root=document_root).replace("\n", os.linesep)
def get_cert_file(self, achall):
"""Returns standardized name for challenge certificate.
:param achall: Annotated DVSNI challenge.
:type achall: :class:`letsencrypt.achallenges.DVSNI`
:returns: certificate file name
:rtype: str
"""
return os.path.join(
self.configurator.config.work_dir, achall.nonce_domain + ".crt")

View file

@ -1,54 +1,13 @@
"""Module contains classes used by the Apache Configurator."""
class Addr(object):
r"""Represents an Apache VirtualHost address.
:param str addr: addr part of vhost address
:param str port: port number or \*, or ""
"""
def __init__(self, tup):
self.tup = tup
@classmethod
def fromstring(cls, str_addr):
"""Initialize Addr from string."""
tup = str_addr.partition(':')
return cls((tup[0], tup[2]))
def __str__(self):
if self.tup[1]:
return "%s:%s" % self.tup
return self.tup[0]
def __eq__(self, other):
if isinstance(other, self.__class__):
return self.tup == other.tup
return False
def __hash__(self):
return hash(self.tup)
def get_addr(self):
"""Return addr part of Addr object."""
return self.tup[0]
def get_port(self):
"""Return port."""
return self.tup[1]
def get_addr_obj(self, port):
"""Return new address object with same addr and new port."""
return self.__class__((self.tup[0], port))
class VirtualHost(object): # pylint: disable=too-few-public-methods
"""Represents an Apache Virtualhost.
:ivar str filep: file path of VH
:ivar str path: Augeas path to virtual host
:ivar set addrs: Virtual Host addresses (:class:`set` of :class:`Addr`)
:ivar set addrs: Virtual Host addresses (:class:`set` of
:class:`common.Addr`)
:ivar set names: Server names/aliases of vhost
(:class:`list` of :class:`str`)

View file

@ -347,8 +347,7 @@ class ApacheParser(object):
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")
raise errors.NoInstallationError("Could not find configuration root")
def _set_user_config_file(self, root):
"""Set the appropriate user configuration file

View file

@ -12,10 +12,11 @@ from letsencrypt import achallenges
from letsencrypt import errors
from letsencrypt import le_util
from letsencrypt.plugins import common
from letsencrypt.tests import acme_util
from letsencrypt_apache import configurator
from letsencrypt_apache import obj
from letsencrypt_apache import parser
from letsencrypt_apache.tests import util
@ -31,8 +32,7 @@ class TwoVhost80Test(util.ApacheTest):
"mod_loaded") as mock_load:
mock_load.return_value = True
self.config = util.get_apache_configurator(
self.config_path, self.config_dir, self.work_dir,
self.ssl_options)
self.config_path, self.config_dir, self.work_dir)
self.vh_truth = util.get_vh_truth(
self.temp_dir, "debian_apache_2_4/two_vhost_80")
@ -112,7 +112,7 @@ class TwoVhost80Test(util.ApacheTest):
self.vh_truth[1].filep)
def test_is_name_vhost(self):
addr = obj.Addr.fromstring("*:80")
addr = common.Addr.fromstring("*:80")
self.assertTrue(self.config.is_name_vhost(addr))
self.config.version = (2, 2)
self.assertFalse(self.config.is_name_vhost(addr))
@ -133,7 +133,7 @@ class TwoVhost80Test(util.ApacheTest):
self.assertEqual(ssl_vhost.path,
"/files" + ssl_vhost.filep + "/IfModule/VirtualHost")
self.assertEqual(len(ssl_vhost.addrs), 1)
self.assertEqual(set([obj.Addr.fromstring("*:443")]), ssl_vhost.addrs)
self.assertEqual(set([common.Addr.fromstring("*:443")]), ssl_vhost.addrs)
self.assertEqual(ssl_vhost.names, set(["encryption-example.demo"]))
self.assertTrue(ssl_vhost.ssl)
self.assertFalse(ssl_vhost.enabled)
@ -196,17 +196,14 @@ class TwoVhost80Test(util.ApacheTest):
mock_popen().communicate.return_value = (
"Server Version: Apache (Debian)", "")
self.assertRaises(
errors.LetsEncryptConfiguratorError, self.config.get_version)
self.assertRaises(errors.ConfiguratorError, self.config.get_version)
mock_popen().communicate.return_value = (
"Server Version: Apache/2.3{0} Apache/2.4.7".format(os.linesep), "")
self.assertRaises(
errors.LetsEncryptConfiguratorError, self.config.get_version)
self.assertRaises(errors.ConfiguratorError, self.config.get_version)
mock_popen.side_effect = OSError("Can't find program")
self.assertRaises(
errors.LetsEncryptConfiguratorError, self.config.get_version)
self.assertRaises(errors.ConfiguratorError, self.config.get_version)
if __name__ == "__main__":

View file

@ -1,5 +1,4 @@
"""Test for letsencrypt_apache.dvsni."""
import pkg_resources
import unittest
import shutil
@ -7,18 +6,17 @@ import mock
from acme import challenges
from letsencrypt import achallenges
from letsencrypt import le_util
from letsencrypt.plugins import common
from letsencrypt.plugins import common_test
from letsencrypt.tests import acme_util
from letsencrypt_apache import obj
from letsencrypt_apache.tests import util
class DvsniPerformTest(util.ApacheTest):
"""Test the ApacheDVSNI challenge."""
achalls = common_test.DvsniTest.achalls
def setUp(self):
super(DvsniPerformTest, self).setUp()
@ -26,38 +24,11 @@ class DvsniPerformTest(util.ApacheTest):
"mod_loaded") as mock_load:
mock_load.return_value = True
config = util.get_apache_configurator(
self.config_path, self.config_dir, self.work_dir,
self.ssl_options)
self.config_path, self.config_dir, self.work_dir)
from letsencrypt_apache import dvsni
self.sni = dvsni.ApacheDvsni(config)
rsa256_file = pkg_resources.resource_filename(
"acme.jose", "testdata/rsa256_key.pem")
rsa256_pem = pkg_resources.resource_string(
"acme.jose", "testdata/rsa256_key.pem")
auth_key = le_util.Key(rsa256_file, rsa256_pem)
self.achalls = [
achallenges.DVSNI(
challb=acme_util.chall_to_challb(
challenges.DVSNI(
r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9"
"\xf1\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4",
nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18",
), "pending"),
domain="encryption-example.demo", key=auth_key),
achallenges.DVSNI(
challb=acme_util.chall_to_challb(
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",
), "pending"),
domain="letsencrypt.demo", key=auth_key),
]
def tearDown(self):
shutil.rmtree(self.temp_dir)
shutil.rmtree(self.config_dir)
@ -67,28 +38,6 @@ class DvsniPerformTest(util.ApacheTest):
resp = self.sni.perform()
self.assertEqual(len(resp), 0)
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
m_open = mock.mock_open()
response = challenges.DVSNIResponse(s="randomS1")
achall = mock.MagicMock(nonce=self.achalls[0].nonce,
nonce_domain=self.achalls[0].nonce_domain)
achall.gen_cert_and_response.return_value = ("pem", response)
with mock.patch("letsencrypt_apache.dvsni.open", m_open, create=True):
# pylint: disable=protected-access
self.assertEqual(response, self.sni._setup_challenge_cert(
achall, "randomS1"))
self.assertTrue(m_open.called)
self.assertEqual(
m_open.call_args[0], (self.sni.get_cert_file(achall), "w"))
self.assertEqual(m_open().write.call_args[0][0], "pem")
def test_perform1(self):
achall = self.achalls[0]
self.sni.add_chall(achall)
@ -140,8 +89,9 @@ class DvsniPerformTest(util.ApacheTest):
def test_mod_config(self):
for achall in self.achalls:
self.sni.add_chall(achall)
v_addr1 = [obj.Addr(("1.2.3.4", "443")), obj.Addr(("5.6.7.8", "443"))]
v_addr2 = [obj.Addr(("127.0.0.1", "443"))]
v_addr1 = [common.Addr(("1.2.3.4", "443")),
common.Addr(("5.6.7.8", "443"))]
v_addr2 = [common.Addr(("127.0.0.1", "443"))]
ll_addr = []
ll_addr.append(v_addr1)
ll_addr.append(v_addr2)

View file

@ -1,63 +1,23 @@
"""Test the helper objects in letsencrypt_apache.obj."""
"""Tests for letsencrypt_apache.obj."""
import unittest
class AddrTest(unittest.TestCase):
"""Test the Addr class."""
def setUp(self):
from letsencrypt_apache.obj import Addr
self.addr1 = Addr.fromstring("192.168.1.1")
self.addr2 = Addr.fromstring("192.168.1.1:*")
self.addr3 = Addr.fromstring("192.168.1.1:80")
def test_fromstring(self):
self.assertEqual(self.addr1.get_addr(), "192.168.1.1")
self.assertEqual(self.addr1.get_port(), "")
self.assertEqual(self.addr2.get_addr(), "192.168.1.1")
self.assertEqual(self.addr2.get_port(), "*")
self.assertEqual(self.addr3.get_addr(), "192.168.1.1")
self.assertEqual(self.addr3.get_port(), "80")
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")
def test_get_addr_obj(self):
self.assertEqual(str(self.addr1.get_addr_obj("443")), "192.168.1.1:443")
self.assertEqual(str(self.addr2.get_addr_obj("")), "192.168.1.1")
self.assertEqual(str(self.addr1.get_addr_obj("*")), "192.168.1.1:*")
def test_eq(self):
self.assertEqual(self.addr1, self.addr2.get_addr_obj(""))
self.assertNotEqual(self.addr1, self.addr2)
self.assertFalse(self.addr1 == 3333)
def test_set_inclusion(self):
from letsencrypt_apache.obj import Addr
set_a = set([self.addr1, self.addr2])
addr1b = Addr.fromstring("192.168.1.1")
addr2b = Addr.fromstring("192.168.1.1:*")
set_b = set([addr1b, addr2b])
self.assertEqual(set_a, set_b)
from letsencrypt.plugins import common
class VirtualHostTest(unittest.TestCase):
"""Test the VirtualHost class."""
def setUp(self):
from letsencrypt_apache.obj import VirtualHost
from letsencrypt_apache.obj import Addr
self.vhost1 = VirtualHost(
"filep", "vh_path",
set([Addr.fromstring("localhost")]), False, False)
set([common.Addr.fromstring("localhost")]), False, False)
def test_eq(self):
from letsencrypt_apache.obj import Addr
from letsencrypt_apache.obj import VirtualHost
vhost1b = VirtualHost(
"filep", "vh_path",
set([Addr.fromstring("localhost")]), False, False)
set([common.Addr.fromstring("localhost")]), False, False)
self.assertEqual(vhost1b, self.vhost1)
self.assertEqual(str(vhost1b), str(self.vhost1))

View file

@ -112,7 +112,7 @@ class ApacheParserTest(util.ApacheTest):
mock_path.isfile.return_value = False
# pylint: disable=protected-access
self.assertRaises(errors.LetsEncryptConfiguratorError,
self.assertRaises(errors.ConfiguratorError,
self.parser._set_locations, self.ssl_options)
mock_path.isfile.side_effect = [True, False, False]

View file

@ -1,13 +1,11 @@
"""Common utilities for letsencrypt_apache."""
import os
import pkg_resources
import shutil
import tempfile
import unittest
import mock
from letsencrypt import constants as core_constants
from letsencrypt.plugins import common
from letsencrypt_apache import configurator
from letsencrypt_apache import constants
@ -19,10 +17,13 @@ class ApacheTest(unittest.TestCase): # pylint: disable=too-few-public-methods
def setUp(self):
super(ApacheTest, self).setUp()
self.temp_dir, self.config_dir, self.work_dir = dir_setup(
"debian_apache_2_4/two_vhost_80")
self.temp_dir, self.config_dir, self.work_dir = common.dir_setup(
test_dir="debian_apache_2_4/two_vhost_80",
pkg="letsencrypt_apache.tests")
self.ssl_options = setup_ssl_options(self.config_dir)
self.ssl_options = common.setup_ssl_options(
self.config_dir, constants.MOD_SSL_CONF_SRC,
constants.MOD_SSL_CONF_DEST)
self.config_path = os.path.join(
self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2")
@ -33,36 +34,8 @@ class ApacheTest(unittest.TestCase): # pylint: disable=too-few-public-methods
"acme.jose", "testdata/rsa256_key.pem")
def dir_setup(test_dir="debian_apache_2_4/two_vhost_80",
pkg="letsencrypt_apache.tests"):
"""Setup the directories necessary for the configurator."""
temp_dir = tempfile.mkdtemp("temp")
config_dir = tempfile.mkdtemp("config")
work_dir = tempfile.mkdtemp("work")
os.chmod(temp_dir, core_constants.CONFIG_DIRS_MODE)
os.chmod(config_dir, core_constants.CONFIG_DIRS_MODE)
os.chmod(work_dir, core_constants.CONFIG_DIRS_MODE)
test_configs = pkg_resources.resource_filename(
pkg, os.path.join("testdata", test_dir))
shutil.copytree(
test_configs, os.path.join(temp_dir, test_dir), symlinks=True)
return temp_dir, config_dir, work_dir
def setup_ssl_options(
config_dir, mod_ssl_conf=constants.MOD_SSL_CONF):
"""Move the ssl_options into position and return the path."""
option_path = os.path.join(config_dir, "options-ssl.conf")
shutil.copyfile(mod_ssl_conf, option_path)
return option_path
def get_apache_configurator(
config_path, config_dir, work_dir, ssl_options, version=(2, 4, 7)):
config_path, config_dir, work_dir, version=(2, 4, 7)):
"""Create an Apache Configurator with the specified options."""
backups = os.path.join(work_dir, "backups")
@ -74,8 +47,7 @@ def get_apache_configurator(
config = configurator.ApacheConfigurator(
config=mock.MagicMock(
apache_server_root=config_path,
apache_mod_ssl_conf=ssl_options,
le_vhost_ext="-le-ssl.conf",
apache_le_vhost_ext=constants.CLI_DEFAULTS["le_vhost_ext"],
backup_dir=backups,
config_dir=config_dir,
temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"),
@ -99,21 +71,21 @@ def get_vh_truth(temp_dir, config_name):
obj.VirtualHost(
os.path.join(prefix, "encryption-example.conf"),
os.path.join(aug_pre, "encryption-example.conf/VirtualHost"),
set([obj.Addr.fromstring("*:80")]),
set([common.Addr.fromstring("*:80")]),
False, True, set(["encryption-example.demo"])),
obj.VirtualHost(
os.path.join(prefix, "default-ssl.conf"),
os.path.join(aug_pre, "default-ssl.conf/IfModule/VirtualHost"),
set([obj.Addr.fromstring("_default_:443")]), True, False),
set([common.Addr.fromstring("_default_:443")]), True, False),
obj.VirtualHost(
os.path.join(prefix, "000-default.conf"),
os.path.join(aug_pre, "000-default.conf/VirtualHost"),
set([obj.Addr.fromstring("*:80")]), False, True,
set([common.Addr.fromstring("*:80")]), False, True,
set(["ip-172-30-0-17"])),
obj.VirtualHost(
os.path.join(prefix, "letsencrypt.conf"),
os.path.join(aug_pre, "letsencrypt.conf/VirtualHost"),
set([obj.Addr.fromstring("*:80")]), False, True,
set([common.Addr.fromstring("*:80")]), False, True,
set(["letsencrypt.demo"])),
]
return vh_truth

View file

@ -56,8 +56,6 @@ class NginxConfigurator(common.Plugin):
def add_parser_arguments(cls, add):
add("server-root", default=constants.CLI_DEFAULTS["server_root"],
help="Nginx server root directory.")
add("mod-ssl-conf", default=constants.CLI_DEFAULTS["mod_ssl_conf"],
help="Contains standard nginx SSL directives.")
add("ctl", default=constants.CLI_DEFAULTS["ctl"], help="Path to the "
"'nginx' binary, used for 'configtest' and retrieving nginx "
"version number.")
@ -91,18 +89,22 @@ class NginxConfigurator(common.Plugin):
self.reverter = reverter.Reverter(self.config)
self.reverter.recovery_routine()
@property
def mod_ssl_conf(self):
"""Full absolute path to SSL configuration file."""
return os.path.join(self.config.config_dir, constants.MOD_SSL_CONF_DEST)
# This is called in determine_authenticator and determine_installer
def prepare(self):
"""Prepare the authenticator/installer."""
self.parser = parser.NginxParser(
self.conf('server-root'),
self.conf('mod-ssl-conf'))
self.conf('server-root'), self.mod_ssl_conf)
# Set Version
if self.version is None:
self.version = self.get_version()
temp_install(self.conf('mod-ssl-conf'))
temp_install(self.mod_ssl_conf)
# Entry point in main.py for installing cert
def deploy_cert(self, domain, cert_path, key_path, chain_path=None):
@ -128,11 +130,10 @@ class NginxConfigurator(common.Plugin):
directives, True)
logging.info("Deployed Certificate to VirtualHost %s for %s",
vhost.filep, vhost.names)
except errors.LetsEncryptMisconfigurationError:
except errors.MisconfigurationError:
logging.warn(
"Cannot find a cert or key directive in %s for %s",
vhost.filep, vhost.names)
logging.warn("VirtualHost was not modified")
"Cannot find a cert or key directive in %s for %s. "
"VirtualHost was not modified.", vhost.filep, vhost.names)
# Presumably break here so that the virtualhost is not modified
return False
@ -316,9 +317,9 @@ class NginxConfigurator(common.Plugin):
return self._enhance_func[enhancement](
self.choose_vhost(domain), options)
except (KeyError, ValueError):
raise errors.LetsEncryptConfiguratorError(
raise errors.ConfiguratorError(
"Unsupported enhancement: {0}".format(enhancement))
except errors.LetsEncryptConfiguratorError:
except errors.ConfiguratorError:
logging.warn("Failed %s for %s", enhancement, domain)
######################################
@ -352,9 +353,7 @@ class NginxConfigurator(common.Plugin):
if proc.returncode != 0:
# Enter recovery routine...
logging.error("Config test failed")
logging.error(stdout)
logging.error(stderr)
logging.error("Config test failed\n%s\n%s", stdout, stderr)
return False
return True
@ -383,7 +382,7 @@ class NginxConfigurator(common.Plugin):
:returns: version
:rtype: tuple
:raises errors.LetsEncryptConfiguratorError:
:raises .ConfiguratorError:
Unable to find Nginx version or version is unsupported
"""
@ -394,7 +393,7 @@ class NginxConfigurator(common.Plugin):
stderr=subprocess.PIPE)
text = proc.communicate()[1] # nginx prints output to stderr
except (OSError, ValueError):
raise errors.LetsEncryptConfiguratorError(
raise errors.ConfiguratorError(
"Unable to run %s -V" % self.conf('ctl'))
version_regex = re.compile(r"nginx/([0-9\.]*)", re.IGNORECASE)
@ -407,22 +406,19 @@ class NginxConfigurator(common.Plugin):
ssl_matches = ssl_regex.findall(text)
if not version_matches:
raise errors.LetsEncryptConfiguratorError(
"Unable to find Nginx version")
raise errors.ConfiguratorError("Unable to find Nginx version")
if not ssl_matches:
raise errors.LetsEncryptConfiguratorError(
raise errors.ConfiguratorError(
"Nginx build is missing SSL module (--with-http_ssl_module).")
if not sni_matches:
raise errors.LetsEncryptConfiguratorError(
"Nginx build doesn't support SNI")
raise errors.ConfiguratorError("Nginx build doesn't support SNI")
nginx_version = tuple([int(i) for i in version_matches[0].split(".")])
# nginx < 0.8.48 uses machine hostname as default server_name instead of
# the empty string
if nginx_version < (0, 8, 48):
raise errors.LetsEncryptConfiguratorError(
"Nginx version must be 0.8.48+")
raise errors.ConfiguratorError("Nginx version must be 0.8.48+")
return nginx_version
@ -570,14 +566,11 @@ def nginx_restart(nginx_ctl):
if nginx_proc.returncode != 0:
# Enter recovery routine...
logging.error("Nginx Restart Failed!")
logging.error(stdout)
logging.error(stderr)
logging.error("Nginx Restart Failed!\n%s\n%s", stdout, stderr)
return False
except (OSError, ValueError):
logging.fatal(
"Nginx Restart Failed - Please Check the Configuration")
logging.fatal("Nginx Restart Failed - Please Check the Configuration")
sys.exit(1)
return True
@ -592,4 +585,4 @@ def temp_install(options_ssl):
# Check to make sure options-ssl.conf is installed
if not os.path.isfile(options_ssl):
shutil.copyfile(constants.MOD_SSL_CONF, options_ssl)
shutil.copyfile(constants.MOD_SSL_CONF_SRC, options_ssl)

View file

@ -4,13 +4,15 @@ import pkg_resources
CLI_DEFAULTS = dict(
server_root="/etc/nginx",
mod_ssl_conf="/etc/letsencrypt/options-ssl-nginx.conf",
ctl="nginx",
)
"""CLI defaults."""
MOD_SSL_CONF = pkg_resources.resource_filename(
MOD_SSL_CONF_DEST = "options-ssl-nginx.conf"
"""Name of the mod_ssl config file as saved in `IConfig.config_dir`."""
MOD_SSL_CONF_SRC = pkg_resources.resource_filename(
"letsencrypt_nginx", "options-ssl-nginx.conf")
"""Path to the Nginx mod_ssl config file found in the Let's Encrypt
"""Path to the nginx mod_ssl config file found in the Let's Encrypt
distribution."""

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