mirror of
https://github.com/certbot/certbot.git
synced 2026-08-02 19:31:51 +02:00
acme: progress with v03 Simple HTTP challenge.
This commit is contained in:
+93
-13
@@ -50,7 +50,13 @@ class SimpleHTTP(DVChallenge):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
typ = "simpleHttp"
|
typ = "simpleHttp"
|
||||||
token = jose.Field("token")
|
|
||||||
|
TOKEN_SIZE = 128 / 8 # Based on the entropy value from the spec
|
||||||
|
"""Minimum size of the :attr:`token` in bytes."""
|
||||||
|
|
||||||
|
token = jose.Field(
|
||||||
|
"token", encoder=jose.encode_b64jose, decoder=functools.partial(
|
||||||
|
jose.decode_b64jose, size=TOKEN_SIZE, minimum=True))
|
||||||
|
|
||||||
|
|
||||||
@ChallengeResponse.register
|
@ChallengeResponse.register
|
||||||
@@ -73,7 +79,7 @@ class SimpleHTTPResponse(ChallengeResponse):
|
|||||||
MAX_PATH_LEN = 25
|
MAX_PATH_LEN = 25
|
||||||
"""Maximum allowed `path` length."""
|
"""Maximum allowed `path` length."""
|
||||||
|
|
||||||
CONTENT_TYPE = "text/plain"
|
CONTENT_TYPE = "application/jose+json"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def good_path(self):
|
def good_path(self):
|
||||||
@@ -110,7 +116,62 @@ class SimpleHTTPResponse(ChallengeResponse):
|
|||||||
return self._URI_TEMPLATE.format(
|
return self._URI_TEMPLATE.format(
|
||||||
scheme=self.scheme, domain=domain, path=self.path)
|
scheme=self.scheme, domain=domain, path=self.path)
|
||||||
|
|
||||||
def simple_verify(self, chall, domain, port=None):
|
def gen_resource(self, chall):
|
||||||
|
"""Generate provisioned resource.
|
||||||
|
|
||||||
|
:param .SimpleHTTP chall:
|
||||||
|
:rtype: SimpleHTTPProvisionedResource
|
||||||
|
|
||||||
|
"""
|
||||||
|
return SimpleHTTPProvisionedResource(
|
||||||
|
token=chall.token, path=self.path, tls=self.tls)
|
||||||
|
|
||||||
|
def gen_validation(self, chall, account_key, alg=jose.RS256, **kwargs):
|
||||||
|
"""Generate validation.
|
||||||
|
|
||||||
|
:param .SimpleHTTP chall:
|
||||||
|
:param .JWK account_key: Private account key.
|
||||||
|
:param .JWA alg:
|
||||||
|
|
||||||
|
:returns: `.SimpleHTTPProvisionedResource` signed in `.JWS`
|
||||||
|
:rtype: .JWS
|
||||||
|
|
||||||
|
"""
|
||||||
|
return jose.JWS.sign(
|
||||||
|
payload=self.gen_resource(chall).json_dumps().encode('utf-8'),
|
||||||
|
key=account_key, alg=alg, **kwargs)
|
||||||
|
|
||||||
|
def check_validation(self, validation, chall, account_public_key):
|
||||||
|
"""Check validation.
|
||||||
|
|
||||||
|
:param .JWS validation:
|
||||||
|
:param .SimpleHTTP chall:
|
||||||
|
:type account_public_key:
|
||||||
|
`~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey`
|
||||||
|
or
|
||||||
|
`~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey`
|
||||||
|
or
|
||||||
|
`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey`
|
||||||
|
wrapped in `.ComparableKey
|
||||||
|
|
||||||
|
:rtype: bool
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not validation.verify(key=account_public_key):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
resource = SimpleHTTPProvisionedResource.json_loads(
|
||||||
|
validation.payload.decode('utf-8'))
|
||||||
|
except jose.DeserializationError as error:
|
||||||
|
logger.debug(error)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return (resource.token == chall.token and
|
||||||
|
resource.path == self.path and
|
||||||
|
resource.tls == self.tls)
|
||||||
|
|
||||||
|
def simple_verify(self, chall, domain, account_public_key, port=None):
|
||||||
"""Simple verify.
|
"""Simple verify.
|
||||||
|
|
||||||
According to the ACME specification, "the ACME server MUST
|
According to the ACME specification, "the ACME server MUST
|
||||||
@@ -119,6 +180,16 @@ class SimpleHTTPResponse(ChallengeResponse):
|
|||||||
|
|
||||||
:param .SimpleHTTP chall: Corresponding challenge.
|
:param .SimpleHTTP chall: Corresponding challenge.
|
||||||
:param unicode domain: Domain name being verified.
|
:param unicode domain: Domain name being verified.
|
||||||
|
:param account_public_key: Public key for the key pair
|
||||||
|
being authorized. If ``None`` key verification is not
|
||||||
|
performed!
|
||||||
|
:type account_public_key:
|
||||||
|
`~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey`
|
||||||
|
or
|
||||||
|
`~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey`
|
||||||
|
or
|
||||||
|
`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey`
|
||||||
|
wrapped in `.ComparableKey
|
||||||
:param int port: Port used in the validation.
|
:param int port: Port used in the validation.
|
||||||
|
|
||||||
:returns: ``True`` iff validation is successful, ``False``
|
:returns: ``True`` iff validation is successful, ``False``
|
||||||
@@ -144,16 +215,25 @@ class SimpleHTTPResponse(ChallengeResponse):
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
"Received %s. Headers: %s", http_response, http_response.headers)
|
"Received %s. Headers: %s", http_response, http_response.headers)
|
||||||
|
|
||||||
good_token = http_response.text == chall.token
|
if self.CONTENT_TYPE != http_response.headers.get(
|
||||||
if not good_token:
|
"Content-Type", self.CONTENT_TYPE):
|
||||||
logger.error(
|
return False
|
||||||
"Unable to verify %s! Expected: %r, returned: %r.",
|
|
||||||
uri, chall.token, http_response.text)
|
try:
|
||||||
# TODO: spec contradicts itself, c.f.
|
validation = jose.JWS.json_loads(http_response.text)
|
||||||
# https://github.com/letsencrypt/acme-spec/pull/156/files#r33136438
|
except jose.DeserializationError as error:
|
||||||
good_ct = self.CONTENT_TYPE == http_response.headers.get(
|
logger.debug(error)
|
||||||
"Content-Type", self.CONTENT_TYPE)
|
return False
|
||||||
return self.good_path and good_ct and good_token
|
|
||||||
|
return self.check_validation(validation, chall, account_public_key)
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleHTTPProvisionedResource(jose.JSONObjectWithFields):
|
||||||
|
"""SimpleHTTP provisioned resource."""
|
||||||
|
typ = fields.Fixed("type", SimpleHTTP.typ)
|
||||||
|
token = SimpleHTTP._fields["token"]
|
||||||
|
path = SimpleHTTPResponse._fields["path"]
|
||||||
|
tls = SimpleHTTPResponse._fields["tls"]
|
||||||
|
|
||||||
|
|
||||||
@Challenge.register
|
@Challenge.register
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ class SimpleHTTPTest(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
from acme.challenges import SimpleHTTP
|
from acme.challenges import SimpleHTTP
|
||||||
self.msg = SimpleHTTP(
|
self.msg = SimpleHTTP(
|
||||||
token='evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA')
|
token=jose.decode_b64jose(
|
||||||
|
'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA'))
|
||||||
self.jmsg = {
|
self.jmsg = {
|
||||||
'type': 'simpleHttp',
|
'type': 'simpleHttp',
|
||||||
'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA',
|
'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA',
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_to_partial_json(self):
|
def test_to_partial_json(self):
|
||||||
@@ -62,11 +63,27 @@ class SimpleHTTPResponseTest(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
from acme.challenges import SimpleHTTP
|
from acme.challenges import SimpleHTTP
|
||||||
self.chall = SimpleHTTP(token="foo")
|
self.chall = SimpleHTTP(token=(r"x" * 16))
|
||||||
self.resp_http = SimpleHTTPResponse(path="bar", tls=False)
|
self.resp_http = SimpleHTTPResponse(path="bar", tls=False)
|
||||||
self.resp_https = SimpleHTTPResponse(path="bar", tls=True)
|
self.resp_https = SimpleHTTPResponse(path="bar", tls=True)
|
||||||
self.good_headers = {'Content-Type': SimpleHTTPResponse.CONTENT_TYPE}
|
self.good_headers = {'Content-Type': SimpleHTTPResponse.CONTENT_TYPE}
|
||||||
|
|
||||||
|
def test_to_partial_json(self):
|
||||||
|
self.assertEqual(self.jmsg_http, self.msg_http.to_partial_json())
|
||||||
|
self.assertEqual(self.jmsg_https, self.msg_https.to_partial_json())
|
||||||
|
|
||||||
|
def test_from_json(self):
|
||||||
|
from acme.challenges import SimpleHTTPResponse
|
||||||
|
self.assertEqual(
|
||||||
|
self.msg_http, SimpleHTTPResponse.from_json(self.jmsg_http))
|
||||||
|
self.assertEqual(
|
||||||
|
self.msg_https, SimpleHTTPResponse.from_json(self.jmsg_https))
|
||||||
|
|
||||||
|
def test_from_json_hashable(self):
|
||||||
|
from acme.challenges import SimpleHTTPResponse
|
||||||
|
hash(SimpleHTTPResponse.from_json(self.jmsg_http))
|
||||||
|
hash(SimpleHTTPResponse.from_json(self.jmsg_https))
|
||||||
|
|
||||||
def test_good_path(self):
|
def test_good_path(self):
|
||||||
self.assertTrue(self.msg_http.good_path)
|
self.assertTrue(self.msg_http.good_path)
|
||||||
self.assertTrue(self.msg_https.good_path)
|
self.assertTrue(self.msg_https.good_path)
|
||||||
@@ -89,21 +106,45 @@ class SimpleHTTPResponseTest(unittest.TestCase):
|
|||||||
'https://example.com/.well-known/acme-challenge/'
|
'https://example.com/.well-known/acme-challenge/'
|
||||||
'6tbIMBC5Anhl5bOlWT5ZFA', self.msg_https.uri('example.com'))
|
'6tbIMBC5Anhl5bOlWT5ZFA', self.msg_https.uri('example.com'))
|
||||||
|
|
||||||
def test_to_partial_json(self):
|
def test_gen_check_validation(self):
|
||||||
self.assertEqual(self.jmsg_http, self.msg_http.to_partial_json())
|
account_key = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||||
self.assertEqual(self.jmsg_https, self.msg_https.to_partial_json())
|
self.assertTrue(self.resp_http.check_validation(
|
||||||
|
validation=self.resp_http.gen_validation(self.chall, account_key),
|
||||||
|
chall=self.chall, account_public_key=account_key.public_key()))
|
||||||
|
|
||||||
def test_from_json(self):
|
def test_gen_check_validation_wrong_key(self):
|
||||||
from acme.challenges import SimpleHTTPResponse
|
key1 = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||||
self.assertEqual(
|
key2 = jose.JWKRSA.load(test_util.load_vector('rsa1024_key.pem'))
|
||||||
self.msg_http, SimpleHTTPResponse.from_json(self.jmsg_http))
|
self.assertFalse(self.resp_http.check_validation(
|
||||||
self.assertEqual(
|
validation=self.resp_http.gen_validation(self.chall, key1),
|
||||||
self.msg_https, SimpleHTTPResponse.from_json(self.jmsg_https))
|
chall=self.chall, account_public_key=key2.public_key()))
|
||||||
|
|
||||||
def test_from_json_hashable(self):
|
def test_check_validation_wrong_payload(self):
|
||||||
from acme.challenges import SimpleHTTPResponse
|
account_key = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||||
hash(SimpleHTTPResponse.from_json(self.jmsg_http))
|
validations = tuple(
|
||||||
hash(SimpleHTTPResponse.from_json(self.jmsg_https))
|
jose.JWS.sign(payload=payload, alg=jose.RS256, key=account_key)
|
||||||
|
for payload in (b'', b'{}', self.chall.json_dumps().encode('utf-8'),
|
||||||
|
self.resp_http.json_dumps().encode('utf-8'))
|
||||||
|
)
|
||||||
|
for validation in validations:
|
||||||
|
self.assertFalse(self.resp_http.check_validation(
|
||||||
|
validation=validation, chall=self.chall,
|
||||||
|
account_public_key=account_key.public_key()))
|
||||||
|
|
||||||
|
def test_check_validation_wrong_fields(self):
|
||||||
|
resource = self.resp_http.gen_resource(self.chall)
|
||||||
|
account_key = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||||
|
validations = tuple(
|
||||||
|
jose.JWS.sign(payload=bad_resource.json_dumps().encode('utf-8'),
|
||||||
|
alg=jose.RS256, key=account_key)
|
||||||
|
for bad_resource in (resource.update(tls=True),
|
||||||
|
resource.update(path='xxx'),
|
||||||
|
resource.update(token=r'x'*20))
|
||||||
|
)
|
||||||
|
for validation in validations:
|
||||||
|
self.assertFalse(self.resp_http.check_validation(
|
||||||
|
validation=validation, chall=self.chall,
|
||||||
|
account_public_key=account_key.public_key()))
|
||||||
|
|
||||||
@mock.patch("acme.challenges.requests.get")
|
@mock.patch("acme.challenges.requests.get")
|
||||||
def test_simple_verify_good_token(self, mock_get):
|
def test_simple_verify_good_token(self, mock_get):
|
||||||
@@ -132,7 +173,8 @@ class SimpleHTTPResponseTest(unittest.TestCase):
|
|||||||
|
|
||||||
@mock.patch("acme.challenges.requests.get")
|
@mock.patch("acme.challenges.requests.get")
|
||||||
def test_simple_verify_port(self, mock_get):
|
def test_simple_verify_port(self, mock_get):
|
||||||
self.resp_http.simple_verify(self.chall, "local", 4430)
|
self.resp_http.simple_verify(
|
||||||
|
self.chall, domain="local", account_public_key=None, port=4430)
|
||||||
self.assertEqual("local:4430", urllib_parse.urlparse(
|
self.assertEqual("local:4430", urllib_parse.urlparse(
|
||||||
mock_get.mock_calls[0][1][0]).netloc)
|
mock_get.mock_calls[0][1][0]).netloc)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
"""ACME JSON fields."""
|
"""ACME JSON fields."""
|
||||||
|
import logging
|
||||||
|
|
||||||
import pyrfc3339
|
import pyrfc3339
|
||||||
|
|
||||||
from acme import jose
|
from acme import jose
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Fixed(jose.Field):
|
||||||
|
"""Fixed field."""
|
||||||
|
|
||||||
|
def __init__(self, json_name, value):
|
||||||
|
self.value = value
|
||||||
|
super(Fixed, self).__init__(
|
||||||
|
json_name=json_name, default=value, omitempty=False)
|
||||||
|
|
||||||
|
def decode(self, value):
|
||||||
|
if value != self.value:
|
||||||
|
raise jose.DeserializationError('Expected {0!r}'.format(self.value))
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def encode(self, value):
|
||||||
|
if value != self.value:
|
||||||
|
logger.warn('Overriding fixed field ({0}) with {1}'.format(
|
||||||
|
self.json_name, value))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class RFC3339Field(jose.Field):
|
class RFC3339Field(jose.Field):
|
||||||
"""RFC3339 field encoder/decoder.
|
"""RFC3339 field encoder/decoder.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,26 @@ import pytz
|
|||||||
from acme import jose
|
from acme import jose
|
||||||
|
|
||||||
|
|
||||||
|
class FixedTest(unittest.TestCase):
|
||||||
|
"""Tests for acme.fields.Fixed."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from acme.fields import Fixed
|
||||||
|
self.field = Fixed('name', 'x')
|
||||||
|
|
||||||
|
def test_decode(self):
|
||||||
|
self.assertEqual('x', self.field.decode('x'))
|
||||||
|
|
||||||
|
def test_decode_bad(self):
|
||||||
|
self.assertRaises(jose.DeserializationError, self.field.decode, 'y')
|
||||||
|
|
||||||
|
def test_encode(self):
|
||||||
|
self.assertEqual('x', self.field.encode('x'))
|
||||||
|
|
||||||
|
def test_encode_override(self):
|
||||||
|
self.assertEqual('y', self.field.encode('y'))
|
||||||
|
|
||||||
|
|
||||||
class RFC3339FieldTest(unittest.TestCase):
|
class RFC3339FieldTest(unittest.TestCase):
|
||||||
"""Tests for acme.fields.RFC3339Field."""
|
"""Tests for acme.fields.RFC3339Field."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user