mirror of
https://github.com/certbot/certbot.git
synced 2026-07-31 18:34:41 +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"
|
||||
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
|
||||
@@ -73,7 +79,7 @@ class SimpleHTTPResponse(ChallengeResponse):
|
||||
MAX_PATH_LEN = 25
|
||||
"""Maximum allowed `path` length."""
|
||||
|
||||
CONTENT_TYPE = "text/plain"
|
||||
CONTENT_TYPE = "application/jose+json"
|
||||
|
||||
@property
|
||||
def good_path(self):
|
||||
@@ -110,7 +116,62 @@ class SimpleHTTPResponse(ChallengeResponse):
|
||||
return self._URI_TEMPLATE.format(
|
||||
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.
|
||||
|
||||
According to the ACME specification, "the ACME server MUST
|
||||
@@ -119,6 +180,16 @@ class SimpleHTTPResponse(ChallengeResponse):
|
||||
|
||||
:param .SimpleHTTP chall: Corresponding challenge.
|
||||
: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.
|
||||
|
||||
:returns: ``True`` iff validation is successful, ``False``
|
||||
@@ -144,16 +215,25 @@ class SimpleHTTPResponse(ChallengeResponse):
|
||||
logger.debug(
|
||||
"Received %s. Headers: %s", http_response, http_response.headers)
|
||||
|
||||
good_token = http_response.text == chall.token
|
||||
if not good_token:
|
||||
logger.error(
|
||||
"Unable to verify %s! Expected: %r, returned: %r.",
|
||||
uri, chall.token, http_response.text)
|
||||
# TODO: spec contradicts itself, c.f.
|
||||
# https://github.com/letsencrypt/acme-spec/pull/156/files#r33136438
|
||||
good_ct = self.CONTENT_TYPE == http_response.headers.get(
|
||||
"Content-Type", self.CONTENT_TYPE)
|
||||
return self.good_path and good_ct and good_token
|
||||
if self.CONTENT_TYPE != http_response.headers.get(
|
||||
"Content-Type", self.CONTENT_TYPE):
|
||||
return False
|
||||
|
||||
try:
|
||||
validation = jose.JWS.json_loads(http_response.text)
|
||||
except jose.DeserializationError as error:
|
||||
logger.debug(error)
|
||||
return False
|
||||
|
||||
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
|
||||
|
||||
@@ -22,10 +22,11 @@ class SimpleHTTPTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from acme.challenges import SimpleHTTP
|
||||
self.msg = SimpleHTTP(
|
||||
token='evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA')
|
||||
token=jose.decode_b64jose(
|
||||
'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA'))
|
||||
self.jmsg = {
|
||||
'type': 'simpleHttp',
|
||||
'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA',
|
||||
'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA',
|
||||
}
|
||||
|
||||
def test_to_partial_json(self):
|
||||
@@ -62,11 +63,27 @@ class SimpleHTTPResponseTest(unittest.TestCase):
|
||||
}
|
||||
|
||||
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_https = SimpleHTTPResponse(path="bar", tls=True)
|
||||
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):
|
||||
self.assertTrue(self.msg_http.good_path)
|
||||
self.assertTrue(self.msg_https.good_path)
|
||||
@@ -89,21 +106,45 @@ class SimpleHTTPResponseTest(unittest.TestCase):
|
||||
'https://example.com/.well-known/acme-challenge/'
|
||||
'6tbIMBC5Anhl5bOlWT5ZFA', self.msg_https.uri('example.com'))
|
||||
|
||||
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_gen_check_validation(self):
|
||||
account_key = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||
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):
|
||||
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_gen_check_validation_wrong_key(self):
|
||||
key1 = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||
key2 = jose.JWKRSA.load(test_util.load_vector('rsa1024_key.pem'))
|
||||
self.assertFalse(self.resp_http.check_validation(
|
||||
validation=self.resp_http.gen_validation(self.chall, key1),
|
||||
chall=self.chall, account_public_key=key2.public_key()))
|
||||
|
||||
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_check_validation_wrong_payload(self):
|
||||
account_key = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
|
||||
validations = tuple(
|
||||
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")
|
||||
def test_simple_verify_good_token(self, mock_get):
|
||||
@@ -132,7 +173,8 @@ class SimpleHTTPResponseTest(unittest.TestCase):
|
||||
|
||||
@mock.patch("acme.challenges.requests.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(
|
||||
mock_get.mock_calls[0][1][0]).netloc)
|
||||
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
"""ACME JSON fields."""
|
||||
import logging
|
||||
|
||||
import pyrfc3339
|
||||
|
||||
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):
|
||||
"""RFC3339 field encoder/decoder.
|
||||
|
||||
|
||||
@@ -7,6 +7,26 @@ import pytz
|
||||
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):
|
||||
"""Tests for acme.fields.RFC3339Field."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user