From 2b8f2cc1130976ce8d14cd69388c5d5c588feb39 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 28 Feb 2015 20:40:33 +0000 Subject: [PATCH 01/54] rm letsencrypt.py, chmod -x, remove sheebangs --- letsencrypt.py | 1 - letsencrypt/client/standalone_authenticator.py | 0 letsencrypt/scripts/main.py | 1 - setup.py | 1 - 4 files changed, 3 deletions(-) delete mode 120000 letsencrypt.py mode change 100755 => 100644 letsencrypt/client/standalone_authenticator.py mode change 100755 => 100644 letsencrypt/scripts/main.py mode change 100755 => 100644 setup.py diff --git a/letsencrypt.py b/letsencrypt.py deleted file mode 120000 index 77b93ee70..000000000 --- a/letsencrypt.py +++ /dev/null @@ -1 +0,0 @@ -letsencrypt/scripts/main.py \ No newline at end of file diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/standalone_authenticator.py old mode 100755 new mode 100644 diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py old mode 100755 new mode 100644 index 989e07f96..d1df56c09 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python """Parse command line and call the appropriate functions. .. todo:: Sanity check all input. Be sure to avoid shell code etc... diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 index 1fc643304..60d68f4a1 --- a/setup.py +++ b/setup.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python import codecs import os import re From 7d41cadc99532906253a98d8ef8867889af21380 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 16 Mar 2015 22:58:33 -0700 Subject: [PATCH 02/54] make _path_satisfied conform to API --- letsencrypt/client/auth_handler.py | 4 +++- letsencrypt/client/tests/auth_handler_test.py | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 4e3b5f68f..980a7d7cd 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -203,7 +203,9 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes def _path_satisfied(self, dom): """Returns whether a path has been completely satisfied.""" - return all(self.responses[dom][i] is not None for i in self.paths[dom]) + return all( + self.responses[dom][i] is not None and + self.responses[dom][i] is not False for i in self.paths[dom]) def _get_chall_pref(self, domain): """Return list of challenge preferences. diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index 91874dc0c..734f4cc65 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -481,7 +481,7 @@ class PathSatisfiedTest(unittest.TestCase): self.handler.responses[dom[0]] = [None, "sat", "sat2", None] self.handler.paths[dom[1]] = [0] - self.handler.responses[dom[1]] = ["sat", None, None, None] + self.handler.responses[dom[1]] = ["sat", None, None, False] self.handler.paths[dom[2]] = [0] self.handler.responses[dom[2]] = ["sat"] @@ -496,7 +496,7 @@ class PathSatisfiedTest(unittest.TestCase): self.assertTrue(self.handler._path_satisfied(dom[i])) def test_not_satisfied(self): - dom = ["0", "1", "2"] + dom = ["0", "1", "2", "3"] self.handler.paths[dom[0]] = [1, 2] self.handler.responses[dom[0]] = ["sat1", None, "sat2", None] @@ -506,6 +506,9 @@ class PathSatisfiedTest(unittest.TestCase): self.handler.paths[dom[2]] = [0] self.handler.responses[dom[2]] = [None] + self.handler.paths[dom[2]] = [0] + self.handler.responses[dom[2]] = [False] + for i in xrange(3): self.assertFalse(self.handler._path_satisfied(dom[i])) From b47cc8eb8f27b3f5f56b15afdcf425f195ac94c7 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 16 Mar 2015 23:00:31 -0700 Subject: [PATCH 03/54] fix _path_satisfied test --- letsencrypt/client/tests/auth_handler_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index 734f4cc65..e0169ab15 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -506,8 +506,8 @@ class PathSatisfiedTest(unittest.TestCase): self.handler.paths[dom[2]] = [0] self.handler.responses[dom[2]] = [None] - self.handler.paths[dom[2]] = [0] - self.handler.responses[dom[2]] = [False] + self.handler.paths[dom[3]] = [0] + self.handler.responses[dom[3]] = [False] for i in xrange(3): self.assertFalse(self.handler._path_satisfied(dom[i])) From b6203d512c0e607f5263bb20e90d0774e410ade3 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 17 Mar 2015 15:46:27 +0000 Subject: [PATCH 04/54] acme.jose: (Typed)JSONObjectWithFields, Field, JWA. --- .pylintrc | 9 +- docs/api/{acme.rst => acme/index.rst} | 9 - docs/api/acme/jose.rst | 60 +++ docs/conf.py | 2 +- letsencrypt/acme/challenges.py | 235 +++------- letsencrypt/acme/challenges_test.py | 101 ++--- letsencrypt/acme/errors.py | 9 +- letsencrypt/acme/interfaces.py | 69 --- letsencrypt/acme/jose/__init__.py | 68 +++ letsencrypt/acme/{jose.py => jose/b64.py} | 24 +- .../acme/{jose_test.py => jose/b64_test.py} | 10 +- letsencrypt/acme/jose/errors.py | 31 ++ letsencrypt/acme/jose/errors_test.py | 17 + letsencrypt/acme/jose/interfaces.py | 198 ++++++++ letsencrypt/acme/jose/interfaces_test.py | 106 +++++ letsencrypt/acme/jose/json_util.py | 426 ++++++++++++++++++ letsencrypt/acme/jose/json_util_test.py | 319 +++++++++++++ letsencrypt/acme/jose/jwa.py | 125 +++++ letsencrypt/acme/jose/jwa_test.py | 105 +++++ letsencrypt/acme/jose/jwk.py | 100 ++++ letsencrypt/acme/jose/jwk_test.py | 88 ++++ letsencrypt/acme/jose/testdata/README | 10 + letsencrypt/acme/jose/testdata/csr2.pem | 10 + .../acme/jose/testdata/rsa1024_key.pem | 15 + letsencrypt/acme/jose/testdata/rsa256_key.pem | 6 + letsencrypt/acme/jose/testdata/rsa512_key.pem | 9 + letsencrypt/acme/jose/util.py | 123 +++++ letsencrypt/acme/jose/util_test.py | 107 +++++ letsencrypt/acme/messages.py | 347 +++++--------- letsencrypt/acme/messages_test.py | 140 +++--- letsencrypt/acme/other.py | 93 +--- letsencrypt/acme/other_test.py | 68 +-- letsencrypt/acme/util.py | 238 ---------- letsencrypt/acme/util_test.py | 240 ---------- letsencrypt/client/achallenges.py | 6 +- letsencrypt/client/auth_handler.py | 3 +- letsencrypt/client/client.py | 4 +- letsencrypt/client/network.py | 4 +- letsencrypt/client/revoker.py | 4 +- letsencrypt/client/tests/acme_util.py | 4 +- letsencrypt/client/tests/auth_handler_test.py | 2 +- linter_plugin.py | 4 + 42 files changed, 2288 insertions(+), 1260 deletions(-) rename docs/api/{acme.rst => acme/index.rst} (79%) create mode 100644 docs/api/acme/jose.rst delete mode 100644 letsencrypt/acme/interfaces.py create mode 100644 letsencrypt/acme/jose/__init__.py rename letsencrypt/acme/{jose.py => jose/b64.py} (79%) rename letsencrypt/acme/{jose_test.py => jose/b64_test.py} (87%) create mode 100644 letsencrypt/acme/jose/errors.py create mode 100644 letsencrypt/acme/jose/errors_test.py create mode 100644 letsencrypt/acme/jose/interfaces.py create mode 100644 letsencrypt/acme/jose/interfaces_test.py create mode 100644 letsencrypt/acme/jose/json_util.py create mode 100644 letsencrypt/acme/jose/json_util_test.py create mode 100644 letsencrypt/acme/jose/jwa.py create mode 100644 letsencrypt/acme/jose/jwa_test.py create mode 100644 letsencrypt/acme/jose/jwk.py create mode 100644 letsencrypt/acme/jose/jwk_test.py create mode 100644 letsencrypt/acme/jose/testdata/README create mode 100644 letsencrypt/acme/jose/testdata/csr2.pem create mode 100644 letsencrypt/acme/jose/testdata/rsa1024_key.pem create mode 100644 letsencrypt/acme/jose/testdata/rsa256_key.pem create mode 100644 letsencrypt/acme/jose/testdata/rsa512_key.pem create mode 100644 letsencrypt/acme/jose/util.py create mode 100644 letsencrypt/acme/jose/util_test.py delete mode 100644 letsencrypt/acme/util_test.py diff --git a/.pylintrc b/.pylintrc index fe4d471ac..4835dbf74 100644 --- a/.pylintrc +++ b/.pylintrc @@ -38,7 +38,8 @@ load-plugins=linter_plugin # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=fixme,locally-disabled +disable=fixme,locally-disabled,abstract-class-not-used +# abstract-class-not-used cannot be disabled locally (at least in pylint 1.4.1) [REPORTS] @@ -148,10 +149,10 @@ module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ # Regular expression matching correct method names -method-rgx=[a-z_][a-z0-9_]{2,40}$ +method-rgx=[a-z_][a-z0-9_]{2,50}$ # Naming hint for method names -method-name-hint=[a-z_][a-z0-9_]{2,40}$ +method-name-hint=[a-z_][a-z0-9_]{2,50}$ # Regular expression which should only match function or class names that do # not require a docstring. @@ -311,7 +312,7 @@ max-branches=12 max-statements=50 # Maximum number of parents for a class (see R0901). -max-parents=7 +max-parents=12 # Maximum number of attributes for a class (see R0902). max-attributes=7 diff --git a/docs/api/acme.rst b/docs/api/acme/index.rst similarity index 79% rename from docs/api/acme.rst rename to docs/api/acme/index.rst index 04c33917a..89801611e 100644 --- a/docs/api/acme.rst +++ b/docs/api/acme/index.rst @@ -5,12 +5,6 @@ :members: -Interfaces ----------- - -.. automodule:: letsencrypt.acme.interfaces - :members: - Messages -------- @@ -46,6 +40,3 @@ Utilities .. automodule:: letsencrypt.acme.util :members: - -.. automodule:: letsencrypt.acme.jose - :members: diff --git a/docs/api/acme/jose.rst b/docs/api/acme/jose.rst new file mode 100644 index 000000000..50e86adaa --- /dev/null +++ b/docs/api/acme/jose.rst @@ -0,0 +1,60 @@ +:mod:`letsencrypt.acme.jose` +============================ + +.. contents:: + +.. automodule:: letsencrypt.acme.jose + :members: + + +JSON Web Algorithms +------------------- + +.. automodule:: letsencrypt.acme.jose.jwa + :members: + + +JSON Web Key +------------ + +.. automodule:: letsencrypt.acme.jose.jwk + :members: + + +Implementation details +---------------------- + + +Interfaces +~~~~~~~~~~ + +.. automodule:: letsencrypt.acme.jose.interfaces + :members: + + +Errors +~~~~~~ + +.. automodule:: letsencrypt.acme.jose.errors + :members: + + +JSON utilities +~~~~~~~~~~~~~~ + +.. automodule:: letsencrypt.acme.jose.json_util + :members: + + +JOSE Base64 +~~~~~~~~~~~ + +.. automodule:: letsencrypt.acme.jose.b64 + :members: + + +Utilities +~~~~~~~~~ + +.. automodule:: letsencrypt.acme.jose.util + :members: diff --git a/docs/conf.py b/docs/conf.py index 2a29b9dd3..6e2c484ca 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,7 +55,7 @@ extensions = [ ] autodoc_member_order = 'bysource' -autodoc_default_flags = ['show-inheritance'] +autodoc_default_flags = ['show-inheritance', 'private-members'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/letsencrypt/acme/challenges.py b/letsencrypt/acme/challenges.py index 4bbeb4cd2..9227fa1a1 100644 --- a/letsencrypt/acme/challenges.py +++ b/letsencrypt/acme/challenges.py @@ -7,13 +7,12 @@ import Crypto.Random from letsencrypt.acme import jose from letsencrypt.acme import other -from letsencrypt.acme import util # pylint: disable=too-few-public-methods -class Challenge(util.TypedACMEObject): +class Challenge(jose.TypedJSONObjectWithFields): # _fields_to_json | pylint: disable=abstract-method """ACME challenge.""" TYPES = {} @@ -27,40 +26,33 @@ class DVChallenge(Challenge): # pylint: disable=abstract-method """Domain validation challenges.""" -class ChallengeResponse(util.TypedACMEObject): +class ChallengeResponse(jose.TypedJSONObjectWithFields): # _fields_to_json | pylint: disable=abstract-method """ACME challenge response.""" TYPES = {} @classmethod - def from_valid_json(cls, jobj): + def from_json(cls, jobj): if jobj is None: # if the client chooses not to respond to a given # challenge, then the corresponding entry in the response # array is set to None (null) return None - return super(ChallengeResponse, cls).from_valid_json(jobj) + return super(ChallengeResponse, cls).from_json(jobj) @Challenge.register class SimpleHTTPS(DVChallenge): """ACME "simpleHttps" challenge.""" - acme_type = "simpleHttps" - __slots__ = ("token",) - - def _fields_to_json(self): - return {"token": self.token} - - @classmethod - def from_valid_json(cls, jobj): - return cls(token=jobj["token"]) + typ = "simpleHttps" + token = jose.Field("token") @ChallengeResponse.register class SimpleHTTPSResponse(ChallengeResponse): """ACME "simpleHttps" challenge response.""" - acme_type = "simpleHttps" - __slots__ = ("path",) + typ = "simpleHttps" + path = jose.Field("path") URI_TEMPLATE = "https://{domain}/.well-known/acme-challenge/{path}" """URI template for HTTPS server provisioned resource.""" @@ -76,13 +68,6 @@ class SimpleHTTPSResponse(ChallengeResponse): """ return self.URI_TEMPLATE.format(domain=domain, path=self.path) - def _fields_to_json(self): - return {"path": self.path} - - @classmethod - def from_valid_json(cls, jobj): - return cls(path=jobj["path"]) - @Challenge.register class DVSNI(DVChallenge): @@ -92,8 +77,7 @@ class DVSNI(DVChallenge): :ivar str nonce: Random data, **not** hex-encoded. """ - acme_type = "dvsni" - __slots__ = ("r", "nonce") + typ = "dvsni" DOMAIN_SUFFIX = ".acme.invalid" """Domain name suffix.""" @@ -104,22 +88,17 @@ class DVSNI(DVChallenge): NONCE_SIZE = 16 """Required size of the :attr:`nonce` in bytes.""" + r = jose.Field("r", encoder=jose.b64encode, # pylint: disable=invalid-name + decoder=functools.partial(jose.decode_b64jose, size=R_SIZE)) + nonce = jose.Field("nonce", encoder=binascii.hexlify, + decoder=functools.partial(functools.partial( + jose.decode_hex16, size=NONCE_SIZE))) + @property def nonce_domain(self): """Domain name used in SNI.""" return binascii.hexlify(self.nonce) + self.DOMAIN_SUFFIX - def _fields_to_json(self): - return { - "r": jose.b64encode(self.r), - "nonce": binascii.hexlify(self.nonce), - } - - @classmethod - def from_valid_json(cls, jobj): - return cls(r=util.decode_b64jose(jobj["r"], cls.R_SIZE), - nonce=util.decode_hex16(jobj["nonce"], cls.NONCE_SIZE)) - @ChallengeResponse.register class DVSNIResponse(ChallengeResponse): @@ -128,8 +107,7 @@ class DVSNIResponse(ChallengeResponse): :param str s: Random data, **not** base64-encoded. """ - acme_type = "dvsni" - __slots__ = ("s",) + typ = "dvsni" DOMAIN_SUFFIX = DVSNI.DOMAIN_SUFFIX """Domain name suffix.""" @@ -137,6 +115,9 @@ class DVSNIResponse(ChallengeResponse): S_SIZE = 32 """Required size of the :attr:`s` in bytes.""" + s = jose.Field("s", encoder=jose.b64encode, # pylint: disable=invalid-name + decoder=functools.partial(jose.decode_b64jose, size=S_SIZE)) + def __init__(self, s=None, *args, **kwargs): s = Crypto.Random.get_random_bytes(self.S_SIZE) if s is None else s super(DVSNIResponse, self).__init__(s=s, *args, **kwargs) @@ -157,90 +138,34 @@ class DVSNIResponse(ChallengeResponse): """Domain name for certificate subjectAltName.""" return self.z(chall) + self.DOMAIN_SUFFIX - def _fields_to_json(self): - return {"s": jose.b64encode(self.s)} - - @classmethod - def from_valid_json(cls, jobj): - return cls(s=util.decode_b64jose(jobj["s"], cls.S_SIZE)) - - @Challenge.register class RecoveryContact(ClientChallenge): """ACME "recoveryContact" challenge.""" - acme_type = "recoveryContact" - __slots__ = ("activation_url", "success_url", "contact") + typ = "recoveryContact" - def _fields_to_json(self): - fields = {} - add = functools.partial(_extend_if_not_none, fields) - add(self.activation_url, "activationURL") - add(self.success_url, "successURL") - add(self.contact, "contact") - return fields - - @classmethod - def from_valid_json(cls, jobj): - return cls(activation_url=jobj.get("activationURL"), - success_url=jobj.get("successURL"), - contact=jobj.get("contact")) + activation_url = jose.Field("activationURL", omitempty=True) + success_url = jose.Field("successURL", omitempty=True) + contact = jose.Field("contact", omitempty=True) @ChallengeResponse.register class RecoveryContactResponse(ChallengeResponse): """ACME "recoveryContact" challenge response.""" - acme_type = "recoveryContact" - __slots__ = ("token",) - - def _fields_to_json(self): - fields = {} - if self.token is not None: - fields["token"] = self.token - return fields - - @classmethod - def from_valid_json(cls, jobj): - return cls(token=jobj.get("token")) + typ = "recoveryContact" + token = jose.Field("token", omitempty=True) @Challenge.register class RecoveryToken(ClientChallenge): """ACME "recoveryToken" challenge.""" - acme_type = "recoveryToken" - __slots__ = () - - def _fields_to_json(self): - return {} - - @classmethod - def from_valid_json(cls, jobj): - return cls() + typ = "recoveryToken" @ChallengeResponse.register class RecoveryTokenResponse(ChallengeResponse): """ACME "recoveryToken" challenge response.""" - acme_type = "recoveryToken" - __slots__ = ("token",) - - def _fields_to_json(self): - fields = {} - if self.token is not None: - fields["token"] = self.token - return fields - - @classmethod - def from_valid_json(cls, jobj): - return cls(token=jobj.get("token")) - - -def _extend_if_not_empty(dikt, param, name): - if param: - dikt[name] = param - -def _extend_if_not_none(dikt, param, name): - if param is not None: - dikt[name] = param + typ = "recoveryToken" + token = jose.Field("token", omitempty=True) @Challenge.register @@ -251,57 +176,40 @@ class ProofOfPossession(ClientChallenge): :ivar hints: Various clues for the client (:class:`Hints`). """ - acme_type = "proofOfPossession" - __slots__ = ("alg", "nonce", "hints") + typ = "proofOfPossession" NONCE_SIZE = 16 - class Hints(util.ACMEObject): + class Hints(jose.JSONObjectWithFields): """Hints for "proofOfPossession" challenge. - :ivar jwk: JSON Web Key (:class:`letsencrypt.acme.other.JWK`) + :ivar jwk: JSON Web Key (:class:`letsencrypt.acme.jose.JWK`) :ivar list certs: List of :class:`M2Crypto.X509.X509` cetificates. """ - __slots__ = ( - "jwk", "cert_fingerprints", "certs", "subject_key_identifiers", - "serial_numbers", "issuers", "authorized_for") + jwk = jose.Field("jwk", decoder=jose.JWK.from_json) + cert_fingerprints = jose.Field( + "certFingerprints", omitempty=True, default=()) + certs = jose.Field("certs", omitempty=True, default=()) + subject_key_identifiers = jose.Field( + "subjectKeyIdentifiers", omitempty=True, default=()) + serial_numbers = jose.Field("serialNumbers", omitempty=True, default=()) + issuers = jose.Field("issuers", omitempty=True, default=()) + authorized_for = jose.Field("authorizedFor", omitempty=True, default=()) - def to_json(self): - fields = {"jwk": self.jwk} - add = functools.partial(_extend_if_not_empty, fields) - add(self.cert_fingerprints, "certFingerprints") - add([util.encode_cert(cert) for cert in self.certs], "certs") - add(self.subject_key_identifiers, "subjectKeyIdentifiers") - add(self.serial_numbers, "serialNumbers") - add(self.issuers, "issuers") - add(self.authorized_for, "authorizedFor") - return fields + @certs.encoder + def certs(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(jose.encode_cert(cert) for cert in value) - @classmethod - def from_valid_json(cls, jobj): - return cls( - jwk=other.JWK.from_valid_json(jobj["jwk"]), - cert_fingerprints=jobj.get("certFingerprints", []), - certs=[util.decode_cert(cert) - for cert in jobj.get("certs", [])], - subject_key_identifiers=jobj.get("subjectKeyIdentifiers", []), - serial_numbers=jobj.get("serialNumbers", []), - issuers=jobj.get("issuers", []), - authorized_for=jobj.get("authorizedFor", [])) + @certs.decoder + def certs(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(jose.decode_cert(cert) for cert in value) - def _fields_to_json(self): - return { - "alg": self.alg, - "nonce": jose.b64encode(self.nonce), - "hints": self.hints, - } - - @classmethod - def from_valid_json(cls, jobj): - return cls(alg=jobj["alg"], - nonce=util.decode_b64jose(jobj["nonce"], cls.NONCE_SIZE), - hints=cls.Hints.from_valid_json(jobj["hints"])) + alg = jose.Field("alg", decoder=jose.JWASignature.from_json) + nonce = jose.Field( + "nonce", encoder=jose.b64encode, decoder=functools.partial( + jose.decode_b64jose, size=NONCE_SIZE)) + hints = jose.Field("hints", decoder=Hints.from_json) @ChallengeResponse.register @@ -312,50 +220,29 @@ class ProofOfPossessionResponse(ChallengeResponse): :ivar signature: :class:`~letsencrypt.acme.other.Signature` of this message. """ - acme_type = "proofOfPossession" - __slots__ = ("nonce", "signature") + typ = "proofOfPossession" NONCE_SIZE = ProofOfPossession.NONCE_SIZE + nonce = jose.Field( + "nonce", encoder=jose.b64encode, decoder=functools.partial( + jose.decode_b64jose, size=NONCE_SIZE)) + signature = jose.Field("signature", decoder=other.Signature.from_json) + def verify(self): """Verify the challenge.""" + # self.signature is not Field | pylint: disable=no-member return self.signature.verify(self.nonce) - def _fields_to_json(self): - return { - "nonce": jose.b64encode(self.nonce), - "signature": self.signature, - } - - @classmethod - def from_valid_json(cls, jobj): - return cls(nonce=util.decode_b64jose(jobj["nonce"], cls.NONCE_SIZE), - signature=other.Signature.from_valid_json(jobj["signature"])) - @Challenge.register class DNS(DVChallenge): """ACME "dns" challenge.""" - acme_type = "dns" - __slots__ = ("token",) - - def _fields_to_json(self): - return {"token": self.token} - - @classmethod - def from_valid_json(cls, jobj): - return cls(token=jobj["token"]) + typ = "dns" + token = jose.Field("token") @ChallengeResponse.register class DNSResponse(ChallengeResponse): """ACME "dns" challenge response.""" - acme_type = "dns" - __slots__ = () - - def _fields_to_json(self): - return {} - - @classmethod - def from_valid_json(cls, jobj): - return cls() + typ = "dns" diff --git a/letsencrypt/acme/challenges_test.py b/letsencrypt/acme/challenges_test.py index 53b3ff3f1..081560fe1 100644 --- a/letsencrypt/acme/challenges_test.py +++ b/letsencrypt/acme/challenges_test.py @@ -6,13 +6,11 @@ import unittest import Crypto.PublicKey.RSA import M2Crypto -from letsencrypt.acme import errors from letsencrypt.acme import jose from letsencrypt.acme import other -from letsencrypt.acme import util -CERT = util.ComparableX509(M2Crypto.X509.load_cert( +CERT = jose.ComparableX509(M2Crypto.X509.load_cert( pkg_resources.resource_filename( 'letsencrypt.client.tests', 'testdata/cert.pem'))) KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( @@ -35,7 +33,7 @@ class SimpleHTTPSTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.challenges import SimpleHTTPS - self.assertEqual(self.msg, SimpleHTTPS.from_valid_json(self.jmsg)) + self.assertEqual(self.msg, SimpleHTTPS.from_json(self.jmsg)) class SimpleHTTPSResponseTest(unittest.TestCase): @@ -58,7 +56,7 @@ class SimpleHTTPSResponseTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.challenges import SimpleHTTPSResponse self.assertEqual( - self.msg, SimpleHTTPSResponse.from_valid_json(self.jmsg)) + self.msg, SimpleHTTPSResponse.from_json(self.jmsg)) class DVSNITest(unittest.TestCase): @@ -84,19 +82,19 @@ class DVSNITest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.challenges import DVSNI - self.assertEqual(self.msg, DVSNI.from_valid_json(self.jmsg)) + self.assertEqual(self.msg, DVSNI.from_json(self.jmsg)) def test_from_json_invalid_r_length(self): from letsencrypt.acme.challenges import DVSNI self.jmsg['r'] = 'abcd' self.assertRaises( - errors.ValidationError, DVSNI.from_valid_json, self.jmsg) + jose.DeserializationError, DVSNI.from_json, self.jmsg) def test_from_json_invalid_nonce_length(self): from letsencrypt.acme.challenges import DVSNI self.jmsg['nonce'] = 'abcd' self.assertRaises( - errors.ValidationError, DVSNI.from_valid_json, self.jmsg) + jose.DeserializationError, DVSNI.from_json, self.jmsg) class DVSNIResponseTest(unittest.TestCase): @@ -129,7 +127,7 @@ class DVSNIResponseTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.challenges import DVSNIResponse - self.assertEqual(self.msg, DVSNIResponse.from_valid_json(self.jmsg)) + self.assertEqual(self.msg, DVSNIResponse.from_json(self.jmsg)) class RecoveryContactTest(unittest.TestCase): @@ -152,7 +150,7 @@ class RecoveryContactTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.challenges import RecoveryContact - self.assertEqual(self.msg, RecoveryContact.from_valid_json(self.jmsg)) + self.assertEqual(self.msg, RecoveryContact.from_json(self.jmsg)) def test_json_without_optionals(self): del self.jmsg['activationURL'] @@ -160,7 +158,7 @@ class RecoveryContactTest(unittest.TestCase): del self.jmsg['contact'] from letsencrypt.acme.challenges import RecoveryContact - msg = RecoveryContact.from_valid_json(self.jmsg) + msg = RecoveryContact.from_json(self.jmsg) self.assertTrue(msg.activation_url is None) self.assertTrue(msg.success_url is None) @@ -181,13 +179,13 @@ class RecoveryContactResponseTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.challenges import RecoveryContactResponse self.assertEqual( - self.msg, RecoveryContactResponse.from_valid_json(self.jmsg)) + self.msg, RecoveryContactResponse.from_json(self.jmsg)) def test_json_without_optionals(self): del self.jmsg['token'] from letsencrypt.acme.challenges import RecoveryContactResponse - msg = RecoveryContactResponse.from_valid_json(self.jmsg) + msg = RecoveryContactResponse.from_json(self.jmsg) self.assertTrue(msg.token is None) self.assertEqual(self.jmsg, msg.to_json()) @@ -205,7 +203,7 @@ class RecoveryTokenTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.challenges import RecoveryToken - self.assertEqual(self.msg, RecoveryToken.from_valid_json(self.jmsg)) + self.assertEqual(self.msg, RecoveryToken.from_json(self.jmsg)) class RecoveryTokenResponseTest(unittest.TestCase): @@ -221,13 +219,13 @@ class RecoveryTokenResponseTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.challenges import RecoveryTokenResponse self.assertEqual( - self.msg, RecoveryTokenResponse.from_valid_json(self.jmsg)) + self.msg, RecoveryTokenResponse.from_json(self.jmsg)) def test_json_without_optionals(self): del self.jmsg['token'] from letsencrypt.acme.challenges import RecoveryTokenResponse - msg = RecoveryTokenResponse.from_valid_json(self.jmsg) + msg = RecoveryTokenResponse.from_json(self.jmsg) self.assertTrue(msg.token is None) self.assertEqual(self.jmsg, msg.to_json()) @@ -236,37 +234,37 @@ class RecoveryTokenResponseTest(unittest.TestCase): class ProofOfPossessionHintsTest(unittest.TestCase): def setUp(self): - jwk = other.JWK(key=KEY.publickey()) - issuers = [ + jwk = jose.JWKRSA(key=KEY.publickey()) + issuers = ( 'C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA', 'O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure', - ] - cert_fingerprints = [ + ) + cert_fingerprints = ( '93416768eb85e33adc4277f4c9acd63e7418fcfe', '16d95b7b63f1972b980b14c20291f3c0d1855d95', '48b46570d9fc6358108af43ad1649484def0debf', - ] - subject_key_identifiers = ['d0083162dcc4c8a23ecb8aecbd86120e56fd24e5'] - authorized_for = ['www.example.com', 'example.net'] - serial_numbers = [34234239832, 23993939911, 17] + ) + subject_key_identifiers = ('d0083162dcc4c8a23ecb8aecbd86120e56fd24e5') + authorized_for = ('www.example.com', 'example.net') + serial_numbers = (34234239832, 23993939911, 17) from letsencrypt.acme.challenges import ProofOfPossession self.msg = ProofOfPossession.Hints( jwk=jwk, issuers=issuers, cert_fingerprints=cert_fingerprints, - certs=[CERT], subject_key_identifiers=subject_key_identifiers, + certs=(CERT,), subject_key_identifiers=subject_key_identifiers, authorized_for=authorized_for, serial_numbers=serial_numbers) self.jmsg_to = { 'jwk': jwk, 'certFingerprints': cert_fingerprints, - 'certs': [jose.b64encode(CERT.as_der())], + 'certs': (jose.b64encode(CERT.as_der()),), 'subjectKeyIdentifiers': subject_key_identifiers, 'serialNumbers': serial_numbers, 'issuers': issuers, 'authorizedFor': authorized_for, } self.jmsg_from = self.jmsg_to.copy() - self.jmsg_from.update({'jwk': jwk.to_json()}) + self.jmsg_from.update({'jwk': jwk.fully_serialize()}) def test_to_json(self): self.assertEqual(self.jmsg_to, self.msg.to_json()) @@ -274,7 +272,7 @@ class ProofOfPossessionHintsTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.challenges import ProofOfPossession self.assertEqual( - self.msg, ProofOfPossession.Hints.from_valid_json(self.jmsg_from)) + self.msg, ProofOfPossession.Hints.from_json(self.jmsg_from)) def test_json_without_optionals(self): for optional in ['certFingerprints', 'certs', 'subjectKeyIdentifiers', @@ -283,14 +281,14 @@ class ProofOfPossessionHintsTest(unittest.TestCase): del self.jmsg_to[optional] from letsencrypt.acme.challenges import ProofOfPossession - msg = ProofOfPossession.Hints.from_valid_json(self.jmsg_from) + msg = ProofOfPossession.Hints.from_json(self.jmsg_from) - self.assertEqual(msg.cert_fingerprints, []) - self.assertEqual(msg.certs, []) - self.assertEqual(msg.subject_key_identifiers, []) - self.assertEqual(msg.serial_numbers, []) - self.assertEqual(msg.issuers, []) - self.assertEqual(msg.authorized_for, []) + self.assertEqual(msg.cert_fingerprints, ()) + self.assertEqual(msg.certs, ()) + self.assertEqual(msg.subject_key_identifiers, ()) + self.assertEqual(msg.serial_numbers, ()) + self.assertEqual(msg.issuers, ()) + self.assertEqual(msg.authorized_for, ()) self.assertEqual(self.jmsg_to, msg.to_json()) @@ -300,27 +298,25 @@ class ProofOfPossessionTest(unittest.TestCase): def setUp(self): from letsencrypt.acme.challenges import ProofOfPossession hints = ProofOfPossession.Hints( - jwk=other.JWK(key=KEY.publickey()), cert_fingerprints=[], certs=[], - serial_numbers=[], subject_key_identifiers=[], issuers=[], - authorized_for=[]) + jwk=jose.JWKRSA(key=KEY.publickey()), cert_fingerprints=(), + certs=(), serial_numbers=(), subject_key_identifiers=(), + issuers=(), authorized_for=()) self.msg = ProofOfPossession( - alg='RS256', nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ', - hints=hints) + alg=jose.RS256, hints=hints, + nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ') self.jmsg_to = { 'type': 'proofOfPossession', - 'alg': 'RS256', + 'alg': jose.RS256, 'nonce': 'eET5udtV7aoX8Xl8gYiZIA', 'hints': hints, } self.jmsg_from = { 'type': 'proofOfPossession', - 'alg': 'RS256', + 'alg': jose.RS256.fully_serialize(), 'nonce': 'eET5udtV7aoX8Xl8gYiZIA', - 'hints': hints.to_json(), + 'hints': hints.fully_serialize(), } - self.jmsg_from['hints']['jwk'] = self.jmsg_from[ - 'hints']['jwk'].to_json() def test_to_json(self): self.assertEqual(self.jmsg_to, self.msg.to_json()) @@ -328,7 +324,7 @@ class ProofOfPossessionTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.challenges import ProofOfPossession self.assertEqual( - self.msg, ProofOfPossession.from_valid_json(self.jmsg_from)) + self.msg, ProofOfPossession.from_json(self.jmsg_from)) class ProofOfPossessionResponseTest(unittest.TestCase): @@ -338,7 +334,7 @@ class ProofOfPossessionResponseTest(unittest.TestCase): # nonce and challenge nonce are the same, don't make the same # mistake here... signature = other.Signature( - alg='RS256', jwk=other.JWK(key=KEY.publickey()), + alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.publickey()), sig='\xa7\xc1\xe7\xe82o\xbc\xcd\xd0\x1e\x010#Z|\xaf\x15\x83' '\x94\x8f#\x9b\nQo(\x80\x15,\x08\xfcz\x1d\xfd\xfd.\xaap' '\xfa\x06\xd1\xa2f\x8d8X2>%d\xbd%\xe1T\xdd\xaa0\x18\xde' @@ -359,11 +355,8 @@ class ProofOfPossessionResponseTest(unittest.TestCase): self.jmsg_from = { 'type': 'proofOfPossession', 'nonce': 'eET5udtV7aoX8Xl8gYiZIA', - 'signature': signature.to_json(), + 'signature': signature.fully_serialize(), } - self.jmsg_from['signature']['jwk'] = self.jmsg_from[ - 'signature']['jwk'].to_json() - def test_verify(self): self.assertTrue(self.msg.verify()) @@ -374,7 +367,7 @@ class ProofOfPossessionResponseTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.challenges import ProofOfPossessionResponse self.assertEqual( - self.msg, ProofOfPossessionResponse.from_valid_json(self.jmsg_from)) + self.msg, ProofOfPossessionResponse.from_json(self.jmsg_from)) class DNSTest(unittest.TestCase): @@ -389,7 +382,7 @@ class DNSTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.challenges import DNS - self.assertEqual(self.msg, DNS.from_valid_json(self.jmsg)) + self.assertEqual(self.msg, DNS.from_json(self.jmsg)) class DNSResponseTest(unittest.TestCase): @@ -404,7 +397,7 @@ class DNSResponseTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.challenges import DNSResponse - self.assertEqual(self.msg, DNSResponse.from_valid_json(self.jmsg)) + self.assertEqual(self.msg, DNSResponse.from_json(self.jmsg)) if __name__ == '__main__': diff --git a/letsencrypt/acme/errors.py b/letsencrypt/acme/errors.py index c88881412..d69efda11 100644 --- a/letsencrypt/acme/errors.py +++ b/letsencrypt/acme/errors.py @@ -1,13 +1,8 @@ """ACME errors.""" +from letsencrypt.acme.jose import errors as jose_errors class Error(Exception): """Generic ACME error.""" -class ValidationError(Error): - """ACME object validation error.""" - -class UnrecognizedTypeError(ValidationError): - """Unrecognized ACME object type error.""" - -class SchemaValidationError(ValidationError): +class SchemaValidationError(jose_errors.DeserializationError): """JSON schema ACME object validation error.""" diff --git a/letsencrypt/acme/interfaces.py b/letsencrypt/acme/interfaces.py deleted file mode 100644 index e49956b4b..000000000 --- a/letsencrypt/acme/interfaces.py +++ /dev/null @@ -1,69 +0,0 @@ -"""ACME interfaces. - -Separation between :class:`IJSONSerializable` and :class:`IJSONDeserializable` -is necessary because we want to use ``cls.from_valid_json`` -classmethod on class and ``cls().to_json()`` on object, i.e. class -instance. ``cls.to_json()`` doesn't make much sense. Therefore a class -definition that requires both must call -``zope.interface.implements(IJSONSerializable)`` and -``zope.interface.classImplements(IJSONDeSerializable)`` (note the -difference btween `implements` and `classImplements`) and -:class:`letsencrypt.acme.util.ACMEObject` definition is an example. - -""" -import zope.interface - -# pylint: disable=no-self-argument,no-method-argument,no-init,inherit-non-class -# pylint: disable=too-few-public-methods - - -class IJSONSerializable(zope.interface.Interface): - # pylint: disable=too-few-public-methods - """JSON serializable object.""" - - def to_json(): - """Prepare JSON serializable object. - - Note, however, that this method might return other - :class:`letsencrypt.acme.interfaces.IJSONSerializable` - objects that haven't been serialized yet, which is fine as - long as :func:`letsencrypt.acme.util.dump_ijsonserializable` - is used. For example:: - - class Foo(object): - zope.interface.implements(IJSONSerializable) - - def to_json(self): - return 'foo' - - class Bar(object): - zope.interface.implements(IJSONSerializable) - - def to_json(self): - return [Foo(), Foo()] - - bar = Bar() - assert isinstance(bar.to_json()[0], Foo) - assert isinstance(bar.to_json()[1], Foo) - assert json.dumps( - bar, default=dump_ijsonserializable) == ['foo', 'foo'] - - :returns: JSON object ready to be serialized. - - """ - -class IJSONDeserializable(zope.interface.Interface): - """JSON deserializable class.""" - - def from_valid_json(jobj): - """Deserialize valid JSON object. - - :param jobj: JSON object validated against JSON schema (found in - schemata/ directory). - - :raises letsencrypt.acme.errors.ValidationError: It might be the - case that ``jobj`` validates against schema, but still is not - valid (e.g. unparseable X509 certificate, or wrong padding in - JOSE base64 encoded string). - - """ diff --git a/letsencrypt/acme/jose/__init__.py b/letsencrypt/acme/jose/__init__.py new file mode 100644 index 000000000..488775810 --- /dev/null +++ b/letsencrypt/acme/jose/__init__.py @@ -0,0 +1,68 @@ +"""Javascript Object Signing and Encryption (jose). + +This package is a Python implementation of the stadards developed by +IETF `Javascript Object Signing and Encryption (Active WG)`_, in +particular the following RFCs: + + - `JSON Web Algorithms (JWA)`_ + - `JSON Web Key (JWK)`_ + + +.. _`Javascript Object Signing and Encryption (Active WG)`: + https://tools.ietf.org/wg/jose/ + +.. _`JSON Web Algorithms (JWA)`: + https://datatracker.ietf.org/doc/draft-ietf-jose-json-web-algorithms/ + +.. _`JSON Web Key (JWK)`: + https://datatracker.ietf.org/doc/draft-ietf-jose-json-web-key/ + +""" +from letsencrypt.acme.jose.b64 import ( + b64decode, + b64encode, +) + +from letsencrypt.acme.jose.errors import ( + DeserializationError, + SerializationError, + Error, + UnrecognizedTypeError, +) + +from letsencrypt.acme.jose.interfaces import JSONDeSerializable + +from letsencrypt.acme.jose.json_util import ( + Field, + JSONObjectWithFields, + TypedJSONObjectWithFields, + decode_b64jose, + decode_cert, + decode_csr, + decode_hex16, + encode_cert, + encode_csr, +) + +from letsencrypt.acme.jose.jwa import ( + HS256, + HS384, + HS512, + JWASignature, + PS256, + PS384, + PS512, + RS256, + RS384, + RS512, +) + +from letsencrypt.acme.jose.jwk import ( + JWK, + JWKRSA, +) + +from letsencrypt.acme.jose.util import ( + ComparableX509, + ImmutableMap, +) diff --git a/letsencrypt/acme/jose.py b/letsencrypt/acme/jose/b64.py similarity index 79% rename from letsencrypt/acme/jose.py rename to letsencrypt/acme/jose/b64.py index 81c1abbf7..8f2d284ce 100644 --- a/letsencrypt/acme/jose.py +++ b/letsencrypt/acme/jose/b64.py @@ -1,13 +1,19 @@ -"""JOSE.""" -import base64 +"""JOSE Base64. -# https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-37#appendix-C -# -# Jose Base64: -# -# - URL-safe Base64 -# -# - padding stripped +`JOSE Base64`_ is defined as: + + - URL-safe Base64 + - padding stripped + + +.. _`JOSE Base64`: + https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-37#appendix-C + +.. warning:: Do NOT try to call this module "base64", + as it will "shadow" the standard library. + +""" +import base64 def b64encode(data): diff --git a/letsencrypt/acme/jose_test.py b/letsencrypt/acme/jose/b64_test.py similarity index 87% rename from letsencrypt/acme/jose_test.py rename to letsencrypt/acme/jose/b64_test.py index 42cf8051c..89ff27f5d 100644 --- a/letsencrypt/acme/jose_test.py +++ b/letsencrypt/acme/jose/b64_test.py @@ -1,4 +1,4 @@ -"""Tests for letsencrypt.acme.jose.""" +"""Tests for letsencrypt.acme.jose.b64.""" import unittest @@ -19,11 +19,11 @@ B64_URL_UNSAFE_EXAMPLES = { class B64EncodeTest(unittest.TestCase): - """Tests for letsencrypt.acme.jose.b64encode.""" + """Tests for letsencrypt.acme.jose.b64.b64encode.""" @classmethod def _call(cls, data): - from letsencrypt.acme.jose import b64encode + from letsencrypt.acme.jose.b64 import b64encode return b64encode(data) def test_unsafe_url(self): @@ -39,11 +39,11 @@ class B64EncodeTest(unittest.TestCase): class B64DecodeTest(unittest.TestCase): - """Tests for letsencrypt.acme.jose.b64decode.""" + """Tests for letsencrypt.acme.jose.b64.b64decode.""" @classmethod def _call(cls, data): - from letsencrypt.acme.jose import b64decode + from letsencrypt.acme.jose.b64 import b64decode return b64decode(data) def test_unsafe_url(self): diff --git a/letsencrypt/acme/jose/errors.py b/letsencrypt/acme/jose/errors.py new file mode 100644 index 000000000..74708c4a4 --- /dev/null +++ b/letsencrypt/acme/jose/errors.py @@ -0,0 +1,31 @@ +"""JOSE errors.""" + + +class Error(Exception): + """Generic JOSE Error.""" + + +class DeserializationError(Error): + """JSON deserialization error.""" + + +class SerializationError(Error): + """JSON serialization error.""" + + +class UnrecognizedTypeError(DeserializationError): + """Unrecognized type error. + + :ivar str typ: The unrecognized type of the JSON object. + :ivar jobj: Full JSON object. + + """ + + def __init__(self, typ, jobj): + self.typ = typ + self.jobj = jobj + super(UnrecognizedTypeError, self).__init__(str(self)) + + def __str__(self): + return '{0} was not recognized, full message: {1}'.format( + self.typ, self.jobj) diff --git a/letsencrypt/acme/jose/errors_test.py b/letsencrypt/acme/jose/errors_test.py new file mode 100644 index 000000000..dd6af6c1a --- /dev/null +++ b/letsencrypt/acme/jose/errors_test.py @@ -0,0 +1,17 @@ +"""Tests for letsencrypt.acme.jose.errors.""" +import unittest + + +class UnrecognizedTypeErrorTest(unittest.TestCase): + def setUp(self): + from letsencrypt.acme.jose.errors import UnrecognizedTypeError + self.error = UnrecognizedTypeError('foo', {'type': 'foo'}) + + def test_str(self): + self.assertEqual( + "foo was not recognized, full message: {'type': 'foo'}", + str(self.error)) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/jose/interfaces.py b/letsencrypt/acme/jose/interfaces.py new file mode 100644 index 000000000..446a5d2b0 --- /dev/null +++ b/letsencrypt/acme/jose/interfaces.py @@ -0,0 +1,198 @@ +"""JOSE interfaces.""" +import abc +import collections +import json + +from letsencrypt.acme.jose import util + +# pylint: disable=no-self-argument,no-method-argument,no-init,inherit-non-class +# pylint: disable=too-few-public-methods + + +class JSONDeSerializable(object): + # pylint: disable=too-few-public-methods + """Interface for (de)serializable JSON objects. + + Please recall, that standard Python library implements + :class:`json.JSONEncoder` and :class:`json.JSONDecoder` that perform + translations based on respective :ref:`conversion tables + ` that look pretty much like the one below (for + complete tables see relevant Python documentation): + + .. _conversion-table: + + ====== ====== + JSON Python + ====== ====== + object dict + ... ... + ====== ====== + + While the above **conversion table** is about translation of JSON + documents to/from the basic Python types only, + :class:`JSONDeSerializable` introduces the following two concepts: + + serialization + Turning an arbitrary Python object into Python object that can + be encoded into a JSON document. **Full serialization** produces + a Python object composed of only basic types as required by the + :ref:`conversion table `. + **Partial serialization** (acomplished by :meth:`to_json`) + produces a Python object that might also be built from other + :class:`JSONDeSerializable` objects. + + deserialization + Turning a decoded Python object (necessarily one of the basic + types as required by the :ref:`conversion table + `) into an arbitrary Python object. + + Serialization produces **serialized object** ("partially serialized + object" or "fully serialized object" for partial and full + serialization respectively) and deserialization produces + **deserialized object**, both usually denoted in the source code as + ``jobj``. + + Wording in the official Python documentation might be confusing + after reading the above, but in the light of those definitions, one + can view :meth:`json.JSONDecoder.decode` as decoder and + deserializer of basic types, :meth:`json.JSONEncoder.default` as + serializer of basic types, :meth:`json.JSONEncoder.encode` as + serializer and encoder of basic types. + + One could extend :mod:`json` to support arbitrary object + (de)serialization either by: + + - overriding :meth:`json.JSONDecoder.decode` and + :meth:`json.JSONEncoder.default` in subclasses + + - or passing ``object_hook`` argument (or ``object_hook_pairs``) + to :func:`json.load`/:func:`json.loads` or ``default`` argument + for :func:`json.dump`/:func:`json.dumps`. + + Interestingly, ``default`` is required to perform only partial + serialization, as :func:`json.dumps` applies ``default`` + recursively. This is the idea behind making :meth:`to_json` produce + only partial serialization, while providing custom :meth:`json_dumps` + that dumps with ``default`` set to :meth:`json_dump_default`. + + To make further documentation a bit more concrete, please, consider + the following imaginatory implementation example:: + + class Foo(JSONDeSerializable): + def to_json(self): + return 'foo' + + @classmethod + def from_json(cls, jobj): + return Foo() + + class Bar(JSONDeSerializable): + def to_json(self): + return [Foo(), Foo()] + + @classmethod + def from_json(cls, jobj): + return Bar() + + """ + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def to_json(self): # pragma: no cover + """Partially serialize. + + Following the example, **partial serialization** means the following:: + + assert isinstance(Bar().to_json()[0], Foo) + assert isinstance(Bar().to_json()[1], Foo) + + # in particular... + assert Bar().to_json() != ['foo', 'foo'] + + :raises letsencrypt.acme.jose.errors.SerializationError: + in case of any serialization error. + :returns: Partially serializable object. + + """ + raise NotImplementedError() + + def fully_serialize(self): + """Fully serialize. + + Again, following the example from before, **full serialization** + means the following:: + + assert Bar().fully_serialize() == ['foo', 'foo'] + + :raises letsencrypt.acme.jose.errors.SerializationError: + in case of any serialization error. + :returns: Fully serialized object. + + """ + partial = self.to_json() + try_serialize = (lambda x: x.fully_serialize() + if isinstance(x, JSONDeSerializable) else x) + if isinstance(partial, basestring): # strings are sequences + return partial + if isinstance(partial, collections.Sequence): + return [try_serialize(elem) for elem in partial] + elif isinstance(partial, collections.Mapping): + return dict([(try_serialize(key), try_serialize(value)) + for key, value in partial.iteritems()]) + else: + return partial + + @util.abstractclassmethod + def from_json(cls, unused_jobj): + """Deserialize a decoded JSON document. + + :param jobj: Python object, composed of only other basic data + types, as decoded from JSON document. Not necessarily + :class:`dict` (as decoded from "JSON object" document). + + :raises letsencrypt.acme.jose.errors.DeserializationError: + if decoding was unsuccessful, e.g. in case of unparseable + X509 certificate, or wrong padding in JOSE base64 encoded + string, etc. + + """ + # TypeError: Can't instantiate abstract class with + # abstract methods from_json, to_json + return cls() # pylint: disable=abstract-class-instantiated + + @classmethod + def json_loads(cls, json_string): + """Deserialize from JSON document string.""" + return cls.from_json(json.loads(json_string)) + + def json_dumps(self, **kwargs): + """Dump to JSON string using proper serializer. + + :returns: JSON document string. + :rtype: str + + """ + return json.dumps(self, default=self.json_dump_default, **kwargs) + + def json_dumps_pretty(self): + """Dump the object to pretty JSON document string.""" + return self.json_dumps(sort_keys=True, indent=4, separators=(',', ': ')) + + @classmethod + def json_dump_default(cls, python_object): + """Serialize Python object. + + This function is meant to be passed as ``default`` to + :func:`json.load` or :func:`json.loads`. They call + ``default(python_object)`` only for non-basic Python types, so + this function necessarily raises :class:`TypeError` if + ``python_object`` is not an instance of + :class:`IJSONSerializable`. + + Please read the class docstring for more information. + + """ + if isinstance(python_object, JSONDeSerializable): + return python_object.to_json() + else: # this branch is necessary, cannot just "return" + raise TypeError(repr(python_object) + ' is not JSON serializable') diff --git a/letsencrypt/acme/jose/interfaces_test.py b/letsencrypt/acme/jose/interfaces_test.py new file mode 100644 index 000000000..2e5606bce --- /dev/null +++ b/letsencrypt/acme/jose/interfaces_test.py @@ -0,0 +1,106 @@ +"""Tests for letsencrypt.acme.jose.interfaces.""" +import unittest + + +class JSONDeSerializableTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.acme.jose.interfaces import JSONDeSerializable + + # pylint: disable=missing-docstring,invalid-name + + class Basic(JSONDeSerializable): + def __init__(self, v): + self.v = v + + def to_json(self): + return self.v + + @classmethod + def from_json(cls, jobj): + return cls(jobj) + + class Sequence(JSONDeSerializable): + def __init__(self, x, y): + self.x = x + self.y = y + + def to_json(self): + return [self.x, self.y] + + @classmethod + def from_json(cls, jobj): + return cls( + Basic.from_json(jobj[0]), Basic.from_json(jobj[1])) + + class Mapping(JSONDeSerializable): + def __init__(self, x, y): + self.x = x + self.y = y + + def to_json(self): + return {self.x: self.y} + + @classmethod + def from_json(cls, jobj): + return cls(Basic.from_json(jobj.keys()[0]), + Basic.from_json(jobj.values()[0])) + + self.basic1 = Basic('foo1') + self.basic2 = Basic('foo2') + self.seq = Sequence(self.basic1, self.basic2) + self.mapping = Mapping(self.basic1, self.basic2) + + # pylint: disable=invalid-name + self.Basic = Basic + self.Sequence = Sequence + self.Mapping = Mapping + + def test_fully_serialize_sequence(self): + self.assertEqual(self.seq.fully_serialize(), ['foo1', 'foo2']) + + def test_fully_serialize_mapping(self): + self.assertEqual(self.mapping.fully_serialize(), {'foo1': 'foo2'}) + + def test_fully_serialize_other(self): + mock_value = object() + self.assertTrue(self.Basic(mock_value).fully_serialize() is mock_value) + + def test_from_json_not_implemented(self): + from letsencrypt.acme.jose.interfaces import JSONDeSerializable + self.assertRaises(TypeError, JSONDeSerializable.from_json, 'xxx') + + def test_json_loads(self): + seq = self.Sequence.json_loads('["foo1", "foo2"]') + self.assertTrue(isinstance(seq, self.Sequence)) + self.assertTrue(isinstance(seq.x, self.Basic)) + self.assertTrue(isinstance(seq.y, self.Basic)) + self.assertEqual(seq.x.v, 'foo1') + self.assertEqual(seq.y.v, 'foo2') + + def test_json_dumps(self): + self.assertEqual('["foo1", "foo2"]', self.seq.json_dumps()) + + def test_json_dumps_pretty(self): + self.assertEqual( + self.seq.json_dumps_pretty(), '[\n "foo1",\n "foo2"\n]') + + def test_json_dump_default(self): + from letsencrypt.acme.jose.interfaces import JSONDeSerializable + + self.assertEqual( + 'foo1', JSONDeSerializable.json_dump_default(self.basic1)) + + jobj = JSONDeSerializable.json_dump_default(self.seq) + self.assertEqual(len(jobj), 2) + self.assertTrue(jobj[0] is self.basic1) + self.assertTrue(jobj[1] is self.basic2) + + def test_json_dump_default_type_error(self): + from letsencrypt.acme.jose.interfaces import JSONDeSerializable + self.assertRaises( + TypeError, JSONDeSerializable.json_dump_default, object()) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/jose/json_util.py b/letsencrypt/acme/jose/json_util.py new file mode 100644 index 000000000..8abcf5e32 --- /dev/null +++ b/letsencrypt/acme/jose/json_util.py @@ -0,0 +1,426 @@ +"""JSON (de)serialization framework. + +The framework presented here is somewhat based on `Go's "json" package`_ +(especially the ``omitempty`` functionality). + +.. _`Go's "json" package`: http://golang.org/pkg/encoding/json/ + +""" +import abc +import binascii +import logging + +import M2Crypto + +from letsencrypt.acme.jose import b64 +from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import interfaces +from letsencrypt.acme.jose import util + + +class Field(object): + """JSON object field. + + :class:`Field` is meant to be used together with + :class:`JSONObjectWithFields`. + + ``encoder`` (``decoder``) is a callable that accepts a single + parameter, i.e. a value to be encoded (decoded), and returns the + serialized (deserialized) value. In case of errors it should raise + :class:`~letsencrypt.acme.jose.errors.SerializationError` + (:class:`~letsencrypt.acme.jose.errors.DeserializationError`). + + For greater flexibility, ``encoder2`` and ``decoder2`` accept two + parameters: the whole object ("``self``" in case of encoding, and + JSON serialized object ``jobj`` in case of decoding) and the value + to be encoded/decoded. + + Note, that ``decoder`` and ``decoder2`` should perform partial + serialization only. + + :ivar str json_name: Name of the field when encoded to JSON. + :ivar default: Default value (used when not present in JSON object). + :ivar bool omitempty: If ``True`` and the field value is empty, then + it will not be included in the serialized JSON object, and + ``default`` will be used for deserialization. Otherwise, if ``False``, + field is considered as required, value will always be included in the + serialized JSON objected, and it must also be present when + deserializing. + + """ + __slots__ = ('json_name', 'default', 'omitempty', + 'fdec', 'fenc', 'fdec2', 'fenc2') + + def __init__(self, json_name, default=None, omitempty=False, + decoder=None, encoder=None, decoder2=None, encoder2=None): + # pylint: disable=too-many-arguments + self.json_name = json_name + self.default = default + self.omitempty = omitempty + + self.fdec2 = decoder2 + self.fenc2 = encoder2 + self.fdec = self.default_decoder if decoder is None else decoder + self.fenc = self.default_encoder if encoder is None else encoder + + @classmethod + def _empty(cls, value): + """Is the provided value cosidered "empty" for this field? + + This is useful for subclasses that might want to override the + definition of being empty, e.g. for some more exotic data types. + + """ + return not value + + def omit(self, value): + """Omit the value in output?""" + return self._empty(value) and self.omitempty + + def _update_params(self, **kwargs): + current = dict(json_name=self.json_name, default=self.default, + omitempty=self.omitempty, + decoder=self.fdec, encoder=self.fenc, + decoder2=self.fdec2, encoder2=self.fenc2) + current.update(kwargs) + return type(self)(**current) # pylint: disable=star-args + + def decoder(self, fdec): + """Descriptor to change the decoder on JSON object field.""" + return self._update_params(decoder=fdec, decoder2=None) + + def encoder(self, fenc): + """Descriptor to change the encoder on JSON object field.""" + return self._update_params(encoder=fenc, encoder2=None) + + def decoder2(self, fdec2): + """Descriptor to change the decoder2 on JSON object field.""" + return self._update_params(decoder2=fdec2, decoder=None) + + def encoder2(self, fenc2): + """Descriptor to change the encoder2 on JSON object field.""" + return self._update_params(encoder2=fenc2, encoder=None) + + def decode(self, value, jobj=None): + """Decode a value, optionally with context JSON object.""" + if self.fdec2 is not None: + return self.fdec2(jobj, value) + return self.fdec(value) + + def encode(self, value, obj=None): + """Encode a value, optionally with context JSON object.""" + if self.fenc2 is not None: + return self.fenc2(obj, value) + return self.fenc(value) + + @classmethod + def default_decoder(cls, value): + """Default decoder. + + Recursively deserialize into immutable types ( + :class:`letsencrypt.acme.jose.util.frozendict` instead of + :func:`dict`, :func:`tuple` instead of :func:`list`). + + """ + # bases cases for different types returned by json.loads + if isinstance(value, list): + return tuple(cls.default_decoder(subvalue) for subvalue in value) + elif isinstance(value, dict): + return util.frozendict( + dict((cls.default_decoder(key), cls.default_decoder(value)) + for key, value in value.iteritems())) + else: # integer or string + return value + + @classmethod + def default_encoder(cls, value): + """Default (passthrough) encoder.""" + # field.to_json() is no good as encoder has to do partial + # serialization only + return value + + +class JSONObjectWithFieldsMeta(abc.ABCMeta): + """Metaclass for :class:`JSONObjectWithFields` and its subclasses. + + It makes sure that, for any class ``cls`` with ``__metaclass__`` + set to ``JSONObjectWithFieldsMeta``: + + 1. All fields (attributes of type :class:`Field`) in the class + definition are moved to the ``cls._fields`` dictionary, where + keys are field attribute names and values are fields themselves. + + 2. ``cls.__slots__`` is extended by all field attribute names + (i.e. not :attr:`Field.json_name`). + + In a consequence, for a field attribute name ``some_field``, + ``cls.some_field`` will be a slot descriptor and not an instance + of :class:`Field`. For example:: + + some_field = Field('someField', default=()) + + class Foo(object): + __metaclass__ = JSONObjectWithFieldsMeta + __slots__ = ('baz',) + some_field = some_field + + assert Foo.__slots__ == ('some_field', 'baz') + assert Foo.some_field is not Field + + assert Foo._fields.keys() == ['some_field'] + assert Foo._fields['some_field'] is some_field + + As an implementation note, this metaclass inherits from + :class:`abc.ABCMeta` (and not the usual :class:`type`) to mitigate + the metaclass conflict (:class:`ImmutableMap` and + :class:`JSONDeSerializable`, parents of :class:`JSONObjectWithFields`, + use :class:`abc.ABCMeta` as its metaclass). + + """ + + def __new__(mcs, name, bases, dikt): + fields = {} + for key, value in dikt.items(): # not iterkeys() (in-place edit!) + if isinstance(value, Field): + fields[key] = dikt.pop(key) + + dikt['__slots__'] = tuple( + list(dikt.get('__slots__', ())) + fields.keys()) + dikt['_fields'] = fields + + return abc.ABCMeta.__new__(mcs, name, bases, dikt) + + +class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable): + # pylint: disable=too-few-public-methods + """JSON object with fields. + + Example:: + + class Foo(JSONObjectWithFields): + bar = Field('Bar') + empty = Field('Empty', omitempty=True) + + @bar.encoder + def bar(value): + return value + 'bar' + + @bar.decoder + def bar(value): + if not value.endswith('bar'): + raise errors.DeserializationError('No bar suffix!') + return value[:-3] + + assert Foo(bar='baz').to_json() == {'Bar': 'bazbar'} + assert Foo.from_json({'Bar': 'bazbar'}) == Foo(bar='baz') + assert (Foo.from_json({'Bar': 'bazbar', 'Empty': '!'}) + == Foo(bar='baz', empty='!')) + assert Foo(bar='baz').bar == 'baz' + + """ + __metaclass__ = JSONObjectWithFieldsMeta + + @classmethod + def _defaults(cls): + """Get default fields values.""" + return dict([(slot, field.default) for slot, field + in cls._fields.iteritems() if field.omitempty]) + + def __init__(self, **kwargs): + # pylint: disable=star-args + super(JSONObjectWithFields, self).__init__( + **(dict(self._defaults(), **kwargs))) + + def fields_to_json(self): + """Serialize fields to JSON.""" + jobj = {} + for slot, field in self._fields.iteritems(): + value = getattr(self, slot) + + if field.omit(value): + logging.debug('Ommiting empty field "%s" (%s)', slot, value) + else: + try: + jobj[field.json_name] = field.encode(value, self) + except errors.SerializationError as error: + raise errors.SerializationError( + 'Could not encode {0} ({1}): {2}'.format( + slot, value, error)) + return jobj + + def to_json(self): + return self.fields_to_json() + + @classmethod + def _check_required(cls, jobj): + missing = set() + for _, field in cls._fields.iteritems(): + if not field.omitempty and field.json_name not in jobj: + missing.add(field.json_name) + + if missing: + raise errors.DeserializationError( + 'The following field are required: {0}'.format( + ','.join(missing))) + + @classmethod + def fields_from_json(cls, jobj): + """Deserialize fields from JSON.""" + cls._check_required(jobj) + fields = {} + for slot, field in cls._fields.iteritems(): + if field.json_name not in jobj and field.omitempty: + fields[slot] = field.default + else: + value = jobj[field.json_name] + try: + fields[slot] = field.decode(value, jobj) + except errors.DeserializationError as error: + raise errors.DeserializationError( + 'Could not decode {0!r} ({1!r}): {2}'.format( + slot, value, error)) + return fields + + @classmethod + def from_json(cls, jobj): + return cls(**cls.fields_from_json(jobj)) + + +def decode_b64jose(data, size=None, minimum=False): + """Decode JOSE Base-64 field. + + :param int size: Required length (after decoding). + :param bool minimum: If ``True``, then `size` will be treated as + minimum required length, as opposed to exact equality. + + """ + try: + decoded = b64.b64decode(data) + except TypeError as error: + raise errors.DeserializationError(error) + + if size is not None and ((not minimum and len(decoded) != size) + or (minimum and len(decoded) < size)): + raise errors.DeserializationError() + + return decoded + + +def decode_hex16(value, size=None, minimum=False): + """Decode hexlified field. + + :param int size: Required length (after decoding). + :param bool minimum: If ``True``, then `size` will be treated as + minimum required length, as opposed to exact equality. + + """ + if size is not None and ((not minimum and len(value) != size * 2) + or (minimum and len(value) < size * 2)): + raise errors.DeserializationError() + try: + return binascii.unhexlify(value) + except TypeError as error: + raise errors.DeserializationError(error) + +def encode_cert(cert): + """Encode certificate as JOSE Base-64 DER. + + :param cert: Certificate. + :type cert: :class:`letsencrypt.acme.jose.util.ComparableX509` + + """ + return b64.b64encode(cert.as_der()) + +def decode_cert(b64der): + """Decode JOSE Base-64 DER-encoded certificate.""" + try: + return util.ComparableX509(M2Crypto.X509.load_cert_der_string( + decode_b64jose(b64der))) + except M2Crypto.X509.X509Error as error: + raise errors.DeserializationError(error) + +def encode_csr(csr): + """Encode CSR as JOSE Base-64 DER.""" + return encode_cert(csr) + +def decode_csr(b64der): + """Decode JOSE Base-64 DER-encoded CSR.""" + try: + return util.ComparableX509(M2Crypto.X509.load_request_der_string( + decode_b64jose(b64der))) + except M2Crypto.X509.X509Error as error: + raise errors.DeserializationError(error) + + +class TypedJSONObjectWithFields(JSONObjectWithFields): + """JSON object with type.""" + + typ = NotImplemented + """Type of the object. Subclasses must override.""" + + type_field_name = "type" + """Field name used to distinguish different object types. + + Subclasses will probably have to override this. + + """ + + TYPES = NotImplemented + """Types registered for JSON deserialization""" + + @classmethod + def register(cls, type_cls, typ=None): + """Register class for JSON deserialization.""" + typ = type_cls.typ if typ is None else typ + cls.TYPES[typ] = type_cls + return type_cls + + @classmethod + def get_type_cls(cls, jobj): + """Get the registered class for ``jobj``.""" + if cls in cls.TYPES.itervalues(): + assert jobj[cls.type_field_name] + # cls is already registered type_cls, force to use it + # so that, e.g Revocation.from_json(jobj) fails if + # jobj["type"] != "revocation". + return cls + + if not isinstance(jobj, dict): + raise errors.DeserializationError( + "{0} is not a dictionary object".format(jobj)) + try: + typ = jobj[cls.type_field_name] + except KeyError: + raise errors.DeserializationError("missing type field") + + try: + type_cls = cls.TYPES[typ] + except KeyError: + raise errors.UnrecognizedTypeError(typ, jobj) + + return type_cls + + def to_json(self): + """Get JSON serializable object. + + :returns: Serializable JSON object representing ACME typed object. + :meth:`validate` will almost certianly not work, due to reasons + explained in :class:`letsencrypt.acme.interfaces.IJSONSerializable`. + :rtype: dict + + """ + jobj = self.fields_to_json() + jobj[self.type_field_name] = self.typ + return jobj + + @classmethod + def from_json(cls, jobj): + """Deserialize ACME object from valid JSON object. + + :raises letsencrypt.acme.errors.UnrecognizedTypeError: if type + of the ACME object has not been registered. + + """ + # make sure subclasses don't cause infinite recursive from_json calls + type_cls = cls.get_type_cls(jobj) + return type_cls(**type_cls.fields_from_json(jobj)) diff --git a/letsencrypt/acme/jose/json_util_test.py b/letsencrypt/acme/jose/json_util_test.py new file mode 100644 index 000000000..da548aaee --- /dev/null +++ b/letsencrypt/acme/jose/json_util_test.py @@ -0,0 +1,319 @@ +"""Tests for letsencrypt.acme.jose.json_util.""" +import os +import pkg_resources +import unittest + +import M2Crypto +import mock + +from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import interfaces +from letsencrypt.acme.jose import util + + +CERT = M2Crypto.X509.load_cert(pkg_resources.resource_filename( + 'letsencrypt.client.tests', os.path.join('testdata', 'cert.pem'))) +CSR = M2Crypto.X509.load_request(pkg_resources.resource_filename( + 'letsencrypt.client.tests', os.path.join('testdata', 'csr.pem'))) + + +class FieldTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.json_util.Field.""" + + def test_descriptors(self): + mock_jobj = mock.MagicMock() + mock_obj = mock.MagicMock() + mock_value = mock.MagicMock() + + # pylint: disable=missing-docstring + + def decoder(unused_value): + return 'd' + + def encoder(unused_value): + return 'e' + + def decoder2(jobj, unused_value): + self.assertTrue(jobj is mock_jobj) + return 'd2' + + def encoder2(obj, unused_value): + self.assertTrue(obj is mock_obj) + return 'e2' + + from letsencrypt.acme.jose.json_util import Field + field = Field('foo', decoder=decoder, encoder=encoder, + decoder2=decoder2, encoder2=encoder2) + + self.assertEqual('e2', field.encode(mock_value, mock_obj)) + self.assertEqual('d2', field.decode(mock_value, mock_jobj)) + + field = field.encoder(encoder) + self.assertEqual('e', field.encode(mock_value, mock_obj)) + self.assertEqual('d2', field.decode(mock_value, mock_jobj)) + + field = field.decoder(decoder) + self.assertEqual('e', field.encode(mock_value, mock_obj)) + self.assertEqual('d', field.decode(mock_value, mock_jobj)) + + field = field.encoder2(encoder2) + self.assertEqual('e2', field.encode(mock_value, mock_obj)) + self.assertEqual('d', field.decode(mock_value, mock_jobj)) + + field = field.decoder2(decoder2) + self.assertEqual('e2', field.encode(mock_value, mock_obj)) + self.assertEqual('d2', field.decode(mock_value, mock_jobj)) + + def test_default_encoder_is_partial(self): + class MockField(interfaces.JSONDeSerializable): + # pylint: disable=missing-docstring + def to_json(self): + return 'foo' + @classmethod + def from_json(cls, jobj): + pass + mock_field = MockField() + + from letsencrypt.acme.jose.json_util import Field + self.assertTrue(Field.default_encoder(mock_field) is mock_field) + # in particular... + self.assertNotEqual('foo', Field.default_encoder(mock_field)) + + def test_default_encoder_passthrough(self): + mock_value = mock.MagicMock() + from letsencrypt.acme.jose.json_util import Field + self.assertTrue(Field.default_encoder(mock_value) is mock_value) + + def test_default_decoder_list_to_tuple(self): + from letsencrypt.acme.jose.json_util import Field + self.assertEqual((1, 2, 3), Field.default_decoder([1, 2, 3])) + + def test_default_decoder_dict_to_frozendict(self): + from letsencrypt.acme.jose.json_util import Field + obj = Field.default_decoder({'x': 2}) + self.assertTrue(isinstance(obj, util.frozendict)) + self.assertEqual(obj, util.frozendict(x=2)) + + def test_default_decoder_passthrough(self): + mock_value = mock.MagicMock() + from letsencrypt.acme.jose.json_util import Field + self.assertTrue(Field.default_decoder(mock_value) is mock_value) + + +class JSONObjectWithFieldsTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.json_util.JSONObjectWithFields.""" + # pylint: disable=protected-access + + def setUp(self): + from letsencrypt.acme.jose.json_util import JSONObjectWithFields + from letsencrypt.acme.jose.json_util import Field + + class MockJSONObjectWithFields(JSONObjectWithFields): + # pylint: disable=invalid-name,missing-docstring,no-self-argument + # pylint: disable=too-few-public-methods + x = Field('x', omitempty=True, + encoder=(lambda x: x * 2), + decoder=(lambda x: x / 2)) + y = Field('y') + z = Field('Z') # on purpose uppercase + + @y.encoder + def y(value): + if value == 500: + raise errors.SerializationError() + return value + + @y.decoder + def y(value): + if value == 500: + raise errors.DeserializationError() + return value + + # pylint: disable=invalid-name + self.MockJSONObjectWithFields = MockJSONObjectWithFields + self.mock = MockJSONObjectWithFields(x=None, y=2, z=3) + + def test_init_defaults(self): + self.assertEqual(self.mock, self.MockJSONObjectWithFields(y=2, z=3)) + + def test_fields_to_json_omits_empty(self): + self.assertEqual(self.mock.fields_to_json(), {'y': 2, 'Z': 3}) + + def test_fields_from_json_fills_default_for_empty(self): + self.assertEqual( + {'x': None, 'y': 2, 'z': 3}, + self.MockJSONObjectWithFields.fields_from_json({'y': 2, 'Z': 3})) + + def test_fields_from_json_fails_on_missing(self): + self.assertRaises( + errors.DeserializationError, + self.MockJSONObjectWithFields.fields_from_json, {'y': 0}) + self.assertRaises( + errors.DeserializationError, + self.MockJSONObjectWithFields.fields_from_json, {'Z': 0}) + self.assertRaises( + errors.DeserializationError, + self.MockJSONObjectWithFields.fields_from_json, {'x': 0, 'y': 0}) + self.assertRaises( + errors.DeserializationError, + self.MockJSONObjectWithFields.fields_from_json, {'x': 0, 'Z': 0}) + + def test_fields_to_json_encoder(self): + self.assertEqual(self.MockJSONObjectWithFields(x=1, y=2, z=3).to_json(), + {'x': 2, 'y': 2, 'Z': 3}) + + def test_fields_from_json_decoder(self): + self.assertEqual( + {'x': 2, 'y': 2, 'z': 3}, + self.MockJSONObjectWithFields.fields_from_json( + {'x': 4, 'y': 2, 'Z': 3})) + + def test_fields_to_json_error_passthrough(self): + self.assertRaises( + errors.SerializationError, self.MockJSONObjectWithFields( + x=1, y=500, z=3).to_json) + + def test_fields_from_json_error_passthrough(self): + self.assertRaises( + errors.DeserializationError, + self.MockJSONObjectWithFields.from_json, + {'x': 4, 'y': 500, 'Z': 3}) + + +class DeEncodersTest(unittest.TestCase): + def setUp(self): + self.b64_cert = ( + 'MIIB3jCCAYigAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhM' + 'CVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKz' + 'ApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxF' + 'DASBgNVBAMMC2V4YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIx' + 'ODIyMzQ0NVowdzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRI' + 'wEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTW' + 'ljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4YW1wbGUuY29tMFwwD' + 'QYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR7R_drnBSQ_zfx1vQLHUbFLh1' + 'AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c-pVE6K-EdE_twuUCAwE' + 'AATANBgkqhkiG9w0BAQsFAANBAC24z0IdwIVKSlntksllvr6zJepBH5fMnd' + 'fk3XJp10jT6VE-14KNtjh02a56GoraAvJAT5_H67E8GvJ_ocNnB_o' + ) + self.b64_csr = ( + 'MIIBXTCCAQcCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2F' + 'uMRIwEAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECw' + 'wWVW5pdmVyc2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAwwLZXhhbXBsZS5jb' + '20wXDANBgkqhkiG9w0BAQEFAANLADBIAkEArHVztFHtH92ucFJD_N_HW9As' + 'dRsUuHUBBBDlHwNlRd3fp580rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3' + 'C5QIDAQABoCkwJwYJKoZIhvcNAQkOMRowGDAWBgNVHREEDzANggtleGFtcG' + 'xlLmNvbTANBgkqhkiG9w0BAQsFAANBAHJH_O6BtC9aGzEVCMGOZ7z9iIRHW' + 'Szr9x_bOzn7hLwsbXPAgO1QxEwL-X-4g20Gn9XBE1N9W6HCIEut2d8wACg' + ) + + def test_decode_b64_jose_padding_error(self): + from letsencrypt.acme.jose.json_util import decode_b64jose + self.assertRaises(errors.DeserializationError, decode_b64jose, 'x') + + def test_decode_b64_jose_size(self): + from letsencrypt.acme.jose.json_util import decode_b64jose + self.assertEqual('foo', decode_b64jose('Zm9v', size=3)) + self.assertRaises( + errors.DeserializationError, decode_b64jose, 'Zm9v', size=2) + self.assertRaises( + errors.DeserializationError, decode_b64jose, 'Zm9v', size=4) + + def test_decode_b64_jose_minimum_size(self): + from letsencrypt.acme.jose.json_util import decode_b64jose + self.assertEqual('foo', decode_b64jose('Zm9v', size=3, minimum=True)) + self.assertEqual('foo', decode_b64jose('Zm9v', size=2, minimum=True)) + self.assertRaises(errors.DeserializationError, decode_b64jose, + 'Zm9v', size=4, minimum=True) + + def test_decode_hex16(self): + from letsencrypt.acme.jose.json_util import decode_hex16 + self.assertEqual('foo', decode_hex16('666f6f')) + + def test_decode_hex16_minimum_size(self): + from letsencrypt.acme.jose.json_util import decode_hex16 + self.assertEqual('foo', decode_hex16('666f6f', size=3, minimum=True)) + self.assertEqual('foo', decode_hex16('666f6f', size=2, minimum=True)) + self.assertRaises(errors.DeserializationError, decode_hex16, + '666f6f', size=4, minimum=True) + + def test_decode_hex16_odd_length(self): + from letsencrypt.acme.jose.json_util import decode_hex16 + self.assertRaises(errors.DeserializationError, decode_hex16, 'x') + + def test_encode_cert(self): + from letsencrypt.acme.jose.json_util import encode_cert + self.assertEqual(self.b64_cert, encode_cert(CERT)) + + def test_decode_cert(self): + from letsencrypt.acme.jose.json_util import decode_cert + cert = decode_cert(self.b64_cert) + self.assertTrue(isinstance(cert, util.ComparableX509)) + self.assertEqual(cert, CERT) + self.assertRaises(errors.DeserializationError, decode_cert, '') + + def test_encode_csr(self): + from letsencrypt.acme.jose.json_util import encode_csr + self.assertEqual(self.b64_cert, encode_csr(CERT)) + + def test_decode_csr(self): + from letsencrypt.acme.jose.json_util import decode_csr + csr = decode_csr(self.b64_csr) + self.assertTrue(isinstance(csr, util.ComparableX509)) + self.assertEqual(csr, CSR) + self.assertRaises(errors.DeserializationError, decode_csr, '') + + +class TypedJSONObjectWithFieldsTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.acme.jose.json_util import TypedJSONObjectWithFields + + # pylint: disable=missing-docstring,abstract-method + # pylint: disable=too-few-public-methods + + class MockParentTypedJSONObjectWithFields(TypedJSONObjectWithFields): + TYPES = {} + type_field_name = 'type' + + @MockParentTypedJSONObjectWithFields.register + class MockTypedJSONObjectWithFields( + MockParentTypedJSONObjectWithFields): + typ = 'test' + __slots__ = ('foo',) + + @classmethod + def fields_from_json(cls, jobj): + return {'foo': jobj['foo']} + + def fields_to_json(self): + return {'foo': self.foo} + + self.parent_cls = MockParentTypedJSONObjectWithFields + self.msg = MockTypedJSONObjectWithFields(foo='bar') + + def test_to_json(self): + self.assertEqual(self.msg.to_json(), { + 'type': 'test', + 'foo': 'bar', + }) + + def test_from_json_non_dict_fails(self): + for value in [[], (), 5, "asd"]: # all possible input types + self.assertRaises( + errors.DeserializationError, self.parent_cls.from_json, value) + + def test_from_json_dict_no_type_fails(self): + self.assertRaises( + errors.DeserializationError, self.parent_cls.from_json, {}) + + def test_from_json_unknown_type_fails(self): + self.assertRaises(errors.UnrecognizedTypeError, + self.parent_cls.from_json, {'type': 'bar'}) + + def test_from_json_returns_obj(self): + self.assertEqual({'foo': 'bar'}, self.parent_cls.from_json( + {'type': 'test', 'foo': 'bar'})) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/jose/jwa.py b/letsencrypt/acme/jose/jwa.py new file mode 100644 index 000000000..99c9a8631 --- /dev/null +++ b/letsencrypt/acme/jose/jwa.py @@ -0,0 +1,125 @@ +"""JSON Web Algorithm. + +https://tools.ietf.org/html/draft-ietf-jose-json-web-algorithms-40 + +""" +import abc + +from Crypto.Hash import HMAC +from Crypto.Hash import SHA256 +from Crypto.Hash import SHA384 +from Crypto.Hash import SHA512 + +from Crypto.Signature import PKCS1_PSS +from Crypto.Signature import PKCS1_v1_5 + +from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import interfaces +from letsencrypt.acme.jose import jwk + + +class JWA(interfaces.JSONDeSerializable): # pylint: disable=abstract-method,too-few-public-methods + """JSON Web Algorithm.""" + + +class JWASignature(JWA): + """JSON Web Signature Algorithm.""" + SIGNATURES = {} + + def __init__(self, name): + self.name = name + + def __eq__(self, other): + return isinstance(other, JWASignature) and self.name == other.name + + @classmethod + def register(cls, signature_cls): + """Register class for JSON deserialization.""" + cls.SIGNATURES[signature_cls.name] = signature_cls + return signature_cls + + def to_json(self): + return self.name + + @classmethod + def from_json(cls, jobj): + return cls.SIGNATURES[jobj] + + @abc.abstractmethod + def sign(self, key, msg): # pragma: no cover + """Sign the ``msg`` using ``key``.""" + raise NotImplementedError() + + @abc.abstractmethod + def verify(self, key, msg, sig): # pragma: no cover + """Verify the ``msg` and ``sig`` using ``key``.""" + raise NotImplementedError() + + def __repr__(self): + return self.name + + +class _JWAHS(JWASignature): + + kty = jwk.JWKOct + + def __init__(self, name, digestmod): + super(_JWAHS, self).__init__(name) + self.digestmod = digestmod + + def sign(self, key, msg): + return HMAC.new(key, msg, self.digestmod).digest() + + def verify(self, key, msg, sig): + # TODO: use constant compare to mitigate timing attack? + return self.sign(key, msg) == sig + + +class _JWARS(JWASignature): + + kty = jwk.JWKRSA + + def __init__(self, name, padding, digestmod): + super(_JWARS, self).__init__(name) + self.padding = padding + self.digestmod = digestmod + + def sign(self, key, msg): + try: + return self.padding.new(key).sign(self.digestmod.new(msg)) + except TypeError as error: # key has no private part + raise errors.Error(error) + except (AttributeError, ValueError) as error: + # key is too small: ValueError for PS, AttributeError for RS + raise errors.Error(error) + + def verify(self, key, msg, sig): + return self.padding.new(key).verify(self.digestmod.new(msg), sig) + + +class _JWAES(JWASignature): # pylint: disable=abstract-class-not-used + + # TODO: implement ES signatures + + def sign(self, key, msg): # pragma: no cover + raise NotImplementedError() + + def verify(self, key, msg, sig): # pragma: no cover + raise NotImplementedError() + + +HS256 = JWASignature.register(_JWAHS('HS256', SHA256)) +HS384 = JWASignature.register(_JWAHS('HS384', SHA384)) +HS512 = JWASignature.register(_JWAHS('HS512', SHA512)) + +RS256 = JWASignature.register(_JWARS('RS256', PKCS1_v1_5, SHA256)) +RS384 = JWASignature.register(_JWARS('RS384', PKCS1_v1_5, SHA384)) +RS512 = JWASignature.register(_JWARS('RS512', PKCS1_v1_5, SHA512)) + +PS256 = JWASignature.register(_JWARS('PS256', PKCS1_PSS, SHA256)) +PS384 = JWASignature.register(_JWARS('PS384', PKCS1_PSS, SHA384)) +PS512 = JWASignature.register(_JWARS('PS512', PKCS1_PSS, SHA512)) + +ES256 = JWASignature.register(_JWAES('ES256')) +ES256 = JWASignature.register(_JWAES('ES384')) +ES256 = JWASignature.register(_JWAES('ES512')) diff --git a/letsencrypt/acme/jose/jwa_test.py b/letsencrypt/acme/jose/jwa_test.py new file mode 100644 index 000000000..712b50510 --- /dev/null +++ b/letsencrypt/acme/jose/jwa_test.py @@ -0,0 +1,105 @@ +"""Tests for letsencrypt.acme.jose.jwa.""" +import os +import pkg_resources +import unittest + +from Crypto.PublicKey import RSA + +from letsencrypt.acme.jose import errors + + +RSA256_KEY = RSA.importKey(pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa256_key.pem'))) +RSA512_KEY = RSA.importKey(pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa512_key.pem'))) +RSA1024_KEY = RSA.importKey(pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa1024_key.pem'))) + + +class JWASignatureTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jwa.JWASignature.""" + + def setUp(self): + from letsencrypt.acme.jose.jwa import JWASignature + + class MockSig(JWASignature): + # pylint: disable=missing-docstring,too-few-public-methods + # pylint: disable=abstract-class-not-used + def sign(self, key, msg): + raise NotImplementedError() + + def verify(self, key, msg, sig): + raise NotImplementedError() + + # pylint: disable=invalid-name + self.Sig1 = MockSig('Sig1') + self.Sig2 = MockSig('Sig2') + + def test_eq(self): + self.assertEqual(self.Sig1, self.Sig1) + self.assertNotEqual(self.Sig1, self.Sig2) + + def test_repr(self): + self.assertEqual('Sig1', repr(self.Sig1)) + self.assertEqual('Sig2', repr(self.Sig2)) + + def test_to_json(self): + self.assertEqual(self.Sig1.to_json(), 'Sig1') + self.assertEqual(self.Sig2.to_json(), 'Sig2') + + def test_from_json(self): + from letsencrypt.acme.jose.jwa import JWASignature + from letsencrypt.acme.jose.jwa import RS256 + self.assertTrue(JWASignature.from_json('RS256') is RS256) + + +class JWAHSTest(unittest.TestCase): # pylint: disable=too-few-public-methods + + def test_it(self): + from letsencrypt.acme.jose.jwa import HS256 + sig = ( + "\xceR\xea\xcd\x94\xab\xcf\xfb\xe0\xacA.:\x1a'\x08i\xe2\xc4" + "\r\x85+\x0e\x85\xaeUZ\xd4\xb3\x97zO" + ) + self.assertEqual(HS256.sign('some key', 'foo'), sig) + self.assertTrue(HS256.verify('some key', 'foo', sig) is True) + self.assertTrue(HS256.verify('some key', 'foo', sig + '!') is False) + + +class JWARSTest(unittest.TestCase): + + def test_sign_no_private_part(self): + from letsencrypt.acme.jose.jwa import RS256 + self.assertRaises( + errors.Error, RS256.sign, RSA512_KEY.publickey(), 'foo') + + def test_sign_key_too_small(self): + from letsencrypt.acme.jose.jwa import RS256 + from letsencrypt.acme.jose.jwa import PS256 + self.assertRaises(errors.Error, RS256.sign, RSA256_KEY, 'foo') + self.assertRaises(errors.Error, PS256.sign, RSA256_KEY, 'foo') + self.assertRaises(errors.Error, PS256.sign, RSA512_KEY, 'foo') + + def test_rs(self): + from letsencrypt.acme.jose.jwa import RS256 + sig = ( + '\x13\xf0\xe5\x83\x91\xd8~\x02q\xdf\xbdwX\x97\xecn\xe4UH\xb0' + '\xe1oq\x94\x9f\xf4\x0f\xcb0\x05\xa9\x0fs\xea\xf3\xe3\xe7' + '\x1cAh\xb3@\xb8\xe4UnG\xa0\xb2K\xac-\x1c1\x1c\xe9dw}2@\xa7' + '\xf0\xe8' + ) + self.assertEqual(RS256.sign(RSA512_KEY, 'foo'), sig) + # next tests guard that only True/False are return as oppossed + # to e.g. 1/0 + self.assertTrue(RS256.verify(RSA512_KEY, 'foo', sig) is True) + self.assertFalse(RS256.verify(RSA512_KEY, 'foo', sig + '!') is False) + + def test_ps(self): + from letsencrypt.acme.jose.jwa import PS256 + sig = PS256.sign(RSA1024_KEY, 'foo') + self.assertTrue(PS256.verify(RSA1024_KEY, 'foo', sig) is True) + self.assertTrue(PS256.verify(RSA1024_KEY, 'foo', sig + '!') is False) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/jose/jwk.py b/letsencrypt/acme/jose/jwk.py new file mode 100644 index 000000000..ccdef790e --- /dev/null +++ b/letsencrypt/acme/jose/jwk.py @@ -0,0 +1,100 @@ +"""JSON Web Key.""" +import binascii + +import Crypto.PublicKey.RSA + +from letsencrypt.acme.jose import b64 +from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import json_util + + +class JWK(json_util.TypedJSONObjectWithFields): + # pylint: disable=too-few-public-methods + """JSON Web Key.""" + type_field_name = 'kty' + TYPES = {} + + +@JWK.register +class JWKES(JWK): # pragma: no cover + # pylint: disable=abstract-class-not-used + """ES JWK. + + .. warning:: This is not yet implemented! + + """ + typ = 'ES' + + def fields_to_json(self): + raise NotImplementedError() + + @classmethod + def fields_from_json(cls, jobj): + raise NotImplementedError() + + +@JWK.register +class JWKOct(JWK): + """Symmetric JWK.""" + typ = 'oct' + __slots__ = ('key',) + + def fields_to_json(self): + # TODO: An "alg" member SHOULD also be present to identify the + # algorithm intended to be used with the key, unless the + # application uses another means or convention to determine + # the algorithm used. + return {'k': self.key} + + @classmethod + def fields_from_json(cls, jobj): + return cls(key=jobj['k']) + + +@JWK.register +class JWKRSA(JWK): + """RSA JWK.""" + typ = 'RSA' + __slots__ = ('key',) + + @classmethod + def _encode_param(cls, data): + def _leading_zeros(arg): + if len(arg) % 2: + return '0' + arg + return arg + + return b64.b64encode(binascii.unhexlify( + _leading_zeros(hex(data)[2:].rstrip('L')))) + + @classmethod + def _decode_param(cls, data): + try: + return long(binascii.hexlify(json_util.decode_b64jose(data)), 16) + except ValueError: # invalid literal for long() with base 16 + raise errors.DeserializationError() + + @classmethod + def load(cls, key): + """Load RSA key from string. + + :param str key: RSA key in string form. + + :returns: + :rtype: :class:`JWKRSA` + + """ + return cls(key=Crypto.PublicKey.RSA.importKey(key)) + + @classmethod + def fields_from_json(cls, jobj): + return cls(key=Crypto.PublicKey.RSA.construct( + (cls._decode_param(jobj['n']), + cls._decode_param(jobj['e'])))) + + def fields_to_json(self): + return { + 'n': self._encode_param(self.key.n), + 'e': self._encode_param(self.key.e), + } + diff --git a/letsencrypt/acme/jose/jwk_test.py b/letsencrypt/acme/jose/jwk_test.py new file mode 100644 index 000000000..7f851e65e --- /dev/null +++ b/letsencrypt/acme/jose/jwk_test.py @@ -0,0 +1,88 @@ +"""Tests for letsencrypt.acme.jose.jwk.""" +import os +import pkg_resources +import unittest + +from Crypto.PublicKey import RSA + +from letsencrypt.acme.jose import errors + + +RSA256_KEY = RSA.importKey(pkg_resources.resource_string( + 'letsencrypt.client.tests', os.path.join('testdata', 'rsa256_key.pem'))) +RSA512_KEY = RSA.importKey(pkg_resources.resource_string( + 'letsencrypt.client.tests', os.path.join('testdata', 'rsa512_key.pem'))) + + +class JWKOctTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jwk.JWKOct.""" + + def setUp(self): + from letsencrypt.acme.jose.jwk import JWKOct + self.jwk = JWKOct(key='foo') + self.jobj = {'kty': 'oct', 'k': 'foo'} + + def test_to_json(self): + self.assertEqual(self.jwk.to_json(), self.jobj) + + def test_from_json(self): + from letsencrypt.acme.jose.jwk import JWKOct + self.assertEqual(self.jwk, JWKOct.from_json(self.jobj)) + + +class JWKRSATest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jwk.JWKRSA.""" + + def setUp(self): + from letsencrypt.acme.jose.jwk import JWKRSA + self.jwk256 = JWKRSA(key=RSA256_KEY.publickey()) + self.jwk256json = { + 'kty': 'RSA', + 'e': 'AQAB', + 'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5' + '80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q', + } + self.jwk512 = JWKRSA(key=RSA512_KEY.publickey()) + self.jwk512json = { + 'kty': 'RSA', + 'e': 'AQAB', + 'n': '9LYRcVE3Nr-qleecEcX8JwVDnjeG1X7ucsCasuuZM0e09c' + 'mYuUzxIkMjO_9x4AVcvXXRXPEV-LzWWkfkTlzRMw', + } + + def test_equals(self): + self.assertEqual(self.jwk256, self.jwk256) + self.assertEqual(self.jwk512, self.jwk512) + + def test_not_equals(self): + self.assertNotEqual(self.jwk256, self.jwk512) + self.assertNotEqual(self.jwk512, self.jwk256) + + def test_load(self): + from letsencrypt.acme.jose.jwk import JWKRSA + self.assertEqual(JWKRSA(key=RSA256_KEY), JWKRSA.load( + pkg_resources.resource_string( + 'letsencrypt.client.tests', + os.path.join('testdata', 'rsa256_key.pem')))) + + def test_to_json(self): + self.assertEqual(self.jwk256.to_json(), self.jwk256json) + self.assertEqual(self.jwk512.to_json(), self.jwk512json) + + def test_from_json(self): + from letsencrypt.acme.jose.jwk import JWK + self.assertEqual(self.jwk256, JWK.from_json(self.jwk256json)) + # TODO: fix schemata to allow RSA512 + #self.assertEqual(self.jwk512, JWK.from_json(self.jwk512json)) + + def test_from_json_non_schema_errors(self): + # valid against schema, but still failing + from letsencrypt.acme.jose.jwk import JWK + self.assertRaises(errors.DeserializationError, JWK.from_json, + {'kty': 'RSA', 'e': 'AQAB', 'n': ''}) + self.assertRaises(errors.DeserializationError, JWK.from_json, + {'kty': 'RSA', 'e': 'AQAB', 'n': '1'}) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/jose/testdata/README b/letsencrypt/acme/jose/testdata/README new file mode 100644 index 000000000..9e0f2059b --- /dev/null +++ b/letsencrypt/acme/jose/testdata/README @@ -0,0 +1,10 @@ +The following commands has been used to generate test keys: + + for x in 256 512 1024; do openssl genrsa -out rsa${k}_key.pem $k; done + +and for the CSR: + + python -c from letsencrypt.client.crypto_util import make_csr; + import pkg_resources; open("csr2.pem", + "w").write(make_csr(pkg_resources.resource_string("letsencrypt.client.tests", + "testdata/rsa512_key.pem"), ["example2.com"])[0]) diff --git a/letsencrypt/acme/jose/testdata/csr2.pem b/letsencrypt/acme/jose/testdata/csr2.pem new file mode 100644 index 000000000..bd059a448 --- /dev/null +++ b/letsencrypt/acme/jose/testdata/csr2.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBXzCCAQkCAQAwejELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIw +EAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECwwWVW5pdmVy +c2l0eSBvZiBNaWNoaWdhbjEVMBMGA1UEAwwMZXhhbXBsZTIuY29tMFwwDQYJKoZI +hvcNAQEBBQADSwAwSAJBAPS2EXFRNza/qpXnnBHF/CcFQ543htV+7nLAmrLrmTNH +tPXJmLlM8SJDIzv/ceAFXL110VzxFfi81lpH5E5c0TMCAwEAAaAqMCgGCSqGSIb3 +DQEJDjEbMBkwFwYDVR0RBBAwDoIMZXhhbXBsZTIuY29tMA0GCSqGSIb3DQEBCwUA +A0EAwsdL4FLIgISKV4vXFmc6QTV7CjBiP4XmPFbeN+gMFdR7QcnRyyxSpXxB0v8Z +oqYboP5LGFt9zC6/9GyjcI9/IQ== +-----END CERTIFICATE REQUEST----- diff --git a/letsencrypt/acme/jose/testdata/rsa1024_key.pem b/letsencrypt/acme/jose/testdata/rsa1024_key.pem new file mode 100644 index 000000000..de5339d03 --- /dev/null +++ b/letsencrypt/acme/jose/testdata/rsa1024_key.pem @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQCaifO0fGlcAcjjcYEAPYcIL0Hf0KiNa9VCJ14RBdlZxLWRrVFi +4tdNCKSKqzKuKrrA8DWd4PHFD7UpLyRrPPXY6GozAyCT+5UFBClGJ2KyNKu+eU6/ +w4C1kpO4lpeXs8ptFc1lA9P8V1M/MkWzTE402nPNK0uUmZNo2tsFpGJUSQIDAQAB +AoGAFjLWxQhSAhtnhfRZ+XTdHrnbFpFchOQGgDgzdPKIJDLzefeRh0jacIBbUmgB +Ia+Vn/1hVkpnsEzvUvkonBbnoYWlYVQdpNTmrrew7SOztf8/1fYCsSkyDAvqGTXc +TmHM0PaLS+junoWcKOvQRVb0N3k+43OnBkr2b393Sx30qGECQQDNO2IBWOsYs8cB +CZQAZs8zBlbwBFZibqovqpLwXt9adBIsT9XzgagGbJMpzSuoHTUn3QqqJd9uHD8X +UTmmoh4NAkEAwMRauo+PlNj8W1lusflko52KL17+E5cmeOERM2xvhZNpO7d3/1ak +Co9dxVMicrYSh7jXbcXFNt3xNDTv6Dg8LQJAPuJwMDt/pc0IMCAwMkNOP7M0lkyt +73E7QmnAplhblcq0+tDnnLpgsr84BHnyY4u3iuRm7SW3pXSQPGPOB2nrTQJANBXa +HgakWSe4KEal7ljgpITwzZPxOwHgV1EZALgP+hu2l3gfaFLUyDWstKCd8jjYEOwU +6YhCnWyiu+SB3lEzkQJBAJapJpfypFyY8kQNYlYILLBcPu5fmy3QUZKHJ4L3rIVJ +c2UTLMeBBgGFHT04CtWntmjwzSv+V6lwiCxKXsIUySc= +-----END RSA PRIVATE KEY----- diff --git a/letsencrypt/acme/jose/testdata/rsa256_key.pem b/letsencrypt/acme/jose/testdata/rsa256_key.pem new file mode 100644 index 000000000..659274d1d --- /dev/null +++ b/letsencrypt/acme/jose/testdata/rsa256_key.pem @@ -0,0 +1,6 @@ +-----BEGIN RSA PRIVATE KEY----- +MIGrAgEAAiEAm2Fylv+Uz7trgTW8EBHP3FQSMeZs2GNQ6VRo1sIVJEkCAwEAAQIh +AJT0BA/xD01dFCAXzSNyj9nfSZa3NpqzJZZn/eOm7vghAhEAzUVNZn4lLLBD1R6N +E8TKNQIRAMHHyn3O5JeY36lwKwkUlEUCEAliRauN0L0+QZuYjfJ9aJECEGx4dru3 +rTPCyighdqWNlHUCEQCiLjlwSRtWgmMBudCkVjzt +-----END RSA PRIVATE KEY----- diff --git a/letsencrypt/acme/jose/testdata/rsa512_key.pem b/letsencrypt/acme/jose/testdata/rsa512_key.pem new file mode 100644 index 000000000..77627dcd2 --- /dev/null +++ b/letsencrypt/acme/jose/testdata/rsa512_key.pem @@ -0,0 +1,9 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIBPAIBAAJBAJ+afYCLq33YTZumktV+Lg9LpDGKCv/DxuXkXc40mFc+82KbsyR8 +5/S2pmNQrKzL/jLmenQT67PnRaVNqEsvj2UCAwEAAQJAJWqOaYhU19fRud+/JJXE +LonJIGQAWB2Jj3OOGj1ySWF13ahdsQxXKQoVSUTnrvLJkrQwXwNFck9BnZ1otL6u +MQIhAMw84RdsMJufn7bCMe6ppVukoGKRbjxE8ar/tBGUOOFrAiEAyA2ysBdOXF8z +FweoKED11siyJbHuuavMaoL1ZI779m8CIQCWuf8seA3PbBhEmkCbb9u3LGGpHMcL +952aoydTKd5ojQIhAKuSA+O9uTjDdL+Vk4QiYjS4nwBxH3ohewkGE4sQjcsFAiEA +uToAFyz5vUHnk8vME9y+ZIHSePBqckGwXVOfgIbATF0= +-----END RSA PRIVATE KEY----- diff --git a/letsencrypt/acme/jose/util.py b/letsencrypt/acme/jose/util.py new file mode 100644 index 000000000..5f516884f --- /dev/null +++ b/letsencrypt/acme/jose/util.py @@ -0,0 +1,123 @@ +"""JOSE utilities.""" +import collections + + +class abstractclassmethod(classmethod): + # pylint: disable=invalid-name,too-few-public-methods + """Descriptor for an abstract classmethod. + + It augments the :mod:`abc` framework with an abstract + classmethod. This is implemented as :class:`abc.abstractclassmethod` + in the standard Python library starting with version 3.2. + + This particular implementation, allegedly based on Python 3.3 source + code, is stolen from + http://stackoverflow.com/questions/11217878/python-2-7-combine-abc-abstractmethod-and-classmethod. + + """ + __isabstractmethod__ = True + + def __init__(self, target): + target.__isabstractmethod__ = True + super(abstractclassmethod, self).__init__(target) + + +class ComparableX509(object): # pylint: disable=too-few-public-methods + """Wrapper for M2Crypto.X509.* objects that supports __eq__. + + Wraps around: + + - :class:`M2Crypto.X509.X509` + - :class:`M2Crypto.X509.Request` + + """ + def __init__(self, wrapped): + self._wrapped = wrapped + + def __getattr__(self, name): + return getattr(self._wrapped, name) + + def __eq__(self, other): + return self.as_der() == other.as_der() + + +class ImmutableMap(collections.Mapping, collections.Hashable): + # pylint: disable=too-few-public-methods + """Immutable key to value mapping with attribute access.""" + + __slots__ = () + """Must be overriden in subclasses.""" + + def __init__(self, **kwargs): + if set(kwargs) != set(self.__slots__): + raise TypeError( + '__init__() takes exactly the following arguments: {0} ' + '({1} given)'.format(', '.join(self.__slots__), + ', '.join(kwargs) if kwargs else 'none')) + for slot in self.__slots__: + object.__setattr__(self, slot, kwargs.pop(slot)) + + def __getitem__(self, key): + try: + return getattr(self, key) + except AttributeError: + raise KeyError(key) + + def __iter__(self): + return iter(self.__slots__) + + def __len__(self): + return len(self.__slots__) + + def __hash__(self): + return hash(tuple(getattr(self, slot) for slot in self.__slots__)) + + def __setattr__(self, name, value): + raise AttributeError("can't set attribute") + + def __repr__(self): + return '{0}({1})'.format(self.__class__.__name__, ', '.join( + '{0}={1!r}'.format(key, value) for key, value in self.iteritems())) + + +class frozendict(collections.Mapping, collections.Hashable): + # pylint: disable=invalid-name,too-few-public-methods + """Frozen dictionary.""" + __slots__ = ('_items', '_keys') + + def __init__(self, *args, **kwargs): + if kwargs and not args: + items = dict(kwargs) + elif len(args) == 1 and isinstance(args[0], collections.Mapping): + items = args[0] + else: + raise TypeError() + # TODO: support generators/iterators + + object.__setattr__(self, '_items', items) + object.__setattr__(self, '_keys', tuple(sorted(items.iterkeys()))) + + def __getitem__(self, key): + return self._items[key] + + def __iter__(self): + return iter(self._keys) + + def __len__(self): + return len(self._items) + + def __hash__(self): + return hash(tuple((key, value) for key, value in self.items())) + + def __getattr__(self, name): + try: + return self._items[name] + except KeyError: + raise AttributeError(name) + + def __setattr__(self, name, value): + raise AttributeError("can't set attribute") + + def __repr__(self): + return 'frozendict({0})'.format(', '.join( + '{0}={1!r}'.format(key, value) for key, value in self.iteritems())) diff --git a/letsencrypt/acme/jose/util_test.py b/letsencrypt/acme/jose/util_test.py new file mode 100644 index 000000000..671b45472 --- /dev/null +++ b/letsencrypt/acme/jose/util_test.py @@ -0,0 +1,107 @@ +"""Tests for letsencrypt.acme.jose.util.""" +import functools +import unittest + + +class ImmutableMapTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.util.ImmutableMap.""" + + def setUp(self): + # pylint: disable=invalid-name,too-few-public-methods + # pylint: disable=missing-docstring + from letsencrypt.acme.jose.util import ImmutableMap + + class A(ImmutableMap): + __slots__ = ('x', 'y') + + class B(ImmutableMap): + __slots__ = ('x', 'y') + + self.A = A + self.B = B + + self.a1 = self.A(x=1, y=2) + self.a1_swap = self.A(y=2, x=1) + self.a2 = self.A(x=3, y=4) + self.b = self.B(x=1, y=2) + + def test_get_missing_item_raises_key_error(self): + self.assertRaises(KeyError, self.a1.__getitem__, 'z') + + def test_order_of_args_does_not_matter(self): + self.assertEqual(self.a1, self.a1_swap) + + def test_type_error_on_missing(self): + self.assertRaises(TypeError, self.A, x=1) + self.assertRaises(TypeError, self.A, y=2) + + def test_type_error_on_unrecognized(self): + self.assertRaises(TypeError, self.A, x=1, z=2) + self.assertRaises(TypeError, self.A, x=1, y=2, z=3) + + def test_get_attr(self): + self.assertEqual(1, self.a1.x) + self.assertEqual(2, self.a1.y) + self.assertEqual(1, self.a1_swap.x) + self.assertEqual(2, self.a1_swap.y) + + def test_set_attr_raises_attribute_error(self): + self.assertRaises( + AttributeError, functools.partial(self.a1.__setattr__, 'x'), 10) + + def test_equal(self): + self.assertEqual(self.a1, self.a1) + self.assertEqual(self.a2, self.a2) + self.assertNotEqual(self.a1, self.a2) + + def test_hash(self): + self.assertEqual(hash((1, 2)), hash(self.a1)) + + def test_unhashable(self): + self.assertRaises(TypeError, self.A(x=1, y={}).__hash__) + + def test_repr(self): + self.assertEqual('A(x=1, y=2)', repr(self.a1)) + self.assertEqual('A(x=1, y=2)', repr(self.a1_swap)) + self.assertEqual('B(x=1, y=2)', repr(self.b)) + self.assertEqual("B(x='foo', y='bar')", repr(self.B(x='foo', y='bar'))) + + +class frozendictTest(unittest.TestCase): # pylint: disable=invalid-name + """Tests for letsencrypt.acme.jose.util.frozendict.""" + + def setUp(self): + from letsencrypt.acme.jose.util import frozendict + self.fdict = frozendict(x=1, y='2') + + def test_init_dict(self): + from letsencrypt.acme.jose.util import frozendict + self.assertEqual(self.fdict, frozendict({'x': 1, 'y': '2'})) + + def test_init_other_raises_type_error(self): + from letsencrypt.acme.jose.util import frozendict + # specifically fail for generators... + self.assertRaises(TypeError, frozendict, {'a': 'b'}.iteritems()) + + def test_len(self): + self.assertEqual(2, len(self.fdict)) + + def test_hash(self): + self.assertEqual(1278944519403861804, hash(self.fdict)) + + def test_getattr_proxy(self): + self.assertEqual(1, self.fdict.x) + self.assertEqual('2', self.fdict.y) + + def test_getattr_raises_attribute_error(self): + self.assertRaises(AttributeError, self.fdict.__getattr__, 'z') + + def test_setattr_immutable(self): + self.assertRaises(AttributeError, self.fdict.__setattr__, 'z', 3) + + def test_repr(self): + self.assertEqual("frozendict(x=1, y='2')", repr(self.fdict)) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py index 64f7a0350..b3a376c8e 100644 --- a/letsencrypt/acme/messages.py +++ b/letsencrypt/acme/messages.py @@ -1,6 +1,4 @@ """ACME protocol messages.""" -import json - import jsonschema from letsencrypt.acme import challenges @@ -9,11 +7,16 @@ from letsencrypt.acme import jose from letsencrypt.acme import other from letsencrypt.acme import util +from letsencrypt.acme.jose import errors as jose_errors +from letsencrypt.acme.jose import json_util -class Message(util.TypedACMEObject): + +class Message(jose.TypedJSONObjectWithFields): # _fields_to_json | pylint: disable=abstract-method + # pylint: disable=too-few-public-methods """ACME message.""" TYPES = {} + type_field_name = "type" schema = NotImplemented """JSON schema the object is tested against in :meth:`from_json`. @@ -24,28 +27,6 @@ class Message(util.TypedACMEObject): """ - @classmethod - def get_msg_cls(cls, jobj): - """Get the registered class for ``jobj``.""" - if cls in cls.TYPES.itervalues(): - # cls is already registered Message type, force to use it - # so that, e.g Revocation.from_json(jobj) fails if - # jobj["type"] != "revocation". - return cls - - if not isinstance(jobj, dict): - raise errors.ValidationError( - "{0} is not a dictionary object".format(jobj)) - try: - msg_type = jobj["type"] - except KeyError: - raise errors.ValidationError("missing type field") - - try: - return cls.TYPES[msg_type] - except KeyError: - raise errors.UnrecognizedTypeError(msg_type) - @classmethod def from_json(cls, jobj): """Deserialize from (possibly invalid) JSON object. @@ -57,35 +38,21 @@ class Message(util.TypedACMEObject): :raises letsencrypt.acme.errors.SchemaValidationError: if the input JSON object could not be validated against JSON schema specified in :attr:`schema`. - :raises letsencrypt.acme.errors.ValidationError: for any other generic - error in decoding. + :raises letsencrypt.acme.jose.errors.DeserializationError: for any + other generic error in decoding. :returns: instance of the class """ - msg_cls = cls.get_msg_cls(jobj) + msg_cls = cls.get_type_cls(jobj) + # TODO: is that schema testing still relevant? try: jsonschema.validate(jobj, msg_cls.schema) except jsonschema.ValidationError as error: raise errors.SchemaValidationError(error) - return cls.from_valid_json(jobj) - - @classmethod - def json_loads(cls, json_string): - """Load JSON string.""" - return cls.from_json(json.loads(json_string)) - - def json_dumps(self, *args, **kwargs): - """Dump to JSON string using proper serializer. - - :returns: JSON serialized string. - :rtype: str - - """ - return json.dumps( - self, *args, default=util.dump_ijsonserializable, **kwargs) + return super(Message, cls).from_json(jobj) @Message.register # pylint: disable=too-few-public-methods @@ -96,86 +63,55 @@ class Challenge(Message): :ivar list challenges: List of :class:`~letsencrypt.acme.challenges.Challenge` objects. - """ - acme_type = "challenge" - schema = util.load_schema(acme_type) - __slots__ = ("session_id", "nonce", "challenges", "combinations") + .. todo:: + 1. can challenges contain two challenges of the same type? + 2. can challenges contain duplicates? + 3. check "combinations" indices are in valid range + 4. turn "combinations" elements into sets? + 5. turn "combinations" into set? - def _fields_to_json(self): - fields = { - "sessionID": self.session_id, - "nonce": jose.b64encode(self.nonce), - "challenges": self.challenges, - } - if self.combinations: - fields["combinations"] = self.combinations - return fields + """ + typ = "challenge" + schema = util.load_schema(typ) + + session_id = jose.Field("sessionID") + nonce = jose.Field("nonce", encoder=jose.b64encode, + decoder=jose.decode_b64jose) + challenges = jose.Field("challenges") + combinations = jose.Field("combinations", omitempty=True, default=()) + + @challenges.decoder + def challenges(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(challenges.Challenge.from_json(chall) for chall in value) @property def resolved_combinations(self): """Combinations with challenges instead of indices.""" - return [[self.challenges[idx] for idx in combo] - for combo in self.combinations] - - @classmethod - def from_valid_json(cls, jobj): - # TODO: can challenges contain two challenges of the same type? - # TODO: can challenges contain duplicates? - # TODO: check "combinations" indices are in valid range - # TODO: turn "combinations" elements into sets? - # TODO: turn "combinations" into set? - return cls(session_id=jobj["sessionID"], - nonce=util.decode_b64jose(jobj["nonce"]), - challenges=[challenges.Challenge.from_valid_json(chall) - for chall in jobj["challenges"]], - combinations=jobj.get("combinations", [])) + return tuple(tuple(self.challenges[idx] for idx in combo) + for combo in self.combinations) @Message.register # pylint: disable=too-few-public-methods class ChallengeRequest(Message): """ACME "challengeRequest" message.""" - acme_type = "challengeRequest" - schema = util.load_schema(acme_type) - __slots__ = ("identifier",) - - def _fields_to_json(self): - return { - "identifier": self.identifier, - } - - @classmethod - def from_valid_json(cls, jobj): - return cls(identifier=jobj["identifier"]) + typ = "challengeRequest" + schema = util.load_schema(typ) + identifier = jose.Field("identifier") @Message.register # pylint: disable=too-few-public-methods class Authorization(Message): """ACME "authorization" message. - :ivar jwk: :class:`letsencrypt.acme.other.JWK` + :ivar jwk: :class:`letsencrypt.acme.jose.JWK` """ - acme_type = "authorization" - schema = util.load_schema(acme_type) - __slots__ = ("recovery_token", "identifier", "jwk") + typ = "authorization" + schema = util.load_schema(typ) - def _fields_to_json(self): - fields = {} - if self.recovery_token is not None: - fields["recoveryToken"] = self.recovery_token - if self.identifier is not None: - fields["identifier"] = self.identifier - if self.jwk is not None: - fields["jwk"] = self.jwk - return fields - - @classmethod - def from_valid_json(cls, jobj): - jwk = jobj.get("jwk") - if jwk is not None: - jwk = other.JWK.from_valid_json(jwk) - return cls(recovery_token=jobj.get("recoveryToken"), - identifier=jobj.get("identifier"), jwk=jwk) + recovery_token = jose.Field("recoveryToken", omitempty=True) + identifier = jose.Field("identifier", omitempty=True) + jwk = jose.Field("jwk", decoder=jose.JWK.from_json, omitempty=True) @Message.register @@ -189,9 +125,20 @@ class AuthorizationRequest(Message): :ivar signature: Signature (:class:`letsencrypt.acme.other.Signature`). """ - acme_type = "authorizationRequest" - schema = util.load_schema(acme_type) - __slots__ = ("session_id", "nonce", "responses", "signature", "contact") + typ = "authorizationRequest" + schema = util.load_schema(typ) + + session_id = jose.Field("sessionID") + nonce = jose.Field("nonce", encoder=jose.b64encode, + decoder=jose.decode_b64jose) + responses = jose.Field("responses") + signature = jose.Field("signature", decoder=other.Signature.from_json) + contact = jose.Field("contact", omitempty=True, default=()) + + @responses.decoder + def responses(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(challenges.ChallengeResponse.from_json(chall) + for chall in value) @classmethod def create(cls, name, key, sig_nonce=None, **kwargs): @@ -213,7 +160,7 @@ class AuthorizationRequest(Message): signature = other.Signature.from_msg( name + kwargs["nonce"], key, sig_nonce) return cls( - signature=signature, contact=kwargs.pop("contact", []), **kwargs) + signature=signature, contact=kwargs.pop("contact", ()), **kwargs) def verify(self, name): """Verify signature. @@ -228,29 +175,9 @@ class AuthorizationRequest(Message): :rtype: bool """ + # self.signature is not Field | pylint: disable=no-member return self.signature.verify(name + self.nonce) - def _fields_to_json(self): - fields = { - "sessionID": self.session_id, - "nonce": jose.b64encode(self.nonce), - "responses": self.responses, - "signature": self.signature, - } - if self.contact: - fields["contact"] = self.contact - return fields - - @classmethod - def from_valid_json(cls, jobj): - return cls( - session_id=jobj["sessionID"], - nonce=util.decode_b64jose(jobj["nonce"]), - responses=[challenges.ChallengeResponse.from_valid_json(chall) - for chall in jobj["responses"]], - signature=other.Signature.from_valid_json(jobj["signature"]), - contact=jobj.get("contact", [])) - @Message.register # pylint: disable=too-few-public-methods class Certificate(Message): @@ -263,24 +190,21 @@ class Certificate(Message): wrapped in :class:`letsencrypt.acme.util.ComparableX509` ). """ - acme_type = "certificate" - schema = util.load_schema(acme_type) - __slots__ = ("certificate", "chain", "refresh") + typ = "certificate" + schema = util.load_schema(typ) - def _fields_to_json(self): - fields = {"certificate": util.encode_cert(self.certificate)} - if self.chain: - fields["chain"] = [util.encode_cert(cert) for cert in self.chain] - if self.refresh is not None: - fields["refresh"] = self.refresh - return fields + certificate = jose.Field("certificate", encoder=jose.encode_cert, + decoder=jose.decode_cert) + chain = jose.Field("chain", omitempty=True, default=()) + refresh = jose.Field("refresh", omitempty=True) - @classmethod - def from_valid_json(cls, jobj): - return cls(certificate=util.decode_cert(jobj["certificate"]), - chain=[util.decode_cert(cert) for cert in - jobj.get("chain", [])], - refresh=jobj.get("refresh")) + @chain.decoder + def chain(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(jose.decode_cert(cert) for cert in value) + + @chain.encoder + def chain(value): # pylint: disable=missing-docstring,no-self-argument + return tuple(jose.encode_cert(cert) for cert in value) @Message.register @@ -292,9 +216,26 @@ class CertificateRequest(Message): :ivar signature: Signature (:class:`letsencrypt.acme.other.Signature`). """ - acme_type = "certificateRequest" - schema = util.load_schema(acme_type) - __slots__ = ("csr", "signature") + typ = "certificateRequest" + schema = util.load_schema(typ) + + csr = jose.Field("csr", encoder=jose.encode_csr, + decoder=jose.decode_csr) + signature = jose.Field("signature") + + @classmethod + def fields_from_json(cls, jobj): + cls._check_required(jobj) + + sig = other.Signature.from_json( + jobj[cls._fields['signature'].json_name]) + if not sig.verify(json_util.decode_b64jose(jobj["csr"])): + raise jose_errors.DeserializationError( + 'Signature could not be verified') + # verify signature before decoding principle! + csr = jose.decode_csr(jobj[cls._fields['csr'].json_name]) + + return {'signature': sig, 'csr': csr} @classmethod def create(cls, key, sig_nonce=None, **kwargs): @@ -324,49 +265,32 @@ class CertificateRequest(Message): :rtype: bool """ + # self.signature is not Field | pylint: disable=no-member return self.signature.verify(self.csr.as_der()) - def _fields_to_json(self): - return { - "csr": util.encode_csr(self.csr), - "signature": self.signature, - } - - @classmethod - def from_valid_json(cls, jobj): - return cls(csr=util.decode_csr(jobj["csr"]), - signature=other.Signature.from_valid_json(jobj["signature"])) - @Message.register # pylint: disable=too-few-public-methods class Defer(Message): """ACME "defer" message.""" - acme_type = "defer" - schema = util.load_schema(acme_type) - __slots__ = ("token", "interval", "message") + typ = "defer" + schema = util.load_schema(typ) - def _fields_to_json(self): - fields = {"token": self.token} - if self.interval is not None: - fields["interval"] = self.interval - if self.message is not None: - fields["message"] = self.message - return fields - - @classmethod - def from_valid_json(cls, jobj): - return cls(token=jobj["token"], interval=jobj.get("interval"), - message=jobj.get("message")) + token = jose.Field("token") + interval = jose.Field("interval", omitempty=True) + message = jose.Field("message", omitempty=True) @Message.register # pylint: disable=too-few-public-methods class Error(Message): """ACME "error" message.""" - acme_type = "error" - schema = util.load_schema(acme_type) - __slots__ = ("error", "message", "more_info") + typ = "error" + schema = util.load_schema(typ) - CODES = { + error = jose.Field("error") + message = jose.Field("message", omitempty=True) + more_info = jose.Field("moreInfo", omitempty=True) + + MESSAGE_CODES = { "malformed": "The request message was malformed", "unauthorized": "The client lacks sufficient authorization", "serverInternal": "The server experienced an internal error", @@ -375,33 +299,12 @@ class Error(Message): "badCSR": "The CSR is unacceptable (e.g., due to a short key)", } - def _fields_to_json(self): - fields = {"error": self.error} - if self.message is not None: - fields["message"] = self.message - if self.more_info is not None: - fields["moreInfo"] = self.more_info - return fields - - @classmethod - def from_valid_json(cls, jobj): - return cls(error=jobj["error"], message=jobj.get("message"), - more_info=jobj.get("moreInfo")) - @Message.register # pylint: disable=too-few-public-methods class Revocation(Message): """ACME "revocation" message.""" - acme_type = "revocation" - schema = util.load_schema(acme_type) - __slots__ = () - - def _fields_to_json(self): - return {} - - @classmethod - def from_valid_json(cls, jobj): - return cls() + typ = "revocation" + schema = util.load_schema(typ) @Message.register @@ -413,9 +316,12 @@ class RevocationRequest(Message): :ivar signature: Signature (:class:`letsencrypt.acme.other.Signature`). """ - acme_type = "revocationRequest" - schema = util.load_schema(acme_type) - __slots__ = ("certificate", "signature") + typ = "revocationRequest" + schema = util.load_schema(typ) + + certificate = jose.Field("certificate", decoder=jose.decode_cert, + encoder=jose.encode_cert) + signature = jose.Field("signature", decoder=other.Signature.from_json) @classmethod def create(cls, key, sig_nonce=None, **kwargs): @@ -445,34 +351,13 @@ class RevocationRequest(Message): :rtype: bool """ + # self.signature is not Field | pylint: disable=no-member return self.signature.verify(self.certificate.as_der()) - def _fields_to_json(self): - return { - "certificate": util.encode_cert(self.certificate), - "signature": self.signature, - } - - @classmethod - def from_valid_json(cls, jobj): - return cls(certificate=util.decode_cert(jobj["certificate"]), - signature=other.Signature.from_valid_json(jobj["signature"])) - @Message.register # pylint: disable=too-few-public-methods class StatusRequest(Message): - """ACME "statusRequest" message. - - :ivar unicode token: Token provided in ACME "defer" message. - - """ - acme_type = "statusRequest" - schema = util.load_schema(acme_type) - __slots__ = ("token",) - - def _fields_to_json(self): - return {"token": self.token} - - @classmethod - def from_valid_json(cls, jobj): - return cls(token=jobj["token"]) + """ACME "statusRequest" message.""" + typ = "statusRequest" + schema = util.load_schema(typ) + token = jose.Field("token") diff --git a/letsencrypt/acme/messages_test.py b/letsencrypt/acme/messages_test.py index ab9f4f64e..ba1962b40 100644 --- a/letsencrypt/acme/messages_test.py +++ b/letsencrypt/acme/messages_test.py @@ -9,17 +9,21 @@ from letsencrypt.acme import challenges from letsencrypt.acme import errors from letsencrypt.acme import jose from letsencrypt.acme import other -from letsencrypt.acme import util + +from letsencrypt.acme.jose import errors as jose_errors KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( 'letsencrypt.client.tests', 'testdata/rsa256_key.pem')) -CERT = util.ComparableX509(M2Crypto.X509.load_cert( +CERT = jose.ComparableX509(M2Crypto.X509.load_cert( pkg_resources.resource_filename( 'letsencrypt.client.tests', 'testdata/cert.pem'))) -CSR = util.ComparableX509(M2Crypto.X509.load_request( +CSR = jose.ComparableX509(M2Crypto.X509.load_request( pkg_resources.resource_filename( 'letsencrypt.client.tests', 'testdata/csr.pem'))) +CSR2 = jose.ComparableX509(M2Crypto.X509.load_request( + pkg_resources.resource_filename( + 'letsencrypt.acme.jose', 'testdata/csr2.pem'))) class MessageTest(unittest.TestCase): @@ -35,7 +39,7 @@ class MessageTest(unittest.TestCase): @MockParentMessage.register class MockMessage(MockParentMessage): - acme_type = 'test' + typ = 'test' schema = { 'type': 'object', 'properties': { @@ -56,43 +60,21 @@ class MessageTest(unittest.TestCase): self.parent_cls = MockParentMessage self.msg = MockMessage(price=123, name='foo') - def test_from_json_non_dict_fails(self): - self.assertRaises(errors.ValidationError, self.parent_cls.from_json, []) - - def test_from_json_dict_no_type_fails(self): - self.assertRaises(errors.ValidationError, self.parent_cls.from_json, {}) - - def test_from_json_unrecognized_type(self): - self.assertRaises(errors.UnrecognizedTypeError, - self.parent_cls.from_json, {'type': 'foo'}) - def test_from_json_validates(self): self.assertRaises(errors.SchemaValidationError, self.parent_cls.from_json, {'type': 'test', 'price': 'asd'}) - def test_from_json(self): - self.assertEqual(self.msg, self.parent_cls.from_json( - {'type': 'test', 'name': 'foo', 'price': 123})) - - def test_json_loads(self): - self.assertEqual(self.msg, self.parent_cls.json_loads( - '{"type": "test", "name": "foo", "price": 123}')) - - def test_json_dumps(self): - self.assertEqual(self.msg.json_dumps(sort_keys=True), - '{"name": "foo", "price": 123, "type": "test"}') - class ChallengeTest(unittest.TestCase): def setUp(self): - challs = [ + challs = ( challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'), challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'), challenges.RecoveryToken(), - ] - combinations = [[0, 2], [1, 2]] + ) + combinations = ((0, 2), (1, 2)) from letsencrypt.acme.messages import Challenge self.msg = Challenge( @@ -112,21 +94,21 @@ class ChallengeTest(unittest.TestCase): 'type': 'challenge', 'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4', 'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ', - 'challenges': [chall.to_json() for chall in challs], - 'combinations': combinations, + 'challenges': [chall.fully_serialize() for chall in challs], + 'combinations': [[0, 2], [1, 2]], # TODO array tuples } def test_resolved_combinations(self): - self.assertEqual(self.msg.resolved_combinations, [ - [ + self.assertEqual(self.msg.resolved_combinations, ( + ( challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'), challenges.RecoveryToken() - ], - [ + ), + ( challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'), challenges.RecoveryToken(), - ] - ]) + ) + )) def test_to_json(self): self.assertEqual(self.msg.to_json(), self.jmsg_to) @@ -142,7 +124,7 @@ class ChallengeTest(unittest.TestCase): from letsencrypt.acme.messages import Challenge msg = Challenge.from_json(self.jmsg_from) - self.assertEqual(msg.combinations, []) + self.assertEqual(msg.combinations, ()) self.assertEqual(msg.to_json(), self.jmsg_to) @@ -168,7 +150,7 @@ class ChallengeRequestTest(unittest.TestCase): class AuthorizationTest(unittest.TestCase): def setUp(self): - jwk = other.JWK(key=KEY.publickey()) + jwk = jose.JWKRSA(key=KEY.publickey()) from letsencrypt.acme.messages import Authorization self.msg = Authorization(recovery_token='tok', jwk=jwk, @@ -207,14 +189,14 @@ class AuthorizationTest(unittest.TestCase): class AuthorizationRequestTest(unittest.TestCase): def setUp(self): - self.responses = [ + self.responses = ( challenges.SimpleHTTPSResponse(path='Hf5GrX4Q7EBax9hc2jJnfw'), None, # null challenges.RecoveryTokenResponse(token='23029d88d9e123e'), - ] - self.contact = ["mailto:cert-admin@example.com", "tel:+12025551212"] + ) + self.contact = ("mailto:cert-admin@example.com", "tel:+12025551212") signature = other.Signature( - alg='RS256', jwk=other.JWK(key=KEY.publickey()), + alg=jose.RS256, jwk=jose.JWKRSA(key=KEY.publickey()), sig='-v\xd8\xc2\xa3\xba0\xd6\x92\x16\xb5.\xbe\xa1[\x04\xbe' '\x1b\xa1X\xd2)\x18\x94\x8f\xd7\xd0\xc0\xbbcI`W\xdf v' '\xe4\xed\xe8\x03J\xe8\xc8l#\x10<\x96\xd2\xcdr\xa3' '\x1b\xa1\xf5!f\xef\xc64\xb6\x13') self.nonce = '\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9' - from letsencrypt.acme.other import JWK - self.jwk = JWK(key=RSA256_KEY.publickey()) + self.alg = jose.RS256 + self.jwk = jose.JWKRSA(key=RSA256_KEY.publickey()) b64sig = ('SUPYKucUnhlTt8_sMxLiigOYdf_wlOLXPI-o7aRLTsOquVjDd6r' 'AX9AFJHk-bCMQPJbSzXKjG6H1IWbvxjS2Ew') @@ -88,7 +40,7 @@ class SignatureTest(unittest.TestCase): self.jsig_from = { 'nonce': b64nonce, - 'alg': self.alg, + 'alg': self.alg.to_json(), 'jwk': self.jwk.to_json(), 'sig': b64sig, } @@ -130,15 +82,17 @@ class SignatureTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.other import Signature self.assertEqual( - self.signature, Signature.from_valid_json(self.jsig_from)) + self.signature, Signature.from_json(self.jsig_from)) def test_from_json_non_schema_errors(self): from letsencrypt.acme.other import Signature jwk = self.jwk.to_json() - self.assertRaises(errors.ValidationError, Signature.from_valid_json, { - 'alg': 'RS256', 'sig': 'x', 'nonce': '', 'jwk': jwk}) - self.assertRaises(errors.ValidationError, Signature.from_valid_json, { - 'alg': 'RS256', 'sig': '', 'nonce': 'x', 'jwk': jwk}) + self.assertRaises( + jose.DeserializationError, Signature.from_json, { + 'alg': 'RS256', 'sig': 'x', 'nonce': '', 'jwk': jwk}) + self.assertRaises( + jose.DeserializationError, Signature.from_json, { + 'alg': 'RS256', 'sig': '', 'nonce': 'x', 'jwk': jwk}) if __name__ == '__main__': diff --git a/letsencrypt/acme/util.py b/letsencrypt/acme/util.py index cc00dc2bb..8bea9091a 100644 --- a/letsencrypt/acme/util.py +++ b/letsencrypt/acme/util.py @@ -1,247 +1,9 @@ """ACME utilities.""" -import binascii import json import pkg_resources -import M2Crypto -import zope.interface - -from letsencrypt.acme import errors -from letsencrypt.acme import interfaces -from letsencrypt.acme import jose - - -class ComparableX509(object): # pylint: disable=too-few-public-methods - """Wrapper for M2Crypto.X509.* objects that supports __eq__. - - Wraps around: - - - :class:`M2Crypto.X509.X509` - - :class:`M2Crypto.X509.Request` - - """ - def __init__(self, wrapped): - self._wrapped = wrapped - - def __getattr__(self, name): - return getattr(self._wrapped, name) - - def __eq__(self, other): - return self.as_der() == other.as_der() - def load_schema(name): """Load JSON schema from distribution.""" return json.load(open(pkg_resources.resource_filename( __name__, "schemata/%s.json" % name))) - - -def dump_ijsonserializable(python_object): - """Serialize IJSONSerializable to JSON. - - This is meant to be passed to :func:`json.dumps` as ``default`` - argument in order to facilitate recursive calls to - :meth:`~letsencrypt.acme.interfaces.IJSONSerializable.to_json`. - Please see :meth:`letsencrypt.acme.interfaces.IJSONSerializable.to_json` - for an example. - - """ - # providedBy | pylint: disable=no-member - if interfaces.IJSONSerializable.providedBy(python_object): - return python_object.to_json() - else: - raise TypeError(repr(python_object) + ' is not JSON serializable') - - -class ImmutableMap(object): # pylint: disable=too-few-public-methods - """Immutable key to value mapping with attribute access.""" - - __slots__ = () - """Must be overriden in subclasses.""" - - def __init__(self, **kwargs): - if set(kwargs) != set(self.__slots__): - raise TypeError( - '__init__() takes exactly the following arguments: {0} ' - '({1} given)'.format(', '.join(self.__slots__), - ', '.join(kwargs) if kwargs else 'none')) - for slot in self.__slots__: - object.__setattr__(self, slot, kwargs.pop(slot)) - - def __setattr__(self, name, value): - raise AttributeError("can't set attribute") - - def __eq__(self, other): - return isinstance(other, self.__class__) and all( - getattr(self, slot) == getattr(other, slot) - for slot in self.__slots__) - - def __hash__(self): - return hash(tuple(getattr(self, slot) for slot in self.__slots__)) - - def __repr__(self): - return '{0}({1})'.format(self.__class__.__name__, ', '.join( - '{0}={1!r}'.format(slot, getattr(self, slot)) - for slot in self.__slots__)) - - -class ACMEObject(ImmutableMap): # pylint: disable=too-few-public-methods - """ACME object.""" - zope.interface.implements(interfaces.IJSONSerializable) - zope.interface.classImplements(interfaces.IJSONDeserializable) - - def to_json(self): # pragma: no cover - """Serialize to JSON.""" - raise NotImplementedError() - - @classmethod - def from_valid_json(cls, jobj): # pragma: no cover - """Deserialize from valid JSON object.""" - raise NotImplementedError() - - -def decode_b64jose(value, size=None, minimum=False): - """Decode ACME object JOSE Base64 encoded field. - - :param str value: Encoded field value. - :param int size: If specified, this function will check if data size - (after decoding) matches. - :param bool minimum: If ``True``, then ``size`` is the minimum required - size, otherwise ``size`` must be exact. - - :raises letsencrypt.acme.errors.ValidationError: if anything goes wrong - :returns: Decoded value. - - """ - try: - decoded = jose.b64decode(value) - except TypeError: - raise errors.ValidationError() - - if size is not None and ((not minimum and len(decoded) != size) - or (minimum and len(decoded) < size)): - raise errors.ValidationError() - - return decoded - - -def decode_hex16(value, size=None, minimum=False): - """Decode ACME object hex16-encoded field. - - :param str value: Encoded field value. - :param int size: If specified, this function will check if data size - (after decoding) matches. - :param bool minimum: If ``True``, then ``size`` is the minimum required - size, otherwise ``size`` must be exact. - - """ - # binascii.hexlify.__doc__: "The resulting string is therefore twice - # as long as the length of data." - if size is not None and ((not minimum and len(value) != size * 2) - or (minimum and len(value) < size * 2)): - raise errors.ValidationError() - try: - return binascii.unhexlify(value) - except TypeError as error: # odd-length string (binascci.unhexlify.__doc__) - raise errors.ValidationError(error) - - -def encode_cert(cert): - """Encode ACME object X509 certificate field.""" - return jose.b64encode(cert.as_der()) - - -def decode_cert(b64der): - """Decode ACME object X509 certificate field. - - :param str b64der: Input data that's meant to be valid base64 - DER-encoded certificate. - - :raises letsencrypt.acme.errors.ValidationError: if anything goes wrong - - :returns: Decoded certificate. - :rtype: :class:`M2Crypto.X509.X509` wrapped in :class:`ComparableX509`. - - """ - try: - return ComparableX509(M2Crypto.X509.load_cert_der_string( - decode_b64jose(b64der))) - except M2Crypto.X509.X509Error: - raise errors.ValidationError() - - -def encode_csr(csr): - """Encode ACME object CSR field.""" - return encode_cert(csr) - - -def decode_csr(b64der): - """Decode ACME object CSR field. - - :param str b64der: Input data that's meant to be valid base64 - DER-encoded CSR. - - :raises letsencrypt.acme.errors.ValidationError: if anything goes wrong - - :returns: Decoded certificate. - :rtype: :class:`M2Crypto.X509.X509` wrapped in :class:`ComparableX509`. - - """ - try: - return ComparableX509(M2Crypto.X509.load_request_der_string( - decode_b64jose(b64der))) - except M2Crypto.X509.X509Error: - raise errors.ValidationError() - - -class TypedACMEObject(ACMEObject): - """ACME object with type (immutable).""" - - acme_type = NotImplemented - """ACME "type" field. Subclasses must override.""" - - TYPES = NotImplemented - """Types registered for JSON deserialization""" - - @classmethod - def register(cls, msg_cls): - """Register class for JSON deserialization.""" - cls.TYPES[msg_cls.acme_type] = msg_cls - return msg_cls - - def to_json(self): - """Get JSON serializable object. - - :returns: Serializable JSON object representing ACME typed object. - :rtype: dict - - """ - jobj = self._fields_to_json() - jobj["type"] = self.acme_type - return jobj - - def _fields_to_json(self): # pragma: no cover - """Prepare ACME object fields for JSON serialiazation. - - Subclasses must override this method. - - :returns: Serializable JSON object containg all ACME object fields - apart from "type". - :rtype: dict - - """ - raise NotImplementedError() - - @classmethod - def from_valid_json(cls, jobj): - """Deserialize ACME object from valid JSON object. - - :raises letsencrypt.acme.errors.UnrecognizedTypeError: if type - of the ACME object has not been registered. - - """ - try: - msg_cls = cls.TYPES[jobj["type"]] - except KeyError: - raise errors.UnrecognizedTypeError(jobj["type"]) - return msg_cls.from_valid_json(jobj) diff --git a/letsencrypt/acme/util_test.py b/letsencrypt/acme/util_test.py deleted file mode 100644 index 0b500a2c7..000000000 --- a/letsencrypt/acme/util_test.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Tests for letsencrypt.acme.util.""" -import functools -import json -import os -import pkg_resources -import unittest - -import M2Crypto -import zope.interface - -from letsencrypt.acme import errors -from letsencrypt.acme import interfaces - - -CERT = M2Crypto.X509.load_cert(pkg_resources.resource_filename( - 'letsencrypt.client.tests', os.path.join('testdata', 'cert.pem'))) -CSR = M2Crypto.X509.load_request(pkg_resources.resource_filename( - 'letsencrypt.client.tests', os.path.join('testdata', 'csr.pem'))) - - -class DumpIJSONSerializableTest(unittest.TestCase): - """Tests for letsencrypt.acme.util.dump_ijsonserializable.""" - - class MockJSONSerialiazable(object): - # pylint: disable=missing-docstring,too-few-public-methods,no-self-use - zope.interface.implements(interfaces.IJSONSerializable) - - def to_json(self): - return [3, 2, 1] - - @classmethod - def _call(cls, obj): - from letsencrypt.acme.util import dump_ijsonserializable - return json.dumps(obj, default=dump_ijsonserializable) - - def test_json_type(self): - self.assertEqual('5', self._call(5)) - - def test_ijsonserializable(self): - self.assertEqual('[3, 2, 1]', self._call(self.MockJSONSerialiazable())) - - def test_raises_type_error(self): - self.assertRaises(TypeError, self._call, object()) - - -class ImmutableMapTest(unittest.TestCase): - """Tests for letsencrypt.acme.util.ImmutableMap.""" - - def setUp(self): - # pylint: disable=invalid-name,too-few-public-methods - # pylint: disable=missing-docstring - from letsencrypt.acme.util import ImmutableMap - - class A(ImmutableMap): - __slots__ = ('x', 'y') - - class B(ImmutableMap): - __slots__ = ('x', 'y') - - self.A = A - self.B = B - - self.a1 = self.A(x=1, y=2) - self.a1_swap = self.A(y=2, x=1) - self.a2 = self.A(x=3, y=4) - self.b = self.B(x=1, y=2) - - def test_order_of_args_does_not_matter(self): - self.assertEqual(self.a1, self.a1_swap) - - def test_type_error_on_missing(self): - self.assertRaises(TypeError, self.A, x=1) - self.assertRaises(TypeError, self.A, y=2) - - def test_type_error_on_unrecognized(self): - self.assertRaises(TypeError, self.A, x=1, z=2) - self.assertRaises(TypeError, self.A, x=1, y=2, z=3) - - def test_get_attr(self): - self.assertEqual(1, self.a1.x) - self.assertEqual(2, self.a1.y) - self.assertEqual(1, self.a1_swap.x) - self.assertEqual(2, self.a1_swap.y) - - def test_set_attr_raises_attribute_error(self): - self.assertRaises( - AttributeError, functools.partial(self.a1.__setattr__, 'x'), 10) - - def test_equal(self): - self.assertEqual(self.a1, self.a1) - self.assertEqual(self.a2, self.a2) - self.assertNotEqual(self.a1, self.a2) - - def test_same_slots_diff_cls_not_equal(self): - self.assertEqual(self.a1.x, self.b.x) - self.assertEqual(self.a1.y, self.b.y) - self.assertNotEqual(self.a1, self.b) - - def test_hash(self): - self.assertEqual(hash((1, 2)), hash(self.a1)) - - def test_unhashable(self): - self.assertRaises(TypeError, self.A(x=1, y={}).__hash__) - - def test_repr(self): - self.assertEqual('A(x=1, y=2)', repr(self.a1)) - self.assertEqual('A(x=1, y=2)', repr(self.a1_swap)) - self.assertEqual('B(x=1, y=2)', repr(self.b)) - self.assertEqual("B(x='foo', y='bar')", repr(self.B(x='foo', y='bar'))) - - -class EncodersAndDecodersTest(unittest.TestCase): - """Tests for encoders and decoders from letsencrypt.acme.util""" - # pylint: disable=protected-access - - def setUp(self): - self.b64_cert = ( - 'MIIB3jCCAYigAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhM' - 'CVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKz' - 'ApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxF' - 'DASBgNVBAMMC2V4YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIx' - 'ODIyMzQ0NVowdzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRI' - 'wEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTW' - 'ljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4YW1wbGUuY29tMFwwD' - 'QYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR7R_drnBSQ_zfx1vQLHUbFLh1' - 'AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c-pVE6K-EdE_twuUCAwE' - 'AATANBgkqhkiG9w0BAQsFAANBAC24z0IdwIVKSlntksllvr6zJepBH5fMnd' - 'fk3XJp10jT6VE-14KNtjh02a56GoraAvJAT5_H67E8GvJ_ocNnB_o' - ) - self.b64_csr = ( - 'MIIBXTCCAQcCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2F' - 'uMRIwEAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECw' - 'wWVW5pdmVyc2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAwwLZXhhbXBsZS5jb' - '20wXDANBgkqhkiG9w0BAQEFAANLADBIAkEArHVztFHtH92ucFJD_N_HW9As' - 'dRsUuHUBBBDlHwNlRd3fp580rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3' - 'C5QIDAQABoCkwJwYJKoZIhvcNAQkOMRowGDAWBgNVHREEDzANggtleGFtcG' - 'xlLmNvbTANBgkqhkiG9w0BAQsFAANBAHJH_O6BtC9aGzEVCMGOZ7z9iIRHW' - 'Szr9x_bOzn7hLwsbXPAgO1QxEwL-X-4g20Gn9XBE1N9W6HCIEut2d8wACg' - ) - - def test_decode_b64_jose_padding_error(self): - from letsencrypt.acme.util import decode_b64jose - self.assertRaises(errors.ValidationError, decode_b64jose, 'x') - - def test_decode_b64_jose_size(self): - from letsencrypt.acme.util import decode_b64jose - self.assertEqual('foo', decode_b64jose('Zm9v', size=3)) - self.assertRaises( - errors.ValidationError, decode_b64jose, 'Zm9v', size=2) - self.assertRaises( - errors.ValidationError, decode_b64jose, 'Zm9v', size=4) - - def test_decode_b64_jose_minimum_size(self): - from letsencrypt.acme.util import decode_b64jose - self.assertEqual('foo', decode_b64jose('Zm9v', size=3, minimum=True)) - self.assertEqual('foo', decode_b64jose('Zm9v', size=2, minimum=True)) - self.assertRaises(errors.ValidationError, decode_b64jose, - 'Zm9v', size=4, minimum=True) - - def test_decode_hex16(self): - from letsencrypt.acme.util import decode_hex16 - self.assertEqual('foo', decode_hex16('666f6f')) - - def test_decode_hex16_minimum_size(self): - from letsencrypt.acme.util import decode_hex16 - self.assertEqual('foo', decode_hex16('666f6f', size=3, minimum=True)) - self.assertEqual('foo', decode_hex16('666f6f', size=2, minimum=True)) - self.assertRaises(errors.ValidationError, decode_hex16, - '666f6f', size=4, minimum=True) - - def test_decode_hex16_odd_length(self): - from letsencrypt.acme.util import decode_hex16 - self.assertRaises(errors.ValidationError, decode_hex16, 'x') - - def test_encode_cert(self): - from letsencrypt.acme.util import encode_cert - self.assertEqual(self.b64_cert, encode_cert(CERT)) - - def test_decode_cert(self): - from letsencrypt.acme.util import ComparableX509 - from letsencrypt.acme.util import decode_cert - cert = decode_cert(self.b64_cert) - self.assertTrue(isinstance(cert, ComparableX509)) - self.assertEqual(cert, CERT) - self.assertRaises(errors.ValidationError, decode_cert, '') - - def test_encode_csr(self): - from letsencrypt.acme.util import encode_csr - self.assertEqual(self.b64_csr, encode_csr(CSR)) - - def test_decode_csr(self): - from letsencrypt.acme.util import ComparableX509 - from letsencrypt.acme.util import decode_csr - csr = decode_csr(self.b64_csr) - self.assertTrue(isinstance(csr, ComparableX509)) - self.assertEqual(csr, CSR) - self.assertRaises(errors.ValidationError, decode_csr, '') - - -class TypedACMEObjectTest(unittest.TestCase): - - def setUp(self): - from letsencrypt.acme.util import TypedACMEObject - - # pylint: disable=missing-docstring,abstract-method - # pylint: disable=too-few-public-methods - - class MockParentTypedACMEObject(TypedACMEObject): - TYPES = {} - - @MockParentTypedACMEObject.register - class MockTypedACMEObject(MockParentTypedACMEObject): - acme_type = 'test' - - @classmethod - def from_valid_json(cls, unused_obj): - return '!' - - def _fields_to_json(self): - return {'foo': 'bar'} - - self.parent_cls = MockParentTypedACMEObject - self.msg = MockTypedACMEObject() - - def test_to_json(self): - self.assertEqual(self.msg.to_json(), { - 'type': 'test', - 'foo': 'bar', - }) - - def test_from_json_unknown_type_fails(self): - self.assertRaises(errors.UnrecognizedTypeError, - self.parent_cls.from_valid_json, {'type': 'bar'}) - - def test_from_json_returns_obj(self): - self.assertEqual(self.parent_cls.from_valid_json({'type': 'test'}), '!') - - -if __name__ == '__main__': - unittest.main() diff --git a/letsencrypt/client/achallenges.py b/letsencrypt/client/achallenges.py index 835bd1e8d..cc7c322fe 100644 --- a/letsencrypt/client/achallenges.py +++ b/letsencrypt/client/achallenges.py @@ -17,7 +17,7 @@ Note, that all annotated challenges act as a proxy objects:: """ from letsencrypt.acme import challenges -from letsencrypt.acme import util as acme_util +from letsencrypt.acme.jose import util as jose_util from letsencrypt.client import crypto_util @@ -25,7 +25,7 @@ from letsencrypt.client import crypto_util # pylint: disable=too-few-public-methods -class AnnotatedChallenge(acme_util.ImmutableMap): +class AnnotatedChallenge(jose_util.ImmutableMap): """Client annotated challenge. Wraps around :class:`~letsencrypt.acme.challenges.Challenge` and @@ -88,7 +88,7 @@ class ProofOfPossession(AnnotatedChallenge): acme_type = challenges.ProofOfPossession -class Indexed(acme_util.ImmutableMap): +class Indexed(jose_util.ImmutableMap): """Indexed and annotated ACME challenge. Wraps around :class:`AnnotatedChallenge` and annotates with an diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 4e3b5f68f..7ded4322c 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -299,8 +299,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes else: raise errors.LetsEncryptClientError( - "Received unsupported challenge of type: " - "%s" % chall.acme_type) + "Received unsupported challenge of type: %s", chall.typ) ichall = achallenges.Indexed(achall=achall, index=index) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index d415403f3..2f3f9a769 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -7,7 +7,7 @@ import Crypto.PublicKey.RSA import M2Crypto from letsencrypt.acme import messages -from letsencrypt.acme import util as acme_util +from letsencrypt.acme.jose import util as jose_util from letsencrypt.client import auth_handler from letsencrypt.client import client_authenticator @@ -130,7 +130,7 @@ class Client(object): logging.info("Preparing and sending CSR...") return self.network.send_and_receive_expected( messages.CertificateRequest.create( - csr=acme_util.ComparableX509( + csr=jose_util.ComparableX509( M2Crypto.X509.load_request_der_string(csr_der)), key=Crypto.PublicKey.RSA.importKey(self.authkey.pem)), messages.Certificate) diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py index b61a8a2f8..de6db575b 100644 --- a/letsencrypt/client/network.py +++ b/letsencrypt/client/network.py @@ -5,7 +5,7 @@ import time import requests -from letsencrypt.acme import errors as acme_errors +from letsencrypt.acme import jose from letsencrypt.acme import messages from letsencrypt.client import errors @@ -57,7 +57,7 @@ class Network(object): json_string = response.json() try: return messages.Message.from_json(json_string) - except acme_errors.ValidationError as error: + except jose.DeserializationError as error: logging.error(json_string) raise # TODO diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index 98cf1704e..c18b5ffa6 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -17,7 +17,7 @@ import Crypto.PublicKey.RSA import M2Crypto from letsencrypt.acme import messages -from letsencrypt.acme import util as acme_util +from letsencrypt.acme.jose import util as jose_util from letsencrypt.client import errors from letsencrypt.client import le_util @@ -240,7 +240,7 @@ class Revoker(object): """ # These will both have to change in the future away from M2Crypto # pylint: disable=protected-access - certificate = acme_util.ComparableX509(cert._cert) + certificate = jose_util.ComparableX509(cert._cert) try: with open(cert.backup_key_path, "rU") as backup_key_file: key = Crypto.PublicKey.RSA.importKey(backup_key_file.read()) diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index 233436361..aba839f8c 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -5,7 +5,7 @@ import pkg_resources import Crypto.PublicKey.RSA from letsencrypt.acme import challenges -from letsencrypt.acme import other +from letsencrypt.acme import jose KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( @@ -26,7 +26,7 @@ RECOVERY_TOKEN = challenges.RecoveryToken() POP = challenges.ProofOfPossession( alg="RS256", nonce="xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ", hints=challenges.ProofOfPossession.Hints( - jwk=other.JWK(key=KEY.publickey()), + jwk=jose.JWKRSA(key=KEY.publickey()), cert_fingerprints=[ "93416768eb85e33adc4277f4c9acd63e7418fcfe", "16d95b7b63f1972b980b14c20291f3c0d1855d95", diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index 91874dc0c..abf7032b9 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -335,7 +335,7 @@ class SatisfyChallengesTest(unittest.TestCase): # pylint: disable=no-self-use exp_resp = [None] * len(challs) for i in path: - exp_resp[i] = TRANSLATE[challs[i].acme_type] + str(domain) + exp_resp[i] = TRANSLATE[challs[i].typ] + str(domain) return exp_resp diff --git a/linter_plugin.py b/linter_plugin.py index ac2a01f6d..9a165d81f 100644 --- a/linter_plugin.py +++ b/linter_plugin.py @@ -21,5 +21,9 @@ def _transform(cls): for slot in cls.slots(): cls.locals[slot.value] = [nodes.EmptyNode()] + if cls.name == 'JSONObjectWithFields': + # _fields is magically introduced by JSONObjectWithFieldsMeta + cls.locals['_fields'] = [nodes.EmptyNode()] + MANAGER.register_transform(nodes.Class, _transform) From 2d9bce8a7f57998117a577738f4767112324068e Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 18 Mar 2015 14:32:43 +0000 Subject: [PATCH 05/54] Fix typo --- letsencrypt/acme/jose/testdata/README | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt/acme/jose/testdata/README b/letsencrypt/acme/jose/testdata/README index 9e0f2059b..4b37ae921 100644 --- a/letsencrypt/acme/jose/testdata/README +++ b/letsencrypt/acme/jose/testdata/README @@ -1,10 +1,10 @@ -The following commands has been used to generate test keys: +The following command has been used to generate test keys: for x in 256 512 1024; do openssl genrsa -out rsa${k}_key.pem $k; done and for the CSR: - python -c from letsencrypt.client.crypto_util import make_csr; + python -c from 'letsencrypt.client.crypto_util import make_csr; import pkg_resources; open("csr2.pem", "w").write(make_csr(pkg_resources.resource_string("letsencrypt.client.tests", - "testdata/rsa512_key.pem"), ["example2.com"])[0]) + "testdata/rsa512_key.pem"), ["example2.com"])[0])' From d74ca1bbaa68435024fab3457b9ade8f8631de42 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 18 Mar 2015 15:04:27 +0000 Subject: [PATCH 06/54] tox: PYTHONHASHSEED=0 --- tox.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.ini b/tox.ini index bf609a747..485816d45 100644 --- a/tox.ini +++ b/tox.ini @@ -12,6 +12,8 @@ commands = setenv = PYTHONPATH = {toxinidir} + PYTHONHASHSEED = 0 +# https://testrun.org/tox/latest/example/basic.html#special-handling-of-pythonhas [testenv:cover] basepython = python2.7 From ef72b147ae241f6fc12cd3aa658bc3cb8069245d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 18 Mar 2015 22:00:56 +0000 Subject: [PATCH 07/54] Remove signature verification on CertificateRequest deserialization. --- letsencrypt/acme/messages.py | 16 +--------------- letsencrypt/acme/messages_test.py | 7 ------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py index b3a376c8e..c60dd84e8 100644 --- a/letsencrypt/acme/messages.py +++ b/letsencrypt/acme/messages.py @@ -221,21 +221,7 @@ class CertificateRequest(Message): csr = jose.Field("csr", encoder=jose.encode_csr, decoder=jose.decode_csr) - signature = jose.Field("signature") - - @classmethod - def fields_from_json(cls, jobj): - cls._check_required(jobj) - - sig = other.Signature.from_json( - jobj[cls._fields['signature'].json_name]) - if not sig.verify(json_util.decode_b64jose(jobj["csr"])): - raise jose_errors.DeserializationError( - 'Signature could not be verified') - # verify signature before decoding principle! - csr = jose.decode_csr(jobj[cls._fields['csr'].json_name]) - - return {'signature': sig, 'csr': csr} + signature = jose.Field("signature", decoder=other.Signature.from_json) @classmethod def create(cls, key, sig_nonce=None, **kwargs): diff --git a/letsencrypt/acme/messages_test.py b/letsencrypt/acme/messages_test.py index ba1962b40..540882d7c 100644 --- a/letsencrypt/acme/messages_test.py +++ b/letsencrypt/acme/messages_test.py @@ -343,13 +343,6 @@ class CertificateRequestTest(unittest.TestCase): from letsencrypt.acme.messages import CertificateRequest self.assertEqual(self.msg, CertificateRequest.from_json(self.jmsg_from)) - def test_from_json_wrong_signature_raises_error(self): - from letsencrypt.acme.messages import CertificateRequest - self.jmsg_from['csr'] = jose.b64encode(CSR2.as_der()) - self.assertRaises( - jose_errors.DeserializationError, CertificateRequest.from_json, - self.jmsg_from) - class DeferTest(unittest.TestCase): From 77eb5f7625062b78ad130c1faba3c7aae61bc5b6 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 18 Mar 2015 22:07:37 +0000 Subject: [PATCH 08/54] jose.* imports cleanup --- letsencrypt/acme/messages.py | 3 --- letsencrypt/acme/messages_test.py | 2 -- letsencrypt/acme/other.py | 13 ++++++------- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py index c60dd84e8..1009398ea 100644 --- a/letsencrypt/acme/messages.py +++ b/letsencrypt/acme/messages.py @@ -7,9 +7,6 @@ from letsencrypt.acme import jose from letsencrypt.acme import other from letsencrypt.acme import util -from letsencrypt.acme.jose import errors as jose_errors -from letsencrypt.acme.jose import json_util - class Message(jose.TypedJSONObjectWithFields): # _fields_to_json | pylint: disable=abstract-method diff --git a/letsencrypt/acme/messages_test.py b/letsencrypt/acme/messages_test.py index 540882d7c..a419e4072 100644 --- a/letsencrypt/acme/messages_test.py +++ b/letsencrypt/acme/messages_test.py @@ -10,8 +10,6 @@ from letsencrypt.acme import errors from letsencrypt.acme import jose from letsencrypt.acme import other -from letsencrypt.acme.jose import errors as jose_errors - KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( 'letsencrypt.client.tests', 'testdata/rsa256_key.pem')) diff --git a/letsencrypt/acme/other.py b/letsencrypt/acme/other.py index 824aa722d..99a4ec551 100644 --- a/letsencrypt/acme/other.py +++ b/letsencrypt/acme/other.py @@ -6,7 +6,6 @@ import Crypto.Random import Crypto.PublicKey.RSA from letsencrypt.acme import jose -from letsencrypt.acme.jose import json_util class Signature(jose.JSONObjectWithFields): @@ -23,13 +22,13 @@ class Signature(jose.JSONObjectWithFields): NONCE_SIZE = 16 """Minimum size of nonce in bytes.""" - alg = json_util.Field('alg', decoder=jose.JWASignature.from_json) - sig = json_util.Field('sig', encoder=jose.b64encode, - decoder=json_util.decode_b64jose) - nonce = json_util.Field( + alg = jose.Field('alg', decoder=jose.JWASignature.from_json) + sig = jose.Field('sig', encoder=jose.b64encode, + decoder=jose.decode_b64jose) + nonce = jose.Field( 'nonce', encoder=jose.b64encode, decoder=functools.partial( - json_util.decode_b64jose, size=NONCE_SIZE, minimum=True)) - jwk = json_util.Field('jwk', decoder=jose.JWK.from_json) + jose.decode_b64jose, size=NONCE_SIZE, minimum=True)) + jwk = jose.Field('jwk', decoder=jose.JWK.from_json) @classmethod def from_msg(cls, msg, key, nonce=None, nonce_size=None, alg=jose.RS256): From 8208a7f4d5c6a5679a4c3e8b606e1c03e1a3b5b0 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 18 Mar 2015 22:08:01 +0000 Subject: [PATCH 09/54] MockMessage test cleanup --- letsencrypt/acme/messages_test.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/letsencrypt/acme/messages_test.py b/letsencrypt/acme/messages_test.py index a419e4072..bd6f4d702 100644 --- a/letsencrypt/acme/messages_test.py +++ b/letsencrypt/acme/messages_test.py @@ -45,15 +45,8 @@ class MessageTest(unittest.TestCase): 'name': {'type': 'string'}, }, } - __slots__ = ('price', 'name') - - @classmethod - def from_valid_json(cls, jobj): - return cls(price=jobj.get('price'), name=jobj.get('name')) - - def _fields_to_json(self): - # pylint: disable=no-member - return {'price': self.price, 'name': self.name} + price = jose.Field('price') + name = jose.Field('name') self.parent_cls = MockParentMessage self.msg = MockMessage(price=123, name='foo') From 7def7df8976db91c7aa88ffb1117f8b2a9a933cf Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 17 Mar 2015 15:47:45 +0000 Subject: [PATCH 10/54] JWS --- docs/api/acme/jose.rst | 7 + letsencrypt/acme/jose/__init__.py | 6 + letsencrypt/acme/jose/jwk.py | 40 ++- letsencrypt/acme/jose/jwk_test.py | 11 + letsencrypt/acme/jose/jws.py | 406 ++++++++++++++++++++++++++++++ letsencrypt/acme/jose/jws_test.py | 234 +++++++++++++++++ setup.py | 1 + 7 files changed, 701 insertions(+), 4 deletions(-) create mode 100644 letsencrypt/acme/jose/jws.py create mode 100644 letsencrypt/acme/jose/jws_test.py diff --git a/docs/api/acme/jose.rst b/docs/api/acme/jose.rst index 50e86adaa..9a64d33d3 100644 --- a/docs/api/acme/jose.rst +++ b/docs/api/acme/jose.rst @@ -21,6 +21,13 @@ JSON Web Key :members: +JSON Web Signature +------------------ + +.. automodule:: letsencrypt.acme.jose.jws + :members: + + Implementation details ---------------------- diff --git a/letsencrypt/acme/jose/__init__.py b/letsencrypt/acme/jose/__init__.py index 488775810..4c7398b79 100644 --- a/letsencrypt/acme/jose/__init__.py +++ b/letsencrypt/acme/jose/__init__.py @@ -6,6 +6,7 @@ particular the following RFCs: - `JSON Web Algorithms (JWA)`_ - `JSON Web Key (JWK)`_ + - `JSON Web Signature (JWS)`_ .. _`Javascript Object Signing and Encryption (Active WG)`: @@ -17,6 +18,9 @@ particular the following RFCs: .. _`JSON Web Key (JWK)`: https://datatracker.ietf.org/doc/draft-ietf-jose-json-web-key/ +.. _`JSON Web Signature (JWS)`: + https://datatracker.ietf.org/doc/draft-ietf-jose-json-web-signature/ + """ from letsencrypt.acme.jose.b64 import ( b64decode, @@ -62,6 +66,8 @@ from letsencrypt.acme.jose.jwk import ( JWKRSA, ) +from letsencrypt.acme.jose.jws import JWS + from letsencrypt.acme.jose.util import ( ComparableX509, ImmutableMap, diff --git a/letsencrypt/acme/jose/jwk.py b/letsencrypt/acme/jose/jwk.py index ccdef790e..1a83a5305 100644 --- a/letsencrypt/acme/jose/jwk.py +++ b/letsencrypt/acme/jose/jwk.py @@ -1,4 +1,5 @@ """JSON Web Key.""" +import abc import binascii import Crypto.PublicKey.RSA @@ -6,6 +7,7 @@ import Crypto.PublicKey.RSA from letsencrypt.acme.jose import b64 from letsencrypt.acme.jose import errors from letsencrypt.acme.jose import json_util +from letsencrypt.acme.jose import util class JWK(json_util.TypedJSONObjectWithFields): @@ -14,6 +16,20 @@ class JWK(json_util.TypedJSONObjectWithFields): type_field_name = 'kty' TYPES = {} + @util.abstractclassmethod + def load(cls, string): # pragma: no cover + """Load key from normalized string form.""" + raise NotImplementedError() + + @abc.abstractmethod + def public(self): # pragma: no cover + """Generate JWK with public key. + + For symmetric cryptosystems, this would return ``self``. + + """ + raise NotImplementedError() + @JWK.register class JWKES(JWK): # pragma: no cover @@ -32,6 +48,13 @@ class JWKES(JWK): # pragma: no cover def fields_from_json(cls, jobj): raise NotImplementedError() + @classmethod + def load(cls, string): + raise NotImplementedError() + + def public(self): + raise NotImplementedError() + @JWK.register class JWKOct(JWK): @@ -50,6 +73,13 @@ class JWKOct(JWK): def fields_from_json(cls, jobj): return cls(key=jobj['k']) + @classmethod + def load(cls, string): + return cls(key=string) + + def public(self): + return self + @JWK.register class JWKRSA(JWK): @@ -75,16 +105,19 @@ class JWKRSA(JWK): raise errors.DeserializationError() @classmethod - def load(cls, key): + def load(cls, string): """Load RSA key from string. - :param str key: RSA key in string form. + :param str string: RSA key in string form. :returns: :rtype: :class:`JWKRSA` """ - return cls(key=Crypto.PublicKey.RSA.importKey(key)) + return cls(key=Crypto.PublicKey.RSA.importKey(string)) + + def public(self): + return type(self)(key=self.key.publickey()) @classmethod def fields_from_json(cls, jobj): @@ -97,4 +130,3 @@ class JWKRSA(JWK): 'n': self._encode_param(self.key.n), 'e': self._encode_param(self.key.e), } - diff --git a/letsencrypt/acme/jose/jwk_test.py b/letsencrypt/acme/jose/jwk_test.py index 7f851e65e..b75d3e1ce 100644 --- a/letsencrypt/acme/jose/jwk_test.py +++ b/letsencrypt/acme/jose/jwk_test.py @@ -29,6 +29,13 @@ class JWKOctTest(unittest.TestCase): from letsencrypt.acme.jose.jwk import JWKOct self.assertEqual(self.jwk, JWKOct.from_json(self.jobj)) + def test_load(self): + from letsencrypt.acme.jose.jwk import JWKOct + self.assertEqual(self.jwk, JWKOct.load('foo')) + + def test_public(self): + self.assertTrue(self.jwk.public() is self.jwk) + class JWKRSATest(unittest.TestCase): """Tests for letsencrypt.acme.jose.jwk.JWKRSA.""" @@ -36,6 +43,7 @@ class JWKRSATest(unittest.TestCase): def setUp(self): from letsencrypt.acme.jose.jwk import JWKRSA self.jwk256 = JWKRSA(key=RSA256_KEY.publickey()) + self.jwk256_private = JWKRSA(key=RSA256_KEY) self.jwk256json = { 'kty': 'RSA', 'e': 'AQAB', @@ -65,6 +73,9 @@ class JWKRSATest(unittest.TestCase): 'letsencrypt.client.tests', os.path.join('testdata', 'rsa256_key.pem')))) + def test_public(self): + self.assertEqual(self.jwk256, self.jwk256_private.public()) + def test_to_json(self): self.assertEqual(self.jwk256.to_json(), self.jwk256json) self.assertEqual(self.jwk512.to_json(), self.jwk512json) diff --git a/letsencrypt/acme/jose/jws.py b/letsencrypt/acme/jose/jws.py new file mode 100644 index 000000000..81106dd2c --- /dev/null +++ b/letsencrypt/acme/jose/jws.py @@ -0,0 +1,406 @@ +"""JOSE Web Signature.""" +import argparse +import base64 +import sys + +import M2Crypto.X509 + +from letsencrypt.acme.jose import b64 +from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import json_util +from letsencrypt.acme.jose import jwa +from letsencrypt.acme.jose import jwk +from letsencrypt.acme.jose import util + + +class MediaType(object): + """MediaType field encoder/decoder.""" + + PREFIX = 'application/' + """MIME Media Type and Content Type prefix.""" + + @classmethod + def decode(cls, value): + """Decoder.""" + # 4.1.10 + if '/' not in value: + if ';' in value: + raise errors.DeserializationError('Unexpected semi-colon') + return cls.PREFIX + value + return value + + @classmethod + def encode(cls, value): + """Encoder.""" + # 4.1.10 + if ';' not in value: + assert value.startswith(cls.PREFIX) + return value[len(cls.PREFIX):] + return value + + +class Header(json_util.JSONObjectWithFields): + """JOSE Header. + + .. warning:: This class supports **only** Registered Header + Parameter Names (as defined in section 4.1 of the + protocol). If you need Public Header Parameter Names (4.2) + or Private Header Parameter Names (4.3), you must subclass + and override :meth:`from_json` and :meth:`to_json` + appropriately. + + .. warning:: This class does not support any extensions through + the "crit" (Critical) Header Parameter (4.1.11) and as a + conforming implementation, :meth:`from_json` treats its + occurence as an error. Please subclass if you seek for + a diferent behaviour. + + :ivar x5tS256: "x5t#S256" + :ivar str typ: MIME Media Type, inc. :const:`MediaType.PREFIX`. + :ivar str cty: Content-Type, inc. :const:`MediaType.PREFIX`. + + """ + alg = json_util.Field( + 'alg', decoder=jwa.JWASignature.from_json, omitempty=True) + jku = json_util.Field('jku', omitempty=True) + jwk = json_util.Field('jwk', decoder=jwk.JWK.from_json, omitempty=True) + kid = json_util.Field('kid', omitempty=True) + x5u = json_util.Field('x5u', omitempty=True) + x5c = json_util.Field('x5c', omitempty=True, default=()) + x5t = json_util.Field( + 'x5t', decoder=json_util.decode_b64jose, omitempty=True) + x5tS256 = json_util.Field( + 'x5t#S256', decoder=json_util.decode_b64jose, omitempty=True) + typ = json_util.Field('typ', encoder=MediaType.encode, + decoder=MediaType.decode, omitempty=True) + cty = json_util.Field('cty', encoder=MediaType.encode, + decoder=MediaType.decode, omitempty=True) + crit = json_util.Field('crit', omitempty=True, default=()) + + def not_omitted(self): + """Fields that would not be omitted in the JSON object.""" + return dict((name, getattr(self, name)) + for name, field in self._fields.iteritems() + if not field.omit(getattr(self, name))) + + def __add__(self, other): + if not isinstance(other, type(self)): + raise TypeError('Header cannot be added to: {0}'.format( + type(other))) + + not_omitted_self = self.not_omitted() + not_omitted_other = other.not_omitted() + + if set(not_omitted_self).intersection(not_omitted_other): + raise TypeError('Addition of overlapping headers not defined') + + not_omitted_self.update(not_omitted_other) + return type(self)(**not_omitted_self) # pylint: disable=star-args + + def find_key(self): + """Find key based on header. + + .. todo:: Supports only "jwk" header parameter lookup. + + :returns: (Public) key found in the header. + :rtype: :class:`letsencrypt.acme.jose.jwk.JWK` + + :raises letsencrypt.acme.jose.errors.Error: if key could not be found + + """ + if self.jwk is None: + raise errors.Error('No key found') + return self.jwk + + @crit.decoder + def crit(unused_value): + # pylint: disable=missing-docstring,no-self-argument,no-self-use + raise errors.DeserializationError( + '"crit" is not supported, please subclass') + + # x5c does NOT use JOSE Base64 (4.1.6) + + @x5c.encoder + def x5c(value): # pylint: disable=missing-docstring,no-self-argument + return [base64.b64encode(cert.as_der()) for cert in value] + + @x5c.decoder + def x5c(value): # pylint: disable=missing-docstring,no-self-argument + try: + return tuple(util.ComparableX509(M2Crypto.X509.load_cert_der_string( + base64.b64decode(cert))) for cert in value) + except M2Crypto.X509.X509Error as error: + raise errors.DeserializationError(error) + + +class Signature(json_util.JSONObjectWithFields): + """JWS Signature. + + :ivar combined: Combined Header (protected and unprotected, + :class:`Header`). + :ivar unicode protected: JWS protected header (Jose Base-64 decoded). + :ivar header: JWS Unprotected Header (:class:`Header`). + :ivar str signature: The signature. + + """ + header_cls = Header + + __slots__ = ('combined',) + protected = json_util.Field( + 'protected', omitempty=True, default='', + decoder=json_util.decode_b64jose, encoder=b64.b64encode) # TODO: utf-8? + header = json_util.Field( + 'header', omitempty=True, default=header_cls(), + decoder=header_cls.from_json) + signature = json_util.Field( + 'signature', decoder=json_util.decode_b64jose, + encoder=b64.b64encode) + + def __init__(self, **kwargs): + if 'combined' not in kwargs: + kwargs = self._with_combined(kwargs) + super(Signature, self).__init__(**kwargs) + assert self.combined.alg is not None + + @classmethod + def _with_combined(cls, kwargs): + assert 'combined' not in kwargs + header = kwargs.get('header', cls._fields['header'].default) + protected = kwargs.get('protected', cls._fields['protected'].default) + + if protected: + combined = header + cls.header_cls.json_loads(protected) + else: + combined = header + + kwargs['combined'] = combined + return kwargs + + def verify(self, payload, key=None): + """Verify. + + :param key: Key used for verification. + :type key: :class:`letsencrypt.acme.jose.jwk.JWK` + + """ + key = self.combined.find_key() if key is None else key + return self.combined.alg.verify( + key=key.key, sig=self.signature, + msg=(b64.b64encode(self.protected) + '.' + + b64.b64encode(payload))) + + @classmethod + def sign(cls, payload, key, alg, include_jwk=True, + protect=frozenset(), **kwargs): + """Sign. + + :param key: Key for signature. + :type key: :class:`letsencrypt.acme.jose.jwk.JWK` + + """ + assert isinstance(key, alg.kty) + + header_params = kwargs + header_params['alg'] = alg + if include_jwk: + header_params['jwk'] = key.public() + + assert set(header_params).issubset(cls.header_cls._fields) + assert protect.issubset(cls.header_cls._fields) + + protected_params = {} + for header in protect: + protected_params[header] = header_params.pop(header) + if protected_params: + # pylint: disable=star-args + protected = cls.header_cls(**protected_params).json_dumps() + else: + protected = '' + + header = cls.header_cls(**header_params) # pylint: disable=star-args + signature = alg.sign(key.key, b64.b64encode(protected) + + '.' + b64.b64encode(payload)) + + return cls(protected=protected, header=header, signature=signature) + + def fields_to_json(self): + fields = super(Signature, self).fields_to_json() + if not fields['header'].not_omitted(): + del fields['header'] + return fields + + @classmethod + def fields_from_json(cls, jobj): + fields = super(Signature, cls).fields_from_json(jobj) + fields_with_combined = cls._with_combined(fields) + if 'alg' not in fields_with_combined['combined'].not_omitted(): + raise errors.DeserializationError('alg not present') + return fields_with_combined + + +class JWS(json_util.JSONObjectWithFields): + """JSON Web Signature. + + from letsencrypt.acme.jose import interfaces + + :ivar str payload: JWS Payload. + :ivar str signaturea: JWS Signatures. + + """ + __slots__ = ('payload', 'signatures') + + def verify(self, key=None): + """Verify.""" + return all(sig.verify(self.payload, key) for sig in self.signatures) + + @classmethod + def sign(cls, payload, **kwargs): + """Sign.""" + return cls(payload=payload, signatures=( + Signature.sign(payload=payload, **kwargs),)) + + @property + def signature(self): + """Get a singleton signature. + + :rtype: :class:`Signature` + + """ + assert len(self.signatures) == 1 + return self.signatures[0] + + def to_compact(self): + """Compact serialization.""" + assert len(self.signatures) == 1 + + assert 'alg' not in self.signature.header.not_omitted() + # ... it must be in protected + + return '{0}.{1}.{2}'.format( + b64.b64encode(self.signature.protected), + b64.b64encode(self.payload), + b64.b64encode(self.signature.signature)) + + @classmethod + def from_compact(cls, compact): + """Compact deserialization.""" + try: + protected, payload, signature = compact.split('.') + except ValueError: + raise errors.DeserializationError( + 'Compact JWS serialization should comprise of exactly' + ' 3 dot-separated components') + sig = Signature(protected=json_util.decode_b64jose(protected), + signature=json_util.decode_b64jose(signature)) + return cls(payload=json_util.decode_b64jose(payload), signatures=(sig,)) + + def to_json(self, flat=True): # pylint: disable=arguments-differ + assert self.signatures + payload = b64.b64encode(self.payload) + + if flat and len(self.signatures) == 1: + ret = self.signatures[0].to_json() + ret['payload'] = payload + return ret + else: + return { + 'payload': payload, + 'signatures': self.signatures, + } + + @classmethod + def from_json(cls, jobj): + if 'signature' in jobj and 'signatures' in jobj: + raise errors.DeserializationError('Flat mixed with non-flat') + elif 'signature' in jobj: # flat + return cls(payload=json_util.decode_b64jose(jobj.pop('payload')), + signatures=(Signature.from_json(jobj),)) + else: + return cls(payload=json_util.decode_b64jose(jobj['payload']), + signatures=tuple(Signature.from_json(sig) + for sig in jobj['signatures'])) + +class CLI(object): + """JWS CLI.""" + + @classmethod + def sign(cls, args): + """Sign.""" + key = args.alg.kty.load(args.key.read()) + if args.protect is None: + args.protect = [] + if args.compact: + args.protect.append('alg') + + sig = JWS.sign(payload=sys.stdin.read(), key=key, alg=args.alg, + protect=set(args.protect)) + + if args.compact: + print sig.to_compact() + else: # JSON + print sig.json_dumps_pretty() + + @classmethod + def verify(cls, args): + """Verify.""" + if args.compact: + sig = JWS.from_compact(sys.stdin.read()) + else: # JSON + try: + sig = JWS.json_loads(sys.stdin.read()) + except errors.Error as error: + print error + return -1 + + if args.key is not None: + assert args.kty is not None + key = args.kty.load(args.key.read()) + else: + key = None + + sys.stdout.write(sig.payload) + return int(not sig.verify(key=key)) + + @classmethod + def _alg_type(cls, arg): + return jwa.JWASignature.from_json(arg) + + @classmethod + def _header_type(cls, arg): + assert arg in Signature.header_cls._fields + return arg + + @classmethod + def _kty_type(cls, arg): + assert arg in jwk.JWK.TYPES + return jwk.JWK.TYPES[arg] + + @classmethod + def run(cls, args=sys.argv[1:]): + """Parse arguments and sign/verify.""" + parser = argparse.ArgumentParser() + parser.add_argument('--compact', action='store_true') + + subparsers = parser.add_subparsers() + parser_sign = subparsers.add_parser('sign') + parser_sign.set_defaults(func=cls.sign) + parser_sign.add_argument( + '-k', '--key', type=argparse.FileType(), required=True) + parser_sign.add_argument( + '-a', '--alg', type=cls._alg_type, default=jwa.RS256) + parser_sign.add_argument( + '-p', '--protect', action='append', type=cls._header_type) + + parser_verify = subparsers.add_parser('verify') + parser_verify.set_defaults(func=cls.verify) + parser_verify.add_argument( + '-k', '--key', type=argparse.FileType(), required=False) + parser_verify.add_argument( + '--kty', type=cls._kty_type, required=False) + + parsed = parser.parse_args(args) + return parsed.func(parsed) + + +if __name__ == '__main__': + exit(CLI.run()) # pragma: no cover diff --git a/letsencrypt/acme/jose/jws_test.py b/letsencrypt/acme/jose/jws_test.py new file mode 100644 index 000000000..28dfd0c2f --- /dev/null +++ b/letsencrypt/acme/jose/jws_test.py @@ -0,0 +1,234 @@ +"""Tests for letsencrypt.acme.jose.jws.""" +import base64 +import os +import pkg_resources +import unittest + +import Crypto.PublicKey.RSA +import M2Crypto.X509 +import mock + +from letsencrypt.acme.jose import b64 +from letsencrypt.acme.jose import errors +from letsencrypt.acme.jose import jwa +from letsencrypt.acme.jose import jwk +from letsencrypt.acme.jose import util + + +CERT = util.ComparableX509(M2Crypto.X509.load_cert( + pkg_resources.resource_filename( + 'letsencrypt.client.tests', 'testdata/cert.pem'))) +RSA512_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa512_key.pem'))) + + +class MediaTypeTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jws.MediaType.""" + + def test_decode(self): + from letsencrypt.acme.jose.jws import MediaType + self.assertEqual('application/app', MediaType.decode('application/app')) + self.assertEqual('application/app', MediaType.decode('app')) + self.assertRaises( + errors.DeserializationError, MediaType.decode, 'app;foo') + + def test_encode(self): + from letsencrypt.acme.jose.jws import MediaType + self.assertEqual('app', MediaType.encode('application/app')) + self.assertEqual('application/app;foo', + MediaType.encode('application/app;foo')) + + +class HeaderTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jws.Header.""" + + def setUp(self): + from letsencrypt.acme.jose.jws import Header + self.header1 = Header(jwk='foo') + self.header2 = Header(jwk='bar') + self.crit = Header(crit=('a', 'b')) + self.empty = Header() + + def test_add_non_empty(self): + from letsencrypt.acme.jose.jws import Header + self.assertEqual(Header(jwk='foo', crit=('a', 'b')), + self.header1 + self.crit) + + def test_add_empty(self): + self.assertEqual(self.header1, self.header1 + self.empty) + self.assertEqual(self.header1, self.empty + self.header1) + + def test_add_overlapping_error(self): + self.assertRaises(TypeError, self.header1.__add__, self.header2) + + def test_add_wrong_type_error(self): + self.assertRaises(TypeError, self.header1.__add__, 'xxx') + + def test_crit_decode_always_errors(self): + from letsencrypt.acme.jose.jws import Header + self.assertRaises(errors.DeserializationError, Header.from_json, + {'crit': ['a', 'b']}) + + def test_x5c_decoding(self): + from letsencrypt.acme.jose.jws import Header + header = Header(x5c=(CERT, CERT)) + jobj = header.to_json() + cert_b64 = base64.b64encode(CERT.as_der()) + self.assertEqual(jobj, {'x5c': [cert_b64, cert_b64]}) + self.assertEqual(header, Header.from_json(jobj)) + jobj['x5c'][0] = base64.b64encode('xxx' + CERT.as_der()) + self.assertRaises(errors.DeserializationError, Header.from_json, jobj) + + def test_find_key(self): + self.assertEqual('foo', self.header1.find_key()) + self.assertEqual('bar', self.header2.find_key()) + self.assertRaises(errors.Error, self.crit.find_key) + + +class SignatureTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jws.Signature.""" + + def test_from_json(self): + from letsencrypt.acme.jose.jws import Header + from letsencrypt.acme.jose.jws import Signature + self.assertEqual( + Signature(signature='foo', header=Header(alg=jwa.RS256)), + Signature.from_json( + {'signature': 'Zm9v', 'header': {'alg': 'RS256'}})) + + def test_from_json_no_alg_error(self): + from letsencrypt.acme.jose.jws import Signature + self.assertRaises(errors.DeserializationError, + Signature.from_json, {'signature': 'foo'}) + + +class JWSTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.jws.JWS.""" + + def setUp(self): + self.privkey = jwk.JWKRSA(key=RSA512_KEY) + self.pubkey = self.privkey.public() + + from letsencrypt.acme.jose.jws import JWS + self.unprotected = JWS.sign( + payload='foo', key=self.privkey, alg=jwa.RS256) + self.protected = JWS.sign( + payload='foo', key=self.privkey, alg=jwa.RS256, + protect=frozenset(['jwk', 'alg'])) + self.mixed = JWS.sign( + payload='foo', key=self.privkey, alg=jwa.RS256, + protect=frozenset(['alg'])) + + def test_pubkey_jwk(self): + self.assertEqual(self.unprotected.signature.combined.jwk, self.pubkey) + self.assertEqual(self.protected.signature.combined.jwk, self.pubkey) + self.assertEqual(self.mixed.signature.combined.jwk, self.pubkey) + + def test_sign_unprotected(self): + self.assertTrue(self.unprotected.verify()) + + def test_sign_protected(self): + self.assertTrue(self.protected.verify()) + + def test_sign_mixed(self): + self.assertTrue(self.mixed.verify()) + + def test_compact_lost_unprotected(self): + compact = self.mixed.to_compact() + self.assertEqual( + 'eyJhbGciOiAiUlMyNTYifQ.Zm9v.KBvYScRMEqJlp2xsReoY3CNDpVCWEU' + '1PyRrf44nPBsmyQz__iuNR56pPNcACeHzJQnXhTVTxqFgjge2i_vw9NA', + compact) + + from letsencrypt.acme.jose.jws import JWS + mixed = JWS.from_compact(compact) + + self.assertNotEqual(self.mixed, mixed) + self.assertEqual( + set(['alg']), set(mixed.signature.combined.not_omitted())) + + def test_from_compact_missing_components(self): + from letsencrypt.acme.jose.jws import JWS + self.assertRaises(errors.DeserializationError, JWS.from_compact, '.') + + def test_json_omitempty(self): + protected_jobj = self.protected.to_json(flat=True) + unprotected_jobj = self.unprotected.to_json(flat=True) + + self.assertTrue('protected' not in unprotected_jobj) + self.assertTrue('header' not in protected_jobj) + + unprotected_jobj['header'] = unprotected_jobj[ + 'header'].fully_serialize() + + from letsencrypt.acme.jose.jws import JWS + self.assertEqual(JWS.from_json(protected_jobj), self.protected) + self.assertEqual(JWS.from_json(unprotected_jobj), self.unprotected) + + def test_json_flat(self): + jobj_to = { + 'signature': b64.b64encode(self.mixed.signature.signature), + 'payload': b64.b64encode('foo'), + 'header': self.mixed.signature.header, + 'protected': b64.b64encode(self.mixed.signature.protected), + } + jobj_from = jobj_to.copy() + jobj_from['header'] = jobj_from['header'].fully_serialize() + + self.assertEqual(self.mixed.to_json(flat=True), jobj_to) + from letsencrypt.acme.jose.jws import JWS + self.assertEqual(self.mixed, JWS.from_json(jobj_from)) + + def test_json_not_flat(self): + jobj_to = { + 'signatures': (self.mixed.signature,), + 'payload': b64.b64encode('foo'), + } + jobj_from = jobj_to.copy() + jobj_from['signatures'] = [jobj_to['signatures'][0].fully_serialize()] + + self.assertEqual(self.mixed.to_json(flat=False), jobj_to) + from letsencrypt.acme.jose.jws import JWS + self.assertEqual(self.mixed, JWS.from_json(jobj_from)) + + def test_from_json_mixed_flat(self): + from letsencrypt.acme.jose.jws import JWS + self.assertRaises(errors.DeserializationError, JWS.from_json, + {'signatures': (), 'signature': 'foo'}) + + +class CLITest(unittest.TestCase): + + def setUp(self): + self.key_path = pkg_resources.resource_filename( + __name__, os.path.join('testdata', 'rsa512_key.pem')) + + def test_unverified(self): + from letsencrypt.acme.jose.jws import CLI + with mock.patch('sys.stdin') as sin, mock.patch('sys.stdout'): + sin.read.return_value = '{"payload": "foo", "signature": "xxx"}' + self.assertEqual(-1, CLI.run(['verify'])) + + def test_json(self): + from letsencrypt.acme.jose.jws import CLI + + with mock.patch('sys.stdin') as sin, mock.patch('sys.stdout') as sout: + sin.read.return_value = 'foo' + CLI.run(['sign', '-k', self.key_path, '-a', 'RS256', + '-p', 'jwk']) + sin.read.return_value = sout.write.mock_calls[0][1][0] + self.assertEqual(0, CLI.run(['verify'])) + + def test_compact(self): + from letsencrypt.acme.jose.jws import CLI + + with mock.patch('sys.stdin') as sin, mock.patch('sys.stdout') as sout: + sin.read.return_value = 'foo' + CLI.run(['--compact', 'sign', '-k', self.key_path]) + sin.read.return_value = sout.write.mock_calls[0][1][0] + self.assertEqual(0, CLI.run([ + '--compact', 'verify', '--kty', 'RSA', '-k', self.key_path])) + + +if __name__ == '__main__': + unittest.main() diff --git a/setup.py b/setup.py index 46aca4434..436fcf0ea 100755 --- a/setup.py +++ b/setup.py @@ -107,6 +107,7 @@ setup( entry_points={ 'console_scripts': [ 'letsencrypt = letsencrypt.scripts.main:main', + 'jws = letsencrypt.acme.jose.jws:CLI.run', ], }, From e6b07f66d5f50a7368fc9c7dd0936c8fb222b84a Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 20 Mar 2015 23:17:41 +0000 Subject: [PATCH 11/54] Remove decoder2/encoder2 --- letsencrypt/acme/jose/json_util.py | 39 ++++++------------------- letsencrypt/acme/jose/json_util_test.py | 31 +++----------------- 2 files changed, 13 insertions(+), 57 deletions(-) diff --git a/letsencrypt/acme/jose/json_util.py b/letsencrypt/acme/jose/json_util.py index 8abcf5e32..8d32d5b8b 100644 --- a/letsencrypt/acme/jose/json_util.py +++ b/letsencrypt/acme/jose/json_util.py @@ -30,13 +30,7 @@ class Field(object): :class:`~letsencrypt.acme.jose.errors.SerializationError` (:class:`~letsencrypt.acme.jose.errors.DeserializationError`). - For greater flexibility, ``encoder2`` and ``decoder2`` accept two - parameters: the whole object ("``self``" in case of encoding, and - JSON serialized object ``jobj`` in case of decoding) and the value - to be encoded/decoded. - - Note, that ``decoder`` and ``decoder2`` should perform partial - serialization only. + Note, that ``decoder`` should perform partial serialization only. :ivar str json_name: Name of the field when encoded to JSON. :ivar default: Default value (used when not present in JSON object). @@ -52,14 +46,12 @@ class Field(object): 'fdec', 'fenc', 'fdec2', 'fenc2') def __init__(self, json_name, default=None, omitempty=False, - decoder=None, encoder=None, decoder2=None, encoder2=None): + decoder=None, encoder=None): # pylint: disable=too-many-arguments self.json_name = json_name self.default = default self.omitempty = omitempty - self.fdec2 = decoder2 - self.fenc2 = encoder2 self.fdec = self.default_decoder if decoder is None else decoder self.fenc = self.default_encoder if encoder is None else encoder @@ -80,37 +72,24 @@ class Field(object): def _update_params(self, **kwargs): current = dict(json_name=self.json_name, default=self.default, omitempty=self.omitempty, - decoder=self.fdec, encoder=self.fenc, - decoder2=self.fdec2, encoder2=self.fenc2) + decoder=self.fdec, encoder=self.fenc) current.update(kwargs) return type(self)(**current) # pylint: disable=star-args def decoder(self, fdec): """Descriptor to change the decoder on JSON object field.""" - return self._update_params(decoder=fdec, decoder2=None) + return self._update_params(decoder=fdec) def encoder(self, fenc): """Descriptor to change the encoder on JSON object field.""" - return self._update_params(encoder=fenc, encoder2=None) + return self._update_params(encoder=fenc) - def decoder2(self, fdec2): - """Descriptor to change the decoder2 on JSON object field.""" - return self._update_params(decoder2=fdec2, decoder=None) - - def encoder2(self, fenc2): - """Descriptor to change the encoder2 on JSON object field.""" - return self._update_params(encoder2=fenc2, encoder=None) - - def decode(self, value, jobj=None): + def decode(self, value): """Decode a value, optionally with context JSON object.""" - if self.fdec2 is not None: - return self.fdec2(jobj, value) return self.fdec(value) - def encode(self, value, obj=None): + def encode(self, value): """Encode a value, optionally with context JSON object.""" - if self.fenc2 is not None: - return self.fenc2(obj, value) return self.fenc(value) @classmethod @@ -241,7 +220,7 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable): logging.debug('Ommiting empty field "%s" (%s)', slot, value) else: try: - jobj[field.json_name] = field.encode(value, self) + jobj[field.json_name] = field.encode(value) except errors.SerializationError as error: raise errors.SerializationError( 'Could not encode {0} ({1}): {2}'.format( @@ -274,7 +253,7 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable): else: value = jobj[field.json_name] try: - fields[slot] = field.decode(value, jobj) + fields[slot] = field.decode(value) except errors.DeserializationError as error: raise errors.DeserializationError( 'Could not decode {0!r} ({1!r}): {2}'.format( diff --git a/letsencrypt/acme/jose/json_util_test.py b/letsencrypt/acme/jose/json_util_test.py index da548aaee..e5bffd294 100644 --- a/letsencrypt/acme/jose/json_util_test.py +++ b/letsencrypt/acme/jose/json_util_test.py @@ -21,8 +21,6 @@ class FieldTest(unittest.TestCase): """Tests for letsencrypt.acme.jose.json_util.Field.""" def test_descriptors(self): - mock_jobj = mock.MagicMock() - mock_obj = mock.MagicMock() mock_value = mock.MagicMock() # pylint: disable=missing-docstring @@ -33,36 +31,15 @@ class FieldTest(unittest.TestCase): def encoder(unused_value): return 'e' - def decoder2(jobj, unused_value): - self.assertTrue(jobj is mock_jobj) - return 'd2' - - def encoder2(obj, unused_value): - self.assertTrue(obj is mock_obj) - return 'e2' - from letsencrypt.acme.jose.json_util import Field - field = Field('foo', decoder=decoder, encoder=encoder, - decoder2=decoder2, encoder2=encoder2) - - self.assertEqual('e2', field.encode(mock_value, mock_obj)) - self.assertEqual('d2', field.decode(mock_value, mock_jobj)) + field = Field('foo') field = field.encoder(encoder) - self.assertEqual('e', field.encode(mock_value, mock_obj)) - self.assertEqual('d2', field.decode(mock_value, mock_jobj)) + self.assertEqual('e', field.encode(mock_value)) field = field.decoder(decoder) - self.assertEqual('e', field.encode(mock_value, mock_obj)) - self.assertEqual('d', field.decode(mock_value, mock_jobj)) - - field = field.encoder2(encoder2) - self.assertEqual('e2', field.encode(mock_value, mock_obj)) - self.assertEqual('d', field.decode(mock_value, mock_jobj)) - - field = field.decoder2(decoder2) - self.assertEqual('e2', field.encode(mock_value, mock_obj)) - self.assertEqual('d2', field.decode(mock_value, mock_jobj)) + self.assertEqual('e', field.encode(mock_value)) + self.assertEqual('d', field.decode(mock_value)) def test_default_encoder_is_partial(self): class MockField(interfaces.JSONDeSerializable): From 615b2c789d6512e9ac56e139273b792b9ceb8a0b Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 21 Mar 2015 07:30:55 +0000 Subject: [PATCH 12/54] _JWAHS.verify constant compare warning --- letsencrypt/acme/jose/jwa.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/letsencrypt/acme/jose/jwa.py b/letsencrypt/acme/jose/jwa.py index 99c9a8631..984a10f41 100644 --- a/letsencrypt/acme/jose/jwa.py +++ b/letsencrypt/acme/jose/jwa.py @@ -71,7 +71,12 @@ class _JWAHS(JWASignature): return HMAC.new(key, msg, self.digestmod).digest() def verify(self, key, msg, sig): - # TODO: use constant compare to mitigate timing attack? + """Verify the signature. + + .. warning:: + Does not protect against timing attack (no constant compare). + + """ return self.sign(key, msg) == sig From 5eb007cc317db5a12e01ded18e344a73155f7c1e Mon Sep 17 00:00:00 2001 From: Garrett Robinson Date: Fri, 20 Mar 2015 09:25:36 -0700 Subject: [PATCH 13/54] Add Vagrantfile, document use * Adds workaround to setup.py for issue with Vagrant sync filesystem and hard linking (used by distutils in Python < 2.7.9). This workaround is only used in a Vagrant environment. * Adds Vagrant-related files to .gitignore --- .gitignore | 2 ++ CONTRIBUTING.rst | 27 ++++++++++++++++++++++++++- Vagrantfile | 32 ++++++++++++++++++++++++++++++++ setup.py | 6 ++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 Vagrantfile diff --git a/.gitignore b/.gitignore index e1dca3a57..5b5cfb530 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ venv/ .tox/ .coverage m3 +*~ +.vagrant diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 9cb73a654..19dd8ab89 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -23,8 +23,33 @@ The following tools are there to help you: - ``./venv/bin/tox -e lint`` checks the style of the whole project, while ``./venv/bin/pylint --rcfile=.pylintrc file`` will check a single `file` only. - .. _coding-style: + +Vagrant +======= + +If you are a Vagrant user, Let's Encrypt comes with a Vagrantfile that automates +setting up a development environment in an Ubuntu 14.04 LTS VM. To set it up, +simply run ``vagrant up``. The repository is synced to ``/vagrant``, so you can +get started with: + +:: + + vagrant ssh + cd /vagrant + ./venv/bin/python setup.py install + sudo ./venv/bin/letsencrypt + +Support for other Linux distributions coming soon. + +**Note:** Unfortunately, Python distutils and, by extension, setup.py and tox, +use hard linking quite extensively. Hard linking is not supported by the +default sync filesystem in Vagrant. As a result, all actions with these +commands are *significantly slower* in Vagrant. One potential fix is to `use +NFS`_ (`related issue`_). + +.. _use NFS: http://docs.vagrantup.com/v2/synced-folders/nfs.html +.. _related issue: https://github.com/ClusterHQ/flocker/issues/516 Coding style ============ diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 000000000..a9e5494ac --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,32 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# Vagrantfile API/syntax version. Don't touch unless you know what you're doing! +VAGRANTFILE_API_VERSION = "2" + +# Setup instructions from docs/using.rst +$ubuntu_setup_script = < Date: Sat, 21 Mar 2015 20:17:29 +0000 Subject: [PATCH 14/54] Remove remnants of fdec2/fenc2 --- letsencrypt/acme/jose/json_util.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/letsencrypt/acme/jose/json_util.py b/letsencrypt/acme/jose/json_util.py index 8d32d5b8b..01eada89c 100644 --- a/letsencrypt/acme/jose/json_util.py +++ b/letsencrypt/acme/jose/json_util.py @@ -42,8 +42,7 @@ class Field(object): deserializing. """ - __slots__ = ('json_name', 'default', 'omitempty', - 'fdec', 'fenc', 'fdec2', 'fenc2') + __slots__ = ('json_name', 'default', 'omitempty', 'fdec', 'fenc') def __init__(self, json_name, default=None, omitempty=False, decoder=None, encoder=None): From 71d8999e7cf59a213bcb9ace3055f7446a9bcab0 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 21 Mar 2015 20:50:43 +0000 Subject: [PATCH 15/54] Bump up minimum coverage to 86% --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 485816d45..bb5ac1bb7 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ setenv = basepython = python2.7 commands = pip install -e .[testing] - python setup.py nosetests --with-coverage --cover-min-percentage=85 + python setup.py nosetests --with-coverage --cover-min-percentage=86 [testenv:lint] # recent versions of pylint do not support Python 2.6 (#97, #187) From 006fcbbf46b01d4de4664a80ff70032cc637c6e1 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 21 Mar 2015 20:54:02 +0000 Subject: [PATCH 16/54] py26 compat: with x, y -> with x: with y --- letsencrypt/acme/jose/jws_test.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/letsencrypt/acme/jose/jws_test.py b/letsencrypt/acme/jose/jws_test.py index 28dfd0c2f..6e6b51350 100644 --- a/letsencrypt/acme/jose/jws_test.py +++ b/letsencrypt/acme/jose/jws_test.py @@ -205,29 +205,33 @@ class CLITest(unittest.TestCase): def test_unverified(self): from letsencrypt.acme.jose.jws import CLI - with mock.patch('sys.stdin') as sin, mock.patch('sys.stdout'): + with mock.patch('sys.stdin') as sin: sin.read.return_value = '{"payload": "foo", "signature": "xxx"}' - self.assertEqual(-1, CLI.run(['verify'])) + with mock.patch('sys.stdout'): + self.assertEqual(-1, CLI.run(['verify'])) def test_json(self): from letsencrypt.acme.jose.jws import CLI - with mock.patch('sys.stdin') as sin, mock.patch('sys.stdout') as sout: + with mock.patch('sys.stdin') as sin: sin.read.return_value = 'foo' - CLI.run(['sign', '-k', self.key_path, '-a', 'RS256', - '-p', 'jwk']) - sin.read.return_value = sout.write.mock_calls[0][1][0] - self.assertEqual(0, CLI.run(['verify'])) + with mock.patch('sys.stdout') as sout: + CLI.run(['sign', '-k', self.key_path, '-a', 'RS256', + '-p', 'jwk']) + sin.read.return_value = sout.write.mock_calls[0][1][0] + self.assertEqual(0, CLI.run(['verify'])) def test_compact(self): from letsencrypt.acme.jose.jws import CLI - with mock.patch('sys.stdin') as sin, mock.patch('sys.stdout') as sout: + with mock.patch('sys.stdin') as sin: sin.read.return_value = 'foo' - CLI.run(['--compact', 'sign', '-k', self.key_path]) - sin.read.return_value = sout.write.mock_calls[0][1][0] - self.assertEqual(0, CLI.run([ - '--compact', 'verify', '--kty', 'RSA', '-k', self.key_path])) + with mock.patch('sys.stdout') as sout: + CLI.run(['--compact', 'sign', '-k', self.key_path]) + sin.read.return_value = sout.write.mock_calls[0][1][0] + self.assertEqual(0, CLI.run([ + '--compact', 'verify', '--kty', 'RSA', + '-k', self.key_path])) if __name__ == '__main__': From d06cd7aa39484035fb2a4e881afb6644e897f115 Mon Sep 17 00:00:00 2001 From: pde and jdkasten Date: Sat, 21 Mar 2015 13:56:40 -0700 Subject: [PATCH 17/54] Update dev docs - Import and edit James's API docs into CONTRIBUTING.rst - Linke correctly to CONTRIBUTING from the main README --- CONTRIBUTING.rst | 77 ++++++++++++++++++++++++++++++++++++++++++++++++ README.rst | 3 +- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index bfd0a1c0f..654d5569b 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -27,6 +27,83 @@ The following tools are there to help you: .. _installing dependencies and setting up Let's Encrypt: https://letsencrypt.readthedocs.org/en/latest/using.html +CODE COMPONENTS AND LAYOUT +========================== + +letsencrypt/acme - contains all protocol specific code +letsencrypt/client - all client code +letsencrypt/scripts - just the starting point of the code, main.py + +Plugin-architecture +------------------- + +Let's Encrypt has a plugin architecture to facilitate support for different +webservers, other TLS servers, and operating systems. + +The most common kind of plugin is a "Configurator", which is likely to +implement the "Authenticator" and "Installer" interfaces (though some +Configurators may implement just one of those). + +Defined here: +https://github.com/letsencrypt/lets-encrypt-preview/blob/master/letsencrypt/client/interfaces.py + +There are also "Display" plugins, which implement bindings to alternative UI +libraries. + +Authenticators +-------------- + +Authenticators are plugins designed to solve challenges received from the +ACME server. From the protocol, there are essentially two different types +of challenges. Challenges that must be solved by individual plugins in +order to satisfy domain validation (dvsni, simpleHttps, dns) and client +specific challenges (recoveryToken, recoveryContact, pop). Client specific +challenges are always handled by the "Authenticator" +client_authenticator.py. Right now we have two DV Authenticators, +apache/configurator.py and the standalone_authenticator.py. The Standalone +and Apache authenticators only solve the DVSNI challenge currently. (You +can set which challenges your authenticator can handle through the +get_chall_pref(domain) function) + +(FYI: We also have a partial implementation for a dns_authenticator in a +separate branch). + +Challenge types are defined here... +( +https://github.com/letsencrypt/lets-encrypt-preview/blob/master/letsencrypt/client/constants.py#L16 +) + +Installer +--------- + +Installers classes exist to actually setup the certificate and be able +to enhance the configuration. (Turn on HSTS, redirect to HTTPS, etc). You +can indicate your abilities through the supported_enhancements call. We +currently only have one Installer written (still developing), +apache/configurator.py + +Installers and Authenticators will oftentimes be the same class/object. +Installers and Authenticators are kept separate because it should be +possible to use the standalone_authenticator (it sets up its own Python +server to perform challenges) with a program that cannot solve challenges +itself. (I am imagining MTA installers). + +*Display* - we currently offer a pythondialog and "text" mode for +displays. I have rewritten the interface which should be merged within the +next day (the rewrite is in the revoker branch of the repo and should be +merged within the next day) + +Here is what the display interface will look like +https://github.com/letsencrypt/lets-encrypt-preview/blob/revoker/letsencrypt/client/interfaces.py#L217 + +Augeus +------ + +Some plugins, especially those designed to reconfigure UNIX servers, can take +inherit from the augeus_configurator.py class in order to more efficiently +handle common operations on UNIX server configuration files. + + .. _coding-style: Coding style ============ diff --git a/README.rst b/README.rst index c27279ade..86d85ed1d 100644 --- a/README.rst +++ b/README.rst @@ -80,7 +80,7 @@ Documentation: https://letsencrypt.readthedocs.org/ Software project: https://github.com/letsencrypt/lets-encrypt-preview -Notes for developers: [CONTRIBUTING.rst](/CONTRIBUTING.rst) +Notes for developers: CONTRIBUTING.rst_ Main Website: https://letsencrypt.org/ @@ -91,3 +91,4 @@ email to client-dev+subscribe@letsencrypt.org) .. _Freenode: https://freenode.net .. _client-dev: https://groups.google.com/a/letsencrypt.org/forum/#!forum/client-dev +.. _CONTRIBUTING.rst: https://github.com/letsencrypt/lets-encrypt-preview/blob/master/CONTRIBUTING.rst From 4e21703503f181b493b5fef93f830c91df0e7c61 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sat, 21 Mar 2015 15:14:58 -0700 Subject: [PATCH 18/54] add acme.jose and acme.schemata packages to setup.py --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index 570f77f13..038c0554a 100755 --- a/setup.py +++ b/setup.py @@ -93,6 +93,8 @@ setup( packages=[ 'letsencrypt', 'letsencrypt.acme', + 'letsencrypt.acme.jose', + 'letsencrypt.acme.schemata', 'letsencrypt.client', 'letsencrypt.client.apache', 'letsencrypt.client.display', From 12287e70fc149b3a8c0edd1c3b32b07169dd8f0f Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sat, 21 Mar 2015 16:20:32 -0700 Subject: [PATCH 19/54] remove schemata --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index 038c0554a..e0b1dcd63 100755 --- a/setup.py +++ b/setup.py @@ -94,7 +94,6 @@ setup( 'letsencrypt', 'letsencrypt.acme', 'letsencrypt.acme.jose', - 'letsencrypt.acme.schemata', 'letsencrypt.client', 'letsencrypt.client.apache', 'letsencrypt.client.display', From 0a981e02f42808f44ad6c6f68b1823ccf078894f Mon Sep 17 00:00:00 2001 From: Garrett Robinson Date: Fri, 20 Mar 2015 09:25:36 -0700 Subject: [PATCH 20/54] Bring back Vagrant documentation (fixes #309). --- CONTRIBUTING.rst | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 654d5569b..eda8045f3 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -27,6 +27,34 @@ The following tools are there to help you: .. _installing dependencies and setting up Let's Encrypt: https://letsencrypt.readthedocs.org/en/latest/using.html + +Vagrant +======= + +If you are a Vagrant user, Let's Encrypt comes with a Vagrantfile that automates +setting up a development environment in an Ubuntu 14.04 LTS VM. To set it up, +simply run ``vagrant up``. The repository is synced to ``/vagrant``, so you can +get started with: + +:: + + vagrant ssh + cd /vagrant + ./venv/bin/python setup.py install + sudo ./venv/bin/letsencrypt + +Support for other Linux distributions coming soon. + +**Note:** Unfortunately, Python distutils and, by extension, setup.py and tox, +use hard linking quite extensively. Hard linking is not supported by the +default sync filesystem in Vagrant. As a result, all actions with these +commands are *significantly slower* in Vagrant. One potential fix is to `use +NFS`_ (`related issue`_). + +.. _use NFS: http://docs.vagrantup.com/v2/synced-folders/nfs.html +.. _related issue: https://github.com/ClusterHQ/flocker/issues/516 + + CODE COMPONENTS AND LAYOUT ========================== From 37a7ef216064d2f01fb515888a8d93c23af5baaf Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sun, 22 Mar 2015 14:07:58 +0000 Subject: [PATCH 21/54] Reorg CONTRIBUTING --- CONTRIBUTING | 18 ++++++++++++++++++ CONTRIBUTING.rst => docs/contributing.rst | 4 ++++ docs/index.rst | 2 +- docs/project.rst | 5 ----- 4 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 CONTRIBUTING rename CONTRIBUTING.rst => docs/contributing.rst (99%) delete mode 100644 docs/project.rst diff --git a/CONTRIBUTING b/CONTRIBUTING new file mode 100644 index 000000000..d54f7beee --- /dev/null +++ b/CONTRIBUTING @@ -0,0 +1,18 @@ + + +http://letsencrypt.readthedocs.org/en/latest/contributing.html diff --git a/CONTRIBUTING.rst b/docs/contributing.rst similarity index 99% rename from CONTRIBUTING.rst rename to docs/contributing.rst index eda8045f3..d2104aee1 100644 --- a/CONTRIBUTING.rst +++ b/docs/contributing.rst @@ -1,3 +1,7 @@ +============ +Contributing +============ + .. _hacking: Hacking diff --git a/docs/index.rst b/docs/index.rst index b290b2231..34615168c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,7 @@ Welcome to the Let's Encrypt client documentation! intro using - project + contributing .. toctree:: :maxdepth: 1 diff --git a/docs/project.rst b/docs/project.rst deleted file mode 100644 index 421f0b062..000000000 --- a/docs/project.rst +++ /dev/null @@ -1,5 +0,0 @@ -================================ -The Let's Encrypt Client Project -================================ - -.. include:: ../CONTRIBUTING.rst From 3206eb674a744c020330a4e1dbf009888b858377 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sun, 22 Mar 2015 22:25:50 +0000 Subject: [PATCH 22/54] rst cleanup: contributing, using --- docs/conf.py | 2 +- docs/contributing.rst | 178 +++++++++++++++++++++++------------------- docs/using.rst | 22 +++--- 3 files changed, 108 insertions(+), 94 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 6e2c484ca..a6e5da4ff 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -101,7 +101,7 @@ exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +default_role = 'py:obj' # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True diff --git a/docs/contributing.rst b/docs/contributing.rst index d2104aee1..e3b81b3d4 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -8,16 +8,18 @@ Hacking ======= In order to start hacking, you will first have to create a development -environment. Start by `installing dependencies and setting up Let's Encrypt`_. +environment. Start by :doc:`installing dependencies and setting up +Let's Encrypt `. Now you can install the development packages: -:: +.. code-block:: shell - ./venv/bin/python setup.py dev + ./venv/bin/python setup.py dev -The code base, including your pull requests, **must** have 100% test statement -coverage **and** be compliant with the coding-style_. +The code base, including your pull requests, **must** have 100% test +statement coverage **and** be compliant with the :ref:`coding style +`. The following tools are there to help you: @@ -27,116 +29,127 @@ The following tools are there to help you: - ``./venv/bin/tox -e cover`` checks the test coverage only. - ``./venv/bin/tox -e lint`` checks the style of the whole project, - while ``./venv/bin/pylint --rcfile=.pylintrc file`` will check a single `file` only. + while ``./venv/bin/pylint --rcfile=.pylintrc file`` will check a + single ``file`` only. + +.. _installing dependencies and setting up Let's Encrypt: + https://letsencrypt.readthedocs.org/en/latest/using.html -.. _installing dependencies and setting up Let's Encrypt: https://letsencrypt.readthedocs.org/en/latest/using.html - Vagrant -======= +------- -If you are a Vagrant user, Let's Encrypt comes with a Vagrantfile that automates -setting up a development environment in an Ubuntu 14.04 LTS VM. To set it up, -simply run ``vagrant up``. The repository is synced to ``/vagrant``, so you can -get started with: +If you are a Vagrant user, Let's Encrypt comes with a Vagrantfile that +automates setting up a development environment in an Ubuntu 14.04 +LTS VM. To set it up, simply run ``vagrant up``. The repository is +synced to ``/vagrant``, so you can get started with: -:: +.. code-block:: shell - vagrant ssh - cd /vagrant - ./venv/bin/python setup.py install - sudo ./venv/bin/letsencrypt + vagrant ssh + cd /vagrant + ./venv/bin/python setup.py install + sudo ./venv/bin/letsencrypt Support for other Linux distributions coming soon. -**Note:** Unfortunately, Python distutils and, by extension, setup.py and tox, -use hard linking quite extensively. Hard linking is not supported by the -default sync filesystem in Vagrant. As a result, all actions with these -commands are *significantly slower* in Vagrant. One potential fix is to `use -NFS`_ (`related issue`_). +.. note:: + Unfortunately, Python distutils and, by extension, setup.py and + tox, use hard linking quite extensively. Hard linking is not + supported by the default sync filesystem in Vagrant. As a result, + all actions with these commands are *significantly slower* in + Vagrant. One potential fix is to `use NFS`_ (`related issue`_). .. _use NFS: http://docs.vagrantup.com/v2/synced-folders/nfs.html .. _related issue: https://github.com/ClusterHQ/flocker/issues/516 -CODE COMPONENTS AND LAYOUT +Code components and layout ========================== -letsencrypt/acme - contains all protocol specific code -letsencrypt/client - all client code -letsencrypt/scripts - just the starting point of the code, main.py +letsencrypt/acme + contains all protocol specific code +letsencrypt/client + all client code +letsencrypt/scripts + just the starting point of the code, main.py + Plugin-architecture ------------------- -Let's Encrypt has a plugin architecture to facilitate support for different -webservers, other TLS servers, and operating systems. +Let's Encrypt has a plugin architecture to facilitate support for +different webservers, other TLS servers, and operating systems. The most common kind of plugin is a "Configurator", which is likely to -implement the "Authenticator" and "Installer" interfaces (though some +implement the `~letsencrypt.client.interfaces.IAuthenticator` and +`~letsencrypt.client.interfaces.IInstaller` interfaces (though some Configurators may implement just one of those). -Defined here: -https://github.com/letsencrypt/lets-encrypt-preview/blob/master/letsencrypt/client/interfaces.py +There are also `~letsencrypt.client.interfaces.IDisplay` plugins, +which implement bindings to alternative UI libraries. -There are also "Display" plugins, which implement bindings to alternative UI -libraries. Authenticators -------------- -Authenticators are plugins designed to solve challenges received from the -ACME server. From the protocol, there are essentially two different types -of challenges. Challenges that must be solved by individual plugins in -order to satisfy domain validation (dvsni, simpleHttps, dns) and client -specific challenges (recoveryToken, recoveryContact, pop). Client specific -challenges are always handled by the "Authenticator" -client_authenticator.py. Right now we have two DV Authenticators, -apache/configurator.py and the standalone_authenticator.py. The Standalone -and Apache authenticators only solve the DVSNI challenge currently. (You -can set which challenges your authenticator can handle through the -get_chall_pref(domain) function) +Authenticators are plugins designed to solve challenges received from +the ACME server. From the protocol, there are essentially two +different types of challenges. Challenges that must be solved by +individual plugins in order to satisfy domain validation (subclasses +of `~.DVChallenge`, i.e. `~.challenges.DVSNI`, +`~.challenges.SimpleHTTPS`, `~.challenges.DNS`) and client specific +challenges (subclasses of `~.ClientChallenge`, +i.e. `~.challenges.RecoveryToken`, `~.challenges.RecoveryContact`, +`~.challenges.ProofOfPossession`). Client specific challenges are +always handled by the `~.ClientAuthenticator`. Right now we have two +DV Authenticators, `~.ApacheConfigurator` and the +`~.StandaloneAuthenticator`. The Standalone and Apache authenticators +only solve the `~.challenges.DVSNI` challenge currently. (You can set +which challenges your authenticator can handle through the +:meth:`~.IAuthenticator.get_chall_pref`. -(FYI: We also have a partial implementation for a dns_authenticator in a -separate branch). +(FYI: We also have a partial implementation for a `~.DNSAuthenticator` +in a separate branch). -Challenge types are defined here... -( -https://github.com/letsencrypt/lets-encrypt-preview/blob/master/letsencrypt/client/constants.py#L16 -) Installer --------- Installers classes exist to actually setup the certificate and be able -to enhance the configuration. (Turn on HSTS, redirect to HTTPS, etc). You -can indicate your abilities through the supported_enhancements call. We -currently only have one Installer written (still developing), -apache/configurator.py +to enhance the configuration. (Turn on HSTS, redirect to HTTPS, +etc). You can indicate your abilities through the +:meth:`~.IInstaller.supported_enhancements` call. We currently only +have one Installer written (still developing), `~.ApacheConfigurator`. -Installers and Authenticators will oftentimes be the same class/object. -Installers and Authenticators are kept separate because it should be -possible to use the standalone_authenticator (it sets up its own Python -server to perform challenges) with a program that cannot solve challenges -itself. (I am imagining MTA installers). +Installers and Authenticators will oftentimes be the same +class/object. Installers and Authenticators are kept separate because +it should be possible to use the `~.StandaloneAuthenticator` (it sets +up its own Python server to perform challenges) with a program that +cannot solve challenges itself. (I am imagining MTA installers). -*Display* - we currently offer a pythondialog and "text" mode for -displays. I have rewritten the interface which should be merged within the -next day (the rewrite is in the revoker branch of the repo and should be -merged within the next day) -Here is what the display interface will look like -https://github.com/letsencrypt/lets-encrypt-preview/blob/revoker/letsencrypt/client/interfaces.py#L217 +Display +~~~~~~~ -Augeus +We currently offer a pythondialog and "text" mode for displays. I have +rewritten the interface which should be merged within the next day +(the rewrite is in the revoker branch of the repo and should be merged +within the next day). Display plugins implement +`~letsencrypt.client.interfaces.IDisplay` interface. + + +Augeas ------ -Some plugins, especially those designed to reconfigure UNIX servers, can take -inherit from the augeus_configurator.py class in order to more efficiently -handle common operations on UNIX server configuration files. +Some plugins, especially those designed to reconfigure UNIX servers, +can take inherit from `~.AugeasConfigurator` class in order to more +efficiently handle common operations on UNIX server configuration +files. .. _coding-style: + Coding style ============ @@ -147,9 +160,7 @@ Please: 2. Read `PEP 8 - Style Guide for Python Code`_. 3. Follow the `Google Python Style Guide`_, with the exception that we - use `Sphinx-style`_ documentation: - - :: + use `Sphinx-style`_ documentation:: def foo(arg): """Short description. @@ -164,20 +175,23 @@ Please: 4. Remember to use ``./venv/bin/pylint``. -.. _Google Python Style Guide: https://google-styleguide.googlecode.com/svn/trunk/pyguide.html +.. _Google Python Style Guide: + https://google-styleguide.googlecode.com/svn/trunk/pyguide.html .. _Sphinx-style: http://sphinx-doc.org/ -.. _PEP 8 - Style Guide for Python Code: https://www.python.org/dev/peps/pep-0008 +.. _PEP 8 - Style Guide for Python Code: + https://www.python.org/dev/peps/pep-0008 -Updating the Documentation +Updating the documentation ========================== -In order to generate the Sphinx documentation, run the following commands. +In order to generate the Sphinx documentation, run the following +commands: -:: +.. code-block:: shell - cd docs - make clean html SPHINXBUILD=../venv/bin/sphinx-build + cd docs + make clean html SPHINXBUILD=../venv/bin/sphinx-build - -This should generate documentation in the ``docs/_build/html`` directory. +This should generate documentation in the ``docs/_build/html`` +directory. diff --git a/docs/using.rst b/docs/using.rst index 9b09833e4..362b75d81 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -21,30 +21,30 @@ In general: Ubuntu ------ -:: +.. code-block:: shell - sudo apt-get install python python-setuptools python-virtualenv python-dev \ - gcc swig dialog libaugeas0 libssl-dev libffi-dev \ - ca-certificates + sudo apt-get install python python-setuptools python-virtualenv python-dev \ + gcc swig dialog libaugeas0 libssl-dev libffi-dev \ + ca-certificates .. Please keep the above command in sync with .travis.yml (before_install) Mac OSX ------- -:: +.. code-block:: shell - sudo brew install augeas swig + sudo brew install augeas swig Installation ============ -:: +.. code-block:: shell - virtualenv --no-site-packages -p python2 venv - ./venv/bin/python setup.py install - sudo ./venv/bin/letsencrypt + virtualenv --no-site-packages -p python2 venv + ./venv/bin/python setup.py install + sudo ./venv/bin/letsencrypt Usage @@ -52,7 +52,7 @@ Usage The letsencrypt commandline tool has a builtin help: -:: +.. code-block:: shell ./venv/bin/letsencrypt --help From 55a80e768a84847ccb543edb4aef71bf0289b8f9 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sun, 22 Mar 2015 22:29:24 +0000 Subject: [PATCH 23/54] CONTRIBUTING: md file suffix --- CONTRIBUTING => CONTRIBUTING.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CONTRIBUTING => CONTRIBUTING.md (100%) diff --git a/CONTRIBUTING b/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING rename to CONTRIBUTING.md From 6d38b1b09e491ef78ac4db2eb2c3f1eb1927d039 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sun, 22 Mar 2015 22:30:57 +0000 Subject: [PATCH 24/54] HTTPS ReadTheDocs link in CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d54f7beee..bf19b18e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,4 +15,4 @@ to the Sphinx generated docs is provided below. --> -http://letsencrypt.readthedocs.org/en/latest/contributing.html +https://letsencrypt.readthedocs.org/en/latest/contributing.html From 1a0af51f6f6133116f33f3668ac3a2a5e7db230f Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Mon, 23 Mar 2015 08:25:44 +0000 Subject: [PATCH 25/54] Fix Sphinx M2Crypto.X509 import errors --- letsencrypt/acme/jose/jws.py | 2 +- letsencrypt/acme/jose/jws_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/acme/jose/jws.py b/letsencrypt/acme/jose/jws.py index 81106dd2c..3b962aede 100644 --- a/letsencrypt/acme/jose/jws.py +++ b/letsencrypt/acme/jose/jws.py @@ -3,7 +3,7 @@ import argparse import base64 import sys -import M2Crypto.X509 +import M2Crypto from letsencrypt.acme.jose import b64 from letsencrypt.acme.jose import errors diff --git a/letsencrypt/acme/jose/jws_test.py b/letsencrypt/acme/jose/jws_test.py index 6e6b51350..215960e15 100644 --- a/letsencrypt/acme/jose/jws_test.py +++ b/letsencrypt/acme/jose/jws_test.py @@ -5,7 +5,7 @@ import pkg_resources import unittest import Crypto.PublicKey.RSA -import M2Crypto.X509 +import M2Crypto import mock from letsencrypt.acme.jose import b64 From 53bdf5e24627cf94526a3c12f30e48b164d891e7 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Mon, 23 Mar 2015 08:27:57 +0000 Subject: [PATCH 26/54] Fix _enable_redirect docstring --- letsencrypt/client/apache/configurator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 93db689f8..89a2ff4e2 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -548,8 +548,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def _enable_redirect(self, ssl_vhost, unused_options): """Redirect all equivalent HTTP traffic to ssl_vhost. - .. todo:: This enhancement should be rewritten and will unfortunately - require lots of debugging by hand. + .. todo:: This enhancement should be rewritten and will + unfortunately require lots of debugging by hand. + Adds Redirect directive to the port 80 equivalent of ssl_vhost First the function attempts to find the vhost with equivalent ip addresses that serves on non-ssl ports From 533cfa42c74fa0f314805ea22846cda2e294615d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Mon, 23 Mar 2015 08:35:36 +0000 Subject: [PATCH 27/54] MANIFEST: Update CONTRIBUTING extension --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index bea6fd9bb..3bd657b87 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,6 @@ include README.rst include CHANGES.rst -include CONTRIBUTING.rst +include CONTRIBUTING.md include linter_plugin.py include letsencrypt/EULA recursive-include letsencrypt *.json From 71e17df03a5f9f3d87a244d7fd9656c0d2b8285d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Mon, 23 Mar 2015 09:19:13 +0000 Subject: [PATCH 28/54] InsecurePlatformWarning (fixes #304) --- letsencrypt/client/network.py | 3 +++ setup.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py index de6db575b..2719583c3 100644 --- a/letsencrypt/client/network.py +++ b/letsencrypt/client/network.py @@ -11,6 +11,9 @@ from letsencrypt.acme import messages from letsencrypt.client import errors +# https://urllib3.readthedocs.org/en/latest/security.html#insecureplatformwarning +requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() + logging.getLogger("requests").setLevel(logging.WARNING) diff --git a/setup.py b/setup.py index 520147433..45873e9e8 100755 --- a/setup.py +++ b/setup.py @@ -32,7 +32,9 @@ install_requires = [ 'ConfArgParse', 'jsonschema', 'mock', + 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) 'psutil>=2.1.0', # net_connections introduced in 2.1.0 + 'pyasn1', # urllib3 InsecurePlatformWarning (#304) 'pycrypto', 'PyOpenSSL', 'python-augeas', From 8e3a496b8b3f7be3e934d7b0ce1d87971862ab24 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 24 Mar 2015 16:21:09 -0700 Subject: [PATCH 29/54] simplify path_satisfied test --- letsencrypt/client/auth_handler.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 41c7a9f68..05f3722cf 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -203,9 +203,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes def _path_satisfied(self, dom): """Returns whether a path has been completely satisfied.""" - return all( - self.responses[dom][i] is not None and - self.responses[dom][i] is not False for i in self.paths[dom]) + # Make sure that there are no 'None' or 'False' entries along path. + return all(self.responses[dom][i] for i in self.paths[dom]) def _get_chall_pref(self, domain): """Return list of challenge preferences. From a1fe6039d874a15b8defaf45dc40ddd8cdad0db3 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 24 Mar 2015 17:49:38 -0700 Subject: [PATCH 30/54] Fix bug with no DVSNI challenges --- letsencrypt/client/apache/dvsni.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index b980fdb36..033bcde20 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -50,7 +50,7 @@ class ApacheDvsni(object): def perform(self): """Peform a DVSNI challenge.""" if not self.achalls: - return None + return [] # Save any changes to the configuration as a precaution # About to make temporary changes to the config self.configurator.save() @@ -160,19 +160,19 @@ class ApacheDvsni(object): ips = " ".join(str(i) for i in ip_addrs) document_root = os.path.join( self.configurator.config.config_dir, "dvsni_page/") - return ("\n" - "ServerName " + achall.nonce_domain + "\n" - "UseCanonicalName on\n" - "SSLStrictSNIVHostCheck on\n" - "\n" - "LimitRequestBody 1048576\n" - "\n" - "Include " + self.configurator.parser.loc["ssl_options"] + "\n" - "SSLCertificateFile " + self.get_cert_file(achall) + "\n" - "SSLCertificateKeyFile " + achall.key.file + "\n" - "\n" - "DocumentRoot " + document_root + "\n" - "\n\n") + return ("{0}" + "ServerName " + achall.nonce_domain + "{0}" + "UseCanonicalName on{0}" + "SSLStrictSNIVHostCheck on{0}" + "{0}" + "LimitRequestBody 1048576{0}" + "{0}" + "Include " + self.configurator.parser.loc["ssl_options"] + "{0}" + "SSLCertificateFile " + self.get_cert_file(achall) + "{0}" + "SSLCertificateKeyFile " + achall.key.file + "{0}" + "{0}" + "DocumentRoot " + document_root + "{0}" + "{0}{0}".format(os.linesep)) def get_cert_file(self, achall): """Returns standardized name for challenge certificate. From f5a6bb389ec5a2fb1806e15a0a365133282f6cb8 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 25 Mar 2015 10:21:01 +0000 Subject: [PATCH 31/54] Fix #316 --- letsencrypt/client/apache/dvsni.py | 40 +++++++++++++------ letsencrypt/client/tests/apache/dvsni_test.py | 2 +- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index 033bcde20..29ae57308 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -26,6 +26,23 @@ class ApacheDvsni(object): :param str challenge_conf: location of the challenge config file """ + + VHOST_TEMPLATE = """\ + + ServerName {server_name} + UseCanonicalName on + SSLStrictSNIVHostCheck on + + LimitRequestBody 1048576 + + Include {ssl_options_conf_path} + SSLCertificateFile {cert_path} + SSLCertificateKeyFile {key_path} + + DocumentRoot {document_root} + + +""" def __init__(self, configurator): self.configurator = configurator self.achalls = [] @@ -160,19 +177,16 @@ class ApacheDvsni(object): ips = " ".join(str(i) for i in ip_addrs) document_root = os.path.join( self.configurator.config.config_dir, "dvsni_page/") - return ("{0}" - "ServerName " + achall.nonce_domain + "{0}" - "UseCanonicalName on{0}" - "SSLStrictSNIVHostCheck on{0}" - "{0}" - "LimitRequestBody 1048576{0}" - "{0}" - "Include " + self.configurator.parser.loc["ssl_options"] + "{0}" - "SSLCertificateFile " + self.get_cert_file(achall) + "{0}" - "SSLCertificateKeyFile " + achall.key.file + "{0}" - "{0}" - "DocumentRoot " + document_root + "{0}" - "{0}{0}".format(os.linesep)) + # 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 CLRF, Python still + # parses it as '\n'... c.f.: + # https://docs.python.org/2.7/reference/lexical_analysis.html + return self.VHOST_TEMPLATE.format( + vhost=ips, server_name=achall.nonce_domain, + 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. diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/tests/apache/dvsni_test.py index 384e426bb..110916e94 100644 --- a/letsencrypt/client/tests/apache/dvsni_test.py +++ b/letsencrypt/client/tests/apache/dvsni_test.py @@ -60,7 +60,7 @@ class DvsniPerformTest(util.ApacheTest): def test_perform0(self): resp = self.sni.perform() - self.assertTrue(resp is None) + self.assertTrue(len(resp) == 0) def test_setup_challenge_cert(self): # This is a helper function that can be used for handling From 23e92da0b5873b6469f8f4ef58421d28d2a2af2f Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 25 Mar 2015 10:25:27 +0000 Subject: [PATCH 32/54] Fix typo --- letsencrypt/client/apache/dvsni.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index 29ae57308..71bd03c7e 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -179,7 +179,7 @@ class ApacheDvsni(object): self.configurator.config.config_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 CLRF, Python still + # Linux (Debian sid), when source file uses CRLF, Python still # parses it as '\n'... c.f.: # https://docs.python.org/2.7/reference/lexical_analysis.html return self.VHOST_TEMPLATE.format( From 7d834a0ae8b68138ac18bf92b54f33074a13869f Mon Sep 17 00:00:00 2001 From: James Kasten Date: Wed, 25 Mar 2015 10:46:22 -0700 Subject: [PATCH 33/54] assertTrue to assertEqual --- letsencrypt/client/tests/apache/dvsni_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/tests/apache/dvsni_test.py index 110916e94..f3e0e9ce5 100644 --- a/letsencrypt/client/tests/apache/dvsni_test.py +++ b/letsencrypt/client/tests/apache/dvsni_test.py @@ -60,7 +60,7 @@ class DvsniPerformTest(util.ApacheTest): def test_perform0(self): resp = self.sni.perform() - self.assertTrue(len(resp) == 0) + self.assertEqual(len(resp), 0) def test_setup_challenge_cert(self): # This is a helper function that can be used for handling From 8a9bd1ee0b58e0c54b0055cee20ecdac5cf0289f Mon Sep 17 00:00:00 2001 From: James Kasten Date: Wed, 25 Mar 2015 18:38:13 -0700 Subject: [PATCH 34/54] update/fix documentation of reverter --- letsencrypt/client/reverter.py | 36 ++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/letsencrypt/client/reverter.py b/letsencrypt/client/reverter.py index 715b44f80..ebb85a954 100644 --- a/letsencrypt/client/reverter.py +++ b/letsencrypt/client/reverter.py @@ -83,7 +83,8 @@ class Reverter(object): def view_config_changes(self): """Displays all saved checkpoints. - All checkpoints are printed to the console. + All checkpoints are printed by + :meth:`letsencrypt.client.interfaces.IDisplay.notification`. .. todo:: Decide on a policy for error handling, OSError IOError... @@ -130,17 +131,17 @@ class Reverter(object): os.linesep.join(output), display_util.HEIGHT) def add_to_temp_checkpoint(self, save_files, save_notes): - """Add files to temporary checkpoint + """Add files to temporary checkpoint. - param set save_files: set of filepaths to save - param str save_notes: notes about changes during the save + :param set save_files: set of filepaths to save + :param str save_notes: notes about changes during the save """ self._add_to_checkpoint_dir( self.config.temp_checkpoint_dir, save_files, save_notes) def add_to_checkpoint(self, save_files, save_notes): - """Add files to a permanent checkpoint + """Add files to a permanent checkpoint. :param set save_files: set of filepaths to save :param str save_notes: notes about changes during the save @@ -324,15 +325,18 @@ class Reverter(object): new_fd.close() def recovery_routine(self): - """Revert all previously modified files. + """Revert configuration to most recent finalized checkpoint. - First, any changes found in IConfig.temp_checkpoint_dir are removed, - then IN_PROGRESS changes are removed The order is important. - IN_PROGRESS is unable to add files that are already added by a TEMP - change. Thus TEMP must be rolled back first because that will be the - 'latest' occurrence of the file. + Remove all changes (temporary and permanent) that have not been + finalized. This is useful to protect against crashes and other + execution interruptions. """ + # First, any changes found in IConfig.temp_checkpoint_dir are removed, + # then IN_PROGRESS changes are removed The order is important. + # IN_PROGRESS is unable to add files that are already added by a TEMP + # change. Thus TEMP must be rolled back first because that will be the + # 'latest' occurrence of the file. self.revert_temporary_config() if os.path.isdir(self.config.in_progress_dir): try: @@ -385,11 +389,10 @@ class Reverter(object): return True def finalize_checkpoint(self, title): - """Move IN_PROGRESS checkpoint to timestamped checkpoint. + """Finalize the checkpoint. - Adds title to self.config.in_progress_dir CHANGES_SINCE - Move self.config.in_progress_dir to Backups directory and - rename the directory as a timestamp + Timestamps and permanently saves all changes made through the use + of :func:`~add_to_checkpoint` and :func:`~register_file_creation` :param str title: Title describing checkpoint @@ -397,6 +400,9 @@ class Reverter(object): checkpoint is not able to be finalized. """ + # Adds title to self.config.in_progress_dir CHANGES_SINCE + # Move self.config.in_progress_dir to Backups directory and + # rename the directory as a timestamp # Check to make sure an "in progress" directory exists if not os.path.isdir(self.config.in_progress_dir): return From ff532469a56875ff24cc8aada7215ee2700111ab Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 26 Mar 2015 13:55:23 +0000 Subject: [PATCH 35/54] Setuptools entry_points plugins --- docs/index.rst | 1 + docs/plugins.rst | 5 ++++ .../plugins/letsencrypt_example_plugins.py | 16 +++++++++++ examples/plugins/setup.py | 16 +++++++++++ letsencrypt/client/client.py | 27 +++++++++++++++++++ .../client/standalone_authenticator.py | 2 +- .../tests/standalone_authenticator_test.py | 24 ++++++++--------- letsencrypt/scripts/main.py | 8 +++--- setup.py | 6 +++++ 9 files changed, 87 insertions(+), 18 deletions(-) create mode 100644 docs/plugins.rst create mode 100644 examples/plugins/letsencrypt_example_plugins.py create mode 100644 examples/plugins/setup.py diff --git a/docs/index.rst b/docs/index.rst index 34615168c..72be096f9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ Welcome to the Let's Encrypt client documentation! intro using contributing + plugins .. toctree:: :maxdepth: 1 diff --git a/docs/plugins.rst b/docs/plugins.rst new file mode 100644 index 000000000..552985aab --- /dev/null +++ b/docs/plugins.rst @@ -0,0 +1,5 @@ +======= +Plugins +======= + +You can find an example in ``examples/plugins/`` directory. diff --git a/examples/plugins/letsencrypt_example_plugins.py b/examples/plugins/letsencrypt_example_plugins.py new file mode 100644 index 000000000..6817c7f1d --- /dev/null +++ b/examples/plugins/letsencrypt_example_plugins.py @@ -0,0 +1,16 @@ +"""Example Let's Encrypt plugins.""" +import zope.interface + +from letsencrypt.client import interfaces + + +class Authenticator(object): + zope.interface.implements(interfaces.IAuthenticator) + + description = 'Example Authenticator plugin' + + def __init__(self, config): + self.config = config + + # Implement all methods from IAuthenticator, remembering to add + # "self" as first argument, e.g. def prepare(self)... diff --git a/examples/plugins/setup.py b/examples/plugins/setup.py new file mode 100644 index 000000000..845d6eb66 --- /dev/null +++ b/examples/plugins/setup.py @@ -0,0 +1,16 @@ +from setuptools import setup + + +setup( + name='letsencrypt-example-plugins', + package='letsencrypt_example_plugins.py', + install_requires=[ + 'letsencrypt', + 'zope.interface', + ], + entry_points={ + 'letsencrypt.authenticators': [ + 'example = letsencrypt_example_plugins:Authenticator', + ], + }, +) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 2f3f9a769..a448c10ce 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,11 +1,15 @@ """ACME protocol client class and helper functions.""" import logging import os +import pkg_resources import sys import Crypto.PublicKey.RSA import M2Crypto +import zope.interface.exceptions +import zope.interface.verify + from letsencrypt.acme import messages from letsencrypt.acme.jose import util as jose_util @@ -13,6 +17,7 @@ from letsencrypt.client import auth_handler from letsencrypt.client import client_authenticator from letsencrypt.client import crypto_util from letsencrypt.client import errors +from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import network from letsencrypt.client import reverter @@ -23,6 +28,28 @@ from letsencrypt.client.display import ops as display_ops from letsencrypt.client.display import enhancements +SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT = 'letsencrypt.authenticators' +"""Setuptools entry point group name for Authenticator plugins.""" + + +def init_auths(config): + """Find (setuptools entry points) and initialize Authenticators.""" + auths = {} + for entrypoint in pkg_resources.iter_entry_points( + SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT): + auth_cls = entrypoint.load() + auth = auth_cls(config) + try: + zope.interface.verify.verifyObject(interfaces.IAuthenticator, auth) + except zope.interface.exceptions.BrokenImplementation: + logging.debug( + '"%s" object does not provide IAuthenticator, skipping', + entrypoint.name) + else: + auths[auth] = entrypoint.name + return auths + + class Client(object): """ACME protocol client. diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/standalone_authenticator.py index bf08a39ec..22597eba7 100644 --- a/letsencrypt/client/standalone_authenticator.py +++ b/letsencrypt/client/standalone_authenticator.py @@ -33,7 +33,7 @@ class StandaloneAuthenticator(object): description = "Standalone Authenticator" - def __init__(self): + def __init__(self, unused_config): self.child_pid = None self.parent_pid = os.getpid() self.subproc_state = None diff --git a/letsencrypt/client/tests/standalone_authenticator_test.py b/letsencrypt/client/tests/standalone_authenticator_test.py index 9adf6a167..62b955e7e 100644 --- a/letsencrypt/client/tests/standalone_authenticator_test.py +++ b/letsencrypt/client/tests/standalone_authenticator_test.py @@ -51,7 +51,7 @@ class ChallPrefTest(unittest.TestCase): def setUp(self): from letsencrypt.client.standalone_authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) def test_chall_pref(self): self.assertEqual(self.authenticator.get_chall_pref("example.com"), @@ -63,7 +63,7 @@ class SNICallbackTest(unittest.TestCase): def setUp(self): from letsencrypt.client.standalone_authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) test_key = pkg_resources.resource_string( __name__, "testdata/rsa256_key.pem") key = le_util.Key("foo", test_key) @@ -106,7 +106,7 @@ class ClientSignalHandlerTest(unittest.TestCase): def setUp(self): from letsencrypt.client.standalone_authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} self.authenticator.child_pid = 12345 @@ -135,7 +135,7 @@ class SubprocSignalHandlerTest(unittest.TestCase): def setUp(self): from letsencrypt.client.standalone_authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} self.authenticator.child_pid = 12345 self.authenticator.parent_pid = 23456 @@ -187,7 +187,7 @@ class AlreadyListeningTest(unittest.TestCase): def setUp(self): from letsencrypt.client.standalone_authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) @mock.patch("letsencrypt.client.standalone_authenticator.psutil." "net_connections") @@ -290,7 +290,7 @@ class PerformTest(unittest.TestCase): def setUp(self): from letsencrypt.client.standalone_authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) test_key = pkg_resources.resource_string( __name__, "testdata/rsa256_key.pem") @@ -367,7 +367,7 @@ class StartListenerTest(unittest.TestCase): def setUp(self): from letsencrypt.client.standalone_authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) @mock.patch("letsencrypt.client.standalone_authenticator." "Crypto.Random.atfork") @@ -402,7 +402,7 @@ class DoParentProcessTest(unittest.TestCase): def setUp(self): from letsencrypt.client.standalone_authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") @mock.patch("letsencrypt.client.standalone_authenticator." @@ -452,7 +452,7 @@ class DoChildProcessTest(unittest.TestCase): def setUp(self): from letsencrypt.client.standalone_authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) test_key = pkg_resources.resource_string( __name__, "testdata/rsa256_key.pem") key = le_util.Key("foo", test_key) @@ -545,7 +545,7 @@ class CleanupTest(unittest.TestCase): def setUp(self): from letsencrypt.client.standalone_authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) self.achall = achallenges.DVSNI( chall=challenges.DVSNI(r="whee", nonce="foononce"), domain="foo.example.com", key="key") @@ -575,7 +575,7 @@ class MoreInfoTest(unittest.TestCase): def setUp(self): from letsencrypt.client.standalone_authenticator import ( StandaloneAuthenticator) - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) def test_more_info(self): """Make sure exceptions aren't raised.""" @@ -587,7 +587,7 @@ class InitTest(unittest.TestCase): def setUp(self): from letsencrypt.client.standalone_authenticator import ( StandaloneAuthenticator) - self.authenticator = StandaloneAuthenticator() + self.authenticator = StandaloneAuthenticator(None) def test_prepare(self): """Make sure exceptions aren't raised. diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 11caf944a..d3c2318d6 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -136,12 +136,10 @@ def main(): # pylint: disable=too-many-branches, too-many-statements if not args.eula: display_eula() - all_auths = [ - configurator.ApacheConfigurator(config), - standalone.StandaloneAuthenticator(), - ] + all_auths = client.init_auths(config) + logging.debug('Initialized authenticators: %s', all_auths) try: - auth = client.determine_authenticator(all_auths) + auth = client.determine_authenticator(all_auths.keys()) except errors.LetsEncryptClientError: logging.critical("No authentication mechanisms were found on your " "system.") diff --git a/setup.py b/setup.py index fac3eef90..c07c1f2ce 100644 --- a/setup.py +++ b/setup.py @@ -119,6 +119,12 @@ setup( 'letsencrypt = letsencrypt.scripts.main:main', 'jws = letsencrypt.acme.jose.jws:CLI.run', ], + 'letsencrypt.authenticators': [ + 'apache = letsencrypt.client.apache.configurator' + ':ApacheConfigurator', + 'standalone = letsencrypt.client.standalone_authenticator' + ':StandaloneAuthenticator', + ], }, zip_safe=False, From 03383c38241bbb4fb0b7b1c438c5652937c8140d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 26 Mar 2015 13:59:33 +0000 Subject: [PATCH 36/54] Fix quotes --- letsencrypt/client/client.py | 4 ++-- letsencrypt/scripts/main.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index a448c10ce..01f5e1c80 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -28,7 +28,7 @@ from letsencrypt.client.display import ops as display_ops from letsencrypt.client.display import enhancements -SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT = 'letsencrypt.authenticators' +SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT = "letsencrypt.authenticators" """Setuptools entry point group name for Authenticator plugins.""" @@ -43,7 +43,7 @@ def init_auths(config): zope.interface.verify.verifyObject(interfaces.IAuthenticator, auth) except zope.interface.exceptions.BrokenImplementation: logging.debug( - '"%s" object does not provide IAuthenticator, skipping', + "%r object does not provide IAuthenticator, skipping", entrypoint.name) else: auths[auth] = entrypoint.name diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index d3c2318d6..d51288c3a 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -137,7 +137,7 @@ def main(): # pylint: disable=too-many-branches, too-many-statements display_eula() all_auths = client.init_auths(config) - logging.debug('Initialized authenticators: %s', all_auths) + logging.debug('Initialized authenticators: %s', all_auths.values()) try: auth = client.determine_authenticator(all_auths.keys()) except errors.LetsEncryptClientError: From d9871b59f032bb64ddd5d65b6dfbb7619d3c7cc5 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 26 Mar 2015 14:42:07 +0000 Subject: [PATCH 37/54] pylint: unused imports --- letsencrypt/scripts/main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index d51288c3a..8b2c62935 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -20,8 +20,6 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import log -from letsencrypt.client import standalone_authenticator as standalone -from letsencrypt.client.apache import configurator from letsencrypt.client.display import util as display_util from letsencrypt.client.display import ops as display_ops From 8e32ae82467a0d2597eb835a3ed8a1d5546e72ad Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 26 Mar 2015 22:00:00 +0000 Subject: [PATCH 38/54] Move init_auths to scripts/main.py. --- letsencrypt/client/client.py | 27 --------------------------- letsencrypt/scripts/main.py | 26 +++++++++++++++++++++++++- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 01f5e1c80..2f3f9a769 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,15 +1,11 @@ """ACME protocol client class and helper functions.""" import logging import os -import pkg_resources import sys import Crypto.PublicKey.RSA import M2Crypto -import zope.interface.exceptions -import zope.interface.verify - from letsencrypt.acme import messages from letsencrypt.acme.jose import util as jose_util @@ -17,7 +13,6 @@ from letsencrypt.client import auth_handler from letsencrypt.client import client_authenticator from letsencrypt.client import crypto_util from letsencrypt.client import errors -from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import network from letsencrypt.client import reverter @@ -28,28 +23,6 @@ from letsencrypt.client.display import ops as display_ops from letsencrypt.client.display import enhancements -SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT = "letsencrypt.authenticators" -"""Setuptools entry point group name for Authenticator plugins.""" - - -def init_auths(config): - """Find (setuptools entry points) and initialize Authenticators.""" - auths = {} - for entrypoint in pkg_resources.iter_entry_points( - SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT): - auth_cls = entrypoint.load() - auth = auth_cls(config) - try: - zope.interface.verify.verifyObject(interfaces.IAuthenticator, auth) - except zope.interface.exceptions.BrokenImplementation: - logging.debug( - "%r object does not provide IAuthenticator, skipping", - entrypoint.name) - else: - auths[auth] = entrypoint.name - return auths - - class Client(object): """ACME protocol client. diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 8b2c62935..3b4b7c10d 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -11,6 +11,8 @@ import sys import confargparse import zope.component +import zope.interface.exceptions +import zope.interface.verify import letsencrypt @@ -24,6 +26,28 @@ from letsencrypt.client.display import util as display_util from letsencrypt.client.display import ops as display_ops +SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT = "letsencrypt.authenticators" +"""Setuptools entry point group name for Authenticator plugins.""" + + +def init_auths(config): + """Find (setuptools entry points) and initialize Authenticators.""" + auths = {} + for entrypoint in pkg_resources.iter_entry_points( + SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT): + auth_cls = entrypoint.load() + auth = auth_cls(config) + try: + zope.interface.verify.verifyObject(interfaces.IAuthenticator, auth) + except zope.interface.exceptions.BrokenImplementation: + logging.debug( + "%r object does not provide IAuthenticator, skipping", + entrypoint.name) + else: + auths[auth] = entrypoint.name + return auths + + def create_parser(): """Create parser.""" parser = confargparse.ConfArgParser( @@ -134,7 +158,7 @@ def main(): # pylint: disable=too-many-branches, too-many-statements if not args.eula: display_eula() - all_auths = client.init_auths(config) + all_auths = init_auths(config) logging.debug('Initialized authenticators: %s', all_auths.values()) try: auth = client.determine_authenticator(all_auths.keys()) From 6b78789ea3963ba406538d733f9ff65db8337fa7 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 26 Mar 2015 22:12:40 +0000 Subject: [PATCH 39/54] Improve plugins.rst --- docs/plugins.rst | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/plugins.rst b/docs/plugins.rst index 552985aab..fafb8d5d3 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -2,4 +2,12 @@ Plugins ======= -You can find an example in ``examples/plugins/`` directory. +Let's Encrypt client supports dynamic discovery of plugins through the +`setuptools entry points`_. This way you can, for example, create a +custom implementation of +`~letsencrypt.client.interfaces.IAuthenticator` without having to +merge it with the core upstream source code. Example is provided in +``examples/plugins/`` directory. + +.. _`setuptools entry points`: + https://pythonhosted.org/setuptools/setuptools.html#dynamic-discovery-of-services-and-plugins From 2d848994f4aec4a640ac6c526d9ea28f68dc8ff2 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 26 Mar 2015 17:23:17 -0700 Subject: [PATCH 40/54] Move IAuthenticators and IInstallers into plugins dir --- letsencrypt/client/apache/__init__.py | 1 - letsencrypt/client/client.py | 2 +- letsencrypt/client/constants.py | 2 +- letsencrypt/client/plugins/__init__.py | 1 + letsencrypt/client/plugins/apache/__init__.py | 1 + .../{ => plugins}/apache/configurator.py | 45 ++++--- .../client/{ => plugins}/apache/dvsni.py | 11 +- .../client/{ => plugins}/apache/obj.py | 0 .../{ => plugins}/apache/options-ssl.conf | 0 .../client/{ => plugins}/apache/parser.py | 0 .../apache/tests}/__init__.py | 0 .../apache/tests}/configurator_test.py | 32 +++-- .../apache/tests}/dvsni_test.py | 20 +-- .../apache/tests}/obj_test.py | 14 +- .../apache/tests}/parser_test.py | 20 +-- .../default_vhost/apache2/apache2.conf | 0 .../other-vhosts-access-log.conf | 0 .../apache2/conf-available/security.conf | 0 .../apache2/conf-available/serve-cgi-bin.conf | 0 .../conf-enabled/other-vhosts-access-log.conf | 0 .../apache2/conf-enabled/security.conf | 0 .../apache2/conf-enabled/serve-cgi-bin.conf | 0 .../default_vhost/apache2/envvars | 0 .../apache2/mods-available/ssl.conf | 0 .../apache2/mods-available/ssl.load | 0 .../default_vhost/apache2/ports.conf | 0 .../apache2/sites-available/000-default.conf | 0 .../apache2/sites-available/default-ssl.conf | 0 .../apache2/sites-enabled/000-default.conf | 0 .../debian_apache_2_4/default_vhost/sites | 0 .../two_vhost_80/apache2/apache2.conf | 0 .../other-vhosts-access-log.conf | 0 .../apache2/conf-available/security.conf | 0 .../apache2/conf-available/serve-cgi-bin.conf | 0 .../conf-enabled/other-vhosts-access-log.conf | 0 .../apache2/conf-enabled/security.conf | 0 .../apache2/conf-enabled/serve-cgi-bin.conf | 0 .../two_vhost_80/apache2/envvars | 0 .../apache2/mods-available/ssl.conf | 0 .../apache2/mods-available/ssl.load | 0 .../two_vhost_80/apache2/ports.conf | 0 .../apache2/sites-available/000-default.conf | 0 .../apache2/sites-available/default-ssl.conf | 0 .../sites-available/encryption-example.conf | 0 .../apache2/sites-available/letsencrypt.conf | 0 .../apache2/sites-enabled/000-default.conf | 0 .../sites-enabled/encryption-example.conf | 0 .../apache2/sites-enabled/letsencrypt.conf | 0 .../debian_apache_2_4/two_vhost_80/sites | 0 .../apache => plugins/apache/tests}/util.py | 14 +- .../client/plugins/standalone/__init__.py | 1 + .../standalone/authenticator.py} | 0 .../plugins/standalone/tests/__init__.py | 1 + .../standalone/tests/authenticator_test.py} | 125 ++++++++++-------- letsencrypt/client/tests/client_test.py | 8 +- letsencrypt/client/tests/revoker_test.py | 2 +- setup.py | 11 +- 57 files changed, 173 insertions(+), 138 deletions(-) delete mode 100644 letsencrypt/client/apache/__init__.py create mode 100644 letsencrypt/client/plugins/__init__.py create mode 100644 letsencrypt/client/plugins/apache/__init__.py rename letsencrypt/client/{ => plugins}/apache/configurator.py (96%) rename letsencrypt/client/{ => plugins}/apache/dvsni.py (95%) rename letsencrypt/client/{ => plugins}/apache/obj.py (100%) rename letsencrypt/client/{ => plugins}/apache/options-ssl.conf (100%) rename letsencrypt/client/{ => plugins}/apache/parser.py (100%) rename letsencrypt/client/{tests/apache => plugins/apache/tests}/__init__.py (100%) rename letsencrypt/client/{tests/apache => plugins/apache/tests}/configurator_test.py (87%) rename letsencrypt/client/{tests/apache => plugins/apache/tests}/dvsni_test.py (91%) rename letsencrypt/client/{tests/apache => plugins/apache/tests}/obj_test.py (82%) rename letsencrypt/client/{tests/apache => plugins/apache/tests}/parser_test.py (84%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/apache2.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/other-vhosts-access-log.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/security.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/serve-cgi-bin.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/other-vhosts-access-log.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/security.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/serve-cgi-bin.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/envvars (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.load (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/ports.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/000-default.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/default-ssl.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-enabled/000-default.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/default_vhost/sites (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/apache2.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/other-vhosts-access-log.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/security.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/serve-cgi-bin.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/other-vhosts-access-log.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/security.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/serve-cgi-bin.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/envvars (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.load (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/ports.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/000-default.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/encryption-example.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/letsencrypt.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/000-default.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/encryption-example.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/letsencrypt.conf (100%) rename letsencrypt/client/{ => plugins/apache}/tests/testdata/debian_apache_2_4/two_vhost_80/sites (100%) rename letsencrypt/client/{tests/apache => plugins/apache/tests}/util.py (89%) create mode 100644 letsencrypt/client/plugins/standalone/__init__.py rename letsencrypt/client/{standalone_authenticator.py => plugins/standalone/authenticator.py} (100%) create mode 100644 letsencrypt/client/plugins/standalone/tests/__init__.py rename letsencrypt/client/{tests/standalone_authenticator_test.py => plugins/standalone/tests/authenticator_test.py} (84%) diff --git a/letsencrypt/client/apache/__init__.py b/letsencrypt/client/apache/__init__.py deleted file mode 100644 index f1b2c08e7..000000000 --- a/letsencrypt/client/apache/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Let's Encrypt client.apache.""" diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 01f5e1c80..09817cc21 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -23,7 +23,7 @@ from letsencrypt.client import network from letsencrypt.client import reverter from letsencrypt.client import revoker -from letsencrypt.client.apache import configurator +from letsencrypt.client.plugins.apache import configurator from letsencrypt.client.display import ops as display_ops from letsencrypt.client.display import enhancements diff --git a/letsencrypt/client/constants.py b/letsencrypt/client/constants.py index 3e27d88ac..43cf5e8a0 100644 --- a/letsencrypt/client/constants.py +++ b/letsencrypt/client/constants.py @@ -31,7 +31,7 @@ List of expected options parameters: APACHE_MOD_SSL_CONF = pkg_resources.resource_filename( - "letsencrypt.client.apache", "options-ssl.conf") + "letsencrypt.client.plugins.apache", "options-ssl.conf") """Path to the Apache mod_ssl config file found in the Let's Encrypt distribution.""" diff --git a/letsencrypt/client/plugins/__init__.py b/letsencrypt/client/plugins/__init__.py new file mode 100644 index 000000000..538189015 --- /dev/null +++ b/letsencrypt/client/plugins/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.plugins.""" diff --git a/letsencrypt/client/plugins/apache/__init__.py b/letsencrypt/client/plugins/apache/__init__.py new file mode 100644 index 000000000..70172b06d --- /dev/null +++ b/letsencrypt/client/plugins/apache/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.plugins.apache.""" diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/plugins/apache/configurator.py similarity index 96% rename from letsencrypt/client/apache/configurator.py rename to letsencrypt/client/plugins/apache/configurator.py index 89a2ff4e2..5b682216b 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/plugins/apache/configurator.py @@ -18,9 +18,9 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util -from letsencrypt.client.apache import dvsni -from letsencrypt.client.apache import obj -from letsencrypt.client.apache import parser +from letsencrypt.client.plugins.apache import dvsni +from letsencrypt.client.plugins.apache import obj +from letsencrypt.client.plugins.apache import parser # TODO: Augeas sections ie. , beginning and closing @@ -68,11 +68,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :type config: :class:`~letsencrypt.client.interfaces.IConfig` :ivar parser: Handles low level parsing - :type parser: :class:`letsencrypt.client.apache.parser` + :type parser: :class:`letsencrypt.client.plugins.apache.parser` :ivar tup version: version of Apache :ivar list vhosts: All vhosts found in the configuration - (:class:`list` of :class:`letsencrypt.client.apache.obj.VirtualHost`) + (:class:`list` of + :class:`letsencrypt.client.plugins.apache.obj.VirtualHost`) :ivar dict assoc: Mapping between domains and vhosts @@ -203,7 +204,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str target_name: domain name :returns: ssl vhost associated with name - :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` + :rtype: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` """ # Allows for domain names to be associated with a virtual host @@ -244,7 +245,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str domain: domain name to associate :param vhost: virtual host to associate with domain - :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type vhost: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` """ self.assoc[domain] = vhost @@ -281,7 +282,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Helper function for get_virtual_hosts(). :param host: In progress vhost whose names will be added - :type host: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type host: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` """ name_match = self.aug.match(("%s//*[self::directive=~regexp('%s')] | " @@ -302,7 +303,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str path: Augeas path to virtual host :returns: newly created vhost - :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` + :rtype: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` """ addrs = set() @@ -326,7 +327,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Returns list of virtual hosts found in the Apache configuration. :returns: List of - :class:`letsencrypt.client.apache.obj.VirtualHost` objects + :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` objects found in configuration :rtype: list @@ -404,7 +405,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Checks to see if the server is ready for SNI challenges. :param vhost: VirtualHost to check SNI compatibility - :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type vhost: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` :param str default_addr: TODO - investigate function further @@ -436,10 +437,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. note:: This function saves the configuration :param nonssl_vhost: Valid VH that doesn't have SSLEngine on - :type nonssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type nonssl_vhost: :class:`~apache.obj.VirtualHost` :returns: SSL vhost - :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` + :rtype: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` """ avail_fp = nonssl_vhost.filep @@ -559,13 +560,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. note:: This function saves the configuration :param ssl_vhost: Destination of traffic, an ssl enabled vhost - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type ssl_vhost: :class:`~apache.obj.VirtualHost` :param unused_options: Not currently used :type unused_options: Not Available :returns: Success, general_vhost (HTTP vhost) - :rtype: (bool, :class:`letsencrypt.client.apache.obj.VirtualHost`) + :rtype: (bool, :class:`~apache.obj.VirtualHost`) """ if not mod_loaded("rewrite_module", self.config.apache_ctl): @@ -617,7 +618,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): -1 is also returned in case of no redirection/rewrite directives :param vhost: vhost to check - :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type vhost: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: Success, code value... see documentation :rtype: bool, int @@ -649,10 +650,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Creates an http_vhost specifically to redirect for the ssl_vhost. :param ssl_vhost: ssl vhost - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type ssl_vhost: :class:`~apache.obj.VirtualHost` :returns: Success, vhost - :rtype: (bool, :class:`letsencrypt.client.apache.obj.VirtualHost`) + :rtype: (bool, :class:`~apache.obj.VirtualHost`) """ # Consider changing this to a dictionary check @@ -734,7 +735,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if not conflict: returns space separated list of new host addrs :param ssl_vhost: SSL Vhost to check for possible port 80 redirection - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type ssl_vhost: :class:`~apache.obj.VirtualHost` :returns: TODO :rtype: TODO @@ -767,10 +768,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): Consider changing this into a dict check :param ssl_vhost: ssl vhost to check - :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type ssl_vhost: :class:`~apache.obj.VirtualHost` :returns: HTTP vhost or None if unsuccessful - :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` or None + :rtype: :class:`~apache.obj.VirtualHost` or None """ # _default_:443 check @@ -860,7 +861,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. todo:: Make sure link is not broken... :param vhost: vhost to enable - :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + :type vhost: :class:`~apache.obj.VirtualHost` :returns: Success :rtype: bool diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/plugins/apache/dvsni.py similarity index 95% rename from letsencrypt/client/apache/dvsni.py rename to letsencrypt/client/plugins/apache/dvsni.py index 71bd03c7e..2e1c948aa 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/plugins/apache/dvsni.py @@ -2,20 +2,19 @@ import logging import os -from letsencrypt.client.apache import parser +from letsencrypt.client.plugins.apache import parser class ApacheDvsni(object): """Class performs DVSNI challenges within the Apache configurator. :ivar configurator: ApacheConfigurator object - :type configurator: - :class:`letsencrypt.client.apache.configurator.ApacheConfigurator` + :type configurator: :class:`~apache.configurator.ApacheConfigurator` :ivar list achalls: Annotated :class:`~letsencrypt.client.achallenges.DVSNI` challenges. - :param list indicies: Meant to hold indices of challenges in a + :param list indices: Meant to hold indices of challenges in a 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 @@ -129,7 +128,7 @@ class ApacheDvsni(object): Result: Apache config includes virtual servers for issued challs :param list ll_addrs: list of list of - :class:`letsencrypt.client.apache.obj.Addr` to apply + :class:`letsencrypt.client.plugins.apache.obj.Addr` to apply """ # TODO: Use ip address of existing vhost instead of relying on FQDN @@ -168,7 +167,7 @@ class ApacheDvsni(object): :type achall: :class:`letsencrypt.client.achallenges.DVSNI` :param list ip_addrs: addresses of challenged domain - :class:`list` of type :class:`letsencrypt.client.apache.obj.Addr` + :class:`list` of type :class:`~apache.obj.Addr` :returns: virtual host configuration text :rtype: str diff --git a/letsencrypt/client/apache/obj.py b/letsencrypt/client/plugins/apache/obj.py similarity index 100% rename from letsencrypt/client/apache/obj.py rename to letsencrypt/client/plugins/apache/obj.py diff --git a/letsencrypt/client/apache/options-ssl.conf b/letsencrypt/client/plugins/apache/options-ssl.conf similarity index 100% rename from letsencrypt/client/apache/options-ssl.conf rename to letsencrypt/client/plugins/apache/options-ssl.conf diff --git a/letsencrypt/client/apache/parser.py b/letsencrypt/client/plugins/apache/parser.py similarity index 100% rename from letsencrypt/client/apache/parser.py rename to letsencrypt/client/plugins/apache/parser.py diff --git a/letsencrypt/client/tests/apache/__init__.py b/letsencrypt/client/plugins/apache/tests/__init__.py similarity index 100% rename from letsencrypt/client/tests/apache/__init__.py rename to letsencrypt/client/plugins/apache/tests/__init__.py diff --git a/letsencrypt/client/tests/apache/configurator_test.py b/letsencrypt/client/plugins/apache/tests/configurator_test.py similarity index 87% rename from letsencrypt/client/tests/apache/configurator_test.py rename to letsencrypt/client/plugins/apache/tests/configurator_test.py index 1bb4207a3..0b7d4f570 100644 --- a/letsencrypt/client/tests/apache/configurator_test.py +++ b/letsencrypt/client/plugins/apache/tests/configurator_test.py @@ -1,4 +1,4 @@ -"""Test for letsencrypt.client.apache.configurator.""" +"""Test for letsencrypt.client.plugins.apache.configurator.""" import os import re import shutil @@ -12,11 +12,11 @@ from letsencrypt.client import achallenges from letsencrypt.client import errors from letsencrypt.client import le_util -from letsencrypt.client.apache import configurator -from letsencrypt.client.apache import obj -from letsencrypt.client.apache import parser +from letsencrypt.client.plugins.apache import configurator +from letsencrypt.client.plugins.apache import obj +from letsencrypt.client.plugins.apache import parser -from letsencrypt.client.tests.apache import util +from letsencrypt.client.plugins.apache.tests import util class TwoVhost80Test(util.ApacheTest): @@ -25,7 +25,7 @@ class TwoVhost80Test(util.ApacheTest): def setUp(self): super(TwoVhost80Test, self).setUp() - with mock.patch("letsencrypt.client.apache.configurator." + with mock.patch("letsencrypt.client.plugins.apache.configurator." "mod_loaded") as mock_load: mock_load.return_value = True self.config = util.get_apache_configurator( @@ -46,6 +46,12 @@ class TwoVhost80Test(util.ApacheTest): ['letsencrypt.demo', 'encryption-example.demo', 'ip-172-30-0-17'])) def test_get_virtual_hosts(self): + """Make sure all vhosts are being properly found. + + .. note:: If test fails, only finding 1 Vhost... it is likely that + it is a problem with is_enabled. + + """ vhs = self.config.get_virtual_hosts() self.assertEqual(len(vhs), 4) found = 0 @@ -59,6 +65,14 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(found, 4) def test_is_site_enabled(self): + """Test if site is enabled. + + .. note:: This test currently fails for hard links + (which may happen if you move dirs incorrectly) + .. warning:: This test does not work when running using the + unittest.main() function. It incorrectly copies symlinks. + + """ self.assertTrue(self.config.is_site_enabled(self.vh_truth[0].filep)) self.assertFalse(self.config.is_site_enabled(self.vh_truth[1].filep)) self.assertTrue(self.config.is_site_enabled(self.vh_truth[2].filep)) @@ -134,9 +148,9 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(len(self.config.vhosts), 5) - @mock.patch("letsencrypt.client.apache.configurator." + @mock.patch("letsencrypt.client.plugins.apache.configurator." "dvsni.ApacheDvsni.perform") - @mock.patch("letsencrypt.client.apache.configurator." + @mock.patch("letsencrypt.client.plugins.apache.configurator." "ApacheConfigurator.restart") def test_perform(self, mock_restart, mock_dvsni_perform): # Only tests functionality specific to configurator.perform @@ -166,7 +180,7 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(mock_restart.call_count, 1) - @mock.patch("letsencrypt.client.apache.configurator." + @mock.patch("letsencrypt.client.plugins.apache.configurator." "subprocess.Popen") def test_get_version(self, mock_popen): mock_popen().communicate.return_value = ( diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/plugins/apache/tests/dvsni_test.py similarity index 91% rename from letsencrypt/client/tests/apache/dvsni_test.py rename to letsencrypt/client/plugins/apache/tests/dvsni_test.py index f3e0e9ce5..9bddfc481 100644 --- a/letsencrypt/client/tests/apache/dvsni_test.py +++ b/letsencrypt/client/plugins/apache/tests/dvsni_test.py @@ -1,4 +1,4 @@ -"""Test for letsencrypt.client.apache.dvsni.""" +"""Test for letsencrypt.client.plugins.apache.dvsni.""" import pkg_resources import unittest import shutil @@ -10,9 +10,9 @@ from letsencrypt.acme import challenges from letsencrypt.client import achallenges from letsencrypt.client import le_util -from letsencrypt.client.apache.obj import Addr +from letsencrypt.client.plugins.apache.obj import Addr -from letsencrypt.client.tests.apache import util +from letsencrypt.client.plugins.apache.tests import util class DvsniPerformTest(util.ApacheTest): @@ -21,20 +21,20 @@ class DvsniPerformTest(util.ApacheTest): def setUp(self): super(DvsniPerformTest, self).setUp() - with mock.patch("letsencrypt.client.apache.configurator." + with mock.patch("letsencrypt.client.plugins.apache.configurator." "mod_loaded") as mock_load: mock_load.return_value = True config = util.get_apache_configurator( self.config_path, self.config_dir, self.work_dir, self.ssl_options) - from letsencrypt.client.apache import dvsni + from letsencrypt.client.plugins.apache import dvsni self.sni = dvsni.ApacheDvsni(config) rsa256_file = pkg_resources.resource_filename( - "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", "testdata/rsa256_key.pem") rsa256_pem = pkg_resources.resource_string( - "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", "testdata/rsa256_key.pem") auth_key = le_util.Key(rsa256_file, rsa256_pem) self.achalls = [ @@ -74,7 +74,7 @@ class DvsniPerformTest(util.ApacheTest): nonce_domain=self.achalls[0].nonce_domain) achall.gen_cert_and_response.return_value = ("pem", response) - with mock.patch("letsencrypt.client.apache.dvsni.open", + with mock.patch("letsencrypt.client.plugins.apache.dvsni.open", m_open, create=True): # pylint: disable=protected-access self.assertEqual(response, self.sni._setup_challenge_cert( @@ -82,7 +82,7 @@ class DvsniPerformTest(util.ApacheTest): self.assertTrue(m_open.called) self.assertEqual( - m_open.call_args[0], (self.sni.get_cert_file(achall), 'w')) + m_open.call_args[0], (self.sni.get_cert_file(achall), "w")) self.assertEqual(m_open().write.call_args[0][0], "pem") def test_perform1(self): @@ -166,5 +166,5 @@ class DvsniPerformTest(util.ApacheTest): set([self.achalls[1].nonce_domain])) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/tests/apache/obj_test.py b/letsencrypt/client/plugins/apache/tests/obj_test.py similarity index 82% rename from letsencrypt/client/tests/apache/obj_test.py rename to letsencrypt/client/plugins/apache/tests/obj_test.py index 070fa7b11..b0c65eadb 100644 --- a/letsencrypt/client/tests/apache/obj_test.py +++ b/letsencrypt/client/plugins/apache/tests/obj_test.py @@ -1,11 +1,11 @@ -"""Test the helper objects in apache.obj.py.""" +"""Test the helper objects in letsencrypt.client.plugins.apache.obj.""" import unittest class AddrTest(unittest.TestCase): """Test the Addr class.""" def setUp(self): - from letsencrypt.client.apache.obj import Addr + from letsencrypt.client.plugins.apache.obj import Addr self.addr1 = Addr.fromstring("192.168.1.1") self.addr2 = Addr.fromstring("192.168.1.1:*") self.addr3 = Addr.fromstring("192.168.1.1:80") @@ -34,7 +34,7 @@ class AddrTest(unittest.TestCase): self.assertFalse(self.addr1 == 3333) def test_set_inclusion(self): - from letsencrypt.client.apache.obj import Addr + from letsencrypt.client.plugins.apache.obj import Addr set_a = set([self.addr1, self.addr2]) addr1b = Addr.fromstring("192.168.1.1") addr2b = Addr.fromstring("192.168.1.1:*") @@ -46,15 +46,15 @@ class AddrTest(unittest.TestCase): class VirtualHostTest(unittest.TestCase): """Test the VirtualHost class.""" def setUp(self): - from letsencrypt.client.apache.obj import VirtualHost - from letsencrypt.client.apache.obj import Addr + from letsencrypt.client.plugins.apache.obj import VirtualHost + from letsencrypt.client.plugins.apache.obj import Addr self.vhost1 = VirtualHost( "filep", "vh_path", set([Addr.fromstring("localhost")]), False, False) def test_eq(self): - from letsencrypt.client.apache.obj import Addr - from letsencrypt.client.apache.obj import VirtualHost + from letsencrypt.client.plugins.apache.obj import Addr + from letsencrypt.client.plugins.apache.obj import VirtualHost vhost1b = VirtualHost( "filep", "vh_path", set([Addr.fromstring("localhost")]), False, False) diff --git a/letsencrypt/client/tests/apache/parser_test.py b/letsencrypt/client/plugins/apache/tests/parser_test.py similarity index 84% rename from letsencrypt/client/tests/apache/parser_test.py rename to letsencrypt/client/plugins/apache/tests/parser_test.py index f30927886..d394feeaa 100644 --- a/letsencrypt/client/tests/apache/parser_test.py +++ b/letsencrypt/client/plugins/apache/tests/parser_test.py @@ -1,4 +1,4 @@ -"""Tests the ApacheParser class.""" +"""Tests for letsencrypt.client.plugins.apache.parser.""" import os import shutil import sys @@ -11,7 +11,7 @@ import zope.component from letsencrypt.client import errors from letsencrypt.client.display import util as display_util -from letsencrypt.client.tests.apache import util +from letsencrypt.client.plugins.apache.tests import util class ApacheParserTest(util.ApacheTest): @@ -22,7 +22,7 @@ class ApacheParserTest(util.ApacheTest): zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) - from letsencrypt.client.apache.parser import ApacheParser + from letsencrypt.client.plugins.apache.parser import ApacheParser self.aug = augeas.Augeas(flags=augeas.Augeas.NONE) self.parser = ApacheParser(self.aug, self.config_path, self.ssl_options) @@ -32,19 +32,19 @@ class ApacheParserTest(util.ApacheTest): shutil.rmtree(self.work_dir) def test_root_normalized(self): - from letsencrypt.client.apache.parser import ApacheParser + from letsencrypt.client.plugins.apache.parser import ApacheParser path = os.path.join(self.temp_dir, "debian_apache_2_4/////" "two_vhost_80/../two_vhost_80/apache2") parser = ApacheParser(self.aug, path, None) self.assertEqual(parser.root, self.config_path) def test_root_absolute(self): - from letsencrypt.client.apache.parser import ApacheParser + from letsencrypt.client.plugins.apache.parser import ApacheParser parser = ApacheParser(self.aug, os.path.relpath(self.config_path), None) self.assertEqual(parser.root, self.config_path) def test_root_no_trailing_slash(self): - from letsencrypt.client.apache.parser import ApacheParser + from letsencrypt.client.plugins.apache.parser import ApacheParser parser = ApacheParser(self.aug, self.config_path + os.path.sep, None) self.assertEqual(parser.root, self.config_path) @@ -67,7 +67,7 @@ class ApacheParserTest(util.ApacheTest): self.assertTrue(matches) def test_find_dir(self): - from letsencrypt.client.apache.parser import case_i + from letsencrypt.client.plugins.apache.parser import case_i test = self.parser.find_dir(case_i("Listen"), "443") # This will only look in enabled hosts test2 = self.parser.find_dir(case_i("documentroot")) @@ -92,7 +92,7 @@ class ApacheParserTest(util.ApacheTest): Path must be valid before attempting to add to augeas """ - from letsencrypt.client.apache.parser import get_aug_path + from letsencrypt.client.plugins.apache.parser import get_aug_path self.parser.add_dir_to_ifmodssl( get_aug_path(self.parser.loc["default"]), "FakeDirective", "123") @@ -103,11 +103,11 @@ class ApacheParserTest(util.ApacheTest): self.assertTrue("IfModule" in matches[0]) def test_get_aug_path(self): - from letsencrypt.client.apache.parser import get_aug_path + from letsencrypt.client.plugins.apache.parser import get_aug_path self.assertEqual("/files/etc/apache", get_aug_path("/etc/apache")) def test_set_locations(self): - with mock.patch("letsencrypt.client.apache.parser." + with mock.patch("letsencrypt.client.plugins.apache.parser." "os.path") as mock_path: mock_path.isfile.return_value = False diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/apache2.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/apache2.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/apache2.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/apache2.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/other-vhosts-access-log.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/other-vhosts-access-log.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/other-vhosts-access-log.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/other-vhosts-access-log.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/security.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/security.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/security.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/security.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/serve-cgi-bin.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/serve-cgi-bin.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/serve-cgi-bin.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-available/serve-cgi-bin.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/other-vhosts-access-log.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/other-vhosts-access-log.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/other-vhosts-access-log.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/other-vhosts-access-log.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/security.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/security.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/security.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/security.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/serve-cgi-bin.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/serve-cgi-bin.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/serve-cgi-bin.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/conf-enabled/serve-cgi-bin.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/envvars b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/envvars similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/envvars rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/envvars diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.load b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.load similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.load rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/mods-available/ssl.load diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/ports.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/ports.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/ports.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/ports.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/000-default.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/000-default.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/000-default.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/000-default.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/default-ssl.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/default-ssl.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/default-ssl.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-available/default-ssl.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-enabled/000-default.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-enabled/000-default.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-enabled/000-default.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/apache2/sites-enabled/000-default.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/sites b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/sites similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/default_vhost/sites rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/default_vhost/sites diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/apache2.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/apache2.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/apache2.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/apache2.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/other-vhosts-access-log.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/other-vhosts-access-log.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/other-vhosts-access-log.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/other-vhosts-access-log.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/security.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/security.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/security.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/security.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/serve-cgi-bin.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/serve-cgi-bin.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/serve-cgi-bin.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-available/serve-cgi-bin.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/other-vhosts-access-log.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/other-vhosts-access-log.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/other-vhosts-access-log.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/other-vhosts-access-log.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/security.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/security.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/security.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/security.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/serve-cgi-bin.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/serve-cgi-bin.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/serve-cgi-bin.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/conf-enabled/serve-cgi-bin.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/envvars b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/envvars similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/envvars rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/envvars diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.load b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.load similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.load rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/mods-available/ssl.load diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/ports.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/ports.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/ports.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/ports.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/000-default.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/000-default.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/000-default.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/000-default.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/default-ssl.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/encryption-example.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/encryption-example.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/encryption-example.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/encryption-example.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/letsencrypt.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/letsencrypt.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/letsencrypt.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/letsencrypt.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/000-default.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/000-default.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/000-default.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/000-default.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/encryption-example.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/encryption-example.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/encryption-example.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/encryption-example.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/letsencrypt.conf b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/letsencrypt.conf similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/letsencrypt.conf rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/letsencrypt.conf diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/sites b/letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/sites similarity index 100% rename from letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/sites rename to letsencrypt/client/plugins/apache/tests/testdata/debian_apache_2_4/two_vhost_80/sites diff --git a/letsencrypt/client/tests/apache/util.py b/letsencrypt/client/plugins/apache/tests/util.py similarity index 89% rename from letsencrypt/client/tests/apache/util.py rename to letsencrypt/client/plugins/apache/tests/util.py index 6e8cf7d53..d1ba17f5a 100644 --- a/letsencrypt/client/tests/apache/util.py +++ b/letsencrypt/client/plugins/apache/tests/util.py @@ -1,4 +1,4 @@ -"""Common utilities for letsencrypt.client.apache.""" +"""Common utilities for letsencrypt.client.plugins.apache.""" import os import pkg_resources import shutil @@ -8,8 +8,8 @@ import unittest import mock from letsencrypt.client import constants -from letsencrypt.client.apache import configurator -from letsencrypt.client.apache import obj +from letsencrypt.client.plugins.apache import configurator +from letsencrypt.client.plugins.apache import obj class ApacheTest(unittest.TestCase): # pylint: disable=too-few-public-methods @@ -26,9 +26,9 @@ class ApacheTest(unittest.TestCase): # pylint: disable=too-few-public-methods self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2") self.rsa256_file = pkg_resources.resource_filename( - "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", "testdata/rsa256_key.pem") self.rsa256_pem = pkg_resources.resource_string( - "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + "letsencrypt.client.tests", "testdata/rsa256_key.pem") def dir_setup(test_dir="debian_apache_2_4/two_vhost_80"): @@ -38,7 +38,7 @@ def dir_setup(test_dir="debian_apache_2_4/two_vhost_80"): work_dir = tempfile.mkdtemp("work") test_configs = pkg_resources.resource_filename( - "letsencrypt.client.tests", "testdata/%s" % test_dir) + "letsencrypt.client.plugins.apache.tests", "testdata/%s" % test_dir) shutil.copytree( test_configs, os.path.join(temp_dir, test_dir), symlinks=True) @@ -59,7 +59,7 @@ def get_apache_configurator( backups = os.path.join(work_dir, "backups") - with mock.patch("letsencrypt.client.apache.configurator." + with mock.patch("letsencrypt.client.plugins.apache.configurator." "subprocess.Popen") as mock_popen: # This just states that the ssl module is already loaded mock_popen().communicate.return_value = ("ssl_module", "") diff --git a/letsencrypt/client/plugins/standalone/__init__.py b/letsencrypt/client/plugins/standalone/__init__.py new file mode 100644 index 000000000..41de6eaf7 --- /dev/null +++ b/letsencrypt/client/plugins/standalone/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.plugins.standalone.""" diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/plugins/standalone/authenticator.py similarity index 100% rename from letsencrypt/client/standalone_authenticator.py rename to letsencrypt/client/plugins/standalone/authenticator.py diff --git a/letsencrypt/client/plugins/standalone/tests/__init__.py b/letsencrypt/client/plugins/standalone/tests/__init__.py new file mode 100644 index 000000000..059cd2780 --- /dev/null +++ b/letsencrypt/client/plugins/standalone/tests/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt Standalone Tests""" diff --git a/letsencrypt/client/tests/standalone_authenticator_test.py b/letsencrypt/client/plugins/standalone/tests/authenticator_test.py similarity index 84% rename from letsencrypt/client/tests/standalone_authenticator_test.py rename to letsencrypt/client/plugins/standalone/tests/authenticator_test.py index 62b955e7e..390d21b9f 100644 --- a/letsencrypt/client/tests/standalone_authenticator_test.py +++ b/letsencrypt/client/plugins/standalone/tests/authenticator_test.py @@ -1,4 +1,4 @@ -"""Tests for letsencrypt.client.standalone_authenticator.""" +"""Tests for letsencrypt.client.plugins.standalone.authenticator.""" import os import pkg_resources import psutil @@ -49,7 +49,7 @@ class CallableExhausted(Exception): class ChallPrefTest(unittest.TestCase): """Tests for chall_pref() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator self.authenticator = StandaloneAuthenticator(None) @@ -61,11 +61,11 @@ class ChallPrefTest(unittest.TestCase): class SNICallbackTest(unittest.TestCase): """Tests for sni_callback() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator self.authenticator = StandaloneAuthenticator(None) test_key = pkg_resources.resource_string( - __name__, "testdata/rsa256_key.pem") + "letsencrypt.client.tests", "testdata/rsa256_key.pem") key = le_util.Key("foo", test_key) self.cert = achallenges.DVSNI( chall=challenges.DVSNI(r="x"*32, nonce="abcdef"), @@ -104,7 +104,7 @@ class SNICallbackTest(unittest.TestCase): class ClientSignalHandlerTest(unittest.TestCase): """Tests for client_signal_handler() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator self.authenticator = StandaloneAuthenticator(None) self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} @@ -133,15 +133,15 @@ class ClientSignalHandlerTest(unittest.TestCase): class SubprocSignalHandlerTest(unittest.TestCase): """Tests for subproc_signal_handler() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator self.authenticator = StandaloneAuthenticator(None) self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} self.authenticator.child_pid = 12345 self.authenticator.parent_pid = 23456 - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.sys.exit") def test_subproc_signal_handler(self, mock_exit, mock_kill): self.authenticator.ssl_conn = mock.MagicMock() self.authenticator.connection = mock.MagicMock() @@ -155,8 +155,8 @@ class SubprocSignalHandlerTest(unittest.TestCase): self.authenticator.parent_pid, signal.SIGUSR1) mock_exit.assert_called_once_with(0) - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.sys.exit") def test_subproc_signal_handler_trouble(self, mock_exit, mock_kill): """Test attempting to shut down a non-existent connection. @@ -185,14 +185,15 @@ class SubprocSignalHandlerTest(unittest.TestCase): class AlreadyListeningTest(unittest.TestCase): """Tests for already_listening() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator self.authenticator = StandaloneAuthenticator(None) - @mock.patch("letsencrypt.client.standalone_authenticator.psutil." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil." "net_connections") - @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "psutil.Process") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_race_condition(self, mock_get_utility, mock_process, mock_net): # This tests a race condition, or permission problem, or OS @@ -216,10 +217,11 @@ class AlreadyListeningTest(unittest.TestCase): self.assertEqual(mock_get_utility.generic_notification.call_count, 0) mock_process.assert_called_once_with(4416) - @mock.patch("letsencrypt.client.standalone_authenticator.psutil." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil." "net_connections") - @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "psutil.Process") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_not_listening(self, mock_get_utility, mock_process, mock_net): from psutil._common import sconn @@ -236,10 +238,11 @@ class AlreadyListeningTest(unittest.TestCase): self.assertEqual(mock_get_utility.generic_notification.call_count, 0) self.assertEqual(mock_process.call_count, 0) - @mock.patch("letsencrypt.client.standalone_authenticator.psutil." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil." "net_connections") - @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "psutil.Process") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_listening_ipv4(self, mock_get_utility, mock_process, mock_net): from psutil._common import sconn @@ -259,10 +262,11 @@ class AlreadyListeningTest(unittest.TestCase): self.assertEqual(mock_get_utility.call_count, 1) mock_process.assert_called_once_with(4416) - @mock.patch("letsencrypt.client.standalone_authenticator.psutil." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil." "net_connections") - @mock.patch("letsencrypt.client.standalone_authenticator.psutil.Process") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "psutil.Process") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_listening_ipv6(self, mock_get_utility, mock_process, mock_net): from psutil._common import sconn @@ -288,12 +292,12 @@ class AlreadyListeningTest(unittest.TestCase): class PerformTest(unittest.TestCase): """Tests for perform() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator self.authenticator = StandaloneAuthenticator(None) test_key = pkg_resources.resource_string( - __name__, "testdata/rsa256_key.pem") + "letsencrypt.client.tests", "testdata/rsa256_key.pem") self.key = le_util.Key("something", test_key) self.achall1 = achallenges.DVSNI( @@ -365,13 +369,13 @@ class PerformTest(unittest.TestCase): class StartListenerTest(unittest.TestCase): """Tests for start_listener() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator self.authenticator = StandaloneAuthenticator(None) - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "Crypto.Random.atfork") - @mock.patch("letsencrypt.client.standalone_authenticator.os.fork") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.fork") def test_start_listener_fork_parent(self, mock_fork, mock_atfork): self.authenticator.do_parent_process = mock.Mock() self.authenticator.do_parent_process.return_value = True @@ -384,9 +388,9 @@ class StartListenerTest(unittest.TestCase): self.authenticator.do_parent_process.assert_called_once_with(1717) mock_atfork.assert_called_once_with() - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "Crypto.Random.atfork") - @mock.patch("letsencrypt.client.standalone_authenticator.os.fork") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.fork") def test_start_listener_fork_child(self, mock_fork, mock_atfork): self.authenticator.do_parent_process = mock.Mock() self.authenticator.do_child_process = mock.Mock() @@ -400,12 +404,13 @@ class StartListenerTest(unittest.TestCase): class DoParentProcessTest(unittest.TestCase): """Tests for do_parent_process() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator self.authenticator = StandaloneAuthenticator(None) - @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "signal.signal") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_do_parent_process_ok(self, mock_get_utility, mock_signal): self.authenticator.subproc_state = "ready" @@ -414,8 +419,9 @@ class DoParentProcessTest(unittest.TestCase): self.assertEqual(mock_get_utility.call_count, 1) self.assertEqual(mock_signal.call_count, 3) - @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "signal.signal") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_do_parent_process_inuse(self, mock_get_utility, mock_signal): self.authenticator.subproc_state = "inuse" @@ -424,8 +430,9 @@ class DoParentProcessTest(unittest.TestCase): self.assertEqual(mock_get_utility.call_count, 1) self.assertEqual(mock_signal.call_count, 3) - @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "signal.signal") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_do_parent_process_cantbind(self, mock_get_utility, mock_signal): self.authenticator.subproc_state = "cantbind" @@ -434,8 +441,9 @@ class DoParentProcessTest(unittest.TestCase): self.assertEqual(mock_get_utility.call_count, 1) self.assertEqual(mock_signal.call_count, 3) - @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "signal.signal") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "zope.component.getUtility") def test_do_parent_process_timeout(self, mock_get_utility, mock_signal): # Normally times out in 5 seconds and returns False. We can @@ -450,11 +458,11 @@ class DoParentProcessTest(unittest.TestCase): class DoChildProcessTest(unittest.TestCase): """Tests for do_child_process() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator self.authenticator = StandaloneAuthenticator(None) test_key = pkg_resources.resource_string( - __name__, "testdata/rsa256_key.pem") + "letsencrypt.client.tests", "testdata/rsa256_key.pem") key = le_util.Key("foo", test_key) self.key = key self.cert = achallenges.DVSNI( @@ -466,9 +474,10 @@ class DoChildProcessTest(unittest.TestCase): self.authenticator.tasks = {"abcdef.acme.invalid": self.cert} self.authenticator.parent_pid = 12345 - @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "socket.socket") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.sys.exit") def test_do_child_process_cantbind1( self, mock_exit, mock_kill, mock_socket): mock_exit.side_effect = IndentationError("subprocess would exit here") @@ -488,9 +497,10 @@ class DoChildProcessTest(unittest.TestCase): mock_exit.assert_called_once_with(1) mock_kill.assert_called_once_with(12345, signal.SIGUSR2) - @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "socket.socket") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.sys.exit") def test_do_child_process_cantbind2(self, mock_exit, mock_kill, mock_socket): mock_exit.side_effect = IndentationError("subprocess would exit here") @@ -504,7 +514,8 @@ class DoChildProcessTest(unittest.TestCase): mock_exit.assert_called_once_with(1) mock_kill.assert_called_once_with(12345, signal.SIGUSR1) - @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "socket.socket") def test_do_child_process_cantbind3(self, mock_socket): """Test case where attempt to bind socket results in an unhandled socket error. (The expected behavior is arguably wrong because it @@ -517,10 +528,11 @@ class DoChildProcessTest(unittest.TestCase): self.assertRaises( socket.error, self.authenticator.do_child_process, 1717, self.key) - @mock.patch("letsencrypt.client.standalone_authenticator." + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "OpenSSL.SSL.Connection") - @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "socket.socket") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") def test_do_child_process_success( self, mock_kill, mock_socket, mock_connection): sample_socket = mock.MagicMock() @@ -543,7 +555,7 @@ class DoChildProcessTest(unittest.TestCase): class CleanupTest(unittest.TestCase): """Tests for cleanup() method.""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ + from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator self.authenticator = StandaloneAuthenticator(None) self.achall = achallenges.DVSNI( @@ -552,8 +564,9 @@ class CleanupTest(unittest.TestCase): self.authenticator.tasks = {self.achall.nonce_domain: "stuff"} self.authenticator.child_pid = 12345 - @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") - @mock.patch("letsencrypt.client.standalone_authenticator.time.sleep") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator.os.kill") + @mock.patch("letsencrypt.client.plugins.standalone.authenticator." + "time.sleep") def test_cleanup(self, mock_sleep, mock_kill): mock_sleep.return_value = None mock_kill.return_value = None @@ -573,7 +586,7 @@ class CleanupTest(unittest.TestCase): class MoreInfoTest(unittest.TestCase): """Tests for more_info() method. (trivially)""" def setUp(self): - from letsencrypt.client.standalone_authenticator import ( + from letsencrypt.client.plugins.standalone.authenticator import ( StandaloneAuthenticator) self.authenticator = StandaloneAuthenticator(None) @@ -585,7 +598,7 @@ class MoreInfoTest(unittest.TestCase): class InitTest(unittest.TestCase): """Tests for more_info() method. (trivially)""" def setUp(self): - from letsencrypt.client.standalone_authenticator import ( + from letsencrypt.client.plugins.standalone.authenticator import ( StandaloneAuthenticator) self.authenticator = StandaloneAuthenticator(None) diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 5ae6d6107..1c1a0d68a 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -8,8 +8,9 @@ from letsencrypt.client import errors class DetermineAuthenticatorTest(unittest.TestCase): def setUp(self): - from letsencrypt.client.apache.configurator import ApacheConfigurator - from letsencrypt.client.standalone_authenticator import ( + from letsencrypt.client.plugins.apache.configurator import ( + ApacheConfigurator) + from letsencrypt.client.plugins.standalone.authenticator import ( StandaloneAuthenticator) self.mock_stand = mock.MagicMock( @@ -65,7 +66,8 @@ class DetermineAuthenticatorTest(unittest.TestCase): class RollbackTest(unittest.TestCase): """Test the rollback function.""" def setUp(self): - from letsencrypt.client.apache.configurator import ApacheConfigurator + from letsencrypt.client.plugins.apache.configurator import ( + ApacheConfigurator) self.m_install = mock.MagicMock(spec=ApacheConfigurator) @classmethod diff --git a/letsencrypt/client/tests/revoker_test.py b/letsencrypt/client/tests/revoker_test.py index f5a940df8..ff2ce6aca 100644 --- a/letsencrypt/client/tests/revoker_test.py +++ b/letsencrypt/client/tests/revoker_test.py @@ -10,7 +10,7 @@ import mock from letsencrypt.client import errors from letsencrypt.client import le_util -from letsencrypt.client.apache import configurator +from letsencrypt.client.plugins.apache import configurator from letsencrypt.client.display import util as display_util diff --git a/setup.py b/setup.py index c07c1f2ce..ca7de3abb 100644 --- a/setup.py +++ b/setup.py @@ -96,10 +96,13 @@ setup( 'letsencrypt.acme', 'letsencrypt.acme.jose', 'letsencrypt.client', - 'letsencrypt.client.apache', 'letsencrypt.client.display', + 'letsencrypt.client.plugins', + 'letsencrypt.client.plugins.apache', + 'letsencrypt.client.plugins.apache.tests', + 'letsencrypt.client.plugins.standalone', + 'letsencrypt.client.plugins.standalone.tests', 'letsencrypt.client.tests', - 'letsencrypt.client.tests.apache', 'letsencrypt.client.tests.display', 'letsencrypt.scripts', ], @@ -120,9 +123,9 @@ setup( 'jws = letsencrypt.acme.jose.jws:CLI.run', ], 'letsencrypt.authenticators': [ - 'apache = letsencrypt.client.apache.configurator' + 'apache = letsencrypt.client.plugins.apache.configurator' ':ApacheConfigurator', - 'standalone = letsencrypt.client.standalone_authenticator' + 'standalone = letsencrypt.client.plugins.standalone.authenticator' ':StandaloneAuthenticator', ], }, From 32c33e64df284fd648c25ba74a65c8e329f11f7f Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 26 Mar 2015 17:39:08 -0700 Subject: [PATCH 41/54] cleanup of plugins --- .../client/plugins/apache/configurator.py | 46 ++++++------- letsencrypt/client/plugins/apache/dvsni.py | 8 +-- .../plugins/apache/tests/configurator_test.py | 6 +- .../plugins/apache/tests/parser_test.py | 2 +- .../standalone/tests/authenticator_test.py | 64 +++++++++---------- 5 files changed, 63 insertions(+), 63 deletions(-) diff --git a/letsencrypt/client/plugins/apache/configurator.py b/letsencrypt/client/plugins/apache/configurator.py index 5b682216b..fb5ba7bd1 100644 --- a/letsencrypt/client/plugins/apache/configurator.py +++ b/letsencrypt/client/plugins/apache/configurator.py @@ -165,7 +165,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): parser.case_i("SSLCertificateChainFile"), None, vhost.path) if len(path["cert_file"]) == 0 or len(path["cert_key"]) == 0: - # Throw some "can't find all of the directives error" + # 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") @@ -224,7 +224,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.assoc[target_name] = vhost return vhost - # Check for non ssl vhosts with servernames/aliases == 'name' + # Check for non ssl vhosts with servernames/aliases == "name" for vhost in self.vhosts: if not vhost.ssl and target_name in vhost.names: vhost = self.make_vhost_ssl(vhost) @@ -288,9 +288,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): name_match = self.aug.match(("%s//*[self::directive=~regexp('%s')] | " "%s//*[self::directive=~regexp('%s')]" % (host.path, - parser.case_i('ServerName'), + parser.case_i("ServerName"), host.path, - parser.case_i('ServerAlias')))) + parser.case_i("ServerAlias")))) for name in name_match: args = self.aug.match(name + "/*") @@ -335,7 +335,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Search sites-available, httpd.conf for possible virtual hosts paths = self.aug.match( ("/files%s/sites-available//*[label()=~regexp('%s')]" % - (self.parser.root, parser.case_i('VirtualHost')))) + (self.parser.root, parser.case_i("VirtualHost")))) vhs = [] for path in paths: @@ -455,8 +455,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.reverter.register_file_creation(False, ssl_fp) try: - with open(avail_fp, 'r') as orig_file: - with open(ssl_fp, 'w') as new_file: + with open(avail_fp, "r") as orig_file: + with open(ssl_fp, "w") as new_file: new_file.write("\n") for line in orig_file: new_file.write(line) @@ -472,7 +472,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # change address to address:443 addr_match = "/files%s//* [label()=~regexp('%s')]/arg" ssl_addr_p = self.aug.match( - addr_match % (ssl_fp, parser.case_i('VirtualHost'))) + addr_match % (ssl_fp, parser.case_i("VirtualHost"))) for addr in ssl_addr_p: old_addr = obj.Addr.fromstring( @@ -483,7 +483,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Add directives vh_p = self.aug.match("/files%s//* [label()=~regexp('%s')]" % - (ssl_fp, parser.case_i('VirtualHost'))) + (ssl_fp, parser.case_i("VirtualHost"))) if len(vh_p) != 1: logging.error("Error: should only be one vhost in %s", avail_fp) sys.exit(1) @@ -496,7 +496,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Log actions and create save notes logging.info("Created an SSL vhost at %s", ssl_fp) - self.save_notes += 'Created ssl vhost at %s\n' % ssl_fp + self.save_notes += "Created ssl vhost at %s\n" % ssl_fp self.save() # We know the length is one because of the assertion above @@ -597,7 +597,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.parser.add_dir(general_v.path, "RewriteEngine", "On") self.parser.add_dir(general_v.path, "RewriteRule", constants.APACHE_REWRITE_HTTPS_ARGS) - self.save_notes += ('Redirecting host in %s to ssl vhost in %s\n' % + self.save_notes += ("Redirecting host in %s to ssl vhost in %s\n" % (general_v.filep, ssl_vhost.filep)) self.save() @@ -701,7 +701,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): redirect_filename = "le-redirect-%s.conf" % ssl_vhost.names[0] redirect_filepath = os.path.join( - self.parser.root, 'sites-available', redirect_filename) + self.parser.root, "sites-available", redirect_filename) # Register the new file that will be created # Note: always register the creation before writing to ensure file will @@ -709,7 +709,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.reverter.register_file_creation(False, redirect_filepath) # Write out file - with open(redirect_filepath, 'w') as redirect_fd: + with open(redirect_filepath, "w") as redirect_fd: redirect_fd.write(redirect_file) logging.info("Created redirect file: %s", redirect_filename) @@ -719,8 +719,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.vhosts.append(new_vhost) # Finally create documentation for the change - self.save_notes += ('Created a port 80 vhost, %s, for redirection to ' - 'ssl vhost %s\n' % + self.save_notes += ("Created a port 80 vhost, %s, for redirection to " + "ssl vhost %s\n" % (new_vhost.filep, ssl_vhost.filep)) def _conflicting_host(self, ssl_vhost): @@ -877,7 +877,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): os.symlink(vhost.filep, enabled_path) vhost.enabled = True logging.info("Enabling available site: %s", vhost.filep) - self.save_notes += 'Enabled site %s\n' % vhost.filep + self.save_notes += "Enabled site %s\n" % vhost.filep return True return False @@ -899,7 +899,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ try: proc = subprocess.Popen( - ['sudo', self.config.apache_ctl, 'configtest'], # TODO: sudo? + ["sudo", self.config.apache_ctl, "configtest"], # TODO: sudo? stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() @@ -943,7 +943,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ try: proc = subprocess.Popen( - [self.config.apache_ctl, '-v'], + [self.config.apache_ctl, "-v"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) text = proc.communicate()[0] @@ -958,7 +958,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): raise errors.LetsEncryptConfiguratorError( "Unable to find Apache version") - return tuple([int(i) for i in matches[0].split('.')]) + return tuple([int(i) for i in matches[0].split(".")]) def more_info(self): """Human-readable string to help understand the module""" @@ -1033,8 +1033,8 @@ def enable_mod(mod_name, apache_init_script, apache_enmod): # Use check_output so the command will finish before reloading # TODO: a2enmod is debian specific... subprocess.check_call(["sudo", apache_enmod, mod_name], # TODO: sudo? - stdout=open("/dev/null", 'w'), - stderr=open("/dev/null", 'w')) + 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) @@ -1056,7 +1056,7 @@ def mod_loaded(module, apache_ctl): """ try: proc = subprocess.Popen( - [apache_ctl, '-M'], + [apache_ctl, "-M"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() @@ -1094,7 +1094,7 @@ def apache_restart(apache_init_script): """ try: - proc = subprocess.Popen([apache_init_script, 'restart'], + proc = subprocess.Popen([apache_init_script, "restart"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() diff --git a/letsencrypt/client/plugins/apache/dvsni.py b/letsencrypt/client/plugins/apache/dvsni.py index 2e1c948aa..7755658e7 100644 --- a/letsencrypt/client/plugins/apache/dvsni.py +++ b/letsencrypt/client/plugins/apache/dvsni.py @@ -117,7 +117,7 @@ class ApacheDvsni(object): cert_pem, response = achall.gen_cert_and_response(s) # Write out challenge cert - with open(cert_path, 'w') as cert_chall_fd: + with open(cert_path, "w") as cert_chall_fd: cert_chall_fd.write(cert_pem) return response @@ -141,7 +141,7 @@ class ApacheDvsni(object): self.configurator.reverter.register_file_creation( True, self.challenge_conf) - with open(self.challenge_conf, 'w') as new_conf: + with open(self.challenge_conf, "w") as new_conf: new_conf.write(config_text) def _conf_include_check(self, main_config): @@ -179,13 +179,13 @@ class ApacheDvsni(object): # 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 - # parses it as '\n'... c.f.: + # parses it as "\n"... c.f.: # https://docs.python.org/2.7/reference/lexical_analysis.html return self.VHOST_TEMPLATE.format( vhost=ips, server_name=achall.nonce_domain, 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) + document_root=document_root).replace("\n", os.linesep) def get_cert_file(self, achall): """Returns standardized name for challenge certificate. diff --git a/letsencrypt/client/plugins/apache/tests/configurator_test.py b/letsencrypt/client/plugins/apache/tests/configurator_test.py index 0b7d4f570..91758d196 100644 --- a/letsencrypt/client/plugins/apache/tests/configurator_test.py +++ b/letsencrypt/client/plugins/apache/tests/configurator_test.py @@ -43,7 +43,7 @@ class TwoVhost80Test(util.ApacheTest): def test_get_all_names(self): names = self.config.get_all_names() self.assertEqual(names, set( - ['letsencrypt.demo', 'encryption-example.demo', 'ip-172-30-0-17'])) + ["letsencrypt.demo", "encryption-example.demo", "ip-172-30-0-17"])) def test_get_virtual_hosts(self): """Make sure all vhosts are being properly found. @@ -197,7 +197,7 @@ class TwoVhost80Test(util.ApacheTest): errors.LetsEncryptConfiguratorError, self.config.get_version) mock_popen().communicate.return_value = ( - "Server Version: Apache/2.3\n Apache/2.4.7", "") + "Server Version: Apache/2.3{0} Apache/2.4.7".format(os.linesep), "") self.assertRaises( errors.LetsEncryptConfiguratorError, self.config.get_version) @@ -206,5 +206,5 @@ class TwoVhost80Test(util.ApacheTest): errors.LetsEncryptConfiguratorError, self.config.get_version) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/plugins/apache/tests/parser_test.py b/letsencrypt/client/plugins/apache/tests/parser_test.py index d394feeaa..1696841f8 100644 --- a/letsencrypt/client/plugins/apache/tests/parser_test.py +++ b/letsencrypt/client/plugins/apache/tests/parser_test.py @@ -125,5 +125,5 @@ class ApacheParserTest(util.ApacheTest): self.assertEqual(results["default"], results["name"]) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/plugins/standalone/tests/authenticator_test.py b/letsencrypt/client/plugins/standalone/tests/authenticator_test.py index 390d21b9f..577bc7e74 100644 --- a/letsencrypt/client/plugins/standalone/tests/authenticator_test.py +++ b/letsencrypt/client/plugins/standalone/tests/authenticator_test.py @@ -201,14 +201,14 @@ class AlreadyListeningTest(unittest.TestCase): # found to match the identified listening PID. from psutil._common import sconn conns = [ - sconn(fd=-1, family=2, type=1, laddr=('0.0.0.0', 30), - raddr=(), status='LISTEN', pid=None), - sconn(fd=3, family=2, type=1, laddr=('192.168.5.10', 32783), - raddr=('20.40.60.80', 22), status='ESTABLISHED', pid=1234), - sconn(fd=-1, family=10, type=1, laddr=('::1', 54321), - raddr=('::1', 111), status='CLOSE_WAIT', pid=None), - sconn(fd=3, family=2, type=1, laddr=('0.0.0.0', 17), - raddr=(), status='LISTEN', pid=4416)] + sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), + raddr=(), status="LISTEN", pid=None), + sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), + raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), + sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), + raddr=("::1", 111), status="CLOSE_WAIT", pid=None), + sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17), + raddr=(), status="LISTEN", pid=4416)] mock_net.return_value = conns mock_process.side_effect = psutil.NoSuchProcess("No such PID") # We simulate being unable to find the process name of PID 4416, @@ -226,12 +226,12 @@ class AlreadyListeningTest(unittest.TestCase): def test_not_listening(self, mock_get_utility, mock_process, mock_net): from psutil._common import sconn conns = [ - sconn(fd=-1, family=2, type=1, laddr=('0.0.0.0', 30), - raddr=(), status='LISTEN', pid=None), - sconn(fd=3, family=2, type=1, laddr=('192.168.5.10', 32783), - raddr=('20.40.60.80', 22), status='ESTABLISHED', pid=1234), - sconn(fd=-1, family=10, type=1, laddr=('::1', 54321), - raddr=('::1', 111), status='CLOSE_WAIT', pid=None)] + sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), + raddr=(), status="LISTEN", pid=None), + sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), + raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), + sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), + raddr=("::1", 111), status="CLOSE_WAIT", pid=None)] mock_net.return_value = conns mock_process.name.return_value = "inetd" self.assertFalse(self.authenticator.already_listening(17)) @@ -247,14 +247,14 @@ class AlreadyListeningTest(unittest.TestCase): def test_listening_ipv4(self, mock_get_utility, mock_process, mock_net): from psutil._common import sconn conns = [ - sconn(fd=-1, family=2, type=1, laddr=('0.0.0.0', 30), - raddr=(), status='LISTEN', pid=None), - sconn(fd=3, family=2, type=1, laddr=('192.168.5.10', 32783), - raddr=('20.40.60.80', 22), status='ESTABLISHED', pid=1234), - sconn(fd=-1, family=10, type=1, laddr=('::1', 54321), - raddr=('::1', 111), status='CLOSE_WAIT', pid=None), - sconn(fd=3, family=2, type=1, laddr=('0.0.0.0', 17), - raddr=(), status='LISTEN', pid=4416)] + sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), + raddr=(), status="LISTEN", pid=None), + sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), + raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), + sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), + raddr=("::1", 111), status="CLOSE_WAIT", pid=None), + sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17), + raddr=(), status="LISTEN", pid=4416)] mock_net.return_value = conns mock_process.name.return_value = "inetd" result = self.authenticator.already_listening(17) @@ -271,16 +271,16 @@ class AlreadyListeningTest(unittest.TestCase): def test_listening_ipv6(self, mock_get_utility, mock_process, mock_net): from psutil._common import sconn conns = [ - sconn(fd=-1, family=2, type=1, laddr=('0.0.0.0', 30), - raddr=(), status='LISTEN', pid=None), - sconn(fd=3, family=2, type=1, laddr=('192.168.5.10', 32783), - raddr=('20.40.60.80', 22), status='ESTABLISHED', pid=1234), - sconn(fd=-1, family=10, type=1, laddr=('::1', 54321), - raddr=('::1', 111), status='CLOSE_WAIT', pid=None), - sconn(fd=3, family=10, type=1, laddr=('::', 12345), raddr=(), - status='LISTEN', pid=4420), - sconn(fd=3, family=2, type=1, laddr=('0.0.0.0', 17), - raddr=(), status='LISTEN', pid=4416)] + sconn(fd=-1, family=2, type=1, laddr=("0.0.0.0", 30), + raddr=(), status="LISTEN", pid=None), + sconn(fd=3, family=2, type=1, laddr=("192.168.5.10", 32783), + raddr=("20.40.60.80", 22), status="ESTABLISHED", pid=1234), + sconn(fd=-1, family=10, type=1, laddr=("::1", 54321), + raddr=("::1", 111), status="CLOSE_WAIT", pid=None), + sconn(fd=3, family=10, type=1, laddr=("::", 12345), raddr=(), + status="LISTEN", pid=4420), + sconn(fd=3, family=2, type=1, laddr=("0.0.0.0", 17), + raddr=(), status="LISTEN", pid=4416)] mock_net.return_value = conns mock_process.name.return_value = "inetd" result = self.authenticator.already_listening(12345) From 8fa2204afe8b6ee6fe758f51f438495ca17e0659 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 27 Mar 2015 12:29:27 -0700 Subject: [PATCH 42/54] Add disclaimer in plugins doc --- docs/plugins.rst | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/plugins.rst b/docs/plugins.rst index fafb8d5d3..0451bfe3f 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -5,9 +5,15 @@ Plugins Let's Encrypt client supports dynamic discovery of plugins through the `setuptools entry points`_. This way you can, for example, create a custom implementation of -`~letsencrypt.client.interfaces.IAuthenticator` without having to -merge it with the core upstream source code. Example is provided in +`~letsencrypt.client.interfaces.IAuthenticator` or the +'~letsencrypt.client.interfaces.IInstaller' without having to +merge it with the core upstream source code. An example is provided in ``examples/plugins/`` directory. +Please be aware though that as this client is still in a developer-preview +stage, the API may undergo a few changes. If you believe the plugin will be +beneficial to the community, please consider submitting a pull request to the +repo and we will update it with any necessary API changes. + .. _`setuptools entry points`: https://pythonhosted.org/setuptools/setuptools.html#dynamic-discovery-of-services-and-plugins From 0a1687eed5e8ad79c12399de0a9426be2c0871ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=A4rtner?= Date: Fri, 27 Mar 2015 20:31:29 +0100 Subject: [PATCH 43/54] Fixed wrong linking to CONTRIBUTING --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 86d85ed1d..b65230dc4 100644 --- a/README.rst +++ b/README.rst @@ -80,7 +80,7 @@ Documentation: https://letsencrypt.readthedocs.org/ Software project: https://github.com/letsencrypt/lets-encrypt-preview -Notes for developers: CONTRIBUTING.rst_ +Notes for developers: CONTRIBUTING.md_ Main Website: https://letsencrypt.org/ From 5763da07ff0a88354d131a297801100e9a1db81e Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 27 Mar 2015 12:38:46 -0700 Subject: [PATCH 44/54] Finish contributing.md update Fix relevant links in README --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b65230dc4..fac36dbd7 100644 --- a/README.rst +++ b/README.rst @@ -91,4 +91,4 @@ email to client-dev+subscribe@letsencrypt.org) .. _Freenode: https://freenode.net .. _client-dev: https://groups.google.com/a/letsencrypt.org/forum/#!forum/client-dev -.. _CONTRIBUTING.rst: https://github.com/letsencrypt/lets-encrypt-preview/blob/master/CONTRIBUTING.rst +.. _CONTRIBUTING.md: https://github.com/letsencrypt/lets-encrypt-preview/blob/master/CONTRIBUTING.md From 989b8f059b4e6f6ad4267e1435043bae935750db Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 27 Mar 2015 15:20:43 -0700 Subject: [PATCH 45/54] Update documentation --- docs/api/client/apache.rst | 29 ------------ docs/api/client/plugins/apache.rst | 29 ++++++++++++ docs/api/client/plugins/standalone.rst | 11 +++++ docs/api/client/standalone_authenticator.rst | 5 -- .../plugins/letsencrypt_example_plugins.py | 2 + .../client/plugins/apache/configurator.py | 47 +++++++++++-------- 6 files changed, 69 insertions(+), 54 deletions(-) delete mode 100644 docs/api/client/apache.rst create mode 100644 docs/api/client/plugins/apache.rst create mode 100644 docs/api/client/plugins/standalone.rst delete mode 100644 docs/api/client/standalone_authenticator.rst diff --git a/docs/api/client/apache.rst b/docs/api/client/apache.rst deleted file mode 100644 index e69826cf9..000000000 --- a/docs/api/client/apache.rst +++ /dev/null @@ -1,29 +0,0 @@ -:mod:`letsencrypt.client.apache` --------------------------------- - -.. automodule:: letsencrypt.client.apache - :members: - -:mod:`letsencrypt.client.apache.configurator` -============================================= - -.. automodule:: letsencrypt.client.apache.configurator - :members: - -:mod:`letsencrypt.client.apache.dvsni` -============================================= - -.. automodule:: letsencrypt.client.apache.dvsni - :members: - -:mod:`letsencrypt.client.apache.obj` -==================================== - -.. automodule:: letsencrypt.client.apache.obj - :members: - -:mod:`letsencrypt.client.apache.parser` -======================================= - -.. automodule:: letsencrypt.client.apache.parser - :members: diff --git a/docs/api/client/plugins/apache.rst b/docs/api/client/plugins/apache.rst new file mode 100644 index 000000000..6e6e6c462 --- /dev/null +++ b/docs/api/client/plugins/apache.rst @@ -0,0 +1,29 @@ +:mod:`letsencrypt.client.plugins.apache` +---------------------------------------- + +.. automodule:: letsencrypt.client.plugins.apache + :members: + +:mod:`letsencrypt.client.plugins.apache.configurator` +===================================================== + +.. automodule:: letsencrypt.client.plugins.apache.configurator + :members: + +:mod:`letsencrypt.client.plugins.apache.dvsni` +============================================== + +.. automodule:: letsencrypt.client.plugins.apache.dvsni + :members: + +:mod:`letsencrypt.client.plugins.apache.obj` +============================================ + +.. automodule:: letsencrypt.client.plugins.apache.obj + :members: + +:mod:`letsencrypt.client.plugins.apache.parser` +=============================================== + +.. automodule:: letsencrypt.client.plugins.apache.parser + :members: diff --git a/docs/api/client/plugins/standalone.rst b/docs/api/client/plugins/standalone.rst new file mode 100644 index 000000000..44cf4b8ca --- /dev/null +++ b/docs/api/client/plugins/standalone.rst @@ -0,0 +1,11 @@ +:mod:`letsencrypt.client.plugins.standalone` +-------------------------------------------- + +.. automodule:: letsencrypt.client.plugins.standalone + :members: + +:mod:`letsencrypt.client.plugins.standalone.authenticator` +========================================================== + +.. automodule:: letsencrypt.client.plugins.standalone.authenticator + :members: diff --git a/docs/api/client/standalone_authenticator.rst b/docs/api/client/standalone_authenticator.rst deleted file mode 100644 index d05f4f057..000000000 --- a/docs/api/client/standalone_authenticator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.standalone_authenticator` --------------------------------------------------- - -.. automodule:: letsencrypt.client.standalone_authenticator - :members: diff --git a/examples/plugins/letsencrypt_example_plugins.py b/examples/plugins/letsencrypt_example_plugins.py index 6817c7f1d..987a2b33b 100644 --- a/examples/plugins/letsencrypt_example_plugins.py +++ b/examples/plugins/letsencrypt_example_plugins.py @@ -14,3 +14,5 @@ class Authenticator(object): # Implement all methods from IAuthenticator, remembering to add # "self" as first argument, e.g. def prepare(self)... + + # For full examples, see letsencrypt.client.plugins diff --git a/letsencrypt/client/plugins/apache/configurator.py b/letsencrypt/client/plugins/apache/configurator.py index fb5ba7bd1..028c32bbb 100644 --- a/letsencrypt/client/plugins/apache/configurator.py +++ b/letsencrypt/client/plugins/apache/configurator.py @@ -68,12 +68,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :type config: :class:`~letsencrypt.client.interfaces.IConfig` :ivar parser: Handles low level parsing - :type parser: :class:`letsencrypt.client.plugins.apache.parser` + :type parser: :class:`~letsencrypt.client.plugins.apache.parser` :ivar tup version: version of Apache :ivar list vhosts: All vhosts found in the configuration (:class:`list` of - :class:`letsencrypt.client.plugins.apache.obj.VirtualHost`) + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost`) :ivar dict assoc: Mapping between domains and vhosts @@ -204,7 +204,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str target_name: domain name :returns: ssl vhost associated with name - :rtype: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` + :rtype: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ # Allows for domain names to be associated with a virtual host @@ -245,7 +245,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str domain: domain name to associate :param vhost: virtual host to associate with domain - :type vhost: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` + :type vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ self.assoc[domain] = vhost @@ -282,7 +282,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Helper function for get_virtual_hosts(). :param host: In progress vhost whose names will be added - :type host: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` + :type host: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ name_match = self.aug.match(("%s//*[self::directive=~regexp('%s')] | " @@ -303,7 +303,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str path: Augeas path to virtual host :returns: newly created vhost - :rtype: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` + :rtype: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ addrs = set() @@ -327,7 +327,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Returns list of virtual hosts found in the Apache configuration. :returns: List of - :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` objects + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` objects found in configuration :rtype: list @@ -405,7 +405,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Checks to see if the server is ready for SNI challenges. :param vhost: VirtualHost to check SNI compatibility - :type vhost: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` + :type vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :param str default_addr: TODO - investigate function further @@ -437,10 +437,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. note:: This function saves the configuration :param nonssl_vhost: Valid VH that doesn't have SSLEngine on - :type nonssl_vhost: :class:`~apache.obj.VirtualHost` + :type nonssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: SSL vhost - :rtype: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` + :rtype: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` """ avail_fp = nonssl_vhost.filep @@ -560,13 +561,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. note:: This function saves the configuration :param ssl_vhost: Destination of traffic, an ssl enabled vhost - :type ssl_vhost: :class:`~apache.obj.VirtualHost` + :type ssl_vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :param unused_options: Not currently used :type unused_options: Not Available :returns: Success, general_vhost (HTTP vhost) - :rtype: (bool, :class:`~apache.obj.VirtualHost`) + :rtype: (bool, :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost`) """ if not mod_loaded("rewrite_module", self.config.apache_ctl): @@ -618,7 +619,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): -1 is also returned in case of no redirection/rewrite directives :param vhost: vhost to check - :type vhost: :class:`letsencrypt.client.plugins.apache.obj.VirtualHost` + :type vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: Success, code value... see documentation :rtype: bool, int @@ -650,10 +651,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Creates an http_vhost specifically to redirect for the ssl_vhost. :param ssl_vhost: ssl vhost - :type ssl_vhost: :class:`~apache.obj.VirtualHost` + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` - :returns: Success, vhost - :rtype: (bool, :class:`~apache.obj.VirtualHost`) + :returns: tuple of the form + (`success`, + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost`) + :rtype: tuple """ # Consider changing this to a dictionary check @@ -735,7 +739,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if not conflict: returns space separated list of new host addrs :param ssl_vhost: SSL Vhost to check for possible port 80 redirection - :type ssl_vhost: :class:`~apache.obj.VirtualHost` + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: TODO :rtype: TODO @@ -768,10 +773,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): Consider changing this into a dict check :param ssl_vhost: ssl vhost to check - :type ssl_vhost: :class:`~apache.obj.VirtualHost` + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: HTTP vhost or None if unsuccessful - :rtype: :class:`~apache.obj.VirtualHost` or None + :rtype: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` + or None """ # _default_:443 check @@ -861,7 +868,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. todo:: Make sure link is not broken... :param vhost: vhost to enable - :type vhost: :class:`~apache.obj.VirtualHost` + :type vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :returns: Success :rtype: bool From fa79e3c5ef411b04a5d59e07051e4a3b93c16c3c Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 27 Mar 2015 15:37:34 -0700 Subject: [PATCH 46/54] fix pylint >80 character errors --- letsencrypt/client/plugins/apache/configurator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/plugins/apache/configurator.py b/letsencrypt/client/plugins/apache/configurator.py index 028c32bbb..e6104a559 100644 --- a/letsencrypt/client/plugins/apache/configurator.py +++ b/letsencrypt/client/plugins/apache/configurator.py @@ -561,13 +561,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): .. note:: This function saves the configuration :param ssl_vhost: Destination of traffic, an ssl enabled vhost - :type ssl_vhost: :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost` :param unused_options: Not currently used :type unused_options: Not Available :returns: Success, general_vhost (HTTP vhost) - :rtype: (bool, :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost`) + :rtype: (bool, + :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost`) """ if not mod_loaded("rewrite_module", self.config.apache_ctl): From 567cec1824b2a319f6f50d32634e206706d2b95e Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 27 Mar 2015 21:08:14 -0700 Subject: [PATCH 47/54] Fix gen_chall_path, add unittests --- letsencrypt/client/auth_handler.py | 35 +++++---- letsencrypt/client/tests/acme_util.py | 22 +++--- letsencrypt/client/tests/auth_handler_test.py | 72 +++++++++++++++++++ tox.ini | 2 +- 4 files changed, 104 insertions(+), 27 deletions(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 05f3722cf..136265aa6 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -315,24 +315,23 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes def gen_challenge_path(challs, preferences, combinations): """Generate a plan to get authority over the identity. - .. todo:: Make sure that the challenges are feasible... - Example: Do you have the recovery key? + .. todo:: This can be possibly be rewritten to use resolved_combinations. - :param list challs: A list of challenges + :param tuple challs: A tuple of challenges (:class:`letsencrypt.acme.challenges.Challenge`) from :class:`letsencrypt.acme.messages.Challenge` server message to be fulfilled by the client in order to prove possession of the identifier. :param list preferences: List of challenge preferences for domain - (:class:`letsencrypt.acme.challenges.Challege` subclasses) + (:class:`letsencrypt.acme.challenges.Challenge` subclasses) - :param list combinations: A collection of sets of challenges from + :param tuple combinations: A collection of sets of challenges from :class:`letsencrypt.acme.messages.Challenge`, each of which would be sufficient to prove possession of the identifier. - :returns: List of indices from ``challenges``. - :rtype: list + :returns: tuple of indices from ``challenges``. + :rtype: tuple """ if combinations: @@ -349,29 +348,34 @@ def _find_smart_path(challs, preferences, combinations): """ chall_cost = {} - max_cost = 0 + max_cost = 1 for i, chall_cls in enumerate(preferences): chall_cost[chall_cls] = i max_cost += i + # max_cost is now equal to sum(indices) + 1 + best_combo = [] # Set above completing all of the available challenges - best_combo_cost = max_cost + 1 + best_combo_cost = max_cost combo_total = 0 for combo in combinations: for challenge_index in combo: combo_total += chall_cost.get(challs[ challenge_index].__class__, max_cost) + if combo_total < best_combo_cost: best_combo = combo best_combo_cost = combo_total - combo_total = 0 + + combo_total = 0 if not best_combo: - logging.fatal("Client does not support any combination of " - "challenges to satisfy ACME server") - sys.exit(22) + msg = ("Client does not support any combination of challenges that " + "will satisfy the CA.") + logging.fatal(msg) + raise errors.LetsEncryptAuthHandlerError(msg) return best_combo @@ -387,13 +391,14 @@ def _find_dumb_path(challs, preferences): assert len(preferences) == len(set(preferences)) path = [] - satisfied = set() + # This cannot be a set() because POP challenge is not currently hashable + satisfied = [] for pref_c in preferences: for i, offered_chall in enumerate(challs): if (isinstance(offered_chall, pref_c) and is_preferred(offered_chall, satisfied)): path.append(i) - satisfied.add(offered_chall) + satisfied.append(offered_chall) return path diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index aba839f8c..1b121e49f 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -27,19 +27,19 @@ POP = challenges.ProofOfPossession( alg="RS256", nonce="xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ", hints=challenges.ProofOfPossession.Hints( jwk=jose.JWKRSA(key=KEY.publickey()), - cert_fingerprints=[ + cert_fingerprints=( "93416768eb85e33adc4277f4c9acd63e7418fcfe", "16d95b7b63f1972b980b14c20291f3c0d1855d95", "48b46570d9fc6358108af43ad1649484def0debf" - ], - certs=[], # TODO - subject_key_identifiers=["d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"], - serial_numbers=[34234239832, 23993939911, 17], - issuers=[ + ), + certs=(), # TODO + subject_key_identifiers=("d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"), + serial_numbers=(34234239832, 23993939911, 17), + issuers=( "C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA", "O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure", - ], - authorized_for=["www.example.com", "example.net"], + ), + authorized_for=("www.example.com", "example.net"), ) ) @@ -61,6 +61,6 @@ def gen_combos(challs): else: renewal_chall.append(i) - # Gen combos for 1 of each type - return [[i, j] for i in xrange(len(dv_chall)) - for j in xrange(len(renewal_chall))] + # Gen combos for 1 of each type, lowest index first (makes testing easier) + return tuple((i, j) if i < j else (j, i) + for i in dv_chall for j in renewal_chall) diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index 478d4c0ac..6150899de 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -513,6 +513,78 @@ class PathSatisfiedTest(unittest.TestCase): self.assertFalse(self.handler._path_satisfied(dom[i])) +class GenChallengePathTest(unittest.TestCase): + """Tests for letsencrypt.client.auth_handler.gen_challenge_path. + + .. todo:: Add more tests for dumb_path... depending on what we want to do. + + """ + def setUp(self): + logging.disable(logging.fatal) + + def tearDown(self): + logging.disable(logging.NOTSET) + + @classmethod + def _call(cls, challs, preferences, combinations): + from letsencrypt.client.auth_handler import gen_challenge_path + return gen_challenge_path(challs, preferences, combinations) + + def test_common_case(self): + """Given DVSNI and SimpleHTTPS with appropriate combos.""" + challs = (acme_util.DVSNI, acme_util.SIMPLE_HTTPS) + prefs = [challenges.DVSNI] + combos = ((0,), (1,)) + + # Smart then trivial dumb path test + self.assertEqual(self._call(challs, prefs, combos), (0,)) + self.assertTrue(self._call(challs, prefs, None)) + # Rearrange order... + self.assertEqual(self._call(challs[::-1], prefs, combos), (1,)) + self.assertTrue(self._call(challs[::-1], prefs, None)) + + def test_common_case_with_continuity(self): + challs = (acme_util.RECOVERY_TOKEN, + acme_util.RECOVERY_CONTACT, + acme_util.DVSNI, + acme_util.SIMPLE_HTTPS) + prefs = [challenges.RecoveryToken, challenges.DVSNI] + combos = acme_util.gen_combos(challs) + self.assertEqual(self._call(challs, prefs, combos), (0, 2)) + + # dumb_path() trivial test + self.assertTrue(self._call(challs, prefs, None)) + + def test_full_client_server(self): + challs = (acme_util.RECOVERY_TOKEN, + acme_util.RECOVERY_CONTACT, + acme_util.POP, + acme_util.DVSNI, + acme_util.SIMPLE_HTTPS, + acme_util.DNS) + # Typical webserver client that can do everything except DNS + # Attempted to make the order realistic + prefs = [challenges.RecoveryToken, + challenges.ProofOfPossession, + challenges.SimpleHTTPS, + challenges.DVSNI, + challenges.RecoveryContact] + combos = acme_util.gen_combos(challs) + self.assertEqual(self._call(challs, prefs, combos), (0, 4)) + + # Dumb path trivial test + self.assertTrue(self._call(challs, prefs, None)) + + def test_not_supported(self): + challs = (acme_util.POP, acme_util.DVSNI) + prefs = [challenges.DVSNI] + combos = ((0, 1),) + + self.assertRaises(errors.LetsEncryptAuthHandlerError, + self._call, + challs, prefs, combos) + + class MutuallyExclusiveTest(unittest.TestCase): """Tests for letsencrypt.client.auth_handler.mutually_exclusive.""" diff --git a/tox.ini b/tox.ini index bb5ac1bb7..fe9da1865 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ setenv = basepython = python2.7 commands = pip install -e .[testing] - python setup.py nosetests --with-coverage --cover-min-percentage=86 + python setup.py nosetests --with-coverage --cover-min-percentage=87 [testenv:lint] # recent versions of pylint do not support Python 2.6 (#97, #187) From b67068e9865817a15cc958647794beb052d0a86b Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 27 Mar 2015 21:09:03 -0700 Subject: [PATCH 48/54] fix typo in challenges doc --- letsencrypt/acme/challenges.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/acme/challenges.py b/letsencrypt/acme/challenges.py index 9227fa1a1..7e107962d 100644 --- a/letsencrypt/acme/challenges.py +++ b/letsencrypt/acme/challenges.py @@ -184,7 +184,7 @@ class ProofOfPossession(ClientChallenge): """Hints for "proofOfPossession" challenge. :ivar jwk: JSON Web Key (:class:`letsencrypt.acme.jose.JWK`) - :ivar list certs: List of :class:`M2Crypto.X509.X509` cetificates. + :ivar list certs: List of :class:`M2Crypto.X509.X509` certificates. """ jwk = jose.Field("jwk", decoder=jose.JWK.from_json) From da14e149b1c88ac24949552a738904da1775664d Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 27 Mar 2015 21:36:06 -0700 Subject: [PATCH 49/54] add exception documentation --- letsencrypt/client/auth_handler.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 136265aa6..38e2c1c7d 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -333,6 +333,10 @@ def gen_challenge_path(challs, preferences, combinations): :returns: tuple of indices from ``challenges``. :rtype: tuple + :raises letsencrypt.client.errors.LetsEncryptAuthHandlerError: If a + path cannot be created that satisfies the CA given the preferences and + combinations. + """ if combinations: return _find_smart_path(challs, preferences, combinations) From d4594f02ed9491aed85a05fdbba9badde2ee9907 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 28 Mar 2015 07:14:11 +0000 Subject: [PATCH 50/54] HashableRSAKey --- letsencrypt/acme/challenges_test.py | 6 ++++-- letsencrypt/acme/jose/__init__.py | 1 + letsencrypt/acme/jose/jwk.py | 9 +++++++-- letsencrypt/acme/jose/util.py | 20 ++++++++++++++++++ letsencrypt/acme/jose/util_test.py | 29 +++++++++++++++++++++++++++ letsencrypt/acme/messages_test.py | 5 +++-- letsencrypt/acme/other_test.py | 10 +++++---- letsencrypt/client/auth_handler.py | 5 +++-- letsencrypt/client/client.py | 7 ++++--- letsencrypt/client/tests/acme_util.py | 6 ++++-- 10 files changed, 81 insertions(+), 17 deletions(-) diff --git a/letsencrypt/acme/challenges_test.py b/letsencrypt/acme/challenges_test.py index 081560fe1..f1507c7fd 100644 --- a/letsencrypt/acme/challenges_test.py +++ b/letsencrypt/acme/challenges_test.py @@ -13,8 +13,10 @@ from letsencrypt.acme import other CERT = jose.ComparableX509(M2Crypto.X509.load_cert( pkg_resources.resource_filename( 'letsencrypt.client.tests', 'testdata/cert.pem'))) -KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - 'letsencrypt.client.tests', os.path.join('testdata', 'rsa256_key.pem'))) +KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + 'letsencrypt.client.tests', + os.path.join('testdata', 'rsa256_key.pem')))) class SimpleHTTPSTest(unittest.TestCase): diff --git a/letsencrypt/acme/jose/__init__.py b/letsencrypt/acme/jose/__init__.py index 4c7398b79..20f9ba7d3 100644 --- a/letsencrypt/acme/jose/__init__.py +++ b/letsencrypt/acme/jose/__init__.py @@ -70,5 +70,6 @@ from letsencrypt.acme.jose.jws import JWS from letsencrypt.acme.jose.util import ( ComparableX509, + HashableRSAKey, ImmutableMap, ) diff --git a/letsencrypt/acme/jose/jwk.py b/letsencrypt/acme/jose/jwk.py index 1a83a5305..1b7e00e56 100644 --- a/letsencrypt/acme/jose/jwk.py +++ b/letsencrypt/acme/jose/jwk.py @@ -83,7 +83,11 @@ class JWKOct(JWK): @JWK.register class JWKRSA(JWK): - """RSA JWK.""" + """RSA JWK. + + :ivar key: `Crypto.PublicKey.RSA` wrapped in `.HashableRSAKey` + + """ typ = 'RSA' __slots__ = ('key',) @@ -114,7 +118,8 @@ class JWKRSA(JWK): :rtype: :class:`JWKRSA` """ - return cls(key=Crypto.PublicKey.RSA.importKey(string)) + return cls(key=util.HashableRSAKey( + Crypto.PublicKey.RSA.importKey(string))) def public(self): return type(self)(key=self.key.publickey()) diff --git a/letsencrypt/acme/jose/util.py b/letsencrypt/acme/jose/util.py index 5f516884f..7bac8b866 100644 --- a/letsencrypt/acme/jose/util.py +++ b/letsencrypt/acme/jose/util.py @@ -41,6 +41,26 @@ class ComparableX509(object): # pylint: disable=too-few-public-methods return self.as_der() == other.as_der() +class HashableRSAKey(object): # pylint: disable=too-few-public-methods + """Wrapper for `Crypto.PublicKey.RSA` objects that supports hashing.""" + + def __init__(self, wrapped): + self._wrapped = wrapped + + def __getattr__(self, name): + return getattr(self._wrapped, name) + + def __eq__(self, other): + return self._wrapped == other + + def __hash__(self): + return hash((type(self), self.exportKey(format='DER'))) + + def publickey(self): + """Get wrapped public key.""" + return type(self)(self._wrapped.publickey()) + + class ImmutableMap(collections.Mapping, collections.Hashable): # pylint: disable=too-few-public-methods """Immutable key to value mapping with attribute access.""" diff --git a/letsencrypt/acme/jose/util_test.py b/letsencrypt/acme/jose/util_test.py index 671b45472..14d40b0fd 100644 --- a/letsencrypt/acme/jose/util_test.py +++ b/letsencrypt/acme/jose/util_test.py @@ -1,7 +1,36 @@ """Tests for letsencrypt.acme.jose.util.""" import functools +import os +import pkg_resources import unittest +import Crypto.PublicKey.RSA + + +class HashableRSAKeyTest(unittest.TestCase): + """Tests for letsencrypt.acme.jose.util.HashableRSAKey.""" + + def setUp(self): + from letsencrypt.acme.jose.util import HashableRSAKey + self.key = HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa256_key.pem')))) + self.key_same = HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + __name__, os.path.join('testdata', 'rsa256_key.pem')))) + + def test_eq(self): + # if __eq__ is not defined, then two HashableRSAKeys with same + # _wrapped do not equate + self.assertEqual(self.key, self.key_same) + + def test_hash(self): + self.assertTrue(isinstance(hash(self.key), int)) + + def test_publickey(self): + from letsencrypt.acme.jose.util import HashableRSAKey + self.assertTrue(isinstance(self.key.publickey(), HashableRSAKey)) + class ImmutableMapTest(unittest.TestCase): """Tests for letsencrypt.acme.jose.util.ImmutableMap.""" diff --git a/letsencrypt/acme/messages_test.py b/letsencrypt/acme/messages_test.py index bd6f4d702..0d15633a5 100644 --- a/letsencrypt/acme/messages_test.py +++ b/letsencrypt/acme/messages_test.py @@ -11,8 +11,9 @@ from letsencrypt.acme import jose from letsencrypt.acme import other -KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - 'letsencrypt.client.tests', 'testdata/rsa256_key.pem')) +KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + 'letsencrypt.client.tests', 'testdata/rsa256_key.pem'))) CERT = jose.ComparableX509(M2Crypto.X509.load_cert( pkg_resources.resource_filename( 'letsencrypt.client.tests', 'testdata/cert.pem'))) diff --git a/letsencrypt/acme/other_test.py b/letsencrypt/acme/other_test.py index 61c37f6a3..047abe54d 100644 --- a/letsencrypt/acme/other_test.py +++ b/letsencrypt/acme/other_test.py @@ -7,10 +7,12 @@ import Crypto.PublicKey.RSA from letsencrypt.acme import jose -RSA256_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - 'letsencrypt.client.tests', 'testdata/rsa256_key.pem')) -RSA512_KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - 'letsencrypt.client.tests', 'testdata/rsa512_key.pem')) +RSA256_KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + 'letsencrypt.client.tests', 'testdata/rsa256_key.pem'))) +RSA512_KEY = jose.HashableRSAKey( + Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( + 'letsencrypt.client.tests', 'testdata/rsa512_key.pem'))) class SignatureTest(unittest.TestCase): diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 05f3722cf..565be1a2d 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -5,6 +5,7 @@ import sys import Crypto.PublicKey.RSA from letsencrypt.acme import challenges +from letsencrypt.acme import jose from letsencrypt.acme import messages from letsencrypt.client import achallenges @@ -119,8 +120,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes nonce=self.msgs[domain].nonce, responses=self.responses[domain], name=domain, - key=Crypto.PublicKey.RSA.importKey( - self.authkey[domain].pem)), + key=jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + self.authkey[domain].pem))), messages.Authorization) logging.info("Received Authorization for %s", domain) return auth diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 2f3f9a769..e66c45dc2 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -6,8 +6,8 @@ import sys import Crypto.PublicKey.RSA import M2Crypto +from letsencrypt.acme import jose from letsencrypt.acme import messages -from letsencrypt.acme.jose import util as jose_util from letsencrypt.client import auth_handler from letsencrypt.client import client_authenticator @@ -130,9 +130,10 @@ class Client(object): logging.info("Preparing and sending CSR...") return self.network.send_and_receive_expected( messages.CertificateRequest.create( - csr=jose_util.ComparableX509( + csr=jose.ComparableX509( M2Crypto.X509.load_request_der_string(csr_der)), - key=Crypto.PublicKey.RSA.importKey(self.authkey.pem)), + key=jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + self.authkey.pem))), messages.Certificate) def save_certificate(self, certificate_msg, cert_path, chain_path): diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index aba839f8c..be47bccfd 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -8,8 +8,10 @@ from letsencrypt.acme import challenges from letsencrypt.acme import jose -KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( - "letsencrypt.client.tests", os.path.join("testdata", "rsa256_key.pem"))) +KEY = jose.HashableRSAKey(Crypto.PublicKey.RSA.importKey( + pkg_resources.resource_string( + "letsencrypt.client.tests", + os.path.join("testdata", "rsa256_key.pem")))) # Challenges SIMPLE_HTTPS = challenges.SimpleHTTPS( From cd0b99ae5d14e14de15166dc48dce1839fe6f0f4 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Sun, 29 Mar 2015 23:11:05 -0700 Subject: [PATCH 51/54] Fix ambiguity about describing a port as "open" --- letsencrypt/client/plugins/standalone/authenticator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/plugins/standalone/authenticator.py b/letsencrypt/client/plugins/standalone/authenticator.py index 22597eba7..e0b06aa30 100644 --- a/letsencrypt/client/plugins/standalone/authenticator.py +++ b/letsencrypt/client/plugins/standalone/authenticator.py @@ -410,5 +410,5 @@ class StandaloneAuthenticator(object): "on port 443 and perform DVSNI challenges. Once a certificate" "is attained, it will be saved in the " "(TODO) current working directory.{0}{0}" - "Port 443 must be open in order to use the " + "TCP port 443 must be available in order to use the " "Standalone Authenticator.".format(os.linesep)) From 8561de7e73896da9043a437463ab838b104d7758 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 30 Mar 2015 12:09:07 -0700 Subject: [PATCH 52/54] Small doc change and formatting --- letsencrypt/acme/challenges.py | 3 ++- letsencrypt/client/tests/auth_handler_test.py | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt/acme/challenges.py b/letsencrypt/acme/challenges.py index 7e107962d..0ff4306a5 100644 --- a/letsencrypt/acme/challenges.py +++ b/letsencrypt/acme/challenges.py @@ -184,7 +184,8 @@ class ProofOfPossession(ClientChallenge): """Hints for "proofOfPossession" challenge. :ivar jwk: JSON Web Key (:class:`letsencrypt.acme.jose.JWK`) - :ivar list certs: List of :class:`M2Crypto.X509.X509` certificates. + :ivar list certs: List of :class:`letsencrypt.acme.jose.ComparableX509` + certificates. """ jwk = jose.Field("jwk", decoder=jose.JWK.from_json) diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index 6150899de..106c1230f 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -581,8 +581,7 @@ class GenChallengePathTest(unittest.TestCase): combos = ((0, 1),) self.assertRaises(errors.LetsEncryptAuthHandlerError, - self._call, - challs, prefs, combos) + self._call, challs, prefs, combos) class MutuallyExclusiveTest(unittest.TestCase): From 9ffcbf9934f3dbba90d8e84299c80c43abe8adbd Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 30 Mar 2015 13:33:44 -0700 Subject: [PATCH 53/54] revert to set --- letsencrypt/client/auth_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index c264ee239..f0b257984 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -397,13 +397,13 @@ def _find_dumb_path(challs, preferences): path = [] # This cannot be a set() because POP challenge is not currently hashable - satisfied = [] + satisfied = set() for pref_c in preferences: for i, offered_chall in enumerate(challs): if (isinstance(offered_chall, pref_c) and is_preferred(offered_chall, satisfied)): path.append(i) - satisfied.append(offered_chall) + satisfied.add(offered_chall) return path From 1c254d64ef84d798a39f95c98210bf6246aefdb9 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 30 Mar 2015 13:54:06 -0700 Subject: [PATCH 54/54] remove old comment --- letsencrypt/client/auth_handler.py | 1 - 1 file changed, 1 deletion(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index f0b257984..72843332b 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -396,7 +396,6 @@ def _find_dumb_path(challs, preferences): assert len(preferences) == len(set(preferences)) path = [] - # This cannot be a set() because POP challenge is not currently hashable satisfied = set() for pref_c in preferences: for i, offered_chall in enumerate(challs):