diff --git a/acme/acme/client.py b/acme/acme/client.py index ef982b093..9c32a81a4 100644 --- a/acme/acme/client.py +++ b/acme/acme/client.py @@ -4,6 +4,7 @@ import heapq import logging import time +import six from six.moves import http_client # pylint: disable=import-error import OpenSSL @@ -32,7 +33,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes Clean up raised error types hierarchy, document, and handle (wrap) instances of `.DeserializationError` raised in `from_json()`. - :ivar str new_reg_uri: Location of new-reg + :ivar messages.Directory directory: :ivar key: `.JWK` (private) :ivar alg: `.JWASignature` :ivar bool verify_ssl: Verify SSL certificates? @@ -43,12 +44,23 @@ class Client(object): # pylint: disable=too-many-instance-attributes """ DER_CONTENT_TYPE = 'application/pkix-cert' - def __init__(self, new_reg_uri, key, alg=jose.RS256, - verify_ssl=True, net=None): - self.new_reg_uri = new_reg_uri + def __init__(self, directory, key, alg=jose.RS256, verify_ssl=True, + net=None): + """Initialize. + + :param directory: Directory Resource (`.messages.Directory`) or + URI from which the resource will be downloaded. + + """ self.key = key self.net = ClientNetwork(key, alg, verify_ssl) if net is None else net + if isinstance(directory, six.string_types): + self.directory = messages.Directory.from_json( + self.net.get(directory).json()) + else: + self.directory = directory + @classmethod def _regr_from_response(cls, response, uri=None, new_authzr_uri=None, terms_of_service=None): @@ -82,7 +94,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes new_reg = messages.NewRegistration() if new_reg is None else new_reg assert isinstance(new_reg, messages.NewRegistration) - response = self.net.post(self.new_reg_uri, new_reg) + response = self.net.post(self.directory[new_reg], new_reg) # TODO: handle errors assert response.status_code == http_client.CREATED @@ -441,7 +453,7 @@ class Client(object): # pylint: disable=too-many-instance-attributes :raises .ClientError: If revocation is unsuccessful. """ - response = self.net.post(messages.Revocation.url(self.new_reg_uri), + response = self.net.post(self.directory[messages.Revocation], messages.Revocation(certificate=cert)) if response.status_code != http_client.OK: raise errors.ClientError( diff --git a/acme/acme/client_test.py b/acme/acme/client_test.py index dcc0832e3..ce03256c3 100644 --- a/acme/acme/client_test.py +++ b/acme/acme/client_test.py @@ -33,10 +33,14 @@ class ClientTest(unittest.TestCase): self.net.post.return_value = self.response self.net.get.return_value = self.response + self.directory = messages.Directory({ + messages.NewRegistration: 'https://www.letsencrypt-demo.org/acme/new-reg', + messages.Revocation: 'https://www.letsencrypt-demo.org/acme/revoke-cert', + }) + from acme.client import Client self.client = Client( - new_reg_uri='https://www.letsencrypt-demo.org/acme/new-reg', - key=KEY, alg=jose.RS256, net=self.net) + directory=self.directory, key=KEY, alg=jose.RS256, net=self.net) self.identifier = messages.Identifier( typ=messages.IDENTIFIER_FQDN, value='example.com') @@ -72,6 +76,13 @@ class ClientTest(unittest.TestCase): uri='https://www.letsencrypt-demo.org/acme/cert/1', cert_chain_uri='https://www.letsencrypt-demo.org/ca') + def test_init_downloads_directory(self): + uri = 'http://www.letsencrypt-demo.org/directory' + from acme.client import Client + self.client = Client( + directory=uri, key=KEY, alg=jose.RS256, net=self.net) + self.net.get.assert_called_once_with(uri) + def test_register(self): # "Instance of 'Field' has no to_json/update member" bug: # pylint: disable=no-member @@ -348,8 +359,8 @@ class ClientTest(unittest.TestCase): def test_revoke(self): self.client.revoke(self.certr.body) - self.net.post.assert_called_once_with(messages.Revocation.url( - self.client.new_reg_uri), mock.ANY) + self.net.post.assert_called_once_with( + self.directory[messages.Revocation], mock.ANY) def test_revoke_bad_status_raises_error(self): self.response.status_code = http_client.METHOD_NOT_ALLOWED diff --git a/acme/acme/messages.py b/acme/acme/messages.py index 970cf4e6e..1a7463fba 100644 --- a/acme/acme/messages.py +++ b/acme/acme/messages.py @@ -1,11 +1,10 @@ """ACME protocol messages.""" import collections -from six.moves.urllib import parse as urllib_parse # pylint: disable=import-error - from acme import challenges from acme import fields from acme import jose +from acme import util class Error(jose.JSONObjectWithFields, Exception): @@ -128,6 +127,56 @@ class Identifier(jose.JSONObjectWithFields): value = jose.Field('value') +class Directory(jose.JSONDeSerializable): + """Directory.""" + + _REGISTERED_TYPES = {} + + @classmethod + def _canon_key(cls, key): + return getattr(key, 'resource_type', key) + + @classmethod + def register(cls, resource_body_cls): + """Register resource.""" + assert resource_body_cls.resource_type not in cls._REGISTERED_TYPES + cls._REGISTERED_TYPES[resource_body_cls.resource_type] = resource_body_cls + return resource_body_cls + + def __init__(self, jobj): + canon_jobj = util.map_keys(jobj, self._canon_key) + if not set(canon_jobj).issubset(self._REGISTERED_TYPES): + # TODO: acme-spec is not clear about this: 'It is a JSON + # dictionary, whose keys are the "resource" values listed + # in {{https-requests}}'z + raise ValueError('Wrong directory fields') + # TODO: check that everything is an absolute URL; acme-spec is + # not clear on that + self._jobj = canon_jobj + + def __getattr__(self, name): + try: + return self[name.replace('_', '-')] + except KeyError as error: + raise AttributeError(str(error)) + + def __getitem__(self, name): + try: + return self._jobj[self._canon_key(name)] + except KeyError: + raise KeyError('Directory field not found') + + def to_partial_json(self): + return self._jobj + + @classmethod + def from_json(cls, jobj): + try: + return cls(jobj) + except ValueError as error: + raise jose.DeserializationError(str(error)) + + class Resource(jose.JSONObjectWithFields): """ACME Resource. @@ -216,6 +265,7 @@ class Registration(ResourceBody): """All emails found in the ``contact`` field.""" return self._filter_contact(self.email_prefix) +@Directory.register class NewRegistration(Registration): """New registration.""" resource_type = 'new-reg' @@ -328,6 +378,7 @@ class Authorization(ResourceBody): return tuple(tuple(self.challenges[idx] for idx in combo) for combo in self.combinations) +@Directory.register class NewAuthorization(Authorization): """New authorization.""" resource_type = 'new-authz' @@ -344,6 +395,7 @@ class AuthorizationResource(ResourceWithURI): new_cert_uri = jose.Field('new_cert_uri') +@Directory.register class CertificateRequest(jose.JSONObjectWithFields): """ACME new-cert request. @@ -369,6 +421,7 @@ class CertificateResource(ResourceWithURI): authzrs = jose.Field('authzrs') +@Directory.register class Revocation(jose.JSONObjectWithFields): """Revocation message. @@ -380,16 +433,3 @@ class Revocation(jose.JSONObjectWithFields): resource = fields.Resource(resource_type) certificate = jose.Field( 'certificate', decoder=jose.decode_cert, encoder=jose.encode_cert) - - # TODO: acme-spec#138, this allows only one ACME server instance per domain - PATH = '/acme/revoke-cert' - """Path to revocation URL, see `url`""" - - @classmethod - def url(cls, base): - """Get revocation URL. - - :param str base: New Registration Resource or server (root) URL. - - """ - return urllib_parse.urljoin(base, cls.PATH) diff --git a/acme/acme/messages_test.py b/acme/acme/messages_test.py index 481c2e2a3..c0aafe2e1 100644 --- a/acme/acme/messages_test.py +++ b/acme/acme/messages_test.py @@ -92,6 +92,45 @@ class ConstantTest(unittest.TestCase): self.assertFalse(self.const_a != const_a_prime) +class DirectoryTest(unittest.TestCase): + """Tests for acme.messages.Directory.""" + + def setUp(self): + from acme.messages import Directory + self.dir = Directory({ + 'new-reg': 'reg', + mock.MagicMock(resource_type='new-cert'): 'cert', + }) + + def test_init_wrong_key_value_error(self): + from acme.messages import Directory + self.assertRaises(ValueError, Directory, {'foo': 'bar'}) + + def test_getitem(self): + self.assertEqual('reg', self.dir['new-reg']) + from acme.messages import NewRegistration + self.assertEqual('reg', self.dir[NewRegistration]) + self.assertEqual('reg', self.dir[NewRegistration()]) + + def test_getitem_fails_with_key_error(self): + self.assertRaises(KeyError, self.dir.__getitem__, 'foo') + + def test_getattr(self): + self.assertEqual('reg', self.dir.new_reg) + + def test_getattr_fails_with_attribute_error(self): + self.assertRaises(AttributeError, self.dir.__getattr__, 'foo') + + def test_to_partial_json(self): + self.assertEqual( + self.dir.to_partial_json(), {'new-reg': 'reg', 'new-cert': 'cert'}) + + def test_from_json_deserialization_error_on_wrong_key(self): + from acme.messages import Directory + self.assertRaises( + jose.DeserializationError, Directory.from_json, {'foo': 'bar'}) + + class RegistrationTest(unittest.TestCase): """Tests for acme.messages.Registration.""" @@ -320,13 +359,6 @@ class CertificateResourceTest(unittest.TestCase): class RevocationTest(unittest.TestCase): """Tests for acme.messages.RevocationTest.""" - def test_url(self): - from acme.messages import Revocation - url = 'https://letsencrypt-demo.org/acme/revoke-cert' - self.assertEqual(url, Revocation.url('https://letsencrypt-demo.org')) - self.assertEqual( - url, Revocation.url('https://letsencrypt-demo.org/acme/new-reg')) - def setUp(self): from acme.messages import Revocation self.rev = Revocation(certificate=CERT) diff --git a/acme/acme/util.py b/acme/acme/util.py new file mode 100644 index 000000000..1fff89a9e --- /dev/null +++ b/acme/acme/util.py @@ -0,0 +1,7 @@ +"""ACME utilities.""" +import six + + +def map_keys(dikt, func): + """Map dictionary keys.""" + return dict((func(key), value) for key, value in six.iteritems(dikt)) diff --git a/acme/acme/util_test.py b/acme/acme/util_test.py new file mode 100644 index 000000000..00aa8b02d --- /dev/null +++ b/acme/acme/util_test.py @@ -0,0 +1,16 @@ +"""Tests for acme.util.""" +import unittest + + +class MapKeysTest(unittest.TestCase): + """Tests for acme.util.map_keys.""" + + def test_it(self): + from acme.util import map_keys + self.assertEqual({'a': 'b', 'c': 'd'}, + map_keys({'a': 'b', 'c': 'd'}, lambda key: key)) + self.assertEqual({2: 2, 4: 4}, map_keys({1: 2, 3: 4}, lambda x: x + 1)) + + +if __name__ == '__main__': + unittest.main() # pragma: no cover diff --git a/letsencrypt/client.py b/letsencrypt/client.py index e8dd08c8e..e5cdc81c9 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -33,7 +33,7 @@ logger = logging.getLogger(__name__) def _acme_from_config_key(config, key): # TODO: Allow for other alg types besides RS256 - return acme_client.Client(new_reg_uri=config.server, key=key, + return acme_client.Client(directory=config.server, key=key, verify_ssl=(not config.no_verify_ssl)) diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py index 230860762..0d00f2d75 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -16,7 +16,7 @@ CLI_DEFAULTS = dict( "letsencrypt", "cli.ini"), ], verbose_count=-(logging.WARNING / 10), - server="https://acme-staging.api.letsencrypt.org/acme/new-reg", + server="https://acme-staging.api.letsencrypt.org/directory", rsa_key_size=2048, rollback_checkpoints=1, config_dir="/etc/letsencrypt", diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index 2271b9050..3dee1b1ea 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -194,8 +194,7 @@ class IConfig(zope.interface.Interface): filtered, stripped or sanitized. """ - server = zope.interface.Attribute( - "ACME new registration URI (including /acme/new-reg).") + server = zope.interface.Attribute("ACME Directory Resource URI.") email = zope.interface.Attribute( "Email used for registration and recovery contact.") rsa_key_size = zope.interface.Attribute("Size of the RSA key.") diff --git a/letsencrypt/revoker.py b/letsencrypt/revoker.py index 160d911a5..e8b154012 100644 --- a/letsencrypt/revoker.py +++ b/letsencrypt/revoker.py @@ -48,7 +48,7 @@ class Revoker(object): """ def __init__(self, installer, config, no_confirm=False): # XXX - self.acme = acme_client.Client(new_reg_uri=None, key=None, alg=None) + self.acme = acme_client.Client(directory=None, key=None, alg=None) self.installer = installer self.config = config diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index b992089cc..df3a341a2 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -70,7 +70,7 @@ class ClientTest(unittest.TestCase): def test_init_acme_verify_ssl(self): self.acme_client.assert_called_once_with( - new_reg_uri=mock.ANY, key=mock.ANY, verify_ssl=True) + directory=mock.ANY, key=mock.ANY, verify_ssl=True) def _mock_obtain_certificate(self): self.client.auth_handler = mock.MagicMock() diff --git a/tests/boulder-integration.sh b/tests/boulder-integration.sh index 23bfcf3ca..786ceb1b2 100755 --- a/tests/boulder-integration.sh +++ b/tests/boulder-integration.sh @@ -4,9 +4,7 @@ # instance (see ./boulder-start.sh). # # Environment variables: -# SERVER: Passed as "letsencrypt --server" argument. Boulder -# monolithic defaults to :4000, AMQP defaults to :4300. This -# script defaults to monolithic. +# SERVER: Passed as "letsencrypt --server" argument. # # Note: this script is called by Boulder integration test suite! diff --git a/tests/integration/_common.sh b/tests/integration/_common.sh index 8656b8518..c8b142cf2 100755 --- a/tests/integration/_common.sh +++ b/tests/integration/_common.sh @@ -13,7 +13,7 @@ export root store_flags letsencrypt_test () { letsencrypt \ - --server "${SERVER:-http://localhost:4000/acme/new-reg}" \ + --server "${SERVER:-http://localhost:4000/directory}" \ --no-verify-ssl \ --dvsni-port 5001 \ --simple-http-port 5001 \