From 3d883fd77f2285f11e6250591a09d9d3c50ddd33 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 12 Feb 2015 08:43:42 +0000 Subject: [PATCH 01/18] (Typed)ACMEObject class hierarchy, from_json vs from_valid_json. --- letsencrypt/acme/errors.py | 8 +- letsencrypt/acme/interfaces.py | 11 +++ letsencrypt/acme/jose.py | 6 +- letsencrypt/acme/jose_test.py | 2 +- letsencrypt/acme/messages.py | 129 ++++++++++++---------------- letsencrypt/acme/messages_test.py | 74 ++++++++-------- letsencrypt/acme/other.py | 10 +-- letsencrypt/acme/other_test.py | 3 +- letsencrypt/acme/util.py | 138 +++++++++++++++--------------- letsencrypt/acme/util_test.py | 118 ++++++++++--------------- 10 files changed, 227 insertions(+), 272 deletions(-) diff --git a/letsencrypt/acme/errors.py b/letsencrypt/acme/errors.py index a70271894..c88881412 100644 --- a/letsencrypt/acme/errors.py +++ b/letsencrypt/acme/errors.py @@ -4,10 +4,10 @@ class Error(Exception): """Generic ACME error.""" class ValidationError(Error): - """ACME message validation error.""" + """ACME object validation error.""" -class UnrecognizedMessageTypeError(ValidationError): - """Unrecognized ACME message type error.""" +class UnrecognizedTypeError(ValidationError): + """Unrecognized ACME object type error.""" class SchemaValidationError(ValidationError): - """JSON schema ACME message validation error.""" + """JSON schema ACME object validation error.""" diff --git a/letsencrypt/acme/interfaces.py b/letsencrypt/acme/interfaces.py index 0d9e56495..c80b6c2fa 100644 --- a/letsencrypt/acme/interfaces.py +++ b/letsencrypt/acme/interfaces.py @@ -2,6 +2,7 @@ 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): @@ -20,3 +21,13 @@ class IJSONSerializable(zope.interface.Interface): :rtype: dict """ + +class IJSONDeserializable(zope.interface.Interface): + """JSON deserializable class.""" + + def from_valid_json(jobj): + """Deserialize valid JSON object. + + :param jobj: Validated JSON object. + + """ diff --git a/letsencrypt/acme/jose.py b/letsencrypt/acme/jose.py index 6d2097ba5..b08e1e036 100644 --- a/letsencrypt/acme/jose.py +++ b/letsencrypt/acme/jose.py @@ -13,7 +13,7 @@ def _leading_zeros(arg): return arg -class JWK(util.JSONDeSerializable, util.ImmutableMap): +class JWK(util.ACMEObject): # pylint: disable=too-few-public-methods """JSON Web Key. @@ -21,7 +21,6 @@ class JWK(util.JSONDeSerializable, util.ImmutableMap): """ __slots__ = ('key',) - schema = util.load_schema('jwk') @classmethod def _encode_param(cls, param): @@ -35,7 +34,6 @@ class JWK(util.JSONDeSerializable, util.ImmutableMap): return long(binascii.hexlify(b64decode(param)), 16) def to_json(self): - """Serialize to JSON.""" return { 'kty': 'RSA', # TODO 'n': self._encode_param(self.key.n), @@ -43,7 +41,7 @@ class JWK(util.JSONDeSerializable, util.ImmutableMap): } @classmethod - def _from_valid_json(cls, jobj): + def from_valid_json(cls, jobj): assert 'RSA' == jobj['kty'] # TODO return cls(key=Crypto.PublicKey.RSA.construct( (cls._decode_param(jobj['n']), cls._decode_param(jobj['e'])))) diff --git a/letsencrypt/acme/jose_test.py b/letsencrypt/acme/jose_test.py index a1a872704..4f2828ec5 100644 --- a/letsencrypt/acme/jose_test.py +++ b/letsencrypt/acme/jose_test.py @@ -45,7 +45,7 @@ class JWKTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.jose import JWK - self.assertEqual(self.jwk256, JWK.from_json(self.jwk256json)) + self.assertEqual(self.jwk256, JWK.from_valid_json(self.jwk256json)) # TODO: fix schemata to allow RSA512 #self.assertEqual(self.jwk512, JWK.from_json(self.jwk512json)) diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py index a345be9f9..3e3efd3b7 100644 --- a/letsencrypt/acme/messages.py +++ b/letsencrypt/acme/messages.py @@ -1,58 +1,21 @@ """ACME protocol messages.""" +import json + +import jsonschema import M2Crypto -import zope.interface from letsencrypt.acme import errors -from letsencrypt.acme import interfaces from letsencrypt.acme import jose from letsencrypt.acme import other from letsencrypt.acme import util -class Message(util.JSONDeSerializable, util.ImmutableMap): - """ACME message. - - Messages are considered immutable. - - """ - zope.interface.implements(interfaces.IJSONSerializable) - - acme_type = NotImplemented - """ACME message "type" field. Subclasses must override.""" - +class Message(util.TypedACMEObject): + # _fields_to_json | pylint: disable=abstract-method + """ACME message.""" TYPES = {} - """Message 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 message. - :meth:`validate` will almost certainly not work, due to reasons - explained in :class:`letsencrypt.acme.interfaces.IJSONSerializable`. - :rtype: dict - - """ - jobj = self._fields_to_json() - jobj["type"] = self.acme_type - return jobj - - def _fields_to_json(self): - """Prepare ACME message fields for JSON serialiazation. - - Subclasses must override this method. - - :returns: Serializable JSON object containg all ACME message fields - apart from "type". - :rtype: dict - - """ - raise NotImplementedError() + schema = NotImplemented @classmethod def get_msg_cls(cls, jobj): @@ -74,30 +37,47 @@ class Message(util.JSONDeSerializable, util.ImmutableMap): try: msg_cls = cls.TYPES[msg_type] except KeyError: - raise errors.UnrecognizedMessageTypeError(msg_type) + raise errors.UnrecognizedTypeError(msg_type) return msg_cls @classmethod - def from_json(cls, jobj, validate=True): - """Deserialize validated ACME message from JSON string. + def from_json(cls, jobj): + """Deserialize from (possibly invalid) JSON object. - :param str jobj: JSON object. - :param bool validate: Validate against schema before deserializing. - Useful if :class:`JWK` is part of already validated json object. + Note that the input ``jobj`` has not been sanitized in any way. - :raises letsencrypt.acme.errors.ValidationError: if validation - was unsuccessful + :param jobj: JSON object. - :returns: Valid ACME message. - :rtype: subclass of :class:`Message` + :raises letsencrypt.acme.errors.SchemaValidationError: if ``validate`` + was ``True`` and object couldn't be validated. + + :returns: instance of the class """ msg_cls = cls.get_msg_cls(jobj) - if validate: - msg_cls.validate_json(jobj) - # pylint: disable=protected-access - return msg_cls._from_valid_json(jobj) + + 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) @Message.register # pylint: disable=too-few-public-methods @@ -118,7 +98,7 @@ class Challenge(Message): return fields @classmethod - def _from_valid_json(cls, jobj): + def from_valid_json(cls, jobj): return cls(session_id=jobj["sessionID"], nonce=jose.b64decode(jobj["nonce"]), challenges=jobj["challenges"], @@ -142,7 +122,7 @@ class ChallengeRequest(Message): } @classmethod - def _from_valid_json(cls, jobj): + def from_valid_json(cls, jobj): return cls(identifier=jobj["identifier"]) @@ -164,10 +144,10 @@ class Authorization(Message): return fields @classmethod - def _from_valid_json(cls, jobj): + def from_valid_json(cls, jobj): jwk = jobj.get("jwk") if jwk is not None: - jwk = jose.JWK.from_json(jwk, validate=False) + jwk = jose.JWK.from_valid_json(jwk) return cls(recovery_token=jobj.get("recoveryToken"), identifier=jobj.get("identifier"), jwk=jwk) @@ -236,12 +216,11 @@ class AuthorizationRequest(Message): return fields @classmethod - def _from_valid_json(cls, jobj): + def from_valid_json(cls, jobj): return cls(session_id=jobj["sessionID"], nonce=jose.b64decode(jobj["nonce"]), responses=jobj["responses"], - signature=other.Signature.from_json( - jobj["signature"], validate=False), + signature=other.Signature.from_valid_json(jobj["signature"]), contact=jobj.get("contact", [])) @@ -278,7 +257,7 @@ class Certificate(Message): return jose.b64encode(cert.as_der()) @classmethod - def _from_valid_json(cls, jobj): + def from_valid_json(cls, jobj): return cls(certificate=cls._decode_cert(jobj["certificate"]), chain=[cls._decode_cert(cert) for cert in jobj.get("chain", [])], @@ -344,10 +323,9 @@ class CertificateRequest(Message): } @classmethod - def _from_valid_json(cls, jobj): + def from_valid_json(cls, jobj): return cls(csr=cls._decode_csr(jobj["csr"]), - signature=other.Signature.from_json( - jobj["signature"], validate=False)) + signature=other.Signature.from_valid_json(jobj["signature"])) @Message.register # pylint: disable=too-few-public-methods @@ -366,7 +344,7 @@ class Defer(Message): return fields @classmethod - def _from_valid_json(cls, jobj): + def from_valid_json(cls, jobj): return cls(token=jobj["token"], interval=jobj.get("interval"), message=jobj.get("message")) @@ -396,7 +374,7 @@ class Error(Message): return fields @classmethod - def _from_valid_json(cls, jobj): + def from_valid_json(cls, jobj): return cls(error=jobj["error"], message=jobj.get("message"), more_info=jobj.get("moreInfo")) @@ -412,7 +390,7 @@ class Revocation(Message): return {} @classmethod - def _from_valid_json(cls, jobj): + def from_valid_json(cls, jobj): return cls() @@ -475,10 +453,9 @@ class RevocationRequest(Message): } @classmethod - def _from_valid_json(cls, jobj): + def from_valid_json(cls, jobj): return cls(certificate=cls._decode_cert(jobj["certificate"]), - signature=other.Signature.from_json( - jobj["signature"], validate=False)) + signature=other.Signature.from_valid_json(jobj["signature"])) @Message.register # pylint: disable=too-few-public-methods @@ -496,5 +473,5 @@ class StatusRequest(Message): return {"token": self.token} @classmethod - def _from_valid_json(cls, jobj): + def from_valid_json(cls, jobj): return cls(token=jobj["token"]) diff --git a/letsencrypt/acme/messages_test.py b/letsencrypt/acme/messages_test.py index 018854225..9ec4c5ba1 100644 --- a/letsencrypt/acme/messages_test.py +++ b/letsencrypt/acme/messages_test.py @@ -4,7 +4,6 @@ import unittest import Crypto.PublicKey.RSA import M2Crypto.X509 -import mock from letsencrypt.acme import errors from letsencrypt.acme import jose @@ -28,7 +27,13 @@ class MessageTest(unittest.TestCase): def setUp(self): # pylint: disable=missing-docstring,too-few-public-methods from letsencrypt.acme.messages import Message - class TestMessage(Message): + + class MockParentMessage(Message): + # pylint: disable=abstract-method + TYPES = {} + + @MockParentMessage.register + class MockMessage(MockParentMessage): acme_type = 'test' schema = { 'type': 'object', @@ -37,53 +42,45 @@ class MessageTest(unittest.TestCase): 'name': {'type': 'string'}, }, } + __slots__ = ('price', 'name') @classmethod - def _from_valid_json(cls, jobj): - return jobj + def from_valid_json(cls, jobj): + return cls(price=jobj.get('price'), name=jobj.get('name')) def _fields_to_json(self): - return {'foo': 'bar'} + # pylint: disable=no-member + return {'price': self.price, 'name': self.name} - self.msg_cls = TestMessage - - def test_to_json(self): - self.assertEqual(self.msg_cls().to_json(), { - 'type': 'test', - 'foo': 'bar', - }) - - def test_fields_to_json_not_implemented(self): - from letsencrypt.acme.messages import Message - # pylint: disable=protected-access - self.assertRaises(NotImplementedError, Message()._fields_to_json) - - @classmethod - def _from_json(cls, jobj, validate=True): - from letsencrypt.acme.messages import Message - return Message.from_json(jobj, validate) + self.parent_cls = MockParentMessage + self.msg = MockMessage(price=123, name='foo') def test_from_json_non_dict_fails(self): - self.assertRaises(errors.ValidationError, self._from_json, []) + self.assertRaises(errors.ValidationError, self.parent_cls.from_json, []) def test_from_json_dict_no_type_fails(self): - self.assertRaises(errors.ValidationError, self._from_json, {}) + self.assertRaises(errors.ValidationError, self.parent_cls.from_json, {}) - def test_from_json_unknown_type_fails(self): - self.assertRaises(errors.UnrecognizedMessageTypeError, - self._from_json, {'type': 'bar'}) + def test_from_json_unrecognized_type(self): + self.assertRaises(errors.UnrecognizedTypeError, + self.parent_cls.from_json, {'type': 'foo'}) - @mock.patch('letsencrypt.acme.messages.Message.TYPES') - def test_from_json_validate_errors(self, types): - types.__getitem__.side_effect = lambda x: {'foo': self.msg_cls}[x] + def test_from_json_validates(self): self.assertRaises(errors.SchemaValidationError, - self._from_json, {'type': 'foo', 'price': 'asd'}) + self.parent_cls.from_json, + {'type': 'test', 'price': 'asd'}) - @mock.patch('letsencrypt.acme.messages.Message.TYPES') - def test_from_json_valid_returns_cls(self, types): - types.__getitem__.side_effect = lambda x: {'foo': self.msg_cls}[x] - self.assertEqual(self._from_json({'type': 'foo'}, validate=False), - {'type': 'foo'}) + 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): @@ -408,10 +405,7 @@ class RevocationTest(unittest.TestCase): def setUp(self): from letsencrypt.acme.messages import Revocation self.msg = Revocation() - - self.jmsg = { - 'type': 'revocation', - } + self.jmsg = {'type': 'revocation'} def test_to_json(self): self.assertEqual(self.msg.to_json(), self.jmsg) diff --git a/letsencrypt/acme/other.py b/letsencrypt/acme/other.py index 1fe0d9463..a874a8cab 100644 --- a/letsencrypt/acme/other.py +++ b/letsencrypt/acme/other.py @@ -1,4 +1,4 @@ -"""JSON objects in ACME protocol other than messages.""" +"""Other ACME objects.""" import logging from Crypto import Random @@ -9,7 +9,7 @@ from letsencrypt.acme import jose from letsencrypt.acme import util -class Signature(util.JSONDeSerializable, util.ImmutableMap): +class Signature(util.ACMEObject): """ACME signature. :ivar str alg: Signature algorithm. @@ -23,7 +23,6 @@ class Signature(util.JSONDeSerializable, util.ImmutableMap): """ __slots__ = ('alg', 'sig', 'nonce', 'jwk') - schema = util.load_schema('signature') NONCE_LEN = 16 """Size of nonce in bytes, as specified in the ACME protocol.""" @@ -68,7 +67,6 @@ class Signature(util.JSONDeSerializable, util.ImmutableMap): hashed, self.sig) def to_json(self): - """Prepare JSON serializable object.""" return { 'alg': self.alg, 'sig': jose.b64encode(self.sig), @@ -77,7 +75,7 @@ class Signature(util.JSONDeSerializable, util.ImmutableMap): } @classmethod - def _from_valid_json(cls, jobj): + def from_valid_json(cls, jobj): return cls(alg=jobj['alg'], sig=jose.b64decode(jobj['sig']), nonce=jose.b64decode(jobj['nonce']), - jwk=jose.JWK.from_json(jobj['jwk'], validate=False)) + jwk=jose.JWK.from_valid_json(jobj['jwk'])) diff --git a/letsencrypt/acme/other_test.py b/letsencrypt/acme/other_test.py index 292fbd886..5279cd66b 100644 --- a/letsencrypt/acme/other_test.py +++ b/letsencrypt/acme/other_test.py @@ -78,9 +78,8 @@ class SigatureTest(unittest.TestCase): def test_from_json(self): from letsencrypt.acme.other import Signature - # pylint: disable=protected-access self.assertEqual( - self.signature, Signature._from_valid_json(self.jsig_from)) + self.signature, Signature.from_valid_json(self.jsig_from)) if __name__ == '__main__': diff --git a/letsencrypt/acme/util.py b/letsencrypt/acme/util.py index 8906e584a..d7ea7fac6 100644 --- a/letsencrypt/acme/util.py +++ b/letsencrypt/acme/util.py @@ -2,7 +2,6 @@ import json import pkg_resources -import jsonschema import zope.interface from letsencrypt.acme import errors @@ -34,73 +33,6 @@ def load_schema(name): __name__, "schemata/%s.json" % name))) -class JSONDeSerializable(object): - """JSON (de)serializable object.""" - zope.interface.implements(interfaces.IJSONSerializable) - - schema = NotImplemented - - @classmethod - def validate_json(cls, jobj): - """Validate JSON object against schema. - - :raises letsencrypt.acme.errors.SchemaValidationError: if object - couldn't be validated. - - """ - try: - jsonschema.validate(jobj, cls.schema) - except jsonschema.ValidationError as error: - raise errors.SchemaValidationError(error) - - @classmethod - def from_json(cls, jobj, validate=True): - """Deserialize from JSON. - - Note that the input ``jobj`` has not been sanitized in any way. - - :param jobj: JSON object. - :param bool validate: Validate against schema before deserializing. - Useful if :class:`JWK` is part of already validated json object. - - :raises letsencrypt.acme.errors.SchemaValidationError: if ``validate`` - was ``True`` and object couldn't be validated. - - :returns: instance of the class - - """ - if validate: - cls.validate_json(jobj) - return cls._from_valid_json(jobj) - - @classmethod - def _from_valid_json(cls, jobj): - """Deserializa from valid JSON object. - - :param jobj: JSON object that has been validated against schema. - - """ - raise NotImplementedError() - - @classmethod - def json_loads(cls, json_string, validate=True): - """Load JSON string.""" - return cls.from_json(json.loads(json_string), validate) - - def to_json(self): - """Prepare JSON serializable object.""" - raise NotImplementedError() - - def json_dumps(self): - """Dump to JSON string using proper serializer. - - :returns: JSON serialized string. - :rtype: str - - """ - return json.dumps(self, default=dump_ijsonserializable) - - def dump_ijsonserializable(python_object): """Serialize IJSONSerializable to JSON. @@ -145,3 +77,73 @@ class ImmutableMap(object): # pylint: disable=too-few-public-methods 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() + + +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. + :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["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 index cf71963e8..65c52f7ff 100644 --- a/letsencrypt/acme/util_test.py +++ b/letsencrypt/acme/util_test.py @@ -9,79 +9,16 @@ from letsencrypt.acme import errors from letsencrypt.acme import interfaces -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] - - -class JSONDeSerializableTest(unittest.TestCase): - """Tests for letsencrypt.acme.util.JSONDeSerializable.""" - - def setUp(self): - from letsencrypt.acme.util import JSONDeSerializable - - class Tester(JSONDeSerializable): - # pylint: disable=missing-docstring,no-self-use, - # pylint: disable=too-few-public-methods - zope.interface.implements(interfaces.IJSONSerializable) - - schema = {'type': 'integer'} - - def __init__(self, jobj): - self.jobj = jobj - - @classmethod - def _from_valid_json(cls, jobj): - return cls(jobj) - - def to_json(self): - return {'foo': MockJSONSerialiazable()} - - self.tester_cls = Tester - - def test_validate_invalid_json(self): - self.assertRaises(errors.SchemaValidationError, - self.tester_cls.validate_json, 'bang!') - - def test_validate_valid_json(self): - self.tester_cls.validate_json(5) - - def test_from_json(self): - self.assertEqual(5, self.tester_cls.from_json(5, validate=True).jobj) - - def test_from_json_no_validation(self): - self.assertEqual(['1', 2], self.tester_cls.from_json( - ['1', 2], validate=False).jobj) - - def test_from_valid_json_raises_error(self): - from letsencrypt.acme.util import JSONDeSerializable - # pylint: disable=protected-access - self.assertRaises( - NotImplementedError, JSONDeSerializable._from_valid_json, 'foo') - - def test_json_loads(self): - tester = self.tester_cls.json_loads('5', validate=True) - self.assertEqual(tester.jobj, 5) - - def test_json_loads_no_validation(self): - self.assertEqual( - 'foo', self.tester_cls.json_loads('"foo"', validate=False).jobj) - - def test_to_json_raises_error(self): - from letsencrypt.acme.util import JSONDeSerializable - self.assertRaises(NotImplementedError, JSONDeSerializable().to_json) - - def test_json_dumps(self): - self.assertEqual( - self.tester_cls('foo').json_dumps(), '{"foo": [3, 2, 1]}') - - 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 @@ -91,7 +28,7 @@ class DumpIJSONSerializableTest(unittest.TestCase): self.assertEqual('5', self._call(5)) def test_ijsonserializable(self): - self.assertEqual('[3, 2, 1]', self._call(MockJSONSerialiazable())) + self.assertEqual('[3, 2, 1]', self._call(self.MockJSONSerialiazable())) def test_raises_type_error(self): self.assertRaises(TypeError, self._call, object()) @@ -163,5 +100,44 @@ class ImmutableMapTest(unittest.TestCase): self.assertEqual("B(x='foo', y='bar')", repr(self.B(x='foo', y='bar'))) +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() From 76085f0bb07ac6bbb5d5b228a48895cd086a209b Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 13 Feb 2015 12:37:00 +0000 Subject: [PATCH 02/18] Non-schema errors for acme.messages --- letsencrypt/acme/interfaces.py | 8 ++- letsencrypt/acme/jose.py | 46 --------------- letsencrypt/acme/jose_test.py | 48 ---------------- letsencrypt/acme/messages.py | 34 +---------- letsencrypt/acme/messages_test.py | 8 +-- letsencrypt/acme/other.py | 78 ++++++++++++++++++++----- letsencrypt/acme/other_test.py | 63 +++++++++++++++++++- letsencrypt/acme/util.py | 51 ++++++++++++++++ letsencrypt/acme/util_test.py | 96 +++++++++++++++++++++++++++++++ letsencrypt/client/network.py | 12 +++- 10 files changed, 294 insertions(+), 150 deletions(-) diff --git a/letsencrypt/acme/interfaces.py b/letsencrypt/acme/interfaces.py index c80b6c2fa..a106f2aca 100644 --- a/letsencrypt/acme/interfaces.py +++ b/letsencrypt/acme/interfaces.py @@ -28,6 +28,12 @@ class IJSONDeserializable(zope.interface.Interface): def from_valid_json(jobj): """Deserialize valid JSON object. - :param jobj: Validated 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.py b/letsencrypt/acme/jose.py index b08e1e036..81c1abbf7 100644 --- a/letsencrypt/acme/jose.py +++ b/letsencrypt/acme/jose.py @@ -1,51 +1,5 @@ """JOSE.""" import base64 -import binascii - -import Crypto.PublicKey.RSA - -from letsencrypt.acme import util - - -def _leading_zeros(arg): - if len(arg) % 2: - return '0' + arg - return arg - - -class JWK(util.ACMEObject): - # pylint: disable=too-few-public-methods - """JSON Web Key. - - .. todo:: Currently works for RSA public keys only. - - """ - __slots__ = ('key',) - - @classmethod - def _encode_param(cls, param): - """Encode numeric key parameter.""" - return b64encode(binascii.unhexlify( - _leading_zeros(hex(param)[2:].rstrip('L')))) - - @classmethod - def _decode_param(cls, param): - """Decode numeric key parameter.""" - return long(binascii.hexlify(b64decode(param)), 16) - - def to_json(self): - return { - 'kty': 'RSA', # TODO - 'n': self._encode_param(self.key.n), - 'e': self._encode_param(self.key.e), - } - - @classmethod - def from_valid_json(cls, jobj): - assert 'RSA' == jobj['kty'] # TODO - return cls(key=Crypto.PublicKey.RSA.construct( - (cls._decode_param(jobj['n']), cls._decode_param(jobj['e'])))) - # https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-37#appendix-C # diff --git a/letsencrypt/acme/jose_test.py b/letsencrypt/acme/jose_test.py index 4f2828ec5..42cf8051c 100644 --- a/letsencrypt/acme/jose_test.py +++ b/letsencrypt/acme/jose_test.py @@ -1,54 +1,6 @@ """Tests for letsencrypt.acme.jose.""" -import pkg_resources import unittest -import Crypto.PublicKey.RSA - - -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')) - - -class JWKTest(unittest.TestCase): - """Tests fro letsencrypt.acme.jose.JWK.""" - - def setUp(self): - from letsencrypt.acme.jose import JWK - self.jwk256 = JWK(key=RSA256_KEY.publickey()) - self.jwk256json = { - 'kty': 'RSA', - 'e': 'AQAB', - 'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5' - '80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q', - } - self.jwk512 = JWK(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_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 import JWK - self.assertEqual(self.jwk256, JWK.from_valid_json(self.jwk256json)) - # TODO: fix schemata to allow RSA512 - #self.assertEqual(self.jwk512, JWK.from_json(self.jwk512json)) - # https://en.wikipedia.org/wiki/Base64#Examples B64_PADDING_EXAMPLES = { diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py index 3e3efd3b7..63978e9fd 100644 --- a/letsencrypt/acme/messages.py +++ b/letsencrypt/acme/messages.py @@ -2,7 +2,6 @@ import json import jsonschema -import M2Crypto from letsencrypt.acme import errors from letsencrypt.acme import jose @@ -100,7 +99,7 @@ class Challenge(Message): @classmethod def from_valid_json(cls, jobj): return cls(session_id=jobj["sessionID"], - nonce=jose.b64decode(jobj["nonce"]), + nonce=cls._decode_b64jose(jobj["nonce"]), challenges=jobj["challenges"], combinations=jobj.get("combinations", [])) @@ -147,7 +146,7 @@ class Authorization(Message): def from_valid_json(cls, jobj): jwk = jobj.get("jwk") if jwk is not None: - jwk = jose.JWK.from_valid_json(jwk) + jwk = other.JWK.from_valid_json(jwk) return cls(recovery_token=jobj.get("recoveryToken"), identifier=jobj.get("identifier"), jwk=jwk) @@ -218,7 +217,7 @@ class AuthorizationRequest(Message): @classmethod def from_valid_json(cls, jobj): return cls(session_id=jobj["sessionID"], - nonce=jose.b64decode(jobj["nonce"]), + nonce=cls._decode_b64jose(jobj["nonce"]), responses=jobj["responses"], signature=other.Signature.from_valid_json(jobj["signature"]), contact=jobj.get("contact", [])) @@ -247,15 +246,6 @@ class Certificate(Message): fields["refresh"] = self.refresh return fields - @classmethod - def _decode_cert(cls, b64der): - return util.ComparableX509(M2Crypto.X509.load_cert_der_string( - jose.b64decode(b64der))) - - @classmethod - def _encode_cert(cls, cert): - return jose.b64encode(cert.as_der()) - @classmethod def from_valid_json(cls, jobj): return cls(certificate=cls._decode_cert(jobj["certificate"]), @@ -307,15 +297,6 @@ class CertificateRequest(Message): """ return self.signature.verify(self.csr.as_der()) - @classmethod - def _decode_csr(cls, b64der): - return util.ComparableX509(M2Crypto.X509.load_request_der_string( - jose.b64decode(b64der))) - - @classmethod - def _encode_csr(cls, csr): - return jose.b64encode(csr.as_der()) - def _fields_to_json(self): return { "csr": self._encode_csr(self.csr), @@ -437,15 +418,6 @@ class RevocationRequest(Message): """ return self.signature.verify(self.certificate.as_der()) - @classmethod - def _decode_cert(cls, b64der): - return util.ComparableX509(M2Crypto.X509.load_cert_der_string( - jose.b64decode(b64der))) - - @classmethod - def _encode_cert(cls, cert): - return jose.b64encode(cert.as_der()) - def _fields_to_json(self): return { "certificate": self._encode_cert(self.certificate), diff --git a/letsencrypt/acme/messages_test.py b/letsencrypt/acme/messages_test.py index 9ec4c5ba1..0bc793eeb 100644 --- a/letsencrypt/acme/messages_test.py +++ b/letsencrypt/acme/messages_test.py @@ -146,7 +146,7 @@ class ChallengeRequestTest(unittest.TestCase): class AuthorizationTest(unittest.TestCase): def setUp(self): - jwk = jose.JWK(key=KEY.publickey()) + jwk = other.JWK(key=KEY.publickey()) from letsencrypt.acme.messages import Authorization self.msg = Authorization(recovery_token='tok', jwk=jwk, @@ -192,7 +192,7 @@ class AuthorizationRequestTest(unittest.TestCase): ] self.contact = ["mailto:cert-admin@example.com", "tel:+12025551212"] signature = other.Signature( - alg='RS256', jwk=jose.JWK(key=KEY.publickey()), + alg='RS256', jwk=other.JWK(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' - self.jwk = jose.JWK(key=RSA256_KEY.publickey()) + + from letsencrypt.acme.other import JWK + self.jwk = JWK(key=RSA256_KEY.publickey()) b64sig = ('SUPYKucUnhlTt8_sMxLiigOYdf_wlOLXPI-o7aRLTsOquVjDd6r' 'AX9AFJHk-bCMQPJbSzXKjG6H1IWbvxjS2Ew') @@ -81,6 +132,14 @@ class SigatureTest(unittest.TestCase): self.assertEqual( self.signature, Signature.from_valid_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}) + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/acme/util.py b/letsencrypt/acme/util.py index d7ea7fac6..ac7bf3874 100644 --- a/letsencrypt/acme/util.py +++ b/letsencrypt/acme/util.py @@ -1,11 +1,14 @@ """ACME utilities.""" +import binascii import json import pkg_resources +import M2Crypto.X509 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 @@ -93,6 +96,54 @@ class ACMEObject(ImmutableMap): # pylint: disable=too-few-public-methods """Deserialize from valid JSON object.""" raise NotImplementedError() + @classmethod + def _decode_b64jose(cls, data, size=None, minimum=False): + try: + decoded = jose.b64decode(data) + 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 + + @classmethod + def _encode_hex16(cls, data): + return binascii.hexlify(data) + + @classmethod + def _decode_hex16(cls, data, size=None, minimum=False): + if size is not None and ((not minimum and len(data) != size * 2) + or (minimum and len(data) < size * 2)): + raise errors.ValidationError() + return binascii.unhexlify(data) + + @classmethod + def _encode_cert(cls, cert): + return jose.b64encode(cert.as_der()) + + @classmethod + def _decode_cert(cls, b64der): + try: + return ComparableX509(M2Crypto.X509.load_cert_der_string( + cls._decode_b64jose(b64der))) + except M2Crypto.X509.X509Error: + raise errors.ValidationError() + + @classmethod + def _encode_csr(cls, csr): + return cls._encode_cert(csr) + + @classmethod + def _decode_csr(cls, b64der): + try: + return ComparableX509(M2Crypto.X509.load_request_der_string( + cls._decode_b64jose(b64der))) + except M2Crypto.X509.X509Error: + raise errors.ValidationError() + class TypedACMEObject(ACMEObject): """ACME object with type (immutable).""" diff --git a/letsencrypt/acme/util_test.py b/letsencrypt/acme/util_test.py index 65c52f7ff..c64c5dfca 100644 --- a/letsencrypt/acme/util_test.py +++ b/letsencrypt/acme/util_test.py @@ -1,14 +1,23 @@ """Tests for letsencrypt.acme.util.""" import functools import json +import os +import pkg_resources import unittest +import M2Crypto.X509 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.""" @@ -100,6 +109,93 @@ class ImmutableMapTest(unittest.TestCase): self.assertEqual("B(x='foo', y='bar')", repr(self.B(x='foo', y='bar'))) +class ACMEObjectTest(unittest.TestCase): + """Tests for letsencrypt.acme.util.ACMEObject.""" + # 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 ACMEObject + self.assertRaises( + errors.ValidationError, ACMEObject._decode_b64jose, 'x') + + def test_decode_b64_jose_size(self): + from letsencrypt.acme.util import ACMEObject + self.assertEqual('foo', ACMEObject._decode_b64jose('Zm9v', size=3)) + self.assertRaises( + errors.ValidationError, ACMEObject._decode_b64jose, 'Zm9v', size=2) + self.assertRaises( + errors.ValidationError, ACMEObject._decode_b64jose, 'Zm9v', size=4) + + def test_decode_b64_jose_minimum_size(self): + from letsencrypt.acme.util import ACMEObject + self.assertEqual( + 'foo', ACMEObject._decode_b64jose('Zm9v', size=3, minimum=True)) + self.assertEqual( + 'foo', ACMEObject._decode_b64jose('Zm9v', size=2, minimum=True)) + self.assertRaises(errors.ValidationError, ACMEObject._decode_b64jose, + 'Zm9v', size=4, minimum=True) + + def test_encode_hex16(self): + from letsencrypt.acme.util import ACMEObject + self.assertEqual('666f6f', ACMEObject._encode_hex16('foo')) + + def test_decode_hex16(self): + from letsencrypt.acme.util import ACMEObject + self.assertEqual('foo', ACMEObject._decode_hex16('666f6f')) + + def test_decode_hex16_minimum_size(self): + from letsencrypt.acme.util import ACMEObject + self.assertEqual( + 'foo', ACMEObject._decode_hex16('666f6f', size=3, minimum=True)) + self.assertEqual( + 'foo', ACMEObject._decode_hex16('666f6f', size=2, minimum=True)) + self.assertRaises(errors.ValidationError, ACMEObject._decode_hex16, + '666f6f', size=4, minimum=True) + + def test_encode_cert(self): + from letsencrypt.acme.util import ACMEObject + self.assertEqual(self.b64_cert, ACMEObject._encode_cert(CERT)) + + def test_decode_cert(self): + from letsencrypt.acme.util import ACMEObject + self.assertEqual(CERT, ACMEObject._decode_cert(self.b64_cert)) + self.assertRaises(errors.ValidationError, ACMEObject._decode_cert, '') + + def test_encode_csr(self): + from letsencrypt.acme.util import ACMEObject + self.assertEqual(self.b64_csr, ACMEObject._encode_csr(CSR)) + + def test_decode_csr(self): + from letsencrypt.acme.util import ACMEObject + self.assertEqual(CSR, ACMEObject._decode_csr(self.b64_csr)) + self.assertRaises(errors.ValidationError, ACMEObject._decode_csr, '') + + class TypedACMEObjectTest(unittest.TestCase): def setUp(self): diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py index bdba746b0..b61a8a2f8 100644 --- a/letsencrypt/client/network.py +++ b/letsencrypt/client/network.py @@ -5,6 +5,7 @@ import time import requests +from letsencrypt.acme import errors as acme_errors from letsencrypt.acme import messages from letsencrypt.client import errors @@ -36,8 +37,8 @@ class Network(object): :returns: Server response message. :rtype: :class:`letsencrypt.acme.messages.Message` - :raises TypeError: if `msg` is not JSON serializable - :raises jsonschema.ValidationError: if not valid ACME message + :raises letsencrypt.acme.errors.ValidationError: if `msg` is not + valid serializable ACME JSON message. :raises errors.LetsEncryptClientError: in case of connection error or if response from server is not a valid ACME message. @@ -53,7 +54,12 @@ class Network(object): raise errors.LetsEncryptClientError( 'Sending ACME message to server has failed: %s' % error) - return messages.Message.from_json(response.json(), validate=True) + json_string = response.json() + try: + return messages.Message.from_json(json_string) + except acme_errors.ValidationError as error: + logging.error(json_string) + raise # TODO def send_and_receive_expected(self, msg, expected): """Send ACME message to server and return expected message. From 52257c4d6b1bad063fe75a14a45cbbad4bf416ca Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 12 Feb 2015 08:45:03 +0000 Subject: [PATCH 03/18] Add acme.challenges. --- docs/api/acme/challenges.rst | 5 + letsencrypt/acme/challenges.py | 360 ++++++++++++++++++++++++ letsencrypt/acme/challenges_test.py | 411 ++++++++++++++++++++++++++++ 3 files changed, 776 insertions(+) create mode 100644 docs/api/acme/challenges.rst create mode 100644 letsencrypt/acme/challenges.py create mode 100644 letsencrypt/acme/challenges_test.py diff --git a/docs/api/acme/challenges.rst b/docs/api/acme/challenges.rst new file mode 100644 index 000000000..373748d61 --- /dev/null +++ b/docs/api/acme/challenges.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.acme.challenges` +---------------------------------- + +.. automodule:: letsencrypt.acme.challenges + :members: diff --git a/letsencrypt/acme/challenges.py b/letsencrypt/acme/challenges.py new file mode 100644 index 000000000..fb3110bb1 --- /dev/null +++ b/letsencrypt/acme/challenges.py @@ -0,0 +1,360 @@ +"""ACME Identifier Validation Challenges.""" +import functools +import hashlib + +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): + # _fields_to_json | pylint: disable=abstract-method + """ACME challenge.""" + TYPES = {} + + +class ClientChallenge(Challenge): # pylint: disable=abstract-method + """Client validation challenges.""" + + +class DVChallenge(Challenge): # pylint: disable=abstract-method + """Domain validation challenges.""" + + +class ChallengeResponse(util.TypedACMEObject): + # _fields_to_json | pylint: disable=abstract-method + """ACME challenge response.""" + TYPES = {} + + @classmethod + def from_valid_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) + + +@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"]) + + +@ChallengeResponse.register +class SimpleHTTPSResponse(ChallengeResponse): + """ACME "simpleHttps" challenge response.""" + acme_type = "simpleHttps" + __slots__ = ("path",) + + URI_TEMPLATE = "https://{domain}/.well-known/acme-challenge/{path}" + """URI template for HTTPS server provisioned resource.""" + + def uri(self, domain): + """Create an URI to the provisioned resource. + + Forms an URI to the HTTPS server provisioned resource (containing + :attr:`~SimpleHTTPS.token`) by populating the :attr:`URI_TEMPLATE`. + + :param str domain: Domain name being verified. + + """ + 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): + """ACME "dvsni" challenge. + + :ivar str r: Random data, **not** base64-encoded. + :ivar str nonce: Random data, **not** hex-encoded. + + """ + acme_type = "dvsni" + __slots__ = ("r", "nonce") + + DOMAIN_SUFFIX = ".acme.invalid" + """Domain name suffix.""" + + R_SIZE = 32 + """Required size of the :attr:`r` in bytes.""" + + NONCE_SIZE = 16 + """Required size of the :attr:`nonce` in bytes.""" + + @property + def nonce_domain(self): + """Domain name used in SNI.""" + return self._encode_hex16(self.nonce) + self.DOMAIN_SUFFIX + + def _fields_to_json(self): + return { + "r": jose.b64encode(self.r), + "nonce": self._encode_hex16(self.nonce), + } + + @classmethod + def from_valid_json(cls, jobj): + return cls(r=cls._decode_b64jose(jobj["r"], cls.R_SIZE), + nonce=cls._decode_hex16(jobj["nonce"], cls.NONCE_SIZE)) + + +@ChallengeResponse.register +class DVSNIResponse(ChallengeResponse): + """ACME "dvsni" challenge response. + + :param str s: Random data, **not** base64-encoded. + + """ + acme_type = "dvsni" + __slots__ = ("s",) + + DOMAIN_SUFFIX = DVSNI.DOMAIN_SUFFIX + """Domain name suffix.""" + + S_SIZE = 32 + """Required size of the :attr:`s` in bytes.""" + + 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) + + def z(self, chall): # pylint: disable=invalid-name + """Compute the parameter ``z``. + + :param challenge: Corresponding challenge. + :type challenge: :class:`DVSNI` + + """ + z = hashlib.new("sha256") # pylint: disable=invalid-name + z.update(chall.r) + z.update(self.s) + return z.hexdigest() + + def z_domain(self, chall): + """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=cls._decode_b64jose(jobj["s"], cls.S_SIZE)) + + +@Challenge.register +class RecoveryContact(ClientChallenge): + """ACME "recoveryContact" challenge.""" + acme_type = "recoveryContact" + __slots__ = ("activation_url", "success_url", "contact") + + 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")) + + +@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")) + + +@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() + + +@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 + + +@Challenge.register +class ProofOfPossession(ClientChallenge): + """ACME "proofOfPossession" challenge. + + :ivar str nonce: Random data, **not** base64-encoded. + :ivar hints: Various clues for the client (:class:`Hints`). + + """ + acme_type = "proofOfPossession" + __slots__ = ("alg", "nonce", "hints") + + NONCE_SIZE = 16 + + class Hints(util.ACMEObject): + """Hints for "proofOfPossession" challenge. + + :ivar jwk: JSON Web Key (:class:`letsencrypt.acme.other.JWK`) + :ivar list certs: List of :class:`M2Crypto.X509.X509` cetificates. + + """ + __slots__ = ( + "jwk", "cert_fingerprints", "certs", "subject_key_identifiers", + "serial_numbers", "issuers", "authorized_for") + + def to_json(self): + fields = {"jwk": self.jwk} + add = functools.partial(_extend_if_not_empty, fields) + add(self.cert_fingerprints, "certFingerprints") + add([self._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 + + @classmethod + def from_valid_json(cls, jobj): + return cls( + jwk=other.JWK.from_valid_json(jobj["jwk"]), + cert_fingerprints=jobj.get("certFingerprints", []), + certs=[cls._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", [])) + + 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=cls._decode_b64jose(jobj["nonce"], cls.NONCE_SIZE), + hints=cls.Hints.from_valid_json(jobj["hints"])) + + +@ChallengeResponse.register +class ProofOfPossessionResponse(ChallengeResponse): + """ACME "proofOfPossession" challenge response. + + :ivar str nonce: Random data, **not** base64-encoded. + :ivar signature: :class:`~letsencrypt.acme.other.Signature` of this message. + + """ + acme_type = "proofOfPossession" + __slots__ = ("nonce", "signature") + + NONCE_SIZE = ProofOfPossession.NONCE_SIZE + + def verify(self): + """Verify the challenge.""" + 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=cls._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"]) + + +@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() diff --git a/letsencrypt/acme/challenges_test.py b/letsencrypt/acme/challenges_test.py new file mode 100644 index 000000000..a2293d294 --- /dev/null +++ b/letsencrypt/acme/challenges_test.py @@ -0,0 +1,411 @@ +"""Tests for letsencrypt.acme.challenges.""" +import os +import pkg_resources +import unittest + +import Crypto.PublicKey.RSA +import M2Crypto.X509 + +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( + 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'))) + + +class SimpleHTTPSTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.acme.challenges import SimpleHTTPS + self.msg = SimpleHTTPS( + token='evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA') + self.jmsg = { + 'type': 'simpleHttps', + 'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA', + } + + def test_to_json(self): + self.assertEqual(self.jmsg, self.msg.to_json()) + + def test_from_json(self): + from letsencrypt.acme.challenges import SimpleHTTPS + self.assertEqual(self.msg, SimpleHTTPS.from_valid_json(self.jmsg)) + + +class SimpleHTTPSResponseTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.acme.challenges import SimpleHTTPSResponse + self.msg = SimpleHTTPSResponse(path='6tbIMBC5Anhl5bOlWT5ZFA') + self.jmsg = { + 'type': 'simpleHttps', + 'path': '6tbIMBC5Anhl5bOlWT5ZFA', + } + + def test_uri(self): + self.assertEqual('https://example.com/.well-known/acme-challenge/' + '6tbIMBC5Anhl5bOlWT5ZFA', self.msg.uri('example.com')) + + def test_to_json(self): + self.assertEqual(self.jmsg, self.msg.to_json()) + + def test_from_json(self): + from letsencrypt.acme.challenges import SimpleHTTPSResponse + self.assertEqual( + self.msg, SimpleHTTPSResponse.from_valid_json(self.jmsg)) + + +class DVSNITest(unittest.TestCase): + + def setUp(self): + from letsencrypt.acme.challenges import DVSNI + self.msg = DVSNI( + r="O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6" + "\xbf'\xb3\xed\x9a9nX\x0f'\\m\xe7\x12", + nonce='\xa8-_\xf8\xeft\r\x12\x88\x1fm<"w\xab.') + self.jmsg = { + 'type': 'dvsni', + 'r': 'Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI', + 'nonce': 'a82d5ff8ef740d12881f6d3c2277ab2e', + } + + def test_nonce_domain(self): + self.assertEqual('a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid', + self.msg.nonce_domain) + + def test_to_json(self): + self.assertEqual(self.jmsg, self.msg.to_json()) + + def test_from_json(self): + from letsencrypt.acme.challenges import DVSNI + self.assertEqual(self.msg, DVSNI.from_valid_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) + + 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) + + +class DVSNIResponseTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.acme.challenges import DVSNIResponse + self.msg = DVSNIResponse( + s='\xf5\xd6\xe3\xb2]\xe0L\x0bN\x9cKJ\x14I\xa1K\xa3#\xf9\xa8' + '\xcd\x8c7\x0e\x99\x19)\xdc\xb7\xf3\x9bw') + self.jmsg = { + 'type': 'dvsni', + 's': '9dbjsl3gTAtOnEtKFEmhS6Mj-ajNjDcOmRkp3Lfzm3c', + } + + def test_z_and_domain(self): + from letsencrypt.acme.challenges import DVSNI + challenge = DVSNI( + r="O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6" + "\xbf'\xb3\xed\x9a9nX\x0f'\\m\xe7\x12", + nonce=long('439736375371401115242521957580409149254868992063' + '44333654741504362774620418661L')) + # pylint: disable=invalid-name + z = '38e612b0397cc2624a07d351d7ef50e46134c0213d9ed52f7d7c611acaeed41b' + self.assertEqual(z, self.msg.z(challenge)) + self.assertEqual( + '{0}.acme.invalid'.format(z), self.msg.z_domain(challenge)) + + def test_to_json(self): + self.assertEqual(self.jmsg, self.msg.to_json()) + + def test_from_json(self): + from letsencrypt.acme.challenges import DVSNIResponse + self.assertEqual(self.msg, DVSNIResponse.from_valid_json(self.jmsg)) + + +class RecoveryContactTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.acme.challenges import RecoveryContact + self.msg = RecoveryContact( + activation_url='https://example.ca/sendrecovery/a5bd99383fb0', + success_url='https://example.ca/confirmrecovery/bb1b9928932', + contact='c********n@example.com') + self.jmsg = { + 'type': 'recoveryContact', + 'activationURL' : 'https://example.ca/sendrecovery/a5bd99383fb0', + 'successURL' : 'https://example.ca/confirmrecovery/bb1b9928932', + 'contact' : 'c********n@example.com', + } + + def test_to_json(self): + self.assertEqual(self.jmsg, self.msg.to_json()) + + def test_from_json(self): + from letsencrypt.acme.challenges import RecoveryContact + self.assertEqual(self.msg, RecoveryContact.from_valid_json(self.jmsg)) + + def test_json_without_optionals(self): + del self.jmsg['activationURL'] + del self.jmsg['successURL'] + del self.jmsg['contact'] + + from letsencrypt.acme.challenges import RecoveryContact + msg = RecoveryContact.from_valid_json(self.jmsg) + + self.assertTrue(msg.activation_url is None) + self.assertTrue(msg.success_url is None) + self.assertTrue(msg.contact is None) + self.assertEqual(self.jmsg, msg.to_json()) + + +class RecoveryContactResponseTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.acme.challenges import RecoveryContactResponse + self.msg = RecoveryContactResponse(token='23029d88d9e123e') + self.jmsg = {'type': 'recoveryContact', 'token': '23029d88d9e123e'} + + def test_to_json(self): + self.assertEqual(self.jmsg, self.msg.to_json()) + + def test_from_json(self): + from letsencrypt.acme.challenges import RecoveryContactResponse + self.assertEqual( + self.msg, RecoveryContactResponse.from_valid_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) + + self.assertTrue(msg.token is None) + self.assertEqual(self.jmsg, msg.to_json()) + + +class RecoveryTokenTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.acme.challenges import RecoveryToken + self.msg = RecoveryToken() + self.jmsg = {'type': 'recoveryToken'} + + def test_to_json(self): + self.assertEqual(self.jmsg, self.msg.to_json()) + + def test_from_json(self): + from letsencrypt.acme.challenges import RecoveryToken + self.assertEqual(self.msg, RecoveryToken.from_valid_json(self.jmsg)) + + +class RecoveryTokenResponseTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.acme.challenges import RecoveryTokenResponse + self.msg = RecoveryTokenResponse(token='23029d88d9e123e') + self.jmsg = {'type': 'recoveryToken', 'token': '23029d88d9e123e'} + + def test_to_json(self): + self.assertEqual(self.jmsg, self.msg.to_json()) + + def test_from_json(self): + from letsencrypt.acme.challenges import RecoveryTokenResponse + self.assertEqual( + self.msg, RecoveryTokenResponse.from_valid_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) + + self.assertTrue(msg.token is None) + self.assertEqual(self.jmsg, msg.to_json()) + + +class ProofOfPossessionHintsTest(unittest.TestCase): + + def setUp(self): + jwk = other.JWK(key=KEY.publickey()) + issuers = [ + 'C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA', + 'O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure', + ] + cert_fingerprints = [ + '93416768eb85e33adc4277f4c9acd63e7418fcfe', + '16d95b7b63f1972b980b14c20291f3c0d1855d95', + '48b46570d9fc6358108af43ad1649484def0debf', + ] + 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, + authorized_for=authorized_for, serial_numbers=serial_numbers) + + self.jmsg_to = { + 'jwk': jwk, + 'certFingerprints': cert_fingerprints, + '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()}) + + def test_to_json(self): + self.assertEqual(self.jmsg_to, self.msg.to_json()) + + def test_from_json(self): + from letsencrypt.acme.challenges import ProofOfPossession + self.assertEqual( + self.msg, ProofOfPossession.Hints.from_valid_json(self.jmsg_from)) + + def test_json_without_optionals(self): + for optional in ['certFingerprints', 'certs', 'subjectKeyIdentifiers', + 'serialNumbers', 'issuers', 'authorizedFor']: + del self.jmsg_from[optional] + del self.jmsg_to[optional] + + from letsencrypt.acme.challenges import ProofOfPossession + msg = ProofOfPossession.Hints.from_valid_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(self.jmsg_to, msg.to_json()) + + +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=[]) + self.msg = ProofOfPossession( + alg='RS256', nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ', + hints=hints) + + self.jmsg_to = { + 'type': 'proofOfPossession', + 'alg': 'RS256', + 'nonce': 'eET5udtV7aoX8Xl8gYiZIA', + 'hints': hints, + } + self.jmsg_from = { + 'type': 'proofOfPossession', + 'alg': 'RS256', + 'nonce': 'eET5udtV7aoX8Xl8gYiZIA', + 'hints': hints.to_json(), + } + 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()) + + def test_from_json(self): + from letsencrypt.acme.challenges import ProofOfPossession + self.assertEqual( + self.msg, ProofOfPossession.from_valid_json(self.jmsg_from)) + + +class ProofOfPossessionResponseTest(unittest.TestCase): + + def setUp(self): + # acme-spec uses a confusing example in which both signature + # 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()), + 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' + '\x99\x08\xf0\x0e{', + nonce='\x99\xc7Q\xb3f2\xbc\xdci\xfe\xd6\x98k\xc67\xdf', + ) + + from letsencrypt.acme.challenges import ProofOfPossessionResponse + self.msg = ProofOfPossessionResponse( + nonce='xD\xf9\xb9\xdbU\xed\xaa\x17\xf1y|\x81\x88\x99 ', + signature=signature) + + self.jmsg_to = { + 'type': 'proofOfPossession', + 'nonce': 'eET5udtV7aoX8Xl8gYiZIA', + 'signature': signature, + } + self.jmsg_from = { + 'type': 'proofOfPossession', + 'nonce': 'eET5udtV7aoX8Xl8gYiZIA', + 'signature': signature.to_json(), + } + self.jmsg_from['signature']['jwk'] = self.jmsg_from[ + 'signature']['jwk'].to_json() + + + def test_verify(self): + self.assertTrue(self.msg.verify()) + + def test_to_json(self): + self.assertEqual(self.jmsg_to, self.msg.to_json()) + + def test_from_json(self): + from letsencrypt.acme.challenges import ProofOfPossessionResponse + self.assertEqual( + self.msg, ProofOfPossessionResponse.from_valid_json(self.jmsg_from)) + + +class DNSTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.acme.challenges import DNS + self.msg = DNS(token='17817c66b60ce2e4012dfad92657527a') + self.jmsg = {'type': 'dns', 'token': '17817c66b60ce2e4012dfad92657527a'} + + def test_to_json(self): + self.assertEqual(self.jmsg, self.msg.to_json()) + + def test_from_json(self): + from letsencrypt.acme.challenges import DNS + self.assertEqual(self.msg, DNS.from_valid_json(self.jmsg)) + + +class DNSResponseTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.acme.challenges import DNSResponse + self.msg = DNSResponse() + self.jmsg = {'type': 'dns'} + + def test_to_json(self): + self.assertEqual(self.jmsg, self.msg.to_json()) + + def test_from_json(self): + from letsencrypt.acme.challenges import DNSResponse + self.assertEqual(self.msg, DNSResponse.from_valid_json(self.jmsg)) + + +if __name__ == '__main__': + unittest.main() From 0b8767f990e058a56bf2ebc2cece535df4f5cfc9 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 12 Feb 2015 08:47:17 +0000 Subject: [PATCH 04/18] Catch-all __slots__ linter_plugin --- linter_plugin.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/linter_plugin.py b/linter_plugin.py index d5faf33ac..ac2a01f6d 100644 --- a/linter_plugin.py +++ b/linter_plugin.py @@ -14,8 +14,10 @@ def _transform(cls): # fix the "no-member" error on instances of # letsencrypt.acme.util.ImmutableMap subclasses (instance # attributes are initialized dynamically based on __slots__) - if (('Message' in cls.basenames or 'ImmutableMap' in cls.basenames or - 'util.ImmutableMap' in cls.basenames) and (cls.slots() is not None)): + + # TODO: this is too broad and applies to any tested class... + + if cls.slots() is not None: for slot in cls.slots(): cls.locals[slot.value] = [nodes.EmptyNode()] From 9ba69f8878e7b78413d9d1860f796989bcd93f4e Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 12 Feb 2015 10:31:04 +0000 Subject: [PATCH 05/18] Use acme.challenges in acme.messages --- letsencrypt/acme/messages.py | 27 ++++++++++++---- letsencrypt/acme/messages_test.py | 53 ++++++++++++++++++++++--------- 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py index 63978e9fd..a673631fa 100644 --- a/letsencrypt/acme/messages.py +++ b/letsencrypt/acme/messages.py @@ -3,6 +3,7 @@ import json import jsonschema +from letsencrypt.acme import challenges from letsencrypt.acme import errors from letsencrypt.acme import jose from letsencrypt.acme import other @@ -96,11 +97,23 @@ class Challenge(Message): fields["combinations"] = self.combinations return fields + @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=cls._decode_b64jose(jobj["nonce"]), - challenges=jobj["challenges"], + challenges=[challenges.Challenge.from_valid_json(chall) + for chall in jobj["challenges"]], combinations=jobj.get("combinations", [])) @@ -216,11 +229,13 @@ class AuthorizationRequest(Message): @classmethod def from_valid_json(cls, jobj): - return cls(session_id=jobj["sessionID"], - nonce=cls._decode_b64jose(jobj["nonce"]), - responses=jobj["responses"], - signature=other.Signature.from_valid_json(jobj["signature"]), - contact=jobj.get("contact", [])) + return cls( + session_id=jobj["sessionID"], + nonce=cls._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 diff --git a/letsencrypt/acme/messages_test.py b/letsencrypt/acme/messages_test.py index 0bc793eeb..4662da1f7 100644 --- a/letsencrypt/acme/messages_test.py +++ b/letsencrypt/acme/messages_test.py @@ -5,6 +5,7 @@ import unittest import Crypto.PublicKey.RSA import M2Crypto.X509 +from letsencrypt.acme import challenges from letsencrypt.acme import errors from letsencrypt.acme import jose from letsencrypt.acme import other @@ -86,10 +87,10 @@ class MessageTest(unittest.TestCase): class ChallengeTest(unittest.TestCase): def setUp(self): - challenges = [ - {'type': 'simpleHttps', 'token': 'IlirfxKKXAsHtmzK29Pj8A'}, - {'type': 'dns', 'token': 'DGyRejmCefe7v4NfDGDKfA'}, - {'type': 'recoveryToken'}, + challs = [ + challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'), + challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'), + challenges.RecoveryToken(), ] combinations = [[0, 2], [1, 2]] @@ -97,31 +98,52 @@ class ChallengeTest(unittest.TestCase): self.msg = Challenge( session_id='aefoGaavieG9Wihuk2aufai3aeZ5EeW4', nonce='\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9', - challenges=challenges, combinations=combinations) + challenges=challs, combinations=combinations) - self.jmsg = { + self.jmsg_to = { 'type': 'challenge', 'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4', 'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ', - 'challenges': challenges, + 'challenges': challs, 'combinations': combinations, } + self.jmsg_from = { + 'type': 'challenge', + 'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4', + 'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ', + 'challenges': [chall.to_json() for chall in challs], + 'combinations': combinations, + } + + def test_resolved_combinations(self): + self.assertEqual(self.msg.resolved_combinations, [ + [ + challenges.SimpleHTTPS(token='IlirfxKKXAsHtmzK29Pj8A'), + challenges.RecoveryToken() + ], + [ + challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA'), + challenges.RecoveryToken(), + ] + ]) + def test_to_json(self): - self.assertEqual(self.msg.to_json(), self.jmsg) + self.assertEqual(self.msg.to_json(), self.jmsg_to) def test_from_json(self): from letsencrypt.acme.messages import Challenge - self.assertEqual(Challenge.from_json(self.jmsg), self.msg) + self.assertEqual(Challenge.from_json(self.jmsg_from), self.msg) def test_json_without_optionals(self): - del self.jmsg['combinations'] + del self.jmsg_from['combinations'] + del self.jmsg_to['combinations'] from letsencrypt.acme.messages import Challenge - msg = Challenge.from_json(self.jmsg) + msg = Challenge.from_json(self.jmsg_from) self.assertEqual(msg.combinations, []) - self.assertEqual(msg.to_json(), self.jmsg) + self.assertEqual(msg.to_json(), self.jmsg_to) class ChallengeRequestTest(unittest.TestCase): @@ -186,9 +208,9 @@ class AuthorizationRequestTest(unittest.TestCase): def setUp(self): self.responses = [ - {'type': 'simpleHttps', 'path': 'Hf5GrX4Q7EBax9hc2jJnfw'}, + challenges.SimpleHTTPSResponse(path='Hf5GrX4Q7EBax9hc2jJnfw'), None, # null - {'type': 'recoveryToken', 'token': '23029d88d9e123e'}, + challenges.RecoveryTokenResponse(token='23029d88d9e123e'), ] self.contact = ["mailto:cert-admin@example.com", "tel:+12025551212"] signature = other.Signature( @@ -220,7 +242,8 @@ class AuthorizationRequestTest(unittest.TestCase): 'type': 'authorizationRequest', 'sessionID': 'aefoGaavieG9Wihuk2aufai3aeZ5EeW4', 'nonce': '7Nbyb1lI6xPVI3Hg3aKSqQ', - 'responses': self.responses, + 'responses': [None if response is None else response.to_json() + for response in self.responses], 'signature': signature.to_json(), 'contact': self.contact, } From 97bf10120c6f74121aa905ea3a9419f73515d352 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 13 Feb 2015 22:37:45 +0000 Subject: [PATCH 06/18] Use acme.challenges in client --- docs/api/client/achallenges.rst | 5 + docs/api/client/challenge_util.rst | 5 - letsencrypt/client/achallenges.py | 102 ++++++ letsencrypt/client/apache/configurator.py | 30 +- letsencrypt/client/apache/dvsni.py | 71 ++-- letsencrypt/client/auth_handler.py | 256 ++++++--------- letsencrypt/client/challenge_util.py | 74 ----- letsencrypt/client/client_authenticator.py | 22 +- letsencrypt/client/constants.py | 18 +- letsencrypt/client/interfaces.py | 42 +-- letsencrypt/client/recovery_token.py | 17 +- .../client/standalone_authenticator.py | 42 +-- letsencrypt/client/tests/achallenges_test.py | 62 ++++ letsencrypt/client/tests/acme_util.py | 120 +++---- .../client/tests/apache/configurator_test.py | 26 +- letsencrypt/client/tests/apache/dvsni_test.py | 93 +++--- letsencrypt/client/tests/auth_handler_test.py | 304 +++++++++++------- .../client/tests/challenge_util_test.py | 57 ---- .../client/tests/client_authenticator_test.py | 28 +- .../client/tests/recovery_token_test.py | 25 +- .../tests/standalone_authenticator_test.py | 105 +++--- 21 files changed, 742 insertions(+), 762 deletions(-) create mode 100644 docs/api/client/achallenges.rst delete mode 100644 docs/api/client/challenge_util.rst create mode 100644 letsencrypt/client/achallenges.py delete mode 100644 letsencrypt/client/challenge_util.py create mode 100644 letsencrypt/client/tests/achallenges_test.py delete mode 100644 letsencrypt/client/tests/challenge_util_test.py diff --git a/docs/api/client/achallenges.rst b/docs/api/client/achallenges.rst new file mode 100644 index 000000000..46a13ee8b --- /dev/null +++ b/docs/api/client/achallenges.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.achallenges` +------------------------------------- + +.. automodule:: letsencrypt.client.achallenges + :members: diff --git a/docs/api/client/challenge_util.rst b/docs/api/client/challenge_util.rst deleted file mode 100644 index 3866230a5..000000000 --- a/docs/api/client/challenge_util.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.challenge_util` ----------------------------------------- - -.. automodule:: letsencrypt.client.challenge_util - :members: diff --git a/letsencrypt/client/achallenges.py b/letsencrypt/client/achallenges.py new file mode 100644 index 000000000..835bd1e8d --- /dev/null +++ b/letsencrypt/client/achallenges.py @@ -0,0 +1,102 @@ +"""Client annotated ACME challenges. + +Please use names such as ``achall`` and ``ichall`` (respectively ``achalls`` +and ``ichalls`` for collections) to distiguish from variables "of type" +:class:`letsencrypt.acme.challenges.Challenge` (denoted by ``chall``):: + + from letsencrypt.acme import challenges + from letsencrypt.client import achallenges + + chall = challenges.DNS(token='foo') + achall = achallenges.DNS(chall=chall, domain='example.com') + ichall = achallenges.Indexed(achall=achall, index=0) + +Note, that all annotated challenges act as a proxy objects:: + + ichall.token == achall.token == chall.token + +""" +from letsencrypt.acme import challenges +from letsencrypt.acme import util as acme_util + +from letsencrypt.client import crypto_util + + +# pylint: disable=too-few-public-methods + + +class AnnotatedChallenge(acme_util.ImmutableMap): + """Client annotated challenge. + + Wraps around :class:`~letsencrypt.acme.challenges.Challenge` and + annotates with data usfeul for the client. + + """ + acme_type = NotImplemented + + def __getattr__(self, name): + return getattr(self.chall, name) + + +class DVSNI(AnnotatedChallenge): + """Client annotated "dvsni" ACME challenge.""" + __slots__ = ('chall', 'domain', 'key') + acme_type = challenges.DVSNI + + def gen_cert_and_response(self, s=None): # pylint: disable=invalid-name + """Generate a DVSNI cert and save it to filepath. + + :returns: ``(cert_pem, response)`` tuple, where ``cert_pem`` is the PEM + encoded certificate and ``response`` is an instance + :class:`letsencrypt.acme.challenges.DVSNIResponse`. + :rtype: tuple + + """ + response = challenges.DVSNIResponse(s=s) + cert_pem = crypto_util.make_ss_cert(self.key.pem, [ + self.nonce_domain, self.domain, response.z_domain(self.chall)]) + return cert_pem, response + + +class SimpleHTTPS(AnnotatedChallenge): + """Client annotated "simpleHttps" ACME challenge.""" + __slots__ = ('chall', 'domain', 'key') + acme_type = challenges.SimpleHTTPS + + +class DNS(AnnotatedChallenge): + """Client annotated "dns" ACME challenge.""" + __slots__ = ('chall', 'domain') + acme_type = challenges.DNS + + +class RecoveryContact(AnnotatedChallenge): + """Client annotated "recoveryContact" ACME challenge.""" + __slots__ = ('chall', 'domain') + acme_type = challenges.RecoveryContact + + +class RecoveryToken(AnnotatedChallenge): + """Client annotated "recoveryToken" ACME challenge.""" + __slots__ = ('chall', 'domain') + acme_type = challenges.RecoveryToken + + +class ProofOfPossession(AnnotatedChallenge): + """Client annotated "proofOfPossession" ACME challenge.""" + __slots__ = ('chall', 'domain') + acme_type = challenges.ProofOfPossession + + +class Indexed(acme_util.ImmutableMap): + """Indexed and annotated ACME challenge. + + Wraps around :class:`AnnotatedChallenge` and annotates with an + ``index`` in order to maintain the proper position of the response + within a larger challenge list. + + """ + __slots__ = ('achall', 'index') + + def __getattr__(self, name): + return getattr(self.achall, name) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index af71ff5f7..93db689f8 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -9,8 +9,10 @@ import sys import zope.interface +from letsencrypt.acme import challenges + +from letsencrypt.client import achallenges from letsencrypt.client import augeas_configurator -from letsencrypt.client import challenge_util from letsencrypt.client import constants from letsencrypt.client import errors from letsencrypt.client import interfaces @@ -971,34 +973,26 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): ########################################################################### def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use """Return list of challenge preferences.""" - return ["dvsni"] + return [challenges.DVSNI] - def perform(self, chall_list): + def perform(self, achalls): """Perform the configuration related challenge. This function currently assumes all challenges will be fulfilled. If this turns out not to be the case in the future. Cleanup and outstanding challenges will have to be designed better. - :param list chall_list: List of challenges to be - fulfilled by configurator. - - :returns: list of responses. All responses are returned in the same - order as received by the perform function. A None response - indicates the challenge was not perfromed. - :rtype: list - """ - self._chall_out += len(chall_list) - responses = [None] * len(chall_list) + self._chall_out += len(achalls) + responses = [None] * len(achalls) apache_dvsni = dvsni.ApacheDvsni(self) - for i, chall in enumerate(chall_list): - if isinstance(chall, challenge_util.DvsniChall): + for i, achall in enumerate(achalls): + if isinstance(achall, achallenges.DVSNI): # Currently also have dvsni hold associated index # of the challenge. This helps to put all of the responses back # together when they are all complete. - apache_dvsni.add_chall(chall, i) + apache_dvsni.add_chall(achall, i) sni_response = apache_dvsni.perform() # Must restart in order to activate the challenges. @@ -1013,9 +1007,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return responses - def cleanup(self, chall_list): + def cleanup(self, achalls): """Revert all challenges.""" - self._chall_out -= len(chall_list) + self._chall_out -= len(achalls) # If all of the challenges have been finished, clean up everything if self._chall_out <= 0: diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index 9b4cd957a..b980fdb36 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -2,9 +2,6 @@ import logging import os -from letsencrypt.client import challenge_util -from letsencrypt.client import constants - from letsencrypt.client.apache import parser @@ -15,18 +12,14 @@ class ApacheDvsni(object): :type configurator: :class:`letsencrypt.client.apache.configurator.ApacheConfigurator` - :ivar dvsni_chall: Data required for challenges. - where DvsniChall tuples have the following fields - `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) - `key` (:class:`letsencrypt.client.le_util.Key`) - :type dvsni_chall: `list` of - :class:`letsencrypt.client.challenge_util.DvsniChall` + :ivar list achalls: Annotated :class:`~letsencrypt.client.achallenges.DVSNI` + challenges. :param list indicies: 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 - maintaining state about where all of the SimpleHttps Challenges, + maintaining state about where all of the SimpleHTTPS Challenges, Dvsni Challenges belong in the response array. This is an optional utility. @@ -35,28 +28,28 @@ class ApacheDvsni(object): """ def __init__(self, configurator): self.configurator = configurator - self.dvsni_chall = [] + self.achalls = [] self.indices = [] self.challenge_conf = os.path.join( configurator.config.config_dir, "le_dvsni_cert_challenge.conf") # self.completed = 0 - def add_chall(self, chall, idx=None): + def add_chall(self, achall, idx=None): """Add challenge to DVSNI object to perform at once. - :param chall: DVSNI challenge info - :type chall: :class:`letsencrypt.client.challenge_util.DvsniChall` + :param achall: Annotated DVSNI challenge. + :type achall: :class:`letsencrypt.client.achallenges.DVSNI` :param int idx: index to challenge in a larger array """ - self.dvsni_chall.append(chall) + self.achalls.append(achall) if idx is not None: self.indices.append(idx) def perform(self): """Peform a DVSNI challenge.""" - if not self.dvsni_chall: + if not self.achalls: return None # Save any changes to the configuration as a precaution # About to make temporary changes to the config @@ -64,12 +57,12 @@ class ApacheDvsni(object): addresses = [] default_addr = "*:443" - for chall in self.dvsni_chall: - vhost = self.configurator.choose_vhost(chall.domain) + for achall in self.achalls: + vhost = self.configurator.choose_vhost(achall.domain) if vhost is None: logging.error( "No vhost exists with servername or alias of: %s", - chall.domain) + achall.domain) logging.error("No _default_:443 vhost exists") logging.error("Please specify servernames in the Apache config") return None @@ -87,9 +80,8 @@ class ApacheDvsni(object): responses = [] # Create all of the challenge certs - for chall in self.dvsni_chall: - s_b64 = self._setup_challenge_cert(chall) - responses.append({"type": "dvsni", "s": s_b64}) + for achall in self.achalls: + responses.append(self._setup_challenge_cert(achall)) # Setup the configuration self._mod_config(addresses) @@ -99,20 +91,20 @@ class ApacheDvsni(object): return responses - def _setup_challenge_cert(self, chall): + def _setup_challenge_cert(self, achall, s=None): + # pylint: disable=invalid-name """Generate and write out challenge certificate.""" - cert_path = self.get_cert_file(chall.nonce) + cert_path = self.get_cert_file(achall) # Register the path before you write out the file self.configurator.reverter.register_file_creation(True, cert_path) - cert_pem, s_b64 = challenge_util.dvsni_gen_cert( - chall.domain, chall.r_b64, chall.nonce, chall.key) + cert_pem, response = achall.gen_cert_and_response(s) # Write out challenge cert with open(cert_path, 'w') as cert_chall_fd: cert_chall_fd.write(cert_pem) - return s_b64 + return response def _mod_config(self, ll_addrs): """Modifies Apache config files to include challenge vhosts. @@ -126,9 +118,7 @@ class ApacheDvsni(object): # TODO: Use ip address of existing vhost instead of relying on FQDN config_text = "\n" for idx, lis in enumerate(ll_addrs): - config_text += self._get_config_text( - self.dvsni_chall[idx].nonce, lis, - self.dvsni_chall[idx].key.file) + config_text += self._get_config_text(self.achalls[idx], lis) config_text += "\n" self._conf_include_check(self.configurator.parser.loc["default"]) @@ -154,13 +144,14 @@ class ApacheDvsni(object): parser.get_aug_path(main_config), "Include", self.challenge_conf) - def _get_config_text(self, nonce, ip_addrs, dvsni_key_file): + def _get_config_text(self, achall, ip_addrs): """Chocolate virtual server configuration text - :param str nonce: hex form of nonce + :param achall: Annotated DVSNI challenge. + :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` - :param str dvsni_key_file: Path to key file :returns: virtual host configuration text :rtype: str @@ -170,26 +161,28 @@ class ApacheDvsni(object): document_root = os.path.join( self.configurator.config.config_dir, "dvsni_page/") return ("\n" - "ServerName " + nonce + constants.DVSNI_DOMAIN_SUFFIX + "\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(nonce) + "\n" - "SSLCertificateKeyFile " + dvsni_key_file + "\n" + "SSLCertificateFile " + self.get_cert_file(achall) + "\n" + "SSLCertificateKeyFile " + achall.key.file + "\n" "\n" "DocumentRoot " + document_root + "\n" "\n\n") - def get_cert_file(self, nonce): + def get_cert_file(self, achall): """Returns standardized name for challenge certificate. - :param str nonce: hex form of nonce + :param achall: Annotated DVSNI challenge. + :type achall: :class:`letsencrypt.client.achallenges.DVSNI` :returns: certificate file name :rtype: str """ - return os.path.join(self.configurator.config.work_dir, nonce + ".crt") + return os.path.join( + self.configurator.config.work_dir, achall.nonce_domain + ".crt") diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 3a2b28648..e63a7baf2 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -4,9 +4,10 @@ import sys import Crypto.PublicKey.RSA +from letsencrypt.acme import challenges from letsencrypt.acme import messages -from letsencrypt.client import challenge_util +from letsencrypt.client import achallenges from letsencrypt.client import constants from letsencrypt.client import errors @@ -29,13 +30,14 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :ivar list domains: list of str domains to get authorization :ivar dict authkey: Authorized Keys for each domain. values are of type :class:`letsencrypt.client.le_util.Key` - :ivar dict responses: keys: domain, values: list of dict responses - :ivar dict msgs: ACME Challenge messages with domain as a key + :ivar dict responses: keys: domain, values: list of responses + (:class:`letsencrypt.acme.challenges.ChallengeResponse`. + :ivar dict msgs: ACME Challenge messages with domain as a key. :ivar dict paths: optimal path for authorization. eg. paths[domain] :ivar dict dv_c: Keys - domain, Values are DV challenges in the form of - :class:`letsencrypt.client.challenge_util.IndexedChall` + :class:`letsencrypt.client.achallenges.Indexed` :ivar dict client_c: Keys - domain, Values are Client challenges in the form - of :class:`letsencrypt.client.challenge_util.IndexedChall` + of :class:`letsencrypt.client.achallenges.Indexed` """ def __init__(self, dv_auth, client_auth, network): @@ -69,7 +71,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes "Multiple ACMEChallengeMessages for the same domain " "is not supported.") self.domains.append(domain) - self.responses[domain] = ["null"] * len(msg.challenges) + self.responses[domain] = [None] * len(msg.challenges) self.msgs[domain] = msg self.authkey[domain] = authkey @@ -155,8 +157,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes flat_dv = [] for dom in self.domains: - flat_client.extend(ichall.chall for ichall in self.client_c[dom]) - flat_dv.extend(ichall.chall for ichall in self.dv_c[dom]) + flat_client.extend(ichall.achall for ichall in self.client_c[dom]) + flat_dv.extend(ichall.achall for ichall in self.dv_c[dom]) client_resp = [] dv_resp = [] @@ -185,12 +187,12 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes self._assign_responses(dv_resp, self.dv_c) def _assign_responses(self, flat_list, ichall_dict): - """Assign responses from flat_list back to the IndexedChall dicts. + """Assign responses from flat_list back to the Indexed dicts. :param list flat_list: flat_list of responses from an IAuthenticator :param dict ichall_dict: Master dict mapping all domains to a list of - their associated 'client' and 'dv' IndexedChallenges, or their - :class:`letsencrypt.client.challenge_util.IndexedChall` list + their associated 'client' and 'dv' Indexed challengesenges, or their + :class:`letsencrypt.client.achallenges.Indexed` list """ flat_index = 0 @@ -201,9 +203,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes def _path_satisfied(self, dom): """Returns whether a path has been completely satisfied.""" - return all( - None != self.responses[dom][i] and "null" != self.responses[dom][i] - for i in self.paths[dom]) + return all(self.responses[dom][i] is not None for i in self.paths[dom]) def _get_chall_pref(self, domain): """Return list of challenge preferences. @@ -226,8 +226,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes # These are indexed challenges... give just the challenges to the auth # Chose to make these lists instead of a generator to make it easier to # work with... - dv_list = [ichall.chall for ichall in self.dv_c[domain]] - client_list = [ichall.chall for ichall in self.client_c[domain]] + dv_list = [ichall.achall for ichall in self.dv_c[domain]] + client_list = [ichall.achall for ichall in self.client_c[domain]] if dv_list: self.dv_auth.cleanup(dv_list) if client_list: @@ -259,156 +259,99 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :param list path: List of indices from `challenges`. :returns: dv_chall, list of - :class:`letsencrypt.client.challenge_util.IndexedChall` + :class:`letsencrypt.client.achallenges.Indexed` client_chall, list of - :class:`letsencrypt.client.challenge_util.IndexedChall` + :class:`letsencrypt.client.achallenges.Indexed` :rtype: tuple :raises errors.LetsEncryptClientError: If Challenge type is not recognized """ - challenges = self.msgs[domain].challenges - dv_chall = [] client_chall = [] for index in path: - chall = challenges[index] + chall = self.msgs[domain].challenges[index] - # Authenticator Challenges - if chall["type"] in constants.DV_CHALLENGES: - dv_chall.append(challenge_util.IndexedChall( - self._construct_dv_chall(chall, domain), index)) + if isinstance(chall, challenges.DVSNI): + logging.info("DVSNI challenge for %s.", domain) + achall = achallenges.DVSNI( + chall=chall, domain=domain, key=self.authkey[domain]) + elif isinstance(chall, challenges.SimpleHTTPS): + logging.info("SimpleHTTPS challenge for %s.", domain) + achall = achallenges.SimpleHTTPS( + chall=chall, domain=domain, key=self.authkey[domain]) + elif isinstance(chall, challenges.DNS): + logging.info("DNS challenge for %s.", domain) + achall = achallenges.DNS(chall=chall, domain=domain) - # Client Challenges - elif chall["type"] in constants.CLIENT_CHALLENGES: - client_chall.append(challenge_util.IndexedChall( - self._construct_client_chall(chall, domain), index)) + elif isinstance(chall, challenges.RecoveryToken): + logging.info("Recovery Token Challenge for %s.", domain) + achall = achallenges.RecoveryToken(chall=chall, domain=domain) + elif isinstance(chall, challenges.RecoveryContact): + logging.info("Recovery Contact Challenge for %s.", domain) + achall = achallenges.RecoveryContact(chall=chall, domain=domain) + elif isinstance(chall, challenges.ProofOfPossession): + logging.info("Proof-of-Possession Challenge for %s", domain) + achall = achallenges.ProofOfPossession( + chall=chall, domain=domain) else: raise errors.LetsEncryptClientError( - "Received unrecognized challenge of type: " - "%s" % chall["type"]) + "Received unsupported challenge of type: " + "%s" % chall.acme_type) + + ichall = achallenges.Indexed(achall=achall, index=index) + + if isinstance(chall, challenges.ClientChallenge): + client_chall.append(ichall) + elif isinstance(chall, challenges.DVChallenge): + dv_chall.append(ichall) return dv_chall, client_chall - def _construct_dv_chall(self, chall, domain): - """Construct Auth Type Challenges. - :param dict chall: Single challenge - :param str domain: challenge's domain - - :returns: challenge_util named tuple Chall object - :rtype: `collections.namedtuple` - - :raises errors.LetsEncryptClientError: If unimplemented challenge exists - - """ - if chall["type"] == "dvsni": - logging.info(" DVSNI challenge for name %s.", domain) - return challenge_util.DvsniChall( - domain, str(chall["r"]), str(chall["nonce"]), - self.authkey[domain]) - - elif chall["type"] == "simpleHttps": - logging.info(" SimpleHTTPS challenge for name %s.", domain) - return challenge_util.SimpleHttpsChall( - domain, str(chall["token"]), self.authkey[domain]) - - elif chall["type"] == "dns": - logging.info(" DNS challenge for name %s.", domain) - return challenge_util.DnsChall(domain, str(chall["token"])) - - else: - raise errors.LetsEncryptClientError( - "Unimplemented Auth Challenge: %s" % chall["type"]) - - def _construct_client_chall(self, chall, domain): # pylint: disable=no-self-use - """Construct Client Type Challenges. - - :param dict chall: Single challenge - :param str domain: challenge's domain - - :returns: challenge_util named tuple Chall object - :rtype: `collections.namedtuple` - - :raises errors.LetsEncryptClientError: If unimplemented challenge exists - - """ - if chall["type"] == "recoveryToken": - logging.info(" Recovery Token Challenge for name: %s.", domain) - return challenge_util.RecTokenChall(domain) - - elif chall["type"] == "recoveryContact": - logging.info(" Recovery Contact Challenge for name: %s.", domain) - return challenge_util.RecContactChall( - domain, - chall.get("activationURL", None), - chall.get("successURL", None), - chall.get("contact", None)) - - elif chall["type"] == "proofOfPossession": - logging.info(" Proof-of-Possession Challenge for name: " - "%s", domain) - return challenge_util.PopChall( - domain, chall["alg"], chall["nonce"], chall["hints"]) - - else: - raise errors.LetsEncryptClientError( - "Unimplemented Client Challenge: %s" % chall["type"]) - - -def gen_challenge_path(challenges, preferences, combos=None): +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? - :param list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. + :param list challs: A list 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) - :param combos: A collection of sets of challenges from ACME - "challenge" server message ("combinations"), each of which would + :param list 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. - :type combos: list or None - :returns: List of indices from `challenges`. + :returns: List of indices from ``challenges``. :rtype: list """ - if combos: - return _find_smart_path(challenges, preferences, combos) + if combinations: + return _find_smart_path(challs, preferences, combinations) else: - return _find_dumb_path(challenges, preferences) + return _find_dumb_path(challs, preferences) -def _find_smart_path(challenges, preferences, combos): +def _find_smart_path(challs, preferences, combinations): """Find challenge path with server hints. Can be called if combinations is included. Function uses a simple ranking system to choose the combo with the lowest cost. - :param list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. - - :param combos: A collection of sets of challenges from ACME - "challenge" server message ("combinations"), each of which would - be sufficient to prove possession of the identifier. - :type combos: list or None - - :returns: List of indices from `challenges`. - :rtype: list - """ chall_cost = {} max_cost = 0 - for i, chall in enumerate(preferences): - chall_cost[chall] = i + for i, chall_cls in enumerate(preferences): + chall_cost[chall_cls] = i max_cost += i best_combo = [] @@ -416,10 +359,10 @@ def _find_smart_path(challenges, preferences, combos): best_combo_cost = max_cost + 1 combo_total = 0 - for combo in combos: + for combo in combinations: for challenge_index in combo: - combo_total += chall_cost.get(challenges[ - challenge_index]["type"], max_cost) + 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 @@ -433,47 +376,48 @@ def _find_smart_path(challenges, preferences, combos): return best_combo -def _find_dumb_path(challenges, preferences): +def _find_dumb_path(challs, preferences): """Find challenge path without server hints. Should be called if the combinations hint is not included by the server. This function returns the best path that does not contain multiple mutually exclusive challenges. - :param list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. - - :param list preferences: A list of preferences representing the - challenge type found within the ACME spec. Each challenge type - can only be listed once. - - :returns: List of indices from `challenges`. - :rtype: list - """ - # Add logic for a crappy server - # Choose a DV - path = [] assert len(preferences) == len(set(preferences)) + + path = [] + satisfied = set() for pref_c in preferences: - for i, offered_challenge in enumerate(challenges): - if (pref_c == offered_challenge["type"] and - is_preferred(offered_challenge["type"], path)): - path.append((i, offered_challenge["type"])) - - return [i for (i, _) in path] + 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) + return path -def is_preferred(offered_challenge_type, path): - """Return whether or not the challenge is preferred in path.""" - for _, challenge_type in path: - for mutually_exclusive in constants.EXCLUSIVE_CHALLENGES: - # Second part is in case we eventually allow multiple names - # to be challenges at the same time - if (challenge_type in mutually_exclusive and - offered_challenge_type in mutually_exclusive and - challenge_type != offered_challenge_type): +def mutually_exclusive(obj1, obj2, groups, different=False): + """Are two objects mutually exclusive?""" + for group in groups: + obj1_present = False + obj2_present = False + + for obj_cls in group: + obj1_present |= isinstance(obj1, obj_cls) + obj2_present |= isinstance(obj2, obj_cls) + + if obj1_present and obj2_present and ( + not different or not isinstance(obj1, obj2.__class__)): return False - + return True + + +def is_preferred(offered_chall, satisfied, + exclusive_groups=constants.EXCLUSIVE_CHALLENGES): + """Return whether or not the challenge is preferred in path.""" + for chall in satisfied: + if not mutually_exclusive( + offered_chall, chall, exclusive_groups, different=True): + return False return True diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py deleted file mode 100644 index 7ff9dd660..000000000 --- a/letsencrypt/client/challenge_util.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Challenge specific utility functions.""" -import collections -import hashlib - -from Crypto import Random - -from letsencrypt.acme import jose - -from letsencrypt.client import constants -from letsencrypt.client import crypto_util - - -# Authenticator Challenges -DvsniChall = collections.namedtuple("DvsniChall", "domain, r_b64, nonce, key") -SimpleHttpsChall = collections.namedtuple( - "SimpleHttpsChall", "domain, token, key") -DnsChall = collections.namedtuple("DnsChall", "domain, token") - -# Client Challenges -RecContactChall = collections.namedtuple( - "RecContactChall", "domain, a_url, s_url, contact") -RecTokenChall = collections.namedtuple("RecTokenChall", "domain") -PopChall = collections.namedtuple("PopChall", "domain, alg, nonce, hints") - -# Helper Challenge Wrapper - Can be used to maintain the proper position of -# the response within a larger challenge list -IndexedChall = collections.namedtuple("IndexedChall", "chall, index") - - -# DVSNI Challenge functions -def dvsni_gen_cert(name, r_b64, nonce, key): - """Generate a DVSNI cert and save it to filepath. - - :param str name: domain to validate - :param str r_b64: jose base64 encoded dvsni r value - :param str nonce: hex value of nonce - - :param key: Key to perform challenge - :type key: :class:`letsencrypt.client.le_util.Key` - - :returns: tuple of (cert_pem, s) where - cert_pem is the certificate in pem form - s is the dvsni s value, jose base64 encoded - :rtype: tuple - - """ - # Generate S - dvsni_s = Random.get_random_bytes(constants.S_SIZE) - dvsni_r = jose.b64decode(r_b64) - - # Generate extension - ext = _dvsni_gen_ext(dvsni_r, dvsni_s) - - cert_pem = crypto_util.make_ss_cert( - key.pem, [nonce + constants.DVSNI_DOMAIN_SUFFIX, name, ext]) - - return cert_pem, jose.b64encode(dvsni_s) - - -def _dvsni_gen_ext(dvsni_r, dvsni_s): - """Generates z extension to be placed in certificate extension. - - :param bytearray dvsni_r: DVSNI r value - :param bytearray dvsni_s: DVSNI s value - - :returns: z + :const:`~letsencrypt.client.constants.DVSNI_DOMAIN_SUFFIX` - :rtype: str - - """ - z_base = hashlib.new("sha256") - z_base.update(dvsni_r) - z_base.update(dvsni_s) - - return z_base.hexdigest() + constants.DVSNI_DOMAIN_SUFFIX diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/client_authenticator.py index 7229239dc..3cef97355 100644 --- a/letsencrypt/client/client_authenticator.py +++ b/letsencrypt/client/client_authenticator.py @@ -1,7 +1,9 @@ """Client Authenticator""" import zope.interface -from letsencrypt.client import challenge_util +from letsencrypt.acme import challenges + +from letsencrypt.client import achallenges from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import recovery_token @@ -30,22 +32,22 @@ class ClientAuthenticator(object): def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use """Return list of challenge preferences.""" - return ["recoveryToken"] + return [challenges.RecoveryToken] - def perform(self, chall_list): + def perform(self, achalls): """Perform client specific challenges for IAuthenticator""" responses = [] - for chall in chall_list: - if isinstance(chall, challenge_util.RecTokenChall): - responses.append(self.rec_token.perform(chall)) + for achall in achalls: + if isinstance(achall, achallenges.RecoveryToken): + responses.append(self.rec_token.perform(achall)) else: raise errors.LetsEncryptClientAuthError("Unexpected Challenge") return responses - def cleanup(self, chall_list): + def cleanup(self, achalls): """Cleanup call for IAuthenticator.""" - for chall in chall_list: - if isinstance(chall, challenge_util.RecTokenChall): - self.rec_token.cleanup(chall) + for achall in achalls: + if isinstance(achall, achallenges.RecoveryToken): + self.rec_token.cleanup(achall) else: raise errors.LetsEncryptClientAuthError("Unexpected Challenge") diff --git a/letsencrypt/client/constants.py b/letsencrypt/client/constants.py index 5a1715788..3e27d88ac 100644 --- a/letsencrypt/client/constants.py +++ b/letsencrypt/client/constants.py @@ -1,26 +1,21 @@ """Let's Encrypt constants.""" import pkg_resources +from letsencrypt.acme import challenges + S_SIZE = 32 """Size (in bytes) of secret base64-encoded octet string "s" used in -challanges.""" +challenges.""" NONCE_SIZE = 16 """Size of nonce used in JWS objects (in bytes).""" -EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])] +EXCLUSIVE_CHALLENGES = frozenset([frozenset([ + challenges.DVSNI, challenges.SimpleHTTPS])]) """Mutually exclusive challenges.""" -DV_CHALLENGES = frozenset(["dvsni", "simpleHttps", "dns"]) -"""Challenges that must be solved by a -:class:`letsencrypt.client.interfaces.IAuthenticator` object.""" - -CLIENT_CHALLENGES = frozenset( - ["recoveryToken", "recoveryContact", "proofOfPossession"]) -"""Challenges that are handled by the Let's Encrypt client.""" - ENHANCEMENTS = ["redirect", "http-header", "ocsp-stapling", "spdy"] """List of possible :class:`letsencrypt.client.interfaces.IInstaller` @@ -48,9 +43,6 @@ APACHE_REWRITE_HTTPS_ARGS = [ DVSNI_CHALLENGE_PORT = 443 """Port to perform DVSNI challenge.""" -DVSNI_DOMAIN_SUFFIX = ".acme.invalid" -"""Suffix appended to domains in DVSNI validation.""" - TEMP_CHECKPOINT_DIR = "temp_checkpoint" """Temporary checkpoint directory (relative to IConfig.work_dir).""" diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index f0afae5f5..6779d4e1e 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -30,43 +30,43 @@ class IAuthenticator(zope.interface.Interface): :param str domain: Domain for which challenge preferences are sought. - :returns: list of strings with the most preferred challenges first. - If a type is not specified, it means the Authenticator cannot - perform the challenge. + :returns: List of challege types (subclasses of + :class:`letsencrypt.acme.challenges.Challenge`) with the most + preferred challenges first. If a type is not specified, it means the + Authenticator cannot perform the challenge. :rtype: list """ - def perform(chall_list): + def perform(achalls): """Perform the given challenge. - :param list chall_list: List of namedtuple types defined in - :mod:`letsencrypt.client.challenge_util` (``DvsniChall``, etc.). + :param list achalls: Non-empty (guaranteed) list of + :class:`~letsencrypt.client.achallenges.AnnotatedChallenge` + instances, such that it contains types found within + :func:`get_chall_pref` only. - - chall_list will never be empty - - chall_list will only contain types found within - :func:`get_chall_pref` - - :returns: ACME Challenge responses or if it cannot be completed then: + :returns: List of ACME + :class:`~letsencrypt.acme.challenges.ChallengeResponse` instances + or if the :class:`~letsencrypt.acme.challenges.Challenge` cannot + be fulfilled then: ``None`` - Authenticator can perform challenge, but can't at this time + Authenticator can perform challenge, but not at this time. ``False`` - Authenticator will never be able to perform (error) + Authenticator will never be able to perform (error). - :rtype: :class:`list` of :class:`dict` + :rtype: :class:`list` of + :class:`letsencrypt.acme.challenges.ChallengeResponse` """ - def cleanup(chall_list): + def cleanup(achalls): """Revert changes and shutdown after challenges complete. - :param list chall_list: List of namedtuple types defined in - :mod:`letsencrypt.client.challenge_util` (``DvsniChall``, etc.) - - - Only challenges given previously in the perform function will be - found in chall_list. - - chall_list will never be empty + :param list achalls: Non-empty (guaranteed) list of + :class:`~letsencrypt.client.achallenges.AnnotatedChallenge` + instances, a subset of those previously passed to :func:`perform`. """ diff --git a/letsencrypt/client/recovery_token.py b/letsencrypt/client/recovery_token.py index 4d556eb51..f0c7d5839 100644 --- a/letsencrypt/client/recovery_token.py +++ b/letsencrypt/client/recovery_token.py @@ -4,6 +4,8 @@ import os import zope.component +from letsencrypt.acme import challenges + from letsencrypt.client import le_util from letsencrypt.client import interfaces @@ -21,7 +23,7 @@ class RecoveryToken(object): """Perform the Recovery Token Challenge. :param chall: Recovery Token Challenge - :type chall: :class:`letsencrypt.client.challenge_util.RecTokenChall` + :type chall: :class:`letsencrypt.client.achallenges.RecoveryToken` :returns: response :rtype: dict @@ -30,13 +32,13 @@ class RecoveryToken(object): token_fp = os.path.join(self.token_dir, chall.domain) if os.path.isfile(token_fp): with open(token_fp) as token_fd: - return self.generate_response(token_fd.read()) + return challenges.RecoveryTokenResponse(token=token_fd.read()) cancel, token = zope.component.getUtility( interfaces.IDisplay).input( "%s - Input Recovery Token: " % chall.domain) if cancel != 1: - return self.generate_response(token) + return challenges.RecoveryTokenResponse(token=token) return None @@ -44,7 +46,7 @@ class RecoveryToken(object): """Cleanup the saved recovery token if it exists. :param chall: Recovery Token Challenge - :type chall: :class:`letsencrypt.client.challenge_util.RecTokenChall` + :type chall: :class:`letsencrypt.client.achallenges.RecoveryToken` """ try: @@ -53,13 +55,6 @@ class RecoveryToken(object): if err.errno != errno.ENOENT: raise - def generate_response(self, token): # pylint: disable=no-self-use - """Generate json response.""" - return { - "type": "recoveryToken", - "token": token, - } - def requires_human(self, domain): """Indicates whether or not domain can be auto solved.""" return not os.path.isfile(os.path.join(self.token_dir, domain)) diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/standalone_authenticator.py index b19a74f36..0c9a57319 100755 --- a/letsencrypt/client/standalone_authenticator.py +++ b/letsencrypt/client/standalone_authenticator.py @@ -12,7 +12,9 @@ import OpenSSL.SSL import zope.component import zope.interface -from letsencrypt.client import challenge_util +from letsencrypt.acme import challenges + +from letsencrypt.client import achallenges from letsencrypt.client import constants from letsencrypt.client import interfaces @@ -328,9 +330,9 @@ class StandaloneAuthenticator(object): :returns: A list containing only 'dvsni'. """ - return ["dvsni"] + return [challenges.DVSNI] - def perform(self, chall_list): + def perform(self, achalls): """Perform the challenge. .. warning:: @@ -340,13 +342,6 @@ class StandaloneAuthenticator(object): validations for multiple independent sets of domains, a separate StandaloneAuthenticator should be instantiated. - :param list chall_list: List of namedtuple types defined in - :mod:`letsencrypt.client.challenge_util` (``DvsniChall``, etc.) - - :returns: ACME Challenge DVSNI responses following IAuthenticator - interface. - :rtype: :class:`list` of :class`dict` - """ if self.child_pid or self.tasks: # We should not be willing to continue with perform @@ -354,17 +349,15 @@ class StandaloneAuthenticator(object): raise ValueError(".perform() was called with pending tasks!") results_if_success = [] results_if_failure = [] - if not chall_list or not isinstance(chall_list, list): + if not achalls or not isinstance(achalls, list): raise ValueError(".perform() was called without challenge list") - for chall in chall_list: - if isinstance(chall, challenge_util.DvsniChall): + for achall in achalls: + if isinstance(achall, achallenges.DVSNI): # We will attempt to do it - name, r_b64 = chall.domain, chall.r_b64 - nonce, key = chall.nonce, chall.key - cert, s_b64 = challenge_util.dvsni_gen_cert( - name, r_b64, nonce, key) - self.tasks[nonce + constants.DVSNI_DOMAIN_SUFFIX] = cert - results_if_success.append({"type": "dvsni", "s": s_b64}) + key = achall.key # TODO: bug; one key per start_listener + cert_pem, response = achall.gen_cert_and_response() + self.tasks[achall.nonce_domain] = cert_pem + results_if_success.append(response) results_if_failure.append(None) else: # We will not attempt to do this challenge because it @@ -388,7 +381,7 @@ class StandaloneAuthenticator(object): # rather than returning a list of None objects. return results_if_failure - def cleanup(self, chall_list): + def cleanup(self, achalls): """Clean up. If some challenges are removed from the list, the authenticator @@ -398,11 +391,10 @@ class StandaloneAuthenticator(object): """ # Remove this from pending tasks list - for chall in chall_list: - assert isinstance(chall, challenge_util.DvsniChall) - nonce = chall.nonce - if nonce + constants.DVSNI_DOMAIN_SUFFIX in self.tasks: - del self.tasks[nonce + constants.DVSNI_DOMAIN_SUFFIX] + for achall in achalls: + assert isinstance(achall, achallenges.DVSNI) + if achall.nonce_domain in self.tasks: + del self.tasks[achall.nonce_domain] else: # Could not find the challenge to remove! raise ValueError("could not find the challenge to remove") diff --git a/letsencrypt/client/tests/achallenges_test.py b/letsencrypt/client/tests/achallenges_test.py new file mode 100644 index 000000000..1ed307bd9 --- /dev/null +++ b/letsencrypt/client/tests/achallenges_test.py @@ -0,0 +1,62 @@ +"""Tests for letsencrypt.client.achallenges.""" +import os +import pkg_resources +import re +import unittest + +import M2Crypto +import mock + +from letsencrypt.acme import challenges +from letsencrypt.client import le_util + + +class DVSNITest(unittest.TestCase): + """Tests for letsencrypt.client.achallenges.DVSNI.""" + + def setUp(self): + self.chall = challenges.DVSNI(r="r_value", nonce="12345ABCDE") + self.response = challenges.DVSNIResponse() + key = le_util.Key("path", pkg_resources.resource_string( + __name__, os.path.join("testdata", "rsa256_key.pem"))) + + from letsencrypt.client.achallenges import DVSNI + self.achall = DVSNI(chall=self.chall, domain="example.com", key=key) + + def test_proxy(self): + self.assertEqual(self.chall.r, self.achall.r) + self.assertEqual(self.chall.nonce, self.achall.nonce) + + def test_gen_cert_and_response(self): + cert_pem, _ = self.achall.gen_cert_and_response(s=self.response.s) + + cert = M2Crypto.X509.load_cert_string(cert_pem) + self.assertEqual(cert.get_subject().CN, self.chall.nonce_domain) + + sans = cert.get_ext("subjectAltName").get_value() + self.assertEqual( + set([self.chall.nonce_domain, "example.com", + self.response.z_domain(self.chall)]), + set(re.findall(r"DNS:([^, $]*)", sans)), + ) + + +class IndexedTest(unittest.TestCase): + """Tests for letsencrypt.client.achallenges.Indexed.""" + + def setUp(self): + from letsencrypt.client.achallenges import Indexed + self.achall = mock.MagicMock() + self.ichall = Indexed(achall=self.achall, index=0) + + def test_attributes(self): + self.assertEqual(self.achall, self.ichall.achall) + self.assertEqual(0, self.ichall.index) + + def test_proxy(self): + self.assertEqual(self.achall.foo, self.ichall.foo) + + + +if __name__ == "__main__": + unittest.main() diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index 86bdbb282..233436361 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -1,79 +1,53 @@ """Class helps construct valid ACME messages for testing.""" -from letsencrypt.client import constants +import os +import pkg_resources + +import Crypto.PublicKey.RSA + +from letsencrypt.acme import challenges +from letsencrypt.acme import other -CHALLENGES = { - "simpleHttps": - { - "type": "simpleHttps", - "token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA" - }, - "dvsni": - { - "type": "dvsni", - "r": "Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI", - "nonce": "a82d5ff8ef740d12881f6d3c2277ab2e" - }, - "dns": - { - "type": "dns", - "token": "17817c66b60ce2e4012dfad92657527a" - }, - "recoveryContact": - { - "type": "recoveryContact", - "activationURL": "https://example.ca/sendrecovery/a5bd99383fb0", - "successURL": "https://example.ca/confirmrecovery/bb1b9928932", - "contact": "c********n@example.com" - }, - "recoveryToken": - { - "type": "recoveryToken" - }, - "proofOfPossession": - { - "type": "proofOfPossession", - "alg": "RS256", - "nonce": "eET5udtV7aoX8Xl8gYiZIA", - "hints": { - "jwk": { - "kty": "RSA", - "e": "AQAB", - "n": "KxITJ0rNlfDMAtfDr8eAw...fSSoehDFNZKQKzTZPtQ" - }, - "certFingerprints": [ - "93416768eb85e33adc4277f4c9acd63e7418fcfe", - "16d95b7b63f1972b980b14c20291f3c0d1855d95", - "48b46570d9fc6358108af43ad1649484def0debf" - ], - "subjectKeyIdentifiers": - ["d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"], - "serialNumbers": [34234239832, 23993939911, 17], - "issuers": [ - "C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA", - "O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure" - ], - "authorizedFor": ["www.example.com", "example.net"] - } - } -} +KEY = Crypto.PublicKey.RSA.importKey(pkg_resources.resource_string( + "letsencrypt.client.tests", os.path.join("testdata", "rsa256_key.pem"))) +# Challenges +SIMPLE_HTTPS = challenges.SimpleHTTPS( + token="evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA") +DVSNI = challenges.DVSNI( + r="O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6\xbf'\xb3" + "\xed\x9a9nX\x0f'\\m\xe7\x12", nonce="a82d5ff8ef740d12881f6d3c2277ab2e") +DNS = challenges.DNS(token="17817c66b60ce2e4012dfad92657527a") +RECOVERY_CONTACT = challenges.RecoveryContact( + activation_url="https://example.ca/sendrecovery/a5bd99383fb0", + success_url="https://example.ca/confirmrecovery/bb1b9928932", + contact="c********n@example.com") +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()), + cert_fingerprints=[ + "93416768eb85e33adc4277f4c9acd63e7418fcfe", + "16d95b7b63f1972b980b14c20291f3c0d1855d95", + "48b46570d9fc6358108af43ad1649484def0debf" + ], + 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"], + ) +) -def get_dv_challenges(): - """Returns all auth challenges.""" - return [chall for typ, chall in CHALLENGES.iteritems() - if typ in constants.DV_CHALLENGES] - - -def get_client_challenges(): - """Returns all client challenges.""" - return [chall for typ, chall in CHALLENGES.iteritems() - if typ in constants.CLIENT_CHALLENGES] - - -def get_challenges(): - """Returns all challenges.""" - return [chall for chall in CHALLENGES.itervalues()] +CHALLENGES = [SIMPLE_HTTPS, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP] +DV_CHALLENGES = [chall for chall in CHALLENGES + if isinstance(chall, challenges.DVChallenge)] +CLIENT_CHALLENGES = [chall for chall in CHALLENGES + if isinstance(chall, challenges.ClientChallenge)] def gen_combos(challs): @@ -81,8 +55,8 @@ def gen_combos(challs): dv_chall = [] renewal_chall = [] - for i, chall in enumerate(challs): - if chall["type"] in constants.DV_CHALLENGES: + for i, chall in enumerate(challs): # pylint: disable=redefined-outer-name + if isinstance(chall, challenges.DVChallenge): dv_chall.append(i) else: renewal_chall.append(i) diff --git a/letsencrypt/client/tests/apache/configurator_test.py b/letsencrypt/client/tests/apache/configurator_test.py index a67c0088a..a49e76f40 100644 --- a/letsencrypt/client/tests/apache/configurator_test.py +++ b/letsencrypt/client/tests/apache/configurator_test.py @@ -6,7 +6,9 @@ import unittest import mock -from letsencrypt.client import challenge_util +from letsencrypt.acme import challenges + +from letsencrypt.client import achallenges from letsencrypt.client import errors from letsencrypt.client import le_util @@ -140,16 +142,16 @@ class TwoVhost80Test(util.ApacheTest): # Only tests functionality specific to configurator.perform # Note: As more challenges are offered this will have to be expanded auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem) - chall1 = challenge_util.DvsniChall( - "encryption-example.demo", - "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", - "37bc5eb75d3e00a19b4f6355845e5a18", - auth_key) - chall2 = challenge_util.DvsniChall( - "letsencrypt.demo", - "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", - "59ed014cac95f77057b1d7a1b2c596ba", - auth_key) + achall1 = achallenges.DVSNI( + chall=challenges.DVSNI( + r="jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + nonce="37bc5eb75d3e00a19b4f6355845e5a18"), + domain="encryption-example.demo", key=auth_key) + achall2 = achallenges.DVSNI( + chall=challenges.DVSNI( + r="uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", + nonce="59ed014cac95f77057b1d7a1b2c596ba"), + domain="letsencrypt.demo", key=auth_key) dvsni_ret_val = [ {"type": "dvsni", "s": "randomS1"}, @@ -157,7 +159,7 @@ class TwoVhost80Test(util.ApacheTest): ] mock_dvsni_perform.return_value = dvsni_ret_val - responses = self.config.perform([chall1, chall2]) + responses = self.config.perform([achall1, achall2]) self.assertEqual(mock_dvsni_perform.call_count, 1) self.assertEqual(responses, dvsni_ret_val) diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/tests/apache/dvsni_test.py index f44e603dc..384e426bb 100644 --- a/letsencrypt/client/tests/apache/dvsni_test.py +++ b/letsencrypt/client/tests/apache/dvsni_test.py @@ -5,8 +5,9 @@ import shutil import mock -from letsencrypt.client import challenge_util -from letsencrypt.client import constants +from letsencrypt.acme import challenges + +from letsencrypt.client import achallenges from letsencrypt.client import le_util from letsencrypt.client.apache.obj import Addr @@ -36,17 +37,21 @@ class DvsniPerformTest(util.ApacheTest): "letsencrypt.client.tests", 'testdata/rsa256_key.pem') auth_key = le_util.Key(rsa256_file, rsa256_pem) - self.challs = [] - self.challs.append(challenge_util.DvsniChall( - "encryption-example.demo", - "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", - "37bc5eb75d3e00a19b4f6355845e5a18", - auth_key)) - self.challs.append(challenge_util.DvsniChall( - "letsencrypt.demo", - "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", - "59ed014cac95f77057b1d7a1b2c596ba", - auth_key)) + self.achalls = [ + achallenges.DVSNI( + chall=challenges.DVSNI( + r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9\xf1" + "\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4", + nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18", + ), domain="encryption-example.demo", key=auth_key), + achallenges.DVSNI( + chall=challenges.DVSNI( + r="\xba\xa9\xda? 80 char... dv_c, c_c = self.handler._challenge_factory(dom, [0]) @@ -387,11 +391,11 @@ class GetAuthorizationsTest(unittest.TestCase): self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c def test_progress_failure(self): - challenges = acme_util.get_challenges() self.handler.add_chall_msg( "0", - messages.Challenge(session_id="0", nonce="nonce0", - challenges=challenges, combinations=[]), + messages.Challenge( + session_id="0", nonce="nonce0", challenges=acme_util.CHALLENGES, + combinations=[]), "dummy_key") # Don't do anything to satisfy challenges @@ -406,21 +410,19 @@ class GetAuthorizationsTest(unittest.TestCase): def _sat_failure(self): dom = "0" self.handler.paths[dom] = gen_path( - ["dns", "recoveryToken"], self.handler.msgs[dom].challenges) + [acme_util.DNS, acme_util.RECOVERY_TOKEN], + self.handler.msgs[dom].challenges) dv_c, c_c = self.handler._challenge_factory( dom, self.handler.paths[dom]) self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c def test_incremental_progress(self): - challs = [] - challs.append(acme_util.get_challenges()) - challs.append(acme_util.get_dv_challenges()) - for i in xrange(2): - dom = str(i) + for dom, challs in [("0", acme_util.CHALLENGES), + ("1", acme_util.DV_CHALLENGES)]: self.handler.add_chall_msg( dom, - messages.Challenge(session_id=dom, nonce="nonce%d" % i, - challenges=challs[i], combinations=[]), + messages.Challenge(session_id=dom, nonce="nonce", + combinations=[], challenges=challs), "dummy_key") self.mock_sat_chall.side_effect = self._sat_incremental @@ -437,7 +439,7 @@ class GetAuthorizationsTest(unittest.TestCase): # Only solve one of "0" required challs self.handler.responses["0"][1] = "onecomplete" self.handler.responses["0"][3] = None - self.handler.responses["1"] = ["null", "null", "goodresp"] + self.handler.responses["1"] = [None, None, "goodresp"] self.handler.paths["0"] = [1, 3] self.handler.paths["1"] = [2] # This is probably overkill... but set it anyway @@ -476,10 +478,10 @@ class PathSatisfiedTest(unittest.TestCase): def test_satisfied_true(self): dom = ["0", "1", "2", "3", "4"] self.handler.paths[dom[0]] = [1, 2] - self.handler.responses[dom[0]] = ["null", "sat", "sat2", "null"] + self.handler.responses[dom[0]] = [None, "sat", "sat2", None] self.handler.paths[dom[1]] = [0] - self.handler.responses[dom[1]] = ["sat", None, None, "null"] + self.handler.responses[dom[1]] = ["sat", None, None, None] self.handler.paths[dom[2]] = [0] self.handler.responses[dom[2]] = ["sat"] @@ -494,46 +496,104 @@ class PathSatisfiedTest(unittest.TestCase): self.assertTrue(self.handler._path_satisfied(dom[i])) def test_not_satisfied(self): - dom = ["0", "1", "2", "3", "4"] + dom = ["0", "1", "2"] self.handler.paths[dom[0]] = [1, 2] - self.handler.responses[dom[0]] = ["sat1", "null", "sat2", "null"] + self.handler.responses[dom[0]] = ["sat1", None, "sat2", None] self.handler.paths[dom[1]] = [0] - self.handler.responses[dom[1]] = [None, "null", "null", "null"] + self.handler.responses[dom[1]] = [None, None, None, None] self.handler.paths[dom[2]] = [0] self.handler.responses[dom[2]] = [None] - self.handler.paths[dom[3]] = [0] - self.handler.responses[dom[3]] = ["null"] - - for i in xrange(4): + for i in xrange(3): self.assertFalse(self.handler._path_satisfied(dom[i])) +class MutuallyExclusiveTest(unittest.TestCase): + """Tests for letsencrypt.client.auth_handler.mutually_exclusive.""" + + # pylint: disable=invalid-name,missing-docstring,too-few-public-methods + class A(object): + pass + + class B(object): + pass + + class C(object): + pass + + class D(C): + pass + + @classmethod + def _call(cls, chall1, chall2, different=False): + from letsencrypt.client.auth_handler import mutually_exclusive + return mutually_exclusive(chall1, chall2, groups=frozenset([ + frozenset([cls.A, cls.B]), frozenset([cls.A, cls.C]), + ]), different=different) + + def test_group_members(self): + self.assertFalse(self._call(self.A(), self.B())) + self.assertFalse(self._call(self.A(), self.C())) + + def test_cross_group(self): + self.assertTrue(self._call(self.B(), self.C())) + + def test_same_type(self): + self.assertFalse(self._call(self.A(), self.A(), different=False)) + self.assertTrue(self._call(self.A(), self.A(), different=True)) + + # in particular... + obj = self.A() + self.assertFalse(self._call(obj, obj, different=False)) + self.assertTrue(self._call(obj, obj, different=True)) + + def test_subclass(self): + self.assertFalse(self._call(self.A(), self.D())) + self.assertFalse(self._call(self.D(), self.A())) + + +class IsPreferredTest(unittest.TestCase): + """Tests for letsencrypt.client.auth_handler.is_preferred.""" + + @classmethod + def _call(cls, chall, satisfied): + from letsencrypt.client.auth_handler import is_preferred + return is_preferred(chall, satisfied, exclusive_groups=frozenset([ + frozenset([challenges.DVSNI, challenges.SimpleHTTPS]), + frozenset([challenges.DNS, challenges.SimpleHTTPS]), + ])) + + def test_empty_satisfied(self): + self.assertTrue(self._call(acme_util.DNS, frozenset())) + + def test_mutually_exclusvie(self): + self.assertFalse( + self._call(acme_util.DVSNI, frozenset([acme_util.SIMPLE_HTTPS]))) + + def test_mutually_exclusive_same_type(self): + self.assertTrue( + self._call(acme_util.DVSNI, frozenset([acme_util.DVSNI]))) + + def gen_auth_resp(chall_list): """Generate a dummy authorization response.""" return ["%s%s" % (chall.__class__.__name__, chall.domain) for chall in chall_list] -def gen_path(str_list, challenges): +def gen_path(required, challs): """Generate a path for challenge messages + :param required: :param list str_list: challenge message types (:class:`str`) - :param dict challenges: ACME challenge messages + :param challs: ACME challenge messages :return: :class:`list` of :class:`int` """ - path = [] - for i, chall in enumerate(challenges): - for str_chall in str_list: - if chall["type"] == str_chall: - path.append(i) - continue - return path - + return [challs.index(chall) for chall in required] if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/tests/challenge_util_test.py b/letsencrypt/client/tests/challenge_util_test.py deleted file mode 100644 index c7848a213..000000000 --- a/letsencrypt/client/tests/challenge_util_test.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Tests for challenge_util.""" -import os -import pkg_resources -import re -import unittest - -import M2Crypto - -from letsencrypt.acme import jose - -from letsencrypt.client import challenge_util -from letsencrypt.client import constants -from letsencrypt.client import le_util - - -class DvsniGenCertTest(unittest.TestCase): - # pylint: disable=too-few-public-methods - """Tests for letsencrypt.client.challenge_util.dvsni_gen_cert.""" - - def test_standard(self): - """Basic test for straightline code.""" - domain = "example.com" - dvsni_r = "r_value" - r_b64 = jose.b64encode(dvsni_r) - pem = pkg_resources.resource_string( - __name__, os.path.join("testdata", "rsa256_key.pem")) - key = le_util.Key("path", pem) - nonce = "12345ABCDE" - cert_pem, s_b64 = self._call(domain, r_b64, nonce, key) - - # pylint: disable=protected-access - ext = challenge_util._dvsni_gen_ext( - dvsni_r, jose.b64decode(s_b64)) - self._standard_check_cert(cert_pem, domain, nonce, ext) - - def _standard_check_cert(self, pem, domain, nonce, ext): - """Check the certificate fields.""" - dns_regex = r"DNS:([^, $]*)" - cert = M2Crypto.X509.load_cert_string(pem) - self.assertEqual( - cert.get_subject().CN, nonce + constants.DVSNI_DOMAIN_SUFFIX) - - sans = cert.get_ext("subjectAltName").get_value() - - exp_sans = set([nonce + constants.DVSNI_DOMAIN_SUFFIX, domain, ext]) - act_sans = set(re.findall(dns_regex, sans)) - - self.assertEqual(exp_sans, act_sans) - - @classmethod - def _call(cls, name, r_b64, nonce, key): - from letsencrypt.client.challenge_util import dvsni_gen_cert - return dvsni_gen_cert(name, r_b64, nonce, key) - - -if __name__ == "__main__": - unittest.main() diff --git a/letsencrypt/client/tests/client_authenticator_test.py b/letsencrypt/client/tests/client_authenticator_test.py index c79f26e0a..7db1956d5 100644 --- a/letsencrypt/client/tests/client_authenticator_test.py +++ b/letsencrypt/client/tests/client_authenticator_test.py @@ -3,7 +3,9 @@ import unittest import mock -from letsencrypt.client import challenge_util +from letsencrypt.acme import challenges + +from letsencrypt.client import achallenges from letsencrypt.client import errors @@ -19,31 +21,29 @@ class PerformTest(unittest.TestCase): name="rec_token_perform", side_effect=gen_client_resp) def test_rec_token1(self): - token = challenge_util.RecTokenChall("0") + token = achallenges.RecoveryToken(chall=None, domain="0") responses = self.auth.perform([token]) - self.assertEqual(responses, ["RecTokenChall0"]) + self.assertEqual(responses, ["RecoveryToken0"]) def test_rec_token5(self): tokens = [] for i in xrange(5): - tokens.append(challenge_util.RecTokenChall(str(i))) + tokens.append(achallenges.RecoveryToken(chall=None, domain=str(i))) responses = self.auth.perform(tokens) self.assertEqual(len(responses), 5) for i in xrange(5): - self.assertEqual(responses[i], "RecTokenChall%d" % i) + self.assertEqual(responses[i], "RecoveryToken%d" % i) def test_unexpected(self): - unexpected = challenge_util.DvsniChall( - "0", "rb64", "123", "invalid_key") - self.assertRaises( - errors.LetsEncryptClientAuthError, self.auth.perform, [unexpected]) + errors.LetsEncryptClientAuthError, self.auth.perform, [ + achallenges.DVSNI(chall=None, domain="0", key="invalid_key")]) def test_chall_pref(self): self.assertEqual( - self.auth.get_chall_pref("example.com"), ["recoveryToken"]) + self.auth.get_chall_pref("example.com"), [challenges.RecoveryToken]) class CleanupTest(unittest.TestCase): @@ -58,8 +58,8 @@ class CleanupTest(unittest.TestCase): self.auth.rec_token.cleanup = self.mock_cleanup def test_rec_token2(self): - token1 = challenge_util.RecTokenChall("0") - token2 = challenge_util.RecTokenChall("1") + token1 = achallenges.RecoveryToken(chall=None, domain="0") + token2 = achallenges.RecoveryToken(chall=None, domain="1") self.auth.cleanup([token1, token2]) @@ -67,8 +67,8 @@ class CleanupTest(unittest.TestCase): [mock.call(token1), mock.call(token2)]) def test_unexpected(self): - token = challenge_util.RecTokenChall("0") - unexpected = challenge_util.DvsniChall("0", "rb64", "123", "dummy_key") + token = achallenges.RecoveryToken(chall=None, domain="0") + unexpected = achallenges.DVSNI(chall=None, domain="0", key="dummy_key") self.assertRaises(errors.LetsEncryptClientAuthError, self.auth.cleanup, [token, unexpected]) diff --git a/letsencrypt/client/tests/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py index 0a49137d8..01ba78d72 100644 --- a/letsencrypt/client/tests/recovery_token_test.py +++ b/letsencrypt/client/tests/recovery_token_test.py @@ -6,7 +6,9 @@ import tempfile import mock -from letsencrypt.client import challenge_util +from letsencrypt.acme import challenges + +from letsencrypt.client import achallenges class RecoveryTokenTest(unittest.TestCase): @@ -36,34 +38,37 @@ class RecoveryTokenTest(unittest.TestCase): self.rec_token.store_token("example3.com", 333) self.assertFalse(self.rec_token.requires_human("example3.com")) - self.rec_token.cleanup(challenge_util.RecTokenChall("example3.com")) + self.rec_token.cleanup(achallenges.RecoveryToken( + chall=None, domain="example3.com")) self.assertTrue(self.rec_token.requires_human("example3.com")) # Shouldn't throw an error - self.rec_token.cleanup(challenge_util.RecTokenChall("example4.com")) + self.rec_token.cleanup(achallenges.RecoveryToken( + chall=None, domain="example4.com")) # SHOULD throw an error (OSError other than nonexistent file) self.assertRaises( OSError, self.rec_token.cleanup, - challenge_util.RecTokenChall("a"+"r"*10000+".com")) + achallenges.RecoveryToken(chall=None, domain="a"+"r"*10000+".com")) def test_perform_stored(self): self.rec_token.store_token("example4.com", 444) response = self.rec_token.perform( - challenge_util.RecTokenChall("example4.com")) + achallenges.RecoveryToken(chall=None, domain="example4.com")) - self.assertEqual(response, {"type": "recoveryToken", "token": "444"}) + self.assertEqual( + response, challenges.RecoveryTokenResponse(token="444")) @mock.patch("letsencrypt.client.recovery_token.zope.component.getUtility") def test_perform_not_stored(self, mock_input): mock_input().input.side_effect = [(0, "555"), (1, "000")] response = self.rec_token.perform( - challenge_util.RecTokenChall("example5.com")) - - self.assertEqual(response, {"type": "recoveryToken", "token": "555"}) + achallenges.RecoveryToken(chall=None, domain="example5.com")) + self.assertEqual( + response, challenges.RecoveryTokenResponse(token="555")) response = self.rec_token.perform( - challenge_util.RecTokenChall("example6.com")) + achallenges.RecoveryToken(chall=None, domain="example6.com")) self.assertTrue(response is None) diff --git a/letsencrypt/client/tests/standalone_authenticator_test.py b/letsencrypt/client/tests/standalone_authenticator_test.py index 6811371df..198fd1b0e 100644 --- a/letsencrypt/client/tests/standalone_authenticator_test.py +++ b/letsencrypt/client/tests/standalone_authenticator_test.py @@ -9,9 +9,9 @@ import mock import OpenSSL.crypto import OpenSSL.SSL -from letsencrypt.acme import jose +from letsencrypt.acme import challenges -from letsencrypt.client import challenge_util +from letsencrypt.client import achallenges from letsencrypt.client import le_util @@ -53,8 +53,8 @@ class ChallPrefTest(unittest.TestCase): self.authenticator = StandaloneAuthenticator() def test_chall_pref(self): - self.assertEqual( - self.authenticator.get_chall_pref("example.com"), ["dvsni"]) + self.assertEqual(self.authenticator.get_chall_pref("example.com"), + [challenges.DVSNI]) class SNICallbackTest(unittest.TestCase): @@ -63,11 +63,12 @@ class SNICallbackTest(unittest.TestCase): from letsencrypt.client.standalone_authenticator import \ StandaloneAuthenticator self.authenticator = StandaloneAuthenticator() - name, r_b64 = "example.com", jose.b64encode("x" * 32) test_key = pkg_resources.resource_string( __name__, "testdata/rsa256_key.pem") - nonce, key = "abcdef", le_util.Key("foo", test_key) - self.cert = challenge_util.dvsni_gen_cert(name, r_b64, nonce, key)[0] + key = le_util.Key("foo", test_key) + self.cert = achallenges.DVSNI( + chall=challenges.DVSNI(r="x"*32, nonce="abcdef"), + domain="example.com", key=key).gen_cert_and_response()[0] private_key = OpenSSL.crypto.load_privatekey( OpenSSL.crypto.FILETYPE_PEM, key.pem) self.authenticator.private_key = private_key @@ -260,80 +261,71 @@ class PerformTest(unittest.TestCase): StandaloneAuthenticator self.authenticator = StandaloneAuthenticator() - def test_perform_when_already_listening(self): test_key = pkg_resources.resource_string( __name__, "testdata/rsa256_key.pem") - key = le_util.Key("something", test_key) - chall1 = challenge_util.DvsniChall( - "foo.example.com", "whee", "foononce", key) + self.key = le_util.Key("something", test_key) + + self.achall1 = achallenges.DVSNI( + chall=challenges.DVSNI(r="whee", nonce="foo"), + domain="foo.example.com", key=self.key) + self.achall2 = achallenges.DVSNI( + chall=challenges.DVSNI(r="whee", nonce="bar"), + domain="bar.example.com", key=self.key) + bad_achall = ("This", "Represents", "A Non-DVSNI", "Challenge") + self.achalls = [self.achall1, self.achall2, bad_achall] + + def test_perform_when_already_listening(self): self.authenticator.already_listening = mock.Mock() self.authenticator.already_listening.return_value = True - result = self.authenticator.perform([chall1]) + result = self.authenticator.perform([self.achall1]) self.assertEqual(result, [None]) def test_can_perform(self): """What happens if start_listener() returns True.""" - test_key = pkg_resources.resource_string( - __name__, "testdata/rsa256_key.pem") - key = le_util.Key("something", test_key) - chall1 = challenge_util.DvsniChall( - "foo.example.com", "whee", "foononce", key) - chall2 = challenge_util.DvsniChall( - "bar.example.com", "whee", "barnonce", key) - bad_chall = ("This", "Represents", "A Non-DVSNI", "Challenge") self.authenticator.start_listener = mock.Mock() self.authenticator.start_listener.return_value = True - result = self.authenticator.perform([chall1, chall2, bad_chall]) + result = self.authenticator.perform(self.achalls) self.assertEqual(len(self.authenticator.tasks), 2) self.assertTrue( - self.authenticator.tasks.has_key("foononce.acme.invalid")) + self.authenticator.tasks.has_key(self.achall1.nonce_domain)) self.assertTrue( - self.authenticator.tasks.has_key("barnonce.acme.invalid")) + self.authenticator.tasks.has_key(self.achall2.nonce_domain)) self.assertTrue(isinstance(result, list)) self.assertEqual(len(result), 3) - self.assertTrue(isinstance(result[0], dict)) - self.assertTrue(isinstance(result[1], dict)) + self.assertTrue(isinstance(result[0], challenges.ChallengeResponse)) + self.assertTrue(isinstance(result[1], challenges.ChallengeResponse)) self.assertFalse(result[2]) - self.assertTrue(result[0].has_key("s")) - self.assertTrue(result[1].has_key("s")) - self.authenticator.start_listener.assert_called_once_with(443, key) + self.authenticator.start_listener.assert_called_once_with(443, self.key) def test_cannot_perform(self): """What happens if start_listener() returns False.""" - test_key = pkg_resources.resource_string( - __name__, "testdata/rsa256_key.pem") - key = le_util.Key("something", test_key) - chall1 = challenge_util.DvsniChall( - "foo.example.com", "whee", "foononce", key) - chall2 = challenge_util.DvsniChall( - "bar.example.com", "whee", "barnonce", key) - bad_chall = ("This", "Represents", "A Non-DVSNI", "Challenge") self.authenticator.start_listener = mock.Mock() self.authenticator.start_listener.return_value = False - result = self.authenticator.perform([chall1, chall2, bad_chall]) + result = self.authenticator.perform(self.achalls) self.assertEqual(len(self.authenticator.tasks), 2) self.assertTrue( - self.authenticator.tasks.has_key("foononce.acme.invalid")) + self.authenticator.tasks.has_key(self.achall1.nonce_domain)) self.assertTrue( - self.authenticator.tasks.has_key("barnonce.acme.invalid")) + self.authenticator.tasks.has_key(self.achall2.nonce_domain)) self.assertTrue(isinstance(result, list)) self.assertEqual(len(result), 3) self.assertEqual(result, [None, None, False]) - self.authenticator.start_listener.assert_called_once_with(443, key) + self.authenticator.start_listener.assert_called_once_with( + 443, self. key) def test_perform_with_pending_tasks(self): self.authenticator.tasks = {"foononce.acme.invalid": "cert_data"} - extra_challenge = challenge_util.DvsniChall("a", "b", "c", "d") + extra_achall = achallenges.DVSNI(chall="a", domain="b", key="c") self.assertRaises( - ValueError, self.authenticator.perform, [extra_challenge]) + ValueError, self.authenticator.perform, [extra_achall]) def test_perform_without_challenge_list(self): - extra_challenge = challenge_util.DvsniChall("a", "b", "c", "d") + extra_achall = achallenges.DVSNI(chall="a", domain="b", key="c") # This is wrong because a challenge must be specified. self.assertRaises(ValueError, self.authenticator.perform, []) # This is wrong because it must be a list, not a bare challenge. self.assertRaises( - ValueError, self.authenticator.perform, extra_challenge) + ValueError, self.authenticator.perform, extra_achall) # This is wrong because the list must contain at least one challenge. self.assertRaises( ValueError, self.authenticator.perform, range(20)) @@ -430,12 +422,13 @@ class DoChildProcessTest(unittest.TestCase): from letsencrypt.client.standalone_authenticator import \ StandaloneAuthenticator self.authenticator = StandaloneAuthenticator() - name, r_b64 = "example.com", jose.b64encode("x" * 32) test_key = pkg_resources.resource_string( __name__, "testdata/rsa256_key.pem") - nonce, key = "abcdef", le_util.Key("foo", test_key) + key = le_util.Key("foo", test_key) self.key = key - self.cert = challenge_util.dvsni_gen_cert(name, r_b64, nonce, key)[0] + self.cert = achallenges.DVSNI( + chall=challenges.DVSNI(r="x"*32, nonce="abcdef"), + domain="example.com", key=key).gen_cert_and_response()[0] private_key = OpenSSL.crypto.load_privatekey( OpenSSL.crypto.FILETYPE_PEM, key.pem) self.authenticator.private_key = private_key @@ -522,7 +515,10 @@ class CleanupTest(unittest.TestCase): from letsencrypt.client.standalone_authenticator import \ StandaloneAuthenticator self.authenticator = StandaloneAuthenticator() - self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} + self.achall = achallenges.DVSNI( + chall=challenges.DVSNI(r="whee", nonce="foononce"), + domain="foo.example.com", key="key") + self.authenticator.tasks = {self.achall.nonce_domain: "stuff"} self.authenticator.child_pid = 12345 @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") @@ -530,16 +526,17 @@ class CleanupTest(unittest.TestCase): def test_cleanup(self, mock_sleep, mock_kill): mock_sleep.return_value = None mock_kill.return_value = None - chall = challenge_util.DvsniChall( - "foo.example.com", "whee", "foononce", "key") - self.authenticator.cleanup([chall]) + + self.authenticator.cleanup([self.achall]) + mock_kill.assert_called_once_with(12345, signal.SIGINT) mock_sleep.assert_called_once_with(1) def test_bad_cleanup(self): - chall = challenge_util.DvsniChall( - "bad.example.com", "whee", "badnonce", "key") - self.assertRaises(ValueError, self.authenticator.cleanup, [chall]) + self.assertRaises( + ValueError, self.authenticator.cleanup, [achallenges.DVSNI( + chall=challenges.DVSNI(r="whee", nonce="badnonce"), + domain="bad.example.com", key="key")]) class MoreInfoTest(unittest.TestCase): From ba98b5cb229e35486d9bf6ad7ee1323cc8f1a47e Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sun, 22 Feb 2015 14:34:12 +0000 Subject: [PATCH 07/18] Bump up coverage --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index d4af50fa5..bf609a747 100644 --- a/tox.ini +++ b/tox.ini @@ -17,7 +17,7 @@ setenv = basepython = python2.7 commands = pip install -e .[testing] - python setup.py nosetests --with-coverage --cover-min-percentage=83 + python setup.py nosetests --with-coverage --cover-min-percentage=85 [testenv:lint] # recent versions of pylint do not support Python 2.6 (#97, #187) From 67314e9f159bb1c4c725b6963caee7cfb458bc5d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Mon, 23 Feb 2015 22:23:53 +0000 Subject: [PATCH 08/18] Increase min-similarity-lines. --- .pylintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pylintrc b/.pylintrc index 2970f2bc9..fe4d471ac 100644 --- a/.pylintrc +++ b/.pylintrc @@ -192,7 +192,7 @@ additional-builtins= [SIMILARITIES] # Minimum lines number of a similarity. -min-similarity-lines=4 +min-similarity-lines=6 # Ignore comments when computing similarities. ignore-comments=yes From ca2bbc13a328ae193cf36c23ab9a4d2aacefbc17 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 24 Feb 2015 14:39:26 +0000 Subject: [PATCH 09/18] Improve letsencrypt.acme docs. --- docs/api/acme.rst | 51 ++++++++++++++++++++++++++++++++++ docs/api/acme/challenges.rst | 5 ---- docs/api/acme/errors.rst | 5 ---- docs/api/acme/interfaces.rst | 5 ---- docs/api/acme/jose.rst | 5 ---- docs/api/acme/messages.rst | 5 ---- docs/api/acme/other.rst | 5 ---- docs/api/acme/util.rst | 5 ---- docs/conf.py | 3 ++ letsencrypt/acme/__init__.py | 23 ++++++++++++++- letsencrypt/acme/interfaces.py | 1 - letsencrypt/acme/messages.py | 28 +++++++++++-------- setup.py | 2 +- 13 files changed, 94 insertions(+), 49 deletions(-) create mode 100644 docs/api/acme.rst delete mode 100644 docs/api/acme/challenges.rst delete mode 100644 docs/api/acme/errors.rst delete mode 100644 docs/api/acme/interfaces.rst delete mode 100644 docs/api/acme/jose.rst delete mode 100644 docs/api/acme/messages.rst delete mode 100644 docs/api/acme/other.rst delete mode 100644 docs/api/acme/util.rst diff --git a/docs/api/acme.rst b/docs/api/acme.rst new file mode 100644 index 000000000..04c33917a --- /dev/null +++ b/docs/api/acme.rst @@ -0,0 +1,51 @@ +:mod:`letsencrypt.acme` +======================= + +.. automodule:: letsencrypt.acme + :members: + + +Interfaces +---------- + +.. automodule:: letsencrypt.acme.interfaces + :members: + +Messages +-------- + +.. automodule:: letsencrypt.acme.messages + :members: + + +Challenges +---------- + +.. automodule:: letsencrypt.acme.challenges + :members: + + +Other ACME objects +------------------ +.. automodule:: letsencrypt.acme.other + :members: + + +Errors +------ + +.. automodule:: letsencrypt.acme.errors + :members: + + + :members: + + +Utilities +--------- + +.. automodule:: letsencrypt.acme.util + :members: + +.. automodule:: letsencrypt.acme.jose + :members: diff --git a/docs/api/acme/challenges.rst b/docs/api/acme/challenges.rst deleted file mode 100644 index 373748d61..000000000 --- a/docs/api/acme/challenges.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.acme.challenges` ----------------------------------- - -.. automodule:: letsencrypt.acme.challenges - :members: diff --git a/docs/api/acme/errors.rst b/docs/api/acme/errors.rst deleted file mode 100644 index 53132bd15..000000000 --- a/docs/api/acme/errors.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.acme.errors` ------------------------------- - -.. automodule:: letsencrypt.acme.errors - :members: diff --git a/docs/api/acme/interfaces.rst b/docs/api/acme/interfaces.rst deleted file mode 100644 index 5ed652834..000000000 --- a/docs/api/acme/interfaces.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.acme.interfaces` ----------------------------------- - -.. automodule:: letsencrypt.acme.interfaces - :members: diff --git a/docs/api/acme/jose.rst b/docs/api/acme/jose.rst deleted file mode 100644 index d82dc1f15..000000000 --- a/docs/api/acme/jose.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.acme.jose` ----------------------------- - -.. automodule:: letsencrypt.acme.jose - :members: diff --git a/docs/api/acme/messages.rst b/docs/api/acme/messages.rst deleted file mode 100644 index d231f9c52..000000000 --- a/docs/api/acme/messages.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.acme.messages` --------------------------------- - -.. automodule:: letsencrypt.acme.messages - :members: diff --git a/docs/api/acme/other.rst b/docs/api/acme/other.rst deleted file mode 100644 index 8372e3028..000000000 --- a/docs/api/acme/other.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.acme.other` ------------------------------ - -.. automodule:: letsencrypt.acme.other - :members: diff --git a/docs/api/acme/util.rst b/docs/api/acme/util.rst deleted file mode 100644 index 960cf8882..000000000 --- a/docs/api/acme/util.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.acme.util` ----------------------------- - -.. automodule:: letsencrypt.acme.util - :members: diff --git a/docs/conf.py b/docs/conf.py index 2f25c9a7b..2a29b9dd3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,6 +54,9 @@ extensions = [ 'repoze.sphinx.autointerface', ] +autodoc_member_order = 'bysource' +autodoc_default_flags = ['show-inheritance'] + # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/letsencrypt/acme/__init__.py b/letsencrypt/acme/__init__.py index 69418608b..95744bbd5 100644 --- a/letsencrypt/acme/__init__.py +++ b/letsencrypt/acme/__init__.py @@ -1 +1,22 @@ -"""ACME protocol implementation.""" +"""ACME protocol implementation. + +.. warning:: This module is an implementation of the draft `ACME + protocol version 00`_, and not the latest (as of time of writing), + "RESTified" `ACME protocol version 01`_. It should work with the + server from the `Node.js implementation`_, but will not work with + Boulder_. + + +.. _`ACME protocol`: https://github.com/letsencrypt/acme-spec + +.. _`ACME protocol version 00`: + https://github.com/letsencrypt/acme-spec/blob/v00/draft-barnes-acme.md + +.. _`ACME protocol version 01`: + https://github.com/letsencrypt/acme-spec/blob/v01/draft-barnes-acme.md + +.. _Boulder: https://github.com/letsencrypt/boulder + +.. _`Node.js implementation`: https://github.com/letsencrypt/node-acme + +""" diff --git a/letsencrypt/acme/interfaces.py b/letsencrypt/acme/interfaces.py index a106f2aca..b6e40a436 100644 --- a/letsencrypt/acme/interfaces.py +++ b/letsencrypt/acme/interfaces.py @@ -18,7 +18,6 @@ class IJSONSerializable(zope.interface.Interface): objects, that haven't been serialized yet, which is fine as long as :func:`letsencrypt.acme.util.dump_ijsonserializable` is used. - :rtype: dict """ diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py index a673631fa..4f0b43759 100644 --- a/letsencrypt/acme/messages.py +++ b/letsencrypt/acme/messages.py @@ -82,7 +82,13 @@ class Message(util.TypedACMEObject): @Message.register # pylint: disable=too-few-public-methods class Challenge(Message): - """ACME "challenge" message.""" + """ACME "challenge" message. + + :ivar str nonce: Random data, **not** base64-encoded. + :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") @@ -119,11 +125,7 @@ class Challenge(Message): @Message.register # pylint: disable=too-few-public-methods class ChallengeRequest(Message): - """ACME "challengeRequest" message. - - :ivar str identifier: Domain name. - - """ + """ACME "challengeRequest" message.""" acme_type = "challengeRequest" schema = util.load_schema(acme_type) __slots__ = ("identifier",) @@ -140,7 +142,11 @@ class ChallengeRequest(Message): @Message.register # pylint: disable=too-few-public-methods class Authorization(Message): - """ACME "authorization" message.""" + """ACME "authorization" message. + + :ivar jwk: :class:`letsencrypt.acme.other.JWK` + + """ acme_type = "authorization" schema = util.load_schema(acme_type) __slots__ = ("recovery_token", "identifier", "jwk") @@ -168,11 +174,11 @@ class Authorization(Message): class AuthorizationRequest(Message): """ACME "authorizationRequest" message. - :ivar str session_id: "sessionID" from the server challenge - :ivar str nonce: Nonce from the server challenge - :ivar list responses: List of completed challenges + :ivar str nonce: Random data from the corresponding + :attr:`Challenge.nonce`, **not** base64-encoded. + :ivar list responses: List of completed challenges ( + :class:`letsencrypt.acme.challenges.ChallengeResponse`). :ivar signature: Signature (:class:`letsencrypt.acme.other.Signature`). - :ivar contact: TODO """ acme_type = "authorizationRequest" diff --git a/setup.py b/setup.py index 5a5361a1c..179fda443 100755 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ dev_extras = [ docs_extras = [ 'repoze.sphinx.autointerface', - 'Sphinx', + 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags 'sphinx_rtd_theme', ] From 8f41aa69cdbe036a985b461ff7fc5dd7f5c55ae4 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 28 Feb 2015 07:33:54 +0000 Subject: [PATCH 10/18] import M2Crypto.X509 -> import M2Crypto --- letsencrypt/acme/challenges_test.py | 2 +- letsencrypt/acme/messages_test.py | 2 +- letsencrypt/acme/util.py | 2 +- letsencrypt/acme/util_test.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/letsencrypt/acme/challenges_test.py b/letsencrypt/acme/challenges_test.py index a2293d294..53b3ff3f1 100644 --- a/letsencrypt/acme/challenges_test.py +++ b/letsencrypt/acme/challenges_test.py @@ -4,7 +4,7 @@ import pkg_resources import unittest import Crypto.PublicKey.RSA -import M2Crypto.X509 +import M2Crypto from letsencrypt.acme import errors from letsencrypt.acme import jose diff --git a/letsencrypt/acme/messages_test.py b/letsencrypt/acme/messages_test.py index 4662da1f7..ab9f4f64e 100644 --- a/letsencrypt/acme/messages_test.py +++ b/letsencrypt/acme/messages_test.py @@ -3,7 +3,7 @@ import pkg_resources import unittest import Crypto.PublicKey.RSA -import M2Crypto.X509 +import M2Crypto from letsencrypt.acme import challenges from letsencrypt.acme import errors diff --git a/letsencrypt/acme/util.py b/letsencrypt/acme/util.py index ac7bf3874..0969ceb64 100644 --- a/letsencrypt/acme/util.py +++ b/letsencrypt/acme/util.py @@ -3,7 +3,7 @@ import binascii import json import pkg_resources -import M2Crypto.X509 +import M2Crypto import zope.interface from letsencrypt.acme import errors diff --git a/letsencrypt/acme/util_test.py b/letsencrypt/acme/util_test.py index c64c5dfca..fddb24fc3 100644 --- a/letsencrypt/acme/util_test.py +++ b/letsencrypt/acme/util_test.py @@ -5,7 +5,7 @@ import os import pkg_resources import unittest -import M2Crypto.X509 +import M2Crypto import zope.interface from letsencrypt.acme import errors From a4e4d9859808f28a8d83a6c14f3723865a47ad64 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 28 Feb 2015 08:29:13 +0000 Subject: [PATCH 11/18] Improve acme docs --- letsencrypt/acme/interfaces.py | 45 ++++++++++++++++--- letsencrypt/acme/messages.py | 14 +++++- letsencrypt/acme/util.py | 7 +-- letsencrypt/client/auth_handler.py | 2 +- letsencrypt/client/tests/auth_handler_test.py | 9 ++-- 5 files changed, 60 insertions(+), 17 deletions(-) diff --git a/letsencrypt/acme/interfaces.py b/letsencrypt/acme/interfaces.py index b6e40a436..164ffcea2 100644 --- a/letsencrypt/acme/interfaces.py +++ b/letsencrypt/acme/interfaces.py @@ -1,4 +1,16 @@ -"""ACME interfaces.""" +"""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 @@ -12,12 +24,31 @@ class IJSONSerializable(zope.interface.Interface): def to_json(): """Prepare JSON serializable object. - :returns: JSON object ready to be serialized. Note, however, that - this 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. + 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.implemeents(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. """ diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py index 4f0b43759..777a5b7d8 100644 --- a/letsencrypt/acme/messages.py +++ b/letsencrypt/acme/messages.py @@ -16,6 +16,13 @@ class Message(util.TypedACMEObject): TYPES = {} schema = NotImplemented + """JSON schema the object is tested against in :meth:`from_json`. + + Subclasses must overrride it with a value that is acceptable by + :func:`jsonschema.validate`, most probably using + :func:`letsencrypt.acme.util.load_schema`. + + """ @classmethod def get_msg_cls(cls, jobj): @@ -49,8 +56,11 @@ class Message(util.TypedACMEObject): :param jobj: JSON object. - :raises letsencrypt.acme.errors.SchemaValidationError: if ``validate`` - was ``True`` and object couldn't be validated. + :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. :returns: instance of the class diff --git a/letsencrypt/acme/util.py b/letsencrypt/acme/util.py index 0969ceb64..819ed2e86 100644 --- a/letsencrypt/acme/util.py +++ b/letsencrypt/acme/util.py @@ -40,7 +40,10 @@ def dump_ijsonserializable(python_object): """Serialize IJSONSerializable to JSON. This is meant to be passed to :func:`json.dumps` as ``default`` - argument. + 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 @@ -164,8 +167,6 @@ class TypedACMEObject(ACMEObject): """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 """ diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index e63a7baf2..70cd89889 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -191,7 +191,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :param list flat_list: flat_list of responses from an IAuthenticator :param dict ichall_dict: Master dict mapping all domains to a list of - their associated 'client' and 'dv' Indexed challengesenges, or their + their associated 'client' and 'dv' Indexed challenges, or their :class:`letsencrypt.client.achallenges.Indexed` list """ diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py index f35c61a55..91874dc0c 100644 --- a/letsencrypt/client/tests/auth_handler_test.py +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -584,11 +584,12 @@ def gen_auth_resp(chall_list): def gen_path(required, challs): - """Generate a path for challenge messages + """Generate a combination by picking ``required`` from ``challs``. - :param required: - :param list str_list: challenge message types (:class:`str`) - :param challs: ACME challenge messages + :param required: Required types of challenges (subclasses of + :class:`~letsencrypt.acme.challenges.Challenge`). + :param challs: Sequence of ACME challenge messages, corresponding to + :attr:`letsencrypt.acme.messages.Challenge.challenges`. :return: :class:`list` of :class:`int` From cde829fa7c3c01abda489ff5b32df7ff4659627d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 28 Feb 2015 08:29:46 +0000 Subject: [PATCH 12/18] Fix typo --- letsencrypt/acme/other_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/acme/other_test.py b/letsencrypt/acme/other_test.py index 38206f59b..273f19116 100644 --- a/letsencrypt/acme/other_test.py +++ b/letsencrypt/acme/other_test.py @@ -60,7 +60,7 @@ class JWKTest(unittest.TestCase): {'kty': 'RSA', 'e': 'AQAB', 'n': '1'}) -class SigatureTest(unittest.TestCase): +class SignatureTest(unittest.TestCase): # pylint: disable=too-many-instance-attributes """Tests for letsencrypt.acme.sig.Signature.""" From 5720cdc8c9b22ddba8912ef31e84f1528a51866d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 28 Feb 2015 08:33:22 +0000 Subject: [PATCH 13/18] Inlining --- letsencrypt/acme/challenges.py | 5 +++-- letsencrypt/acme/messages.py | 4 +--- letsencrypt/acme/util.py | 4 ---- letsencrypt/acme/util_test.py | 4 ---- 4 files changed, 4 insertions(+), 13 deletions(-) diff --git a/letsencrypt/acme/challenges.py b/letsencrypt/acme/challenges.py index fb3110bb1..bea3a7ebb 100644 --- a/letsencrypt/acme/challenges.py +++ b/letsencrypt/acme/challenges.py @@ -1,4 +1,5 @@ """ACME Identifier Validation Challenges.""" +import binascii import functools import hashlib @@ -106,12 +107,12 @@ class DVSNI(DVChallenge): @property def nonce_domain(self): """Domain name used in SNI.""" - return self._encode_hex16(self.nonce) + self.DOMAIN_SUFFIX + return binascii.hexlify(self.nonce) + self.DOMAIN_SUFFIX def _fields_to_json(self): return { "r": jose.b64encode(self.r), - "nonce": self._encode_hex16(self.nonce), + "nonce": binascii.hexlify(self.nonce), } @classmethod diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py index 777a5b7d8..6f95dc344 100644 --- a/letsencrypt/acme/messages.py +++ b/letsencrypt/acme/messages.py @@ -42,12 +42,10 @@ class Message(util.TypedACMEObject): raise errors.ValidationError("missing type field") try: - msg_cls = cls.TYPES[msg_type] + return cls.TYPES[msg_type] except KeyError: raise errors.UnrecognizedTypeError(msg_type) - return msg_cls - @classmethod def from_json(cls, jobj): """Deserialize from (possibly invalid) JSON object. diff --git a/letsencrypt/acme/util.py b/letsencrypt/acme/util.py index 819ed2e86..87ccd5365 100644 --- a/letsencrypt/acme/util.py +++ b/letsencrypt/acme/util.py @@ -112,10 +112,6 @@ class ACMEObject(ImmutableMap): # pylint: disable=too-few-public-methods return decoded - @classmethod - def _encode_hex16(cls, data): - return binascii.hexlify(data) - @classmethod def _decode_hex16(cls, data, size=None, minimum=False): if size is not None and ((not minimum and len(data) != size * 2) diff --git a/letsencrypt/acme/util_test.py b/letsencrypt/acme/util_test.py index fddb24fc3..702c08a90 100644 --- a/letsencrypt/acme/util_test.py +++ b/letsencrypt/acme/util_test.py @@ -160,10 +160,6 @@ class ACMEObjectTest(unittest.TestCase): self.assertRaises(errors.ValidationError, ACMEObject._decode_b64jose, 'Zm9v', size=4, minimum=True) - def test_encode_hex16(self): - from letsencrypt.acme.util import ACMEObject - self.assertEqual('666f6f', ACMEObject._encode_hex16('foo')) - def test_decode_hex16(self): from letsencrypt.acme.util import ACMEObject self.assertEqual('foo', ACMEObject._decode_hex16('666f6f')) From c83f7c24738138d2d53d5f7042014e35d956d44f Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 28 Feb 2015 08:34:51 +0000 Subject: [PATCH 14/18] Remove bool() cast from Signature.verify --- letsencrypt/acme/other.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/acme/other.py b/letsencrypt/acme/other.py index 9dc12591c..dbdaee3d0 100644 --- a/letsencrypt/acme/other.py +++ b/letsencrypt/acme/other.py @@ -109,8 +109,8 @@ class Signature(util.ACMEObject): """ hashed = Crypto.Hash.SHA256.new(self.nonce + msg) - return bool(Crypto.Signature.PKCS1_v1_5.new(self.jwk.key).verify( - hashed, self.sig)) + return Crypto.Signature.PKCS1_v1_5.new(self.jwk.key).verify( + hashed, self.sig) def to_json(self): return { From ce1e43c6034ad76315b04c1b0603e748d7cf2908 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 28 Feb 2015 09:03:23 +0000 Subject: [PATCH 15/18] Move decoders/encoders to top-level acme.util --- letsencrypt/acme/challenges.py | 14 ++-- letsencrypt/acme/messages.py | 20 +++--- letsencrypt/acme/other.py | 6 +- letsencrypt/acme/util.py | 122 +++++++++++++++++++++++---------- letsencrypt/acme/util_test.py | 71 ++++++++++--------- 5 files changed, 144 insertions(+), 89 deletions(-) diff --git a/letsencrypt/acme/challenges.py b/letsencrypt/acme/challenges.py index bea3a7ebb..4bbeb4cd2 100644 --- a/letsencrypt/acme/challenges.py +++ b/letsencrypt/acme/challenges.py @@ -117,8 +117,8 @@ class DVSNI(DVChallenge): @classmethod def from_valid_json(cls, jobj): - return cls(r=cls._decode_b64jose(jobj["r"], cls.R_SIZE), - nonce=cls._decode_hex16(jobj["nonce"], cls.NONCE_SIZE)) + return cls(r=util.decode_b64jose(jobj["r"], cls.R_SIZE), + nonce=util.decode_hex16(jobj["nonce"], cls.NONCE_SIZE)) @ChallengeResponse.register @@ -162,7 +162,7 @@ class DVSNIResponse(ChallengeResponse): @classmethod def from_valid_json(cls, jobj): - return cls(s=cls._decode_b64jose(jobj["s"], cls.S_SIZE)) + return cls(s=util.decode_b64jose(jobj["s"], cls.S_SIZE)) @Challenge.register @@ -271,7 +271,7 @@ class ProofOfPossession(ClientChallenge): fields = {"jwk": self.jwk} add = functools.partial(_extend_if_not_empty, fields) add(self.cert_fingerprints, "certFingerprints") - add([self._encode_cert(cert) for cert in self.certs], "certs") + 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") @@ -283,7 +283,7 @@ class ProofOfPossession(ClientChallenge): return cls( jwk=other.JWK.from_valid_json(jobj["jwk"]), cert_fingerprints=jobj.get("certFingerprints", []), - certs=[cls._decode_cert(cert) + certs=[util.decode_cert(cert) for cert in jobj.get("certs", [])], subject_key_identifiers=jobj.get("subjectKeyIdentifiers", []), serial_numbers=jobj.get("serialNumbers", []), @@ -300,7 +300,7 @@ class ProofOfPossession(ClientChallenge): @classmethod def from_valid_json(cls, jobj): return cls(alg=jobj["alg"], - nonce=cls._decode_b64jose(jobj["nonce"], cls.NONCE_SIZE), + nonce=util.decode_b64jose(jobj["nonce"], cls.NONCE_SIZE), hints=cls.Hints.from_valid_json(jobj["hints"])) @@ -329,7 +329,7 @@ class ProofOfPossessionResponse(ChallengeResponse): @classmethod def from_valid_json(cls, jobj): - return cls(nonce=cls._decode_b64jose(jobj["nonce"], cls.NONCE_SIZE), + return cls(nonce=util.decode_b64jose(jobj["nonce"], cls.NONCE_SIZE), signature=other.Signature.from_valid_json(jobj["signature"])) diff --git a/letsencrypt/acme/messages.py b/letsencrypt/acme/messages.py index 6f95dc344..64f7a0350 100644 --- a/letsencrypt/acme/messages.py +++ b/letsencrypt/acme/messages.py @@ -125,7 +125,7 @@ class Challenge(Message): # TODO: turn "combinations" elements into sets? # TODO: turn "combinations" into set? return cls(session_id=jobj["sessionID"], - nonce=cls._decode_b64jose(jobj["nonce"]), + nonce=util.decode_b64jose(jobj["nonce"]), challenges=[challenges.Challenge.from_valid_json(chall) for chall in jobj["challenges"]], combinations=jobj.get("combinations", [])) @@ -245,7 +245,7 @@ class AuthorizationRequest(Message): def from_valid_json(cls, jobj): return cls( session_id=jobj["sessionID"], - nonce=cls._decode_b64jose(jobj["nonce"]), + 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"]), @@ -268,17 +268,17 @@ class Certificate(Message): __slots__ = ("certificate", "chain", "refresh") def _fields_to_json(self): - fields = {"certificate": self._encode_cert(self.certificate)} + fields = {"certificate": util.encode_cert(self.certificate)} if self.chain: - fields["chain"] = [self._encode_cert(cert) for cert in 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 @classmethod def from_valid_json(cls, jobj): - return cls(certificate=cls._decode_cert(jobj["certificate"]), - chain=[cls._decode_cert(cert) for cert in + return cls(certificate=util.decode_cert(jobj["certificate"]), + chain=[util.decode_cert(cert) for cert in jobj.get("chain", [])], refresh=jobj.get("refresh")) @@ -328,13 +328,13 @@ class CertificateRequest(Message): def _fields_to_json(self): return { - "csr": self._encode_csr(self.csr), + "csr": util.encode_csr(self.csr), "signature": self.signature, } @classmethod def from_valid_json(cls, jobj): - return cls(csr=cls._decode_csr(jobj["csr"]), + return cls(csr=util.decode_csr(jobj["csr"]), signature=other.Signature.from_valid_json(jobj["signature"])) @@ -449,13 +449,13 @@ class RevocationRequest(Message): def _fields_to_json(self): return { - "certificate": self._encode_cert(self.certificate), + "certificate": util.encode_cert(self.certificate), "signature": self.signature, } @classmethod def from_valid_json(cls, jobj): - return cls(certificate=cls._decode_cert(jobj["certificate"]), + return cls(certificate=util.decode_cert(jobj["certificate"]), signature=other.Signature.from_valid_json(jobj["signature"])) diff --git a/letsencrypt/acme/other.py b/letsencrypt/acme/other.py index dbdaee3d0..c21611103 100644 --- a/letsencrypt/acme/other.py +++ b/letsencrypt/acme/other.py @@ -34,7 +34,7 @@ class JWK(util.ACMEObject): @classmethod def _decode_param(cls, data): try: - return long(binascii.hexlify(cls._decode_b64jose(data)), 16) + return long(binascii.hexlify(util.decode_b64jose(data)), 16) except ValueError: # invalid literal for long() with base 16 raise errors.ValidationError(data) @@ -123,7 +123,7 @@ class Signature(util.ACMEObject): @classmethod def from_valid_json(cls, jobj): assert jobj['alg'] == 'RS256' # TODO: support other algorithms - return cls(alg=jobj['alg'], sig=cls._decode_b64jose(jobj['sig']), - nonce=cls._decode_b64jose( + return cls(alg=jobj['alg'], sig=util.decode_b64jose(jobj['sig']), + nonce=util.decode_b64jose( jobj['nonce'], cls.NONCE_SIZE, minimum=True), jwk=JWK.from_valid_json(jobj['jwk'])) diff --git a/letsencrypt/acme/util.py b/letsencrypt/acme/util.py index 87ccd5365..cc00dc2bb 100644 --- a/letsencrypt/acme/util.py +++ b/letsencrypt/acme/util.py @@ -99,49 +99,99 @@ class ACMEObject(ImmutableMap): # pylint: disable=too-few-public-methods """Deserialize from valid JSON object.""" raise NotImplementedError() - @classmethod - def _decode_b64jose(cls, data, size=None, minimum=False): - try: - decoded = jose.b64decode(data) - 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() +def decode_b64jose(value, size=None, minimum=False): + """Decode ACME object JOSE Base64 encoded field. - return decoded + :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. - @classmethod - def _decode_hex16(cls, data, size=None, minimum=False): - if size is not None and ((not minimum and len(data) != size * 2) - or (minimum and len(data) < size * 2)): - raise errors.ValidationError() - return binascii.unhexlify(data) + :raises letsencrypt.acme.errors.ValidationError: if anything goes wrong + :returns: Decoded value. - @classmethod - def _encode_cert(cls, cert): - return jose.b64encode(cert.as_der()) + """ + try: + decoded = jose.b64decode(value) + except TypeError: + raise errors.ValidationError() - @classmethod - def _decode_cert(cls, b64der): - try: - return ComparableX509(M2Crypto.X509.load_cert_der_string( - cls._decode_b64jose(b64der))) - except M2Crypto.X509.X509Error: - raise errors.ValidationError() + if size is not None and ((not minimum and len(decoded) != size) + or (minimum and len(decoded) < size)): + raise errors.ValidationError() - @classmethod - def _encode_csr(cls, csr): - return cls._encode_cert(csr) + return decoded - @classmethod - def _decode_csr(cls, b64der): - try: - return ComparableX509(M2Crypto.X509.load_request_der_string( - cls._decode_b64jose(b64der))) - except M2Crypto.X509.X509Error: - raise errors.ValidationError() + +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): diff --git a/letsencrypt/acme/util_test.py b/letsencrypt/acme/util_test.py index 702c08a90..0b500a2c7 100644 --- a/letsencrypt/acme/util_test.py +++ b/letsencrypt/acme/util_test.py @@ -109,8 +109,8 @@ class ImmutableMapTest(unittest.TestCase): self.assertEqual("B(x='foo', y='bar')", repr(self.B(x='foo', y='bar'))) -class ACMEObjectTest(unittest.TestCase): - """Tests for letsencrypt.acme.util.ACMEObject.""" +class EncodersAndDecodersTest(unittest.TestCase): + """Tests for encoders and decoders from letsencrypt.acme.util""" # pylint: disable=protected-access def setUp(self): @@ -139,57 +139,62 @@ class ACMEObjectTest(unittest.TestCase): ) def test_decode_b64_jose_padding_error(self): - from letsencrypt.acme.util import ACMEObject - self.assertRaises( - errors.ValidationError, ACMEObject._decode_b64jose, 'x') + 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 ACMEObject - self.assertEqual('foo', ACMEObject._decode_b64jose('Zm9v', size=3)) + from letsencrypt.acme.util import decode_b64jose + self.assertEqual('foo', decode_b64jose('Zm9v', size=3)) self.assertRaises( - errors.ValidationError, ACMEObject._decode_b64jose, 'Zm9v', size=2) + errors.ValidationError, decode_b64jose, 'Zm9v', size=2) self.assertRaises( - errors.ValidationError, ACMEObject._decode_b64jose, 'Zm9v', size=4) + errors.ValidationError, decode_b64jose, 'Zm9v', size=4) def test_decode_b64_jose_minimum_size(self): - from letsencrypt.acme.util import ACMEObject - self.assertEqual( - 'foo', ACMEObject._decode_b64jose('Zm9v', size=3, minimum=True)) - self.assertEqual( - 'foo', ACMEObject._decode_b64jose('Zm9v', size=2, minimum=True)) - self.assertRaises(errors.ValidationError, ACMEObject._decode_b64jose, + 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 ACMEObject - self.assertEqual('foo', ACMEObject._decode_hex16('666f6f')) + 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 ACMEObject - self.assertEqual( - 'foo', ACMEObject._decode_hex16('666f6f', size=3, minimum=True)) - self.assertEqual( - 'foo', ACMEObject._decode_hex16('666f6f', size=2, minimum=True)) - self.assertRaises(errors.ValidationError, ACMEObject._decode_hex16, + 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 ACMEObject - self.assertEqual(self.b64_cert, ACMEObject._encode_cert(CERT)) + 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 ACMEObject - self.assertEqual(CERT, ACMEObject._decode_cert(self.b64_cert)) - self.assertRaises(errors.ValidationError, ACMEObject._decode_cert, '') + 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 ACMEObject - self.assertEqual(self.b64_csr, ACMEObject._encode_csr(CSR)) + 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 ACMEObject - self.assertEqual(CSR, ACMEObject._decode_csr(self.b64_csr)) - self.assertRaises(errors.ValidationError, ACMEObject._decode_csr, '') + 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): From d230a210f82166ac0dc0ae7997a529478ec3541d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 28 Feb 2015 09:06:51 +0000 Subject: [PATCH 16/18] Add indent to auth_handler log entries --- letsencrypt/client/auth_handler.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 70cd89889..4e3b5f68f 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -275,25 +275,25 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes chall = self.msgs[domain].challenges[index] if isinstance(chall, challenges.DVSNI): - logging.info("DVSNI challenge for %s.", domain) + logging.info(" DVSNI challenge for %s.", domain) achall = achallenges.DVSNI( chall=chall, domain=domain, key=self.authkey[domain]) elif isinstance(chall, challenges.SimpleHTTPS): - logging.info("SimpleHTTPS challenge for %s.", domain) + logging.info(" SimpleHTTPS challenge for %s.", domain) achall = achallenges.SimpleHTTPS( chall=chall, domain=domain, key=self.authkey[domain]) elif isinstance(chall, challenges.DNS): - logging.info("DNS challenge for %s.", domain) + logging.info(" DNS challenge for %s.", domain) achall = achallenges.DNS(chall=chall, domain=domain) elif isinstance(chall, challenges.RecoveryToken): - logging.info("Recovery Token Challenge for %s.", domain) + logging.info(" Recovery Token Challenge for %s.", domain) achall = achallenges.RecoveryToken(chall=chall, domain=domain) elif isinstance(chall, challenges.RecoveryContact): - logging.info("Recovery Contact Challenge for %s.", domain) + logging.info(" Recovery Contact Challenge for %s.", domain) achall = achallenges.RecoveryContact(chall=chall, domain=domain) elif isinstance(chall, challenges.ProofOfPossession): - logging.info("Proof-of-Possession Challenge for %s", domain) + logging.info(" Proof-of-Possession Challenge for %s", domain) achall = achallenges.ProofOfPossession( chall=chall, domain=domain) From 636e05e1c47282cfc27ffc32c463cd696b4546f7 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 28 Feb 2015 09:09:44 +0000 Subject: [PATCH 17/18] Use DVSNIResponse in confiurator_test --- letsencrypt/client/tests/apache/configurator_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/tests/apache/configurator_test.py b/letsencrypt/client/tests/apache/configurator_test.py index a49e76f40..1bb4207a3 100644 --- a/letsencrypt/client/tests/apache/configurator_test.py +++ b/letsencrypt/client/tests/apache/configurator_test.py @@ -154,8 +154,8 @@ class TwoVhost80Test(util.ApacheTest): domain="letsencrypt.demo", key=auth_key) dvsni_ret_val = [ - {"type": "dvsni", "s": "randomS1"}, - {"type": "dvsni", "s": "randomS2"} + challenges.DVSNIResponse(s="randomS1"), + challenges.DVSNIResponse(s="randomS2"), ] mock_dvsni_perform.return_value = dvsni_ret_val From 528bcc286e7c421254ec9953025fd7d55b72dd8d Mon Sep 17 00:00:00 2001 From: James Kasten Date: Wed, 4 Mar 2015 23:01:27 -0800 Subject: [PATCH 18/18] fix typo --- letsencrypt/acme/interfaces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/acme/interfaces.py b/letsencrypt/acme/interfaces.py index 164ffcea2..e49956b4b 100644 --- a/letsencrypt/acme/interfaces.py +++ b/letsencrypt/acme/interfaces.py @@ -37,7 +37,7 @@ class IJSONSerializable(zope.interface.Interface): return 'foo' class Bar(object): - zope.interface.implemeents(IJSONSerializable) + zope.interface.implements(IJSONSerializable) def to_json(self): return [Foo(), Foo()]