Add ACME Directory Resource

This commit is contained in:
Jakub Warmuz
2015-09-09 17:22:20 +00:00
parent 44f7703f00
commit bf754b6302
11 changed files with 155 additions and 38 deletions
+18 -6
View File
@@ -4,6 +4,7 @@ import heapq
import logging import logging
import time import time
import six
from six.moves import http_client # pylint: disable=import-error from six.moves import http_client # pylint: disable=import-error
import OpenSSL 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) Clean up raised error types hierarchy, document, and handle (wrap)
instances of `.DeserializationError` raised in `from_json()`. 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 key: `.JWK` (private)
:ivar alg: `.JWASignature` :ivar alg: `.JWASignature`
:ivar bool verify_ssl: Verify SSL certificates? :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' DER_CONTENT_TYPE = 'application/pkix-cert'
def __init__(self, new_reg_uri, key, alg=jose.RS256, def __init__(self, directory, key, alg=jose.RS256, verify_ssl=True,
verify_ssl=True, net=None): net=None):
self.new_reg_uri = new_reg_uri """Initialize.
:param directory: Directory Resource (`.messages.Directory`) or
URI from which the resource will be downloaded.
"""
self.key = key self.key = key
self.net = ClientNetwork(key, alg, verify_ssl) if net is None else net 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 @classmethod
def _regr_from_response(cls, response, uri=None, new_authzr_uri=None, def _regr_from_response(cls, response, uri=None, new_authzr_uri=None,
terms_of_service=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 new_reg = messages.NewRegistration() if new_reg is None else new_reg
assert isinstance(new_reg, messages.NewRegistration) 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 # TODO: handle errors
assert response.status_code == http_client.CREATED 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. :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)) messages.Revocation(certificate=cert))
if response.status_code != http_client.OK: if response.status_code != http_client.OK:
raise errors.ClientError( raise errors.ClientError(
+15 -4
View File
@@ -33,10 +33,14 @@ class ClientTest(unittest.TestCase):
self.net.post.return_value = self.response self.net.post.return_value = self.response
self.net.get.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 from acme.client import Client
self.client = Client( self.client = Client(
new_reg_uri='https://www.letsencrypt-demo.org/acme/new-reg', directory=self.directory, key=KEY, alg=jose.RS256, net=self.net)
key=KEY, alg=jose.RS256, net=self.net)
self.identifier = messages.Identifier( self.identifier = messages.Identifier(
typ=messages.IDENTIFIER_FQDN, value='example.com') typ=messages.IDENTIFIER_FQDN, value='example.com')
@@ -72,6 +76,13 @@ class ClientTest(unittest.TestCase):
uri='https://www.letsencrypt-demo.org/acme/cert/1', uri='https://www.letsencrypt-demo.org/acme/cert/1',
cert_chain_uri='https://www.letsencrypt-demo.org/ca') 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): def test_register(self):
# "Instance of 'Field' has no to_json/update member" bug: # "Instance of 'Field' has no to_json/update member" bug:
# pylint: disable=no-member # pylint: disable=no-member
@@ -348,8 +359,8 @@ class ClientTest(unittest.TestCase):
def test_revoke(self): def test_revoke(self):
self.client.revoke(self.certr.body) self.client.revoke(self.certr.body)
self.net.post.assert_called_once_with(messages.Revocation.url( self.net.post.assert_called_once_with(
self.client.new_reg_uri), mock.ANY) self.directory[messages.Revocation], mock.ANY)
def test_revoke_bad_status_raises_error(self): def test_revoke_bad_status_raises_error(self):
self.response.status_code = http_client.METHOD_NOT_ALLOWED self.response.status_code = http_client.METHOD_NOT_ALLOWED
+55 -15
View File
@@ -1,11 +1,10 @@
"""ACME protocol messages.""" """ACME protocol messages."""
import collections import collections
from six.moves.urllib import parse as urllib_parse # pylint: disable=import-error
from acme import challenges from acme import challenges
from acme import fields from acme import fields
from acme import jose from acme import jose
from acme import util
class Error(jose.JSONObjectWithFields, Exception): class Error(jose.JSONObjectWithFields, Exception):
@@ -128,6 +127,56 @@ class Identifier(jose.JSONObjectWithFields):
value = jose.Field('value') 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(error.message)
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): class Resource(jose.JSONObjectWithFields):
"""ACME Resource. """ACME Resource.
@@ -216,6 +265,7 @@ class Registration(ResourceBody):
"""All emails found in the ``contact`` field.""" """All emails found in the ``contact`` field."""
return self._filter_contact(self.email_prefix) return self._filter_contact(self.email_prefix)
@Directory.register
class NewRegistration(Registration): class NewRegistration(Registration):
"""New registration.""" """New registration."""
resource_type = 'new-reg' resource_type = 'new-reg'
@@ -328,6 +378,7 @@ class Authorization(ResourceBody):
return tuple(tuple(self.challenges[idx] for idx in combo) return tuple(tuple(self.challenges[idx] for idx in combo)
for combo in self.combinations) for combo in self.combinations)
@Directory.register
class NewAuthorization(Authorization): class NewAuthorization(Authorization):
"""New authorization.""" """New authorization."""
resource_type = 'new-authz' resource_type = 'new-authz'
@@ -344,6 +395,7 @@ class AuthorizationResource(ResourceWithURI):
new_cert_uri = jose.Field('new_cert_uri') new_cert_uri = jose.Field('new_cert_uri')
@Directory.register
class CertificateRequest(jose.JSONObjectWithFields): class CertificateRequest(jose.JSONObjectWithFields):
"""ACME new-cert request. """ACME new-cert request.
@@ -369,6 +421,7 @@ class CertificateResource(ResourceWithURI):
authzrs = jose.Field('authzrs') authzrs = jose.Field('authzrs')
@Directory.register
class Revocation(jose.JSONObjectWithFields): class Revocation(jose.JSONObjectWithFields):
"""Revocation message. """Revocation message.
@@ -380,16 +433,3 @@ class Revocation(jose.JSONObjectWithFields):
resource = fields.Resource(resource_type) resource = fields.Resource(resource_type)
certificate = jose.Field( certificate = jose.Field(
'certificate', decoder=jose.decode_cert, encoder=jose.encode_cert) '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)
+39 -7
View File
@@ -92,6 +92,45 @@ class ConstantTest(unittest.TestCase):
self.assertFalse(self.const_a != const_a_prime) 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): class RegistrationTest(unittest.TestCase):
"""Tests for acme.messages.Registration.""" """Tests for acme.messages.Registration."""
@@ -320,13 +359,6 @@ class CertificateResourceTest(unittest.TestCase):
class RevocationTest(unittest.TestCase): class RevocationTest(unittest.TestCase):
"""Tests for acme.messages.RevocationTest.""" """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): def setUp(self):
from acme.messages import Revocation from acme.messages import Revocation
self.rev = Revocation(certificate=CERT) self.rev = Revocation(certificate=CERT)
+7
View File
@@ -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))
+16
View File
@@ -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
+1 -1
View File
@@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
def _acme_from_config_key(config, key): def _acme_from_config_key(config, key):
# TODO: Allow for other alg types besides RS256 # 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)) verify_ssl=(not config.no_verify_ssl))
+1 -1
View File
@@ -16,7 +16,7 @@ CLI_DEFAULTS = dict(
"letsencrypt", "cli.ini"), "letsencrypt", "cli.ini"),
], ],
verbose_count=-(logging.WARNING / 10), 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, rsa_key_size=2048,
rollback_checkpoints=1, rollback_checkpoints=1,
config_dir="/etc/letsencrypt", config_dir="/etc/letsencrypt",
+1 -2
View File
@@ -194,8 +194,7 @@ class IConfig(zope.interface.Interface):
filtered, stripped or sanitized. filtered, stripped or sanitized.
""" """
server = zope.interface.Attribute( server = zope.interface.Attribute("ACME Directory Resource URI.")
"ACME new registration URI (including /acme/new-reg).")
email = zope.interface.Attribute( email = zope.interface.Attribute(
"Email used for registration and recovery contact.") "Email used for registration and recovery contact.")
rsa_key_size = zope.interface.Attribute("Size of the RSA key.") rsa_key_size = zope.interface.Attribute("Size of the RSA key.")
+1 -1
View File
@@ -48,7 +48,7 @@ class Revoker(object):
""" """
def __init__(self, installer, config, no_confirm=False): def __init__(self, installer, config, no_confirm=False):
# XXX # 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.installer = installer
self.config = config self.config = config
+1 -1
View File
@@ -70,7 +70,7 @@ class ClientTest(unittest.TestCase):
def test_init_acme_verify_ssl(self): def test_init_acme_verify_ssl(self):
self.acme_client.assert_called_once_with( 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): def _mock_obtain_certificate(self):
self.client.auth_handler = mock.MagicMock() self.client.auth_handler = mock.MagicMock()