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`. @@ -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