mirror of
https://github.com/certbot/certbot.git
synced 2026-08-01 02:44:21 +02:00
Add ACME Directory Resource
This commit is contained in:
+18
-6
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user