acme: progress with v03 Simple HTTP challenge.

This commit is contained in:
Jakub Warmuz
2015-07-29 20:58:54 +00:00
parent a55991055e
commit ca5823ffd8
4 changed files with 197 additions and 30 deletions
+93 -13
View File
@@ -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
+59 -17
View File
@@ -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)
+25
View File
@@ -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.
+20
View File
@@ -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."""