diff --git a/Dockerfile b/Dockerfile
index b6a07388c..78aa7a75b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/README.rst b/README.rst
index db32889db..40c054fe3 100644
--- a/README.rst
+++ b/README.rst
@@ -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)
+`_.
+
+
About the Let's Encrypt Client
==============================
@@ -47,6 +57,9 @@ server automatically!::
:target: https://quay.io/repository/letsencrypt/lets-encrypt-preview
: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,7 +98,7 @@ Current Features
Links
-----
-Documentation: https://letsencrypt.readthedocs.org/
+Documentation: https://letsencrypt.readthedocs.org
Software project: https://github.com/letsencrypt/lets-encrypt-preview
diff --git a/Vagrantfile b/Vagrantfile
index 7eb2b4cce..1d3b48f06 100644
--- a/Vagrantfile
+++ b/Vagrantfile
@@ -8,8 +8,6 @@ VAGRANTFILE_API_VERSION = "2"
$ubuntu_setup_script = < csr.der
+
+and for the certificate:
+
+ openssl req -key rsa512_key.pem -new -subj '/CN=example.com' -x509 -outform DER > cert.der
diff --git a/acme/jose/testdata/cert.der b/acme/jose/testdata/cert.der
new file mode 100644
index 000000000..5f1018505
Binary files /dev/null and b/acme/jose/testdata/cert.der differ
diff --git a/acme/jose/testdata/csr.der b/acme/jose/testdata/csr.der
new file mode 100644
index 000000000..adc29ff18
Binary files /dev/null and b/acme/jose/testdata/csr.der differ
diff --git a/acme/jose/testdata/csr2.pem b/acme/jose/testdata/csr2.pem
deleted file mode 100644
index bd059a448..000000000
--- a/acme/jose/testdata/csr2.pem
+++ /dev/null
@@ -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-----
diff --git a/acme/jws.py b/acme/jws.py
new file mode 100644
index 000000000..a23015d93
--- /dev/null
+++ b/acme/jws.py
@@ -0,0 +1,59 @@
+"""ACME JOSE JWS."""
+from acme import errors
+from acme import jose
+
+
+class Header(jose.Header):
+ """ACME JOSE Header.
+
+ .. todo:: Implement ``acmePath``.
+
+ """
+ nonce = jose.Field('nonce', omitempty=True)
+
+ @classmethod
+ def validate_nonce(cls, nonce):
+ """Validate nonce.
+
+ :returns: ``None`` if ``nonce`` is valid, decoding errors otherwise.
+
+ """
+ try:
+ jose.b64decode(nonce)
+ except (ValueError, TypeError) as error:
+ return error
+ else:
+ return None
+
+ @nonce.decoder
+ def nonce(value): # pylint: disable=missing-docstring,no-self-argument
+ error = Header.validate_nonce(value)
+ if error is not None:
+ # TODO: custom error
+ raise errors.Error("Invalid nonce: {0}".format(error))
+ return value
+
+
+class Signature(jose.Signature):
+ """ACME Signature."""
+ __slots__ = jose.Signature._orig_slots # pylint: disable=no-member
+
+ # TODO: decoder/encoder should accept cls? Otherwise, subclassing
+ # JSONObjectWithFields is tricky...
+ header_cls = Header
+ header = jose.Field(
+ 'header', omitempty=True, default=header_cls(),
+ decoder=header_cls.from_json)
+
+ # TODO: decoder should check that nonce is in the protected header
+
+
+class JWS(jose.JWS):
+ """ACME JWS."""
+ signature_cls = Signature
+ __slots__ = jose.JWS._orig_slots # pylint: disable=no-member
+
+ @classmethod
+ def sign(cls, payload, key, alg, nonce): # pylint: disable=arguments-differ
+ return super(JWS, cls).sign(payload, key=key, alg=alg,
+ protect=frozenset(['nonce']), nonce=nonce)
diff --git a/acme/jws_test.py b/acme/jws_test.py
new file mode 100644
index 000000000..f4a03f70d
--- /dev/null
+++ b/acme/jws_test.py
@@ -0,0 +1,58 @@
+"""Tests for acme.jws."""
+import os
+import pkg_resources
+import unittest
+
+import Crypto.PublicKey.RSA
+
+from acme import errors
+from acme import jose
+
+
+RSA512_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string(
+ 'acme.jose', os.path.join('testdata', 'rsa512_key.pem')))
+
+
+class HeaderTest(unittest.TestCase):
+ """Tests for acme.jws.Header."""
+
+ good_nonce = jose.b64encode('foo')
+ wrong_nonce = 'F'
+ # Following just makes sure wrong_nonce is wrong
+ try:
+ jose.b64decode(wrong_nonce)
+ except (ValueError, TypeError):
+ assert True
+ else:
+ assert False # pragma: no cover
+
+ def test_validate_nonce(self):
+ from acme.jws import Header
+ self.assertTrue(Header.validate_nonce(self.good_nonce) is None)
+ self.assertFalse(Header.validate_nonce(self.wrong_nonce) is None)
+
+ def test_nonce_decoder(self):
+ from acme.jws import Header
+ nonce_field = Header._fields['nonce']
+
+ self.assertRaises(errors.Error, nonce_field.decode, self.wrong_nonce)
+ self.assertEqual(self.good_nonce, nonce_field.decode(self.good_nonce))
+
+
+class JWSTest(unittest.TestCase):
+ """Tests for acme.jws.JWS."""
+
+ def setUp(self):
+ self.privkey = jose.JWKRSA(key=RSA512_KEY)
+ self.pubkey = self.privkey.public()
+ self.nonce = jose.b64encode('Nonce')
+
+ def test_it(self):
+ from acme.jws import JWS
+ jws = JWS.sign(payload='foo', key=self.privkey,
+ alg=jose.RS256, nonce=self.nonce)
+ JWS.from_json(jws.to_json())
+
+
+if __name__ == '__main__':
+ unittest.main() # pragma: no cover
diff --git a/acme/messages.py b/acme/messages.py
index 6d46f894c..c6d15bbf1 100644
--- a/acme/messages.py
+++ b/acme/messages.py
@@ -1,106 +1,287 @@
-"""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 = {
+ '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, 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)
+ 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 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 +290,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)
diff --git a/acme/messages2.py b/acme/messages2.py
deleted file mode 100644
index 253aaa95b..000000000
--- a/acme/messages2.py
+++ /dev/null
@@ -1,297 +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)',
- }
-
- 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)
diff --git a/acme/messages2_test.py b/acme/messages2_test.py
deleted file mode 100644
index c1521e2c3..000000000
--- a/acme/messages2_test.py
+++ /dev/null
@@ -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.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A')),
- ChallengeBody(uri='http://challb2', status=STATUS_VALID,
- chall=challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA')),
- ChallengeBody(uri='http://challb3', status=STATUS_VALID,
- chall=challenges.RecoveryToken()),
- )
- combinations = ((0, 2), (1, 2))
-
- from 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
diff --git a/acme/messages_test.py b/acme/messages_test.py
index 4e0823085..9b3c03fbc 100644
--- a/acme/messages_test.py
+++ b/acme/messages_test.py
@@ -3,477 +3,336 @@ 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.SimpleHTTPS(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.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'),
- challenges.RecoveryToken()
- ),
- (
- challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'),
- challenges.RecoveryToken(),
- )
- ))
-
- def test_to_partial_json(self):
- self.assertEqual(self.msg.to_partial_json(), self.jmsg_to)
-
- def test_from_json(self):
- from 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.SimpleHTTPSResponse(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):
- from acme.messages import Error
- self.assertEqual(Error.from_json(self.jmsg), self.msg)
+ self.assertEqual(self.const_a, self.MockConstant.from_json('a'))
+ self.assertRaises(
+ jose.DeserializationError, self.MockConstant.from_json, 'c')
- def test_json_without_optionals(self):
- del self.jmsg['message']
- del self.jmsg['moreInfo']
+ def test_from_json_hashable(self):
+ hash(self.MockConstant.from_json('a'))
- from acme.messages import Error
- msg = Error.from_json(self.jmsg)
+ def test_repr(self):
+ self.assertEqual('MockConstant(a)', repr(self.const_a))
+ self.assertEqual('MockConstant(b)', repr(self.const_b))
- self.assertTrue(msg.message is None)
- self.assertTrue(msg.more_info is None)
- self.assertEqual(self.jmsg, msg.to_partial_json())
+ 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 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.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__':
diff --git a/acme/schemata/authorization.json b/acme/schemata/authorization.json
deleted file mode 100644
index 122f263e1..000000000
--- a/acme/schemata/authorization.json
+++ /dev/null
@@ -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"
- }
- }
-}
diff --git a/acme/schemata/authorizationRequest.json b/acme/schemata/authorizationRequest.json
deleted file mode 100644
index 2d4371cb8..000000000
--- a/acme/schemata/authorizationRequest.json
+++ /dev/null
@@ -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"
- }
- }
- }
-}
diff --git a/acme/schemata/certificate.json b/acme/schemata/certificate.json
deleted file mode 100644
index 1d4e98947..000000000
--- a/acme/schemata/certificate.json
+++ /dev/null
@@ -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"
- }
- }
-}
diff --git a/acme/schemata/certificateRequest.json b/acme/schemata/certificateRequest.json
deleted file mode 100644
index ef3e18f98..000000000
--- a/acme/schemata/certificateRequest.json
+++ /dev/null
@@ -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"
- }
- }
-}
diff --git a/acme/schemata/challenge.json b/acme/schemata/challenge.json
deleted file mode 100644
index 978fcd4c4..000000000
--- a/acme/schemata/challenge.json
+++ /dev/null
@@ -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"
- }
- }
- }
- }
-}
diff --git a/acme/schemata/challengeRequest.json b/acme/schemata/challengeRequest.json
deleted file mode 100644
index 0762fa9c8..000000000
--- a/acme/schemata/challengeRequest.json
+++ /dev/null
@@ -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"
- }
- }
-}
diff --git a/acme/schemata/challengeobject.json b/acme/schemata/challengeobject.json
deleted file mode 100644
index 5641b407e..000000000
--- a/acme/schemata/challengeobject.json
+++ /dev/null
@@ -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" : [ "simpleHttps" ]
- },
- "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"
- }
- }
- }
- ]
-}
diff --git a/acme/schemata/defer.json b/acme/schemata/defer.json
deleted file mode 100644
index 21edd614b..000000000
--- a/acme/schemata/defer.json
+++ /dev/null
@@ -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"
- }
- }
-}
diff --git a/acme/schemata/error.json b/acme/schemata/error.json
deleted file mode 100644
index 359506b52..000000000
--- a/acme/schemata/error.json
+++ /dev/null
@@ -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"
- }
- }
-}
diff --git a/acme/schemata/jwk.json b/acme/schemata/jwk.json
deleted file mode 100644
index b9cca8840..000000000
--- a/acme/schemata/jwk.json
+++ /dev/null
@@ -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]+$"
- }
- }
-}
diff --git a/acme/schemata/responseobject.json b/acme/schemata/responseobject.json
deleted file mode 100644
index 5ca6babf1..000000000
--- a/acme/schemata/responseobject.json
+++ /dev/null
@@ -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" : [ "simpleHttps" ]
- },
- "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" ]
- }
- }
- }
- ]
-}
diff --git a/acme/schemata/revocation.json b/acme/schemata/revocation.json
deleted file mode 100644
index 53455d506..000000000
--- a/acme/schemata/revocation.json
+++ /dev/null
@@ -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" ]
- }
- }
-}
diff --git a/acme/schemata/revocationRequest.json b/acme/schemata/revocationRequest.json
deleted file mode 100644
index 7559d0ee0..000000000
--- a/acme/schemata/revocationRequest.json
+++ /dev/null
@@ -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"
- }
- }
-}
diff --git a/acme/schemata/signature.json b/acme/schemata/signature.json
deleted file mode 100644
index e70652e7c..000000000
--- a/acme/schemata/signature.json
+++ /dev/null
@@ -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]+$"
- }
- }
- }
- }
- ]
- }
-}
diff --git a/acme/schemata/statusRequest.json b/acme/schemata/statusRequest.json
deleted file mode 100644
index 8e4221cbe..000000000
--- a/acme/schemata/statusRequest.json
+++ /dev/null
@@ -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"
- }
- }
-}
diff --git a/bootstrap/README b/bootstrap/README
index 847129c03..6a04ac0ba 100644
--- a/bootstrap/README
+++ b/bootstrap/README
@@ -1,2 +1,7 @@
This directory contains scripts that install necessary OS-specific
-prerequisite dependencies (see docs/using.rst).
\ No newline at end of file
+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://*
diff --git a/bootstrap/_deb_common.sh b/bootstrap/_deb_common.sh
index 4e4c75b33..653daca53 100755
--- a/bootstrap/_deb_common.sh
+++ b/bootstrap/_deb_common.sh
@@ -45,5 +45,15 @@ fi
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 \
diff --git a/bootstrap/_rpm_common.sh b/bootstrap/_rpm_common.sh
new file mode 100755
index 000000000..1209cd44a
--- /dev/null
+++ b/bootstrap/_rpm_common.sh
@@ -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 \
diff --git a/bootstrap/centos.sh b/bootstrap/centos.sh
new file mode 120000
index 000000000..a0db46d70
--- /dev/null
+++ b/bootstrap/centos.sh
@@ -0,0 +1 @@
+_rpm_common.sh
\ No newline at end of file
diff --git a/bootstrap/fedora.sh b/bootstrap/fedora.sh
new file mode 120000
index 000000000..a0db46d70
--- /dev/null
+++ b/bootstrap/fedora.sh
@@ -0,0 +1 @@
+_rpm_common.sh
\ No newline at end of file
diff --git a/docs/api/network2.rst b/docs/api/network2.rst
deleted file mode 100644
index a73308e1b..000000000
--- a/docs/api/network2.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-:mod:`letsencrypt.network2`
----------------------------
-
-.. automodule:: letsencrypt.network2
- :members:
diff --git a/docs/api/plugins/manual.rst b/docs/api/plugins/manual.rst
new file mode 100644
index 000000000..4661ab7df
--- /dev/null
+++ b/docs/api/plugins/manual.rst
@@ -0,0 +1,5 @@
+:mod:`letsencrypt.plugins.manual`
+---------------------------------
+
+.. automodule:: letsencrypt.plugins.manual
+ :members:
diff --git a/docs/contributing.rst b/docs/contributing.rst
index da28686a2..804cec95c 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -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
`.
@@ -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.
diff --git a/docs/pkgs/acme/index.rst b/docs/pkgs/acme/index.rst
index 9cca3b795..2df2615a5 100644
--- a/docs/pkgs/acme/index.rst
+++ b/docs/pkgs/acme/index.rst
@@ -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
---------
diff --git a/docs/using.rst b/docs/using.rst
index 89cbc48f6..96eb62b05 100644
--- a/docs/using.rst
+++ b/docs/using.rst
@@ -5,9 +5,9 @@ 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
@@ -16,9 +16,31 @@ download docker, and issue the following command
-v "/var/lib/letsencrypt:/var/lib/letsencrypt" \
quay.io/letsencrypt/lets-encrypt-preview: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/lets-encrypt-preview
+ cd lets-encrypt-preview
+
+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/lets-encrypt-preview/archive/master.zip
+
Prerequisites
=============
@@ -30,8 +52,8 @@ are provided mainly for the :ref:`developers ` 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
@@ -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
diff --git a/examples/acme_client.py b/examples/acme_client.py
new file mode 100644
index 000000000..09ff2bfc3
--- /dev/null
+++ b/examples/acme_client.py
@@ -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))
diff --git a/examples/restified.py b/examples/restified.py
deleted file mode 100644
index c0252c1eb..000000000
--- a/examples/restified.py
+++ /dev/null
@@ -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
diff --git a/letsencrypt/account.py b/letsencrypt/account.py
index 3f8e3d012..a97e07504 100644
--- a/letsencrypt/account.py
+++ b/letsencrypt/account.py
@@ -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`
"""
@@ -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,7 +186,7 @@ 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:
@@ -227,5 +227,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
diff --git a/letsencrypt/achallenges.py b/letsencrypt/achallenges.py
index 77e362f22..88dcdbe11 100644
--- a/letsencrypt/achallenges.py
+++ b/letsencrypt/achallenges.py
@@ -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::
@@ -62,10 +62,10 @@ class DVSNI(AnnotatedChallenge):
return cert_pem, response
-class SimpleHTTPS(AnnotatedChallenge):
- """Client annotated "simpleHttps" ACME challenge."""
+class SimpleHTTP(AnnotatedChallenge):
+ """Client annotated "simpleHttp" ACME challenge."""
__slots__ = ('challb', 'domain', 'key')
- acme_type = challenges.SimpleHTTPS
+ acme_type = challenges.SimpleHTTP
class DNS(AnnotatedChallenge):
diff --git a/letsencrypt/augeas_configurator.py b/letsencrypt/augeas_configurator.py
index c59d755c2..a375b2e17 100644
--- a/letsencrypt/augeas_configurator.py
+++ b/letsencrypt/augeas_configurator.py
@@ -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):
diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py
index 37d818dbe..50a66c0d0 100644
--- a/letsencrypt/auth_handler.py
+++ b/letsencrypt/auth_handler.py
@@ -4,7 +4,7 @@ import logging
import time
from acme import challenges
-from acme import messages2
+from acme import messages
from letsencrypt import achallenges
from letsencrypt import constants
@@ -24,13 +24,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
@@ -82,7 +82,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 +134,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.
@@ -196,7 +198,7 @@ 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
@@ -205,9 +207,9 @@ class AuthHandler(object):
status = self._get_chall_status(self.authzr[domain], achall)
# This does nothing for challenges that have yet to be decided yet.
- if status == messages2.STATUS_VALID:
+ if status == messages.STATUS_VALID:
completed.append(achall)
- elif status == messages2.STATUS_INVALID:
+ elif status == messages.STATUS_INVALID:
failed.append(achall)
return completed, failed
@@ -219,7 +221,7 @@ class AuthHandler(object):
each challenge resource.
:param authzr: Authorization Resource
- :type authzr: :class:`acme.messages2.AuthorizationResource`
+ :type authzr: :class:`acme.messages.AuthorizationResource`
:param achall: Annotated challenge for which to get status
:type achall: :class:`letsencrypt.achallenges.AnnotatedChallenge`
@@ -277,8 +279,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):
@@ -319,7 +321,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,28 +333,22 @@ 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.SimpleHTTPS):
- logging.info(" SimpleHTTPS challenge for %s.", domain)
- return achallenges.SimpleHTTPS(
+ elif isinstance(chall, challenges.SimpleHTTP):
+ 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)
@@ -368,8 +364,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.
diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py
index 2e19fe8ac..3bdf2bfc6 100644
--- a/letsencrypt/cli.py
+++ b/letsencrypt/cli.py
@@ -146,21 +146,21 @@ def install(args, config, plugins):
return "Installer could not be determined"
acme, doms = _common_run(
args, config, acc, authenticator=None, installer=installer)
- assert args.cert_path is not None # required=True in the subparser
+ assert args.cert_path is not None
acme.deploy_certificate(doms, acc.key.file, args.cert_path, args.chain_path)
acme.enhance_config(doms, args.redirect)
def revoke(args, unused_config, unused_plugins):
"""Revoke."""
- if args.cert_path is None and args.key_path is None:
- return "At least one of --cert-path or --key-path is required"
+ if args.rev_cert is None and args.rev_key is None:
+ return "At least one of --certificate or --key is required"
# This depends on the renewal config and cannot be completed yet.
zope.component.getUtility(interfaces.IDisplay).notification(
"Revocation is not available with the new Boulder server yet.")
#client.revoke(args.installer, config, plugins, args.no_confirm,
- # args.cert_path, args.key_path)
+ # args.rev_cert, args.rev_key)
def rollback(args, config, plugins):
@@ -252,6 +252,9 @@ def create_parser(plugins):
add("-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(
"testing", description="The following flags are meant for "
"testing purposes only! Do NOT change them, unless you "
@@ -265,6 +268,34 @@ def create_parser(plugins):
"--dvsni-port", type=int, help=config_help("dvsni_port"),
default=flag_default("dvsni_port"))
+ 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__)
+ subparser.set_defaults(func=func)
+ return subparser
+
+ add_subparser("run", run)
+ add_subparser("auth", auth)
+ add_subparser("install", install)
+ parser_revoke = add_subparser("revoke", revoke)
+ parser_rollback = add_subparser("rollback", rollback)
+ add_subparser("config_changes", config_changes)
+
+ parser_plugins = add_subparser("plugins", plugins_cmd)
+ parser_plugins.add_argument("--init", action="store_true")
+ parser_plugins.add_argument("--prepare", action="store_true")
+ parser_plugins.add_argument(
+ "--authenticators", action="append_const", dest="ifaces",
+ const=interfaces.IAuthenticator)
+ parser_plugins.add_argument(
+ "--installers", action="append_const", dest="ifaces",
+ const=interfaces.IInstaller)
+
+ parser.add_argument("--configurator")
+ parser.add_argument("-a", "--authenticator")
+ parser.add_argument("-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:
@@ -283,56 +314,11 @@ def create_parser(plugins):
help="Automatically redirect all HTTP traffic to HTTPS for the newly "
"authenticated vhost.")
- _paths_parser(parser)
- # _plugins_parsing should be the last thing to act upon the main
- # parser (--help should display plugin-specific options last)
- _plugins_parsing(parser, plugins)
-
- _create_subparsers(parser)
-
- return parser
-
-
-def _create_subparsers(parser):
- 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__)
- subparser.set_defaults(func=func)
- return subparser
-
- # the order of add_subparser() calls is important: it defines the
- # order in which subparser names will be displayed in --help
- add_subparser("run", run)
- add_subparser("auth", auth)
- parser_install = add_subparser("install", install)
- parser_plugins = add_subparser("plugins", plugins_cmd)
- parser_revoke = add_subparser("revoke", revoke)
- parser_rollback = add_subparser("rollback", rollback)
- add_subparser("config_changes", config_changes)
-
- parser_install.add_argument(
- "--cert-path", required=True, help="Path to a certificate that "
- "is going to be installed.")
- parser_install.add_argument(
- "--chain-path", help="Accompanying path to a certificate chain.")
-
- parser_plugins.add_argument(
- "--init", action="store_true", help="Initialize plugins.")
- parser_plugins.add_argument("--prepare", action="store_true",
- help="Initialize and prepare plugins.")
- parser_plugins.add_argument(
- "--authenticators", action="append_const", dest="ifaces",
- const=interfaces.IAuthenticator,
- help="Limit to authenticator plugins only.")
- parser_plugins.add_argument(
- "--installers", action="append_const", dest="ifaces",
- const=interfaces.IInstaller, help="Limit to installer plugins only.")
-
parser_revoke.add_argument(
- "--cert-path", type=read_file, help="Revoke a specific certificate.")
+ "--certificate", dest="rev_cert", type=read_file, metavar="CERT_PATH",
+ help="Revoke a specific certificate.")
parser_revoke.add_argument(
- "--key-path", type=read_file,
+ "--key", dest="rev_key", type=read_file, metavar="KEY_PATH",
help="Revoke all certs generated by the provided authorized key.")
parser_rollback.add_argument(
@@ -340,43 +326,41 @@ def _create_subparsers(parser):
default=flag_default("rollback_checkpoints"),
help="Revert configuration N number of checkpoints.")
+ _paths_parser(parser.add_argument_group("paths"))
-def _paths_parser(parser):
- add = parser.add_argument_group("paths").add_argument
- add("--config-dir", default=flag_default("config_dir"),
- help=config_help("config_dir"))
- add("--work-dir", default=flag_default("work_dir"),
- help=config_help("work_dir"))
+ # 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
-def _plugins_parsing(parser, plugins):
- plugins_group = parser.add_argument_group(
- "plugins", description="Let's Encrypt client supports an extensible "
- "plugins architecture. See '%(prog)s plugins' for a list of all "
- "available plugins and their names. You can force a particular "
- "plugin by setting options provided below. Futher down this help "
- "message you will find plugin-specific options (prefixed by "
- "--{plugin_name}.")
- plugins_group.add_argument(
- "-a", "--authenticator", help="Authenticator plugin name.")
- plugins_group.add_argument(
- "-i", "--installer", help="Installer plugin name.")
- plugins_group.add_argument(
- "--configurator", help="Name of the plugin that is both "
- "an authenticator and an installer. Should not be used together "
- "with --authenticator or --installer.")
+def _paths_parser(parser):
+ add = parser.add_argument
+ add("--config-dir", default=flag_default("config_dir"),
+ help=config_help("config_dir"))
+ add("--work-dir", default=flag_default("work_dir"),
+ help=config_help("work_dir"))
+ add("--backup-dir", default=flag_default("backup_dir"),
+ help=config_help("backup_dir"))
+ add("--key-dir", default=flag_default("key_dir"),
+ help=config_help("key_dir"))
+ add("--cert-dir", default=flag_default("certs_dir"),
+ help=config_help("cert_dir"))
- # things should not be reorder past/pre this comment:
- # plugins_group should be displayed in --help before plugin
- # specific groups (so that plugins_group.description makes sense)
+ add("--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"))
- for name, plugin_ep in plugins.iteritems():
- plugin_ep.plugin_cls.inject_parser_options(
- parser.add_argument_group(
- "plugins: {0}".format(name),
- description=plugin_ep.description), name)
+ add("--renewer-config-file", default=flag_default("renewer_config_file"),
+ help=config_help("renewer_config_file"))
+
+ return parser
def main(args=sys.argv[1:]):
diff --git a/letsencrypt/client.py b/letsencrypt/client.py
index d04116de2..ef20bb78c 100644
--- a/letsencrypt/client.py
+++ b/letsencrypt/client.py
@@ -18,7 +18,7 @@ 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
@@ -31,7 +31,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`
@@ -64,7 +64,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))
@@ -159,7 +159,7 @@ class Client(object):
cert_key = crypto_util.init_save_key(
self.config.rsa_key_size, self.config.key_dir)
csr = crypto_util.init_save_csr(
- cert_key, domains, self.config.csr_dir)
+ cert_key, domains, self.config.cert_dir)
# Retrieve certificate
certr = self.network.request_issuance(
diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py
index 7bd5c2ca4..5595c71c4 100644
--- a/letsencrypt/configuration.py
+++ b/letsencrypt/configuration.py
@@ -19,7 +19,7 @@ class NamespaceConfig(object):
- `accounts_dir`
- `account_keys_dir`
- - `csr_dir`
+ - `cert_dir`
- `cert_key_backup`
- `in_progress_dir`
- `key_dir`
@@ -65,8 +65,8 @@ class NamespaceConfig(object):
constants.CERT_KEY_BACKUP_DIR, self.server_path)
@property
- def csr_dir(self): # pylint: disable=missing-docstring
- return os.path.join(self.namespace.config_dir, constants.CSR_DIR)
+ def cert_dir(self): # pylint: disable=missing-docstring
+ return os.path.join(self.namespace.config_dir, constants.CERT_DIR)
@property
def in_progress_dir(self): # pylint: disable=missing-docstring
diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py
index 202871144..c77d11775 100644
--- a/letsencrypt/constants.py
+++ b/letsencrypt/constants.py
@@ -17,6 +17,14 @@ CLI_DEFAULTS = dict(
work_dir="/var/lib/letsencrypt",
no_verify_ssl=False,
dvsni_port=challenges.DVSNI.PORT,
+
+ # TODO: blocked by #485, values ignored
+ backup_dir="not used",
+ key_dir="not used",
+ certs_dir="not used",
+ cert_path="not used",
+ chain_path="not used",
+ renewer_config_file="not used",
)
"""Defaults for CLI flags and `.IConfig` attributes."""
@@ -30,7 +38,7 @@ RENEWER_DEFAULTS = dict(
EXCLUSIVE_CHALLENGES = frozenset([frozenset([
- challenges.DVSNI, challenges.SimpleHTTPS])])
+ challenges.DVSNI, challenges.SimpleHTTP])])
"""Mutually exclusive challenges."""
@@ -65,8 +73,8 @@ CERT_KEY_BACKUP_DIR = "keys-certs"
"""Directory where all certificates and keys are stored (relative to
`IConfig.work_dir`). Used for easy revocation."""
-CSR_DIR = "csrs"
-"""Directory (relative to `IConfig.config_dir`) where CSRs are saved."""
+CERT_DIR = "certs"
+"""See `.IConfig.cert_dir`."""
IN_PROGRESS_DIR = "IN_PROGRESS"
"""Directory used before a permanent checkpoint is finalized (relative to
@@ -90,8 +98,3 @@ RENEWAL_CONFIGS_DIR = "configs"
RENEWER_CONFIG_FILENAME = "renewer.conf"
"""Renewer config file name (relative to `IConfig.config_dir`)."""
-
-
-NETSTAT = "/bin/netstat"
-"""Location of netstat binary for checking whether a listener is already
-running on the specified port (Linux-specific)."""
diff --git a/letsencrypt/crypto_util.py b/letsencrypt/crypto_util.py
index 9172fda46..31df697c0 100644
--- a/letsencrypt/crypto_util.py
+++ b/letsencrypt/crypto_util.py
@@ -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
diff --git a/letsencrypt/errors.py b/letsencrypt/errors.py
index f5d9f5f44..d9078dbf2 100644
--- a/letsencrypt/errors.py
+++ b/letsencrypt/errors.py
@@ -5,14 +5,6 @@ class LetsEncryptClientError(Exception):
"""Generic Let's Encrypt client error."""
-class NetworkError(LetsEncryptClientError):
- """Network error."""
-
-
-class UnexpectedUpdate(NetworkError):
- """Unexpected update."""
-
-
class LetsEncryptReverterError(LetsEncryptClientError):
"""Let's Encrypt Reverter error."""
diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py
index 17905149a..a93716d7d 100644
--- a/letsencrypt/interfaces.py
+++ b/letsencrypt/interfaces.py
@@ -148,8 +148,7 @@ 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.")
@@ -162,7 +161,9 @@ class IConfig(zope.interface.Interface):
account_keys_dir = zope.interface.Attribute(
"Directory where all account keys are stored.")
backup_dir = zope.interface.Attribute("Configuration backups directory.")
- csr_dir = zope.interface.Attribute("CSRs storage.")
+ 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.")
@@ -183,6 +184,15 @@ class IConfig(zope.interface.Interface):
"Port number to perform DVSNI challenge. "
"Boulder in testing mode defaults to 5001.")
+ # TODO: not implemented
+ 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")
+ cert_path = zope.interface.Attribute("not used")
+ chain_path = zope.interface.Attribute("not used")
+
class IInstaller(IPlugin):
"""Generic Let's Encrypt Installer Interface.
diff --git a/letsencrypt/network.py b/letsencrypt/network.py
index f59794971..0f4d9d29b 100644
--- a/letsencrypt/network.py
+++ b/letsencrypt/network.py
@@ -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
diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py
index 32bee2b49..03ddf7df7 100644
--- a/letsencrypt/plugins/common.py
+++ b/letsencrypt/plugins/common.py
@@ -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
diff --git a/letsencrypt/plugins/common_test.py b/letsencrypt/plugins/common_test.py
index 12dd18bdf..6de86f2b8 100644
--- a/letsencrypt/plugins/common_test.py
+++ b/letsencrypt/plugins/common_test.py
@@ -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? {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
diff --git a/letsencrypt/plugins/manual_test.py b/letsencrypt/plugins/manual_test.py
new file mode 100644
index 000000000..c95654dec
--- /dev/null
+++ b/letsencrypt/plugins/manual_test.py
@@ -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
diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py
index b27f5fa4c..7ac22138d 100644
--- a/letsencrypt/renewer.py
+++ b/letsencrypt/renewer.py
@@ -99,7 +99,9 @@ def renew(cert, old_version):
def _create_parser():
parser = argparse.ArgumentParser()
#parser.add_argument("--cron", action="store_true", help="Run as cronjob.")
- return cli._paths_parser(parser) # pylint: disable=protected-access
+ # pylint: disable=protected-access
+ cli._paths_parser(parser.add_argument_group("paths"))
+ return parser
def main(config=None, args=sys.argv[1:]):
"""Main function for autorenewer script."""
diff --git a/letsencrypt/revoker.py b/letsencrypt/revoker.py
index a1ea27e71..402157721 100644
--- a/letsencrypt/revoker.py
+++ b/letsencrypt/revoker.py
@@ -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
@@ -238,6 +239,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)
@@ -250,10 +253,7 @@ class Revoker(object):
raise errors.LetsEncryptRevokerError(
"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.
diff --git a/letsencrypt/tests/account_test.py b/letsencrypt/tests/account_test.py
index d14610252..6e9966a55 100644
--- a/letsencrypt/tests/account_test.py
+++ b/letsencrypt/tests/account_test.py
@@ -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")
)
diff --git a/letsencrypt/tests/acme_util.py b/letsencrypt/tests/acme_util.py
index 8780e8095..7ac05c1fa 100644
--- a/letsencrypt/tests/acme_util.py
+++ b/letsencrypt/tests/acme_util.py
@@ -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.SimpleHTTPS(
+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)
)
diff --git a/letsencrypt/tests/auth_handler_test.py b/letsencrypt/tests/auth_handler_test.py
index 85bcfe8cf..72fba1d0b 100644
--- a/letsencrypt/tests/auth_handler_test.py
+++ b/letsencrypt/tests/auth_handler_test.py
@@ -6,18 +6,18 @@ 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
TRANSLATE = {
"dvsni": "DVSNI",
- "simpleHttps": "SimpleHTTPS",
+ "simpleHttp": "SimpleHTTP",
"dns": "DNS",
"recoveryToken": "RecoveryToken",
"recoveryContact": "RecoveryContact",
@@ -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,9 +57,9 @@ 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])
@@ -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,
@@ -160,10 +160,10 @@ class GetAuthorizationsTest(unittest.TestCase):
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,7 +213,7 @@ 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):
@@ -241,10 +241,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 +269,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,
@@ -299,8 +299,8 @@ class GenChallengePathTest(unittest.TestCase):
return gen_challenge_path(challbs, preferences, combinations)
def test_common_case(self):
- """Given DVSNI and SimpleHTTPS with appropriate combos."""
- challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTPS_P)
+ """Given DVSNI and SimpleHTTP with appropriate combos."""
+ challbs = (acme_util.DVSNI_P, acme_util.SIMPLE_HTTP_P)
prefs = [challenges.DVSNI]
combos = ((0,), (1,))
@@ -315,7 +315,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,13 +328,13 @@ 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
prefs = [challenges.RecoveryToken,
challenges.ProofOfPossession,
- challenges.SimpleHTTPS,
+ challenges.SimpleHTTP,
challenges.DVSNI,
challenges.RecoveryContact]
combos = acme_util.gen_combos(challbs)
@@ -403,8 +403,8 @@ class IsPreferredTest(unittest.TestCase):
def _call(cls, chall, satisfied):
from letsencrypt.auth_handler import is_preferred
return is_preferred(chall, satisfied, exclusive_groups=frozenset([
- frozenset([challenges.DVSNI, challenges.SimpleHTTPS]),
- frozenset([challenges.DNS, challenges.SimpleHTTPS]),
+ frozenset([challenges.DVSNI, challenges.SimpleHTTP]),
+ frozenset([challenges.DNS, challenges.SimpleHTTP]),
]))
def test_empty_satisfied(self):
@@ -413,7 +413,7 @@ 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(
@@ -429,8 +429,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__":
diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py
index 59657b627..593d02cdd 100644
--- a/letsencrypt/tests/client_test.py
+++ b/letsencrypt/tests/client_test.py
@@ -27,14 +27,14 @@ class ClientTest(unittest.TestCase):
self.account = mock.MagicMock(**{"key.pem": KEY})
from letsencrypt.client import Client
- with mock.patch("letsencrypt.client.network2") as network2:
+ with mock.patch("letsencrypt.client.network") as network:
self.client = Client(
config=self.config, account_=self.account, dv_auth=None,
installer=None)
- self.network2 = network2
+ self.network = network
def test_init_network_verify_ssl(self):
- self.network2.Network.assert_called_once_with(
+ self.network.Network.assert_called_once_with(
mock.ANY, mock.ANY, verify_ssl=True)
@mock.patch("letsencrypt.client.zope.component.getUtility")
diff --git a/letsencrypt/tests/configuration_test.py b/letsencrypt/tests/configuration_test.py
index 3dee41d85..faf7021be 100644
--- a/letsencrypt/tests/configuration_test.py
+++ b/letsencrypt/tests/configuration_test.py
@@ -34,7 +34,7 @@ class NamespaceConfigTest(unittest.TestCase):
constants.ACCOUNT_KEYS_DIR = 'keys'
constants.BACKUP_DIR = 'backups'
constants.CERT_KEY_BACKUP_DIR = 'c/'
- constants.CSR_DIR = 'csrs'
+ constants.CERT_DIR = 'certs'
constants.IN_PROGRESS_DIR = '../p'
constants.KEY_DIR = 'keys'
constants.REC_TOKEN_DIR = '/r'
@@ -46,7 +46,7 @@ class NamespaceConfigTest(unittest.TestCase):
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.csr_dir, '/tmp/config/csrs')
+ 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')
diff --git a/letsencrypt/tests/network_test.py b/letsencrypt/tests/network_test.py
new file mode 100644
index 000000000..6acb11315
--- /dev/null
+++ b/letsencrypt/tests/network_test.py
@@ -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
diff --git a/letsencrypt/tests/proof_of_possession_test.py b/letsencrypt/tests/proof_of_possession_test.py
index 0a044810c..415e4caed 100644
--- a/letsencrypt/tests/proof_of_possession_test.py
+++ b/letsencrypt/tests/proof_of_possession_test.py
@@ -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)
diff --git a/letsencrypt/tests/revoker_test.py b/letsencrypt/tests/revoker_test.py
index ae04b5081..cd86594fd 100644
--- a/letsencrypt/tests/revoker_test.py
+++ b/letsencrypt/tests/revoker_test.py
@@ -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
@@ -89,7 +89,7 @@ class RevokerTest(RevokerBase):
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 +105,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 +122,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 +141,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 +165,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
diff --git a/letsencrypt_apache/configurator.py b/letsencrypt_apache/configurator.py
index 078e61564..7da9d2113 100644
--- a/letsencrypt_apache/configurator.py
+++ b/letsencrypt_apache/configurator.py
@@ -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
@@ -185,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
@@ -236,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
@@ -327,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(
@@ -412,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
@@ -493,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))
@@ -796,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
@@ -927,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
@@ -1059,9 +1059,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)
@@ -1124,9 +1123,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):
diff --git a/letsencrypt_apache/dvsni.py b/letsencrypt_apache/dvsni.py
index ed7a216bb..5ff09aa50 100644
--- a/letsencrypt_apache/dvsni.py
+++ b/letsencrypt_apache/dvsni.py
@@ -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
@@ -18,7 +20,7 @@ class ApacheDvsni(object):
larger array. ApacheDvsni is capable of solving many challenges
at once which causes an indexing issue within ApacheConfigurator
who must return all responses in order. Imagine ApacheConfigurator
- maintaining state about where all of the SimpleHTTPS Challenges,
+ maintaining state about where all of the SimpleHTTP Challenges,
Dvsni Challenges belong in the response array. This is an optional
utility.
@@ -42,26 +44,6 @@ class ApacheDvsni(object):
"""
- 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")
diff --git a/letsencrypt_apache/obj.py b/letsencrypt_apache/obj.py
index 905e3f192..fecf46ff9 100644
--- a/letsencrypt_apache/obj.py
+++ b/letsencrypt_apache/obj.py
@@ -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`)
diff --git a/letsencrypt_apache/tests/configurator_test.py b/letsencrypt_apache/tests/configurator_test.py
index e732e1bce..c8383e71f 100644
--- a/letsencrypt_apache/tests/configurator_test.py
+++ b/letsencrypt_apache/tests/configurator_test.py
@@ -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
@@ -111,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))
@@ -132,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)
diff --git a/letsencrypt_apache/tests/dvsni_test.py b/letsencrypt_apache/tests/dvsni_test.py
index 088ac9557..27e9b2584 100644
--- a/letsencrypt_apache/tests/dvsni_test.py
+++ b/letsencrypt_apache/tests/dvsni_test.py
@@ -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()
@@ -31,32 +29,6 @@ class DvsniPerformTest(util.ApacheTest):
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?
+# include_package_data=True,
+# File "/opt/python/2.6.9/lib/python2.6/distutils/core.py", line 152, in setup
+# dist.run_commands()
+# File "/opt/python/2.6.9/lib/python2.6/distutils/dist.py", line 975, in run_commands
+# self.run_command(cmd)
+# File "/opt/python/2.6.9/lib/python2.6/distutils/dist.py", line 995, in run_command
+# cmd_obj.run()
+# File "/home/travis/build/letsencrypt/lets-encrypt-preview/.tox/py26/lib/python2.6/site-packages/setuptools/command/test.py", line 142, in run
+# self.with_project_on_sys_path(self.run_tests)
+# File "/home/travis/build/letsencrypt/lets-encrypt-preview/.tox/py26/lib/python2.6/site-packages/setuptools/command/test.py", line 122, in with_project_on_sys_path
+# func()
+# File "/home/travis/build/letsencrypt/lets-encrypt-preview/.tox/py26/lib/python2.6/site-packages/setuptools/command/test.py", line 163, in run_tests
+# testRunner=self._resolve_as_ep(self.test_runner),
+# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 816, in __init__
+# self.parseArgs(argv)
+# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 843, in parseArgs
+# self.createTests()
+# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 849, in createTests
+# self.module)
+# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 613, in loadTestsFromNames
+# suites = [self.loadTestsFromName(name, module) for name in names]
+# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 587, in loadTestsFromName
+# return self.loadTestsFromModule(obj)
+# File "/home/travis/build/letsencrypt/lets-encrypt-preview/.tox/py26/lib/python2.6/site-packages/setuptools/command/test.py", line 37, in loadTestsFromModule
+# tests.append(self.loadTestsFromName(submodule))
+# File "/opt/python/2.6.9/lib/python2.6/unittest.py", line 584, in loadTestsFromName
+# parent, obj = obj, getattr(obj, part)
+#AttributeError: 'module' object has no attribute 'continuity_auth'
+
+# the above error happens because letsencrypt.continuity_auth cannot import M2Crypto:
+
+#>>> import M2Crypto
+#Traceback (most recent call last):
+# File "", line 1, in
+# File "/root/lets-encrypt-preview/venv/lib/python2.6/site-packages/M2Crypto-0.21.1-py2.6-linux-x86_64.egg/M2Crypto/__init__.py", line 22, in
+# import m2crypto
+# File "/root/lets-encrypt-preview/venv/lib/python2.6/site-packages/M2Crypto-0.21.1-py2.6-linux-x86_64.egg/M2Crypto/m2crypto.py", line 26, in
+# _m2crypto = swig_import_helper()
+# File "/root/lets-encrypt-preview/venv/lib/python2.6/site-packages/M2Crypto-0.21.1-py2.6-linux-x86_64.egg/M2Crypto/m2crypto.py", line 22, in swig_import_helper
+# _mod = imp.load_module('_m2crypto', fp, pathname, description)
+#ImportError: /root/lets-encrypt-preview/venv/lib/python2.6/site-packages/M2Crypto-0.21.1-py2.6-linux-x86_64.egg/M2Crypto/_m2crypto.so: undefined symbol: SSLv2_method
+
+# For more info see:
+
+# - https://github.com/martinpaljak/M2Crypto/commit/84977c532c2444c5487db57146d81bb68dd5431d
+# - http://stackoverflow.com/questions/10547332/install-m2crypto-on-a-virtualenv-without-system-packages
+# - http://stackoverflow.com/questions/8206546/undefined-symbol-sslv2-method
+
+# In short: Python has been built without SSLv2 support, and
+# github.com/M2Crypto/M2Crypto version doesn't contain necessary
+# patch, but it's the only one that has a patch for newer versions of
+# swig...
+
+# Problem seems not exists on Python 2.7. It's unlikely that the
+# target distribution has swig 3.0.5+ and doesn't have Python 2.7, so
+# this file should only be used in conjuction with Python 2.6.
diff --git a/requirements.txt b/requirements.txt
index 0f0223dab..972e87eaf 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,2 @@
# https://github.com/bw2/ConfigArgParse/issues/17
--e git+https://github.com/kuba/ConfigArgParse.git@python2.6#egg=ConfigArgParse
--e .
+git+https://github.com/kuba/ConfigArgParse.git@python2.6#egg=ConfigArgParse
diff --git a/setup.py b/setup.py
index 145b75a69..ef819f50b 100644
--- a/setup.py
+++ b/setup.py
@@ -28,11 +28,65 @@ meta = dict(re.findall(r"""__([a-z]+)__ = "([^"]+)""", read_file(init_fn)))
readme = read_file(os.path.join(here, 'README.rst'))
changes = read_file(os.path.join(here, 'CHANGES.rst'))
+# #358: acme, letsencrypt, letsencrypt_apache, letsencrypt_nginx, etc.
+# shall be distributed separately. Please make sure to keep the
+# dependecy lists up to date: this is being somewhat checked below
+# using an assert statement! Separate lists are helpful for OS package
+# maintainers. and will make the future migration a lot easier.
+acme_install_requires = [
+ 'argparse',
+ #'letsencrypt' # TODO: uses testdata vectors
+ 'mock',
+ 'pycrypto',
+ 'pyrfc3339',
+ 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304)
+ 'pyasn1', # urllib3 InsecurePlatformWarning (#304)
+ 'pytz',
+ 'requests',
+ 'werkzeug',
+ 'M2Crypto',
+]
+letsencrypt_install_requires = [
+ #'acme',
+ 'argparse',
+ 'ConfigArgParse',
+ 'configobj',
+ 'M2Crypto',
+ 'mock',
+ 'parsedatetime',
+ 'psutil>=2.1.0', # net_connections introduced in 2.1.0
+ 'pycrypto',
+ # https://pyopenssl.readthedocs.org/en/latest/api/crypto.html#OpenSSL.crypto.X509Req.get_extensions
+ 'PyOpenSSL>=0.15',
+ 'pyrfc3339',
+ 'python-augeas',
+ 'python2-pythondialog>=3.2.2rc1', # Debian squeeze support, cf. #280
+ 'pytz',
+ 'requests',
+ 'zope.component',
+ 'zope.interface',
+ 'M2Crypto',
+]
+letsencrypt_apache_install_requires = [
+ #'acme',
+ #'letsencrypt',
+ 'mock',
+ 'python-augeas',
+ 'zope.component',
+ 'zope.interface',
+]
+letsencrypt_nginx_install_requires = [
+ #'acme',
+ #'letsencrypt',
+ 'pyparsing>=1.5.5', # Python3 support; perhaps unnecessary?
+ 'mock',
+ 'zope.interface',
+]
+
install_requires = [
'argparse',
'ConfigArgParse',
'configobj',
- 'jsonschema',
'mock',
'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304)
'parsedatetime',
@@ -55,6 +109,13 @@ install_requires = [
'M2Crypto',
]
+assert set(install_requires) == set.union(*(set(ireq) for ireq in (
+ acme_install_requires,
+ letsencrypt_install_requires,
+ letsencrypt_apache_install_requires,
+ letsencrypt_nginx_install_requires
+))), "*install_requires don't match up!"
+
dev_extras = [
# Pin astroid==1.3.5, pylint==1.4.2 as a workaround for #289
'astroid==1.3.5',
@@ -120,6 +181,7 @@ setup(
'jws = letsencrypt.acme.jose.jws:CLI.run',
],
'letsencrypt.plugins': [
+ 'manual = letsencrypt.plugins.manual:ManualAuthenticator',
'standalone = letsencrypt.plugins.standalone.authenticator'
':StandaloneAuthenticator',
diff --git a/tox.cover.sh b/tox.cover.sh
index 80b6474d7..172d2e05b 100755
--- a/tox.cover.sh
+++ b/tox.cover.sh
@@ -15,8 +15,10 @@ cover () {
"$1" --cover-min-percentage="$2" "$1"
}
+rm -f .coverage # --cover-erase is off, make sure stats are correct
+
# don't use sequential composition (;), if letsencrypt_nginx returns
# 0, coveralls submit will be triggered (c.f. .travis.yml,
# after_success)
cover letsencrypt 95 && cover acme 100 && \
- cover letsencrypt_apache 78 && cover letsencrypt_nginx 96
+ cover letsencrypt_apache 76 && cover letsencrypt_nginx 96
diff --git a/tox.ini b/tox.ini
index 0367b5498..aed60f454 100644
--- a/tox.ini
+++ b/tox.ini
@@ -22,12 +22,12 @@ setenv =
[testenv:cover]
basepython = python2.7
commands =
- pip install -e .[testing]
+ pip install -r requirements.txt -e .[testing]
./tox.cover.sh
[testenv:lint]
# recent versions of pylint do not support Python 2.6 (#97, #187)
basepython = python2.7
commands =
- pip install -e .[dev]
+ pip install -r requirements.txt -e .[dev]
pylint --rcfile=.pylintrc letsencrypt acme letsencrypt_apache letsencrypt_nginx